Skip to content

Imperative API: Pixel Art

This demo showcases drawing simple pictures using Excel grid paper.

A 50x50 grid is provided for drawing basic shapes and patterns.

Selected Color:
📄 View Source Code

Imperative Sheet Access with useSheetRef and useStoreRef

Section titled “Imperative Sheet Access with useSheetRef and useStoreRef”

useSheetRef and useStoreRef give you imperative access to the internal sheet state and Redux-like store outside the normal React data flow.

const sheetRef = useSheetRef();
const storeRef = useStoreRef();
// Pass them as props to GridSheet
<GridSheet sheetRef={sheetRef} storeRef={storeRef} ... />

After the grid mounts, sheetRef.current exposes { sheet, apply } and storeRef.current exposes { store, dispatch }.

clip(store) returns the currently selected area as { top, bottom, left, right }. This is the entry point for any operation that acts on the user’s selection.

const area = clip(store);
if (!area) return;
for (let row = area.top; row <= area.bottom; row++) {
for (let col = area.left; col <= area.right; col++) {
const cellAddress = p2a({ y: row, x: col });
diff[cellAddress] = { style: { backgroundColor: selectedColor } };
}
}

p2a({ y, x }) converts a zero-based coordinate into a cell address string like "C3".

Writing Style Changes Imperatively with sheet.update

Section titled “Writing Style Changes Imperatively with sheet.update”

To apply changes programmatically (e.g., from a button click), build a diff object keyed by cell address and call sheet.update({ diff }). Pass the result to apply to commit it to the store.

const { sheet, apply } = sheetRef.current;
const diff: CellsByAddressType = {
A1: { style: { backgroundColor: '#FF0000' } },
B1: { style: { backgroundColor: '#FF0000' } },
};
apply(sheet.update({ diff }));

This pattern lets you drive cell mutations from outside the grid — for example, from a color picker or a fill button.

By default, GridSheet only allocates rows/columns for cells that have data. Use the ensured option in buildInitialCells to reserve a minimum grid size regardless of data.

buildInitialCells({
cells: {
defaultRow: { height: 25 },
defaultCol: { width: 25 },
...toCells(heartColors),
},
ensured: {
numRows: 50,
numCols: 50,
},
})

This is essential for a blank drawing canvas where the user should be able to click any cell even before any data exists.

To save only the relevant data (background colors), iterate over the sheet’s cell range, read cell.style.backgroundColor, and store a compact mapping of address → color.

const coloredCells: { [key: string]: string } = {};
for (let row = sheet.top; row <= sheet.bottom; row++) {
for (let col = sheet.left; col <= sheet.right; col++) {
const cell = sheet.getCell({ y: row, x: col });
if (cell?.style?.backgroundColor) {
coloredCells[p2a({ y: row, x: col })] = cell.style.backgroundColor;
}
}
}
localStorage.setItem('demo3', JSON.stringify({ cells: coloredCells }));

On mount, the saved map is read once and painted onto the sheet with sheet.update + apply (initialCells is initial-only, so later changes go through the sheet API). The Reset button uses the same repaint to restore the heart in place — no page reload, and it can be undone with Ctrl/Cmd+Z.

const paint = (colors: { [address: string]: string }) => {
const { sheet, apply } = sheetRef.current;
const diff: CellsByAddressType = {};
for (let y = 1; y <= 50; y++) {
for (let x = 1; x <= 50; x++) {
const address = p2a({ y, x });
diff[address] = { style: colors[address] ? { backgroundColor: colors[address] } : {} };
}
}
apply(sheet.update({ diff }));
};

useSpellbook accepts an onChange callback that fires after every cell mutation. Wiring the save logic there means the drawing is persisted automatically without requiring an explicit save button.

const book = useSpellbook({
onChange: saveData,
});

The heart shown on first load is written as a compact ASCII-art array and converted into CellsByAddressType, where each key is a cell address and the value sets a backgroundColor.

const HEART = [
'..RR.........RR..',
'.RppR.......RppR.',
'RppppR.....RppppR',
// ...
'........R........',
];
const PIXEL_COLORS = { R: 'red', p: 'pink' };
const heartColors = {};
HEART.forEach((line, i) => {
[...line].forEach((ch, j) => {
if (PIXEL_COLORS[ch]) {
heartColors[p2a({ y: i + 3, x: j + 1 })] = PIXEL_COLORS[ch]; // starts at A3
}
});
});
const toCells = (colors) =>
Object.fromEntries(Object.entries(colors).map(([address, color]) => [address, { style: { backgroundColor: color } }]));

The resulting cells are spread directly into the cells option of buildInitialCells, making it trivial to define any initial pixel art pattern.