From f28d3810d4daa38488d444dc1470a2f09576c3b8 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 12 Nov 2026 16:18:24 +0800 Subject: [PATCH] docs(canvas): clarify editing guards and projection lifetimes --- src/components/canvas/canvas-text-editor.tsx | 3 +++ src/components/canvas/editing-lease.ts | 3 ++- src/components/canvas/node-editing.ts | 4 +++- src/components/canvas/node-internals.ts | 4 +++- src/components/canvas/node-title.tsx | 2 ++ src/components/canvas/retained-projection.ts | 2 ++ src/components/canvas/text-appearance.ts | 1 + 7 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/canvas/canvas-text-editor.tsx b/src/components/canvas/canvas-text-editor.tsx index c162eef..e3e5bbd 100644 --- a/src/components/canvas/canvas-text-editor.tsx +++ b/src/components/canvas/canvas-text-editor.tsx @@ -29,10 +29,13 @@ export function CanvasTextEditor({ id, data, selected, plain = false }: { id: st element?.setSelectionRange(element.value.length, element.value.length); }, [editing]); /** 通知控制器开始文本编辑会话,再开启本地输入态;真正可写还要求节点保持选中。 */ + // begin 没有成功返回值约定;即使作用域包装静默拒绝动作,本地 editing 仍被设 true,文档修改仍由动作护栏判定。 function begin() { actions.begin(id); setEditing(true); } // 同时满足editing与selected才允许输入;展示时textarea只读且不进入Tab顺序。 const active = editing && selected; const label = plain ? "纯文本" : data.title; + // 本组件未监听 compositionstart/end;正文变化直接转交 edit,输入法合并与 history 分组由 Provider 决定。 + // textarea 的 Escape 处理也不检查 isComposing,仅用于退出输入态。 // 输入最多100000字符;onChange立即发动作,blur退出并通知endEditing。Escape只结束编辑,不回滚已输入文本。 // 非编辑态覆盖层不接管单击逻辑,让外层画布选择/拖动;双击使用浏览器原生时序并按移动标记过滤。 // 键盘Enter/F2仅在已选中时进入;双击未选中节点会先发select,再开始编辑。 diff --git a/src/components/canvas/editing-lease.ts b/src/components/canvas/editing-lease.ts index 2fceebc..674dda4 100644 --- a/src/components/canvas/editing-lease.ts +++ b/src/components/canvas/editing-lease.ts @@ -6,7 +6,8 @@ /** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */ export type EditingLease = { preview(value: T): void; commit(): void; cancel(): void }; -/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */ +/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。 + * 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */ export function guardEditingLease(isCurrent: () => boolean, actions: EditingLease): EditingLease { return Object.freeze({ // 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。 diff --git a/src/components/canvas/node-editing.ts b/src/components/canvas/node-editing.ts index 124d635..2f16981 100644 --- a/src/components/canvas/node-editing.ts +++ b/src/components/canvas/node-editing.ts @@ -41,12 +41,14 @@ export const NodeEditing = createContext{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, @@ -61,7 +63,7 @@ export function useScopedNodeEditing(id:string,rendered?:RenderedTarget){ // 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);}; - // 结束手势要同时通过对象有效性与手势归属检查,防止迟到的pointerup结束新交互。 + // 结束手势要同时通过对象有效性与归属检查;这不是对任意迟到事件的完整 token 隔离。 if(editing.finishTransform)scoped.finishTransform=()=>{if(current()&&ownGesture())editing.finishTransform!();}; if(editing.cancelTransform)scoped.cancelTransform=()=>{if(current()&&ownGesture())editing.cancelTransform!();}; // 包装只负责入口检查;事务合法性、持久化和失败报告仍由Provider对应的动作实现负责。 diff --git a/src/components/canvas/node-internals.ts b/src/components/canvas/node-internals.ts index aa630b0..c60beb4 100644 --- a/src/components/canvas/node-internals.ts +++ b/src/components/canvas/node-internals.ts @@ -19,9 +19,11 @@ export function useNodeInternalsUpdate(id:string){ // 首个节点创建store级batch,每次挂载取得本id的owner;清理仅释放该owner。 useEffect(()=>{ let batch=batches.get(scope);if(!batch){batch=createNodeInternalsBatch(update);batches.set(scope,batch);} + // batch 首次创建时捕获 update;同 store 后续使用者沿用该更新器,不在每次 effect 中替换。 const owner=batch.acquire(id);lease.current=owner; return()=>{owner.dispose();if(lease.current===owner)lease.current=undefined;}; },[scope,update,id]); - // 回调通过ref读取当前租约,避免调用方保存旧闭包后继续刷新已卸载节点。 + // 回调通过ref读取当前租约;旧调用者保存的同一回调,在本组件 id 改变后会转向新 id 的租约。 + // effect 尚未安装/已清理则无动作;它不把回调永久绑定到最初 id。 return useCallback(()=>lease.current?.invalidate(),[]); } diff --git a/src/components/canvas/node-title.tsx b/src/components/canvas/node-title.tsx index 8177aa5..4fb7328 100644 --- a/src/components/canvas/node-title.tsx +++ b/src/components/canvas/node-title.tsx @@ -17,11 +17,13 @@ export function NodeTitle({ id, title, selected,renderedTarget }: { id: string; useEffect(() => { if (!selected) setEditing(false); }, [selected]); useEffect(() => { if (editing) { input.current?.focus(); input.current?.select(); } }, [editing]); /** blur是统一提交入口:忽略取消、空名称与无变化名称,随后复位取消标记并退出输入态。 */ + // rename 是同步动作入口,不等待远端 ACK;若实现抛错,下方标记复位与退出也不会自动执行。 function save() { if (!cancelled.current) { const name = value.trim(); if (name && name !== title) actions.rename(id, name); } cancelled.current = false; setEditing(false); } // Enter在非输入法组合态触发blur保存,Escape先置取消标记再blur;按键不继续冒泡到画布。 + // maxLength 是浏览器 UTF-16 长度限制;此 save 只 trim/去空/比旧值,不另做 UTF-8 字节校验。 // 标题长度限制300字符,nodrag/nopan/nowheel避免文本操作被当成画布手势。 if (editing) return setValue(event.target.value)} onBlur={save} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" && !event.nativeEvent.isComposing) event.currentTarget.blur(); if (event.key === "Escape") { cancelled.current = true; event.currentTarget.blur(); } }} />; diff --git a/src/components/canvas/retained-projection.ts b/src/components/canvas/retained-projection.ts index 94ddffa..5f60334 100644 --- a/src/components/canvas/retained-projection.ts +++ b/src/components/canvas/retained-projection.ts @@ -8,9 +8,11 @@ export function createRetainedProjection(){ // 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。 const tagged=new WeakMap(); + // previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。 let previous:{nodes:readonly object[];edges:readonly object[];ids:Set;result:{nodes:object[];edges:object[]}}|undefined; /** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */ return (nodes:N[],edges:E[],ids:ReadonlySet):{nodes:N[];edges:E[]}=>{ + // 清 previous 不清 tagged;同一对象日后再次被保留,仍可能复用已有带标记副本。 if(!ids.size){previous=undefined;return{nodes,edges};} // 数组引用及ID成员都相同才复用;快照复制ids,避免调用方之后原地修改Set影响比较基准。 if(previous?.nodes===nodes&&previous.edges===edges&&previous.ids.size===ids.size&&[...ids].every(id=>previous!.ids.has(id)))return previous.result as {nodes:N[];edges:E[]}; diff --git a/src/components/canvas/text-appearance.ts b/src/components/canvas/text-appearance.ts index 50136a1..f38ef6e 100644 --- a/src/components/canvas/text-appearance.ts +++ b/src/components/canvas/text-appearance.ts @@ -7,6 +7,7 @@ import type { CanvasNodeData } from "@/lib/canvas-elements"; * 字体家族只识别serif/mono,其它继承外层;颜色、背景、对齐与行高使用各自默认值。 * 使用||回退,因此0和空字符串也走默认;这里是显示映射,不承担schema校验。 */ export function textAppearance(data: CanvasNodeData): CSSProperties { + // 每次生成新的扁平 CSS 对象;不缓存节点引用,也不会写回默认值到 data。 const style = data.textStyle || {}; return { fontSize: data.fontSize || (data.kind === "plain-text" ? 24 : 13), color: style.color || "#37352f", fontWeight: style.bold ? 700 : 400, fontStyle: style.italic ? "italic" : "normal", textDecoration: style.underline ? "underline" : "none", textAlign: style.align || "left", lineHeight: style.lineHeight || 1.5, fontFamily: style.fontFamily === "serif" ? 'Georgia, "Songti SC", serif' : style.fontFamily === "mono" ? 'ui-monospace, "SFMono-Regular", monospace' : "inherit", backgroundColor: style.background || "transparent" };