37 lines
4.5 KiB
TypeScript
37 lines
4.5 KiB
TypeScript
/**
|
||
* 视图局部的屏幕空间浮层索引。保存位置、目标代次与焦点/输入法状态,独立于业务文档和撤销历史。
|
||
* 同 ID 浮层重新注册时获得新 token,旧组件迟到清理不能删掉新浮层。
|
||
*/
|
||
import type {Target} from '../canvas-core/types.ts';
|
||
import type {Rect} from '../canvas-core/geometry.ts';
|
||
import {createPaintIndex} from './paint-index.ts';
|
||
import {fault} from '../canvas-core/values.ts';
|
||
/** bounds 使用屏幕空间;target 仍是真实元素 ID+代次,viewId 限定所属交互视图。 */
|
||
export type OverlayEntry={readonly id:string;readonly viewId:string;readonly placement:string;readonly target:Target;readonly bounds:Rect;readonly focused:boolean;readonly composing:boolean};
|
||
/** Ephemeral screen-space placements. A lease prevents late cleanup from removing a replacement. */
|
||
/** entries 保存 overlayId→{token,entry},snapshot 沿用 Map 插入顺序(替换同 ID 不移位),index 只按屏幕 bounds 查询。
|
||
* 不订阅文档/视口,不自行核 target 存活;调用者更新位置,并在 visible 时提供当前目标有效性判断。 */
|
||
export function createOverlayIndex(viewId:string){
|
||
if(typeof viewId!=='string'||!viewId)throw fault('view_session_required');let serial=0,snapshot:readonly OverlayEntry[]=Object.freeze([]);const entries=new Map<string,{token:number;entry:OverlayEntry}>(),listeners=new Set<()=>void>(),index=createPaintIndex();
|
||
/** 先更新 snapshot/index,再同步通知监听集合副本;只发布位置事实,不挂载 DOM。
|
||
* 监听器异常直接传播且会中断后续通知,不回滚已发布条目;不能据 register/update 抛错推断它未生效。 */
|
||
function publish(){snapshot=Object.freeze([...entries.values()].map(row=>row.entry));index.sync(snapshot.map(entry=>({id:entry.id,bounds:entry.bounds})));for(const listener of [...listeners])listener();}
|
||
/** 重建并冻结 entry 外壳/target/bounds,校验 ID/placement/目标代次和合法矩形。
|
||
* focused/composing 的布尔值由类型合同约束,此处不另验;扩展字段浅保留,不是任意对象深克隆。 */
|
||
function capture(id:string,value:Omit<OverlayEntry,'id'|'viewId'>):OverlayEntry{if(typeof id!=='string'||!id||typeof value.placement!=='string'||!value.placement||typeof value.target.id!=='string'||!value.target.id||!Number.isSafeInteger(value.target.incarnation)||value.target.incarnation<1)throw fault('invalid_overlay');const entry=Object.freeze({...value,id,viewId,target:Object.freeze({...value.target}),bounds:Object.freeze({...value.bounds})});const check=createPaintIndex();check.sync([{id,bounds:entry.bounds}]);return entry;}
|
||
// 订阅只关注本视图位置变化,调用方需用返回函数解除监听;getSnapshot 返回稳定的最近发布值。
|
||
return Object.freeze({viewId,getSnapshot:()=>snapshot,subscribe(listener:()=>void){listeners.add(listener);return()=>{listeners.delete(listener);};},
|
||
/** 注册或替换一个浮层,返回只能操作本 token 的 update/release。
|
||
* 注册时 ++serial 分配 token,覆盖注册使旧租约失效;update 先检查 token,再捕获并按 JSON 文本比较。
|
||
* 等值 update 不发布,register 替换始终发布;release/clear 只移除瞬态浮层,不删除元素。 */
|
||
register(id:string,value:Omit<OverlayEntry,'id'|'viewId'>){const token=++serial,entry=capture(id,value);entries.set(id,{token,entry});publish();return Object.freeze({update(value:Omit<OverlayEntry,'id'|'viewId'>){if(entries.get(id)?.token!==token)return;const entry=capture(id,value),old=entries.get(id)!.entry;if(JSON.stringify(old)===JSON.stringify(entry))return;entries.set(id,{token,entry});publish();},release(){if(entries.get(id)?.token!==token)return;entries.delete(id);publish();}});},
|
||
/** 先验证查询矩形并查网格,再按 snapshot 顺序筛选:current(target) 必须真,之后才考虑焦点/组合状态或交集。
|
||
* 焦点/输入法保留不会绕过目标有效性,也不自动移除过期 entries;返回新数组,不发布位置更新。 */
|
||
visible(bounds:Rect,current:(target:Target)=>boolean){const ids=index.query(bounds);return snapshot.filter(entry=>current(entry.target)&&(entry.focused||entry.composing||ids.has(entry.id)));},
|
||
/** 清空所有当前浮层并发布空快照;不关闭监听服务,后续仍可注册新租约。 */
|
||
clear(){entries.clear();publish();},
|
||
});
|
||
}
|
||
/** 由工厂推导接口,调用方无权直接操作内部 token/Map。 */
|
||
export type OverlayIndex=ReturnType<typeof createOverlayIndex>;
|