From e0e8327992b0a73ee6aa4ffd12f8f488e99fe5ea Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 23 Sep 2026 18:12:21 +0800 Subject: [PATCH] fix(preview): redraw PDF pages after settled zoom --- apps/web/tests/document-preview.e2e.ts | 46 ++++++++- apps/web/tests/expected/office-font-notice.md | 2 + .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/pdf/document.ts | 2 +- .../src/client/pdf/pdf.tsx | 77 ++++++++++----- .../src/client/zoom/ZoomViewport.tsx | 5 +- .../tests/pdf-body.client.spec.tsx | 96 ++++++++++++++++++- .../tests/zoom-viewport.client.spec.tsx | 6 +- .../web/document-preview/document.expected.md | 1 + 11 files changed, 213 insertions(+), 34 deletions(-) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 14c2728288..d8b0957d8e 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise { }) } +/** Wait for device resolution, subject to the page bitmap allocation limit. */ +async function expectPdfResolution(canvas: Locator): Promise { + await expect.poll(() => canvas.evaluate((node) => { + const bitmap = node as HTMLCanvasElement + const width = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-width')) + const height = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-height')) + const expected = Math.min(bitmap.getBoundingClientRect().width * window.devicePixelRatio, + Math.sqrt(16_777_216 * width / height)) + return Math.abs(bitmap.width - expected) + })).toBeLessThanOrEqual(1) +} + /** Select a workspace file through the Files tab and wait for its preview identity. */ async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise { await filesTab.click() @@ -808,10 +820,12 @@ else process.exit(1); await pdfZoom.click() await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1) + await expectPdfResolution(canvas) pdfZoom = await revealDocumentZoom(page, preview) await pdfZoom.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() await expect.poll(async () => (await canvas.boundingBox())!.width).toBeCloseTo(pdfWidth, 0) + await expectPdfResolution(canvas) expect(await preview.locator('[data-pdf-page]').count()).toBe(2) await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red') const firstColor = await canvasColor(canvas) @@ -846,6 +860,7 @@ else process.exit(1); `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, '- Zoom reveal: hidden -> bottom hover -> delayed hidden', '- Zoom modes: fit width -> 100% -> 150% -> fit width', + '- Settled zoom redraws the page at device resolution', `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`, @@ -1354,7 +1369,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { ], }) browser = await chromium.launch() - page = await newEnglishPage(browser) + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor: 2, + locale: 'en-US', timezoneId: 'Asia/Shanghai' }) const tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) @@ -1430,6 +1446,7 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expect.poll(() => zoomMenu.innerText()).toBe('150%') await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.5, 1) + await expectPdfResolution(canvas) const zoomScrollport = preview.locator('[data-document-zoom-scrollport]') await zoomScrollport.evaluate((node) => { const bounds = node.getBoundingClientRect() @@ -1438,10 +1455,31 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { }) await expect.poll(() => zoomMenu.innerText()).toBe('166%') await expect.poll(async () => (await canvas.boundingBox())!.width / intrinsicWidth).toBeCloseTo(1.66, 1) + await expectPdfResolution(canvas) + await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await copyPdfText(page, preview, '中文文档') + await successShot(page, 'office-zoom-redrawn') await zoomMenu.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() await expect.poll(() => zoomMenu.innerText()).toBe(fitPercent) + await expectPdfResolution(canvas) await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await zoomScrollport.evaluate(async (node) => { + const bounds = node.getBoundingClientRect() + for (let step = 0; step < 8; step++) { + node.dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true, + deltaY: -40, clientX: bounds.left + bounds.width / 2, clientY: bounds.top + bounds.height / 2 })) + await new Promise(resolve => requestAnimationFrame(() => { resolve() })) + } + }) + await expect.poll(() => zoomMenu.innerText()).toBe('400%') + expect(await zoomScrollport.evaluate(node => node.scrollLeft > node.clientWidth)).toBe(true) + await expectPdfResolution(canvas) + await zoomScrollport.evaluate((node) => { node.scrollTop = 0; node.scrollLeft = 0 }) + await successShot(page, 'office-pinch-400') + await zoomMenu.click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expectPdfResolution(canvas) expect(convert).toHaveBeenCalledTimes(1) await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() await canvas.waitFor({ state: 'visible' }) @@ -1494,6 +1532,8 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { '- Escape restores focus to the warning: true', '- Closing details preserves the warning and document position: true', '- Fit width, presets, and pinch resize the Office PDF continuously: true', + '- Settled zoom redraws the Office PDF at device resolution: true', + '- Continuous pinch to 400% redraws the page after horizontal panning: true', '- Pinch updates the displayed percentage during the gesture: 166%', `- Document top inset: ${topInset}px`, ].join('\n'), MODE) @@ -1502,6 +1542,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') + const officeZoom = await revealDocumentZoom(page, preview) + await officeZoom.click() + await page.getByRole('menuitem', { name: '150%', exact: true }).click() + await expectPdfResolution(canvas) if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 7e2deb69b1..6d9ba31361 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -7,5 +7,7 @@ - Escape restores focus to the warning: true - Closing details preserves the warning and document position: true - Fit width, presets, and pinch resize the Office PDF continuously: true +- Settled zoom redraws the Office PDF at device resolution: true +- Continuous pinch to 400% redraws the page after horizontal panning: true - Pinch updates the displayed percentage during the gesture: 166% - Document top inset: 0px diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 946ec07ffb..17afc4e007 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 0571c4537f5ff98d8ec1713c297397da1941780b -README.zh.md: 949e66546b8a4f25a388178dea47f90a8d6d324c +README.md: 5249bb4cbbc0360e8afac96cd86cdac339addfc5 +README.zh.md: 1b15e805a285770befb5c59e602fdd2aed6fbca5 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 0571c4537f..5249bb4cbb 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -59,7 +59,7 @@ When Developer tools is enabled, HTML fills the body edge to edge in a Blob ifra PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. -Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. +Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. @@ -134,7 +134,7 @@ No direct effect; what the user reads here never enters a model request. - **Preview, not editing.** The viewers provide no file editing or shared search interface; a directory address fails with `not-regular-file`. Unknown extensions use the plain-text reader and remain subject to its UTF-8/NUL checks. - **Office conversion limits.** The preview does not launch native Office editors or download an engine. Binary `.doc` and `.ppt` files return no missing-font diagnostics. Conversion fidelity and resource limits belong to the [LibreOffice provider](../../document/office-to-pdf/README.md). - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap. -- **PDF zoom does not increase raster resolution.** PDF pages render at 96 DPI times the device pixel ratio, subject to the existing 16-million-pixel canvas limit, and fixed zoom enlarges that bitmap. High percentages can therefore soften page text; viewport-near rerendering at the settled zoom remains deferred. +- **PDF raster allocation is bounded.** Each page bitmap is capped at 16,777,216 pixels; very large pages or high zoom on high-density displays can still render below device resolution. - **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; fixed PDF and image zoom can overflow horizontally, and HTML iframe scrolling belongs to its opaque browsing context. - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe. - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 949e66546b..1b15e805a2 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -59,7 +59,7 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 -共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。 +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 @@ -134,7 +134,7 @@ Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8 - **预览而非编辑。** 查看器不提供文件编辑或共享搜索接口;目录地址以 `not-regular-file` 失败。未知扩展名使用纯文本读取,仍受其 UTF-8/NUL 检查限制。 - **Office 转换限制。** 预览不启动原生 Office 编辑器,也不下载引擎。二进制 `.doc` 和 `.ppt` 文件不返回缺失字体诊断。转换保真度与资源限制由 [LibreOffice 提供方](../../document/office-to-pdf/README.zh.md)负责。 - **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。 -- **PDF 缩放不会提高栅格分辨率。** PDF 页面按 96 DPI 乘以设备像素比绘制,并受现有 1600 万像素 Canvas 上限约束;固定缩放会放大这张位图,因此高比例下页面文字可能变虚。按稳定缩放比例重绘视口附近页面仍属于延期工作。 +- **PDF 栅格分配有上限。** 每页位图最多为 16,777,216 像素;超大页面或高像素密度屏幕上的高比例缩放仍可能低于设备分辨率。 - **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;固定 PDF 与图片缩放可产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts index 460bac419d..4415793c21 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/document.ts @@ -35,7 +35,7 @@ export type RenderPdfText = (page: PDFPageProxy, viewport: PageViewport) => { * @param pageNumber - 1-based selected page. * @param canvas - canvas owned by this render only. * @param signal - render lifetime. - * @param pixelRatio - display pixel ratio. + * @param pixelRatio - device pixel ratio multiplied by the settled document zoom. * @param renderText - optional selectable text layer sharing this page and viewport. * @returns the page's CSS dimensions after rendering completes. */ diff --git a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx index fc11b38254..34c43074bb 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/pdf/pdf.tsx @@ -49,6 +49,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode { const [load, setLoad] = useState() const [attempt, setAttempt] = useState(0) const [pageWidth, setPageWidth] = useState() + const [renderZoom, setRenderZoom] = useState(1) const { retainTab, actions, t, ZoomViewport, zoomSurfaceClass } = props const preference = view.zoom ?? FIT_WIDTH const labels: ZoomLabels = { @@ -97,22 +98,23 @@ export function PdfBody(props: PdfBodyProps): ReactNode { {Array.from({ length: load.document.numPages }, (_, index) => ( + onWidth={index === 0 ? setPageWidth : undefined} zoom={renderZoom} zoomSurfaceClass={zoomSurfaceClass} t={t} /> ))} return + signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference} onRenderZoom={setRenderZoom}> {pages} } -function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoomSurfaceClass, t }: { +function PdfPage({ document, page, requested: initiallyRequested, onVisible, signal, onWidth, zoom, zoomSurfaceClass, t }: { readonly document: PdfDocument readonly page: number readonly requested: boolean readonly onVisible: (page: number) => void readonly signal: AbortSignal readonly onWidth: ((width: number) => void) | undefined + readonly zoom: number readonly zoomSurfaceClass: string } & PropsLocale<'sidebarPdf'>): ReactNode { const host = useRef(null) @@ -120,47 +122,78 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig const text = useRef(null) const [width, setWidth] = useState() const [requested, setRequested] = useState(initiallyRequested) + const [visible, setVisible] = useState(initiallyRequested) + const renderedZoom = useRef() + const pendingRender = useRef(Promise.resolve()) + const textTask = useRef>>() const [state, setState] = useState<'loading' | 'ready'>('loading') const [failure, setFailure] = useState<{ readonly error: unknown }>() const [attempt, setAttempt] = useState(0) useEffect(() => { - const node = host.current as HTMLDivElement + // A zoomed canvas can remain visible after its narrower page wrapper has + // scrolled out horizontally. Observe the displayed page once it is ready. + const node = state === 'ready' ? canvas.current as HTMLCanvasElement : host.current as HTMLDivElement if (typeof IntersectionObserver === 'undefined') { setRequested(true) + setVisible(true) return } let disposed = false const observer = new IntersectionObserver((entries) => { - if (disposed || !entries.some(entry => entry.isIntersecting)) return + if (disposed) return + const intersects = entries.some(entry => entry.isIntersecting) + setVisible(intersects) + if (!intersects) return setRequested(true) onVisible(page) - observer.disconnect() }, { rootMargin: '100% 0px' }) observer.observe(node) return () => { disposed = true observer.disconnect() } - }, [page, onVisible]) + }, [page, onVisible, state]) + useEffect(() => () => { textTask.current?.cancel() }, []) useEffect(() => { if (!requested) return - // The canvas is unconditional; this effect runs after its ref is committed. + setFailure(undefined) + if (renderedZoom.current !== undefined && (!visible || renderedZoom.current === zoom)) return const node = canvas.current as HTMLCanvasElement const lifetime = new AbortController() const renderSignal = AbortSignal.any([lifetime.signal, signal]) - setState('loading') - setFailure(undefined) - const createText = pdfTextRenderer(text.current as HTMLDivElement) - let textTask: ReturnType | undefined - void renderPdfPage(document, page, node, renderSignal, window.devicePixelRatio, (pdfPage, viewport) => { - textTask = createText(pdfPage, viewport) - return textTask - }).then( - (size) => { if (!renderSignal.aborted) { setWidth(size.width); onWidth?.(size.width); setState('ready') } }, - (error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }, - ) - return () => { lifetime.abort(); textTask?.cancel() } - }, [document, page, requested, signal, attempt, onWidth]) + let newText: typeof textTask.current + // PDF.js shares a page proxy across renders; its cancelled task must finish + // cleanup before another render starts. The displayed bitmap stays intact. + pendingRender.current = pendingRender.current.then(async () => { + renderSignal.throwIfAborted() + const buffer = node.ownerDocument.createElement('canvas') + try { + const size = await renderPdfPage(document, page, buffer, renderSignal, window.devicePixelRatio * zoom, + textTask.current === undefined ? (pdfPage, viewport) => { + newText = pdfTextRenderer(text.current as HTMLDivElement)(pdfPage, viewport) + return newText + } : undefined) + renderSignal.throwIfAborted() + const context = node.getContext('2d') + if (context === null) throw new Error('PDF canvas has no 2D context') + node.width = buffer.width + node.height = buffer.height + node.style.setProperty('--pdf-page-width', `${size.width}px`) + node.style.setProperty('--pdf-page-height', `${size.height}px`) + context.drawImage(buffer, 0, 0) + textTask.current ??= newText + newText = undefined + renderedZoom.current = zoom + setWidth(size.width) + onWidth?.(size.width) + setState('ready') + } finally { + buffer.width = buffer.height = 0 + newText?.cancel() + } + }).catch((error: unknown) => { if (!renderSignal.aborted) setFailure({ error }) }) + return () => { lifetime.abort(); newText?.cancel() } + }, [document, page, requested, visible, zoom, signal, attempt, onWidth]) return
{failure === undefined && state !== 'ready' && (requested ?
@@ -171,7 +204,7 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
}