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

197
web/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"@ant-design/icons": "^5.2.6",
"@antv/g2": "^5.2.10",
"@antv/g6": "^5.1.0",
"@extend-ai/react-docx": "^0.6.7",
"@floating-ui/react": "^0.27.19",
"@hookform/resolvers": "^3.9.1",
"@js-preview/excel": "^1.7.14",
@@ -73,7 +74,6 @@
"lexical": "^0.23.1",
"lodash": "^4.17.23",
"lucide-react": "^1.7.0",
"mammoth": "^1.7.2",
"next-themes": "^0.4.6",
"openai-speech-stream-player": "^1.0.8",
"papaparse": "^5.5.3",
@@ -1275,6 +1275,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@chenglou/pretext": {
"version": "0.0.5",
"resolved": "https://registry.npmmirror.com/@chenglou/pretext/-/pretext-0.0.5.tgz",
"integrity": "sha512-A8GZN10REdFGsyuiUgLV8jjPDDFMg5GmgxGWV0I3igxBOnzj+jgz2VMmVD7g+SFyoctfeqHFxbNatKSzVRWtRg==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -2014,6 +2020,22 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@extend-ai/react-docx": {
"version": "0.6.7",
"resolved": "https://registry.npmmirror.com/@extend-ai/react-docx/-/react-docx-0.6.7.tgz",
"integrity": "sha512-4z95OFWNYKOEzIVKxoe78Eg6tS+Cu3gA3HnF7C0DEozK+ThECh8U1xSmiqZwp4bjC2OSxvMUtgNNhkEkl6YjAQ==",
"license": "MIT",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/react-virtual": "^3.13.12",
"fast-png": "^8.0.0",
"utif": "^3.1.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.11.6",
"resolved": "https://registry.npmmirror.com/@ffmpeg/ffmpeg/-/ffmpeg-0.11.6.tgz",
@@ -7685,6 +7707,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmmirror.com/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz",
@@ -7698,6 +7737,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.0",
"resolved": "https://registry.npmmirror.com/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -9247,15 +9296,6 @@
"react": "^18"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@xtuc/ieee754": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@@ -10075,26 +10115,6 @@
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.11",
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@@ -10140,12 +10160,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
@@ -12010,12 +12024,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -12224,15 +12232,6 @@
"node": ">=12"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -13419,6 +13418,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-png": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-8.0.0.tgz",
"integrity": "sha512-gCysNasJ8KEMgfdYIKd/wTDo6ENK1PWT0RJO7O+0pgmuHPw2O6tA1WvdxFRJoLf9V8yFYpG0FA1YgI8X97OhJA==",
"license": "MIT",
"dependencies": {
"fflate": "^0.8.2",
"iobuffer": "^6.0.1"
}
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -13490,6 +13499,12 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -15373,6 +15388,12 @@
"deprecated": "The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019.",
"license": "Apache-2.0"
},
"node_modules/iobuffer": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-6.0.1.tgz",
"integrity": "sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==",
"license": "MIT"
},
"node_modules/is-alphabetical": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
@@ -17990,17 +18011,6 @@
"loose-envify": "cli.js"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz",
@@ -18114,39 +18124,6 @@
"tmpl": "1.0.5"
}
},
"node_modules/mammoth": {
"version": "1.11.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.11.0.tgz",
"integrity": "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/mammoth/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/markdown-extensions": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz",
@@ -19847,12 +19824,6 @@
"integrity": "sha512-4pYnxvvOpL1PVgyuXly2GXG7IyGZErKgoaRUZKTi84Sd2sRLYtu5YfcpVip1nbH6awvxDYaIRyQrKP4Jm1zzRA==",
"license": "ISC"
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz",
@@ -25911,12 +25882,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/underscore": {
"version": "1.13.7",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.7.tgz",
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz",
@@ -26220,6 +26185,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utif": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/utif/-/utif-3.1.0.tgz",
"integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.5"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -27825,15 +27799,6 @@
"node": ">=12"
}
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",

View File

@@ -32,6 +32,7 @@
"@ant-design/icons": "^5.2.6",
"@antv/g2": "^5.2.10",
"@antv/g6": "^5.1.0",
"@extend-ai/react-docx": "^0.6.7",
"@floating-ui/react": "^0.27.19",
"@hookform/resolvers": "^3.9.1",
"@js-preview/excel": "^1.7.14",
@@ -95,7 +96,6 @@
"lexical": "^0.23.1",
"lodash": "^4.17.23",
"lucide-react": "^1.7.0",
"mammoth": "^1.7.2",
"next-themes": "^0.4.6",
"openai-speech-stream-player": "^1.0.8",
"papaparse": "^5.5.3",

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 {