/** * 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。 * 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 handler 来写文档。 * capability 表示安装了哪类行为,schema 与各 handler 的实际检查仍须分别阅读。 */ import {builtinDataValidator} from './element-data.ts'; 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'; /** 创建限制字段白名单的几何 handler;可选 constrain 负责约束尺寸,最终几何由文档校验。 * 相同结果返回空数组,避免无变化拖动制造历史或网络操作。 */ 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]); // constrain 在字段形状检查后执行;此处不归一 rotation 或单独证明最终宽高为正,交给文档几何校验。 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 }]; }; /** 移动仅允许 x/y,不能顺带修改大小、旋转或 owner。 */ export const movable = () => defineFeature('geometry.move', { 'geometry.move': geometryHandler(['x', 'y']) }); /** 浅捕获 min/max/aspect 选项,检查显式 min/max,再构造只修改 width/height 的缩放行为。 * 缺省 min=1、max=100000;aspect 默认不启用,内置图片也复用这份默认缩放 feature。 */ 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', { // 缩放 handler:在元素自身的表现单位下应用视觉约束,而不是固定 owner 空间像素值。 '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 => { // owner 变换后 unitScale 也会改变,因此 min/max 要乘它才能维持同一视觉尺寸约束。 const units=unitScale(element.data.presentation),min=(options.min??1)*units,max=(options.max??100000)*units; // 先逐维 clamp,再按原 width/height 比例重算另一维;同时给宽高且 aspect=true 时以宽度为主。 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 }); }, }); }; /** 旋转 handler 只接受 rotation patch,但输出的是整份 geometry 字段变化。 * 不把 360/-1 自动归一到 [0,360),最终几何校验负责拒绝越界结果。 */ export const rotatable = () => defineFeature('geometry.rotate', { 'geometry.rotate': geometryHandler(['rotation']) }); /** 内容编辑按顶层 data 键生成独立条件变化,保存存在性,区分缺失字段与 null。 */ 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]); // 签名要求 Data 对象;上面的运行时 guard 检查 JSON 值域并排除数组,不是任意未知输入的完整对象 schema。 // patch 中出现的键都表示写入,null 是显式值;此 handler 没有以 undefined 表示删除的约定。 // 每个字段携带 before,ACK/重放时才能检查冲突,而不是用整份节点覆盖后来的编辑。 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 }]; }); }, }); /** 内置节点共用的基础 data 合同:title/text 为字符串,检查已识别 unitScale,允许其它合法 JSON 扩展。 * 图片 URL/资源身份、图标名或任务字段并不由此函数逐项验证;它也不代表所有表现字段都有专门 schema。 */ const textData = builtinDataValidator((data: Data) => presentationUnitsValid(data.presentation) && validJson(data) && typeof data.title === 'string' && typeof data.text === 'string'); /** 安装文本、图片、脚本、图标、frame 和 connection 的默认定义。 * 节点共用基础行为;frame 不安装旋转行为。复制声明与资源字段也是定义的一部分。 */ 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'] : [], // frame ports[].nodeId 是显式 element 引用,复制范围外引用按 reject;局部 port id/handle 不是这里的映射目标。 // 只 omit 下列四个顶层任务状态字段,不递归猜测任意插件任务结构;资源字段按声明另供 runtime 扫描。 geometry:rectangleGeometry,copy: { references: id === 'frame' ? [{path:['ports','*','nodeId'],target:'element',external:'reject'}] : [], omit: [['taskId'], ['runId'], ['progress'], ['runStatus']] }, // 后续 connection 使用单独定义:strokeWidth 取 appearance.width 的真值或 1.5,并不在这里验证其类型/正数范围。 // segments 要求 displayPath 已提供且可解释,再调用共享 Bezier 生成器;输出有效性由几何查询边界继续检查。 })).concat([defineComponent({ id: 'connection', dataVersion: 1, structure: 'connection', validate: builtinDataValidator((data: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: [] } })]); } /** null 表示默认连线;否则仅支持最多 64 个 knot 的 bezier-chain,并校验控制点/唯一 ID。 * 这里只检查所需路径字段,不拒绝所有扩展键;corner/smooth/symmetric 是模式标签,不在此验证控制柄相切或镜像关系。 */ 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; // 控制点只能是有限 x/y;缺少坐标、数组或非对象值均不接受。 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; // 每个 knot 的 ID 非空且本路径内唯一;位置和 in/out 都必须是有限点,空 knots 数组也合法。 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; } /** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 * feature ID 为 connection.path,但注册 capability 键是 connection.edit;editor 按后者查 handler。 */ 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 }]; } });