Canvas Experimental
A free-form canvas controller. Move, resize, rotate, marquee-select and snap over an items signal you own. Mid-gesture state is a derived overlay; your signal is written once, at drop, which is exactly the shape stores, undo history and realtime sync want.
@mmstack/dndnpm
This API surface is experimental: it may still change and is not yet battle-tested in production. Pin a version and expect some churn.
#The controller
canvas() takes your array signal and two pure lenses: frame reads an item's position and size (a CanvasFrame: x, y, width, height, optional rotation), patch writes one back immutably. One delegated gesture on the surface decides what a press means by the element it lands on: an item moves, a handle resizes or rotates, empty space starts a marquee.
Demo loads on scroll.
import { Canvas, CanvasItem, CanvasResizeHandle, injectCanvas, type CanvasFrame } from '@mmstack/dnd';
type Widget = { id: string; frame: CanvasFrame };
@Component({
imports: [Canvas, CanvasItem, CanvasResizeHandle],
template: `
<div class="board" [mmCanvas]="ctrl">
@for (w of ctrl.items(); track w.id) {
<div class="widget" [mmCanvasItem]="w">
@if (solo() === w.id) {
<!-- resize acts on a single selected item -->
<i class="handle" mmCanvasResizeHandle="se"></i>
}
</div>
}
</div>
`,
})
class Board {
private readonly widgets = signal<readonly Widget[]>([...]);
protected readonly ctrl = injectCanvas(this.widgets, {
key: (w) => w.id,
frame: (w) => w.frame,
patch: (w, frame) => ({ ...w, frame }),
grid: { size: 8 },
});
protected readonly solo = computed(() => {
const ids = this.ctrl.selection.ids();
return ids.length === 1 ? ids[0] : null;
});
} Moves are transform-only, so the browser composites instead of laying out, and idle items recompute nothing while others drag. On release the controller maps patch over the touched items in one write and leaves everything else reference-identical.
#Interactions
The defaults match the tools people already know. Dragging an unselected item selects it, Shift-click toggles membership, dragging empty space marquee-selects, clicking empty space clears. Shift locks a move to the dominant axis and holds the aspect ratio on a resize, Alt resizes from the center, Ctrl bypasses snapping, Escape cancels. Arrow keys nudge the selection by the grid step, Shift makes it ten steps, and Cmd or Ctrl with an arrow resizes. Alignment snaplines and grid snap resolve from boxes measured once at gesture start, so mid-drag layout can never feed back into its own collision.
#Chrome from signals
The canvas is headless: selection outlines, handles, snaplines and the marquee rectangle are yours to render. ctrl.session exposes the derivation core (guides, marqueeRect, hoverContainer, the live deltas), the item directive sets mm-canvas-selected and mm-canvas-dragging classes, and the handle directives just mark elements with data attributes the gesture arbitration reads. The demo's snaplines are one SVG with a loop over guides().
<svg class="overlay">
@for (g of ctrl.session.guides(); track $index) {
<line class="guide" [attr.x1]="..." [attr.y1]="..." />
}
@if (ctrl.session.marqueeRect(); as r) {
<rect class="marquee" [attr.x]="r.x" [attr.y]="r.y" ... />
}
</svg>#Pan and zoom
panZoom() owns a space transform (wheel zooms around the cursor, middle-button drag pans) and doubles as the canvas space option, so every gesture projects through the live transform. You can zoom in the middle of a drag and the grabbed point stays under the cursor; snap tolerances stay screen-accurate at any scale.
protected readonly zoom = panZoom(this.viewportRef);
protected readonly ctrl = injectCanvas(this.widgets, {
// ...lenses
space: this.zoom,
});
// apply the transform to your content layer:
// [style.transform]="'translate(' + t.x + 'px, ' + t.y + 'px) scale(' + t.scale + ')'"#Containment
For editors where items contain other items, stages in a workflow, sections on a page, the containers option resolves the innermost accepting container under the pointer while you drag and reports it on the commit. With containerOf a drop over a different container becomes a reparent: the controller hands you frames already rebased into the target's coordinate space and does not write, so your tree restructuring and the frame updates land in a single update.
injectCanvas(this.nodes, {
// ...lenses
containers: {
isContainer: (n) => n.kind === 'stage',
containerOf: (n) => n.parent,
canContain: (stage, item) => item.kind === 'task',
},
onReparent: ({ patches, container }) => {
this.nodes.update((arr) =>
arr.map((n) => {
const frame = patches.get(n.id);
return frame ? { ...n, frame, parent: container } : n;
}),
);
},
});#Stores, undo, multiplayer
One identity-preserving write per gesture is what makes the canvas compose with @mmstack/primitives stores without any coupling: an op log sees a whole drag as one batch of per-property ops, storeHistory undoes it as one step, and per-property ops merge cleanly across peers. For live collaboration, ctrl.liveFrames() is the in-flight frames of the current gesture (throttle it into your presence channel), incoming peer drags render through the remoteOverlays option, and lockedKeys rejects local gestures on peer-held items.
import { opLog, store, storeHistory } from '@mmstack/primitives';
const doc = store<{ widgets: Widget[] }>({ widgets: [...] });
const history = storeHistory(doc);
const ctrl = injectCanvas(doc.widgets, { key, frame, patch });
// a whole drag emits ONE batch:
// set widgets.3.frame.x, set widgets.3.frame.y
opLog(doc, { origin: clientId }).subscribe(sendToPeers);
history.undo(); // restores the whole gesture