Editing documents
Every persistent change is an operation dispatched to the document. Operations are deterministic, structured clone friendly data. Undo replays them, the worker bridge posts them to the worker as plain data, and collaborators recompute them from the same data, so all three rely on the same representation.
Dispatching
Create drafts with the ops factories. Ids are assigned on the client, so you can reference a node right away.
import { importImage, ops } from 'peinten';
const folder = ops.addGroup({ name: 'Character' });
const layer = ops.addLayer({ parentId: folder.nodeId, name: 'Line' });
await doc.dispatch([folder, layer]); // an array is atomic and undoes as one step
await doc.setActiveNode(layer.nodeId);
await doc.dispatch(ops.updateNode({ nodeId: layer.nodeId, props: { blendMode: 'multiply', opacity: 0.8 } }));
await doc.dispatch(await importImage(file, { name: file.name }));Placement uses parentId (null for the root) and index, where a higher index is drawn later, i.e. higher in
the stack.
| Group | Factories |
|---|---|
| Nodes | addLayer addGroup addAdjustmentLayer addTextLayer removeNode moveNode updateNode updateText duplicateNode |
| Pixels | stroke clear putPixels applyFilter rasterizeNode warp |
| Selection | selectShape selectWand selectAll deselect invertSelection moveSelection |
| Canvas | resizeCanvas |
putPixels writes straight 8-bit RGBA into a rectangle and leaves the rest of the layer alone; pass replace: true
to clear everything outside the rectangle as well.
The built-in tools produce the same operation drafts; all but the brush tool, whose stroke draft is built by the live stroke session, use these factories.
Canvas size
resizeCanvas changes the document size. offsetX and offsetY say where the old top-left corner lands, so they
decide which side grows or shrinks. Every paint layer, text layer and selection moves by the offset, and pixels that end
up outside the canvas are removed. Undo brings them back, and collaborators apply the same change.
// Add 100 px on every side.
await doc.dispatch(ops.resizeCanvas({ width: w + 200, height: h + 200, offsetX: 100, offsetY: 100 }));
// Crop to the selection, as one undo step.
const { x, y, width, height } = doc.getSnapshot().selection!.bounds;
await doc.dispatch([ops.resizeCanvas({ width, height, offsetX: -x, offsetY: -y }), ops.deselect()]);Each edge must stay between 1 and 8192 pixels. Whenever the size changes, including through undo or a collaborator, views center the canvas again and keep their zoom and rotation.
Local state
Some state is per client and never enters history: the active layer, quick mask mode and filter previews on the
document; the active tool, tool options and brush resources on the runtime. Each has its own method, such as
doc.setActiveNode, doc.setQuickMask or runtime.setResources.
Undo and redo
await doc.undo();
await doc.redo();History is an ordered log of batches with the state before each one. Undo issues an operation that disables your batch; the document rolls back to just before it and replays everything after. Tiles are immutable, so rolling back only swaps references. Each client has its own undo stack and undoes only its own batches. Later batches from collaborators are replayed on top; a batch that no longer applies (for example strokes on a layer whose creation was undone) is skipped on every peer alike.
Undo during a stroke cancels the stroke. Pass history: false to createDocument for throwaway documents such as
brush previews, or history: { limit } to change how many steps are kept.
Custom operations
Define an operation with a valibot schema for its payload and a pure apply, then register
it with the runtime. Collaborators must register the same operations.
import * as v from 'valibot';
import { createRuntime, defineOperation, OperationConflictError } from 'peinten';
interface RenameLayerDraft {
readonly type: 'rename-layer';
readonly nodeId: string;
readonly name: string;
}
export const renameLayer = defineOperation<RenameLayerDraft>({
type: 'rename-layer',
schema: v.object({ nodeId: v.string(), name: v.pipe(v.string(), v.maxLength(256)) }),
apply(state, op) {
const node = state.nodes.get(op.nodeId);
// Skips the batch on every peer alike, e.g. when a collaborator removed the layer meanwhile.
if (!node) throw new OperationConflictError('The layer no longer exists');
const nodes = new Map(state.nodes);
nodes.set(op.nodeId, { ...node, name: op.name });
return { ...state, nodes };
},
});
const runtime = await createRuntime({ tools, operations: [renameLayer] });
const draft: RenameLayerDraft = { type: 'rename-layer', nodeId, name: 'Ink' };
await doc.dispatch(draft);apply must not mutate its input and must give the same result everywhere. When an operation depends on the
environment, add an async prepare(draft, context): it runs once on the issuing client before sending and can embed
the result into the draft. Rasterizing text layers works this way, so every peer receives the same pixels.
Once an operation has been released, collaborators running older code still apply it the old way. When a change to
its schema or apply changes what the operation does, raise its version (1 when omitted). The registry hash
includes versions above 1, so peers running another version are refused when they join instead of producing
different pixels.
Reading and writing pixels
peinten/tiles has the helpers the built-in operations use. Layers store premultiplied RGBA and selections store
coverage, both as 16-bit fixed point where FIX15_ONE (32768) is 1.0, in 64×64 tiles keyed by tileKey(tx, ty).
A missing key is a transparent tile. Committed tiles are shared between states, including history, so never write
into one: copy it, change the copy and build a new map.
import * as v from 'valibot';
import { defineOperation, OperationConflictError } from 'peinten';
import { FIX15_ONE, RGBA_TILE_LENGTH, TILE_SIZE, tileCoords, tileKeysInRect } from 'peinten/tiles';
interface FillRectDraft {
readonly type: 'fill-rect';
readonly nodeId: string;
readonly rect: { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
/** Straight RGBA in 0..1. */
readonly color: readonly [number, number, number, number];
}
const int = v.pipe(v.number(), v.integer());
const unit = v.pipe(v.number(), v.minValue(0), v.maxValue(1));
export const fillRect = defineOperation<FillRectDraft>({
type: 'fill-rect',
schema: v.object({
nodeId: v.string(),
rect: v.object({ x: int, y: int, width: v.pipe(int, v.minValue(1)), height: v.pipe(int, v.minValue(1)) }),
color: v.tuple([unit, unit, unit, unit]),
}),
apply(state, op) {
const layer = state.nodes.get(op.nodeId);
if (layer?.kind !== 'paint') throw new OperationConflictError('Not a paint layer');
const { x, y, width, height } = op.rect;
const [r, g, b, a] = op.color;
// Tiles hold premultiplied fix15 values.
const alpha = Math.round(a * FIX15_ONE);
const pixel = [Math.round(r * alpha), Math.round(g * alpha), Math.round(b * alpha), alpha];
const tiles = new Map(layer.tiles);
for (const key of tileKeysInRect(op.rect)) {
const [tx, ty] = tileCoords(key);
// Committed tiles are shared with earlier states, so write into a copy.
const tile = tiles.get(key)?.slice() ?? new Uint16Array(RGBA_TILE_LENGTH);
const left = Math.max(x - tx * TILE_SIZE, 0);
const right = Math.min(x + width - tx * TILE_SIZE, TILE_SIZE);
for (let py = Math.max(y - ty * TILE_SIZE, 0); py < Math.min(y + height - ty * TILE_SIZE, TILE_SIZE); py++)
for (let px = left; px < right; px++) tile.set(pixel, (py * TILE_SIZE + px) * 4);
tiles.set(key, tile);
}
const nodes = new Map(state.nodes);
nodes.set(layer.id, { ...layer, tiles });
return { ...state, nodes };
},
});| Export | Use |
|---|---|
TILE_SIZE, RGBA_TILE_LENGTH, MASK_TILE_LENGTH, FIX15_ONE | Tile layout and the fixed-point scale |
tileKey, tileCoords, tileKeysInRect, forEachTileInRect | Tile coordinates and the tiles a rectangle touches |
rgba8ToTiles, tilesToRgba8 | Convert between straight 8-bit RGBA and premultiplied fix15 tiles |
isEmptyTile, contentBounds | Drop empty tiles and find the bounds of the visible pixels |
floodSelect | The magic wand's mask: pixels similar to a seed pixel, connected or not |
translateTiles | Move RGBA or mask tiles by whole pixels, dropping what leaves a rectangle |
peinten/tiles has no DOM or GPU dependencies, like apply itself.