0.5 缩减重构
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type JSX,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
type JSX,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
||||
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind } from "@/types/media";
|
||||
@@ -25,59 +25,59 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
file: "文件",
|
||||
};
|
||||
|
||||
const deriveFileName = (value?: string) => {
|
||||
if (!value) {
|
||||
return "未命名资源";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const last = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
const segments = value.split("?")[0]?.split("/") ?? [];
|
||||
const last = segments.pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
}
|
||||
return "未命名资源";
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number | null) => {
|
||||
if (!size || size <= 0) {
|
||||
return "未知大小";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let idx = 0;
|
||||
let current = size;
|
||||
while (current >= 1024 && idx < units.length - 1) {
|
||||
current /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
|
||||
type MediaAlign = "left" | "center" | "right";
|
||||
|
||||
type MediaBlockRenderProps = {
|
||||
block: Block<CustomBlockSchema> & { props: any };
|
||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||
};
|
||||
|
||||
const TYPE_LABEL_MAP: Record<MediaKind, string> = {
|
||||
image: "图片",
|
||||
video: "视频",
|
||||
audio: "音频",
|
||||
file: "文件",
|
||||
};
|
||||
|
||||
const deriveFileName = (value?: string) => {
|
||||
if (!value) {
|
||||
return "未命名资源";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const last = url.pathname.split("/").filter(Boolean).pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
const segments = value.split("?")[0]?.split("/") ?? [];
|
||||
const last = segments.pop();
|
||||
if (last) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
}
|
||||
return "未命名资源";
|
||||
};
|
||||
|
||||
const formatFileSize = (size?: number | null) => {
|
||||
if (!size || size <= 0) {
|
||||
return "未知大小";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let idx = 0;
|
||||
let current = size;
|
||||
while (current >= 1024 && idx < units.length - 1) {
|
||||
current /= 1024;
|
||||
idx += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 10 ? 0 : 1)} ${units[idx]}`;
|
||||
};
|
||||
|
||||
const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -93,144 +93,144 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
|
||||
? (rawAssetType as MediaKind)
|
||||
: "image";
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
const canResize = assetType === "image" || assetType === "video";
|
||||
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
|
||||
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestWidthRef = useRef(localWidth);
|
||||
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
|
||||
const captionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [captionEditing, setCaptionEditing] = useState(false);
|
||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
const resolveDocumentId = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const [, tail] = window.location.pathname.split("/documents/");
|
||||
if (tail) {
|
||||
const id = tail.split(/[/?#]/)[0];
|
||||
if (id) return id;
|
||||
}
|
||||
}
|
||||
return (block.props as { documentId?: string })?.documentId || "";
|
||||
}, [block.props]);
|
||||
const extension = useMemo(() => {
|
||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||
return match?.[1] ?? "";
|
||||
}, [block.props.fileName, fileUrl]);
|
||||
const isOfficeDoc = useMemo(
|
||||
() =>
|
||||
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
||||
extension,
|
||||
),
|
||||
[extension],
|
||||
);
|
||||
|
||||
const handleChoose = () => {
|
||||
openPicker({
|
||||
defaultTab: fileUrl ? "recent" : "upload",
|
||||
mediaType: assetType,
|
||||
onSelect: (selection) => {
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? rawAssetType,
|
||||
fileName: selection.fileName ?? block.props.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
documentId: resolveDocumentId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBorder = () => {
|
||||
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
|
||||
};
|
||||
|
||||
const setAlign = (align: MediaAlign) => {
|
||||
editor.updateBlock(block, { props: { captionAlign: align } });
|
||||
};
|
||||
|
||||
const handleCaptionChange = (value: string) => {
|
||||
editor.updateBlock(block, { props: { caption: value } });
|
||||
};
|
||||
|
||||
const enableCaptionEdit = () => {
|
||||
setCaptionEditing(true);
|
||||
setTimeout(() => captionRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowCaption && captionEditing) {
|
||||
setCaptionEditing(false);
|
||||
}
|
||||
}, [captionEditing, shouldShowCaption]);
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
|
||||
}
|
||||
}, [block.props.width, dragging]);
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const resolvedWidth = useMemo(() => {
|
||||
if (!canResize) return 0;
|
||||
if (localWidth > 0) return clampWidth(localWidth);
|
||||
if (block.props.width && Number(block.props.width) > 0) {
|
||||
return clampWidth(Number(block.props.width));
|
||||
}
|
||||
return 0;
|
||||
}, [block.props.width, canResize, localWidth]);
|
||||
|
||||
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
|
||||
if (!canResize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
|
||||
if (!canvasWidth) {
|
||||
return;
|
||||
}
|
||||
setDragging({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
startWidth: canvasWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return undefined;
|
||||
}
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const delta = event.clientX - dragging.startX;
|
||||
const adjusted = dragging.side === "left" ? -delta : delta;
|
||||
const next = clampWidth(dragging.startWidth + adjusted);
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const handleUp = () => {
|
||||
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
|
||||
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragging, editor, block]);
|
||||
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
const canResize = assetType === "image" || assetType === "video";
|
||||
const [dragging, setDragging] = useState<null | { side: "left" | "right"; startX: number; startWidth: number }>(null);
|
||||
const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0));
|
||||
const mediaRef = useRef<HTMLDivElement | null>(null);
|
||||
const latestWidthRef = useRef(localWidth);
|
||||
const displayFileName = block.props.fileName ?? deriveFileName(fileUrl);
|
||||
const captionRef = useRef<HTMLInputElement | null>(null);
|
||||
const [captionEditing, setCaptionEditing] = useState(false);
|
||||
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
const resolveDocumentId = useCallback(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const [, tail] = window.location.pathname.split("/documents/");
|
||||
if (tail) {
|
||||
const id = tail.split(/[/?#]/)[0];
|
||||
if (id) return id;
|
||||
}
|
||||
}
|
||||
return (block.props as { documentId?: string })?.documentId || "";
|
||||
}, [block.props]);
|
||||
const extension = useMemo(() => {
|
||||
const name = (block.props.fileName || deriveFileName(fileUrl)).toLowerCase();
|
||||
const match = /\.([a-z0-9]+)$/.exec(name);
|
||||
return match?.[1] ?? "";
|
||||
}, [block.props.fileName, fileUrl]);
|
||||
const isOfficeDoc = useMemo(
|
||||
() =>
|
||||
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
||||
extension,
|
||||
),
|
||||
[extension],
|
||||
);
|
||||
|
||||
const handleChoose = () => {
|
||||
openPicker({
|
||||
defaultTab: fileUrl ? "recent" : "upload",
|
||||
mediaType: assetType,
|
||||
onSelect: (selection) => {
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? rawAssetType,
|
||||
fileName: selection.fileName ?? block.props.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
||||
ocrStatus: "idle",
|
||||
documentId: resolveDocumentId(),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBorder = () => {
|
||||
editor.updateBlock(block, { props: { hasBorder: !block.props.hasBorder } });
|
||||
};
|
||||
|
||||
const setAlign = (align: MediaAlign) => {
|
||||
editor.updateBlock(block, { props: { captionAlign: align } });
|
||||
};
|
||||
|
||||
const handleCaptionChange = (value: string) => {
|
||||
editor.updateBlock(block, { props: { caption: value } });
|
||||
};
|
||||
|
||||
const enableCaptionEdit = () => {
|
||||
setCaptionEditing(true);
|
||||
setTimeout(() => captionRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowCaption && captionEditing) {
|
||||
setCaptionEditing(false);
|
||||
}
|
||||
}, [captionEditing, shouldShowCaption]);
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
setLocalWidth(block.props.width ? Number(block.props.width) : 0);
|
||||
}
|
||||
}, [block.props.width, dragging]);
|
||||
useEffect(() => {
|
||||
latestWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const resolvedWidth = useMemo(() => {
|
||||
if (!canResize) return 0;
|
||||
if (localWidth > 0) return clampWidth(localWidth);
|
||||
if (block.props.width && Number(block.props.width) > 0) {
|
||||
return clampWidth(Number(block.props.width));
|
||||
}
|
||||
return 0;
|
||||
}, [block.props.width, canResize, localWidth]);
|
||||
|
||||
const handleResizeStart = (event: ReactMouseEvent<HTMLSpanElement>, side: "left" | "right") => {
|
||||
if (!canResize) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const canvasWidth = resolvedWidth || mediaRef.current?.offsetWidth || 0;
|
||||
if (!canvasWidth) {
|
||||
return;
|
||||
}
|
||||
setDragging({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
startWidth: canvasWidth,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) {
|
||||
return undefined;
|
||||
}
|
||||
const handleMove = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
const delta = event.clientX - dragging.startX;
|
||||
const adjusted = dragging.side === "left" ? -delta : delta;
|
||||
const next = clampWidth(dragging.startWidth + adjusted);
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const handleUp = () => {
|
||||
const finalWidth = latestWidthRef.current > 0 ? latestWidthRef.current : dragging.startWidth;
|
||||
editor.updateBlock(block, { props: { width: clampWidth(finalWidth) } });
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, [dragging, editor, block]);
|
||||
|
||||
const handleLink = () => {
|
||||
const next = window.prompt(`输入${typeLabel}跳转链接`, block.props.linkUrl ?? "");
|
||||
if (next === null) return;
|
||||
@@ -282,7 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
if (!url) return;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
|
||||
const openWithOnlyOffice = async () => {
|
||||
if (!fileUrl) return;
|
||||
if (!officeBase) {
|
||||
@@ -342,65 +342,65 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
if (!assetId) {
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const docId = resolveDocumentId();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(docId);
|
||||
};
|
||||
|
||||
const triggerOcr = async () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("请先上传图片后再执行 OCR");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/media/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId: block.props.assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "触发 OCR 失败");
|
||||
}
|
||||
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
|
||||
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="wolai-media wolai-media--empty">
|
||||
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
选择或上传{typeLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">支持上传、最近及外链插入</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const handleDeleteAsset = async () => {
|
||||
const assetId = (block.props as { assetId?: string })?.assetId;
|
||||
if (!assetId) {
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const docId = resolveDocumentId();
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: [assetId] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除附件失败");
|
||||
return;
|
||||
}
|
||||
editor.removeBlocks([block.id]);
|
||||
emitAssetsChanged(docId);
|
||||
};
|
||||
|
||||
const triggerOcr = async () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("请先上传图片后再执行 OCR");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await fetch("/api/media/ocr", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assetId: block.props.assetId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "触发 OCR 失败");
|
||||
}
|
||||
editor.updateBlock(block, { props: { ocrStatus: "processing" } });
|
||||
window.alert("已提交 OCR 任务,稍后可在搜索面板中通过 OCR 结果搜索。");
|
||||
} catch (error) {
|
||||
window.alert((error as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fileUrl) {
|
||||
return (
|
||||
<div className="wolai-media wolai-media--empty">
|
||||
<Button type="button" variant="outline" onClick={handleChoose} className="gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
选择或上传{typeLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500">支持上传、最近及外链插入</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPreviewContent = () => {
|
||||
if (assetType === "video") {
|
||||
return (
|
||||
@@ -424,17 +424,17 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -548,62 +548,62 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
|
||||
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
|
||||
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
|
||||
)}
|
||||
>
|
||||
<div className="wolai-media__preview">{renderPreviewContent()}</div>
|
||||
{shouldShowCaption && (
|
||||
<figcaption>
|
||||
<input
|
||||
ref={captionRef}
|
||||
value={block.props.caption ?? ""}
|
||||
onChange={(event) => handleCaptionChange(event.target.value)}
|
||||
onBlur={() => setCaptionEditing(false)}
|
||||
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
|
||||
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
|
||||
/>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
|
||||
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
key: "replace",
|
||||
label: `替换${typeLabel}`,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
label: block.props.hasBorder ? "取消边框" : "显示边框",
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
onClick: toggleBorder,
|
||||
}
|
||||
: null,
|
||||
!shouldShowCaption
|
||||
? {
|
||||
key: "caption",
|
||||
label: "添加说明",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
onClick: enableCaptionEdit,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "link",
|
||||
label: block.props.linkUrl ? "编辑链接" : "添加链接",
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
canToggleBorder && block.props.hasBorder && "wolai-media__figure--border",
|
||||
canAlign && block.props.captionAlign === "center" && "wolai-media__figure--center",
|
||||
canAlign && block.props.captionAlign === "right" && "wolai-media__figure--right",
|
||||
)}
|
||||
>
|
||||
<div className="wolai-media__preview">{renderPreviewContent()}</div>
|
||||
{shouldShowCaption && (
|
||||
<figcaption>
|
||||
<input
|
||||
ref={captionRef}
|
||||
value={block.props.caption ?? ""}
|
||||
onChange={(event) => handleCaptionChange(event.target.value)}
|
||||
onBlur={() => setCaptionEditing(false)}
|
||||
placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."}
|
||||
className="w-full border-none bg-transparent text-sm text-[#475569] outline-none"
|
||||
/>
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
|
||||
type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void };
|
||||
const quickActions: QuickAction[] = [
|
||||
{
|
||||
key: "replace",
|
||||
label: `替换${typeLabel}`,
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
label: block.props.hasBorder ? "取消边框" : "显示边框",
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
onClick: toggleBorder,
|
||||
}
|
||||
: null,
|
||||
!shouldShowCaption
|
||||
? {
|
||||
key: "caption",
|
||||
label: "添加说明",
|
||||
icon: <Type className="h-4 w-4" />,
|
||||
onClick: enableCaptionEdit,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "link",
|
||||
label: block.props.linkUrl ? "编辑链接" : "添加链接",
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
!downloadDisabled
|
||||
? {
|
||||
key: "download",
|
||||
@@ -614,16 +614,16 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <Trash className="h-4 w-4" />,
|
||||
onClick: handleDeleteAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
icon: <Trash className="h-4 w-4" />,
|
||||
onClick: handleDeleteAsset,
|
||||
},
|
||||
].filter((action): action is QuickAction => Boolean(action));
|
||||
|
||||
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
||||
|
||||
return (
|
||||
<div className={cn("wolai-media", assetType === "file" && "wolai-media--file")} ref={mediaRef}>
|
||||
<div
|
||||
@@ -636,11 +636,11 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
void viewOriginal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
>
|
||||
{block.props.linkUrl ? (
|
||||
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
||||
{figure}
|
||||
</a>
|
||||
) : (
|
||||
figure
|
||||
)}
|
||||
@@ -651,38 +651,38 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
key={action.key}
|
||||
type="button"
|
||||
className="wolai-media__quickbutton"
|
||||
onClick={action.onClick}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && (
|
||||
<DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>
|
||||
)}
|
||||
{canToggleBorder && (
|
||||
<DropdownMenuItem onClick={toggleBorder}>
|
||||
{block.props.hasBorder ? "取消边框" : "显示边框"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAlign && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-gray-400">说明对齐</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setAlign("left")}>左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("center")}>居中</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("right")}>右对齐</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
onClick={action.onClick}
|
||||
title={action.label}
|
||||
aria-label={action.label}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="wolai-media__quickbutton" aria-label="更多操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onClick={handleChoose}>替换资源</DropdownMenuItem>
|
||||
{!shouldShowCaption && (
|
||||
<DropdownMenuItem onClick={enableCaptionEdit}>添加说明</DropdownMenuItem>
|
||||
)}
|
||||
{canToggleBorder && (
|
||||
<DropdownMenuItem onClick={toggleBorder}>
|
||||
{block.props.hasBorder ? "取消边框" : "显示边框"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canAlign && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs text-gray-400">说明对齐</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setAlign("left")}>左对齐</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("center")}>居中</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setAlign("right")}>右对齐</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -700,14 +700,14 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
>
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
||||
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
||||
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -717,73 +717,73 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
<ResizeHandle side="left" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "left")} />
|
||||
<ResizeHandle side="right" dragging={Boolean(dragging)} onMouseDown={(event) => handleResizeStart(event, "right")} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "media",
|
||||
propSchema: {
|
||||
fileUrl: { default: "", type: "string" },
|
||||
thumbnailUrl: { default: "", type: "string" },
|
||||
caption: { default: "", type: "string" },
|
||||
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
|
||||
hasBorder: { default: true, type: "boolean" },
|
||||
linkUrl: { default: "", type: "string" },
|
||||
assetId: { default: "", type: "string" },
|
||||
assetType: { default: "image", type: "string" },
|
||||
fileName: { default: "", type: "string" },
|
||||
fileSize: { default: 0, type: "number" },
|
||||
mimeType: { default: "", type: "string" },
|
||||
width: { default: 0, type: "number" },
|
||||
ocrStatus: { default: "idle", type: "string" },
|
||||
documentId: { default: "", type: "string" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => <MediaBlockContent {...props} />,
|
||||
},
|
||||
)();
|
||||
const handleCopyLink = async (targetUrl: string | null) => {
|
||||
if (!targetUrl) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetUrl);
|
||||
window.alert("链接已复制");
|
||||
} else {
|
||||
throw new Error("no clipboard");
|
||||
}
|
||||
} catch {
|
||||
window.prompt("请复制以下链接", targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const ResizeHandle = ({
|
||||
side,
|
||||
onMouseDown,
|
||||
dragging,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
dragging: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
|
||||
}) => (
|
||||
<span
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
|
||||
/>
|
||||
);
|
||||
|
||||
const clampWidth = (value: number) => {
|
||||
const min = 240;
|
||||
const max = 960;
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
)}
|
||||
</div>
|
||||
<div className="wolai-media__hint">{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const mediaBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "media",
|
||||
propSchema: {
|
||||
fileUrl: { default: "", type: "string" },
|
||||
thumbnailUrl: { default: "", type: "string" },
|
||||
caption: { default: "", type: "string" },
|
||||
captionAlign: { default: "left", values: ["left", "center", "right"] as MediaAlign[] },
|
||||
hasBorder: { default: true, type: "boolean" },
|
||||
linkUrl: { default: "", type: "string" },
|
||||
assetId: { default: "", type: "string" },
|
||||
assetType: { default: "image", type: "string" },
|
||||
fileName: { default: "", type: "string" },
|
||||
fileSize: { default: 0, type: "number" },
|
||||
mimeType: { default: "", type: "string" },
|
||||
width: { default: 0, type: "number" },
|
||||
ocrStatus: { default: "idle", type: "string" },
|
||||
documentId: { default: "", type: "string" },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => <MediaBlockContent {...props} />,
|
||||
},
|
||||
)();
|
||||
const handleCopyLink = async (targetUrl: string | null) => {
|
||||
if (!targetUrl) return;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(targetUrl);
|
||||
window.alert("链接已复制");
|
||||
} else {
|
||||
throw new Error("no clipboard");
|
||||
}
|
||||
} catch {
|
||||
window.prompt("请复制以下链接", targetUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const ResizeHandle = ({
|
||||
side,
|
||||
onMouseDown,
|
||||
dragging,
|
||||
}: {
|
||||
side: "left" | "right";
|
||||
dragging: boolean;
|
||||
onMouseDown: (event: ReactMouseEvent<HTMLSpanElement>) => void;
|
||||
}) => (
|
||||
<span
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="horizontal"
|
||||
onMouseDown={onMouseDown}
|
||||
className={cn("wolai-media__resize-handle", `wolai-media__resize-handle--${side}`, dragging && "is-dragging")}
|
||||
/>
|
||||
);
|
||||
|
||||
const clampWidth = (value: number) => {
|
||||
const min = 240;
|
||||
const max = 960;
|
||||
if (Number.isNaN(value)) return min;
|
||||
return Math.max(min, Math.min(max, value));
|
||||
};
|
||||
|
||||
@@ -18,6 +18,19 @@ type AgentAssetItem = {
|
||||
};
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
type AiProvider = "online" | "local" | "ollama" | "codex";
|
||||
type CodexMode = "chat" | "test" | "dev";
|
||||
|
||||
const OLLAMA_QWEN3_30B = "qwen3:30b-a3b-instruct-2507-q4_K_M";
|
||||
|
||||
const extractCodexMode = (text: string): CodexMode => {
|
||||
const s = String(text ?? "");
|
||||
const m = s.match(/^\s*#(chat|test|dev)\b/i);
|
||||
if (!m) return "chat";
|
||||
const mode = String(m[1] ?? "").toLowerCase();
|
||||
if (mode === "dev" || mode === "test" || mode === "chat") return mode;
|
||||
return "chat";
|
||||
};
|
||||
|
||||
type MindmapInstanceLike = {
|
||||
setData?: (data: unknown) => void;
|
||||
@@ -35,6 +48,7 @@ const DEFAULT_SESSION_MESSAGES: AgentMessage[] = [
|
||||
type ToolLog =
|
||||
| { type: "tool_call"; id: string; tool: string; args: Record<string, unknown> }
|
||||
| { type: "tool_result"; id: string; tool: string; ok: boolean; ms: number; result: unknown }
|
||||
| { type: "info"; message: string }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
type ChatSession = {
|
||||
@@ -45,6 +59,8 @@ type ChatSession = {
|
||||
messages: AgentMessage[];
|
||||
toolLogs: ToolLog[];
|
||||
attachments: AgentAssetItem[];
|
||||
codexSessionId?: string | null;
|
||||
codexMode?: CodexMode | null;
|
||||
};
|
||||
|
||||
type PanelPage = "chat" | "tools" | "history" | "account" | "settings";
|
||||
@@ -169,7 +185,7 @@ export function MindmapAiAgentPanel({
|
||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiProvider, setAiProvider] = useState<AiProvider>("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
const [maxSteps, setMaxSteps] = useState<number>(10);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
@@ -198,7 +214,7 @@ export function MindmapAiAgentPanel({
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
const m = window.localStorage.getItem("mindmap_ai_model") || "";
|
||||
const stepsRaw = window.localStorage.getItem("mindmap_ai_max_steps") || "";
|
||||
if (p === "local" || p === "online") setAiProvider(p);
|
||||
if (p === "local" || p === "online" || p === "ollama" || p === "codex") setAiProvider(p);
|
||||
if (typeof m === "string") setAiModel(m);
|
||||
const parsed = Number(stepsRaw);
|
||||
if (Number.isFinite(parsed) && parsed >= 1) {
|
||||
@@ -240,6 +256,8 @@ export function MindmapAiAgentPanel({
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions([session]);
|
||||
setActiveSessionId(id);
|
||||
@@ -267,7 +285,10 @@ export function MindmapAiAgentPanel({
|
||||
const messages = Array.isArray((x as any)?.messages) ? (x as any).messages : DEFAULT_SESSION_MESSAGES;
|
||||
const toolLogs = Array.isArray((x as any)?.toolLogs) ? (x as any).toolLogs : [];
|
||||
const attachments = Array.isArray((x as any)?.attachments) ? (x as any).attachments : [];
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments } as ChatSession;
|
||||
const codexSessionId = String((x as any)?.codexSessionId ?? "").trim() || null;
|
||||
const codexModeRaw = String((x as any)?.codexMode ?? "").trim();
|
||||
const codexMode = codexModeRaw === "chat" || codexModeRaw === "test" || codexModeRaw === "dev" ? (codexModeRaw as CodexMode) : null;
|
||||
return { id, title, createdAt, updatedAt, messages, toolLogs, attachments, codexSessionId, codexMode } as ChatSession;
|
||||
})
|
||||
.filter((s) => s.id),
|
||||
);
|
||||
@@ -303,6 +324,8 @@ export function MindmapAiAgentPanel({
|
||||
messages,
|
||||
toolLogs,
|
||||
attachments,
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
@@ -625,6 +648,8 @@ export function MindmapAiAgentPanel({
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
};
|
||||
setSessions((prev) => normalizeSessions([next, ...prev]));
|
||||
setActiveSessionId(id);
|
||||
@@ -659,7 +684,16 @@ export function MindmapAiAgentPanel({
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId
|
||||
? { ...s, messages: DEFAULT_SESSION_MESSAGES, toolLogs: [], attachments: [], updatedAt: Date.now(), title: s.title || "当前会话" }
|
||||
? {
|
||||
...s,
|
||||
messages: DEFAULT_SESSION_MESSAGES,
|
||||
toolLogs: [],
|
||||
attachments: [],
|
||||
updatedAt: Date.now(),
|
||||
title: s.title || "当前会话",
|
||||
codexSessionId: null,
|
||||
codexMode: null,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
),
|
||||
@@ -719,12 +753,18 @@ export function MindmapAiAgentPanel({
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setLoading(false);
|
||||
if (aiProvider === "codex") {
|
||||
setToolLogs((prev) => [...prev, { type: "info", message: "已暂停(可继续对话)" }]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
const hasExplicitCodexMode = /^\s*#(chat|test|dev)\b/i.test(content);
|
||||
const intendedCodexMode: CodexMode =
|
||||
aiProvider === "codex" ? (hasExplicitCodexMode ? extractCodexMode(content) : "chat") : "chat";
|
||||
setDebug("");
|
||||
setToolLogs([]);
|
||||
if (activeSessionId && currentSessionTitle === "新会话") {
|
||||
@@ -737,9 +777,10 @@ export function MindmapAiAgentPanel({
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
|
||||
let controller: AbortController | null = null;
|
||||
try {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
// 不把面板的“欢迎语”当作对话历史发送给服务端,避免影响任务执行
|
||||
@@ -747,6 +788,21 @@ export function MindmapAiAgentPanel({
|
||||
(m, idx) => !(idx === 0 && m.role === "assistant" && /思维导图 AI Agent/.test(m.content)),
|
||||
);
|
||||
|
||||
const payloadMessagesForRequest = payloadMessages;
|
||||
|
||||
const codexSessionIdForRequest =
|
||||
aiProvider === "codex" ? String(currentSession?.codexSessionId ?? "").trim() || null : null;
|
||||
|
||||
if (aiProvider === "codex" && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const res = await fetch("/api/ai-agent/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -755,7 +811,7 @@ export function MindmapAiAgentPanel({
|
||||
stream: true,
|
||||
maxSteps,
|
||||
scope: "mindmap",
|
||||
messages: payloadMessages.slice(-24),
|
||||
messages: payloadMessagesForRequest.slice(-24),
|
||||
toolChoice: toolAuto
|
||||
? {
|
||||
mode: "auto",
|
||||
@@ -775,7 +831,14 @@ export function MindmapAiAgentPanel({
|
||||
fileUrl: a.fileUrl,
|
||||
mimeType: a.mimeType ?? null,
|
||||
})),
|
||||
options: { searxng: networkOn, ai: { provider: aiProvider, model: aiModel.trim() || undefined } },
|
||||
options: {
|
||||
searxng: networkOn,
|
||||
ai: {
|
||||
provider: aiProvider,
|
||||
...(aiProvider === "codex" && codexSessionIdForRequest ? { sessionId: codexSessionIdForRequest } : {}),
|
||||
...(aiProvider !== "codex" && aiModel.trim() ? { model: aiModel.trim() } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -788,6 +851,26 @@ export function MindmapAiAgentPanel({
|
||||
await parseSseChunks(res, (event, dataText) => {
|
||||
rawEvents.push({ event, dataText });
|
||||
|
||||
if (event === "codex_session") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const obj = (typeof data === "object" && data ? (data as Record<string, unknown>) : {}) as Record<string, unknown>;
|
||||
const sessionId = String(obj.sessionId ?? "").trim();
|
||||
if (sessionId && activeSessionId) {
|
||||
setSessions((prev) =>
|
||||
normalizeSessions(
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId ? { ...s, codexSessionId: sessionId, codexMode: intendedCodexMode, updatedAt: Date.now() } : s,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event === "tool_call") {
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
@@ -870,6 +953,7 @@ export function MindmapAiAgentPanel({
|
||||
}
|
||||
|
||||
if (event === "error") {
|
||||
if (controller?.signal.aborted && aiProvider === "codex") return;
|
||||
try {
|
||||
const data = JSON.parse(dataText || "null") as unknown;
|
||||
const msg =
|
||||
@@ -884,6 +968,7 @@ export function MindmapAiAgentPanel({
|
||||
|
||||
setDebug(JSON.stringify(rawEvents.slice(-120), null, 2));
|
||||
} catch (e) {
|
||||
if (controller?.signal.aborted && aiProvider === "codex") return;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `执行失败:${msg}` }]);
|
||||
setToolLogs((prev) => [...prev, { type: "error", message: msg }]);
|
||||
@@ -977,6 +1062,13 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-muted p-2 text-muted-foreground">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<details key={idx} className="rounded border p-2">
|
||||
@@ -1098,9 +1190,14 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" disabled={!loading} onClick={stop} title="停止本次执行">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!loading}
|
||||
onClick={stop}
|
||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</Button>
|
||||
<Button disabled={!canSend} onClick={() => void send()}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
@@ -1222,18 +1319,30 @@ export function MindmapAiAgentPanel({
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiProvider}
|
||||
onChange={(e) => setAiProvider(e.target.value === "local" ? "local" : "online")}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value || "").trim();
|
||||
if (v === "online" || v === "local" || v === "ollama" || v === "codex") setAiProvider(v);
|
||||
else setAiProvider("online");
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="online">在线</option>
|
||||
<option value="local">本地</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="codex">Codex</option>
|
||||
</select>
|
||||
<label className="ml-2 text-xs text-muted-foreground">模型</label>
|
||||
{aiProvider === "online" ? (
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
使用本地 Codex 配置。消息开头加 <code className="rounded bg-muted px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-muted px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel}
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -1243,6 +1352,16 @@ export function MindmapAiAgentPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="h-9 rounded border bg-white px-2 text-sm"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
@@ -1251,6 +1370,7 @@ export function MindmapAiAgentPanel({
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
disabled={loading}
|
||||
list="mindmap-local-model-suggestions"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1258,7 +1378,14 @@ export function MindmapAiAgentPanel({
|
||||
<div className="text-xs text-muted-foreground">
|
||||
本地 AI 需配置 `LOCAL_AI_BASE_URL`/`LOCAL_AI_MODEL` 或 `ai.local.md` / `ai-local.md`。
|
||||
</div>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ollama 默认使用 <code className="rounded bg-muted px-1 py-0.5">http://127.0.0.1:11434/v1</code>(可用 <code className="rounded bg-muted px-1 py-0.5">OLLAMA_BASE_URL</code> 覆盖)。
|
||||
</div>
|
||||
) : null}
|
||||
<datalist id="mindmap-local-model-suggestions">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : null}
|
||||
@@ -1438,13 +1565,37 @@ export function MindmapAiAgentPanel({
|
||||
/>
|
||||
本地
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "ollama"}
|
||||
onChange={() => setAiProvider("ollama")}
|
||||
/>
|
||||
Ollama
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="mindmap-ai-provider"
|
||||
checked={aiProvider === "codex"}
|
||||
onChange={() => setAiProvider("codex")}
|
||||
/>
|
||||
Codex
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">模型:</div>
|
||||
{aiProvider === "online" ? (
|
||||
{aiProvider === "codex" ? (
|
||||
<div className="text-[11px] text-gray-500">
|
||||
消息开头加 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#test</code> /{" "}
|
||||
<code className="rounded bg-gray-100 px-1 py-0.5">#dev</code>(默认 <code className="rounded bg-gray-100 px-1 py-0.5">#chat</code>)。
|
||||
</div>
|
||||
) : aiProvider === "online" ? (
|
||||
<select
|
||||
data-testid="mindmap-ai-model-select"
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel}
|
||||
value={ONLINE_MODELS.includes(aiModel as any) ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
{ONLINE_MODELS.map((m) => (
|
||||
@@ -1453,6 +1604,15 @@ export function MindmapAiAgentPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : aiProvider === "ollama" ? (
|
||||
<select
|
||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-xs outline-none focus:border-blue-300"
|
||||
value={aiModel === "" || aiModel === OLLAMA_QWEN3_30B ? aiModel : ""}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
>
|
||||
<option value="">默认:{OLLAMA_QWEN3_30B}</option>
|
||||
<option value={OLLAMA_QWEN3_30B}>{OLLAMA_QWEN3_30B}</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
data-testid="mindmap-ai-model-input"
|
||||
@@ -1460,8 +1620,12 @@ export function MindmapAiAgentPanel({
|
||||
value={aiModel}
|
||||
onChange={(e) => setAiModel(e.target.value)}
|
||||
placeholder="默认(ai.local.md/环境变量)"
|
||||
list="mindmap-local-model-suggestions-bottom"
|
||||
/>
|
||||
)}
|
||||
<datalist id="mindmap-local-model-suggestions-bottom">
|
||||
<option value={OLLAMA_QWEN3_30B} />
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-gray-500">最大步数:</div>
|
||||
@@ -1572,10 +1736,10 @@ export function MindmapAiAgentPanel({
|
||||
className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
disabled={!loading}
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
title="停止本次执行"
|
||||
title={aiProvider === "codex" ? "暂停本次执行(类似 ESC)" : "停止本次执行"}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
停止
|
||||
{aiProvider === "codex" ? "暂停" : "停止"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1602,6 +1766,13 @@ export function MindmapAiAgentPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "info") {
|
||||
return (
|
||||
<div key={idx} className="rounded border bg-gray-50 p-2 text-gray-600">
|
||||
{l.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (l.type === "tool_call") {
|
||||
return (
|
||||
<div key={idx} className="rounded border p-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,171 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
|
||||
// 菜单项配置
|
||||
interface ContextMenuItem {
|
||||
key?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
show?: (node: MindMapNode | null) => boolean;
|
||||
}
|
||||
|
||||
// 节点右键菜单配置
|
||||
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||
{
|
||||
key: "INSERT_NODE",
|
||||
label: "插入同级节点",
|
||||
shortcut: "Enter",
|
||||
},
|
||||
{
|
||||
key: "INSERT_CHILD_NODE",
|
||||
label: "插入子级节点",
|
||||
shortcut: "Tab",
|
||||
},
|
||||
{
|
||||
key: "INSERT_PARENT_NODE",
|
||||
label: "插入父节点",
|
||||
shortcut: "Shift + Tab",
|
||||
},
|
||||
{
|
||||
key: "ADD_GENERALIZATION",
|
||||
label: "插入概要",
|
||||
shortcut: "Ctrl + G",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "UP_NODE",
|
||||
label: "上移节点",
|
||||
shortcut: "Ctrl + ↑",
|
||||
},
|
||||
{
|
||||
key: "DOWN_NODE",
|
||||
label: "下移节点",
|
||||
shortcut: "Ctrl + ↓",
|
||||
},
|
||||
{
|
||||
key: "UNEXPAND_ALL",
|
||||
label: "收起所有下级节点",
|
||||
},
|
||||
{
|
||||
key: "EXPAND_ALL",
|
||||
label: "展开所有下级节点",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_NODE",
|
||||
label: "删除节点",
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CURRENT_NODE",
|
||||
label: "仅删除当前节点",
|
||||
shortcut: "Shift + Backspace",
|
||||
danger: true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "COPY_NODE",
|
||||
label: "复制节点",
|
||||
shortcut: "Ctrl + C",
|
||||
},
|
||||
{
|
||||
key: "CUT_NODE",
|
||||
label: "剪切节点",
|
||||
shortcut: "Ctrl + X",
|
||||
},
|
||||
{
|
||||
key: "PASTE_NODE",
|
||||
label: "粘贴节点",
|
||||
shortcut: "Ctrl + V",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_HYPERLINK",
|
||||
label: "移除超链接",
|
||||
show: (node) => !!node?.getData?.("hyperlink"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_NOTE",
|
||||
label: "移除备注",
|
||||
show: (node) => !!node?.getData?.("note"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CUSTOM_STYLES",
|
||||
label: "一键去除自定义样式",
|
||||
},
|
||||
{
|
||||
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||
label: "导出该节点为图片",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "AI_CONTINUE",
|
||||
label: "AI续写",
|
||||
},
|
||||
];
|
||||
|
||||
interface MindmapContextMenuProps {
|
||||
mindmap: any | null;
|
||||
}
|
||||
|
||||
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||
|
||||
// 判断是否禁用某个菜单项
|
||||
const isItemDisabled = useCallback(
|
||||
(item: ContextMenuItem): boolean => {
|
||||
if (!targetNode) return false;
|
||||
|
||||
const isRoot = (targetNode as any).isRoot === true;
|
||||
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||
|
||||
switch (item.key) {
|
||||
case "INSERT_NODE":
|
||||
case "INSERT_PARENT_NODE":
|
||||
case "ADD_GENERALIZATION":
|
||||
return isRoot || isGeneralization;
|
||||
|
||||
case "INSERT_CHILD_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "COPY_NODE":
|
||||
case "CUT_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "UP_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||
}
|
||||
|
||||
case "DOWN_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
const children = parent.children;
|
||||
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[targetNode]
|
||||
);
|
||||
|
||||
// 过滤显示的菜单项
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MindMapNode } from "./mindmapTypes";
|
||||
|
||||
// 菜单项配置
|
||||
interface ContextMenuItem {
|
||||
key?: string;
|
||||
label?: string;
|
||||
shortcut?: string;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
show?: (node: MindMapNode | null) => boolean;
|
||||
}
|
||||
|
||||
// 节点右键菜单配置
|
||||
const NODE_MENU_ITEMS: ContextMenuItem[] = [
|
||||
{
|
||||
key: "INSERT_NODE",
|
||||
label: "插入同级节点",
|
||||
shortcut: "Enter",
|
||||
},
|
||||
{
|
||||
key: "INSERT_CHILD_NODE",
|
||||
label: "插入子级节点",
|
||||
shortcut: "Tab",
|
||||
},
|
||||
{
|
||||
key: "INSERT_PARENT_NODE",
|
||||
label: "插入父节点",
|
||||
shortcut: "Shift + Tab",
|
||||
},
|
||||
{
|
||||
key: "ADD_GENERALIZATION",
|
||||
label: "插入概要",
|
||||
shortcut: "Ctrl + G",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "UP_NODE",
|
||||
label: "上移节点",
|
||||
shortcut: "Ctrl + ↑",
|
||||
},
|
||||
{
|
||||
key: "DOWN_NODE",
|
||||
label: "下移节点",
|
||||
shortcut: "Ctrl + ↓",
|
||||
},
|
||||
{
|
||||
key: "UNEXPAND_ALL",
|
||||
label: "收起所有下级节点",
|
||||
},
|
||||
{
|
||||
key: "EXPAND_ALL",
|
||||
label: "展开所有下级节点",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_NODE",
|
||||
label: "删除节点",
|
||||
shortcut: "Delete",
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CURRENT_NODE",
|
||||
label: "仅删除当前节点",
|
||||
shortcut: "Shift + Backspace",
|
||||
danger: true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "COPY_NODE",
|
||||
label: "复制节点",
|
||||
shortcut: "Ctrl + C",
|
||||
},
|
||||
{
|
||||
key: "CUT_NODE",
|
||||
label: "剪切节点",
|
||||
shortcut: "Ctrl + X",
|
||||
},
|
||||
{
|
||||
key: "PASTE_NODE",
|
||||
label: "粘贴节点",
|
||||
shortcut: "Ctrl + V",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "REMOVE_HYPERLINK",
|
||||
label: "移除超链接",
|
||||
show: (node) => !!node?.getData?.("hyperlink"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_NOTE",
|
||||
label: "移除备注",
|
||||
show: (node) => !!node?.getData?.("note"),
|
||||
},
|
||||
{
|
||||
key: "REMOVE_CUSTOM_STYLES",
|
||||
label: "一键去除自定义样式",
|
||||
},
|
||||
{
|
||||
key: "EXPORT_CUR_NODE_TO_PNG",
|
||||
label: "导出该节点为图片",
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
key: "AI_CONTINUE",
|
||||
label: "AI续写",
|
||||
},
|
||||
];
|
||||
|
||||
interface MindmapContextMenuProps {
|
||||
mindmap: any | null;
|
||||
}
|
||||
|
||||
export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [targetNode, setTargetNode] = useState<MindMapNode | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const requestShowRef = useRef<{ x: number; y: number; node: MindMapNode } | null>(null);
|
||||
|
||||
// 判断是否禁用某个菜单项
|
||||
const isItemDisabled = useCallback(
|
||||
(item: ContextMenuItem): boolean => {
|
||||
if (!targetNode) return false;
|
||||
|
||||
const isRoot = (targetNode as any).isRoot === true;
|
||||
const isGeneralization = (targetNode as any).isGeneralization === true;
|
||||
|
||||
switch (item.key) {
|
||||
case "INSERT_NODE":
|
||||
case "INSERT_PARENT_NODE":
|
||||
case "ADD_GENERALIZATION":
|
||||
return isRoot || isGeneralization;
|
||||
|
||||
case "INSERT_CHILD_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "COPY_NODE":
|
||||
case "CUT_NODE":
|
||||
return isGeneralization;
|
||||
|
||||
case "UP_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
return parent.children.findIndex((item: unknown) => item === targetNode) === 0;
|
||||
}
|
||||
|
||||
case "DOWN_NODE": {
|
||||
if (isRoot || isGeneralization) return true;
|
||||
const parent = (targetNode as any).parent;
|
||||
if (!parent || !Array.isArray(parent.children)) return true;
|
||||
const children = parent.children;
|
||||
return children.findIndex((item: unknown) => item === targetNode) === children.length - 1;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[targetNode]
|
||||
);
|
||||
|
||||
// 过滤显示的菜单项
|
||||
const getVisibleItems = useCallback((): ContextMenuItem[] => {
|
||||
return NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
@@ -186,57 +186,57 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
const executeCommand = useCallback(
|
||||
(key: string) => {
|
||||
if (!mindmap || !targetNode) return;
|
||||
|
||||
switch (key) {
|
||||
case "COPY_NODE":
|
||||
mindmap.renderer?.copy?.();
|
||||
break;
|
||||
case "CUT_NODE":
|
||||
mindmap.renderer?.cut?.();
|
||||
break;
|
||||
case "PASTE_NODE":
|
||||
mindmap.renderer?.paste?.();
|
||||
break;
|
||||
case "REMOVE_HYPERLINK":
|
||||
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||
(targetNode as any).setHyperlink("", "");
|
||||
}
|
||||
break;
|
||||
case "REMOVE_NOTE":
|
||||
if (typeof (targetNode as any).setNote === "function") {
|
||||
(targetNode as any).setNote("");
|
||||
}
|
||||
break;
|
||||
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||
const getTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
const nodeText = targetNode.getData?.("text") || "";
|
||||
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||
break;
|
||||
}
|
||||
case "UNEXPAND_ALL":
|
||||
mindmap.execCommand?.(key, false, targetNode);
|
||||
break;
|
||||
case "EXPAND_ALL":
|
||||
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||
break;
|
||||
case "AI_CONTINUE":
|
||||
// 触发 AI 续写
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("mindmap-ai-continue", {
|
||||
detail: { node: targetNode },
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
mindmap.execCommand?.(key);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "COPY_NODE":
|
||||
mindmap.renderer?.copy?.();
|
||||
break;
|
||||
case "CUT_NODE":
|
||||
mindmap.renderer?.cut?.();
|
||||
break;
|
||||
case "PASTE_NODE":
|
||||
mindmap.renderer?.paste?.();
|
||||
break;
|
||||
case "REMOVE_HYPERLINK":
|
||||
if (typeof (targetNode as any).setHyperlink === "function") {
|
||||
(targetNode as any).setHyperlink("", "");
|
||||
}
|
||||
break;
|
||||
case "REMOVE_NOTE":
|
||||
if (typeof (targetNode as any).setNote === "function") {
|
||||
(targetNode as any).setNote("");
|
||||
}
|
||||
break;
|
||||
case "EXPORT_CUR_NODE_TO_PNG": {
|
||||
const getTextFromHtml = (html: string) => {
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
};
|
||||
const nodeText = targetNode.getData?.("text") || "";
|
||||
mindmap.doExport?.export("png", true, getTextFromHtml(String(nodeText)), false, targetNode);
|
||||
break;
|
||||
}
|
||||
case "UNEXPAND_ALL":
|
||||
mindmap.execCommand?.(key, false, targetNode);
|
||||
break;
|
||||
case "EXPAND_ALL":
|
||||
mindmap.execCommand?.(key, (targetNode as any).uid || "");
|
||||
break;
|
||||
case "AI_CONTINUE":
|
||||
// 触发 AI 续写
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("mindmap-ai-continue", {
|
||||
detail: { node: targetNode },
|
||||
})
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
mindmap.execCommand?.(key);
|
||||
break;
|
||||
}
|
||||
|
||||
hide();
|
||||
},
|
||||
@@ -246,275 +246,275 @@ export function MindmapContextMenu({ mindmap }: MindmapContextMenuProps) {
|
||||
// 显示菜单 - 使用 requestAnimationFrame 确保 DOM 更新后再显示
|
||||
const show = useCallback((x: number, y: number, node: MindMapNode) => {
|
||||
setTargetNode(node);
|
||||
|
||||
// 计算可见菜单项数量
|
||||
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(node)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||
const itemHeight = 40;
|
||||
const dividerHeight = 10;
|
||||
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||
}, 0) + 16; // +16 是上下 padding
|
||||
|
||||
const menuWidth = 250;
|
||||
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||
|
||||
// 初始位置:鼠标右侧下方
|
||||
let posX = x + 10;
|
||||
let posY = y + 10;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (posX + menuWidth > window.innerWidth) {
|
||||
posX = x - menuWidth - 20;
|
||||
}
|
||||
|
||||
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||
if (posY + menuHeight > window.innerHeight) {
|
||||
posY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
// 确保不会超出左边界
|
||||
if (posX < 10) {
|
||||
posX = 10;
|
||||
}
|
||||
|
||||
// 确保菜单顶部不会超出窗口
|
||||
if (posY < 10) {
|
||||
posY = 10;
|
||||
}
|
||||
|
||||
setPosition({ x: posX, y: posY });
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// 监听右键事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const handleContextMenu = (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
|
||||
// 检查是否点击在节点上
|
||||
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||
|
||||
// simple-mind-map 的节点结构
|
||||
// 尝试多种选择器
|
||||
const nodeSelectors = [
|
||||
".smm-node", // 主节点容器
|
||||
".smm-node-light", // 亮色主题节点
|
||||
"g[role='node']", // 带 role 属性的 g 元素
|
||||
"g.smooth-smooth", // 特定样式的 g 元素
|
||||
];
|
||||
|
||||
let clickedNodeEl: Element | null = null;
|
||||
for (const selector of nodeSelectors) {
|
||||
clickedNodeEl = target.closest?.(selector) || null;
|
||||
if (clickedNodeEl) break;
|
||||
}
|
||||
|
||||
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||
if (!clickedNodeEl) {
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
// 检查父元素是否包含文本内容
|
||||
const textContainer = parent.querySelector("text");
|
||||
if (textContainer) {
|
||||
clickedNodeEl = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedNodeEl) return;
|
||||
|
||||
// 阻止默认右键菜单
|
||||
mouseEvent.preventDefault();
|
||||
mouseEvent.stopPropagation();
|
||||
|
||||
// 获取当前激活的节点作为右键点击的节点
|
||||
const renderer = mindmap.renderer;
|
||||
if (!renderer) return;
|
||||
|
||||
// 使用 activeNodeList 或 lastActiveNodeList
|
||||
const activeList = renderer.activeNodeList ?? [];
|
||||
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||
|
||||
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||
|
||||
if (node) {
|
||||
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟查找容器,确保 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [mindmap, show]);
|
||||
|
||||
// 监听画布点击事件隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const hideMenu = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
mindmap.on?.("draw_click", hideMenu);
|
||||
mindmap.on?.("node_click", hideMenu);
|
||||
mindmap.on?.("expand_btn_click", hideMenu);
|
||||
|
||||
return () => {
|
||||
mindmap.off?.("draw_click", hideMenu);
|
||||
mindmap.off?.("node_click", hideMenu);
|
||||
mindmap.off?.("expand_btn_click", hideMenu);
|
||||
};
|
||||
}, [mindmap, hide]);
|
||||
|
||||
// 点击外部隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visible, hide]);
|
||||
|
||||
// 渲染菜单
|
||||
const renderMenu = () => {
|
||||
const visibleItems = getVisibleItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mindmap-contextmenu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
maxWidth: "280px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||
padding: "8px 0",
|
||||
fontSize: "14px",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`divider-${index}`}
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "#e5e7eb",
|
||||
margin: "4px 12px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isItemDisabled(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (!item.key) return;
|
||||
executeCommand(item.key);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||
background: "transparent",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = "#f3f4f6";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginLeft: "24px",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === "undefined" || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(renderMenu(), document.body);
|
||||
}
|
||||
|
||||
// 计算可见菜单项数量
|
||||
const visibleItems = NODE_MENU_ITEMS.filter((item) => {
|
||||
if (item.divider) return true;
|
||||
if (item.show && !item.show(node)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 计算实际菜单高度(每个菜单项约40px,分隔符约10px)
|
||||
const itemHeight = 40;
|
||||
const dividerHeight = 10;
|
||||
const estimatedHeight = visibleItems.reduce((acc, item) => {
|
||||
return acc + (item.divider ? dividerHeight : itemHeight);
|
||||
}, 0) + 16; // +16 是上下 padding
|
||||
|
||||
const menuWidth = 250;
|
||||
const menuHeight = estimatedHeight + 20; // 额外的安全边距
|
||||
|
||||
// 初始位置:鼠标右侧下方
|
||||
let posX = x + 10;
|
||||
let posY = y + 10;
|
||||
|
||||
// 如果右侧空间不足,显示在左侧
|
||||
if (posX + menuWidth > window.innerWidth) {
|
||||
posX = x - menuWidth - 20;
|
||||
}
|
||||
|
||||
// 如果下方空间不足,固定到窗口底部(与参考实现一致)
|
||||
if (posY + menuHeight > window.innerHeight) {
|
||||
posY = window.innerHeight - menuHeight - 10;
|
||||
}
|
||||
|
||||
// 确保不会超出左边界
|
||||
if (posX < 10) {
|
||||
posX = 10;
|
||||
}
|
||||
|
||||
// 确保菜单顶部不会超出窗口
|
||||
if (posY < 10) {
|
||||
posY = 10;
|
||||
}
|
||||
|
||||
setPosition({ x: posX, y: posY });
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// 监听右键事件
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const handleContextMenu = (e: Event) => {
|
||||
const mouseEvent = e as MouseEvent;
|
||||
|
||||
// 检查是否点击在节点上
|
||||
const target = mouseEvent.target as HTMLElement | SVGElement;
|
||||
|
||||
// simple-mind-map 的节点结构
|
||||
// 尝试多种选择器
|
||||
const nodeSelectors = [
|
||||
".smm-node", // 主节点容器
|
||||
".smm-node-light", // 亮色主题节点
|
||||
"g[role='node']", // 带 role 属性的 g 元素
|
||||
"g.smooth-smooth", // 特定样式的 g 元素
|
||||
];
|
||||
|
||||
let clickedNodeEl: Element | null = null;
|
||||
for (const selector of nodeSelectors) {
|
||||
clickedNodeEl = target.closest?.(selector) || null;
|
||||
if (clickedNodeEl) break;
|
||||
}
|
||||
|
||||
// 如果没找到节点选择器,尝试查找包含 text 的元素
|
||||
if (!clickedNodeEl) {
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
// 检查父元素是否包含文本内容
|
||||
const textContainer = parent.querySelector("text");
|
||||
if (textContainer) {
|
||||
clickedNodeEl = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!clickedNodeEl) return;
|
||||
|
||||
// 阻止默认右键菜单
|
||||
mouseEvent.preventDefault();
|
||||
mouseEvent.stopPropagation();
|
||||
|
||||
// 获取当前激活的节点作为右键点击的节点
|
||||
const renderer = mindmap.renderer;
|
||||
if (!renderer) return;
|
||||
|
||||
// 使用 activeNodeList 或 lastActiveNodeList
|
||||
const activeList = renderer.activeNodeList ?? [];
|
||||
const lastActiveList = renderer.lastActiveNodeList ?? [];
|
||||
|
||||
const node = (activeList[0] ?? lastActiveList[0] ?? null) as MindMapNode | null;
|
||||
|
||||
if (node) {
|
||||
show(mouseEvent.clientX, mouseEvent.clientY, node);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟查找容器,确保 DOM 已经渲染
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.addEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
const container = document.querySelector("[data-testid='mindmap-canvas']");
|
||||
if (container) {
|
||||
container.removeEventListener("contextmenu", handleContextMenu, true);
|
||||
}
|
||||
};
|
||||
}, [mindmap, show]);
|
||||
|
||||
// 监听画布点击事件隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
|
||||
const hideMenu = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
mindmap.on?.("draw_click", hideMenu);
|
||||
mindmap.on?.("node_click", hideMenu);
|
||||
mindmap.on?.("expand_btn_click", hideMenu);
|
||||
|
||||
return () => {
|
||||
mindmap.off?.("draw_click", hideMenu);
|
||||
mindmap.off?.("node_click", hideMenu);
|
||||
mindmap.off?.("expand_btn_click", hideMenu);
|
||||
};
|
||||
}, [mindmap, hide]);
|
||||
|
||||
// 点击外部隐藏菜单
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, [visible, hide]);
|
||||
|
||||
// 渲染菜单
|
||||
const renderMenu = () => {
|
||||
const visibleItems = getVisibleItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="mindmap-contextmenu"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
zIndex: 9999,
|
||||
minWidth: "200px",
|
||||
maxWidth: "280px",
|
||||
background: "#ffffff",
|
||||
borderRadius: "8px",
|
||||
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.15)",
|
||||
padding: "8px 0",
|
||||
fontSize: "14px",
|
||||
fontFamily:
|
||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return (
|
||||
<div
|
||||
key={`divider-${index}`}
|
||||
style={{
|
||||
height: "1px",
|
||||
background: "#e5e7eb",
|
||||
margin: "4px 12px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isItemDisabled(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key || `item-${index}`}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
if (!item.key) return;
|
||||
executeCommand(item.key);
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "8px 16px",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
color: item.danger ? "#ef4444" : disabled ? "#9ca3af" : "#1f2937",
|
||||
background: "transparent",
|
||||
transition: "background 0.1s",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.background = "#f3f4f6";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "transparent";
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "14px" }}>{item.label || ""}</span>
|
||||
{item.shortcut && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#9ca3af",
|
||||
marginLeft: "24px",
|
||||
}}
|
||||
>
|
||||
{item.shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (typeof document === "undefined" || !visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(renderMenu(), document.body);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,211 +1,211 @@
|
||||
import React from "react";
|
||||
import {
|
||||
fileToolbarMeta,
|
||||
fileToolbarOrder,
|
||||
nodeToolbarMeta,
|
||||
nodeToolbarOrder,
|
||||
type FileToolbarKey,
|
||||
type NodeToolbarKey,
|
||||
} from "./mindmapToolbarConfig";
|
||||
|
||||
const stopEditorEvent = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
canBack: boolean;
|
||||
canForward: boolean;
|
||||
painterMode: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPainter: () => void;
|
||||
onSibling: () => void;
|
||||
onChild: () => void;
|
||||
onDelete: () => void;
|
||||
onImage: () => void;
|
||||
onIcon: () => void;
|
||||
onLink: () => void;
|
||||
onNote: () => void;
|
||||
onTag: () => void;
|
||||
onSummary: () => void;
|
||||
onAssociativeLine: () => void;
|
||||
onFormula: () => void;
|
||||
onAttachment: () => void;
|
||||
onOuterFrame: () => void;
|
||||
onAnnotation?: () => void;
|
||||
onAi: () => void;
|
||||
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onNew: () => void;
|
||||
onOpenDirectory: () => void;
|
||||
onSaveAs: () => void;
|
||||
onDeleteMindmap: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportSvg: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
import React from "react";
|
||||
import {
|
||||
fileToolbarMeta,
|
||||
fileToolbarOrder,
|
||||
nodeToolbarMeta,
|
||||
nodeToolbarOrder,
|
||||
type FileToolbarKey,
|
||||
type NodeToolbarKey,
|
||||
} from "./mindmapToolbarConfig";
|
||||
|
||||
const stopEditorEvent = (e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
canBack: boolean;
|
||||
canForward: boolean;
|
||||
painterMode: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPainter: () => void;
|
||||
onSibling: () => void;
|
||||
onChild: () => void;
|
||||
onDelete: () => void;
|
||||
onImage: () => void;
|
||||
onIcon: () => void;
|
||||
onLink: () => void;
|
||||
onNote: () => void;
|
||||
onTag: () => void;
|
||||
onSummary: () => void;
|
||||
onAssociativeLine: () => void;
|
||||
onFormula: () => void;
|
||||
onAttachment: () => void;
|
||||
onOuterFrame: () => void;
|
||||
onAnnotation?: () => void;
|
||||
onAi: () => void;
|
||||
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onNew: () => void;
|
||||
onOpenDirectory: () => void;
|
||||
onSaveAs: () => void;
|
||||
onDeleteMindmap: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportPng: () => void;
|
||||
onExportSvg: () => void;
|
||||
onExportPdf: () => void;
|
||||
onExportMd: () => void;
|
||||
onExportTxt: () => void;
|
||||
onExportXmind: () => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
};
|
||||
|
||||
const ToolbarButton = ({
|
||||
iconClass,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
className = "",
|
||||
}: {
|
||||
iconClass: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
||||
} ${className}`}
|
||||
title={label}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
|
||||
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const MindmapToolbar = ({
|
||||
canBack,
|
||||
canForward,
|
||||
painterMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPainter,
|
||||
onSibling,
|
||||
onChild,
|
||||
onDelete,
|
||||
onImage,
|
||||
onIcon,
|
||||
onLink,
|
||||
onNote,
|
||||
onTag,
|
||||
onSummary,
|
||||
onAssociativeLine,
|
||||
onFormula,
|
||||
onAttachment,
|
||||
onOuterFrame,
|
||||
onAnnotation,
|
||||
onAi,
|
||||
onImport,
|
||||
onNew,
|
||||
onOpenDirectory,
|
||||
onSaveAs,
|
||||
onDeleteMindmap,
|
||||
onExportJson,
|
||||
onExportPng,
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportMd,
|
||||
onExportTxt,
|
||||
onExportXmind,
|
||||
fileInputRef,
|
||||
}: ToolbarProps) => {
|
||||
const [showExport, setShowExport] = React.useState(false);
|
||||
|
||||
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
||||
back: onUndo,
|
||||
forward: onRedo,
|
||||
painter: onPainter,
|
||||
siblingNode: onSibling,
|
||||
childNode: onChild,
|
||||
deleteNode: onDelete,
|
||||
image: onImage,
|
||||
icon: onIcon,
|
||||
link: onLink,
|
||||
note: onNote,
|
||||
tag: onTag,
|
||||
summary: onSummary,
|
||||
associativeLine: onAssociativeLine,
|
||||
formula: onFormula,
|
||||
attachment: onAttachment,
|
||||
outerFrame: onOuterFrame,
|
||||
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
|
||||
ai: onAi,
|
||||
};
|
||||
|
||||
const fileHandlers: Record<FileToolbarKey, () => void> = {
|
||||
directory: onOpenDirectory,
|
||||
newFile: onNew,
|
||||
openFile: () => fileInputRef.current?.click(),
|
||||
import: () => fileInputRef.current?.click(),
|
||||
saveAs: onSaveAs,
|
||||
deleteFile: onDeleteMindmap,
|
||||
exportMenu: () => setShowExport((v) => !v),
|
||||
};
|
||||
|
||||
const getNodeDisabled = (key: NodeToolbarKey) => {
|
||||
if (key === "back") return !canBack;
|
||||
if (key === "forward") return !canForward;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
contentEditable={false}
|
||||
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Left Section: Edit & Node Operations */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{nodeToolbarOrder.map((key) => {
|
||||
const meta = nodeToolbarMeta[key];
|
||||
const onClick = nodeHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
disabled={getNodeDisabled(key)}
|
||||
active={key === "painter" ? painterMode : false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Section: File & Export Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{fileToolbarOrder.map((key) => {
|
||||
const meta = fileToolbarMeta[key];
|
||||
const onClick = fileHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
const ToolbarButton = ({
|
||||
iconClass,
|
||||
label,
|
||||
onClick,
|
||||
disabled = false,
|
||||
active = false,
|
||||
className = "",
|
||||
}: {
|
||||
iconClass: string;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
||||
} ${className}`}
|
||||
title={label}
|
||||
>
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border shadow-sm transition-colors ${
|
||||
active ? "border-blue-200 bg-white" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<i className={`iconfont ${iconClass} text-[15px] leading-none`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium leading-tight whitespace-nowrap">
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
export const MindmapToolbar = ({
|
||||
canBack,
|
||||
canForward,
|
||||
painterMode,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPainter,
|
||||
onSibling,
|
||||
onChild,
|
||||
onDelete,
|
||||
onImage,
|
||||
onIcon,
|
||||
onLink,
|
||||
onNote,
|
||||
onTag,
|
||||
onSummary,
|
||||
onAssociativeLine,
|
||||
onFormula,
|
||||
onAttachment,
|
||||
onOuterFrame,
|
||||
onAnnotation,
|
||||
onAi,
|
||||
onImport,
|
||||
onNew,
|
||||
onOpenDirectory,
|
||||
onSaveAs,
|
||||
onDeleteMindmap,
|
||||
onExportJson,
|
||||
onExportPng,
|
||||
onExportSvg,
|
||||
onExportPdf,
|
||||
onExportMd,
|
||||
onExportTxt,
|
||||
onExportXmind,
|
||||
fileInputRef,
|
||||
}: ToolbarProps) => {
|
||||
const [showExport, setShowExport] = React.useState(false);
|
||||
|
||||
const nodeHandlers: Record<NodeToolbarKey, () => void> = {
|
||||
back: onUndo,
|
||||
forward: onRedo,
|
||||
painter: onPainter,
|
||||
siblingNode: onSibling,
|
||||
childNode: onChild,
|
||||
deleteNode: onDelete,
|
||||
image: onImage,
|
||||
icon: onIcon,
|
||||
link: onLink,
|
||||
note: onNote,
|
||||
tag: onTag,
|
||||
summary: onSummary,
|
||||
associativeLine: onAssociativeLine,
|
||||
formula: onFormula,
|
||||
attachment: onAttachment,
|
||||
outerFrame: onOuterFrame,
|
||||
annotation: onAnnotation ?? (() => window.alert("标注功能开发中")),
|
||||
ai: onAi,
|
||||
};
|
||||
|
||||
const fileHandlers: Record<FileToolbarKey, () => void> = {
|
||||
directory: onOpenDirectory,
|
||||
newFile: onNew,
|
||||
openFile: () => fileInputRef.current?.click(),
|
||||
import: () => fileInputRef.current?.click(),
|
||||
saveAs: onSaveAs,
|
||||
deleteFile: onDeleteMindmap,
|
||||
exportMenu: () => setShowExport((v) => !v),
|
||||
};
|
||||
|
||||
const getNodeDisabled = (key: NodeToolbarKey) => {
|
||||
if (key === "back") return !canBack;
|
||||
if (key === "forward") return !canForward;
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
contentEditable={false}
|
||||
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Left Section: Edit & Node Operations */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{nodeToolbarOrder.map((key) => {
|
||||
const meta = nodeToolbarMeta[key];
|
||||
const onClick = nodeHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
disabled={getNodeDisabled(key)}
|
||||
active={key === "painter" ? painterMode : false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right Section: File & Export Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||
{fileToolbarOrder.map((key) => {
|
||||
const meta = fileToolbarMeta[key];
|
||||
const onClick = fileHandlers[key];
|
||||
return (
|
||||
<ToolbarButton
|
||||
key={key}
|
||||
iconClass={meta.iconClass}
|
||||
label={meta.label}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -213,37 +213,37 @@ export const MindmapToolbar = ({
|
||||
className="hidden"
|
||||
onChange={onImport}
|
||||
/>
|
||||
{showExport ? (
|
||||
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{[
|
||||
{ label: "JSON", onClick: onExportJson },
|
||||
{ label: "PNG", onClick: onExportPng },
|
||||
{ label: "SVG", onClick: onExportSvg },
|
||||
{ label: "PDF", onClick: onExportPdf },
|
||||
{ label: "Markdown", onClick: onExportMd },
|
||||
{ label: "TXT", onClick: onExportTxt },
|
||||
{ label: "XMind", onClick: onExportXmind },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
setShowExport(false);
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<i className="iconfont iconexport text-[12px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{showExport ? (
|
||||
<div className="absolute right-2 top-full z-50 mt-2 w-40 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg">
|
||||
{[
|
||||
{ label: "JSON", onClick: onExportJson },
|
||||
{ label: "PNG", onClick: onExportPng },
|
||||
{ label: "SVG", onClick: onExportSvg },
|
||||
{ label: "PDF", onClick: onExportPdf },
|
||||
{ label: "Markdown", onClick: onExportMd },
|
||||
{ label: "TXT", onClick: onExportTxt },
|
||||
{ label: "XMind", onClick: onExportXmind },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
contentEditable={false}
|
||||
onPointerDown={stopEditorEvent}
|
||||
onMouseDown={stopEditorEvent}
|
||||
onClick={(e) => {
|
||||
stopEditorEvent(e);
|
||||
setShowExport(false);
|
||||
item.onClick();
|
||||
}}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<i className="iconfont iconexport text-[12px]" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
"use client";
|
||||
|
||||
"use client";
|
||||
|
||||
import { BlockNoteEditor, Block } from "@blocknote/core";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import CompactTablePreview from "@/components/online-table/CompactTablePreview";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
|
||||
const DEFAULT_WIDTH = 960;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 420;
|
||||
const MAX_WIDTH = 1400;
|
||||
const MIN_HEIGHT = 320;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
type ResizeHandle =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
const handleMapping: Record<
|
||||
ResizeHandle,
|
||||
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
|
||||
> = {
|
||||
left: { horizontal: "left" },
|
||||
right: { horizontal: "right" },
|
||||
top: { vertical: "top" },
|
||||
bottom: { vertical: "bottom" },
|
||||
"top-left": { horizontal: "left", vertical: "top" },
|
||||
"top-right": { horizontal: "right", vertical: "top" },
|
||||
"bottom-left": { horizontal: "left", vertical: "bottom" },
|
||||
"bottom-right": { horizontal: "right", vertical: "bottom" },
|
||||
};
|
||||
|
||||
// 占位符组件:在紧凑模式下渲染表格块
|
||||
|
||||
const DEFAULT_WIDTH = 960;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const MIN_WIDTH = 420;
|
||||
const MAX_WIDTH = 1400;
|
||||
const MIN_HEIGHT = 320;
|
||||
const MAX_HEIGHT = 900;
|
||||
|
||||
type ResizeHandle =
|
||||
| "left"
|
||||
| "right"
|
||||
| "top"
|
||||
| "bottom"
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right";
|
||||
|
||||
const handleMapping: Record<
|
||||
ResizeHandle,
|
||||
{ horizontal?: "left" | "right"; vertical?: "top" | "bottom" }
|
||||
> = {
|
||||
left: { horizontal: "left" },
|
||||
right: { horizontal: "right" },
|
||||
top: { vertical: "top" },
|
||||
bottom: { vertical: "bottom" },
|
||||
"top-left": { horizontal: "left", vertical: "top" },
|
||||
"top-right": { horizontal: "right", vertical: "top" },
|
||||
"bottom-left": { horizontal: "left", vertical: "bottom" },
|
||||
"bottom-right": { horizontal: "right", vertical: "bottom" },
|
||||
};
|
||||
|
||||
// 占位符组件:在紧凑模式下渲染表格块
|
||||
const OnlineTableBlockComponent = ({
|
||||
block,
|
||||
editor,
|
||||
}: any) => {
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const { tableId } = block.props;
|
||||
const openTableFullScreen = useEditorBridgeStore((state) => state.bridge?.openTableFullScreen);
|
||||
const storedWidth = typeof block.props.width === "number" ? block.props.width : undefined;
|
||||
const storedHeight = typeof block.props.height === "number" ? block.props.height : undefined;
|
||||
const [activeHandle, setActiveHandle] = useState<ResizeHandle | null>(null);
|
||||
@@ -67,27 +67,27 @@ const OnlineTableBlockComponent = ({
|
||||
(next: { width: number; height: number }) => {
|
||||
setDraftSize(next);
|
||||
editor.updateBlock(block, {
|
||||
props: {
|
||||
...block.props,
|
||||
width: next.width,
|
||||
props: {
|
||||
...block.props,
|
||||
width: next.width,
|
||||
height: next.height,
|
||||
},
|
||||
});
|
||||
},
|
||||
[block, editor],
|
||||
);
|
||||
|
||||
// 阶段三:实现双击/按钮进入全屏编辑
|
||||
const handleFullScreen = () => {
|
||||
if (openTableFullScreen) {
|
||||
openTableFullScreen(tableId);
|
||||
} else {
|
||||
console.error("Editor bridge not ready or openTableFullScreen missing.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.removeBlocks([block.id]);
|
||||
|
||||
// 阶段三:实现双击/按钮进入全屏编辑
|
||||
const handleFullScreen = () => {
|
||||
if (openTableFullScreen) {
|
||||
openTableFullScreen(tableId);
|
||||
} else {
|
||||
console.error("Editor bridge not ready or openTableFullScreen missing.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
editor.removeBlocks([block.id]);
|
||||
}, [block.id, editor]);
|
||||
|
||||
const startResize = useCallback(
|
||||
@@ -107,53 +107,53 @@ const OnlineTableBlockComponent = ({
|
||||
const cursor =
|
||||
axes.horizontal && axes.vertical
|
||||
? axes.horizontal === "left"
|
||||
? axes.vertical === "top"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
: axes.vertical === "top"
|
||||
? "nesw-resize"
|
||||
: "nwse-resize"
|
||||
: axes.horizontal
|
||||
? "ew-resize"
|
||||
: "ns-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
if (axes.horizontal === "left") {
|
||||
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else if (axes.horizontal === "right") {
|
||||
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else {
|
||||
nextWidth = startWidth;
|
||||
}
|
||||
|
||||
if (axes.vertical === "top") {
|
||||
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else if (axes.vertical === "bottom") {
|
||||
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else {
|
||||
nextHeight = startHeight;
|
||||
}
|
||||
setDraftSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
setActiveHandle(null);
|
||||
commitSize({
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
});
|
||||
};
|
||||
|
||||
? axes.vertical === "top"
|
||||
? "nwse-resize"
|
||||
: "nesw-resize"
|
||||
: axes.vertical === "top"
|
||||
? "nesw-resize"
|
||||
: "nwse-resize"
|
||||
: axes.horizontal
|
||||
? "ew-resize"
|
||||
: "ns-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
if (axes.horizontal === "left") {
|
||||
nextWidth = clamp(startWidth - deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else if (axes.horizontal === "right") {
|
||||
nextWidth = clamp(startWidth + deltaX, MIN_WIDTH, MAX_WIDTH);
|
||||
} else {
|
||||
nextWidth = startWidth;
|
||||
}
|
||||
|
||||
if (axes.vertical === "top") {
|
||||
nextHeight = clamp(startHeight - deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else if (axes.vertical === "bottom") {
|
||||
nextHeight = clamp(startHeight + deltaY, MIN_HEIGHT, MAX_HEIGHT);
|
||||
} else {
|
||||
nextHeight = startHeight;
|
||||
}
|
||||
setDraftSize({
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
window.removeEventListener("mouseup", handleUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
setActiveHandle(null);
|
||||
commitSize({
|
||||
width: Math.round(nextWidth),
|
||||
height: Math.round(nextHeight),
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
window.addEventListener("mouseup", handleUp);
|
||||
},
|
||||
@@ -167,90 +167,90 @@ const OnlineTableBlockComponent = ({
|
||||
height: clamp(src.height, MIN_HEIGHT, MAX_HEIGHT),
|
||||
};
|
||||
}, [activeHandle, committedSize, draftSize]);
|
||||
|
||||
const handleClass = (handle: ResizeHandle) =>
|
||||
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
|
||||
activeHandle === handle ? "is-dragging" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto" contentEditable={false}>
|
||||
<div
|
||||
className="online-table-block group relative mx-auto"
|
||||
style={{ width: size.width, minWidth: MIN_WIDTH }}
|
||||
>
|
||||
<CompactTablePreview
|
||||
tableId={tableId}
|
||||
onFullScreen={handleFullScreen}
|
||||
onDelete={handleDelete}
|
||||
height={size.height}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向左拖拽以调整宽度"
|
||||
className={handleClass("left")}
|
||||
onMouseDown={startResize("left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向右拖拽以调整宽度"
|
||||
className={handleClass("right")}
|
||||
onMouseDown={startResize("right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向上拖拽以调整高度"
|
||||
className={handleClass("top")}
|
||||
onMouseDown={startResize("top")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向下拖拽以调整高度"
|
||||
className={handleClass("bottom")}
|
||||
onMouseDown={startResize("bottom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-left")}
|
||||
onMouseDown={startResize("top-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-right")}
|
||||
onMouseDown={startResize("top-right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-left")}
|
||||
onMouseDown={startResize("bottom-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-right")}
|
||||
onMouseDown={startResize("bottom-right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Block Spec 定义
|
||||
export const onlineTableBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "onlineTable",
|
||||
propSchema: {
|
||||
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
|
||||
title: { default: "未命名表格" },
|
||||
width: { default: DEFAULT_WIDTH },
|
||||
height: { default: DEFAULT_HEIGHT },
|
||||
},
|
||||
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
|
||||
},
|
||||
{
|
||||
render: (props) => <OnlineTableBlockComponent {...props} />,
|
||||
}
|
||||
);
|
||||
|
||||
const handleClass = (handle: ResizeHandle) =>
|
||||
`wolai-table-resize-handle wolai-table-resize-handle--${handle} ${
|
||||
activeHandle === handle ? "is-dragging" : ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-auto" contentEditable={false}>
|
||||
<div
|
||||
className="online-table-block group relative mx-auto"
|
||||
style={{ width: size.width, minWidth: MIN_WIDTH }}
|
||||
>
|
||||
<CompactTablePreview
|
||||
tableId={tableId}
|
||||
onFullScreen={handleFullScreen}
|
||||
onDelete={handleDelete}
|
||||
height={size.height}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向左拖拽以调整宽度"
|
||||
className={handleClass("left")}
|
||||
onMouseDown={startResize("left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向右拖拽以调整宽度"
|
||||
className={handleClass("right")}
|
||||
onMouseDown={startResize("right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向上拖拽以调整高度"
|
||||
className={handleClass("top")}
|
||||
onMouseDown={startResize("top")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="向下拖拽以调整高度"
|
||||
className={handleClass("bottom")}
|
||||
onMouseDown={startResize("bottom")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-left")}
|
||||
onMouseDown={startResize("top-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("top-right")}
|
||||
onMouseDown={startResize("top-right")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-left")}
|
||||
onMouseDown={startResize("bottom-left")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽以调整宽高"
|
||||
className={handleClass("bottom-right")}
|
||||
onMouseDown={startResize("bottom-right")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Block Spec 定义
|
||||
export const onlineTableBlock = createReactBlockSpec(
|
||||
{
|
||||
type: "onlineTable",
|
||||
propSchema: {
|
||||
tableId: { default: "new-table-id" }, // 应该在创建时被覆盖
|
||||
title: { default: "未命名表格" },
|
||||
width: { default: DEFAULT_WIDTH },
|
||||
height: { default: DEFAULT_HEIGHT },
|
||||
},
|
||||
content: "inline", // 允许内联内容,但通常表格块不会有太多内联内容
|
||||
},
|
||||
{
|
||||
render: (props) => <OnlineTableBlockComponent {...props} />,
|
||||
}
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user