git save current version as 0.0.8

This commit is contained in:
liaibo
2025-12-02 18:10:39 +08:00
parent 3a9b0dc83c
commit 15921bfeb7
466 changed files with 2477 additions and 352016 deletions
+1
View File
@@ -16,6 +16,7 @@ const eslintConfig = defineConfig([
"build/**",
"next-env.d.ts",
"public/luckysheet/**",
"public/pdf.worker.min.js",
]),
{
rules: {
+1
View File
@@ -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",
+119
View File
@@ -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,
};
};
+44
View File
@@ -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 });
+11
View File
@@ -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>;
+51
View File
@@ -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[];
}
+82
View File
@@ -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;