Skip to content

Commit b019698

Browse files
committed
fix(files): let a mouse wheel scroll CSV and XLSX previews horizontally
The zoomable previews (docx, pdf, pptx, image) bind `bindPreviewWheelZoom`, whose horizontal branch maps a trackpad's `deltaX` — and Shift+`deltaY` on a plain mouse — onto the container's `scrollLeft`. The tabular previews never bound anything, which did not matter while their table fitted the frame. Now that it is wider, a mouse whose wheel reports only `deltaY` can reach the overflow solely by dragging the scrollbar; hovering the table and scrolling does nothing sideways. Extract that horizontal branch into `bindPreviewHorizontalWheel`, sharing the delta logic with the zooming variant rather than duplicating it, and bind it in all three tabular preview containers through a `useHorizontalWheelScroll` ref callback. The new binder deliberately ignores ctrl/cmd+wheel so browser page zoom still works over a table, since these previews have no zoom of their own.
1 parent 1dd85eb commit b019698

6 files changed

Lines changed: 168 additions & 8 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
66
import { useCsvTruncationImport } from './csv-import'
77
import { DataTable } from './data-table'
88
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
9+
import { useHorizontalWheelScroll } from './use-horizontal-wheel-scroll'
910

1011
/**
1112
* Read-only preview for a CSV that is too large to load fully into the editor. Streams only the
@@ -19,6 +20,7 @@ export const CsvTablePreview = memo(function CsvTablePreview({
1920
file: WorkspaceFileRecord
2021
workspaceId: string
2122
}) {
23+
const scrollRef = useHorizontalWheelScroll()
2224
const version = Number(new Date(file.updatedAt)) || file.size
2325
const {
2426
data,
@@ -42,7 +44,7 @@ export const CsvTablePreview = memo(function CsvTablePreview({
4244
}
4345

4446
return (
45-
<div className='flex flex-1 flex-col overflow-auto p-6'>
47+
<div ref={scrollRef} className='flex flex-1 flex-col overflow-auto p-6'>
4648
<DataTable headers={data.headers} rows={data.rows} />
4749
</div>
4850
)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getFileExtension } from '@/lib/uploads/utils/file-utils'
77
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
88
import { DataTable } from './data-table'
99
import { MermaidDiagram } from './mermaid-diagram'
10+
import { useHorizontalWheelScroll } from './use-horizontal-wheel-scroll'
1011
import { ZoomablePreview } from './zoomable-preview'
1112

1213
type PreviewType = 'markdown' | 'html' | 'csv' | 'svg' | 'mermaid' | null
@@ -264,6 +265,7 @@ const CsvPreview = memo(function CsvPreview({
264265
file: CsvImportFileDescriptor
265266
readOnly?: boolean
266267
}) {
268+
const scrollRef = useHorizontalWheelScroll()
267269
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
268270
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
269271

@@ -276,7 +278,7 @@ const CsvPreview = memo(function CsvPreview({
276278
}
277279

278280
return (
279-
<div className='min-h-0 flex-1 overflow-auto p-6'>
281+
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto p-6'>
280282
<DataTable headers={headers} rows={rows} />
281283
</div>
282284
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* A mouse whose wheel reports only `deltaY` has no native gesture for reaching a preview
5+
* table's horizontal overflow short of dragging the scrollbar, so the tabular previews bind
6+
* `bindPreviewHorizontalWheel`. It must move the container on a horizontal gesture, stay out
7+
* of the way otherwise, and — unlike the zooming variant — leave ctrl/cmd+wheel to the browser
8+
* so page zoom still works over a table.
9+
*/
10+
import { beforeEach, describe, expect, it } from 'vitest'
11+
import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
12+
13+
/** jsdom does no layout, so scrollWidth/clientWidth are stubbed to model an overflowing container. */
14+
function makeContainer({ scrollWidth = 2000, clientWidth = 1000 } = {}): HTMLElement {
15+
const el = document.createElement('div')
16+
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true })
17+
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true })
18+
el.scrollLeft = 0
19+
document.body.appendChild(el)
20+
return el
21+
}
22+
23+
function wheel(el: HTMLElement, init: WheelEventInit): WheelEvent {
24+
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })
25+
el.dispatchEvent(event)
26+
return event
27+
}
28+
29+
describe('bindPreviewHorizontalWheel', () => {
30+
let container: HTMLElement
31+
let unbind: () => void
32+
33+
beforeEach(() => {
34+
document.body.innerHTML = ''
35+
container = makeContainer()
36+
unbind = bindPreviewHorizontalWheel(container)
37+
})
38+
39+
it("scrolls by a trackpad's horizontal delta", () => {
40+
const event = wheel(container, { deltaX: 120, deltaY: 0 })
41+
42+
expect(container.scrollLeft).toBe(120)
43+
expect(event.defaultPrevented).toBe(true)
44+
})
45+
46+
it('maps shift+wheel to horizontal for a vertical-only mouse', () => {
47+
const event = wheel(container, { deltaX: 0, deltaY: 120, shiftKey: true })
48+
49+
expect(container.scrollLeft).toBe(120)
50+
expect(event.defaultPrevented).toBe(true)
51+
})
52+
53+
it('leaves a plain vertical wheel alone so the container still scrolls down', () => {
54+
const event = wheel(container, { deltaX: 0, deltaY: 120 })
55+
56+
expect(container.scrollLeft).toBe(0)
57+
expect(event.defaultPrevented).toBe(false)
58+
})
59+
60+
/** Zoom is the browser's here — the tabular previews have no zoom of their own. */
61+
it.each([
62+
['ctrl', { ctrlKey: true }],
63+
['cmd', { metaKey: true }],
64+
])('leaves %s+wheel to the browser', (_label, modifier) => {
65+
const event = wheel(container, { deltaX: 120, deltaY: 0, ...modifier })
66+
67+
expect(container.scrollLeft).toBe(0)
68+
expect(event.defaultPrevented).toBe(false)
69+
})
70+
71+
it('does nothing when the container has no horizontal overflow', () => {
72+
const fitted = makeContainer({ scrollWidth: 1000, clientWidth: 1000 })
73+
const unbindFitted = bindPreviewHorizontalWheel(fitted)
74+
75+
const event = wheel(fitted, { deltaX: 120, deltaY: 0 })
76+
77+
expect(fitted.scrollLeft).toBe(0)
78+
expect(event.defaultPrevented).toBe(false)
79+
unbindFitted()
80+
})
81+
82+
it('stops scrolling once unbound', () => {
83+
unbind()
84+
85+
wheel(container, { deltaX: 120, deltaY: 0 })
86+
87+
expect(container.scrollLeft).toBe(0)
88+
})
89+
90+
it('scrolls a child gesture, since the listener captures', () => {
91+
const cell = document.createElement('td')
92+
container.appendChild(cell)
93+
94+
wheel(cell, { deltaX: 80, deltaY: 0 })
95+
96+
expect(container.scrollLeft).toBe(80)
97+
})
98+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,45 @@ interface BindPreviewWheelZoomOptions {
88
onPan?: (event: WheelEvent) => void
99
}
1010

11+
/**
12+
* Horizontal component of a wheel gesture: a trackpad's own `deltaX`, or `deltaY`
13+
* while Shift is held — the only horizontal gesture available on a mouse whose
14+
* wheel reports `deltaY` alone.
15+
*/
16+
function horizontalDeltaOf(event: WheelEvent): number {
17+
return event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
18+
}
19+
20+
/**
21+
* Scroll `container` horizontally for a wheel gesture. No-op when the gesture carries
22+
* no horizontal component or the container has nothing to scroll, leaving the event to
23+
* scroll vertically as usual.
24+
*/
25+
function applyHorizontalWheel(container: HTMLElement, event: WheelEvent): void {
26+
const horizontalDelta = horizontalDeltaOf(event)
27+
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
28+
29+
event.preventDefault()
30+
container.scrollLeft += horizontalDelta
31+
}
32+
33+
/**
34+
* Bind horizontal wheel gestures for a preview scroll container that has no zoom of its
35+
* own — the tabular CSV/XLSX previews, whose table is wider than its frame. A mouse whose
36+
* wheel reports only `deltaY` otherwise has no way to reach that overflow short of dragging
37+
* the scrollbar. Unlike {@link bindPreviewWheelZoom} this leaves `ctrl`/`cmd`+wheel alone,
38+
* so browser page zoom still works over a table.
39+
*/
40+
export function bindPreviewHorizontalWheel(container: HTMLElement): () => void {
41+
const onWheel = (event: WheelEvent) => {
42+
if (event.ctrlKey || event.metaKey) return
43+
applyHorizontalWheel(container, event)
44+
}
45+
46+
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
47+
return () => container.removeEventListener('wheel', onWheel, { capture: true })
48+
}
49+
1150
/**
1251
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
1352
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
@@ -34,11 +73,7 @@ export function bindPreviewWheelZoom(
3473
return
3574
}
3675

37-
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
38-
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
39-
40-
event.preventDefault()
41-
container.scrollLeft += horizontalDelta
76+
applyHorizontalWheel(container, event)
4277
}
4378

4479
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
'use client'
2+
3+
import { useCallback, useRef } from 'react'
4+
import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
5+
6+
/**
7+
* Ref callback that gives a preview scroll container horizontal wheel scrolling.
8+
*
9+
* The tabular previews render a table wider than its frame, and a mouse whose wheel
10+
* reports only `deltaY` has no native way to reach the overflow short of dragging the
11+
* scrollbar. Binding is done through a ref callback rather than an effect so the
12+
* listener attaches with the node and detaches when React passes `null`.
13+
*/
14+
export function useHorizontalWheelScroll() {
15+
const unbindRef = useRef<(() => void) | null>(null)
16+
17+
return useCallback((node: HTMLDivElement | null) => {
18+
unbindRef.current?.()
19+
unbindRef.current = node ? bindPreviewHorizontalWheel(node) : null
20+
}, [])
21+
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1010
import { DataTable } from './data-table'
1111
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
1212
import { useDocPreviewBinary } from './use-doc-preview-binary'
13+
import { useHorizontalWheelScroll } from './use-horizontal-wheel-scroll'
1314

1415
const logger = createLogger('XlsxPreview')
1516

@@ -29,6 +30,7 @@ export const XlsxPreview = memo(function XlsxPreview({
2930
file: WorkspaceFileRecord
3031
workspaceId: string
3132
}) {
33+
const scrollRef = useHorizontalWheelScroll()
3234
const preview = useDocPreviewBinary(workspaceId, file)
3335
const fileData = preview.data
3436

@@ -130,7 +132,7 @@ export const XlsxPreview = memo(function XlsxPreview({
130132
))}
131133
</div>
132134
</div>
133-
<div className='flex-1 overflow-auto p-6'>
135+
<div ref={scrollRef} className='flex-1 overflow-auto p-6'>
134136
<DataTable headers={currentSheet.headers} rows={currentSheet.rows} />
135137
{currentSheet.truncated && (
136138
<p className='mt-3 text-center text-[12px] text-[var(--text-muted)]'>

0 commit comments

Comments
 (0)