Merge pull request #4506 from deepseek-harness/fix/office-font-warning

fix(web): move Office font warnings into the document toolbar
This commit is contained in:
Yudong Han authored and GitHub committed 2026-09-18 12:34:15 +08:00
commit fee816628f
31 files changed
+251 -201

No files matched your search

@@ -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
@@ -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.
@@ -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。缓存字节限制不约束临时传输或查看器内存。
+33 -16
View File
@@ -752,44 +752,61 @@ 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 reload = preview.getByRole('button', { name: 'Read the file again', exact: true })
const reloadBox = (await reload.boundingBox())!
expect(warningBox.x + warningBox.width).toBeLessThanOrEqual(reloadBox.x)
expect(Math.abs(warningBox.y + warningBox.height / 2 - reloadBox.y - reloadBox.height / 2)).toBeLessThan(1)
expect([warningBox.width, warningBox.height]).toEqual([reloadBox.width, reloadBox.height])
expect(await warning.evaluate(node => getComputedStyle(node).borderRadius))
.toBe(await reload.evaluate(node => getComputedStyle(node).borderRadius))
expect(await warning.locator('svg').evaluate(node => node.getBoundingClientRect().width))
.toBe(await reload.locator('svg').evaluate(node => node.getBoundingClientRect().width))
const warningColor = await warning.evaluate(node => getComputedStyle(node).color)
await warning.hover()
expect(await warning.evaluate(node => getComputedStyle(node).color)).toBe(warningColor)
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',
'- Warning and reload share button geometry and icon size: 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)
@@ -1,7 +1,9 @@
# Office font notice
# Office font warning
- Warning precedes reload in the same toolbar: true
- Warning and reload share button geometry and icon size: 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
+2 -2
View File
@@ -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 docs/subsystems/sidebar-right.md
sidebar-right.md: a6478e10f19bc11f2a8bfa89d6213a85ffd76ffc
sidebar-right.zh.md: af23b93945ce714cf4f0485752d8e07f155055c2
sidebar-right.md: 663139313edb3b2341874c05030faa8c26998944
sidebar-right.zh.md: bdab35cb88bb62c33e447b67592858c0c92f7a4c
+1 -1
View File
@@ -115,7 +115,7 @@ The [registry](../../packages/client/ui-sidebar-documentpreview/src/client/docum
Preview records its loaded version and the version observed when a read starts. Refresh rereads only that tab, without changing shared metadata or another tab's content. Reads are non-transactional; versions are opaque equality tokens, not ordered timestamps ([resource observation and Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md)).
A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, and its body owns font notices above a nested PDF view. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
A renderer that owns loading receives `{ kind: 'renderer', revision, loaded, reload }` instead of file bytes. The body loads through its own injected callbacks, cancels on revision changes and unmount, and reports its displayed source version through `loaded(version)`. The parent ignores stale reports and retains the shared reload and source-change controls. Office uses this mode to request [Host-rendered PDFs](office-to-pdf.md); its own store and bounded cache retain converted bytes, while its body renders a nested PDF view. `OfficeFontAction` renders the missing-font warning before reload through `sidebar.right.tab.document.action`. The [package README](../../packages/client/ui-sidebar-documentpreview/README.md#what-it-registers) defines the loading lifecycle.
## Resource model
+1 -1
View File
@@ -115,7 +115,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed
Preview 记录已载入版本和读取开始时的观察版本。刷新只重读当前 tab,不改变共享元数据或其他 tab 的内容。读取不具备事务性;版本是不透明的相等性令牌,不是可排序的时间戳([资源观察与 Preview RPC](../../.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md))。
自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文在嵌套 PDF 视图上方管理字体提示。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
自行加载的渲染器接收 `{ kind: 'renderer', revision, loaded, reload }`,而不是文件字节。正文通过自己的注入回调加载,在 revision 变化和卸载时取消请求,并通过 `loaded(version)` 报告已展示的源版本。父组件忽略过期报告,保留共享的重新加载与源文件变更控件。Office 使用此模式请求 [Host 渲染的 PDF](office-to-pdf.zh.md);自己的 store 和有界缓存保留转换字节,正文渲染嵌套 PDF 视图,`OfficeFontAction` 通过 `sidebar.right.tab.document.action` 在刷新按钮前显示缺失字体警告。[包 README](../../packages/client/ui-sidebar-documentpreview/README.zh.md#what-it-registers)定义加载生命周期。
## 资源模型
@@ -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-primitives/README.md
README.md: 165df8a18eac4a64befbbb43b0270fc57e3952d6
README.zh.md: da3186dde43e21d01938c597c13aa422c75ce865
README.md: 62f3346050ac106b9324f19666560347d5b37928
README.zh.md: d30726da1881affb589709d9bf667c78875cf16d
+1 -1
View File
@@ -69,7 +69,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` holds for the window its owner names through `holdMs`, because how long a banner has to stay depends on how much there is to read; the same value drives its unmount timer and the stylesheet's fade delay, so the two cannot disagree. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus.
### Rendering agent output
+1 -1
View File
@@ -69,7 +69,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 的停留时长由使用方通过 `holdMs` 指定,因为横幅该留多久取决于有多少内容要读;同一个值同时驱动它的卸载定时器与样式表的淡出延迟,两者不可能再错位。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
### 渲染 agent 输出
@@ -586,6 +586,23 @@ export const IconWarningOutlineMedium = (props: IconProps) => (
<IconWarningOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconWarningTriangleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
<path d="M6.87 2.6a1.33 1.33 0 0 1 2.26 0l5.34 9.33A1.33 1.33 0 0 1 13.33 14H2.67a1.33 1.33 0 0 1-1.14-2.07Z" stroke="currentColor" />
<path d="M8 6v3m0 2.33h.01" stroke="currentColor" />
</svg>
)
/** Regular rounded warning triangle with an exclamation mark. */
export const IconWarningTriangleOutlineRegular = (props: IconProps) => (
<IconWarningTriangleOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium rounded warning triangle with an exclamation mark. */
export const IconWarningTriangleOutlineMedium = (props: IconProps) => (
<IconWarningTriangleOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M8 8.5C9.65685 8.5 11 7.15685 11 5.5C11 3.84315 9.65685 2.5 8 2.5C6.34315 2.5 5 3.84315 5 5.5C5 7.15685 6.34315 8.5 8 8.5Z" stroke="currentColor" />
@@ -17,15 +17,15 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('product icon set', () => {
it('exports regular and medium variants for all 76 public glyphs', () => {
expect(iconNames.length).toBe(152)
it('exports regular and medium variants for all 77 public glyphs', () => {
expect(iconNames.length).toBe(154)
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
expect(medium.sort()).toEqual(regular.sort())
// The composer menu's own glyphs, pinned by name.
expect(iconNames).toEqual(expect.arrayContaining([
'IconPlanOutlineRegular', 'IconCompactOutlineRegular', 'IconShieldOutlineRegular', 'IconDeliverDocRegular',
'IconWarningTriangleOutlineRegular',
]))
})
@@ -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: 47f59c9561993d598dfe3ab65370412bfa3accf6
README.zh.md: c0338a666a6a50aaf0d5203cbfcb2ac04ab2f0e8
README.md: 7303680aa8092c2741ea38eb716f4a72f25a0ff3
README.zh.md: c0175aacaff513181e509b65487d50ea02c18fa1
@@ -77,12 +77,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.
<details>
<summary>Office implementation — click to expand</summary>
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.
@@ -77,12 +77,12 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
后台请求为前台工作保留最后一个在途请求槽和读者槽;将任一限额设为一会拒绝后台读取。渲染器替换后会重新执行一次代次查询与授权检查。重试期间再次替换会显示本地化的繁忙提示。
黄色提示条说明转换时不可用的字体。“显示更多”打开锚定字体列表;关闭列表保留提示条。关闭提示条会收起其占位并使 PDF 上移,关闭状态仅在预览持续挂载且源文件版本相同时保留;切换到其他标签再切回会重新显示提示条。过长提示文字在操作按钮前渐隐,减少动态效果偏好会禁用收起动画。
当前 Office 预览缺失字体时,文档工具栏的刷新按钮前显示黄色圆角三角形警告。悬停或键盘聚焦时显示缺失字体数量;点击后打开锚定字体列表。按 Escape、点击关闭或点击外部会关闭列表,警告图标和文档位置保持不变。重新加载会关闭旧详情;没有缺失字体的预览不显示警告。不保留已读或关闭提示的状态。
<details>
<summary>Office 实现——点击展开</summary>
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 前复制。
@@ -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<TextStore>
& InjectFace<TextPreviewInjected>
& PropsLocale<'sidebarDocumentPreview'>
@@ -318,6 +318,7 @@ export function TextPreview({
</button>
</Tooltip>
)}
{content !== undefined && renderSlot('sidebar.right.tab.document.action', { content }, { entryKey: selected.id, hookContext: useTabInfo })}
<Tooltip label={t('reload')} side="bottom" delayMs={500}>
<button
type="button"
@@ -53,6 +53,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
}
/** Renderer-specific controls before the document toolbar's reload button. */
'sidebar.right.tab.document.action': {
kind: 'keyed'
scope: 'session'
owner: { readonly content: DocumentContent }
hookContext: UseSidebarRightTabInfo
inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.document', UseSidebarRightTabInfo> } }
}
}
}
@@ -106,6 +106,7 @@ export function apply(ctx: ClientContext): void {
name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store,
children: {
'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } },
'sidebar.right.tab.document.action': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } },
},
inject: (sessionId, actions): TextPreviewInjected => ({
...face(sessionId, actions), hooks: { documentPreviews: source },
@@ -1,82 +1,14 @@
.space {
display: grid;
flex: none;
grid-template-rows: 1fr;
background: var(--dsw-alias-bg-document-preview);
transition: grid-template-rows 180ms ease, opacity 180ms ease;
}
.space[data-dismissed='true'] {
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
}
.clip {
min-height: 0;
overflow: hidden;
}
.notice {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 40px;
margin: 12px 12px 0;
padding: 0 8px 0 12px;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-state-warn-tertiary);
border: 0.5px solid var(--dsw-alias-state-warn-secondary);
border-radius: 10px;
box-shadow: var(--dsw-elevation-panel);
font-size: 12px;
line-height: 1.5;
}
.warning, .anchor {
.anchor {
display: inline-flex;
flex: none;
}
.warning {
.anchor .warning, .anchor .warning:hover {
color: var(--dsw-alias-state-warn-label);
}
.message {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.message > span {
display: block;
overflow: hidden;
}
.message[data-clipped='true'] > span {
mask-image: linear-gradient(to right, black calc(100% - 44px), transparent calc(100% - 12px));
}
.message[data-clipped='true']::after {
position: absolute;
right: 0;
top: 0;
content: '...';
}
.notice .more, .notice .close {
flex: none;
padding: 0 4px;
color: inherit;
background: transparent;
font-size: 12px;
line-height: 1.5;
}
.notice .more:hover, .notice .close:hover {
background: var(--dsw-alias-interactive-bg-hover);
.anchor .warning:hover, .anchor .warning[aria-expanded='true'] {
background: var(--dsw-alias-state-warn-tertiary);
}
.panel {
@@ -133,7 +65,3 @@
padding: 8px 0;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
@media (prefers-reduced-motion: reduce) {
.space { transition: none; }
}
@@ -1,39 +1,32 @@
/** Missing-font notice and a non-modal details panel for one source version. */
/** Toolbar warning and non-modal details for the current preview’s missing fonts. */
import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import {
Button, IconCloseOutlineRegular, IconWarningOutlineRegular, useAnchoredPosition, useDismissOnOutsidePointer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconCloseOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import common from '../TextPreview.module.css'
import css from './FontNotice.module.css'
/** Notice inputs supplied by the document owner and Office locale registration. */
export type FontNoticeProps = PropsLocale<'sidebarOffice'> & {
readonly resourceAddress: string
readonly sourceVersion: string
readonly fonts: readonly string[]
}
/**
* Show missing fonts; dismissal applies to the same source version while this component stays mounted.
* @param props - source identity, converted content, and localized copy.
* @returns a collapsible notice and its anchored details, or nothing when fonts are available.
* Show a warning while fonts are unavailable for the current preview.
* @param props - missing font families and localized copy.
* @returns a warning button and its anchored details, or nothing when fonts are available.
*/
export function FontNotice({ resourceAddress, sourceVersion, fonts, t }: FontNoticeProps): ReactNode {
const identity = JSON.stringify([resourceAddress, sourceVersion])
const [dismissed, setDismissed] = useState<string>()
const [expanded, setExpanded] = useState<string>()
const visible = fonts.length > 0 && dismissed !== identity
const open = visible && expanded === identity
const root = useRef<HTMLDivElement>(null)
export function FontNotice({ fonts, t }: FontNoticeProps): ReactNode {
const [expanded, setExpanded] = useState(false)
const open = fonts.length > 0 && expanded
const anchor = useRef<HTMLSpanElement>(null)
const panel = useRef<HTMLDivElement>(null)
const message = useRef<HTMLSpanElement>(null)
const id = useId()
const position = useAnchoredPosition({ open, anchorRef: anchor, panelRef: panel, gap: 8, margin: 12 })
useDismissOnOutsidePointer(root, open, () => { setExpanded(undefined) }, panel)
useDismissOnOutsidePointer(anchor, open, () => { setExpanded(false) }, panel)
const closeDetails = (): void => {
setExpanded(undefined)
setExpanded(false)
anchor.current?.querySelector('button')?.focus()
}
const positioned = position !== null
@@ -41,43 +34,25 @@ export function FontNotice({ resourceAddress, sourceVersion, fonts, t }: FontNot
if (!open || !positioned) return
panel.current?.focus()
}, [open, positioned])
useLayoutEffect(() => {
const element = message.current
if (element === null) return
const label = element.firstElementChild as HTMLSpanElement
const measure = (): void => { element.dataset.clipped = String(label.scrollWidth > element.clientWidth) }
measure()
const observer = new ResizeObserver(measure)
observer.observe(element)
return () => { observer.disconnect() }
}, [fonts, t])
if (fonts.length === 0) return null
const label = t('viewMissingFonts', { count: fonts.length })
return <>
<div className={css.space} data-office-font-notice data-dismissed={!visible} aria-hidden={!visible} {...(!visible ? { inert: '' } : {})}>
<div className={css.clip}>
<div ref={root} className={css.notice}>
<IconWarningOutlineRegular className={css.warning} />
<span ref={message} className={css.message} role="status">
<span>{t('missingFonts', { fonts: fonts.join(', ') })}</span>
</span>
<span ref={anchor} className={css.anchor}>
<Button size="sm" className={css.more} aria-expanded={open} aria-controls={open ? id : undefined}
aria-haspopup="dialog" onClick={() => { setExpanded(open ? undefined : identity) }}>
{t('showMore')}
</Button>
</span>
<Button size="sm" className={css.close} aria-label={t('dismissNotice')}
onClick={() => { setDismissed(identity); setExpanded(undefined) }} icon={<IconCloseOutlineRegular />} />
</div>
</div>
</div>
<Tooltip label={label} side="bottom" delayMs={500} disabled={open}>
<span ref={anchor} className={css.anchor} data-office-font-warning>
<button type="button" className={clsx(common.tool, css.warning)} aria-label={label}
aria-expanded={open} aria-controls={open ? id : undefined} aria-haspopup="dialog"
onClick={() => { setExpanded(value => !value) }}>
<IconWarningTriangleOutlineRegular />
</button>
</span>
</Tooltip>
{open && createPortal(<div ref={panel} id={id} role="dialog" aria-labelledby={`${id}-title`}
aria-describedby={`${id}-description`} tabIndex={-1} className={css.panel}
style={{ ...position, visibility: position === null ? 'hidden' : undefined }}
onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeDetails() } }}
onBlur={(event) => {
if (event.relatedTarget instanceof Node && !event.currentTarget.contains(event.relatedTarget)
&& !anchor.current?.contains(event.relatedTarget)) setExpanded(undefined)
&& !anchor.current?.contains(event.relatedTarget)) setExpanded(false)
}}>
<div className={css.panelHeader}>
<h3 id={`${id}-title`}>{t('missingFontsTitle')}</h3>
@@ -1,4 +1,4 @@
/** Office owns source loading, conversion failures, and font notices around the shared PDF view. */
/** Office owns source loading and conversion failures around the shared PDF view. */
import { useEffect, type ReactNode } from 'react'
import type { PropsLocale, PropsRenderSlots, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
@@ -11,7 +11,6 @@ import { hostFileOf } from '../rpc.ts'
import { LoadingIndicator } from '../LoadingIndicator.tsx'
import type { ReadOfficeDocument } from './cache.ts'
import type { OfficeStore } from './store.ts'
import { FontNotice } from './FontNotice.tsx'
import common from '../TextPreview.module.css'
import css from './OfficeBody.module.css'
@@ -44,7 +43,7 @@ export type OfficeBodyProps = DocumentPreviewProps & PropsStore<OfficeStore> & O
/**
* Load one Office revision and preserve its result while its tab remains open.
* @param props - renderer loading request, tab state, conversion callbacks, and PDF slot.
* @returns conversion status or the font notice and PDF scrollport.
* @returns conversion status or the PDF scrollport.
*/
export function OfficeBody(props: OfficeBodyProps): ReactNode {
const { tab } = props.useTabInfo()
@@ -84,7 +83,6 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode {
}
if (file === undefined) return <LoadingIndicator className={common.statusLine} label={t('loading')} />
return <div className={css.body}>
<FontNotice resourceAddress={resourceAddress} sourceVersion={file.version} fonts={file.missingFonts} t={t} />
<div className={css.scrollport} ref={props.scrollportRef}>
{props.renderSlot('sidebar.right.tab.document.office.pdf', {
resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
@@ -0,0 +1,21 @@
/** Font warning for the Office revision displayed by the document toolbar. */
import type { ReactNode } from 'react'
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { OfficeStore } from './store.ts'
import { FontNotice } from './FontNotice.tsx'
/** Office toolbar inputs share the body’s conversion result. */
export type OfficeFontActionProps = PropsRuntime<'sidebar.right.tab.document.action'>
& PropsStore<OfficeStore> & PropsLocale<'sidebarOffice'>
/**
* Expose font details only for the currently loaded Office revision.
* @param props - toolbar revision, tab reader, Office store, and localized copy.
* @returns the font warning, or nothing before conversion completes.
*/
export function OfficeFontAction({ content, useTabInfo, useStore, t }: OfficeFontActionProps): ReactNode {
const { tab } = useTabInfo()
const held = useStore(state => state.byTab[tab.id])
if (content.kind !== 'renderer' || held?.revision !== content.revision || held.file === undefined) return null
return <FontNotice key={content.revision} fonts={held.file.missingFonts} t={t} />
}
@@ -14,6 +14,7 @@ import { OfficePreviewCache, type ReadOfficeBytes, type ReadOfficeDocument } fro
import { pdfBodyRegistration } from '../pdf/index.ts'
import { LazyPdfBody } from '../pdf/LazyPdfBody.tsx'
import { OfficeBody, type OfficeBodyInjected } from './OfficeBody.tsx'
import { OfficeFontAction } from './OfficeFontAction.tsx'
import { createOfficeStore } from './store.ts'
import type { Config } from '../../config.ts'
@@ -44,6 +45,9 @@ export function apply(ctx: Context, config: Config['office']): void {
title: () => t('title'), loading: 'renderer', wrap: false,
}))
const store = createOfficeStore()
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.action', () => ctx.slots.register({
name: 'sidebar.right.tab.document.action', key: id, locale: 'sidebarOffice', store,
}, OfficeFontAction)))
const retainTab = retainDocumentTabs(ctx)
const documentT = ctx.locale.bind('sidebarDocumentPreview')
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document', () => ctx.slots.register({
@@ -3,9 +3,7 @@ export const zh = {
title: 'Office 文档',
loading: '正在读取…',
retry: '重试',
missingFonts: '缺少文档使用的字体:{fonts},可能影响文字和排版。',
showMore: '显示更多',
dismissNotice: '关闭字体提示',
viewMissingFonts: '缺失 {count} 种字体,点击查看',
missingFontsTitle: '缺失的字体',
missingFontsDescription: '本次预览无法使用以下字体,预览中的文字和排版可能与原文档不同。',
missingFontsCount: '{count} 种字体',
@@ -27,9 +25,7 @@ export const en = {
title: 'Office document',
loading: 'Reading…',
retry: 'Retry',
missingFonts: 'Fonts used in this document are unavailable: {fonts}. Text and layout may differ.',
showMore: 'Show more',
dismissNotice: 'Dismiss font notice',
viewMissingFonts: 'Missing fonts: {count}. Click to view.',
missingFontsTitle: 'Missing fonts',
missingFontsDescription: 'These fonts are unavailable for this preview. Text and layout may differ from the original document.',
missingFontsCount: 'Fonts: {count}',
@@ -13,6 +13,7 @@ import { Context } from '@deepseek-ai/cordis'
import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
import { apply, inject } from '../src/client/index.ts'
import { OfficeFontAction } from '../src/client/office/OfficeFontAction.tsx'
import { OfficeBody } from '../src/client/office/OfficeBody.tsx'
import { TextPreview } from '../src/client/TextPreview.tsx'
import { TextTitle } from '../src/client/TextTitle.tsx'
@@ -97,6 +98,7 @@ describe('ui-sidebar-documentpreview apply', () => {
['sidebar.right.tab.document', IMAGE_BODY_ID, 'sidebarImage', ImageBody],
['sidebar.right.tab.document', PDF_BODY_ID, 'sidebarPdf', LazyPdfBody],
['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code', 'sidebarCodePreview', CodeBody],
['sidebar.right.tab.document.action', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeFontAction],
['sidebar.right.tab.document', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarOffice', OfficeBody],
['sidebar.right.tab.document.office.pdf', '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', 'sidebarPdf', LazyPdfBody],
])
@@ -30,7 +30,11 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
/** Preserve the body-slot callback used by component fixtures. */
export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return body }
export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] {
return (name, owner, options) => name === 'sidebar.right.tab.document'
? body(name, owner as unknown as Parameters<BodySlot>[1], options as Parameters<BodySlot>[2])
: null
}
export const TAB_ID = 'tab-1' as TabId
export const SESSION = 's-1' as SessionId
@@ -15,7 +15,6 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() })
function props(overrides: Partial<FontNoticeProps> = {}): FontNoticeProps {
return {
resourceAddress: 'dsh-resource://file/session/s-1/report.docx', sourceVersion: 'v1',
fonts: ['Consolas', 'Missing Serif'],
t: makeTranslate(en), ...overrides,
}
@@ -23,7 +22,7 @@ function props(overrides: Partial<FontNoticeProps> = {}): FontNoticeProps {
it('opens details, restores focus on Escape or close, and dismisses outside without stealing focus', () => {
render(<FontNotice {...props()} />)
const more = screen.getByRole('button', { name: en.showMore })
const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })
fireEvent.click(more)
const dialog = screen.getByRole('dialog', { name: en.missingFontsTitle })
expect(dialog.textContent).toContain('Consolas')
@@ -36,7 +35,8 @@ it('opens details, restores focus on Escape or close, and dismisses outside with
expect(document.activeElement).toBe(more)
fireEvent.click(more)
fireEvent.click(screen.getByRole('button', { name: en.closeDetails }))
expect(screen.getByRole('status').textContent).toContain('Consolas')
expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(more)
expect(screen.queryByRole('status')).toBeNull()
expect(document.activeElement).toBe(more)
fireEvent.click(more)
fireEvent.pointerDown(document.body)
@@ -48,7 +48,7 @@ it('opens details, restores focus on Escape or close, and dismisses outside with
it('keeps details open while focus moves inside them and closes when focus leaves the notice', () => {
render(<><FontNotice {...props()} /><button>Other action</button></>)
const more = screen.getByRole('button', { name: en.showMore })
const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })
fireEvent.click(more)
const dialog = screen.getByRole('dialog')
fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: en.closeDetails }) })
@@ -59,24 +59,23 @@ it('keeps details open while focus moves inside them and closes when focus leave
expect(screen.queryByRole('dialog')).toBeNull()
})
it('hides the entire notice until another source version is loaded', () => {
const input = props()
const view = render(<FontNotice {...input} />)
fireEvent.click(screen.getByRole('button', { name: en.showMore }))
fireEvent.click(screen.getByRole('button', { name: en.dismissNotice }))
expect(screen.queryByRole('status')).toBeNull()
it('shows a warning without automatically opening details or remembering that they were viewed', () => {
const view = render(<FontNotice {...props()} />)
const warning = screen.getByRole('button')
expect(warning.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('button', { name: en.showMore })).toBeNull()
view.rerender(<FontNotice {...input} />)
expect(screen.queryByRole('status')).toBeNull()
view.rerender(<FontNotice {...input} sourceVersion="v2" />)
expect(screen.getByRole('status')).toBeDefined()
fireEvent.click(warning)
fireEvent.click(screen.getByRole('button', { name: en.closeDetails }))
expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(warning)
view.unmount()
render(<FontNotice {...props()} />)
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('dialog')).toBeNull()
})
it('has localized copy and does not reserve a notice for fonts that are available', () => {
const view = render(<FontNotice {...props({ t: makeTranslate(zh) })} />)
fireEvent.click(screen.getByRole('button', { name: zh.showMore }))
fireEvent.click(screen.getByRole('button', { name: makeTranslate(zh)('viewMissingFonts', { count: 2 }) }))
expect(screen.getByRole('dialog', { name: zh.missingFontsTitle })).toBeDefined()
view.rerender(<FontNotice {...props({ fonts: [] })} />)
expect(view.container.childElementCount).toBe(0)
@@ -99,7 +99,7 @@ it.each(['remote', 'render', 'files'] as const)('keeps Office registration and g
expect(h.register).toHaveBeenCalledWith(expect.objectContaining({
name: 'sidebar.right.tab.document', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice',
}), OfficeBody)
expect(h.removeNotice).toHaveBeenCalledTimes(2)
expect(h.removeNotice).toHaveBeenCalledTimes(3)
})
it('requests a Host PDF with source identity and borrows the same binary cache result', async () => {
@@ -9,6 +9,7 @@ import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types
import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts'
import type { DocumentContent } from '../src/client/document/contract.ts'
import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx'
import { OfficeFontAction, type OfficeFontActionProps } from '../src/client/office/OfficeFontAction.tsx'
import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx'
import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts'
import type { ReadOfficeDocument } from '../src/client/office/cache.ts'
@@ -45,7 +46,8 @@ it('lets a non-Office renderer load content, report its version, and reload thro
useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded])
return <p>{displayed?.text ?? 'Loading custom content'}</p>
}
const renderSlot: TextPreviewProps['renderSlot'] = (_name, input) => {
const renderSlot: TextPreviewProps['renderSlot'] = (name, input) => {
if (name !== 'sidebar.right.tab.document') return null
const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
return <CustomBody content={owner.content} />
}
@@ -72,9 +74,9 @@ const definition: DocumentPreviewDefinition = {
id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer',
}
type Result = Awaited<ReturnType<ReadOfficeDocument>>
const result = (version = 'v1'): Result => ({ ok: true, value: {
const result = (version = 'v1', missingFonts: readonly string[] = []): Result => ({ ok: true, value: {
absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`),
offset: 0, eof: true, missingFonts: [], generation: 'engine' as OfficeToPdfGeneration,
offset: 0, eof: true, missingFonts, generation: 'engine' as OfficeToPdfGeneration,
} })
function setup() {
@@ -99,7 +101,11 @@ function setup() {
}
const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message
let request: Extract<DocumentContent, { kind: 'renderer' }> | undefined
const slots: TextPreviewProps['renderSlot'] = (_key, input, options) => {
const slots: TextPreviewProps['renderSlot'] = (key, input, options) => {
if (key === 'sidebar.right.tab.document.action') {
return <OfficeFontAction {...{ ...input, useTabInfo: options.hookContext, useStore: useOffice,
actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} />
}
const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
if (owner.content.kind !== 'renderer') return <p>Raw bytes</p>
request = owner.content
@@ -211,3 +217,26 @@ it('starts no conversion when a body receives ordinary shared content', () => {
expect(view.container.childElementCount).toBe(0)
expect(h.read).not.toHaveBeenCalled()
})
it('shows the current revision’s missing fonts in the toolbar and clears stale details during reload', async () => {
const h = setup()
const view = render(<h.View />)
expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull()
await act(async () => { h.pending[0]!.deferred.resolve(result('v1', ['Missing Serif'])) })
const warning = screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' })
fireEvent.click(warning)
expect(screen.getByRole('dialog').textContent).toContain('Missing Serif')
fireEvent.click(screen.getByRole('button', { name: 'reload' }))
expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
await act(async () => { h.pending[1]!.deferred.resolve(result('v2', ['Missing Sans'])) })
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' }))
expect(screen.getByRole('dialog').textContent).toContain('Missing Sans')
expect(screen.getByRole('dialog').textContent).not.toContain('Missing Serif')
h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } })
view.rerender(<h.View renderer={false} />)
await settle()
expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
})
@@ -2710,6 +2710,54 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:45',
},
{
key: 'sidebar.right.tab.document.action',
kind: 'keyed',
scope: 'session',
summary: 'Renderer-specific controls before the document toolbar\'s reload button.',
doc: 'Renderer-specific controls before the document toolbar\'s reload button.',
registerOptions: [
{
name: 'key',
requirement: 'required',
type: 'string',
doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.',
},
],
ownerProps: [
'/**\n * Ordinary file contents, or a request for the selected renderer to load its content.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n | { readonly kind: \'bytes\'; readonly data: Uint8Array<ArrayBuffer> }\n | {\n readonly kind: \'renderer\'\n /** Changes on reload or implementation replacement; retained contents belong to one revision. */\n readonly revision: number\n /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */\n readonly loaded: (version: string) => void\n /** Cancel the current load and start a new revision. */\n readonly reload: () => void\n }',
],
ownerPropsReferences: [
'DocumentTextPage',
],
standardProps: [
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'usePanelInfo: UsePanelInfo',
'useSessions: UseSessions',
'useSessionStatus: UseSessionStatus',
'useSessionRetainInfo: UseSessionRetainInfo',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'useChat: UseChat',
'useConversation: UseConversation',
'useInput: SnapshotSelectorHook<InputState>',
'inputActions: InputActions',
'useSession: SessionSnapshotSelector',
'sessionId: SessionId',
'useProjection: UseProjection',
'useTrajectory: UseTrajectory',
],
keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet',
hookContext: 'UseSidebarRightTabInfo',
slotInject: '{ hooks: { tabInfo: SlotHookFactory<\'sidebar.right.tab.document\', UseSidebarRightTabInfo> } }',
declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted',
occupants: [
'client-ui-sidebar-documentpreview OfficeFontAction',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.action\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.action\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:57',
},
{
key: 'sidebar.right.tab.document.office.pdf',
kind: 'keyed',
@@ -2756,7 +2804,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.office.pdf\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.office.pdf\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:21',
source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:20',
},
{
key: 'sidebar.right.tab.guide',