diff --git a/src/lib/canvas-bindings/frame-queue.ts b/src/lib/canvas-bindings/frame-queue.ts index d0c1744..dcc2299 100644 --- a/src/lib/canvas-bindings/frame-queue.ts +++ b/src/lib/canvas-bindings/frame-queue.ts @@ -6,23 +6,28 @@ 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 在一次批次中只出现一次。 */ +/** 创建一条可手动 flush 的通知队列;Set 按键身份去重,并保留首次插入顺序。 + * clock/publish 由宿主实现,不读取 DOM/模型;调度器异常原样抛出,不另设重试或全局错误处理。 */ export function createFrameQueue(clock:FrameClock,publish:(dirty:ReadonlySet)=>void){ let dirty=new Set(),scheduled:{handle?:unknown}|undefined,disposed=false; - /** 同步排空已有脏项,先换新 Set 再回调,回调中的新失效留给下一批,不会被本批 clear 擦掉。 */ + /** 同步排空:先撤当前调度并换新 Set,再 publish,回调中的 invalidate 留给下一批。 + * batch 以 ReadonlySet 类型借出但未运行时冻结,与新 dirty 已分离;publish 抛错不把本批自动放回队列。 */ 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 对象身份防止已取消的旧回调误刷后来的批次。 */ + /** 逐项加入 dirty,至多安排一个帧回调;pending 对象身份防旧回调误刷后来一批。 + * request 抛错会清 scheduled,但保留 dirty 供后续 invalidate/flush 重试;不自动再 request。 */ 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。 */ + /** 标记 disposed 后尽力取消帧并清 dirty;后续 invalidate/flush 无效,不替上游关闭 editor。 + * 这里不捕获 clock.cancel 异常,宿主取消端口须可靠清理;重复 dispose 不会重试一次失败取消。 */ 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,常用于不能等下一帧才反映的状态边界。 */ + * 上游标 immediate 时先 invalidate 再同步 flush,合并的旧失效也一起通知;读取从不等待帧。 + * 同一工厂的不同订阅不共享队列;退订函数每次都会调用上游 stop,幂等性依赖该 stop 合同。 */ 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()); diff --git a/src/lib/canvas-bindings/input-router.ts b/src/lib/canvas-bindings/input-router.ts index f743e73..d0b0aac 100644 --- a/src/lib/canvas-bindings/input-router.ts +++ b/src/lib/canvas-bindings/input-router.ts @@ -16,7 +16,8 @@ const overlayTarget = '[data-canvas-overlay-view],[role="dialog"],[role="menu"], /** 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 父关系。 */ + * portal 需要自身携带浮层标记/角色;这里只检查实际路径,不推断不存在于 DOM 路径的 React 父关系。 + * closest 可查节点的祖先;两个标记独立累计,可同时为 true。DOM 端口异常原样传播,不在此兜底成未编辑。 */ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:()=>readonly unknown[]}):Pick{ let textEditing=false,overlay=false; for(const target of [event.target,...(event.composedPath?.()??[])]){ @@ -28,7 +29,9 @@ export function canvasKeyContext(event:{readonly target?:unknown;composedPath?:( } return {textEditing,overlay}; } -/** 已被宿主处理的事件不再认领;输入法/文本与浮层直接截住,随后按固定优先级短路调用处理器。 */ +/** 已 defaultPrevented 的事件先返回 unclaimed,即使同时是文本/浮层输入也不再次认领。 + * composing/textEditing 返回 text,overlay 返回 overlay;这两层只是上下文判断,不调用下方 handler。 + * gesture→tool→shortcut 同步短路,抛错直接传播;返回 claim 不会自行 preventDefault/stopPropagation。 */ 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/pointer-capture.ts b/src/lib/canvas-bindings/pointer-capture.ts index ee774db..ebe9dba 100644 --- a/src/lib/canvas-bindings/pointer-capture.ts +++ b/src/lib/canvas-bindings/pointer-capture.ts @@ -6,9 +6,11 @@ /** 最小结构化 DOM 端口,让纯测试无需真实 HTMLElement。 */ export type PointerCapturePort={setPointerCapture(id:number):void;hasPointerCapture(id:number):boolean;releasePointerCapture(id:number):void}; /** 普通事件匹配 pointerId 即可;lostpointercapture 还要匹配原捕获目标且确认它已失去捕获。 - * 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 */ + * 如果同目标已重新捕获该指针,旧 lost 事件不拥有新手势;脱离 DOM 导致查询抛错则允许匹配目标清理。 + * 缺 hasPointerCapture 的匹配目标也视为已丢失;此函数不释放 DOM 捕获、不提交/取消 editor 手势。 */ 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;} -/** 平台可能因指针失效/节点卸载拒绝捕获,以布尔结果交给宿主决定是否继续手势。 */ +/** setPointerCapture 未抛错则返回 true,不再调用 hasPointerCapture 确认平台状态。 + * 平台拒绝/节点卸载等异常统一 false,具体是否继续/取消手势仍由调用方决定。 */ 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 cdf582d..d5c5166 100644 --- a/src/lib/canvas-bindings/selectors.ts +++ b/src/lib/canvas-bindings/selectors.ts @@ -5,15 +5,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 的无效刷新。 */ +/** 一个 store 共享一份最近源快照/selection 缓存,每个订阅另存 observed 引用。 + * selector/isEqual 须同步且适合不可变源;不 clone/freeze 选中值,不修复上游原地修改后身份不变的问题。 */ 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 引用,只更新已观察的根身份。 */ + /** 根身份相同则跳过 selector;新根等价则沿用旧 selection,仅推进已观察源身份。 + * selector/isEqual 抛错发生在缓存赋值前,原缓存保留,getSnapshot 仍向调用者抛错。 */ 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;计算失败仍通知,让使用方在自己的读取/错误边界重新处理。 + // subscribe 先同步读取初值(失败则尚未接上游);每个 observer 独立比较 selection 身份。 + // 上游通知时计算失败仍 notify,供使用方重读/错误边界处理;不把 notify 的异常吞成成功。 + // 直接返回上游 stop,无额外 dispose:退订幂等性、异常与通知时序沿用源合同。 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 3a8a920..edb89ad 100644 --- a/src/lib/canvas-bindings/session-frames.ts +++ b/src/lib/canvas-bindings/session-frames.ts @@ -6,7 +6,9 @@ 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;退订时同步撤销帧队列。 */ +/** 每个订阅保留上一份 session 引用,按不可变 view/gesture 身份发现失效,任意 dirty 最终只触发 notify()。 + * key 集合是帧内去重依据,不向消费者发布逐元素差分;getSnapshot 直接借用源方法,须不依赖 this 绑定。 + * 此包装不使用 immediate 标志,也不对上游 subscribe/stop 抛错作 finally 清理,宿主须保证正常订阅生命周期。 */ 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()); @@ -17,6 +19,7 @@ export function createSessionFrameSource(source:ExternalSource, 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 再排队,因此一帧内多次变化相对每次上游快照计算,不只比较帧起点与终点。 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 0808a25..36424b4 100644 --- a/src/lib/canvas-bindings/session-sources.ts +++ b/src/lib/canvas-bindings/session-sources.ts @@ -10,7 +10,9 @@ 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. */ -/** 建立按视图模式和元素手势分离的订阅通道,比较派生值后才通知观察者。 */ +/** 建立按视图模式和几何手势分离的通道;只追踪 modes/gesture,不把 selection/viewport/connectionGesture 混入。 + * modes 是 viewId→elementId→观察器桶,gestures 是 elementId→观察器桶,同一上游变化先选候选再比值。 + * 有观察器时 previous 持有一份上游快照引用供比较,不复制完整 session;最后退订后释放该引用。 */ export function createSessionSources(source:ExternalSource){ // update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。 type Observer={update(snapshot:SessionSnapshot):boolean;notify():void}; @@ -18,9 +20,11 @@ export function createSessionSources(source:ExternalSource){ // 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。 const targetIndexes=new WeakMap>(); let stop:(()=>void)|undefined,previous:SessionSnapshot|undefined; - /** 首次遇到一组目标时建索引;相同 ID 的新 incarnation 不会匹配旧手势。 */ + /** 按 targets 数组身份缓存 elementId→incarnation,要求上游不原地改 targets。 + * 合法会话应保证同组 ID 唯一;Map 对重复 ID 留最后一项,此 helper 不另验目标表形状。 */ 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;一个监听器失败也不饿死其余监听器。 */ + /** 在 changed 入口取得同一份 next,先推进 previous,再用 Set 合并所有候选,避免观察器重复更新。 + * 先更新全部候选 observed 后 notify;各 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);}; @@ -40,7 +44,9 @@ 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; } - /** 一个派生通道的稳定读取和订阅装配。读取失败通过通知触发使用方重读,不把错误吞成旧成功值。 */ + /** 每个 channel 独享 getSnapshot 的派生值缓存;每个订阅另存 observed,二者不靠回调共享可变业务数据。 + * equal 深比较派生 JSON 风格值,等价时保留旧引用;选中 preview 仍借用原不可变 Geometry,不做深克隆。 + * observer.read 失败会标记失败并通知,后续成功也通知一次恢复;直接 getSnapshot 的读取异常仍向外抛出。 */ function channel(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource{ let initialized=false,value:T; return Object.freeze({ @@ -51,18 +57,21 @@ export function createSessionSources(source:ExternalSource){ const remove=register(observer); // 第一个观察器才连上游;注册失败撤回本次桶,防止留下无人管理的观察器。 if(!stop){previous=source.getSnapshot();try{stop=source.subscribe(changed);}catch(error){remove();previous=undefined;throw error;}} - // 单次退订幂等;所有桶清空后释放全局上游订阅及上一份会话快照。 + // 退订 token 先置 inactive,remove 清桶;最后一桶消失才 stop 上游并清 previous。 + // stop 异常不在这里捕获,状态重置不保证继续执行;正常宿主清理端口须不抛错。 let active=true;return()=>{if(!active)return;active=false;remove();if(!modes.size&&!gestures.size){stop?.();stop=undefined;previous=undefined;}}; }, }); } return Object.freeze({ - /** 派生某视图中单元素的编辑模式;视图/模式缺失时返回 null。 */ + /** 捕获 viewId/id 两个标量,按 view→element 桶登记观察器;视图/模式缺失返回 null。 + * 调用 mode 只创建派生通道,首次 subscribe 才注册桶和必要的上游监听。 */ 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。 */ + /** 入口捕获 target.id/incarnation,之后改调用者 target 不影响本通道;订阅按 ID 分桶,读值再匹配代次。 + * 匹配元素跨视图可见 preview,只有 gesture.viewId 对应视图 active;没有 preview 不代表不在手势 targets 中。 */ 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};