Contents

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.

GroupFactories
NodesaddLayer addGroup addAdjustmentLayer addTextLayer removeNode moveNode updateNode updateText duplicateNode
Pixelsstroke clear putPixels applyFilter rasterizeNode warp
SelectionselectShape selectWand selectAll deselect invertSelection moveSelection
CanvasresizeCanvas

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 };
  },
});
ExportUse
TILE_SIZE, RGBA_TILE_LENGTH, MASK_TILE_LENGTH, FIX15_ONETile layout and the fixed-point scale
tileKey, tileCoords, tileKeysInRect, forEachTileInRectTile coordinates and the tiles a rectangle touches
rgba8ToTiles, tilesToRgba8Convert between straight 8-bit RGBA and premultiplied fix15 tiles
isEmptyTile, contentBoundsDrop empty tiles and find the bounds of the visible pixels
floodSelectThe magic wand's mask: pixels similar to a seed pixel, connected or not
translateTilesMove RGBA or mask tiles by whole pixels, dropping what leaves a rectangle

peinten/tiles has no DOM or GPU dependencies, like apply itself.