"use client"; import "./xmind-native.css"; import { XmindMarkerPanel } from "./xmind-marker-panel"; import { withXmindMarkers } from "./mind-marker-board"; import { getXmindMarker, getXmindMarkerIds, toggleXmindMarker, type XmindMarker } from "@/lib/flow-board/xmind-markers"; 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, getTextEditorsByElement, memorizeLatest, setCreationMode, } from "@plait/common"; import { BoardTransforms, DEFAULT_COLOR, PlaitBoard, PlaitHistoryBoard, type PlaitElement, PlaitNode, PlaitPointerType, type PlaitTheme, type Point, type Selection, type ThemeColorMode, Transforms, type Viewport, getHitElementsBySelection, getSelectedElements, toHostPoint, toViewBoxPoint, } from "@plait/core"; import { ArrowLineShape, BasicShapes, DrawTransforms, MemorizeKey, } from "@plait/draw"; import { MindLayoutType } from "@plait/layouts"; import { BranchShape, canSetAbstract, DEFAULT_MIND_IMAGE_WIDTH, getBranchShapeByMindElement, getRectangleByNode, getFontSizeByMindElement, getSelectedMindElements, getShapeByElement, MindElement, MindElementShape, MindPointerType, MindTransforms, PlaitMind, type PlaitMindBoard, } from "@plait/mind"; import { type FontSizes, getTextMarksByElement, MarkTypes, TextTransforms, } from "@plait/text-plugins"; import type { BoardChangeData } from "@plait-board/react-board"; import { FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT, getMindMapImportTitle, isSupportedMindMapImportFile, parseMindMapFileToDocument, MindMapImportError, } from "@/lib/flow-board/mind-map-import"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { IMAGE_UPLOAD_MAX_BYTES, IMAGE_UPLOAD_TOO_LARGE_MESSAGE, } from "@/lib/image-upload-limits"; import { type FlowBoardLayoutOption, type FlowBoardThemeOption, type FlowBoardToolId, XmindEditorTopBar, XmindFormatPanel, XmindSheetBar, } from "@/components/flow-board/xmind-editor-chrome"; import { FileText, Loader2, Plus, Trash2, X, } from "lucide-react"; import { useTheme } from "next-themes"; import { type ChangeEvent, type ClipboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast as appToast } from "sonner"; import { insertMindAnnotation, withMindAnnotations } from "./mind-annotation-board"; import { isMindAnnotation, validateTopicRange } from "@/lib/flow-board/mind-annotations"; import { XmindAnnotationPanel } from "./xmind-annotation-panel"; import { XmindTopicPanel, type TopicDetailSection } from "./xmind-topic-panel"; import { XmindTaskEditor } from "./xmind-task-editor"; import { XmindGantt } from "./xmind-gantt"; import { XmindProjectCalendar } from "./xmind-project-calendar"; import { XmindImageExport } from "./xmind-image-export"; import type { ImageExportOptions } from "@/lib/flow-board/image-export"; import { calendarRoot, getProjectCalendar, patchProjectCalendar, validateWorkingCalendar, type ProjectCalendarConfig } from "@/lib/flow-board/project-calendar"; import { XmindTopicBadges } from "./xmind-topic-badges"; import { XmindNotesPopover } from "./xmind-notes-popover"; import { getTopicTask, patchTopicTask, scheduleTaskChanges, type TopicTask } from "@/lib/flow-board/topic-tasks"; import { XmindOutline } from "./xmind-outline"; import { withSpecialMindLayouts } from "./mind-special-layout-board"; import { SPECIAL_MIND_LAYOUTS, isSpecialMindLayout } from "@/lib/flow-board/mind-special-layout"; import { applyOutlineCommand, renameOutlineTopic, type OutlineCommand, type OutlineDrop } from "@/lib/flow-board/mind-outline"; import { getTopicEntries, patchTopicDetails, type TopicDetails } from "@/lib/flow-board/topic-details"; // Keep editor feedback away from the floating tools and their hover targets. const toast = { ...appToast, success: (message: Parameters[0], options?: Parameters[1]) => appToast.success(message, { position: "bottom-center", ...options }), info: (message: Parameters[0], options?: Parameters[1]) => appToast.info(message, { position: "bottom-center", ...options }), error: (message: Parameters[0], options?: Parameters[1]) => appToast.error(message, { position: "bottom-center", ...options }), warning: (message: Parameters[0], options?: Parameters[1]) => appToast.warning(message, { position: "bottom-center", ...options }), }; type FlowBoardWorkspaceProps = { siteName: string; siteLogo: string | null; siteLogoDark: string | null; previewMode?: boolean; }; type FlowBoardSnapshot = { version?: number; activeSheetId?: string; sheets?: FlowBoardSheetSnapshot[]; children: PlaitElement[]; viewport?: Viewport; theme?: PlaitTheme; canvas: FlowBoardCanvasSettings; updatedAt?: string; }; type FlowBoardCanvasSettings = { backgroundColor: string; gridColor: string; gridEnabled: boolean; gridSize: number; }; type FlowBoardSheetSnapshot = { id: string; title: string; children: PlaitElement[]; viewport?: Viewport; theme?: PlaitTheme; canvas: FlowBoardCanvasSettings; }; type FlowBoardCloudFile = { id: string; title: string; createdAt?: string | null; updatedAt?: string | null; }; type FlowBoardCloudFilePayload = FlowBoardCloudFile & { snapshot?: unknown; }; type FlowBoardMarkerSelectionState = { count: number; names: string[]; mixedNames: string[]; }; type FlowBoardSelectionStyleState = { branchColor: string; branchShape: BranchShape; branchWidth: number; fill: string; fontSize: string; isBold: boolean; isItalic: boolean; isUnderlined: boolean; layout: string; nodeShape: MindElementShape; textColor: string; }; const FLOW_BOARD_FILES_API = "/api/flow-board/files"; const DEFAULT_FLOW_BOARD_CANVAS: FlowBoardCanvasSettings = { backgroundColor: "#ffffff", gridColor: "#d8dde8", gridEnabled: false, gridSize: 24, }; const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR; const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES; const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", ]); const FLOW_BOARD_LAYOUT_OPTIONS: FlowBoardLayoutOption[] = [ { id: MindLayoutType.standard, label: "思维导图", preview: "both" }, { id: MindLayoutType.right, label: "向右逻辑", preview: "right" }, { id: MindLayoutType.left, label: "向左逻辑", preview: "left" }, { id: MindLayoutType.downward, label: "向下逻辑", preview: "down" }, { id: MindLayoutType.upward, label: "向上逻辑", preview: "up" }, { id: MindLayoutType.rightBottomIndented, label: "目录组织", preview: "indent", }, ...Object.entries(SPECIAL_MIND_LAYOUTS).map(([id, value]) => ({ id, label: value.label, preview: value.preview })), ]; const FLOW_BOARD_PLUGINS = [withSpecialMindLayouts, withXmindMarkers]; const FLOW_BOARD_THEME_OPTIONS: FlowBoardThemeOption[] = [ { id: "default", label: "经典", colors: ["#2f6feb", "#8fb3ff", "#dbe7ff"] }, { id: "colorful", label: "彩虹", colors: ["#ef6461", "#f4c95d", "#5abf90"] }, { id: "soft", label: "柔和", colors: ["#7d8fc7", "#b7c2e2", "#e4e9f5"] }, { id: "retro", label: "复古", colors: ["#bf6b55", "#d7a86e", "#6b8b75"] }, { id: "dark", label: "深色", colors: ["#24272d", "#59616e", "#9aa4b2"] }, { id: "starry", label: "星空", colors: ["#243b74", "#5471c6", "#9cadf0"] }, ]; function isFlowBoardRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function shouldUseDefaultTextColor(value: unknown) { if (typeof value !== "string") { return true; } const normalized = value.trim().toUpperCase(); return ( normalized === "" || normalized === "NO_COLOR" || normalized === "TRANSPARENT" ); } function normalizeFlowBoardTextPayload(value: unknown): unknown { if (Array.isArray(value)) { let changed = false; const nextValue = value.map((item) => { const nextItem = normalizeFlowBoardTextPayload(item); if (nextItem !== item) { changed = true; } return nextItem; }); return changed ? nextValue : value; } if (!isFlowBoardRecord(value)) { return value; } let nextValue: Record | null = null; const ensureNextValue = () => { if (!nextValue) { nextValue = { ...value }; } return nextValue; }; if ( typeof value.text === "string" && shouldUseDefaultTextColor(value.color) ) { ensureNextValue().color = FLOW_BOARD_DEFAULT_TEXT_COLOR; } else if (Array.isArray(value.text) || isFlowBoardRecord(value.text)) { const nextText = normalizeFlowBoardTextPayload(value.text); if (nextText !== value.text) { ensureNextValue().text = nextText; } } if (Array.isArray(value.children)) { const nextChildren = normalizeFlowBoardTextPayload(value.children); if (nextChildren !== value.children) { ensureNextValue().children = nextChildren; } } return nextValue || value; } function normalizeFlowBoardElementText(element: unknown): PlaitElement { if (!isFlowBoardRecord(element)) { return element as PlaitElement; } let nextElement: Record | null = null; const ensureNextElement = () => { if (!nextElement) { nextElement = { ...element }; } return nextElement; }; if (Array.isArray(element.text) || isFlowBoardRecord(element.text)) { const nextText = normalizeFlowBoardTextPayload(element.text); if (nextText !== element.text) { ensureNextElement().text = nextText; } } if (Array.isArray(element.texts)) { const nextTexts = normalizeFlowBoardTextPayload(element.texts); if (nextTexts !== element.texts) { ensureNextElement().texts = nextTexts; } } if ( element.shape === "text" && shouldUseDefaultTextColor(element.strokeColor) ) { ensureNextElement().strokeColor = FLOW_BOARD_DEFAULT_TEXT_COLOR; } return (nextElement || element) as PlaitElement; } function normalizeFlowBoardChildren(children: unknown): PlaitElement[] { if (!Array.isArray(children)) { return []; } let changed = false; const nextChildren = children.map((element) => { const nextElement = normalizeFlowBoardElementText(element); if (nextElement !== element) { changed = true; } return nextElement; }); return changed ? nextChildren : (children as PlaitElement[]); } function seedFlowBoardDefaultTextColor() { const remember = memorizeLatest as ( memorizedKey: string, propertyKey: string, propertyValue: unknown ) => void; [ MemorizeKey.text, MemorizeKey.basicShape, MemorizeKey.flowchart, MemorizeKey.arrowLine, MemorizeKey.UML, ].forEach((memorizedKey) => { remember(memorizedKey, "text", { color: FLOW_BOARD_DEFAULT_TEXT_COLOR }); }); } function createFlowBoardSheetId() { if (typeof globalThis.crypto?.randomUUID === "function") { return `sheet-${globalThis.crypto.randomUUID()}`; } return `sheet-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } function cloneFlowBoardSheet(value: T): T { if (typeof globalThis.structuredClone === "function") { return globalThis.structuredClone(value); } return JSON.parse(JSON.stringify(value)) as T; } function normalizeFlowBoardCanvasSettings( value: unknown ): FlowBoardCanvasSettings { const canvas = isFlowBoardRecord(value) ? value : {}; const normalizeColor = (color: unknown, fallback: string) => typeof color === "string" && /^#[0-9a-f]{6}$/i.test(color) ? color.toLowerCase() : fallback; const requestedGridSize = typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize) ? Math.round(canvas.gridSize) : DEFAULT_FLOW_BOARD_CANVAS.gridSize; return { backgroundColor: normalizeColor( canvas.backgroundColor, DEFAULT_FLOW_BOARD_CANVAS.backgroundColor ), gridColor: normalizeColor( canvas.gridColor, DEFAULT_FLOW_BOARD_CANVAS.gridColor ), gridEnabled: typeof canvas.gridEnabled === "boolean" ? canvas.gridEnabled : DEFAULT_FLOW_BOARD_CANVAS.gridEnabled, gridSize: Math.min(64, Math.max(8, requestedGridSize)), }; } function normalizeFlowBoardSheet( value: unknown, index: number ): FlowBoardSheetSnapshot | null { if (!isFlowBoardRecord(value)) { return null; } const id = typeof value.id === "string" ? value.id.trim() : ""; const title = typeof value.title === "string" ? value.title.trim() : ""; return { id: id || `sheet-${index + 1}`, title: title || `画布 ${index + 1}`, children: normalizeFlowBoardChildren(value.children), viewport: isFlowBoardRecord(value.viewport) ? (value.viewport as Viewport) : undefined, theme: isFlowBoardRecord(value.theme) ? (value.theme as unknown as PlaitTheme) : undefined, canvas: normalizeFlowBoardCanvasSettings(value.canvas), }; } function normalizeSnapshot(value: unknown): FlowBoardSnapshot { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Partial) : {}; const normalizedSheets = Array.isArray(raw.sheets) ? raw.sheets .map(normalizeFlowBoardSheet) .filter((sheet): sheet is FlowBoardSheetSnapshot => Boolean(sheet)) : []; const sheets = normalizedSheets.length > 0 ? normalizedSheets : [ { id: "sheet-1", title: "画布 1", children: normalizeFlowBoardChildren(raw.children), viewport: raw.viewport, theme: raw.theme, canvas: normalizeFlowBoardCanvasSettings(raw.canvas), }, ]; const requestedActiveSheetId = typeof raw.activeSheetId === "string" ? raw.activeSheetId : ""; const activeSheet = sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0]; const activeChildren = normalizeFlowBoardChildren( raw.children ?? activeSheet.children ); const activeViewport = raw.viewport ?? activeSheet.viewport; const activeTheme = raw.theme ?? activeSheet.theme; const activeCanvas = normalizeFlowBoardCanvasSettings( raw.canvas ?? activeSheet.canvas ); const nextSheets = sheets.map((sheet) => sheet.id === activeSheet.id ? { ...sheet, children: activeChildren, viewport: activeViewport, theme: activeTheme, canvas: activeCanvas, } : sheet ); return { version: 2, activeSheetId: activeSheet.id, sheets: nextSheets, children: activeChildren, viewport: activeViewport, theme: activeTheme, canvas: activeCanvas, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined, }; } function formatFileUpdatedAt(value?: string | null) { if (!value) return "刚刚"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "刚刚"; return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } function normalizeCloudFile(value: unknown): FlowBoardCloudFilePayload | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } const raw = value as Partial; const id = typeof raw.id === "string" ? raw.id.trim() : ""; if (!id) return null; return { id, title: typeof raw.title === "string" && raw.title.trim() ? raw.title.trim() : "未命名流程白板", snapshot: raw.snapshot, createdAt: typeof raw.createdAt === "string" ? raw.createdAt : null, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : null, }; } function getFlowBoardFileUrl(fileId: string) { return `${FLOW_BOARD_FILES_API}/${encodeURIComponent(fileId)}`; } function sortCloudFiles(files: FlowBoardCloudFile[]) { return [...files].sort( (a, b) => new Date(b.updatedAt || b.createdAt || 0).getTime() - new Date(a.updatedAt || a.createdAt || 0).getTime() ); } function getExistingFlowBoardMindElements( board: PlaitBoard, elements: MindElement[] ) { return elements.flatMap((element) => { try { const path = PlaitBoard.findPath(board, element); const currentElement = PlaitNode.get(board, path); return MindElement.isMindElement(board, currentElement) ? [currentElement] : []; } catch (_error) { return []; } }); } function uniqueFlowBoardMindElements(elements: MindElement[]) { const seen = new Set(); return elements.filter((element) => { if (seen.has(element)) { return false; } seen.add(element); return true; }); } function preferFlowBoardNodeElements(elements: MindElement[]) { const nodeElements = elements.filter((element) => !PlaitMind.isMind(element)); return nodeElements.length > 0 ? nodeElements : elements; } function getFlowBoardMindElementsBySelection( board: PlaitBoard, selection: Selection | null | undefined ) { if (!selection) { return [] as MindElement[]; } const hitElements = getHitElementsBySelection(board, selection, (element) => MindElement.isMindElement(board, element) ); return getSelectedMindElements(board, hitElements); } function getFlowBoardSelectedMindElements( board: PlaitBoard, selection: Selection | null | undefined ) { return preferFlowBoardNodeElements( uniqueFlowBoardMindElements([ ...getSelectedMindElements(board), ...getFlowBoardMindElementsBySelection(board, selection), ]) ); } function getFlowBoardMarkerSelectionState( board: PlaitBoard | null, selectedElements?: MindElement[] ): FlowBoardMarkerSelectionState { if (!board) { return { count: 0, names: [], mixedNames: [] }; } const targetElements = uniqueFlowBoardMindElements(getExistingFlowBoardMindElements(board, selectedElements || getSelectedMindElements(board))); const counts = new Map(); targetElements.forEach(element => { getXmindMarkerIds(element.data?.emojis).forEach(id => counts.set(id, (counts.get(id) || 0) + 1)); }); return { count: targetElements.length, names: [...counts].filter(([, count]) => count === targetElements.length).map(([id]) => id), mixedNames: [...counts].filter(([, count]) => count < targetElements.length).map(([id]) => id), }; } function getFlowBoardSelectionStyleState( board: PlaitBoard | null, selectedElements: MindElement[] ): FlowBoardSelectionStyleState { const element = selectedElements[0]; if (!board || !element) { return { branchColor: "#5b7fd8", branchShape: BranchShape.bight, branchWidth: 2, fill: "#ffffff", fontSize: "14", isBold: false, isItalic: false, isUnderlined: false, layout: MindLayoutType.standard, nodeShape: MindElementShape.roundRectangle, textColor: FLOW_BOARD_DEFAULT_TEXT_COLOR, }; } let root: MindElement = element; try { root = MindElement.getRoot(board, element); } catch (_error) { root = element; } const marks = getTextMarksByElement(element); return { branchColor: element.branchColor || root.branchColor || "#5b7fd8", branchShape: getBranchShapeByMindElement(board, element) || BranchShape.bight, branchWidth: element.branchWidth || root.branchWidth || 2, fill: element.fill || "#ffffff", fontSize: String( marks[MarkTypes.fontSize] || getFontSizeByMindElement(board, element) || 14 ), isBold: Boolean(marks[MarkTypes.bold]), isItalic: Boolean(marks[MarkTypes.italic]), isUnderlined: Boolean(marks[MarkTypes.underline]), layout: isSpecialMindLayout(root.specialLayout) ? root.specialLayout : root.layout || MindLayoutType.standard, nodeShape: getShapeByElement(board, element), textColor: (typeof marks[MarkTypes.color] === "string" && marks[MarkTypes.color]) || FLOW_BOARD_DEFAULT_TEXT_COLOR, }; } function persistFlowBoardTopicEditors( board: PlaitBoard, element: MindElement, editors: ReturnType ) { const topic = editors[0]?.children[0]; if (!topic) { return; } const currentElement = getExistingFlowBoardMindElements(board, [element])[0] || element; MindTransforms.setTopic( board as PlaitMindBoard, currentElement, cloneFlowBoardSheet(topic) as Parameters[2] ); } function getFlowBoardPastePoint(board: PlaitBoard): Point { const movingPoint = PlaitBoard.getMovingPointInBoard(board); if (movingPoint) { return toViewBoxPoint(board, toHostPoint(board, movingPoint[0], movingPoint[1])); } const container = PlaitBoard.getBoardContainer(board); const rect = container.getBoundingClientRect(); return toViewBoxPoint( board, toHostPoint(board, rect.left + rect.width / 2, rect.top + rect.height / 2) ); } function isFlowBoardEditablePasteTarget(target: HTMLElement | null) { if (!target) { return false; } return Boolean( target.isContentEditable || target.closest( [ "input", "textarea", "select", "[contenteditable='true']", "[role='textbox']", "[data-slate-editor='true']", ".slate-editable-container", ".plait-text-container", ".ttd-dialog-input", ].join(", ") ) ); } function getFlowBoardImageDimensions(file: File, maxWidth = 400) { return new Promise<{ width: number; height: number }>((resolve) => { const url = URL.createObjectURL(file); const image = new Image(); image.onload = () => { const originalWidth = image.naturalWidth || 400; const originalHeight = image.naturalHeight || 300; const width = Math.min(originalWidth, maxWidth); const height = Math.round((width / originalWidth) * originalHeight); URL.revokeObjectURL(url); resolve({ width, height }); }; image.onerror = () => { URL.revokeObjectURL(url); resolve({ width: 400, height: 300 }); }; image.src = url; }); } function readFlowBoardImageFile(file: File) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error || new Error("图片读取失败")); reader.readAsDataURL(file); }); } async function uploadFlowBoardImageFile(file: File) { const formData = new FormData(); formData.append("file", file); formData.append("purpose", "canvas-upload"); const response = await fetchUploadWithRetry(formData); if (!response.ok) { throw new Error(`Flow board image upload failed: ${response.status}`); } const payload = (await response.json()) as { url?: unknown }; const url = typeof payload.url === "string" ? payload.url : ""; if (!url) { throw new Error("Flow board image upload response missing url"); } return url; } export function DrawnixWorkspace({ siteName, siteLogo, siteLogoDark, previewMode = false, }: FlowBoardWorkspaceProps) { const { resolvedTheme } = useTheme(); const [snapshot, setSnapshot] = useState(() => normalizeSnapshot({ children: [] }) ); const [isReady, setIsReady] = useState(false); const [currentFileId, setCurrentFileId] = useState(null); const [currentTitle, setCurrentTitle] = useState("流程白板"); const [cloudFiles, setCloudFiles] = useState([]); const [isFilePanelOpen, setIsFilePanelOpen] = useState(false); const [isFileListLoading, setIsFileListLoading] = useState(false); const [isSavingNow, setIsSavingNow] = useState(false); const [isExporting, setIsExporting] = useState(false); const [isImageExportOpen, setIsImageExportOpen] = useState(false); const [isExportingImage, setIsExportingImage] = 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] = useState("selection"); const [isFormatPanelOpen, setIsFormatPanelOpen] = useState(true); const [zoomPercent, setZoomPercent] = useState(100); const snapshotRef = useRef(snapshot); const currentFileIdRef = useRef(null); const currentTitleRef = useRef("流程白板"); const saveTimerRef = useRef | null>(null); const boardRef = useRef(null); const [relationshipSourceId, setRelationshipSourceState] = useState(null); const relationshipSourceRef = useRef(null); const setRelationshipSourceId = useCallback((id: string | null) => { relationshipSourceRef.current = id; setRelationshipSourceState(id); }, []); const [selectedTopicId, setSelectedTopicId] = useState(null); const [isTopicPanelOpen, setIsTopicPanelOpen] = useState(false); const [isNotesPopoverOpen, setIsNotesPopoverOpen] = useState(false); const [isGanttOpen, setIsGanttOpen] = useState(false); const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [taskTopicId, setTaskTopicId] = useState(null); const [topicDetailSection, setTopicDetailSection] = useState("notes"); const [annotationId, setAnnotationId] = useState(null); const mindMapFileInputRef = useRef(null); const imageFileInputRef = useRef(null); const selectedMindElementsRef = useRef([]); const selectedMindSelectionRef = useRef(null); const shellControlInteractionRef = useRef(false); const [isMarkerPanelOpen, setIsMarkerPanelOpen] = useState(false); const [isImportingMindMap, setIsImportingMindMap] = useState(false); const [markerSelectionState, setMarkerSelectionState] = useState({ count: 0, names: [], mixedNames: [], }); const [selectionStyleState, setSelectionStyleState] = useState(() => getFlowBoardSelectionStyleState(null, []) ); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (isPresenting || (event.target as HTMLElement)?.closest(".xmind-gantt, .xmind-task-backdrop, .xmind-project-calendar-backdrop, .xmind-image-export")) return; if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) { 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; } return siteLogo || siteLogoDark; }, [resolvedTheme, siteLogo, siteLogoDark]); const getActiveMarkerElements = useCallback(() => { const board = boardRef.current; if (!board) { selectedMindElementsRef.current = []; return [] as MindElement[]; } const selectedElements = getFlowBoardSelectedMindElements( board, selectedMindSelectionRef.current || board.selection ); if (selectedElements.length > 0) { selectedMindElementsRef.current = selectedElements; return selectedElements; } const fallbackElements = getExistingFlowBoardMindElements( board, selectedMindElementsRef.current ); selectedMindElementsRef.current = fallbackElements; return fallbackElements; }, []); const refreshMarkerSelectionState = useCallback( ( options: { preserveFallback?: boolean; selection?: Selection | null; } = {} ) => { const board = boardRef.current; if (!board) { selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); return; } const focusedShellControl = document.activeElement?.closest('[data-flow-board-shell-control="true"]'); const preserveFallback = options.preserveFallback || Boolean(focusedShellControl); const annotation = getSelectedElements(board).find(isMindAnnotation); if (!preserveFallback || annotation) { setAnnotationId(annotation?.id || null); } if (annotation) { setSelectedTopicId(null); selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); return; } const selectedElements = getFlowBoardSelectedMindElements( board, options.selection ?? selectedMindSelectionRef.current ?? board.selection ); if (selectedElements.length > 0) { setSelectedTopicId(selectedElements.length === 1 ? selectedElements[0].id : null); selectedMindElementsRef.current = selectedElements; setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, selectedElements) ); if (!preserveFallback) { setSelectionStyleState( getFlowBoardSelectionStyleState(board, selectedElements) ); } return; } if (!preserveFallback) { setSelectedTopicId(null); selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); return; } const fallbackElements = getExistingFlowBoardMindElements( board, selectedMindElementsRef.current ); selectedMindElementsRef.current = fallbackElements; setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, fallbackElements) ); }, [] ); 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); seedFlowBoardDefaultTextColor(); selectedMindSelectionRef.current = board.selection; selectedMindElementsRef.current = getFlowBoardSelectedMindElements( board, board.selection ); setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, selectedMindElementsRef.current) ); setSelectionStyleState( getFlowBoardSelectionStyleState( board, selectedMindElementsRef.current ) ); setActiveTool("selection"); setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100)); }, []); useEffect(() => { seedFlowBoardDefaultTextColor(); }, []); useEffect(() => { currentFileIdRef.current = currentFileId; }, [currentFileId]); useEffect(() => { currentTitleRef.current = currentTitle; }, [currentTitle]); const updateBrowserFileParam = useCallback((fileId: string | null) => { if (typeof window === "undefined") return; const url = new URL(window.location.href); if (fileId) { url.searchParams.set("file", fileId); } else { url.searchParams.delete("file"); } window.history.replaceState(null, "", `${url.pathname}${url.search}`); }, []); const loadCloudFiles = useCallback(async () => { setIsFileListLoading(true); try { const response = await fetch(FLOW_BOARD_FILES_API, { cache: "no-store", credentials: "include", }); if (!response.ok) { throw new Error(`Failed to load flow board files: ${response.status}`); } const result = (await response.json()) as { data?: unknown[] }; const files = sortCloudFiles( (Array.isArray(result.data) ? result.data : []) .map(normalizeCloudFile) .filter((file): file is FlowBoardCloudFile => Boolean(file)) ); setCloudFiles(files); return files; } finally { setIsFileListLoading(false); } }, []); const hydrateCloudFile = useCallback( (file: FlowBoardCloudFilePayload) => { const nextSnapshot = normalizeSnapshot(file.snapshot); snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot); searchUndoRef.current = []; searchRedoRef.current = []; setCurrentFileId(file.id); setCurrentTitle(file.title || "未命名流程白板"); setLastSavedAt( file.updatedAt ? new Date(file.updatedAt) : nextSnapshot.updatedAt ? new Date(nextSnapshot.updatedAt) : null ); setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); selectedMindElementsRef.current = []; updateBrowserFileParam(file.id); }, [updateBrowserFileParam] ); const persistSnapshot = useCallback( async ( nextSnapshot: FlowBoardSnapshot, options: { fileId?: string | null; title?: string; toast?: boolean } = {} ) => { const targetFileId = options.fileId !== undefined ? options.fileId : currentFileIdRef.current; const nextTitle = String((options.title ?? currentTitleRef.current) || "").trim() || "未命名流程白板"; if (previewMode) { setLastSavedAt(new Date(nextSnapshot.updatedAt || Date.now())); if (options.toast) { toast.success("本地预览状态已保留"); } return { id: "local-preview", title: nextTitle, snapshot: nextSnapshot, updatedAt: nextSnapshot.updatedAt || new Date().toISOString(), }; } const response = await fetch( targetFileId ? getFlowBoardFileUrl(targetFileId) : FLOW_BOARD_FILES_API, { body: JSON.stringify({ snapshot: nextSnapshot, title: nextTitle, }), credentials: "include", headers: { "Content-Type": "application/json", }, method: targetFileId ? "PUT" : "POST", } ); if (!response.ok) { throw new Error(`Failed to save flow board file: ${response.status}`); } const result = (await response.json()) as { data?: unknown }; const savedFile = normalizeCloudFile(result.data); if (!savedFile) { throw new Error("Flow board save response is invalid"); } currentFileIdRef.current = savedFile.id; currentTitleRef.current = savedFile.title; setCurrentFileId(savedFile.id); setCurrentTitle(savedFile.title); setLastSavedAt( savedFile.updatedAt ? new Date(savedFile.updatedAt) : new Date(nextSnapshot.updatedAt || Date.now()) ); updateBrowserFileParam(savedFile.id); setCloudFiles((current) => sortCloudFiles([ { id: savedFile.id, title: savedFile.title, createdAt: savedFile.createdAt, updatedAt: savedFile.updatedAt, }, ...current.filter((file) => file.id !== savedFile.id), ]) ); if (options.toast) { toast.success("流程白板已保存到云端"); } return savedFile; }, [previewMode, updateBrowserFileParam] ); useEffect(() => { let cancelled = false; if (previewMode) { setIsReady(true); return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } }; } const loadDraft = async () => { try { const files = await loadCloudFiles(); const requestedFileId = typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("file") || ""; const initialFile = files.find((file) => file.id === requestedFileId) || files[0] || null; if (!initialFile) { if (!cancelled) { const emptySnapshot = normalizeSnapshot({ children: [] }); snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState( getFlowBoardSelectionStyleState(null, []) ); selectedMindElementsRef.current = []; updateBrowserFileParam(null); } return; } const response = await fetch(getFlowBoardFileUrl(initialFile.id), { cache: "no-store", credentials: "include", }); if (!response.ok) { throw new Error( `Failed to load flow board file: ${response.status}` ); } const result = (await response.json()) as { data?: unknown; }; const file = normalizeCloudFile(result.data); if (!file) { throw new Error("Flow board file response is invalid"); } if (!cancelled) { hydrateCloudFile(file); } } catch (error) { console.error("[FlowBoard] Failed to load cloud file:", error); toast.error("流程白板云文件加载失败"); } finally { if (!cancelled) { setIsReady(true); } } }; loadDraft().catch(console.error); return () => { cancelled = true; if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } }; }, [previewMode]); const scheduleSave = useCallback( (nextSnapshot: FlowBoardSnapshot) => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } saveTimerRef.current = setTimeout(async () => { setIsSavingNow(true); try { await persistSnapshot(nextSnapshot); } catch (error) { console.error("[FlowBoard] Failed to save cloud file:", error); toast.error("流程白板保存到云端失败"); } finally { setIsSavingNow(false); } }, 350); }, [persistSnapshot] ); const updateSnapshot = useCallback( (partialSnapshot: Partial) => { const nextSnapshot = normalizeSnapshot({ ...snapshotRef.current, ...partialSnapshot, updatedAt: new Date().toISOString(), }); snapshotRef.current = nextSnapshot; setSnapshot(nextSnapshot); scheduleSave(nextSnapshot); }, [scheduleSave] ); const handleDrawnixChange = useCallback( (value: BoardChangeData) => { const shouldPersist = value.operations.some( (operation) => operation.type !== "set_selection" ); if (!shouldPersist) { refreshMarkerSelectionState({ preserveFallback: shellControlInteractionRef.current, }); return; } updateSnapshot({ children: value.children, theme: value.theme, viewport: value.viewport, }); refreshMarkerSelectionState({ preserveFallback: shellControlInteractionRef.current }); }, [refreshMarkerSelectionState, updateSnapshot] ); const handleDrawnixSelectionChange = useCallback( (selection: Selection | null) => { selectedMindSelectionRef.current = selection; refreshMarkerSelectionState({ preserveFallback: shellControlInteractionRef.current, selection, }); const board = boardRef.current; const sourceId = relationshipSourceRef.current; if (board && sourceId && !shellControlInteractionRef.current) { const target = getFlowBoardSelectedMindElements(board, selection) .find((element) => element.id !== sourceId); if (target) { setRelationshipSourceId(null); // Finish the selection operation before inserting another board operation. queueMicrotask(() => { if (boardRef.current !== board) return; const annotation = insertMindAnnotation(board, { kind: "relationship", sourceId, targetId: target.id, }); if (annotation) setAnnotationId(annotation.id); setActiveTool("selection"); }); } } }, [refreshMarkerSelectionState, setRelationshipSourceId] ); const handlePatchTopicDetails = useCallback((patch: Partial) => { const board = boardRef.current; if (!board || !selectedTopicId) return; const topic = getTopicEntries(board.children).find(({ element }) => element.id === selectedTopicId)?.element; if (topic) { const metadata = patchTopicDetails(topic, patch); if (JSON.stringify(metadata) === JSON.stringify(topic.xmind || {})) return; PlaitHistoryBoard.withNewBatch(board, () => Transforms.setNode(board, { xmind: metadata }, PlaitBoard.findPath(board, topic))); } }, [selectedTopicId]); const handleSaveTopicTask = useCallback((id: string, task: TopicTask | null) => { const board = boardRef.current; if (!board) return; const entries = getTopicEntries(board.children); try { const changes = task ? scheduleTaskChanges(board.children, id, task) : new Map([[id, null]]); if (!task) for (const entry of entries) { const existing = getTopicTask(entry.element); if (existing?.dependencies.some(dep => dep.id === id)) changes.set(entry.element.id, {...existing, dependencies: existing.dependencies.filter(dep => dep.id !== id)}); } PlaitHistoryBoard.withNewBatch(board, () => { for (const [topicId, value] of changes) { const entry = getTopicEntries(board.children).find(e => e.element.id === topicId); if (entry) Transforms.setNode(board, {xmind: patchTopicTask(entry.element, value)}, entry.path); } }); } catch (error) { toast.error(error instanceof Error ? error.message : "无法保存任务"); } }, []); const handleSaveProjectCalendar = useCallback((config: ProjectCalendarConfig) => { const board = boardRef.current; const root = board && calendarRoot(board.children); if (!board || !root) { toast.error("请先创建一个中心主题。"); return false; } try { const problem = config.settings && validateWorkingCalendar(config.settings); if (problem) throw new Error(problem); const first = getTopicEntries(board.children).find(entry => getTopicTask(entry.element)); const changes = first ? scheduleTaskChanges(board.children, first.element.id, getTopicTask(first.element)!, config) : new Map(); PlaitHistoryBoard.withNewBatch(board, () => { Transforms.setNode(board, {xmind: patchProjectCalendar(root, config)}, PlaitBoard.findPath(board, root)); for (const [id, task] of changes) { const entry = getTopicEntries(board.children).find(item => item.element.id === id); if (entry) Transforms.setNode(board, {xmind: patchTopicTask(entry.element, task)}, entry.path); } }); return true; } catch (error) { toast.error(error instanceof Error ? error.message : "无法保存工作日设置"); return false; } }, []); const handleJumpToTopic = useCallback((id: string) => { const board = boardRef.current; if (!board) return; const entry = getTopicEntries(board.children).find(({ element }) => element.id === id); if (!entry) return; // Collapsed descendants have no rendered Plait path yet. const path = entry.path; for (let length = 1; length < path.length; length++) { const ancestorPath = path.slice(0, length); const ancestor = PlaitNode.get(board, ancestorPath); if (ancestor.isCollapsed) Transforms.setNode(board, { isCollapsed: false }, ancestorPath); } requestAnimationFrame(() => { if (boardRef.current !== board) return; const target = getTopicEntries(board.children).find(({ element }) => element.id === id)?.element; if (!target) return; const node = MindElement.getNode(target as MindElement); if (!node) return; const rect = getRectangleByNode(node); const center: Point = [rect.x + rect.width / 2, rect.y + rect.height / 2]; Transforms.setSelection(board, { anchor: center, focus: center }); BoardTransforms.moveToCenter(board, center); setSelectedTopicId(id); }); }, []); const handleOutlineRename = useCallback((id: string, title: string) => { const board = boardRef.current; const entry = board && getTopicEntries(board.children).find(({ element }) => element.id === id); if (!board || !entry || entry.title === (title.trim() || "未命名主题")) return; PlaitHistoryBoard.withNewBatch(board, () => Transforms.setNode(board, { data: renameOutlineTopic(entry.element, title) }, entry.path)); }, []); const handleOutlineToggle = useCallback((id: string) => { const board = boardRef.current; const entry = board && getTopicEntries(board.children).find(({ element }) => element.id === id); if (board && entry) PlaitHistoryBoard.withNewBatch(board, () => Transforms.setNode(board, { isCollapsed: !entry.element.isCollapsed }, entry.path)); }, []); const handleOutlineCommand = useCallback((id: string | null, command: OutlineCommand | OutlineDrop) => { const board = boardRef.current; if (!board) return null; try { const result = applyOutlineCommand(board.children, id, command); PlaitHistoryBoard.withNewBatch(board, () => { for (let index = board.children.length - 1; index >= 0; index--) { const before = board.children[index]; if (!["mind", "mindmap"].includes(String(before.type))) continue; const after = result.children.find(element => element.id === before.id); if (!after) Transforms.removeNode(board, [index]); else if (JSON.stringify(before) !== JSON.stringify(after)) { const { id: _id, type: _type, ...properties } = after; Transforms.setNode(board, properties, [index]); } } for (const element of result.children) { if (!board.children.some(before => before.id === element.id)) Transforms.insertNode(board, element, [board.children.length]); } }); setSelectedTopicId(result.selectedId || null); if (result.selectedId) requestAnimationFrame(() => handleJumpToTopic(result.selectedId)); return result.selectedId; } catch (error) { toast.info(error instanceof Error ? error.message : "大纲操作失败"); return null; } }, [handleJumpToTopic]); const handleApplyFlowBoardMarker = useCallback((marker: XmindMarker) => { const board = boardRef.current; if (!board) return; const elements = uniqueFlowBoardMindElements(getExistingFlowBoardMindElements(board, getActiveMarkerElements())); if (!elements.length) { toast.info("请先选中一个主题"); return; } const remove = elements.every(element => getXmindMarkerIds(element.data?.emojis).includes(marker.id)); PlaitHistoryBoard.withNewBatch(board, () => { elements.forEach(element => { const data = { ...element.data, emojis: toggleXmindMarker(element.data?.emojis || [], marker.id, remove) }; Transforms.setNode(board, { data }, PlaitBoard.findPath(board, element)); }); }); refreshMarkerSelectionState({ preserveFallback: true }); }, [getActiveMarkerElements, refreshMarkerSelectionState]); const handleClearFlowBoardMarkers = useCallback(() => { const board = boardRef.current; if (!board) return; const elements = uniqueFlowBoardMindElements(getExistingFlowBoardMindElements(board, getActiveMarkerElements())); if (!elements.length) { toast.info("请先选中一个主题"); return; } PlaitHistoryBoard.withNewBatch(board, () => { elements.forEach(element => { const data = { ...element.data, emojis: (element.data?.emojis || []).filter(emoji => !getXmindMarker(emoji.name)) }; const xmind = isFlowBoardRecord(element.xmind) ? { ...element.xmind } : {}; delete xmind.markers; Transforms.setNode(board, { data, xmind }, PlaitBoard.findPath(board, element)); }); }); refreshMarkerSelectionState({ preserveFallback: true }); }, [getActiveMarkerElements, refreshMarkerSelectionState]); const handleInsertFlowBoardAbstract = useCallback(() => { const board = boardRef.current; if (!board) { return; } const selectedElements = getActiveMarkerElements().filter(canSetAbstract); if (selectedElements.length === 0) { toast.info("请先选中要汇总的思维导图节点"); return; } const paths = selectedElements.map((element) => PlaitBoard.findPath(board, element)); const parent = PlaitNode.get(board, paths[0].slice(0, -1)); const rangeError = validateTopicRange(paths, parent.rightNodeCount); if (rangeError) { toast.info(rangeError); return; } try { MindTransforms.insertAbstract(board, selectedElements); refreshMarkerSelectionState({ preserveFallback: true }); toast.success("已添加合并汇总"); } catch (error) { console.error("[FlowBoard] Failed to insert abstract:", error); toast.error("合并汇总失败,请选择同一组思维导图分支节点"); } }, [getActiveMarkerElements, refreshMarkerSelectionState]); const handleInsertRelationship = useCallback(() => { const board = boardRef.current; if (!board) return; if (relationshipSourceId) { setRelationshipSourceId(null); return; } const elements = getActiveMarkerElements(); if (elements.length === 1) { setRelationshipSourceId(elements[0].id); BoardTransforms.updatePointerType(board, PlaitPointerType.selection); setActiveTool("selection"); return; } if (elements.length !== 2) { toast.info("请选择两个主题,或先选一个主题再点选联系目标"); return; } const annotation = insertMindAnnotation(board, { kind: "relationship", sourceId: elements[0].id, targetId: elements[1].id, }); if (annotation) setAnnotationId(annotation.id); }, [getActiveMarkerElements, relationshipSourceId]); const handleInsertBoundary = useCallback(() => { const board = boardRef.current; const elements = getActiveMarkerElements().filter((element) => element.start === undefined); if (!board || elements.length === 0) return; const paths = elements.map((element) => PlaitBoard.findPath(board, element)); const parent = paths[0].length > 1 ? PlaitNode.get(board, paths[0].slice(0, -1)) : null; const rangeError = validateTopicRange(paths, parent?.rightNodeCount); if (rangeError) { toast.info(rangeError); return; } const annotation = insertMindAnnotation(board, { kind: "boundary", topicIds: elements.map((element) => element.id), padding: 18, }); if (annotation) setAnnotationId(annotation.id); setRelationshipSourceId(null); }, [getActiveMarkerElements]); const handleToolChange = useCallback((tool: FlowBoardToolId) => { if (tool === "image") { imageFileInputRef.current?.click(); return; } const board = boardRef.current; if (!board) { return; } const pointers: Record, string> = { arrow: ArrowLineShape.curve, hand: PlaitPointerType.hand, mind: MindPointerType.mind, selection: PlaitPointerType.selection, shape: BasicShapes.roundRectangle, text: BasicShapes.text, }; BoardTransforms.updatePointerType(board, pointers[tool]); 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]; if (!board || !element) { toast.info("请先选中一个主题"); return; } try { MindTransforms.insertChildNode(board as PlaitMindBoard, element); setActiveTool("selection"); } catch (error) { console.error("[FlowBoard] Failed to insert child topic:", error); toast.error("新增子主题失败"); } }, [getActiveMarkerElements]); const handleInsertSiblingTopic = useCallback(() => { const board = boardRef.current; const element = getActiveMarkerElements()[0]; if (!board || !element || PlaitMind.isMind(element)) { toast.info("请先选中一个非中心主题"); return; } try { MindTransforms.insertSiblingNode(board as PlaitMindBoard, element); setActiveTool("selection"); } catch (error) { console.error("[FlowBoard] Failed to insert sibling topic:", error); toast.error("新增同级主题失败"); } }, [getActiveMarkerElements]); const handleLayoutChange = useCallback( (layout: string) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { toast.info("请先选中一个思维导图主题"); return; } const roots = new Set(); selectedElements.forEach((element) => { try { roots.add(MindElement.getRoot(board, element)); } catch (_error) { roots.add(element); } }); roots.forEach((root) => { Transforms.setNode( board, { layout: (isSpecialMindLayout(layout) ? SPECIAL_MIND_LAYOUTS[layout].base : layout) as MindLayoutType, specialLayout: isSpecialMindLayout(layout) ? layout : null }, PlaitBoard.findPath(board, root) ); }); setSelectionStyleState((current) => ({ ...current, layout: layout as MindLayoutType, })); }, [getActiveMarkerElements] ); const handleNodeFillChange = useCallback( (fill: string) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } selectedElements.forEach((element) => { Transforms.setNode( board, { fill }, PlaitBoard.findPath(board, element) ); }); setSelectionStyleState((current) => ({ ...current, fill })); }, [getActiveMarkerElements] ); const handleNodeShapeChange = useCallback( (shape: "round-rectangle" | "underline") => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } const nodeShape = shape as MindElementShape; selectedElements.forEach((element) => { Transforms.setNode( board, { shape: nodeShape }, PlaitBoard.findPath(board, element) ); }); setSelectionStyleState((current) => ({ ...current, nodeShape })); }, [getActiveMarkerElements] ); const handleBranchColorChange = useCallback( (branchColor: string) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } selectedElements.forEach((element) => { Transforms.setNode( board, { branchColor }, PlaitBoard.findPath(board, element) ); }); setSelectionStyleState((current) => ({ ...current, branchColor })); }, [getActiveMarkerElements] ); const handleBranchWidthChange = useCallback( (branchWidth: number) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } selectedElements.forEach((element) => { Transforms.setNode( board, { branchWidth }, PlaitBoard.findPath(board, element) ); }); setSelectionStyleState((current) => ({ ...current, branchWidth })); }, [getActiveMarkerElements] ); const handleBranchShapeChange = useCallback( (shape: "bight" | "polyline") => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } const branchShape = shape as BranchShape; selectedElements.forEach((element) => { Transforms.setNode( board, { branchShape }, PlaitBoard.findPath(board, element) ); }); setSelectionStyleState((current) => ({ ...current, branchShape })); }, [getActiveMarkerElements] ); const handleFontSizeChange = useCallback( (fontSize: string) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } selectedElements.forEach((element) => { const editors = getTextEditorsByElement(element); TextTransforms.setFontSize( board, fontSize as FontSizes, getFontSizeByMindElement(board, element), editors ); persistFlowBoardTopicEditors(board, element, editors); }); setSelectionStyleState((current) => ({ ...current, fontSize })); }, [getActiveMarkerElements] ); const handleTextColorChange = useCallback( (textColor: string) => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } selectedElements.forEach((element) => { const editors = getTextEditorsByElement(element); TextTransforms.setTextColor(board, textColor, undefined, editors); persistFlowBoardTopicEditors(board, element, editors); }); setSelectionStyleState((current) => ({ ...current, textColor })); }, [getActiveMarkerElements] ); const handleToggleTextMark = useCallback( (mark: "bold" | "italic" | "underlined") => { const board = boardRef.current; const selectedElements = getActiveMarkerElements(); if (!board || selectedElements.length === 0) { return; } const markType = mark === "bold" ? MarkTypes.bold : mark === "italic" ? MarkTypes.italic : MarkTypes.underline; selectedElements.forEach((element) => { const editors = getTextEditorsByElement(element); TextTransforms.setTextMarks(board, markType, editors); persistFlowBoardTopicEditors(board, element, editors); }); setSelectionStyleState((current) => { if (mark === "bold") { return { ...current, isBold: !current.isBold }; } if (mark === "italic") { return { ...current, isItalic: !current.isItalic }; } return { ...current, isUnderlined: !current.isUnderlined }; }); }, [getActiveMarkerElements] ); const handleThemePresetChange = useCallback((themeId: string) => { const board = boardRef.current; if (!board) { return; } 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(() => { 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(() => { 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; if (!board) { return; } const nextZoom = Math.min( 4, Math.max(0.1, (board.viewport?.zoom || 1) + delta) ); BoardTransforms.updateZoom(board, nextZoom); setZoomPercent(Math.round(nextZoom * 100)); }, []); const handleFitViewport = useCallback(() => { const board = boardRef.current; if (!board) { return; } BoardTransforms.fitViewport(board); setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100)); }, []); const handleTitleChange = useCallback((value: string) => { currentTitleRef.current = value; setCurrentTitle(value); }, []); const handleTitleCommit = useCallback(async () => { const title = currentTitleRef.current.trim() || "未命名流程白板"; currentTitleRef.current = title; setCurrentTitle(title); setIsSavingNow(true); try { await persistSnapshot(snapshotRef.current, { title }); } catch (error) { console.error("[FlowBoard] Failed to rename cloud file:", error); toast.error("文件重命名失败"); } finally { setIsSavingNow(false); } }, [persistSnapshot]); const handleSwitchSheet = useCallback( (sheetId: string) => { const current = snapshotRef.current; if (sheetId === current.activeSheetId) { return; } const target = current.sheets?.find((sheet) => sheet.id === sheetId); if (!target) { return; } updateSnapshot({ activeSheetId: target.id, canvas: target.canvas, children: target.children, viewport: target.viewport, theme: target.theme, }); selectedMindElementsRef.current = []; selectedMindSelectionRef.current = null; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); }, [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 || []; const nextSheet: FlowBoardSheetSnapshot = { id: createFlowBoardSheetId(), title: `画布 ${sheets.length + 1}`, children: [], theme: current.theme, canvas: cloneFlowBoardSheet(current.canvas), }; updateSnapshot({ activeSheetId: nextSheet.id, canvas: nextSheet.canvas, children: nextSheet.children, viewport: undefined, theme: nextSheet.theme, sheets: [...sheets, nextSheet], }); selectedMindElementsRef.current = []; selectedMindSelectionRef.current = null; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); }, [updateSnapshot]); const handleRenameSheet = useCallback( (sheetId: string, title: string) => { const nextTitle = title.trim(); if (!nextTitle) { return; } const current = snapshotRef.current; const sheets = (current.sheets || []).map((sheet) => sheet.id === sheetId ? { ...sheet, title: nextTitle } : sheet ); updateSnapshot({ sheets }); }, [updateSnapshot] ); const handleDuplicateSheet = useCallback( (sheetId: string) => { const current = snapshotRef.current; const sheets = current.sheets || []; const sourceIndex = sheets.findIndex((sheet) => sheet.id === sheetId); const source = sheets[sourceIndex]; if (!source) { return; } const duplicatedSheet: FlowBoardSheetSnapshot = { ...cloneFlowBoardSheet(source), id: createFlowBoardSheetId(), title: `${source.title} 副本`, }; const nextSheets = [...sheets]; nextSheets.splice(sourceIndex + 1, 0, duplicatedSheet); updateSnapshot({ activeSheetId: duplicatedSheet.id, canvas: duplicatedSheet.canvas, children: duplicatedSheet.children, sheets: nextSheets, theme: duplicatedSheet.theme, viewport: duplicatedSheet.viewport, }); selectedMindElementsRef.current = []; selectedMindSelectionRef.current = null; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); }, [updateSnapshot] ); const handleDeleteSheet = useCallback( (sheetId: string) => { const current = snapshotRef.current; const sheets = current.sheets || []; if (sheets.length <= 1) { return; } const targetIndex = sheets.findIndex((sheet) => sheet.id === sheetId); if (targetIndex < 0 || !window.confirm("确定要删除当前画布吗?")) { return; } const nextSheets = sheets.filter((sheet) => sheet.id !== sheetId); const activeSheet = current.activeSheetId === sheetId ? nextSheets[Math.min(targetIndex, nextSheets.length - 1)] : nextSheets.find((sheet) => sheet.id === current.activeSheetId) || nextSheets[0]; updateSnapshot({ activeSheetId: activeSheet.id, canvas: activeSheet.canvas, children: activeSheet.children, sheets: nextSheets, theme: activeSheet.theme, viewport: activeSheet.viewport, }); selectedMindElementsRef.current = []; selectedMindSelectionRef.current = null; setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); }, [updateSnapshot] ); const handleFlowBoardPastedImages = useCallback(async ( board: PlaitBoard, files: File[], topicId: string | null = null ) => { const supportedFiles = files.filter((file) => FLOW_BOARD_IMAGE_UPLOAD_TYPES.has(file.type) ); if (supportedFiles.length !== files.length) { toast.error("流程白板粘贴图片仅支持 JPEG、PNG 或 WebP"); } if (supportedFiles.length === 0) { return; } const basePoint = getFlowBoardPastePoint(board); // A topic has one image. Capture its identity before any asynchronous upload. const filesToInsert = topicId ? supportedFiles.slice(0, 1) : supportedFiles; let insertedCount = 0; for (let index = 0; index < filesToInsert.length; index += 1) { const file = filesToInsert[index]; if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) { toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE); continue; } try { const [url, dimensions] = await Promise.all([ previewMode ? readFlowBoardImageFile(file) : uploadFlowBoardImageFile(file), getFlowBoardImageDimensions(file, topicId ? DEFAULT_MIND_IMAGE_WIDTH : 400), ]); // Switching sheets or deleting the target while uploading must not paste elsewhere. if (boardRef.current !== board) return; if (topicId) { const topic = getTopicEntries(board.children).find(entry => entry.element.id === topicId)?.element; if (!topic || !MindElement.isMindElement(board, topic)) return; MindTransforms.setImage(board, topic, { url, ...dimensions }); insertedCount += 1; continue; } const targetPoint: Point = [ basePoint[0] + index * 24, basePoint[1] + index * 24, ]; DrawTransforms.insertImage(board, { url, ...dimensions }, targetPoint); insertedCount += 1; } catch (error) { console.error("[FlowBoard] Failed to paste image:", error); toast.error("图片上传失败"); } } if (insertedCount > 0) { toast.success( insertedCount === 1 ? topicId ? "已插入主题图片" : "已粘贴图片到白板" : `已粘贴 ${insertedCount} 张图片到白板`, { position: "bottom-center" } ); } }, [previewMode]); const handleFlowBoardPasteCapture = useCallback((event: ClipboardEvent) => { const board = boardRef.current; if (!board || !event.clipboardData) { return; } const target = event.target instanceof HTMLElement ? event.target : null; const selectedTopics = getSelectedMindElements(board); const topicId = selectedTopics.length === 1 ? selectedTopics[0].id : null; const isTopicEditor = Boolean(topicId && target?.closest(".drawnix [data-slate-editor='true']")); if ( target?.closest("[data-flow-board-shell-control='true']") || (isFlowBoardEditablePasteTarget(target) && !isTopicEditor) ) { return; } let imageFiles = Array.from(event.clipboardData.items || []) .filter((item) => item.kind === "file" && item.type.startsWith("image/")) .map((item) => item.getAsFile()) .filter((file): file is File => Boolean(file)); if (imageFiles.length === 0) { imageFiles = Array.from(event.clipboardData.files || []).filter(file => file.type.startsWith("image/")); } if (imageFiles.length === 0) { return; } event.preventDefault(); event.stopPropagation(); void handleFlowBoardPastedImages(board, imageFiles, topicId); }, [handleFlowBoardPastedImages]); const handleFlowBoardFileInputChangeCapture = useCallback( (event: ChangeEvent) => { const board = boardRef.current; const target = event.target instanceof HTMLInputElement ? event.target : null; if ( !board || !target || target.type !== "file" || target.closest("[data-flow-board-shell-control='true']") ) { return; } const files = Array.from(target.files || []); const imageFiles = files.filter((file) => file.type.startsWith("image/")); if (imageFiles.length === 0) { return; } event.preventDefault(); event.stopPropagation(); void handleFlowBoardPastedImages(board, imageFiles); target.value = ""; }, [handleFlowBoardPastedImages] ); const handleValueChange = useCallback( (children: PlaitElement[]) => { if (snapshotRef.current.children === children) { return; } updateSnapshot({ children }); refreshMarkerSelectionState(); }, [refreshMarkerSelectionState, updateSnapshot] ); const handleViewportChange = useCallback( (viewport: Viewport) => { setZoomPercent(Math.round((viewport.zoom || 1) * 100)); if (snapshotRef.current.viewport === viewport) { return; } updateSnapshot({ viewport }); }, [updateSnapshot] ); const handleThemeChange = useCallback( (themeColorMode: ThemeColorMode) => { if (snapshotRef.current.theme?.themeColorMode === themeColorMode) { return; } updateSnapshot({ theme: { themeColorMode } }); }, [updateSnapshot] ); const handleCanvasSettingsChange = useCallback( (partialCanvas: Partial) => { updateSnapshot({ canvas: normalizeFlowBoardCanvasSettings({ ...snapshotRef.current.canvas, ...partialCanvas, }), }); }, [updateSnapshot] ); const flushPendingSave = useCallback( async (showToast = false) => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); saveTimerRef.current = null; } setIsSavingNow(true); try { await persistSnapshot(snapshotRef.current, { toast: showToast }); } catch (error) { console.error("[FlowBoard] Manual cloud save failed:", error); toast.error("流程白板保存到云端失败"); } finally { setIsSavingNow(false); } }, [persistSnapshot] ); const handleExportXmind = useCallback(async () => { if (isExporting) return; setIsExporting(true); try { const { exportXmind } = await import("@/lib/flow-board/xmind-export"); const { blob, warnings } = await exportXmind({ ...snapshotRef.current, children: boardRef.current?.children || snapshotRef.current.children }, currentTitleRef.current); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${currentTitleRef.current.replace(/[\\/:*?"<>|]/g, "_") || "思维导图"}.xmind`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 30_000); if (warnings.length) toast.warning(warnings.join(";"), { duration: 10_000 }); else toast.success("XMind 文件已导出"); } catch (error) { toast.error(error instanceof Error ? error.message : "XMind 导出失败,请重试"); } finally { setIsExporting(false); } }, [isExporting]); const handleExportImage = useCallback(async (options: Pick) => { const board = boardRef.current; if (!board || isExportingImage) return; setIsExportingImage(true); try { const { exportBoardImage } = await import("@/lib/flow-board/image-export"); const { blob } = await exportBoardImage(board, { ...options, backgroundColor: snapshotRef.current.canvas.backgroundColor || "#ffffff" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; const sheet = snapshotRef.current.sheets?.find(item => item.id === snapshotRef.current.activeSheetId); anchor.download = `${`${currentTitleRef.current || "思维导图"}-${sheet?.title || "工作表"}`.replace(/[\\/:*?"<>|]/g, "_")}.png`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 30_000); setIsImageExportOpen(false); toast.success("PNG 图片已导出"); } catch (error) { toast.error(error instanceof Error ? error.message : "图片导出失败,请重试"); } finally { setIsExportingImage(false); } }, [isExportingImage]); const handleImportMindMapFile = useCallback( async (file: File) => { if (!isSupportedMindMapImportFile(file)) { toast.error("请选择 XMind、MD、OPML 或 FreeMind .mm 文件"); return; } setIsImportingMindMap(true); setIsSavingNow(true); try { const hasCurrentContent = Boolean(currentFileIdRef.current) || snapshotRef.current.children.length > 0; if (hasCurrentContent) { 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({ activeSheetId: imported.sheets[0].id, sheets: imported.sheets, updatedAt: new Date().toISOString(), }); if (previewMode) { hydrateCloudFile({ id: `preview-${crypto.randomUUID()}`, title, snapshot: nextSnapshot }); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); toast.success(`已导入 ${imported.sheets.length} 张画布(本地预览)`); if (imported.warnings.length) toast.info(imported.warnings.join(";"), { duration: 12000 }); return; } const response = await fetch(FLOW_BOARD_FILES_API, { body: JSON.stringify({ title, snapshot: nextSnapshot, }), credentials: "include", headers: { "Content-Type": "application/json", }, method: "POST", }); if (!response.ok) { throw new Error(`Failed to import mind map flow board: ${response.status}`); } const result = (await response.json()) as { data?: unknown }; const importedFile = normalizeCloudFile(result.data); if (!importedFile) { throw new Error("Flow board mind map import response is invalid"); } hydrateCloudFile(importedFile); setCloudFiles((current) => sortCloudFiles([ { id: importedFile.id, title: importedFile.title, createdAt: importedFile.createdAt, updatedAt: importedFile.updatedAt, }, ...current.filter((item) => item.id !== importedFile.id), ]) ); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); toast.success(`已导入 ${imported.sheets.length} 张画布`); if (imported.warnings.length) toast.info(imported.warnings.join(";"), { duration: 12000 }); } catch (error) { console.error("[FlowBoard] Failed to import mind map:", error); toast.error(error instanceof MindMapImportError ? error.message : "思维导图导入失败,请检查文件或稍后重试"); } finally { setIsImportingMindMap(false); setIsSavingNow(false); if (mindMapFileInputRef.current) { mindMapFileInputRef.current.value = ""; } } }, [flushPendingSave, hydrateCloudFile, previewMode] ); const handleOpenFilePanel = useCallback(async () => { setIsMarkerPanelOpen(false); setIsFormatPanelOpen(false); setIsFilePanelOpen(true); try { await loadCloudFiles(); } catch (error) { console.error("[FlowBoard] Failed to refresh file list:", error); toast.error("云文件列表加载失败"); } }, [loadCloudFiles]); const handleOpenCloudFile = useCallback( async (fileId: string) => { if (fileId === currentFileIdRef.current) { setIsFilePanelOpen(false); setIsFormatPanelOpen(true); return; } const hasCurrentContent = Boolean(currentFileIdRef.current) || snapshotRef.current.children.length > 0; if (hasCurrentContent) { await flushPendingSave(false); } try { const response = await fetch(getFlowBoardFileUrl(fileId), { cache: "no-store", credentials: "include", }); if (!response.ok) { throw new Error(`Failed to open flow board file: ${response.status}`); } const result = (await response.json()) as { data?: unknown }; const file = normalizeCloudFile(result.data); if (!file) { throw new Error("Flow board file response is invalid"); } hydrateCloudFile(file); setIsFilePanelOpen(false); setIsFormatPanelOpen(true); } catch (error) { console.error("[FlowBoard] Failed to open cloud file:", error); toast.error("流程白板云文件打开失败"); } }, [flushPendingSave, hydrateCloudFile] ); const handleCreateCloudFile = useCallback(async () => { const hasCurrentContent = Boolean(currentFileIdRef.current) || snapshotRef.current.children.length > 0; if (hasCurrentContent) { await flushPendingSave(false); } setIsSavingNow(true); try { const now = new Date(); const nextSnapshot = normalizeSnapshot({ children: [], updatedAt: now.toISOString(), }); const response = await fetch(FLOW_BOARD_FILES_API, { body: JSON.stringify({ title: `流程白板 ${now.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", })}`, snapshot: nextSnapshot, }), credentials: "include", headers: { "Content-Type": "application/json", }, method: "POST", }); if (!response.ok) { throw new Error(`Failed to create flow board file: ${response.status}`); } const result = (await response.json()) as { data?: unknown }; const file = normalizeCloudFile(result.data); if (!file) { throw new Error("Flow board create response is invalid"); } hydrateCloudFile(file); setCloudFiles((current) => sortCloudFiles([ { id: file.id, title: file.title, createdAt: file.createdAt, updatedAt: file.updatedAt, }, ...current.filter((item) => item.id !== file.id), ]) ); setIsFilePanelOpen(false); setIsFormatPanelOpen(true); toast.success("已新建云端流程白板"); } catch (error) { console.error("[FlowBoard] Failed to create cloud file:", error); toast.error("新建流程白板云文件失败"); } finally { setIsSavingNow(false); } }, [flushPendingSave, hydrateCloudFile]); const handleDeleteCloudFile = useCallback( async (fileId: string) => { if (!window.confirm("确定要删除这个流程白板云文件吗?")) { return; } try { const response = await fetch(getFlowBoardFileUrl(fileId), { credentials: "include", method: "DELETE", }); if (!response.ok) { throw new Error( `Failed to delete flow board file: ${response.status}` ); } const remainingFiles = cloudFiles.filter((file) => file.id !== fileId); setCloudFiles(remainingFiles); toast.success("已删除流程白板云文件"); if (fileId !== currentFileIdRef.current) { return; } const nextFile = remainingFiles[0] || null; if (nextFile) { const nextResponse = await fetch(getFlowBoardFileUrl(nextFile.id), { cache: "no-store", credentials: "include", }); if (!nextResponse.ok) { throw new Error( `Failed to open next flow board file: ${nextResponse.status}` ); } const result = (await nextResponse.json()) as { data?: unknown }; const file = normalizeCloudFile(result.data); if (file) { hydrateCloudFile(file); } return; } const emptySnapshot = normalizeSnapshot({ children: [] }); snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [], mixedNames: [] }); setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); selectedMindElementsRef.current = []; updateBrowserFileParam(null); } catch (error) { console.error("[FlowBoard] Failed to delete cloud file:", error); toast.error("删除流程白板云文件失败"); } }, [cloudFiles, hydrateCloudFile, updateBrowserFileParam] ); const isFormatPanelVisible = !isZenMode && isFormatPanelOpen && !isFilePanelOpen && !isMarkerPanelOpen; const topicEntries = getTopicEntries(snapshot.children); const selectedTopic = topicEntries.find(({ element }) => element.id === selectedTopicId)?.element || null; const selectedAnnotation = snapshot.children.find( (element) => element.id === annotationId && isMindAnnotation(element) ); const sheetTabs = (snapshot.sheets || []).map((sheet) => ({ id: sheet.id, title: sheet.title, })); if (!isReady) { return (
正在恢复流程白板...
); } return (
{ if (isPresenting || (event.target as HTMLElement)?.closest(".xmind-gantt, .xmind-task-backdrop, .xmind-project-calendar-backdrop, .xmind-image-export")) 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" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) { 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" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) { event.preventDefault(); setIsNotesPopoverOpen(true); setIsTopicPanelOpen(false); setIsFormatPanelOpen(true); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); setTopicDetailSection("notes"); } if (event.key === "Escape" && relationshipSourceId) { event.preventDefault(); setRelationshipSourceId(null); } if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s" ) { event.preventDefault(); void flushPendingSave(true); } }} onKeyDown={(event) => { if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation(); }} onKeyUp={(event) => { if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation(); }} onCopy={(event) => { if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation(); }} onCut={(event) => { if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation(); }} onPasteCapture={handleFlowBoardPasteCapture} onPointerDownCapture={(event) => { const target = event.target; shellControlInteractionRef.current = target instanceof Element && Boolean(target.closest('[data-flow-board-shell-control="true"]')); window.setTimeout(() => { shellControlInteractionRef.current = false; }, 1000); }} > { const file = event.currentTarget.files?.[0]; if (file) { void handleImportMindMapFile(file); } }} ref={mindMapFileInputRef} type="file" /> { const board = boardRef.current; const files = Array.from(event.currentTarget.files || []); if (board && files.length > 0) { const topics = getActiveMarkerElements(); void handleFlowBoardPastedImages(board, files, topics.length === 1 ? topics[0].id : null); } event.currentTarget.value = ""; }} ref={imageFileInputRef} type="file" /> {!isZenMode ? setIsSearchOpen(true)} onToggleZen={() => setIsZenMode(true)} onToggleOutline={() => setIsOutlineOpen(value => !value)} onPresentation={() => setIsPresenting(true)} onGantt={() => setIsGanttOpen(true)} onTask={() => { const selected = getActiveMarkerElements(); if (selected.length !== 1) {toast.info("请选择一个主题"); return;} setTaskTopicId(selected[0].id); }} activeTool={activeTool} canEditMind={markerSelectionState.count > 0} currentTitle={currentTitle} formatPanelOpen={isFormatPanelVisible} markerPanelOpen={isMarkerPanelOpen} isSaving={isSavingNow || isImportingMindMap} onBack={() => { window.location.href = "/"; }} onImport={() => mindMapFileInputRef.current?.click()} onExport={() => void handleExportXmind()} onExportImage={() => setIsImageExportOpen(true)} isExporting={isExporting || isExportingImage} onInsertChild={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "child"); } : handleInsertChildTopic} onInsertSibling={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "sibling"); } : handleInsertSiblingTopic} onRelationship={handleInsertRelationship} onBoundary={handleInsertBoundary} relationshipPending={Boolean(relationshipSourceId)} onOpenFiles={() => { void handleOpenFilePanel(); }} onRedo={handleRedo} onSave={() => { void flushPendingSave(true); }} onSummary={handleInsertFlowBoardAbstract} onTopicDetails={(section) => { const selected = getActiveMarkerElements(); if (selected.length !== 1) { toast.info("请选择一个主题"); return; } setSelectedTopicId(selected[0].id); setTopicDetailSection(section); setIsNotesPopoverOpen(section === "notes"); setIsTopicPanelOpen(section !== "notes"); setIsFormatPanelOpen(true); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); }} onTitleChange={handleTitleChange} onTitleCommit={() => { void handleTitleCommit(); }} onToggleFormat={() => { setIsTopicPanelOpen(false); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); setIsFormatPanelOpen((open) => !open); }} onToggleMarkers={() => { setIsFilePanelOpen(false); setIsFormatPanelOpen(false); setIsMarkerPanelOpen((open) => !open); refreshMarkerSelectionState({ preserveFallback: true }); }} onToolChange={handleToolChange} onUndo={handleUndo} siteLogo={displayLogo} siteName={siteName} /> : } {isFormatPanelVisible && isTopicPanelOpen ? ( setIsTopicPanelOpen(false)} /> ) : isFormatPanelVisible && selectedAnnotation && isMindAnnotation(selectedAnnotation) ? ( setIsFormatPanelOpen(false)} onChange={(patch) => { const board = boardRef.current; const index = board?.children.findIndex((element) => element.id === annotationId) ?? -1; if (board && index >= 0) Transforms.setNode(board, patch, [index]); }} onDelete={() => { const board = boardRef.current; const index = board?.children.findIndex((element) => element.id === annotationId) ?? -1; if (board && index >= 0) Transforms.removeNode(board, [index]); setAnnotationId(null); }} /> ) : isFormatPanelVisible ? ( handleCanvasSettingsChange({ backgroundColor }) } onCanvasGridColorChange={(gridColor) => handleCanvasSettingsChange({ gridColor }) } onCanvasGridEnabledChange={(gridEnabled) => handleCanvasSettingsChange({ gridEnabled }) } onCanvasGridSizeChange={(gridSize) => handleCanvasSettingsChange({ gridSize }) } onClose={() => setIsFormatPanelOpen(false)} onFontSizeChange={handleFontSizeChange} onLayoutChange={handleLayoutChange} onNodeFillChange={handleNodeFillChange} onNodeShapeChange={handleNodeShapeChange} onTextColorChange={handleTextColorChange} onThemeChange={handleThemePresetChange} onToggleTextMark={handleToggleTextMark} selectedCount={markerSelectionState.count} textColor={selectionStyleState.textColor} themes={FLOW_BOARD_THEME_OPTIONS} /> ) : null} {!isZenMode && isFilePanelOpen ? ( ) : null} {!isZenMode && isMarkerPanelOpen ? { setIsMarkerPanelOpen(false); setIsFormatPanelOpen(true); }} /> : null}
{relationshipSourceId ? (
点击另一个主题建立联系
) : null} {!snapshot.children.length && !isOutlineOpen ? : null} } onSelectionChange={handleDrawnixSelectionChange} onThemeChange={handleThemeChange} onValueChange={handleValueChange} onViewportChange={handleViewportChange} theme={snapshot.theme} tutorial={false} value={snapshot.children} viewport={snapshot.viewport} />
{!isOutlineOpen ? { const board=boardRef.current; const topic=board && getTopicEntries(board.children).find(e=>e.element.id===id)?.element; const node=topic && MindElement.getNode(topic as MindElement); if(board && node) { const rect=getRectangleByNode(node);const center:Point=[rect.x+rect.width/2,rect.y+rect.height/2];Transforms.setSelection(board,{anchor:center,focus:center}); } setSelectedTopicId(id);setTopicDetailSection("notes");setIsNotesPopoverOpen(true);setIsTopicPanelOpen(false);setIsFormatPanelOpen(true);setIsFilePanelOpen(false);setIsMarkerPanelOpen(false); }} /> : null} {isOutlineOpen ?
: null} {!isZenMode ? { if (isOutlineOpen) setOutlineZoomPercent(percent); else { const board = boardRef.current; if (board) { BoardTransforms.updateZoom(board,percent / 100); setZoomPercent(percent); } } }} outlineOpen={isOutlineOpen} onToggleOutline={() => setIsOutlineOpen(value => !value)} activeSheetId={snapshot.activeSheetId || "sheet-1"} formatPanelOpen={isFormatPanelVisible} onAddSheet={handleAddSheet} onDeleteSheet={handleDeleteSheet} onDuplicateSheet={handleDuplicateSheet} onFit={isOutlineOpen ? () => setOutlineZoomPercent(100) : handleFitViewport} onRenameSheet={handleRenameSheet} onSwitchSheet={handleSwitchSheet} onZoomIn={() => isOutlineOpen ? setOutlineZoomPercent(value => Math.min(200, value + 10)) : handleZoom(0.1)} onZoomOut={() => isOutlineOpen ? setOutlineZoomPercent(value => Math.max(50, value - 10)) : handleZoom(-0.1)} sheets={sheetTabs} zoomPercent={isOutlineOpen ? outlineZoomPercent : zoomPercent} /> : null} {isSearchOpen && !isZenMode ? setIsSearchOpen(false)} onJump={handleSearchJump} onReplace={handleSearchReplace} /> : null} {isNotesPopoverOpen ? handlePatchTopicDetails({notesDocument})} onDetails={() => {setIsNotesPopoverOpen(false);setIsTopicPanelOpen(true);setIsFormatPanelOpen(true);setIsFilePanelOpen(false);setIsMarkerPanelOpen(false);}} onClose={() => setIsNotesPopoverOpen(false)} /> : null} {isImageExportOpen ? void handleExportImage(options)} onClose={() => setIsImageExportOpen(false)} /> : null} {isCalendarOpen ? setIsCalendarOpen(false)} /> : null} {isGanttOpen ? setIsGanttOpen(false)} onEdit={setTaskTopicId} onCalendar={() => setIsCalendarOpen(true)} onChange={handleSaveTopicTask} onUndo={handleUndo} onRedo={handleRedo} /> : null} {taskTopicId && topicEntries.some(e => e.element.id === taskTopicId) ? e.element.id === taskTopicId)!.element} elements={snapshot.children} onSave={handleSaveTopicTask} onClose={() => setTaskTopicId(null)} onGantt={() => setIsGanttOpen(true)} onCalendar={() => setIsCalendarOpen(true)} /> : null} {isPresenting ? setIsPresenting(false)} /> : null}
); }