fix(web): simplify read-only spreadsheet preview controls (#5006)
* fix(web): simplify read-only spreadsheet preview controls * fix(web): soften read-only spreadsheet freeze dividers * fix(web): hide read-only spreadsheet freeze dividers * fix(web): keep spreadsheet formula bar on one line
This commit is contained in:
1 parent
38596d5c70
commit
dd004b4221
11 files changed
+274
-25
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
|
||||
2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d
|
||||
2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386
|
||||
2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019
|
||||
2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb
|
||||
@@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@
|
||||
|
||||
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
|
||||
|
||||
The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, and escaped table copying when upgrading FortuneSheet.
|
||||
The core patch also skips drawing freeze dividers when `allowEdit === false`, retaining the frozen-pane calculations and editable-mode dividers. These dividers are Canvas strokes, so scoped CSS cannot change them. Preview-scoped CSS hides the row and column freeze drag handles. The ESM and CommonJS entries receive the same patches. Recheck worksheet switching, complete initial selections, literal formula-bar text, escaped table copying, and frozen-pane rendering and scrolling when upgrading FortuneSheet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
|
||||
|
||||
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
|
||||
|
||||
ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本以及经过转义的表格复制。
|
||||
core 补丁还在 `allowEdit === false` 时跳过冻结分隔线的绘制,保留冻结窗格计算和编辑模式下的分隔线。这些分隔线由 Canvas 绘制,因此局部 CSS 无法修改。预览区域内的 CSS 隐藏行列冻结拖拽条。ESM 和 CommonJS 入口应用相同补丁。升级 FortuneSheet 时需要重新检查工作表切换、完整初始选区、公式栏字面文本、经过转义的表格复制,以及冻结窗格的绘制和滚动。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { realOfficeBytes } from './office-fixture.ts'
|
||||
import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts'
|
||||
import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts'
|
||||
import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts'
|
||||
import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
|
||||
@@ -104,15 +104,30 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise<void> {
|
||||
await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull()
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => {
|
||||
/** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */
|
||||
async function expectExcelLayout(excel: Locator): Promise<void> {
|
||||
await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false)
|
||||
expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0)
|
||||
await expect.poll(() => excel.evaluate((node) => {
|
||||
const pane = node.getBoundingClientRect()
|
||||
const canvas = node.querySelector('canvas')!.getBoundingClientRect()
|
||||
const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect()
|
||||
const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect()
|
||||
return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width),
|
||||
Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom))
|
||||
})).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) })
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await input.fill(PROMPT)
|
||||
@@ -120,7 +135,13 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
const sessionId = await settled
|
||||
const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'budget.xlsx'), await excelFixture())
|
||||
const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3)
|
||||
await Promise.all([
|
||||
writeFile(join(cwd, 'budget.xlsx'), await excelFixture()),
|
||||
writeFile(join(cwd, 'legacy.xls'), xlsFixture()),
|
||||
writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`),
|
||||
writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'),
|
||||
])
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
@@ -132,6 +153,16 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } })
|
||||
const formula = excel.locator('.fortune-fx-input')
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
await page.keyboard.press('Shift+ArrowLeft')
|
||||
const selection = excel.locator('.fortune-name-box')
|
||||
await expect.poll(() => selection.innerText()).toBe('A1:B1')
|
||||
await expectExcelLayout(excel)
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
|
||||
await page.keyboard.press('ControlOrMeta+C')
|
||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16')
|
||||
const arrow = excel.locator('.luckysheet-sheets-item-function').first()
|
||||
await arrow.evaluate((node) => { (node as HTMLElement).focus() })
|
||||
expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false)
|
||||
const canvas = excel.locator('canvas').first()
|
||||
const originalCanvas = await canvas.elementHandle()
|
||||
const original = await canvas.boundingBox()
|
||||
@@ -145,17 +176,177 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while
|
||||
await expect.poll(() => formula.innerText()).toBe('46281')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(page.viewportSize()).toEqual(viewport)
|
||||
await successShot(page, 'excel-resize-wide')
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-wide-${locale}`)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => {
|
||||
const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect()
|
||||
return selected.right - node.getBoundingClientRect().right
|
||||
})).toBeLessThanOrEqual(1)
|
||||
await page.setViewportSize({ width: 1680, height: 600 })
|
||||
await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400)
|
||||
await expectExcelLayout(excel)
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
await successShot(page, `excel-resize-narrow-${locale}`)
|
||||
await page.setViewportSize(viewport!)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width))
|
||||
expect(await formula.innerText()).toBe('46281')
|
||||
await successShot(page, 'excel-resize-restored')
|
||||
expect(await selection.innerText()).toBe('A1:B1')
|
||||
expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-resize-restored-${locale}`)
|
||||
const zoom = excel.locator('.fortune-zoom-ratio-current')
|
||||
await excel.locator('.fortune-zoom-button').first().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('90%')
|
||||
await excel.locator('.fortune-zoom-button').last().click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('100%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click()
|
||||
await expect.poll(() => zoom.innerText()).toBe('150%')
|
||||
await zoom.click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click()
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) })
|
||||
const preview = column.locator('[data-textpreview-url]')
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
for (const name of ['budget.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) {
|
||||
await openPreviewFile(column, filesTab, preview, name)
|
||||
await excel.locator('.fortune-sheet-overlay').waitFor()
|
||||
await expectExcelLayout(excel)
|
||||
if (name === 'values.csv') {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
|
||||
await expect.poll(() => formula.textContent()).toBe(longCellText)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
await expectExcelLayout(excel)
|
||||
await expect.poll(() => formula.evaluate((node) => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
return range.getClientRects().length
|
||||
})).toBe(1)
|
||||
expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true)
|
||||
expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await formula.hover()
|
||||
await page.mouse.wheel(400, 0)
|
||||
await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0)
|
||||
await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`)
|
||||
await layout.evaluate((node) => { node.textContent = '' })
|
||||
await expectExcelLayout(excel)
|
||||
}
|
||||
await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`)
|
||||
}
|
||||
}
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
await originalCanvas.dispose()
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
/** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */
|
||||
async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise<number> {
|
||||
return await excel.evaluate((node, direction) => {
|
||||
const canvas = node.querySelector('canvas')!
|
||||
const bounds = canvas.getBoundingClientRect()
|
||||
const scale = canvas.width / bounds.width
|
||||
const columnHeader = node.querySelector<HTMLElement>('.fortune-col-header')!
|
||||
const rowHeader = node.querySelector<HTMLElement>('.fortune-row-header')!
|
||||
const columnHandle = node.querySelector<HTMLElement>('.fortune-cols-freeze-handle')!
|
||||
const rowHandle = node.querySelector<HTMLElement>('.fortune-rows-freeze-handle')!
|
||||
// Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements.
|
||||
const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft
|
||||
const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2
|
||||
const context = canvas.getContext('2d')!
|
||||
let ink = 0
|
||||
for (let offset = -Math.round(scale); offset < 0; offset += 1) {
|
||||
const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale)
|
||||
const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale)
|
||||
const pixel = context.getImageData(px, py, 1, 1).data
|
||||
ink += 255 - pixel[0]!
|
||||
}
|
||||
return ink / scale
|
||||
}, axis)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => {
|
||||
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' })
|
||||
const consoleErrors = watchConsole(page)
|
||||
onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await page.locator('[data-composer-input]').first().fill(PROMPT)
|
||||
await page.locator('[data-composer-input]').first().press('Enter')
|
||||
const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd
|
||||
if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
|
||||
await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture())
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click()
|
||||
const excel = column.locator('[data-excel-preview]')
|
||||
const selectTopLeft = async () => {
|
||||
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } })
|
||||
return await excel.locator('.fortune-fx-input').innerText()
|
||||
}
|
||||
for (const sheet of ['Both', 'Rows', 'Columns', 'None']) {
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) {
|
||||
expect(await excel.locator(handle).isVisible()).toBe(false)
|
||||
expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0)
|
||||
}
|
||||
for (const ratio of ['100%', '150%']) {
|
||||
await excel.locator('.fortune-zoom-ratio-current').click()
|
||||
await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click()
|
||||
await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio)
|
||||
await excel.locator('.fortune-fx-input').click()
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) {
|
||||
await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0)
|
||||
}
|
||||
}
|
||||
await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 })
|
||||
await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 })
|
||||
await expect.poll(async () => {
|
||||
const value = await selectTopLeft()
|
||||
const match = /^R(\d+)C(\d+)$/.exec(value)
|
||||
return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' }
|
||||
}).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' })
|
||||
await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`)
|
||||
}
|
||||
await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click()
|
||||
await expect.poll(selectTopLeft).toBe('R1C1')
|
||||
const canvas = await excel.locator('canvas').first().elementHandle()
|
||||
const selection = await excel.locator('.fortune-name-box').innerText()
|
||||
const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' })
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
await page.emulateMedia({ colorScheme })
|
||||
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
|
||||
await expectExcelLayout(excel)
|
||||
await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`)
|
||||
}
|
||||
await layout.evaluate(node => node.parentNode!.removeChild(node))
|
||||
await expectExcelLayout(excel)
|
||||
expect(await canvas!.evaluate(node => node.isConnected)).toBe(true)
|
||||
expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection)
|
||||
await canvas!.dispose()
|
||||
expect(consoleErrors.pageErrors).toEqual([])
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8
|
||||
README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8
|
||||
README.md: 7354c47c7d7385085156424bdef63a7ab6c2e566
|
||||
README.zh.md: d6985c7d8ab3650ad0c7410970ad08070c48f65b
|
||||
@@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin
|
||||
<a id="excel-preview"></a>
|
||||
## Excel preview
|
||||
|
||||
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service.
|
||||
Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs and zoom controls remain visible; selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service.
|
||||
|
||||
XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate.
|
||||
|
||||
@@ -82,7 +82,7 @@ Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits c
|
||||
|
||||
The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ExcelJS decodes entity spellings in cached XLSX string formula results again; a saved literal `<` is displayed as `<`.
|
||||
|
||||
The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency.
|
||||
The read-only formula bar displays formulas and cell text literally on one line, with horizontal scrolling for long content. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency.
|
||||
|
||||
The pinned [ExcelJS patch](../../../patches/[email protected]) resolves the workbook, styles, shared strings, worksheets, comments, Tables, and VML through package relationships, including absolute and relative targets and ASCII case-equivalent part names, and recognizes SpreadsheetML and VML names by namespace URI. Strict OOXML SpreadsheetML and relationship URIs map to the same supported preview features; this is not full Strict conformance. XML parts accept UTF-8 and either byte order of UTF-16; CDATA contributes literal text. Drawing and conditional-format notices follow relationships regardless of part directories. Unreferenced `xl/drawings/*.xml` parts and their relationship files are also omitted, without adding notices; comment VML remains. Missing referenced parts and ambiguous case-equivalent ZIP entries fail the preview. Comments and Table metadata survive parsing but have no dedicated preview controls. The patch covers the Node sources and `dist/exceljs.js`; its browser entry selects that patched bundle. Dependency upgrades must preserve both entry paths and pass the [independent-writer regressions and fuzz diagnostics](tests/fuzz/README.md).
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。保留工作表标签和缩放控件,隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。
|
||||
|
||||
XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
|
||||
|
||||
@@ -82,7 +82,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别
|
||||
|
||||
惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。ExcelJS 会再次解码 XLSX 字符串公式缓存结果中的实体写法;保存的字面文本 `<` 会显示为 `<`。
|
||||
|
||||
只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
|
||||
只读公式栏以单行按字面显示公式和单元格文本,长内容可横向滚动。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。
|
||||
|
||||
固定版本的 [ExcelJS 补丁](../../../patches/[email protected])通过包内关系解析工作簿、样式、共享字符串、工作表、批注、Table 和 VML,支持绝对及相对目标,以及 ASCII 大小写等价的部件名,并按命名空间 URI 识别 SpreadsheetML 和 VML 名称。Strict OOXML 的 SpreadsheetML 和关系 URI 映射到相同的已支持预览功能;这不代表完整支持 Strict 标准。XML 部件支持 UTF-8 及两种字节序的 UTF-16;CDATA 按字面文本读取。绘图和条件格式提示按关系查找内容,不依赖部件目录。未被引用的 `xl/drawings/*.xml` 部件及其关系文件也会被移除,但不会产生提示;批注 VML 保持不变。缺失引用部件或存在大小写等价的歧义 ZIP 条目会使预览失败。解析保留批注和 Table 元数据,但预览器没有专门展示它们的控件。补丁同时覆盖 Node 源码和 `dist/exceljs.js`,浏览器入口选择已修补的 bundle。升级依赖时必须保留两个入口的行为,并通过[独立写入器回归测试和 fuzz 诊断](tests/fuzz/README.zh.md)。
|
||||
|
||||
|
||||
@@ -16,6 +16,34 @@
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* Hide editing handles and redundant controls within the read-only preview. */
|
||||
.workbook :global(.fortune-stat-area),
|
||||
.workbook :global(.luckysheet-sheets-item-function),
|
||||
.workbook :global(.fortune-cols-freeze-handle),
|
||||
.workbook :global(.fortune-rows-freeze-handle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbook :global(.luckysheet-sheet-area div.luckysheet-sheets-item) {
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.workbook :global(.fortune-sheettab-container) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.workbook :global(.fortune-fx-input-container) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workbook :global(.fortune-fx-input) {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.notice {
|
||||
display: flex;
|
||||
flex: none;
|
||||
|
||||
@@ -95,3 +95,17 @@ export async function meetingMinutesFixture(): Promise<Uint8Array<ArrayBuffer>>
|
||||
workbook.addWorksheet('填写说明')
|
||||
return new Uint8Array(await workbook.xlsx.writeBuffer())
|
||||
}
|
||||
|
||||
/** Build scrollable sheets with each combination of frozen rows and columns. */
|
||||
export async function excelFreezeFixture(): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
for (const [name, xSplit, ySplit] of [['Both', 1, 2], ['Rows', 0, 2], ['Columns', 1, 0], ['None', 0, 0]] as const) {
|
||||
const sheet = workbook.addWorksheet(name, { views: xSplit || ySplit ? [{ state: 'frozen', xSplit, ySplit }] : [] })
|
||||
sheet.columns = Array.from({ length: 12 }, () => ({ width: 16 }))
|
||||
for (let row = 1; row <= 80; row += 1) {
|
||||
sheet.getRow(row).height = 24
|
||||
sheet.getRow(row).values = Array.from({ length: 12 }, (_, column) => `R${row}C${column + 1}`)
|
||||
}
|
||||
}
|
||||
return new Uint8Array(await workbook.xlsx.writeBuffer())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/index.esm.js b/dist/index.esm.js
|
||||
index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3af3bfadc 100644
|
||||
index 5c9820b1a261305d500e69eeaf350821872beaf4..c0e1bf786c0333fa56edc007c3fd700d834fa40f 100644
|
||||
--- a/dist/index.esm.js
|
||||
+++ b/dist/index.esm.js
|
||||
@@ -62866,7 +62866,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) {
|
||||
@@ -11,8 +11,16 @@ index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3
|
||||
} else {
|
||||
var _style = "";
|
||||
if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) {
|
||||
@@ -73869,6 +73869,7 @@ var Canvas = /*#__PURE__*/function () {
|
||||
}, {
|
||||
key: "drawFreezeLine",
|
||||
value: function drawFreezeLine(_ref2) {
|
||||
+ if (this.sheetCtx.allowEdit === false) return;
|
||||
var horizontalTop = _ref2.horizontalTop,
|
||||
verticalLeft = _ref2.verticalLeft;
|
||||
var renderCtx = this.canvasElement.getContext("2d");
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02546b0b5b 100644
|
||||
index 04113a18dee9feee28e9434ee3023626c800e7d3..8e68fb2436e7662e3c26ba0308e70b7be1567767 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -62876,7 +62876,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) {
|
||||
@@ -24,3 +32,11 @@ index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02
|
||||
} else {
|
||||
var _style = "";
|
||||
if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) {
|
||||
@@ -73879,6 +73879,7 @@ var Canvas = /*#__PURE__*/function () {
|
||||
}, {
|
||||
key: "drawFreezeLine",
|
||||
value: function drawFreezeLine(_ref2) {
|
||||
+ if (this.sheetCtx.allowEdit === false) return;
|
||||
var horizontalTop = _ref2.horizontalTop,
|
||||
verticalLeft = _ref2.verticalLeft;
|
||||
var renderCtx = this.canvasElement.getContext("2d");
|
||||
Generated
+4
-4
@@ -12,7 +12,7 @@ overrides:
|
||||
patchedDependencies:
|
||||
'@earendil-works/[email protected]': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f
|
||||
'@electron/[email protected]': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
|
||||
'@fortune-sheet/[email protected]': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0
|
||||
'@fortune-sheet/[email protected]': 7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24
|
||||
'@fortune-sheet/[email protected]': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04
|
||||
'@yao-pkg/[email protected]': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
|
||||
[email protected]: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
|
||||
@@ -5039,7 +5039,7 @@ importers:
|
||||
version: link:../../util/workspace-path
|
||||
'@fortune-sheet/core':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)
|
||||
version: 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24)
|
||||
'@fortune-sheet/react':
|
||||
specifier: 1.0.4
|
||||
version: 1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)([email protected]([email protected]))([email protected])
|
||||
@@ -22472,7 +22472,7 @@ snapshots:
|
||||
bessel: 1.0.2
|
||||
jstat: 1.9.6
|
||||
|
||||
'@fortune-sheet/[email protected](patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)':
|
||||
'@fortune-sheet/[email protected](patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24)':
|
||||
dependencies:
|
||||
'@fortune-sheet/formula-parser': 0.2.13
|
||||
dayjs: 1.11.21
|
||||
@@ -22488,7 +22488,7 @@ snapshots:
|
||||
|
||||
'@fortune-sheet/[email protected](patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
'@fortune-sheet/core': 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)
|
||||
'@fortune-sheet/core': 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24)
|
||||
'@types/regenerator-runtime': 0.13.8
|
||||
immer: 9.0.21
|
||||
lodash: 4.18.1
|
||||
|
||||
Reference in new issue
Block a user