fix(web): synchronize read-only spreadsheet scroll state

This commit is contained in:
lsdsjy committed 2026-09-23 22:53:08 +08:00
1 parent 651f419bf8
commit b9fb1cceea
6 files changed
+94 -10

No files matched your search

@@ -24,8 +24,8 @@
en: 7009d93302da168f
zh: b31d78c144c0fc57
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview:
en: d21b1c3d95ba48e4
zh: 501fc3b5e04ee9cd
en: 7afc31581bc2790a
zh: 9511efaa96e0f749
/deepseek-ai-dsh-client-ui-sidebar-documentpreview/office-preview:
en: a6eccd94913000de
zh: 1ede588c7083f2a3
@@ -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 fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs start at the left edge; overflowing tabs scroll with horizontal trackpad gestures or left/right navigation beside a separate zoom control. Spreadsheet controls retain dark text on their light background in both app themes. Pixel-based trackpad gestures pan the grid on both axes together, including diagonally, at the gesture’s speed; frozen headings stay fixed. 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.
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 start at the left edge; overflowing tabs scroll with horizontal trackpad gestures or left/right navigation beside a separate zoom control. Spreadsheet controls retain dark text on their light background in both app themes. Pixel-based trackpad gestures pan the grid on both axes together, including diagonally, at the gesture’s speed; frozen headings stay fixed. Reversing direction at a sheet edge retains the next gesture’s movement. 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.
@@ -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 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
/** Read-only spreadsheet panning keeps the workbook context equal to the clamped scrollbar offsets. */
import { expect, it } from 'vitest'
import { defaultContext, handleGlobalWheel, type Context, type GlobalCache } from '@fortune-sheet/core'
/**
* Model one browser scroll container: jsdom stores out-of-range offsets verbatim, while a browser
* clamps them to the scrollable range that starts at zero.
* @returns a scroll container whose offsets clamp on assignment.
*/
function scrollbar(): HTMLDivElement {
const element = document.createElement('div')
let left = 0
let top = 0
Object.defineProperty(element, 'scrollLeft', {
get: () => left,
set: (value: number) => { left = Math.max(0, value) },
})
Object.defineProperty(element, 'scrollTop', {
get: () => top,
set: (value: number) => { top = Math.max(0, value) },
})
return element
}
/**
* @returns read-only workbook state scrolled to a non-zero offset.
*/
function readOnlyContext(): Context {
const context = defaultContext({
globalCache: { undoList: [], redoList: [] },
cellInput: { current: null }, fxInput: { current: null }, canvas: { current: null },
cellArea: { current: null }, workbookContainer: { current: null },
})
context.allowEdit = false
context.scrollLeft = 240
context.scrollTop = 240
return context
}
/**
* @param deltaX - horizontal pixel delta of the gesture.
* @param deltaY - vertical pixel delta of the gesture.
* @returns the trackpad wheel event the browser delivers for that gesture.
*/
function wheel(deltaX: number, deltaY: number): WheelEvent {
return new WheelEvent('wheel', { deltaX, deltaY, deltaMode: 0, cancelable: true })
}
it('leaves the workbook context on the clamped scrollbar offsets after read-only panning', () => {
const context = readOnlyContext()
const cache: GlobalCache = { undoList: [], redoList: [] }
const scrollbarX = scrollbar()
const scrollbarY = scrollbar()
scrollbarX.scrollLeft = 240
scrollbarY.scrollTop = 240
handleGlobalWheel(context, wheel(0, -10000), cache, scrollbarX, scrollbarY)
expect(scrollbarY.scrollTop).toBe(0)
expect(context.scrollTop).toBe(0)
handleGlobalWheel(context, wheel(-10000, 0), cache, scrollbarX, scrollbarY)
expect(scrollbarX.scrollLeft).toBe(0)
expect(context.scrollLeft).toBe(0)
})
it('keeps a reversed gesture after the read-only scrollbar clamps at the leading edge', () => {
const context = readOnlyContext()
const cache: GlobalCache = { undoList: [], redoList: [] }
const scrollbarX = scrollbar()
const scrollbarY = scrollbar()
scrollbarX.scrollLeft = 240
scrollbarY.scrollTop = 240
handleGlobalWheel(context, wheel(-10000, -10000), cache, scrollbarX, scrollbarY)
handleGlobalWheel(context, wheel(20, 20), cache, scrollbarX, scrollbarY)
expect(scrollbarX.scrollLeft).toBe(20)
expect(scrollbarY.scrollTop).toBe(20)
expect(context.scrollLeft).toBe(20)
expect(context.scrollTop).toBe(20)
})
+8 -2
View File
@@ -19,7 +19,7 @@ index 5c9820b1a261305d500e69eeaf350821872beaf4..819c6d2e3ffa422b1d1bf163bf56df13
var horizontalTop = _ref2.horizontalTop,
verticalLeft = _ref2.verticalLeft;
var renderCtx = this.canvasElement.getContext("2d");
@@ -77244,6 +77245,13 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) {
@@ -77244,6 +77245,16 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) {
var _cache$searchDialog;
if (((_cache$searchDialog = cache.searchDialog) === null || _cache$searchDialog === void 0 ? void 0 : _cache$searchDialog.mouseEnter) && ctx.showSearch && ctx.showReplace) return;
if (ctx.filterContextMenu != null) return;
@@ -27,6 +27,9 @@ index 5c9820b1a261305d500e69eeaf350821872beaf4..819c6d2e3ffa422b1d1bf163bf56df13
+ if (ctx.allowEdit === false && e.deltaMode === 0) {
+ scrollbarX.scrollLeft += e.deltaX;
+ scrollbarY.scrollTop += e.deltaY;
+ // Keep React scrollbar effects aligned with the browser-clamped offsets.
+ ctx.scrollLeft = scrollbarX.scrollLeft;
+ ctx.scrollTop = scrollbarY.scrollTop;
+ e.preventDefault();
+ return;
+ }
@@ -54,7 +57,7 @@ index 04113a18dee9feee28e9434ee3023626c800e7d3..29e35f51fbf1c3bb16cca54ce2bda81b
var horizontalTop = _ref2.horizontalTop,
verticalLeft = _ref2.verticalLeft;
var renderCtx = this.canvasElement.getContext("2d");
@@ -77254,6 +77255,13 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) {
@@ -77254,6 +77255,16 @@ function handleGlobalWheel(ctx, e, cache, scrollbarX, scrollbarY) {
var _cache$searchDialog;
if (((_cache$searchDialog = cache.searchDialog) === null || _cache$searchDialog === void 0 ? void 0 : _cache$searchDialog.mouseEnter) && ctx.showSearch && ctx.showReplace) return;
if (ctx.filterContextMenu != null) return;
@@ -62,6 +65,9 @@ index 04113a18dee9feee28e9434ee3023626c800e7d3..29e35f51fbf1c3bb16cca54ce2bda81b
+ if (ctx.allowEdit === false && e.deltaMode === 0) {
+ scrollbarX.scrollLeft += e.deltaX;
+ scrollbarY.scrollTop += e.deltaY;
+ // Keep React scrollbar effects aligned with the browser-clamped offsets.
+ ctx.scrollLeft = scrollbarX.scrollLeft;
+ ctx.scrollTop = scrollbarY.scrollTop;
+ e.preventDefault();
+ return;
+ }
+4 -4
View File
@@ -12,7 +12,7 @@ overrides:
patchedDependencies:
'@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f
'@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861
'@fortune-sheet/core@1.0.4': 886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68
'@fortune-sheet/core@1.0.4': 8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532
'@fortune-sheet/react@1.0.4': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37
'@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7
exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48
@@ -5078,7 +5078,7 @@ importers:
version: link:../../util/workspace-path
'@fortune-sheet/core':
specifier: 1.0.4
version: 1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68)
version: 1.0.4(patch_hash=8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532)
'@fortune-sheet/react':
specifier: 1.0.4
version: 1.0.4(patch_hash=2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -22625,7 +22625,7 @@ snapshots:
bessel: 1.0.2
jstat: 1.9.6
'@fortune-sheet/core@1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68)':
'@fortune-sheet/core@1.0.4(patch_hash=8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532)':
dependencies:
'@fortune-sheet/formula-parser': 0.2.13
dayjs: 1.11.21
@@ -22641,7 +22641,7 @@ snapshots:
'@fortune-sheet/react@1.0.4(patch_hash=2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@fortune-sheet/core': 1.0.4(patch_hash=886223a70a0cf76d4fa174608852763fe76bbdc9374db127665024c8c150dd68)
'@fortune-sheet/core': 1.0.4(patch_hash=8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532)
'@types/regenerator-runtime': 0.13.8
immer: 9.0.21
lodash: 4.18.1