From 79253245f3f8a0e2f172f767e3060f3e6fa4e1d1 Mon Sep 17 00:00:00 2001 From: 134 Date: Sun, 4 Oct 2026 13:32:54 +0800 Subject: [PATCH] fix(flow-board): align Xmind editor UI and restore marquee feedback Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support. Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes. --- app/(chat)/flow-board/layout.tsx | 7 +- components/flow-board/drawnix-workspace.tsx | 182 +++- components/flow-board/flow-board-client.tsx | 2 + .../flow-board/mind-special-layout-board.ts | 3 + .../flow-board/xmind-annotation-panel.tsx | 2 +- components/flow-board/xmind-command-menu.tsx | 70 ++ components/flow-board/xmind-editor-chrome.tsx | 974 ++++-------------- components/flow-board/xmind-native.css | 161 +++ components/flow-board/xmind-presentation.tsx | 28 + components/flow-board/xmind-search-panel.tsx | 45 + components/flow-board/xmind-topic-panel.tsx | 2 +- docs/XMind-26.04-客户端界面对照.md | 30 + docs/XMind-复刻功能对照.md | 6 +- lib/flow-board/topic-search.ts | 58 ++ lib/flow-board/xmind-export.ts | 1 + lib/flow-board/xmind-import.ts | 15 +- lib/flow-board/xmind-typography.ts | 23 + public/fonts/xmind/NeverMind-Bold.ttf | Bin 0 -> 94244 bytes public/fonts/xmind/NeverMind-Extrabold.ttf | Bin 0 -> 94328 bytes public/fonts/xmind/NeverMind-Medium.ttf | Bin 0 -> 94020 bytes public/fonts/xmind/NeverMind-Regular.ttf | Bin 0 -> 92120 bytes public/fonts/xmind/README.md | 5 + public/icons/xmind/README.md | 16 + public/icons/xmind/boundary.svg | 1 + public/icons/xmind/export.svg | 16 + public/icons/xmind/insert.svg | 1 + public/icons/xmind/pitch.svg | 1 + public/icons/xmind/relationship.svg | 1 + public/icons/xmind/sidebar.svg | 1 + public/icons/xmind/subtopic.svg | 1 + public/icons/xmind/summary.svg | 1 + public/icons/xmind/topic.svg | 1 + public/icons/xmind/zen.svg | 1 + tests/flow-board/annotations.spec.ts | 11 +- tests/flow-board/editor-actions.ts | 35 + tests/flow-board/matrix-tables.spec.ts | 15 +- tests/flow-board/native-editor.spec.ts | 86 ++ tests/flow-board/outline.spec.ts | 15 +- tests/flow-board/selection.spec.ts | 95 ++ tests/flow-board/special-layouts.spec.ts | 14 +- tests/flow-board/topic-details.spec.ts | 21 +- tests/flow-board/tree-table.spec.ts | 14 +- tests/flow-board/vertical-timeline.spec.ts | 11 +- tests/flow-board/xmind-export.spec.ts | 5 +- tests/flow-board/xmind-import.spec.ts | 2 + .../flow-board/xmind-native-fixtures.spec.ts | 10 + tests/routes/flow-board-search.test.ts | 37 + 47 files changed, 1138 insertions(+), 888 deletions(-) create mode 100644 components/flow-board/xmind-command-menu.tsx create mode 100644 components/flow-board/xmind-native.css create mode 100644 components/flow-board/xmind-presentation.tsx create mode 100644 components/flow-board/xmind-search-panel.tsx create mode 100644 docs/XMind-26.04-客户端界面对照.md create mode 100644 lib/flow-board/topic-search.ts create mode 100644 lib/flow-board/xmind-typography.ts create mode 100644 public/fonts/xmind/NeverMind-Bold.ttf create mode 100644 public/fonts/xmind/NeverMind-Extrabold.ttf create mode 100644 public/fonts/xmind/NeverMind-Medium.ttf create mode 100644 public/fonts/xmind/NeverMind-Regular.ttf create mode 100644 public/fonts/xmind/README.md create mode 100644 public/icons/xmind/README.md create mode 100644 public/icons/xmind/boundary.svg create mode 100644 public/icons/xmind/export.svg create mode 100644 public/icons/xmind/insert.svg create mode 100644 public/icons/xmind/pitch.svg create mode 100644 public/icons/xmind/relationship.svg create mode 100644 public/icons/xmind/sidebar.svg create mode 100644 public/icons/xmind/subtopic.svg create mode 100644 public/icons/xmind/summary.svg create mode 100644 public/icons/xmind/topic.svg create mode 100644 public/icons/xmind/zen.svg create mode 100644 tests/flow-board/editor-actions.ts create mode 100644 tests/flow-board/native-editor.spec.ts create mode 100644 tests/flow-board/selection.spec.ts create mode 100644 tests/routes/flow-board-search.test.ts diff --git a/app/(chat)/flow-board/layout.tsx b/app/(chat)/flow-board/layout.tsx index 2e0557122..0bcc5079b 100644 --- a/app/(chat)/flow-board/layout.tsx +++ b/app/(chat)/flow-board/layout.tsx @@ -3,10 +3,5 @@ export default function FlowBoardLayout({ }: { children: React.ReactNode; }) { - return ( - <> - - {children} - - ); + return <>{children}; } diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index 84ce2dc8d..0a13a2d4a 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -1,5 +1,10 @@ "use client"; +import "./xmind-native.css"; +import { XmindSearchPanel } from "./xmind-search-panel"; +import { XmindPresentation } from "./xmind-presentation"; +import { replaceTopicText, type TopicSearchResult } from "@/lib/flow-board/topic-search"; + import { Drawnix, type DrawnixProps } from "@drawnix/drawnix"; import { BoardCreationMode, @@ -197,7 +202,7 @@ type FlowBoardSelectionStyleState = { const FLOW_BOARD_FILES_API = "/api/flow-board/files"; const DEFAULT_FLOW_BOARD_CANVAS: FlowBoardCanvasSettings = { - backgroundColor: "#f7f8fb", + backgroundColor: "#ffffff", gridColor: "#d8dde8", gridEnabled: false, gridSize: 24, @@ -928,6 +933,12 @@ export function DrawnixWorkspace({ const [isSavingNow, setIsSavingNow] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isOutlineOpen, setIsOutlineOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [isZenMode, setIsZenMode] = useState(false); + const [isPresenting, setIsPresenting] = useState(false); + const [pendingTopicJump, setPendingTopicJump] = useState(null); + const searchUndoRef = useRef<{ before: FlowBoardSheetSnapshot[]; after: FlowBoardSheetSnapshot[] }[]>([]); + const searchRedoRef = useRef<{ before: FlowBoardSheetSnapshot[]; after: FlowBoardSheetSnapshot[] }[]>([]); const [outlineZoomPercent, setOutlineZoomPercent] = useState(100); const [, setLastSavedAt] = useState(null); const [activeTool, setActiveTool] = @@ -966,6 +977,20 @@ export function DrawnixWorkspace({ getFlowBoardSelectionStyleState(null, []) ); + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (isPresenting) return; + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true); + } + if (event.key === "Escape" && (isZenMode || isSearchOpen)) { + event.preventDefault(); event.stopPropagation(); setIsZenMode(false); setIsSearchOpen(false); + } + }; + window.addEventListener("keydown",onKey,true); + return () => window.removeEventListener("keydown",onKey,true); + }, [isZenMode,isSearchOpen,isPresenting]); + const displayLogo = useMemo(() => { if (resolvedTheme === "dark" && siteLogoDark) { return siteLogoDark; @@ -1063,6 +1088,8 @@ export function DrawnixWorkspace({ const handleDrawnixInit = useCallback((board: PlaitBoard) => { boardRef.current = board; withMindAnnotations(board); + BoardTransforms.updatePointerType(board, PlaitPointerType.selection); + setCreationMode(board, BoardCreationMode.dnd); setRelationshipSourceId(null); setAnnotationId(null); setSelectedTopicId(null); @@ -1081,9 +1108,7 @@ export function DrawnixWorkspace({ selectedMindElementsRef.current ) ); - setActiveTool( - (PlaitBoard.getPointer(board) as FlowBoardToolId) || "selection" - ); + setActiveTool("selection"); setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100)); }, []); @@ -1139,6 +1164,8 @@ export function DrawnixWorkspace({ const nextSnapshot = normalizeSnapshot(file.snapshot); snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot); + searchUndoRef.current = []; + searchRedoRef.current = []; setCurrentFileId(file.id); setCurrentTitle(file.title || "未命名流程白板"); setLastSavedAt( @@ -1632,12 +1659,25 @@ export function DrawnixWorkspace({ text: BasicShapes.text, }; BoardTransforms.updatePointerType(board, pointers[tool]); - if (tool !== "selection" && tool !== "hand") { - setCreationMode(board, BoardCreationMode.drawing); - } + setCreationMode(board, tool === "selection" || tool === "hand" || tool === "mind" + ? BoardCreationMode.dnd : BoardCreationMode.drawing); setActiveTool(tool); }, []); + useEffect(() => { + const handlePointerShortcut = (event: KeyboardEvent) => { + if (isPresenting || isOutlineOpen || event.metaKey || event.ctrlKey || event.altKey || + isFlowBoardEditablePasteTarget(event.target as HTMLElement)) return; + const key = event.key.toLowerCase(); + if (key !== "v" && key !== "h") return; + event.preventDefault(); + event.stopImmediatePropagation(); + handleToolChange(key === "v" ? "selection" : "hand"); + }; + window.addEventListener("keydown", handlePointerShortcut, true); + return () => window.removeEventListener("keydown", handlePointerShortcut, true); + }, [handleToolChange, isPresenting, isOutlineOpen]); + const handleInsertChildTopic = useCallback(() => { const board = boardRef.current; const element = getActiveMarkerElements()[0]; @@ -1879,13 +1919,36 @@ export function DrawnixWorkspace({ BoardTransforms.updateThemeColor(board, themeId as ThemeColorMode); }, []); + const searchFingerprint = (sheets: FlowBoardSheetSnapshot[]) => JSON.stringify(sheets.map(sheet => ({ id: sheet.id, children: sheet.children }))); + const restoreSearchSheets = useCallback((sheets: FlowBoardSheetSnapshot[]) => { + const board = boardRef.current; + const current = snapshotRef.current; + const active = sheets.find(sheet => sheet.id === current.activeSheetId); + if (!board || !active) return; + PlaitHistoryBoard.withoutSaving(board, () => { + for (const entry of getTopicEntries(board.children)) { + const target = getTopicEntries(active.children).find(item => item.element.id === entry.element.id); + if (target && JSON.stringify(target.element.data) !== JSON.stringify(entry.element.data)) { + Transforms.setNode(board, { data: structuredClone(target.element.data) }, entry.path); + } + } + }); + updateSnapshot({ sheets: structuredClone(sheets), children: board.children }); + }, [updateSnapshot]); + const handleUndo = useCallback(() => { - boardRef.current?.undo(); - }, []); + const edit = searchUndoRef.current.at(-1); + if (edit && searchFingerprint(snapshotRef.current.sheets || []) === searchFingerprint(edit.after)) { + searchUndoRef.current.pop(); searchRedoRef.current.push(edit); restoreSearchSheets(edit.before); + } else boardRef.current?.undo(); + }, [restoreSearchSheets]); const handleRedo = useCallback(() => { - boardRef.current?.redo(); - }, []); + const edit = searchRedoRef.current.at(-1); + if (edit && searchFingerprint(snapshotRef.current.sheets || []) === searchFingerprint(edit.before)) { + searchRedoRef.current.pop(); searchUndoRef.current.push(edit); restoreSearchSheets(edit.after); + } else boardRef.current?.redo(); + }, [restoreSearchSheets]); const handleZoom = useCallback((delta: number) => { const board = boardRef.current; @@ -1954,6 +2017,46 @@ export function DrawnixWorkspace({ [updateSnapshot] ); + const handleSearchJump = useCallback((result: TopicSearchResult) => { + if (result.sheetId === snapshotRef.current.activeSheetId) handleJumpToTopic(result.id); + else { + setPendingTopicJump(result); + handleSwitchSheet(result.sheetId); + } + }, [handleJumpToTopic, handleSwitchSheet]); + + useEffect(() => { + if (!pendingTopicJump || snapshot.activeSheetId !== pendingTopicJump.sheetId) return; + let frame = 0; + let attempts = 0; + const jump = () => { + const entries = getTopicEntries(boardRef.current?.children || []); + if (entries.some(entry => entry.element.id === pendingTopicJump.id)) { + handleJumpToTopic(pendingTopicJump.id); setPendingTopicJump(null); + } else if (++attempts < 30) frame = requestAnimationFrame(jump); + }; + frame = requestAnimationFrame(jump); + return () => cancelAnimationFrame(frame); + }, [pendingTopicJump, snapshot.activeSheetId, handleJumpToTopic]); + + const handleSearchReplace = useCallback((results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => { + const board = boardRef.current; + if (!board || !query || !results.length) return; + const current = snapshotRef.current; + const before = structuredClone(current.sheets || []); + const ids = new Set(results.map(result => `${result.sheetId}:${result.id}`)); + const after = structuredClone(before); + for (const sheet of after) { + for (const entry of getTopicEntries(sheet.children)) { + if (ids.has(`${sheet.id}:${entry.element.id}`)) entry.element.data = { ...entry.element.data, topic: replaceTopicText(entry.element,query,replacement,caseSensitive) }; + } + } + if (searchFingerprint(before) === searchFingerprint(after)) return; + searchUndoRef.current.push({ before, after }); + searchRedoRef.current = []; + restoreSearchSheets(after); + }, [restoreSearchSheets]); + const handleAddSheet = useCallback(() => { const current = snapshotRef.current; const sheets = current.sheets || []; @@ -2268,6 +2371,9 @@ export function DrawnixWorkspace({ await flushPendingSave(false); } + if (typeof document !== "undefined" && document.fonts) { + await Promise.all([document.fonts.load("18px NeverMind"), document.fonts.load("bold 30px NeverMind"), document.fonts.load("500 18px NeverMind"), document.fonts.load("800 30px NeverMind")]); + } const title = getMindMapImportTitle(file.name); const imported = await parseMindMapFileToDocument(file, title); const nextSnapshot = normalizeSnapshot({ @@ -2511,7 +2617,7 @@ export function DrawnixWorkspace({ ); const isFormatPanelVisible = - isFormatPanelOpen && !isFilePanelOpen && !isMarkerPanelOpen; + !isZenMode && isFormatPanelOpen && !isFilePanelOpen && !isMarkerPanelOpen; const topicEntries = getTopicEntries(snapshot.children); const selectedTopic = topicEntries.find(({ element }) => element.id === selectedTopicId)?.element || null; const selectedAnnotation = snapshot.children.find( @@ -2534,9 +2640,25 @@ export function DrawnixWorkspace({ return (
{ + if (isPresenting) return; + if (event.key === "Escape") { + if (isSearchOpen) { event.preventDefault(); setIsSearchOpen(false); } + if (isZenMode) { event.preventDefault(); setIsZenMode(false); } + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true); + } + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) { + event.preventDefault(); event.stopPropagation(); + if (event.shiftKey) handleRedo(); else handleUndo(); + } + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "n") { + event.preventDefault(); setIsTopicPanelOpen(true); setIsFormatPanelOpen(true); setTopicDetailSection("notes"); + } if (event.key === "Escape" && relationshipSourceId) { event.preventDefault(); setRelationshipSourceId(null); @@ -2622,7 +2744,11 @@ export function DrawnixWorkspace({ type="file" /> - setIsSearchOpen(true)} + onToggleZen={() => setIsZenMode(true)} + onToggleOutline={() => setIsOutlineOpen(value => !value)} + onPresentation={() => setIsPresenting(true)} activeTool={activeTool} canEditMind={markerSelectionState.count > 0} currentTitle={currentTitle} @@ -2677,7 +2803,7 @@ export function DrawnixWorkspace({ onUndo={handleUndo} siteLogo={displayLogo} siteName={siteName} - /> + /> : } {isFormatPanelVisible && isTopicPanelOpen ? ( ) : null} - {isFilePanelOpen ? ( + {!isZenMode && isFilePanelOpen ? ( ) : null} - {isMarkerPanelOpen ? ( + {!isZenMode && isMarkerPanelOpen ? (
); } diff --git a/components/flow-board/flow-board-client.tsx b/components/flow-board/flow-board-client.tsx index 62a773b80..c8c24f8c8 100644 --- a/components/flow-board/flow-board-client.tsx +++ b/components/flow-board/flow-board-client.tsx @@ -1,5 +1,7 @@ "use client"; +import "@/public/vendor/drawnix.css"; + import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; diff --git a/components/flow-board/mind-special-layout-board.ts b/components/flow-board/mind-special-layout-board.ts index 3b7b05899..c656d1a5f 100644 --- a/components/flow-board/mind-special-layout-board.ts +++ b/components/flow-board/mind-special-layout-board.ts @@ -16,6 +16,8 @@ import { specialSubtreeBounds, } from "@/lib/flow-board/mind-special-layout"; +import { measureXmindTopics } from "@/lib/flow-board/xmind-typography"; + const ns = "http://www.w3.org/2000/svg"; type TableCell = { x: number; @@ -457,6 +459,7 @@ function specialLink(component: MindNodeComponent) { } class SpecialMindRoot extends PlaitMindComponent { override updateMindLayout(element = this.element) { + measureXmindTopics(this.board, element); super.updateMindLayout(element); if (isSpecialMindLayout(element.specialLayout)) arrangeSpecialMind(this.root, element.specialLayout); diff --git a/components/flow-board/xmind-annotation-panel.tsx b/components/flow-board/xmind-annotation-panel.tsx index 8fda3b266..a2b0aeeb6 100644 --- a/components/flow-board/xmind-annotation-panel.tsx +++ b/components/flow-board/xmind-annotation-panel.tsx @@ -20,7 +20,7 @@ export function XmindAnnotationPanel({ .join(""); return (