Improve docx preview (#15907)

This commit is contained in:
Yingfeng
2026-06-11 20:43:58 +08:00
committed by GitHub
parent bde2b1fc6d
commit bae8c6f109
4 changed files with 276 additions and 243 deletions

View File

@@ -1,134 +1,242 @@
import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin';
import request from '@/utils/request';
import { DocxEditorViewer, useDocxEditor } from '@extend-ai/react-docx';
import classNames from 'classnames';
import mammoth from 'mammoth';
import { useEffect, useState } from 'react';
import { ZoomIn, ZoomOut } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface DocPreviewerProps {
className?: string;
url: string;
}
// Word document preview component. Behavior:
// 1) Fetches the document as a Blob.
// 2) Detects .docx input via a ZIP header probe.
// 3) Renders .docx using Mammoth; presents a controlled "unsupported" notice for non-ZIP payloads.
// ZIP file header bytes "PK"
const ZIP_HEADER_0 = 0x50;
const ZIP_HEADER_1 = 0x4b;
const isZipLikeBlob = async (blob: Blob): Promise<boolean> => {
try {
const headerSlice = blob.slice(0, 4);
const buf = await headerSlice.arrayBuffer();
const bytes = new Uint8Array(buf);
return (
bytes.length >= 2 &&
bytes[0] === ZIP_HEADER_0 &&
bytes[1] === ZIP_HEADER_1
);
} catch (e) {
console.error('Failed to inspect blob header', e);
return false;
}
};
const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 175, 200] as const;
const clampZoom = (scale: number, direction: 1 | -1): number => {
let idx = ZOOM_STEPS.indexOf(scale as (typeof ZOOM_STEPS)[number]);
if (idx < 0) {
if (direction > 0) {
idx = ZOOM_STEPS.findIndex((v) => v > scale);
} else {
for (let i = ZOOM_STEPS.length - 1; i >= 0; i--) {
if (ZOOM_STEPS[i] < scale) {
idx = i;
break;
}
}
}
}
idx = Math.max(
0,
Math.min(ZOOM_STEPS.length - 1, idx < 0 ? 0 : idx + direction),
);
return ZOOM_STEPS[idx] ?? scale;
};
// Word document preview component.
// Uses @extend-ai/react-docx for canvas-based page-level rendering.
// Falls back to an unsupported notice for legacy .doc (non-ZIP) payloads.
export const DocPreviewer: React.FC<DocPreviewerProps> = ({
className,
url,
}) => {
const [htmlContent, setHtmlContent] = useState<string>('');
const editor = useDocxEditor({ initialFileName: 'document.docx' });
const { importDocxFile, status, totalPages } = editor;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [zoomScale, setZoomScale] = useState(100);
const cancelledRef = useRef(false);
// Determines whether the Blob represents a .docx document by checking for the ZIP
// file signature ("PK") in the initial bytes. A valid .docx file is a ZIP container
// and always begins with:
// 50 4B 03 04 ("PK..")
//
// Legacy .doc files use the CFBF binary format, commonly starting with:
// D0 CF 11 E0 A1 B1 1A E1
//
// Note that some files distributed with a “.doc” extension may internally be .docx
// documents (e.g., renamed files or files produced by systems that export .docx
// content under a .doc filename). These files will still present the ZIP signature
// and are therefore treated as supported .docx payloads. The header inspection
// ensures correct routing regardless of filename or reported extension.
const isZipLikeBlob = async (blob: Blob): Promise<boolean> => {
try {
const headerSlice = blob.slice(0, 4);
const buf = await headerSlice.arrayBuffer();
const bytes = new Uint8Array(buf);
// ZIP files start with "PK" (0x50, 0x4B)
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
} catch (e) {
console.error('Failed to inspect blob header', e);
return false;
}
};
const fetchDocument = async () => {
// Fetch the document blob and load it into the editor
const fetchDocument = useCallback(async () => {
if (!url) return;
cancelledRef.current = false;
setLoading(true);
setError(null);
const res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
message.error('Document parsing failed');
console.error('Error loading document:', url);
},
});
let res;
try {
res = await request(url, {
method: 'GET',
responseType: 'blob',
onError: () => {
if (!cancelledRef.current) {
message.error('Document parsing failed');
console.error('Error loading document:', url);
}
},
});
} catch {
if (!cancelledRef.current) {
setError('Failed to fetch document.');
setLoading(false);
}
return;
}
if (cancelledRef.current) return;
try {
const blob: Blob = res.data;
const contentType: string =
blob.type || (res as any).headers?.['content-type'] || '';
// Execution path selection: ZIP-like payloads are treated as .docx and rendered via Mammoth;
// non-ZIP payloads receive an explicit unsupported notice.
const looksLikeZip = await isZipLikeBlob(blob);
if (!looksLikeZip) {
// Non-ZIP payload (likely legacy .doc or another format): skip Mammoth processing.
setHtmlContent(`
<div class="flex h-full items-center justify-center">
<div class="border border-dashed border-border-normal rounded-xl p-8 max-w-2xl text-center">
<p class="text-2xl font-bold mb-4">
Preview is not available for this Word document
</p>
<p class="italic text-sm text-muted-foreground leading-relaxed">
Mammoth supports modern <code>.docx</code> files only.<br/>
The file header does not indicate a <code>.docx</code> ZIP archive.
</p>
</div>
</div>
`);
setError(
'This file header does not indicate a .docx ZIP archive. Only .docx files are supported.',
);
setLoading(false);
return;
}
// ZIP-like payload: parse as .docx with Mammoth
const arrayBuffer = await blob.arrayBuffer();
const result = await mammoth.convertToHtml(
{ arrayBuffer },
{ includeDefaultStyleMap: true },
);
const file = new File([blob], 'document.docx', {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const styledContent = result.value
.replace(/<p>/g, '<p class="mb-2">')
.replace(/<h(\d)>/g, '<h$1 class="font-semibold mt-4 mb-2">');
await importDocxFile(file);
setHtmlContent(styledContent);
if (!cancelledRef.current) {
setZoomScale(100);
setLoading(false);
}
} catch (err) {
message.error('Failed to parse document.');
console.error('Error parsing document:', err);
} finally {
setLoading(false);
if (!cancelledRef.current) {
message.error('Failed to parse document.');
console.error('Error parsing document:', err);
setLoading(false);
}
}
};
}, [url, importDocxFile]);
useEffect(() => {
if (url) {
fetchDocument();
fetchDocument();
return () => {
cancelledRef.current = true;
};
}, [fetchDocument]);
// Monitor editor status for library-level errors
useEffect(() => {
if (status === 'Only .docx files are supported') {
setError(status);
setLoading(false);
}
}, [url]);
}, [status]);
const handleZoomIn = useCallback(() => {
setZoomScale((s) => clampZoom(s, 1));
}, []);
const handleZoomOut = useCallback(() => {
setZoomScale((s) => clampZoom(s, -1));
}, []);
const showContent = !loading && !error;
const pageCount = showContent && totalPages > 0 ? totalPages : 0;
return (
<div
className={classNames(
'relative w-full h-full p-4 bg-background-paper border border-border-normal rounded-md overflow-auto',
'relative w-full h-full flex flex-col bg-background-paper border border-border-normal rounded-md overflow-hidden',
className,
)}
>
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
{/* Toolbar */}
<div className="flex items-center justify-between shrink-0 px-4 py-2 border-b border-border-normal bg-background-paper">
<span className="text-sm text-muted-foreground">
{loading ? 'Loading...' : error ? '' : `Page ${pageCount || '-'}`}
</span>
<div className="flex items-center gap-1">
<button
type="button"
disabled={loading || !!error || zoomScale <= ZOOM_STEPS[0]}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
onClick={handleZoomOut}
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-sm w-12 text-center tabular-nums select-none">
{zoomScale}%
</span>
<button
type="button"
disabled={
loading ||
!!error ||
zoomScale >= ZOOM_STEPS[ZOOM_STEPS.length - 1]
}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
onClick={handleZoomIn}
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4" />
</button>
</div>
)}
</div>
{!loading && <div dangerouslySetInnerHTML={{ __html: htmlContent }} />}
{/* Viewer / Error area */}
<div className="relative flex-1 overflow-auto bg-background-paper">
{loading && (
<div className="absolute inset-0 flex items-center justify-center">
<Spin />
</div>
)}
{error && !loading && (
<div className="flex items-center justify-center h-full p-8">
<div className="border border-dashed border-border-normal rounded-xl p-8 max-w-2xl text-center">
<p className="text-2xl font-bold mb-4">
Preview is not available for this Word document
</p>
<p className="italic text-sm text-muted-foreground leading-relaxed">
@extend-ai/react-docx supports modern <code>.docx</code> files
only.
<br />
{error}
</p>
</div>
</div>
)}
{showContent && (
<div className="flex justify-center p-4">
<div style={{ zoom: zoomScale / 100 }}>
<DocxEditorViewer
editor={editor}
mode="read-only"
loadingState={
<div className="flex items-center justify-center p-8">
<Spin />
</div>
}
pageGapBackgroundColor="#f5f5f5"
/>
</div>
</div>
)}
</div>
</div>
);
};

