fix(preview): redraw PDF pages after settled zoom

This commit is contained in:
yudshj committed 2026-09-23 18:12:21 +08:00
1 parent e68fb0bc8d
commit e0e8327992
11 files changed
+213 -34

No files matched your search

+45 -1
View File
@@ -77,6 +77,18 @@ async function canvasColor(canvas: Locator): Promise<string> {
})
}
/** Wait for device resolution, subject to the page bitmap allocation limit. */
async function expectPdfResolution(canvas: Locator): Promise<void> {
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<void> {
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<void>(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}`)
}
@@ -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
@@ -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
@@ -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 `<img>` 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 `<img>` 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.
@@ -59,7 +59,7 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 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 的是同一批元素。
@@ -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.
*/
@@ -49,6 +49,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
const [load, setLoad] = useState<LoadState>()
const [attempt, setAttempt] = useState(0)
const [pageWidth, setPageWidth] = useState<number>()
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) => (
<PdfPage key={index} document={load.document} page={index + 1}
requested={index === 0 || view.page === index + 1} onVisible={pageVisible} signal={tab.signal}
onWidth={index === 0 ? setPageWidth : undefined} zoomSurfaceClass={zoomSurfaceClass} t={t} />
onWidth={index === 0 ? setPageWidth : undefined} zoom={renderZoom} zoomSurfaceClass={zoomSurfaceClass} t={t} />
))}
</section>
return <ZoomViewport preference={preference} intrinsicWidth={pageWidth} labels={labels}
signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference}>
signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference} onRenderZoom={setRenderZoom}>
{pages}
</ZoomViewport>
}
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<HTMLDivElement>(null)
@@ -120,47 +122,78 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
const text = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState<number>()
const [requested, setRequested] = useState(initiallyRequested)
const [visible, setVisible] = useState(initiallyRequested)
const renderedZoom = useRef<number>()
const pendingRender = useRef(Promise.resolve())
const textTask = useRef<ReturnType<ReturnType<typeof pdfTextRenderer>>>()
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<typeof createText> | 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 <div ref={host} className={css.page} data-pdf-page={page}>
{failure === undefined && state !== 'ready' && (requested
? <div className={css.placeholder} role="status" aria-label={t('rendering')} />
@@ -171,7 +204,7 @@ function PdfPage({ document, page, requested: initiallyRequested, onVisible, sig
</div>}
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties}
hidden={state !== 'ready' || failure !== undefined}>
hidden={state !== 'ready'}>
<canvas ref={canvas} className={css.canvas} role="img" aria-label={t('pageImage', { page })} />
<div ref={text} className={css.text} data-pdf-text />
</div>
@@ -42,6 +42,8 @@ export interface ZoomViewportProps {
readonly signal: AbortSignal
readonly scrollportRef: RefCallback<HTMLElement>
readonly onPreference: (preference: ZoomPreference) => void
/** Resolved scale after a gesture settles or fit-width measurement changes. */
readonly onRenderZoom?: (zoom: number) => void
readonly children: ReactNode
}
@@ -164,7 +166,8 @@ export function ZoomViewport(props: ZoomViewportProps): ReactNode {
frame.current?.setAttribute('data-document-zoom-mode', props.preference.kind)
frame.current?.style.setProperty('--document-zoom', String(zoom))
controls.current?.showZoom(zoom)
}, [props.preference.kind, zoom])
props.onRenderZoom?.(zoom)
}, [props.onRenderZoom, props.preference.kind, zoom])
useEffect(() => {
const node = scrollport.current as HTMLDivElement
const clearPendingPinch = (): void => {
@@ -28,6 +28,8 @@ const loads: Array<{
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
const context: Pick<CanvasRenderingContext2D, 'drawImage'> = { drawImage: () => {} }
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as CanvasRenderingContext2D)
loads.length = 0
engine.open.mockReset().mockImplementation(() => {
const deferred = Promise.withResolvers<PdfDocument>()
@@ -35,7 +37,10 @@ beforeEach(() => {
loads.push({ deferred, dispose })
return { document: deferred.promise, dispose }
})
engine.render.mockReset().mockResolvedValue({ width: 100, height: 100 })
engine.render.mockReset().mockImplementation(async (_document, _page, canvas, _signal, ratio) => {
canvas.width = canvas.height = 100 * ratio
return { width: 100, height: 100 }
})
overlay.create.mockReset()
overlay.render.mockReset()
overlay.cancel.mockReset()
@@ -188,11 +193,13 @@ describe('PDF body', () => {
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ctrlKey: true,
deltaY: -10, clientX: 60, clientY: 70 })
const controls = view.container.querySelector('[data-document-zoom-controls]') as HTMLElement
const rendersBeforeGesture = engine.render.mock.calls.length
expect(controls.hasAttribute('data-document-zoom-visible')).toBe(false)
expect(scrollport.dispatchEvent(event)).toBe(false)
await act(async () => {})
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
expect(h.instance.getSnapshot().byTab[h.tabId]?.zoom).toBeUndefined()
expect(engine.render).toHaveBeenCalledTimes(rendersBeforeGesture)
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('111%')
expect((view.container.querySelector('[data-document-zoom-frame]') as HTMLElement)
.style.getPropertyValue('--document-zoom')).toBe(String(Math.exp(0.1)))
@@ -208,6 +215,8 @@ describe('PDF body', () => {
if (zoom?.kind !== 'fixed') throw new Error('pinch did not commit a fixed zoom')
expect(zoom.scale).toBeCloseTo(Math.exp(0.1))
const committed = zoom.scale
expect(engine.render).toHaveBeenCalledTimes(rendersBeforeGesture + 1)
expect(engine.render.mock.calls.at(-1)![4]).toBeCloseTo(window.devicePixelRatio * committed)
await act(async () => { await vi.advanceTimersByTimeAsync(419) })
expect(controls.getAttribute('data-document-zoom-visible')).toBe('true')
await act(async () => { await vi.advanceTimersByTimeAsync(1) })
@@ -237,6 +246,62 @@ describe('PDF body', () => {
expect(h.instance.getSnapshot().byTab[h.tabId]).toBeUndefined()
})
it('redraws only nearby pages and refreshes a retained page when it returns at the new zoom', async () => {
IntersectionObserverStub.instances = []
vi.stubGlobal('IntersectionObserver', IntersectionObserverStub)
const h = harness()
const view = render(<h.View />)
await act(async () => { loads[0]!.deferred.resolve(documentOf(3)) })
const first = view.container.querySelector('[data-pdf-page="1"]') as HTMLElement
const second = view.container.querySelector('[data-pdf-page="2"]') as HTMLElement
const firstCanvas = first.querySelector('canvas')!
const secondCanvas = second.querySelector('canvas')!
const firstObserver = IntersectionObserverStub.instances.find(instance => instance.observed.has(firstCanvas))!
const placeholderObserver = IntersectionObserverStub.instances.find(instance => instance.observed.has(second))!
await act(async () => { placeholderObserver.intersect(second, true) })
const secondObserver = IntersectionObserverStub.instances.find(instance => instance.observed.has(secondCanvas))!
act(() => { secondObserver.intersect(secondCanvas, false) })
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 2 }) })
expect(engine.render.mock.calls.map(([, page]) => page)).toEqual([1, 2, 1])
expect(first.querySelector('canvas')!.width).toBe(200 * window.devicePixelRatio)
expect(second.querySelector('canvas')!.width).toBe(100 * window.devicePixelRatio)
await act(async () => { firstObserver.intersect(firstCanvas, false); secondObserver.intersect(secondCanvas, true) })
expect(engine.render.mock.calls.map(([, page]) => page)).toEqual([1, 2, 1, 2])
expect(second.querySelector('canvas')!.width).toBe(200 * window.devicePixelRatio)
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 1 }) })
expect(second.querySelector('canvas')!.width).toBe(100 * window.devicePixelRatio)
})
it('retains the displayed page while cancelling and joining superseded zoom renders', async () => {
const h = harness()
const view = render(<h.View />)
await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) })
const canvas = screen.getByRole('img') as HTMLCanvasElement
const obsolete = Promise.withResolvers<{ width: number; height: number }>()
const latest = Promise.withResolvers<{ width: number; height: number }>()
engine.render.mockImplementationOnce((_document, _page, buffer) => {
buffer.width = buffer.height = 200
return obsolete.promise
}).mockImplementationOnce((_document, _page, buffer) => {
buffer.width = buffer.height = 300
return latest.promise
})
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 2 }) })
expect(canvas.width).toBe(100)
expect(screen.getByRole('img')).toBe(canvas)
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 3 }) })
expect(engine.render.mock.calls[1]![3].aborted).toBe(true)
expect(engine.render).toHaveBeenCalledTimes(2)
await act(async () => { obsolete.resolve({ width: 100, height: 100 }) })
expect(engine.render).toHaveBeenCalledTimes(3)
expect(canvas.width).toBe(100)
await act(async () => { latest.resolve({ width: 100, height: 100 }) })
expect(canvas.width).toBe(300)
expect(canvas.style.getPropertyValue('--pdf-page-width')).toBe('100px')
expect(screen.queryByRole('alert')).toBeNull()
view.unmount()
})
it('keeps the replacement document when the previous load settles late', async () => {
const h = harness()
const mounted = render(<h.View data="old" />)
@@ -254,7 +319,7 @@ describe('PDF body', () => {
const viewport = { width: 100, height: 100, scale: 1 } as PageViewport
overlay.render.mockReturnValue({ promise: Promise.resolve(), cancel: overlay.cancel })
engine.render.mockImplementation(async (_document, _number, _canvas, _signal, _ratio, renderText) => {
await renderText!(pdfPage, viewport).promise
await renderText?.(pdfPage, viewport).promise
return { width: viewport.width, height: viewport.height }
})
const h = harness()
@@ -264,6 +329,9 @@ describe('PDF body', () => {
expect(overlay.render).toHaveBeenCalledExactlyOnceWith(pdfPage, viewport)
expect(screen.getByRole('img', { name: 'PDF page 1' })).toBeDefined()
expect(overlay.cancel).not.toHaveBeenCalled()
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 2 }) })
expect(overlay.render).toHaveBeenCalledOnce()
expect(overlay.cancel).not.toHaveBeenCalled()
view.unmount()
expect(overlay.cancel).toHaveBeenCalledOnce()
})
@@ -306,6 +374,8 @@ describe('PDF body', () => {
expect(screen.getByRole('img', { name: 'PDF page 2' })).toBeTruthy()
view.unmount()
expect(IntersectionObserverStub.instances.every(instance => instance.disconnected)).toBe(true)
act(() => { observer.intersect(second, true) })
expect(engine.render.mock.calls.map(([, page]) => page)).toEqual([1, 2])
})
it('ignores successful and failed page renders after their body unmounts', async () => {
@@ -360,4 +430,26 @@ describe('PDF body', () => {
expect(screen.queryByRole('alert')).toBeNull()
expect(screen.getByRole('img', { name: 'PDF page 1' })).toBeTruthy()
})
it('keeps the previous bitmap when canvas allocation fails and retries the zoom', async () => {
const h = harness()
render(<h.View />)
await act(async () => { loads[0]!.deferred.resolve(documentOf(1)) })
const canvas = screen.getByRole('img') as HTMLCanvasElement
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValueOnce(null)
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 2 }) })
expect(canvas.width).toBe(100)
expect(screen.getByRole('img')).toBe(canvas)
expect(screen.getByRole('alert').textContent).toContain('PDF canvas has no 2D context')
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await act(async () => {})
expect(canvas.width).toBe(200)
expect(screen.queryByRole('alert')).toBeNull()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValueOnce(null)
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 3 }) })
expect(screen.getByRole('alert')).toBeTruthy()
await act(async () => { h.instance.actions.zoom(h.tabId, { kind: 'fixed', scale: 2 }) })
expect(screen.queryByRole('alert')).toBeNull()
expect(canvas.width).toBe(200)
})
})
@@ -32,26 +32,30 @@ afterEach(() => {
})
it('fits without upscaling, follows resize, and leaves fixed zoom unchanged', () => {
const onRenderZoom = vi.fn()
function View() {
const [preference, setPreference] = useState<ZoomPreference>(FIT_WIDTH)
return <ZoomViewport preference={preference} intrinsicWidth={1000} horizontalInset={20}
labels={{ controls: 'Zoom controls', menu: 'Choose zoom', out: 'Zoom out', into: 'Zoom in',
fitWidth: 'Fit width', value: percent => `${String(percent)}%` }} signal={new AbortController().signal}
scrollportRef={() => {}} onPreference={setPreference}>
scrollportRef={() => {}} onPreference={setPreference} onRenderZoom={onRenderZoom}>
<div className={zoomSurfaceClass} style={{ '--document-zoom-width': '1000px' } as CSSProperties} />
</ZoomViewport>
}
const view = render(<View />)
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('48%')
expect(onRenderZoom).toHaveBeenLastCalledWith(0.48)
expect(view.container.querySelector('[data-document-zoom-mode]')?.getAttribute('data-document-zoom-mode')).toBe('fit-width')
width = 320
act(() => { resize?.([], {} as ResizeObserver) })
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('30%')
expect(onRenderZoom).toHaveBeenLastCalledWith(0.3)
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
fireEvent.click(screen.getByRole('menuitem', { name: '100%' }))
width = 220
act(() => { resize?.([], {} as ResizeObserver) })
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('100%')
expect(onRenderZoom).toHaveBeenLastCalledWith(1)
fireEvent.click(screen.getByRole('button', { name: 'Choose zoom' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fit width' }))
expect(screen.getByRole('button', { name: 'Choose zoom' }).textContent).toContain('20%')
@@ -41,6 +41,7 @@
- Continuous pages: 2
- 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: false
- Canvas fills: red -> blue -> blue
- Same tab: true