Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right Current lesson
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
The one truth in this chapter: the value of a professional Canvas is not that objects can be dragged, but that every interaction rule remains consistent.
Spread graph paper on a table. Add three paper cutouts, a cord with a plug at each end, and a few small magnets. When the child drags a cutout near a grid line, a magnet gently pulls it into place. When the child pinches the bottom-right corner, a pin keeps the top-left corner still. While holding a “keep the shape” card, the cutout grows only at its original proportions. Push a cutout to the edge of the table and the tablecloth slowly unrolls so the child can continue moving.
Predict what happens first. If the toy city is enlarged to 400%, should a magnet engage at the same map distance or at the same six pixels in the child’s view? After dragging the bottom-right corner past the top-left corner, which corner becomes the new “fixed pin”? If a cutout connected to the cord is deleted, should the loose end silently attach somewhere else?
- Pin the opposite cornerpull the other side
- Pull only when closethe visible distance stays constant
- Plugs have namesconnections do not guess
- Unroll at the table edgekeep dragging without losing the hand
“Feel” comes from many small rules staying consistent across every Zoom, Rotation, Group, and input device. Users may not know the words Pivot, Binding, or Cursor Semantics, but they notice immediately when handles grow and shrink, snapping becomes stronger or weaker, selection boxes jump, or copied connectors attach incorrectly.
Translate the toys into Canvas
| Toy action | Canvas Lab | Invariant |
|---|---|---|
| Circle one or more cutouts | Single/Multi/Marquee Selection | Selection is a set of stable IDs |
| Transparent frame around the cutouts | Selection Bounds | Derived from the selected Geometry’s World Bounds |
| Eight little clips | Resize Handles | Fixed screen size and independent hit rules |
| Ring above the frame | Rotation Handle | Change angle around a Pivot |
| Fixed pin | Anchor/Opposite Corner | World position does not drift during Resize |
| “Keep the shape” card | Aspect-Ratio Constraint | A Guard decides whether the constraint is active |
| Angle dial | Angle Constraint | Quantize relative to the starting angle instead of accumulating repeatedly |
| Small magnets and bright lines | Snapping/Alignment Guides/Distribution/Grid | Threshold is defined in screen pixels |
| Cord plugs | Connector/Binding/Ports | Reference a stable ID and an explicit port |
| Copier | Clipboard Schema/Copy/Paste | Validate, reassign IDs, and preserve internal references |
| Unroll the tablecloth at an edge | Edge Scrolling | Recalculate World Point after Camera movement |
The analogy has limits. A magnet does not automatically resolve priority among candidates; the implementation needs deterministic rules. Flipping a cutout is intuitive, but negative Scale may be unacceptable for text. A real cord can lie anywhere, while Connector Binding must handle permissions, deletion, and port compatibility. Clipboard content is untrusted input, not output from your own copier; users and other applications can change it.
Kill the wrong instincts first
- “If dragging works, the editor is finished.” Multi-selection, crossing Resize boundaries, Rotation, snapping, keyboard alternatives, and Undo determine reliability.
- “Drawing Handles in world space is easiest.” Zoom turns them into houses or dust. Keep their visual and hit sizes fixed in a Screen Overlay.
- “Use a fixed 10 world units for Snap.” Magnetic strength changes completely with Zoom. Convert from a screen threshold.
- “Resize only changes width and height.” Rotated objects and multi-selection require transforming the pointer into Selection Local Space and updating matrices around the Opposite Corner.
- “Copy means
JSON.stringify(selection).” External Bindings, unknown Shapes, maliciously large payloads, and duplicate IDs corrupt the Document. - “Let a connector find the nearest Shape every frame.” It jumps among overlapping targets. Once committed, store a validated Binding.
- “Edge scrolling changes only the Camera.” After the Camera moves, the same Screen Pointer maps to a new World Point, so Preview must be recalculated.
Production backpack
Prerequisite contract
The Chapter 8 Geometry Provider supplies Bounds, Outline, Nearest Point, and Snap Points. Chapter 9 gives Tool State explicit Pointing, Dragging, Resizing, and Rotating states plus a Transaction. The Document has Stable IDs, BindingRecords, and Group Transforms. This chapter never bypasses the Command Boundary.
Formal knowledge
Single Selection is one ID; Multi-Selection is multiple IDs with stable order or set semantics. Marquee Select converts a screen rectangle into a World Query, then applies the product rule of “fully contained” or “intersects.” Selection Bounds are derived state, usually the union of all selected objects’ transformed bounds. A rotated multi-selection can use a world-axis-aligned box or a fixed selection-oriented box. Record the decision in an ADR; never switch algorithms during Pointer Move.
Resize Handles and the Rotation Handle are usually drawn in Screen Space in a DOM or Canvas Overlay. At Resize start, freeze initialSelectionSnapshot, the Selection Matrix, Pivot, Anchor, and Opposite Corner. Calculate every frame from the initial snapshot rather than accumulating from the previous Preview, avoiding floating-point drift. When the Pointer crosses the Opposite Handle, the scale sign changes and handle semantics flip. If the product prohibits mirrored text, clamp to a minimum size and show the correct cursor. Aspect-Ratio Constraint solves from the initial width-to-height ratio. Angle Constraint quantizes atan2(pointer-pivot)-startAngle, for example to 15° increments.
Snapping is five separate responsibilities: candidate generation, distance scoring, priority, threshold, and visual feedback. Grid Snap supplies points. Alignment Guides compare left/center/right and top/middle/bottom. Distribution compares gaps. Define the threshold in screen CSS px, then use the Camera inverse to obtain world tolerance. Break equal scores by type priority, Z-Order, and stable ID; never rely on accidental array order. A modifier can enable or disable snapping midway, and Preview recalculates from the raw pointer every frame.
A Connector is a Shape. A Binding relates a Connector endpoint to a target Shape and Port. A Port Provider supplies connectable points, directions, and permissions. Smart Connector first uses the Broad Phase to find nearby ports, then scores them by screen distance, direction, and compatibility. When a target is deleted, follow an explicit product rule: either “unbind and fix the endpoint at its last world point” or cascade the delete. Never leave a dangling ID. Orthogonal or obstacle-avoiding routing for a Connector crossing Shapes can come later; this chapter first guarantees consistent binding.
Copy/Paste writes a Clipboard Schema containing mime/version/records/bindings/bounds. Paste first limits bytes, parses, validates types and fields, isolates Unknown Shapes, reissues IDs for every internal Record, then rewrites Parent and Binding references through an old-to-new ID map. A Binding to an external target that was not copied should be removed or converted to a free endpoint. Drag/Drop likewise treats DataTransfer as untrusted input. Duplicate Drag creates one Preview clone after crossing the threshold and commits one Batch; never clone on every Move.
Freehand uses the Coalesced Points from Chapter 7. First resample by minimum screen distance, then apply a moving average, Catmull–Rom, or pressure-aware stroke smoothing. Depending on the product, raw points may be retained for re-editing while the Renderer caches the smoothed path. Cursor Semantics should explain “what can happen now”: handles select the correct resize cursor for object rotation, while connector ends, duplicate dragging, forbidden operations, and text editing each provide feedback. Also supply visual and accessible cues that do not depend only on the Cursor.
Edge Scrolling derives Camera velocity from the Pointer’s distance to a Viewport edge and integrates against an rAF clock, not event frequency. Capture still belongs to InputController. Each frame moves the Camera first, converts the fixed Screen Pointer to a new World Point, and updates Preview. Pointer End/Cancel, leaving the activation band, or switching Tools must stop auto-scroll.
Evidence and compatibility (verified 2026-08-29)
- MDN Clipboard API explains that the asynchronous clipboard requires a secure context, permission, and user activation.
ClipboardEvent.clipboardDatais the DataTransfer entry point for copy/paste events. Preserve an in-eventtext/plainfallback. - MDN HTML Drag and Drop API and
DataTransferdefine the boundary for external file and data drops; the data still requires validation. - Edge Scrolling uses MDN Pointer Capture to preserve input continuity, but
pointercancel/lostpointercapturemust still stop the animation. - See MDN
cursorfor available Cursor values and platform differences. Cursor cannot be the only state cue.
The engineering increment for this chapter
Starting point: Single-object translation, handles that change size with Zoom, and a Connector that is only a two-point Polyline. Finish line: Multi-select, Marquee, Resize, Rotate, Guides, Grid, Smart Connector, Copy/Paste, Modifier Duplicate, and Edge Scroll all run through the Chapter 9 Transaction.
Add these files:
src/engine/interactions/SelectionTransform.ts: Selection, Pivot, Resize, and Rotate;src/engine/snapping/SnapEngine.ts: Grid, Alignment, Distribution candidates, and Guides;src/engine/bindings/ConnectorBinding.ts: Ports and disconnection rules;src/engine/clipboard/CanvasClipboard.ts: schema, ID remap, and fallback;src/engine/freehand/resample.ts: coalesced-point resampling and smoothing;src/engine/viewport/EdgeScroller.ts: rAF-based edge velocity;src/engine/interactions/__tests__/selection-transform.test.tsandtests/browser/editor-feel.spec.ts.
The complete core below expresses “fixed opposite corner, crossing and flipping, aspect constraint, and screen-threshold snapping” as pure functions. Every Preview is calculated from the frozen initial value:
export type Vec = Readonly<{ x: number; y: number }>;
export type Bounds = Readonly<{ x: number; y: number; width: number; height: number }>;
export type Handle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
export type Guide = Readonly<{
axis: 'x' | 'y';
value: number;
sourceId: string;
priority: number;
}>;
export type ResizeResult = Readonly<{
bounds: Bounds;
scaleX: number;
scaleY: number;
nextHandle: Handle;
}>;
const components: Record<Handle, readonly [-1 | 0 | 1, -1 | 0 | 1]> = {
nw: [-1, -1],
n: [0, -1],
ne: [1, -1],
e: [1, 0],
se: [1, 1],
s: [0, 1],
sw: [-1, 1],
w: [-1, 0],
};
const byComponents = new Map<string, Handle>(
Object.entries(components).map(([name, pair]) => [pair.join(','), name as Handle]),
);
export function resizeFromHandle(
initial: Bounds,
handle: Handle,
pointer: Vec,
keepAspect: boolean,
minimum = 1,
): ResizeResult {
if (!(initial.width > 0) || !(initial.height > 0) || !(minimum > 0))
throw new Error('RESIZE_BOUNDS_MUST_BE_POSITIVE');
const [hx, hy] = components[handle];
const opposite: Vec = {
x: hx < 0 ? initial.x + initial.width : hx > 0 ? initial.x : initial.x + initial.width / 2,
y: hy < 0 ? initial.y + initial.height : hy > 0 ? initial.y : initial.y + initial.height / 2,
};
let dx = hx === 0 ? initial.width : pointer.x - opposite.x;
let dy = hy === 0 ? initial.height : pointer.y - opposite.y;
if (keepAspect && hx !== 0 && hy !== 0) {
const ratio = Math.max(minimum, initial.width) / Math.max(minimum, initial.height);
if (Math.abs(dx / ratio) > Math.abs(dy)) dy = Math.sign(dy || hy) * Math.abs(dx / ratio);
else dx = Math.sign(dx || hx) * Math.abs(dy * ratio);
}
const width = hx === 0 ? initial.width : Math.max(minimum, Math.abs(dx));
const height = hy === 0 ? initial.height : Math.max(minimum, Math.abs(dy));
const nextX = hx === 0 ? 0 : (Math.sign(dx || hx) as -1 | 1);
const nextY = hy === 0 ? 0 : (Math.sign(dy || hy) as -1 | 1);
const endpointX = opposite.x + nextX * width;
const endpointY = opposite.y + nextY * height;
const x = hx === 0 ? initial.x : Math.min(opposite.x, endpointX);
const y = hy === 0 ? initial.y : Math.min(opposite.y, endpointY);
const sx = hx === 0 ? 1 : (nextX * width) / (hx * initial.width);
const sy = hy === 0 ? 1 : (nextY * height) / (hy * initial.height);
return {
bounds: { x, y, width, height },
scaleX: sx,
scaleY: sy,
nextHandle: byComponents.get(`${nextX},${nextY}`)!,
};
}
export function constrainedAngle(
pivot: Vec,
pointer: Vec,
startPointerAngle: number,
startShapeAngle: number,
stepDegrees: number,
): number {
const pointerAngle = Math.atan2(pointer.y - pivot.y, pointer.x - pivot.x);
const delta = Math.atan2(
Math.sin(pointerAngle - startPointerAngle),
Math.cos(pointerAngle - startPointerAngle),
);
const step = (stepDegrees * Math.PI) / 180;
const constrainedDelta = step > 0 ? Math.round(delta / step) * step : delta;
return startShapeAngle + constrainedDelta;
}
export function snapPoint(
raw: Vec,
guides: readonly Guide[],
thresholdScreenPx: number,
cameraScale: number,
): Readonly<{ point: Vec; active: readonly Guide[] }> {
if (
!(cameraScale > 0) ||
!Number.isFinite(cameraScale) ||
!(thresholdScreenPx >= 0) ||
!Number.isFinite(thresholdScreenPx)
)
return { point: raw, active: [] };
const thresholdWorld = thresholdScreenPx / cameraScale;
const choose = (axis: 'x' | 'y') =>
guides
.filter((g) => g.axis === axis && Math.abs(g.value - raw[axis]) <= thresholdWorld)
.sort(
(a, b) =>
Math.abs(a.value - raw[axis]) - Math.abs(b.value - raw[axis]) ||
b.priority - a.priority ||
a.sourceId.localeCompare(b.sourceId),
)[0];
const xGuide = choose('x'),
yGuide = choose('y');
return {
point: { x: xGuide?.value ?? raw.x, y: yGuide?.value ?? raw.y },
active: [xGuide, yGuide].filter((g): g is Guide => Boolean(g)),
};
}
The key Clipboard operation is “validate and rename,” not stringify. This executable implementation rejects an unknown version, duplicate source IDs, and oversized data, then rewrites internal connections. The caller’s makeId() must reserve names from the Document’s global ID allocator; write the fully validated return value in one Transaction:
type ClipShape = Readonly<{ id: string; type: 'rect' | 'ellipse' | 'text'; x: number; y: number }>;
type ClipBinding = Readonly<{ id: string; fromId: string; toId: string }>;
type CanvasClip = Readonly<{
mime: 'application/x-canvas-lab+json';
version: 1;
shapes: readonly ClipShape[];
bindings: readonly ClipBinding[];
}>;
export function parseAndRemapClipboard(text: string, makeId: () => string): CanvasClip {
if (new TextEncoder().encode(text).byteLength > 1_000_000) throw new Error('CLIPBOARD_TOO_LARGE');
let value: unknown;
try {
value = JSON.parse(text);
} catch {
throw new Error('CLIPBOARD_INVALID_JSON');
}
if (!value || typeof value !== 'object') throw new Error('CLIPBOARD_INVALID');
const v = value as Record<string, unknown>;
if (
v.mime !== 'application/x-canvas-lab+json' ||
v.version !== 1 ||
!Array.isArray(v.shapes) ||
!Array.isArray(v.bindings)
)
throw new Error('CLIPBOARD_UNSUPPORTED');
const isShape = (s: unknown): s is ClipShape =>
Boolean(
s &&
typeof s === 'object' &&
typeof (s as ClipShape).id === 'string' &&
['rect', 'ellipse', 'text'].includes((s as ClipShape).type) &&
Number.isFinite((s as ClipShape).x) &&
Number.isFinite((s as ClipShape).y),
);
if (!v.shapes.every(isShape) || v.shapes.length > 5000)
throw new Error('CLIPBOARD_INVALID_SHAPE');
if (new Set(v.shapes.map((shape) => shape.id)).size !== v.shapes.length)
throw new Error('CLIPBOARD_DUPLICATE_ID');
const issued = new Set<string>();
const nextId = () => {
const id = makeId();
if (!id || issued.has(id)) throw new Error('CLIPBOARD_ID_FACTORY_COLLISION');
issued.add(id);
return id;
};
const ids = new Map(v.shapes.map((s) => [s.id, nextId()]));
const shapes = v.shapes.map((s) => ({ ...s, id: ids.get(s.id)!, x: s.x + 16, y: s.y + 16 }));
const isBinding = (b: unknown): b is ClipBinding =>
Boolean(
b &&
typeof b === 'object' &&
typeof (b as ClipBinding).id === 'string' &&
typeof (b as ClipBinding).fromId === 'string' &&
typeof (b as ClipBinding).toId === 'string',
);
if (!v.bindings.every(isBinding) || v.bindings.length > 10_000)
throw new Error('CLIPBOARD_INVALID_BINDING');
const bindings = v.bindings.flatMap((b): ClipBinding[] => {
const fromId = ids.get(b.fromId),
toId = ids.get(b.toId);
return fromId && toId ? [{ id: nextId(), fromId, toId }] : [];
});
return { mime: 'application/x-canvas-lab+json', version: 1, shapes, bindings };
}
Tests lock down the crossing and Zoom rules that visual inspection most easily misses:
import { describe, expect, it } from 'vitest';
import { constrainedAngle, resizeFromHandle, snapPoint } from '../SelectionTransform';
describe('professional interaction invariants', () => {
it('flips handle semantics after crossing the opposite corner while keeping the anchor fixed', () => {
const result = resizeFromHandle(
{ x: 0, y: 0, width: 100, height: 50 },
'se',
{ x: -20, y: -10 },
false,
);
expect(result.nextHandle).toBe('nw');
expect(result.bounds).toEqual({ x: -20, y: -10, width: 20, height: 10 });
expect({
x: result.bounds.x + result.bounds.width,
y: result.bounds.y + result.bounds.height,
}).toEqual({ x: 0, y: 0 });
});
it('does not incorrectly flip the northwest handle to southeast before it crosses', () => {
const result = resizeFromHandle(
{ x: 0, y: 0, width: 100, height: 50 },
'nw',
{ x: -20, y: -10 },
false,
);
expect(result.nextHandle).toBe('nw');
expect(result.scaleX).toBeGreaterThan(0);
expect(result.scaleY).toBeGreaterThan(0);
expect({
x: result.bounds.x + result.bounds.width,
y: result.bounds.y + result.bounds.height,
}).toEqual({ x: 100, y: 50 });
});
it('keeps a 6px snap threshold the same at different zoom levels', () => {
const guides = [{ axis: 'x' as const, value: 100, sourceId: 'grid-100', priority: 1 }];
expect(snapPoint({ x: 105, y: 0 }, guides, 6, 1).point.x).toBe(100);
expect(snapPoint({ x: 100.5, y: 0 }, guides, 6, 10).point.x).toBe(100);
expect(snapPoint({ x: 100.7, y: 0 }, guides, 6, 10).point.x).toBe(100.7);
});
it('calculates rotation delta from the frozen start angle instead of accumulating each frame', () => {
const degrees = (value: number) => (value * Math.PI) / 180;
const angle = constrainedAngle(
{ x: 0, y: 0 },
{ x: Math.cos(degrees(29)), y: Math.sin(degrees(29)) },
degrees(4),
degrees(10),
15,
);
expect(angle).toBeCloseTo(degrees(40));
});
});
Run pnpm vitest run src/engine/interactions src/engine/clipboard. Boundary crossing, aspect ratio, rotation, ID remapping, and malicious-payload tests should all pass. Run pnpm playwright test tests/browser/editor-feel.spec.ts. At 25%, 100%, and 400% Zoom, handle side lengths should differ by no more than 1 px; a point 5 px from a guide should snap, while one 7 px away should not.
Return to the magnet table. Handles are clips, the Opposite Corner is the pin, SnapEngine is the magnet that works only when the visible distance is close enough, and Binding is a named plug. This code does not add effects to a demo; it preserves one set of physical rules across every zoom, rotation, and cancellation path.
Break it on purpose
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Handle crosses Opposite | Negative width; selection box disappears | Scale sign and anchor trace | Flip handle semantics and normalize bounds | Parameterize all four corner crossings | Recalculate from initial snapshot |
| Rotated Multi-Selection | Objects rotate around wrong center | Visualize Selection matrix and pivot | Fix selection coordinate frame | 90°/37° multi-selection tests | Discard accumulated transform |
| Group Resize | Children drift | Local/world round trip | Compose transform in group local space | Nested-group fixture | Restore original local transform |
| Snap after changing Zoom | Magnet becomes stronger or weaker | Screen distance and world-threshold log | Invert screen threshold | Three Zoom levels | Recalculate candidates |
| Delete Connector Target | Dangling Binding or incorrect reattachment | Dangling-ID validator | Unlink at last world point or explicit cascade | Target-deletion test | Isolate invalid binding |
| Paste Unknown Shape | Crash or empty object | Schema rejection code | Isolate Unknown or migrate version | Unknown-type fixture | Do not write Document |
| Tamper with Clipboard | Duplicate IDs or excessive memory | Byte-limit and validator logs | Limit, validate, and remap | Fuzz JSON | Roll back entire paste atomically |
| Edge Scroll + Capture Lost | Camera keeps drifting | Active rAF count | Cancel stops scroller | Lost-capture browser test | Stop rAF and roll back Preview |
Pass with evidence
| Requirement | Acceptable evidence | Not acceptable |
|---|---|---|
| Handle is the same size at every Zoom | Browser pixel measurement within 1 px | “Looks about right” |
| Snap feels consistent | Fixed 5/7 screen-px tests at multiple Zoom levels | Fixed world units |
| Selection does not jump | Replay calculated from frozen snapshot | Multiplying frame after frame |
| Connector does not misbind | Stable scoring plus delete/overlap fixtures | “Take the nearest one” |
| Clipboard is safe | Size/type/version/fuzz/atomic-rollback tests | Only try/catch around JSON |
| Edge Scroll stops | Active rAF and Cancel assertions | No visible movement after release |
| Test surface | Automated evidence | Manual evidence |
|---|---|---|
| Handle and Snap threshold | Playwright pixel measurement and multi-Zoom fixtures | Compare feel with a real mouse and touchscreen |
| Connector stability | Binding validator and deletion/overlap tests | Drag across dense ports and watch for jumping |
| Edge Scrolling | Fake clock, rAF, and cancel counts | Drag to all four edges and lose focus midway |
- Single/Multi/Marquee Selection and Selection Bounds have deterministic rules.
- Resize/Rotate Handle, Pivot, Anchor, Opposite Corner, aspect, and angle constraints have tests.
- Alignment, Distribution, and Grid Snap share screen thresholds and draw Guides.
- Connector, Binding, Ports, and the Target deletion protocol are defined.
- Copy/Paste, Drag/Drop, and Duplicate Drag all pass through Schema, ID remap, and Transaction.
- Freehand uses coalesced points, resampling, and a smoothing strategy; Cursor has semantics but is not the only cue.
- Edge Scrolling has verified stop, recalculation, and cancellation paths with Pointer Capture.
Explain it to a five-year-old
Answer without using “snap,” “scale,” “binding,” or “clipboard”:
- Why is a small magnet’s distance based on what the child’s eyes see rather than map squares?
- After pulling a cutout’s bottom-right corner past the top-left pin, how can it keep growing naturally?
- When copying a cord, why must the new cutouts and plugs receive new names?
- New question: after the child pushes a cutout to the table edge and the tablecloth starts moving, why must we measure the map position under the hand again?