feat: rename graph titles with select-then-edit interaction

This commit is contained in:
2026-11-06 01:40:40 +08:00
parent c9ce5fc2f1
commit af9988f78d
11 changed files with 212 additions and 9 deletions
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./editable-title.module.css";
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);
const pending = useRef(false);
const cancelled = useRef(false);
useEffect(() => {
if (mode !== "selected") return;
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]);
useEffect(() => {
if (mode === "editing") { input.current?.focus(); input.current?.select(); }
}, [mode]);
function edit() {
if (!editable) return;
cancelled.current = false;
setDraft(title);
setError("");
setMode("editing");
}
function cancel() {
cancelled.current = true;
setError("");
setMode("selected");
requestAnimationFrame(() => button.current?.focus());
}
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; }
}
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>
);
}