/** * 工作台标题的局部交互状态机:idle→selected→editing→saving,支持二次点击/F2编辑、Enter/失焦保存、Escape取消。 * 只管理草稿、焦点和提示,真正改名由异步onSave负责;组件本身不修改目录或画布文档。 */ "use client"; import { useEffect, useRef, useState } from "react"; import styles from "./editable-title.module.css"; /** editable控制能否进入编辑;title是宿主已采用值,draft只在一次编辑会话内使用,保存失败回editing重试。 */ export function EditableTitle({ title, editable, onSave, className = "" }: { title: string; editable: boolean; onSave: (title: string) => Promise; className?: string; }) { const [mode, setMode] = useState<"idle" | "selected" | "editing" | "saving">("idle"); const [draft, setDraft] = useState(title); const [error, setError] = useState(""); const shell = useRef(null); const input = useRef(null); const button = useRef(null); // ref防Enter和随后的blur重复调用;cancelled防Escape/已完成后的blur重新提交。 const pending = useRef(false); const cancelled = useRef(false); /** selected阶段才监听外侧pointerdown;离开此模式即退订,编辑/保存不由这条外侧点击逻辑擅自退出。 */ useEffect(() => { if (mode !== "selected") return; /** 只在实际DOM目标落在shell外时取消选择,标题内部点击保留进入编辑的机会。 */ const deselect = (event: PointerEvent) => { if (event.target instanceof Node && !shell.current?.contains(event.target)) setMode("idle"); }; document.addEventListener("pointerdown", deselect); return () => document.removeEventListener("pointerdown", deselect); }, [mode]); /** 进入editing后挂载的input才可聚焦/全选,不在渲染阶段直接访问未就绪DOM。 */ useEffect(() => { if (mode === "editing") { input.current?.focus(); input.current?.select(); } }, [mode]); /** 每次从宿主最新title初始化草稿并清旧错误,不能沿用上次取消的输入。 */ function edit() { if (!editable) return; cancelled.current = false; setDraft(title); setError(""); setMode("editing"); } /** 标记取消以抑制blur保存,回selected并下一帧把焦点还给标题按钮。 */ function cancel() { cancelled.current = true; setError(""); setMode("selected"); requestAnimationFrame(() => button.current?.focus()); } /** 同步ref挡重复提交,trim后校验非空及UTF-8≤300字节;同名不发请求。 * 成功回idle,失败留草稿/错误重编辑,finally释放pending允许下一次明确尝试。 */ async function save() { if (pending.current || cancelled.current) return; const name = draft.trim(); if (!name || new TextEncoder().encode(name).length > 300) { setError("名称不能为空,且不能超过 300 字节(约 100 个汉字)。"); input.current?.focus(); return; } if (name === title) { cancelled.current = true; setMode("idle"); return; } pending.current = true; setMode("saving"); setError(""); try { await onSave(name); cancelled.current = true; setMode("idle"); } catch (error) { setError(error instanceof Error ? error.message : "改名失败,请重试。"); setMode("editing"); } finally { pending.current = false; } } // saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。 return (
{mode === "editing" || mode === "saving" ? ( setDraft(event.target.value)} onBlur={() => void save()} onKeyDown={(event) => { event.stopPropagation(); if (event.nativeEvent.isComposing || mode === "saving") return; if (event.key === "Enter") { event.preventDefault(); void save(); } if (event.key === "Escape") { event.preventDefault(); cancel(); } }} /> ) : ( )} {error && {error}}
); }