feat(frontend): 接入 pdf.js 预览并调整产物预览逻辑

This commit is contained in:
肖应宇 2026-04-11 16:23:00 +08:00 committed by Titan
parent 3d38501cd5
commit b7ead65f1d
4 changed files with 303 additions and 60 deletions

View File

@ -81,6 +81,7 @@
"nextra-theme-docs": "^4.6.1",
"nuxt-og-image": "^5.1.13",
"ogl": "^1.0.11",
"pdfjs-dist": "^5.6.205",
"pptx-preview": "^1.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -191,6 +191,9 @@ importers:
ogl:
specifier: ^1.0.11
version: 1.0.11
pdfjs-dist:
specifier: ^5.6.205
version: 5.6.205
pptx-preview:
specifier: ^1.0.7
version: 1.0.7
@ -963,6 +966,77 @@ packages:
'@mermaid-js/parser@0.6.3':
resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==}
'@napi-rs/canvas-android-arm64@0.1.97':
resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.97':
resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.97':
resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.97':
resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.97':
resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==}
engines: {node: '>= 10'}
'@napi-rs/simple-git-android-arm-eabi@0.1.22':
resolution: {integrity: sha512-JQZdnDNm8o43A5GOzwN/0Tz3CDBQtBUNqzVwEopm32uayjdjxev1Csp1JeaqF3v9djLDIvsSE39ecsN2LhCKKQ==}
engines: {node: '>= 10'}
@ -4605,6 +4679,9 @@ packages:
node-mock-http@1.0.4:
resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
node-readable-to-web-readable-stream@0.4.2:
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@ -4789,6 +4866,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.6.205:
resolution: {integrity: sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==}
engines: {node: '>=20.19.0 || >=22.13.0 || >=24'}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
@ -6751,6 +6832,54 @@ snapshots:
dependencies:
langium: 3.3.1
'@napi-rs/canvas-android-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.97':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.97':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.97':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.97':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.97':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.97':
optional: true
'@napi-rs/canvas@0.1.97':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.97
'@napi-rs/canvas-darwin-arm64': 0.1.97
'@napi-rs/canvas-darwin-x64': 0.1.97
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97
'@napi-rs/canvas-linux-arm64-gnu': 0.1.97
'@napi-rs/canvas-linux-arm64-musl': 0.1.97
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-gnu': 0.1.97
'@napi-rs/canvas-linux-x64-musl': 0.1.97
'@napi-rs/canvas-win32-arm64-msvc': 0.1.97
'@napi-rs/canvas-win32-x64-msvc': 0.1.97
optional: true
'@napi-rs/simple-git-android-arm-eabi@0.1.22':
optional: true
@ -10952,6 +11081,9 @@ snapshots:
node-mock-http@1.0.4: {}
node-readable-to-web-readable-stream@0.4.2:
optional: true
normalize-path@3.0.0: {}
npm-run-path@5.3.0:
@ -11205,6 +11337,11 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@5.6.205:
optionalDependencies:
'@napi-rs/canvas': 0.1.97
node-readable-to-web-readable-stream: 0.4.2
perfect-debounce@2.1.0: {}
performance-now@2.1.0:

View File