View File

@@ -6,7 +6,6 @@ import { getAuthorization } from '@/utils/authorization-util';
import jsPreviewExcel from '@js-preview/excel';
import { useSize } from 'ahooks';
import axios from 'axios';
import mammoth from 'mammoth';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
export const useDocumentResizeObserver = () => {
@@ -127,45 +126,6 @@ export const useFetchExcel = (filePath: string) => {
return { status, containerRef, error };
};
export const useFetchDocx = (filePath: string) => {
const [succeed, setSucceed] = useState(true);
const [error, setError] = useState<string>();
const { fetchDocument } = useFetchDocument();
const containerRef = useRef<HTMLDivElement>(null);
const fetchDocumentAsync = useCallback(async () => {
try {
const jsonFile = await fetchDocument(filePath);
mammoth
.convertToHtml(
{ arrayBuffer: jsonFile.data },
{ includeDefaultStyleMap: true },
)
.then((result) => {
setSucceed(true);
const docEl = document.createElement('div');
docEl.className = 'document-container';
docEl.innerHTML = result.value;
const container = containerRef.current;
if (container) {
container.innerHTML = docEl.outerHTML;
}
})
.catch(() => {
setSucceed(false);
});
} catch (error: any) {
setError(error.toString());
}
}, [filePath, fetchDocument]);
useEffect(() => {
fetchDocumentAsync();
}, [fetchDocumentAsync]);
return { succeed, containerRef, error };
};
export const useCatchDocumentError = (url: string) => {
const httpHeaders = useMemo(() => {
return {