Skip to content
185 linesCodeBlameRaw
1/**
2 * Design (`kind: "design"`): a frame-based layout canvas and the changes
3 * agents make to it. Wire shapes are snake_case.
4 *
5 * In the Yjs document: `Y.Map("canvas")` holds `DesignCanvas`; `Y.Map("nodes")`
6 * maps each node's id to a `Y.Map` of `DesignNode` (a `text` node's content
7 * and an `html` node's source are `Y.Text`). Frames lay their children out
8 * themselves (`row`, `column`), so agents describe structure, not
9 * coordinates.
10 */
11
12export type DesignNodeType = "frame" | "rect" | "ellipse" | "line" | "arrow" | "text" | "image" | "html" | "component" | "instance";
13export const DESIGN_NODE_TYPES: readonly DesignNodeType[] = ["frame", "rect", "ellipse", "line", "arrow", "text", "image", "html", "component", "instance"];
14
15export type DesignFrameLayout = "free" | "row" | "column";
16export type DesignAlign = "start" | "center" | "end" | "stretch";
17
18export type DesignCanvas = { background: string | null; grid: number | null };
19
20/** A node as stored: every field but `id` and `type` may be absent. */
21export type DesignNode = {
22 id: string;
23 type: DesignNodeType;
24 /** The frame or component it sits in; null at the top. */
25 parent: string | null;
26 /** Its order among its siblings: a fractional index. */
27 index: string;
28 x: number;
29 y: number;
30 w: number;
31 h: number;
32 rotation?: number;
33 fill?: string | null;
34 stroke?: string | null;
35 stroke_width?: number;
36 radius?: number;
37 opacity?: number;
38 name?: string;
39 locked?: boolean;
40 hidden?: boolean;
41 /** Frames and components. */
42 layout?: DesignFrameLayout;
43 gap?: number;
44 padding?: number;
45 align?: DesignAlign;
46 /** `line` and `arrow`: the nodes their ends are bound to. */
47 from_node?: string | null;
48 to_node?: string | null;
49 /** `image`: a file of the folio, never bytes. */
50 file?: string;
51 /** `instance`: the component it is an instance of, and what it changes. */
52 component_id?: string;
53 overrides?: Record<string, unknown>;
54};
55
56/**
57 * A node as an agent writes it: nested, so `children` imply `parent` and
58 * `index`. Sizes and positions are optional inside `row` and `column`
59 * frames. `text` is a text node's content; `html` an html node's source.
60 */
61export type DesignNodeSpec = Partial<Omit<DesignNode, "parent" | "index" | "type">> & {
62 type: DesignNodeType;
63 text?: string;
64 html?: string;
65 children?: DesignNodeSpec[];
66};
67
68/** Limits the room enforces when it saves. */
69export const DESIGN_MAX_NODES = 5_000;
70export const DESIGN_MAX_HTML_BYTES = 200 * 1024;
71export const DESIGN_MAX_FILE_BYTES = 25 * 1024 * 1024;
72/** The deepest a node spec nests. */
73export const DESIGN_MAX_DEPTH = 32;
74
75/** The Yjs names the canvas uses. */
76export const DESIGN_CANVAS_MAP = "canvas";
77export const DESIGN_NODES_MAP = "nodes";
78
79/** What a card shows of a design: its first frame's top nodes, simplified, at most `DESIGN_PREVIEW_NODES`. */
80export type DesignPreview = {
81 kind: "design";
82 frame: { name: string; w: number; h: number } | null;
83 nodes: { type: DesignNodeType; x: number; y: number; w: number; h: number; fill: string | null }[];
84};
85export const DESIGN_PREVIEW_NODES = 50;
86
87/** A change an agent makes to a design. */
88export type DesignOp =
89 /** Add nodes, or change the ones whose `id` exists. Nested specs are placed in their parents. */
90 | { op: "upsert_nodes"; parent?: string | null; nodes: DesignNodeSpec[] }
91 | { op: "delete_nodes"; ids: string[] }
92 /** Replace a frame's children (and its own fields) with a spec. */
93 | { op: "replace_frame"; frame_id: string; spec: DesignNodeSpec }
94 | { op: "set_html"; node_id: string; html: string }
95 | { op: "move"; ids: string[]; dx: number; dy: number };
96
97export const DESIGN_OPS: readonly DesignOp["op"][] = ["upsert_nodes", "delete_nodes", "replace_frame", "set_html", "move"];
98
99/** How many nodes a spec list makes, its children included, and how deep it goes. */
100export function countDesignSpecs(specs: readonly DesignNodeSpec[], depth = 1): { nodes: number; depth: number } {
101 let nodes = 0;
102 let deepest = specs.length > 0 ? depth : depth - 1;
103 for (const spec of specs) {
104 nodes += 1;
105 if (spec.children && spec.children.length > 0) {
106 const inner = countDesignSpecs(spec.children, depth + 1);
107 nodes += inner.nodes;
108 deepest = Math.max(deepest, inner.depth);
109 }
110 }
111 return { nodes, depth: deepest };
112}
113
114/** What is wrong with a node spec, or null. */
115export function designSpecError(spec: unknown, depth = 1): string | null {
116 if (depth > DESIGN_MAX_DEPTH) return `Node specs nest at most ${DESIGN_MAX_DEPTH} deep.`;
117 if (!isObject(spec)) return "A node spec is an object.";
118 if (typeof spec.type !== "string" || !(DESIGN_NODE_TYPES as readonly string[]).includes(spec.type)) return `There is no node type called ${String(spec.type)}.`;
119 for (const key of ["x", "y", "w", "h", "rotation", "stroke_width", "radius", "opacity", "gap", "padding"]) {
120 if (spec[key] !== undefined && (typeof spec[key] !== "number" || !Number.isFinite(spec[key]))) return `${key} is a number.`;
121 }
122 if (spec.opacity !== undefined && ((spec.opacity as number) < 0 || (spec.opacity as number) > 1)) return "opacity is between 0 and 1.";
123 if (spec.layout !== undefined && !["free", "row", "column"].includes(spec.layout as string)) return "layout is free, row or column.";
124 if (typeof spec.html === "string" && utf8Bytes(spec.html) > DESIGN_MAX_HTML_BYTES) return "An html node holds at most 200 KB.";
125 if (spec.html !== undefined && spec.type !== "html") return "Only an html node has html.";
126 if (spec.type === "instance" && typeof spec.component_id !== "string") return "An instance names its component_id.";
127 if (spec.children !== undefined) {
128 if (!Array.isArray(spec.children)) return "children is a list.";
129 if (spec.children.length > 0 && spec.type !== "frame" && spec.type !== "component") return "Only frames and components have children.";
130 for (const child of spec.children) {
131 const error = designSpecError(child, depth + 1);
132 if (error) return error;
133 }
134 }
135 return null;
136}
137
138/** What is wrong with a design op, or null. Checks its shape; whether its ids exist is the room's to say. */
139export function designOpError(op: unknown): string | null {
140 if (!isObject(op) || typeof op.op !== "string") return "A design change has an op.";
141 const ids = (key: string) =>
142 Array.isArray(op[key]) && (op[key] as unknown[]).length > 0 && (op[key] as unknown[]).every((item) => typeof item === "string" && item.length > 0) ? null : `${op.op} needs ${key}.`;
143 const id = (key: string) => (typeof op[key] === "string" && (op[key] as string).length > 0 ? null : `${op.op} needs ${key}.`);
144 switch (op.op) {
145 case "upsert_nodes": {
146 if (!Array.isArray(op.nodes) || op.nodes.length === 0) return "upsert_nodes needs nodes.";
147 for (const spec of op.nodes) {
148 const error = designSpecError(spec);
149 if (error) return error;
150 }
151 if (countDesignSpecs(op.nodes as DesignNodeSpec[]).nodes > DESIGN_MAX_NODES) return `A design holds at most ${DESIGN_MAX_NODES} nodes.`;
152 return null;
153 }
154 case "delete_nodes":
155 return ids("ids");
156 case "replace_frame": {
157 const error = id("frame_id") ?? designSpecError(op.spec);
158 if (error) return error;
159 return countDesignSpecs([op.spec as DesignNodeSpec]).nodes > DESIGN_MAX_NODES ? `A design holds at most ${DESIGN_MAX_NODES} nodes.` : null;
160 }
161 case "set_html":
162 if (id("node_id")) return id("node_id");
163 if (typeof op.html !== "string") return "set_html needs html.";
164 return utf8Bytes(op.html) > DESIGN_MAX_HTML_BYTES ? "An html node holds at most 200 KB." : null;
165 case "move":
166 if (ids("ids")) return ids("ids");
167 return typeof op.dx === "number" && typeof op.dy === "number" && Number.isFinite(op.dx) && Number.isFinite(op.dy) ? null : "move needs dx and dy.";
168 default:
169 return `There is no design op called ${op.op}.`;
170 }
171}
172
173/** The length of `text` in UTF-8, in bytes. */
174function utf8Bytes(text: string): number {
175 let bytes = 0;
176 for (const char of text) {
177 const code = char.codePointAt(0)!;
178 bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
179 }
180 return bytes;
181}
182
183function isObject(value: unknown): value is Record<string, unknown> {
184 return typeof value === "object" && value !== null && !Array.isArray(value);
185}