/** * 把编辑器文档与瞬态手势投影成 renderer 可消费的节点、边与坐标变换。 * 模型 ID/代次/owner 保留原意;折叠分区只改变显示端点,不迁移连线归属或修改真实引用。 * 同一套 projectNode/projectEdge 被完整遍历与增量补丁共用,避免两条路径产生不同显示规则。 */ import type {DocumentQueries} from '../canvas-core/queries.ts'; import type {GeometryProjectionCache} from './geometry-cache.ts'; import type { Document, ElementRecord, Geometry, Id, Json, Point, Registry } from '../canvas-core/types.ts'; import { builtinRendererCatalog, type RendererCatalog } from './renderers.ts'; import { connectionPathValid } from '../canvas-core/features.ts'; import { unitScale } from '../canvas-core/units.ts'; import { fault } from '../canvas-core/values.ts'; import type {CanvasNodeData,EdgeData} from './compat-types.ts'; import { childCanvasTransform, toWorld, transformPath, type Transform } from '../canvas-core/coordinates.ts'; export { toWorld, toLocal, transformPath, type Transform } from '../canvas-core/coordinates.ts'; /** 当前显示 canvas 自身作为场景原点,不叠加它在外层画布中的布局。 */ const origin:Transform={x:0,y:0,scale:1,rotation:0}; /** 无法解释或缺 renderer 时的降级外壳;只暴露类型/版本及从已知结构派生的连接把手。 */ export type FallbackProjection = {reason:'unknown-component'|'unsupported-version'|'renderer-unavailable';componentId:string;dataVersion:number;handles:{id:string;side:'top'|'right'|'bottom'|'left'}[]}; /** renderer 节点形状。__headlessTarget 绑定真实身份,record-v1 另带模型记录与所属 canvas 变换。 */ export type ProjectionNode = {id:Id;type:string;position:Point;width?:number;height?:number;selected?:boolean;draggable?:boolean;zIndex?:number;measured?:{width?:number;height?:number};style?:{width?:number|string;height?:number|string;pointerEvents?:'none'|'auto'};data:CanvasNodeData&{__headlessFallback?:FallbackProjection;__headlessRecord?:ElementRecord;__headlessTransform?:Transform}}; /** 显示端点可以指向折叠入口;record-v1 把路径解释留给 renderer,compat-v1 接已转换路径。 */ export type ProjectionEdge={id:Id;type?:string;source:Id;target:Id;sourceHandle?:string|null;targetHandle?:string|null;selected?:boolean;zIndex?:number;data:EdgeData&{__headlessRecord?:ElementRecord;__headlessTransform?:Transform}}; /** 仅将 JSON 对象当作 presentation 读取,空值/数组不推测为有效展示配置。 */ const object = (value: Json | undefined) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; /** Construct one node. Scene updates and full traversal share exactly the same projection rules. */ /** 优先采用手势预览几何,再计算场景中心/尺寸/旋转;已知组件的移动能力仍由 registry 判断。 * renderer 不可用与数据未知分别记录 fallback 原因,不修改原始 payload 来伪造兼容数据。 */ export function projectNode(element:Extract,canvasId:Id,registry:Registry,selected:{has(id:Id):boolean},geometry:Geometry|undefined,transform:Transform,depth:number,child:Id|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionNode{ const g = geometry ?? element.geometry, projectedGeometry=cache?.node(element.id,element.incarnation,g,transform),center = projectedGeometry?undefined:toWorld(transform, { x: g.x + g.width / 2, y: g.y + g.height / 2 }); const width = projectedGeometry?.width??g.width * transform.scale, height = projectedGeometry?.height??g.height * transform.scale, resolved=registry.resolve(element),known=resolved==='known'; const renderer=renderers.resolve(element); // 组件未知、版本不支持、renderer 缺失是不同失败边界;降级时不展开任意组件数据给旧 renderer。 const fallback:FallbackProjection|undefined=!known||!renderer?{reason:resolved==='known'?'renderer-unavailable':resolved,componentId:element.componentId,dataVersion:element.dataVersion,handles:[]}:undefined; const type = fallback ? 'unsupported' : renderer!.rendererId; const data = element.data; // 几何统一在场景单位;frame 层序按深度调整,兼容 frame 的内容区不截住其内部元素点击。 const projected: ProjectionNode = { id: element.id, type, position: projectedGeometry?{...projectedGeometry.position}:{ x: center!.x - width / 2, y: center!.y - height / 2 }, width, height, selected: selected.has(element.id), draggable: known && registry.supports(element, 'geometry.move'), zIndex: element.componentId === 'frame' ? depth - 100 : depth * 2, style: { width, height, ...(element.componentId === 'frame'&&!fallback&&renderer?.projection==='compat-v1' ? { pointerEvents: 'none' as const } : {}) }, data: { ...(fallback?{}:data), __headlessTarget:Object.freeze({id:element.id,incarnation:element.incarnation,ownerCanvasId:element.ownerCanvasId,canvasId:canvasId}), kind: element.componentId as CanvasNodeData['kind'], title: typeof data.title === 'string' ? data.title : element.componentId, text: !fallback&&typeof data.text === 'string' ? data.text : '', __headlessFallback:fallback, __headlessRecord:!fallback&&renderer?.projection==='record-v1'?element:undefined,__headlessTransform:!fallback&&renderer?.projection==='record-v1'?transform:undefined, rotation: projectedGeometry?.rotation??(g.rotation + transform.rotation) % 360, viewScale: transform.scale*(!fallback?unitScale(data.presentation):1), ...(child ? { subgraph: { boardId: child } } : {}) } as CanvasNodeData, }; return projected; } /** Endpoints are already resolved to their displayed node IDs by the caller. */ /** 调用方已确定显示 source/target;真实端点被入口替代时清除原 portId,避免端口指向错误外壳。 * 预览只替代本条边的路径;不把 data 内可能残留的 path 混入已验证的结构路径。 */ export function projectEdge(element:Extract,source:Id,target:Id,registry:Registry,selected:{has(id:Id):boolean},transform:Transform,edgePreview:{target:{id:Id};path:Json}|null|undefined,renderers:RendererCatalog,cache?:GeometryProjectionCache):ProjectionEdge{ const renderer=renderers.resolve(element),fallbackEdge=registry.resolve(element)!=='known'||!renderer; // 原生 record-v1 接记录和变换自行绘制;兼容路径转换到场景,未知边不解释自定义路径。 const path = fallbackEdge||renderer?.projection==='record-v1' ? undefined : (cache?cache.edge(element.id,element.incarnation,edgePreview?.target.id===element.id?edgePreview.path:element.path,transform):transformPath(edgePreview?.target.id === element.id ? edgePreview.path : element.path, transform)); const renderPath = path!==undefined&&connectionPathValid(path) ? path : undefined; const { path: unusedPath, ...edgeData } = element.data; void unusedPath; return { id: element.id, ...(fallbackEdge?{type:'unsupported'}:renderer!.rendererId==='default'?{}:{type:renderer!.rendererId}), source, target, sourceHandle: source === element.source.elementId ? element.source.portId : undefined, targetHandle: target === element.target.elementId ? element.target.portId : undefined, selected: selected.has(element.id), data: fallbackEdge?{}:renderer?.projection==='record-v1'?{__headlessRecord:element,__headlessTransform:transform}:{ ...edgeData, ...(renderPath ? { path: renderPath } : {}) } as EdgeData }; } /** Renderer projection retains identity/owner in the model, including nested and collapsed canvases. */ /** 从显示根深度优先投影展开分区,记录可见节点和被折叠祖先代理的子 canvas。 * 只输出 owner 在当前显示范围的连线,再把不可见子节点端点映射到其折叠入口。 */ export function projectCanvas(document: Document, canvasId: Id, registry: Registry, selected: ReadonlySet | { has(id: Id): boolean }, geometry: (id: Id) => Geometry | undefined, edgePreview?: { target: { id: Id }; path: Json } | null, renderers:RendererCatalog=builtinRendererCatalog,services:{queries?:DocumentQueries;geometry?:GeometryProjectionCache;owner?:object}={}) { if (!document.canvases.has(canvasId)) throw fault('canvas_not_loaded', [canvasId]); // 索引必须对应同一 document/registry;几何缓存以 owner/project/canvas 隔离。 const queries=services.queries,cache=services.geometry;if(queries&&queries.document!==document)throw fault('stale_query_snapshot');if(queries&&queries.registry!==registry)throw fault('query_registry_changed');cache?.begin(services.owner??cache,document.projectId,canvasId); // transforms 只包含展开范围;hiddenBy 把所有折叠后代 canvas 指向当前可见的入口节点。 const transforms = new Map([[canvasId, origin]]), hiddenBy = new Map(), childByEntry = new Map(); if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id); const members = new Map(); if(!queries)for (const element of document.elements.values()) { const list = members.get(element.ownerCanvasId) ?? []; list.push(element); members.set(element.ownerCanvasId, list); } /** 有查询索引用其稳定成员序;无索引时由模型直接归属派生一次成员表,不维护第二份事实。 */ const getMembers=(id:Id)=>queries?queries.members(id).map(id=>document.elements.get(id)!):members.get(id)??[]; const nodes: ProjectionNode[] = [], visible = new Set(); /** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */ const visit = (id: Id, depth: number) => { const transform = transforms.get(id)!; for (const element of getMembers(id)) if (element.structure === 'node') { const g=geometry(element.id)??element.geometry,child=queries?queries.relations.childCanvasByEntry.get(element.id):childByEntry.get(element.id),data=element.data; const projected=projectNode(element,canvasId,registry,selected,g,transform,depth,child,renderers,cache),fallback=projected.data.__headlessFallback; nodes.push(projected); visible.add(element.id); if (child) { const presentation = object(data.presentation); // 未知入口无法安全解释子分区展示规则,也按折叠处理;后代都映射到这一可见入口。 if (!!fallback || presentation.collapsed === true) { const stack = [child]; while (stack.length) { const hidden = stack.pop()!; hiddenBy.set(hidden, element.id); if(queries)stack.push(...queries.childCanvases(hidden));else for (const canvas of document.canvases.values()) if (canvas.parent?.canvasId === hidden) stack.push(canvas.id); } } else { transforms.set(child, cache?cache.child(child,element.incarnation,g,transform,data.presentation):childCanvasTransform(transform,g,data.presentation)); visit(child, depth + 1); } } } }; visit(canvasId, 0); // 只从已展开 owner 找边;边在模型中仍归其原 canvas,显示代理不是把边迁入子 canvas。 const edges: ProjectionEdge[] = []; for (const element of queries?queries.orderedElements([...transforms.keys()].flatMap(id=>queries.members(id).filter(id=>document.elements.get(id)?.structure==='connection'))).map(id=>document.elements.get(id)!):document.elements.values()) if (element.structure === 'connection' && transforms.has(element.ownerCanvasId)) { /** 可见端点保留原 ID;隐藏端点改用折叠祖先入口。无法显示或两端同代理时仅省略本帧边。 */ const endpoint = (id: Id) => visible.has(id) ? id : hiddenBy.get(document.elements.get(id)?.ownerCanvasId ?? ''); const source = endpoint(element.source.elementId), target = endpoint(element.target.elementId); if (!source || !target || source === target) continue; edges.push(projectEdge(element,source,target,registry,selected,transforms.get(element.ownerCanvasId)!,edgePreview,renderers,cache)); } // Handles come from known envelope endpoints, never from opaque component payloads. // 降级把手只取标准方向与边结构的端口引用,不从未知组件的任意 payload 推测端口声明。 const nodesById=new Map(nodes.map(node=>[node.id,node])); const fallbackNodes=new Map(nodes.filter(node=>node.data.__headlessFallback).map(node=>[node.id,node.data.__headlessFallback!])); const handleIds=new Map>(); for(const [id,fallback] of fallbackNodes){fallback.handles=(['top','right','bottom','left'] as const).map(side=>({id:side,side}));handleIds.set(id,new Set(fallback.handles.map(handle=>handle.id)));} for(const edge of edges)for(const end of ['source','target'] as const){ const fallback=fallbackNodes.get(edge[end]); if(!fallback){ // 未知边接兼容节点时只保留实际已知 handle,防止 renderer 寻找不存在的 DOM 端口。 if(edge.type==='unsupported'&&!nodesById.get(edge[end])?.data.__headlessRecord){ const node=nodesById.get(edge[end]),field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]; if(!handleIds.has(edge[end])){const ids=new Set(['top','right','bottom','left']);if(Array.isArray(node?.data.ports))for(const port of node.data.ports)if(port&&typeof port.id==='string')ids.add(`port:${port.id}`);handleIds.set(edge[end],ids);} if(handle&&!handleIds.get(edge[end])!.has(handle))edge[field]=undefined; } continue; } // 降级节点可补出边明确引用的把手,分配一个可显示的方向;这是显示外壳,不更改模型 portId。 const field=end==='source'?'sourceHandle':'targetHandle',handle=edge[field]|| (end==='source'?'right':'left');edge[field]=handle; const seen=handleIds.get(edge[end])!;if(!seen.has(handle)){seen.add(handle);fallback.handles.push({id:handle,side:end==='source'?'right':'left'});} } // 本轮遍历结束后删除未访问的几何缓存项,只返回派生场景,不发布 editor 修改。 cache?.end(); return { document,canvasId,nodes, edges, transforms }; }