128 lines
14 KiB
TypeScript
128 lines
14 KiB
TypeScript
/**
|
||
* 把编辑器文档与瞬态手势投影成 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<ElementRecord,{structure:'node'}>,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<ElementRecord,{structure:'connection'}>,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<Id> | { 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<Id, Transform>([[canvasId, origin]]), hiddenBy = new Map<Id, Id>(), childByEntry = new Map<Id, Id>();
|
||
if(!queries)for (const canvas of document.canvases.values()) if (canvas.parent) childByEntry.set(canvas.parent.entryElementId, canvas.id);
|
||
const members = new Map<Id, ElementRecord[]>();
|
||
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<Id>();
|
||
/** 递归展开只改变投影:先输出入口,再按其当前几何/单位比例变换子分区。 */
|
||
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<Id,Set<string>>();
|
||
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 };
|
||
}
|