@ -112,7 +112,7 @@ export function ArtifactFileDetail({
}, [filepath]);
const artifactViewerSrcDoc = useMemo(() => {
if (!artifactUrl) {
return "";
return undefined;
}
return buildArtifactViewerSrcDoc({
artifactUrl,
@ -120,6 +120,11 @@ export function ArtifactFileDetail({
kind: artifactPreviewKind,
});
}, [artifactUrl, fileName, artifactPreviewKind]);
// Native PDF iframe rendering is intentionally disabled; PDFs are rendered via pdf.js.
const artifactViewerSrc = useMemo(() => {
return undefined;
}, []);
const artifactViewerSandbox = "allow-same-origin allow-scripts allow-downloads";
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
@ -608,7 +613,13 @@ export function ArtifactFileDetail({
</div>
)}
{!isCodeFile && (
isOfficePreviewKind(artifactPreviewKind) ? (
artifactPreviewKind === "pdf" ? (
<ArtifactPdfPreview
className="h-full mb-[207px]"
artifactUrl={artifactUrl}
fileName={fileName}
/>
) : isOfficePreviewKind(artifactPreviewKind) ? (
<ArtifactOfficePreview
className="h-full mb-[207px]"
kind={artifactPreviewKind}
@ -619,8 +630,9 @@ export function ArtifactFileDetail({
<PreviewIframe
className="size-full border-0"
containerClassName="h-full mb-[207px]"
src={artifactViewerSrc}
srcDoc={artifactViewerSrcDoc}
sandbox="allow-same-origin allow-scripts allow-downloads"
sandbox={artifactViewerSandbox}
title={`Artifact preview: ${fileName}`}
/>
)
@ -841,6 +853,144 @@ function PreviewIframe({
);
}
function ArtifactPdfPreview({
className,
artifactUrl,
fileName,
}: {
className?: string;
artifactUrl: string;
fileName: string;
}) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pageCount, setPageCount] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let disposed = false;
async function renderPdf() {
if (!artifactUrl || !containerRef.current) return;
setIsLoading(true);
setError(null);
setPageCount(0);
containerRef.current.innerHTML = "";
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.arrayBuffer();
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ data });
const pdf = await loadingTask.promise;
if (disposed || !containerRef.current) {
await loadingTask.destroy();
return;
}
setPageCount(pdf.numPages);
const hostWidth = Math.max(
640,
Math.min(containerRef.current.clientWidth || 960, 1200),
);
const dpr = window.devicePixelRatio || 1;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
if (disposed || !containerRef.current) break;
const page = await pdf.getPage(pageNum);
const baseViewport = page.getViewport({ scale: 1 });
const scale = (hostWidth - 32) / baseViewport.width;
const viewport = page.getViewport({ scale });
const pageWrapper = document.createElement("div");
pageWrapper.className =
"mx-auto mb-4 w-fit rounded-md border border-[#e4e7ec] bg-white p-2 shadow-sm";
const canvas = document.createElement("canvas");
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
const context = canvas.getContext("2d");
if (!context) continue;
context.scale(dpr, dpr);
pageWrapper.appendChild(canvas);
containerRef.current.appendChild(pageWrapper);
const renderTask = page.render({
canvas,
canvasContext: context,
viewport,
});
await renderTask.promise;
}
} catch (err) {
console.error("Failed to render pdf preview:", err);
if (!disposed) {
setError("无法预览该 PDF 文件,请下载后查看。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
}
void renderPdf();
return () => {
disposed = true;
};
}, [artifactUrl]);
if (error) {
return (
<div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}>
<div className="mx-auto grid max-w-xl gap-3 rounded-md border border-[#e4e7ec] bg-white p-5 text-center">
<p className="text-sm font-medium break-all">{fileName}</p>
<p className="text-muted-foreground text-sm">{error}</p>
<a
className="text-sm font-semibold text-blue-600 hover:underline"
href={artifactUrl}
target="_blank"
rel="noopener noreferrer"
>
</a>
</div>
</div>
);
}
return (
<div className={cn("relative overflow-auto bg-[#f8f9fb] p-4", className)}>
<div className="mb-3 text-center text-xs text-[#667085]">
{pageCount > 0 ? `${fileName} · ${pageCount} page(s)` : fileName}
</div>
<div ref={containerRef} />
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/70">
<LoaderIcon className="text-muted-foreground size-5 animate-spin" />
</div>
)}
</div>
);
}
function ArtifactOfficePreview({
className,
kind,
@ -858,7 +1008,6 @@ function ArtifactOfficePreview({
const [sheetNames, setSheetNames] = useState<string[]>([]);
const [activeSheet, setActiveSheet] = useState<string>("");
const docxContainerRef = useRef<HTMLDivElement | null>(null);
const pptxContainerRef = useRef<HTMLDivElement | null>(null);
const workbookRef = useRef<XLSX.WorkBook | null>(null);
const canRenderDocx = kind === "docx";
@ -972,54 +1121,12 @@ function ArtifactOfficePreview({
}
}, [activeSheet, canRenderXlsx]);
useEffect(() => {
let disposed = false;
type PptxPreviewModule = {
init: (
container: HTMLElement,
options: { width: number; height: number },
) => {
preview: (buffer: ArrayBuffer) => Promise<void> | void;
};
};
async function renderPptx() {
if (!canRenderPptx || !artifactUrl || !pptxContainerRef.current) {
return;
}
setIsLoading(true);
setError(null);
try {
const response = await fetch(artifactUrl);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const bytes = await response.arrayBuffer();
const pptxModule = (await import("pptx-preview")) as unknown as PptxPreviewModule;
if (disposed || !pptxContainerRef.current) {
return;
}
const container = pptxContainerRef.current;
container.innerHTML = "";
const previewer = pptxModule.init(container, { width: 960, height: 540 });
await Promise.resolve(previewer.preview(bytes));
} catch (err) {
console.error("Failed to render pptx preview:", err);
if (!disposed) {
setError("无法预览该 PPT 文件。");
}
} finally {
if (!disposed) {
setIsLoading(false);
}
}
if (!canRenderPptx) {
return;
}
void renderPptx();
return () => {
disposed = true;
};
}, [artifactUrl, canRenderPptx]);
setIsLoading(false);
setError("请下载ppt文件以获得最佳效果");
}, [canRenderPptx]);
return (
<div className={cn("relative h-full overflow-hidden bg-white", className)}>
@ -1056,12 +1163,6 @@ function ArtifactOfficePreview({
dangerouslySetInnerHTML={{ __html: xlsxHtml }}
/>
)}
{canRenderPptx && (
<div
ref={pptxContainerRef}
className="pptx-preview-wrap mx-auto w-full overflow-auto"
/>
)}
</div>
{error && (
@ -1099,7 +1200,7 @@ function ArtifactPreviewFallback({
target="_blank"
rel="noreferrer"
>
</a>
</div>
);
@ -1239,7 +1340,11 @@ function buildArtifactViewerSrcDoc({
return `<div class="audio-wrap"><audio class="audio" src="${safeUrl}" controls preload="metadata"></audio></div>`;
}
if (kind === "pdf") {
return `<iframe class="preview frame" src="${safeUrl}#view=FitH"></iframe>`;
return `<div class="fallback">
<p class="title">${safeName}</p>
<p class="desc">PDF preview is temporarily disabled. Please download the file to view it.</p>
<a class="link" href="${safeUrl}" target="_blank" rel="noopener noreferrer">Open in new tab</a>
</div>`;
}
if (kind === "html") {
return `<iframe class="preview frame" src="${safeUrl}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads"></iframe>`;

View File

@ -165,7 +165,7 @@ async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
return;
}
if (kind === "pptx") {
await expect(page.locator(".pptx-preview-wrap").first()).toBeVisible({
await expect(page.getByText("请下载ppt文件以获得最佳效果").first()).toBeVisible({
timeout: 60_000,
});
return;