- Run record
- loading
Loading this Run…
Loading the Revisions of this Run…
A published source file of Revision 0 of Canvely — Spring Menu 1.0.0, shown as text in the browser.
Source is browser-viewable only. There is no download or archive endpoint, and no additional license is granted beyond inspection and research.
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
memo,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import {
type Doc,
type FrameLayer,
type Layer,
type Leaf,
type OBox,
type Pt,
type Rect,
type TextLayer,
applyOBoxToLeaf,
axes,
boundsOf,
childrenOf,
existing,
fmt,
frameAt,
frameOffset,
getIndex,
hitTest,
insertNode,
isLeaf,
leafOBox,
marqueeHits,
newId,
nextName,
nodeBounds,
normalizeAngle,
obCorners,
obPoint,
rectToOBox,
replaceNodes,
resizeOBox,
round2,
scaleMany,
selectionBounds,
topSelection,
translateMany,
updateNode,
} from "./model";
import {
type Tool,
type View,
beginGesture,
cancelGesture,
clampZoom,
endGesture,
fitAll,
getState,
live,
select,
setState,
setTool,
setView,
useEditor,
zoomAt,
} from "./store";
import { commitTextEdit, startTextEdit } from "./actions";
export const canvasPointer = { over: false };
// ---------------------------------------------------------------------------
// Layer rendering
function leafStyle(n: Leaf): CSSProperties {
const s: CSSProperties = {
left: n.x,
top: n.y,
width: n.width,
height: n.height,
opacity: n.opacity,
transform: n.rotation ? `rotate(${n.rotation}deg)` : undefined,
};
if (n.type === "rect") {
s.background = n.fill;
s.borderRadius = Math.max(0, Math.min(n.corner_radius, n.width / 2, n.height / 2));
if (n.stroke && n.stroke.width > 0) s.boxShadow = `inset 0 0 0 ${n.stroke.width}px ${n.stroke.color}`;
} else if (n.type === "ellipse") {
s.background = n.fill;
s.borderRadius = "50%";
} else {
s.color = n.fill;
s.fontSize = n.font_size;
s.fontWeight = n.font_weight;
s.lineHeight = n.line_height;
s.textAlign = n.align;
}
return s;
}
function TextEditor({ node }: { node: TextLayer }) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current!;
el.textContent = node.text;
el.focus({ preventScroll: true });
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
// Only on mount: the editor owns its content until it commits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const done = () => commitTextEdit(ref.current?.innerText ?? "");
return (
<div
ref={ref}
data-text-editor=""
className="layer layer-text is-editing"
style={leafStyle(node)}
contentEditable="plaintext-only"
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={`Text of ${node.name}. Press Escape to finish.`}
data-placeholder="Type something"
spellCheck={false}
onBlur={done}
onPointerDown={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.preventDefault();
done();
document.querySelector<HTMLElement>("[data-canvas]")?.focus({ preventScroll: true });
}
}}
/>
);
}
const NodeView = memo(function NodeView({ node, editingId }: { node: Layer; editingId: string | null }) {
if (!node.visible) return null;
if (node.type === "group") {
return (
<>
{node.children.map((c) => (
<NodeView key={c.id} node={c} editingId={editingId} />
))}
</>
);
}
if (node.type === "frame") {
return (
<div className="frame" style={{ left: node.x, top: node.y, width: node.width, height: node.height, background: node.fill }}>
{node.children.map((c) => (
<NodeView key={c.id} node={c} editingId={editingId} />
))}
</div>
);
}
if (node.type === "text") {
if (node.id === editingId) return <TextEditor node={node} />;
return (
<div className="layer layer-text" style={leafStyle(node)}>
{node.text}
</div>
);
}
return <div className={`layer layer-${node.type}`} style={leafStyle(node)} />;
});
// ---------------------------------------------------------------------------
// Interaction helpers
type Gesture =
| {
kind: "pending";
pointerId: number;
startS: Pt;
startD: Pt;
target: string | null;
wasSelected: boolean;
shift: boolean;
touch: boolean;
}
| { kind: "move"; pointerId: number; startD: Pt; ids: string[]; base: Doc; b0: Rect; targets: Rect[] }
| {
kind: "resize";
pointerId: number;
startD: Pt;
hx: number;
hy: number;
mode: "leaf" | "frame" | "box";
ids: string[];
ob0: OBox;
base: Doc;
}
| { kind: "rotate"; pointerId: number; id: string; c: Pt; a0: number; rot0: number; base: Doc }
| { kind: "marquee"; pointerId: number; startD: Pt; scope: string | null; additive: boolean; baseSel: string[] }
| { kind: "pan"; pointerId: number; startS: Pt; v0: View }
| {
kind: "create";
pointerId: number;
type: Exclude<Tool, "move">;
startD: Pt;
startS: Pt;
node: Leaf;
parentId: string | null;
off: Pt;
base: Doc;
moved: boolean;
}
| { kind: "pinch"; d0: number; mid0: Pt; v0: View };
interface Guide {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface Hud {
text: string;
x: number;
y: number;
}
function resolveClick(doc: Doc, chain: string[], selection: string[]): string {
const idx = getIndex(doc);
for (const id of selection) {
const info = idx.get(id);
if (!info?.parentId || idx.get(info.parentId)?.node.type !== "group") continue;
const i = chain.indexOf(info.parentId);
if (i >= 0 && i + 1 < chain.length) return chain[i + 1];
}
return chain[0];
}
function snapTargets(doc: Doc, ids: string[]): Rect[] {
const idx = getIndex(doc);
const moving = new Set(ids);
const rects: Rect[] = [];
const parents = new Set<string | null>();
const frames = new Set<string>();
for (const id of ids) {
const info = idx.get(id);
if (!info) continue;
if (!parents.has(info.parentId)) {
parents.add(info.parentId);
const off = frameOffset(doc, id);
for (const sib of childrenOf(doc, info.parentId)) {
if (moving.has(sib.id) || !sib.visible) continue;
const b = nodeBounds(sib, off);
if (b) rects.push(b);
}
}
if (info.frameId && !frames.has(info.frameId)) {
frames.add(info.frameId);
const b = boundsOf(doc, info.frameId);
if (b) rects.push(b);
}
}
return rects;
}
function snapMove(targets: Rect[], b: Rect, thr: number): { dx: number; dy: number; guides: Guide[] } {
const xs = (r: Rect) => [r.x, r.x + r.w / 2, r.x + r.w];
const ys = (r: Rect) => [r.y, r.y + r.h / 2, r.y + r.h];
let bx = Infinity;
let by = Infinity;
for (const t of targets) {
for (const tx of xs(t)) for (const m of xs(b)) if (Math.abs(tx - m) <= thr && Math.abs(tx - m) < Math.abs(bx)) bx = tx - m;
for (const ty of ys(t)) for (const m of ys(b)) if (Math.abs(ty - m) <= thr && Math.abs(ty - m) < Math.abs(by)) by = ty - m;
}
const dx = Number.isFinite(bx) ? bx : 0;
const dy = Number.isFinite(by) ? by : 0;
const nb = { x: b.x + dx, y: b.y + dy, w: b.w, h: b.h };
const vx = new Map<number, [number, number]>();
const hy = new Map<number, [number, number]>();
const eps = 0.01;
for (const t of targets) {
if (Number.isFinite(bx))
for (const tx of xs(t))
for (const m of xs(nb))
if (Math.abs(tx - m) < eps) {
const k = round2(tx);
const cur = vx.get(k);
const lo = Math.min(t.y, nb.y, cur?.[0] ?? Infinity);
const hi = Math.max(t.y + t.h, nb.y + nb.h, cur?.[1] ?? -Infinity);
vx.set(k, [lo, hi]);
}
if (Number.isFinite(by))
for (const ty of ys(t))
for (const m of ys(nb))
if (Math.abs(ty - m) < eps) {
const k = round2(ty);
const cur = hy.get(k);
const lo = Math.min(t.x, nb.x, cur?.[0] ?? Infinity);
const hi = Math.max(t.x + t.w, nb.x + nb.w, cur?.[1] ?? -Infinity);
hy.set(k, [lo, hi]);
}
}
const guides: Guide[] = [];
vx.forEach(([lo, hi], x) => guides.push({ x1: x, y1: lo, x2: x, y2: hi }));
hy.forEach(([lo, hi], y) => guides.push({ x1: lo, y1: y, x2: hi, y2: y }));
return { dx, dy, guides };
}
function isDark(hex: string) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex);
if (!m) return false;
const v = parseInt(m[1], 16);
const r = (v >> 16) & 255, g = (v >> 8) & 255, b = v & 255;
return 0.299 * r + 0.587 * g + 0.114 * b < 140;
}
function makeLayer(type: Exclude<Tool, "move">, doc: Doc, x: number, y: number, frame: FrameLayer | null): Leaf {
const common = { id: newId(), visible: true, locked: false, x: round2(x), y: round2(y), rotation: 0, opacity: 1 };
if (type === "rect") {
return { ...common, type: "rect", name: nextName(doc, "Rectangle"), width: 1, height: 1, fill: "#C9C1B4", corner_radius: 0, stroke: null };
}
if (type === "ellipse") {
return { ...common, type: "ellipse", name: nextName(doc, "Ellipse"), width: 1, height: 1, fill: "#E0B25A" };
}
return {
...common,
type: "text",
name: nextName(doc, "Text"),
width: 1,
height: 1,
text: "",
font_size: 24,
font_weight: 500,
line_height: 1.3,
align: "left",
fill: frame && isDark(frame.fill) ? "#FBF6EE" : "#2B2520",
};
}
const DEFAULT_SIZE: Record<Exclude<Tool, "move">, [number, number]> = {
rect: [120, 80],
ellipse: [96, 96],
text: [220, 32],
};
const HANDLES: [number, number][] = [
[0, -1],
[1, 0],
[0, 1],
[-1, 0],
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
];
function resizeCursor(hx: number, hy: number, rot: number) {
const a = (((Math.atan2(hy, hx) * 180) / Math.PI + rot) % 180 + 180) % 180;
return ["ew-resize", "nwse-resize", "ns-resize", "nesw-resize"][Math.round(a / 45) % 4];
}
const ROTATE_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M7 15.5A6.5 6.5 0 1 1 12 18.5" stroke="#fff" stroke-width="4"/><path d="M4 12.5l3 3.3 3.3-3" stroke="#fff" stroke-width="4"/><path d="M7 15.5A6.5 6.5 0 1 1 12 18.5" stroke="#111" stroke-width="1.6"/><path d="M4 12.5l3 3.3 3.3-3" stroke="#111" stroke-width="1.6"/></g></svg>',
)}") 12 12, crosshair`;
const TOOL_HINT: Record<Exclude<Tool, "move">, string> = {
rect: "Drag to draw a rectangle, or click for a 120 × 80 one. Hold Shift for a square. Esc cancels.",
ellipse: "Drag to draw an ellipse, or click for a 96 px circle. Hold Shift for a circle. Esc cancels.",
text: "Click to add a text box, or drag to set its width. Esc cancels.",
};
// ---------------------------------------------------------------------------
export function Canvas() {
const st = useEditor();
const { doc, view, selection, tool, editing } = st;
const ref = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const touches = useRef(new Map<number, Pt>());
const fitted = useRef(false);
const [guides, setGuides] = useState<Guide[]>([]);
const [marquee, setMarquee] = useState<Rect | null>(null);
const [hud, setHud] = useState<Hud | null>(null);
const [hover, setHover] = useState<string | null>(null);
const [activeKind, setActiveKind] = useState<Gesture["kind"] | null>(null);
const setG = (g: Gesture | null) => {
gesture.current = g;
setActiveKind(g ? g.kind : null);
};
const screenPt = (e: { clientX: number; clientY: number }): Pt => {
const r = ref.current!.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
const toDoc = (s: Pt): Pt => {
const v = getState().view;
return { x: (s.x - v.x) / v.zoom, y: (s.y - v.y) / v.zoom };
};
// Viewport size + first fit
useLayoutEffect(() => {
const el = ref.current!;
const ro = new ResizeObserver(() => {
const r = el.getBoundingClientRect();
const prev = getState().viewport;
if (r.width === prev.w && r.height === prev.h) return;
setState({ viewport: { w: r.width, h: r.height } });
if (!fitted.current && r.width > 0 && r.height > 0) {
fitted.current = true;
fitAll();
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Wheel: pan, and Ctrl/Cmd + wheel (or pinch) zooms around the pointer.
useEffect(() => {
const el = ref.current!;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1;
let dx = e.deltaX * unit;
let dy = e.deltaY * unit;
const s = screenPt(e);
if (e.ctrlKey || e.metaKey) {
const d = Math.max(-60, Math.min(60, dy));
zoomAt(s.x, s.y, getState().view.zoom * Math.exp(-d / 100));
return;
}
if (e.shiftKey && dx === 0) {
dx = dy;
dy = 0;
}
const v = getState().view;
setView({ ...v, x: v.x - dx, y: v.y - dy });
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
// Escape cancels an in-progress gesture before global shortcuts see it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape" || !gesture.current) return;
const g = gesture.current;
if (g.kind === "move" || g.kind === "resize" || g.kind === "rotate" || g.kind === "create") cancelGesture();
if (g.kind === "marquee") select(g.baseSel);
if (g.kind === "create") setTool("move");
setG(null);
clearTransient();
e.stopImmediatePropagation();
e.preventDefault();
};
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, []);
const clearTransient = () => {
setGuides([]);
setMarquee(null);
setHud(null);
};
const capture = (pointerId: number) => {
try {
ref.current?.setPointerCapture(pointerId);
} catch {
// Pointer may already be released.
}
};
const commitOpenEditor = () => {
if (!getState().editing) return;
const ed = document.querySelector<HTMLElement>("[data-text-editor]");
commitTextEdit(ed?.innerText ?? "");
};
const startPan = (pointerId: number, s: Pt) => {
setG({ kind: "pan", pointerId, startS: s, v0: getState().view });
capture(pointerId);
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
commitOpenEditor();
const s = screenPt(e);
if (e.pointerType === "touch") {
touches.current.set(e.pointerId, s);
if (touches.current.size === 2) {
const g = gesture.current;
if (g && (g.kind === "move" || g.kind === "resize" || g.kind === "rotate" || g.kind === "create")) cancelGesture();
const [a, b] = [...touches.current.values()];
setG({ kind: "pinch", d0: Math.hypot(a.x - b.x, a.y - b.y) || 1, mid0: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, v0: getState().view });
clearTransient();
return;
}
}
if (gesture.current) return;
const state = getState();
if (e.button === 1 || (e.button === 0 && state.spaceDown)) {
e.preventDefault();
startPan(e.pointerId, s);
return;
}
if (e.button !== 0) return;
ref.current?.focus({ preventScroll: true });
const p = toDoc(s);
if (state.tool !== "move") {
const frame = frameAt(state.doc, p, true);
const off = frame ? { x: frame.x, y: frame.y } : { x: 0, y: 0 };
const node = makeLayer(state.tool, state.doc, p.x - off.x, p.y - off.y, frame);
const parentId = frame?.id ?? null;
const base = state.doc;
beginGesture();
live(insertNode(base, parentId, childrenOf(base, parentId).length, node), [node.id]);
setG({ kind: "create", pointerId: e.pointerId, type: state.tool, startD: p, startS: s, node, parentId, off, base, moved: false });
capture(e.pointerId);
return;
}
const tol = 3 / state.view.zoom;
const chain = hitTest(state.doc, p, tol);
if (!chain) {
if (e.pointerType === "touch") {
startPan(e.pointerId, s);
return;
}
const f = frameAt(state.doc, p, true);
if (!e.shiftKey) select([]);
setG({ kind: "marquee", pointerId: e.pointerId, startD: p, scope: f?.id ?? null, additive: e.shiftKey, baseSel: e.shiftKey ? state.selection : [] });
capture(e.pointerId);
return;
}
const target = resolveClick(state.doc, chain, state.selection);
const wasSelected = state.selection.includes(target);
if (e.shiftKey) {
select(wasSelected ? state.selection.filter((id) => id !== target) : [...state.selection, target]);
} else if (!wasSelected) {
select([target]);
}
setG({
kind: "pending",
pointerId: e.pointerId,
startS: s,
startD: p,
target,
wasSelected,
shift: e.shiftKey,
touch: e.pointerType === "touch",
});
capture(e.pointerId);
};
const onLabelDown = (e: ReactPointerEvent, id: string) => {
e.stopPropagation();
commitOpenEditor();
if (e.button !== 0 || gesture.current) return;
const state = getState();
if (state.spaceDown) {
startPan(e.pointerId, screenPt(e));
return;
}
const info = getIndex(state.doc).get(id);
if (!info || info.locked) return;
if (state.tool !== "move") setTool("move");
ref.current?.focus({ preventScroll: true });
const wasSelected = state.selection.includes(id);
if (e.shiftKey) select(wasSelected ? state.selection.filter((x) => x !== id) : [...state.selection, id]);
else if (!wasSelected) select([id]);
const s = screenPt(e);
setG({ kind: "pending", pointerId: e.pointerId, startS: s, startD: toDoc(s), target: id, wasSelected, shift: e.shiftKey, touch: e.pointerType === "touch" });
capture(e.pointerId);
};
const startResize = (e: ReactPointerEvent, hx: number, hy: number) => {
e.stopPropagation();
if (e.button !== 0 || gesture.current) return;
const state = getState();
const doc0 = state.doc;
const idx = getIndex(doc0);
const sel = topSelection(doc0, state.selection);
if (!sel.length) return;
const p = toDoc(screenPt(e));
let mode: "leaf" | "frame" | "box" = "box";
let ob0: OBox;
const only = sel.length === 1 ? idx.get(sel[0])!.node : null;
if (only && isLeaf(only)) {
mode = "leaf";
ob0 = leafOBox(only, frameOffset(doc0, only.id));
} else if (only && only.type === "frame") {
mode = "frame";
ob0 = rectToOBox({ x: only.x, y: only.y, w: only.width, h: only.height });
} else {
const b = selectionBounds(doc0, sel);
if (!b) return;
ob0 = rectToOBox(b);
}
beginGesture();
setG({ kind: "resize", pointerId: e.pointerId, startD: p, hx, hy, mode, ids: sel, ob0, base: doc0 });
capture(e.pointerId);
};
const startRotate = (e: ReactPointerEvent) => {
e.stopPropagation();
if (e.button !== 0 || gesture.current) return;
const state = getState();
const id = state.selection[0];
const n = getIndex(state.doc).get(id)?.node;
if (!n || !isLeaf(n)) return;
const ob = leafOBox(n, frameOffset(state.doc, id));
const p = toDoc(screenPt(e));
beginGesture();
setG({ kind: "rotate", pointerId: e.pointerId, id, c: { x: ob.cx, y: ob.cy }, a0: Math.atan2(p.y - ob.cy, p.x - ob.cx), rot0: n.rotation || 0, base: state.doc });
capture(e.pointerId);
setHud({ text: `${fmt(n.rotation || 0)}°`, ...screenPt(e) });
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const s = screenPt(e);
if (e.pointerType === "touch" && touches.current.has(e.pointerId)) touches.current.set(e.pointerId, s);
const g = gesture.current;
const state = getState();
const z = state.view.zoom;
if (!g) {
if (e.pointerType === "mouse" && state.tool === "move" && !state.spaceDown) {
const chain = hitTest(state.doc, toDoc(s), 3 / z);
const h = chain ? resolveClick(state.doc, chain, state.selection) : null;
if (h !== hover) setHover(h);
}
return;
}
if (g.kind === "pinch") {
const pts = [...touches.current.values()];
if (pts.length < 2) return;
const [a, b] = pts;
const d = Math.hypot(a.x - b.x, a.y - b.y);
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const nz = clampZoom(g.v0.zoom * (d / g.d0));
const docX = (g.mid0.x - g.v0.x) / g.v0.zoom;
const docY = (g.mid0.y - g.v0.y) / g.v0.zoom;
setView({ zoom: nz, x: mid.x - docX * nz, y: mid.y - docY * nz });
return;
}
if (g.pointerId !== e.pointerId) return;
const p = toDoc(s);
switch (g.kind) {
case "pending": {
const dist = Math.hypot(s.x - g.startS.x, s.y - g.startS.y);
if (dist < (g.touch ? 6 : 3)) return;
const idx = getIndex(state.doc);
const ids = topSelection(state.doc, state.selection).filter((id) => {
const info = idx.get(id)!;
return !info.locked && !info.hidden;
});
if (!ids.length || (g.shift && !state.selection.includes(g.target ?? ""))) {
setG(null);
return;
}
const b0 = selectionBounds(state.doc, ids);
if (!b0) return;
setHover(null);
beginGesture();
const mg: Gesture = { kind: "move", pointerId: g.pointerId, startD: g.startD, ids, base: state.doc, b0, targets: snapTargets(state.doc, ids) };
setG(mg);
applyMove(mg, p, e);
return;
}
case "move":
applyMove(g, p, e);
return;
case "resize": {
const delta = { x: p.x - g.startD.x, y: p.y - g.startD.y };
const ob = resizeOBox(g.ob0, g.hx, g.hy, delta, e.shiftKey, e.altKey);
const base = g.base;
let next: Doc;
if (g.mode === "leaf") {
const n = getIndex(base).get(g.ids[0])!.node as Leaf;
next = replaceNodes(base, new Map([[n.id, applyOBoxToLeaf(n, frameOffset(base, n.id), ob)]]));
} else if (g.mode === "frame") {
next = updateNode(base, g.ids[0], {
x: round2(ob.cx - ob.w / 2),
y: round2(ob.cy - ob.h / 2),
width: round2(ob.w),
height: round2(ob.h),
});
} else {
const b0 = { x: g.ob0.cx - g.ob0.w / 2, y: g.ob0.cy - g.ob0.h / 2, w: g.ob0.w, h: g.ob0.h };
const b1 = { x: ob.cx - ob.w / 2, y: ob.cy - ob.h / 2, w: ob.w, h: ob.h };
next = scaleMany(base, g.ids, b0, b1);
}
live(next);
const v = getState().view;
const pts = obCorners(ob).map((c) => ({ x: c.x * v.zoom + v.x, y: c.y * v.zoom + v.y }));
setHud({
text: `${fmt(ob.w)} × ${fmt(ob.h)}`,
x: pts.reduce((a, c) => a + c.x, 0) / 4,
y: Math.max(...pts.map((c) => c.y)) + 18,
});
return;
}
case "rotate": {
const a = Math.atan2(p.y - g.c.y, p.x - g.c.x);
let rot = g.rot0 + ((a - g.a0) * 180) / Math.PI;
if (e.shiftKey) rot = Math.round(rot / 15) * 15;
rot = normalizeAngle(rot);
live(updateNode(g.base, g.id, { rotation: rot }));
setHud({ text: `${fmt(rot)}°`, x: s.x + 18, y: s.y + 22 });
return;
}
case "marquee": {
const r = {
x: Math.min(g.startD.x, p.x),
y: Math.min(g.startD.y, p.y),
w: Math.abs(p.x - g.startD.x),
h: Math.abs(p.y - g.startD.y),
};
setMarquee(r);
const hits = marqueeHits(state.doc, r, g.scope);
select(g.additive ? [...g.baseSel, ...hits.filter((h) => !g.baseSel.includes(h))] : hits);
return;
}
case "pan": {
setView({ ...g.v0, x: g.v0.x + (s.x - g.startS.x), y: g.v0.y + (s.y - g.startS.y) });
return;
}
case "create": {
if (!g.moved && Math.hypot(s.x - g.startS.x, s.y - g.startS.y) < 3) return;
g.moved = true;
let dx = p.x - g.startD.x;
let dy = p.y - g.startD.y;
if (e.shiftKey && g.type !== "text") {
const side = Math.max(Math.abs(dx), Math.abs(dy));
dx = (dx < 0 ? -1 : 1) * side;
dy = (dy < 0 ? -1 : 1) * side;
}
const w = Math.max(1, Math.abs(dx));
const h = Math.max(1, Math.abs(dy));
const x = Math.min(g.startD.x, g.startD.x + dx) - g.off.x;
const y = Math.min(g.startD.y, g.startD.y + dy) - g.off.y;
const node = { ...g.node, x: round2(x), y: round2(y), width: round2(w), height: round2(h) } as Leaf;
live(insertNode(g.base, g.parentId, childrenOf(g.base, g.parentId).length, node), [node.id]);
setHud({ text: `${fmt(w)} × ${fmt(h)}`, x: s.x + 16, y: s.y + 24 });
return;
}
}
};
const applyMove = (g: Extract<Gesture, { kind: "move" }>, p: Pt, e: { ctrlKey: boolean; metaKey: boolean }) => {
const d = { x: p.x - g.startD.x, y: p.y - g.startD.y };
const moved = { x: g.b0.x + d.x, y: g.b0.y + d.y, w: g.b0.w, h: g.b0.h };
const noSnap = e.ctrlKey || e.metaKey;
const snap = noSnap ? { dx: 0, dy: 0, guides: [] } : snapMove(g.targets, moved, 5 / getState().view.zoom);
live(translateMany(g.base, g.ids, d.x + snap.dx, d.y + snap.dy));
setGuides(snap.guides);
};
const finish = (e: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) => {
if (e.pointerType === "touch") touches.current.delete(e.pointerId);
const g = gesture.current;
if (!g) return;
if (g.kind === "pinch") {
if (touches.current.size < 2) setG(null);
return;
}
if (g.pointerId !== e.pointerId) return;
setG(null);
clearTransient();
const state = getState();
switch (g.kind) {
case "pending":
if (!cancelled && !g.shift && g.wasSelected && g.target && state.selection.length > 1) select([g.target]);
return;
case "move":
case "resize":
case "rotate":
endGesture();
return;
case "create": {
if (cancelled) {
cancelGesture();
return;
}
let node = getIndex(state.doc).get(g.node.id)?.node as Leaf | undefined;
if (!node) {
cancelGesture();
return;
}
if (!g.moved) {
const [w, h] = DEFAULT_SIZE[g.type];
node = { ...node, width: w, height: h };
live(insertNode(g.base, g.parentId, childrenOf(g.base, g.parentId).length, node), [node.id]);
}
setTool("move");
if (g.type === "text") startTextEdit(node.id, true);
else endGesture([node.id]);
return;
}
default:
return;
}
};
const onDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => {
const state = getState();
if (state.tool !== "move" || state.editing) return;
const chain = hitTest(state.doc, toDoc(screenPt(e)), 3 / state.view.zoom);
if (!chain) return;
let i = -1;
for (let k = chain.length - 1; k >= 0; k--) {
if (state.selection.includes(chain[k])) {
i = k;
break;
}
}
if (i === -1) {
select([chain[0]]);
return;
}
if (i < chain.length - 1) {
select([chain[i + 1]]);
return;
}
const leaf = getIndex(state.doc).get(chain[i])?.node;
if (leaf?.type === "text") startTextEdit(leaf.id);
};
// -------------------------------------------------------------------------
// Overlay geometry (screen space)
const z = view.zoom;
const S = (p: Pt): Pt => ({ x: p.x * z + view.x, y: p.y * z + view.y });
const sob = (b: OBox): OBox => ({ cx: b.cx * z + view.x, cy: b.cy * z + view.y, w: b.w * z, h: b.h * z, rot: b.rot });
const idx = getIndex(doc);
const sel = existing(doc, selection);
const outlineOf = (id: string): OBox | null => {
const info = idx.get(id);
if (!info) return null;
if (isLeaf(info.node)) return sob(leafOBox(info.node, frameOffset(doc, id)));
const b = boundsOf(doc, id);
return b ? sob(rectToOBox(b)) : null;
};
let selBox: OBox | null = null;
let singleLeaf = false;
if (sel.length === 1) {
selBox = outlineOf(sel[0]);
singleLeaf = isLeaf(idx.get(sel[0])!.node);
} else if (sel.length > 1) {
const b = selectionBounds(doc, sel);
if (b) selBox = sob(rectToOBox(b));
}
const allEditable = sel.every((id) => !idx.get(id)!.locked && !idx.get(id)!.hidden);
const moving = activeKind === "move";
const showHandles = !!selBox && allEditable && tool === "move" && !editing && !moving && activeKind !== "pinch";
const polyPts = (b: OBox) =>
obCorners(b)
.map((c) => `${c.x},${c.y}`)
.join(" ");
let dims: Hud | null = null;
if (selBox && !hud && !editing && allEditable) {
const pts = obCorners(selBox);
let w: number;
let h: number;
if (sel.length === 1 && singleLeaf) {
const n = idx.get(sel[0])!.node as Leaf;
w = n.width;
h = n.height;
} else {
w = selBox.w / z;
h = selBox.h / z;
}
dims = { text: `${fmt(w)} × ${fmt(h)}`, x: pts.reduce((a, c) => a + c.x, 0) / 4, y: Math.max(...pts.map((c) => c.y)) + 18 };
}
const hoverBox = hover && !sel.includes(hover) && !activeKind ? outlineOf(hover) : null;
const frames = doc.children.filter((n): n is FrameLayer => n.type === "frame" && n.visible);
const isEmpty = !doc.children.some((n) => n.visible);
let cursor = "default";
if (st.spaceDown) cursor = activeKind === "pan" ? "grabbing" : "grab";
else if (activeKind === "pan") cursor = "grabbing";
else if (tool !== "move") cursor = "crosshair";
const smallW = selBox ? selBox.w < 30 : false;
const smallH = selBox ? selBox.h < 30 : false;
return (
<div
ref={ref}
data-canvas=""
className="canvas"
tabIndex={0}
role="application"
aria-roledescription="design canvas"
aria-label={`Canvas for ${doc.file}. ${sel.length ? `${sel.length} selected.` : "Nothing selected."} Use the Layers panel to pick layers with the keyboard.`}
style={{ cursor }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={(e) => finish(e, false)}
onPointerCancel={(e) => finish(e, true)}
onPointerEnter={() => (canvasPointer.over = true)}
onPointerLeave={() => {
canvasPointer.over = false;
setHover(null);
}}
onDoubleClick={onDoubleClick}
onScroll={(e) => {
e.currentTarget.scrollTop = 0;
e.currentTarget.scrollLeft = 0;
}}
>
<div className="world" style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${z})` }}>
{doc.children.map((n) => (
<NodeView key={n.id} node={n} editingId={editing?.id ?? null} />
))}
</div>
{frames.map((f) => {
const p = S({ x: f.x, y: f.y });
const selected = sel.includes(f.id);
return (
<div
key={f.id}
className={`frame-label${selected ? " is-selected" : ""}${f.locked ? " is-locked" : ""}`}
style={{ left: p.x, top: p.y - 20, maxWidth: Math.max(f.width * z, 28) }}
onPointerDown={(e) => onLabelDown(e, f.id)}
onDoubleClick={(e) => e.stopPropagation()}
onPointerEnter={() => setHover(null)}
data-frame-label={f.id}
>
{f.name}
</div>
);
})}
<svg className="overlay" width="100%" height="100%" aria-hidden="true">
{hoverBox && <polygon className="hover-outline" points={polyPts(hoverBox)} />}
{sel.length > 1 &&
sel.map((id) => {
const b = outlineOf(id);
return b ? <polygon key={id} className="item-outline" points={polyPts(b)} /> : null;
})}
{selBox && !editing && (
<polygon className={`sel-outline${allEditable ? "" : " is-locked"}`} points={polyPts(selBox)} />
)}
{guides.map((g, i) => {
const a = S({ x: g.x1, y: g.y1 });
const b = S({ x: g.x2, y: g.y2 });
return (
<g key={i} className="guide">
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} />
<path d={`M${a.x - 3},${a.y - 3}l6,6m0,-6l-6,6M${b.x - 3},${b.y - 3}l6,6m0,-6l-6,6`} />
</g>
);
})}
{showHandles && selBox && (
<g className="handles">
{singleLeaf &&
([
[-1, -1],
[1, -1],
[1, 1],
[-1, 1],
] as const).map(([hx, hy]) => {
const c = obPoint(selBox!, hx, hy);
const { u, v } = axes(selBox!.rot);
const ox = (hx * u.x + hy * v.x) * 11;
const oy = (hx * u.y + hy * v.y) * 11;
return (
<circle
key={`r${hx}${hy}`}
className="rotate-zone"
cx={c.x + ox}
cy={c.y + oy}
r={11}
style={{ cursor: ROTATE_CURSOR }}
onPointerDown={startRotate}
/>
);
})}
{singleLeaf &&
(() => {
const top = obPoint(selBox!, 0, -1);
const knob = obPoint(selBox!, 0, -1, -24);
return (
<g className="rotate-handle" style={{ cursor: ROTATE_CURSOR }} onPointerDown={startRotate} data-rotate-handle="">
<line x1={top.x} y1={top.y} x2={knob.x} y2={knob.y} />
<circle cx={knob.x} cy={knob.y} r={10} className="hit" />
<circle cx={knob.x} cy={knob.y} r={4.5} className="knob" />
</g>
);
})()}
{HANDLES.map(([hx, hy]) => {
if ((hx === 0 && smallW) || (hy === 0 && smallH)) return null;
const c = obPoint(selBox!, hx, hy);
const corner = hx !== 0 && hy !== 0;
return (
<g
key={`${hx}${hy}`}
className={`handle${corner ? " is-corner" : ""}`}
transform={`rotate(${selBox!.rot} ${c.x} ${c.y})`}
style={{ cursor: resizeCursor(hx, hy, selBox!.rot) }}
onPointerDown={(e) => startResize(e, hx, hy)}
data-handle={`${hx},${hy}`}
>
<rect className="hit" x={c.x - 7 + hx * 3} y={c.y - 7 + hy * 3} width={14} height={14} />
<rect className="knob" x={c.x - 4} y={c.y - 4} width={8} height={8} rx={1.5} />
</g>
);
})}
</g>
)}
{marquee &&
(() => {
const a = S({ x: marquee.x, y: marquee.y });
return <rect className="marquee" x={a.x} y={a.y} width={marquee.w * z} height={marquee.h * z} />;
})()}
</svg>
{(hud || dims) && (
<div className={`hud${hud ? " is-live" : ""}`} style={{ left: (hud ?? dims)!.x, top: (hud ?? dims)!.y }} aria-hidden="true">
{(hud ?? dims)!.text}
</div>
)}
{tool !== "move" && <div className="tool-hint">{TOOL_HINT[tool]}</div>}
{isEmpty && (
<div className="canvas-empty">
<strong>This file is empty</strong>
<span>Pick Rectangle (R), Ellipse (O) or Text (T) and drag on the canvas to start. Reset demo brings the Spring Menu back.</span>
</div>
)}
</div>
);
}