Something went wrong. Try again.
inkfinite (an infinite canvas/whiteboard) monorepo ink.stormlightlabs.org
svelte rust tauri svg canvas perfect-freehand
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127/** * Interactive editor state owned by `@inkfinite/core`. * * These records deliberately use editor-facing names and coordinate fields. They * are not the Rust-owned records from `@inkfinite/bindings/model`; conversion * between the two representations lives in `persistence/canonical.ts`. */
import type { EditorTransform as GeneratedEditorTransform, PathCurveKind, PathTopologyOperation} from '@inkfinite/bindings/editor';import type { BuiltinShapeKind } from '@inkfinite/bindings';import type { FilterEffect as NativeFilterEffect, FilterPrimitive as NativeFilterPrimitive, MaskEffect as NativeMaskEffect, MaskMode as NativeMaskMode, PaintValue as NativePaintValue, PathFillRule as NativePathFillRule, PathGeometry as NativePathGeometry, PathHandleMode as NativePathHandleMode, PathSegment as NativePathSegment, PathSubpath as NativePathSubpath, ResolvedArrowGeometry as NativeResolvedArrowGeometry} from '@inkfinite/bindings/model';import { v4 } from 'uuid';import type { Vec2 } from './math';
/** * Generate a unique ID with an optional prefix * @param prefix - Optional prefix for the ID (e.g., 'shape', 'page', 'binding') * @returns A unique ID string (UUID v4 format with prefix) */export function createId(prefix?: string): string { const id = v4(); return prefix ? `${prefix}:${id}` : id;}
export type EditorPageRecord = { id: string; name: string; /** Flat draw-order projection. Layer order and each layer's shape order are authoritative. */ shapeIds: string[]; /** Layer IDs in back-to-front order. */ layerIds?: string[];};
export const EditorPageRecord = { /** * Create a new page record */ create(name: string, id?: string): EditorPageRecord { return { id: id ?? createId('page'), name, shapeIds: [], layerIds: [] }; },
/** * Clone a page record */ clone(page: EditorPageRecord): EditorPageRecord { return { id: page.id, name: page.name, shapeIds: [...page.shapeIds], ...(page.layerIds ? { layerIds: [...page.layerIds] } : {}) }; }};
/** Ordered visual layer owned by one page. */export type EditorLayerRecord = { id: string; pageId: string; name: string; shapeIds: string[]; visible: boolean; locked: boolean; opacity: number;};
export const EditorLayerRecord = { /** Creates an empty, visible layer. */ create(pageId: string, name = 'Layer', id?: string): EditorLayerRecord { return { id: id ?? createId('layer'), pageId, name, shapeIds: [], visible: true, locked: false, opacity: 1 }; },
/** Clones a layer without sharing its child-order array. */ clone(layer: EditorLayerRecord): EditorLayerRecord { return { ...layer, shapeIds: [...layer.shapeIds] }; }};
export type PaintValue = NativePaintValue;export type RectProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue; radius: number } & ShapeEffects;export type EllipseProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue } & ShapeEffects;export type LineProps = { a: Vec2; b: Vec2; stroke: PaintValue; width: number } & ShapeEffects;
/** Fill rule for compound native paths. */export type PathFillRule = NativePathFillRule;
/** A normalized native path segment. */export type PathSegment = NativePathSegment;
/** Whether cubic handles at an anchor move together. */export type PathHandleMode = NativePathHandleMode;
/** Canonical curve type used by topology operations. */export type { PathCurveKind, PathTopologyOperation };
/** One native path subpath. */export type PathSubpath = NativePathSubpath;
/** Native path geometry and its compound fill rule. */export type PathGeometry = NativePathGeometry;
/** Ephemeral reference to one path anchor, identified by its segment destination. */export type PathAnchorRef = { subpathIndex: number; segmentIndex: number };
/** Ephemeral reference to a rendered path segment and its curve parameter. */export type PathSegmentRef = PathAnchorRef & { t: number };
/** Ephemeral selection state used by the direct-selection tool. */export type PathSelection = { pathId: string; anchors: PathAnchorRef[]; widthPoints?: number[] };
/** A path control handle exposed by the direct-selection tool. */export type PathControlRef = PathAnchorRef & { control: 'quadratic' | 'control_1' | 'control_2' };
/** Topology operations staged for one path commit. */export type PathTopologyEdit = { shapeId: string; operations: PathTopologyOperation[] };
/** Native filter definition shared by canvas and SVG rendering. */export type FilterEffect = NativeFilterEffect;
/** One primitive in the editable filter subset. */export type FilterPrimitive = NativeFilterPrimitive;
/** Mask source mode. */export type MaskMode = NativeMaskMode;
/** Native path-based mask shared by canvas and SVG rendering. */export type MaskEffect = NativeMaskEffect;
/** Non-destructive effects accepted by drawable shape properties. */export type ShapeEffects = { /** Editable path used as a local clip region. */ clipPath?: PathGeometry; /** Editable alpha or luminance mask. */ maskEffect?: MaskEffect; /** Ordered subset of SVG filter primitives. */ filter?: FilterEffect;};
function cloneShapeEffects(props: ShapeEffects): ShapeEffects { return { clipPath: props.clipPath ? (JSON.parse(JSON.stringify(props.clipPath)) as PathGeometry) : undefined, maskEffect: props.maskEffect ? (JSON.parse(JSON.stringify(props.maskEffect)) as MaskEffect) : undefined, filter: props.filter ? (JSON.parse(JSON.stringify(props.filter)) as FilterEffect) : undefined };}
/** Native path painting properties stored alongside its geometry. */export type PathProps = PathGeometry & ShapeEffects & { fill?: PaintValue; stroke?: PaintValue; stroke_width?: number };
/** Rust-resolved arrow shaft geometry projected for interactive consumers. */export type ResolvedArrowGeometry = NativeResolvedArrowGeometry;
/** * Arrow endpoint binding metadata */export type ArrowEndpoint = { kind: 'free' | 'bound'; bindingId?: string };
/** * Arrow style configuration */export type ArrowStyle = { stroke: PaintValue; width: number; headStart?: boolean; headEnd?: boolean; headStartStyle?: 'open' | 'triangle'; headEndStyle?: 'open' | 'triangle'; dash?: number[];};
/** * Arrow routing configuration */export type ArrowRouting = { kind: 'straight' | 'curved' | 'orthogonal'; /** Signed local-space offset of a two-point curved arrow from its chord. */ bend?: number; /** Radius used to round interior waypoints on curved routes and orthogonal corners. */ cornerRadius?: number; /** Let the renderer choose a route around other shapes. */ automatic?: boolean;};
/** * Arrow label configuration. * * `distance` is an optional absolute distance along the resolved path. When it * is omitted, `align` chooses the start, center, or end anchor. `offset` is a * signed displacement along the path's local normal. */export type ArrowLabel = { text: string; align: 'center' | 'start' | 'end'; offset: number; distance?: number };
/** * Arrow properties using modern format * Modern format: { points, start, end, style, routing?, label? } */export type ArrowProps = ShapeEffects & { points: Vec2[]; start: ArrowEndpoint; end: ArrowEndpoint; style: ArrowStyle; routing?: ArrowRouting; label?: ArrowLabel;};
/** Attachment settings for text laid out along a native path. * * `offset` is measured in the supporting path's local coordinate system. For * centered and end-aligned text it identifies the corresponding text anchor; * `direction: 'reverse'` traverses the path from its end to its start. */export type TextPath = { pathId: string; offset: number; align: 'start' | 'center' | 'end'; side: 'left' | 'right'; direction: 'forward' | 'reverse';};
export type TextProps = ShapeEffects & { text: string; fontSize: number; fontFamily: string; color: PaintValue; w?: number; textPath?: TextPath;};
/** Shape used to clip an image while it is rendered. */export type ImageMask = { kind: 'rectangle' | 'ellipse' | 'rounded'; radius?: number };
/** Embedded image geometry and the asset it displays. */export type ImageProps = ShapeEffects & { w: number; h: number; assetId: string; /** Normalized crop inset on each edge, in the range 0..1. */ crop?: { top: number; right: number; bottom: number; left: number }; /** Optional caption rendered over the lower edge of the image. */ caption?: string; /** Optional non-destructive display mask. */ mask?: ImageMask;};
/** A native link to a web URL, file, or page in the current document. */export type ReferenceProps = ShapeEffects & { w: number; h: number; referenceType: 'url' | 'file' | 'page'; value: string; label?: string;};
/** Native frame dimensions and title used for hierarchy selection and overlays. */export type ContainerProps = ShapeEffects & { w?: number; h?: number; title?: string; fill?: PaintValue; stroke?: PaintValue; radius?: number;};
/** * Markdown block properties * - md: markdown source text * - w: fixed width (required for layout) * - h: auto-computed height from layout (optional override) * - style: font and color settings */export type MarkdownProps = ShapeEffects & { md: string; w: number; h?: number; fontSize: number; fontFamily: string; color: PaintValue; bg?: PaintValue; border?: PaintValue;};
/** * Point with optional pressure value (0-1) * Format: [x, y, pressure?] */export type StrokePoint = [number, number, number?];
/** A width override at a normalized distance along a freehand stroke. */export type StrokeWidthPoint = { offset: number; width: number };
/** * Brush configuration for stroke rendering * Maps to perfect-freehand options */export type BrushConfig = { size: number; thinning: number; smoothing: number; streamline: number; simulatePressure: boolean;};
/** * Style properties for stroke appearance */export type StrokeStyle = { color: PaintValue; opacity: number };
/** * Properties for freehand stroke shapes * Points are in world coordinates * Outline and bounds are computed lazily and not persisted */export type StrokeProps = ShapeEffects & { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig; /** Optional absolute widths in local units, interpolated by normalized path distance. */ widthProfile?: StrokeWidthPoint[];};
/** Semantic fields shared by native shapes and card containers. */export type ShapeMetadata = { name: string | null; title: string | null; role: string | null; description: string | null; body: string | null; tags: string[]; source: string | null; link: string | null; customMetadata: Record<string, unknown>; locked: boolean; agentEditable: boolean; provenance?: { actorId: string; origin: 'human' | 'agent' | 'sync' | 'system'; timestamp: number; source: string | null; };};
/** Built-in shape kinds accepted by the interactive editor model. */export type ShapeType = BuiltinShapeKind;
/** Full projected transform shared with the Rust editor projection. */export type EditorTransform = GeneratedEditorTransform;
export type BaseShape = { id: string; type: ShapeType; pageId: string; x: number; y: number; rot: number; /** Full projected transform used to preserve native ancestor composition. */ editorTransform?: EditorTransform; /** Opacity applied to the complete shape; omitted values use `1`. */ opacity?: number; /** Opacity applied only to fills; omitted values use `1`. */ fillOpacity?: number; /** Opacity applied only to strokes; omitted values use `1`. */ strokeOpacity?: number; groupId?: string; /** Owning layer assigned when the shape enters an editor document. */ layerId?: string; /** Whether this shape is excluded from selection and editing. */ locked?: boolean; /** Whether an agent may propose or apply edits to this shape; omitted values allow edits. */ agentEditable?: boolean; /** Semantic fields projected from the native record. */ metadata?: ShapeMetadata;};export type RectShape = BaseShape & { type: 'rect'; props: RectProps };export type EllipseShape = BaseShape & { type: 'ellipse'; props: EllipseProps };export type LineShape = BaseShape & { type: 'line'; props: LineProps };export type ArrowShape = BaseShape & { type: 'arrow'; props: ArrowProps; /** Latest Rust-resolved geometry, when the shape came from a canonical projection. */ resolvedGeometry?: ResolvedArrowGeometry;};export type TextShape = BaseShape & { type: 'text'; props: TextProps };export type ImageShape = BaseShape & { type: 'image'; props: ImageProps };export type ReferenceShape = BaseShape & { type: 'reference'; props: ReferenceProps };export type StrokeShape = BaseShape & { type: 'stroke'; props: StrokeProps };export type PathShape = BaseShape & { type: 'path'; props: PathProps };export type MarkdownShape = BaseShape & { type: 'markdown'; props: MarkdownProps };export type ContainerShape = BaseShape & { type: 'container'; props: ContainerProps };
export type EditorShapeRecord = | RectShape | EllipseShape | LineShape | ArrowShape | TextShape | ImageShape | ReferenceShape | StrokeShape | PathShape | MarkdownShape | ContainerShape;
export const EditorShapeRecord = { /** * Create a rectangle shape */ createRect(pageId: string, x: number, y: number, properties: RectProps, id?: string): RectShape { return { id: id ?? createId('shape'), type: 'rect', pageId, x, y, rot: 0, props: properties }; },
/** * Create an ellipse shape */ createEllipse(pageId: string, x: number, y: number, properties: EllipseProps, id?: string): EllipseShape { return { id: id ?? createId('shape'), type: 'ellipse', pageId, x, y, rot: 0, props: properties }; },
/** * Create a line shape */ createLine(pageId: string, x: number, y: number, properties: LineProps, id?: string): LineShape { return { id: id ?? createId('shape'), type: 'line', pageId, x, y, rot: 0, props: properties }; },
/** * Create an arrow shape */ createArrow(pageId: string, x: number, y: number, properties: ArrowProps, id?: string): ArrowShape { return { id: id ?? createId('shape'), type: 'arrow', pageId, x, y, rot: 0, props: properties }; },
/** * Create a text shape */ createText(pageId: string, x: number, y: number, properties: TextProps, id?: string): TextShape { return { id: id ?? createId('shape'), type: 'text', pageId, x, y, rot: 0, props: properties }; },
/** Create an image backed by an embedded document asset. */ createImage(pageId: string, x: number, y: number, properties: ImageProps, id?: string): ImageShape { return { id: id ?? createId('shape'), type: 'image', pageId, x, y, rot: 0, props: properties }; },
/** Create a URL, file, or page reference card. */ createReference(pageId: string, x: number, y: number, properties: ReferenceProps, id?: string): ReferenceShape { return { id: id ?? createId('shape'), type: 'reference', pageId, x, y, rot: 0, props: properties }; },
/** * Create a stroke shape */ createStroke(pageId: string, x: number, y: number, properties: StrokeProps, id?: string): StrokeShape { return { id: id ?? createId('shape'), type: 'stroke', pageId, x, y, rot: 0, props: properties }; },
/** Create a native path shape. */ createPath(pageId: string, x: number, y: number, properties: PathProps, id?: string): PathShape { return { id: id ?? createId('shape'), type: 'path', pageId, x, y, rot: 0, props: properties }; },
/** * Create a markdown block shape */ createMarkdown(pageId: string, x: number, y: number, properties: MarkdownProps, id?: string): MarkdownShape { return { id: id ?? createId('shape'), type: 'markdown', pageId, x, y, rot: 0, props: properties }; },
/** Create a selectable native container. */ createContainer(pageId: string, x: number, y: number, properties: ContainerProps, id?: string): ContainerShape { return { id: id ?? createId('shape'), type: 'container', pageId, x, y, rot: 0, props: properties }; },
/** * Clone a shape record */ clone(shape: EditorShapeRecord): EditorShapeRecord { const metadata = shape.metadata ? { ...shape.metadata, tags: [...shape.metadata.tags], customMetadata: JSON.parse(JSON.stringify(shape.metadata.customMetadata)) as Record< string, unknown >, ...(shape.metadata.provenance ? { provenance: { ...shape.metadata.provenance } } : {}) } : undefined; if (shape.type === 'stroke') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props), points: shape.props.points.map((p) => [...p] as StrokePoint), style: { ...shape.props.style }, brush: { ...shape.props.brush }, widthProfile: shape.props.widthProfile?.map((point) => ({ ...point })) } }; } if (shape.type === 'arrow') { if (!Array.isArray(shape.props.points)) { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props) } } as ArrowShape; } return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props), points: shape.props.points.map((p) => ({ ...p })), start: { ...shape.props.start }, end: { ...shape.props.end }, style: { ...shape.props.style, dash: shape.props.style.dash ? [...shape.props.style.dash] : undefined }, routing: shape.props.routing ? { ...shape.props.routing } : undefined, label: shape.props.label ? { ...shape.props.label } : undefined } }; } if (shape.type === 'text') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props), textPath: shape.props.textPath ? { ...shape.props.textPath } : undefined } }; } if (shape.type === 'markdown') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props) } }; } if (shape.type === 'image') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props), crop: shape.props.crop ? { ...shape.props.crop } : undefined, mask: shape.props.mask ? { ...shape.props.mask } : undefined } }; } if (shape.type === 'reference') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props) } }; } if (shape.type === 'path') { return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props), subpaths: shape.props.subpaths.map((subpath) => ({ ...subpath, handle_modes: subpath.handle_modes ? [...subpath.handle_modes] : subpath.handle_modes, segments: subpath.segments.map((segment) => ({ ...segment, to: { ...segment.to }, ...('control' in segment ? { control: { ...segment.control } } : {}), ...('control_1' in segment ? { control_1: { ...segment.control_1 }, control_2: { ...segment.control_2 } } : {}) })) })) } } as PathShape; } return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props, ...cloneShapeEffects(shape.props) } } as EditorShapeRecord; }};
export type BindingType = 'arrow-end' | 'relation';export type BindingHandle = 'start' | 'end';
/** * Binding anchor configuration * - center: bind to shape center * - edge: bind to shape edge with normalized coordinates (nx, ny in [-1, 1]) */export type BindingAnchor = { kind: 'center' } | { kind: 'edge'; nx: number; ny: number };
export type EditorBindingRecord = { id: string; type: BindingType; fromShapeId: string; toShapeId: string; handle: BindingHandle; anchor: BindingAnchor; /** Optional semantic relationship type, such as `depends_on`. */ relationType?: string;};
export const EditorBindingRecord = { /** * Create a binding record for arrow endpoints */ create( fromShapeId: string, toShapeId: string, handle: BindingHandle, anchor?: BindingAnchor, id?: string, relationType?: string ): EditorBindingRecord { if (!anchor) { anchor = { kind: 'center' }; } return { id: id ?? createId('binding'), type: 'arrow-end', fromShapeId, toShapeId, handle, anchor, ...(relationType === undefined ? {} : { relationType }) }; },
/** Creates a typed relationship that does not participate in arrow routing. */ createRelation(fromShapeId: string, toShapeId: string, relationType: string, id?: string): EditorBindingRecord { return { id: id ?? createId('binding'), type: 'relation', fromShapeId, toShapeId, handle: 'end', anchor: { kind: 'center' }, relationType }; },
/** * Clone a binding record */ clone(binding: EditorBindingRecord): EditorBindingRecord { return { ...binding, anchor: binding.anchor.kind === 'edge' ? { ...binding.anchor } : { kind: 'center' } }; }};
/** A retained binary asset imported from an external document. */export type ImportedAsset = { id: string; name: string; mediaType: string; digest: string; bytes: number[] };
/** * Mutable document state consumed by editor tools and renderers. * * This is an interactive projection, not the serialized Rust document. Use the * canonical persistence adapter when crossing into generated native contracts. */export type EditorDocument = { pages: Record<string, EditorPageRecord>; /** Layers indexed by stable ID. */ layers?: Record<string, EditorLayerRecord>; /** Binary assets retained by interchange imports. */ assets?: Record<string, ImportedAsset>; shapes: Record<string, EditorShapeRecord>; bindings: Record<string, EditorBindingRecord>;};
export const EditorDocument = { /** * Create an empty document */ create(): EditorDocument { return { pages: {}, layers: {}, shapes: {}, bindings: {} }; },
/** * Clone a document */ clone(document: EditorDocument): EditorDocument { return { pages: Object.fromEntries( Object.entries(document.pages).map(([id, page]) => [id, EditorPageRecord.clone(page)]) ), ...(document.layers ? { layers: Object.fromEntries( Object.entries(document.layers).map(([id, layer]) => [id, EditorLayerRecord.clone(layer)]) ) } : {}), ...(document.assets ? { assets: Object.fromEntries( Object.entries(document.assets).map(([id, asset]) => [ id, { ...asset, bytes: [...asset.bytes] } ]) ) } : {}), shapes: Object.fromEntries( Object.entries(document.shapes).map(([id, shape]) => [id, EditorShapeRecord.clone(shape)]) ), bindings: Object.fromEntries( Object.entries(document.bindings).map(([id, binding]) => [id, EditorBindingRecord.clone(binding)]) ) }; }};
/** * Ensures every page has a valid layer structure for the editor. * * Normalization is deterministic and idempotent. Existing flat shape order is * preserved exactly in a stable default layer, while layered documents retain * their layer and child order. */export function ensureDocumentLayers(document: EditorDocument): EditorDocument { const pages = Object.fromEntries( Object.entries(document.pages).map(([id, page]) => [id, EditorPageRecord.clone(page)]) ); const layers = Object.fromEntries( Object.entries(document.layers ?? {}).map(([id, layer]) => [id, EditorLayerRecord.clone(layer)]) ); const shapes = Object.fromEntries( Object.entries(document.shapes).map(([id, shape]) => [id, EditorShapeRecord.clone(shape)]) );
for (const page of Object.values(pages)) { const ownedLayerIds = (page.layerIds ?? []).filter((id) => layers[id]?.pageId === page.id); const layerIds = ownedLayerIds.length > 0 ? ownedLayerIds : [`layer:${page.id}:default`]; if (ownedLayerIds.length === 0) { const id = layerIds[0]; layers[id] = { id, pageId: page.id, name: 'Default', shapeIds: [...page.shapeIds], visible: true, locked: false, opacity: 1 }; }
const fallbackId = layerIds[0]; const ordered = page.shapeIds.filter((id) => shapes[id]?.pageId === page.id); const seen = new Set<string>(); for (const layerId of layerIds) { const layer = layers[layerId]; layer.opacity = Math.max(0, Math.min(1, Number.isFinite(layer.opacity) ? layer.opacity : 1)); layer.shapeIds = layer.shapeIds.filter((shapeId) => { const shape = shapes[shapeId]; if (!shape || shape.pageId !== page.id || seen.has(shapeId)) return false; seen.add(shapeId); shape.layerId = layerId; return true; }); } for (const shapeId of ordered) { if (seen.has(shapeId)) continue; const requestedLayerId = shapes[shapeId].layerId; const destinationId = requestedLayerId && layerIds.includes(requestedLayerId) ? requestedLayerId : fallbackId; layers[destinationId].shapeIds.push(shapeId); shapes[shapeId].layerId = destinationId; seen.add(shapeId); } for (const shape of Object.values(shapes)) { if (shape.pageId !== page.id || seen.has(shape.id)) continue; layers[fallbackId].shapeIds.push(shape.id); shape.layerId = fallbackId; seen.add(shape.id); } page.layerIds = layerIds; page.shapeIds = layerIds.flatMap((id) => layers[id].shapeIds); }
return { ...document, pages, layers, shapes };}
export type ValidationResult = { ok: true } | { ok: false; errors: string[] };
/** * Validate a document for consistency and referential integrity * @param doc - The document to validate * @returns ValidationResult with ok status and any errors found */export function validateDoc(document: EditorDocument): ValidationResult { const errors: string[] = [];
if (Object.keys(document.pages).length === 0 && Object.keys(document.shapes).length > 0) { errors.push('Document has shapes but no pages'); }
for (const [shapeId, shape] of Object.entries(document.shapes)) { if (shape.id !== shapeId) { errors.push(`Shape key '${shapeId}' does not match shape.id '${shape.id}'`); }
if (!document.pages[shape.pageId]) { errors.push(`Shape '${shapeId}' references non-existent page '${shape.pageId}'`); }
const page = document.pages[shape.pageId]; if (page && !page.shapeIds.includes(shapeId)) { errors.push(`Shape '${shapeId}' not listed in page '${shape.pageId}' shapeIds`); }
for (const [name, value] of [ ['opacity', shape.opacity], ['fill opacity', shape.fillOpacity], ['stroke opacity', shape.strokeOpacity] ] as const) { if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 1)) { errors.push(`Shape '${shapeId}' has invalid ${name}`); } }
switch (shape.type) { case 'rect': { if (shape.props.w < 0) errors.push(`Rect shape '${shapeId}' has negative width`); if (shape.props.h < 0) errors.push(`Rect shape '${shapeId}' has negative height`); if (shape.props.radius < 0) errors.push(`Rect shape '${shapeId}' has negative radius`);
break; } case 'ellipse': { if (shape.props.w < 0) errors.push(`Ellipse shape '${shapeId}' has negative width`); if (shape.props.h < 0) errors.push(`Ellipse shape '${shapeId}' has negative height`);
break; } case 'line': { if (shape.props.width < 0) errors.push(`Line shape '${shapeId}' has negative width`);
break; } case 'arrow': { const props = shape.props;
if (!props.points || props.points.length < 2) { errors.push(`Arrow shape '${shapeId}' points array must have at least 2 points`); } if (!props.style) { errors.push(`Arrow shape '${shapeId}' missing style`); } else if (props.style.width < 0) { errors.push(`Arrow shape '${shapeId}' has negative width in style`); } else { for (const [name, value] of [ ['start head style', props.style.headStartStyle], ['end head style', props.style.headEndStyle] ] as const) { if (value !== undefined && value !== 'open' && value !== 'triangle') { errors.push(`Arrow shape '${shapeId}' has invalid ${name}`); } } } if (props.routing) { if (props.routing.bend !== undefined && !Number.isFinite(props.routing.bend)) { errors.push(`Arrow shape '${shapeId}' has non-finite bend`); } if (props.routing.cornerRadius !== undefined && !Number.isFinite(props.routing.cornerRadius)) { errors.push(`Arrow shape '${shapeId}' has invalid cornerRadius`); } else if (props.routing.cornerRadius !== undefined && props.routing.cornerRadius < 0) { errors.push(`Arrow shape '${shapeId}' has negative cornerRadius`); } } if (props.label) { if (!['center', 'start', 'end'].includes(props.label.align)) { errors.push(`Arrow shape '${shapeId}' has invalid label alignment`); } if (!Number.isFinite(props.label.offset)) { errors.push(`Arrow shape '${shapeId}' has non-finite label offset`); } if (props.label.distance !== undefined && !Number.isFinite(props.label.distance)) { errors.push(`Arrow shape '${shapeId}' has non-finite label distance`); } }
break; } case 'text': { if (shape.props.fontSize <= 0) errors.push(`Text shape '${shapeId}' has invalid fontSize`); if (shape.props.w !== undefined && shape.props.w < 0) { errors.push(`Text shape '${shapeId}' has negative width`); } if (shape.props.textPath) { const attachment = shape.props.textPath; const path = document.shapes[attachment.pathId]; if (!path || path.type !== 'path') { errors.push( `Text shape '${shapeId}' references a missing supporting path '${attachment.pathId}'` ); } if (!Number.isFinite(attachment.offset)) { errors.push(`Text shape '${shapeId}' has a non-finite path offset`); } if (!['start', 'center', 'end'].includes(attachment.align)) { errors.push(`Text shape '${shapeId}' has invalid path alignment`); } if (!['left', 'right'].includes(attachment.side)) { errors.push(`Text shape '${shapeId}' has invalid path side`); } if (!['forward', 'reverse'].includes(attachment.direction)) { errors.push(`Text shape '${shapeId}' has invalid path direction`); } }
break; } case 'stroke': { if (shape.props.points.length < 2) { errors.push(`Stroke shape '${shapeId}' has fewer than 2 points`); } if (shape.props.brush.size <= 0) { errors.push(`Stroke shape '${shapeId}' has invalid brush size`); } if ( !Number.isFinite(shape.props.style.opacity) || shape.props.style.opacity < 0 || shape.props.style.opacity > 1 ) { errors.push(`Stroke shape '${shapeId}' has invalid opacity`); } if (shape.props.widthProfile) { let previousOffset = -1; for (const point of shape.props.widthProfile) { if (!Number.isFinite(point.offset) || point.offset < 0 || point.offset > 1) { errors.push(`Stroke shape '${shapeId}' has an invalid width offset`); } if (!Number.isFinite(point.width) || point.width <= 0) { errors.push(`Stroke shape '${shapeId}' has an invalid width`); } if (point.offset <= previousOffset) { errors.push(`Stroke shape '${shapeId}' width offsets must be increasing`); } previousOffset = point.offset; } }
break; } case 'path': { if (shape.props.subpaths.length === 0) { errors.push(`Path shape '${shapeId}' has no subpaths`); } if (shape.props.fill_rule !== 'nonzero' && shape.props.fill_rule !== 'evenodd') { errors.push(`Path shape '${shapeId}' has an invalid fill rule`); } for (const [subpathIndex, subpath] of shape.props.subpaths.entries()) { if (subpath.segments.length === 0 || subpath.segments[0]?.type !== 'move') { errors.push(`Path shape '${shapeId}' subpath ${subpathIndex} must begin with a move`); } if (subpath.segments.slice(1).some((segment) => segment.type === 'move')) { errors.push(`Path shape '${shapeId}' subpath ${subpathIndex} has a later move`); } } if (shape.props.stroke_width !== undefined && shape.props.stroke_width < 0) { errors.push(`Path shape '${shapeId}' has negative stroke width`); } break; } case 'image': { if (!document.assets?.[shape.props.assetId]) { errors.push(`Image shape '${shapeId}' references missing asset '${shape.props.assetId}'`); } if (shape.props.w < 0 || shape.props.h < 0) { errors.push(`Image shape '${shapeId}' has negative dimensions`); } if (shape.props.crop) { for (const [edge, value] of Object.entries(shape.props.crop)) { if (!Number.isFinite(value) || value < 0 || value > 1) { errors.push(`Image shape '${shapeId}' has invalid ${edge} crop`); } } if ( shape.props.crop.left + shape.props.crop.right >= 1 || shape.props.crop.top + shape.props.crop.bottom >= 1 ) { errors.push(`Image shape '${shapeId}' crop leaves no source area`); } } if (shape.props.mask) { if (!['rectangle', 'ellipse', 'rounded'].includes(shape.props.mask.kind)) { errors.push(`Image shape '${shapeId}' has an invalid mask`); } if (shape.props.mask.kind !== 'rounded' && shape.props.mask.radius !== undefined) { errors.push(`Image shape '${shapeId}' has a radius on a non-rounded mask`); } if ( shape.props.mask.radius !== undefined && (shape.props.mask.radius < 0 || shape.props.mask.radius > Math.min(shape.props.w, shape.props.h) / 2) ) { errors.push(`Image shape '${shapeId}' has an invalid mask radius`); } } break; } case 'reference': { if (shape.props.w < 0 || shape.props.h < 0 || !shape.props.value.trim()) { errors.push(`Reference shape '${shapeId}' has invalid dimensions or value`); } if (!['url', 'file', 'page'].includes(shape.props.referenceType)) { errors.push(`Reference shape '${shapeId}' has an invalid type`); } if (shape.props.referenceType === 'url' && !/^https?:\/\//i.test(shape.props.value)) { errors.push(`Reference shape '${shapeId}' URL must use http:// or https://`); } if (shape.props.referenceType === 'page' && !document.pages[shape.props.value]) { errors.push(`Reference shape '${shapeId}' points to missing page '${shape.props.value}'`); } break; } case 'markdown': { if (shape.props.fontSize <= 0) { errors.push(`Markdown shape '${shapeId}' has invalid fontSize`); } if (shape.props.w <= 0) { errors.push(`Markdown shape '${shapeId}' has invalid width`); } if (shape.props.h !== undefined && shape.props.h <= 0) { errors.push(`Markdown shape '${shapeId}' has invalid height`); }
break; } case 'container': { if (shape.props.w !== undefined && shape.props.w < 0) errors.push(`Container shape '${shapeId}' has negative width`); if (shape.props.h !== undefined && shape.props.h < 0) errors.push(`Container shape '${shapeId}' has negative height`); break; } } }
for (const [pageId, page] of Object.entries(document.pages)) { if (page.id !== pageId) { errors.push(`Page key '${pageId}' does not match page.id '${page.id}'`); }
for (const shapeId of page.shapeIds) { if (!document.shapes[shapeId]) { errors.push(`Page '${pageId}' references non-existent shape '${shapeId}'`); } }
const uniqueIds = new Set(page.shapeIds); if (uniqueIds.size !== page.shapeIds.length) { errors.push(`Page '${pageId}' has duplicate shape IDs`); } }
for (const [bindingId, binding] of Object.entries(document.bindings)) { if (binding.id !== bindingId) { errors.push(`Binding key '${bindingId}' does not match binding.id '${binding.id}'`); }
const semanticRelationship = binding.type === 'relation' || binding.relationType !== undefined; const visualBinding = binding.type !== 'relation'; const recordLabel = semanticRelationship ? 'Relationship' : 'Binding'; const fromShape = document.shapes[binding.fromShapeId]; if (!fromShape) { errors.push(`${recordLabel} '${bindingId}' references non-existent fromShape '${binding.fromShapeId}'`); } else if (visualBinding && fromShape.type !== 'arrow') { errors.push(`Binding '${bindingId}' fromShape '${binding.fromShapeId}' is not an arrow`); }
if (!document.shapes[binding.toShapeId]) { errors.push(`${recordLabel} '${bindingId}' references non-existent toShape '${binding.toShapeId}'`); }
if (binding.relationType !== undefined && binding.relationType.trim() === '') { errors.push(`Relationship '${bindingId}' has an empty relation type`); }
if (visualBinding && binding.handle !== 'start' && binding.handle !== 'end') { errors.push(`Binding '${bindingId}' has invalid handle '${binding.handle}'`); }
if (visualBinding && binding.anchor.kind === 'edge') { if (binding.anchor.nx < -1 || binding.anchor.nx > 1) { errors.push(`Binding '${bindingId}' has invalid nx '${binding.anchor.nx}' (must be in [-1, 1])`); } if (binding.anchor.ny < -1 || binding.anchor.ny > 1) { errors.push(`Binding '${bindingId}' has invalid ny '${binding.anchor.ny}' (must be in [-1, 1])`); } } }
if (errors.length > 0) { return { ok: false, errors }; }
return { ok: true };}