From 9b27cc0587ccbdd79019483b7db72abc95fe4159 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 18 Sep 2026 11:05:13 +0800 Subject: [PATCH] fix(web): move Office font warnings into the document toolbar --- .../2026-09-11-node-office-kit.i18n.yaml | 4 +- .../2026-09-11-node-office-kit.md | 2 +- .../2026-09-11-node-office-kit.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 40 +++++---- apps/web/tests/expected/office-font-notice.md | 11 +-- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/TextPreview.tsx | 3 +- .../src/client/document/contract.ts | 8 ++ .../src/client/icons.tsx | 9 ++ .../src/client/index.ts | 1 + .../src/client/office/FontNotice.module.css | 88 +++---------------- .../src/client/office/FontNotice.tsx | 70 +++++---------- .../src/client/office/OfficeBody.tsx | 6 +- .../src/client/office/OfficeFontAction.tsx | 21 +++++ .../src/client/office/index.ts | 4 + .../src/client/office/locales.ts | 8 +- .../tests/apply.client.spec.ts | 2 + .../tests/fixtures.client.ts | 6 +- .../tests/office-font-notice.client.spec.tsx | 31 ++++--- .../tests/office-registration.client.spec.ts | 2 +- .../tests/renderer-loading.client.spec.tsx | 37 +++++++- .../src/client/slot-catalog.ts | 50 ++++++++++- 24 files changed, 229 insertions(+), 188 deletions(-) create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/office/OfficeFontAction.tsx diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 5ab066c127..c27ef04676 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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 .agents/notes/implemented/architecture/2026-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: d77d6c5392532f39a9993af60faad6474bc8f36c -2026-09-11-node-office-kit.zh.md: a0cbd22e4f06bd4f2878adf27852f9d2378b8b33 +2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 +2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index d77d6c5392..6cab947d7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -22,7 +22,7 @@ The kit owns default serif, sans-serif, and monospace preference groups, includi DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine. -The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the Office scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. +The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index a0cbd22e4f..8ed1a81922 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -22,7 +22,7 @@ kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中 DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。 -[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在 Office 滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 +[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 24137b06ad..2d36397c57 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -675,44 +675,52 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await preview.getByRole('button', { name: 'Read the file again', exact: true }).click() await canvas.waitFor({ state: 'visible' }) expect(convert).toHaveBeenCalledTimes(1) - const notice = preview.locator('[data-office-font-notice]') - const more = notice.getByRole('button', { name: 'Show more', exact: true }) - await more.waitFor({ state: 'visible' }) + const warning = preview.locator('[data-office-font-warning]').getByRole('button') + await warning.waitFor({ state: 'visible' }) + expect(await warning.getAttribute('aria-expanded')).toBe('false') + expect(await page.getByRole('dialog', { name: 'Missing fonts', exact: true }).count()).toBe(0) + const warningBox = (await warning.boundingBox())! + const reloadBox = (await preview.getByRole('button', { name: 'Read the file again', exact: true }).boundingBox())! + expect(warningBox.x + warningBox.width).toBeLessThanOrEqual(reloadBox.x) + expect(Math.abs(warningBox.y + warningBox.height / 2 - reloadBox.y - reloadBox.height / 2)).toBeLessThan(1) + await warning.hover() + await page.getByRole('tooltip', { name: /Missing fonts:/ }).waitFor({ state: 'visible' }) const before = await canvas.evaluate(node => node.getBoundingClientRect().top) - await more.click() + await successShot(page, 'office-font-warning') + await warning.click() const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true }) await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' }) + expect(await page.getByRole('tooltip', { name: /Missing fonts:/ }).count()).toBe(0) await successShot(page, 'office-font-details') await page.keyboard.press('Escape') await expect.poll(() => details.count()).toBe(0) - expect(await more.evaluate(node => node === document.activeElement)).toBe(true) - await more.click() + expect(await warning.evaluate(node => node === document.activeElement)).toBe(true) + await warning.click() await page.getByRole('button', { name: 'Close font details', exact: true }).click() - expect(await more.isVisible()).toBe(true) - await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click() - await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0) + expect(await warning.isVisible()).toBe(true) const after = await canvas.evaluate(node => node.getBoundingClientRect().top) - expect(before - after).toBeGreaterThan(40) + expect(after).toBe(before) const topInset = await preview.evaluate((node) => { const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect() const canvas = node.querySelector('canvas')!.getBoundingClientRect() return canvas.top - body.top }) expect(topInset).toBe(0) - await successShot(page, 'office-font-dismissed') await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [ - '# Office font notice', '', + '# Office font warning', '', + '- Warning precedes reload in the same toolbar: true', + '- Details open only on request: true', '- Requested absent family is listed: true', - '- Escape restores focus to Show more: true', - '- Closing details preserves the notice: true', - '- Dismissing the notice collapses its occupied height: 0', - `- Document top inset after dismissal: ${topInset}px`, + '- Escape restores focus to the warning: true', + '- Closing details preserves the warning and document position: true', + `- Document top inset: ${topInset}px`, ].join('\n'), MODE) await successShot(page, 'office-docx') for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) { 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('中文文档') + if (['doc', 'xls', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) await successShot(page, `office-${extension}`) } expect(convert).toHaveBeenCalledTimes(6) diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 4de2908510..635bb2dd34 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -1,7 +1,8 @@ -# Office font notice +# Office font warning +- Warning precedes reload in the same toolbar: true +- Details open only on request: true - Requested absent family is listed: true -- Escape restores focus to Show more: true -- Closing details preserves the notice: true -- Dismissing the notice collapses its occupied height: 0 -- Document top inset after dismissal: 0px +- Escape restores focus to the warning: true +- Closing details preserves the warning and document position: true +- 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 c9c5f48b0a..edd91f5dff 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: f1d187cf4adc3b0e0400fa02babca1d28bffad15 -README.zh.md: cbdd915fb5e7a307c3c44717d472f63c5b4766b9 +README.md: 5725368ecc02e4d278c6e031d9ef1c8942a5e09d +README.zh.md: d62fc71d6fef2798bd3b7d104f7002415ac779b0 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index f1d187cf4a..5725368ecc 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -75,12 +75,12 @@ Opening an Office file requests conversion on demand. Each read checks renderer Background requests leave the final pending-request and reader slots available for foreground work; setting either limit to one rejects background reads. Renderer replacement restarts generation discovery and authorization once. Another replacement during that retry reports the localized busy message. -A yellow notice identifies fonts unavailable during conversion. Show more opens an anchored list; closing the list preserves the notice. Dismissing the notice collapses its space and moves the PDF upward. Dismissal applies to the same source version while the preview remains mounted; switching away from the tab and back shows the notice again. Long notice text fades before the action, and reduced-motion preferences disable the collapse animation. +A yellow rounded-triangle warning appears before Reload in the document toolbar when the current Office preview has missing fonts. Hover or keyboard focus shows the missing-font count; clicking opens an anchored list. Escape, Close, or clicking outside closes the list without hiding the warning or moving the document. Reloading closes the old details; previews with no missing fonts show no warning. Viewed and dismissed states are not retained.
Office implementation — click to expand -Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The notice sits above the Office scrollport. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. +Office registration, loading, caching, and font notices live in `src/client/office/`. The Office body owns converted PDF bytes and font metadata, and declares a nested PDF slot that reuses the lazy PDF body and its tab viewing state. The keyed `sidebar.right.tab.document.action` slot places renderer controls before Reload. The Office action shares the body’s store and reads only the current revision’s font metadata. Registration remains available without the Host renderer; optional `remote.officeToPdf` and `remote.workspaceFiles` injections supply conversion and freshness callbacks, and their removal restores unavailable guidance. Registrations and tab retention follow effect lifetimes. The [conversion service](../../document/office-to-pdf/README.md) owns the Host Remote methods, mounted by `api/remotes`. The shared `documentFileBytes()` helper decodes ordinary file and converted PDF responses into one owned byte buffer without expanding bytes into JavaScript array elements. Renderers borrow retained bytes read-only and copy them before Worker transfer. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index cbdd915fb5..d62fc71d6f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -75,12 +75,12 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。 -黄色提示条说明转换时不可用的字体。“显示更多”打开锚定字体列表;关闭列表保留提示条。关闭提示条会收起其占位并使 PDF 上移,关闭状态仅在预览持续挂载且源文件版本相同时保留;切换到其他标签再切回会重新显示提示条。过长提示文字在操作按钮前渐隐,减少动态效果偏好会禁用收起动画。 +当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开锚定字体列表。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
Office 实现——点击展开 -Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。字体提示位于 Office 滚动区上方。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 +Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Office 正文持有转换后的 PDF 字节和字体元数据,并声明嵌套 PDF slot,复用惰性 PDF 正文及其 tab 阅读状态。keyed slot `sidebar.right.tab.document.action` 将渲染器操作放在刷新按钮前。Office 操作与正文共享 store,仅读取当前 revision 的字体元数据。Host 渲染器缺失时,注册仍然可用;可选的 `remote.officeToPdf` 和 `remote.workspaceFiles` 注入提供转换与版本检查回调,移除后恢复不可用提示。注册和 tab 状态保留都遵循 effect 生命周期。[转换服务](../../document/office-to-pdf/README.zh.md)拥有 Host Remote 方法,由 `api/remotes` 挂载。 共享 `documentFileBytes()` 辅助函数将普通文件与转换后 PDF 的响应解码到一个独立持有的字节缓冲区,不会将字节展开为 JavaScript 数组元素。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index cad86a1a41..ac9227a624 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -82,7 +82,7 @@ export interface TextPreviewInjected extends TextInjected { /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ export type TextPreviewProps = & PropsRuntime<'sidebar.right.pane.tab'> - & PropsRenderSlots<'sidebar.right.tab.document'> + & PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.action'> & PropsStore & InjectFace & PropsLocale<'sidebarDocumentPreview'> @@ -318,6 +318,7 @@ export function TextPreview({ )} + {content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })} - - + + {open && createPortal(