DesignGym / web /js /stage.js
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
34.5 kB
// The canvas: renders the document as DOM and turns pointer input into edits.
//
// Elements are drawn with the same box model the server renderer uses (see
// core/text.py): absolute boxes at canvas pixels, rotation about the centre,
// text with `white-space: pre`, a `font_size * line_height` line box, and no
// kerning, ligatures or synthetic bold. The artboard is scaled with a CSS
// transform, so everything below is in canvas pixels except the overlay
// (selection box, handles, guides), which lives in screen space so handles
// stay the same size at every zoom level.
import { bounds, clamp, cssColor, deg, fmt, h, rad, rotate, unionBounds } from "./util.js";
import { ensureFonts } from "./fonts.js";
const HANDLES = [
["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 SNAP_PX = 6;
const DRAG_THRESHOLD = 3;
export class Stage {
constructor(app, root) {
this.app = app;
this.store = app.store;
this.root = root;
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.preview = new Map(); // id -> props shown during a gesture
this.nodes = new Map();
this.hover = null;
this.guides = [];
this.marquee = null;
this.gesture = null;
this.editing = null;
this.space = false;
this.fitted = false;
// The page stays fitted to the stage as it resizes (a pane opening, the window changing) until the
// person zooms or pans themselves, as editors do; Fit (Shift+1) turns that back on.
this.autoFit = true;
this.el = h("div", { class: "stage", tabindex: "-1" });
this.artboard = h("div", { class: "artboard" });
this.overlay = h("div", { class: "overlay" });
this.el.append(this.artboard, this.overlay);
root.append(this.el);
this.el.addEventListener("pointerdown", (e) => this.onDown(e));
this.el.addEventListener("pointermove", (e) => this.onMove(e));
this.el.addEventListener("pointerup", (e) => this.onUp(e));
this.el.addEventListener("pointercancel", (e) => this.onUp(e));
this.el.addEventListener("dblclick", (e) => this.onDouble(e));
this.el.addEventListener("wheel", (e) => this.onWheel(e), { passive: false });
this.el.addEventListener("contextmenu", (e) => this.onContext(e));
this.el.addEventListener("pointerleave", () => { this.hover = null; this.drawOverlay(); });
new ResizeObserver(() => {
if ((!this.fitted || this.autoFit) && this.store.doc) this.fit();
this.drawOverlay();
}).observe(this.el);
this.store.on("doc", () => this.render());
this.store.on("selection", () => { this.rotateMode = false; this.render(); }); // a new selection starts with scale handles
this.store.on("tool", (t) => { this.el.dataset.tool = t; });
this.el.dataset.tool = this.store.tool;
}
// ------------------------------------------------------------ geometry
toScreen(x, y) { return [this.panX + x * this.zoom, this.panY + y * this.zoom]; }
toCanvas(clientX, clientY) {
const r = this.el.getBoundingClientRect();
return [(clientX - r.left - this.panX) / this.zoom, (clientY - r.top - this.panY) / this.zoom];
}
view(el) {
const p = this.preview.get(el.id);
return p ? { ...el, ...p } : el;
}
hit(x, y) {
const els = this.store.doc.elements;
for (let i = els.length - 1; i >= 0; i--) {
const el = this.view(els[i]);
if (el.visible === false) continue;
const cx = el.x + el.width / 2, cy = el.y + el.height / 2;
const [lx, ly] = rotate(x - cx, y - cy, -rad(el.angle || 0));
const pad = 2 / this.zoom;
if (Math.abs(lx) <= el.width / 2 + pad && Math.abs(ly) <= el.height / 2 + pad) return els[i];
}
return null;
}
// ------------------------------------------------------------ zoom / pan
setZoom(z, cx = null, cy = null) {
this.autoFit = false;
z = clamp(z, 0.02, 16);
const r = this.el.getBoundingClientRect();
cx = cx ?? r.width / 2;
cy = cy ?? r.height / 2;
const [wx, wy] = [(cx - this.panX) / this.zoom, (cy - this.panY) / this.zoom];
this.zoom = z;
this.panX = cx - wx * z;
this.panY = cy - wy * z;
this.applyView();
}
zoomBy(f) { this.setZoom(this.zoom * f); }
fit() {
const doc = this.store.doc;
if (!doc) return;
if (this.fitRect({ x0: 0, y0: 0, x1: doc.width, y1: doc.height })) { this.fitted = true; this.autoFit = true; }
}
fitRect(b) {
const r = this.el.getBoundingClientRect();
if (r.width < 10 || r.height < 10) return false;
const pad = this.app.skin.fitPadding ?? 48;
const w = b.x1 - b.x0, hgt = b.y1 - b.y0;
const z = Math.min((r.width - pad * 2) / w, (r.height - pad * 2) / hgt);
this.zoom = clamp(z, 0.02, 8);
this.panX = (r.width - w * this.zoom) / 2 - b.x0 * this.zoom;
this.panY = (r.height - hgt * this.zoom) / 2 - b.y0 * this.zoom;
this.applyView();
return true;
}
applyView() {
const doc = this.store.doc;
if (!doc) return;
Object.assign(this.artboard.style, {
width: `${doc.width}px`, height: `${doc.height}px`,
transform: `translate(${this.panX}px, ${this.panY}px) scale(${this.zoom})`,
});
this.app.emit("zoom", this.zoom);
this.drawOverlay();
}
// ------------------------------------------------------------ rendering
render() {
const doc = this.store.doc;
if (!doc) return;
if (!this.fitted) this.fit();
this.artboard.style.background = cssColor(doc.background);
this.artboard.style.width = `${doc.width}px`;
this.artboard.style.height = `${doc.height}px`;
const seen = new Set();
let prev = null;
for (const raw of doc.elements) {
const el = this.view(raw);
seen.add(el.id);
let node = this.nodes.get(el.id);
if (!node || node.dataset.kind !== el.kind) {
node?.remove();
node = this.makeNode(el);
this.nodes.set(el.id, node);
}
if (this.editing !== el.id) this.paint(node, el);
// Keep DOM order equal to stacking order.
const want = prev ? prev.nextSibling : this.artboard.firstChild;
if (want !== node) this.artboard.insertBefore(node, want);
prev = node;
}
for (const [id, node] of this.nodes) {
if (!seen.has(id)) { node.remove(); this.nodes.delete(id); }
}
ensureFonts(doc.elements.filter((e) => e.kind === "text").map((e) => e.font_family))
.then(() => this.drawOverlay());
this.drawOverlay();
}
makeNode(el) {
const node = h("div", { class: `el el-${el.kind}`, dataset: { id: el.id, kind: el.kind } });
if (el.kind === "image") {
node.append(h("img", { draggable: "false", alt: "" }));
} else if (el.kind === "text") {
node.append(h("div", { class: "txt", spellcheck: "false" }));
}
return node;
}
paint(node, el) {
const st = node.style;
st.left = `${el.x}px`;
st.top = `${el.y}px`;
st.width = `${el.width}px`;
st.height = `${el.height}px`;
const tf = [];
if (el.angle) tf.push(`rotate(${el.angle}deg)`);
if (el.flip_x || el.flip_y) tf.push(`scale(${el.flip_x ? -1 : 1}, ${el.flip_y ? -1 : 1})`);
st.transform = tf.join(" ");
st.opacity = el.opacity ?? 1;
st.display = el.visible === false ? "none" : "";
if (el.kind === "image") {
const img = node.firstChild;
const url = this.store.assetById.get(el.asset_id)?.url;
if (url && img.getAttribute("src") !== url) img.src = url;
} else if (el.kind === "shape") {
st.background = el.fill ? cssColor(el.fill) : "transparent";
st.border = el.stroke && el.stroke_width ? `${el.stroke_width}px solid ${cssColor(el.stroke)}` : "none";
st.borderRadius = el.shape === "ellipse" ? "50%" : `${el.radius || 0}px`;
} else {
this.paintText(node.firstChild, el);
}
}
paintText(t, el) {
const lh = el.font_size * el.line_height;
Object.assign(t.style, {
fontFamily: `'${el.font_family}'`,
fontSize: `${el.font_size}px`,
lineHeight: `${lh}px`,
letterSpacing: `${el.letter_spacing || 0}px`,
color: cssColor(el.color || "#000000"),
textAlign: el.align,
fontWeight: el.bold ? "700" : "400",
fontStyle: el.italic ? "italic" : "normal",
textDecoration: el.underline ? "underline" : "none",
textTransform: el.uppercase ? "uppercase" : "none",
});
const key = JSON.stringify([el.text, el.runs]);
if (t.dataset.key === key) return;
t.dataset.key = key;
t.textContent = "";
if (!el.runs || !el.runs.length) {
t.textContent = el.text;
return;
}
for (const r of el.runs) {
const span = h("span", {}, r.text);
if (r.color) span.style.color = cssColor(r.color);
if (r.bold !== undefined && r.bold !== null) span.style.fontWeight = r.bold ? "700" : "400";
if (r.italic !== undefined && r.italic !== null) span.style.fontStyle = r.italic ? "italic" : "normal";
t.append(span);
}
}
// ------------------------------------------------------------ overlay
drawOverlay() {
const o = this.overlay;
o.textContent = "";
const doc = this.store.doc;
if (!doc) return;
const skin = this.app.skin;
const sel = this.store.selected().map((e) => this.view(e));
if (skin.frameLabel) { // Figma names the frame just above its top-left corner
const [fx, fy] = this.toScreen(0, 0);
const label = h("div", { class: "frame-label", style: { left: `${fx}px`, top: `${fy - 18}px` } }, skin.frameLabel);
// The frame's name selects the frame, as in Figma.
label.addEventListener("pointerdown", (e) => { if (e.button === 0) { e.stopPropagation(); e.preventDefault(); this.app.selectFrame?.(); } });
label.classList.toggle("on", !!this.app.frameSelected);
o.append(label);
if (this.app.frameSelected) {
const [x1, y1] = this.toScreen(doc.width, doc.height);
o.append(h("div", { class: "frame-sel", style: { left: `${fx}px`, top: `${fy}px`, width: `${x1 - fx}px`, height: `${y1 - fy}px` } }),
h("div", { class: "size-badge frame-badge", style: { left: `${(fx + x1) / 2}px`, top: `${y1 + 6}px` } }, `${doc.width} × ${doc.height}`));
}
}
if (skin.pageHeader) { // Canva names the page just above it
const [x0, y0] = this.toScreen(0, 0);
const [x1] = this.toScreen(doc.width, 0);
o.append(h("div", { class: "page-header", style: { left: `${x0}px`, top: `${y0 - 30}px`, width: `${x1 - x0}px` } },
h("span", { class: "page-header-title" }, skin.pageHeader)));
}
if (this.hover && !this.gesture && !this.store.selection.includes(this.hover)) {
const el = this.store.find(this.hover);
if (el && el.visible !== false) o.append(this.box(this.view(el), "hover-box"));
}
// Photoshop shows a layer's bounding box and handles only with Show Transform Controls (or Free Transform).
const handles = this.app.showHandles ? this.app.showHandles() : true;
if (sel.length === 1 && !this.editing && handles) {
const el = sel[0];
const box = this.box(el, "sel-box");
if (el.locked) box.classList.add("locked");
else {
const ik = skin.name === "inkscape";
if (ik) box.classList.add(this.rotateMode ? "ik-rotate" : "ik-scale");
for (const [name, dx, dy] of HANDLES) {
if (ik && this.rotateMode) break; // rotate mode: only the corner rotate arrows
if (el.kind === "text" && dy !== 0 && dx === 0) continue;
box.append(h("div", { class: `handle h-${name} ${dx && dy ? "corner" : "side"}`,
dataset: { handle: name, dx, dy } }));
}
if (skin.rotateHandle) {
box.append(h("div", { class: "rot-handle", dataset: { handle: "rotate" },
html: skin.rotateIcon || "" }));
}
for (const [name, dx, dy] of HANDLES) {
if (ik && !this.rotateMode) break; // scale mode: corners scale, no rotate zones
if (dx && dy) box.append(h("div", { class: `rot-zone z-${name}`, dataset: { handle: "rotate" } }));
}
}
o.append(box);
if (skin.alwaysShowSize || this.gesture) o.append(this.sizeBadge(el));
if (skin.floatingToolbar && !this.gesture && !el.locked) o.append(this.app.floatingToolbar(this.screenRect(el)));
} else if (sel.length > 1) {
for (const el of sel) o.append(this.box(el, "sel-box multi-member"));
const u = unionBounds(sel);
const group = this.rectBox(u, "sel-box group-box");
o.append(group);
if (this.gesture) o.append(this.sizeBadge({ x: u.x0, y: u.y0, width: u.x1 - u.x0, height: u.y1 - u.y0, angle: 0 }));
} else if (this.editing) {
const el = this.store.find(this.editing);
if (el) o.append(this.box(this.view(el), "sel-box editing"));
}
for (const g of this.guides) {
if (g.axis === "x") {
const [sx] = this.toScreen(g.pos, 0);
const [, sy0] = this.toScreen(0, g.from);
const [, sy1] = this.toScreen(0, g.to);
o.append(h("div", { class: "guide guide-x", style: { left: `${sx}px`, top: `${sy0}px`, height: `${sy1 - sy0}px` } }));
} else {
const [, sy] = this.toScreen(0, g.pos);
const [sx0] = this.toScreen(g.from, 0);
const [sx1] = this.toScreen(g.to, 0);
o.append(h("div", { class: "guide guide-y", style: { top: `${sy}px`, left: `${sx0}px`, width: `${sx1 - sx0}px` } }));
}
}
if (this.marquee) {
const m = this.marquee;
o.append(this.rectBox({ x0: Math.min(m.x0, m.x1), y0: Math.min(m.y0, m.y1),
x1: Math.max(m.x0, m.x1), y1: Math.max(m.y0, m.y1) }, "marquee"));
}
if (this.draft) o.append(this.rectBox(this.draft, `draft draft-${this.draft.shape}`));
}
screenRect(el) {
const [cx, cy] = this.toScreen(el.x + el.width / 2, el.y + el.height / 2);
const w = el.width * this.zoom, hh = el.height * this.zoom;
const b = bounds(el);
const [x0, y0] = this.toScreen(b.x0, b.y0);
const [x1, y1] = this.toScreen(b.x1, b.y1);
return { cx, cy, w, h: hh, x0, y0, x1, y1 };
}
box(el, cls) {
const r = this.screenRect(el);
return h("div", { class: cls, style: {
left: `${r.cx - r.w / 2}px`, top: `${r.cy - r.h / 2}px`, width: `${r.w}px`, height: `${r.h}px`,
transform: el.angle ? `rotate(${el.angle}deg)` : "",
} });
}
rectBox(b, cls) {
const [x0, y0] = this.toScreen(b.x0, b.y0);
const [x1, y1] = this.toScreen(b.x1, b.y1);
return h("div", { class: cls, style: { left: `${x0}px`, top: `${y0}px`, width: `${x1 - x0}px`, height: `${y1 - y0}px` } });
}
sizeBadge(el) {
const r = this.screenRect(el);
const g = this.gesture;
let text = `${fmt(el.width)} × ${fmt(el.height)}`;
if (g?.kind === "move") text = `X ${fmt(el.x)} Y ${fmt(el.y)}`;
if (g?.kind === "rotate") text = `${fmt(el.angle || 0)}°`;
return h("div", { class: "size-badge", style: { left: `${(r.x0 + r.x1) / 2}px`, top: `${r.y1 + 8}px` } }, text);
}
// ------------------------------------------------------------ input
onWheel(e) {
e.preventDefault();
const r = this.el.getBoundingClientRect();
if (e.ctrlKey || e.metaKey) {
const f = Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.0025));
this.setZoom(this.zoom * f, e.clientX - r.left, e.clientY - r.top);
} else {
const dx = e.shiftKey && !e.deltaX ? e.deltaY : e.deltaX;
const dy = e.shiftKey && !e.deltaX ? 0 : e.deltaY;
this.autoFit = false;
this.panX -= dx;
this.panY -= dy;
this.applyView();
}
}
onDown(e) {
if (this.editing) {
if (e.target.closest(".txt[contenteditable]")) return;
this.commitEdit();
}
if (e.button === 2) return;
this.el.focus({ preventScroll: true });
const [x, y] = this.toCanvas(e.clientX, e.clientY);
const start = { cx: e.clientX, cy: e.clientY, x, y };
const tool = this.store.tool;
if (this.placing && e.button === 0) { // Figma: images chosen from the file window wait for a click
this.begin(e, { kind: "place", start, asset: this.placing.queue[0] });
return;
}
if (e.button === 1 || tool === "hand" || this.space) {
this.begin(e, { kind: "pan", start, panX: this.panX, panY: this.panY });
return;
}
const handle = e.target.closest("[data-handle]");
if (handle && this.store.selection.length === 1) {
const el = this.store.find(this.store.selection[0]);
if (el && !el.locked) {
if (handle.dataset.handle === "rotate") this.begin(e, { kind: "rotate", start, el: { ...el } });
else this.begin(e, { kind: "resize", start, el: { ...el }, dx: +handle.dataset.dx, dy: +handle.dataset.dy });
return;
}
}
if (e.target.closest(".floating-toolbar")) return;
if (tool === "rect" || tool === "ellipse") {
this.begin(e, { kind: "draw", start, shape: tool === "rect" ? "rect" : "ellipse" });
return;
}
if (tool === "text") {
this.begin(e, { kind: "text", start });
return;
}
// Photoshop's Move tool with Auto-Select off moves the selected layer wherever you drag.
if (this.app.autoSelect === false && tool === "move" && this.store.selection.length && !e.shiftKey && !e.metaKey && !e.ctrlKey) {
const els = this.store.selected().filter((el) => !el.locked).map((el) => ({ ...el }));
this.begin(e, { kind: "move", start, els, clicked: els[0]?.id, wasSelected: true });
return;
}
const hit = this.hit(x, y);
if (hit) {
const selected = this.store.selection.includes(hit.id);
if (e.shiftKey || e.metaKey || e.ctrlKey) {
this.store.select(hit.id, { toggle: true });
return;
}
if (!selected) this.store.select(hit.id);
const els = this.store.selected().filter((el) => !el.locked).map((el) => ({ ...el }));
this.begin(e, { kind: "move", start, els, clicked: hit.id, wasSelected: selected });
} else {
if (!e.shiftKey) this.store.select([]);
this.begin(e, { kind: "marquee", start, base: e.shiftKey ? [...this.store.selection] : [] });
}
}
begin(e, g) {
this.gesture = g;
g.moved = false;
this.el.setPointerCapture(e.pointerId);
this.store.busy(true);
}
onMove(e) {
const g = this.gesture;
const [x, y] = this.toCanvas(e.clientX, e.clientY);
if (!g) {
const hit = this.store.tool === "move" && !this.space ? this.hit(x, y) : null;
const id = hit?.id || null;
if (id !== this.hover) { this.hover = id; this.drawOverlay(); }
const handle = e.target.closest?.("[data-handle]");
this.el.dataset.cursor = handle ? handle.dataset.handle : hit ? "move" : "";
return;
}
const dist = Math.hypot(e.clientX - g.start.cx, e.clientY - g.start.cy);
if (!g.moved && dist < DRAG_THRESHOLD) return;
g.moved = true;
const dx = x - g.start.x, dy = y - g.start.y;
if (g.kind === "pan") {
this.autoFit = false;
this.panX = g.panX + (e.clientX - g.start.cx);
this.panY = g.panY + (e.clientY - g.start.cy);
this.applyView();
} else if (g.kind === "move") this.dragMove(g, dx, dy, e);
else if (g.kind === "resize") this.dragResize(g, x, y, e);
else if (g.kind === "rotate") this.dragRotate(g, x, y, e);
else if (g.kind === "marquee") this.dragMarquee(g, x, y);
else if (g.kind === "place") { // drag out the image's box; it keeps its proportions
const w = x - g.start.x, hh = Math.sign(y - g.start.y || 1) * Math.abs(w) / g.asset.aspect;
this.draft = { x0: Math.min(g.start.x, g.start.x + w), y0: Math.min(g.start.y, g.start.y + hh),
x1: Math.max(g.start.x, g.start.x + w), y1: Math.max(g.start.y, g.start.y + hh), shape: "rect" };
this.drawOverlay();
} else if (g.kind === "draw") {
let x0 = g.start.x, y0 = g.start.y, w = x - x0, hh = y - y0;
if (e.shiftKey) { const m = Math.max(Math.abs(w), Math.abs(hh)); w = Math.sign(w || 1) * m; hh = Math.sign(hh || 1) * m; }
this.draft = { x0: Math.min(x0, x0 + w), y0: Math.min(y0, y0 + hh), x1: Math.max(x0, x0 + w), y1: Math.max(y0, y0 + hh), shape: g.shape };
this.drawOverlay();
}
}
async onUp(e) {
const g = this.gesture;
if (!g) return;
this.gesture = null;
try { this.el.releasePointerCapture(e.pointerId); } catch {}
const [x, y] = this.toCanvas(e.clientX, e.clientY);
try {
if (g.kind === "move") {
if (g.moved) {
const changes = g.els.map((el) => [el.id, this.pick(this.preview.get(el.id), ["x", "y"])]);
await this.store.updateMany(changes.filter(([, p]) => p));
} else if (g.wasSelected && this.store.selection.length > 1) {
this.store.select(g.clicked);
} else if (g.wasSelected && this.app.skin.name === "inkscape") {
// Inkscape: clicking the selection again toggles scale arrows <-> rotate arrows.
this.rotateMode = !this.rotateMode;
this.drawOverlay();
}
} else if (g.kind === "resize" || g.kind === "rotate") {
if (g.moved) {
const p = this.preview.get(g.el.id);
if (p) await this.store.update(g.el.id, p);
}
} else if (g.kind === "marquee") {
this.marquee = null;
} else if (g.kind === "draw") {
const d = this.draft;
this.draft = null;
let box = d;
if (!g.moved || !d || d.x1 - d.x0 < 4 || d.y1 - d.y0 < 4) {
const side = Math.round(Math.min(this.store.doc.width, this.store.doc.height) * 0.25);
box = { x0: x - side / 2, y0: y - side / 2, x1: x + side / 2, y1: y + side / 2 };
}
const r = await this.store.op("add_shape", {
shape: g.shape, x: Math.round(box.x0), y: Math.round(box.y0),
width: Math.round(box.x1 - box.x0), height: Math.round(box.y1 - box.y0),
fill: this.app.defaultFill(),
});
if (r?.element_id) this.store.select(r.element_id);
this.store.setTool("move");
} else if (g.kind === "text") {
await this.app.addTextAt(x, y);
this.store.setTool("move");
} else if (g.kind === "place") {
const d = this.draft;
this.draft = null;
const big = g.moved && d && d.x1 - d.x0 >= 4;
await this.app.addAsset(g.asset, big ? null : [x, y],
big ? { x: d.x0, y: d.y0, width: d.x1 - d.x0, height: d.y1 - d.y0 } : null);
this.placing?.queue.shift();
if (!this.placing?.queue.length) this.stopPlacing();
else this.placing.update();
}
} catch (err) {
console.warn(err);
} finally {
this.preview.clear();
this.guides = [];
this.store.busy(false);
this.render();
}
}
pick(obj, keys) {
if (!obj) return null;
const out = {};
for (const k of keys) if (k in obj) out[k] = Math.round(obj[k] * 100) / 100;
return Object.keys(out).length ? out : null;
}
// ------------------------------------------------------------ gestures
snapTargets(excludeIds) {
const doc = this.store.doc;
const xs = [0, doc.width / 2, doc.width], ys = [0, doc.height / 2, doc.height];
const spansX = xs.map(() => [0, doc.height]), spansY = ys.map(() => [0, doc.width]);
for (const el of doc.elements) {
if (excludeIds.has(el.id) || el.visible === false) continue;
const b = bounds(el);
for (const v of [b.x0, (b.x0 + b.x1) / 2, b.x1]) { xs.push(v); spansX.push([b.y0, b.y1]); }
for (const v of [b.y0, (b.y0 + b.y1) / 2, b.y1]) { ys.push(v); spansY.push([b.x0, b.x1]); }
}
return { xs, ys, spansX, spansY };
}
snapAxis(values, targets) {
const tol = SNAP_PX / this.zoom;
let best = null;
for (const v of values) {
for (let i = 0; i < targets.length; i++) {
const d = targets[i] - v;
if (Math.abs(d) <= tol && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, pos: targets[i], i };
}
}
return best;
}
dragMove(g, dx, dy, e) {
if (!g.els.length) return;
if (e.shiftKey) { if (Math.abs(dx) > Math.abs(dy)) dy = 0; else dx = 0; }
const moved = g.els.map((el) => ({ ...el, x: el.x + dx, y: el.y + dy }));
const u = unionBounds(moved);
this.guides = [];
if (!e.altKey) {
const t = this.snapTargets(new Set(g.els.map((el) => el.id)));
const sx = this.snapAxis([u.x0, (u.x0 + u.x1) / 2, u.x1], t.xs);
const sy = this.snapAxis([u.y0, (u.y0 + u.y1) / 2, u.y1], t.ys);
if (sx) { dx += sx.d; }
if (sy) { dy += sy.d; }
const u2 = { x0: u.x0 + (sx?.d || 0), x1: u.x1 + (sx?.d || 0), y0: u.y0 + (sy?.d || 0), y1: u.y1 + (sy?.d || 0) };
if (sx) {
const span = t.spansX[sx.i];
this.guides.push({ axis: "x", pos: sx.pos, from: Math.min(span[0], u2.y0) - 8, to: Math.max(span[1], u2.y1) + 8 });
}
if (sy) {
const span = t.spansY[sy.i];
this.guides.push({ axis: "y", pos: sy.pos, from: Math.min(span[0], u2.x0) - 8, to: Math.max(span[1], u2.x1) + 8 });
}
}
for (const el of g.els) this.preview.set(el.id, { x: el.x + dx, y: el.y + dy });
this.render();
}
dragResize(g, px, py, e) {
const el = g.el;
const a = rad(el.angle || 0);
const cx = el.x + el.width / 2, cy = el.y + el.height / 2;
const { dx, dy } = g;
const [ax, ay] = rotate(-dx * el.width / 2, -dy * el.height / 2, a);
const anchor = [cx + ax, cy + ay];
let [vx, vy] = rotate(px - anchor[0], py - anchor[1], -a);
let w = dx ? Math.max(1, vx * dx) : el.width;
let hh = dy ? Math.max(1, vy * dy) : el.height;
const corner = dx && dy;
const keepAspect = el.kind === "text" ? corner : (el.kind === "image" ? corner !== e.shiftKey : e.shiftKey && corner);
if (keepAspect && corner) {
const s = Math.max(w / el.width, hh / el.height);
w = el.width * s;
hh = el.height * s;
}
const props = {};
if (el.kind === "text") {
if (corner) {
const s = w / el.width;
props.font_size = Math.max(1, Math.round(el.font_size * s * 100) / 100);
props.width = w;
props.letter_spacing = Math.round((el.letter_spacing || 0) * s * 100) / 100;
hh = el.height * s;
} else {
props.width = w;
hh = el.height;
}
} else {
props.width = w;
props.height = hh;
}
// Snap the moving edges of an unrotated element to guides.
this.guides = [];
if (!el.angle && !e.altKey) {
const t = this.snapTargets(new Set([el.id]));
if (dx) {
const edge = dx > 0 ? anchor[0] + w : anchor[0] - w;
const s = this.snapAxis([edge], t.xs);
if (s && !keepAspect) { w += dx > 0 ? s.d : -s.d; props.width = w; this.guides.push({ axis: "x", pos: s.pos, from: 0, to: this.store.doc.height }); }
}
if (dy && el.kind !== "text") {
const edge = dy > 0 ? anchor[1] + hh : anchor[1] - hh;
const s = this.snapAxis([edge], t.ys);
if (s && !keepAspect) { hh += dy > 0 ? s.d : -s.d; props.height = hh; this.guides.push({ axis: "y", pos: s.pos, from: 0, to: this.store.doc.width }); }
}
}
const [ox, oy] = rotate(dx * w / 2, dy * hh / 2, a);
const ncx = anchor[0] + ox, ncy = anchor[1] + oy;
props.x = ncx - w / 2;
props.y = ncy - hh / 2;
if (el.kind === "text") {
this.preview.set(el.id, { ...props, height: hh, auto_width: false });
} else this.preview.set(el.id, props);
this.render();
}
dragRotate(g, px, py, e) {
const el = g.el;
const cx = el.x + el.width / 2, cy = el.y + el.height / 2;
const ang = deg(Math.atan2(py - cy, px - cx));
if (g.a0 === undefined) g.a0 = deg(Math.atan2(g.start.y - cy, g.start.x - cx)) - (el.angle || 0);
let a = ang - g.a0;
if (e.shiftKey) a = Math.round(a / 15) * 15;
else for (const s of [-180, -90, 0, 90, 180]) if (Math.abs(a - s) < 3) a = s;
a = ((a % 360) + 540) % 360 - 180;
this.preview.set(el.id, { angle: Math.round(a * 100) / 100 });
this.render();
}
dragMarquee(g, x, y) {
this.marquee = { x0: g.start.x, y0: g.start.y, x1: x, y1: y };
const m = { x0: Math.min(g.start.x, x), y0: Math.min(g.start.y, y), x1: Math.max(g.start.x, x), y1: Math.max(g.start.y, y) };
const ids = this.store.doc.elements.filter((el) => {
if (el.visible === false || el.locked) return false;
const b = bounds(el);
return b.x1 >= m.x0 && b.x0 <= m.x1 && b.y1 >= m.y0 && b.y0 <= m.y1;
}).map((el) => el.id);
this.store.select([...new Set([...g.base, ...ids])]);
this.drawOverlay();
}
// ------------------------------------------------------------ text editing
onDouble(e) {
const [x, y] = this.toCanvas(e.clientX, e.clientY);
const hit = this.hit(x, y);
if (hit && hit.kind === "text" && !hit.locked) this.startEdit(hit.id, { selectAll: false });
}
startEdit(id, { selectAll = true } = {}) {
const el = this.store.find(id);
const node = this.nodes.get(id);
if (!el || !node || el.kind !== "text") return;
this.store.select([id]);
this.editing = id;
this.store.busy(true);
const t = node.firstChild;
t.dataset.key = "";
t.textContent = el.text;
t.setAttribute("contenteditable", "plaintext-only");
if (el.auto_width) node.style.width = "max-content";
node.classList.add("editing");
t.focus();
const range = document.createRange();
range.selectNodeContents(t);
if (!selectAll) range.collapse(false);
const s = window.getSelection();
s.removeAllRanges();
s.addRange(range);
const lh = el.font_size * el.line_height;
const onInput = () => {
const lines = editedText(t).split("\n").length;
node.style.height = `${Math.max(1, lines) * lh}px`;
this.drawOverlay();
};
const onKey = (ev) => {
ev.stopPropagation();
if (ev.key === "Escape" || (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey))) {
ev.preventDefault();
this.commitEdit();
} else if (ev.key === "Enter") {
ev.preventDefault();
insertNewline(t);
onInput();
}
};
const onBlur = () => setTimeout(() => { if (this.editing === id) this.commitEdit(); }, 0);
t.addEventListener("input", onInput);
t.addEventListener("keydown", onKey);
t.addEventListener("blur", onBlur);
this._editCleanup = () => {
t.removeEventListener("input", onInput);
t.removeEventListener("keydown", onKey);
t.removeEventListener("blur", onBlur);
t.removeAttribute("contenteditable");
node.classList.remove("editing");
};
this.drawOverlay();
this.app.emit("editing", id);
}
/** Leave text editing without keeping the typing (Photoshop's options-bar cancel). */
cancelEdit() {
const id = this.editing;
if (!id) return;
const node = this.nodes.get(id);
this._editCleanup?.();
this.editing = null;
this.store.busy(false);
if (node) node.firstChild.dataset.key = "";
this.render();
this.app.emit("editing", null);
}
async commitEdit() {
const id = this.editing;
if (!id) return;
const node = this.nodes.get(id);
const text = editedText(node.firstChild);
this._editCleanup?.();
this.editing = null;
const el = this.store.find(id);
try {
if (el && text !== el.text) {
if (!text.trim()) await this.store.op("delete_element", { element_id: id });
else await this.store.update(id, { text });
}
} finally {
this.store.busy(false);
if (node) node.firstChild.dataset.key = "";
this.render();
this.app.emit("editing", null);
}
}
// ------------------------------------------------------------ misc
onContext(e) {
e.preventDefault();
const [x, y] = this.toCanvas(e.clientX, e.clientY);
const hit = this.hit(x, y);
if (hit && !this.store.selection.includes(hit.id)) this.store.select(hit.id);
this.app.contextMenu(e.clientX, e.clientY, !!hit);
}
/** Figma's "place image" mode: each click (or drag) on the canvas places the next chosen image. */
startPlacing(assets) {
this.stopPlacing();
const ghost = h("div", { class: "place-ghost" });
const update = () => {
const a = this.placing?.queue[0];
if (!a) return;
ghost.textContent = "";
ghost.append(h("img", { src: a.url, alt: "" }));
if (this.placing.queue.length > 1) ghost.append(h("span", { class: "fc-ghost-count" }, String(this.placing.queue.length)));
};
const follow = (e) => Object.assign(ghost.style, { left: `${e.clientX}px`, top: `${e.clientY}px` });
document.body.append(ghost);
document.addEventListener("pointermove", follow);
this.placing = { queue: [...assets], ghost, follow, update };
this.el.classList.add("placing");
update();
}
stopPlacing() {
if (!this.placing) return;
this.placing.ghost.remove();
document.removeEventListener("pointermove", this.placing.follow);
this.placing = null;
this.el.classList.remove("placing");
}
dropPoint(clientX, clientY) {
const r = this.el.getBoundingClientRect();
if (clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom) return null;
return this.toCanvas(clientX, clientY);
}
}
// Chromium ignores insertText("\n") in a plaintext-only editable, and may
// wrap lines in <div>/<br> on its own. Newlines are therefore inserted as
// text nodes, and the edited text is read back structurally.
function insertNewline(root) {
const sel = window.getSelection();
if (!sel.rangeCount) return;
const range = sel.getRangeAt(0);
range.deleteContents();
const nl = document.createTextNode("\n");
range.insertNode(nl);
// A newline at the very end only renders once something follows it.
if (!nl.nextSibling || (nl.nextSibling.nodeType === 3 && !nl.nextSibling.data)) {
const pad = document.createTextNode("\u200b");
nl.after(pad);
}
range.setStartAfter(nl);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
function editedText(root) {
let out = "";
const walk = (node, top) => {
for (const c of node.childNodes) {
if (c.nodeType === 3) out += c.data;
else if (c.nodeName === "BR") out += "\n";
else {
const block = c.nodeName === "DIV" || c.nodeName === "P";
if (block && out && !out.endsWith("\n")) out += "\n";
walk(c, false);
}
}
};
walk(root, true);
return out.replace(/\u200b/g, "").replace(/\r/g, "");
}