fix(web): unify document loading and spaced theme-aware pages
This commit is contained in:
1 parent
55b06e6f81
commit
ed4445b028
36 files changed
+216
-110
No files matched your search
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,34 @@ async function successShot(page: Page, name: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<undefined>()
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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] = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="宋体"/></w:rPr><w:t>${TEXT}</w:t></w:r></w:p><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>`
|
||||
files[main] = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${[0, 1].map(index => `<w:p><w:r><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="宋体"/></w:rPr>${index === 1 ? '<w:br w:type="page"/>' : ''}<w:t>${TEXT}</w:t></w:r></w:p>`).join('')}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:body></w:document>`
|
||||
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] = `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="${OFFICE_REL_NS}"><p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`
|
||||
files['ppt/_rels/presentation.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/slide" Target="slides/slide1.xml"/></Relationships>`
|
||||
files[main] = `<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="${OFFICE_REL_NS}"><p:sldIdLst><p:sldId id="256" r:id="rId1"/><p:sldId id="257" r:id="rId2"/></p:sldIdLst><p:sldSz cx="9144000" cy="6858000"/><p:notesSz cx="6858000" cy="9144000"/></p:presentation>`
|
||||
files['ppt/_rels/presentation.xml.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/slide" Target="slides/slide1.xml"/><Relationship Id="rId2" Type="${OFFICE_REL_NS}/slide" Target="slides/slide2.xml"/></Relationships>`
|
||||
files['ppt/slides/slide1.xml'] = `<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Preview"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="914400" y="914400"/><a:ext cx="7315200" cy="1828800"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="2400"><a:latin typeface="${font}"/><a:ea typeface="宋体"/></a:rPr><a:t>${TEXT}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp></p:spTree></p:cSld></p:sld>`
|
||||
files['ppt/slides/slide2.xml'] = files['ppt/slides/slide1.xml']
|
||||
break
|
||||
}
|
||||
files['_rels/.rels'] = `<Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OFFICE_REL_NS}/officeDocument" Target="${main}"/></Relationships>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<img>` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `<img>` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL.
|
||||
|
||||
Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a graphite background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. 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.
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel preview
|
||||
|
||||
@@ -61,9 +61,9 @@ Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从
|
||||
|
||||
PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `<img>` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。
|
||||
|
||||
共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用石墨灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 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 色的语言名称和带提示的复制图标;换行仍由文档工具栏控制。默认显示源码行号,但复制文本不包含行号;纯文本与代码使用相同字号和行高。代码直接坐在分栏自身的背景上,而不是会话卡片的填充色;复制条与占满剩余高度的内部滚动区相邻,因此横纵滚动条都从复制控件下方开始。
|
||||
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <span className={clsx(css.loading, className)} role="status" aria-label={label} data-document-loading>
|
||||
<StateDot state="ongoing" />
|
||||
return <span className={clsx(css.loading, inline && css.inline)} role="status" aria-label={label} data-document-loading>
|
||||
<StateDot state="ongoing" size={inline ? 14 : 28} />
|
||||
{!inline && <span>{label}</span>}
|
||||
</span>
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -219,7 +219,7 @@ export function TextPreview({
|
||||
<div className={css.status} data-textpreview-state="loading">
|
||||
{meta.status === 'none'
|
||||
? <p className={css.statusLine}>{t('resourceUnavailable')}</p>
|
||||
: <LoadingIndicator className={css.statusLine} label={t('loading')} />}
|
||||
: <LoadingIndicator label={t('loading')} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export function TextPreview({
|
||||
}}
|
||||
>
|
||||
{mode !== 'renderer' && !hasContent && current?.failure === undefined && (
|
||||
<LoadingIndicator className={clsx(css.statusLine, css.bodyLoading)} label={t('loading')} />
|
||||
<LoadingIndicator label={t('loading')} />
|
||||
)}
|
||||
{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 ? <LoadingIndicator label={t('loading')} /> : t('loadMore')}
|
||||
{current.loading ? <LoadingIndicator inline label={t('loading')} /> : t('loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 <Suspense fallback={<LoadingIndicator className={css.status} label={props.t('loading')} />}>
|
||||
<LoadedExcelBody {...props} format={format} />
|
||||
const loading = <LoadingIndicator label={props.t('loading')} />
|
||||
return <Suspense fallback={loading}>
|
||||
<LoadedExcelBody {...props} format={format} loading={loading} />
|
||||
</Suspense>
|
||||
}
|
||||
@@ -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<State>()
|
||||
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 <p className={css.status} role="alert">{t('invalid')}</p>
|
||||
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
|
||||
<StateDot state="ongoing" />
|
||||
</span>
|
||||
if (state?.data !== data || state.format !== format) return loading
|
||||
if ('error' in state) return <div className={css.status} role="alert">
|
||||
<span>{t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}</span>
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
|
||||
return <LoadingIndicator label={t('loading')} />
|
||||
}
|
||||
if (frame.url === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
return <iframe key={frame.url} name={frameName} className={css.frame} src={frame.url} sandbox="allow-scripts" title={t('frame')} data-html-preview />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
export const zh = {
|
||||
title: 'HTML',
|
||||
frame: 'HTML 文档预览',
|
||||
loading: '正在读取…',
|
||||
loading: '文档渲染中...',
|
||||
failed: '无法预览这份 HTML 文档',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
@@ -13,7 +13,7 @@ export type HtmlPreviewKey = keyof typeof zh
|
||||
export const en = {
|
||||
title: 'HTML',
|
||||
frame: 'HTML document preview',
|
||||
loading: 'Reading…',
|
||||
loading: 'Rendering document...',
|
||||
failed: 'This HTML document could not be previewed.',
|
||||
} satisfies Record<HtmlPreviewKey, string>
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.frame:has([data-document-loading]) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* A wider image shrinks to the pane's width at its aspect ratio; a smaller
|
||||
one keeps its intrinsic size. The auto margins centre it either way. */
|
||||
.surface {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ImageBody(props: ImageBodyProps): ReactNode {
|
||||
return <p className={css.status} role="alert">{t('unsupported')}</p>
|
||||
}
|
||||
if (source?.data !== data || source.mediaType !== mediaType) {
|
||||
return <LoadingIndicator className={css.status} label={t('loading')} />
|
||||
return <LoadingIndicator label={t('loading')} />
|
||||
}
|
||||
if (source.kind === 'failed') return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
const { name } = pathPartsOf(path)
|
||||
@@ -114,7 +114,7 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={width} horizontalInset={24}
|
||||
labels={labels} signal={signal} scrollportRef={scrollportRef} onPreference={onPreference}>
|
||||
<div className={css.frame} data-image-preview>
|
||||
{state === 'loading' && <LoadingIndicator className={css.status} label={t('loading')} />}
|
||||
{state === 'loading' && <LoadingIndicator label={t('loading')} />}
|
||||
{state === 'failed' && <p className={css.status} role="alert">{t('failed')}</p>}
|
||||
<div className={`${css.surface} ${zoomSurfaceClass}`} data-document-zoom-surface
|
||||
style={{ '--document-zoom-width': `${width ?? 0}px` } as CSSProperties} hidden={state !== 'ready'}>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const zh = {
|
||||
...zoomZh,
|
||||
title: '图片',
|
||||
preview: '图片预览:{name}',
|
||||
loading: '正在读取…',
|
||||
loading: '文档渲染中...',
|
||||
failed: '无法显示这张图片',
|
||||
unsupported: '图片预览需要完整文件内容',
|
||||
} satisfies Record<string, string>
|
||||
@@ -18,7 +18,7 @@ export const en = {
|
||||
...zoomEn,
|
||||
title: 'Image',
|
||||
preview: 'Image preview: {name}',
|
||||
loading: 'Reading…',
|
||||
loading: 'Rendering document...',
|
||||
failed: 'This image could not be displayed.',
|
||||
unsupported: 'Image preview requires the complete file contents.',
|
||||
} satisfies Record<ImagePreviewKey, string>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
loading: '正在读取…',
|
||||
loading: '文档渲染中...',
|
||||
loadMore: '加载更多',
|
||||
changed: '文件已更新,当前显示为旧内容',
|
||||
reloadNow: '重新载入',
|
||||
@@ -39,7 +39,7 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
loading: 'Reading…',
|
||||
loading: 'Rendering document...',
|
||||
loadMore: 'Load more',
|
||||
changed: 'The file has changed, showing the previous content.',
|
||||
reloadNow: 'Reload',
|
||||
|
||||
@@ -68,7 +68,7 @@ export function OfficeBody(props: OfficeBodyProps): ReactNode {
|
||||
<Button size="sm" onClick={request.reload}>{t('retry')}</Button>
|
||||
</div>
|
||||
}
|
||||
if (file === undefined) return <LoadingIndicator className={common.statusLine} label={t('loading')} />
|
||||
if (file === undefined) return <LoadingIndicator label={t('loading')} />
|
||||
return <div className={css.body}>
|
||||
{props.renderSlot('sidebar.right.tab.document.office.pdf', {
|
||||
resourceAddress, content: { kind: 'bytes', data: file.data }, wrap: props.wrap, scrollportRef: props.scrollportRef,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Office preview copy and Host render configuration guidance. */
|
||||
export const zh = {
|
||||
title: 'Office 文档',
|
||||
loading: '正在读取…',
|
||||
loading: '文档渲染中...',
|
||||
retry: '重试',
|
||||
viewMissingFonts: '缺失 {count} 种字体,点击查看',
|
||||
missingFontsTitle: '缺失的字体',
|
||||
@@ -23,7 +23,7 @@ export type OfficePreviewKey = keyof typeof zh
|
||||
/** English translations checked against the Chinese key set. */
|
||||
export const en = {
|
||||
title: 'Office document',
|
||||
loading: 'Reading…',
|
||||
loading: 'Rendering document...',
|
||||
retry: 'Retry',
|
||||
viewMissingFonts: 'Missing fonts: {count}. Click to view.',
|
||||
missingFontsTitle: 'Missing fonts',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/** Load the PDF renderer only after a PDF body is mounted. */
|
||||
import { lazy, Suspense, type ReactNode } from 'react'
|
||||
import { LoadingIndicator } from '../LoadingIndicator.tsx'
|
||||
import css from '../TextPreview.module.css'
|
||||
import type { PdfBodyProps } from './pdf.tsx'
|
||||
|
||||
const LoadedPdfBody = lazy(async () => ({ default: (await import('./pdf.tsx')).PdfBody }))
|
||||
@@ -12,7 +11,8 @@ const LoadedPdfBody = lazy(async () => ({ default: (await import('./pdf.tsx')).P
|
||||
* @returns the deferred PDF renderer.
|
||||
*/
|
||||
export function LazyPdfBody(props: PdfBodyProps): ReactNode {
|
||||
return <Suspense fallback={<LoadingIndicator className={css.status} label={props.t('loading')} />}>
|
||||
<LoadedPdfBody {...props} />
|
||||
const loading = <LoadingIndicator label={props.t('loading')} />
|
||||
return <Suspense fallback={loading}>
|
||||
<LoadedPdfBody {...props} loading={loading} />
|
||||
</Suspense>
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
min-height: 100%;
|
||||
min-width: 100%;
|
||||
padding: 12px;
|
||||
overflow: visible;
|
||||
background: var(--dsw-alias-bg-document-preview);
|
||||
font-family: var(--dsw-font, sans-serif);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
:global([data-document-zoom-mode="fixed"]) .body {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
@@ -139,12 +145,3 @@
|
||||
.body .status {
|
||||
color: var(--dsw-alias-label-document-preview);
|
||||
}
|
||||
|
||||
/* The document-open wait fills the still-empty body and centres, matching the
|
||||
owner's read spinner position. */
|
||||
.opening {
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ export const zh = {
|
||||
...zoomZh,
|
||||
title: 'PDF',
|
||||
pageImage: 'PDF 第 {page} 页',
|
||||
loading: '正在读取…',
|
||||
loading: '文档渲染中...',
|
||||
rendering: '正在绘制页面…',
|
||||
failed: '无法显示 PDF:{message}',
|
||||
password: '此 PDF 需要密码,暂不支持预览',
|
||||
@@ -22,7 +22,7 @@ export const en = {
|
||||
...zoomEn,
|
||||
title: 'PDF',
|
||||
pageImage: 'PDF page {page}',
|
||||
loading: 'Reading…',
|
||||
loading: 'Rendering document...',
|
||||
rendering: 'Rendering page…',
|
||||
failed: 'Cannot display PDF: {message}',
|
||||
password: 'This PDF requires a password; password-protected previews are not supported.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** PDF page presentation; binary content and tab information come from the document owner. */
|
||||
import { useCallback, useEffect, useRef, useState, type ComponentType, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { DocumentPreviewProps } from '../document/contract.ts'
|
||||
@@ -39,10 +39,10 @@ const DEFAULT_PDF_VIEW: PdfView = { page: 1 }
|
||||
const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' }
|
||||
/**
|
||||
* Present a PDF with tab-local viewing preferences and component-owned rendering resources.
|
||||
* @param props - complete bytes and framework-owned tab/store/locale seats.
|
||||
* @param props - complete bytes, framework-owned tab/store/locale seats, and main-bundle loading content.
|
||||
* @returns the PDF reader.
|
||||
*/
|
||||
export function PdfBody(props: PdfBodyProps): ReactNode {
|
||||
export function PdfBody(props: PdfBodyProps & { readonly loading: ReactNode }): ReactNode {
|
||||
const { tab } = props.useTabInfo()
|
||||
const view = props.useStore(state => state.byTab[tab.id] ?? DEFAULT_PDF_VIEW)
|
||||
const data = props.content.kind === 'bytes' ? props.content.data : undefined
|
||||
@@ -82,12 +82,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
|
||||
}
|
||||
}, [data, tab.signal, attempt])
|
||||
if (data === undefined) return <p className={css.status} role="alert">{t('unsupported')}</p>
|
||||
// The open wait centres like the owner's read spinner before it, so one
|
||||
// spinner position covers everything until the first page block appears.
|
||||
if (load?.data !== data) return <span className={`${css.status} ${css.opening}`} role="status"
|
||||
aria-label={t('loading')} data-document-loading>
|
||||
<StateDot state="ongoing" />
|
||||
</span>
|
||||
if (load?.data !== data) return props.loading
|
||||
if (load.kind === 'failed') {
|
||||
return <div className={css.status} role="alert">
|
||||
<span>{failureText(load.error, t)}</span>
|
||||
@@ -101,7 +96,7 @@ export function PdfBody(props: PdfBodyProps): ReactNode {
|
||||
onWidth={index === 0 ? setPageWidth : undefined} zoom={renderZoom} zoomSurfaceClass={zoomSurfaceClass} t={t} />
|
||||
))}
|
||||
</section>
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={pageWidth} labels={labels}
|
||||
return <ZoomViewport preference={preference} intrinsicWidth={pageWidth} horizontalInset={24} labels={labels}
|
||||
signal={tab.signal} scrollportRef={props.scrollportRef} onPreference={setPreference} onRenderZoom={setRenderZoom}>
|
||||
{pages}
|
||||
</ZoomViewport>
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('document toolbar', () => {
|
||||
expect(view.container.textContent).toContain('held')
|
||||
})
|
||||
|
||||
it('keeps Reading above the first code page and reload, retaining code during pagination', async () => {
|
||||
it('shows document preparation before the first page and reload, retaining code during pagination', async () => {
|
||||
const h = harness()
|
||||
const first = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
|
||||
const next = Promise.withResolvers<Awaited<ReturnType<typeof h.read>>>()
|
||||
@@ -93,7 +93,7 @@ describe('document toolbar', () => {
|
||||
expect(view.container.querySelector('[data-code-preview]')).toBeNull()
|
||||
expect(body?.firstElementChild).toBe(view.getByRole('status'))
|
||||
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
|
||||
expect(view.getByRole('status').textContent).toBe('')
|
||||
expect(view.getByRole('status').textContent).toBe('loading')
|
||||
expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
|
||||
|
||||
await act(async () => { first.resolve(page(1, ['const prefix = 1;'], false)); await first.promise })
|
||||
|
||||
@@ -12,9 +12,10 @@ vi.mock('../src/client/excel/parse.ts', () => ({ parseExcel: mocked.parse }))
|
||||
vi.mock('@fortune-sheet/react', () => ({ Workbook: mocked.workbook }))
|
||||
import { ExcelBody } from '../src/client/excel/excel.tsx'
|
||||
import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx'
|
||||
import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
|
||||
|
||||
const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps
|
||||
const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps
|
||||
const loadedProps = { ...props, format: 'xlsx', loading: <LoadingIndicator label={en.loading} /> } satisfies LoadedExcelBodyProps
|
||||
const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0, unsupportedFeatures: [] }
|
||||
const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1, unsupportedFeatures: [] }
|
||||
afterEach(() => { cleanup(); vi.resetAllMocks(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/** The shared loading status: an icon-only spinner named by its label. */
|
||||
/** Document loading text and compact additional-page feedback. */
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
|
||||
@@ -7,8 +7,15 @@ import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('LoadingIndicator', () => {
|
||||
it('renders no visible text and carries the label as the accessible name', () => {
|
||||
const { getByRole } = render(<LoadingIndicator label="Reading…" />)
|
||||
it('shows the localized document status with a larger spinner', () => {
|
||||
const { getByRole } = render(<LoadingIndicator label="文档渲染中..." />)
|
||||
const status = getByRole('status', { name: '文档渲染中...' })
|
||||
expect(status.textContent).toBe('文档渲染中...')
|
||||
expect(status.querySelector('svg')?.getAttribute('width')).toBe('28')
|
||||
})
|
||||
|
||||
it('keeps additional-page loading compact and accessible', () => {
|
||||
const { getByRole } = render(<LoadingIndicator inline label="Reading…" />)
|
||||
const status = getByRole('status')
|
||||
expect(status.textContent).toBe('')
|
||||
expect(status.getAttribute('aria-label')).toBe('Reading…')
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('../src/client/pdf/document.ts', () => ({ renderPdfPage: engine.render }
|
||||
import { PdfBody, type PdfBodyProps } from '../src/client/pdf/pdf.tsx'
|
||||
import { createPdfStore, type PdfState } from '../src/client/pdf/store.ts'
|
||||
import { en } from '../src/client/pdf/locales.ts'
|
||||
import { LoadingIndicator } from '../src/client/LoadingIndicator.tsx'
|
||||
import { PdfWorkerFailure } from '../src/client/pdf/errors.ts'
|
||||
import { ZoomViewport, zoomSurfaceClass } from '../src/client/zoom/ZoomViewport.tsx'
|
||||
|
||||
@@ -27,7 +28,8 @@ const loads: Array<{
|
||||
}> = []
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
||||
// Keep the 100px fixture pages at actual size inside the document gutters.
|
||||
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(124)
|
||||
const context: Pick<CanvasRenderingContext2D, 'drawImage'> = { drawImage: () => {} }
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(context as CanvasRenderingContext2D)
|
||||
loads.length = 0
|
||||
@@ -93,7 +95,8 @@ function harness() {
|
||||
useTabInfo: () => ({ tab: { id: tabId, signal: controller.signal } }),
|
||||
useStore, actions: instance.actions, retainTab: vi.fn(), t: makeTranslate(en),
|
||||
} as unknown as PdfBodyProps
|
||||
return <PdfBody {...props} scrollportRef={scrollportRef} ZoomViewport={ZoomViewport} zoomSurfaceClass={zoomSurfaceClass} />
|
||||
return <PdfBody {...props} loading={<LoadingIndicator label={en.loading} />} scrollportRef={scrollportRef}
|
||||
ZoomViewport={ZoomViewport} zoomSurfaceClass={zoomSurfaceClass} />
|
||||
}
|
||||
return { instance, controller, tabId, scrollportRef, View }
|
||||
}
|
||||
@@ -118,7 +121,7 @@ describe('PDF body', () => {
|
||||
it('shows loading, adds zoom controls, and renders a continuous page sequence', async () => {
|
||||
const h = harness()
|
||||
const view = render(<h.View />)
|
||||
expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Reading…')
|
||||
expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Rendering document...')
|
||||
expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
|
||||
expect(screen.getByRole('status').querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
await act(async () => { loads[0]!.deferred.resolve(documentOf()) })
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
en: f7e14d496c0a1a7d
|
||||
zh: 6ebdde24b68813ea
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
|
||||
en: 02e044fedfcf89b2
|
||||
zh: 82198724d22bbc8e
|
||||
en: c630d76c649c6d72
|
||||
zh: b39f8e02f3f6b4d3
|
||||
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
|
||||
en: bf76e7c34a331595
|
||||
zh: 5accd0260ef7c61e
|
||||
|
||||
@@ -55,7 +55,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
|
||||
|
||||
`base.css` suppresses only the outline of focused elements marked `data-dsh-automatic-focus` by the [primitive focus helper](../ui-primitives/README.md); ordinary keyboard focus styling, borders, shadows, and error states remain intact.
|
||||
|
||||
System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers.
|
||||
System toasts use `--dsw-alias-toast-bg` and `--dsw-alias-toast-label` for a shared background and text color across callers. Document previews pair `--dsw-alias-bg-document-preview` with `--dsw-alias-label-document-preview` so the backdrop and status text follow the same theme.
|
||||
|
||||
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ kind: "package-reference"
|
||||
|
||||
`base.css` 仅抑制[基础控件焦点工具](../ui-primitives/README.zh.md)通过 `data-dsh-automatic-focus` 标记的聚焦元素外轮廓线;正常键盘焦点样式、边框、阴影及错误状态保持不变。
|
||||
|
||||
系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。
|
||||
系统提示使用 `--dsw-alias-toast-bg` 和 `--dsw-alias-toast-label`,在各调用方之间统一背景与文字颜色。文档预览配对使用 `--dsw-alias-bg-document-preview` 与 `--dsw-alias-label-document-preview`,使底色与状态文字遵循相同主题。
|
||||
|
||||
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
|
||||
|
||||
|
||||
@@ -165,8 +165,8 @@ body[data-ds-dark-theme] {
|
||||
|
||||
body {
|
||||
--dsw-alias-bg-base: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-200);
|
||||
--dsw-alias-bg-document-preview: var(--dsw-static-neutral-bluish-100);
|
||||
--dsw-alias-label-document-preview: var(--dsw-static-neutral-bluish-700);
|
||||
--dsw-alias-bg-layer-1: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-layer-2: var(--dsw-static-neutral-bluish-00);
|
||||
--dsw-alias-bg-layer-3: var(--dsw-static-neutral-bluish-00);
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
- Continuous pages: 2
|
||||
- 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: false
|
||||
- Canvas fills: red -> blue -> blue
|
||||
@@ -70,6 +71,7 @@
|
||||
|
||||
- Viewer: Code
|
||||
- Initial reading indicator: true
|
||||
- Loading feedback: centered 28px spinner with "Rendering document..."
|
||||
- Lines: 64 -> 65
|
||||
- Prefix retained: true
|
||||
- Tail: const tail = "CODE_TAIL";
|
||||
|
||||
Reference in new issue
Block a user