74 lines
6.4 KiB
TypeScript
74 lines
6.4 KiB
TypeScript
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<Geometry>;
|
|
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<Geometry>;
|
|
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<string>();
|
|
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 }];
|
|
} });
|