web based infinite canvas
1import { v4 } from "uuid";
2import type { Vec2 } from "./math";
3/**
4 * Generate a unique ID with an optional prefix
5 * @param prefix - Optional prefix for the ID (e.g., 'shape', 'page', 'binding')
6 * @returns A unique ID string (UUID v4 format with prefix)
7 */
8export function createId(prefix?: string): string {
9 const id = v4();
10 return prefix ? `${prefix}:${id}` : id;
11}
12
13export type PageRecord = { id: string; name: string; shapeIds: string[] };
14
15export const PageRecord = {
16 /**
17 * Create a new page record
18 */
19 create(name: string, id?: string): PageRecord {
20 return { id: id ?? createId("page"), name, shapeIds: [] };
21 },
22
23 /**
24 * Clone a page record
25 */
26 clone(page: PageRecord): PageRecord {
27 return { id: page.id, name: page.name, shapeIds: [...page.shapeIds] };
28 },
29};
30
31export type RectProps = { w: number; h: number; fill: string; stroke: string; radius: number };
32export type EllipseProps = { w: number; h: number; fill: string; stroke: string };
33export type LineProps = { a: Vec2; b: Vec2; stroke: string; width: number };
34
35/**
36 * Arrow endpoint binding metadata
37 */
38export type ArrowEndpoint = { kind: "free" | "bound"; bindingId?: string };
39
40/**
41 * Arrow style configuration
42 */
43export type ArrowStyle = { stroke: string; width: number; headStart?: boolean; headEnd?: boolean; dash?: number[] };
44
45/**
46 * Arrow routing configuration
47 */
48export type ArrowRouting = { kind: "straight" | "orthogonal"; cornerRadius?: number };
49
50/**
51 * Arrow label configuration
52 */
53export type ArrowLabel = { text: string; align: "center" | "start" | "end"; offset: number };
54
55/**
56 * Arrow properties using modern format
57 * Modern format: { points, start, end, style, routing?, label? }
58 */
59export type ArrowProps = {
60 points: Vec2[];
61 start: ArrowEndpoint;
62 end: ArrowEndpoint;
63 style: ArrowStyle;
64 routing?: ArrowRouting;
65 label?: ArrowLabel;
66};
67
68export type TextProps = { text: string; fontSize: number; fontFamily: string; color: string; w?: number };
69
70/**
71 * Markdown block properties
72 * - md: markdown source text
73 * - w: fixed width (required for layout)
74 * - h: auto-computed height from layout (optional override)
75 * - style: font and color settings
76 */
77export type MarkdownProps = {
78 md: string;
79 w: number;
80 h?: number;
81 fontSize: number;
82 fontFamily: string;
83 color: string;
84 bg?: string;
85 border?: string;
86};
87
88/**
89 * Point with optional pressure value (0-1)
90 * Format: [x, y, pressure?]
91 */
92export type StrokePoint = [number, number, number?];
93
94/**
95 * Brush configuration for stroke rendering
96 * Maps to perfect-freehand options
97 */
98export type BrushConfig = {
99 size: number;
100 thinning: number;
101 smoothing: number;
102 streamline: number;
103 simulatePressure: boolean;
104};
105
106/**
107 * Style properties for stroke appearance
108 */
109export type StrokeStyle = { color: string; opacity: number };
110
111/**
112 * Properties for freehand stroke shapes
113 * Points are in world coordinates
114 * Outline and bounds are computed lazily and not persisted
115 */
116export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig };
117
118export type ShapeType = "rect" | "ellipse" | "line" | "arrow" | "text" | "stroke" | "markdown";
119export type BaseShape = {
120 id: string;
121 type: ShapeType;
122 pageId: string;
123 x: number;
124 y: number;
125 rot: number;
126 groupId?: string;
127};
128export type RectShape = BaseShape & { type: "rect"; props: RectProps };
129export type EllipseShape = BaseShape & { type: "ellipse"; props: EllipseProps };
130export type LineShape = BaseShape & { type: "line"; props: LineProps };
131export type ArrowShape = BaseShape & { type: "arrow"; props: ArrowProps };
132export type TextShape = BaseShape & { type: "text"; props: TextProps };
133export type StrokeShape = BaseShape & { type: "stroke"; props: StrokeProps };
134export type MarkdownShape = BaseShape & { type: "markdown"; props: MarkdownProps };
135
136export type ShapeRecord = RectShape | EllipseShape | LineShape | ArrowShape | TextShape | StrokeShape | MarkdownShape;
137
138export const ShapeRecord = {
139 /**
140 * Create a rectangle shape
141 */
142 createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape {
143 return { id: id ?? createId("shape"), type: "rect", pageId, x, y, rot: 0, props: properties };
144 },
145
146 /**
147 * Create an ellipse shape
148 */
149 createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape {
150 return { id: id ?? createId("shape"), type: "ellipse", pageId, x, y, rot: 0, props: properties };
151 },
152
153 /**
154 * Create a line shape
155 */
156 createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape {
157 return { id: id ?? createId("shape"), type: "line", pageId, x, y, rot: 0, props: properties };
158 },
159
160 /**
161 * Create an arrow shape
162 */
163 createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape {
164 return { id: id ?? createId("shape"), type: "arrow", pageId, x, y, rot: 0, props: properties };
165 },
166
167 /**
168 * Create a text shape
169 */
170 createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape {
171 return { id: id ?? createId("shape"), type: "text", pageId, x, y, rot: 0, props: properties };
172 },
173
174 /**
175 * Create a stroke shape
176 */
177 createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape {
178 return { id: id ?? createId("shape"), type: "stroke", pageId, x, y, rot: 0, props: properties };
179 },
180
181 /**
182 * Create a markdown block shape
183 */
184 createMarkdown(pageId: string, x: number, y: number, properties: MarkdownProps, id?: string): MarkdownShape {
185 return { id: id ?? createId("shape"), type: "markdown", pageId, x, y, rot: 0, props: properties };
186 },
187
188 /**
189 * Clone a shape record
190 */
191 clone(shape: ShapeRecord): ShapeRecord {
192 if (shape.type === "stroke") {
193 return {
194 ...shape,
195 props: {
196 ...shape.props,
197 points: shape.props.points.map((p) => [...p] as StrokePoint),
198 style: { ...shape.props.style },
199 brush: { ...shape.props.brush },
200 },
201 };
202 }
203 if (shape.type === "arrow") {
204 return {
205 ...shape,
206 props: {
207 points: shape.props.points.map((p) => ({ ...p })),
208 start: { ...shape.props.start },
209 end: { ...shape.props.end },
210 style: { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined },
211 routing: shape.props.routing ? { ...shape.props.routing } : undefined,
212 label: shape.props.label ? { ...shape.props.label } : undefined,
213 },
214 };
215 }
216 if (shape.type === "markdown") {
217 return { ...shape, props: { ...shape.props } };
218 }
219 return { ...shape, props: { ...shape.props } } as ShapeRecord;
220 },
221};
222
223export type BindingType = "arrow-end";
224export type BindingHandle = "start" | "end";
225
226/**
227 * Binding anchor configuration
228 * - center: bind to shape center
229 * - edge: bind to shape edge with normalized coordinates (nx, ny in [-1, 1])
230 */
231export type BindingAnchor = { kind: "center" } | { kind: "edge"; nx: number; ny: number };
232
233export type BindingRecord = {
234 id: string;
235 type: BindingType;
236 fromShapeId: string;
237 toShapeId: string;
238 handle: BindingHandle;
239 anchor: BindingAnchor;
240};
241
242export const BindingRecord = {
243 /**
244 * Create a binding record for arrow endpoints
245 */
246 create(
247 fromShapeId: string,
248 toShapeId: string,
249 handle: BindingHandle,
250 anchor?: BindingAnchor,
251 id?: string,
252 ): BindingRecord {
253 if (!anchor) {
254 anchor = { kind: "center" };
255 }
256 return { id: id ?? createId("binding"), type: "arrow-end", fromShapeId, toShapeId, handle, anchor };
257 },
258
259 /**
260 * Clone a binding record
261 */
262 clone(binding: BindingRecord): BindingRecord {
263 return { ...binding, anchor: binding.anchor.kind === "edge" ? { ...binding.anchor } : { kind: "center" } };
264 },
265};
266
267export type Document = {
268 pages: Record<string, PageRecord>;
269 shapes: Record<string, ShapeRecord>;
270 bindings: Record<string, BindingRecord>;
271};
272
273export const Document = {
274 /**
275 * Create an empty document
276 */
277 create(): Document {
278 return { pages: {}, shapes: {}, bindings: {} };
279 },
280
281 /**
282 * Clone a document
283 */
284 clone(document: Document): Document {
285 return {
286 pages: Object.fromEntries(Object.entries(document.pages).map(([id, page]) => [id, PageRecord.clone(page)])),
287 shapes: Object.fromEntries(Object.entries(document.shapes).map(([id, shape]) => [id, ShapeRecord.clone(shape)])),
288 bindings: Object.fromEntries(
289 Object.entries(document.bindings).map(([id, binding]) => [id, BindingRecord.clone(binding)]),
290 ),
291 };
292 },
293};
294
295export type ValidationResult = { ok: true } | { ok: false; errors: string[] };
296
297/**
298 * Validate a document for consistency and referential integrity
299 * @param doc - The document to validate
300 * @returns ValidationResult with ok status and any errors found
301 */
302export function validateDoc(document: Document): ValidationResult {
303 const errors: string[] = [];
304
305 if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) {
306 errors.push("Document has shapes but no pages");
307 }
308
309 for (const [shapeId, shape] of Object.entries(document.shapes)) {
310 if (shape.id !== shapeId) {
311 errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`);
312 }
313
314 if (!document.pages[shape.pageId]) {
315 errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`);
316 }
317
318 const page = document.pages[shape.pageId];
319 if (page && !page.shapeIds.includes(shapeId)) {
320 errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`);
321 }
322
323 switch (shape.type) {
324 case "rect": {
325 if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`);
326 if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`);
327 if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`);
328
329 break;
330 }
331 case "ellipse": {
332 if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`);
333 if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`);
334
335 break;
336 }
337 case "line": {
338 if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`);
339
340 break;
341 }
342 case "arrow": {
343 const props = shape.props;
344
345 if (!props.points || props.points.length < 2) {
346 errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`);
347 }
348 if (!props.style) {
349 errors.push(`Arrow shape '${shapeId}' missing style`);
350 } else if (props.style.width < 0) {
351 errors.push(`Arrow shape '${shapeId}' has negative width in style`);
352 }
353 if (props.routing) {
354 if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) {
355 errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`);
356 }
357 }
358 if (props.label) {
359 if (!["center", "start", "end"].includes(props.label.align)) {
360 errors.push(`Arrow shape '${shapeId}' has invalid label alignment`);
361 }
362 }
363
364 break;
365 }
366 case "text": {
367 if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`);
368 if (shape.props.w !== undefined && shape.props.w < 0) {
369 errors.push(`Text shape '${shapeId}' has negative width`);
370 }
371
372 break;
373 }
374 case "stroke": {
375 if (shape.props.points.length < 2) {
376 errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`);
377 }
378 if (shape.props.brush.size <= 0) {
379 errors.push(`Stroke shape '${shapeId}' has invalid brush size`);
380 }
381 if (shape.props.style.opacity < 0 || shape.props.style.opacity > 1) {
382 errors.push(`Stroke shape '${shapeId}' has invalid opacity`);
383 }
384
385 break;
386 }
387 case "markdown": {
388 if (shape.props.fontSize <= 0) {
389 errors.push(`Markdown shape '${shapeId}' has invalid fontSize`);
390 }
391 if (shape.props.w <= 0) {
392 errors.push(`Markdown shape '${shapeId}' has invalid width`);
393 }
394 if (shape.props.h !== undefined && shape.props.h <= 0) {
395 errors.push(`Markdown shape '${shapeId}' has invalid height`);
396 }
397
398 break;
399 }
400 }
401 }
402
403 for (const [pageId, page] of Object.entries(document.pages)) {
404 if (page.id !== pageId) {
405 errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`);
406 }
407
408 for (const shapeId of page.shapeIds) {
409 if (!document.shapes[shapeId]) {
410 errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`);
411 }
412 }
413
414 const uniqueIds = new Set(page.shapeIds);
415 if (uniqueIds.size !== page.shapeIds.length) {
416 errors.push(`Page '${pageId}' has duplicate shape IDs`);
417 }
418 }
419
420 for (const [bindingId, binding] of Object.entries(document.bindings)) {
421 if (binding.id !== bindingId) {
422 errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`);
423 }
424
425 const fromShape = document.shapes[binding.fromShapeId];
426 if (!fromShape) {
427 errors.push(`Binding '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`);
428 } else if (fromShape.type !== "arrow") {
429 errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`);
430 }
431
432 if (!document.shapes[binding.toShapeId]) {
433 errors.push(`Binding '${bindingId}' references non-existent toShape '${binding.toShapeId}'`);
434 }
435
436 if (binding.handle !== "start" && binding.handle !== "end") {
437 errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`);
438 }
439
440 if (binding.anchor.kind === "edge") {
441 if (binding.anchor.nx < -1 || binding.anchor.nx > 1) {
442 errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`);
443 }
444 if (binding.anchor.ny < -1 || binding.anchor.ny > 1) {
445 errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`);
446 }
447 }
448 }
449
450 if (errors.length > 0) {
451 return { ok: false, errors };
452 }
453
454 return { ok: true };
455}