ai-chatbot/components/flow-board/drawnix-workspace.tsx

1858 lines
55 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Drawnix, type DrawnixProps } from "@drawnix/drawnix";
import { memorizeLatest } from "@plait/common";
import {
DEFAULT_COLOR,
PlaitBoard,
type PlaitElement,
type PlaitTheme,
type Point,
type Selection,
type ThemeColorMode,
Transforms,
type Viewport,
getHitElementsBySelection,
toHostPoint,
toViewBoxPoint,
} from "@plait/core";
import { DrawTransforms, MemorizeKey } from "@plait/draw";
import {
canSetAbstract,
getSelectedMindElements,
MindElement,
MindTransforms,
PlaitMind,
} from "@plait/mind";
import type { BoardChangeData } from "@plait-board/react-board";
import {
FLOW_BOARD_MIND_MAP_IMPORT_ACCEPT,
getMindMapImportTitle,
isSupportedMindMapImportFile,
parseMindMapFileToDrawnixMind,
} from "@/lib/flow-board/mind-map-import";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
IMAGE_UPLOAD_MAX_BYTES,
IMAGE_UPLOAD_TOO_LARGE_MESSAGE,
} from "@/lib/image-upload-limits";
import {
FloatingLogoPill,
FloatingSidebarAction,
FloatingSidebarRail,
FloatingSidebarShell,
} from "@/components/home/floating-sidebar-shell";
import {
CheckCircle2,
ChevronDown,
FileText,
FolderOpen,
Flag,
GitMerge,
Loader2,
type LucideIcon,
Plus,
Save,
Star,
Tag,
Trash2,
Upload,
UserRound,
X,
} from "lucide-react";
import { useTheme } from "next-themes";
import {
type ChangeEvent,
type ClipboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
type FlowBoardWorkspaceProps = {
siteName: string;
siteLogo: string | null;
siteLogoDark: string | null;
};
type FlowBoardSnapshot = {
children: PlaitElement[];
viewport?: Viewport;
theme?: PlaitTheme;
updatedAt?: string;
};
type FlowBoardCloudFile = {
id: string;
title: string;
createdAt?: string | null;
updatedAt?: string | null;
};
type FlowBoardCloudFilePayload = FlowBoardCloudFile & {
snapshot?: unknown;
};
type FlowBoardMarkerGroupId =
| "tag"
| "priority"
| "task"
| "flag"
| "star"
| "avatar"
| "symbol";
type FlowBoardMarkerItem = {
name: string;
label: string;
};
type FlowBoardMarkerGroup = {
id: FlowBoardMarkerGroupId;
label: string;
icon: LucideIcon;
markers: FlowBoardMarkerItem[];
};
type FlowBoardMarkerSelectionState = {
count: number;
names: string[];
};
const FLOW_BOARD_FILES_API = "/api/flow-board/files";
const FLOW_BOARD_DEFAULT_TEXT_COLOR = DEFAULT_COLOR;
const FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES = IMAGE_UPLOAD_MAX_BYTES;
const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
]);
const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [
{
id: "tag",
label: "标签",
icon: Tag,
markers: [
{ name: "🔴", label: "红色" },
{ name: "🟠", label: "橙色" },
{ name: "🟡", label: "黄色" },
{ name: "🟢", label: "绿色" },
{ name: "🔵", label: "蓝色" },
{ name: "🟣", label: "紫色" },
{ name: "⚪", label: "灰色" },
],
},
{
id: "priority",
label: "优先级",
icon: CheckCircle2,
markers: [
{ name: "1️⃣", label: "优先级 1" },
{ name: "2️⃣", label: "优先级 2" },
{ name: "3️⃣", label: "优先级 3" },
{ name: "4️⃣", label: "优先级 4" },
{ name: "5️⃣", label: "优先级 5" },
{ name: "6️⃣", label: "优先级 6" },
{ name: "7️⃣", label: "优先级 7" },
],
},
{
id: "task",
label: "任务",
icon: CheckCircle2,
markers: [
{ name: "▶️", label: "开始" },
{ name: "◔", label: "25%" },
{ name: "◑", label: "50%" },
{ name: "◕", label: "75%" },
{ name: "🚧", label: "处理中" },
{ name: "✅", label: "完成" },
],
},
{
id: "flag",
label: "旗帜",
icon: Flag,
markers: [
{ name: "🚩", label: "红旗" },
{ name: "🏁", label: "终点" },
{ name: "🏳️", label: "白旗" },
{ name: "🎯", label: "目标" },
{ name: "📍", label: "定位" },
{ name: "📌", label: "固定" },
],
},
{
id: "star",
label: "星星",
icon: Star,
markers: [
{ name: "⭐", label: "星标" },
{ name: "🌟", label: "重点" },
{ name: "✨", label: "亮点" },
{ name: "💫", label: "灵感" },
{ name: "🔥", label: "高热" },
{ name: "💎", label: "价值" },
],
},
{
id: "avatar",
label: "人像",
icon: UserRound,
markers: [
{ name: "👤", label: "成员" },
{ name: "🙋", label: "负责人" },
{ name: "👥", label: "多人" },
{ name: "🧑‍💻", label: "执行" },
{ name: "🧑‍🎨", label: "创意" },
{ name: "🧑‍💼", label: "业务" },
],
},
{
id: "symbol",
label: "符号",
icon: Tag,
markers: [
{ name: "❗", label: "注意" },
{ name: "❓", label: "疑问" },
{ name: "💡", label: "想法" },
{ name: "❤️", label: "喜欢" },
{ name: "⚡", label: "快速" },
{ name: "🔗", label: "关联" },
],
},
];
const FLOW_BOARD_MARKER_NAMES_BY_GROUP = new Map(
FLOW_BOARD_MARKER_GROUPS.map((group) => [
group.id,
new Set(group.markers.map((marker) => marker.name)),
])
);
function isFlowBoardRecord(value: unknown): value is Record<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 normalizeSnapshot(value: unknown): FlowBoardSnapshot {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<FlowBoardSnapshot>)
: {};
return {
children: normalizeFlowBoardChildren(raw.children),
viewport: raw.viewport,
theme: raw.theme,
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined,
};
}
function formatFileUpdatedAt(value?: string | null) {
if (!value) return "刚刚";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "刚刚";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function normalizeCloudFile(value: unknown): FlowBoardCloudFilePayload | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const raw = value as Partial<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.filter((element) => {
try {
PlaitBoard.findPath(board, element);
return true;
} catch (_error) {
return false;
}
});
}
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 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,
}: FlowBoardWorkspaceProps) {
const { resolvedTheme } = useTheme();
const [snapshot, setSnapshot] = useState<FlowBoardSnapshot>({
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 [, setLastSavedAt] = useState<Date | null>(null);
const snapshotRef = useRef<FlowBoardSnapshot>({ children: [] });
const currentFileIdRef = useRef<string | null>(null);
const currentTitleRef = useRef("流程白板");
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boardRef = useRef<PlaitBoard | null>(null);
const mindMapFileInputRef = useRef<HTMLInputElement | null>(null);
const selectedMindElementsRef = useRef<MindElement[]>([]);
const selectedMindSelectionRef = useRef<Selection | null>(null);
const [isMarkerPanelOpen, setIsMarkerPanelOpen] = useState(false);
const [isImportingMindMap, setIsImportingMindMap] = useState(false);
const [markerSelectionState, setMarkerSelectionState] =
useState<FlowBoardMarkerSelectionState>({
count: 0,
names: [],
});
const displayLogo = useMemo(() => {
if (resolvedTheme === "dark" && siteLogoDark) {
return siteLogoDark;
}
return siteLogo || siteLogoDark;
}, [resolvedTheme, siteLogo, siteLogoDark]);
const getActiveMarkerElements = useCallback(() => {
const board = boardRef.current;
if (!board) {
selectedMindElementsRef.current = [];
return [] as MindElement[];
}
const selectedElements = getFlowBoardSelectedMindElements(
board,
selectedMindSelectionRef.current || board.selection
);
if (selectedElements.length > 0) {
selectedMindElementsRef.current = selectedElements;
return selectedElements;
}
const fallbackElements = getExistingFlowBoardMindElements(
board,
selectedMindElementsRef.current
);
selectedMindElementsRef.current = fallbackElements;
return fallbackElements;
}, []);
const refreshMarkerSelectionState = useCallback(
(
options: {
preserveFallback?: boolean;
selection?: Selection | null;
} = {}
) => {
const board = boardRef.current;
if (!board) {
selectedMindElementsRef.current = [];
setMarkerSelectionState({ count: 0, names: [] });
return;
}
const selectedElements = getFlowBoardSelectedMindElements(
board,
options.selection ?? selectedMindSelectionRef.current ?? board.selection
);
if (selectedElements.length > 0) {
selectedMindElementsRef.current = selectedElements;
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, selectedElements)
);
return;
}
if (!options.preserveFallback) {
selectedMindElementsRef.current = [];
setMarkerSelectionState({ count: 0, names: [] });
return;
}
const fallbackElements = getExistingFlowBoardMindElements(
board,
selectedMindElementsRef.current
);
selectedMindElementsRef.current = fallbackElements;
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, fallbackElements)
);
},
[]
);
const handleDrawnixInit = useCallback((board: PlaitBoard) => {
boardRef.current = board;
seedFlowBoardDefaultTextColor();
selectedMindSelectionRef.current = board.selection;
selectedMindElementsRef.current = getFlowBoardSelectedMindElements(
board,
board.selection
);
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, selectedMindElementsRef.current)
);
}, []);
useEffect(() => {
seedFlowBoardDefaultTextColor();
}, []);
useEffect(() => {
currentFileIdRef.current = currentFileId;
}, [currentFileId]);
useEffect(() => {
currentTitleRef.current = currentTitle;
}, [currentTitle]);
const updateBrowserFileParam = useCallback((fileId: string | null) => {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
if (fileId) {
url.searchParams.set("file", fileId);
} else {
url.searchParams.delete("file");
}
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}, []);
const loadCloudFiles = useCallback(async () => {
setIsFileListLoading(true);
try {
const response = await fetch(FLOW_BOARD_FILES_API, {
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to load flow board files: ${response.status}`);
}
const result = (await response.json()) as { data?: unknown[] };
const files = sortCloudFiles(
(Array.isArray(result.data) ? result.data : [])
.map(normalizeCloudFile)
.filter((file): file is FlowBoardCloudFile => Boolean(file))
);
setCloudFiles(files);
return files;
} finally {
setIsFileListLoading(false);
}
}, []);
const hydrateCloudFile = useCallback(
(file: FlowBoardCloudFilePayload) => {
const nextSnapshot = normalizeSnapshot(file.snapshot);
snapshotRef.current = nextSnapshot;
setSnapshot(nextSnapshot);
setCurrentFileId(file.id);
setCurrentTitle(file.title || "未命名流程白板");
setLastSavedAt(
file.updatedAt
? new Date(file.updatedAt)
: nextSnapshot.updatedAt
? new Date(nextSnapshot.updatedAt)
: null
);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(file.id);
},
[updateBrowserFileParam]
);
const persistSnapshot = useCallback(
async (
nextSnapshot: FlowBoardSnapshot,
options: { fileId?: string | null; title?: string; toast?: boolean } = {}
) => {
const targetFileId =
options.fileId !== undefined ? options.fileId : currentFileIdRef.current;
const nextTitle =
String((options.title ?? currentTitleRef.current) || "").trim() ||
"未命名流程白板";
const response = await fetch(
targetFileId ? getFlowBoardFileUrl(targetFileId) : FLOW_BOARD_FILES_API,
{
body: JSON.stringify({
snapshot: nextSnapshot,
title: nextTitle,
}),
credentials: "include",
headers: {
"Content-Type": "application/json",
},
method: targetFileId ? "PUT" : "POST",
}
);
if (!response.ok) {
throw new Error(`Failed to save flow board file: ${response.status}`);
}
const result = (await response.json()) as { data?: unknown };
const savedFile = normalizeCloudFile(result.data);
if (!savedFile) {
throw new Error("Flow board save response is invalid");
}
currentFileIdRef.current = savedFile.id;
currentTitleRef.current = savedFile.title;
setCurrentFileId(savedFile.id);
setCurrentTitle(savedFile.title);
setLastSavedAt(
savedFile.updatedAt
? new Date(savedFile.updatedAt)
: new Date(nextSnapshot.updatedAt || Date.now())
);
updateBrowserFileParam(savedFile.id);
setCloudFiles((current) =>
sortCloudFiles([
{
id: savedFile.id,
title: savedFile.title,
createdAt: savedFile.createdAt,
updatedAt: savedFile.updatedAt,
},
...current.filter((file) => file.id !== savedFile.id),
])
);
if (options.toast) {
toast.success("流程白板已保存到云端");
}
return savedFile;
},
[updateBrowserFileParam]
);
useEffect(() => {
let cancelled = false;
const loadDraft = async () => {
try {
const files = await loadCloudFiles();
const requestedFileId =
typeof window === "undefined"
? ""
: new URLSearchParams(window.location.search).get("file") || "";
const initialFile =
files.find((file) => file.id === requestedFileId) || files[0] || null;
if (!initialFile) {
if (!cancelled) {
const emptySnapshot = { children: [] };
snapshotRef.current = emptySnapshot;
setSnapshot(emptySnapshot);
setCurrentFileId(null);
setCurrentTitle("流程白板");
setLastSavedAt(null);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(null);
}
return;
}
const response = await fetch(getFlowBoardFileUrl(initialFile.id), {
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error(
`Failed to load flow board file: ${response.status}`
);
}
const result = (await response.json()) as {
data?: unknown;
};
const file = normalizeCloudFile(result.data);
if (!file) {
throw new Error("Flow board file response is invalid");
}
if (!cancelled) {
hydrateCloudFile(file);
}
} catch (error) {
console.error("[FlowBoard] Failed to load cloud file:", error);
toast.error("流程白板云文件加载失败");
} finally {
if (!cancelled) {
setIsReady(true);
}
}
};
loadDraft().catch(console.error);
return () => {
cancelled = true;
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
};
}, []);
const scheduleSave = useCallback(
(nextSnapshot: FlowBoardSnapshot) => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
}
saveTimerRef.current = setTimeout(async () => {
setIsSavingNow(true);
try {
await persistSnapshot(nextSnapshot);
} catch (error) {
console.error("[FlowBoard] Failed to save cloud file:", error);
toast.error("流程白板保存到云端失败");
} finally {
setIsSavingNow(false);
}
}, 350);
},
[persistSnapshot]
);
const updateSnapshot = useCallback(
(partialSnapshot: Partial<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();
return;
}
updateSnapshot({
children: value.children,
theme: value.theme,
viewport: value.viewport,
});
refreshMarkerSelectionState();
},
[refreshMarkerSelectionState, updateSnapshot]
);
const handleDrawnixSelectionChange = useCallback(
(selection: Selection | null) => {
selectedMindSelectionRef.current = selection;
refreshMarkerSelectionState({ selection });
},
[refreshMarkerSelectionState]
);
const handleApplyFlowBoardMarker = useCallback(
(group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getActiveMarkerElements();
if (selectedElements.length === 0) {
toast.info("请先选中一个思维导图节点");
return;
}
selectedElements.forEach((element) => {
updateFlowBoardElementMarker(board, element, group, marker);
});
refreshMarkerSelectionState({ preserveFallback: true });
},
[getActiveMarkerElements, refreshMarkerSelectionState]
);
const handleClearFlowBoardMarkers = useCallback(() => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getActiveMarkerElements();
if (selectedElements.length === 0) {
toast.info("请先选中一个思维导图节点");
return;
}
const allMarkerNames = new Set(
FLOW_BOARD_MARKER_GROUPS.flatMap((group) =>
group.markers.map((marker) => marker.name)
)
);
selectedElements.forEach((element) => {
const currentEmojis = Array.isArray(element.data?.emojis)
? element.data.emojis
: [];
const nextEmojis = currentEmojis.filter(
(emoji) => !allMarkerNames.has(emoji.name)
);
if (nextEmojis.length === currentEmojis.length) {
return;
}
const nextData = { ...element.data };
if (nextEmojis.length > 0) {
nextData.emojis = nextEmojis;
} else {
delete nextData.emojis;
}
Transforms.setNode(
board,
{ data: nextData },
PlaitBoard.findPath(board, element)
);
});
refreshMarkerSelectionState({ preserveFallback: true });
toast.success("已清空所选节点标记");
}, [getActiveMarkerElements, refreshMarkerSelectionState]);
const handleInsertFlowBoardAbstract = useCallback(() => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getActiveMarkerElements().filter(canSetAbstract);
if (selectedElements.length === 0) {
toast.info("请先选中要汇总的思维导图节点");
return;
}
try {
MindTransforms.insertAbstract(board, selectedElements);
refreshMarkerSelectionState({ preserveFallback: true });
toast.success("已添加合并汇总");
} catch (error) {
console.error("[FlowBoard] Failed to insert abstract:", error);
toast.error("合并汇总失败,请选择同一组思维导图分支节点");
}
}, [getActiveMarkerElements, refreshMarkerSelectionState]);
const handleFlowBoardPastedImages = useCallback(async (
board: PlaitBoard,
files: File[]
) => {
const supportedFiles = files.filter((file) =>
FLOW_BOARD_IMAGE_UPLOAD_TYPES.has(file.type)
);
if (supportedFiles.length !== files.length) {
toast.error("流程白板粘贴图片仅支持 JPEG、PNG 或 WebP");
}
if (supportedFiles.length === 0) {
return;
}
const basePoint = getFlowBoardPastePoint(board);
let insertedCount = 0;
for (let index = 0; index < supportedFiles.length; index += 1) {
const file = supportedFiles[index];
if (file.size > FLOW_BOARD_IMAGE_UPLOAD_MAX_BYTES) {
toast.error(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) => {
if (snapshotRef.current.viewport === viewport) {
return;
}
updateSnapshot({ viewport });
},
[updateSnapshot]
);
const handleThemeChange = useCallback(
(themeColorMode: ThemeColorMode) => {
if (snapshotRef.current.theme?.themeColorMode === themeColorMode) {
return;
}
updateSnapshot({ theme: { themeColorMode } });
},
[updateSnapshot]
);
const flushPendingSave = useCallback(
async (showToast = false) => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
setIsSavingNow(true);
try {
await persistSnapshot(snapshotRef.current, { toast: showToast });
} catch (error) {
console.error("[FlowBoard] Manual cloud save failed:", error);
toast.error("流程白板保存到云端失败");
} finally {
setIsSavingNow(false);
}
},
[persistSnapshot]
);
const handleImportMindMapFile = useCallback(
async (file: File) => {
if (!isSupportedMindMapImportFile(file)) {
toast.error("请选择 MD、OPML 或 FreeMind .mm 文件");
return;
}
setIsImportingMindMap(true);
setIsSavingNow(true);
try {
const hasCurrentContent =
Boolean(currentFileIdRef.current) ||
snapshotRef.current.children.length > 0;
if (hasCurrentContent) {
await flushPendingSave(false);
}
const title = getMindMapImportTitle(file.name);
const mindElement = await parseMindMapFileToDrawnixMind(file, title);
const nextSnapshot: FlowBoardSnapshot = normalizeSnapshot({
children: [
{
...mindElement,
points: mindElement.points?.length
? mindElement.points
: ([[0, 0]] as Point[]),
},
],
updatedAt: new Date().toISOString(),
});
const response = await fetch(FLOW_BOARD_FILES_API, {
body: JSON.stringify({
title,
snapshot: nextSnapshot,
}),
credentials: "include",
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to import mind map flow board: ${response.status}`);
}
const result = (await response.json()) as { data?: unknown };
const importedFile = normalizeCloudFile(result.data);
if (!importedFile) {
throw new Error("Flow board mind map import response is invalid");
}
hydrateCloudFile(importedFile);
setCloudFiles((current) =>
sortCloudFiles([
{
id: importedFile.id,
title: importedFile.title,
createdAt: importedFile.createdAt,
updatedAt: importedFile.updatedAt,
},
...current.filter((item) => item.id !== importedFile.id),
])
);
setIsFilePanelOpen(false);
setIsMarkerPanelOpen(false);
toast.success("已生成思维导图");
} catch (error) {
console.error("[FlowBoard] Failed to import mind map:", error);
toast.error("思维导图导入失败");
} finally {
setIsImportingMindMap(false);
setIsSavingNow(false);
if (mindMapFileInputRef.current) {
mindMapFileInputRef.current.value = "";
}
}
},
[flushPendingSave, hydrateCloudFile]
);
const handleOpenFilePanel = useCallback(async () => {
setIsMarkerPanelOpen(false);
setIsFilePanelOpen(true);
try {
await loadCloudFiles();
} catch (error) {
console.error("[FlowBoard] Failed to refresh file list:", error);
toast.error("云文件列表加载失败");
}
}, [loadCloudFiles]);
const handleOpenCloudFile = useCallback(
async (fileId: string) => {
if (fileId === currentFileIdRef.current) {
setIsFilePanelOpen(false);
return;
}
const hasCurrentContent =
Boolean(currentFileIdRef.current) ||
snapshotRef.current.children.length > 0;
if (hasCurrentContent) {
await flushPendingSave(false);
}
try {
const response = await fetch(getFlowBoardFileUrl(fileId), {
cache: "no-store",
credentials: "include",
});
if (!response.ok) {
throw new Error(`Failed to open flow board file: ${response.status}`);
}
const result = (await response.json()) as { data?: unknown };
const file = normalizeCloudFile(result.data);
if (!file) {
throw new Error("Flow board file response is invalid");
}
hydrateCloudFile(file);
setIsFilePanelOpen(false);
} catch (error) {
console.error("[FlowBoard] Failed to open cloud file:", error);
toast.error("流程白板云文件打开失败");
}
},
[flushPendingSave, hydrateCloudFile]
);
const handleCreateCloudFile = useCallback(async () => {
const hasCurrentContent =
Boolean(currentFileIdRef.current) ||
snapshotRef.current.children.length > 0;
if (hasCurrentContent) {
await flushPendingSave(false);
}
setIsSavingNow(true);
try {
const now = new Date();
const nextSnapshot: FlowBoardSnapshot = {
children: [],
updatedAt: now.toISOString(),
};
const response = await fetch(FLOW_BOARD_FILES_API, {
body: JSON.stringify({
title: `流程白板 ${now.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}`,
snapshot: nextSnapshot,
}),
credentials: "include",
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
if (!response.ok) {
throw new Error(`Failed to create flow board file: ${response.status}`);
}
const result = (await response.json()) as { data?: unknown };
const file = normalizeCloudFile(result.data);
if (!file) {
throw new Error("Flow board create response is invalid");
}
hydrateCloudFile(file);
setCloudFiles((current) =>
sortCloudFiles([
{
id: file.id,
title: file.title,
createdAt: file.createdAt,
updatedAt: file.updatedAt,
},
...current.filter((item) => item.id !== file.id),
])
);
setIsFilePanelOpen(false);
toast.success("已新建云端流程白板");
} catch (error) {
console.error("[FlowBoard] Failed to create cloud file:", error);
toast.error("新建流程白板云文件失败");
} finally {
setIsSavingNow(false);
}
}, [flushPendingSave, hydrateCloudFile]);
const handleDeleteCloudFile = useCallback(
async (fileId: string) => {
if (!window.confirm("确定要删除这个流程白板云文件吗?")) {
return;
}
try {
const response = await fetch(getFlowBoardFileUrl(fileId), {
credentials: "include",
method: "DELETE",
});
if (!response.ok) {
throw new Error(
`Failed to delete flow board file: ${response.status}`
);
}
const remainingFiles = cloudFiles.filter((file) => file.id !== fileId);
setCloudFiles(remainingFiles);
toast.success("已删除流程白板云文件");
if (fileId !== currentFileIdRef.current) {
return;
}
const nextFile = remainingFiles[0] || null;
if (nextFile) {
const nextResponse = await fetch(getFlowBoardFileUrl(nextFile.id), {
cache: "no-store",
credentials: "include",
});
if (!nextResponse.ok) {
throw new Error(
`Failed to open next flow board file: ${nextResponse.status}`
);
}
const result = (await nextResponse.json()) as { data?: unknown };
const file = normalizeCloudFile(result.data);
if (file) {
hydrateCloudFile(file);
}
return;
}
const emptySnapshot = { children: [] };
snapshotRef.current = emptySnapshot;
setSnapshot(emptySnapshot);
setCurrentFileId(null);
setCurrentTitle("流程白板");
setLastSavedAt(null);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(null);
} catch (error) {
console.error("[FlowBoard] Failed to delete cloud file:", error);
toast.error("删除流程白板云文件失败");
}
},
[cloudFiles, hydrateCloudFile, updateBrowserFileParam]
);
if (!isReady) {
return (
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
<div className="rounded-2xl 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}
onPasteCapture={handleFlowBoardPasteCapture}
>
<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};
}
.drawnix:not(.drawnix--mobile) .app-toolbar {
top: auto;
bottom: 36px;
left: 36px;
}
`}
</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"
/>
<FloatingSidebarShell
className="absolute top-[18px] left-7 z-30"
flowBoardControl
>
<FloatingLogoPill
onClick={() => {
window.location.href = "/";
}}
siteLogo={displayLogo}
siteName={siteName}
tooltip={null}
variant="plain"
/>
</FloatingSidebarShell>
<FloatingSidebarShell
className="absolute left-6 top-[92px] z-20"
flowBoardControl
>
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]">
<FloatingSidebarAction
disabled={isSavingNow}
icon={Save}
label="保存云端"
loading={isSavingNow}
onClick={() => flushPendingSave(true)}
/>
<FloatingSidebarAction
icon={FolderOpen}
label="云文件"
onClick={handleOpenFilePanel}
/>
<FloatingSidebarAction
disabled={isSavingNow || isImportingMindMap}
icon={Upload}
label="导入"
loading={isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
/>
<FloatingSidebarAction
active={isMarkerPanelOpen}
icon={Tag}
label="标记"
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState({ preserveFallback: true });
}}
/>
<FloatingSidebarAction
icon={GitMerge}
label="合并汇总"
onClick={handleInsertFlowBoardAbstract}
/>
<FloatingSidebarAction
icon={Plus}
label="新建"
onClick={handleCreateCloudFile}
/>
</FloatingSidebarRail>
</FloatingSidebarShell>
{isFilePanelOpen ? (
<aside
className="absolute top-20 right-4 bottom-5 z-30 flex w-[360px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-[28px] border border-black/10 bg-white shadow-[0_24px_80px_rgba(17,24,39,0.16)]"
data-flow-board-shell-control="true"
>
<div className="flex items-center justify-between border-black/5 border-b px-5 py-4">
<div>
<div className="font-bold text-[#111827] text-sm">
云文件列表
</div>
<div className="mt-1 text-[#7a8297] text-xs">
流程白板会自动保存到当前登录账号
</div>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-full text-[#7a8297] transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={() => setIsFilePanelOpen(false)}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="border-black/5 border-b p-4">
<button
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl bg-[#111827] font-semibold text-sm text-white shadow-sm 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-[22px] 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-2xl 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-2xl 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 rounded-full 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 top-20 right-4 bottom-5 z-30 flex w-[330px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-[28px] border border-black/10 bg-white shadow-[0_24px_80px_rgba(17,24,39,0.16)]"
data-flow-board-shell-control="true"
onMouseDownCapture={(event) => {
event.stopPropagation();
}}
onPointerDownCapture={(event) => {
event.stopPropagation();
}}
>
<div className="flex items-center justify-between border-black/5 border-b px-5 py-4">
<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-9 w-9 items-center justify-center rounded-full text-[#7a8297] transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={() => setIsMarkerPanelOpen(false)}
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-xl 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-2xl border bg-white text-[20px] shadow-sm transition hover:-translate-y-0.5 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-2xl 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}
<Drawnix
afterInit={handleDrawnixInit}
className="h-full w-full"
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}
/>
</main>
);
}