- 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.
// Document model, tree helpers, geometry and pure document operations.
export type LayerType = "frame" | "group" | "rect" | "ellipse" | "text";
interface BaseLayer {
id: string;
name: string;
visible: boolean;
locked: boolean;
}
export interface Stroke {
color: string;
width: number;
}
interface BoxProps {
x: number;
y: number;
width: number;
height: number;
rotation: number;
}
export interface FrameLayer extends BaseLayer {
type: "frame";
x: number;
y: number;
width: number;
height: number;
fill: string;
children: Layer[];
}
export interface GroupLayer extends BaseLayer {
type: "group";
children: Layer[];
}
export interface RectLayer extends BaseLayer, BoxProps {
type: "rect";
fill: string;
opacity: number;
corner_radius: number;
stroke: Stroke | null;
}
export interface EllipseLayer extends BaseLayer, BoxProps {
type: "ellipse";
fill: string;
opacity: number;
}
export interface TextLayer extends BaseLayer, BoxProps {
type: "text";
text: string;
font_size: number;
font_weight: number;
line_height: number;
align: "left" | "center" | "right";
fill: string;
opacity: number;
}
export type Leaf = RectLayer | EllipseLayer | TextLayer;
export type Container = FrameLayer | GroupLayer;
export type Layer = FrameLayer | GroupLayer | Leaf;
export interface Doc {
product: string;
workspace: string;
file: string;
fictional_notice: string;
children: Layer[];
}
export interface Pt {
x: number;
y: number;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Oriented box: center, unrotated size and clockwise rotation in degrees. */
export interface OBox {
cx: number;
cy: number;
w: number;
h: number;
rot: number;
}
export const TYPE_LABEL: Record<LayerType, string> = {
frame: "Frame",
group: "Group",
rect: "Rectangle",
ellipse: "Ellipse",
text: "Text",
};
export const round2 = (n: number) => {
const r = Math.round(n * 100) / 100;
return Object.is(r, -0) ? 0 : r;
};
export function fmt(n: number): string {
return String(round2(n));
}
export function isContainer(n: Layer): n is Container {
return n.type === "frame" || n.type === "group";
}
export function isLeaf(n: Layer): n is Leaf {
return n.type === "rect" || n.type === "ellipse" || n.type === "text";
}
export function normalizeAngle(a: number): number {
let r = (((a % 360) + 540) % 360) - 180;
if (r === -180) r = 180;
return round2(r);
}
// ---------------------------------------------------------------------------
// Index
export interface NodeInfo {
node: Layer;
parentId: string | null;
frameId: string | null;
depth: number;
index: number;
ancestorIds: string[];
/** Effective visibility / lock, including ancestors. */
hidden: boolean;
locked: boolean;
}
const indexCache = new WeakMap<Doc, Map<string, NodeInfo>>();
export function getIndex(doc: Doc): Map<string, NodeInfo> {
const cached = indexCache.get(doc);
if (cached) return cached;
const m = new Map<string, NodeInfo>();
const walk = (
list: Layer[],
parentId: string | null,
frameId: string | null,
ancestors: string[],
hidden: boolean,
locked: boolean,
) => {
list.forEach((node, index) => {
const h = hidden || !node.visible;
const l = locked || node.locked;
m.set(node.id, { node, parentId, frameId, depth: ancestors.length, index, ancestorIds: ancestors, hidden: h, locked: l });
if (isContainer(node)) {
walk(node.children, node.id, node.type === "frame" ? node.id : frameId, [...ancestors, node.id], h, l);
}
});
};
walk(doc.children, null, null, [], false, false);
indexCache.set(doc, m);
return m;
}
export function childrenOf(doc: Doc, parentId: string | null): Layer[] {
if (parentId === null) return doc.children;
const n = getIndex(doc).get(parentId)?.node;
return n && isContainer(n) ? n.children : [];
}
export function frameOffset(doc: Doc, id: string): Pt {
const idx = getIndex(doc);
const info = idx.get(id);
if (!info?.frameId) return { x: 0, y: 0 };
const f = idx.get(info.frameId)!.node as FrameLayer;
return { x: f.x, y: f.y };
}
/** Drops ids that no longer exist and ids whose ancestor is also selected. */
export function topSelection(doc: Doc, ids: string[]): string[] {
const idx = getIndex(doc);
const set = new Set(ids);
return ids.filter((id) => {
const info = idx.get(id);
return info && !info.ancestorIds.some((a) => set.has(a));
});
}
export function existing(doc: Doc, ids: string[]): string[] {
const idx = getIndex(doc);
return ids.filter((id) => idx.has(id));
}
// ---------------------------------------------------------------------------
// Geometry
export function leafOBox(n: Leaf, off: Pt): OBox {
return { cx: off.x + n.x + n.width / 2, cy: off.y + n.y + n.height / 2, w: n.width, h: n.height, rot: n.rotation || 0 };
}
export function axes(rot: number): { u: Pt; v: Pt } {
const r = (rot * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
return { u: { x: c, y: s }, v: { x: -s, y: c } };
}
/** Point on an oriented box, given local offsets in units of half-size (-1..1). */
export function obPoint(b: OBox, hx: number, hy: number, extraV = 0): Pt {
const { u, v } = axes(b.rot);
const lx = (hx * b.w) / 2;
const ly = (hy * b.h) / 2 + extraV;
return { x: b.cx + lx * u.x + ly * v.x, y: b.cy + lx * u.y + ly * v.y };
}
export function obCorners(b: OBox): Pt[] {
return [obPoint(b, -1, -1), obPoint(b, 1, -1), obPoint(b, 1, 1), obPoint(b, -1, 1)];
}
export function toLocal(b: OBox, p: Pt): Pt {
const { u, v } = axes(b.rot);
const dx = p.x - b.cx;
const dy = p.y - b.cy;
return { x: dx * u.x + dy * u.y, y: dx * v.x + dy * v.y };
}
export function boundsOfPts(pts: Pt[]): Rect {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const p of pts) {
if (p.x < x0) x0 = p.x;
if (p.y < y0) y0 = p.y;
if (p.x > x1) x1 = p.x;
if (p.y > y1) y1 = p.y;
}
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
}
export function unionRect(a: Rect | null, b: Rect | null): Rect | null {
if (!a) return b;
if (!b) return a;
const x = Math.min(a.x, b.x);
const y = Math.min(a.y, b.y);
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
}
export function rectToOBox(r: Rect): OBox {
return { cx: r.x + r.w / 2, cy: r.y + r.h / 2, w: r.w, h: r.h, rot: 0 };
}
export function nodeBounds(n: Layer, off: Pt): Rect | null {
if (n.type === "frame") return { x: n.x, y: n.y, w: n.width, h: n.height };
if (n.type === "group") {
let r: Rect | null = null;
for (const c of n.children) r = unionRect(r, nodeBounds(c, off));
return r;
}
return boundsOfPts(obCorners(leafOBox(n, off)));
}
export function boundsOf(doc: Doc, id: string): Rect | null {
const info = getIndex(doc).get(id);
if (!info) return null;
return nodeBounds(info.node, frameOffset(doc, id));
}
export function selectionBounds(doc: Doc, ids: string[]): Rect | null {
let r: Rect | null = null;
for (const id of ids) r = unionRect(r, boundsOf(doc, id));
return r;
}
function inRect(p: Pt, r: Rect) {
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h;
}
function leafContains(n: Leaf, off: Pt, p: Pt, tol: number): boolean {
const b = leafOBox(n, off);
const l = toLocal(b, p);
const hw = Math.max(b.w / 2, tol);
const hh = Math.max(b.h / 2, tol);
if (n.type === "ellipse") return (l.x / hw) ** 2 + (l.y / hh) ** 2 <= 1;
return Math.abs(l.x) <= hw && Math.abs(l.y) <= hh;
}
function hitList(list: Layer[], off: Pt, p: Pt, tol: number, chain: string[]): string[] | null {
for (let i = list.length - 1; i >= 0; i--) {
const n = list[i];
if (!n.visible || n.locked) continue;
if (n.type === "group") {
const r = hitList(n.children, off, p, tol, [...chain, n.id]);
if (r) return r;
continue;
}
if (n.type === "frame") continue;
if (leafContains(n, off, p, tol)) return [...chain, n.id];
}
return null;
}
/**
* Topmost visible, unlocked leaf under p. Returns the chain of selectable ids
* from the outermost layer inside the frame (or top level) down to the leaf.
*/
export function hitTest(doc: Doc, p: Pt, tol: number): string[] | null {
const list = doc.children;
for (let i = list.length - 1; i >= 0; i--) {
const n = list[i];
if (!n.visible) continue;
if (n.type === "frame") {
if (!inRect(p, { x: n.x, y: n.y, w: n.width, h: n.height })) continue;
if (n.locked) return null;
return hitList(n.children, { x: n.x, y: n.y }, p, tol, []);
}
if (n.locked) continue;
if (n.type === "group") {
const r = hitList(n.children, { x: 0, y: 0 }, p, tol, [n.id]);
if (r) return r;
continue;
}
if (leafContains(n, { x: 0, y: 0 }, p, tol)) return [n.id];
}
return null;
}
/** Topmost visible frame containing p. */
export function frameAt(doc: Doc, p: Pt, unlockedOnly: boolean): FrameLayer | null {
for (let i = doc.children.length - 1; i >= 0; i--) {
const n = doc.children[i];
if (n.type !== "frame" || !n.visible) continue;
if (inRect(p, { x: n.x, y: n.y, w: n.width, h: n.height })) {
if (unlockedOnly && n.locked) return null;
return n;
}
}
return null;
}
function rectPts(r: Rect): Pt[] {
return [
{ x: r.x, y: r.y },
{ x: r.x + r.w, y: r.y },
{ x: r.x + r.w, y: r.y + r.h },
{ x: r.x, y: r.y + r.h },
];
}
function separatedOnAxes(a: Pt[], b: Pt[], src: Pt[]): boolean {
for (let i = 0; i < src.length; i++) {
const p = src[i];
const q = src[(i + 1) % src.length];
const nx = -(q.y - p.y);
const ny = q.x - p.x;
if (nx === 0 && ny === 0) continue;
let amin = Infinity, amax = -Infinity, bmin = Infinity, bmax = -Infinity;
for (const s of a) {
const d = s.x * nx + s.y * ny;
amin = Math.min(amin, d);
amax = Math.max(amax, d);
}
for (const s of b) {
const d = s.x * nx + s.y * ny;
bmin = Math.min(bmin, d);
bmax = Math.max(bmax, d);
}
if (amax < bmin || bmax < amin) return true;
}
return false;
}
function polyIntersectsRect(poly: Pt[], r: Rect): boolean {
const rp = rectPts(r);
return !separatedOnAxes(poly, rp, poly) && !separatedOnAxes(poly, rp, rp);
}
function leafPolygon(n: Leaf, off: Pt): Pt[] {
const b = leafOBox(n, off);
if (n.type !== "ellipse") return obCorners(b);
const pts: Pt[] = [];
for (let i = 0; i < 32; i++) {
const t = (i / 32) * Math.PI * 2;
pts.push(obPoint(b, Math.cos(t), Math.sin(t)));
}
return pts;
}
function nodeIntersects(n: Layer, off: Pt, r: Rect): boolean {
if (!n.visible || n.locked) return false;
if (n.type === "frame") return polyIntersectsRect(rectPts({ x: n.x, y: n.y, w: n.width, h: n.height }), r);
if (n.type === "group") return n.children.some((c) => nodeIntersects(c, off, r));
return polyIntersectsRect(leafPolygon(n, off), r);
}
export function marqueeHits(doc: Doc, r: Rect, scopeFrameId: string | null): string[] {
let list = doc.children;
let off = { x: 0, y: 0 };
if (scopeFrameId) {
const f = getIndex(doc).get(scopeFrameId)?.node as FrameLayer | undefined;
if (!f) return [];
list = f.children;
off = { x: f.x, y: f.y };
}
return list.filter((n) => nodeIntersects(n, off, r)).map((n) => n.id);
}
/**
* Resize an oriented box by dragging handle (hx, hy) by delta (document units),
* keeping the opposite side fixed (or the center with fromCenter).
*/
export function resizeOBox(b: OBox, hx: number, hy: number, delta: Pt, keepAspect: boolean, fromCenter: boolean): OBox {
const { u, v } = axes(b.rot);
const dlx = delta.x * u.x + delta.y * u.y;
const dly = delta.x * v.x + delta.y * v.y;
const hpx = (hx * b.w) / 2 + dlx;
const hpy = (hy * b.h) / 2 + dly;
let nw = b.w;
let nh = b.h;
if (fromCenter) {
if (hx) nw = 2 * hpx * hx;
if (hy) nh = 2 * hpy * hy;
} else {
if (hx) nw = hpx * hx + b.w / 2;
if (hy) nh = hpy * hy + b.h / 2;
}
if (keepAspect && b.w > 0 && b.h > 0) {
let s: number;
if (hx && hy) s = Math.max(nw / b.w, nh / b.h);
else if (hx) s = nw / b.w;
else s = nh / b.h;
s = Math.max(s, 1 / b.w, 1 / b.h);
nw = b.w * s;
nh = b.h * s;
} else {
nw = Math.max(1, nw);
nh = Math.max(1, nh);
}
let lcx = 0;
let lcy = 0;
if (!fromCenter) {
if (hx) lcx = (hx * (nw - b.w)) / 2;
if (hy) lcy = (hy * (nh - b.h)) / 2;
}
return { cx: b.cx + lcx * u.x + lcy * v.x, cy: b.cy + lcx * u.y + lcy * v.y, w: nw, h: nh, rot: b.rot };
}
// ---------------------------------------------------------------------------
// Pure operations
let idCounter = 0;
export function newId(): string {
idCounter += 1;
return `l${Date.now().toString(36)}${idCounter.toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
}
export function replaceNodes(doc: Doc, repl: Map<string, Layer>): Doc {
if (repl.size === 0) return doc;
const mapList = (list: Layer[]): Layer[] => {
let changed = false;
const out = list.map((n) => {
const r = repl.get(n.id);
if (r) {
changed = true;
return r;
}
if (isContainer(n)) {
const c = mapList(n.children);
if (c !== n.children) {
changed = true;
return { ...n, children: c } as Layer;
}
}
return n;
});
return changed ? out : list;
};
const children = mapList(doc.children);
return children === doc.children ? doc : { ...doc, children };
}
export function updateNode(doc: Doc, id: string, patch: Partial<Layer>): Doc {
const n = getIndex(doc).get(id)?.node;
if (!n) return doc;
return replaceNodes(doc, new Map([[id, { ...n, ...patch } as Layer]]));
}
export function setChildren(doc: Doc, parentId: string | null, children: Layer[]): Doc {
if (parentId === null) return { ...doc, children };
const p = getIndex(doc).get(parentId)?.node;
if (!p || !isContainer(p)) return doc;
return replaceNodes(doc, new Map([[parentId, { ...p, children } as Layer]]));
}
export function translateNode(n: Layer, dx: number, dy: number): Layer {
if (n.type === "group") return { ...n, children: n.children.map((c) => translateNode(c, dx, dy)) };
return { ...n, x: round2(n.x + dx), y: round2(n.y + dy) };
}
export function translateMany(doc: Doc, ids: string[], dx: number, dy: number): Doc {
const idx = getIndex(doc);
const repl = new Map<string, Layer>();
for (const id of ids) {
const info = idx.get(id);
if (info) repl.set(id, translateNode(info.node, dx, dy));
}
return replaceNodes(doc, repl);
}
/** Scale a node from box b0 into box b1 (absolute canvas coordinates). */
export function scaleNode(n: Layer, off: Pt, b0: Rect, b1: Rect): Layer {
const sx = b0.w > 0 ? b1.w / b0.w : 1;
const sy = b0.h > 0 ? b1.h / b0.h : 1;
if (n.type === "group") return { ...n, children: n.children.map((c) => scaleNode(c, off, b0, b1)) };
if (n.type === "frame") {
return {
...n,
x: round2(b1.x + (n.x - b0.x) * sx),
y: round2(b1.y + (n.y - b0.y) * sy),
width: round2(Math.max(1, n.width * sx)),
height: round2(Math.max(1, n.height * sy)),
};
}
const ob = leafOBox(n, off);
const ncx = b1.x + (ob.cx - b0.x) * sx;
const ncy = b1.y + (ob.cy - b0.y) * sy;
const r = (ob.rot * Math.PI) / 180;
const c = Math.abs(Math.cos(r));
const s = Math.abs(Math.sin(r));
const nw = Math.max(1, n.width * Math.hypot(sx * c, sy * s));
const nh = Math.max(1, n.height * Math.hypot(sx * s, sy * c));
return {
...n,
x: round2(ncx - off.x - nw / 2),
y: round2(ncy - off.y - nh / 2),
width: round2(nw),
height: round2(nh),
};
}
export function scaleMany(doc: Doc, ids: string[], b0: Rect, b1: Rect): Doc {
const idx = getIndex(doc);
const repl = new Map<string, Layer>();
for (const id of ids) {
const info = idx.get(id);
if (info) repl.set(id, scaleNode(info.node, frameOffset(doc, id), b0, b1));
}
return replaceNodes(doc, repl);
}
/** Apply an oriented box (absolute) to a leaf, keeping rotation. */
export function applyOBoxToLeaf(n: Leaf, off: Pt, b: OBox): Leaf {
return {
...n,
x: round2(b.cx - off.x - b.w / 2),
y: round2(b.cy - off.y - b.h / 2),
width: round2(b.w),
height: round2(b.h),
};
}
function pruneEmptyGroups(list: Layer[]): Layer[] {
let changed = false;
const out: Layer[] = [];
for (const n of list) {
if (isContainer(n)) {
const c = pruneEmptyGroups(n.children);
if (n.type === "group" && c.length === 0) {
changed = true;
continue;
}
if (c !== n.children) {
changed = true;
out.push({ ...n, children: c } as Layer);
continue;
}
}
out.push(n);
}
return changed ? out : list;
}
export function removeNodes(doc: Doc, ids: string[]): Doc {
const set = new Set(ids);
const filter = (list: Layer[]): Layer[] =>
list.filter((n) => !set.has(n.id)).map((n) => (isContainer(n) ? ({ ...n, children: filter(n.children) } as Layer) : n));
return { ...doc, children: pruneEmptyGroups(filter(doc.children)) };
}
export function insertNode(doc: Doc, parentId: string | null, index: number, node: Layer): Doc {
const list = [...childrenOf(doc, parentId)];
list.splice(Math.max(0, Math.min(index, list.length)), 0, node);
return setChildren(doc, parentId, list);
}
export function nextName(doc: Doc, base: string): string {
const re = new RegExp(`^${base} (\\d+)$`);
let max = 0;
for (const info of getIndex(doc).values()) {
const m = re.exec(info.node.name);
if (m) max = Math.max(max, Number(m[1]));
}
return `${base} ${max + 1}`;
}
function cloneWithNewIds(n: Layer): Layer {
if (isContainer(n)) return { ...n, id: newId(), children: n.children.map(cloneWithNewIds) } as Layer;
return { ...n, id: newId() };
}
export function duplicate(doc: Doc, ids: string[]): { doc: Doc; ids: string[] } {
const idx = getIndex(doc);
const top = topSelection(doc, ids);
const byParent = new Map<string | null, Set<string>>();
for (const id of top) {
const p = idx.get(id)!.parentId;
if (!byParent.has(p)) byParent.set(p, new Set());
byParent.get(p)!.add(id);
}
let out = doc;
const copyOf = new Map<string, string>();
for (const [parentId, sel] of byParent) {
const list: Layer[] = [];
for (const n of childrenOf(out, parentId)) {
list.push(n);
if (sel.has(n.id)) {
const c = translateNode(cloneWithNewIds(n), 10, 10);
copyOf.set(n.id, c.id);
list.push(c);
}
}
out = setChildren(out, parentId, list);
}
return { doc: out, ids: top.map((id) => copyOf.get(id)!).filter(Boolean) };
}
export type OpResult = { doc: Doc; selection: string[] } | { error: string };
export function groupLayers(doc: Doc, ids: string[]): OpResult {
const idx = getIndex(doc);
const top = topSelection(doc, ids);
if (top.some((id) => idx.get(id)!.node.type === "frame")) {
return { error: "Frames can’t be grouped. Frames always stay at the top level of the canvas." };
}
if (top.length < 2) return { error: "Select two or more layers to make a group." };
const parents = new Set(top.map((id) => idx.get(id)!.parentId));
if (parents.size > 1) {
return { error: "These layers live in different frames or groups. Select layers that share the same parent to group them." };
}
const parentId = top.map((id) => idx.get(id)!.parentId)[0];
const list = childrenOf(doc, parentId);
const sel = new Set(top);
let topIdx = -1;
list.forEach((n, i) => {
if (sel.has(n.id)) topIdx = i;
});
const group: GroupLayer = {
id: newId(),
type: "group",
name: nextName(doc, "Group"),
visible: true,
locked: false,
children: list.filter((n) => sel.has(n.id)),
};
const next: Layer[] = [];
list.forEach((n, i) => {
if (i === topIdx) next.push(group);
else if (!sel.has(n.id)) next.push(n);
});
return { doc: setChildren(doc, parentId, next), selection: [group.id] };
}
export function ungroupLayers(doc: Doc, ids: string[]): OpResult {
const idx = getIndex(doc);
const top = topSelection(doc, ids);
const groups = top.filter((id) => idx.get(id)!.node.type === "group");
if (groups.length === 0) return { error: "Only groups can be ungrouped. Select a group first." };
let out = doc;
const selection: string[] = top.filter((id) => !groups.includes(id));
for (const gid of groups) {
const info = getIndex(out).get(gid)!;
const g = info.node as GroupLayer;
const kids = g.children.map((c) => ({ ...c, visible: c.visible && g.visible, locked: c.locked || g.locked }) as Layer);
const list = [...childrenOf(out, info.parentId)];
list.splice(info.index, 1, ...kids);
out = setChildren(out, info.parentId, list);
selection.push(...kids.map((k) => k.id));
}
return { doc: out, selection };
}
export function shiftOrder(doc: Doc, ids: string[], dir: 1 | -1): Doc {
const idx = getIndex(doc);
const top = topSelection(doc, ids);
const byParent = new Map<string | null, Set<string>>();
for (const id of top) {
const p = idx.get(id)!.parentId;
if (!byParent.has(p)) byParent.set(p, new Set());
byParent.get(p)!.add(id);
}
let out = doc;
for (const [parentId, sel] of byParent) {
const list = [...childrenOf(out, parentId)];
const order = list.map((_, i) => i);
if (dir === 1) order.reverse();
let changed = false;
for (const i of order) {
if (!sel.has(list[i].id)) continue;
const j = i + dir;
if (j < 0 || j >= list.length || sel.has(list[j].id)) continue;
[list[i], list[j]] = [list[j], list[i]];
changed = true;
}
if (changed) out = setChildren(out, parentId, list);
}
return out;
}
// ---------------------------------------------------------------------------
// Validation of stored documents
export function looksLikeDoc(d: unknown): d is Doc {
if (!d || typeof d !== "object") return false;
const doc = d as Doc;
if (!Array.isArray(doc.children)) return false;
const ok = (n: Layer): boolean =>
!!n && typeof n.id === "string" && typeof n.type === "string" && (!isContainer(n) || (Array.isArray(n.children) && n.children.every(ok)));
return doc.children.every(ok);
}