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
+19 -3
View File
@@ -7,9 +7,10 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings } from "lucide-react";
import { IconButton } from "@/components/ui/icon-button";
import { api, errorText, type Profile } from "@/lib/api";
import { boardHref, createBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { boardHref, createBoard, renameBoard, DEFAULT_BOARD_TITLE, loadWorkspaceHome, type Board, type Workspace } from "@/lib/workspace";
import { useActiveBoard } from "@/components/workspace/active-board";
import toolbar from "@/components/ui/toolbar.module.css";
import { EditableTitle } from "@/components/workspace/editable-title";
import { UserAvatar } from "@/components/workspace/user-avatar";
import { AccountPanel } from "@/components/workspace/account-panel";
import { readDemoBoards } from "@/lib/demo-workspace";
@@ -17,7 +18,7 @@ import styles from "@/components/workspace/workspace.module.css";
export default function DashboardSidebar() {
const router = useRouter();
const { board: activeBoard } = useActiveBoard();
const { board: activeBoard, setBoard: setActiveBoard } = useActiveBoard();
const reduceMotion = useReducedMotion();
const params = useParams<{ workspace?: string; board?: string }>();
const searchParams = useSearchParams();
@@ -139,6 +140,19 @@ export default function DashboardSidebar() {
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const canCreate = status === "ready" && (!workspace || Boolean(currentWorkspace && currentWorkspace.role !== "viewer"));
async function renameTitle(title: string) {
const id = params.board;
if (!id || !workspace) return;
try {
if (demo) {
const list = readDemoBoards(workspace);
localStorage.setItem("endoleap_demo_boards_" + workspace, JSON.stringify(list.map((board) => board.id === id ? { ...board, title } : board)));
} else await renameBoard(api, workspace, id, title);
setBoards((list) => list.map((board) => board.id === id ? { ...board, title } : board));
setActiveBoard((board) => board?.id === id && board.workspace_id === workspace ? { ...board, title } : board);
} catch (error) { throw new Error(errorText(error)); }
}
const createButton = <IconButton icon={FilePlus2} label={creating ? "正在创建图谱" : "新建页面"} disabled={!canCreate || creating} onClick={newPage} aria-busy={creating} />;
const transition = { duration: reduceMotion ? 0 : 0.24, ease: [0.22, 1, 0.36, 1] as const };
@@ -159,7 +173,9 @@ export default function DashboardSidebar() {
srcSet="/brand/png/logo-light/endoleap-32.png 1x, /brand/png/logo-light/endoleap-64.png 2x, /brand/png/logo-light/endoleap-128.png 4x"
width={20} height={20} alt="" />
</Link>
<h1 className={styles.floatingTitle} title={currentTitle}>{currentTitle}</h1>
<EditableTitle key={workspace + ":" + (params.board || "home")} title={currentTitle}
editable={Boolean(params.board && activeBoard?.id === params.board && activeBoard.workspace_id === workspace && canCreate)}
onSave={renameTitle} className={styles.floatingTitle} />
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
<UserAvatar name={name} url={profile?.avatar_url} />
</button>
@@ -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>
);
}
+6 -3
View File
@@ -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; } }
+2
View File
@@ -45,6 +45,8 @@ export const errorText = (error: unknown) => {
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
version_conflict: "图谱已在别处修改,请再次保存以读取最新版本。",
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
+16
View File
@@ -8,6 +8,7 @@ export type Board = {
title: string;
version: number;
content?: { schema_version: number };
description?: string;
};
export const DEFAULT_BOARD_TITLE = "未命名图谱";
@@ -56,3 +57,18 @@ export async function createBoard(
});
return { ...result, workspace_id: result.workspace_id || id, title: name };
}
export async function renameBoard(request: Request, workspaceId: string, boardId: string, title: string): Promise<Board> {
const name = title.trim();
if (!name || new TextEncoder().encode(name).length > 300) throw new Error("invalid_title");
const path = `workspaces/${encodeURIComponent(workspaceId)}/materials/${encodeURIComponent(boardId)}`;
const current = await request<Board>(path);
if (current.kind !== "board" || current.id !== boardId || current.workspace_id !== workspaceId) throw new Error("invalid_reference");
await request(path, {
method: "PATCH",
headers: { "If-Match": `"${current.version}"` },
body: JSON.stringify({ title: name, description: current.description || "", content: current.content || { schema_version: 1 } }),
});
return { ...current, title: name, version: current.version + 1 };
}