From 28e3f4944d774a2fd95e5344f07988eeaf64100e Mon Sep 17 00:00:00 2001 From: tianma-if Date: Sat, 5 Sep 2026 19:34:19 +0800 Subject: [PATCH] feat(web): align diagram editor header --- .../src/components/DiagramEditorPane.test.mjs | 14 + apps/web/src/components/DiagramEditorPane.tsx | 311 +++++++++++++++--- apps/web/src/components/EditorPane.tsx | 2 +- apps/web/src/components/MemoCard.tsx | 4 +- apps/web/src/components/WorkspaceApp.tsx | 28 ++ 5 files changed, 313 insertions(+), 46 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 21063c7c..468d19aa 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -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('{updatedLabel}'); + 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(" { expect(source).toContain("EDITOR_LOCAL_SAVE_DELAY_MS"); expect(source).toContain("window.setTimeout(() => saveRef.current(), EDITOR_LOCAL_SAVE_DELAY_MS)"); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 22b3ba4c..f212e472 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -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; + onOpenNextMemo: () => void; + onOpenPreviousMemo: () => void; + onPermanentDeleted: (memoId: string) => Promise; + onRestored: (memoId: string) => Promise; onSaved: (memo: MemoDetail) => Promise; + onSaveAsTemplate: (memo: MemoDetail, name: string) => Promise; + 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(null); @@ -381,6 +421,10 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on const [editSessionReady, setEditSessionReady] = useState(false); const [saveError, setSaveError] = useState(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(null); const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); const [historyState, setHistoryState] = useState({ undo: false, redo: false }); const [nodeEditor, setNodeEditor] = useState(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 (
-
- - - - - {t("diagram.back")} - -
{kindLabel}
- { - 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), - )); - } - }} - /> - {saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))} - - {!readOnly && saveFailed && ( - - )} +
+
+
+ + + + + {t("diagram.back")} + +
+ + + + + {t("editor.previousMemo")} + + + + + + {t("editor.nextMemo")} + +
+
+ + + + + {t(desktopFocusMode ? "editor.exitFocusMode" : "editor.focusMode")} + + + + + + {t("editor.previousMemo")} + + + + + + {t("editor.nextMemo")} + +
+ {updatedLabel} +
+ +
+ + {saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))} + + + {saveError ? : dirty || saving ? : } + {saveError ?? (dirty || saving ? t("diagram.saving") : t("diagram.saved"))} + + {!readOnly && saveFailed && ( + + )} + + + + + + + void handleCopyMemoId()}> + + {t(isLocalMemoId(memo.id) ? "editor.copyNoteIdAfterSync" : "editor.copyNoteId")} + + setHistoryOpen(true)}> + + {t("editor.versionHistory")} + + {!readOnly && ( + setShareOpen(true)}> + + {t(isLocalMemoId(memo.id) ? "sharing.afterSync" : "sharing.action")} + + )} + + exportDiagram("png")}> + + {t("diagram.exportPng")} + + exportDiagram("svg")}> + + {t("diagram.exportSvg")} + + {!readOnly && ( + + + {t("templates.saveAsTemplate")} + + )} + + {readOnly ? ( + <> + void onRestored(memo.id)}> + + {t("editor.restoreMemo")} + + void onPermanentDeleted(memo.id)}> + + {t("editor.deleteForever")} + + + ) : ( + void onDeleted(memo.id)}> + + {t("editor.deleteMemo")} + + )} + + +
+
+ +
+ { + 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), + )); + } + }} + /> +
{kindLabel}
+
@@ -1406,6 +1611,24 @@ export const DiagramEditorPane = ({ memo, repository, readOnly, onBackToList, on onConfirm={onBackToList} /> ) : null} + {historyOpen ? ( + setHistoryOpen(false)} + onRestored={async (restoredMemo) => { + setHistoryOpen(false); + await onSaved(restoredMemo); + }} + /> + ) : null} + + {memoIdCopyNotice ? ( + + {t(memoIdCopyNotice === "copied" ? "editor.noteIdCopied" : "editor.noteIdCopyFailed", { id: memo.id })} + + ) : null}
); diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 4748c68f..a21ba95d 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -3881,7 +3881,7 @@ const RichEditorPane = ({
- {t("editor.updatedAt", { time: updatedLabel })} + {updatedLabel} diff --git a/apps/web/src/components/MemoCard.tsx b/apps/web/src/components/MemoCard.tsx index c402bb7a..29ce868c 100644 --- a/apps/web/src/components/MemoCard.tsx +++ b/apps/web/src/components/MemoCard.tsx @@ -398,7 +398,9 @@ export const MemoCard = ({
{memo.tags.slice(0, 3).map((tag) => ( { 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} /> ) : (