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