2025-11-23 10:55:04 +08:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
useMemo,
|
2026-01-02 07:25:50 +08:00
|
|
|
useCallback,
|
2025-11-23 10:55:04 +08:00
|
|
|
type JSX,
|
|
|
|
|
type MouseEvent as ReactMouseEvent,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { createReactBlockSpec } from "@blocknote/react";
|
|
|
|
|
import type { Block, BlockNoteEditor } from "@blocknote/core";
|
2026-01-02 07:25:50 +08:00
|
|
|
import { Download, Image as ImageIcon, Link as LinkIcon, MoreHorizontal, Paperclip, RefreshCcw, Trash, Type } from "lucide-react";
|
2025-11-23 10:55:04 +08:00
|
|
|
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";
|
2026-01-02 07:25:50 +08:00
|
|
|
import { emitAssetsChanged } from "@/lib/events";
|
2026-01-15 20:54:21 +08:00
|
|
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
2025-11-23 10:55:04 +08:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuLabel,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu";
|
|
|
|
|
import type { CustomBlockSchema } from "../schema";
|
|
|
|
|
|
|
|
|
|
type MediaAlign = "left" | "center" | "right";
|
|
|
|
|
|
|
|
|
|
type MediaBlockRenderProps = {
|
2026-01-08 06:28:14 +08:00
|
|
|
block: Block<CustomBlockSchema> & { props: any };
|
2025-11-23 10:55:04 +08:00
|
|
|
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]}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
const MediaBlockContent = ({ block, editor }: any) => {
|
2025-11-23 10:55:04 +08:00
|
|
|
const { openPicker } = useImagePicker();
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const fileUrl = block.props.fileUrl as string;
|
|
|
|
|
const rawAssetType = (block.props.assetType as string) || "image";
|
|
|
|
|
const assetType: MediaKind =
|
|
|
|
|
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);
|
2025-12-26 07:52:40 +08:00
|
|
|
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
|
2026-01-15 20:54:21 +08:00
|
|
|
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
2026-01-02 07:25:50 +08:00
|
|
|
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]);
|
2025-12-26 07:52:40 +08:00
|
|
|
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(
|
|
|
|
|
() =>
|
2026-01-11 12:35:53 +08:00
|
|
|
["doc", "docx", "rtf", "ppt", "pptx", "xls", "xlsx", "odp", "ods", "odt", "pdf", "csv"].includes(
|
2025-12-26 07:52:40 +08:00
|
|
|
extension,
|
|
|
|
|
),
|
|
|
|
|
[extension],
|
|
|
|
|
);
|
2025-11-23 10:55:04 +08:00
|
|
|
|
|
|
|
|
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 ?? "",
|
2026-01-02 07:25:50 +08:00
|
|
|
fileSize: selection.fileSize ?? block.props.fileSize ?? null,
|
2025-11-23 10:55:04 +08:00
|
|
|
mimeType: selection.mimeType ?? block.props.mimeType ?? "",
|
|
|
|
|
ocrStatus: "idle",
|
2026-01-02 07:25:50 +08:00
|
|
|
documentId: resolveDocumentId(),
|
2025-11-23 10:55:04 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
editor.updateBlock(block, { props: { linkUrl: next.trim() } });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const viewOriginal = () => {
|
|
|
|
|
if (!fileUrl) return;
|
|
|
|
|
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-26 07:52:40 +08:00
|
|
|
const openWithOnlyOffice = async () => {
|
|
|
|
|
if (!fileUrl) return;
|
|
|
|
|
if (!officeBase) {
|
|
|
|
|
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-01-15 20:54:21 +08:00
|
|
|
const assetId = (block.props as { assetId?: string })?.assetId;
|
|
|
|
|
const res = assetId
|
|
|
|
|
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
|
|
|
|
|
: await fetch(
|
|
|
|
|
`/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent(
|
|
|
|
|
displayFileName,
|
|
|
|
|
)}&for=onlyoffice`,
|
|
|
|
|
);
|
2025-12-26 07:52:40 +08:00
|
|
|
if (!res.ok) {
|
|
|
|
|
const payload = await res.json().catch(() => null);
|
|
|
|
|
throw new Error(payload?.error ?? "生成签名链接失败");
|
|
|
|
|
}
|
|
|
|
|
const { signedUrl } = (await res.json()) as { signedUrl: string };
|
|
|
|
|
const target = new URL("/onlyoffice", window.location.origin);
|
|
|
|
|
target.searchParams.set("fileUrl", signedUrl);
|
|
|
|
|
target.searchParams.set("fileName", displayFileName);
|
|
|
|
|
target.searchParams.set("fileType", extension || "docx");
|
|
|
|
|
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
window.alert((error as Error).message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
const downloadAsset = () => {
|
|
|
|
|
if (!fileUrl) return;
|
|
|
|
|
const anchor = document.createElement("a");
|
|
|
|
|
anchor.href = fileUrl;
|
2025-12-26 07:52:40 +08:00
|
|
|
anchor.download = block.props.fileName || block.props.caption || typeLabel;
|
2025-11-23 10:55:04 +08:00
|
|
|
anchor.click();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
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 (
|
|
|
|
|
<video
|
|
|
|
|
controls
|
|
|
|
|
className="max-h-[420px] w-full rounded-2xl bg-black"
|
|
|
|
|
poster={block.props.thumbnailUrl || undefined}
|
|
|
|
|
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
|
|
|
|
>
|
|
|
|
|
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
|
|
|
|
|
</video>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (assetType === "audio") {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
|
|
|
|
|
<audio controls className="w-full">
|
|
|
|
|
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
|
|
|
|
</audio>
|
|
|
|
|
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (assetType === "file") {
|
2026-01-18 19:01:31 +08:00
|
|
|
// 根据文件扩展名确定图标颜色
|
|
|
|
|
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]";
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-23 10:55:04 +08:00
|
|
|
return (
|
2026-01-18 19:01:31 +08:00
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (isOfficeDoc) {
|
|
|
|
|
void openWithOnlyOffice();
|
|
|
|
|
} else {
|
|
|
|
|
downloadAsset();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (isOfficeDoc) {
|
|
|
|
|
void openWithOnlyOffice();
|
|
|
|
|
} else {
|
|
|
|
|
downloadAsset();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="group flex items-center gap-2 px-3 py-2 rounded-[4px] bg-white hover:bg-[#F5F5F5] transition-colors duration-200 cursor-pointer select-none"
|
|
|
|
|
>
|
|
|
|
|
<span className={cn("w-5 h-5 shrink-0 flex items-center justify-center", getIconColor())}>
|
|
|
|
|
<Paperclip className="h-4 w-4" />
|
2025-11-23 10:55:04 +08:00
|
|
|
</span>
|
2026-01-18 19:01:31 +08:00
|
|
|
<div className="flex flex-col justify-center gap-0.5 overflow-hidden">
|
|
|
|
|
<p className="text-[14px] text-[#37352F] font-normal truncate">{displayFileName}</p>
|
2025-11-23 10:55:04 +08:00
|
|
|
{block.props.fileSize ? (
|
2026-01-18 19:01:31 +08:00
|
|
|
<p className="text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</p>
|
2025-11-23 10:55:04 +08:00
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
|
|
|
|
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: "download",
|
|
|
|
|
label: `下载${typeLabel}`,
|
|
|
|
|
icon: <Download className="h-4 w-4" />,
|
|
|
|
|
onClick: downloadAsset,
|
|
|
|
|
},
|
2026-01-02 07:25:50 +08:00
|
|
|
{
|
|
|
|
|
key: "delete",
|
|
|
|
|
label: `删除${typeLabel}`,
|
|
|
|
|
icon: <Trash className="h-4 w-4" />,
|
|
|
|
|
onClick: handleDeleteAsset,
|
|
|
|
|
},
|
2025-11-23 10:55:04 +08:00
|
|
|
].filter((action): action is QuickAction => Boolean(action));
|
|
|
|
|
|
|
|
|
|
const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="wolai-media" ref={mediaRef}>
|
2025-12-26 07:52:40 +08:00
|
|
|
<div
|
|
|
|
|
className="wolai-media__canvas"
|
|
|
|
|
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
|
|
|
|
onDoubleClick={() => {
|
|
|
|
|
if (assetType === "file" && isOfficeDoc) {
|
|
|
|
|
void openWithOnlyOffice();
|
|
|
|
|
} else if (assetType === "file") {
|
|
|
|
|
viewOriginal();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
2025-11-23 10:55:04 +08:00
|
|
|
{block.props.linkUrl ? (
|
|
|
|
|
<a href={block.props.linkUrl} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
{figure}
|
|
|
|
|
</a>
|
|
|
|
|
) : (
|
|
|
|
|
figure
|
|
|
|
|
)}
|
|
|
|
|
<div className="wolai-media__quickbar">
|
|
|
|
|
{quickActions.map((action) => (
|
|
|
|
|
<button
|
|
|
|
|
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 />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuItem onClick={viewOriginal}>查看原文件</DropdownMenuItem>
|
2025-12-26 07:52:40 +08:00
|
|
|
{assetType === "file" && isOfficeDoc && (
|
|
|
|
|
<DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>
|
|
|
|
|
)}
|
2025-11-23 10:55:04 +08:00
|
|
|
<DropdownMenuItem onClick={downloadAsset}>下载到本地</DropdownMenuItem>
|
|
|
|
|
{canTriggerOcr && (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem disabled={busy} onClick={triggerOcr}>
|
|
|
|
|
{busy ? "OCR 进行中..." : "触发 OCR 识别"}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
{canResize && (
|
|
|
|
|
<>
|
|
|
|
|
<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" },
|
2026-01-02 07:25:50 +08:00
|
|
|
documentId: { default: "", type: "string" },
|
2025-11-23 10:55:04 +08:00
|
|
|
},
|
|
|
|
|
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));
|
|
|
|
|
};
|