From d54180e11ddd7f30a6fa73e0bee850e43b057373 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 24 Sep 2026 16:39:07 +0800 Subject: [PATCH] feat: recreate XMind editing and file workflows --- app/api/flow-board/_utils.ts | 131 +- app/dev/flow-board-preview/page.tsx | 17 + components/flow-board/drawnix-workspace.tsx | 1457 +++++++++++++++-- components/flow-board/flow-board-client.tsx | 1 + .../flow-board/mind-annotation-board.ts | 206 +++ .../flow-board/mind-special-layout-board.ts | 503 ++++++ .../flow-board/xmind-annotation-panel.tsx | 129 ++ components/flow-board/xmind-editor-chrome.tsx | 1084 ++++++++++++ components/flow-board/xmind-outline.tsx | 310 ++++ components/flow-board/xmind-topic-panel.tsx | 329 ++++ docs/XMind-26.05-安装包核验.md | 41 + docs/XMind-复刻功能对照.md | 99 ++ lib/db/schema.ts | 15 +- lib/flow-board/mind-annotations.ts | 123 ++ lib/flow-board/mind-map-import.ts | 199 +-- lib/flow-board/mind-outline.ts | 183 +++ lib/flow-board/mind-special-layout.ts | 394 +++++ lib/flow-board/topic-details.ts | 128 ++ lib/flow-board/xmind-export.ts | 355 ++++ lib/flow-board/xmind-import.ts | 657 ++++++++ package.json | 7 + patches/@drawnix__drawnix@0.4.0-2.patch | 52 + .../@plait-board__react-text@0.4.0-2.patch | 46 + patches/README.md | 35 + playwright.flow-board-ui.config.ts | 23 + playwright.flow-board.config.ts | 12 + pnpm-lock.yaml | 86 +- proxy.ts | 21 +- scripts/flow-board/inspect-xmind-package.py | 41 + tests/flow-board/annotations.spec.ts | 106 ++ tests/flow-board/matrix-tables.spec.ts | 224 +++ tests/flow-board/outline.spec.ts | 158 ++ tests/flow-board/special-layouts.spec.ts | 123 ++ tests/flow-board/topic-details.spec.ts | 194 +++ tests/flow-board/tree-table.spec.ts | 108 ++ tests/flow-board/vertical-timeline.spec.ts | 108 ++ tests/flow-board/xmind-export.spec.ts | 132 ++ tests/flow-board/xmind-import.spec.ts | 72 + .../flow-board/xmind-native-fixtures.spec.ts | 101 ++ tests/routes/flow-board-annotations.test.ts | 139 ++ tests/routes/flow-board-outline.test.ts | 109 ++ tests/routes/flow-board-snapshot.test.ts | 160 ++ .../routes/flow-board-special-layout.test.ts | 167 ++ tests/routes/flow-board-topic-details.test.ts | 79 + tests/routes/flow-board-xmind-export.test.ts | 111 ++ 45 files changed, 8404 insertions(+), 371 deletions(-) create mode 100644 app/dev/flow-board-preview/page.tsx create mode 100644 components/flow-board/mind-annotation-board.ts create mode 100644 components/flow-board/mind-special-layout-board.ts create mode 100644 components/flow-board/xmind-annotation-panel.tsx create mode 100644 components/flow-board/xmind-editor-chrome.tsx create mode 100644 components/flow-board/xmind-outline.tsx create mode 100644 components/flow-board/xmind-topic-panel.tsx create mode 100644 docs/XMind-26.05-安装包核验.md create mode 100644 docs/XMind-复刻功能对照.md create mode 100644 lib/flow-board/mind-annotations.ts create mode 100644 lib/flow-board/mind-outline.ts create mode 100644 lib/flow-board/mind-special-layout.ts create mode 100644 lib/flow-board/topic-details.ts create mode 100644 lib/flow-board/xmind-export.ts create mode 100644 lib/flow-board/xmind-import.ts create mode 100644 patches/@drawnix__drawnix@0.4.0-2.patch create mode 100644 patches/@plait-board__react-text@0.4.0-2.patch create mode 100644 patches/README.md create mode 100644 playwright.flow-board-ui.config.ts create mode 100644 playwright.flow-board.config.ts create mode 100644 scripts/flow-board/inspect-xmind-package.py create mode 100644 tests/flow-board/annotations.spec.ts create mode 100644 tests/flow-board/matrix-tables.spec.ts create mode 100644 tests/flow-board/outline.spec.ts create mode 100644 tests/flow-board/special-layouts.spec.ts create mode 100644 tests/flow-board/topic-details.spec.ts create mode 100644 tests/flow-board/tree-table.spec.ts create mode 100644 tests/flow-board/vertical-timeline.spec.ts create mode 100644 tests/flow-board/xmind-export.spec.ts create mode 100644 tests/flow-board/xmind-import.spec.ts create mode 100644 tests/flow-board/xmind-native-fixtures.spec.ts create mode 100644 tests/routes/flow-board-annotations.test.ts create mode 100644 tests/routes/flow-board-outline.test.ts create mode 100644 tests/routes/flow-board-snapshot.test.ts create mode 100644 tests/routes/flow-board-special-layout.test.ts create mode 100644 tests/routes/flow-board-topic-details.test.ts create mode 100644 tests/routes/flow-board-xmind-export.test.ts diff --git a/app/api/flow-board/_utils.ts b/app/api/flow-board/_utils.ts index 61a5de0f4..7ac064783 100644 --- a/app/api/flow-board/_utils.ts +++ b/app/api/flow-board/_utils.ts @@ -1,4 +1,8 @@ -import type { FlowBoardDraft, FlowBoardSnapshotData } from "@/lib/db/schema"; +import type { + FlowBoardDraft, + FlowBoardSheetSnapshotData, + FlowBoardSnapshotData, +} from "@/lib/db/schema"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -8,6 +12,73 @@ function normalizeRecord(value: unknown): Record | null { return isRecord(value) ? value : null; } +const DEFAULT_FLOW_BOARD_CANVAS = { + backgroundColor: "#f7f8fb", + gridColor: "#d8dde8", + gridEnabled: false, + gridSize: 24, +}; + +function normalizeCanvasColor(value: unknown, fallback: string) { + return typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) + ? value.toLowerCase() + : fallback; +} + +function normalizeFlowBoardCanvas(value: unknown): Record { + const canvas = isRecord(value) ? value : {}; + const requestedGridSize = + typeof canvas.gridSize === "number" && Number.isFinite(canvas.gridSize) + ? Math.round(canvas.gridSize) + : DEFAULT_FLOW_BOARD_CANVAS.gridSize; + + return { + backgroundColor: normalizeCanvasColor( + canvas.backgroundColor, + DEFAULT_FLOW_BOARD_CANVAS.backgroundColor + ), + gridColor: normalizeCanvasColor( + 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 normalizeSheetId(value: unknown, index: number) { + const id = typeof value === "string" ? value.trim().slice(0, 128) : ""; + return id || `sheet-${index + 1}`; +} + +function normalizeSheetTitle(value: unknown, index: number) { + const title = typeof value === "string" ? value.trim().slice(0, 128) : ""; + return title || `画布 ${index + 1}`; +} + +function normalizeFlowBoardSheet( + value: unknown, + index: number +): FlowBoardSheetSnapshotData | null { + if (!isRecord(value)) { + return null; + } + + return { + id: normalizeSheetId(value.id, index), + title: normalizeSheetTitle(value.title, index), + children: Array.isArray(value.children) + ? value.children.filter(isRecord) + : [], + viewport: normalizeRecord(value.viewport), + theme: normalizeRecord(value.theme), + canvas: normalizeFlowBoardCanvas(value.canvas), + }; +} + export function normalizeFlowBoardTitle(value: unknown) { const title = String(value || "").trim(); return title.slice(0, 256) || "未命名流程白板"; @@ -17,15 +88,67 @@ export function normalizeFlowBoardSnapshot( value: unknown ): FlowBoardSnapshotData { const raw = isRecord(value) ? value : {}; + const legacyChildren = Array.isArray(raw.children) + ? raw.children.filter(isRecord) + : []; + const normalizedSheets = Array.isArray(raw.sheets) + ? raw.sheets + .map(normalizeFlowBoardSheet) + .filter( + (sheet): sheet is FlowBoardSheetSnapshotData => Boolean(sheet) + ) + : []; + const sheets = + normalizedSheets.length > 0 + ? normalizedSheets + : [ + { + id: "sheet-1", + title: "画布 1", + children: legacyChildren, + viewport: normalizeRecord(raw.viewport), + theme: normalizeRecord(raw.theme), + canvas: normalizeFlowBoardCanvas(raw.canvas), + }, + ]; + const requestedActiveSheetId = + typeof raw.activeSheetId === "string" ? raw.activeSheetId.trim() : ""; + const activeSheet = + sheets.find((sheet) => sheet.id === requestedActiveSheetId) || sheets[0]; + const activeChildren = Array.isArray(raw.children) + ? legacyChildren + : activeSheet.children; + const activeViewport = isRecord(raw.viewport) + ? raw.viewport + : activeSheet.viewport; + const activeTheme = isRecord(raw.theme) ? raw.theme : activeSheet.theme; + const activeCanvas = isRecord(raw.canvas) + ? normalizeFlowBoardCanvas(raw.canvas) + : normalizeFlowBoardCanvas(activeSheet.canvas); + const nextSheets = sheets.map((sheet) => + sheet.id === activeSheet.id + ? { + ...sheet, + children: activeChildren, + viewport: activeViewport, + theme: activeTheme, + canvas: activeCanvas, + } + : sheet + ); const updatedAt = typeof raw.updatedAt === "string" && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString(); return { - children: Array.isArray(raw.children) ? raw.children.filter(isRecord) : [], - viewport: normalizeRecord(raw.viewport), - theme: normalizeRecord(raw.theme), + version: 2, + activeSheetId: activeSheet.id, + sheets: nextSheets, + children: activeChildren, + viewport: activeViewport, + theme: activeTheme, + canvas: activeCanvas, updatedAt, }; } diff --git a/app/dev/flow-board-preview/page.tsx b/app/dev/flow-board-preview/page.tsx new file mode 100644 index 000000000..a0e419f7d --- /dev/null +++ b/app/dev/flow-board-preview/page.tsx @@ -0,0 +1,17 @@ +import { notFound } from "next/navigation"; +import { FlowBoardClient } from "@/components/flow-board/flow-board-client"; + +export default function FlowBoardPreviewPage() { + if (process.env.NODE_ENV === "production") { + notFound(); + } + + return ( + + ); +} diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index 489bdbdd3..84ce2dc8d 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -1,11 +1,20 @@ "use client"; import { Drawnix, type DrawnixProps } from "@drawnix/drawnix"; -import { memorizeLatest } from "@plait/common"; import { + BoardCreationMode, + getTextEditorsByElement, + memorizeLatest, + setCreationMode, +} from "@plait/common"; +import { + BoardTransforms, DEFAULT_COLOR, PlaitBoard, + PlaitHistoryBoard, type PlaitElement, + PlaitNode, + PlaitPointerType, type PlaitTheme, type Point, type Selection, @@ -13,23 +22,45 @@ import { Transforms, type Viewport, getHitElementsBySelection, + getSelectedElements, toHostPoint, toViewBoxPoint, } from "@plait/core"; -import { DrawTransforms, MemorizeKey } from "@plait/draw"; import { + ArrowLineShape, + BasicShapes, + DrawTransforms, + MemorizeKey, +} from "@plait/draw"; +import { MindLayoutType } from "@plait/layouts"; +import { + BranchShape, canSetAbstract, + 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, - parseMindMapFileToDrawnixMind, + parseMindMapFileToDocument, + MindMapImportError, } from "@/lib/flow-board/mind-map-import"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { @@ -37,26 +68,24 @@ import { IMAGE_UPLOAD_TOO_LARGE_MESSAGE, } from "@/lib/image-upload-limits"; import { - FloatingLogoPill, - FloatingSidebarAction, - FloatingSidebarRail, - FloatingSidebarShell, -} from "@/components/home/floating-sidebar-shell"; + type FlowBoardLayoutOption, + type FlowBoardThemeOption, + type FlowBoardToolId, + XmindEditorTopBar, + XmindFormatPanel, + XmindSheetBar, +} from "@/components/flow-board/xmind-editor-chrome"; import { CheckCircle2, ChevronDown, FileText, - FolderOpen, Flag, - GitMerge, Loader2, type LucideIcon, Plus, - Save, Star, Tag, Trash2, - Upload, UserRound, X, } from "lucide-react"; @@ -71,20 +100,50 @@ import { useState, } from "react"; import { toast } 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 { 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"; 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; @@ -122,7 +181,27 @@ type FlowBoardMarkerSelectionState = { names: 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: "#f7f8fb", + 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([ @@ -130,6 +209,28 @@ const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([ "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]; +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"] }, +]; const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [ { id: "tag", @@ -366,16 +467,130 @@ function seedFlowBoardDefaultTextColor() { }); } +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 { - children: normalizeFlowBoardChildren(raw.children), - viewport: raw.viewport, - theme: raw.theme, + version: 2, + activeSheetId: activeSheet.id, + sheets: nextSheets, + children: activeChildren, + viewport: activeViewport, + theme: activeTheme, + canvas: activeCanvas, updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined, }; } @@ -437,12 +652,15 @@ function getExistingFlowBoardMindElements( board: PlaitBoard, elements: MindElement[] ) { - return elements.filter((element) => { + return elements.flatMap((element) => { try { - PlaitBoard.findPath(board, element); - return true; + const path = PlaitBoard.findPath(board, element); + const currentElement = PlaitNode.get(board, path); + return MindElement.isMindElement(board, currentElement) + ? [currentElement] + : []; } catch (_error) { - return false; + return []; } }); } @@ -511,6 +729,73 @@ function getFlowBoardMarkerSelectionState( }; } +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 updateFlowBoardElementMarker( board: PlaitBoard, element: MindElement, @@ -628,11 +913,12 @@ export function DrawnixWorkspace({ siteName, siteLogo, siteLogoDark, + previewMode = false, }: FlowBoardWorkspaceProps) { const { resolvedTheme } = useTheme(); - const [snapshot, setSnapshot] = useState({ - children: [], - }); + const [snapshot, setSnapshot] = useState(() => + normalizeSnapshot({ children: [] }) + ); const [isReady, setIsReady] = useState(false); const [currentFileId, setCurrentFileId] = useState(null); const [currentTitle, setCurrentTitle] = useState("流程白板"); @@ -640,15 +926,34 @@ export function DrawnixWorkspace({ const [isFilePanelOpen, setIsFilePanelOpen] = useState(false); const [isFileListLoading, setIsFileListLoading] = useState(false); const [isSavingNow, setIsSavingNow] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [isOutlineOpen, setIsOutlineOpen] = useState(false); + const [outlineZoomPercent, setOutlineZoomPercent] = useState(100); const [, setLastSavedAt] = useState(null); - const snapshotRef = useRef({ children: [] }); + 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 [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] = @@ -656,6 +961,10 @@ export function DrawnixWorkspace({ count: 0, names: [], }); + const [selectionStyleState, setSelectionStyleState] = + useState(() => + getFlowBoardSelectionStyleState(null, []) + ); const displayLogo = useMemo(() => { if (resolvedTheme === "dark" && siteLogoDark) { @@ -699,24 +1008,43 @@ export function DrawnixWorkspace({ if (!board) { selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); return; } + const annotation = getSelectedElements(board).find(isMindAnnotation); + if (!options.preserveFallback || annotation) { + setAnnotationId(annotation?.id || null); + } + if (annotation) { + setSelectedTopicId(null); + selectedMindElementsRef.current = []; + setMarkerSelectionState({ count: 0, names: [] }); + 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 (!options.preserveFallback) { + setSelectionStyleState( + getFlowBoardSelectionStyleState(board, selectedElements) + ); + } return; } if (!options.preserveFallback) { + setSelectedTopicId(null); selectedMindElementsRef.current = []; setMarkerSelectionState({ count: 0, names: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); return; } @@ -734,6 +1062,10 @@ export function DrawnixWorkspace({ const handleDrawnixInit = useCallback((board: PlaitBoard) => { boardRef.current = board; + withMindAnnotations(board); + setRelationshipSourceId(null); + setAnnotationId(null); + setSelectedTopicId(null); seedFlowBoardDefaultTextColor(); selectedMindSelectionRef.current = board.selection; selectedMindElementsRef.current = getFlowBoardSelectedMindElements( @@ -743,6 +1075,16 @@ export function DrawnixWorkspace({ setMarkerSelectionState( getFlowBoardMarkerSelectionState(board, selectedMindElementsRef.current) ); + setSelectionStyleState( + getFlowBoardSelectionStyleState( + board, + selectedMindElementsRef.current + ) + ); + setActiveTool( + (PlaitBoard.getPointer(board) as FlowBoardToolId) || "selection" + ); + setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100)); }, []); useEffect(() => { @@ -807,6 +1149,7 @@ export function DrawnixWorkspace({ : null ); setMarkerSelectionState({ count: 0, names: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); selectedMindElementsRef.current = []; updateBrowserFileParam(file.id); }, @@ -823,6 +1166,18 @@ export function DrawnixWorkspace({ 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, { @@ -875,12 +1230,21 @@ export function DrawnixWorkspace({ return savedFile; }, - [updateBrowserFileParam] + [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(); @@ -893,13 +1257,16 @@ export function DrawnixWorkspace({ if (!initialFile) { if (!cancelled) { - const emptySnapshot = { children: [] }; + const emptySnapshot = normalizeSnapshot({ children: [] }); snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [] }); + setSelectionStyleState( + getFlowBoardSelectionStyleState(null, []) + ); selectedMindElementsRef.current = []; updateBrowserFileParam(null); } @@ -943,7 +1310,7 @@ export function DrawnixWorkspace({ clearTimeout(saveTimerRef.current); } }; - }, []); + }, [previewMode]); const scheduleSave = useCallback( (nextSnapshot: FlowBoardSnapshot) => { @@ -986,7 +1353,9 @@ export function DrawnixWorkspace({ (operation) => operation.type !== "set_selection" ); if (!shouldPersist) { - refreshMarkerSelectionState(); + refreshMarkerSelectionState({ + preserveFallback: shellControlInteractionRef.current, + }); return; } @@ -995,7 +1364,6 @@ export function DrawnixWorkspace({ theme: value.theme, viewport: value.viewport, }); - refreshMarkerSelectionState(); }, [refreshMarkerSelectionState, updateSnapshot] ); @@ -1003,11 +1371,113 @@ export function DrawnixWorkspace({ const handleDrawnixSelectionChange = useCallback( (selection: Selection | null) => { selectedMindSelectionRef.current = selection; - refreshMarkerSelectionState({ 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] + [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 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( (group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => { const board = boardRef.current; @@ -1085,6 +1555,13 @@ export function DrawnixWorkspace({ 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); @@ -1096,6 +1573,491 @@ export function DrawnixWorkspace({ } }, [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]); + if (tool !== "selection" && tool !== "hand") { + setCreationMode(board, BoardCreationMode.drawing); + } + setActiveTool(tool); + }, []); + + 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 handleUndo = useCallback(() => { + boardRef.current?.undo(); + }, []); + + const handleRedo = useCallback(() => { + boardRef.current?.redo(); + }, []); + + 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: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); + }, + [updateSnapshot] + ); + + 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: [] }); + 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: [] }); + 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: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); + }, + [updateSnapshot] + ); + const handleFlowBoardPastedImages = useCallback(async ( board: PlaitBoard, files: File[] @@ -1217,6 +2179,7 @@ export function DrawnixWorkspace({ const handleViewportChange = useCallback( (viewport: Viewport) => { + setZoomPercent(Math.round((viewport.zoom || 1) * 100)); if (snapshotRef.current.viewport === viewport) { return; } @@ -1235,6 +2198,18 @@ export function DrawnixWorkspace({ [updateSnapshot] ); + const handleCanvasSettingsChange = useCallback( + (partialCanvas: Partial) => { + updateSnapshot({ + canvas: normalizeFlowBoardCanvasSettings({ + ...snapshotRef.current.canvas, + ...partialCanvas, + }), + }); + }, + [updateSnapshot] + ); + const flushPendingSave = useCallback( async (showToast = false) => { if (saveTimerRef.current) { @@ -1255,10 +2230,31 @@ export function DrawnixWorkspace({ [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 handleImportMindMapFile = useCallback( async (file: File) => { if (!isSupportedMindMapImportFile(file)) { - toast.error("请选择 MD、OPML 或 FreeMind .mm 文件"); + toast.error("请选择 XMind、MD、OPML 或 FreeMind .mm 文件"); return; } @@ -1273,18 +2269,20 @@ export function DrawnixWorkspace({ } 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[]), - }, - ], + 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({ @@ -1321,10 +2319,11 @@ export function DrawnixWorkspace({ ); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); - toast.success("已生成思维导图"); + 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("思维导图导入失败"); + toast.error(error instanceof MindMapImportError ? error.message : "思维导图导入失败,请检查文件或稍后重试"); } finally { setIsImportingMindMap(false); setIsSavingNow(false); @@ -1333,11 +2332,12 @@ export function DrawnixWorkspace({ } } }, - [flushPendingSave, hydrateCloudFile] + [flushPendingSave, hydrateCloudFile, previewMode] ); const handleOpenFilePanel = useCallback(async () => { setIsMarkerPanelOpen(false); + setIsFormatPanelOpen(false); setIsFilePanelOpen(true); try { await loadCloudFiles(); @@ -1351,6 +2351,7 @@ export function DrawnixWorkspace({ async (fileId: string) => { if (fileId === currentFileIdRef.current) { setIsFilePanelOpen(false); + setIsFormatPanelOpen(true); return; } @@ -1376,6 +2377,7 @@ export function DrawnixWorkspace({ } hydrateCloudFile(file); setIsFilePanelOpen(false); + setIsFormatPanelOpen(true); } catch (error) { console.error("[FlowBoard] Failed to open cloud file:", error); toast.error("流程白板云文件打开失败"); @@ -1395,10 +2397,10 @@ export function DrawnixWorkspace({ setIsSavingNow(true); try { const now = new Date(); - const nextSnapshot: FlowBoardSnapshot = { + const nextSnapshot = normalizeSnapshot({ children: [], updatedAt: now.toISOString(), - }; + }); const response = await fetch(FLOW_BOARD_FILES_API, { body: JSON.stringify({ title: `流程白板 ${now.toLocaleString("zh-CN", { @@ -1436,6 +2438,7 @@ export function DrawnixWorkspace({ ]) ); setIsFilePanelOpen(false); + setIsFormatPanelOpen(true); toast.success("已新建云端流程白板"); } catch (error) { console.error("[FlowBoard] Failed to create cloud file:", error); @@ -1489,13 +2492,14 @@ export function DrawnixWorkspace({ return; } - const emptySnapshot = { children: [] }; + const emptySnapshot = normalizeSnapshot({ children: [] }); snapshotRef.current = emptySnapshot; setSnapshot(emptySnapshot); setCurrentFileId(null); setCurrentTitle("流程白板"); setLastSavedAt(null); setMarkerSelectionState({ count: 0, names: [] }); + setSelectionStyleState(getFlowBoardSelectionStyleState(null, [])); selectedMindElementsRef.current = []; updateBrowserFileParam(null); } catch (error) { @@ -1506,10 +2510,22 @@ export function DrawnixWorkspace({ [cloudFiles, hydrateCloudFile, updateBrowserFileParam] ); + const isFormatPanelVisible = + 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 (
-
+
正在恢复流程白板...
@@ -1520,7 +2536,29 @@ export function DrawnixWorkspace({
{ + if (event.key === "Escape" && relationshipSourceId) { + event.preventDefault(); + setRelationshipSourceId(null); + } + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "s" + ) { + event.preventDefault(); + void flushPendingSave(true); + } + }} 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); + }} > @@ -1558,74 +2605,152 @@ export function DrawnixWorkspace({ ref={mindMapFileInputRef} type="file" /> - - { - window.location.href = "/"; - }} - siteLogo={displayLogo} - siteName={siteName} - tooltip={null} - variant="plain" - /> - + { + const board = boardRef.current; + const files = Array.from(event.currentTarget.files || []); + if (board && files.length > 0) { + void handleFlowBoardPastedImages(board, files); + } + event.currentTarget.value = ""; + }} + ref={imageFileInputRef} + type="file" + /> - - - flushPendingSave(true)} - /> - - mindMapFileInputRef.current?.click()} - /> - { - setIsFilePanelOpen(false); - setIsMarkerPanelOpen((open) => !open); - refreshMarkerSelectionState({ preserveFallback: true }); - }} - /> - - - - + 0} + currentTitle={currentTitle} + formatPanelOpen={isFormatPanelVisible} + isSaving={isSavingNow || isImportingMindMap} + onBack={() => { + window.location.href = "/"; + }} + onImport={() => mindMapFileInputRef.current?.click()} + onExport={() => void handleExportXmind()} + isExporting={isExporting} + 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); + setIsTopicPanelOpen(true); + 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} {isFilePanelOpen ? ( ) : null} - } - onSelectionChange={handleDrawnixSelectionChange} - onThemeChange={handleThemeChange} - onValueChange={handleValueChange} - onViewportChange={handleViewportChange} - theme={snapshot.theme} - tutorial={false} - value={snapshot.children} - viewport={snapshot.viewport} +
+ {relationshipSourceId ? ( +
+ 点击另一个主题建立联系 + +
+ ) : null} + } + onSelectionChange={handleDrawnixSelectionChange} + onThemeChange={handleThemeChange} + onValueChange={handleValueChange} + onViewportChange={handleViewportChange} + theme={snapshot.theme} + tutorial={false} + value={snapshot.children} + viewport={snapshot.viewport} + /> +
+ + {isOutlineOpen ?
+ +
: null} + 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} />
); diff --git a/components/flow-board/flow-board-client.tsx b/components/flow-board/flow-board-client.tsx index a0d07b016..62a773b80 100644 --- a/components/flow-board/flow-board-client.tsx +++ b/components/flow-board/flow-board-client.tsx @@ -7,6 +7,7 @@ type FlowBoardClientProps = { siteName: string; siteLogo: string | null; siteLogoDark: string | null; + previewMode?: boolean; }; const DrawnixWorkspace = dynamic( diff --git a/components/flow-board/mind-annotation-board.ts b/components/flow-board/mind-annotation-board.ts new file mode 100644 index 000000000..ea71c0740 --- /dev/null +++ b/components/flow-board/mind-annotation-board.ts @@ -0,0 +1,206 @@ +import { + addSelectedElement, + BoardTransforms, + clearSelectedElement, + PlaitBoard, + type PlaitElement, + PlaitHistoryBoard, + PlaitPointerType, + type Point, + Transforms, +} from "@plait/core"; +import { + ArrowLineMarkerType, + ArrowLineShape, + BasicShapes, + createArrowLineElement, + createGeometryElementWithoutText, +} from "@plait/draw"; +import { getRectangleByNode, MindElement } from "@plait/mind"; +import { + type AnnotatedElement, + getBoundaryPoints, + getRelationshipPoints, + isMindAnnotation, + type MindAnnotation, + type TopicRectangle, +} from "@/lib/flow-board/mind-annotations"; + +function getTopics(board: PlaitBoard) { + const topics = new Map< + string, + { element: MindElement; visible: MindElement } + >(); + const visit = (elements: PlaitElement[], collapsedAncestor?: MindElement) => { + for (const element of elements) { + if (!MindElement.isMindElement(board, element)) continue; + topics.set(element.id, { + element, + visible: collapsedAncestor || element, + }); + visit( + element.children || [], + collapsedAncestor || (element.isCollapsed ? element : undefined) + ); + } + }; + visit(board.children); + return topics; +} + +function rectangle(element: MindElement): TopicRectangle | null { + const node = MindElement.getNode(element); + return node ? getRectangleByNode(node) : null; +} + +function annotationPoints( + board: PlaitBoard, + annotation: MindAnnotation +): Point[] | null { + const topics = getTopics(board); + if (annotation.kind === "relationship") { + const source = topics.get(annotation.sourceId); + const target = topics.get(annotation.targetId); + if (!source || !target) return null; + const a = rectangle(source.visible); + const b = rectangle(target.visible); + return a && b ? getRelationshipPoints(a, b) : null; + } + const rectangles: TopicRectangle[] = []; + const seen = new Set(); + const collect = (element: MindElement) => { + if (seen.has(element.id)) return; + seen.add(element.id); + const rect = rectangle(element); + if (rect) rectangles.push(rect); + if (!element.isCollapsed) element.children?.forEach(collect); + }; + for (const id of annotation.topicIds) { + const topic = topics.get(id); + if (topic) collect(topic.visible); + } + return getBoundaryPoints(rectangles, annotation.padding); +} + +export function insertMindAnnotation( + board: PlaitBoard, + annotation: MindAnnotation +) { + const points = annotationPoints(board, annotation); + if (!points) return null; + const endpoints: [Point, Point] = [points[0], points[points.length - 1]]; + const label = { type: "paragraph", children: [{ text: "联系" }] }; + const shape = + annotation.kind === "relationship" + ? createArrowLineElement( + ArrowLineShape.curve, + endpoints, + { marker: ArrowLineMarkerType.none }, + { marker: ArrowLineMarkerType.arrow }, + [{ position: 0.5, text: label }], + { strokeColor: "#64748b", strokeWidth: 2 } + ) + : createGeometryElementWithoutText( + BasicShapes.roundRectangle, + endpoints, + { + fill: "transparent", + strokeColor: "#8b9bb8", + strokeWidth: 2, + } + ); + const element: AnnotatedElement = { + ...shape, + points, + mindAnnotation: annotation, + }; + PlaitHistoryBoard.withNewBatch(board, () => { + // Boundaries sit behind topics, so clicking within them still selects topics. + Transforms.insertNode(board, element, [ + annotation.kind === "boundary" ? 0 : board.children.length, + ]); + }); + BoardTransforms.updatePointerType(board, PlaitPointerType.selection); + clearSelectedElement(board); + addSelectedElement(board, element); + return element; +} + +const installed = new WeakSet(); + +/** Keep native drawable shapes linked to topic IDs, using the existing undo stack. */ +export function withMindAnnotations(board: PlaitBoard) { + if (installed.has(board)) return; + installed.add(board); + const { afterChange, undo, redo, apply } = board; + board.apply = (operation) => { + // Plait keeps a hover reference after deleting or collapsing a topic. Clear it + // while the node's DOM still exists, otherwise the next pointerLeave throws. + if ( + operation.type === "remove_node" || + (operation.type === "set_node" && + ("isCollapsed" in operation.newProperties || + "children" in operation.newProperties)) + ) { + board.pointerLeave(new PointerEvent("pointerleave")); + } + apply(operation); + }; + let pending = false; + let replaying = true; + const synchronize = () => { + pending = false; + const replay = replaying; + replaying = false; + const topics = getTopics(board); + const changes = board.children + .filter(isMindAnnotation) + .flatMap((element) => { + const annotation = element.mindAnnotation; + const missing = + annotation.kind === "relationship" + ? !topics.has(annotation.sourceId) || + !topics.has(annotation.targetId) + : !annotation.topicIds.some((id) => topics.has(id)); + const points = missing ? null : annotationPoints(board, annotation); + if ( + !missing && + (!points || JSON.stringify(points) === JSON.stringify(element.points)) + ) + return []; + return [{ element, points, missing }]; + }); + if (!changes.length) return; + const update = () => { + for (const { element, points, missing } of changes) { + const index = board.children.findIndex( + (child) => child.id === element.id + ); + if (index < 0) continue; + if (missing) Transforms.removeNode(board, [index]); + else if (points) Transforms.setNode(board, { points }, [index]); + } + }; + if (replay) PlaitHistoryBoard.withoutSaving(board, update); + else PlaitHistoryBoard.withMerging(board, update); + }; + board.undo = () => { + replaying = board.history.undos.length > 0; + undo(); + }; + board.redo = () => { + replaying = board.history.redos.length > 0; + redo(); + }; + board.afterChange = () => { + afterChange(); + if ( + !pending && + board.operations.some((op) => op.type !== "set_selection") + ) { + pending = true; + queueMicrotask(synchronize); + } + }; + queueMicrotask(synchronize); +} diff --git a/components/flow-board/mind-special-layout-board.ts b/components/flow-board/mind-special-layout-board.ts new file mode 100644 index 000000000..3b7b05899 --- /dev/null +++ b/components/flow-board/mind-special-layout-board.ts @@ -0,0 +1,503 @@ +import type { PlaitBoard } from "@plait/core"; +import { + MindElement, + MindNodeComponent, + PlaitMind, + PlaitMindComponent, + getBranchColorByMindElement, + getBranchWidthByMindElement, + type MindNode, +} from "@plait/mind"; +import { + arrangeSpecialMind, + isSpecialMindLayout, + isSummaryNode, + specialNodeRect, + specialSubtreeBounds, +} from "@/lib/flow-board/mind-special-layout"; + +const ns = "http://www.w3.org/2000/svg"; +type TableCell = { + x: number; + y: number; + width: number; + height: number; + depth: number; + branch: number; +}; +type TableGrid = { + kind: "spreadsheet"; + x: number; + y: number; + width: number; + height: number; + columns: string[]; + headerHeight: number; + columnHeaderHeight: number; + rowHeaderWidth: number; + columnWidth: number; + rows: { y: number; height: number; branch: number }[]; +}; +const tableLayouts = new Set(["treeTable", "treeTableSide", "spreadsheet"]); +const tableTopicState = new WeakMap< + SVGGElement, + { + foreignObject: SVGForeignObjectElement; + x: string | null; + y: string | null; + width: string | null; + height: string | null; + textStyle: string | null; + contentStyle: string | null; + shape: SVGElement | null; + shapeVisibility: string; + } +>(); +function ensureTreeTableStyles() { + const id = "xmind-tree-table-topic-styles"; + if (document.getElementById(id)) return; + const style = document.createElement("style"); + style.id = id; + style.textContent = ` + [data-special-table-topic] .plait-text-container { + align-items: center !important; + display: flex !important; + height: 100% !important; + justify-content: center !important; + overflow-wrap: normal !important; + text-align: center !important; + white-space: nowrap !important; + width: 100% !important; + } + [data-special-table-topic] .plait-text-container > [data-slate-node="element"] { + text-align: center !important; + white-space: nowrap !important; + width: 100% !important; + } + [data-special-table-topic="0"] .plait-text-container { + color: #fff !important; + } + `; + document.head.append(style); +} +function frameTreeTableTopic( + component: MindNodeComponent, + node: MindNode, + enabled: boolean +) { + const host = component.getElementG(); + const saved = tableTopicState.get(host); + if (!enabled) { + delete host.dataset.specialTableTopic; + if (!saved) return; + for (const [name, value] of [ + ["x", saved.x], + ["y", saved.y], + ["width", saved.width], + ["height", saved.height], + ] as const) { + if (value === null) saved.foreignObject.removeAttribute(name); + else saved.foreignObject.setAttribute(name, value); + } + const text = saved.foreignObject.firstElementChild as HTMLElement | null; + if (text) { + if (saved.textStyle === null) text.removeAttribute("style"); + else text.setAttribute("style", saved.textStyle); + const content = text.firstElementChild as HTMLElement | null; + if (content) { + if (saved.contentStyle === null) content.removeAttribute("style"); + else content.setAttribute("style", saved.contentStyle); + } + } + if (saved.shape) saved.shape.style.visibility = saved.shapeVisibility; + tableTopicState.delete(host); + return; + } + + const cell = (node as MindNode & { tableCell?: TableCell }).tableCell; + const foreignObject = host.querySelector( + ":scope > .text foreignObject" + ); + if (!cell || !foreignObject) return; + ensureTreeTableStyles(); + host.dataset.specialTableTopic = String(cell.depth); + const currentShape = ( + component.nodeShapeGenerator as unknown as { g?: SVGGElement } + ).g; + if (!saved) { + tableTopicState.set(host, { + foreignObject, + x: foreignObject.getAttribute("x"), + y: foreignObject.getAttribute("y"), + width: foreignObject.getAttribute("width"), + height: foreignObject.getAttribute("height"), + textStyle: foreignObject.firstElementChild?.getAttribute("style") ?? null, + contentStyle: + foreignObject.firstElementChild?.firstElementChild?.getAttribute( + "style" + ) ?? null, + shape: currentShape || null, + shapeVisibility: currentShape?.style.visibility || "", + }); + } else if (currentShape && currentShape !== saved.shape) { + saved.shape = currentShape; + saved.shapeVisibility = currentShape.style.visibility || ""; + } + if (currentShape) currentShape.style.visibility = "hidden"; + foreignObject.setAttribute("x", String(cell.x + 14)); + foreignObject.setAttribute("y", String(cell.y + 8)); + foreignObject.setAttribute("width", String(Math.max(1, cell.width - 28))); + foreignObject.setAttribute("height", String(Math.max(1, cell.height - 16))); + const text = foreignObject.firstElementChild as HTMLElement | null; + if (text) { + text.style.alignItems = "center"; + text.style.color = cell.depth === 0 ? "#ffffff" : "#17252a"; + text.style.display = "flex"; + text.style.height = "100%"; + text.style.justifyContent = "center"; + text.style.overflowWrap = "normal"; + text.style.textAlign = "center"; + text.style.whiteSpace = "nowrap"; + text.style.width = "100%"; + const content = text.firstElementChild as HTMLElement | null; + if (content) { + content.style.alignItems = "center"; + content.style.color = cell.depth === 0 ? "#ffffff" : "#17252a"; + content.style.display = "flex"; + content.style.height = "100%"; + content.style.justifyContent = "center"; + content.style.textAlign = "center"; + content.style.whiteSpace = "nowrap"; + content.style.width = "100%"; + } + } +} +function specialLink(component: MindNodeComponent) { + const node = component.node; + let root: MindNode = node; + while (root.parent) root = root.parent; + const kind = root.origin.specialLayout; + const tableLayout = tableLayouts.has(String(kind)); + frameTreeTableTopic(component, node, tableLayout); + if (tableLayout) + requestAnimationFrame(() => { + if (component.getElementG().isConnected) + frameTreeTableTopic(component, node, true); + }); + if (!isSpecialMindLayout(kind) || isSummaryNode(node)) return false; + if ( + kind !== "brace" && + kind !== "verticalTimeline" && + !tableLayout && + node !== root && + node.parent !== root + ) + return false; + component.linkLineG?.remove(); + const group = document.createElementNS(ns, "g"); + group.dataset.specialStructure = kind; + const color = + getBranchColorByMindElement(component.board, component.element) || + "#64748b"; + const width = + getBranchWidthByMindElement(component.board, component.element) || 2; + const path = (d: string) => { + const shape = document.createElementNS(ns, "path"); + shape.setAttribute("d", d); + shape.setAttribute("fill", "none"); + shape.setAttribute("stroke", color); + shape.setAttribute("stroke-width", String(width)); + group.append(shape); + }; + const blendWithWhite = (value: string, amount: number) => { + const match = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); + if (!match) return value; + const channels = match.slice(1).map((channel) => Number.parseInt(channel, 16)); + return `#${channels + .map((channel) => + Math.round(channel + (255 - channel) * amount) + .toString(16) + .padStart(2, "0") + ) + .join("")}`; + }; + const solidColor = (value: unknown, fallback: string) => { + if (typeof value !== "string") return fallback; + const short = value.match(/^#([\da-f])([\da-f])([\da-f])$/i); + if (short) return `#${short.slice(1).map((channel) => channel + channel).join("")}`; + if (/^#[\da-f]{6}$/i.test(value)) return value; + const rgba = value.match(/^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i); + if (!rgba) return fallback; + const alpha = Number.parseInt(rgba[4], 16) / 255; + return `#${rgba + .slice(1, 4) + .map((channel) => + Math.round(Number.parseInt(channel, 16) * alpha + 255 * (1 - alpha)) + .toString(16) + .padStart(2, "0") + ) + .join("")}`; + }; + const rootRect = specialNodeRect(root); + const axisY = rootRect.y + rootRect.height / 2; + const branches = root.children.filter((child) => !isSummaryNode(child)); + if (kind === "treeTable" || kind === "treeTableSide") { + if (node === root) { + group.setAttribute("pointer-events", "none"); + const palette = ["#ffbc9f", "#d8ac8f", "#83c5be", "#f3c78e", "#8ab7d9"]; + const tableNodes: MindNode[] = []; + const collect = (item: MindNode) => { + if (!isSummaryNode(item)) tableNodes.push(item); + item.children.forEach(collect); + }; + collect(root); + const rootFill = solidColor(root.origin.fill, "#006d77"); + tableNodes.forEach((item) => { + const cell = (item as typeof item & { tableCell?: TableCell }).tableCell; + if (!cell) return; + const rectangle = document.createElementNS(ns, "rect"); + rectangle.dataset.specialTableCell = item.origin.id || "topic"; + rectangle.dataset.depth = String(cell.depth); + rectangle.setAttribute("x", String(cell.x)); + rectangle.setAttribute("y", String(cell.y)); + rectangle.setAttribute("width", String(cell.width)); + rectangle.setAttribute("height", String(cell.height)); + const branchColor = palette[cell.branch % palette.length] || palette[0]; + const explicit = solidColor(item.origin.fill, ""); + rectangle.setAttribute( + "fill", + cell.depth === 0 + ? rootFill + : explicit || + blendWithWhite(branchColor, cell.depth === 1 ? 0.08 : 0.72) + ); + rectangle.setAttribute("stroke", rootFill); + rectangle.setAttribute("stroke-width", "2"); + rectangle.setAttribute("vector-effect", "non-scaling-stroke"); + group.append(rectangle); + }); + group.dataset.specialTable = kind; + } else { + group.dataset.specialConnector = kind; + } + } else if (kind === "spreadsheet") { + if (node === root) { + group.setAttribute("pointer-events", "none"); + const grid = (root as MindNode & { tableGrid?: TableGrid }).tableGrid; + if (grid) { + const palette = ["#ff9d8e", "#f6c75d", "#70c8af", "#68a7dd", "#9c8bd8"]; + const rootFill = solidColor(root.origin.fill, "#1f2766"); + const rectangle = ( + x: number, + y: number, + width: number, + height: number, + fill: string, + dataset?: Record + ) => { + const rect = document.createElementNS(ns, "rect"); + rect.setAttribute("x", String(x)); + rect.setAttribute("y", String(y)); + rect.setAttribute("width", String(width)); + rect.setAttribute("height", String(height)); + rect.setAttribute("fill", fill); + rect.setAttribute("stroke", rootFill); + rect.setAttribute("stroke-width", "2"); + rect.setAttribute("vector-effect", "non-scaling-stroke"); + Object.assign(rect.dataset, dataset); + group.append(rect); + }; + rectangle(grid.x, grid.y, grid.width, grid.headerHeight, rootFill, { + specialTableCell: root.origin.id || "topic", + depth: "0", + }); + rectangle( + grid.x, + grid.y + grid.headerHeight, + grid.rowHeaderWidth, + grid.columnHeaderHeight, + rootFill + ); + grid.columns.forEach((column, index) => { + const x = grid.x + grid.rowHeaderWidth + index * grid.columnWidth; + const y = grid.y + grid.headerHeight; + rectangle(x, y, grid.columnWidth, grid.columnHeaderHeight, rootFill, { + specialMatrixColumn: column, + }); + const label = document.createElementNS(ns, "text"); + label.dataset.specialMatrixColumnLabel = column; + label.setAttribute("x", String(x + grid.columnWidth / 2)); + label.setAttribute("y", String(y + grid.columnHeaderHeight / 2 + 5)); + label.setAttribute("fill", "#ffffff"); + label.setAttribute("font-size", "14"); + label.setAttribute("font-weight", "600"); + label.setAttribute("text-anchor", "middle"); + label.textContent = column; + group.append(label); + }); + grid.rows.forEach((row, rowIndex) => { + const rowNode = branches[rowIndex]; + const rowColor = solidColor( + rowNode?.origin.fill, + palette[rowIndex % palette.length] + ); + rectangle(grid.x, row.y, grid.rowHeaderWidth, row.height, rowColor, { + specialTableCell: rowNode?.origin.id || `row-${rowIndex}`, + depth: "1", + }); + grid.columns.forEach((column, columnIndex) => + rectangle( + grid.x + grid.rowHeaderWidth + columnIndex * grid.columnWidth, + row.y, + grid.columnWidth, + row.height, + "#ffffff", + { specialMatrixCell: `${rowIndex}:${columnIndex}` } + ) + ); + rowNode?.children + .filter((child) => !isSummaryNode(child)) + .forEach((entry) => { + const cell = (entry as typeof entry & { tableCell?: TableCell }).tableCell; + if (!cell) return; + rectangle(cell.x, cell.y, cell.width, cell.height, blendWithWhite(rowColor, 0.2), { + specialMatrixEntry: entry.origin.id || "topic", + }); + }); + }); + group.dataset.specialTable = kind; + } + } else group.dataset.specialConnector = kind; + } else if (kind === "verticalTimeline") { + const axisX = rootRect.x + rootRect.width / 2; + if (node === root) { + if (branches.length) { + const end = Math.max( + ...branches.map((child) => { + const rect = specialNodeRect(child); + return rect.y + rect.height / 2; + }) + ); + path(`M${axisX} ${rootRect.y + rootRect.height}V${end}`); + group.dataset.specialAxis = kind; + } + } else { + const rect = specialNodeRect(node); + const right = rect.x > axisX; + const endX = right ? rect.x : rect.x + rect.width; + const endY = rect.y + rect.height / 2; + if (node.parent === root) { + path(`M${axisX} ${endY}H${endX}`); + } else if (node.parent) { + const parent = specialNodeRect(node.parent); + const startX = right ? parent.x + parent.width : parent.x; + const startY = parent.y + parent.height / 2; + const midX = (startX + endX) / 2; + path( + `M${startX} ${startY}C${midX} ${startY} ${midX} ${endY} ${endX} ${endY}` + ); + } + group.dataset.specialConnector = kind; + } + } else if (kind === "brace") { + const parent = node.parent; + const siblings = parent?.children.filter((child) => !isSummaryNode(child)); + if (parent && siblings?.[0] === node) { + const owner = specialNodeRect(parent); + const rects = siblings.map(specialNodeRect); + const top = Math.min(...rects.map((rect) => rect.y + rect.height / 2)); + const bottom = Math.max(...rects.map((rect) => rect.y + rect.height / 2)); + const middle = (top + bottom) / 2; + const right = Math.min(...rects.map((rect) => rect.x)) - 10; + const x = right - 18; + const left = Math.max(owner.x + owner.width + 6, x - 18); + const radius = Math.min(18, (bottom - top) / 4); + path( + top === bottom + ? `M${left} ${top}H${right}` + : `M${right} ${top}Q${x} ${top} ${x} ${top + radius}V${middle - radius}Q${x} ${middle} ${left} ${middle}Q${x} ${middle} ${x} ${middle + radius}V${bottom - radius}Q${x} ${bottom} ${right} ${bottom}` + ); + } + } else if (node === root) { + if (branches.length) { + const end = + Math.max( + ...branches.map((child) => { + const rect = specialSubtreeBounds(child); + return rect.x + rect.width; + }) + ) + 32; + path(`M${rootRect.x + rootRect.width} ${axisY}H${end}`); + group.dataset.specialAxis = kind; + } + } else { + const rect = specialNodeRect(node); + const above = rect.y + rect.height / 2 < axisY; + const endX = rect.x + rect.width / 2; + const startX = endX - (kind === "fishbone" ? 48 : 0); + path( + `M${startX} ${axisY}L${endX} ${above ? rect.y + rect.height : rect.y}` + ); + group.dataset.specialConnector = kind; + if (kind === "timeline") { + const circle = document.createElementNS(ns, "circle"); + circle.setAttribute("cx", String(startX)); + circle.setAttribute("cy", String(axisY)); + circle.setAttribute("r", "4"); + circle.setAttribute("fill", color); + group.append(circle); + } + } + component.linkLineG = group; + const host = component.getElementG(); + if (tableLayout && node === root && host.parentElement) + host.parentElement.insertBefore(group, host.parentElement.firstChild); + else host.append(group); + return true; +} +class SpecialMindRoot extends PlaitMindComponent { + override updateMindLayout(element = this.element) { + super.updateMindLayout(element); + if (isSpecialMindLayout(element.specialLayout)) + arrangeSpecialMind(this.root, element.specialLayout); + } + override drawLink() { + if (!specialLink(this)) { + this.linkLineG?.remove(); + this.linkLineG = undefined; + super.drawLink(); + } + } +} +class SpecialMindNode extends MindNodeComponent { + private lastSpecialLayout: unknown; + private rootLayout() { + let root = this.node; + while (root.parent) root = root.parent; + return root.origin.specialLayout; + } + override onContextChanged( + ...args: Parameters + ) { + super.onContextChanged(...args); + // A structure switch can leave descendant positions and parent identity unchanged. + if (this.rootLayout() !== this.lastSpecialLayout) this.drawLink(); + } + override drawLink() { + if (!specialLink(this)) super.drawLink(); + this.lastSpecialLayout = this.rootLayout(); + } +} +const installed = new WeakSet(); +export function withSpecialMindLayouts(board: PlaitBoard) { + if (installed.has(board)) return board; + installed.add(board); + const drawElement = board.drawElement; + board.drawElement = (context) => { + if (PlaitMind.isMind(context.element)) return SpecialMindRoot; + if (MindElement.isMindElement(board, context.element)) + return SpecialMindNode; + return drawElement(context); + }; + return board; +} diff --git a/components/flow-board/xmind-annotation-panel.tsx b/components/flow-board/xmind-annotation-panel.tsx new file mode 100644 index 000000000..8fda3b266 --- /dev/null +++ b/components/flow-board/xmind-annotation-panel.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { Trash2, X } from "lucide-react"; +import type { AnnotatedElement } from "@/lib/flow-board/mind-annotations"; + +export function XmindAnnotationPanel({ + element, + onChange, + onDelete, + onClose, +}: { + element: AnnotatedElement; + onChange: (patch: Record) => void; + onDelete: () => void; + onClose: () => void; +}) { + const relationship = element.mindAnnotation.kind === "relationship"; + const label = (element.texts?.[0]?.text?.children || []) + .map((leaf: { text?: string }) => leaf.text || "") + .join(""); + return ( + + ); +} diff --git a/components/flow-board/xmind-editor-chrome.tsx b/components/flow-board/xmind-editor-chrome.tsx new file mode 100644 index 000000000..5ac624116 --- /dev/null +++ b/components/flow-board/xmind-editor-chrome.tsx @@ -0,0 +1,1084 @@ +"use client"; + +import { + ArrowLeft, + ArrowUpRight, + Bold, + ChevronDown, + Copy, + FileText, + FolderOpen, + GitBranchPlus, + GitMerge, + Hand, + Image as ImageIcon, + Italic, + ListPlus, + Link2, + StickyNote, + Maximize, + Minus, + MousePointer2, + Network, + PanelRight, + Pencil, + Plus, + Redo2, + Save, + Shapes, + Tag, + Trash2, + Type, + Underline, + Undo2, + Upload, + Download, + X, + type LucideIcon, +} from "lucide-react"; +import { type ReactNode, useState } from "react"; + +export type FlowBoardToolId = + | "selection" + | "hand" + | "mind" + | "text" + | "arrow" + | "shape" + | "image"; + +export type FlowBoardSheetTab = { + id: string; + title: string; +}; + +export type FlowBoardLayoutOption = { + id: string; + label: string; + preview: + | "both" + | "right" + | "left" + | "down" + | "up" + | "indent" + | "fishbone" + | "timeline" + | "brace" + | "verticalTimeline" + | "treeTable" + | "treeTableSide" + | "spreadsheet"; +}; + +export type FlowBoardThemeOption = { + id: string; + label: string; + colors: [string, string, string]; +}; + +type ToolbarActionProps = { + active?: boolean; + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; +}; + +function ToolbarAction({ + active = false, + disabled = false, + icon: Icon, + label, + onClick, +}: ToolbarActionProps) { + return ( + + ); +} + +function IconAction({ + children, + disabled = false, + label, + tooltip, + onClick, +}: { + children: ReactNode; + disabled?: boolean; + label: string; + tooltip?: string; + onClick: () => void; +}) { + return ( + + ); +} + +function LayoutPreview({ + direction, +}: { + direction: FlowBoardLayoutOption["preview"]; +}) { + const horizontal = direction === "right" || direction === "left"; + const vertical = direction === "down" || direction === "up"; + const leaf = (x: number, y: number) => ( + + ); + const root = (x: number, y: number) => ( + + ); + return ( + + ); +} + +export function XmindEditorTopBar({ + activeTool, + canEditMind, + currentTitle, + formatPanelOpen, + isSaving, + onBack, + onImport, + onExport, + isExporting, + onInsertChild, + onInsertSibling, + onRelationship, + onBoundary, + relationshipPending, + onOpenFiles, + onRedo, + onSave, + onSummary, + onTopicDetails, + onTitleChange, + onTitleCommit, + onToggleFormat, + onToggleMarkers, + onToolChange, + onUndo, + siteLogo, + siteName, +}: { + activeTool: FlowBoardToolId; + canEditMind: boolean; + currentTitle: string; + formatPanelOpen: boolean; + isSaving: boolean; + onBack: () => void; + onImport: () => void; + onExport: () => void; + isExporting: boolean; + onInsertChild: () => void; + onInsertSibling: () => void; + onRelationship: () => void; + onBoundary: () => void; + relationshipPending: boolean; + onOpenFiles: () => void; + onRedo: () => void; + onSave: () => void; + onSummary: () => void; + onTopicDetails: (section: "notes" | "links" | "labels") => void; + onTitleChange: (value: string) => void; + onTitleCommit: () => void; + onToggleFormat: () => void; + onToggleMarkers: () => void; + onToolChange: (tool: FlowBoardToolId) => void; + onUndo: () => void; + siteLogo: string | null; + siteName: string; +}) { + return ( +
+
+
+ + + +
+
+ {siteLogo ? ( + {siteName} + ) : ( + siteName.slice(0, 1).toUpperCase() + )} +
+ + {siteName} + +
+ + onTitleChange(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + value={currentTitle} + /> + +
+ + {isSaving ? "正在保存" : "已保存"} + + + + + + + + + + + + + + +
+
+ +
+
+ + + + + + +
+
+ +
+
+ ); +} + +export function XmindFormatPanel({ + activeLayoutId, + activeThemeId, + branchColor, + branchShape, + branchWidth, + canvasBackgroundColor, + canvasGridColor, + canvasGridEnabled, + canvasGridSize, + fontSize, + isBold, + isItalic, + isUnderlined, + layouts, + nodeFill, + nodeShape, + onBranchColorChange, + onBranchShapeChange, + onBranchWidthChange, + onCanvasBackgroundColorChange, + onCanvasGridColorChange, + onCanvasGridEnabledChange, + onCanvasGridSizeChange, + onClose, + onFontSizeChange, + onLayoutChange, + onNodeFillChange, + onNodeShapeChange, + onTextColorChange, + onThemeChange, + onToggleTextMark, + selectedCount, + textColor, + themes, +}: { + activeLayoutId: string; + activeThemeId: string; + branchColor: string; + branchShape: "bight" | "polyline"; + branchWidth: number; + canvasBackgroundColor: string; + canvasGridColor: string; + canvasGridEnabled: boolean; + canvasGridSize: number; + fontSize: string; + isBold: boolean; + isItalic: boolean; + isUnderlined: boolean; + layouts: FlowBoardLayoutOption[]; + nodeFill: string; + nodeShape: "round-rectangle" | "underline"; + onBranchColorChange: (value: string) => void; + onBranchShapeChange: (value: "bight" | "polyline") => void; + onBranchWidthChange: (value: number) => void; + onCanvasBackgroundColorChange: (value: string) => void; + onCanvasGridColorChange: (value: string) => void; + onCanvasGridEnabledChange: (value: boolean) => void; + onCanvasGridSizeChange: (value: number) => void; + onClose: () => void; + onFontSizeChange: (value: string) => void; + onLayoutChange: (value: string) => void; + onNodeFillChange: (value: string) => void; + onNodeShapeChange: (value: "round-rectangle" | "underline") => void; + onTextColorChange: (value: string) => void; + onThemeChange: (value: string) => void; + onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void; + selectedCount: number; + textColor: string; + themes: FlowBoardThemeOption[]; +}) { + const [activePanel, setActivePanel] = useState<"format" | "canvas">( + "format" + ); + const backgroundPresets = [ + "#ffffff", + "#f7f8fb", + "#f1f5f9", + "#fff8e7", + "#eef8f1", + "#f5f0ff", + ]; + + return ( + + ); +} + +export function XmindSheetBar({ + outlineOpen, + onToggleOutline, + activeSheetId, + formatPanelOpen, + onAddSheet, + onDeleteSheet, + onDuplicateSheet, + onFit, + onRenameSheet, + onSwitchSheet, + onZoomIn, + onZoomOut, + sheets, + zoomPercent, +}: { + outlineOpen: boolean; + onToggleOutline: () => void; + activeSheetId: string; + formatPanelOpen: boolean; + onAddSheet: () => void; + onDeleteSheet: (id: string) => void; + onDuplicateSheet: (id: string) => void; + onFit: () => void; + onRenameSheet: (id: string, title: string) => void; + onSwitchSheet: (id: string) => void; + onZoomIn: () => void; + onZoomOut: () => void; + sheets: FlowBoardSheetTab[]; + zoomPercent: number; +}) { + const [renamingSheetId, setRenamingSheetId] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const activeSheet = sheets.find((sheet) => sheet.id === activeSheetId); + + const commitRename = () => { + if (!renamingSheetId) { + return; + } + const nextTitle = renameDraft.trim(); + if (nextTitle) { + onRenameSheet(renamingSheetId, nextTitle); + } + setRenamingSheetId(null); + setRenameDraft(""); + }; + + return ( +
+
+ {sheets.map((sheet) => ( +
+ {renamingSheetId === sheet.id ? ( + setRenameDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + commitRename(); + } else if (event.key === "Escape") { + setRenamingSheetId(null); + setRenameDraft(""); + } + }} + value={renameDraft} + /> + ) : ( + + )} + {sheet.id === activeSheetId ? ( + + ) : null} +
+ ))} + +
+ { + if (!activeSheet) { + return; + } + setRenamingSheetId(activeSheet.id); + setRenameDraft(activeSheet.title); + }} + > + + + activeSheet && onDuplicateSheet(activeSheet.id)} + > + + + activeSheet && onDeleteSheet(activeSheet.id)} + > + + +
+
+ +
+ + + + + + + + + + + +
+
+ ); +} diff --git a/components/flow-board/xmind-outline.tsx b/components/flow-board/xmind-outline.tsx new file mode 100644 index 000000000..705d7c385 --- /dev/null +++ b/components/flow-board/xmind-outline.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { + ArrowDown, + ArrowUp, + ChevronDown, + ChevronRight, + GripVertical, + IndentDecrease, + IndentIncrease, + Plus, + Trash2, +} from "lucide-react"; +import type { PlaitElement } from "@plait/core"; +import { + getTopicDetails, + getTopicEntries, + type TopicEntry, +} from "@/lib/flow-board/topic-details"; +import type { + OutlineCommand, + OutlineDrop, +} from "@/lib/flow-board/mind-outline"; + +type Props = { + zoomPercent: number; + elements: PlaitElement[]; + selectedId: string | null; + onSelect: (id: string) => void; + onRename: (id: string, title: string) => void; + onToggle: (id: string) => void; + onCommand: ( + id: string | null, + command: OutlineCommand | OutlineDrop + ) => string | null; + onUndo: () => void; + onRedo: () => void; +}; + +function OutlineRow({ + entry, + selected, + onSelect, + onRename, + onToggle, + onCommand, + onFocusTopic, +}: { + entry: TopicEntry; + selected: boolean; + onSelect: Props["onSelect"]; + onRename: Props["onRename"]; + onToggle: Props["onToggle"]; + onCommand: Props["onCommand"]; + onFocusTopic: (id: string | null, direction?: number) => void; +}) { + const { element, title, depth } = entry; + const [draft, setDraft] = useState(title); + const [drop, setDrop] = useState(null); + const ref = useRef(null); + const details = getTopicDetails(element); + useEffect(() => setDraft(title), [title]); + const commit = () => { + if (draft !== title) onRename(element.id, draft); + }; + const act = (command: OutlineCommand) => { + commit(); + onFocusTopic(onCommand(element.id, command)); + }; + return ( +
{ + event.preventDefault(); + const box = event.currentTarget.getBoundingClientRect(); + const y = (event.clientY - box.top) / box.height; + setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child"); + }} + onDragLeave={() => setDrop(null)} + onDrop={(event) => { + event.preventDefault(); + const source = event.dataTransfer.getData("application/x-mind-topic"); + if (source && drop) + onFocusTopic( + onCommand(source, { targetId: element.id, placement: drop }) + ); + setDrop(null); + }} + > + { + event.dataTransfer.setData("application/x-mind-topic", element.id); + event.dataTransfer.effectAllowed = "move"; + }} + > + + + {element.children?.length ? ( + + ) : ( + · + )} + setDraft(event.target.value)} + onFocus={() => onSelect(element.id)} + onBlur={commit} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing) return; + if (event.key === "Enter") { + event.preventDefault(); + act(event.shiftKey || depth === 0 ? "child" : "sibling"); + } else if (event.key === "Tab") { + event.preventDefault(); + act(event.shiftKey ? "outdent" : "indent"); + } else if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + commit(); + if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down"); + else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setDraft(title); + } else if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" && + draft !== title + ) { + event.preventDefault(); + event.stopPropagation(); + setDraft(title); + } else if (event.key === "Backspace" && !draft) { + event.preventDefault(); + onFocusTopic(onCommand(element.id, "delete")); + } + }} + /> + {Number.isInteger(element.start) ? ( + 概要 + ) : null} + {details.notes ? ( + + 备注 + + ) : null} + {details.labels.map((label) => ( + + {label} + + ))} + +
+ ); +} + +export function XmindOutline(props: Props) { + const { elements, selectedId, onCommand } = props; + const container = useRef(null); + const [focusId, setFocusId] = useState(null); + const entries = getTopicEntries(elements); + const collapsedPaths = entries + .filter(({ element }) => element.isCollapsed) + .map((entry) => entry.path); + const visible = entries.filter( + (entry) => + !collapsedPaths.some( + (path) => + path.length < entry.path.length && + path.every((index, i) => index === entry.path[i]) + ) + ); + useEffect(() => { + if (!focusId) return; + const input = Array.from( + container.current?.querySelectorAll( + "[data-outline-input]" + ) || [] + ).find((node) => node.dataset.outlineInput === focusId); + if (input) { + input.focus(); + input.select(); + setFocusId(null); + } + }, [focusId, elements]); + const focusTopic = (id: string | null, direction?: number) => { + if (!id) return; + const target = direction + ? visible[ + visible.findIndex((entry) => entry.element.id === id) + direction + ]?.element.id + : id; + if (target) setFocusId(target); + }; + const action = (command: OutlineCommand) => + focusTopic(onCommand(selectedId, command)); + return ( +
{ + event.stopPropagation(); + if ( + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" + ) { + event.preventDefault(); + if (event.shiftKey) props.onRedo(); + else props.onUndo(); + } + }} + > +
+

大纲

+ + {entries.length} 个主题 + + {( + [ + { command: "child", label: "大纲新增子主题", icon: Plus }, + { command: "indent", label: "大纲缩进", icon: IndentIncrease }, + { command: "outdent", label: "大纲提升层级", icon: IndentDecrease }, + { command: "up", label: "大纲上移", icon: ArrowUp }, + { command: "down", label: "大纲下移", icon: ArrowDown }, + { command: "delete", label: "大纲删除主题", icon: Trash2 }, + ] as const + ).map(({ command, label, icon: Icon }) => ( + + ))} +
+
+ {!entries.length ? ( + + ) : ( + visible.map((entry) => ( + + )) + )} +

+ Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab + 提升层级 · Alt+↑↓ 调整顺序 +

+
+
+ ); +} diff --git a/components/flow-board/xmind-topic-panel.tsx b/components/flow-board/xmind-topic-panel.tsx new file mode 100644 index 000000000..a8fc7b8d3 --- /dev/null +++ b/components/flow-board/xmind-topic-panel.tsx @@ -0,0 +1,329 @@ +"use client"; + +import type { PlaitElement } from "@plait/core"; +import { + ExternalLink, + FileDown, + Link2, + StickyNote, + Tag, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + getSafeTopicHref, + getTopicDetails, + getTopicTitle, + type TopicDetails, + type TopicEntry, +} from "@/lib/flow-board/topic-details"; + +export type TopicDetailSection = "notes" | "links" | "labels"; + +export function XmindTopicPanel({ + topic, + topics, + section, + onPatch, + onJump, + onClose, +}: { + topic: PlaitElement | null; + topics: TopicEntry[]; + section: TopicDetailSection; + onPatch: (patch: Partial) => void; + onJump: (id: string) => void; + onClose: () => void; +}) { + const details = getTopicDetails(topic); + const [labelDraft, setLabelDraft] = useState(""); + const [linkDraft, setLinkDraft] = useState(details.href); + const [attachmentError, setAttachmentError] = useState(""); + const [isReadingAttachment, setIsReadingAttachment] = useState(false); + const attachmentRef = useRef(null); + const noteRef = useRef(null); + const linkRef = useRef(null); + const labelRef = useRef(null); + useEffect(() => { + setLinkDraft(details.href); + }, [topic?.id, details.href]); + useEffect(() => { + setLabelDraft(""); + setAttachmentError(""); + }, [topic?.id]); + useEffect(() => { + ({ notes: noteRef, links: linkRef, labels: labelRef })[ + section + ].current?.focus(); + }, [section]); + const safeHref = getSafeTopicHref(linkDraft); + const invalidHref = Boolean(linkDraft.trim()) && !safeHref; + const addLabel = () => { + const value = labelDraft.trim(); + if (value && !details.labels.includes(value)) + onPatch({ labels: [...details.labels, value] }); + setLabelDraft(""); + }; + const addAttachment = async (file: File) => { + setAttachmentError(""); + if (file.size > 8 * 1024 * 1024) { + setAttachmentError("附件不能超过 8 MB。"); + return; + } + setIsReadingAttachment(true); + try { + const url = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(new Error("读取失败")); + reader.onload = () => resolve(String(reader.result)); + // Always download attachments as files, independent of their source type. + reader.readAsDataURL( + new Blob([file], { type: "application/octet-stream" }) + ); + }); + onPatch({ + attachment: { + name: file.name, + url, + mimeType: "application/octet-stream", + }, + }); + } catch { + setAttachmentError("无法读取附件,请重新选择。"); + } finally { + setIsReadingAttachment(false); + } + }; + return ( +