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.
2994 lines
110 KiB
TypeScript
2994 lines
110 KiB
TypeScript
"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>
|
||
);
|
||
}
|