git save current version as 0.0.8
This commit is contained in:
@@ -16,6 +16,7 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"public/luckysheet/**",
|
||||
"public/pdf.worker.min.js",
|
||||
]),
|
||||
{
|
||||
rules: {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "16.0.3",
|
||||
"papaparse": "^5.5.3",
|
||||
"pdfjs-dist": "^5.4.449",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
|
||||
Generated
+119
@@ -116,6 +116,9 @@ importers:
|
||||
papaparse:
|
||||
specifier: ^5.5.3
|
||||
version: 5.5.3
|
||||
pdfjs-dist:
|
||||
specifier: ^5.4.449
|
||||
version: 5.4.449
|
||||
react:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0
|
||||
@@ -855,6 +858,70 @@ packages:
|
||||
'@types/react': '>=16'
|
||||
react: '>=16'
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.83':
|
||||
resolution: {integrity: sha512-TbKM2fh9zXjqFIU8bgMfzG7rkrIYdLKMafgPhFoPwKrpWk1glGbWP7LEu8Y/WrMDqTGFdRqUmuX89yQEzZbkiw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.83':
|
||||
resolution: {integrity: sha512-gp8IDVUloPUmkepHly4xRUOfUJSFNvA4jR7ZRF5nk3YcGzegSFGeICiT4PnYyPgSKEhYAFe1Y2XNy0Mp6Tu8mQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.83':
|
||||
resolution: {integrity: sha512-r4ZJxiP9OgUbdGZhPDEXD3hQ0aIPcVaywtcTXvamYxTU/SWKAbKVhFNTtpRe1J30oQ25gWyxTkUKSBgUkNzdnw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.83':
|
||||
resolution: {integrity: sha512-Uc6aSB05qH1r+9GUDxIE6F5ZF7L0nTFyyzq8ublWUZhw8fEGK8iy931ff1ByGFT04+xHJad1kBcL4R1ZEV8z7Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.83':
|
||||
resolution: {integrity: sha512-eEeaJA7V5KOFq7W0GtoRVbd3ak8UZpK+XLkCgUiFGtlunNw+ZZW9Cr/92MXflGe7o3SqqMUg+f975LPxO/vsOQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.83':
|
||||
resolution: {integrity: sha512-cAvonp5XpbatVGegF9lMQNchs3z5RH6EtamRVnQvtoRtwbzOMcdzwuLBqDBQxQF79MFbuZNkWj3YRJjZCjHVzw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.83':
|
||||
resolution: {integrity: sha512-WFUPQ9qZy31vmLxIJ3MfmHw+R2g/mLCgk8zmh7maJW8snV3vLPA7pZfIS65Dc61EVDp1vaBskwQ2RqPPzwkaew==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.83':
|
||||
resolution: {integrity: sha512-X9YwIjsuy50WwOyYeNhEHjKHO8rrfH9M4U8vNqLuGmqsZdKua/GrUhdQGdjq7lTgdY3g4+Ta5jF8MzAa7UAs/g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.83':
|
||||
resolution: {integrity: sha512-Vv2pLWQS8EnlSM1bstJ7vVhKA+mL4+my4sKUIn/bgIxB5O90dqiDhQjUDLP+5xn9ZMestRWDt3tdQEkGAmzq/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.83':
|
||||
resolution: {integrity: sha512-K1TtjbScfRNYhq8dengLLufXGbtEtWdUXPV505uLFPovyGHzDUGXLFP/zUJzj6xWXwgUjHNLgEPIt7mye0zr6Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.83':
|
||||
resolution: {integrity: sha512-f9GVB9VNc9vn/nroc9epXRNkVpvNPZh69+qzLJIm9DfruxFqX0/jsXG46OGWAJgkO4mN0HvFHjRROMXKVmPszg==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
@@ -3996,6 +4063,10 @@ packages:
|
||||
pdf-lib@1.17.1:
|
||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||
|
||||
pdfjs-dist@5.4.449:
|
||||
resolution: {integrity: sha512-CegnUaT0QwAyQMS+7o2POr4wWUNNe8VaKKlcuoRHeYo98cVnqPpwOXNSx6Trl6szH02JrRcsPgletV6GmF3LtQ==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -5820,6 +5891,50 @@ snapshots:
|
||||
'@types/react': 19.2.5
|
||||
react: 19.2.0
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.83':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.83':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.83
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.83
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.83
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.83
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.83
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.83
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.83
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.83
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.83
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.83
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.7.1
|
||||
@@ -9459,6 +9574,10 @@ snapshots:
|
||||
pako: 1.0.11
|
||||
tslib: 1.14.1
|
||||
|
||||
pdfjs-dist@5.4.449:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.83
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function LoadingPdfAsset() {
|
||||
return (
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
正在加载 PDF 文档...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { PdfAssetViewer } from "./pdf-asset-viewer";
|
||||
|
||||
interface PdfPageProps {
|
||||
params: Promise<{ assetId: string }>;
|
||||
searchParams?: Promise<{ page?: string; highlight?: string }>;
|
||||
}
|
||||
|
||||
export default async function PdfAssetPage({ params, searchParams }: PdfPageProps) {
|
||||
const { assetId } = await params;
|
||||
const query = (await searchParams) ?? {};
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: asset } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id,workspace_id,document_id,file_url,file_name,mime_type,ocr_status,documents!media_assets_document_id_fkey(user_id,title)")
|
||||
.eq("id", assetId)
|
||||
.maybeSingle();
|
||||
|
||||
if (!asset || !asset.file_url || !(asset.mime_type ?? "").toLowerCase().includes("pdf")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("id")
|
||||
.eq("workspace_id", asset.workspace_id)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!membership && asset.documents?.user_id !== session.user.id) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const initialPage = query.page && Number.parseInt(query.page, 10) > 0 ? Number.parseInt(query.page, 10) : 1;
|
||||
const initialHighlightId = typeof query.highlight === "string" ? query.highlight : null;
|
||||
|
||||
return (
|
||||
<PdfAssetViewer
|
||||
assetId={asset.id}
|
||||
workspaceId={asset.workspace_id}
|
||||
documentId={asset.document_id}
|
||||
fileUrl={asset.file_url}
|
||||
fileName={asset.file_name ?? "PDF 文档"}
|
||||
sourceTitle={asset.documents?.title ?? null}
|
||||
initialPage={initialPage}
|
||||
initialHighlightId={initialHighlightId}
|
||||
ocrStatus={asset.ocr_status ?? null}
|
||||
enableOcrLayer={(asset.ocr_status ?? "").toLowerCase() === "success"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Download, ExternalLink, Highlighter, LinkIcon } from "lucide-react";
|
||||
import type { PdfOcrPayload, PdfQuote, PdfQuoteDraft } from "@/types/pdf";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildPdfTargetUri, parsePdfTargetUri } from "@/lib/pdf/uri";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type PdfAssetViewerProps = {
|
||||
assetId: string;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
sourceTitle?: string | null;
|
||||
initialPage?: number;
|
||||
initialHighlightId?: string | null;
|
||||
ocrStatus?: string | null;
|
||||
enableOcrLayer?: boolean;
|
||||
};
|
||||
|
||||
const MAX_QUOTES = 20;
|
||||
const PdfViewerWithQuote = dynamic(
|
||||
() => import("@/components/pdf/pdf-viewer-with-quote").then((mod) => mod.PdfViewerWithQuote),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">PDF 阅读器加载中...</div>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
export function PdfAssetViewer({
|
||||
assetId,
|
||||
workspaceId,
|
||||
documentId,
|
||||
fileUrl,
|
||||
fileName,
|
||||
sourceTitle,
|
||||
initialPage = 1,
|
||||
initialHighlightId = null,
|
||||
ocrStatus = null,
|
||||
enableOcrLayer = false,
|
||||
}: PdfAssetViewerProps) {
|
||||
const [quotes, setQuotes] = useState<PdfQuote[]>([]);
|
||||
const [activeQuote, setActiveQuote] = useState<PdfQuote | null>(null);
|
||||
const [pendingQuote, setPendingQuote] = useState<PdfQuoteDraft | null>(null);
|
||||
const [quotesLoading, setQuotesLoading] = useState(true);
|
||||
const [isSavingQuote, setIsSavingQuote] = useState(false);
|
||||
const [ocrLayer, setOcrLayer] = useState<PdfOcrPayload | null>(null);
|
||||
const [ocrError, setOcrError] = useState<string | null>(null);
|
||||
const [ocrLoading, setOcrLoading] = useState(false);
|
||||
const [ocrReloadKey, setOcrReloadKey] = useState(0);
|
||||
const ocrStatusMessage = useMemo(() => {
|
||||
if (!enableOcrLayer) {
|
||||
if (!ocrStatus) {
|
||||
return "MinerU OCR 未启用或仍在排队。";
|
||||
}
|
||||
return `等待 MinerU 任务完成(当前状态:${ocrStatus})`;
|
||||
}
|
||||
if (ocrLoading) {
|
||||
return "正在载入 OCR 文本层…";
|
||||
}
|
||||
if (ocrLayer) {
|
||||
return "OCR 文本层已启用,图片型 PDF 亦可直接选中引用。";
|
||||
}
|
||||
if (ocrError) {
|
||||
return ocrError;
|
||||
}
|
||||
return "OCR 数据可用,点击重新加载以附加文本层。";
|
||||
}, [enableOcrLayer, ocrLayer, ocrLoading, ocrError, ocrStatus]);
|
||||
const hasOcrLayer = Boolean(ocrLayer);
|
||||
|
||||
const copyToClipboard = useCallback(async (value: string) => {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
throw new Error("no clipboard");
|
||||
} catch {
|
||||
window.prompt("请复制以下引用链接", value);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback(
|
||||
(draft: PdfQuoteDraft | null) => {
|
||||
if (!draft) {
|
||||
setPendingQuote(null);
|
||||
return;
|
||||
}
|
||||
setPendingQuote({
|
||||
...draft,
|
||||
sourceTitle: sourceTitle ?? draft.sourceTitle ?? null,
|
||||
});
|
||||
},
|
||||
[sourceTitle],
|
||||
);
|
||||
|
||||
const handleQuoteCreate = useCallback(async () => {
|
||||
if (!pendingQuote || isSavingQuote) return;
|
||||
setIsSavingQuote(true);
|
||||
const highlightId = crypto.randomUUID();
|
||||
const path = buildPdfTargetUri({ assetId: pendingQuote.assetId, page: pendingQuote.page, highlightId });
|
||||
const targetUrl = new URL(path, window.location.origin).toString();
|
||||
try {
|
||||
const response = await fetch("/api/pdf/quotes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
assetId: pendingQuote.assetId,
|
||||
highlightId,
|
||||
targetUrl,
|
||||
text: pendingQuote.text,
|
||||
page: pendingQuote.page,
|
||||
rects: pendingQuote.rects,
|
||||
fileUrl: pendingQuote.fileUrl,
|
||||
sourceTitle: pendingQuote.sourceTitle ?? sourceTitle ?? null,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error((payload as { error?: string } | null)?.error ?? "生成引用失败");
|
||||
}
|
||||
const savedQuote = (payload as { quote?: PdfQuote } | null)?.quote;
|
||||
if (!savedQuote) {
|
||||
throw new Error("服务端未返回引用数据");
|
||||
}
|
||||
await copyToClipboard(savedQuote.targetUrl);
|
||||
toast.success("已生成引用链接", {
|
||||
description: truncateText(savedQuote.text, 60),
|
||||
});
|
||||
setQuotes((prev) => [savedQuote, ...prev.filter((item) => item.id !== savedQuote.id)].slice(0, MAX_QUOTES));
|
||||
setActiveQuote(savedQuote);
|
||||
setPendingQuote(null);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("pdf:quote-created", {
|
||||
detail: savedQuote,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("生成 PDF 引用失败", error);
|
||||
toast.error("生成引用失败", {
|
||||
description: error instanceof Error ? error.message : "请稍后再试",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingQuote(false);
|
||||
}
|
||||
}, [copyToClipboard, isSavingQuote, pendingQuote, sourceTitle]);
|
||||
|
||||
const handleCopyQuote = async (quote: PdfQuote) => {
|
||||
await copyToClipboard(quote.targetUrl);
|
||||
toast.success("引用链接已复制", {
|
||||
description: truncateText(quote.text, 60),
|
||||
});
|
||||
};
|
||||
|
||||
const handleReloadOcrLayer = () => {
|
||||
setOcrReloadKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const openOriginal = () => {
|
||||
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const downloadFile = () => {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = fileUrl;
|
||||
anchor.download = fileName || "document.pdf";
|
||||
anchor.rel = "noopener";
|
||||
anchor.target = "_blank";
|
||||
anchor.click();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
const fetchQuotes = async () => {
|
||||
setQuotesLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/pdf/quotes?assetId=${assetId}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as { quotes?: PdfQuote[]; error?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error ?? "加载引用失败");
|
||||
}
|
||||
if (!cancelled) {
|
||||
const list = Array.isArray(payload?.quotes) ? payload?.quotes ?? [] : [];
|
||||
setQuotes(list.slice(0, MAX_QUOTES));
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error("加载 PDF 引用失败", error);
|
||||
toast.error("读取引用失败", {
|
||||
description: error instanceof Error ? error.message : "请稍后再试",
|
||||
});
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setQuotesLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void fetchQuotes();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [assetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableOcrLayer) {
|
||||
setOcrLayer(null);
|
||||
setOcrError(null);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const fetchOcrLayer = async () => {
|
||||
setOcrLoading(true);
|
||||
setOcrError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/pdf/ocr/${assetId}`, { signal: controller.signal });
|
||||
const payload = (await response.json().catch(() => null)) as { ocrLayer?: PdfOcrPayload; error?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error ?? "加载 OCR 图层失败");
|
||||
}
|
||||
setOcrLayer(payload?.ocrLayer ?? null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error("加载 OCR 图层失败", error);
|
||||
setOcrLayer(null);
|
||||
setOcrError(error instanceof Error ? error.message : "无法加载 OCR 文本层");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setOcrLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void fetchOcrLayer();
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [assetId, enableOcrLayer, ocrReloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMindmapJump = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ targetUrl?: string; assetId?: string; highlightId?: string; quote?: Partial<PdfQuote> }>).detail;
|
||||
if (!detail?.targetUrl) return;
|
||||
const parsed = parsePdfTargetUri(detail.targetUrl);
|
||||
if (!parsed || parsed.assetId !== assetId) {
|
||||
return;
|
||||
}
|
||||
if (detail.highlightId) {
|
||||
const matched = quotes.find((item) => item.highlightId === detail.highlightId);
|
||||
if (matched) {
|
||||
setActiveQuote(matched);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (detail.quote) {
|
||||
const derived: PdfQuote = {
|
||||
id: detail.quote.id ?? crypto.randomUUID(),
|
||||
highlightId: detail.highlightId ?? detail.quote.highlightId ?? crypto.randomUUID(),
|
||||
targetUrl: detail.targetUrl,
|
||||
assetId,
|
||||
workspaceId,
|
||||
documentId,
|
||||
fileUrl,
|
||||
page: detail.quote.page ?? parsed.page ?? 1,
|
||||
text: detail.quote.text ?? "PDF 引用",
|
||||
rects: detail.quote.rects ?? [],
|
||||
sourceTitle: detail.quote.sourceTitle ?? sourceTitle ?? null,
|
||||
createdAt: detail.quote.createdAt ?? new Date().toISOString(),
|
||||
};
|
||||
setActiveQuote(derived);
|
||||
}
|
||||
};
|
||||
window.addEventListener("mindmap:jump-to-pdf", handleMindmapJump as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("mindmap:jump-to-pdf", handleMindmapJump as EventListener);
|
||||
};
|
||||
}, [assetId, documentId, fileUrl, quotes, sourceTitle, workspaceId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<header className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-foreground">{fileName || "PDF 文档"}</p>
|
||||
{sourceTitle && <p className="text-sm text-muted-foreground">来源:{sourceTitle}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onClick={openOriginal} className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
查看原文件
|
||||
</Button>
|
||||
<Button onClick={downloadFile} className="gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!pendingQuote || isSavingQuote}
|
||||
onClick={() => void handleQuoteCreate()}
|
||||
>
|
||||
<Highlighter className="h-4 w-4" />
|
||||
{isSavingQuote ? "生成中..." : "生成引用"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-[#f5f5f5]">
|
||||
<PdfViewerWithQuote
|
||||
url={fileUrl}
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
assetId={assetId}
|
||||
initialPage={initialPage}
|
||||
initialHighlightId={initialHighlightId}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
activeQuote={activeQuote}
|
||||
ocrLayer={ocrLayer}
|
||||
/>
|
||||
</div>
|
||||
<aside className="w-80 border-l border-border bg-white">
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<p className="text-sm font-semibold text-foreground">引用记录</p>
|
||||
<p className="text-xs text-muted-foreground">选中文本后点击“生成引用”,即可复制链接到导图或笔记。</p>
|
||||
<div className="mt-2 rounded-lg border border-dashed border-border/70 bg-muted/40 p-2">
|
||||
<p className="text-xs text-muted-foreground">当前选区</p>
|
||||
<p className="mt-1 line-clamp-3 text-sm text-foreground">
|
||||
{pendingQuote ? pendingQuote.text : "暂无选中的文本"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{quotesLoading ? (
|
||||
<p className="text-sm text-muted-foreground">引用记录加载中...</p>
|
||||
) : quotes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无引用。拖动选择 PDF 文本并点击“生成引用”。</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{quotes.map((quote) => (
|
||||
<li key={quote.id} className="rounded-lg border border-border/70 bg-white p-3 shadow-sm">
|
||||
<p className="text-xs font-medium text-indigo-600">第 {quote.page} 页</p>
|
||||
<p className="mt-1 line-clamp-3 text-sm text-foreground">{quote.text}</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
setActiveQuote(quote);
|
||||
void handleCopyQuote(quote);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="h-3.5 w-3.5" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
setActiveQuote(quote);
|
||||
window.open(quote.targetUrl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
打开
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="mt-6 rounded-xl border border-dashed border-border/70 bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">MinerU OCR</p>
|
||||
{enableOcrLayer && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasOcrLayer ? "已启用" : ocrLoading ? "加载中" : "待加载"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground leading-relaxed">{ocrStatusMessage}</p>
|
||||
{enableOcrLayer && !ocrLoading && !hasOcrLayer && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-3 w-full"
|
||||
onClick={handleReloadOcrLayer}
|
||||
>
|
||||
重新加载 OCR 文本层
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const truncateText = (value: string, length: number) => {
|
||||
if (value.length <= length) {
|
||||
return value;
|
||||
}
|
||||
return `${value.slice(0, length - 1)}…`;
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { PdfOcrPayload } from "@/types/pdf";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Params = Promise<{ assetId: string }>;
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Params }) {
|
||||
const { assetId } = await params;
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: asset, error } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id,workspace_id,document_id,ocr_payload,ocr_status,documents!media_assets_document_id_fkey(user_id)")
|
||||
.eq("id", assetId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "未找到对应的 PDF" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data: membership } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("id")
|
||||
.eq("workspace_id", asset.workspace_id)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!membership && asset.documents?.user_id !== session.user.id) {
|
||||
return NextResponse.json({ error: "无权限访问该资源" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (!asset.ocr_payload) {
|
||||
return NextResponse.json({ error: "OCR 数据尚未生成" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ocrLayer: asset.ocr_payload as PdfOcrPayload,
|
||||
status: asset.ocr_status,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { PdfQuote, PdfQuoteRect } from "@/types/pdf";
|
||||
import type { Database, Json } from "@/types/supabase";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PdfQuoteRow = Database["public"]["Tables"]["pdf_quotes"]["Row"];
|
||||
|
||||
const sanitizeRects = (value: unknown): PdfQuoteRect[] => {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
return null;
|
||||
}
|
||||
const candidate = item as Record<string, unknown>;
|
||||
const page = Number(candidate.page);
|
||||
const x = Number(candidate.x);
|
||||
const y = Number(candidate.y);
|
||||
const width = Number(candidate.width);
|
||||
const height = Number(candidate.height);
|
||||
if (
|
||||
Number.isNaN(page) ||
|
||||
Number.isNaN(x) ||
|
||||
Number.isNaN(y) ||
|
||||
Number.isNaN(width) ||
|
||||
Number.isNaN(height)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
page,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
})
|
||||
.filter((rect): rect is PdfQuoteRect => rect !== null);
|
||||
};
|
||||
|
||||
const mapRowToQuote = (row: PdfQuoteRow): PdfQuote => ({
|
||||
id: row.id,
|
||||
assetId: row.asset_id,
|
||||
workspaceId: row.workspace_id,
|
||||
documentId: row.document_id,
|
||||
fileUrl: row.file_url ?? null,
|
||||
page: row.page,
|
||||
text: row.quote_text,
|
||||
rects: sanitizeRects(row.rects),
|
||||
highlightId: row.highlight_id,
|
||||
targetUrl: row.target_url,
|
||||
sourceTitle: row.source_title ?? null,
|
||||
createdAt: row.created_at,
|
||||
createdBy: row.created_by ?? null,
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId");
|
||||
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id")
|
||||
.eq("id", assetId)
|
||||
.maybeSingle();
|
||||
|
||||
if (assetError) {
|
||||
return NextResponse.json({ error: assetError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "未找到对应的 PDF 资源" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("pdf_quotes")
|
||||
.select("*")
|
||||
.eq("asset_id", asset.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
quotes: (data ?? []).map(mapRowToQuote),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json().catch(() => null)) as {
|
||||
assetId?: string;
|
||||
highlightId?: string;
|
||||
targetUrl?: string;
|
||||
text?: string;
|
||||
page?: number;
|
||||
rects?: unknown;
|
||||
fileUrl?: string | null;
|
||||
sourceTitle?: string | null;
|
||||
} | null;
|
||||
|
||||
if (
|
||||
!payload?.assetId ||
|
||||
!payload.highlightId ||
|
||||
!payload.targetUrl ||
|
||||
typeof payload.text !== "string" ||
|
||||
!payload.text.trim() ||
|
||||
typeof payload.page !== "number" ||
|
||||
payload.page <= 0
|
||||
) {
|
||||
return NextResponse.json({ error: "引用参数不完整" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rects = sanitizeRects(payload.rects);
|
||||
if (rects.length === 0) {
|
||||
return NextResponse.json({ error: "缺少有效的高亮范围" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabase
|
||||
.from("media_assets")
|
||||
.select("id")
|
||||
.eq("id", payload.assetId)
|
||||
.maybeSingle();
|
||||
|
||||
if (assetError) {
|
||||
return NextResponse.json({ error: assetError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "未找到对应的 PDF 资源" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("pdf_quotes")
|
||||
.upsert(
|
||||
{
|
||||
asset_id: asset.id,
|
||||
highlight_id: payload.highlightId,
|
||||
target_url: payload.targetUrl,
|
||||
quote_text: payload.text.trim(),
|
||||
page: payload.page,
|
||||
rects: rects as unknown as Json,
|
||||
file_url: payload.fileUrl ?? null,
|
||||
source_title: payload.sourceTitle ?? null,
|
||||
created_by: session.user.id,
|
||||
},
|
||||
{ onConflict: "asset_id,highlight_id" },
|
||||
)
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ quote: mapRowToQuote(data) });
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
DEFAULT_MINDMAP_SNAPSHOT,
|
||||
type MindmapNode,
|
||||
type MindmapPdfQuoteData,
|
||||
type MindmapSnapshot,
|
||||
} from '@/types/mindmap';
|
||||
import { normalizeSnapshot } from '@/lib/mindmap/snapshot';
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
CheckSquare,
|
||||
CornerDownLeft,
|
||||
CornerDownRight,
|
||||
ClipboardPaste,
|
||||
Expand,
|
||||
Focus,
|
||||
Italic,
|
||||
@@ -98,6 +100,11 @@ import {
|
||||
countMindmapNodes,
|
||||
type MindmapOutlineEntry,
|
||||
} from '@/lib/mindmap/outline';
|
||||
import type { PdfQuote } from '@/types/pdf';
|
||||
import { parsePdfTargetUri } from '@/lib/pdf/uri';
|
||||
import {
|
||||
extractPdfQuoteFromNode,
|
||||
} from '@/lib/mindmap/pdf-quote';
|
||||
|
||||
type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'error';
|
||||
|
||||
@@ -183,6 +190,11 @@ type MindmapCollaborationRefs = {
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const truncate = (value: string, limit = 80) => {
|
||||
if (!value) return '';
|
||||
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
|
||||
};
|
||||
|
||||
const toPlainText = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
try {
|
||||
@@ -193,6 +205,58 @@ const toPlainText = (value?: string | null) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toMindmapPdfQuoteData = (quote: PdfQuote): MindmapPdfQuoteData => ({
|
||||
id: quote.id ?? quote.highlightId,
|
||||
assetId: quote.assetId,
|
||||
targetUrl: quote.targetUrl,
|
||||
highlightId: quote.highlightId,
|
||||
page: quote.page,
|
||||
text: quote.text,
|
||||
sourceTitle: quote.sourceTitle ?? null,
|
||||
});
|
||||
|
||||
const formatPdfQuoteNote = (quote: { page: number; text: string; sourceTitle?: string | null }) => {
|
||||
const prefix = quote.sourceTitle?.trim()
|
||||
? `${quote.sourceTitle.trim()} · 第 ${quote.page} 页`
|
||||
: `PDF 第 ${quote.page} 页`;
|
||||
return `${prefix}\n${quote.text}`.trim();
|
||||
};
|
||||
|
||||
const mergePdfNoteContent = (existing: string | null | undefined, next: string) => {
|
||||
if (!existing || !existing.trim()) {
|
||||
return next;
|
||||
}
|
||||
if (existing.includes(next)) {
|
||||
return existing;
|
||||
}
|
||||
return `${existing}\n\n${next}`.trim();
|
||||
};
|
||||
|
||||
const isLikelyUrl = (value?: string | null) => {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^https?:\/\//i.test(trimmed) || trimmed.startsWith('app://') || trimmed.startsWith('mindmap://');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
if (!target) return false;
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
if (target instanceof HTMLElement) {
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(target.closest('input, textarea, [contenteditable="true"]'));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const scrubRichTextSnapshot = (input: MindmapSnapshot): MindmapSnapshot => {
|
||||
const clone: MindmapSnapshot = JSON.parse(JSON.stringify(input));
|
||||
const walk = (node?: MindmapNode | null) => {
|
||||
@@ -264,7 +328,10 @@ export function MindmapLabClient({
|
||||
const [collaborationStatus, setCollaborationStatus] = useState<CollaborationStatus>('idle');
|
||||
const [sideTab, setSideTab] = useState('inspector');
|
||||
const [outlinePreview, setOutlinePreview] = useState<MindmapOutlineEntry | null>(null);
|
||||
const [pastingPdfQuote, setPastingPdfQuote] = useState(false);
|
||||
const mindMapRef = useRef<MindMapInstance | null>(null);
|
||||
const activeNodesRef = useRef<MindMapNodeInstance[]>([]);
|
||||
const lastJumpedQuoteRef = useRef<string | null>(null);
|
||||
const pendingSnapshotRef = useRef<MindmapSnapshot>(snapshot);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const configSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -286,10 +353,105 @@ export function MindmapLabClient({
|
||||
[initialDocumentId]
|
||||
);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
pendingSnapshotRef.current = snapshot;
|
||||
}, [snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
activeNodesRef.current = [...(activeNodes || [])];
|
||||
}, [activeNodes]);
|
||||
|
||||
const insertPdfQuoteNode = useCallback(
|
||||
(quote: PdfQuote) => {
|
||||
const instance = mindMapRef.current;
|
||||
if (!instance) return;
|
||||
const targetNodes =
|
||||
activeNodesRef.current && activeNodesRef.current.length > 0
|
||||
? [...activeNodesRef.current]
|
||||
: [];
|
||||
const quoteData = toMindmapPdfQuoteData(quote);
|
||||
instance.execCommand('INSERT_CHILD_NODE', false, targetNodes, {
|
||||
text: 'PDF 引用',
|
||||
hyperlink: quote.targetUrl,
|
||||
hyperlinkTitle: '',
|
||||
note: formatPdfQuoteNote(quoteData),
|
||||
pdfQuote: quoteData,
|
||||
});
|
||||
toast.success('已插入 PDF 引用到导图', {
|
||||
description: truncate(quote.text, 60),
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleQuoteCreated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<PdfQuote>).detail;
|
||||
if (!detail) return;
|
||||
insertPdfQuoteNode(detail);
|
||||
};
|
||||
window.addEventListener(
|
||||
'pdf:quote-created',
|
||||
handleQuoteCreated as EventListener
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'pdf:quote-created',
|
||||
handleQuoteCreated as EventListener
|
||||
);
|
||||
};
|
||||
}, [insertPdfQuoteNode]);
|
||||
|
||||
const fetchPdfQuoteFromLink = useCallback(async (uri: string): Promise<PdfQuote | null> => {
|
||||
const parsed = parsePdfTargetUri(uri.trim());
|
||||
if (!parsed?.assetId || !parsed.highlightId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/pdf/quotes?assetId=${encodeURIComponent(parsed.assetId)}`);
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
quotes?: PdfQuote[];
|
||||
error?: string;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error ?? '加载 PDF 引用失败');
|
||||
}
|
||||
const quotes = Array.isArray(payload?.quotes) ? (payload?.quotes as PdfQuote[]) : [];
|
||||
const matched = quotes.find((quote) => quote.highlightId === parsed.highlightId);
|
||||
return matched ?? null;
|
||||
} catch (error) {
|
||||
console.error('获取 PDF 引用失败', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const primaryNode = activeNodes[0] ?? null;
|
||||
const activePdfQuote = useMemo(
|
||||
() => extractPdfQuoteFromNode(activeNodes[0]) ?? null,
|
||||
[activeNodes]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!activePdfQuote) {
|
||||
lastJumpedQuoteRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (lastJumpedQuoteRef.current === activePdfQuote.highlightId) {
|
||||
return;
|
||||
}
|
||||
lastJumpedQuoteRef.current = activePdfQuote.highlightId;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mindmap:jump-to-pdf', {
|
||||
detail: {
|
||||
assetId: activePdfQuote.assetId,
|
||||
targetUrl: activePdfQuote.targetUrl,
|
||||
highlightId: activePdfQuote.highlightId,
|
||||
quote: activePdfQuote,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [activePdfQuote]);
|
||||
|
||||
useEffect(() => {
|
||||
supabaseBrowser.auth
|
||||
.getSession()
|
||||
@@ -861,7 +1023,6 @@ export function MindmapLabClient({
|
||||
);
|
||||
}, [selectedTheme]);
|
||||
|
||||
const primaryNode = activeNodes[0] ?? null;
|
||||
const primaryNodePayload = useMemo(
|
||||
() => ({
|
||||
data: primaryNode?.getData?.() ?? null,
|
||||
@@ -924,6 +1085,108 @@ export function MindmapLabClient({
|
||||
[activeNodes]
|
||||
);
|
||||
|
||||
const applyHyperlinkToSelection = useCallback(
|
||||
(link: string, options?: { title?: string }) => {
|
||||
const value = link.trim();
|
||||
if (!value) return false;
|
||||
let applied = false;
|
||||
runOnActiveNodes((node) => {
|
||||
applied = true;
|
||||
const nodeData = node.getData?.();
|
||||
const existingTitle = nodeData?.hyperlinkTitle?.trim();
|
||||
const finalTitle =
|
||||
typeof options?.title === 'string'
|
||||
? options.title
|
||||
: existingTitle ?? '';
|
||||
mindMapRef.current?.execCommand(
|
||||
'SET_NODE_HYPERLINK',
|
||||
node,
|
||||
value,
|
||||
finalTitle
|
||||
);
|
||||
});
|
||||
return applied;
|
||||
},
|
||||
[runOnActiveNodes]
|
||||
);
|
||||
|
||||
const attachGeneralHyperlink = useCallback(
|
||||
(link: string) => {
|
||||
if (!link.trim()) return false;
|
||||
const applied = applyHyperlinkToSelection(link);
|
||||
if (!applied) {
|
||||
toast.info('请选择一个节点后再粘贴超链接');
|
||||
return false;
|
||||
}
|
||||
toast.success('已添加超链接', {
|
||||
description: link.trim(),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[applyHyperlinkToSelection]
|
||||
);
|
||||
|
||||
const applyPdfQuoteToSelection = useCallback(
|
||||
(quote: PdfQuote) => {
|
||||
const quoteData = toMindmapPdfQuoteData(quote);
|
||||
let applied = false;
|
||||
runOnActiveNodes((node) => {
|
||||
applied = true;
|
||||
const nodeData = node.getData?.();
|
||||
const existingTitle = nodeData?.hyperlinkTitle?.trim();
|
||||
const finalTitle = existingTitle ?? '';
|
||||
const mergedNote = mergePdfNoteContent(
|
||||
typeof nodeData?.note === 'string' ? nodeData?.note : '',
|
||||
formatPdfQuoteNote(quoteData)
|
||||
);
|
||||
mindMapRef.current?.execCommand('SET_NODE_DATA', node, {
|
||||
pdfQuote: quoteData,
|
||||
note: mergedNote,
|
||||
});
|
||||
mindMapRef.current?.execCommand('SET_NODE_HYPERLINK', node, quote.targetUrl, finalTitle);
|
||||
});
|
||||
return applied;
|
||||
},
|
||||
[runOnActiveNodes]
|
||||
);
|
||||
|
||||
const attachPdfQuoteFromLink = useCallback(
|
||||
async (uri: string, options?: { silentOnNoSelection?: boolean }) => {
|
||||
const parsed = parsePdfTargetUri(uri.trim());
|
||||
if (!parsed?.assetId || !parsed.highlightId) {
|
||||
return false;
|
||||
}
|
||||
let quote: PdfQuote | null = null;
|
||||
try {
|
||||
quote = await fetchPdfQuoteFromLink(uri);
|
||||
} catch (error) {
|
||||
console.error('加载 PDF 引用失败', error);
|
||||
toast.error('加载 PDF 引用失败', {
|
||||
description: error instanceof Error ? error.message : '请稍后再试',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (!quote) {
|
||||
toast.error('未找到对应的 PDF 引用', {
|
||||
description: '请确认链接是否完整或已保存引用',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const applied = applyPdfQuoteToSelection(quote);
|
||||
if (!applied) {
|
||||
if (!options?.silentOnNoSelection) {
|
||||
toast.info('请选择一个节点后再粘贴引用');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
toast.success('已应用 PDF 引用', {
|
||||
description: truncate(quote.text, 60),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[applyPdfQuoteToSelection, fetchPdfQuoteFromLink]
|
||||
);
|
||||
|
||||
const applyNodeStyle = useCallback(
|
||||
(prop: string, value: unknown) => {
|
||||
runOnActiveNodes((node) => {
|
||||
@@ -1076,6 +1339,21 @@ export function MindmapLabClient({
|
||||
router.push(`/documents/${docId}`);
|
||||
return;
|
||||
}
|
||||
const parsedPdf = parsePdfTargetUri(link);
|
||||
if (parsedPdf?.assetId) {
|
||||
const search = new URLSearchParams();
|
||||
if (parsedPdf.page) {
|
||||
search.set('page', String(parsedPdf.page));
|
||||
}
|
||||
if (parsedPdf.highlightId) {
|
||||
search.set('highlight', parsedPdf.highlightId);
|
||||
}
|
||||
const suffix = search.toString();
|
||||
router.push(
|
||||
`/pdf/${parsedPdf.assetId}${suffix ? `?${suffix}` : ''}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析应用内链接失败', error);
|
||||
}
|
||||
@@ -1270,6 +1548,89 @@ export function MindmapLabClient({
|
||||
mindMapRef.current?.execCommand('INSERT_FORMULA', formula.trim());
|
||||
}, []);
|
||||
|
||||
|
||||
const handleOpenPdfQuote = useCallback(() => {
|
||||
if (!activePdfQuote) return;
|
||||
window.open(activePdfQuote.targetUrl, '_blank', 'noopener,noreferrer');
|
||||
}, [activePdfQuote]);
|
||||
|
||||
const handlePastePdfQuoteFromClipboard = useCallback(async () => {
|
||||
if (pastingPdfQuote) return;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.readText) {
|
||||
toast.info('当前浏览器不支持读取剪贴板内容');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPastingPdfQuote(true);
|
||||
const text = (await navigator.clipboard.readText()).trim();
|
||||
if (!text) {
|
||||
toast.info('剪贴板中没有可用的链接');
|
||||
return;
|
||||
}
|
||||
const parsed = parsePdfTargetUri(text);
|
||||
if (!parsed?.assetId || !parsed.highlightId) {
|
||||
toast.info('剪贴板内容不是 PDF 引用链接');
|
||||
return;
|
||||
}
|
||||
await attachPdfQuoteFromLink(text);
|
||||
} catch (error) {
|
||||
console.error('读取剪贴板失败', error);
|
||||
toast.error('读取剪贴板失败', {
|
||||
description: error instanceof Error ? error.message : '请检查浏览器权限或重试',
|
||||
});
|
||||
} finally {
|
||||
setPastingPdfQuote(false);
|
||||
}
|
||||
}, [attachPdfQuoteFromLink, pastingPdfQuote]);
|
||||
|
||||
const interceptClipboardEvent = useCallback(
|
||||
(event: ClipboardEvent) => {
|
||||
if (isEditableTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
const text = event.clipboardData?.getData('text/plain')?.trim();
|
||||
if (!text) return false;
|
||||
const parsed = parsePdfTargetUri(text);
|
||||
if (parsed?.assetId && parsed.highlightId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
void attachPdfQuoteFromLink(text);
|
||||
return true;
|
||||
}
|
||||
if (isLikelyUrl(text)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
attachGeneralHyperlink(text);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[attachGeneralHyperlink, attachPdfQuoteFromLink]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const listener: EventListener = (event) =>
|
||||
interceptClipboardEvent(event as ClipboardEvent);
|
||||
window.addEventListener('paste', listener, true);
|
||||
return () => {
|
||||
window.removeEventListener('paste', listener, true);
|
||||
};
|
||||
}, [interceptClipboardEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerEl) return;
|
||||
const listener = (event: ClipboardEvent) => {
|
||||
interceptClipboardEvent(event);
|
||||
};
|
||||
containerEl.addEventListener('paste', listener, true);
|
||||
return () => {
|
||||
containerEl.removeEventListener('paste', listener, true);
|
||||
};
|
||||
}, [containerEl, interceptClipboardEvent]);
|
||||
|
||||
const handleRestoreHistory = useCallback(
|
||||
(entry: SnapshotHistoryEntry) => {
|
||||
const restored = scrubRichTextSnapshot(entry.snapshot);
|
||||
@@ -1396,6 +1757,20 @@ export function MindmapLabClient({
|
||||
icon: Sparkles,
|
||||
onClick: handleToggleRainbow,
|
||||
},
|
||||
{
|
||||
id: 'pastePdfQuote',
|
||||
label: pastingPdfQuote ? '粘贴中…' : '粘贴 PDF 引用',
|
||||
icon: ClipboardPaste,
|
||||
onClick: handlePastePdfQuoteFromClipboard,
|
||||
disabled: pastingPdfQuote,
|
||||
},
|
||||
{
|
||||
id: 'openPdfQuote',
|
||||
label: activePdfQuote ? `打开第 ${activePdfQuote.page} 页` : '打开 PDF 引用',
|
||||
icon: Link2,
|
||||
onClick: handleOpenPdfQuote,
|
||||
disabled: !activePdfQuote,
|
||||
},
|
||||
],
|
||||
[
|
||||
handleCollapseAll,
|
||||
@@ -1405,7 +1780,11 @@ export function MindmapLabClient({
|
||||
handleToggleRainbow,
|
||||
handleRedo,
|
||||
handleUndo,
|
||||
handleOpenPdfQuote,
|
||||
handlePastePdfQuoteFromClipboard,
|
||||
rainbowEnabled,
|
||||
pastingPdfQuote,
|
||||
activePdfQuote,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,16 @@ import {
|
||||
} 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 {
|
||||
Download,
|
||||
FileImage,
|
||||
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";
|
||||
@@ -83,6 +92,9 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
? (rawAssetType as MediaKind)
|
||||
: "image";
|
||||
const typeLabel = TYPE_LABEL_MAP[assetType] ?? TYPE_LABEL_MAP.image;
|
||||
const isPdfAsset =
|
||||
(block.props.mimeType || "").toLowerCase().includes("pdf") ||
|
||||
(block.props.fileName || "").toLowerCase().endsWith(".pdf");
|
||||
const canAlign = assetType === "image" || assetType === "video";
|
||||
const canToggleBorder = assetType === "image";
|
||||
const canTriggerOcr = assetType === "image";
|
||||
@@ -207,6 +219,14 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
window.open(fileUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const openPdfViewer = () => {
|
||||
if (!block.props.assetId) {
|
||||
window.alert("缺少 PDF 资源 ID,无法打开内置阅读器");
|
||||
return;
|
||||
}
|
||||
window.open(`/pdf/${block.props.assetId}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const downloadAsset = () => {
|
||||
if (!fileUrl) return;
|
||||
const anchor = document.createElement("a");
|
||||
@@ -294,7 +314,7 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
};
|
||||
|
||||
const figure = (
|
||||
const figure = (
|
||||
<figure
|
||||
className={cn(
|
||||
"wolai-media__figure",
|
||||
@@ -327,6 +347,14 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
icon: <RefreshCcw className="h-4 w-4" />,
|
||||
onClick: handleChoose,
|
||||
},
|
||||
isPdfAsset && block.props.assetId
|
||||
? {
|
||||
key: "openPdf",
|
||||
label: "在内置阅读器打开",
|
||||
icon: <FileImage className="h-4 w-4" />,
|
||||
onClick: openPdfViewer,
|
||||
}
|
||||
: null,
|
||||
canToggleBorder
|
||||
? {
|
||||
key: "border",
|
||||
@@ -410,6 +438,9 @@ const MediaBlockContent = ({ block, editor }: MediaBlockRenderProps) => {
|
||||
<DropdownMenuItem onClick={handleLink}>{dropdownLinkLabel}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleCopyLink(fileUrl)}>复制链接</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={viewOriginal}>查看原文件</DropdownMenuItem>
|
||||
{isPdfAsset && block.props.assetId && (
|
||||
<DropdownMenuItem onClick={openPdfViewer}>在内置阅读器打开</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={downloadAsset}>下载到本地</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
|
||||
@@ -327,6 +327,15 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
].filter((item) => matchKeywords(query, item.aliases ?? []));
|
||||
|
||||
const insertMediaSelection = (selection: MediaSelection) => {
|
||||
const nextAssetType =
|
||||
selection.assetType ??
|
||||
(selection.mimeType?.includes("pdf")
|
||||
? "file"
|
||||
: selection.mimeType?.startsWith("video")
|
||||
? "video"
|
||||
: selection.mimeType?.startsWith("audio")
|
||||
? "audio"
|
||||
: "image");
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
@@ -335,7 +344,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
fileUrl: selection.fileUrl,
|
||||
thumbnailUrl: selection.thumbnailUrl ?? selection.fileUrl,
|
||||
assetId: selection.assetId,
|
||||
assetType: selection.assetType ?? "image",
|
||||
assetType: nextAssetType,
|
||||
fileName: selection.fileName ?? "",
|
||||
fileSize: selection.fileSize ?? null,
|
||||
mimeType: selection.mimeType ?? "",
|
||||
@@ -354,7 +363,15 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
onSelect: (selection) => {
|
||||
insertMediaSelection({
|
||||
...selection,
|
||||
assetType: selection.assetType ?? mediaType,
|
||||
assetType:
|
||||
selection.assetType ??
|
||||
(selection.mimeType?.includes("pdf")
|
||||
? "file"
|
||||
: selection.mimeType?.startsWith("video")
|
||||
? "video"
|
||||
: selection.mimeType?.startsWith("audio")
|
||||
? "audio"
|
||||
: mediaType),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export type FloatingToolbarAction = {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type FloatingToolbarProps = {
|
||||
@@ -43,7 +44,7 @@ export const FloatingToolbar = memo(
|
||||
size="sm"
|
||||
className="min-w-[96px] justify-start gap-2 rounded-full bg-white/90 text-sm text-gray-700 shadow-md hover:bg-white dark:bg-neutral-900/80 dark:text-neutral-100"
|
||||
onClick={action.onClick}
|
||||
disabled={disabled}
|
||||
disabled={disabled || action.disabled}
|
||||
>
|
||||
<IconWrapper Icon={action.icon} />
|
||||
{action.label}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function KMindRenderer({
|
||||
const mindMapRef = useRef<MindMapInstance | null>(null);
|
||||
const latestExternalSignature = useRef<string>('');
|
||||
const latestDataRef = useRef<MindmapSnapshot>(data);
|
||||
const checkboxCssInjectedRef = useRef(false);
|
||||
const customCssInjectedRef = useRef(false);
|
||||
const rboxPatchedRef = useRef(false);
|
||||
const applyingExternalRef = useRef(false);
|
||||
|
||||
@@ -220,24 +220,6 @@ export function KMindRenderer({
|
||||
) {
|
||||
host.appendChild(instance.commonCaches.measureRichtextNodeTextSizeEl);
|
||||
}
|
||||
if (!instance.commonCaches.measureCustomNodeContentSizeEl) {
|
||||
const customDiv = document.createElement('div');
|
||||
customDiv.style.cssText = `
|
||||
position: fixed;
|
||||
left: -999999px;
|
||||
top: -999999px;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
`;
|
||||
host.appendChild(customDiv);
|
||||
instance.commonCaches.measureCustomNodeContentSizeEl = customDiv;
|
||||
} else if (
|
||||
instance.commonCaches.measureCustomNodeContentSizeEl &&
|
||||
!instance.commonCaches.measureCustomNodeContentSizeEl.isConnected
|
||||
) {
|
||||
host.appendChild(instance.commonCaches.measureCustomNodeContentSizeEl);
|
||||
}
|
||||
};
|
||||
|
||||
ensureMeasureHost(container);
|
||||
@@ -253,9 +235,9 @@ export function KMindRenderer({
|
||||
view: initialSnapshot.view,
|
||||
});
|
||||
|
||||
if (!checkboxCssInjectedRef.current) {
|
||||
if (!customCssInjectedRef.current) {
|
||||
instance.appendCss?.(CHECKBOX_STYLE_ID, CHECKBOX_CSS);
|
||||
checkboxCssInjectedRef.current = true;
|
||||
customCssInjectedRef.current = true;
|
||||
}
|
||||
|
||||
const handleDataChange = () => {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api";
|
||||
import { EventBus, PDFViewer, TextLayerBuilder } from "pdfjs-dist/web/pdf_viewer";
|
||||
import type { PdfOcrPayload, PdfQuote, PdfQuoteDraft, PdfQuoteRect } from "@/types/pdf";
|
||||
import "./pdf-viewer.css";
|
||||
|
||||
let workerPortInitialized = false;
|
||||
|
||||
const ensurePdfWorkerPort = () => {
|
||||
if (workerPortInitialized || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (!pdfjsLib.GlobalWorkerOptions.workerPort) {
|
||||
pdfjsLib.GlobalWorkerOptions.workerPort = new Worker(
|
||||
new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url),
|
||||
{ type: "module" },
|
||||
);
|
||||
}
|
||||
workerPortInitialized = true;
|
||||
};
|
||||
|
||||
export interface PdfViewerWithQuoteProps {
|
||||
url: string;
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
assetId: string;
|
||||
initialPage?: number;
|
||||
initialHighlightId?: string | null;
|
||||
onSelectionChange?: (draft: PdfQuoteDraft | null) => void;
|
||||
activeQuote?: PdfQuote | null;
|
||||
ocrLayer?: PdfOcrPayload | null;
|
||||
}
|
||||
|
||||
class SimpleTextLayerFactory {
|
||||
private eventBus: EventBus;
|
||||
|
||||
constructor(eventBus: EventBus) {
|
||||
this.eventBus = eventBus;
|
||||
}
|
||||
|
||||
createTextLayerBuilder(
|
||||
pageDiv: HTMLElement,
|
||||
pageIndex: number,
|
||||
viewport: pdfjsLib.PageViewport,
|
||||
enhanceTextSelection = false,
|
||||
) {
|
||||
const textLayerDiv = document.createElement("div");
|
||||
textLayerDiv.className = "textLayer";
|
||||
pageDiv.appendChild(textLayerDiv);
|
||||
return new TextLayerBuilder({
|
||||
textLayerDiv,
|
||||
pageIndex,
|
||||
viewport,
|
||||
eventBus: this.eventBus,
|
||||
enhanceTextSelection,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function PdfViewerWithQuote({
|
||||
url,
|
||||
documentId,
|
||||
workspaceId,
|
||||
assetId,
|
||||
initialPage = 1,
|
||||
initialHighlightId = null,
|
||||
onSelectionChange,
|
||||
activeQuote,
|
||||
ocrLayer = null,
|
||||
}: PdfViewerWithQuoteProps) {
|
||||
ensurePdfWorkerPort();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<PDFViewer | null>(null);
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy | null>(null);
|
||||
const eventBus = useMemo(() => new EventBus(), []);
|
||||
const textLayerFactory = useMemo(() => new SimpleTextLayerFactory(eventBus), [eventBus]);
|
||||
const ocrPagesByNumber = useMemo(() => {
|
||||
if (!ocrLayer?.pages?.length) {
|
||||
return null;
|
||||
}
|
||||
const map = new Map<number, (typeof ocrLayer.pages)[number]>();
|
||||
ocrLayer.pages.forEach((page) => {
|
||||
if (page?.page && page.blocks) {
|
||||
map.set(page.page, page);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [ocrLayer]);
|
||||
const clearOcrLayers = useCallback(() => {
|
||||
ocrLayerMapRef.current.forEach((layer) => layer.remove());
|
||||
ocrLayerMapRef.current.clear();
|
||||
}, []);
|
||||
const renderOcrLayerForPage = useCallback(
|
||||
(pageNumber: number, viewport?: { width: number; height: number }) => {
|
||||
if (!ocrPagesByNumber || !containerRef.current) return;
|
||||
const pageData = ocrPagesByNumber.get(pageNumber);
|
||||
if (!pageData) return;
|
||||
const pageEl = containerRef.current.querySelector<HTMLElement>(`.page[data-page-number="${pageNumber}"]`);
|
||||
if (!pageEl) return;
|
||||
const canvas = pageEl.querySelector<HTMLCanvasElement>("canvas");
|
||||
const cssWidth = canvas ? Number.parseFloat(canvas.style.width || "") : Number.NaN;
|
||||
const cssHeight = canvas ? Number.parseFloat(canvas.style.height || "") : Number.NaN;
|
||||
const width = viewport?.width ?? (!Number.isNaN(cssWidth) ? cssWidth : pageEl.clientWidth);
|
||||
const height = viewport?.height ?? (!Number.isNaN(cssHeight) ? cssHeight : pageEl.clientHeight);
|
||||
if (!width || !height) return;
|
||||
let layer = ocrLayerMapRef.current.get(pageNumber);
|
||||
if (!layer) {
|
||||
layer = document.createElement("div");
|
||||
layer.className = "pdf-ocr-layer";
|
||||
layer.dataset.ocrLayer = "true";
|
||||
pageEl.appendChild(layer);
|
||||
ocrLayerMapRef.current.set(pageNumber, layer);
|
||||
}
|
||||
layer.innerHTML = "";
|
||||
const scaleX = width / pageData.width;
|
||||
const scaleY = height / pageData.height;
|
||||
pageData.blocks.forEach((block) => {
|
||||
const coords = Array.isArray(block?.bbox) ? block.bbox : null;
|
||||
if (!coords || coords.length < 4) {
|
||||
return;
|
||||
}
|
||||
const [x1, y1, x2, y2] = coords;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-ocr-block";
|
||||
overlay.style.left = `${x1 * scaleX}px`;
|
||||
overlay.style.top = `${y1 * scaleY}px`;
|
||||
overlay.style.width = `${Math.max((x2 - x1) * scaleX, 2)}px`;
|
||||
overlay.style.height = `${Math.max((y2 - y1) * scaleY, 12)}px`;
|
||||
overlay.textContent = block.text ?? "";
|
||||
layer.appendChild(overlay);
|
||||
});
|
||||
},
|
||||
[ocrPagesByNumber],
|
||||
);
|
||||
const refreshOcrLayers = useCallback(() => {
|
||||
if (!ocrPagesByNumber || !containerRef.current) return;
|
||||
const pages = containerRef.current.querySelectorAll<HTMLElement>(".page[data-page-number]");
|
||||
pages.forEach((pageEl) => {
|
||||
const pageNumber = Number(pageEl.getAttribute("data-page-number"));
|
||||
if (!Number.isNaN(pageNumber)) {
|
||||
renderOcrLayerForPage(pageNumber);
|
||||
}
|
||||
});
|
||||
}, [ocrPagesByNumber, renderOcrLayerForPage]);
|
||||
const ocrLayerMapRef = useRef<Map<number, HTMLElement>>(new Map());
|
||||
|
||||
const removeHighlights = useCallback(() => {
|
||||
containerRef.current?.querySelectorAll(".pdf-quote-highlight").forEach((node) => node.remove());
|
||||
}, []);
|
||||
|
||||
const appendHighlights = useCallback(
|
||||
(quote: PdfQuote) => {
|
||||
if (!containerRef.current) return;
|
||||
removeHighlights();
|
||||
quote.rects.forEach((rect) => {
|
||||
const pageEl = containerRef.current?.querySelector<HTMLElement>(`[data-page-number="${rect.page}"]`);
|
||||
if (!pageEl) return;
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "pdf-quote-highlight";
|
||||
overlay.style.left = `${rect.x}px`;
|
||||
overlay.style.top = `${rect.y}px`;
|
||||
overlay.style.width = `${rect.width}px`;
|
||||
overlay.style.height = `${rect.height}px`;
|
||||
overlay.dataset.quoteId = quote.highlightId;
|
||||
pageEl.appendChild(overlay);
|
||||
});
|
||||
},
|
||||
[removeHighlights],
|
||||
);
|
||||
|
||||
const scrollToQuote = useCallback((quote: PdfQuote) => {
|
||||
const pageEl = containerRef.current?.querySelector<HTMLElement>(`[data-page-number="${quote.page}"]`);
|
||||
pageEl?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const task = pdfjsLib.getDocument({ url });
|
||||
task.promise
|
||||
.then((doc) => {
|
||||
if (cancelled) {
|
||||
doc.destroy();
|
||||
return;
|
||||
}
|
||||
setPdfDocument(doc);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = typeof error === "object" && error !== null ? (error as Error).message : "";
|
||||
if (message?.includes("worker is being destroyed")) {
|
||||
return;
|
||||
}
|
||||
console.error("加载 PDF 失败", error);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfDocument || !containerRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
const viewer = new PDFViewer({
|
||||
container: containerRef.current,
|
||||
eventBus,
|
||||
textLayerFactory,
|
||||
});
|
||||
viewerRef.current = viewer;
|
||||
viewer.setDocument(pdfDocument);
|
||||
const handlePagesInit = () => {
|
||||
viewer.currentPageNumber = initialPage;
|
||||
};
|
||||
eventBus.on("pagesinit", handlePagesInit);
|
||||
return () => {
|
||||
eventBus.off("pagesinit", handlePagesInit);
|
||||
viewer.cleanup();
|
||||
viewerRef.current = null;
|
||||
};
|
||||
}, [eventBus, initialPage, pdfDocument, textLayerFactory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ocrPagesByNumber) {
|
||||
clearOcrLayers();
|
||||
return;
|
||||
}
|
||||
refreshOcrLayers();
|
||||
}, [clearOcrLayers, ocrPagesByNumber, refreshOcrLayers]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePageRendered = (event: { pageNumber: number; source?: { viewport?: { width: number; height: number } | undefined } }) => {
|
||||
renderOcrLayerForPage(event.pageNumber, event.source?.viewport);
|
||||
};
|
||||
eventBus.on("pagerendered", handlePageRendered);
|
||||
return () => {
|
||||
eventBus.off("pagerendered", handlePageRendered);
|
||||
};
|
||||
}, [eventBus, renderOcrLayerForPage]);
|
||||
|
||||
useEffect(() => () => clearOcrLayers(), [clearOcrLayers]);
|
||||
|
||||
const resolvePageElement = (rect: DOMRect): HTMLElement | null => {
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
return document.elementFromPoint(x, y)?.closest(".page") as HTMLElement | null;
|
||||
};
|
||||
|
||||
const toQuoteRect = (rect: DOMRect, page: number, pageEl: HTMLElement): PdfQuoteRect => {
|
||||
const pageBounds = pageEl.getBoundingClientRect();
|
||||
return {
|
||||
page,
|
||||
x: rect.left - pageBounds.left,
|
||||
y: rect.top - pageBounds.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
if (containerRef.current && !selection.anchorNode) {
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
if (containerRef.current && selection.anchorNode && !containerRef.current.contains(selection.anchorNode)) {
|
||||
onSelectionChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const ranges = Array.from({ length: selection.rangeCount }, (_, index) => selection.getRangeAt(index));
|
||||
const rects: PdfQuoteRect[] = [];
|
||||
|
||||
ranges.forEach((range) => {
|
||||
Array.from(range.getClientRects()).forEach((clientRect) => {
|
||||
if (clientRect.width === 0 || clientRect.height === 0) return;
|
||||
const pageEl = resolvePageElement(clientRect);
|
||||
if (!pageEl) return;
|
||||
const pageNumber = Number(pageEl.getAttribute("data-page-number"));
|
||||
rects.push(toQuoteRect(clientRect, pageNumber, pageEl));
|
||||
});
|
||||
});
|
||||
|
||||
if (rects.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = selection.toString().trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quote: PdfQuoteDraft = {
|
||||
assetId,
|
||||
workspaceId,
|
||||
documentId,
|
||||
fileUrl: url,
|
||||
page: rects[0].page,
|
||||
rects,
|
||||
text,
|
||||
};
|
||||
onSelectionChange?.(quote);
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
};
|
||||
}, [assetId, documentId, onSelectionChange, url, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeQuote) {
|
||||
appendHighlights(activeQuote);
|
||||
scrollToQuote(activeQuote);
|
||||
} else {
|
||||
removeHighlights();
|
||||
}
|
||||
}, [activeQuote, appendHighlights, removeHighlights, scrollToQuote]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialHighlightId || !containerRef.current) return;
|
||||
const highlightEl = containerRef.current.querySelector<HTMLElement>(`[data-quote-id="${initialHighlightId}"]`);
|
||||
if (highlightEl) {
|
||||
highlightEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}, [initialHighlightId]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div ref={containerRef} className="pdf-viewer-container overflow-auto bg-[#f5f5f5]">
|
||||
<div className="pdfViewer" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@import "pdfjs-dist/web/pdf_viewer.css";
|
||||
|
||||
.pdf-viewer-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.pdf-viewer-container .pdfViewer .page {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pdf-quote-highlight {
|
||||
background-color: rgba(37, 99, 235, 0.2);
|
||||
border: 2px solid #2563eb;
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pdf-ocr-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.pdf-ocr-block {
|
||||
position: absolute;
|
||||
white-space: pre-wrap;
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.pdf-ocr-block::selection {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MindmapNode, MindmapPdfQuoteData } from "@/types/mindmap";
|
||||
|
||||
type MaybeNodeInstance = {
|
||||
getData?: () => MindmapNode["data"] | undefined;
|
||||
};
|
||||
|
||||
export const extractPdfQuoteFromNode = (
|
||||
node?: MaybeNodeInstance | null,
|
||||
): MindmapPdfQuoteData | null => {
|
||||
const data = node?.getData?.();
|
||||
if (!data || !data.pdfQuote) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: data.pdfQuote.id,
|
||||
assetId: data.pdfQuote.assetId,
|
||||
targetUrl: data.pdfQuote.targetUrl,
|
||||
highlightId: data.pdfQuote.highlightId,
|
||||
page: data.pdfQuote.page,
|
||||
text: data.pdfQuote.text,
|
||||
sourceTitle: data.pdfQuote.sourceTitle ?? null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
interface PdfUriParams {
|
||||
assetId: string;
|
||||
page?: number;
|
||||
highlightId?: string | null;
|
||||
}
|
||||
|
||||
export const buildPdfTargetUri = ({ assetId, page, highlightId }: PdfUriParams): string => {
|
||||
const search = new URLSearchParams();
|
||||
if (page && page > 0) {
|
||||
search.set("page", String(page));
|
||||
}
|
||||
if (highlightId) {
|
||||
search.set("highlight", highlightId);
|
||||
}
|
||||
const suffix = search.toString();
|
||||
return `/pdf/${encodeURIComponent(assetId)}${suffix ? `?${suffix}` : ""}`;
|
||||
};
|
||||
|
||||
export const parsePdfTargetUri = (uri: string) => {
|
||||
try {
|
||||
const url = uri.startsWith("http://") || uri.startsWith("https://") ? new URL(uri) : new URL(uri, "http://local");
|
||||
const pathname = url.pathname || "";
|
||||
if (!pathname.startsWith("/pdf/")) {
|
||||
return null;
|
||||
}
|
||||
const assetId = decodeURIComponent(pathname.replace("/pdf/", "").split("/")[0] ?? "");
|
||||
if (!assetId) {
|
||||
return null;
|
||||
}
|
||||
const params = url.searchParams;
|
||||
const page = params.get("page");
|
||||
const highlightId = params.get("highlight");
|
||||
return {
|
||||
assetId,
|
||||
page: page ? Number(page) || undefined : undefined,
|
||||
highlightId: highlightId || undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const buildPdfViewerUrl = ({ assetId, page, highlightId }: PdfUriParams): string =>
|
||||
buildPdfTargetUri({ assetId, page, highlightId });
|
||||
@@ -23,11 +23,22 @@ export type MindmapNode = {
|
||||
name: string;
|
||||
icon: string;
|
||||
}>;
|
||||
pdfQuote?: MindmapPdfQuoteData | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
children?: MindmapNode[];
|
||||
};
|
||||
|
||||
export type MindmapPdfQuoteData = {
|
||||
id?: string;
|
||||
assetId: string;
|
||||
targetUrl: string;
|
||||
highlightId: string;
|
||||
page: number;
|
||||
text: string;
|
||||
sourceTitle?: string | null;
|
||||
};
|
||||
|
||||
export type MindmapTheme = {
|
||||
template: string;
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export interface PdfQuoteRect {
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PdfQuote {
|
||||
id: string;
|
||||
highlightId: string;
|
||||
targetUrl: string;
|
||||
assetId: string;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileUrl: string | null;
|
||||
page: number;
|
||||
text: string;
|
||||
rects: PdfQuoteRect[];
|
||||
sourceTitle?: string | null;
|
||||
createdAt: string;
|
||||
createdBy?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfQuoteDraft {
|
||||
assetId: string;
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileUrl: string;
|
||||
page: number;
|
||||
text: string;
|
||||
rects: PdfQuoteRect[];
|
||||
sourceTitle?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfOcrBlock {
|
||||
text: string;
|
||||
bbox: [number, number, number, number];
|
||||
}
|
||||
|
||||
export interface PdfOcrPage {
|
||||
page: number;
|
||||
width: number;
|
||||
height: number;
|
||||
blocks: PdfOcrBlock[];
|
||||
}
|
||||
|
||||
export interface PdfOcrPayload {
|
||||
version?: string;
|
||||
pages: PdfOcrPage[];
|
||||
}
|
||||
@@ -291,8 +291,10 @@ export type Database = {
|
||||
file_name: string | null;
|
||||
file_size: number | null;
|
||||
mime_type: string | null;
|
||||
ocr_payload: Json | null;
|
||||
ocr_text: string | null;
|
||||
ocr_status: string | null;
|
||||
ocr_strategy: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -307,8 +309,10 @@ export type Database = {
|
||||
file_name?: string | null;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
ocr_payload?: Json | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
ocr_strategy?: string;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -323,8 +327,10 @@ export type Database = {
|
||||
file_name?: string | null;
|
||||
file_size?: number | null;
|
||||
mime_type?: string | null;
|
||||
ocr_payload?: Json | null;
|
||||
ocr_text?: string | null;
|
||||
ocr_status?: string | null;
|
||||
ocr_strategy?: string;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -350,6 +356,82 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
pdf_quotes: {
|
||||
Row: {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
document_id: string;
|
||||
asset_id: string;
|
||||
highlight_id: string;
|
||||
target_url: string;
|
||||
quote_text: string;
|
||||
page: number;
|
||||
rects: Json;
|
||||
file_url: string | null;
|
||||
source_title: string | null;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
document_id?: string;
|
||||
asset_id: string;
|
||||
highlight_id: string;
|
||||
target_url: string;
|
||||
quote_text: string;
|
||||
page: number;
|
||||
rects?: Json;
|
||||
file_url?: string | null;
|
||||
source_title?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
workspace_id?: string;
|
||||
document_id?: string;
|
||||
asset_id?: string;
|
||||
highlight_id?: string;
|
||||
target_url?: string;
|
||||
quote_text?: string;
|
||||
page?: number;
|
||||
rects?: Json;
|
||||
file_url?: string | null;
|
||||
source_title?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "pdf_quotes_workspace_id_fkey";
|
||||
columns: ["workspace_id"];
|
||||
referencedRelation: "workspaces";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "pdf_quotes_document_id_fkey";
|
||||
columns: ["document_id"];
|
||||
referencedRelation: "documents";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "pdf_quotes_asset_id_fkey";
|
||||
columns: ["asset_id"];
|
||||
referencedRelation: "media_assets";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
{
|
||||
foreignKeyName: "pdf_quotes_created_by_fkey";
|
||||
columns: ["created_by"];
|
||||
referencedRelation: "profiles";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
};
|
||||
user_recent_pages: {
|
||||
Row: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user