Files
Endoleap/src/lib/canvas-bindings/projection.ts
T

128 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 把编辑器文档与瞬态手势投影成 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 };
}