docs(bindings): explain input ownership and subscription lifecycle
This commit is contained in:
@@ -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<K>(clock:FrameClock,publish:(dirty:ReadonlySet<K>)=>void){
|
||||
let dirty=new Set<K>(),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<K>){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<S>(source:{getSnapshot():S;subscribe(notify:(immediate?:boolean)=>void):()=>void},clock:FrameClock):ExternalSource<S>{
|
||||
return Object.freeze({getSnapshot:()=>source.getSnapshot(),subscribe(notify:()=>void){
|
||||
const queue=createFrameQueue<number>(clock,()=>notify());
|
||||
|
||||
@@ -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<CanvasKeyInput,'textEditing'|'overlay'>{
|
||||
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';
|
||||
|
||||
@@ -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<PointerCapturePort>|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. */}}
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
/** One immutable source, one cached selection; no second document or operation state. */
|
||||
/** 最小快照/订阅协议,可由 editor、session 或派生源实现,订阅返回释放函数。 */
|
||||
export type ExternalSource<S>={getSnapshot():S;subscribe(notify:()=>void):()=>void};
|
||||
/** 用 selector 与等价判断派生稳定快照;等价的新值沿用旧引用,避免 UI 的无效刷新。 */
|
||||
/** 一个 store 共享一份最近源快照/selection 缓存,每个订阅另存 observed 引用。
|
||||
* selector/isEqual 须同步且适合不可变源;不 clone/freeze 选中值,不修复上游原地修改后身份不变的问题。 */
|
||||
export function createSelectorStore<S,T>(source:ExternalSource<S>,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();}});}});
|
||||
}
|
||||
|
||||
@@ -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<SessionSnapshot>,clock:FrameClock):ExternalSource<SessionSnapshot>{
|
||||
return Object.freeze({getSnapshot:source.getSnapshot,subscribe(notify:()=>void){
|
||||
let previous=source.getSnapshot();const queue=createFrameQueue<string>(clock,()=>notify());
|
||||
@@ -17,6 +19,7 @@ export function createSessionFrameSource(source:ExternalSource<SessionSnapshot>,
|
||||
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();};
|
||||
}});
|
||||
|
||||
@@ -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<SessionSnapshot>){
|
||||
// update 先推进内部观察值,notify 后执行外部回调,避免某监听器读到其它桶尚未更新。
|
||||
type Observer={update(snapshot:SessionSnapshot):boolean;notify():void};
|
||||
@@ -18,9 +20,11 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
|
||||
// 按不可变 targets 数组的身份弱缓存 ID→代次索引,避免每个元素订阅逐次线性搜索目标列表。
|
||||
const targetIndexes=new WeakMap<readonly Target[],ReadonlyMap<Id,number>>();
|
||||
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<Observer>(),add=(items:Set<Observer>|undefined)=>{for(const item of items??[])candidates.add(item);};
|
||||
@@ -40,7 +44,9 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
|
||||
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<T>(read:(snapshot:SessionSnapshot)=>T,register:(observer:Observer)=>()=>void):ExternalSource<T>{
|
||||
let initialized=false,value:T;
|
||||
return Object.freeze({
|
||||
@@ -51,18 +57,21 @@ export function createSessionSources(source:ExternalSource<SessionSnapshot>){
|
||||
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<string|null>{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<GestureSelection>{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};
|
||||
|
||||
Reference in New Issue
Block a user