web based infinite canvas
at main 455 lines 14 kB view raw
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}