fix(web): move Office font warnings into the document toolbar

This commit is contained in:
yudshj committed 2026-09-18 11:06:09 +08:00
1 parent 831ea36f2c
commit 9b27cc0587
24 files changed
+229 -188

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。缓存字节限制不约束临时传输或查看器内存。
+24 -16
View File
@@ -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)
@@ -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
@@ -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
@@ -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.
<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.
@@ -75,12 +75,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"
@@ -41,6 +41,14 @@ export interface DocumentBodyOwner {
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** 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> } }
}
/** Document body selected by a registered implementation id. */
'sidebar.right.tab.document': {
kind: 'keyed'
@@ -39,3 +39,12 @@ export const IconWrapFill16 = ({ size = 16, className }: IconProps) => (
<path d="M15 15H14V1H15V15Z" fill="currentColor" />
</svg>
)
/** Rounded triangle with an exclamation mark for unavailable document fonts. */
export const IconFontWarning = ({ size = 18, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10.3 3.9a2 2 0 0 1 3.4 0l8 14a2 2 0 0 1-1.7 3.1H4a2 2 0 0 1-1.7-3.1Z" />
<path d="M12 9v4m0 4h.01" />
</svg>
)
@@ -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,24 @@
.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 {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 6px;
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;
cursor: pointer;
}
.notice .more:hover, .notice .close:hover {
background: var(--dsw-alias-interactive-bg-hover);
.warning:hover, .warning[aria-expanded='true'] {
background: var(--dsw-alias-state-warn-tertiary);
}
.panel {
@@ -133,7 +75,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,31 @@
/** 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 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, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconFontWarning } from '../icons.tsx'
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 +33,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={css.warning} aria-label={label}
aria-expanded={open} aria-controls={open ? id : undefined} aria-haspopup="dialog"
onClick={() => { setExpanded(value => !value) }}>
<IconFontWarning />
</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}',
@@ -12,6 +12,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'
@@ -95,6 +96,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()
})
@@ -2707,6 +2707,54 @@ 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\', () => 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:53',
},
{
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:45',
},
{
@@ -2755,7 +2803,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',