ai-chatbot/components/flow-board/drawnix-workspace.tsx
134 23ced0b760 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 17:38:14 +08:00

2994 lines
110 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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