/** * Canvas v1 document schema (T-167). * * The whiteboard is a page kind (`Page.kind = "whiteboard"`) whose content * lives in `Whiteboard.canvas` (Json) + `Whiteboard.schemaVersion` (Int). * This module is the hand-rolled, zero-dependency validator + type surface * for that Json. There is deliberately NO runtime dependency — the canvas * document must be readable by the API, the web renderer, and the worker * without a shared build pipeline (source-first, like @factory/shared). * * Unknown `version` ⇒ `validateCanvas` rejects ⇒ the renderer must show the * read-only banner (acceptance: "unknown version → read-only"). */ export const CANVAS_VERSION = 1; export const ELEMENT_TYPES = ["sticky", "text", "rect", "ellipse", "connector", "freehand", "group"] as const; export type ElementType = (typeof ELEMENT_TYPES)[number]; /** Connector endpoint: attach to an element at one of 4 anchors. */ export const ANCHORS = ["top", "right", "bottom", "left"] as const; export type Anchor = (typeof ANCHORS)[number]; export interface ElementBase { id: string; // 1-64 chars, unique within the canvas type: ElementType; x: number; // world coords, finite y: number; w: number; // ≥ 0 (connectors: path bbox, may be 0) h: number; z: number; // paint order (int, any range) link?: ElementLink; // optional (T-170): a page/task/external link on the element } /** A link attached to an element (T-170): an internal page/task or an http(s) URL. */ export interface ElementLink { kind: "page" | "task" | "url"; /** For page/task: the target id (the client resolves the route). */ refId?: string; /** For url: the absolute http(s) URL. */ href?: string; } export interface StickyElement extends ElementBase { type: "sticky"; text: string; // ≤ 20_000 chars — plain text, renderer escapes color: string; // palette key: "yellow" | "blue" | "green" | "pink" | "purple" | "orange" } export interface TextElement extends ElementBase { type: "text"; text: string; fontSize: number; // 8..120 } export interface ShapeElement extends ElementBase { type: "rect" | "ellipse"; fill: string | null; // palette key or null stroke: string | null; } export interface ConnectorEndpoint { elementId: string; anchor: Anchor; } export interface ConnectorElement extends ElementBase { type: "connector"; from: ConnectorEndpoint; to: ConnectorEndpoint; kind: "straight" | "orthogonal"; arrow: boolean; } export interface FreehandElement extends ElementBase { type: "freehand"; points: number[]; // flat [x0,y0,x1,y1,…] WORLD coords, even length, ≤ 5000 numbers strokeWidth: number; // 1..40 } export interface GroupElement extends ElementBase { type: "group"; children: string[]; // element ids (non-group elements), ≤ 1000 } export type CanvasElement = StickyElement | TextElement | ShapeElement | ConnectorElement | FreehandElement | GroupElement; /** The canvas document stored in `Whiteboard.canvas`. */ export interface Canvas { version: number; elements: Record; selection?: string[]; // transient, optional } export const STICKY_COLORS = ["yellow", "blue", "green", "pink", "purple", "orange"] as const; const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const MAX_TEXT = 20_000; const MAX_POINTS = 5_000; const MAX_CHILDREN = 1_000; const FONT_MIN = 8; const FONT_MAX = 120; const MAX_HREF = 2_000; const HREF_RE = /^https?:\/\/\S+$/; function isFiniteNum(v: unknown): v is number { return typeof v === "number" && Number.isFinite(v); } function str(v: unknown, max: number): v is string { return typeof v === "string" && v.length <= max; } /** Parse the optional `link` field (T-170). `undefined` = absent. Throws on a * structurally invalid link — call ONLY after {@link parseLinkOrError} has * validated it (reconstruct runs post-validation in validateElement). */ export function parseLink(v: unknown): ElementLink | undefined { if (v === undefined || v === null) return undefined; // absent (optional field) if (typeof v !== "object") throw new Error("link must be an object"); const o = v as Record; if (o.kind === "url") { if (!str(o.href, MAX_HREF) || !HREF_RE.test(o.href as string)) throw new Error("link.href must be an http(s) URL"); return { kind: "url", href: o.href as string }; } if (o.kind === "page" || o.kind === "task") { if (!str(o.refId, 128) || !(o.refId as string)) throw new Error(`link.refId required (kind=${o.kind})`); return { kind: o.kind, refId: o.refId as string }; } throw new Error("link.kind must be one of page|task|url"); } /** Validate the optional `link` field (for the error-reporting pass). */ function parseLinkOrError(v: unknown): { ok: true } | { ok: false; error: string } { try { parseLink(v); return { ok: true }; } catch (err) { return { ok: false, error: (err as Error).message }; } } export type CanvasValidation = { ok: true; canvas: Canvas } | { ok: false; errors: string[] }; /** Validate an arbitrary parsed-Json value as a Canvas (v1). */ export function validateCanvas(input: unknown): CanvasValidation { const errors: string[] = []; if (typeof input !== "object" || input === null) return { ok: false, errors: ["canvas must be an object"] }; const c = input as Record; if (c.version !== CANVAS_VERSION) { return { ok: false, errors: [`unsupported canvas version: ${String(c.version)} (supported: ${CANVAS_VERSION})`] }; } if (typeof c.elements !== "object" || c.elements === null || Array.isArray(c.elements)) { return { ok: false, errors: ["elements must be an object map"] }; } const elements: Record = {}; for (const [id, raw] of Object.entries(c.elements as Record)) { if (!ID_RE.test(id)) { errors.push(`invalid element id: ${JSON.stringify(id)}`); continue; } const el = validateElement(id, raw); if (!el.ok) { errors.push(...el.errors); continue; } elements[id] = el.el; } if (errors.length > 0) return { ok: false, errors }; // referential integrity (second pass) for (const el of Object.values(elements)) { if (el.type === "connector") { for (const ep of [el.from, el.to]) { if (!elements[ep.elementId]) errors.push(`connector ${el.id} references missing element ${ep.elementId}`); } } if (el.type === "group") { for (const child of el.children) { const target = elements[child]; if (!target) errors.push(`group ${el.id} references missing child ${child}`); else if (target.type === "group") errors.push(`group ${el.id} cannot contain a group (${child})`); } } } if (errors.length > 0) return { ok: false, errors }; const canvas: Canvas = { version: CANVAS_VERSION, elements }; if (Array.isArray(c.selection)) { canvas.selection = c.selection.filter((s): s is string => typeof s === "string"); } return { ok: true, canvas }; } function validateElement(id: string, raw: unknown): { ok: true; el: CanvasElement } | { ok: false; errors: string[] } { const errors: string[] = []; if (typeof raw !== "object" || raw === null) return { ok: false, errors: [`element ${id}: must be an object`] }; const e = raw as Record; if (typeof e.type !== "string" || !ELEMENT_TYPES.includes(e.type as ElementType)) { return { ok: false, errors: [`element ${id}: unknown type ${String(e.type)}`] }; } for (const k of ["x", "y", "w", "h"] as const) { if (!isFiniteNum(e[k])) errors.push(`element ${id}: ${k} must be a finite number`); } if (isFiniteNum(e.w) && (e.w as number) < 0) errors.push(`element ${id}: w must be ≥ 0`); if (isFiniteNum(e.h) && (e.h as number) < 0) errors.push(`element ${id}: h must be ≥ 0`); if (typeof e.z !== "number" || !Number.isInteger(e.z)) errors.push(`element ${id}: z must be an integer`); const lk = parseLinkOrError(e.link); if (!lk.ok) errors.push(lk.error); const base = e.type as ElementType; switch (base) { case "sticky": { if (!str(e.text, MAX_TEXT)) errors.push(`element ${id}: text must be a string ≤ ${MAX_TEXT}`); if (!STICKY_COLORS.includes(e.color as (typeof STICKY_COLORS)[number])) errors.push(`element ${id}: color must be a known palette key`); break; } case "text": { if (!str(e.text, MAX_TEXT)) errors.push(`element ${id}: text must be a string ≤ ${MAX_TEXT}`); if (!isFiniteNum(e.fontSize) || e.fontSize < FONT_MIN || e.fontSize > FONT_MAX) errors.push(`element ${id}: fontSize must be ${FONT_MIN}..${FONT_MAX}`); break; } case "rect": case "ellipse": { if (e.fill !== null && typeof e.fill !== "string") errors.push(`element ${id}: fill must be null or a string`); if (e.stroke !== null && typeof e.stroke !== "string") errors.push(`element ${id}: stroke must be null or a string`); break; } case "connector": { for (const k of ["from", "to"] as const) { const ep = e[k]; if (typeof ep !== "object" || ep === null) { errors.push(`element ${id}: ${k} must be an endpoint object`); } else { const o = ep as Record; if (!str(o.elementId, 64)) errors.push(`element ${id}.${k}.elementId invalid`); if (!ANCHORS.includes(o.anchor as Anchor)) errors.push(`element ${id}.${k}.anchor must be one of ${ANCHORS.join(",")}`); } } if (e.kind !== "straight" && e.kind !== "orthogonal") errors.push(`element ${id}: kind must be straight|orthogonal`); if (typeof e.arrow !== "boolean") errors.push(`element ${id}: arrow must be a boolean`); break; } case "freehand": { if (!Array.isArray(e.points) || e.points.length % 2 !== 0 || e.points.length > MAX_POINTS) { errors.push(`element ${id}: points must be an even-length array ≤ ${MAX_POINTS} numbers`); } else if (!e.points.every(isFiniteNum)) { errors.push(`element ${id}: points must be finite numbers`); } if (!isFiniteNum(e.strokeWidth) || e.strokeWidth < 1 || e.strokeWidth > 40) errors.push(`element ${id}: strokeWidth must be 1..40`); break; } case "group": { if (!Array.isArray(e.children) || e.children.length > MAX_CHILDREN || !e.children.every((c) => str(c, 64))) { errors.push(`element ${id}: children must be an id array ≤ ${MAX_CHILDREN}`); } break; } } if (errors.length > 0) return { ok: false, errors }; // rebuild a strictly-typed element (unknown keys dropped — no junk persistence) return { ok: true, el: reconstruct(id, e) }; } /** Strip unknown keys so the stored shape is exactly the schema (no junk persistence). */ function reconstruct(id: string, e: Record): CanvasElement { const num = (k: string) => e[k] as number; // `link` is a base field (T-170) — re-parse it (already validated in validateElement). const link = parseLink(e.link); const lk = link ? { link } : {}; switch (e.type) { case "sticky": 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 }; case "text": 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 }; case "rect": case "ellipse": 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 }; case "connector": { const f = e.from as Record; const t = e.to as Record; return { id, type: "connector", x: num("x"), y: num("y"), w: num("w"), h: num("h"), z: num("z"), from: { elementId: f.elementId as string, anchor: f.anchor as Anchor }, to: { elementId: t.elementId as string, anchor: t.anchor as Anchor }, kind: e.kind as "straight" | "orthogonal", arrow: e.arrow as boolean, ...lk }; } case "freehand": 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 }; case "group": 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 }; default: throw new Error(`reconstruct: unhandled element type ${String(e.type)}`); } } /** True when the value passes `validateCanvas` (convenience for tests/renderers). */ export function isCanvas(v: unknown): v is Canvas { return validateCanvas(v).ok; } /** A fresh, empty canvas document. */ export function emptyCanvas(): Canvas { return { version: CANVAS_VERSION, elements: {} }; }