1695 lines
52 KiB
TypeScript
1695 lines
52 KiB
TypeScript
"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>
|
||
);
|
||
}
|