111 lines
5.3 KiB
TypeScript
111 lines
5.3 KiB
TypeScript
/**
|
|
* 工作台标题的局部交互状态机: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<void>;
|
|
className?: string;
|
|
}) {
|
|
const [mode, setMode] = useState<"idle" | "selected" | "editing" | "saving">("idle");
|
|
const [draft, setDraft] = useState(title);
|
|
const [error, setError] = useState("");
|
|
const shell = useRef<HTMLDivElement>(null);
|
|
const input = useRef<HTMLInputElement>(null);
|
|
const button = useRef<HTMLButtonElement>(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");
|
|
// 不保存帧句柄做卸载取消;执行时通过 ref 可选访问,目标已卸载则不聚焦。
|
|
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 {
|
|
// onSave 没有本组件的 signal/代次护栏;切换目标或卸载后的结果隔离需由宿主回调保证。
|
|
await onSave(name);
|
|
cancelled.current = true;
|
|
setMode("idle");
|
|
} catch (error) {
|
|
setError(error instanceof Error ? error.message : "改名失败,请重试。");
|
|
setMode("editing");
|
|
} finally { pending.current = false; }
|
|
}
|
|
// title prop 改变不自动覆盖正在编辑的 draft;同名判断使用本次 save 闭包看到的 title。
|
|
// saving保留input但只读;输入法组合不把Enter当提交,键盘事件停止冒泡避免触发画布快捷键。
|
|
return (
|
|
<div ref={shell} className={`${styles.shell} ${className}`}>
|
|
{mode === "editing" || mode === "saving" ? (
|
|
<input ref={input} className={styles.input} aria-label="编辑图谱名称" aria-invalid={Boolean(error)}
|
|
aria-describedby={error ? "graph-title-error" : undefined} aria-busy={mode === "saving"}
|
|
value={draft} readOnly={mode === "saving"} maxLength={300}
|
|
onChange={(event) => 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(); }
|
|
}} />
|
|
) : (
|
|
<button ref={button} type="button" className={styles.title} data-selected={mode === "selected"}
|
|
disabled={!editable} aria-label={`图谱名称:${title}`} aria-pressed={mode === "selected"}
|
|
title={editable ? `${title} · 点击选中,再次点击改名` : title}
|
|
onClick={() => mode === "selected" ? edit() : setMode("selected")}
|
|
onKeyDown={(event) => { if (event.key === "F2") { event.preventDefault(); edit(); } if (event.key === "Escape") setMode("idle"); }}>
|
|
{title}
|
|
</button>
|
|
)}
|
|
{error && <span id="graph-title-error" className={styles.error} role="alert">{error}</span>}
|
|
</div>
|
|
);
|
|
}
|