"use client"; import { useEffect, useRef, useState, useMemo, 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, 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"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { CustomBlockSchema } from "../schema"; type MediaAlign = "left" | "center" | "right"; type MediaBlockRenderProps = { block: Block; editor: BlockNoteEditor; }; const TYPE_LABEL_MAP: Record = { 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 }: MediaBlockRenderProps) => { 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); const [localWidth, setLocalWidth] = useState(() => (block.props.width ? Number(block.props.width) : 0)); const mediaRef = useRef(null); const latestWidthRef = useRef(localWidth); const displayFileName = block.props.fileName ?? deriveFileName(fileUrl); const captionRef = useRef(null); const [captionEditing, setCaptionEditing] = useState(false); const shouldShowCaption = captionEditing || Boolean(block.props.caption); 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", }, }); }, }); }; 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, 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"); }; const downloadAsset = () => { if (!fileUrl) return; const anchor = document.createElement("a"); anchor.href = fileUrl; anchor.download = block.props.fileName || block.props.caption || typeLabel; anchor.click(); }; 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 (

支持上传、最近及外链插入

); } const renderPreviewContent = () => { if (assetType === "video") { return ( ); } if (assetType === "audio") { return (

{displayFileName}

); } if (assetType === "file") { return (

{displayFileName}

{block.props.fileSize ? (

{formatFileSize(block.props.fileSize)}

) : null}
); } const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined; return {block.props.caption; }; const figure = (
{renderPreviewContent()}
{shouldShowCaption && (
handleCaptionChange(event.target.value)} onBlur={() => setCaptionEditing(false)} placeholder={assetType === "file" ? "添加文件说明..." : "添加说明..."} className="w-full border-none bg-transparent text-sm text-[#475569] outline-none" />
)}
); type QuickAction = { key: string; label: string; icon: JSX.Element; onClick: () => void }; const quickActions: QuickAction[] = [ { key: "replace", label: `替换${typeLabel}`, icon: , onClick: handleChoose, }, canToggleBorder ? { key: "border", label: block.props.hasBorder ? "取消边框" : "显示边框", icon: , onClick: toggleBorder, } : null, !shouldShowCaption ? { key: "caption", label: "添加说明", icon: , onClick: enableCaptionEdit, } : null, { key: "link", label: block.props.linkUrl ? "编辑链接" : "添加链接", icon: , onClick: handleLink, }, { key: "download", label: `下载${typeLabel}`, icon: , onClick: downloadAsset, }, ].filter((action): action is QuickAction => Boolean(action)); const dropdownLinkLabel = block.props.linkUrl ? "编辑链接" : "添加链接"; return (
{block.props.linkUrl ? ( {figure} ) : ( figure )}
{quickActions.map((action) => ( ))} 替换资源 {!shouldShowCaption && ( 添加说明 )} {canToggleBorder && ( {block.props.hasBorder ? "取消边框" : "显示边框"} )} {canAlign && ( <> 说明对齐 setAlign("left")}>左对齐 setAlign("center")}>居中 setAlign("right")}>右对齐 )} {dropdownLinkLabel} handleCopyLink(fileUrl)}>复制链接 查看原文件 下载到本地 {canTriggerOcr && ( <> {busy ? "OCR 进行中..." : "触发 OCR 识别"} )}
{canResize && ( <> handleResizeStart(event, "left")} /> handleResizeStart(event, "right")} /> )}
{block.props.ocrStatus === "processing" ? "OCR 处理中..." : ""}
); }; 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" }, }, content: "none", }, { render: (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) => void; }) => ( ); const clampWidth = (value: number) => { const min = 240; const max = 960; if (Number.isNaN(value)) return min; return Math.max(min, Math.min(max, value)); };