diff --git a/web/src/components/document-preview/doc-preview.tsx b/web/src/components/document-preview/doc-preview.tsx index 22af1da1c0..fea4a9d44d 100644 --- a/web/src/components/document-preview/doc-preview.tsx +++ b/web/src/components/document-preview/doc-preview.tsx @@ -1,59 +1,26 @@ 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 { + DocxEditorViewer, + useDocxEditor, + useDocxPageLayout, +} from '@extend-ai/react-docx'; import classNames from 'classnames'; import { ZoomIn, ZoomOut } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { + isZipLikeBlob, + useDocumentResizeObserver, + useDocxPreviewZoom, +} from './hooks'; + interface DocPreviewerProps { className?: string; url: string; } -// ZIP file header bytes "PK" -const ZIP_HEADER_0 = 0x50; -const ZIP_HEADER_1 = 0x4b; - -const isZipLikeBlob = async (blob: Blob): Promise => { - 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. @@ -63,9 +30,19 @@ export const DocPreviewer: React.FC = ({ }) => { const editor = useDocxEditor({ initialFileName: 'document.docx' }); const { importDocxFile, status, totalPages } = editor; + const { layout } = useDocxPageLayout(editor); + const { containerWidth, setContainerRef } = useDocumentResizeObserver(); const [loading, setLoading] = useState(false); const [error, setError] = useState(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); // Fetch the document blob and load it into the editor @@ -117,7 +94,6 @@ export const DocPreviewer: React.FC = ({ await importDocxFile(file); if (!cancelledRef.current) { - setZoomScale(100); setLoading(false); } } catch (err) { @@ -144,15 +120,6 @@ export const DocPreviewer: React.FC = ({ } }, [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 ( @@ -170,7 +137,7 @@ export const DocPreviewer: React.FC = ({
{/* Viewer / Error area */} -
+
{loading && (
@@ -221,7 +187,7 @@ export const DocPreviewer: React.FC = ({ )} {showContent && ( -
+
=> { + 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 = () => { const [containerWidth, setContainerWidth] = useState(); const [containerRef, setContainerRef] = useState(null); @@ -146,3 +166,117 @@ export const useCatchDocumentError = (url: string) => { 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, + }; +}; diff --git a/web/src/pages/agent/form/title-chunker-form/hook.ts b/web/src/pages/agent/form/title-chunker-form/hook.ts index d084a4b44d..53323c25c7 100644 --- a/web/src/pages/agent/form/title-chunker-form/hook.ts +++ b/web/src/pages/agent/form/title-chunker-form/hook.ts @@ -78,10 +78,10 @@ function transformApiResponseToForm( } } - if (method === TitleChunkerMethod.Group && !hierarchyGroup) { + if (!hierarchyGroup) { hierarchyGroup = '0'; } - if (method === TitleChunkerMethod.Hierarchy && !hierarchyHierarchy) { + if (!hierarchyHierarchy) { hierarchyHierarchy = hierarchy || Hierarchy.H3; }