feat(web): align diagram editor header

This commit is contained in:
tianma-if committed 2026-09-05 19:34:19 +08:00
1 parent cc73ff4170
commit 28e3f4944d
5 files changed
+313 -46

No files matched your search

@@ -37,6 +37,20 @@ describe("diagram editor keyboard workflow", () => {
});
describe("diagram editor canvas surface", () => {
test("uses the common note header and capability-aware more menu", () => {
expect(source).toContain('<span className="hidden truncate text-xs text-slate-400 sm:inline">{updatedLabel}</span>');
expect(source).not.toContain('t("editor.updatedAt", { time: updatedLabel })');
expect(source).toContain("onToggleDesktopFocusMode");
expect(source).toContain("onOpenPreviousMemo");
expect(source).toContain("onOpenNextMemo");
expect(source).toContain('aria-label={t("editor.moreAria")}');
expect(source).toContain('t("editor.versionHistory")');
expect(source).toContain('"sharing.afterSync" : "sharing.action"');
expect(source).toContain('t("templates.saveAsTemplate")');
expect(source).toContain("<RevisionHistoryDialog");
expect(source).toContain("<ShareMemoDialog");
});
test("autosaves diagram changes without a persistent save button", () => {
expect(source).toContain("EDITOR_LOCAL_SAVE_DELAY_MS");
expect(source).toContain("window.setTimeout(() => saveRef.current(), EDITOR_LOCAL_SAVE_DELAY_MS)");
+267 -44
View File
@@ -1,19 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Export, Graph, History, Keyboard, Selection, type Edge, type Node } from "@antv/x6";
import {
ArrowLeft,
Box,
Check,
ChevronLeft,
ChevronRight,
Circle,
CircleAlert,
Copy,
Diamond,
Download,
FileCode2,
FileImage,
GitBranch,
History as HistoryIcon,
LayoutDashboard,
Link2,
LoaderCircle,
Maximize2,
Minimize2,
MoreHorizontal,
Pencil,
Redo2,
RefreshCw,
RotateCcw,
Trash2,
Undo2,
ZoomIn,
@@ -35,26 +45,40 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { RevisionHistoryDialog } from "@/components/dialogs/RevisionHistoryDialog";
import { ShareMemoDialog } from "@/components/dialogs/ShareMemoDialog";
import { ClipboardCopyNotice } from "@/components/ClipboardCopyNotice";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useAppearanceTheme } from "@/components/ThemeProvider";
import { api } from "@/lib/api";
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
import { copyTextToClipboard } from "@/lib/clipboard";
import { compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "@/lib/diagram-layout";
import { resolveDiagramPalette, type DiagramAppearance } from "@/lib/diagram-theme";
import { isLocalMemoId } from "@/lib/local-mirror";
import { isBrowserOffline } from "@/lib/network-status";
import type { EdgeEverRepository } from "@/lib/repository";
import { cn } from "@/lib/utils";
import { EDITOR_LOCAL_SAVE_DELAY_MS } from "@/lib/app-helpers";
import { cn, formatDateTime } from "@/lib/utils";
type DiagramEditorPaneProps = {
memo: MemoDetail;
repository: EdgeEverRepository;
readOnly: boolean;
desktopFocusMode: boolean;
hasNextMemo: boolean;
hasPreviousMemo: boolean;
onBackToList: () => void;
onDeleted: (memoId: string) => Promise<void>;
onOpenNextMemo: () => void;
onOpenPreviousMemo: () => void;
onPermanentDeleted: (memoId: string) => Promise<void>;
onRestored: (memoId: string) => Promise<void>;
onSaved: (memo: MemoDetail) => Promise<void>;
onSaveAsTemplate: (memo: MemoDetail, name: string) => Promise<void>;
onToggleDesktopFocusMode: () => void;
};
type NodeData = { label: string; shape: DiagramNodeShape; parentId?: string };
@@ -354,7 +378,23 @@ const applyGraphPalette = (
}
};
export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, onSaved }: DiagramEditorPaneProps) => {
export const DiagramEditorPane = ({
memo,
repository,
readOnly,
desktopFocusMode,
hasNextMemo,
hasPreviousMemo,
onBackToList,
onDeleted,
onOpenNextMemo,
onOpenPreviousMemo,
onPermanentDeleted,
onRestored,
onSaved,
onSaveAsTemplate,
onToggleDesktopFocusMode,
}: DiagramEditorPaneProps) => {
const { t } = useTranslation();
const { resolvedTheme } = useAppearanceTheme();
const containerRef = useRef<HTMLDivElement | null>(null);
@@ -381,6 +421,10 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
const [editSessionReady, setEditSessionReady] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveFailed, setSaveFailed] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [memoIdCopyNotice, setMemoIdCopyNotice] = useState<"copied" | "error" | null>(null);
const memoIdCopyTimerRef = useRef<number | null>(null);
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
const [historyState, setHistoryState] = useState({ undo: false, redo: false });
const [nodeEditor, setNodeEditor] = useState<NodeEditorState | null>(null);
@@ -440,8 +484,15 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
setNodeEditor(null);
setTheme(documentTheme);
themeRef.current = documentTheme;
setHistoryOpen(false);
setShareOpen(false);
setMemoIdCopyNotice(null);
}, [documentTheme, memo.id]);
useEffect(() => () => {
if (memoIdCopyTimerRef.current !== null) window.clearTimeout(memoIdCopyTimerRef.current);
}, []);
useEffect(() => {
memoRef.current = memo;
setTitle(memo.title ?? "");
@@ -1192,51 +1243,205 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
return () => window.clearTimeout(timer);
}, [dirty, dirtyVersion, editSessionReady, readOnly, saveFailed, saving]);
const handleCopyMemoId = async () => {
if (isLocalMemoId(memo.id)) return;
const copied = await copyTextToClipboard(memo.id);
setMemoIdCopyNotice(copied ? "copied" : "error");
if (memoIdCopyTimerRef.current !== null) window.clearTimeout(memoIdCopyTimerRef.current);
memoIdCopyTimerRef.current = window.setTimeout(() => {
setMemoIdCopyNotice(null);
memoIdCopyTimerRef.current = null;
}, copied ? 2200 : 3000);
};
const handleSaveAsTemplate = () => {
if (!document || readOnly) return;
const name = window.prompt(t("templates.templateNamePrompt"), titleRef.current);
if (!name?.trim()) return;
const currentDocument = graphRef.current
? graphToDocument(graphRef.current, document.kind, themeRef.current)
: document;
const markdown = serializeDiagramDocument(currentDocument);
void onSaveAsTemplate({
...memoRef.current,
title: titleRef.current,
contentJson: markdownToDoc(diagramFallbackMarkdown(currentDocument)),
contentMarkdown: markdown,
}, name.trim());
};
if (!document) return null;
const kindLabel = document.kind === "mind-map" ? t("diagram.mindMap") : t("diagram.flowchart");
const updatedLabel = formatDateTime(memo.updatedAt);
const currentMarkdown = historyOpen
? serializeDiagramDocument(graphRef.current ? graphToDocument(graphRef.current, document.kind, themeRef.current) : document)
: memo.contentMarkdown;
return (
<TooltipProvider>
<div className="flex h-full min-h-0 flex-col bg-white">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-slate-200 px-3 py-2">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("diagram.back")} onClick={() => dirty ? setConfirmDiscardOpen(true) : onBackToList()}>
<ArrowLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("diagram.back")}</TooltipContent>
</Tooltip>
<div className="hidden rounded-full border border-[var(--brand-green-border)] bg-[var(--brand-green-soft)] px-2.5 py-1 text-xs font-semibold text-[var(--brand-green-text)] sm:block">{kindLabel}</div>
<input
className="min-w-0 flex-1 bg-transparent px-2 text-base font-semibold text-slate-950 outline-none placeholder:text-slate-400 disabled:text-slate-600"
value={title}
disabled={readOnly}
maxLength={160}
placeholder={kindLabel}
aria-label={t("diagram.title")}
onChange={(event) => {
const nextTitle = event.target.value;
titleRef.current = nextTitle;
setTitle(nextTitle);
setDirtyVersion((current) => current + 1);
const graph = graphRef.current;
if (graph) {
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
nextTitle,
graphToDocument(graph, document.kind, themeRef.current),
));
}
}}
/>
<span className={cn("hidden text-xs sm:block", saveError ? "text-rose-600" : "text-slate-400")}>{saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))}</span>
<ThemeToggle />
{!readOnly && saveFailed && (
<Button variant="soft" size="sm" disabled={saving || !editSessionReady} onClick={() => void save()}>
<RefreshCw className="h-4 w-4" />
{t("diagram.retrySave")}
</Button>
)}
<header className="shrink-0 border-b border-slate-200 bg-white">
<div className="flex min-h-12 items-center justify-between gap-2 border-b border-slate-100 px-3 py-2 sm:px-5">
<div className="flex min-w-0 items-center gap-2 text-sm">
<Tooltip>
<TooltipTrigger asChild>
<Button className="lg:hidden" size="icon" variant="ghost" aria-label={t("diagram.back")} onClick={() => dirty ? setConfirmDiscardOpen(true) : onBackToList()}>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("diagram.back")}</TooltipContent>
</Tooltip>
<div className="hidden items-center gap-1 sm:flex lg:hidden">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("editor.previousMemo")} onClick={onOpenPreviousMemo} disabled={!hasPreviousMemo}>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("editor.previousMemo")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("editor.nextMemo")} onClick={onOpenNextMemo} disabled={!hasNextMemo}>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("editor.nextMemo")}</TooltipContent>
</Tooltip>
</div>
<div className="hidden items-center gap-1 lg:flex">
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant={desktopFocusMode ? "soft" : "ghost"} aria-label={t(desktopFocusMode ? "editor.exitFocusMode" : "editor.enterFocusMode")} aria-pressed={desktopFocusMode} onClick={onToggleDesktopFocusMode}>
{desktopFocusMode ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{t(desktopFocusMode ? "editor.exitFocusMode" : "editor.focusMode")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("editor.previousMemo")} onClick={onOpenPreviousMemo} disabled={!hasPreviousMemo}>
<ChevronLeft className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("editor.previousMemo")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("editor.nextMemo")} onClick={onOpenNextMemo} disabled={!hasNextMemo}>
<ChevronRight className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("editor.nextMemo")}</TooltipContent>
</Tooltip>
</div>
<span className="hidden truncate text-xs text-slate-400 sm:inline">{updatedLabel}</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<span className={cn(
"inline-flex max-w-[5.5rem] truncate rounded-full px-2 py-1 text-[11px] font-medium sm:hidden",
saveError ? "bg-rose-50 text-rose-700" : dirty || saving ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700",
)} role="status" aria-live="polite">
{saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))}
</span>
<span className={cn(
"hidden items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium sm:inline-flex",
saveError ? "bg-rose-50 text-rose-700" : dirty || saving ? "bg-amber-50 text-amber-700" : "bg-emerald-50 text-emerald-700",
)} role="status" aria-live="polite">
{saveError ? <CircleAlert className="h-3 w-3" /> : dirty || saving ? <LoaderCircle className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))}
</span>
{!readOnly && saveFailed && (
<Button variant="soft" size="sm" disabled={saving || !editSessionReady} onClick={() => void save()}>
<RefreshCw className="h-4 w-4" />
{t("diagram.retrySave")}
</Button>
)}
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" aria-label={t("editor.moreAria")}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 border border-slate-200 bg-white py-1 shadow-md">
<DropdownMenuItem disabled={isLocalMemoId(memo.id)} onClick={() => void handleCopyMemoId()}>
<Copy className="h-4 w-4 text-slate-500" />
{t(isLocalMemoId(memo.id) ? "editor.copyNoteIdAfterSync" : "editor.copyNoteId")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setHistoryOpen(true)}>
<HistoryIcon className="h-4 w-4 text-slate-500" />
{t("editor.versionHistory")}
</DropdownMenuItem>
{!readOnly && (
<DropdownMenuItem disabled={isLocalMemoId(memo.id)} onClick={() => setShareOpen(true)}>
<Link2 className="h-4 w-4 text-slate-500" />
{t(isLocalMemoId(memo.id) ? "sharing.afterSync" : "sharing.action")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => exportDiagram("png")}>
<FileImage className="h-4 w-4 text-slate-500" />
{t("diagram.exportPng")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportDiagram("svg")}>
<FileCode2 className="h-4 w-4 text-slate-500" />
{t("diagram.exportSvg")}
</DropdownMenuItem>
{!readOnly && (
<DropdownMenuItem onClick={handleSaveAsTemplate}>
<Pencil className="h-4 w-4 text-slate-500" />
{t("templates.saveAsTemplate")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{readOnly ? (
<>
<DropdownMenuItem onClick={() => void onRestored(memo.id)}>
<RotateCcw className="h-4 w-4 text-slate-500" />
{t("editor.restoreMemo")}
</DropdownMenuItem>
<DropdownMenuItem className="text-rose-700 focus:text-rose-700" onClick={() => void onPermanentDeleted(memo.id)}>
<Trash2 className="h-4 w-4" />
{t("editor.deleteForever")}
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem className="text-rose-700 focus:text-rose-700" onClick={() => void onDeleted(memo.id)}>
<Trash2 className="h-4 w-4" />
{t("editor.deleteMemo")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex min-h-14 items-center gap-3 px-4 py-2.5 sm:px-7">
<input
className="min-w-0 flex-1 bg-transparent text-xl font-semibold text-slate-950 outline-none placeholder:text-slate-400 disabled:text-slate-600 sm:text-2xl"
value={title}
disabled={readOnly}
maxLength={160}
placeholder={kindLabel}
aria-label={t("diagram.title")}
onChange={(event) => {
const nextTitle = event.target.value;
titleRef.current = nextTitle;
setTitle(nextTitle);
setDirtyVersion((current) => current + 1);
const graph = graphRef.current;
if (graph) {
setDirty(savedSnapshotRef.current !== diagramEditorSnapshot(
nextTitle,
graphToDocument(graph, document.kind, themeRef.current),
));
}
}}
/>
<div className="shrink-0 rounded-full border border-[var(--brand-green-border)] bg-[var(--brand-green-soft)] px-2.5 py-1 text-xs font-semibold text-[var(--brand-green-text)]">{kindLabel}</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
@@ -1406,6 +1611,24 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on
onConfirm={onBackToList}
/>
) : null}
{historyOpen ? (
<RevisionHistoryDialog
memo={memo}
repository={repository}
currentMarkdown={currentMarkdown}
onClose={() => setHistoryOpen(false)}
onRestored={async (restoredMemo) => {
setHistoryOpen(false);
await onSaved(restoredMemo);
}}
/>
) : null}
<ShareMemoDialog memoId={memo.id} open={shareOpen} onOpenChange={setShareOpen} />
{memoIdCopyNotice ? (
<ClipboardCopyNotice status={memoIdCopyNotice}>
{t(memoIdCopyNotice === "copied" ? "editor.noteIdCopied" : "editor.noteIdCopyFailed", { id: memo.id })}
</ClipboardCopyNotice>
) : null}
</div>
</TooltipProvider>
);
+1 -1
View File
@@ -3881,7 +3881,7 @@ const RichEditorPane = ({
</IconTooltip>
</div>
<span className="hidden truncate text-xs text-slate-400 sm:inline">
{t("editor.updatedAt", { time: updatedLabel })}
{updatedLabel}
</span>
</div>
+3 -1
View File
@@ -398,7 +398,9 @@ export const MemoCard = ({
</div>
<div className={cn("mt-3.5 flex flex-wrap items-center gap-2", listDensity === "compact" && "mt-1.5")}>
<time className="text-xs font-normal text-slate-500 dark:text-slate-400" dateTime={listTimestamp.value}>
{t(listTimestamp.field === "createdAt" ? "memoCard.createdAt" : "memoCard.updatedAt", { time: listTimestampLabel })}
{listTimestamp.field === "createdAt"
? t("memoCard.createdAt", { time: listTimestampLabel })
: listTimestampLabel}
</time>
{memo.tags.slice(0, 3).map((tag) => (
<span
+28
View File
@@ -3420,10 +3420,36 @@ export const WorkspaceApp = ({
memo={selectedMemo}
repository={repository}
readOnly={memoView === "trash" || selectedMemo.isDeleted}
desktopFocusMode={desktopFocusModeActive}
hasNextMemo={Boolean(nextMemoId)}
hasPreviousMemo={Boolean(previousMemoId)}
onBackToList={() => {
clearPendingCreatedMemo();
setActivePane("memos");
}}
onDeleted={async (memoId) => {
deleteMemoMutation.mutate({ memoId, permanent: false });
}}
onOpenNextMemo={() => {
if (nextMemoId) {
clearPendingCreatedMemo();
setCreatedMemoEditId(null);
setSelectedMemoId(nextMemoId);
}
}}
onOpenPreviousMemo={() => {
if (previousMemoId) {
clearPendingCreatedMemo();
setCreatedMemoEditId(null);
setSelectedMemoId(previousMemoId);
}
}}
onPermanentDeleted={async (memoId) => {
setMemoDeleteConfirmation({ kind: "single", memoIds: [memoId], permanent: true });
}}
onRestored={async (memoId) => {
await restoreMemoMutation.mutateAsync(memoId);
}}
onSaved={async (memo) => {
await putLocalMemo(localDataScope, memo);
cacheMemoDetail(queryClient, memo, memoView);
@@ -3433,6 +3459,8 @@ export const WorkspaceApp = ({
queryClient.invalidateQueries({ queryKey: ["notebooks"], refetchType: "inactive" }),
]);
}}
onSaveAsTemplate={handleSaveAsTemplate}
onToggleDesktopFocusMode={toggleDesktopFocusMode}
/>
) : (
<EditorPane