/** * 节点组件与画布控制器之间的动作上下文。Provider实现文本、名称、样式、几何、分区及编辑模式修改。 * useScopedNodeEditing为新版渲染回调追加目标身份/归属检查,旧版无Overlay上下文时沿用原接口。 * 本模块不拥有文档、history或队列;它只把界面动作交给正确的控制器。 */ "use client"; import {createContext,useContext,useMemo} from 'react'; import type {XYPosition} from '@xyflow/react'; import type {NodeDirection} from '@/lib/canvas'; import type {TextStyle} from '@/lib/canvas-elements'; import type {NodeTransform} from '@/lib/canvas-geometry'; import type {EditingLease} from './editing-lease'; import type {RenderedTarget} from '@/lib/canvas-bindings/compat-types'; import {CanvasOverlays} from './canvas-overlays'; /** 默认动作为空实现,便于节点展示独立挂载;可选手势钩子兼容旧版调用与新版EditingLease。 * rotating/resizing是Provider提供的当前模式标记,workspace用于构造导航,schema区分适配路径。 */ const defaultEditing = { beginTransform: undefined as undefined | ((id: string) => boolean | void | EditingLease), finishTransform: undefined as undefined | (() => void), cancelTransform: undefined as undefined | (() => void), endEditing: undefined as undefined | ((id: string) => void), workspace: "", schema: 1 as 1 | 2, // begin/select/edit分别供编辑会话、选择与正文更新使用;branch取得屏幕坐标以定位分支菜单。 begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {}, // 几何、文字样式、标题和分区属性分别走独立动作,避免组件直接合并整份节点数据。 transform: (_id: string, _transform: NodeTransform) => {}, style: (_id: string, _style: TextStyle, _fontSize?: number) => {}, rename: (_id: string, _title: string) => {}, frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {}, // 传元素id开启相应模式,null请求退出;是否确属当前元素在作用域包装中进一步检查。 rotating: null as string | null, rotate: (_id: string | null) => {}, resizing: null as string | null, resize: (_id: string | null) => {}, }; /** 这些生命周期钩子可缺省,其余基础动作保留默认接口形状。 */ type GestureHooks = 'beginTransform' | 'finishTransform' | 'cancelTransform' | 'endEditing'; /** 由具体画布注入实现;默认上下文不会自行创建一个编辑器实例。 */ export const NodeEditing = createContext & Partial>>(defaultEditing); /** Bind legacy renderer actions to the lifetime and placement that rendered them. */ /** 捕获本次渲染目标的incarnation、ownerCanvasId与视图canvasId,再为每次动作读取最新状态。 * 捕获值代表界面当时展示的对象,最新值用来拒绝过期回调;不能只凭相同元素id就认为仍是同一对象。 */ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){ // 只有rendered.id匹配当前节点才采用incarnation;有新版scope却缺少合法rendered身份时拒绝编辑。 const editing=useContext(NodeEditing),scope=useContext(CanvasOverlays),incarnation=rendered?.id===id?rendered.incarnation:undefined,owner=rendered?.ownerCanvasId,canvas=rendered?.canvasId; // current 在动作触发时读取快照,不建立订阅;后续状态变化不会仅因这个检查而触发组件重渲染。 // 无新版scope直接返回旧接口;否则只在接口、视图作用域或目标身份变化时重建包装。 return useMemo(()=>{if(!scope)return editing; // 动作当刻检查engine未关闭、视图仍存在且仍在原canvas、元素生命周期与owner未变。 // candidate必须是本节点(或允许的空参数),阻止一个节点的包装替其它目标发起操作。 const current=(candidate:string|null|undefined=id)=>{const durability=scope.engine.getDurability(),view=scope.engine.sessions.getSnapshot().views.get(scope.index.viewId),record=scope.engine.getSnapshot().document.elements.get(id);return !durability.closing&&!durability.closed&&!!view&&view.canvasId===canvas&&incarnation!==undefined&&record?.incarnation===incarnation&&record.ownerCanvasId===owner&&(candidate===undefined||candidate===null||candidate===id);}; // finish/cancel没有id参数,额外确认当前手势属于本视图,且目标列表含本id/incarnation。 // 这里不捕获 gesture token;同视图同目标的新手势也能通过,精确手势身份须由创建者的租约保护。 const ownGesture=()=>scope.engine.sessions.getSnapshot().gesture?.viewId===scope.index.viewId&&scope.engine.sessions.getSnapshot().gesture?.targets.some(target=>target.id===id&&target.incarnation===incarnation); // 拷贝展示信息,仅替换动作入口;文本/样式/几何等动作逐次检查,不缓存“曾经有效”的结论。 const scoped:typeof editing={...editing, begin:candidate=>{if(current(candidate))editing.begin(candidate??id);},select:candidate=>{if(current(candidate))editing.select(candidate);}, edit:(candidate,text)=>{if(current(candidate))editing.edit(candidate,text);},rename:(candidate,title)=>{if(current(candidate))editing.rename(candidate,title);}, style:(candidate,style,size)=>{if(current(candidate))editing.style(candidate,style,size);},frame:(candidate,patch)=>{if(current(candidate))editing.frame(candidate,patch);}, transform:(candidate,change)=>{if(current(candidate))editing.transform(candidate,change);},branch:(candidate,direction,point)=>{if(current(candidate))editing.branch(candidate,direction,point);}, // 退出模式用null表示,但只准退出本节点当前拥有的rotate/resize模式,避免关闭其它节点的模式。 rotate:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='rotate'))editing.rotate(candidate);},resize:candidate=>{if(current(candidate)&&(candidate!==null||scope.engine.sessions.getSnapshot().views.get(scope.index.viewId)?.modes.get(id)==='resize'))editing.resize(candidate);}, }; // 保持可选钩子的存在性:失效时beginTransform返回false,其余动作静默忽略。 // begin取得的租约由创建者管理;这里不把旧事件转给当前另一个手势。 if(editing.beginTransform)scoped.beginTransform=candidate=>current(candidate)?editing.beginTransform!(candidate):false; if(editing.endEditing)scoped.endEditing=candidate=>{if(current(candidate))editing.endEditing!(candidate);}; // 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。 if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();}; if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();}; // 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。 return scoped; },[editing,scope,id,incarnation,owner,canvas]); }