Fix: The .docx file is not displaying fully; the hierarchy of the pipeline created from the template is missing. (#16134)

### What problem does this PR solve?

Fix: The .docx file is not displaying fully; the hierarchy of the
pipeline created from the template is missing.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
This commit is contained in:
balibabu
2026-06-17 16:18:47 +08:00
committed by GitHub
parent fcb4f78d97
commit 3247e353c7
3 changed files with 165 additions and 65 deletions

View File

@@ -1,59 +1,26 @@
import message from '@/components/ui/message'; import message from '@/components/ui/message';
import { Spin } from '@/components/ui/spin'; import { Spin } from '@/components/ui/spin';
import request from '@/utils/request'; import request from '@/utils/request';
import { DocxEditorViewer, useDocxEditor } from '@extend-ai/react-docx'; import {
DocxEditorViewer,
useDocxEditor,
useDocxPageLayout,
} from '@extend-ai/react-docx';
import classNames from 'classnames'; import classNames from 'classnames';
import { ZoomIn, ZoomOut } from 'lucide-react'; import { ZoomIn, ZoomOut } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import {
isZipLikeBlob,
useDocumentResizeObserver,
useDocxPreviewZoom,
} from './hooks';
interface DocPreviewerProps { interface DocPreviewerProps {
className?: string; className?: string;
url: string; url: string;
} }
// 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. // Word document preview component.
// Uses @extend-ai/react-docx for canvas-based page-level rendering. // Uses @extend-ai/react-docx for canvas-based page-level rendering.
// Falls back to an unsupported notice for legacy .doc (non-ZIP) payloads. // Falls back to an unsupported notice for legacy .doc (non-ZIP) payloads.
@@ -63,9 +30,19 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
}) => { }) => {
const editor = useDocxEditor({ initialFileName: 'document.docx' }); const editor = useDocxEditor({ initialFileName: 'document.docx' });
const { importDocxFile, status, totalPages } = editor; const { importDocxFile, status, totalPages } = editor;
const { layout } = useDocxPageLayout(editor);
const { containerWidth, setContainerRef } = useDocumentResizeObserver();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [zoomScale, setZoomScale] = useState(100); const showContent = !loading && !error;
const { zoomScale, minZoom, maxZoom, handleZoomIn, handleZoomOut } =
useDocxPreviewZoom({
url,
totalPages,
pageWidthPx: layout?.pageWidthPx,
containerWidth,
enabled: showContent,
});
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
// Fetch the document blob and load it into the editor // Fetch the document blob and load it into the editor
@@ -117,7 +94,6 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
await importDocxFile(file); await importDocxFile(file);
if (!cancelledRef.current) { if (!cancelledRef.current) {
setZoomScale(100);
setLoading(false); setLoading(false);
} }
} catch (err) { } catch (err) {
@@ -144,15 +120,6 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
} }
}, [status]); }, [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; const pageCount = showContent && totalPages > 0 ? totalPages : 0;
return ( return (
@@ -170,7 +137,7 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
type="button" type="button"
disabled={loading || !!error || zoomScale <= ZOOM_STEPS[0]} disabled={loading || !!error || zoomScale <= minZoom}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
onClick={handleZoomOut} onClick={handleZoomOut}
aria-label="Zoom out" aria-label="Zoom out"
@@ -182,11 +149,7 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
</span> </span>
<button <button
type="button" type="button"
disabled={ disabled={loading || !!error || zoomScale >= maxZoom}
loading ||
!!error ||
zoomScale >= ZOOM_STEPS[ZOOM_STEPS.length - 1]
}
className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30 transition-opacity"
onClick={handleZoomIn} onClick={handleZoomIn}
aria-label="Zoom in" aria-label="Zoom in"
@@ -197,7 +160,10 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
</div> </div>
{/* Viewer / Error area */} {/* Viewer / Error area */}
<div className="relative flex-1 overflow-auto bg-background-paper"> <div
ref={setContainerRef}
className="relative flex-1 overflow-auto bg-background-paper"
>
{loading && ( {loading && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Spin /> <Spin />
@@ -221,7 +187,7 @@ export const DocPreviewer: React.FC<DocPreviewerProps> = ({
)} )}
{showContent && ( {showContent && (
<div className="flex justify-center p-4"> <div className="flex p-4" style={{ justifyContent: 'safe center' }}>
<div style={{ zoom: zoomScale / 100 }}> <div style={{ zoom: zoomScale / 100 }}>
<DocxEditorViewer <DocxEditorViewer
editor={editor} editor={editor}

View File

@@ -8,6 +8,26 @@ import { useSize } from 'ahooks';
import axios from 'axios'; import axios from 'axios';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// ZIP file header bytes "PK"
const ZIP_HEADER_0 = 0x50;
const ZIP_HEADER_1 = 0x4b;
export 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;
}
};
export const useDocumentResizeObserver = () => { export const useDocumentResizeObserver = () => {
const [containerWidth, setContainerWidth] = useState<number>(); const [containerWidth, setContainerWidth] = useState<number>();
const [containerRef, setContainerRef] = useState<HTMLElement | null>(null); const [containerRef, setContainerRef] = useState<HTMLElement | null>(null);
@@ -146,3 +166,117 @@ export const useCatchDocumentError = (url: string) => {
return error; return error;
}; };
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;
};
interface UseDocxPreviewZoomOptions {
url: string;
totalPages: number;
pageWidthPx?: number;
containerWidth?: number;
paddingPx?: number;
enabled?: boolean;
}
interface UseDocxPreviewZoomResult {
zoomScale: number;
minZoom: number;
maxZoom: number;
handleZoomIn: () => void;
handleZoomOut: () => void;
resetZoom: () => void;
}
export const useDocxPreviewZoom = ({
url,
totalPages,
pageWidthPx,
containerWidth,
paddingPx = 32,
enabled = true,
}: UseDocxPreviewZoomOptions): UseDocxPreviewZoomResult => {
const [zoomScale, setZoomScale] = useState(100);
const [hasUserZoomed, setHasUserZoomed] = useState(false);
const [isInitialFitPending, setIsInitialFitPending] = useState(true);
const resetZoom = useCallback(() => {
setZoomScale(100);
setHasUserZoomed(false);
setIsInitialFitPending(true);
}, []);
useEffect(() => {
resetZoom();
}, [url, resetZoom]);
const handleZoomIn = useCallback(() => {
setHasUserZoomed(true);
setZoomScale((s) => clampZoom(s, 1));
}, []);
const handleZoomOut = useCallback(() => {
setHasUserZoomed(true);
setZoomScale((s) => clampZoom(s, -1));
}, []);
// Fit the page width to the container on first paint and on resize,
// unless the user has manually changed the zoom level.
useEffect(() => {
if (!enabled || totalPages <= 0 || !containerWidth || !pageWidthPx) {
return;
}
const availableWidth = Math.max(0, containerWidth - paddingPx);
if (availableWidth <= 0) {
return;
}
const fitScale = Math.floor((availableWidth / pageWidthPx) * 100);
const clampedFitScale = Math.min(100, fitScale);
if (isInitialFitPending) {
setZoomScale(clampedFitScale);
setIsInitialFitPending(false);
} else if (!hasUserZoomed) {
setZoomScale(clampedFitScale);
}
}, [
enabled,
totalPages,
containerWidth,
pageWidthPx,
paddingPx,
isInitialFitPending,
hasUserZoomed,
]);
return {
zoomScale,
minZoom: ZOOM_STEPS[0],
maxZoom: ZOOM_STEPS[ZOOM_STEPS.length - 1],
handleZoomIn,
handleZoomOut,
resetZoom,
};
};

View File

@@ -78,10 +78,10 @@ function transformApiResponseToForm(
} }
} }
if (method === TitleChunkerMethod.Group && !hierarchyGroup) { if (!hierarchyGroup) {
hierarchyGroup = '0'; hierarchyGroup = '0';
} }
if (method === TitleChunkerMethod.Hierarchy && !hierarchyHierarchy) { if (!hierarchyHierarchy) {
hierarchyHierarchy = hierarchy || Hierarchy.H3; hierarchyHierarchy = hierarchy || Hierarchy.H3;
} }