110 lines
10 KiB
TypeScript
110 lines
10 KiB
TypeScript
/**
|
||
* 内置 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<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]);
|
||
// 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<Geometry>;
|
||
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<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;
|
||
}
|
||
/** 连线编辑 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 }];
|
||
} });
|