From afa30d4b3542507252b2f347828293ef7087c241 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 23:41:13 +0800 Subject: [PATCH] docs(canvas): explain registry contracts and editor publication flow --- src/lib/canvas-core/data-containers.ts | 7 +++ src/lib/canvas-core/editor.ts | 68 ++++++++++++++++++++++++-- src/lib/canvas-core/element-data.ts | 10 ++++ src/lib/canvas-core/features.ts | 21 ++++++++ src/lib/canvas-core/index.ts | 8 +++ src/lib/canvas-core/json-data.ts | 7 +++ src/lib/canvas-core/registry.ts | 24 +++++++++ src/lib/canvas-core/types.ts | 46 +++++++++++++++++ 8 files changed, 188 insertions(+), 3 deletions(-) diff --git a/src/lib/canvas-core/data-containers.ts b/src/lib/canvas-core/data-containers.ts index fd3aa2b..1f04cbc 100644 --- a/src/lib/canvas-core/data-containers.ts +++ b/src/lib/canvas-core/data-containers.ts @@ -1,10 +1,17 @@ +/** + * 框架自建容器的来源标记:只证明容器由本模块复制并浅冻结。 + * 元素本身仍可能可变,因此该标记不能替代 JSON 捕获或深度不可变证明。 + */ // Private shape provenance: values may still be mutable and require capture. // Always copy; external Object.freeze is never accepted as ownership evidence. const owned = new WeakSet(); +/** 消费 iterable 得到新数组并浅冻结;不会冻结数组中的业务值。 */ export function dataArray(values: Iterable): readonly T[] { const result = Object.freeze(Array.from(values)); owned.add(result); return result; } +/** 浅复制对象的可枚举自有属性再冻结外壳;不是任意对象的深复制器。 */ export function dataRecord(value: T): Readonly { const result = Object.freeze({...value}); owned.add(result); return result; } +/** 查询私有 WeakSet 来源,外部 Object.freeze 不能伪造这个事实。 */ export const isDataContainer = (value: object) => owned.has(value); diff --git a/src/lib/canvas-core/editor.ts b/src/lib/canvas-core/editor.ts index 85708ec..7fbbb46 100644 --- a/src/lib/canvas-core/editor.ts +++ b/src/lib/canvas-core/editor.ts @@ -1,3 +1,8 @@ +/** + * 纯编辑器与单 Root 发布协调器:命令先在私有 draft 上规划,再统一发布 document/history/operations/runtime。 + * 不在这里执行网络或 IndexedDB;engine 通过同步准备钩子装配 ledger 等事实,调度器在发布后异步处理。 + * 修改入口共用 entered 防重入和 disposed 生命周期检查;协调提案另用 rootRevision 拒绝过期计划。 + */ import {dataArray,dataRecord} from './data-containers.ts'; import {stageCreation,materializeCreations} from './creation-draft.ts'; import type { Applied, Availability, Capability, Change, Command, Commit, CoreSnapshot, Document, ElementRecord, Geometry, HistoryEntry, HistoryState, Id, OperationIntent, Origin, Registry, Result, Snapshot } from './types.ts'; @@ -14,35 +19,48 @@ import { describeHistoryTransition } from './history-transition.ts'; import { planCreationSize, validateCreationSizeEffect } from './creation-size-plan.ts'; import type { CreationSizeEffect } from './types.ts'; +/** 宿主装配合同:registry/document 是输入,policy 决定准入,runtime 钩子参与同一次准备。 + * 准备钩子必须同步且无外部写入;任一抛错都应让候选 Root 整体被丢弃。 */ export type EditorOptions = { registry: Registry; document: Document; queryBudget?:QueryBudget; createId?: () => Id; historyLimit?: number; + /** 将加载覆盖范围绑定到查询,区分未加载与确实不存在。 */ queryCoverage?: (snapshot:Snapshot)=>QueryCoverage; + /** 每条命令规划前都检查;can 的结果不能代替真正提交时的再次检查。 */ policy?: (command: Command, document: Document) => Availability; + /** 撤销/重做独立准入钩子,runtime 可据依赖、未知请求或权限阻止栈移动。 */ traversalPolicy?: (entry: HistoryEntry, direction: 'undo' | 'redo') => Availability; onListenerError?: (error: unknown) => void; allowRawChanges?: boolean; recovery?: CoreSnapshot & { readonly rootRevision: number }; runtime?: Runtime; - /** Installed by the engine. Pure preparation: an error rejects the entire commit. */ + /** engine 安装的同步 reducer,将核心提交变成下一份 runtime;抛错拒绝整次发布。 */ reduceRuntime?: (previous: Runtime, next: CoreSnapshot, commit: Commit) => Runtime; - /** Engine may attach transport semantics without changing identity, order or document changes. */ + /** engine 可附加任务应用语义,但不得修改意图身份、序列、变化或参与者。 */ prepareIntent?: (intent: OperationIntent, runtime: Runtime) => OperationIntent; - /** Pure coordinator hook for all publication paths; may replace runtime only or reject. */ + /** 所有发布路径的最后准备钩子;只能返回 runtime 或拒绝,不能另发一次嵌套提交。 */ prepareRuntime?: (previous: Snapshot, next: Snapshot) => Runtime; + /** runtime 可给出更大的持久序列,但不得倒退或复用已发布的操作次序。 */ nextOperationSequence?: (runtime: Runtime, fallback: number) => number; }; +/** 事务内接口:dispatch 推进私有 draft,getDocument 只在同步回调尚未结束时有效。 */ export type Transaction = { dispatch(command: Command): Availability; getDocument(): Document }; +/** 从工厂返回值推导公共 API,避免另一份接口声明与实现漂移。 */ export type Editor = ReturnType; +/** 不只检查 Promise 实例,也识别带 then 的对象,禁止异步结果混进同步发布路径。 */ const isThenable = (value: unknown) => !!value && (typeof value === 'object' || typeof value === 'function') && typeof (value as { then?: unknown }).then === 'function'; +/** 创建一个项目的编辑器实例,捕获并校验初始文档;恢复时沿用历史/意图及其序列。 */ export function createEditor(options: EditorOptions) { const source = options.recovery?.document ?? options.document; const document = createDocument(source.projectId, [...source.canvases.values()], [...source.elements.values()], source.tombstones); validateDocument(document, options.registry); let state: Snapshot = Object.freeze({ revision: options.recovery?.revision ?? 0, rootRevision: options.recovery?.rootRevision ?? 0, document, history: options.recovery?.history ?? Object.freeze({ undo: Object.freeze([]), redo: Object.freeze([]), entries: readMap(new Map()) }), operations: options.recovery?.operations ?? readMap(new Map()), runtime: options.runtime as Runtime }); + // 文档索引与 runtime 覆盖范围同次绑定;同一个 doc 对象的加载事实也可能更新。 const bindCoverage=(snapshot:Snapshot,queries:ReturnType)=>options.queryCoverage?withQueryCoverage(queries,options.queryCoverage(snapshot)):queries; let queries=bindCoverage(state,createDocumentQueries(document,options.registry,state.revision,options.queryBudget)); + // WeakMap 按确切快照身份保存查询,既拒绝错误快照,也不永久保留已废弃文档。 const queriesByDocument=new WeakMap([[document,queries]]); + // entered 覆盖准备、发布和通知,防止 handler/reducer/observer 同步重入造成半份状态。 let entered = false, disposed = false, sequence = 0; for (const intent of state.operations.values()) sequence = Math.max(sequence, intent.sequence); const listeners = new Set<(commit: Commit, snapshot: Snapshot) => void>(); @@ -50,10 +68,12 @@ export function createEditor(options: EditorOptions(options: EditorOptions(options: EditorOptions(options: EditorOptions { const handler = options.registry.handlers.get(element.componentId)?.get(capability); if (!handler) throw fault('unsupported_capability', [element.id, capability]); @@ -115,6 +142,7 @@ export function createEditor(options: EditorOptions !['x', 'y', 'width', 'height', 'rotation'].includes(key))) throw fault('invalid_geometry_patch', [element.id]); + // 混合几何 patch 按 move→resize→rotate 分发,各 handler 只能处理自己的字段。 const parts: [Capability, Partial][] = [ ['geometry.move', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'x' || key === 'y'))], ['geometry.resize', Object.fromEntries(Object.entries(command.patch).filter(([key]) => key === 'width' || key === 'height'))], @@ -125,43 +153,53 @@ export function createEditor(options: EditorOptions):applyChanges(document, changes); return { document: next, changes: freeze(clone(changes)) }; } + /** 所有修改入口共用的生命周期/重入闸门;只读 can 使用自己的检查路径。 */ function assertOpen() { if (disposed) throw fault('editor_disposed'); if (entered) throw fault('reentrant_dispatch'); } + /** 协调器最后的同步准备机会,只能替换 runtime;Promise/抛错均阻止发布。 */ function prepareState(next: Snapshot): Snapshot { if(!options.prepareRuntime)return next; const runtime=options.prepareRuntime(state,next); if(isThenable(runtime))throw fault('async_transaction'); return runtime===next.runtime?next:Object.freeze({...next,runtime}); } + /** 发布后通知 listener 快照;逐个隔离异常,观察者失败不能回滚已提交的 Root。 */ function emit(commit: Commit) { for (const listener of [...listeners]) try { listener(commit, state); } catch (error) { try { options.onListenerError?.(error); } catch { /* Observers cannot roll back a committed state. */ } } } + /** 为历史/操作分配 ID,检查本次已用集合及已有表,拒绝宿主生成器返回冲突 ID。 */ function uniqueId(used: Set): Id { const id = createId(); if (!id || used.has(id) || state.operations.has(id) || state.history.entries.has(id)) throw fault('duplicate_operation_id'); used.add(id); return id; } + /** 聚合一次候选提交:意图/历史关联→runtime reducer→查询准备→一次替换 state→通知。 + * state 赋值前任何错误都不暴露候选文档,ACK/remote/replay 不制造新的出站操作。 */ function publish(document: Document, changes: readonly Change[], origin: Origin, history: HistoryState, historyEntryId?: Id, historyEffect?: CreationSizeEffect): Applied { const used = new Set(); for (const id of history.entries.keys()) used.add(id); // Canonical remote/ACK/replay updates must never feed back into the outbox. + // 用户编辑、undo/redo 等需要同步;服务回执/远端回放不能再次进入 outbox。 const outbound = !['remote', 'ack', 'replay'].includes(origin); const operationIds = Object.freeze(outbound ? [uniqueId(used)] : []); const operations = new Map(state.operations); const nextSequence = outbound ? options.nextOperationSequence?.(state.runtime, sequence + 1) ?? sequence + 1 : sequence; if (!Number.isSafeInteger(nextSequence) || outbound && nextSequence <= sequence) throw fault('invalid_operation_sequence'); + // 一个逻辑历史项可能对应多次出站操作,关联 ID 追加在同一候选 history 中。 if (historyEntryId) { const entries = new Map(history.entries), entry = entries.get(historyEntryId)!; entries.set(entry.id, dataRecord(freeze({ ...entry, operationIds: dataArray(freeze([...entry.operationIds, ...operationIds])) }))); history = Object.freeze({ ...history, entries: readMap(entries) }); } if (outbound) { + // 收集旧/新 owner、父 canvas 和端点 owner,形成跨图版本/claim 的完整参与者范围。 const canvasIds = new Set(); for (const change of changes) { if (change.kind === 'canvas') { @@ -191,8 +229,10 @@ export function createEditor(options: EditorOptions!equal((prepared as unknown as Record)[key],(intent as unknown as Record)[key])))throw fault('invalid_prepared_intent'); operations.set(intent.id, freeze(clone(prepared))); @@ -201,6 +241,7 @@ export function createEditor(options: EditorOptions(options: EditorOptions unknown, settings: { label?: string; groupId?: Id; history?: boolean; origin?: Origin } = {}): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; let active = true, document = state.document, failed: unknown; const changes: Change[] = []; const tx: Transaction = Object.freeze({ + /** 私有 dispatch 记住第一次失败,后续命令不再继续推进 draft。 */ dispatch(command: Command): Availability { if (!active) throw fault('transaction_closed'); if (failed) return { enabled: false, ...reject(failed) }; @@ -222,19 +265,23 @@ export function createEditor(options: EditorOptions {}); throw fault('async_transaction'); } if (failed) throw failed; + // 全部命令规划结束才物化创建记录并做整图校验,允许同批先创建节点再接边。 document=materializeCreations(document); validateDocument(document, options.registry); if (!changes.length || documentsEqual(document, state.document)) return Object.freeze({ status: 'applied', revision: state.revision, operationIds: Object.freeze([]) }); let history = state.history, historyEntryId: Id | undefined; if (settings.history !== false && (!settings.origin || settings.origin === 'user')) { const entries = new Map(history.entries), undo = [...history.undo]; + // 新用户编辑产生新分支:先清 redo;仅当前 undo 栈顶 groupId 相同才合并。 for (const id of history.redo) entries.delete(id); const prior = undo.length ? entries.get(undo[undo.length - 1]) : undefined; if (settings.groupId && prior?.groupId === settings.groupId) { @@ -245,6 +292,7 @@ export function createEditor(options: EditorOptions([]), ...(settings.groupId ? { groupId: settings.groupId } : {}) }))); undo.push(historyEntryId); } + // 容量淘汰移除最旧 entry;资源 pin 的后续扫描以更新后的 history 为准。 while (undo.length > limit) entries.delete(undo.shift()!); history = Object.freeze({ undo: Object.freeze(undo), redo: Object.freeze([]), entries: readMap(entries) }); } @@ -252,13 +300,16 @@ export function createEditor(options: EditorOptions[1]): Result { return transaction(tx => { tx.dispatch(command); }, settings); } + /** 只规划和校验可用性,不分配操作 ID、不发布;提交时还要重新校验当前状态。 */ function can(command: Command): Availability { try { if (disposed) throw fault('editor_disposed'); const result = plan(state.document, command); validateDocument(result.document, options.registry); return { enabled: true }; } catch (error) { const result = reject(error); return { enabled: false, code: result.code, targets: result.targets }; } } + /** 撤销/重做先检查准入和全部 changes,再移动栈并发布补偿意图,失败保留原两栈。 */ function traverse(direction: 'undo' | 'redo'): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; @@ -273,6 +324,7 @@ export function createEditor(options: EditorOptions(options: EditorOptions state,getQueries:(document=state.document)=>{const value=queriesByDocument.get(document);if(!value)throw fault('query_snapshot_unavailable');return value;}, registry: options.registry, dispatch, transaction, can, /** Coordinator entry: document, amended history and outbound fact share one Root. */ + /** 异步图片尺寸解析只提交仍匹配 Root/目标的计划,并修正原创建历史,不额外造用户项。 */ resolveCreationSize(effect: CreationSizeEffect, expectedRootRevision: number): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; @@ -298,8 +352,10 @@ export function createEditor(options: EditorOptions) => void) { if (disposed) throw fault('editor_disposed'); listeners.add(listener); return () => { listeners.delete(listener); }; }, /** Engine-only metadata commit, sharing the document/history publication boundary. */ + /** 仅 runtime 元数据更新:推进 rootRevision,保留文档 revision,不产生出站 operation。 */ updateRuntime(reduce: (runtime: Runtime) => Runtime, expectedRootRevision = state.rootRevision): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; @@ -317,6 +373,8 @@ export function createEditor(options: EditorOptions }): Result { try { assertOpen(); } catch (error) { return reject(error); } entered = true; @@ -326,6 +384,7 @@ export function createEditor(options: EditorOptions!history.entries.has(id)))throw fault('invalid_history'); @@ -342,10 +401,13 @@ export function createEditor(options: EditorOptions traverse('undo'), redo: () => traverse('redo'), canUndo: () => !!state.history.undo.length, canRedo: () => !!state.history.redo.length }), + /** 关闭 editor 并释放监听;不在纯内核中取消上传、清数据库或释放宿主资源。 */ dispose() { disposed = true; listeners.clear(); }, }); } +/** 比较规范化表,先做身份/大小快判;无净变化的事务不增加 revision 或历史。 */ function documentsEqual(a: Document, b: Document): boolean { if(a===b)return true; if(a.canvases.size!==b.canvases.size||a.elements.size!==b.elements.size||a.tombstones.size!==b.tombstones.size)return false; diff --git a/src/lib/canvas-core/element-data.ts b/src/lib/canvas-core/element-data.ts index a9bb6ac..24c628d 100644 --- a/src/lib/canvas-core/element-data.ts +++ b/src/lib/canvas-core/element-data.ts @@ -1,22 +1,32 @@ +/** + * 元素数据的捕获来源与内置 schema 校验缓存。 + * 缓存是性能优化,只能用于框架掌控的不可变记录/纯校验器;自定义校验器每次仍调用。 + */ import type {ElementRecord,ComponentDefinition} from './types.ts'; import {detachJsonData} from './json-data.ts'; // Provenance comes from detaching JSON data, never from an external freeze or // document envelope. This does not assert record validity or registry support. const owned=new WeakSet(); +/** 已由本模块捕获的记录直接复用;外部记录重新复制冻结,再标记新记录身份。 */ export function detachElementData(record:ElementRecord):ElementRecord{ if(record&&typeof record==='object'&&owned.has(record))return record; const value=detachJsonData(record); if(value&&typeof value==='object')owned.add(value); return value; } +/** 证明记录来自本模块捕获;不证明其 type、版本、结构或关联引用有效。 */ export const hasOwnedElementData=(record:ElementRecord)=>!!record&&typeof record==='object'&&owned.has(record); // Framework-owned validators depend only on detached immutable data. Custom // validators remain live, including their method receiver and external context. const builtinValidators=new WeakSet(),dataProofs=new WeakMap(); +/** 标记框架内置、仅依赖输入的纯校验器;不要用它包装依赖外部可变状态的校验器。 */ export function builtinDataValidator(validate:T):T{builtinValidators.add(validate);return validate;} +/** 检查组件 data,只有“owned 记录 + 内置 validator”组合才允许复用成功结果。 + * 失败不缓存;更换 validator 后缓存也不命中;Reflect.apply 保留定义对象作为 this。 */ export function componentDataValid(record:ElementRecord,definition:ComponentDefinition):boolean{ + // record 身份和具体 validator 函数同时匹配,才能跳过一次已成功的 schema 检查。 const validate=definition.validate,reusable=hasOwnedElementData(record)&&builtinValidators.has(validate); if(reusable&&dataProofs.get(record)===validate)return true; const valid=Reflect.apply(validate,definition,[record.data]); diff --git a/src/lib/canvas-core/features.ts b/src/lib/canvas-core/features.ts index d3cb0ff..382030e 100644 --- a/src/lib/canvas-core/features.ts +++ b/src/lib/canvas-core/features.ts @@ -1,3 +1,7 @@ +/** + * 内置 feature 与 component 装配。Handler 是纯规划器:读取目标与 patch,只返回字段变化。 + * 权限、事务与发布仍由 editor 负责;UI 不能绕过 editor 直接调用 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'; @@ -5,6 +9,8 @@ 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; @@ -12,17 +18,22 @@ const geometryHandler = (allowed: readonly (keyof Geometry)[], constrain?: (geom 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 配置,构造只修改 width/height 的缩放行为。 */ 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; 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; @@ -33,30 +44,39 @@ export const resizable = (inputOptions: { min?: number; max?: number; aspect?: b }, }); }; +/** 旋转只产生 rotation 修改;[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]); + // 每个字段携带 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 为字符串,允许其它合法 JSON 扩展字段。 */ 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 引用必须随复制映射;任务/run/progress 不复制成新的执行任务。 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: 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。 */ 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; const ids = new Set(); @@ -66,6 +86,7 @@ export function connectionPathValid(path: Json): boolean { } return true; } +/** 连线编辑 handler:旧 path 也必须可解释,拒绝用当前协议重写未知曲线路径。 */ 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]); diff --git a/src/lib/canvas-core/index.ts b/src/lib/canvas-core/index.ts index 77a371d..e2f7f4e 100644 --- a/src/lib/canvas-core/index.ts +++ b/src/lib/canvas-core/index.ts @@ -1,3 +1,9 @@ +/** + * headless core 的公共入口,供宿主装配模型、registry、editor、gesture、查询及复制规划。 + * 这里只重导出 API,不创建 editor 实例,不读取浏览器状态,也不启动网络任务。 + * runtime/browser/service/React bindings 各有独立入口,避免纯内核消费方被迫加载框架。 + */ +// 共享合同与通用值操作是基础层;后续导出在同一组 ID/Change/Document 类型上组合。 export * from './types.ts'; export {fault,reject,freeze,clone,equal,readMap,readSet,validJson} from './values.ts'; export * from './document.ts'; @@ -9,11 +15,13 @@ export * from './coordinates.ts'; export * from './structure.ts'; export * from './units.ts'; export * from './history-transition.ts'; +// 同步/分片复制和可恢复 planner 共用产品规则;检查点只保存规划进度,不是第二份业务文档。 export {planDuplicate,planDuplicateSteps,remapCopyData} from './duplicate.ts'; export type {DuplicatePlan,DuplicateOptions} from './duplicate.ts'; export {createDuplicatePlanner,validateDuplicateCheckpoint} from './duplicate-checkpoint.ts'; export type {DuplicateCheckpoint} from './duplicate-checkpoint.ts'; +// 查询按不可变文档快照绑定;更新 API 用于沿发布变化推进关系与空间索引。 export {createDocumentQueries,advanceDocumentQueries} from './queries.ts'; export type {DocumentQueries,Bounds,QueryBudget,QueryCoverage,ElementLookup,IdQueryResult} from './queries.ts'; diff --git a/src/lib/canvas-core/json-data.ts b/src/lib/canvas-core/json-data.ts index 2763a87..1706b1c 100644 --- a/src/lib/canvas-core/json-data.ts +++ b/src/lib/canvas-core/json-data.ts @@ -1,11 +1,18 @@ +/** + * 对已适合 JSON 往返的数据做脱离与冻结,并记录该次捕获的来源。 + * 输入是否合法由调用边界负责;来源证明不等于组件 schema、owner 或引用已合法。 + */ import {clone,freeze} from './values.ts'; // Private provenance for a fresh, recursively frozen JSON clone. A caller can // request a detached copy, but cannot grant this fact to an existing object. const detached=new WeakSet(); +/** JSON 复制后递归冻结;对象结果记录到 WeakSet,原始值直接返回。 + * clone 会按 JSON 规则处理数据,所以不可用它代替迁移输入的严格捕获校验。 */ export function detachJsonData(input:T):T { const value=freeze(clone(input)); if(value&&typeof value==='object')detached.add(value); return value; } +/** 只查询本模块产生的对象身份,不从外部 frozen 状态反推来源。 */ export const hasDetachedJsonData=(value:object)=>detached.has(value); diff --git a/src/lib/canvas-core/registry.ts b/src/lib/canvas-core/registry.ts index eb8c476..7152797 100644 --- a/src/lib/canvas-core/registry.ts +++ b/src/lib/canvas-core/registry.ts @@ -1,28 +1,40 @@ +/** + * 组件定义的装配与显式数据迁移。Registry 保存可复用的定义/handler,不保存元素实例或 view 状态。 + * 装配时把 feature 合成为 componentId→capability→handler 查找表,冲突即拒绝,避免依赖安装顺序。 + * 迁移只生成候选记录,不写 editor;调用方负责把结果作为一次合法事务发布。 + */ import type { Capability, CapabilityHandler, ComponentDefinition, Data, ElementRecord, Feature, Json, Registry, ReadonlyValueSet } from './types.ts'; import { clone, fault, freeze, readMap, readSet } from './values.ts'; +/** 捕获一份行为表并冻结:ID 非空、至少一个 handler,且每个已提供 handler 都是函数。 */ export function defineFeature(id: string, handlers: Partial>): Feature { if (!id || !Object.values(handlers).length || Object.values(handlers).some(value => typeof value !== 'function')) throw fault('invalid_feature'); return freeze({ id, handlers: { ...handlers } }); } +/** 校验组件定义与复制/几何协议,捕获其配置容器,防止装配后被外部改写。 */ export function defineComponent(definition: ComponentDefinition): ComponentDefinition { if (!definition.id || !Number.isSafeInteger(definition.dataVersion) || definition.dataVersion < 1 || typeof definition.validate !== 'function') throw fault('invalid_component'); + // 复制路径访问自有字段,禁止原型污染键;数组遍历由 * 路径段表达。 const pathValid = (path: readonly string[]) => Array.isArray(path) && path.length > 0 && path.every(key => typeof key === 'string' && !!key && !['__proto__','prototype','constructor'].includes(key)); if (definition.copy) { const references = definition.copy.references, omitted = definition.copy.omit; if (!Array.isArray(references) || !Array.isArray(omitted) || omitted.some(path => !pathValid(path)) || references.some(ref => !pathValid(ref.path) || !['element','canvas'].includes(ref.target) || !['preserve','reject'].includes(ref.external) || ref.many !== undefined && typeof ref.many !== 'boolean')) throw fault('invalid_copy_schema'); + // reference 和 omit 不可指向重叠路径;通配符也参与前缀冲突判断,避免复制顺序改变结果。 const paths: readonly (readonly string[])[] = [...references.map(ref => ref.path), ...omitted]; for (let i = 0; i < paths.length; i++) for (let j = 0; j < i; j++) if (paths[i].slice(0, Math.min(paths[i].length, paths[j].length)).every((key, index) => (key === paths[j][index] || key === '*' || paths[j][index] === '*'))) throw fault('conflicting_copy_paths'); } + // 节点提供 bounds/hit,边提供 segments/strokeWidth;协议种类必须与组件结构一致。 const geometry=definition.geometry;if(geometry&&(geometry.version!==1||geometry.kind!==definition.structure||geometry.kind==='node'&&(typeof geometry.bounds!=='function'||typeof geometry.hit!=='function'||geometry.dataDependencies!==undefined&&(!Array.isArray(geometry.dataDependencies)||geometry.dataDependencies.some(key=>typeof key!=='string'||!key)))||geometry.kind==='connection'&&(typeof geometry.segments!=='function'||typeof geometry.strokeWidth!=='function')))throw fault('invalid_geometry_protocol'); return freeze({ ...definition, geometry:geometry?{...geometry,...(geometry.kind==='node'&&geometry.dataDependencies?{dataDependencies:[...geometry.dataDependencies]}:{})}:undefined, copy: definition.copy ? clone(definition.copy) : undefined, features: definition.features.map(feature => defineFeature(feature.id, feature.handlers)), migrations: definition.migrations ? { ...definition.migrations } : undefined, resourceFields: definition.resourceFields ? [...definition.resourceFields] : undefined }); } +/** 一次性建立只读 registry;组件 ID、同组件 feature ID 和 capability 都要求唯一。 */ export function createRegistry(definitions: readonly ComponentDefinition[]): Registry { const components = new Map(), capabilities = new Map>(), handlers = new Map>(); for (const input of definitions) { const definition = defineComponent(input); if (components.has(definition.id)) throw fault('duplicate_component', [definition.id]); const found = new Map(), features = new Set(); + // 即使两个 feature 的 ID 不同,也不能偷偷覆盖同一个 capability 的 handler。 for (const feature of definition.features) { if (features.has(feature.id)) throw fault('duplicate_feature', [feature.id]); features.add(feature.id); @@ -35,6 +47,7 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg } components.set(definition.id, definition); capabilities.set(definition.id, readSet(found.keys())); handlers.set(definition.id, readMap(found)); } + // 只识别已安装的精确数据版本;可保留未知记录,但不能假设它可被当前 handler 编辑。 const resolve: Registry['resolve'] = element => { const definition = components.get(element.componentId); if (!definition) return 'unknown-component'; @@ -42,17 +55,24 @@ export function createRegistry(definitions: readonly ComponentDefinition[]): Reg return 'known'; }; return Object.freeze({ components: readMap(components), capabilities: readMap(capabilities), handlers: readMap(handlers), resolve, + // supports 同时要求版本受支持及行为已安装,而不是仅看 componentId 是否存在。 supports: (element: ElementRecord, capability: Capability) => resolve(element) === 'known' && !!capabilities.get(element.componentId)?.has(capability) }); } /** Capture each field once; JSON.stringify would silently erase invalid values. */ +/** 迁移边界的严格 JSON 捕获:每个字段只读取一次,捕获后递归冻结。 + * 不先 JSON.stringify,因为 undefined、函数、非有限数会被丢弃/改值,掩盖非法迁移输出。 */ function captureMigrationData(input:unknown,id:string):Data { + // 统一带原元素 ID 抛出 invalid_migration,便于调用方保留原记录并报告失败对象。 const invalid=():never=>{throw fault('invalid_migration',[id]);}; + // 递归接收 JSON 原子、稠密数组和普通对象;深度上限防止循环/异常深输入无限递归。 const capture=(value:unknown,depth=0):Json=>{ if(depth>64)return invalid(); if(value===null||typeof value==='string'||typeof value==='boolean')return value; if(typeof value==='number')return Number.isFinite(value)?value:invalid(); if(!value||typeof value!=='object')return invalid(); + // JSON 无法表达可枚举 Symbol 键;明确拒绝,不能静默漏掉迁移数据。 if(Object.getOwnPropertySymbols(value).some(key=>Object.prototype.propertyIsEnumerable.call(value,key)))return invalid(); + // 同时要求自有连续下标与键数一致,排除空洞及附加属性。 if(Array.isArray(value)){ if(Object.keys(value).length!==value.length)return invalid(); const result:Json[]=[];for(let index=0;index definition.dataVersion) return element; let data = captureMigrationData(element.data,element.id), version = element.dataVersion; + // 一个 converter 仅前进一个版本,不把“有版本号”当成自动跨版本兼容承诺。 while (version < definition.dataVersion) { const migrate = definition.migrations?.[version]; if (!migrate) throw fault('missing_migration', [element.id]); diff --git a/src/lib/canvas-core/types.ts b/src/lib/canvas-core/types.ts index ffbb230..edc12ff 100644 --- a/src/lib/canvas-core/types.ts +++ b/src/lib/canvas-core/types.ts @@ -1,24 +1,44 @@ +/** + * 内核公共合同:记录实例、条件变化、命令意图、历史、发布快照,以及可复用的类型定义。 + * Document 保存元素实例;Registry 保存定义;Snapshot 将文档/历史/操作与 runtime 元数据一起发布。 + * readonly 是静态约束,运行时冻结、校验和安全只读集合仍由对应工厂完成。 + */ import type { HistoryTransition } from './history-transition.ts'; /** Canonical, serializable contracts. No renderer, browser, or service types. */ +/** 可序列化业务值域;运行时仍需排除 NaN/Infinity 等 TS number 允许但 JSON 不支持的值。 */ export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +/** 组件私有数据的顶层键值表;元素信封中的身份、owner、geometry 不放进这里。 */ export type Data = { readonly [key: string]: Json }; +/** 规范化表的稳定字符串键;同项目冲突及具体服务 ID 格式由对应边界验证。 */ export type Id = string; +/** 二维坐标值;所在空间由调用接口约定,不把它隐式当屏幕坐标。 */ export type Point = { readonly x: number; readonly y: number }; +/** 节点在 owner canvas 空间的位置/尺寸/旋转;旋转以度表示,合法范围由 validator 约束。 */ export type Geometry = Point & { readonly width: number; readonly height: number; readonly rotation: number }; +/** 拥有树的一条记录;根的 parent 为 null,子 canvas 指向父 canvas 及父内的入口元素。 */ export type CanvasRecord = { readonly id: Id; readonly parent: null | { readonly canvasId: Id; readonly entryElementId: Id } }; +/** 元素 ID 加生命周期 incarnation;删除后恢复同 ID 时换代,旧回调不能命中新对象。 */ export type Target = { readonly id: Id; readonly incarnation: number }; +/** 连线指向的节点及可选端口;端点的 owner 不必等于连线 owner。 */ export type Endpoint = { readonly elementId: Id; readonly portId?: string }; +/** 元素共有信封:归属、组件、数据版本与实例数据;不包含函数或 React 组件。 */ export type ElementBase = Target & { readonly ownerCanvasId: Id; readonly componentId: string; readonly dataVersion: number; readonly data: Data }; +/** 按 structure 区分节点和边;边的路径属于 owner 空间,节点持有几何。 */ export type ElementRecord = ElementBase & ( | { readonly structure: 'node'; readonly geometry: Geometry } | { readonly structure: 'connection'; readonly source: Endpoint; readonly target: Endpoint; readonly path: Json } ); +/** 项目规范化事实表;父子/引用关系通过 ID 连接,tombstones 保留已死代次下限。 */ export type Document = { readonly projectId: Id; readonly canvases: ReadonlyMap; readonly elements: ReadonlyMap; readonly tombstones: ReadonlyMap }; +/** 可修改字段白名单;data. 后缀表示一个顶层键,整份 geometry 是一个条件字段。 */ export type Field = `data.${string}` | 'geometry' | 'ownerCanvasId' | 'source' | 'target' | 'path'; +/** 字段条件变化:before/after 与存在性一起比较,使缺失和显式 null 不混淆。 */ export type FieldChange = { readonly kind: 'field'; readonly target: Target; readonly field: Field; readonly before: Json; readonly after: Json; readonly beforePresent?: boolean; readonly afterPresent?: boolean }; +/** 统一变化格式;元素/canvas 的 null 表示不存在,可表达创建、删除、替换及反向修改。 */ export type Change = FieldChange | { readonly kind: 'element'; readonly id: Id; readonly before: ElementRecord | null; readonly after: ElementRecord | null } | { readonly kind: 'canvas'; readonly id: Id; readonly before: CanvasRecord | null; readonly after: CanvasRecord | null }; +/** 调用者表达的编辑意图,由 editor 校验与规划;changes.apply 只供显式授权路径使用。 */ export type Command = | { readonly type: 'element.create'; readonly element: ElementRecord } | { readonly type:'connection.endpoints'; readonly target:Target; readonly source?:Endpoint; readonly destination?:Endpoint } @@ -31,31 +51,57 @@ export type Command = | { readonly type: 'canvas.reparent'; readonly canvasId: Id; readonly parent: CanvasRecord['parent'] } | { readonly type: 'canvas.dissolve'; readonly canvasId: Id; readonly entry: Target } | { readonly type: 'changes.apply'; readonly changes: readonly Change[] }; +/** 变化来源决定是否记用户历史/生成出站意图,防止 ACK 和远端回放形成同步回路。 */ export type Origin = 'user' | 'undo' | 'redo' | 'remote' | 'ack' | 'replay' | 'asset-resolution' | 'migration'; +/** 可解释拒绝:稳定错误码和受影响 ID,不代表部分操作已发布。 */ export type Rejected = { readonly status: 'rejected'; readonly code: string; readonly targets: readonly Id[] }; +/** 本地提交结果;revision 与 operationIds 不等于后端已确认或已持久化。 */ export type Applied = { readonly status: 'applied'; readonly revision: number; readonly operationIds: readonly Id[]; readonly historyEntryId?: Id }; +/** 公共命令结果的判别联合,调用方必须按 status 处理失败。 */ export type Result = Applied | Rejected; +/** 一个逻辑撤销单元,包含正向变化和关联操作 ID;不把每个 pointermove 当一项。 */ export type HistoryEntry = { readonly id: Id; readonly label: string; readonly changes: readonly Change[]; readonly operationIds: readonly Id[]; readonly groupId?: Id }; +/** undo/redo 两个 ID 栈(尾部为栈顶),entries 保存记录,栈项与 ledger 事实分开。 */ export type HistoryState = { readonly undo: readonly Id[]; readonly redo: readonly Id[]; readonly entries: ReadonlyMap }; +/** 图片默认尺寸解析对原创建历史的修正计划;绑定目标代次与创建 entry,避免多造用户步骤。 */ export type CreationSizeEffect = { readonly version: 1; readonly kind: 'creation-size'; readonly entryId: Id; readonly target: Target; readonly size: { readonly width: number; readonly height: number } }; +/** 所有意图共用的身份、单调序列、参与 canvas、changes、依赖及可选历史修正证据。 */ type IntentBase = { readonly historyEffect?:CreationSizeEffect; readonly historyTransition?:HistoryTransition; readonly id: Id; readonly operationSchemaVersion: 1; readonly sequence: number; readonly origin: Origin; readonly canvasIds: readonly Id[]; readonly changes: readonly Change[]; readonly dependencies: readonly Id[] }; +/** 生成任务的稳定输入;目标代次、模型/提示词/参数随逻辑操作保存,凭证不入文档。 */ export type RunInput = { readonly target: Target; readonly boardId: Id; readonly modelId: string; readonly prompt: string; readonly parameters: Data; readonly propagate: boolean }; +/** 取消任务定位信息;控制行为由 runtime 与服务端回执协调。 */ export type RunCancelInput = { readonly runId: Id; readonly boardId: Id }; +/** 把已生成输出显式应用到指定目标/画布,保留 output 与任务身份。 */ export type RunApplyInput = RunCancelInput & { readonly targetCanvasId: Id; readonly target: Target; readonly outputId: Id }; +/** 同一操作词汇覆盖文档、上传和任务控制;这是稳定意图,冻结请求/重试状态属于 runtime。 */ export type OperationIntent = IntentBase & ({ readonly operationType: 'document.change' } | { readonly operationType: 'asset.upload'; readonly resourceId: Id } | { readonly operationType: 'run.start'; readonly run: RunInput } | { readonly operationType: 'run.cancel'; readonly action: RunCancelInput } | { readonly operationType: 'run.apply'; readonly action: RunApplyInput }); +/** 一次文档发布的事件摘要;订阅者读取配套 Snapshot,不靠它重建另一份业务文档。 */ export type Commit = { readonly revision: number; readonly origin: Origin; readonly changes: readonly Change[]; readonly operationIds: readonly Id[] }; +/** 同次发布的文档、历史和逻辑操作表;revision 跟踪文档发布,不是服务端版本。 */ export type CoreSnapshot = { readonly revision: number; readonly document: Document; readonly history: HistoryState; readonly operations: ReadonlyMap }; /** One publication boundary. Runtime metadata changes do not dirty the document revision. */ +/** 单 Root 发布边界;rootRevision 也随 runtime 元数据更新,revision 可保持不变。 */ export type Snapshot = CoreSnapshot & { readonly rootRevision: number; readonly runtime: Runtime }; +/** 命令或能力预检查结果;enabled=false 给出原因,不以按钮是否灰掉代替真正提交校验。 */ export type Availability = { readonly enabled: true } | { readonly enabled: false; readonly code: string; readonly targets: readonly Id[] }; +/** 只暴露读能力的集合合同,forEach 也只接触只读视图。 */ export type ReadonlyValueSet = Pick, 'size' | 'has' | 'entries' | 'keys' | 'values' | typeof Symbol.iterator> & { forEach(callback: (value: T, value2: T, set: ReadonlyValueSet) => void): void }; +/** 编辑行为的稳定标识,组件通过安装 feature 获得对应 handler。 */ export type Capability = 'geometry.move' | 'geometry.resize' | 'geometry.rotate' | 'content.edit' | 'connection.edit'; +/** 读取当前元素和输入,返回字段变化;不能越权写其它元素,也不在这里发布或访问网络。 */ export type CapabilityHandler = (context: { readonly element: ElementRecord; readonly input: Readonly> }) => readonly FieldChange[]; +/** 可复用行为集合;同一组件安装重复 capability 时 registry 明确拒绝。 */ export type Feature = { readonly id: string; readonly handlers: Readonly>> }; /** Path segments address own fields; * iterates array entries. many marks a terminal ID array. */ +/** 声明 data 内的 ID 路径;* 遍历数组,many 表示终端 ID 数组,external 决定外部引用保留/拒绝。 */ export type CopyReference = { readonly path: readonly string[]; readonly target: 'element' | 'canvas'; readonly many?: boolean; readonly external: 'preserve' | 'reject' }; +/** 深复制时只重写声明引用并删 omit 字段;不能扫描任意相似字符串猜测引用。 */ export type CopySchema = { readonly references: readonly CopyReference[]; readonly omit: readonly (readonly string[])[] }; +/** 节点几何协议的计算输入;geometry 可是预览值,不要求等于记录的 committed 几何。 */ export type NodeGeometryContext={readonly element:Extract;readonly geometry:Geometry}; +/** 版本化的纯几何扩展:节点 bounds/hit,边 strokeWidth/segments;不依赖 renderer 实例。 */ export type GeometryProtocol=({readonly kind:'node';readonly version:1;readonly dataDependencies?:readonly string[];bounds(context:NodeGeometryContext&{readonly role:'logical'|'hit'|'paint'}):import('./geometry.ts').Rect;hit(context:NodeGeometryContext&{readonly point:Point;readonly tolerance:number}):boolean}|{readonly kind:'connection';readonly version:1;strokeWidth(context:{readonly element:Extract;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):number;segments(context:{readonly element:Extract;readonly source:import('./geometry.ts').Connector;readonly target:import('./geometry.ts').Connector;readonly displayPath:Json|undefined;readonly transform:{readonly x:number;readonly y:number;readonly scale:number;readonly rotation:number}}):readonly import('./geometry.ts').Cubic[]}); +/** 一种组件的定义:精确版本/schema、feature、迁移、资源字段、几何及复制声明,可服务多个实例。 */ export type ComponentDefinition = { readonly id: string; readonly dataVersion: number; readonly structure: ElementRecord['structure']; readonly features: readonly Feature[]; readonly validate: (data: Data) => boolean; readonly migrations?: Readonly Data>>; readonly resourceFields?: readonly string[]; readonly geometry?:GeometryProtocol; readonly copy?: CopySchema }; +/** 只读定义与编译后能力表;resolve 区分未知组件和不支持版本,supports 只允许精确已知版本。 */ export type Registry = { readonly components: ReadonlyMap; readonly capabilities: ReadonlyMap>; readonly handlers: ReadonlyMap>; readonly resolve: (element: ElementRecord) => 'known' | 'unknown-component' | 'unsupported-version'; readonly supports: (element: ElementRecord, capability: Capability) => boolean };