1
14
15export const CANVAS_VERSION = 1;
16
17export const ELEMENT_TYPES = ["sticky", "text", "rect", "ellipse", "connector", "freehand", "group"] as const;
18export type ElementType = (typeof ELEMENT_TYPES)[number];
19
20
21export const ANCHORS = ["top", "right", "bottom", "left"] as const;
22export type Anchor = (typeof ANCHORS)[number];
23
24export interface ElementBase {
25 id: string;
26 type: ElementType;
27 x: number;
28 y: number;
29 w: number;
30 h: number;
31 z: number;
32 link?: ElementLink;
33}
34
35
36export interface ElementLink {
37 kind: "page" | "task" | "url";
38
39 refId?: string;
40
41 href?: string;
42}
43
44export interface StickyElement extends ElementBase {
45 type: "sticky";
46 text: string;
47 color: string;
48}
49export interface TextElement extends ElementBase {
50 type: "text";
51 text: string;
52 fontSize: number;
53}
54export interface ShapeElement extends ElementBase {
55 type: "rect" | "ellipse";
56 fill: string | null;
57 stroke: string | null;
58}
59export interface ConnectorEndpoint {
60 elementId: string;
61 anchor: Anchor;
62}
63export interface ConnectorElement extends ElementBase {
64 type: "connector";
65 from: ConnectorEndpoint;
66 to: ConnectorEndpoint;
67 kind: "straight" | "orthogonal";
68 arrow: boolean;
69}
70export interface FreehandElement extends ElementBase {
71 type: "freehand";
72 points: number[];
73 strokeWidth: number;
74}
75export interface GroupElement extends ElementBase {
76 type: "group";
77 children: string[];
78}
79
80export type CanvasElement = StickyElement | TextElement | ShapeElement | ConnectorElement | FreehandElement | GroupElement;
81
82
83export interface Canvas {
84 version: number;
85 elements: Record<string, CanvasElement>;
86 selection?: string[];
87}
88
89export const STICKY_COLORS = ["yellow", "blue", "green", "pink", "purple", "orange"] as const;
90
91const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
92const MAX_TEXT = 20_000;
93const MAX_POINTS = 5_000;
94const MAX_CHILDREN = 1_000;
95const FONT_MIN = 8;
96const FONT_MAX = 120;
97const MAX_HREF = 2_000;
98const HREF_RE = /^https?:\/\/\S+$/;
99
100function isFiniteNum(v: unknown): v is number {
101 return typeof v === "number" && Number.isFinite(v);
102}
103
104function str(v: unknown, max: number): v is string {
105 return typeof v === "string" && v.length <= max;
106}
107
108
111export function parseLink(v: unknown): ElementLink | undefined {
112 if (v === undefined || v === null) return undefined;
113 if (typeof v !== "object") throw new Error("link must be an object");
114 const o = v as Record<string, unknown>;
115 if (o.kind === "url") {
116 if (!str(o.href, MAX_HREF) || !HREF_RE.test(o.href as string)) throw new Error("link.href must be an http(s) URL");
117 return { kind: "url", href: o.href as string };
118 }
119 if (o.kind === "page" || o.kind === "task") {
120 if (!str(o.refId, 128) || !(o.refId as string)) throw new Error(`link.refId required (kind=${o.kind})`);
121 return { kind: o.kind, refId: o.refId as string };
122 }
123 throw new Error("link.kind must be one of page|task|url");
124}
125
126
127function parseLinkOrError(v: unknown): { ok: true } | { ok: false; error: string } {
128 try {
129 parseLink(v);
130 return { ok: true };
131 } catch (err) {
132 return { ok: false, error: (err as Error).message };
133 }
134}
135
136export type CanvasValidation = { ok: true; canvas: Canvas } | { ok: false; errors: string[] };
137
138
139export function validateCanvas(input: unknown): CanvasValidation {
140 const errors: string[] = [];
141 if (typeof input !== "object" || input === null) return { ok: false, errors: ["canvas must be an object"] };
142 const c = input as Record<string, unknown>;
143
144 if (c.version !== CANVAS_VERSION) {
145 return { ok: false, errors: [`unsupported canvas version: ${String(c.version)} (supported: ${CANVAS_VERSION})`] };
146 }
147 if (typeof c.elements !== "object" || c.elements === null || Array.isArray(c.elements)) {
148 return { ok: false, errors: ["elements must be an object map"] };
149 }
150
151 const elements: Record<string, CanvasElement> = {};
152 for (const [id, raw] of Object.entries(c.elements as Record<string, unknown>)) {
153 if (!ID_RE.test(id)) {
154 errors.push(`invalid element id: ${JSON.stringify(id)}`);
155 continue;
156 }
157 const el = validateElement(id, raw);
158 if (!el.ok) {
159 errors.push(...el.errors);
160 continue;
161 }
162 elements[id] = el.el;
163 }
164
165 if (errors.length > 0) return { ok: false, errors };
166
167
168 for (const el of Object.values(elements)) {
169 if (el.type === "connector") {
170 for (const ep of [el.from, el.to]) {
171 if (!elements[ep.elementId]) errors.push(`connector ${el.id} references missing element ${ep.elementId}`);
172 }
173 }
174 if (el.type === "group") {
175 for (const child of el.children) {
176 const target = elements[child];
177 if (!target) errors.push(`group ${el.id} references missing child ${child}`);
178 else if (target.type === "group") errors.push(`group ${el.id} cannot contain a group (${child})`);
179 }
180 }
181 }
182 if (errors.length > 0) return { ok: false, errors };
183
184 const canvas: Canvas = { version: CANVAS_VERSION, elements };
185 if (Array.isArray(c.selection)) {
186 canvas.selection = c.selection.filter((s): s is string => typeof s === "string");
187 }
188 return { ok: true, canvas };
189}
190
191function validateElement(id: string, raw: unknown): { ok: true; el: CanvasElement } | { ok: false; errors: string[] } {
192 const errors: string[] = [];
193 if (typeof raw !== "object" || raw === null) return { ok: false, errors: [`element ${id}: must be an object`] };
194 const e = raw as Record<string, unknown>;
195
196 if (typeof e.type !== "string" || !ELEMENT_TYPES.includes(e.type as ElementType)) {
197 return { ok: false, errors: [`element ${id}: unknown type ${String(e.type)}`] };
198 }
199 for (const k of ["x", "y", "w", "h"] as const) {
200 if (!isFiniteNum(e[k])) errors.push(`element ${id}: ${k} must be a finite number`);
201 }
202 if (isFiniteNum(e.w) && (e.w as number) < 0) errors.push(`element ${id}: w must be ≥ 0`);
203 if (isFiniteNum(e.h) && (e.h as number) < 0) errors.push(`element ${id}: h must be ≥ 0`);
204 if (typeof e.z !== "number" || !Number.isInteger(e.z)) errors.push(`element ${id}: z must be an integer`);
205
206 const lk = parseLinkOrError(e.link);
207 if (!lk.ok) errors.push(lk.error);
208
209 const base = e.type as ElementType;
210 switch (base) {
211 case "sticky": {
212 if (!str(e.text, MAX_TEXT)) errors.push(`element ${id}: text must be a string ≤ ${MAX_TEXT}`);
213 if (!STICKY_COLORS.includes(e.color as (typeof STICKY_COLORS)[number])) errors.push(`element ${id}: color must be a known palette key`);
214 break;
215 }
216 case "text": {
217 if (!str(e.text, MAX_TEXT)) errors.push(`element ${id}: text must be a string ≤ ${MAX_TEXT}`);
218 if (!isFiniteNum(e.fontSize) || e.fontSize < FONT_MIN || e.fontSize > FONT_MAX) errors.push(`element ${id}: fontSize must be ${FONT_MIN}..${FONT_MAX}`);
219 break;
220 }
221 case "rect":
222 case "ellipse": {
223 if (e.fill !== null && typeof e.fill !== "string") errors.push(`element ${id}: fill must be null or a string`);
224 if (e.stroke !== null && typeof e.stroke !== "string") errors.push(`element ${id}: stroke must be null or a string`);
225 break;
226 }
227 case "connector": {
228 for (const k of ["from", "to"] as const) {
229 const ep = e[k];
230 if (typeof ep !== "object" || ep === null) {
231 errors.push(`element ${id}: ${k} must be an endpoint object`);
232 } else {
233 const o = ep as Record<string, unknown>;
234 if (!str(o.elementId, 64)) errors.push(`element ${id}.${k}.elementId invalid`);
235 if (!ANCHORS.includes(o.anchor as Anchor)) errors.push(`element ${id}.${k}.anchor must be one of ${ANCHORS.join(",")}`);
236 }
237 }
238 if (e.kind !== "straight" && e.kind !== "orthogonal") errors.push(`element ${id}: kind must be straight|orthogonal`);
239 if (typeof e.arrow !== "boolean") errors.push(`element ${id}: arrow must be a boolean`);
240 break;
241 }
242 case "freehand": {
243 if (!Array.isArray(e.points) || e.points.length % 2 !== 0 || e.points.length > MAX_POINTS) {
244 errors.push(`element ${id}: points must be an even-length array ≤ ${MAX_POINTS} numbers`);
245 } else if (!e.points.every(isFiniteNum)) {
246 errors.push(`element ${id}: points must be finite numbers`);
247 }
248 if (!isFiniteNum(e.strokeWidth) || e.strokeWidth < 1 || e.strokeWidth > 40) errors.push(`element ${id}: strokeWidth must be 1..40`);
249 break;
250 }
251 case "group": {
252 if (!Array.isArray(e.children) || e.children.length > MAX_CHILDREN || !e.children.every((c) => str(c, 64))) {
253 errors.push(`element ${id}: children must be an id array ≤ ${MAX_CHILDREN}`);
254 }
255 break;
256 }
257 }
258
259 if (errors.length > 0) return { ok: false, errors };
260
261 return { ok: true, el: reconstruct(id, e) };
262}
263
264
265function reconstruct(id: string, e: Record<string, unknown>): CanvasElement {
266 const num = (k: string) => e[k] as number;
267
268 const link = parseLink(e.link);
269 const lk = link ? { link } : {};
270 switch (e.type) {
271 case "sticky":
272 return { id, type: "sticky", x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), text: e.text as string, color: e.color as string, ...lk };
273 case "text":
274 return { id, type: "text", x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), text: e.text as string, fontSize: e.fontSize as number, ...lk };
275 case "rect":
276 case "ellipse":
277 return { id, type: e.type, x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), fill: e.fill as string | null, stroke: e.stroke as string | null, ...lk };
278 case "connector": {
279 const f = e.from as Record<string, unknown>;
280 const t = e.to as Record<string, unknown>;
281 return {
282 id,
283 type: "connector",
284 x: num("x"),
285 y: num("y"),
286 w: num("w"),
287 h: num("h"),
288 z: num("z"),
289 from: { elementId: f.elementId as string, anchor: f.anchor as Anchor },
290 to: { elementId: t.elementId as string, anchor: t.anchor as Anchor },
291 kind: e.kind as "straight" | "orthogonal",
292 arrow: e.arrow as boolean,
293 ...lk
294 };
295 }
296 case "freehand":
297 return { id, type: "freehand", x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), points: e.points as number[], strokeWidth: e.strokeWidth as number, ...lk };
298 case "group":
299 return { id, type: "group", x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), children: e.children as string[], ...lk };
300 default:
301 throw new Error(`reconstruct: unhandled element type ${String(e.type)}`);
302 }
303}
304
305
306export function isCanvas(v: unknown): v is Canvas {
307 return validateCanvas(v).ok;
308}
309
310
311export function emptyCanvas(): Canvas {
312 return { version: CANVAS_VERSION, elements: {} };
313}
314