refactor: unify floating toolbar sizing and show graph title
This commit is contained in:
@@ -94,3 +94,7 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、
|
||||
- 底部可添加文字、图片、脚本或三节点创作模板。图片支持 PNG/JPEG/WebP/GIF,单张上限 2 MB。
|
||||
- Ctrl / ⌘ + Z 撤销,Shift + Ctrl / ⌘ + Z 重做;支持 JSON 导入导出和 40 步撤销历史。
|
||||
- 状态与导入回归:`node --experimental-strip-types --test tests/canvas.test.mjs tests/workspace.test.mjs`。
|
||||
|
||||
## 悬浮栏尺寸
|
||||
|
||||
左上角显示当前图谱标题,复用页面已读取的资料,不额外请求后端;长标题省略并可悬停查看。左上、右上、底部与帮助按钮共用 `toolbar.module.css`:图标/Logo/浮框头像 16px,图标按钮 28×24px,主文字 12px,容器内边距 4px,间隙 2px,圆角 8px。悬浮栏距屏幕边缘统一为 12px;右上角窄屏时下移避免与标题重叠。
|
||||
|
||||
@@ -68,3 +68,11 @@
|
||||
- 13 项自动测试通过:节点位置变化与撤销重做、删除连线清理、文字编辑事务、导入导出、恶意图片与无效图谱拒绝、草稿隔离、历史上限及既有工作空间流程。
|
||||
- 类型检查、隔离 Webpack 生产构建和完整 API 冒烟回归通过;原有 88 个品牌资源与账户/Foundry 创建接口保持通过。
|
||||
- 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。
|
||||
|
||||
## 悬浮栏标题与尺寸统一(2026-11-06)
|
||||
|
||||
- 当前图谱标题通过共享 ActiveBoardProvider 从图谱页面传到左上悬浮栏,按图谱 ID 与 workspace 校验,避免错显上一张图谱;不增加重复 API 请求,长标题省略并保留完整 title 属性。
|
||||
- 统一所有悬浮栏的 surface 样式:16px 图标、28×24px border-box 按钮、12px 字体、4px 内边距、2px 间隙、8px 圆角;浮框头像缩为 16px,账户菜单内头像仍保持原尺寸。
|
||||
- 删除 canvas 工具栏的 content-box 与 8px/9px 内边距覆盖,缩放文字和帮助按钮采用同一高度、字号及容器;下方和右侧距屏幕边缘统一 12px。
|
||||
- 沿用不访问浏览器的安排,本次验证以类型检查、生产构建和现有接口回归为准,未进行浏览器视觉验收。
|
||||
- 本次类型检查、隔离 Webpack 生产构建和完整 API/品牌资源冒烟检查均通过。
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Check, ChevronsUpDown, FilePlus2, Home, Network, PanelLeftClose, PanelL
|
||||
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 { useActiveBoard } from "@/components/workspace/active-board";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { UserAvatar } from "@/components/workspace/user-avatar";
|
||||
import { AccountPanel } from "@/components/workspace/account-panel";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
@@ -15,6 +17,7 @@ import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
export default function DashboardSidebar() {
|
||||
const router = useRouter();
|
||||
const { board: activeBoard } = useActiveBoard();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const params = useParams<{ workspace?: string; board?: string }>();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -128,6 +131,9 @@ export default function DashboardSidebar() {
|
||||
} catch (e) { setError(errorText(e)); }
|
||||
}
|
||||
|
||||
const currentTitle = activeBoard && activeBoard.id === params.board && activeBoard.workspace_id === workspace
|
||||
? activeBoard.title
|
||||
: boards.find((board) => board.id === params.board)?.title || (params.board ? "正在读取图谱…" : "工作空间");
|
||||
const name = profile?.display_name || profile?.username || "账户";
|
||||
const currentWorkspace = workspaces.find((w) => w.id === workspace);
|
||||
const filtered = boards.filter((b) => b.title.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
@@ -142,7 +148,7 @@ export default function DashboardSidebar() {
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{collapsed && (
|
||||
<motion.div key="sidebar-controls" className={styles.floating} aria-label="侧边栏快捷操作"
|
||||
<motion.div key="sidebar-controls" className={`${toolbar.surface} ${styles.floating}`} aria-label="侧边栏快捷操作"
|
||||
initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
|
||||
@@ -153,6 +159,7 @@ export default function DashboardSidebar() {
|
||||
srcSet="/brand/png/logo-light/endoleap-16.png 1x, /brand/png/logo-light/endoleap-32.png 2x, /brand/png/logo-light/endoleap-48.png 3x"
|
||||
width={16} height={16} alt="" />
|
||||
</Link>
|
||||
<h1 className={styles.floatingTitle} title={currentTitle}>{currentTitle}</h1>
|
||||
<button type="button" className={styles.floatingAvatarButton} popoverTarget="workspace-account" aria-label="账户和工作空间">
|
||||
<UserAvatar name={name} url={profile?.avatar_url} />
|
||||
</button>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Suspense, type ReactNode } from "react";
|
||||
import { ActiveBoardProvider } from "@/components/workspace/active-board";
|
||||
import DashboardSidebar from "./dashboard";
|
||||
import styles from "@/components/workspace/workspace.module.css";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<Suspense fallback={<aside className={styles.sidebar} aria-label="正在加载侧边栏" aria-busy="true" />}>
|
||||
<DashboardSidebar />
|
||||
</Suspense>
|
||||
{children}
|
||||
</div>
|
||||
<ActiveBoardProvider>
|
||||
<div className={styles.shell}>
|
||||
<Suspense fallback={<aside className={styles.sidebar} aria-label="正在加载侧边栏" aria-busy="true" />}>
|
||||
<DashboardSidebar />
|
||||
</Suspense>
|
||||
{children}
|
||||
</div>
|
||||
</ActiveBoardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,28 +10,22 @@
|
||||
.node textarea::placeholder { color: #b2a99e; }
|
||||
.image { display: block; max-width: 100%; max-height: 280px; margin: auto; border-radius: 5px; object-fit: contain; }
|
||||
.node :global(.react-flow__handle) { width: 8px; height: 8px; background: #a47764; border: 2px solid #fff; }
|
||||
.toolbar { display: flex; align-items: center; gap: 5px; padding: 9px; border: 1px solid #e2dcd3; border-radius: 14px; background: #fffffff5; color: #4b443d; box-shadow: 0 7px 24px #32251a0a, 0 1px 3px #32251a05; backdrop-filter: blur(12px); }
|
||||
.toolbar button { box-sizing: content-box; padding: 8px; border-radius: 7px; }
|
||||
.toolbar button[aria-pressed="true"] { background: #f1e6df; color: #78523f; }
|
||||
.toolbar button:disabled { cursor: not-allowed; }
|
||||
.divider { width: 1px; height: 22px; margin: 0 4px; background: #ece6df; }
|
||||
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
|
||||
.toolbar .zoomValue { box-sizing: border-box; width: 48px; height: 32px; padding: 0; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.divider { flex: 0 0 1px; width: 1px; height: 16px; margin: 0 2px; background: #ece6df; }
|
||||
.topRight { position: absolute; top: 12px; right: 12px; z-index: 10; display: flex; flex-direction: column; align-items: flex-end; gap: 3px; }
|
||||
.toolbar .zoomValue { box-sizing: border-box; width: 40px; height: 24px; padding: 0; border-radius: 5px; font: inherit; font-variant-numeric: tabular-nums; }
|
||||
.toolbar .zoomValue:hover { background: #e9e7e3; }
|
||||
.saveStatus { padding-right: 5px; font-size: 10px; color: #a59b90; }
|
||||
.bottomToolbar { position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: 10; }
|
||||
.helpButton { position: absolute; right: 16px; bottom: 22px; display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #e2dcd3; border-radius: 50%; background: white; color: #74675c; box-shadow: 0 3px 12px #32251a0a; z-index: 10; }
|
||||
.helpPopup { position: fixed; inset: auto 16px 68px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
||||
.bottomToolbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 10; }
|
||||
.helpPopup { position: fixed; inset: auto 12px 54px auto; margin: 0; width: 330px; max-width: calc(100vw - 32px); max-height: calc(100dvh - 110px); overflow-y: auto; padding: 18px; border: 1px solid #e2dcd3; border-radius: 12px; background: white; color: #6d6054; box-shadow: 0 8px 30px #32251a14; font-size: 12px; line-height: 1.7; }
|
||||
.helpPopup strong { color: #49372e; font-size: 13px; }
|
||||
.helpPopup p { margin-top: 9px; }
|
||||
.error { position: absolute; top: 78px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||
.error { position: absolute; top: 62px; left: 50%; transform: translateX(-50%); max-width: min(500px, calc(100vw - 32px)); padding: 10px 14px; border: 1px solid #e5c7b8; border-radius: 8px; color: #a25439; background: #fff9f3; font-size: 12px; z-index: 15; }
|
||||
.helpToolbar { position: absolute; right: 12px; bottom: 12px; z-index: 10; }
|
||||
@media (max-width: 700px) {
|
||||
.topRight { top: 58px; right: 10px; }
|
||||
.toolbar { padding: 5px; gap: 2px; border-radius: 11px; }
|
||||
.toolbar button { padding: 6px; }
|
||||
.divider { margin: 0 2px; }
|
||||
.bottomToolbar { bottom: 16px; max-width: calc(100vw - 48px); }
|
||||
.helpButton { right: 7px; bottom: 20px; width: 28px; height: 28px; }
|
||||
.saveStatus { font-size: 9px; }
|
||||
.topRight { top: 54px; right: 12px; }
|
||||
.bottomToolbar { bottom: 12px; max-width: calc(100vw - 68px); }
|
||||
}
|
||||
|
||||
.handMode .node textarea, .handMode .node img { pointer-events: none; }
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@xyflow/react";
|
||||
import { Download, FileText, Hand, HelpCircle, ImagePlus, Maximize, Minus, MousePointer2, Plus, Redo2, Sparkles, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import toolbar from "@/components/ui/toolbar.module.css";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { canvasReducer, canvasStorageKey, decodeCanvas, emptyCanvas, makeCanvasNode, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
@@ -168,7 +169,7 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
</ReactFlow>
|
||||
</NodeEditing.Provider>
|
||||
<div className={styles.topRight}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="图谱视图控制">
|
||||
<div className={`${toolbar.surface} ${styles.toolbar}`} role="toolbar" aria-label="图谱视图控制">
|
||||
<IconButton icon={Undo2} label="撤销" disabled={!state.past.length} onClick={() => dispatch({ type: "undo" })} />
|
||||
<IconButton icon={Redo2} label="重做" disabled={!state.future.length} onClick={() => dispatch({ type: "redo" })} />
|
||||
<span className={styles.divider} />
|
||||
@@ -181,7 +182,7 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
</div>
|
||||
<span className={styles.saveStatus} role="status">{saved ? "保存在此浏览器" : "正在保存到此浏览器…"}</span>
|
||||
</div>
|
||||
<div className={`${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<div className={`${toolbar.surface} ${styles.toolbar} ${styles.bottomToolbar}`} role="toolbar" aria-label="画布创作工具">
|
||||
<IconButton icon={MousePointer2} label="选择工具(V)" aria-pressed={tool === "select"} onClick={() => setTool("select")} />
|
||||
<IconButton icon={Hand} label="抓手工具(H)" aria-pressed={tool === "hand"} onClick={() => setTool("hand")} />
|
||||
<span className={styles.divider} />
|
||||
@@ -192,7 +193,9 @@ function CanvasEditor({ board }: { board: Board }) {
|
||||
<span className={styles.divider} />
|
||||
<IconButton icon={Upload} label="导入图谱" onClick={() => projectInput.current?.click()} />
|
||||
</div>
|
||||
<button className={styles.helpButton} popoverTarget="canvas-help" aria-label="画布操作帮助"><HelpCircle size={18} /></button>
|
||||
<div className={`${toolbar.surface} ${styles.helpToolbar}`}>
|
||||
<IconButton icon={HelpCircle} label="画布操作帮助" popoverTarget="canvas-help" />
|
||||
</div>
|
||||
<div id="canvas-help" popover="auto" className={styles.helpPopup}>
|
||||
<strong>自由创作,从移动画布开始</strong>
|
||||
<p>拖动空白处平移,拖动节点移动位置。</p>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
box-sizing: border-box;
|
||||
width: var(--toolbar-control-width, 28px);
|
||||
height: var(--toolbar-control-height, 24px);
|
||||
padding: 4px 6px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
@@ -12,7 +13,7 @@
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button svg { width: 16px; height: 16px; flex: 0 0 16px; }
|
||||
.button svg { width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); flex: 0 0 var(--toolbar-icon-size, 16px); }
|
||||
.button:hover { background: #e9e7e3; }
|
||||
.button:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
.button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.surface {
|
||||
--toolbar-icon-size: 16px;
|
||||
--toolbar-control-width: 28px;
|
||||
--toolbar-control-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border: 1px solid #e2dcd3;
|
||||
border-radius: 8px;
|
||||
background: #fffffff5;
|
||||
color: #4b443d;
|
||||
box-shadow: 0 3px 14px #32251a0a;
|
||||
backdrop-filter: blur(12px);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
|
||||
type ActiveBoard = Pick<Board, "id" | "workspace_id" | "title"> | null;
|
||||
const ActiveBoardContext = createContext<{
|
||||
board: ActiveBoard;
|
||||
setBoard: Dispatch<SetStateAction<ActiveBoard>>;
|
||||
} | null>(null);
|
||||
|
||||
export function ActiveBoardProvider({ children }: { children: ReactNode }) {
|
||||
const [board, setBoard] = useState<ActiveBoard>(null);
|
||||
const value = useMemo(() => ({ board, setBoard }), [board]);
|
||||
return <ActiveBoardContext.Provider value={value}>{children}</ActiveBoardContext.Provider>;
|
||||
}
|
||||
|
||||
export function useActiveBoard() {
|
||||
const context = useContext(ActiveBoardContext);
|
||||
if (!context) throw new Error("useActiveBoard requires ActiveBoardProvider");
|
||||
return context;
|
||||
}
|
||||
@@ -6,11 +6,13 @@ import { api, errorText } from "@/lib/api";
|
||||
import { readDemoBoards } from "@/lib/demo-workspace";
|
||||
import type { Board } from "@/lib/workspace";
|
||||
import styles from "./workspace.module.css";
|
||||
import { useActiveBoard } from "./active-board";
|
||||
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, setBoard] = useState<Board | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -45,6 +47,13 @@ export default function GraphPage({ workspaceId, boardId }: { workspaceId: strin
|
||||
return () => { active = false; controller.abort(); };
|
||||
}, [workspaceId, boardId, demo, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (board?.id === boardId && board.workspace_id === workspaceId) {
|
||||
setActiveBoard({ id: board.id, workspace_id: board.workspace_id, title: board.title });
|
||||
}
|
||||
return () => setActiveBoard(null);
|
||||
}, [board, boardId, workspaceId, setActiveBoard]);
|
||||
|
||||
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="正在读取画布" />}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
.newWorkspace { color: #2383e2; }
|
||||
.logout { color: #78746d; font-size: 12px; }
|
||||
.separator { height: 1px; margin: 6px 7px; background: #eeece8; }
|
||||
.floating { position: fixed; left: 12px; top: 12px; z-index: 40; display: flex; gap: 6px; padding: 6px; background: #fffefa; border: 1px solid #e7e2db; border-radius: 8px; box-shadow: 0 3px 14px #3c312b0d; color: #85817b; }
|
||||
.floating { position: fixed; left: 12px; top: 12px; max-width: calc(100vw - 24px); z-index: 40; }
|
||||
.floatingError { position: absolute; left: 0; top: 42px; width: 240px; padding: 10px; background: white; border: 1px solid #e7e2db; border-radius: 8px; color: #b14b3c; font-size: 12px; }
|
||||
.settingsPanel { color: #37352f; border-color: #e6e2dc; border-radius: 12px; }
|
||||
.settingsForm { display: flex; flex-direction: column; gap: 16px; padding-top: 4px; }
|
||||
@@ -112,11 +112,15 @@
|
||||
@media (max-width: 600px) { .shell { --sidebar-width: 240px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .accountPopup:popover-open { animation: none; } }
|
||||
|
||||
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 28px; height: 24px; padding: 4px 6px; border-radius: 5px; }
|
||||
.logoLink img { display: block; width: 16px; height: 16px; }
|
||||
.logoLink { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 28px); height: var(--toolbar-control-height, 24px); padding: 4px 6px; border-radius: 5px; }
|
||||
.logoLink img { display: block; width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); }
|
||||
.logoLink:hover { background: #e9e7e3; }
|
||||
.logoLink:focus-visible { outline: 2px solid #9d7d66; outline-offset: 2px; }
|
||||
|
||||
.floatingAvatarButton { display: grid; place-items: center; width: 28px; height: 24px; border-radius: 5px; }
|
||||
.floatingAvatarButton { display: grid; place-items: center; flex: 0 0 auto; box-sizing: border-box; width: var(--toolbar-control-width, 28px); height: var(--toolbar-control-height, 24px); border-radius: 5px; }
|
||||
.floatingAvatarButton:hover { background: #e9e7e3; }
|
||||
.accountPopup[data-floating="true"] { inset: 56px auto auto 12px; max-height: calc(100dvh - 70px); transform-origin: top left; }
|
||||
|
||||
.floatingAvatarButton .avatar { flex-basis: var(--toolbar-icon-size, 16px); width: var(--toolbar-icon-size, 16px); height: var(--toolbar-icon-size, 16px); font-size: 9px; }
|
||||
.floatingTitle { flex: 1 1 auto; min-width: 0; max-width: 240px; margin: 0 4px; color: #37352f; font-size: 12px; line-height: 24px; font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
@media (max-width: 700px) { .floatingTitle { max-width: 180px; } }
|
||||
|
||||
Reference in New Issue
Block a user