feat: rename graph titles with select-then-edit interaction
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.shell { position: relative; display: flex; align-items: center; }
|
||||
.title, .input { box-sizing: border-box; min-width: 0; max-width: 100%; height: 32px; padding: 0 6px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: inherit; font: inherit; line-height: 30px; }
|
||||
.title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; text-align: left; }
|
||||
.title:hover:not(:disabled) { background: #f0ece6; }
|
||||
.title[data-selected="true"] { background: #ede5dc; border-color: #d3c0b2; }
|
||||
.title:disabled { cursor: default; }
|
||||
.input { width: 220px; border-color: #a47764; background: white; outline: none; }
|
||||
.input[readonly] { opacity: 0.65; }
|
||||
.error { position: absolute; top: calc(100% + 10px); left: 0; width: 260px; max-width: calc(100vw - 40px); padding: 8px 10px; border: 1px solid #e5c7b8; border-radius: 7px; background: #fff9f3; color: #a25439; font-size: 12px; line-height: 1.6; white-space: normal; box-shadow: 0 4px 12px #32251a0a; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import GraphCanvas from "@/components/canvas/graph-canvas";
|
||||
export default function GraphPage({ workspaceId, boardId }: { workspaceId: string; boardId: string }) {
|
||||
const demo = useSearchParams().get("demo") === "1";
|
||||
const router = useRouter();
|
||||
const { setBoard: setActiveBoard } = useActiveBoard();
|
||||
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -54,9 +54,12 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
return () => setActiveBoard(null);
|
||||
}, [board, boardId, workspaceId, setActiveBoard]);
|
||||
|
||||
const displayBoard = board && activeBoard?.id === board.id && activeBoard.workspace_id === workspaceId
|
||||
? { ...board, title: activeBoard.title } : board;
|
||||
|
||||
return (
|
||||
<main className={styles.graph} aria-label={board ? board.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
|
||||
{board ? <GraphCanvas key={workspaceId + ":" + boardId} board={board} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
<main className={styles.graph} aria-label={displayBoard ? displayBoard.title + " · 无限图谱" : "无限图谱"} aria-busy={!board && !error}>
|
||||
{displayBoard ? <GraphCanvas key={workspaceId + ":" + boardId} board={displayBoard} /> : <div className={styles.graphSurface} aria-label="正在读取画布" />}
|
||||
{error ? <p className={styles.graphStatus} role="alert">{error}</p>
|
||||
: !board && <p className={styles.graphStatus} role="status">正在打开图谱…</p>}
|
||||
</main>
|
||||
|
||||
@@ -122,5 +122,5 @@
|
||||
.accountPopup[data-floating="true"] { inset: 62px auto auto 12px; max-height: calc(100dvh - 76px); transform-origin: top left; }
|
||||
|
||||
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 20px); width: var(--toolbar-icon-size, 20px); height: var(--toolbar-icon-size, 20px); font-size: 14px; }
|
||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 14px; line-height: var(--toolbar-control-height, 32px); font-weight: 500; overflow: visible; }
|
||||
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||
|
||||
Reference in New Issue
Block a user