import {rectangleGeometry,bezierChainSegments,type BezierChain} from './geometry.ts'; import type { CapabilityHandler, ComponentDefinition, Data, FieldChange, Geometry, Json } from './types.ts'; import { defineComponent, defineFeature } from './registry.ts'; import { presentationUnitsValid, unitScale } from './units.ts'; import { equal, fault, validJson } from './values.ts'; const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geometry: Geometry) => Geometry): CapabilityHandler => ({ element, input }) => { if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); const patch = input.patch as Partial; if (!patch || Object.values(patch).some(value => typeof value !== 'number' || !Number.isFinite(value)) || Object.keys(patch).some(key => !allowed.includes(key as keyof Geometry))) throw fault('invalid_geometry_patch', [element.id]); const after = constrain ? constrain({ ...element.geometry, ...patch }) : { ...element.geometry, ...patch }; return equal(element.geometry, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'geometry', before: element.geometry, after }]; }; export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); export const resizable = (inputOptions: { min?: number; max?: number; aspect?: boolean } = {}) => { const options = Object.freeze({ ...inputOptions }); if (options.min !== undefined && (!Number.isFinite(options.min) || options.min <= 0) || options.max !== undefined && (!Number.isFinite(options.max) || options.max < (options.min ?? 1))) throw fault('invalid_resize_constraint'); return defineFeature('geometry.resize', { 'geometry.resize': ({ element, input }) => { if (element.structure !== 'node') throw fault('unsupported_capability', [element.id]); const patch = input.patch as Partial; return geometryHandler(['width', 'height'], geometry => { const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units; let width = Math.min(max, Math.max(min, geometry.width)), height = Math.min(max, Math.max(min, geometry.height)); if (options.aspect) { const ratio = element.geometry.width / element.geometry.height; if (patch.width !== undefined) height = width / ratio; else width = height * ratio; if (width < min || height < min || width > max || height > max) throw fault('aspect_constraint', [element.id]); } return { ...geometry, width, height }; })({ element, input }); }, }); }; export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) }); export const editable = () => defineFeature('content.edit', { 'content.edit': ({ element, input }) => { const patch = input.patch as Data; if (!patch || !validJson(patch) || Array.isArray(patch)) throw fault('invalid_data_patch', [element.id]); return Object.entries(patch).flatMap(([key, after]): FieldChange[] => { const before = element.data[key] ?? null; return Object.hasOwn(element.data, key) && equal(before, after) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: `data.${key}`, before, after, beforePresent: Object.hasOwn(element.data, key), afterPresent: true }]; }); }, }); const textData = (data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'; export function builtinComponents(): readonly ComponentDefinition[] { return ['text', 'image', 'script', 'plain-text', 'plain-image', 'icon', 'frame'].map(id => defineComponent({ id, dataVersion: 1, structure: 'node', validate: textData, features: [movable(), resizable(), ...(id === 'frame' ? [] : [rotatable()]), editable()], resourceFields: ['image', 'plain-image'].includes(id) ? ['assetId','asset'] : [], geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: data => validJson(data), features: [editable(), connectionEditable()], resourceFields: [],geometry:{kind:'connection',version:1,strokeWidth:({element})=>{const appearance=element.data.appearance;return (appearance&&typeof appearance==='object'&&!Array.isArray(appearance)?appearance.width:undefined) as number||1.5;},segments:context=>{if(context.displayPath===undefined||!connectionPathValid(context.displayPath))throw fault('unsupported_connection_geometry');return bezierChainSegments(context.source,context.target,context.displayPath as unknown as BezierChain|null);}}, copy: { references: [], omit: [] } })]); } export function connectionPathValid(path: Json): boolean { if (path === null) return true; if (typeof path !== 'object' || Array.isArray(path) || path.type !== 'bezier-chain' || !Array.isArray(path.knots) || path.knots.length > 64) return false; const point = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.x === 'number' && typeof value.y === 'number' && Number.isFinite(value.x) && Number.isFinite(value.y); if (path.startControl !== undefined && !point(path.startControl) || path.endControl !== undefined && !point(path.endControl)) return false; const ids = new Set(); for (const knot of path.knots) { if (!knot || typeof knot !== 'object' || Array.isArray(knot) || typeof knot.id !== 'string' || !knot.id || ids.has(knot.id) || !['corner', 'smooth', 'symmetric'].includes(knot.mode as string) || !point(knot.position) || !point(knot.in) || !point(knot.out)) return false; ids.add(knot.id); } return true; } export const connectionEditable = () => defineFeature('connection.path', { 'connection.edit': ({ element, input }) => { const path = (input.patch as { path?: Json }).path; if (element.structure !== 'connection' || !connectionPathValid(element.path)) throw fault('unsupported_connection_path', [element.id]); if (path === undefined || !validJson(path) || !connectionPathValid(path)) throw fault('invalid_connection_path', [element.id]); return equal(element.path, path) ? [] : [{ kind: 'field', target: { id: element.id, incarnation: element.incarnation }, field: 'path', before: element.path, after: path }]; } });