"use client"; import { Drawnix, type DrawnixProps } from "@drawnix/drawnix"; import { memorizeLatest } from "@plait/common"; import { DEFAULT_COLOR, PlaitBoard, type PlaitElement, type PlaitTheme, type Point, type Selection, type ThemeColorMode, Transforms, type Viewport, getHitElementsBySelection, toHostPoint, toViewBoxPoint, } from "@plait/core"; import { DrawTransforms, MemorizeKey } from "@plait/draw"; import { canSetAbstract, getSelectedMindElements, MindElement, MindTransforms, PlaitMind, } from "@plait/mind"; import type { BoardChangeData } from "@plait-board/react-board"; import { FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT, getMindMapImportTitle, isSupportedMindMapImportFile, parseMindMapFileToDrawnixMind, } from "@/lib/flow-board/mind-map-import"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { FloatingLogoPill, FloatingSidebarAction, FloatingSidebarRail, FloatingSidebarShell, } from "@/components/home/floating-sidebar-shell"; import { CheckCircle2, ChevronDown, FileText, FolderOpen, Flag, GitMerge, Loader2, type LucideIcon, Plus, Save, Star, Tag, Trash2, Upload, UserRound, X, } from "lucide-react"; import { useTheme } from "next-themes"; import { type ChangeEvent, type ClipboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; type FlowBoardWorkspaceProps = { siteName: string; siteLogo: string | null; siteLogoDark: string | null; }; type FlowBoardSnapshot = { children: PlaitElement[]; viewport?: Viewport; theme?: PlaitTheme; updatedAt?: string; }; type FlowBoardCloudFile = { id: string; title: string; createdAt?: string | null; updatedAt?: string | null; }; type FlowBoardCloudFilePayload = FlowBoardCloudFile & { snapshot?: unknown; }; type FlowBoardMarkerGroupId = | "tag" | "priority" | "task" | "flag" | "star" | "avatar" | "symbol"; type FlowBoardMarkerItem = { name: string; label: string; }; type FlowBoardMarkerGroup = { id: FlowBoardMarkerGroupId; label: string; icon: LucideIcon; markers: FlowBoardMarkerItem[]; }; type FlowBoardMarkerSelectionState = { count: number; names: string[]; }; const FLOW_BOARD_FILES_API = "/api/flow-board/files"; const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR; const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", ]); const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [ { id: "tag", label: "标签", icon: Tag, markers: [ { name: "🔴", label: "红色" }, { name: "🟠", label: "橙色" }, { name: "🟡", label: "黄色" }, { name: "🟢", label: "绿色" }, { name: "🔵", label: "蓝色" }, { name: "🟣", label: "紫色" }, { name: "⚪", label: "灰色" }, ], }, { id: "priority", label: "优先级", icon: CheckCircle2, markers: [ { name: "1️⃣", label: "优先级 1" }, { name: "2️⃣", label: "优先级 2" }, { name: "3️⃣", label: "优先级 3" }, { name: "4️⃣", label: "优先级 4" }, { name: "5️⃣", label: "优先级 5" }, { name: "6️⃣", label: "优先级 6" }, { name: "7️⃣", label: "优先级 7" }, ], }, { id: "task", label: "任务", icon: CheckCircle2, markers: [ { name: "▶️", label: "开始" }, { name: "◔", label: "25%" }, { name: "◑", label: "50%" }, { name: "◕", label: "75%" }, { name: "🚧", label: "处理中" }, { name: "✅", label: "完成" }, ], }, { id: "flag", label: "旗帜", icon: Flag, markers: [ { name: "🚩", label: "红旗" }, { name: "🏁", label: "终点" }, { name: "🏳️", label: "白旗" }, { name: "🎯", label: "目标" }, { name: "📍", label: "定位" }, { name: "📌", label: "固定" }, ], }, { id: "star", label: "星星", icon: Star, markers: [ { name: "⭐", label: "星标" }, { name: "🌟", label: "重点" }, { name: "✨", label: "亮点" }, { name: "💫", label: "灵感" }, { name: "🔥", label: "高热" }, { name: "💎", label: "价值" }, ], }, { id: "avatar", label: "人像", icon: UserRound, markers: [ { name: "👤", label: "成员" }, { name: "🙋", label: "负责人" }, { name: "👥", label: "多人" }, { name: "🧑‍💻", label: "执行" }, { name: "🧑‍🎨", label: "创意" }, { name: "🧑‍💼", label: "业务" }, ], }, { id: "symbol", label: "符号", icon: Tag, markers: [ { name: "❗", label: "注意" }, { name: "❓", label: "疑问" }, { name: "💡", label: "想法" }, { name: "❤️", label: "喜欢" }, { name: "⚡", label: "快速" }, { name: "🔗", label: "关联" }, ], }, ]; const FLOW_BOARD_MARKER_NAMES_BY_GROUP = new Map( FLOW_BOARD_MARKER_GROUPS.map((group) => [ group.id, new Set(group.markers.map((marker) => marker.name)), ]) ); 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 normalizeSnapshot(value: unknown): FlowBoardSnapshot { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Partial) : {}; return { children: normalizeFlowBoardChildren(raw.children), viewport: raw.viewport, theme: raw.theme, 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 getFlowBoardElementEmojiNames(element: MindElement) { const emojis = Array.isArray(element.data?.emojis) ? element.data.emojis : []; return emojis .map((emoji) => String(emoji?.name || "").trim()) .filter(Boolean); } function getExistingFlowBoardMindElements( board: PlaitBoard, elements: MindElement[] ) { return elements.filter((element) => { try { PlaitBoard.findPath(board, element); return true; } catch (_error) { return false; } }); } 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: [] }; } const targetElements = selectedElements || getSelectedMindElements(board); const names = new Set(); targetElements.forEach((element) => { getFlowBoardElementEmojiNames(element).forEach((name) => { names.add(name); }); }); return { count: targetElements.length, names: Array.from(names), }; } function updateFlowBoardElementMarker( board: PlaitBoard, element: MindElement, group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem ) { const groupMarkerNames = FLOW_BOARD_MARKER_NAMES_BY_GROUP.get(group.id) || new Set(); const currentEmojis = Array.isArray(element.data?.emojis) ? element.data.emojis : []; const isAlreadyApplied = currentEmojis.some( (emoji) => emoji.name === marker.name ); const nextEmojis = isAlreadyApplied ? currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name)) : [ ...currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name)), { name: marker.name }, ]; const nextData = { ...element.data }; if (nextEmojis.length > 0) { nextData.emojis = nextEmojis; } else { delete nextData.emojis; } Transforms.setNode( board, { data: nextData, }, PlaitBoard.findPath(board, element) ); } 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) { 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, 400); 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; }); } 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, }: FlowBoardWorkspaceProps) { const { resolvedTheme } = useTheme(); const [snapshot, setSnapshot] = useState({ 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 [, setLastSavedAt] = useState(null); const snapshotRef = useRef({ children: [] }); const currentFileIdRef = useRef(null); const currentTitleRef = useRef("流程白板"); const saveTimerRef = useRef | null>(null); const boardRef = useRef(null); const mindMapFileInputRef = useRef(null); const selectedMindElementsRef = useRef([]); const selectedMindSelectionRef = useRef(null); const [isMarkerPanelOpen, setIsMarkerPanelOpen] = useState(false); const [isImportingMindMap, setIsImportingMindMap] = useState(false); const [markerSelectionState, setMarkerSelectionState] = useState({ count: 0, names: [], }); 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: [] }); return; } const selectedElements = getFlowBoardSelectedMindElements( board, options.selection ?? selectedMindSelectionRef.current ?? board.selection ); if (selectedElements.length > 0) { selectedMindElementsRef.current = selectedElements; setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, selectedElements) ); return; } if (!options.preserveFallback) { selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [] }); return; } const fallbackElements = getExistingFlowBoardMindElements( board, selectedMindElementsRef.current ); selectedMindElementsRef.current = fallbackElements; setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, fallbackElements) ); }, [] ); const handleDrawnixInit = useCallback((board: PlaitBoard) => { boardRef.current = board; seedFlowBoardDefaultTextColor(); selectedMindSelectionRef.current = board.selection; selectedMindElementsRef.current = getFlowBoardSelectedMindElements( board, board.selection ); setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, selectedMindElementsRef.current) ); }, []); 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); setCurrentFileId(file.id); setCurrentTitle(file.title || "未命名流程白板"); setLastSavedAt( file.updatedAt ? new Date(file.updatedAt) : nextSnapshot.updatedAt ? new Date(nextSnapshot.updatedAt) : null ); setMarkerSelectionState({ count: 0, names: [] }); 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() || "未命名流程白板"; 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; }, [updateBrowserFileParam] ); useEffect(() => { let cancelled = false; 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 = { children: [] }; snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [] }); 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); } }; }, []); 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(); return; } updateSnapshot({ children: value.children, theme: value.theme, viewport: value.viewport, }); refreshMarkerSelectionState(); }, [refreshMarkerSelectionState, updateSnapshot] ); const handleDrawnixSelectionChange = useCallback( (selection: Selection | null) => { selectedMindSelectionRef.current = selection; refreshMarkerSelectionState({ selection }); }, [refreshMarkerSelectionState] ); const handleApplyFlowBoardMarker = useCallback( (group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => { const board = boardRef.current; if (!board) { return; } const selectedElements = getActiveMarkerElements(); if (selectedElements.length === 0) { toast.info("请先选中一个思维导图节点"); return; } selectedElements.forEach((element) => { updateFlowBoardElementMarker(board, element, group, marker); }); refreshMarkerSelectionState({ preserveFallback: true }); }, [getActiveMarkerElements, refreshMarkerSelectionState] ); const handleClearFlowBoardMarkers = useCallback(() => { const board = boardRef.current; if (!board) { return; } const selectedElements = getActiveMarkerElements(); if (selectedElements.length === 0) { toast.info("请先选中一个思维导图节点"); return; } const allMarkerNames = new Set( FLOW_BOARD_MARKER_GROUPS.flatMap((group) => group.markers.map((marker) => marker.name) ) ); selectedElements.forEach((element) => { const currentEmojis = Array.isArray(element.data?.emojis) ? element.data.emojis : []; const nextEmojis = currentEmojis.filter( (emoji) => !allMarkerNames.has(emoji.name) ); if (nextEmojis.length === currentEmojis.length) { return; } const nextData = { ...element.data }; if (nextEmojis.length > 0) { nextData.emojis = nextEmojis; } else { delete nextData.emojis; } Transforms.setNode( board, { data: nextData }, PlaitBoard.findPath(board, element) ); }); refreshMarkerSelectionState({ preserveFallback: true }); toast.success("已清空所选节点标记"); }, [getActiveMarkerElements, refreshMarkerSelectionState]); const handleInsertFlowBoardAbstract = useCallback(() => { const board = boardRef.current; if (!board) { return; } const selectedElements = getActiveMarkerElements().filter(canSetAbstract); if (selectedElements.length === 0) { toast.info("请先选中要汇总的思维导图节点"); 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 handleFlowBoardPastedImages = useCallback(async ( board: PlaitBoard, files: File[] ) => { 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); let insertedCount = 0; for (let index = 0; index < supportedFiles.length; index += 1) { const file = supportedFiles[index]; if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) { toast.error("图片大小不能超过 50MB"); continue; } try { const [url, dimensions] = await Promise.all([ uploadFlowBoardImageFile(file), getFlowBoardImageDimensions(file), ]); 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 ? "已粘贴图片到白板" : `已粘贴 ${insertedCount} 张图片到白板` ); } }, []); const handleFlowBoardPasteCapture = useCallback((event: ClipboardEvent) => { const board = boardRef.current; if (!board || !event.clipboardData) { return; } const target = event.target instanceof HTMLElement ? event.target : null; if ( target?.closest("[data-flow-board-shell-control='true']") || isFlowBoardEditablePasteTarget(target) ) { return; } const 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) { return; } event.preventDefault(); event.stopPropagation(); void handleFlowBoardPastedImages(board, imageFiles); }, [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) => { 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 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 handleImportMindMapFile = useCallback( async (file: File) => { if (!isSupportedMindMapImportFile(file)) { toast.error("请选择 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); } const title = getMindMapImportTitle(file.name); const mindElement = await parseMindMapFileToDrawnixMind(file, title); const nextSnapshot: FlowBoardSnapshot = normalizeSnapshot({ children: [ { ...mindElement, points: mindElement.points?.length ? mindElement.points : ([[0, 0]] as Point[]), }, ], updatedAt: new Date().toISOString(), }); 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("已生成思维导图"); } catch (error) { console.error("[FlowBoard] Failed to import mind map:", error); toast.error("思维导图导入失败"); } finally { setIsImportingMindMap(false); setIsSavingNow(false); if (mindMapFileInputRef.current) { mindMapFileInputRef.current.value = ""; } } }, [flushPendingSave, hydrateCloudFile] ); const handleOpenFilePanel = useCallback(async () => { setIsMarkerPanelOpen(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); 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); } 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: FlowBoardSnapshot = { 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); 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 = { children: [] }; snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [] }); selectedMindElementsRef.current = []; updateBrowserFileParam(null); } catch (error) { console.error("[FlowBoard] Failed to delete cloud file:", error); toast.error("删除流程白板云文件失败"); } }, [cloudFiles, hydrateCloudFile, updateBrowserFileParam] ); if (!isReady) { return (
正在恢复流程白板...
); } return (
{ const file = event.currentTarget.files?.[0]; if (file) { void handleImportMindMapFile(file); } }} ref={mindMapFileInputRef} type="file" /> { window.location.href = "/"; }} siteLogo={displayLogo} siteName={siteName} tooltip={null} variant="plain" /> flushPendingSave(true)} /> mindMapFileInputRef.current?.click()} /> { setIsFilePanelOpen(false); setIsMarkerPanelOpen((open) => !open); refreshMarkerSelectionState({ preserveFallback: true }); }} /> {isFilePanelOpen ? ( ) : null} {isMarkerPanelOpen ? ( ) : null} } onSelectionChange={handleDrawnixSelectionChange} onThemeChange={handleThemeChange} onValueChange={handleValueChange} onViewportChange={handleViewportChange} theme={snapshot.theme} tutorial={false} value={snapshot.children} viewport={snapshot.viewport} />
); }