docs(canvas): clarify editing guards and projection lifetimes

This commit is contained in:
2026-11-12 16:18:24 +08:00
parent 65c54e7b9e
commit f28d3810d4
7 changed files with 16 additions and 3 deletions
@@ -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,再开始编辑。
+2 -1
View File
@@ -6,7 +6,8 @@
/** preview产生临时显示,commit结束并提交,cancel撤销本次预览;具体语义由创建租约的编辑器实现。 */
export type EditingLease<T> = { preview(value: T): void; commit(): void; cancel(): void };
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略,不能结束另一场交互。 */
/** 冻结包装后的方法表,每次调用重新检查isCurrent;过期事件静默忽略。
* 是否识别精确交互由 isCurrent 的实现保证;这里只冻结外壳,不复制 actions 或吞掉检查/动作异常。 */
export function guardEditingLease<T>(isCurrent: () => boolean, actions: EditingLease<T>): EditingLease<T> {
return Object.freeze({
// 指针移动可能持续产生事件,必须逐次检查,不能只在创建租约时检查一次。
+3 -1
View File
@@ -41,12 +41,14 @@ export const NodeEditing = createContext<Omit<typeof defaultEditing, GestureHook
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,
@@ -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对应的动作实现负责。
+3 -1
View File
@@ -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(),[]);
}
+2
View File
@@ -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 <input ref={input} className={`${styles.nodeTitleInput} nodrag nopan nowheel`} value={value} maxLength={300} aria-label="节点名称" onChange={(event) => 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(); } }} />;
@@ -8,9 +8,11 @@
export function createRetainedProjection(){
// 弱引用键避免缓存永久占住已被替换的投影对象;只按对象身份缓存,不监测对象内部原地修改。
const tagged=new WeakMap<object,object>();
// previous 强引用最近一批输入/输出;WeakMap 不等于整个实例完全不持有投影,空 ids 才清这份整批引用。
let previous:{nodes:readonly object[];edges:readonly object[];ids:Set<string>;result:{nodes:object[];edges:object[]}}|undefined;
/** 输入数组和保留ID集合生成节点/边数组;ids为空时直接返回原数组并清除整批缓存。 */
return <N extends {id:string},E extends {id:string}>(nodes:N[],edges:E[],ids:ReadonlySet<string>):{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[]};
+1
View File
@@ -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" };