diff --git a/src/lib/canvas-bindings/element-sources.ts b/src/lib/canvas-bindings/element-sources.ts index 9d1aa48..208bc6e 100644 --- a/src/lib/canvas-bindings/element-sources.ts +++ b/src/lib/canvas-bindings/element-sources.ts @@ -1,9 +1,14 @@ +/** + * 按元素 ID 精确路由的只读查询源。一个上游 editor 订阅服务所有元素桶; + * 普通提交用 changes 找受影响 ID,整份文档替换或加载覆盖变化则做必要的保守复查。 + */ import type {Commit,Id} from '../canvas-core/types.ts'; import type {ElementLookup} from '../canvas-core/queries.ts'; import type {EngineSnapshot} from '../canvas-runtime/contracts.ts'; import {equal} from '../canvas-core/values.ts'; import type {ExternalSource} from './selectors.ts'; +/** 注入根读取、发布订阅与元素查询,绑定层不另建可以编辑的元素表。 */ export type ElementSourcePorts={ getSnapshot():EngineSnapshot; subscribe(listener:(commit:Commit,snapshot:EngineSnapshot)=>void):()=>void; @@ -11,18 +16,23 @@ export type ElementSourcePorts={ }; /** One upstream subscription, ID buckets and cached lookup values, never another document. * Explicit changes use ID routing; replacement/coverage cuts retain a checked fallback. */ +/** 创建惰性订阅中心;每个 forElement 源缓存自身读取值,同 ID 的监听共享上游桶。 */ export function createElementSources(ports:ElementSourcePorts){ + // 失败标记使下次成功查询即使值等价也通知恢复;不把异常缓存成一条虚假元素记录。 type Bucket={value:ElementLookup;listeners:Set<()=>void>;failed?:boolean}; const buckets=new Map();let stop:(()=>void)|undefined,previous:EngineSnapshot|undefined; + /** 先更新全部命中桶再通知,逐个隔离监听器错误,最终重抛首个错误给上游边界。 */ function changed(commit:Commit,next:EngineSnapshot){ // The editor snapshots its listeners; this callback can already be unsubscribed. if(!previous||!buckets.size)return; const before=previous!;previous=next; const ids=new Set(); if(before.document!==next.document){ + // 显式元素/字段改动走 ID 路由;无 changes 的文档替换无法精确定位,复查全部已订阅元素。 if(commit.changes.length){for(const change of commit.changes)if(change.kind==='field')ids.add(change.target.id);else if(change.kind==='element')ids.add(change.id);} else for(const id of buckets.keys())ids.add(id); } + // loading 的已知范围改变会把 absent/unloaded 变成可查询;原先非 present 的桶必须复查。 const coverageChanged=before.runtime.loading!==next.runtime.loading||!!next.runtime.loading&&(before.runtime.loadingRead?.document??before.runtime.acknowledged)!==(next.runtime.loadingRead?.document??next.runtime.acknowledged); if(coverageChanged)for(const[id,bucket]of buckets)if(bucket.value.status!=='present')ids.add(id); const notifications=new Set<()=>void>(); @@ -36,15 +46,20 @@ export function createElementSources(ports:ElementSourcePorts){ for(const notify of notifications)try{notify();}catch(caught){if(!failed){failed=true;error=caught;}} if(failed)throw error; } + /** 创建单元素外部源;单纯创建源不注册上游,只有 subscribe 才建立观察关系。 */ return Object.freeze({forElement(id:Id):ExternalSource{ let value:ElementLookup|undefined; return Object.freeze({ + /** 每次从当前根查询并按值保留稳定引用,不能只依赖通知时机猜测最新数据。 */ getSnapshot(){const next=ports.lookup(ports.getSnapshot(),id);if(!value||!equal(value,next))value=next;return value;}, + /** 每个订阅包装独立 listener,即使调用方传同一个函数也能分别释放。 */ subscribe(notify:()=>void){ let bucket=buckets.get(id);if(!bucket){bucket={value:ports.lookup(ports.getSnapshot(),id),listeners:new Set()};buckets.set(id,bucket);} // Distinct subscriptions may intentionally share the same callback identity. const listener=()=>notify();bucket.listeners.add(listener); + // 首次连上游抛错时撤销这次 listener/空桶,避免半注册泄漏。 if(!stop){previous=ports.getSnapshot();try{stop=ports.subscribe(changed);}catch(error){bucket.listeners.delete(listener);if(!bucket.listeners.size)buckets.delete(id);if(!buckets.size)previous=undefined;throw error;}} + // 最后一个桶清空才退订上游,释放 previous 根引用;其它元素仍有监听时继续共享。 let active=true;return()=>{if(!active)return;active=false;bucket!.listeners.delete(listener);if(!bucket!.listeners.size)buckets.delete(id);if(!buckets.size){stop?.();stop=undefined;previous=undefined;}}; }, }); diff --git a/src/lib/canvas-bindings/frame-queue.ts b/src/lib/canvas-bindings/frame-queue.ts index ba0391a..d0c1744 100644 --- a/src/lib/canvas-bindings/frame-queue.ts +++ b/src/lib/canvas-bindings/frame-queue.ts @@ -1,22 +1,35 @@ +/** + * 显示层的帧合并队列。把多次“需要重画”合并成去重 key 集合,不延迟命令提交、历史或网络任务。 + * 队列只拥有调度句柄和失效索引;真实数据随时从上游快照读取,dispose 时丢弃未发布通知。 + */ import type {ExternalSource} from './selectors.ts'; /** Presentation invalidations only. Commands, history and network work never wait here. */ +/** 宿主注入帧请求/取消能力,可接 requestAnimationFrame 或测试时钟;handle 不保存业务身份。 */ export type FrameClock={request(callback:()=>void):unknown;cancel(handle:unknown):void}; +/** 创建一条可手动 flush 的通知队列;同一 key 在一次批次中只出现一次。 */ export function createFrameQueue(clock:FrameClock,publish:(dirty:ReadonlySet)=>void){ let dirty=new Set(),scheduled:{handle?:unknown}|undefined,disposed=false; + /** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */ function flush(){if(disposed)return;const pending=scheduled;scheduled=undefined;if(pending)clock.cancel(pending.handle);if(!dirty.size)return;const batch=dirty;dirty=new Set();publish(batch);} return Object.freeze({ + /** 合并 keys 并最多安排一个帧回调;pending 对象身份防止已取消的旧回调误刷后来的批次。 */ invalidate(keys:Iterable){if(disposed)return;for(const key of keys)dirty.add(key);if(!dirty.size||scheduled)return;const pending: {handle?:unknown}={};scheduled=pending;try{pending.handle=clock.request(()=>{if(scheduled!==pending)return;scheduled=undefined;flush();});}catch(error){scheduled=undefined;throw error;}}, flush, + /** 幂等关闭并撤销帧句柄,关闭后失效通知无效果;不会替上游关闭 editor。 */ dispose(){if(disposed)return;disposed=true;if(scheduled)clock.cancel(scheduled.handle);scheduled=undefined;dirty.clear();}, }); } /** Coalesce presentation notifications; immediate invalidations flush pending work synchronously. */ +/** 给普通外部数据源加通知节流:读取始终透传当前快照,每个订阅独有可释放的帧队列。 + * 上游标 immediate 时同步 flush,常用于不能等下一帧才反映的状态边界。 */ export function createFrameSource(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource{ return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){ const queue=createFrameQueue(clock,()=>notify()); let stop:()=>void; + // 注册抛错也要撤销可能已经安排的帧,避免一个未建立成功的订阅仍在稍后通知。 try{stop=source.subscribe(immediate=>{queue.invalidate([0]);if(immediate)queue.flush();});}catch(error){queue.dispose();throw error;} + // 先退订上游,finally 保证上游清理抛错时仍释放本订阅的显示队列。 return()=>{try{stop();}finally{queue.dispose();}}; }}); } diff --git a/src/lib/canvas-bindings/geometry-cache.ts b/src/lib/canvas-bindings/geometry-cache.ts index 2403b77..ec48f8b 100644 --- a/src/lib/canvas-bindings/geometry-cache.ts +++ b/src/lib/canvas-bindings/geometry-cache.ts @@ -1,21 +1,36 @@ +/** + * 视图局部的数值/路径投影缓存。按元素代次、几何和变换值复用计算,既不缓存可编辑记录,也不决定可见性。 + * 每次 begin/end 标记本帧访问项,剔除不再出现的条目;切换 scope/project/canvas 时整体清空。 + */ import type {Geometry,Id,Json} from '../canvas-core/types.ts'; import {childCanvasTransform,toWorld,transformPath,type Transform} from '../canvas-core/coordinates.ts'; import {equal,clone,freeze} from '../canvas-core/values.ts'; +/** 仅比较投影使用的四个变换量,普通对象引用变化不必导致重新计算。 */ const sameTransform=(a:Transform,b:Transform)=>a.x===b.x&&a.y===b.y&&a.scale===b.scale&&a.rotation===b.rotation; +/** 节点几何的全部数值字段都参与缓存条件。 */ const sameGeometry=(a:Geometry,b:Geometry)=>a.x===b.x&&a.y===b.y&&a.width===b.width&&a.height===b.height&&a.rotation===b.rotation; +/** 子画布变换只依赖 presentation 中 contentScale/unitScale,忽略无关样式字段。 */ const units=(value:Json|undefined)=>value&&typeof value==='object'&&!Array.isArray(value)?[value.contentScale,value.unitScale]:[]; /** View-local numeric/path projection only. No editable records, history, visibility or task facts. */ +/** 缓存实例应归一个显示上下文;计数器累计实际计算次数供性能诊断,不是模型版本。 */ export function createGeometryProjectionCache(){ let owner:object|undefined,project:Id|undefined,canvas:Id|undefined,nodesComputed=0,childrenComputed=0,edgesComputed=0; type NodeResult={position:{x:number;y:number};width:number;height:number;rotation:number}; const nodes=new Map(),children=new Map(),edges=new Map();let seenNodes=new Set(),seenChildren=new Set(),seenEdges=new Set(); return Object.freeze({ + /** 开始一轮投影并重置 seen;owner 身份隔离不同 renderer/editor,即使 projectId 相同也不能混用。 */ begin(scope:object,projectId:Id,canvasId:Id){if(owner!==scope||project!==projectId||canvas!==canvasId){nodes.clear();children.clear();edges.clear();owner=scope;project=projectId;canvas=canvasId;}seenNodes=new Set();seenChildren=new Set();seenEdges=new Set();}, + /** 将节点中心转到场景,按变换比例缩放宽高并合成旋转;缓存复制输入数值防止外部修改污染键。 */ node(id:Id,incarnation:number,g:Geometry,t:Transform):NodeResult{seenNodes.add(id);const before=nodes.get(id);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t))return before.value;const center=toWorld(t,{x:g.x+g.width/2,y:g.y+g.height/2}),width=g.width*t.scale,height=g.height*t.scale,value=freeze({position:{x:center.x-width/2,y:center.y-height/2},width,height,rotation:(g.rotation+t.rotation)%360});nodes.set(id,{incarnation,geometry:{...g},transform:{...t},value});nodesComputed++;return value;}, + /** 计算分区内部坐标到场景的变换,单位比例变化也会使缓存失效。 */ child(id:Id,incarnation:number,g:Geometry,t:Transform,presentation:Json|undefined):Transform{seenChildren.add(id);const before=children.get(id),currentUnits=units(presentation);if(before&&before.incarnation===incarnation&&sameGeometry(before.geometry,g)&&sameTransform(before.transform,t)&&equal(before.units,currentUnits))return before.value;const value=freeze(childCanvasTransform(t,g,presentation));children.set(id,{incarnation,geometry:{...g},transform:{...t},units:currentUnits,value});childrenComputed++;return value;}, + /** JSON 路径按值比较;先复制/冻结输入再投影,不持有调用方可变路径作为历史缓存。 */ edge(id:Id,incarnation:number,path:Json,t:Transform):Json{seenEdges.add(id);const before=edges.get(id);if(before&&before.incarnation===incarnation&&equal(before.path,path)&&sameTransform(before.transform,t))return before.value;const captured=freeze(clone(path)),value=freeze(transformPath(captured,t));edges.set(id,{incarnation,path:captured,transform:{...t},value});edgesComputed++;return value;}, + /** 删除本轮未访问的缓存项,避免浏览多个分区后无限保留旧节点和路径。 */ end(){for(const id of nodes.keys())if(!seenNodes.has(id))nodes.delete(id);for(const id of children.keys())if(!seenChildren.has(id))children.delete(id);for(const id of edges.keys())if(!seenEdges.has(id))edges.delete(id);}, + /** 返回当前缓存大小和累计计算量的冻结快照;begin 不重置累计计数。 */ stats:()=>Object.freeze({nodesComputed,childrenComputed,edgesComputed,nodes:nodes.size,children:children.size,edges:edges.size}), }); } +/** 由工厂推导的缓存接口,供投影器注入复用。 */ export type GeometryProjectionCache=ReturnType; diff --git a/src/lib/canvas-bindings/hit-query.ts b/src/lib/canvas-bindings/hit-query.ts index 782f5d1..506a133 100644 --- a/src/lib/canvas-bindings/hit-query.ts +++ b/src/lib/canvas-bindings/hit-query.ts @@ -1,3 +1,8 @@ +/** + * 对已捕获显示投影执行命中查询,而不是直接扫描请求队列或另建选择模型。 + * 节点用组件 geometry 协议精测,连线用实际显示锚点/曲线近似求距;像素容差按 zoom 转为场景单位。 + * 预算或几何解释不足时保留 diagnostics/possible,调用方不能把不完整结果当成确定的“没有命中”。 + */ import type {DocumentQueries} from '../canvas-core/queries.ts'; import type {ElementRecord,Geometry,Id,Point,Registry,Target} from '../canvas-core/types.ts'; import type {Connector,Cubic} from '../canvas-core/geometry.ts'; @@ -7,35 +12,60 @@ import {toLocal,type Transform} from '../canvas-core/coordinates.ts'; import {fault,freeze} from '../canvas-core/values.ts'; import {compatConnector} from './connectors.ts'; import type {projectCanvas,ProjectionNode,ProjectionEdge} from './projection.ts'; +/** 查询消费 projectCanvas 的同一帧结果,包含显示节点、边与 canvas→场景变换。 */ type Projection=ReturnType; +/** 命中身份用真实元素 ID+代次;displayPath 说明嵌套显示路径,不改变 ownerCanvasId。 + * 连线另保留屏幕显示端点身份,可能是投影暴露的上级入口,而不是底层真实端点。 */ export type SceneHit={readonly target:Target;readonly kind:'node'|'connection';readonly ownerCanvasId:Id;readonly displayPath:{readonly viewSessionId:Id;readonly canvasIds:readonly Id[];readonly entryIds:readonly Id[]};readonly layer:number;readonly certainty:'confirmed'|'possible';readonly distanceScene?:number;readonly errorScene?:number;readonly displayedEndpoints?:{readonly source:Id;readonly target:Id}}; +/** renderer 可提供实际锚点、曲线、线宽与层级,使命中跟绘制一致;previewIds 补充移动预览候选。 */ export type HitServices={readonly anchor?:(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>Connector|undefined;readonly edgeGeometry?:(edge:ProjectionEdge,source:Connector,target:Connector)=>readonly Cubic[]|undefined;readonly strokeWidth?:(edge:ProjectionEdge)=>number|undefined;readonly layer?:(id:Id,kind:'node'|'connection')=>number|undefined;readonly previewIds?:ReadonlySet}; +/** 候选数、曲线步数/深度、每边段数与像素误差上限,限制一次交互查询的工作量。 */ export type HitBudget={readonly maxCandidates?:number;readonly maxCurveSteps?:number;readonly maxCurveDepth?:number;readonly maxSegments?:number;readonly errorPx?:number}; +/** scene 点直接附缩放;screen 点还带容器 origin 和 viewport 平移,避免混用窗口/画布坐标。 */ export type HitPoint=({readonly space:'scene';readonly point:Point;readonly zoom:number}|{readonly space:'screen';readonly point:Point;readonly viewport:{readonly x:number;readonly y:number;readonly zoom:number;readonly origin:Point}})&{readonly tolerancePx?:number}; +/** 外部几何服务的点必须由有限数值组成,不能让 NaN/Infinity 流入曲线计算。 */ const finitePoint=(point:Point)=>!!point&&Number.isFinite(point.x)&&Number.isFinite(point.y); +/** 把显示节点的位置/尺寸/旋转反算回所属 canvas 局部几何,包含当前预览后的显示结果。 */ function geometry(node:ProjectionNode,t:Transform):Geometry{const width=node.width!/t.scale,height=node.height!/t.scale,center=toLocal(t,{x:node.position.x+node.width!/2,y:node.position.y+node.height!/2});return{x:center.x-width/2,y:center.y-height/2,width,height,rotation:((node.data.rotation??0)-t.rotation+360)%360};} /** Query the captured display frame. It neither edits nor replaces the native input router. */ +/** 必须使用投影对应的同一 document 和 registry。先取空间候选再精测,返回有序命中及完整性诊断; + * 此函数不触发编辑、不消费原生事件,也不替宿主决定是否认领一次输入。 */ export function hitProjectedCanvas(projection:Projection,queries:DocumentQueries,registry:Registry,viewSessionId:Id,query:HitPoint,services:HitServices={},budget:HitBudget={}){ + // 强制快照/registry 身份一致;旧投影搭配新索引可能产生看得见却点不中的结果,直接拒绝。 if(projection.document!==queries.document||!viewSessionId)throw fault('stale_query_snapshot');if(queries.registry!==registry)throw fault('query_registry_changed');const zoom=query.space==='scene'?query.zoom:query.viewport.zoom,tolerancePx=query.tolerancePx??6,errorPx=budget.errorPx??.25,maxCandidates=budget.maxCandidates??4096,maxSteps=budget.maxCurveSteps??8192,maxDepth=budget.maxCurveDepth??20,maxSegments=budget.maxSegments??4096; if(!finitePoint(query.point)||!Number.isFinite(zoom)||zoom<=0||!Number.isFinite(tolerancePx)||tolerancePx<0||!Number.isFinite(errorPx)||errorPx<=0||![maxCandidates,maxSteps,maxDepth,maxSegments].every(n=>Number.isSafeInteger(n)&&n>=1&&n<=1000000)||maxDepth>40)throw fault('invalid_hit_query'); if(query.space==='screen'&&(!finitePoint(query.viewport.origin)||!Number.isFinite(query.viewport.x)||!Number.isFinite(query.viewport.y)))throw fault('invalid_hit_query'); + // 屏幕位置减容器 origin 与 viewport 平移后除 zoom;容差同样换算,不随缩放改变视觉点击范围。 const point=query.space==='scene'?query.point:{x:(query.point.x-query.viewport.origin.x-query.viewport.x)/zoom,y:(query.point.y-query.viewport.origin.y-query.viewport.y)/zoom},tolerance=tolerancePx/zoom,document=projection.document,nodes=new Map(projection.nodes.map(node=>[node.id,node])),rank=new Map(queries.orderedElements([...nodes.keys(),...projection.edges.map(edge=>edge.id)]).map((id,index)=>[id,index])),candidates=new Set(services.previewIds??[]),hits:SceneHit[]=[],diagnostics:{target?:Id;code:string}[]=[];let work=0,remaining=maxSteps; if(!finitePoint(point)||!Number.isFinite(tolerance)||!Number.isFinite(errorPx/zoom))throw fault('invalid_hit_query'); + // 分别把场景点与容差变回各 canvas,查询本地节点索引;previewIds 可补上索引尚未覆盖的预览位置。 for(const[id,t]of projection.transforms){if(!Number.isFinite(t.scale)||t.scale<=0)throw fault('invalid_hit_transform');const p=toLocal(t,point),r=tolerance/t.scale;for(const candidate of queries.candidateNodes(id,{x:p.x-r,y:p.y-r,width:2*r,height:2*r}))candidates.add(candidate);} + /** 由真实 owner 向当前显示根回溯,生成 canvas/入口路径;检测循环和无效父关系。 */ function path(owner:Id){const canvases:Id[]=[],entries:Id[]=[],seen=new Set();let id=owner;while(id!==projection.canvasId){if(seen.has(id))throw fault('canvas_cycle');seen.add(id);const parent=document.canvases.get(id)?.parent;if(!parent)throw fault('invalid_display_path');canvases.push(id);entries.push(parent.entryElementId);id=parent.canvasId;}return{viewSessionId,canvasIds:[projection.canvasId,...canvases.reverse()],entryIds:entries.reverse()};} + /** 自定义层级必须有限;否则记入对应目标诊断,而不是用无效数值打乱排序。 */ const layer=(id:Id,kind:'node'|'connection',fallback:number)=>{const value=services.layer?.(id,kind)??fallback;if(!Number.isFinite(value))throw fault('invalid_hit_layer');return value;}; + // 节点精测只处理投影中实际存在的候选;未知/降级组件使用矩形协议,单元素协议失败隔离为诊断。 for(const id of candidates){const node=nodes.get(id);if(!node)continue;if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(id);if(!record||record.structure!=='node')throw fault('invalid_hit_projection');const t=projection.transforms.get(record.ownerCanvasId);if(!t)throw fault('invalid_hit_transform');const g=geometry(node,t),resolved=!node.data.__headlessFallback&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,protocol=resolved?.kind==='node'?resolved:rectangleGeometry,p=toLocal(t,point),localTolerance=tolerance/t.scale; + // 组件协议返回的几何边界也要校验;精测依据局部旋转几何和按比例换算的容差。 const bounds=protocol.bounds({element:record,geometry:g,role:'hit'});if(![g.x,g.y,g.width,g.height,g.rotation,bounds.x,bounds.y,bounds.width,bounds.height].every(Number.isFinite)||g.width<0||g.height<0||bounds.width<0||bounds.height<0)throw fault('invalid_geometry_bounds'); const result=protocol.hit({element:record,geometry:g,point:p,tolerance:localTolerance});if(typeof result!=='boolean')throw fault('invalid_geometry_response');if(result)hits.push({target:{id,incarnation:record.incarnation},kind:'node',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),layer:layer(id,'node',node.zIndex??0),certainty:'confirmed'}); }catch(error){diagnostics.push({target:id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} + // 优先采用 renderer 锚点。原生 headless 记录不能用旧卡片的兼容锚点猜位置,缺少服务就给出诊断。 const anchor=(node:ProjectionNode,handle:string|null|undefined,end:'source'|'target',edge:ProjectionEdge)=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); + // 连线与节点共用候选预算;逐边解析真实显示端点和画笔,失败不影响其它边的查询。 for(const edge of projection.edges){if(++work>maxCandidates){diagnostics.push({code:'geometry_query_budget'});break;}try{const record=document.elements.get(edge.id);if(!record||record.structure!=='connection')throw fault('invalid_hit_projection');const source=nodes.get(edge.source),target=nodes.get(edge.target);if(!source||!target)throw fault('geometry_anchor_unavailable');const from=anchor(source,edge.sourceHandle,'source',edge),to=anchor(target,edge.targetHandle,'target',edge);if(!from||!to||![from.point,from.normal,to.point,to.normal].every(finitePoint))throw fault('geometry_anchor_unavailable'); const transform=projection.transforms.get(record.ownerCanvasId);if(!transform)throw fault('invalid_hit_transform');const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined,context={element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}; + // renderer 曲线优先,随后才用组件协议;验证段数及每段四个有限点,避免异常几何耗尽查询。 const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments(context):undefined);if(!segments)throw fault('geometry_unavailable');if(!Array.isArray(segments)||segments.length>maxSegments||segments.some(segment=>!Array.isArray(segment)||segment.length!==4||!segment.every(finitePoint)))throw fault('invalid_geometry_response'); + // 有效点击半径 = 场景容差 + 实际线宽/2;不把只点中心线误当成整条可见线的命中范围。 const width=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined);if(typeof width!=='number'||!Number.isFinite(width)||width<0)throw fault('invalid_geometry_paint');let possible:ReturnType|undefined,confirmed:ReturnType|undefined; + // 曲线细分步数由所有边共享剩余预算;无法继续证明时保守返回 possible,不伪造 confirmed。 for(const segment of segments){if(remaining<=0){possible??={hit:true,certainty:'possible',steps:0,distance:Infinity,error:Infinity,budget:true};break;}const result=hitCubic(segment,point,tolerance+width/2,{error:errorPx/zoom,maxDepth,maxSteps:remaining});remaining-=result.steps;if(result.hit){if(result.certainty==='confirmed'){confirmed=result;break;}possible=result;}} + // 同一边任一段确定命中即可 confirmed;只有不确定命中则同时说明精度或预算诊断。 const result=confirmed??possible;if(result?.hit){if(!confirmed)diagnostics.push({target:edge.id,code:result.budget?'geometry_query_budget':'geometry_precision'});hits.push({target:{id:edge.id,incarnation:record.incarnation},kind:'connection',ownerCanvasId:record.ownerCanvasId,displayPath:path(record.ownerCanvasId),displayedEndpoints:{source:edge.source,target:edge.target},layer:layer(edge.id,'connection',edge.zIndex??0),certainty:confirmed?'confirmed':'possible',...(Number.isFinite(result.distance)?{distanceScene:result.distance}:{}),...(Number.isFinite(result.error)?{errorScene:result.error}:{})});} }catch(error){diagnostics.push({target:edge.id,code:(error as{code?:string}).code??'geometry_handler_failed'});}} + // 尚未完整加载的显示分区可能漏掉未收到元素,命中列表存在也不代表整场景查询完整。 if([...projection.transforms.keys()].some(id=>!queries.coverage.completeCanvasIds.includes(id)))diagnostics.push({code:'model_scope_incomplete'}); + // 排序按显示层级从高到低,同层节点优先于边,再按文档层序倒序;只返回结果,不直接改变选择。 hits.sort((a,b)=>b.layer-a.layer||(a.kind===b.kind?0:a.kind==='node'?-1:1)||(rank.get(b.target.id)??0)-(rank.get(a.target.id)??0));return freeze({projectId:document.projectId,revision:queries.revision,viewSessionId,pointScene:{...point},toleranceScene:tolerance,hits,complete:diagnostics.length===0,diagnostics}); } diff --git a/src/lib/canvas-bindings/input-router.ts b/src/lib/canvas-bindings/input-router.ts index 88274da..f743e73 100644 --- a/src/lib/canvas-bindings/input-router.ts +++ b/src/lib/canvas-bindings/input-router.ts @@ -1,14 +1,26 @@ +/** + * 归一化键盘事件的消费顺序:文本编辑 → 浮层 → 手势 → 工具 → 快捷键。 + * 输入来源由宿主传入,路由器只报告获胜层,不直接 preventDefault、执行命令或访问浏览器全局。 + */ /** Host normalizes DOM/native input; only the winning layer consumes an event. */ +/** 将 DOM/native 键盘上下文压成只读值;composing 保护输入法组合,modifier 供具体处理器解释。 */ export type CanvasKeyInput={readonly key:string;readonly composing?:boolean;readonly textEditing?:boolean;readonly overlay?:boolean;readonly defaultPrevented?:boolean;readonly ctrl?:boolean;readonly meta?:boolean;readonly alt?:boolean;readonly shift?:boolean}; +/** 只有一个层获得消费权;unclaimed 表示宿主不应据此拦截事件。 */ export type InputClaim='text'|'overlay'|'gesture'|'tool'|'shortcut'|'unclaimed'; +/** 返回 true 即获得事件;后续低优先级处理器不会再运行。 */ export type KeyHandler=(input:CanvasKeyInput)=>boolean; +// 标准文本控件、可编辑内容和 textbox 角色均视为正在输入,不让画布快捷键抢走按键。 const textTarget = 'input,textarea,select,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'; +// 画布浮层标记和交互 ARIA 角色优先于全局画布命令。 const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"]'; /** Inspect the composed path as well as the retargeted host (portals/shadow DOM). * This uses a structural DOM port so pure consumers need no browser globals. */ +/** 检查 target 和 composedPath 上所有可 closest 的节点,识别 shadow DOM 重定向后的输入上下文。 + * portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 */ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick{ let textEditing=false,overlay=false; for(const target of [event.target,...(event.composedPath?.()??[])]){ + // 使用结构能力判断,跳过 Window、文本节点等无 closest 的路径项,无需 instanceof Element。 if(!target||typeof (target as {closest?:unknown}).closest!=='function')continue; const element=target as {closest(selector:string):unknown}; textEditing ||= !!element.closest(textTarget); @@ -16,6 +28,7 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:( } return {textEditing,overlay}; } +/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */ export function routeCanvasKey(input:CanvasKeyInput,handlers:{gesture?:KeyHandler;tool?:KeyHandler;shortcut?:KeyHandler}):InputClaim{ if(input.defaultPrevented)return 'unclaimed'; if(input.composing||input.textEditing)return 'text'; diff --git a/src/lib/canvas-bindings/node-internals.ts b/src/lib/canvas-bindings/node-internals.ts index 37a138a..358679b 100644 --- a/src/lib/canvas-bindings/node-internals.ts +++ b/src/lib/canvas-bindings/node-internals.ts @@ -1,15 +1,24 @@ +/** + * 把节点端口/尺寸变化的测量请求合并给 renderer,避免同一轮重复调用更新。 + * owner token 区分同 ID 的不同挂载实例;卸载实例的迟到失效不能触发后来新实例的测量。 + */ /** One renderer store's transient measurement requests. Not model or history state. */ +/** 每个 renderer store 独立创建;默认微任务调度,也可注入测试或宿主 defer。 */ export function createNodeInternalsBatch(update:(ids:string[])=>void,defer:(flush:()=>void)=>void=queueMicrotask){ const owners=new Map(),dirty=new Set();let queued=false; + /** 执行时才解析仍有效的 owner,按节点 ID 去重;先清 dirty 再回调,让重入失效进入下一轮。 */ function flush(){ queued=false;const ids=new Set();for(const owner of dirty){const id=owners.get(owner);if(id!==undefined)ids.add(id);}dirty.clear(); if(ids.size)update([...ids]); } return Object.freeze({ + /** 每次挂载取得独立租约,即使节点 ID 相同也能单独 dispose。 */ acquire(id:string){ const owner=Symbol(id);owners.set(owner,id); return Object.freeze({ + /** 仅活跃租约可以变脏,同一批最多安排一次延后 flush。 */ invalidate(){if(!owners.has(owner))return;dirty.add(owner);if(!queued){queued=true;defer(flush);}}, + /** 卸载同时移除 owner 和其未执行请求;已排微任务可自然执行为空,不取得新实例权限。 */ dispose(){owners.delete(owner);dirty.delete(owner);}, }); }, diff --git a/src/lib/canvas-bindings/pointer-capture.ts b/src/lib/canvas-bindings/pointer-capture.ts index 95c172e..ee774db 100644 --- a/src/lib/canvas-bindings/pointer-capture.ts +++ b/src/lib/canvas-bindings/pointer-capture.ts @@ -1,5 +1,14 @@ +/** + * 浏览器指针捕获的容错边界。这里只处理 DOM 事件传输所有权;手势 token、预览和几何仍归 editor/session。 + * DOM 捕获丢失不等于任意事件都能取消当前手势,必须匹配指针与捕获目标。 + */ /** Transport ownership only: document geometry and gesture tokens stay in the editor. */ +/** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */ export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void}; +/** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。 + * 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */ export function ownsPointerCapture(owner:{pointerId:number;capture:unknown}|null|undefined,input:{pointerId:number;type?:string;target?:unknown}){if(!owner||owner.pointerId!==input.pointerId)return false;if(input.type!=='lostpointercapture')return true;if(input.target!==owner.capture)return false;const capture=owner.capture as Partial|undefined;try{if(capture?.hasPointerCapture?.(owner.pointerId))return false;}catch{/* A detached matching target may still need cancellation. */}return true;} +/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */ export function acquirePointerCapture(target:PointerCapturePort,id:number){try{target.setPointerCapture(id);return true;}catch{return false;}} +/** 尽力释放且吞掉平台清理异常;DOM 释放失败不能取得 editor 的提交或取消权限。 */ export function releasePointerCapture(target:PointerCapturePort,id:number){try{if(target.hasPointerCapture(id))target.releasePointerCapture(id);}catch{/* Detached DOM or an already released platform capture has no editor authority. */}} diff --git a/src/lib/canvas-bindings/selectors.ts b/src/lib/canvas-bindings/selectors.ts index 9d4edd4..cdf582d 100644 --- a/src/lib/canvas-bindings/selectors.ts +++ b/src/lib/canvas-bindings/selectors.ts @@ -1,11 +1,19 @@ +/** + * 外部只读快照的选择器适配。只缓存一次选中结果,不复制业务文档或另存操作状态。 + * 依赖上游不可变快照:根对象身份未变时,选择器可以直接复用旧结果。 + */ /** One immutable source, one cached selection; no second document or operation state. */ +/** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */ export type ExternalSource={getSnapshot():S;subscribe(notify:()=>void):()=>void}; +/** 用 selector 与等价判断派生稳定快照;等价的新值沿用旧引用,避免 UI 的无效刷新。 */ export function createSelectorStore(source:ExternalSource,selector:(snapshot:S)=>T,isEqual:(a:T,b:T)=>boolean=Object.is){ let initialized=false,snapshot:S,selection:T; + /** 根未变则跳过计算;根变但结果等价也保持 selection 引用,只更新已观察的根身份。 */ function getSnapshot():T{ const current=source.getSnapshot();if(initialized&&Object.is(snapshot,current))return selection; const next=selector(current),same=initialized&&isEqual(selection,next); snapshot=current;if(!same)selection=next;initialized=true;return selection; } + // 每个订阅单独跟踪 observed;计算失败仍通知,让使用方在自己的读取/错误边界重新处理。 return Object.freeze({getSnapshot,subscribe(notify:()=>void){let observed=getSnapshot();return source.subscribe(()=>{let next:T;try{next=getSnapshot();}catch{notify();return;}if(!Object.is(observed,next)){observed=next;notify();}});}}); } diff --git a/src/lib/canvas-bindings/session-frames.ts b/src/lib/canvas-bindings/session-frames.ts index 8c4c2cc..3a8a920 100644 --- a/src/lib/canvas-bindings/session-frames.ts +++ b/src/lib/canvas-bindings/session-frames.ts @@ -1,14 +1,21 @@ +/** + * 交互会话通知的显示帧合并。每个订阅拥有一条 frame queue,读快照不等帧。 + * 这里只发现视图和手势的显示失效;选择、工具及预览事实继续保存在原 session。 + */ import type {SessionSnapshot} from '../canvas-core/gestures.ts'; import type {ExternalSource} from './selectors.ts'; import {createFrameQueue,type FrameClock} from './frame-queue.ts'; /** One subscription owns one disposable frame queue. Reads always see current sessions. */ +/** 对比前后会话引用生成 view/element 脏 key;退订时同步撤销帧队列。 */ export function createSessionFrameSource(source:ExternalSource,clock:FrameClock):ExternalSource{ return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){ let previous=source.getSnapshot();const queue=createFrameQueue(clock,()=>notify()); + // 两次遍历覆盖视图新增/删除;手势结束要同时刷新旧目标,才能去掉它们的预览。 const stop=source.subscribe(()=>{const next=source.getSnapshot(),dirty=new Set(); for(const [id,view]of previous.views)if(next.views.get(id)!==view)dirty.add(`view:${id}`); for(const [id,view]of next.views)if(previous.views.get(id)!==view)dirty.add(`view:${id}`); if(previous.gesture!==next.gesture)for(const state of [previous.gesture,next.gesture])for(const target of state?.targets??[])dirty.add(`element:${target.id}`); + // 连线手势与普通几何手势分别跟踪,改变或取消时都让旧/新目标失效。 if(previous.connectionGesture!==next.connectionGesture)for(const state of [previous.connectionGesture,next.connectionGesture])if(state)dirty.add(`element:${state.target.id}`); previous=next;queue.invalidate(dirty); });return()=>{stop();queue.dispose();}; diff --git a/src/lib/canvas-bindings/session-sources.ts b/src/lib/canvas-bindings/session-sources.ts index 70de203..0808a25 100644 --- a/src/lib/canvas-bindings/session-sources.ts +++ b/src/lib/canvas-bindings/session-sources.ts @@ -1,19 +1,30 @@ +/** + * 按 view/element 分桶的交互会话订阅。多个元素共享一个上游订阅,变化只路由到相关桶。 + * 派生源保留选中值与观察器,不保存第二份完整 session;最后一个观察器离开后退订上游。 + */ import type {Id,Target,Geometry} from '../canvas-core/types.ts'; import type {SessionSnapshot,GestureState} from '../canvas-core/gestures.ts'; import {equal} from '../canvas-core/values.ts'; import type {ExternalSource} from './selectors.ts'; +/** preview 可被所有匹配代次的视图读取,active 则只标记拥有该手势的视图。 */ export type GestureSelection={readonly preview:Geometry|undefined;readonly active:boolean}; /** Derived view/target subscriptions. Values retain no document or full per-hook session. */ +/** 建立按视图模式和元素手势分离的订阅通道,比较派生值后才通知观察者。 */ export function createSessionSources(source:ExternalSource){ + // update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。 type Observer={update(snapshot:SessionSnapshot):boolean;notify():void}; const modes=new Map>>(),gestures=new Map>(); + // 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。 const targetIndexes=new WeakMap>(); let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined; + /** 首次遇到一组目标时建索引;相同 ID 的新 incarnation 不会匹配旧手势。 */ function targets(gesture:GestureState){let index=targetIndexes.get(gesture.targets);if(!index){index=new Map(gesture.targets.map(target=>[target.id,target.incarnation]));targetIndexes.set(gesture.targets,index);}return index;} + /** 收集可能受影响的观察器,全部 update 完再逐个 notify;一个监听器失败也不饿死其余监听器。 */ function changed(){ if(!previous)return;const before=previous,next=source.getSnapshot();previous=next; const candidates=new Set(),add=(items:Set|undefined)=>{for(const item of items??[])candidates.add(item);}; + // 只检查实际有订阅的视图;模式 Map 或对应元素模式未变时不通知。 if(before.views!==next.views)for(const[viewId,byId]of modes){ const old=before.views.get(viewId)?.modes,current=next.views.get(viewId)?.modes;if(old===current)continue; for(const id of new Set([...(old?.keys()??[]),...(current?.keys()??[])]))if(old?.get(id)!==current?.get(id))add(byId.get(id)); @@ -21,6 +32,7 @@ export function createSessionSources(source:ExternalSource){ const old=before.gesture,current=next.gesture; if(old!==current){ const ids=new Set(); + // 手势身份/视图/目标集合变化要刷新全组;同一手势只有 preview 变时只刷新预览变化的元素。 if(!old||!current||old.token!==current.token||old.viewId!==current.viewId||old.targets!==current.targets){for(const gesture of [old,current])for(const target of gesture?.targets??[])ids.add(target.id);} else if(old.preview!==current.preview){for(const[id,value]of old.preview)if(current.preview.get(id)!==value)ids.add(id);for(const[id,value]of current.preview)if(old.preview.get(id)!==value)ids.add(id);} for(const id of ids)add(gestures.get(id)); @@ -28,6 +40,7 @@ export function createSessionSources(source:ExternalSource){ const notifications:Observer[]=[];for(const observer of candidates)if(observer.update(next))notifications.push(observer); let failed=false,error:unknown;for(const observer of notifications)try{observer.notify();}catch(caught){if(!failed){failed=true;error=caught;}}if(failed)throw error; } + /** 一个派生通道的稳定读取和订阅装配。读取失败通过通知触发使用方重读,不把错误吞成旧成功值。 */ function channel(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource{ let initialized=false,value:T; return Object.freeze({ @@ -36,16 +49,20 @@ export function createSessionSources(source:ExternalSource){ let observed=read(source.getSnapshot()),failed=false; const observer:Observer={notify,update(snapshot){try{const next=read(snapshot);if(!failed&&equal(observed,next))return false;observed=next;failed=false;}catch{failed=true;}return true;}}; const remove=register(observer); + // 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。 if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}} + // 单次退订幂等;所有桶清空后释放全局上游订阅及上一份会话快照。 let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}}; }, }); } return Object.freeze({ + /** 派生某视图中单元素的编辑模式;视图/模式缺失时返回 null。 */ mode(viewId:Id,id:Id):ExternalSource{return channel(snapshot=>snapshot.views.get(viewId)?.modes.get(id)??null,observer=>{ let byId=modes.get(viewId);if(!byId){byId=new Map();modes.set(viewId,byId);}let bucket=byId.get(id);if(!bucket){bucket=new Set();byId.set(id,bucket);}bucket.add(observer); return()=>{bucket!.delete(observer);if(!bucket!.size)byId!.delete(id);if(!byId!.size)modes.delete(viewId);}; });}, + /** 按目标 ID+代次读取预览;同元素跨视图可见预览,但只有原 viewId 拥有 active。 */ gesture(viewId:Id,target:Target):ExternalSource{const{id,incarnation}=target;return channel(snapshot=>{ const gesture=snapshot.gesture,matched=!!gesture&&targets(gesture).get(id)===incarnation; return{preview:matched?gesture!.preview.get(id):undefined,active:matched&&gesture!.viewId===viewId}; diff --git a/src/lib/canvas-bindings/visibility.ts b/src/lib/canvas-bindings/visibility.ts index 2d12c21..44ac7e2 100644 --- a/src/lib/canvas-bindings/visibility.ts +++ b/src/lib/canvas-bindings/visibility.ts @@ -1,3 +1,8 @@ +/** + * 原生节点视口裁剪的最小几何判断,不读取文档或维护渲染状态。 + */ import type {Rect} from '../canvas-core/geometry.ts'; /** Positive-area native node visibility. Degenerate nodes are retained for measurement. */ +/** 正面积矩形要求与视口有正面积交叠,只有边界接触不算可见。 + * 宽/高非正的退化节点保留,让 renderer 有机会测量它,而不是提前裁掉。 */ export function nativeRectVisible(node:Rect,viewport:Rect){if(node.width<=0||node.height<=0)return true;return Math.min(node.x+node.width,viewport.x+viewport.width)>Math.max(node.x,viewport.x)&&Math.min(node.y+node.height,viewport.y+viewport.height)>Math.max(node.y,viewport.y);}