diff --git a/apps/web/tests/bonus-notice.e2e.ts b/apps/web/tests/bonus-notice.e2e.ts index 04e879156f..794661e338 100644 --- a/apps/web/tests/bonus-notice.e2e.ts +++ b/apps/web/tests/bonus-notice.e2e.ts @@ -257,6 +257,11 @@ describe.skipIf(MODE === 'record')('web e2e: bonus notice', () => { const overlay = join(root, 'bonus-notice.overlay.yml') await writeFile(overlay, (await readFile(OVERLAY_TEMPLATE, 'utf8')).replaceAll('{{origin}}', platform.origin)) scaffold = await launchWebScaffold({ harnessHome: home, extraOverlayPath: overlay }) + // Resolve the signed-in identity before opening the renderer. First profile discovery + // emits another account frame, which would supersede the held startup bonus read. + expect(await scaffold.ctx.deepseekAccount.getProfile({ + version: 'test', locale: 'zh-CN', timezoneOffsetSeconds: 28_800, + })).toMatchObject({ status: 'ready', value: { id: ACCOUNT_ID } }) browser = await chromium.launch() }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 6ab45628f3..c4bf43d2eb 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -37,6 +37,34 @@ async function successShot(page: Page, name: string): Promise { await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) } +/** Check the visible loader group against the document body's center. */ +async function expectDocumentLoading(preview: Locator): Promise { + const loading = preview.getByRole('status', { name: 'Rendering document...', exact: true }) + await expect.poll(() => loading.textContent()).toBe('Rendering document...') + await expect.poll(() => loading.evaluate((node) => { + const body = node.closest('[data-textpreview-body]')!.getBoundingClientRect() + const spinner = node.querySelector('svg')!.getBoundingClientRect() + const label = node.querySelector('span')!.getBoundingClientRect() + return Math.max(Math.abs(spinner.width - 28), Math.abs(spinner.height - 28), + Math.abs(spinner.x + spinner.width / 2 - body.x - body.width / 2), + Math.abs((spinner.top + label.bottom) / 2 - body.y - body.height / 2)) + })).toBeLessThanOrEqual(1) +} + +/** Paper edges expose the document backdrop even when fixed zoom overflows the viewport. */ +async function expectPdfPageSpacing(preview: Locator): Promise { + await preview.getByRole('img', { name: 'PDF page 2', exact: true }).waitFor({ state: 'visible' }) + await expect.poll(() => preview.locator('[data-pdf-preview]').evaluate((node) => { + const body = node.getBoundingClientRect() + const pages = [...node.querySelectorAll('canvas')].map(page => page.getBoundingClientRect()) + const first = pages[0]! + const last = pages.at(-1)! + const inset = Math.min(first.top - body.top, body.bottom - last.bottom, + ...pages.flatMap(page => [page.left - body.left, body.right - page.right])) + return Math.max(Math.abs(pages[1]!.top - first.bottom - 12), 12 - inset) + })).toBeLessThanOrEqual(1) +} + /** Exercise native browser selection and copy, including the text overlay's canvas alignment. */ async function copyPdfText(page: Page, preview: Locator, expected: string): Promise { const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first() @@ -888,6 +916,7 @@ else process.exit(1); await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expect.poll(async () => (await canvas.boundingBox())!.width / pdfIntrinsicWidth).toBeCloseTo(1.5, 1) await expectPdfResolution(canvas) + await expectPdfPageSpacing(preview) pdfZoom = await revealDocumentZoom(page, preview) await pdfZoom.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() @@ -905,6 +934,12 @@ else process.exit(1); await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') const secondColor = await canvasColor(secondPage) expect(secondColor).toBe('blue') + for (const colorScheme of ['dark', 'light'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await expectPdfPageSpacing(preview) + await successShot(page, `pdf-spacing-${colorScheme}`) + } expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true) const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) }) const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab') @@ -927,6 +962,7 @@ else process.exit(1); `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`, '- Zoom reveal: hidden -> bottom hover -> delayed hidden', '- Zoom modes: fit width -> 100% -> 150% -> fit width', + '- Paper layout: 12px page gaps and outer backdrop insets in both themes', '- Settled zoom redraws the page at device resolution', `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`, `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`, @@ -1117,6 +1153,7 @@ else process.exit(1); await openFile('pages.ts') await expect.poll(() => waitingForRead).toBe(true) const reading = preview.locator('[data-document-loading]') + await expectDocumentLoading(preview) initialReading = await reading.isVisible() expect(initialReading).toBe(true) expect(await preview.locator('[data-code-preview]').count()).toBe(0) @@ -1195,6 +1232,7 @@ else process.exit(1); '## Code paging', '', `- Viewer: ${await viewer.innerText()}`, `- Initial reading indicator: ${initialReading}`, + '- Loading feedback: centered 28px spinner with "Rendering document..."', `- Lines: ${prefix.length} -> ${completed.length}`, `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`, `- Tail: ${completed.at(-1)}`, @@ -1494,7 +1532,12 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { ...(['doc', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))), ...['doc', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')), ]) - const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert') + const releaseConversion = Promise.withResolvers() + const convertOffice = scaffold.ctx.officeToPdf.convert.bind(scaffold.ctx.officeToPdf) + const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert').mockImplementationOnce(async (...args) => { + await releaseConversion.promise + return convertOffice(...args) + }) try { const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() @@ -1503,11 +1546,18 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await column.locator('[data-files-reload]').click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) }) const preview = column.locator('[data-textpreview-url]') + await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() + for (const colorScheme of ['dark', 'light'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await expectDocumentLoading(preview) + await successShot(page, `office-loading-${colorScheme}`) + } const pdfResponse = page.waitForResponse( response => new URL(response.url()).pathname === '/api/officeToPdf/render', { timeout: 60_000 }, ) - await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click() + releaseConversion.resolve(undefined) const officeTransfer = await pdfResponse expect(officeTransfer.headers()['content-type']).toMatch(/^multipart\/form-data;/) const officeBody = await new Response(new Uint8Array(await officeTransfer.body()), { headers: officeTransfer.headers() }).formData() @@ -1524,6 +1574,21 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0) const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true }) await canvas.waitFor({ state: 'visible', timeout: 60_000 }) + const backgroundZoom = await revealDocumentZoom(page, preview) + await backgroundZoom.click() + await page.getByRole('menuitem', { name: '50%', exact: true }).click() + await expect.poll(() => backgroundZoom.innerText()).toBe('50%') + await expectPdfResolution(canvas) + for (const colorScheme of ['dark', 'light'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => preview.locator('[data-pdf-preview]').evaluate(node => getComputedStyle(node).backgroundColor)) + .toBe(colorScheme === 'dark' ? 'rgb(21, 21, 23)' : 'rgb(235, 238, 242)') + await expectPdfPageSpacing(preview) + await successShot(page, `office-background-${colorScheme}`) + } + await (await revealDocumentZoom(page, preview)).click() + await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() + await expectPdfResolution(canvas) await expect.poll(() => canvas.evaluate((node) => { const canvas = node as HTMLCanvasElement const context = canvas.getContext('2d') @@ -1625,9 +1690,13 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { const canvas = node.querySelector('canvas')!.getBoundingClientRect() return canvas.top - body.top }) - expect(topInset).toBe(0) + expect(topInset).toBe(12) + await expectPdfPageSpacing(preview) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [ '# Office font warning', '', + '- Document preparation: centered 28px spinner with visible rendering status in both themes', + '- Document backdrop: cool light grey in light mode; matte black in dark mode', + '- Word and PowerPoint paper layout: 12px page gaps and outer backdrop insets', '- Warning precedes reload in the same toolbar: true', '- Warning and reload share button geometry and icon size: true', '- Details open only on request: true', @@ -1645,11 +1714,24 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { await openPreviewFile(column, filesTab, preview, `chinese.${extension}`) await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 }) await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档') + if (extension === 'pptx') await expectPdfPageSpacing(preview) const officeZoom = await revealDocumentZoom(page, preview) await officeZoom.click() await page.getByRole('menuitem', { name: '150%', exact: true }).click() await expectPdfResolution(canvas) if (['doc', 'ppt'].includes(extension)) expect(await warning.count()).toBe(0) + if (extension === 'pptx') { + await preview.locator('[data-document-zoom-scrollport]').evaluate((node) => { + const second = node.querySelector('[data-pdf-page="2"]')!.getBoundingClientRect() + node.scrollTop += second.top - node.getBoundingClientRect().top - node.clientHeight / 2 + }) + for (const colorScheme of ['dark', 'light'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await expectPdfPageSpacing(preview) + await successShot(page, `office-pptx-spacing-${colorScheme}`) + } + } await successShot(page, `office-${extension}`) } expect(convert).toHaveBeenCalledTimes(4) @@ -1670,6 +1752,10 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => { } expect(convert).toHaveBeenCalledTimes(8) expect(tripwire.pageErrors).toEqual([]) - } finally { convert.mockRestore() } + } finally { + releaseConversion.resolve(undefined) + await Promise.allSettled(convert.mock.results.filter(result => result.type === 'return').map(result => result.value)) + convert.mockRestore() + } }) }) diff --git a/apps/web/tests/expected/office-font-notice.md b/apps/web/tests/expected/office-font-notice.md index 6d9ba31361..5f5690ae83 100644 --- a/apps/web/tests/expected/office-font-notice.md +++ b/apps/web/tests/expected/office-font-notice.md @@ -1,5 +1,8 @@ # Office font warning +- Document preparation: centered 28px spinner with visible rendering status in both themes +- Document backdrop: cool light grey in light mode; matte black in dark mode +- Word and PowerPoint paper layout: 12px page gaps and outer backdrop insets - Warning precedes reload in the same toolbar: true - Warning and reload share button geometry and icon size: true - Details open only on request: true @@ -10,4 +13,4 @@ - Settled zoom redraws the Office PDF at device resolution: true - Continuous pinch to 400% redraws the page after horizontal panning: true - Pinch updates the displayed percentage during the gesture: 166% -- Document top inset: 0px +- Document top inset: 12px diff --git a/apps/web/tests/office-fixture.ts b/apps/web/tests/office-fixture.ts index 943cdb1c62..9d4614b6e0 100644 --- a/apps/web/tests/office-fixture.ts +++ b/apps/web/tests/office-fixture.ts @@ -8,7 +8,7 @@ const CONTENT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types const TEXT = 'Office preview 中文文档' /** - * Create a valid one-page Office document containing Latin and Chinese text. + * Create a valid Office document containing Latin and Chinese text; generated Word and PowerPoint documents have two pages. * @param extension - Office application and format to exercise. * @param font - Latin family requested by generated OOXML documents; binary fixtures retain their stored fonts. * @returns Compressed document bytes accepted by the production converter. @@ -23,7 +23,7 @@ export function realOfficeBytes(extension: 'doc' | 'docx' | 'xls' | 'xlsx' | 'pp case 'docx': main = 'word/document.xml' parts = { [main]: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml' } - files[main] = `${TEXT}` + files[main] = `${[0, 1].map(index => `${index === 1 ? '' : ''}${TEXT}`).join('')}` break case 'xlsx': main = 'xl/workbook.xml' @@ -40,10 +40,12 @@ export function realOfficeBytes(extension: 'doc' | 'docx' | 'xls' | 'xlsx' | 'pp parts = { [main]: 'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml', 'ppt/slides/slide1.xml': 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml', + 'ppt/slides/slide2.xml': 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml', } - files[main] = `` - files['ppt/_rels/presentation.xml.rels'] = `` + files[main] = `` + files['ppt/_rels/presentation.xml.rels'] = `` files['ppt/slides/slide1.xml'] = `${TEXT}` + files['ppt/slides/slide2.xml'] = files['ppt/slides/slide1.xml'] break } files['_rels/.rels'] = `` diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index ce386e0209..149b108b44 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -21,8 +21,8 @@ en: 949156aa02ec8b07 zh: d3675754e2a37b5d /deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads: - en: 614ddf87d7db87fe - zh: 87993065f7097b46 + en: c6d2f387753d85f7 + zh: ae1617fb90a10fa4 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview: en: b99b0e0b8efc2853 zh: 4327352d1334fc6b diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 9783341cd1..cf2c833b77 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -61,9 +61,9 @@ When Developer tools is enabled, HTML fills the body edge to edge in a Blob ifra PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. -Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. +Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. -Initial reads, additional pages, and HTML/PDF/image preparation share the ongoing `StateDot` loader, which exposes its label to assistive technology and respects reduced-motion preferences; every wait before content exists centres the loader in the pane, so opening a file shows one indicator in one position until the body appears. Loaded pages stay visible while another page loads. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF pages fill the pane's width edge to edge as one vertical sequence and render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. +Initial reads and renderer preparation, including Office conversion, share a centered 28px ongoing `StateDot` with the visible localized status “Rendering document...”. The status exposes the same accessible name and respects reduced-motion preferences. Additional text pages keep a compact inline spinner while loaded content stays visible. The PDF body loads its package-local `client.pdf.js` chunk only when a PDF preview mounts; PDF.js, its Worker source, and embedded support data stay out of the startup `client.js`. PDF and converted Word/PowerPoint pages form a vertical sequence with 12px page gaps and a 12px outer inset showing the theme-specific document background; fit width reserves this inset, and pages render lazily near the viewport; an unrendered page holds its place as a quiet 3:4 placeholder block. PDF.js’s official TextLayerBuilder manages selection boundaries and normalized copying over an aligned text layer. Its companion styles keep blank line breaks unhighlighted; alignment accounts for PDF page units, page rotation, and viewport resizing, and page disposal cancels both layers. Image-only PDFs contain no selectable text. Code previews show a tertiary-colored language label and a copy icon with a tooltip; wrapping remains in the document toolbar. They show source line numbers by default without including them in copied text; plain text uses the same font size and line height as code. Code sits on the pane's own background rather than the chat card's fill; its banner is adjacent to a full-height inner scrollport, so both scrollbars begin below the copy control. ## Excel preview diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index b0e6c1bdcf..d81001febb 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -61,9 +61,9 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从 PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 -共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 +共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 -首次读取、追加页及 HTML/PDF/图片准备共用 ongoing `StateDot` loading,其标签暴露给辅助技术,并遵循减少动态效果偏好;内容出现前的每个等待都把 loading 居中在面板中,打开文件到正文出现始终是同一位置的一个标记。下一页加载期间保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 页面贴边占满面板宽度,组成一个纵向连续序列并在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 +首次读取及渲染器准备(包括 Office 转换)共用居中的 28px ongoing `StateDot`,下方显示本地化状态文字“文档渲染中...”。状态向辅助技术提供相同的名称,并遵循减少动态效果偏好。追加文本页时使用紧凑的行内转圈,同时保留已显示的内容。PDF 正文仅在 PDF 预览挂载时加载包内 `client.pdf.js` chunk;PDF.js、Worker 源码和内嵌支持数据不会进入启动 `client.js`。PDF 与转换后的 Word/PowerPoint 页面组成纵向序列,页间距与四周留白均为 12px,露出当前主题的文档背景色;适应宽度会预留此留白,页面在接近视口时惰性渲染;未渲染的页以安静的 3:4 占位块保持位置。PDF.js 官方 TextLayerBuilder 在与画面重合的文字层上管理选区边界和复制文本规范化。配套样式不高亮空白换行;对齐同时考虑 PDF 页面单位、页面旋转与视口宽度变化,页面释放时取消两层渲染。纯图片 PDF 不包含可选取的文字。代码预览使用 tertiary 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。 ## Excel 预览 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css index 86593522b5..6f1e7337b2 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.module.css @@ -1,7 +1,28 @@ .loading { - display: inline-flex; - gap: 8px; + display: flex; + flex-direction: column; + gap: 12px; align-items: center; - vertical-align: middle; + justify-content: center; + box-sizing: border-box; + width: 100%; + height: 100%; + min-height: 0; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font, sans-serif); + font-size: var(--dsh-content-font-size, 14px); + line-height: 1.5; + text-align: center; white-space: normal; } + +.inline { + display: inline-flex; + flex-direction: row; + gap: 8px; + width: auto; + height: auto; + padding: 0; + vertical-align: middle; +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx index abed6f3faa..8f6e7274ba 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/LoadingIndicator.tsx @@ -5,15 +5,15 @@ import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import css from './LoadingIndicator.module.css' /** - * @param props - localized status label, carried as the accessible name with - * no visible text, and optional placement style. - * @returns an animated, accessible loading status. + * @param props - localized status label and compact inline placement for additional pages. + * @returns a centered document loading status or an accessible inline spinner. */ -export function LoadingIndicator({ label, className }: { +export function LoadingIndicator({ label, inline = false }: { label: string - className?: string | undefined + inline?: boolean }): ReactNode { - return - + return + + {!inline && {label}} } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index afc740dc5e..1328b07099 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -204,14 +204,6 @@ padding: 12px 10px; } -/* The first read, centred in the still-empty body; loaded content replaces it. */ -.bodyLoading { - justify-content: center; - box-sizing: border-box; - width: 100%; - height: 100%; -} - .action { padding: 4px 10px; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 8eeacd9d45..65b517dbef 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -219,7 +219,7 @@ export function TextPreview({
{meta.status === 'none' ?

{t('resourceUnavailable')}

- : } + : }
) } @@ -341,7 +341,7 @@ export function TextPreview({ }} > {mode !== 'renderer' && !hasContent && current?.failure === undefined && ( - + )} {content !== undefined && renderSlot('sidebar.right.tab.document', { resourceAddress: tab.contentId, content, wrap: state.wrap, scrollportRef: bindScrollport, @@ -396,7 +396,7 @@ export function TextPreview({ data-textpreview-more onClick={loadNext} > - {current.loading ? : t('loadMore')} + {current.loading ? : t('loadMore')} )} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx index 8657e67114..1c4c657a24 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/LazyExcelBody.tsx @@ -6,13 +6,12 @@ import { excelFormat, type ExcelFormat } from './format.ts' import type { ExcelLimits } from './model.ts' import { hostFileOf } from '../rpc.ts' import { LoadingIndicator } from '../LoadingIndicator.tsx' -import css from '../TextPreview.module.css' /** Document input plus parser limits and localized copy. */ export type ExcelBodyProps = DocumentPreviewProps & PropsLocale<'sidebarExcel'> & { readonly limits: ExcelLimits } -/** Lazily loaded renderer input with the filename-derived parser choice. */ -export type LoadedExcelBodyProps = ExcelBodyProps & { readonly format: ExcelFormat } +/** Lazily loaded renderer input with the parser choice and main-bundle loading content. */ +export type LoadedExcelBodyProps = ExcelBodyProps & { readonly format: ExcelFormat; readonly loading: ReactNode } const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx')).ExcelBody })) @@ -23,7 +22,8 @@ const LoadedExcelBody = lazy(async () => ({ default: (await import('./excel.tsx' */ export function LazyExcelBody(props: ExcelBodyProps): ReactNode { const format = excelFormat(hostFileOf(props.resourceAddress).path) - return }> - + const loading = + return + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index 0e1b67da4b..ca3fcb8425 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -1,7 +1,7 @@ /** Read-only spreadsheet surface backed by browser-parsed workbook data. */ import { useEffect, useRef, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' -import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconWarningTriangleOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' import type { ExcelFormat } from './format.ts' import type { ExcelPreview } from './model.ts' @@ -18,7 +18,7 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` * @param props - Complete workbook bytes, limits, and locale. * @returns An isolated spreadsheet surface with cancellable loading. */ -export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { +export function ExcelBody({ content, format, limits, t, loading }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined const [state, setState] = useState() const [attempt, setAttempt] = useState(0) @@ -42,9 +42,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): return () => { observer.disconnect() } }, [state]) if (data === undefined) return

{t('invalid')}

- if (state?.data !== data || state.format !== format) return - - + if (state?.data !== data || state.format !== format) return loading if ('error' in state) return
{t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index 8451c57fde..79b97690a4 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -1,6 +1,6 @@ /** Locale-owned Excel preview controls and parser feedback. */ export const zh = { - title: '表格', language: 'zh', loading: '正在打开表格…', + title: '表格', language: 'zh', loading: '文档渲染中...', invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', @@ -15,7 +15,7 @@ export type ExcelPreviewKey = keyof typeof zh /** English Excel preview copy. */ export const en = { - title: 'Spreadsheet', language: 'en', loading: 'Opening spreadsheet…', + title: 'Spreadsheet', language: 'en', loading: 'Rendering document...', invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css index 8147e1714c..02f352bb66 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.module.css @@ -13,12 +13,3 @@ color: var(--dsw-alias-label-secondary); white-space: normal; } - -/* The packing wait fills the still-empty body and centres, like the image - renderer's status. */ -.opening { - justify-content: center; - box-sizing: border-box; - width: 100%; - min-height: 100%; -} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx index 4810dce5f0..135609acdd 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx @@ -1,7 +1,6 @@ /** Static or interactive HTML in an opaque iframe, without parent application access. */ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import clsx from 'clsx' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { DocumentPreviewProps } from '../document/contract.ts' @@ -63,7 +62,7 @@ function HtmlFrame({ data, resourceAddress, readRelated, addResource, setResourc }, [data, readRelated, resourceAddress, addResource, setResources, signal]) if (frame?.data !== data || frame.readRelated !== readRelated) { - return + return } if (frame.url === undefined) return

{t('failed')}

return