36 lines
4.0 KiB
TypeScript
36 lines
4.0 KiB
TypeScript
/**
|
||
* 将Portal中的节点浮层注册到视图OverlayIndex,提供屏幕边界、焦点及输入法组合态。
|
||
* 索引用于输入路由与浮层归属判断;DOM标记与事件监听在ref更换或卸载时清理,不写入文档。
|
||
*/
|
||
"use client";
|
||
import {createContext,useContext,useCallback,useLayoutEffect,useId,useRef} from 'react';
|
||
import {useViewport} from '@xyflow/react';
|
||
import type {OverlayIndex} from '@/lib/canvas-bindings/overlays';
|
||
import type {ProjectEngine} from '@/lib/canvas-runtime/workspace';
|
||
/** 新版Provider提供视图索引与engine;null允许旧编辑器或无engine的展示组件直接复用Hook。 */
|
||
export const CanvasOverlays=createContext<{index:OverlayIndex;engine:ProjectEngine}|null>(null);
|
||
/** Optional for legacy/engine-less renderers; a portal keeps its explicit element target. */
|
||
/** 返回DOM ref回调。注册目标包含id/incarnation,避免同ID元素重建后仍沿用旧浮层身份。
|
||
* useId区分组件实例,placement区分用途;Portal位置不影响目标归属。 */
|
||
export function useCanvasOverlay(targetId:string,placement:string){
|
||
const scope=useContext(CanvasOverlays),instance=useId(),refresh=useRef<()=>void>(()=>{}),cleanup=useRef<()=>void>(()=>{});
|
||
// 订阅视口变化触发重渲染,后续layout effect再读取浮层屏幕矩形。
|
||
useViewport();
|
||
const incarnation=scope?.engine.getSnapshot().document.elements.get(targetId)?.incarnation;
|
||
// 每次换DOM或作用域先清理旧注册;缺少DOM/Provider/元素生命周期时保持空注册。
|
||
const ref=useCallback((element:HTMLElement|null)=>{cleanup.current();refresh.current=()=>{};cleanup.current=()=>{};if(!element||!scope||incarnation===undefined)return;
|
||
// 随机owner标记区分同一DOM上的新旧绑定,旧清理不能删除新绑定写入的数据属性。
|
||
const marker=crypto.randomUUID();element.dataset.canvasOverlayView=scope.index.viewId;element.dataset.canvasOverlayOwner=marker;element.dataset.canvasOverlayTarget=targetId;element.dataset.canvasOverlayPlacement=placement;
|
||
// getBoundingClientRect给出屏幕坐标,四周扩大8px作为输入命中边界;焦点读取当前activeElement。
|
||
let composing=false;const value=()=>{const rect=element.getBoundingClientRect();return{placement,target:{id:targetId,incarnation},bounds:{x:rect.x-8,y:rect.y-8,width:rect.width+16,height:rect.height+16},focused:element.contains(document.activeElement),composing};};
|
||
// 注册返回lease,后续测量只更新这一条;composition事件同步更新组合态供快捷键路由参考。
|
||
const lease=scope.index.register(instance,value()),measure=()=>lease.update(value()),start=()=>{composing=true;measure();},end=()=>{composing=false;measure();};refresh.current=measure;
|
||
// focusout后等微任务再测量,让新焦点落定;ResizeObserver与首帧测量补充尺寸/挂载后的变化。
|
||
const blur=()=>queueMicrotask(measure),observer=new ResizeObserver(measure);observer.observe(element);element.addEventListener('focusin',measure);element.addEventListener('focusout',blur);element.addEventListener('compositionstart',start);element.addEventListener('compositionend',end);let frame=requestAnimationFrame(measure);
|
||
// 清除本绑定拥有的DOM标记、首帧任务、观察器和所有监听,再释放索引租约;不删除目标元素。
|
||
cleanup.current=()=>{if(element.dataset.canvasOverlayOwner===marker){delete element.dataset.canvasOverlayView;delete element.dataset.canvasOverlayOwner;delete element.dataset.canvasOverlayTarget;delete element.dataset.canvasOverlayPlacement;}cancelAnimationFrame(frame);observer.disconnect();element.removeEventListener('focusin',measure);element.removeEventListener('focusout',blur);element.removeEventListener('compositionstart',start);element.removeEventListener('compositionend',end);lease.release();};
|
||
},[scope,instance,targetId,incarnation,placement]);
|
||
// 每次布局提交刷新边界;卸载清理使用ref中的最新函数,兼容期间发生的DOM/目标替换。
|
||
useLayoutEffect(()=>{refresh.current();});useLayoutEffect(()=>()=>cleanup.current(),[]);return ref;
|
||
}
|