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 (