3035 lines
98 KiB
TypeScript
3035 lines
98 KiB
TypeScript
"use client";
|
||
|
||
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,
|
||
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 {
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
FileText,
|
||
Flag,
|
||
Loader2,
|
||
type LucideIcon,
|
||
Plus,
|
||
Star,
|
||
Tag,
|
||
Trash2,
|
||
UserRound,
|
||
X,
|
||
} from "lucide-react";
|
||
import { useTheme } from "next-themes";
|
||
import {
|
||
type ChangeEvent,
|
||
type ClipboardEvent,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { toast } from "sonner";
|
||
import { insertMindAnnotation, withMindAnnotations } from "./mind-annotation-board";
|
||
import { isMindAnnotation, validateTopicRange } from "@/lib/flow-board/mind-annotations";
|
||
import { XmindAnnotationPanel } from "./xmind-annotation-panel";
|
||
import { XmindTopicPanel, type TopicDetailSection } from "./xmind-topic-panel";
|
||
import { XmindOutline } from "./xmind-outline";
|
||
import { withSpecialMindLayouts } from "./mind-special-layout-board";
|
||
import { SPECIAL_MIND_LAYOUTS, isSpecialMindLayout } from "@/lib/flow-board/mind-special-layout";
|
||
import { applyOutlineCommand, renameOutlineTopic, type OutlineCommand, type OutlineDrop } from "@/lib/flow-board/mind-outline";
|
||
import { getTopicEntries, patchTopicDetails, type TopicDetails } from "@/lib/flow-board/topic-details";
|
||
|
||
type FlowBoardWorkspaceProps = {
|
||
siteName: string;
|
||
siteLogo: string | null;
|
||
siteLogoDark: string | null;
|
||
previewMode?: boolean;
|
||
};
|
||
|
||
type FlowBoardSnapshot = {
|
||
version?: number;
|
||
activeSheetId?: string;
|
||
sheets?: FlowBoardSheetSnapshot[];
|
||
children: PlaitElement[];
|
||
viewport?: Viewport;
|
||
theme?: PlaitTheme;
|
||
canvas: FlowBoardCanvasSettings;
|
||
updatedAt?: string;
|
||
};
|
||
|
||
type FlowBoardCanvasSettings = {
|
||
backgroundColor: string;
|
||
gridColor: string;
|
||
gridEnabled: boolean;
|
||
gridSize: number;
|
||
};
|
||
|
||
type FlowBoardSheetSnapshot = {
|
||
id: string;
|
||
title: string;
|
||
children: PlaitElement[];
|
||
viewport?: Viewport;
|
||
theme?: PlaitTheme;
|
||
canvas: FlowBoardCanvasSettings;
|
||
};
|
||
|
||
type FlowBoardCloudFile = {
|
||
id: string;
|
||
title: string;
|
||
createdAt?: string | null;
|
||
updatedAt?: string | null;
|
||
};
|
||
|
||
type FlowBoardCloudFilePayload = FlowBoardCloudFile & {
|
||
snapshot?: unknown;
|
||
};
|
||
|
||
type FlowBoardMarkerGroupId =
|
||
| "tag"
|
||
| "priority"
|
||
| "task"
|
||
| "flag"
|
||
| "star"
|
||
| "avatar"
|
||
| "symbol";
|
||
|
||
type FlowBoardMarkerItem = {
|
||
name: string;
|
||
label: string;
|
||
};
|
||
|
||
type FlowBoardMarkerGroup = {
|
||
id: FlowBoardMarkerGroupId;
|
||
label: string;
|
||
icon: LucideIcon;
|
||
markers: FlowBoardMarkerItem[];
|
||
};
|
||
|
||
type FlowBoardMarkerSelectionState = {
|
||
count: number;
|
||
names: string[];
|
||
};
|
||
|
||
type FlowBoardSelectionStyleState = {
|
||
branchColor: string;
|
||
branchShape: BranchShape;
|
||
branchWidth: number;
|
||
fill: string;
|
||
fontSize: string;
|
||
isBold: boolean;
|
||
isItalic: boolean;
|
||
isUnderlined: boolean;
|
||
layout: string;
|
||
nodeShape: MindElementShape;
|
||
textColor: string;
|
||
};
|
||
|
||
const FLOW_BOARD_FILES_API = "/api/flow-board/files";
|
||
const DEFAULT_FLOW_BOARD_CANVAS: FlowBoardCanvasSettings = {
|
||
backgroundColor: "#f7f8fb",
|
||
gridColor: "#d8dde8",
|
||
gridEnabled: false,
|
||
gridSize: 24,
|
||
};
|
||
const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR;
|
||
const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
|
||
const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([
|
||
"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];
|
||
const FLOW_BOARD_THEME_OPTIONS: FlowBoardThemeOption[] = [
|
||
{ id: "default", label: "经典", colors: ["#2f6feb", "#8fb3ff", "#dbe7ff"] },
|
||
{ id: "colorful", label: "彩虹", colors: ["#ef6461", "#f4c95d", "#5abf90"] },
|
||
{ id: "soft", label: "柔和", colors: ["#7d8fc7", "#b7c2e2", "#e4e9f5"] },
|
||
{ id: "retro", label: "复古", colors: ["#bf6b55", "#d7a86e", "#6b8b75"] },
|
||
{ id: "dark", label: "深色", colors: ["#24272d", "#59616e", "#9aa4b2"] },
|
||
{ id: "starry", label: "星空", colors: ["#243b74", "#5471c6", "#9cadf0"] },
|
||
];
|
||
const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [
|
||
{
|
||
id: "tag",
|
||
label: "标签",
|
||
icon: Tag,
|
||
markers: [
|
||
{ name: "🔴", label: "红色" },
|
||
{ name: "🟠", label: "橙色" },
|
||
{ name: "🟡", label: "黄色" },
|
||
{ name: "🟢", label: "绿色" },
|
||
{ name: "🔵", label: "蓝色" },
|
||
{ name: "🟣", label: "紫色" },
|
||
{ name: "⚪", label: "灰色" },
|
||
],
|
||
},
|
||
{
|
||
id: "priority",
|
||
label: "优先级",
|
||
icon: CheckCircle2,
|
||
markers: [
|
||
{ name: "1️⃣", label: "优先级 1" },
|
||
{ name: "2️⃣", label: "优先级 2" },
|
||
{ name: "3️⃣", label: "优先级 3" },
|
||
{ name: "4️⃣", label: "优先级 4" },
|
||
{ name: "5️⃣", label: "优先级 5" },
|
||
{ name: "6️⃣", label: "优先级 6" },
|
||
{ name: "7️⃣", label: "优先级 7" },
|
||
],
|
||
},
|
||
{
|
||
id: "task",
|
||
label: "任务",
|
||
icon: CheckCircle2,
|
||
markers: [
|
||
{ name: "▶️", label: "开始" },
|
||
{ name: "◔", label: "25%" },
|
||
{ name: "◑", label: "50%" },
|
||
{ name: "◕", label: "75%" },
|
||
{ name: "🚧", label: "处理中" },
|
||
{ name: "✅", label: "完成" },
|
||
],
|
||
},
|
||
{
|
||
id: "flag",
|
||
label: "旗帜",
|
||
icon: Flag,
|
||
markers: [
|
||
{ name: "🚩", label: "红旗" },
|
||
{ name: "🏁", label: "终点" },
|
||
{ name: "🏳️", label: "白旗" },
|
||
{ name: "🎯", label: "目标" },
|
||
{ name: "📍", label: "定位" },
|
||
{ name: "📌", label: "固定" },
|
||
],
|
||
},
|
||
{
|
||
id: "star",
|
||
label: "星星",
|
||
icon: Star,
|
||
markers: [
|
||
{ name: "⭐", label: "星标" },
|
||
{ name: "🌟", label: "重点" },
|
||
{ name: "✨", label: "亮点" },
|
||
{ name: "💫", label: "灵感" },
|
||
{ name: "🔥", label: "高热" },
|
||
{ name: "💎", label: "价值" },
|
||
],
|
||
},
|
||
{
|
||
id: "avatar",
|
||
label: "人像",
|
||
icon: UserRound,
|
||
markers: [
|
||
{ name: "👤", label: "成员" },
|
||
{ name: "🙋", label: "负责人" },
|
||
{ name: "👥", label: "多人" },
|
||
{ name: "🧑💻", label: "执行" },
|
||
{ name: "🧑🎨", label: "创意" },
|
||
{ name: "🧑💼", label: "业务" },
|
||
],
|
||
},
|
||
{
|
||
id: "symbol",
|
||
label: "符号",
|
||
icon: Tag,
|
||
markers: [
|
||
{ name: "❗", label: "注意" },
|
||
{ name: "❓", label: "疑问" },
|
||
{ name: "💡", label: "想法" },
|
||
{ name: "❤️", label: "喜欢" },
|
||
{ name: "⚡", label: "快速" },
|
||
{ name: "🔗", label: "关联" },
|
||
],
|
||
},
|
||
];
|
||
const FLOW_BOARD_MARKER_NAMES_BY_GROUP = new Map(
|
||
FLOW_BOARD_MARKER_GROUPS.map((group) => [
|
||
group.id,
|
||
new Set(group.markers.map((marker) => marker.name)),
|
||
])
|
||
);
|
||
|
||
function isFlowBoardRecord(value: unknown): value is Record<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 getFlowBoardElementEmojiNames(element: MindElement) {
|
||
const emojis = Array.isArray(element.data?.emojis)
|
||
? element.data.emojis
|
||
: [];
|
||
return emojis
|
||
.map((emoji) => String(emoji?.name || "").trim())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function getExistingFlowBoardMindElements(
|
||
board: PlaitBoard,
|
||
elements: MindElement[]
|
||
) {
|
||
return elements.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: [] };
|
||
}
|
||
|
||
const targetElements = selectedElements || getSelectedMindElements(board);
|
||
const names = new Set<string>();
|
||
targetElements.forEach((element) => {
|
||
getFlowBoardElementEmojiNames(element).forEach((name) => {
|
||
names.add(name);
|
||
});
|
||
});
|
||
|
||
return {
|
||
count: targetElements.length,
|
||
names: Array.from(names),
|
||
};
|
||
}
|
||
|
||
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 updateFlowBoardElementMarker(
|
||
board: PlaitBoard,
|
||
element: MindElement,
|
||
group: FlowBoardMarkerGroup,
|
||
marker: FlowBoardMarkerItem
|
||
) {
|
||
const groupMarkerNames =
|
||
FLOW_BOARD_MARKER_NAMES_BY_GROUP.get(group.id) || new Set<string>();
|
||
const currentEmojis = Array.isArray(element.data?.emojis)
|
||
? element.data.emojis
|
||
: [];
|
||
const isAlreadyApplied = currentEmojis.some(
|
||
(emoji) => emoji.name === marker.name
|
||
);
|
||
const nextEmojis = isAlreadyApplied
|
||
? currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name))
|
||
: [
|
||
...currentEmojis.filter((emoji) => !groupMarkerNames.has(emoji.name)),
|
||
{ name: marker.name },
|
||
];
|
||
const nextData = { ...element.data };
|
||
|
||
if (nextEmojis.length > 0) {
|
||
nextData.emojis = nextEmojis;
|
||
} else {
|
||
delete nextData.emojis;
|
||
}
|
||
|
||
Transforms.setNode(
|
||
board,
|
||
{
|
||
data: nextData,
|
||
},
|
||
PlaitBoard.findPath(board, element)
|
||
);
|
||
}
|
||
|
||
function getFlowBoardPastePoint(board: PlaitBoard): Point {
|
||
const movingPoint = PlaitBoard.getMovingPointInBoard(board);
|
||
if (movingPoint) {
|
||
return toViewBoxPoint(board, toHostPoint(board, movingPoint[0], movingPoint[1]));
|
||
}
|
||
|
||
const container = PlaitBoard.getBoardContainer(board);
|
||
const rect = container.getBoundingClientRect();
|
||
return toViewBoxPoint(
|
||
board,
|
||
toHostPoint(board, rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||
);
|
||
}
|
||
|
||
function isFlowBoardEditablePasteTarget(target: HTMLElement | null) {
|
||
if (!target) {
|
||
return false;
|
||
}
|
||
|
||
return Boolean(
|
||
target.isContentEditable ||
|
||
target.closest(
|
||
[
|
||
"input",
|
||
"textarea",
|
||
"select",
|
||
"[contenteditable='true']",
|
||
"[role='textbox']",
|
||
"[data-slate-editor='true']",
|
||
".slate-editable-container",
|
||
".plait-text-container",
|
||
".ttd-dialog-input",
|
||
].join(", ")
|
||
)
|
||
);
|
||
}
|
||
|
||
function getFlowBoardImageDimensions(file: File) {
|
||
return new Promise<{ width: number; height: number }>((resolve) => {
|
||
const url = URL.createObjectURL(file);
|
||
const image = new Image();
|
||
image.onload = () => {
|
||
const originalWidth = image.naturalWidth || 400;
|
||
const originalHeight = image.naturalHeight || 300;
|
||
const width = Math.min(originalWidth, 400);
|
||
const height = Math.round((width / originalWidth) * originalHeight);
|
||
URL.revokeObjectURL(url);
|
||
resolve({ width, height });
|
||
};
|
||
image.onerror = () => {
|
||
URL.revokeObjectURL(url);
|
||
resolve({ width: 400, height: 300 });
|
||
};
|
||
image.src = url;
|
||
});
|
||
}
|
||
|
||
async function uploadFlowBoardImageFile(file: File) {
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
formData.append("purpose", "canvas-upload");
|
||
|
||
const response = await fetchUploadWithRetry(formData);
|
||
if (!response.ok) {
|
||
throw new Error(`Flow board image upload failed: ${response.status}`);
|
||
}
|
||
|
||
const payload = (await response.json()) as { url?: unknown };
|
||
const url = typeof payload.url === "string" ? payload.url : "";
|
||
if (!url) {
|
||
throw new Error("Flow board image upload response missing url");
|
||
}
|
||
|
||
return url;
|
||
}
|
||
|
||
export function DrawnixWorkspace({
|
||
siteName,
|
||
siteLogo,
|
||
siteLogoDark,
|
||
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 [isOutlineOpen, setIsOutlineOpen] = useState(false);
|
||
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 [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: [],
|
||
});
|
||
const [selectionStyleState, setSelectionStyleState] =
|
||
useState<FlowBoardSelectionStyleState>(() =>
|
||
getFlowBoardSelectionStyleState(null, [])
|
||
);
|
||
|
||
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: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
return;
|
||
}
|
||
|
||
const annotation = getSelectedElements(board).find(isMindAnnotation);
|
||
if (!options.preserveFallback || annotation) {
|
||
setAnnotationId(annotation?.id || null);
|
||
}
|
||
if (annotation) {
|
||
setSelectedTopicId(null);
|
||
selectedMindElementsRef.current = [];
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
return;
|
||
}
|
||
const selectedElements = getFlowBoardSelectedMindElements(
|
||
board,
|
||
options.selection ?? selectedMindSelectionRef.current ?? board.selection
|
||
);
|
||
if (selectedElements.length > 0) {
|
||
setSelectedTopicId(selectedElements.length === 1 ? selectedElements[0].id : null);
|
||
selectedMindElementsRef.current = selectedElements;
|
||
setMarkerSelectionState(
|
||
getFlowBoardMarkerSelectionState(board, selectedElements)
|
||
);
|
||
if (!options.preserveFallback) {
|
||
setSelectionStyleState(
|
||
getFlowBoardSelectionStyleState(board, selectedElements)
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!options.preserveFallback) {
|
||
setSelectedTopicId(null);
|
||
selectedMindElementsRef.current = [];
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
return;
|
||
}
|
||
|
||
const fallbackElements = getExistingFlowBoardMindElements(
|
||
board,
|
||
selectedMindElementsRef.current
|
||
);
|
||
selectedMindElementsRef.current = fallbackElements;
|
||
setMarkerSelectionState(
|
||
getFlowBoardMarkerSelectionState(board, fallbackElements)
|
||
);
|
||
},
|
||
[]
|
||
);
|
||
|
||
const handleDrawnixInit = useCallback((board: PlaitBoard) => {
|
||
boardRef.current = board;
|
||
withMindAnnotations(board);
|
||
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(
|
||
(PlaitBoard.getPointer(board) as FlowBoardToolId) || "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);
|
||
setCurrentFileId(file.id);
|
||
setCurrentTitle(file.title || "未命名流程白板");
|
||
setLastSavedAt(
|
||
file.updatedAt
|
||
? new Date(file.updatedAt)
|
||
: nextSnapshot.updatedAt
|
||
? new Date(nextSnapshot.updatedAt)
|
||
: null
|
||
);
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
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: [] });
|
||
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, 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 handleJumpToTopic = useCallback((id: string) => {
|
||
const board = boardRef.current;
|
||
if (!board) return;
|
||
const entry = getTopicEntries(board.children).find(({ element }) => element.id === id);
|
||
if (!entry) return;
|
||
// Collapsed descendants have no rendered Plait path yet.
|
||
const path = entry.path;
|
||
for (let length = 1; length < path.length; length++) {
|
||
const ancestorPath = path.slice(0, length);
|
||
const ancestor = PlaitNode.get(board, ancestorPath);
|
||
if (ancestor.isCollapsed) Transforms.setNode(board, { isCollapsed: false }, ancestorPath);
|
||
}
|
||
requestAnimationFrame(() => {
|
||
if (boardRef.current !== board) return;
|
||
const target = getTopicEntries(board.children).find(({ element }) => element.id === id)?.element;
|
||
if (!target) return;
|
||
const node = MindElement.getNode(target as MindElement);
|
||
if (!node) return;
|
||
const rect = getRectangleByNode(node);
|
||
const center: Point = [rect.x + rect.width / 2, rect.y + rect.height / 2];
|
||
Transforms.setSelection(board, { anchor: center, focus: center });
|
||
BoardTransforms.moveToCenter(board, center);
|
||
setSelectedTopicId(id);
|
||
});
|
||
}, []);
|
||
|
||
const handleOutlineRename = useCallback((id: string, title: string) => {
|
||
const board = boardRef.current;
|
||
const entry = board && getTopicEntries(board.children).find(({ element }) => element.id === id);
|
||
if (!board || !entry || entry.title === (title.trim() || "未命名主题")) return;
|
||
PlaitHistoryBoard.withNewBatch(board, () => Transforms.setNode(board, { data: renameOutlineTopic(entry.element, title) }, entry.path));
|
||
}, []);
|
||
|
||
const handleOutlineToggle = useCallback((id: string) => {
|
||
const board = boardRef.current;
|
||
const entry = board && getTopicEntries(board.children).find(({ element }) => element.id === id);
|
||
if (board && entry) PlaitHistoryBoard.withNewBatch(board, () => Transforms.setNode(board, { isCollapsed: !entry.element.isCollapsed }, entry.path));
|
||
}, []);
|
||
|
||
const handleOutlineCommand = useCallback((id: string | null, command: OutlineCommand | OutlineDrop) => {
|
||
const board = boardRef.current;
|
||
if (!board) return null;
|
||
try {
|
||
const result = applyOutlineCommand(board.children, id, command);
|
||
PlaitHistoryBoard.withNewBatch(board, () => {
|
||
for (let index = board.children.length - 1; index >= 0; index--) {
|
||
const before = board.children[index];
|
||
if (!["mind", "mindmap"].includes(String(before.type))) continue;
|
||
const after = result.children.find(element => element.id === before.id);
|
||
if (!after) Transforms.removeNode(board, [index]);
|
||
else if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||
const { id: _id, type: _type, ...properties } = after;
|
||
Transforms.setNode(board, properties, [index]);
|
||
}
|
||
}
|
||
for (const element of result.children) {
|
||
if (!board.children.some(before => before.id === element.id)) Transforms.insertNode(board, element, [board.children.length]);
|
||
}
|
||
});
|
||
setSelectedTopicId(result.selectedId || null);
|
||
if (result.selectedId) requestAnimationFrame(() => handleJumpToTopic(result.selectedId));
|
||
return result.selectedId;
|
||
} catch (error) {
|
||
toast.info(error instanceof Error ? error.message : "大纲操作失败");
|
||
return null;
|
||
}
|
||
}, [handleJumpToTopic]);
|
||
|
||
const handleApplyFlowBoardMarker = useCallback(
|
||
(group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => {
|
||
const board = boardRef.current;
|
||
if (!board) {
|
||
return;
|
||
}
|
||
|
||
const selectedElements = getActiveMarkerElements();
|
||
if (selectedElements.length === 0) {
|
||
toast.info("请先选中一个思维导图节点");
|
||
return;
|
||
}
|
||
|
||
selectedElements.forEach((element) => {
|
||
updateFlowBoardElementMarker(board, element, group, marker);
|
||
});
|
||
refreshMarkerSelectionState({ preserveFallback: true });
|
||
},
|
||
[getActiveMarkerElements, refreshMarkerSelectionState]
|
||
);
|
||
|
||
const handleClearFlowBoardMarkers = useCallback(() => {
|
||
const board = boardRef.current;
|
||
if (!board) {
|
||
return;
|
||
}
|
||
|
||
const selectedElements = getActiveMarkerElements();
|
||
if (selectedElements.length === 0) {
|
||
toast.info("请先选中一个思维导图节点");
|
||
return;
|
||
}
|
||
|
||
const allMarkerNames = new Set(
|
||
FLOW_BOARD_MARKER_GROUPS.flatMap((group) =>
|
||
group.markers.map((marker) => marker.name)
|
||
)
|
||
);
|
||
|
||
selectedElements.forEach((element) => {
|
||
const currentEmojis = Array.isArray(element.data?.emojis)
|
||
? element.data.emojis
|
||
: [];
|
||
const nextEmojis = currentEmojis.filter(
|
||
(emoji) => !allMarkerNames.has(emoji.name)
|
||
);
|
||
if (nextEmojis.length === currentEmojis.length) {
|
||
return;
|
||
}
|
||
|
||
const nextData = { ...element.data };
|
||
if (nextEmojis.length > 0) {
|
||
nextData.emojis = nextEmojis;
|
||
} else {
|
||
delete nextData.emojis;
|
||
}
|
||
Transforms.setNode(
|
||
board,
|
||
{ data: nextData },
|
||
PlaitBoard.findPath(board, element)
|
||
);
|
||
});
|
||
refreshMarkerSelectionState({ preserveFallback: true });
|
||
toast.success("已清空所选节点标记");
|
||
}, [getActiveMarkerElements, refreshMarkerSelectionState]);
|
||
|
||
const handleInsertFlowBoardAbstract = useCallback(() => {
|
||
const board = boardRef.current;
|
||
if (!board) {
|
||
return;
|
||
}
|
||
|
||
const selectedElements = getActiveMarkerElements().filter(canSetAbstract);
|
||
if (selectedElements.length === 0) {
|
||
toast.info("请先选中要汇总的思维导图节点");
|
||
return;
|
||
}
|
||
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]);
|
||
if (tool !== "selection" && tool !== "hand") {
|
||
setCreationMode(board, BoardCreationMode.drawing);
|
||
}
|
||
setActiveTool(tool);
|
||
}, []);
|
||
|
||
const handleInsertChildTopic = useCallback(() => {
|
||
const board = boardRef.current;
|
||
const element = getActiveMarkerElements()[0];
|
||
if (!board || !element) {
|
||
toast.info("请先选中一个主题");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
MindTransforms.insertChildNode(board as PlaitMindBoard, element);
|
||
setActiveTool("selection");
|
||
} catch (error) {
|
||
console.error("[FlowBoard] Failed to insert child topic:", error);
|
||
toast.error("新增子主题失败");
|
||
}
|
||
}, [getActiveMarkerElements]);
|
||
|
||
const handleInsertSiblingTopic = useCallback(() => {
|
||
const board = boardRef.current;
|
||
const element = getActiveMarkerElements()[0];
|
||
if (!board || !element || PlaitMind.isMind(element)) {
|
||
toast.info("请先选中一个非中心主题");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
MindTransforms.insertSiblingNode(board as PlaitMindBoard, element);
|
||
setActiveTool("selection");
|
||
} catch (error) {
|
||
console.error("[FlowBoard] Failed to insert sibling topic:", error);
|
||
toast.error("新增同级主题失败");
|
||
}
|
||
}, [getActiveMarkerElements]);
|
||
|
||
const handleLayoutChange = useCallback(
|
||
(layout: string) => {
|
||
const board = boardRef.current;
|
||
const selectedElements = getActiveMarkerElements();
|
||
if (!board || selectedElements.length === 0) {
|
||
toast.info("请先选中一个思维导图主题");
|
||
return;
|
||
}
|
||
|
||
const roots = new Set<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 handleUndo = useCallback(() => {
|
||
boardRef.current?.undo();
|
||
}, []);
|
||
|
||
const handleRedo = useCallback(() => {
|
||
boardRef.current?.redo();
|
||
}, []);
|
||
|
||
const handleZoom = useCallback((delta: number) => {
|
||
const board = boardRef.current;
|
||
if (!board) {
|
||
return;
|
||
}
|
||
const nextZoom = Math.min(
|
||
4,
|
||
Math.max(0.1, (board.viewport?.zoom || 1) + delta)
|
||
);
|
||
BoardTransforms.updateZoom(board, nextZoom);
|
||
setZoomPercent(Math.round(nextZoom * 100));
|
||
}, []);
|
||
|
||
const handleFitViewport = useCallback(() => {
|
||
const board = boardRef.current;
|
||
if (!board) {
|
||
return;
|
||
}
|
||
BoardTransforms.fitViewport(board);
|
||
setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100));
|
||
}, []);
|
||
|
||
const handleTitleChange = useCallback((value: string) => {
|
||
currentTitleRef.current = value;
|
||
setCurrentTitle(value);
|
||
}, []);
|
||
|
||
const handleTitleCommit = useCallback(async () => {
|
||
const title = currentTitleRef.current.trim() || "未命名流程白板";
|
||
currentTitleRef.current = title;
|
||
setCurrentTitle(title);
|
||
setIsSavingNow(true);
|
||
try {
|
||
await persistSnapshot(snapshotRef.current, { title });
|
||
} catch (error) {
|
||
console.error("[FlowBoard] Failed to rename cloud file:", error);
|
||
toast.error("文件重命名失败");
|
||
} finally {
|
||
setIsSavingNow(false);
|
||
}
|
||
}, [persistSnapshot]);
|
||
|
||
const handleSwitchSheet = useCallback(
|
||
(sheetId: string) => {
|
||
const current = snapshotRef.current;
|
||
if (sheetId === current.activeSheetId) {
|
||
return;
|
||
}
|
||
const target = current.sheets?.find((sheet) => sheet.id === sheetId);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
updateSnapshot({
|
||
activeSheetId: target.id,
|
||
canvas: target.canvas,
|
||
children: target.children,
|
||
viewport: target.viewport,
|
||
theme: target.theme,
|
||
});
|
||
selectedMindElementsRef.current = [];
|
||
selectedMindSelectionRef.current = null;
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
},
|
||
[updateSnapshot]
|
||
);
|
||
|
||
const handleAddSheet = useCallback(() => {
|
||
const current = snapshotRef.current;
|
||
const sheets = current.sheets || [];
|
||
const nextSheet: FlowBoardSheetSnapshot = {
|
||
id: createFlowBoardSheetId(),
|
||
title: `画布 ${sheets.length + 1}`,
|
||
children: [],
|
||
theme: current.theme,
|
||
canvas: cloneFlowBoardSheet(current.canvas),
|
||
};
|
||
updateSnapshot({
|
||
activeSheetId: nextSheet.id,
|
||
canvas: nextSheet.canvas,
|
||
children: nextSheet.children,
|
||
viewport: undefined,
|
||
theme: nextSheet.theme,
|
||
sheets: [...sheets, nextSheet],
|
||
});
|
||
selectedMindElementsRef.current = [];
|
||
selectedMindSelectionRef.current = null;
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
}, [updateSnapshot]);
|
||
|
||
const handleRenameSheet = useCallback(
|
||
(sheetId: string, title: string) => {
|
||
const nextTitle = title.trim();
|
||
if (!nextTitle) {
|
||
return;
|
||
}
|
||
const current = snapshotRef.current;
|
||
const sheets = (current.sheets || []).map((sheet) =>
|
||
sheet.id === sheetId ? { ...sheet, title: nextTitle } : sheet
|
||
);
|
||
updateSnapshot({ sheets });
|
||
},
|
||
[updateSnapshot]
|
||
);
|
||
|
||
const handleDuplicateSheet = useCallback(
|
||
(sheetId: string) => {
|
||
const current = snapshotRef.current;
|
||
const sheets = current.sheets || [];
|
||
const sourceIndex = sheets.findIndex((sheet) => sheet.id === sheetId);
|
||
const source = sheets[sourceIndex];
|
||
if (!source) {
|
||
return;
|
||
}
|
||
const duplicatedSheet: FlowBoardSheetSnapshot = {
|
||
...cloneFlowBoardSheet(source),
|
||
id: createFlowBoardSheetId(),
|
||
title: `${source.title} 副本`,
|
||
};
|
||
const nextSheets = [...sheets];
|
||
nextSheets.splice(sourceIndex + 1, 0, duplicatedSheet);
|
||
updateSnapshot({
|
||
activeSheetId: duplicatedSheet.id,
|
||
canvas: duplicatedSheet.canvas,
|
||
children: duplicatedSheet.children,
|
||
sheets: nextSheets,
|
||
theme: duplicatedSheet.theme,
|
||
viewport: duplicatedSheet.viewport,
|
||
});
|
||
selectedMindElementsRef.current = [];
|
||
selectedMindSelectionRef.current = null;
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
},
|
||
[updateSnapshot]
|
||
);
|
||
|
||
const handleDeleteSheet = useCallback(
|
||
(sheetId: string) => {
|
||
const current = snapshotRef.current;
|
||
const sheets = current.sheets || [];
|
||
if (sheets.length <= 1) {
|
||
return;
|
||
}
|
||
const targetIndex = sheets.findIndex((sheet) => sheet.id === sheetId);
|
||
if (targetIndex < 0 || !window.confirm("确定要删除当前画布吗?")) {
|
||
return;
|
||
}
|
||
const nextSheets = sheets.filter((sheet) => sheet.id !== sheetId);
|
||
const activeSheet =
|
||
current.activeSheetId === sheetId
|
||
? nextSheets[Math.min(targetIndex, nextSheets.length - 1)]
|
||
: nextSheets.find((sheet) => sheet.id === current.activeSheetId) ||
|
||
nextSheets[0];
|
||
updateSnapshot({
|
||
activeSheetId: activeSheet.id,
|
||
canvas: activeSheet.canvas,
|
||
children: activeSheet.children,
|
||
sheets: nextSheets,
|
||
theme: activeSheet.theme,
|
||
viewport: activeSheet.viewport,
|
||
});
|
||
selectedMindElementsRef.current = [];
|
||
selectedMindSelectionRef.current = null;
|
||
setMarkerSelectionState({ count: 0, names: [] });
|
||
setSelectionStyleState(getFlowBoardSelectionStyleState(null, []));
|
||
},
|
||
[updateSnapshot]
|
||
);
|
||
|
||
const handleFlowBoardPastedImages = useCallback(async (
|
||
board: PlaitBoard,
|
||
files: File[]
|
||
) => {
|
||
const supportedFiles = files.filter((file) =>
|
||
FLOW_BOARD_IMAGE_UPLOAD_TYPES.has(file.type)
|
||
);
|
||
|
||
if (supportedFiles.length !== files.length) {
|
||
toast.error("流程白板粘贴图片仅支持 JPEG、PNG 或 WebP");
|
||
}
|
||
|
||
if (supportedFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const basePoint = getFlowBoardPastePoint(board);
|
||
let insertedCount = 0;
|
||
|
||
for (let index = 0; index < supportedFiles.length; index += 1) {
|
||
const file = supportedFiles[index];
|
||
if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) {
|
||
toast.error(IMAGE_UPLOAD_TOO_LARGE_MESSAGE);
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const [url, dimensions] = await Promise.all([
|
||
uploadFlowBoardImageFile(file),
|
||
getFlowBoardImageDimensions(file),
|
||
]);
|
||
const targetPoint: Point = [
|
||
basePoint[0] + index * 24,
|
||
basePoint[1] + index * 24,
|
||
];
|
||
DrawTransforms.insertImage(board, { url, ...dimensions }, targetPoint);
|
||
insertedCount += 1;
|
||
} catch (error) {
|
||
console.error("[FlowBoard] Failed to paste image:", error);
|
||
toast.error("图片上传失败");
|
||
}
|
||
}
|
||
|
||
if (insertedCount > 0) {
|
||
toast.success(
|
||
insertedCount === 1
|
||
? "已粘贴图片到白板"
|
||
: `已粘贴 ${insertedCount} 张图片到白板`
|
||
);
|
||
}
|
||
}, []);
|
||
|
||
const handleFlowBoardPasteCapture = useCallback((event: ClipboardEvent<HTMLElement>) => {
|
||
const board = boardRef.current;
|
||
if (!board || !event.clipboardData) {
|
||
return;
|
||
}
|
||
|
||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||
if (
|
||
target?.closest("[data-flow-board-shell-control='true']") ||
|
||
isFlowBoardEditablePasteTarget(target)
|
||
) {
|
||
return;
|
||
}
|
||
|
||
const imageFiles = Array.from(event.clipboardData.items || [])
|
||
.filter((item) => item.kind === "file" && item.type.startsWith("image/"))
|
||
.map((item) => item.getAsFile())
|
||
.filter((file): file is File => Boolean(file));
|
||
|
||
if (imageFiles.length === 0) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
void handleFlowBoardPastedImages(board, imageFiles);
|
||
}, [handleFlowBoardPastedImages]);
|
||
|
||
const handleFlowBoardFileInputChangeCapture = useCallback(
|
||
(event: ChangeEvent<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 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);
|
||
}
|
||
|
||
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: [] });
|
||
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 =
|
||
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="relative h-svh w-full overflow-hidden bg-[#f7f8fb]"
|
||
onChangeCapture={handleFlowBoardFileInputChangeCapture}
|
||
onKeyDownCapture={(event) => {
|
||
if (event.key === "Escape" && relationshipSourceId) {
|
||
event.preventDefault();
|
||
setRelationshipSourceId(null);
|
||
}
|
||
if (
|
||
(event.metaKey || event.ctrlKey) &&
|
||
event.key.toLowerCase() === "s"
|
||
) {
|
||
event.preventDefault();
|
||
void flushPendingSave(true);
|
||
}
|
||
}}
|
||
onPasteCapture={handleFlowBoardPasteCapture}
|
||
onPointerDownCapture={(event) => {
|
||
const target = event.target;
|
||
shellControlInteractionRef.current =
|
||
target instanceof Element &&
|
||
Boolean(target.closest('[data-flow-board-shell-control="true"]'));
|
||
window.setTimeout(() => {
|
||
shellControlInteractionRef.current = false;
|
||
}, 1000);
|
||
}}
|
||
>
|
||
<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) {
|
||
void handleFlowBoardPastedImages(board, files);
|
||
}
|
||
event.currentTarget.value = "";
|
||
}}
|
||
ref={imageFileInputRef}
|
||
type="file"
|
||
/>
|
||
|
||
<XmindEditorTopBar
|
||
activeTool={activeTool}
|
||
canEditMind={markerSelectionState.count > 0}
|
||
currentTitle={currentTitle}
|
||
formatPanelOpen={isFormatPanelVisible}
|
||
isSaving={isSavingNow || isImportingMindMap}
|
||
onBack={() => {
|
||
window.location.href = "/";
|
||
}}
|
||
onImport={() => mindMapFileInputRef.current?.click()}
|
||
onExport={() => void handleExportXmind()}
|
||
isExporting={isExporting}
|
||
onInsertChild={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "child"); } : handleInsertChildTopic}
|
||
onInsertSibling={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "sibling"); } : handleInsertSiblingTopic}
|
||
onRelationship={handleInsertRelationship}
|
||
onBoundary={handleInsertBoundary}
|
||
relationshipPending={Boolean(relationshipSourceId)}
|
||
onOpenFiles={() => {
|
||
void handleOpenFilePanel();
|
||
}}
|
||
onRedo={handleRedo}
|
||
onSave={() => {
|
||
void flushPendingSave(true);
|
||
}}
|
||
onSummary={handleInsertFlowBoardAbstract}
|
||
onTopicDetails={(section) => {
|
||
const selected = getActiveMarkerElements();
|
||
if (selected.length !== 1) { toast.info("请选择一个主题"); return; }
|
||
setSelectedTopicId(selected[0].id);
|
||
setTopicDetailSection(section);
|
||
setIsTopicPanelOpen(true);
|
||
setIsFormatPanelOpen(true);
|
||
setIsFilePanelOpen(false);
|
||
setIsMarkerPanelOpen(false);
|
||
}}
|
||
onTitleChange={handleTitleChange}
|
||
onTitleCommit={() => {
|
||
void handleTitleCommit();
|
||
}}
|
||
onToggleFormat={() => {
|
||
setIsTopicPanelOpen(false);
|
||
setIsFilePanelOpen(false);
|
||
setIsMarkerPanelOpen(false);
|
||
setIsFormatPanelOpen((open) => !open);
|
||
}}
|
||
onToggleMarkers={() => {
|
||
setIsFilePanelOpen(false);
|
||
setIsFormatPanelOpen(false);
|
||
setIsMarkerPanelOpen((open) => !open);
|
||
refreshMarkerSelectionState({ preserveFallback: true });
|
||
}}
|
||
onToolChange={handleToolChange}
|
||
onUndo={handleUndo}
|
||
siteLogo={displayLogo}
|
||
siteName={siteName}
|
||
/>
|
||
|
||
{isFormatPanelVisible && isTopicPanelOpen ? (
|
||
<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}
|
||
|
||
{isFilePanelOpen ? (
|
||
<aside
|
||
className="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}
|
||
|
||
{isMarkerPanelOpen ? (
|
||
<aside
|
||
className="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"
|
||
onMouseDownCapture={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDownCapture={(event) => {
|
||
event.stopPropagation();
|
||
}}
|
||
>
|
||
<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">
|
||
{markerSelectionState.count > 0
|
||
? `已选中 ${markerSelectionState.count} 个思维导图节点`
|
||
: "选中思维导图节点后添加标记"}
|
||
</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={() => {
|
||
setIsMarkerPanelOpen(false);
|
||
setIsFormatPanelOpen(true);
|
||
}}
|
||
type="button"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-3 gap-1 border-black/5 border-b p-2">
|
||
{["标记", "贴纸", "插画"].map((tab) => (
|
||
<button
|
||
className={`h-8 rounded-md font-semibold text-xs transition-colors ${
|
||
tab === "标记"
|
||
? "bg-[#111827] text-white"
|
||
: "text-[#7a8297] hover:bg-black/5"
|
||
}`}
|
||
disabled={tab !== "标记"}
|
||
key={tab}
|
||
type="button"
|
||
>
|
||
{tab}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div
|
||
className={`min-h-0 flex-1 space-y-5 overflow-y-auto p-4 ${
|
||
markerSelectionState.count === 0
|
||
? "pointer-events-none opacity-45"
|
||
: ""
|
||
}`}
|
||
>
|
||
{FLOW_BOARD_MARKER_GROUPS.map((group) => {
|
||
const GroupIcon = group.icon;
|
||
return (
|
||
<section key={group.id}>
|
||
<div className="mb-2 flex items-center gap-2 font-semibold text-[#4f566b] text-xs">
|
||
<ChevronDown className="h-3.5 w-3.5" />
|
||
<GroupIcon className="h-3.5 w-3.5" />
|
||
{group.label}
|
||
</div>
|
||
<div className="grid grid-cols-6 gap-2">
|
||
{group.markers.map((marker) => {
|
||
const isActive = markerSelectionState.names.includes(
|
||
marker.name
|
||
);
|
||
return (
|
||
<button
|
||
aria-label={`${group.label} ${marker.label}`}
|
||
className={`flex h-10 w-10 items-center justify-center rounded-md border bg-white text-[20px] shadow-sm transition hover:border-[#2563eb]/30 hover:shadow-md ${
|
||
isActive
|
||
? "border-[#2563eb]/40 ring-2 ring-[#2563eb]/20"
|
||
: "border-black/10"
|
||
}`}
|
||
key={`${group.id}-${marker.name}`}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
onClick={() =>
|
||
handleApplyFlowBoardMarker(group, marker)
|
||
}
|
||
title={marker.label}
|
||
type="button"
|
||
>
|
||
{marker.name}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="border-black/5 border-t p-3">
|
||
<button
|
||
className="flex h-10 w-full items-center justify-center rounded-md border border-black/10 bg-white font-semibold text-[#4f566b] text-sm transition-colors hover:bg-[#f7f8fb] disabled:cursor-not-allowed disabled:opacity-50"
|
||
disabled={markerSelectionState.count === 0}
|
||
onMouseDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
onPointerDown={(event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
}}
|
||
onClick={handleClearFlowBoardMarkers}
|
||
type="button"
|
||
>
|
||
清空所选节点标记
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
) : null}
|
||
|
||
<div
|
||
inert={isOutlineOpen}
|
||
aria-hidden={isOutlineOpen}
|
||
className={`flow-board-canvas-shell absolute bottom-9 left-0 top-[94px] transition-[right] ${
|
||
isFormatPanelVisible ? "right-0 sm:right-[304px]" : "right-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}
|
||
<Drawnix
|
||
plugins={FLOW_BOARD_PLUGINS}
|
||
afterInit={handleDrawnixInit}
|
||
className="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 ? <div className={`absolute top-[94px] bottom-9 left-0 z-20 ${isFormatPanelVisible ? "right-0 sm:right-[304px]" : "right-0"}`}>
|
||
<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}
|
||
<XmindSheetBar
|
||
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}
|
||
/>
|
||
</main>
|
||
);
|
||
}
|