ai-chatbot/components/flow-board/drawnix-workspace.tsx
Your Name e054dcabcc
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: update canvas and flow board features
2026-04-11 22:28:18 +08:00

1695 lines
52 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,
toHostPoint,
toViewBoxPoint,
} from "@plait/core";
import { DrawTransforms, MemorizeKey } from "@plait/draw";
import {
getSelectedMindElements,
type MindElement,
} 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 {
CheckCircle2,
ChevronDown,
ChevronLeft,
FileText,
FolderOpen,
Flag,
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 = 50 * 1024 * 1024;
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[] {
return Array.isArray(children)
? children.map((element) => normalizeFlowBoardElementText(element))
: [];
}
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 formatSavedAt(value: Date | null) {
if (!value) {
return "数据库自动保存";
}
return `已保存 ${value.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}`;
}
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 getFlowBoardMarkerSelectionState(
board: PlaitBoard | null
): FlowBoardMarkerSelectionState {
if (!board) {
return { count: 0, names: [] };
}
const selectedElements = getSelectedMindElements(board);
const names = new Set<string>();
selectedElements.forEach((element) => {
getFlowBoardElementEmojiNames(element).forEach((name) => {
names.add(name);
});
});
return {
count: selectedElements.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 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);
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 [lastSavedAt, 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 [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 refreshMarkerSelectionState = useCallback(() => {
setMarkerSelectionState(getFlowBoardMarkerSelectionState(boardRef.current));
}, []);
const handleDrawnixInit = useCallback((board: PlaitBoard) => {
boardRef.current = board;
seedFlowBoardDefaultTextColor();
setMarkerSelectionState(getFlowBoardMarkerSelectionState(board));
}, []);
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: [] });
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: [] });
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) => {
refreshMarkerSelectionState();
},
[refreshMarkerSelectionState]
);
const handleApplyFlowBoardMarker = useCallback(
(group: FlowBoardMarkerGroup, marker: FlowBoardMarkerItem) => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getSelectedMindElements(board);
if (selectedElements.length === 0) {
toast.info("请先选中一个思维导图节点");
return;
}
selectedElements.forEach((element) => {
updateFlowBoardElementMarker(board, element, group, marker);
});
refreshMarkerSelectionState();
},
[refreshMarkerSelectionState]
);
const handleClearFlowBoardMarkers = useCallback(() => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getSelectedMindElements(board);
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();
toast.success("已清空所选节点标记");
}, [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("图片大小不能超过 50MB");
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']")) {
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[]) => {
updateSnapshot({ children });
refreshMarkerSelectionState();
},
[refreshMarkerSelectionState, updateSnapshot]
);
const handleViewportChange = useCallback(
(viewport: Viewport) => {
updateSnapshot({ viewport });
},
[updateSnapshot]
);
const handleThemeChange = useCallback(
(themeColorMode: ThemeColorMode) => {
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: [] });
updateBrowserFileParam(null);
} catch (error) {
console.error("[FlowBoard] Failed to delete cloud file:", error);
toast.error("删除流程白板云文件失败");
}
},
[cloudFiles, hydrateCloudFile, updateBrowserFileParam]
);
const handleTitleCommit = useCallback(async () => {
const normalizedTitle = currentTitle.trim() || "未命名流程白板";
if (normalizedTitle !== currentTitle) {
setCurrentTitle(normalizedTitle);
currentTitleRef.current = normalizedTitle;
}
await flushPendingSave(false);
}, [currentTitle, flushPendingSave]);
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};
}
`}
</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"
/>
<div className="pointer-events-none absolute top-4 left-4 z-20 flex items-center">
<div
className="pointer-events-auto flex h-11 items-center gap-3 rounded-2xl border border-black/5 bg-white px-3 shadow-sm transition-all hover:shadow-md"
data-flow-board-shell-control="true"
>
<button
className="group flex items-center gap-1 rounded-xl px-2 py-1.5 font-medium text-[#4f566b] text-sm transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={() => {
window.location.href = "/";
}}
type="button"
>
<ChevronLeft className="group-hover:-translate-x-0.5 h-4 w-4 transition-transform" />
返回
</button>
<div className="h-5 w-px bg-black/10" />
<div className="flex items-center gap-2.5 px-1">
{displayLogo ? (
<img
alt={siteName}
className="h-6 w-6 rounded-md object-cover shadow-sm"
src={displayLogo}
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-[#111827] font-bold text-white text-xs shadow-sm">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<div className="flex items-center gap-2">
<input
aria-label="流程白板文件名"
className="h-7 max-w-[180px] rounded-lg border border-transparent bg-transparent px-2 font-bold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]"
onBlur={handleTitleCommit}
onChange={(event) => setCurrentTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
value={currentTitle}
/>
<span className="rounded-md bg-[#f7f8fb] px-1.5 py-0.5 font-medium text-[#7a8297] text-[11px]">
{siteName}
</span>
</div>
</div>
<div className="h-5 w-px bg-black/10" />
<div className="flex items-center gap-1.5">
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
disabled={isSavingNow}
onClick={() => flushPendingSave(true)}
type="button"
>
{isSavingNow ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
保存云端
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleOpenFilePanel}
type="button"
>
<FolderOpen className="h-3.5 w-3.5" />
云文件
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isSavingNow || isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
type="button"
>
{isImportingMindMap ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
导入
</button>
<button
className={`flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-xs transition-colors ${
isMarkerPanelOpen
? "bg-[#111827] text-white shadow-sm hover:bg-[#1f2937]"
: "text-[#4f566b] hover:bg-black/5 hover:text-[#111827]"
}`}
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
}}
type="button"
>
<Tag className="h-3.5 w-3.5" />
标记
</button>
<button
className="flex h-8 items-center gap-1.5 rounded-xl px-2.5 font-medium text-[#4f566b] text-xs transition-colors hover:bg-black/5 hover:text-[#111827]"
onClick={handleCreateCloudFile}
type="button"
>
<Plus className="h-3.5 w-3.5" />
新建
</button>
<div className="ml-1 flex items-center gap-1.5 px-2 text-[#7a8297]">
<Save className="h-3.5 w-3.5" />
<span className="font-medium text-xs">
{formatSavedAt(lastSavedAt)}
</span>
</div>
</div>
</div>
</div>
{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"
>
<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}`}
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}
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>
);
}