)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
index 2b5b9c997d0..8fca660715d 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx
@@ -4,6 +4,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react'
import '@sim/emcn/components/code/code.css'
import { CSV_PREVIEW_MAX_ROWS } from '@/lib/api/contracts/workspace-file-table'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
+import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import'
import { DataTable } from './data-table'
import { MermaidDiagram } from './mermaid-diagram'
@@ -264,6 +265,7 @@ const CsvPreview = memo(function CsvPreview({
file: CsvImportFileDescriptor
readOnly?: boolean
}) {
+ const scrollRef = useHorizontalWheelScroll()
const { headers, rows, truncated } = useMemo(() => parseCsv(content), [content])
useCsvTruncationImport(workspaceId, file, truncated, readOnly)
@@ -276,7 +278,7 @@ const CsvPreview = memo(function CsvPreview({
}
return (
-
+
)
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.test.ts
new file mode 100644
index 00000000000..2d57af9d430
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment jsdom
+ *
+ * A mouse whose wheel reports only `deltaY` has no native gesture for reaching a preview
+ * table's horizontal overflow short of dragging the scrollbar, so the tabular previews bind
+ * `bindPreviewHorizontalWheel`. It must move the container on a horizontal gesture, stay out
+ * of the way otherwise, and — unlike the zooming variant — leave ctrl/cmd+wheel to the browser
+ * so page zoom still works over a table.
+ */
+import { beforeEach, describe, expect, it } from 'vitest'
+import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
+
+/** jsdom does no layout, so scrollWidth/clientWidth are stubbed to model an overflowing container. */
+function makeContainer({ scrollWidth = 2000, clientWidth = 1000 } = {}): HTMLElement {
+ const el = document.createElement('div')
+ Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true })
+ Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true })
+ el.scrollLeft = 0
+ document.body.appendChild(el)
+ return el
+}
+
+function wheel(el: HTMLElement, init: WheelEventInit): WheelEvent {
+ const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })
+ el.dispatchEvent(event)
+ return event
+}
+
+describe('bindPreviewHorizontalWheel', () => {
+ let container: HTMLElement
+ let unbind: () => void
+
+ beforeEach(() => {
+ document.body.innerHTML = ''
+ container = makeContainer()
+ unbind = bindPreviewHorizontalWheel(container)
+ })
+
+ it("scrolls by a trackpad's horizontal delta", () => {
+ const event = wheel(container, { deltaX: 120, deltaY: 0 })
+
+ expect(container.scrollLeft).toBe(120)
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ it('maps shift+wheel to horizontal for a vertical-only mouse', () => {
+ const event = wheel(container, { deltaX: 0, deltaY: 120, shiftKey: true })
+
+ expect(container.scrollLeft).toBe(120)
+ expect(event.defaultPrevented).toBe(true)
+ })
+
+ /**
+ * Cancelling a wheel event is all-or-nothing, so a diagonal trackpad pan must have its
+ * vertical movement re-applied by hand — otherwise `preventDefault` silently eats it.
+ */
+ it('keeps the vertical movement of a diagonal pan', () => {
+ Object.defineProperty(container, 'scrollHeight', { value: 5000, configurable: true })
+ Object.defineProperty(container, 'clientHeight', { value: 500, configurable: true })
+
+ wheel(container, { deltaX: 40, deltaY: 90 })
+
+ expect(container.scrollLeft).toBe(40)
+ expect(container.scrollTop).toBe(90)
+ })
+
+ it('does not also spend a shift gesture vertically', () => {
+ wheel(container, { deltaX: 0, deltaY: 120, shiftKey: true })
+
+ expect(container.scrollLeft).toBe(120)
+ expect(container.scrollTop).toBe(0)
+ })
+
+ /**
+ * `deltaMode` is not always pixels — Firefox reports mouse wheels in lines — while scroll
+ * offsets always are, so a three-line notch added raw would move the table three pixels.
+ */
+ it('converts a line-mode delta to pixels', () => {
+ wheel(container, { deltaX: 3, deltaY: 0, deltaMode: WheelEvent.DOM_DELTA_LINE })
+
+ expect(container.scrollLeft).toBe(48)
+ })
+
+ it('converts a page-mode delta to the container width', () => {
+ wheel(container, { deltaX: 1, deltaY: 0, deltaMode: WheelEvent.DOM_DELTA_PAGE })
+
+ expect(container.scrollLeft).toBe(1000)
+ })
+
+ it('leaves a plain vertical wheel alone so the container still scrolls down', () => {
+ const event = wheel(container, { deltaX: 0, deltaY: 120 })
+
+ expect(container.scrollLeft).toBe(0)
+ expect(event.defaultPrevented).toBe(false)
+ })
+
+ /** Zoom is the browser's here — the tabular previews have no zoom of their own. */
+ it.each([
+ ['ctrl', { ctrlKey: true }],
+ ['cmd', { metaKey: true }],
+ ])('leaves %s+wheel to the browser', (_label, modifier) => {
+ const event = wheel(container, { deltaX: 120, deltaY: 0, ...modifier })
+
+ expect(container.scrollLeft).toBe(0)
+ expect(event.defaultPrevented).toBe(false)
+ })
+
+ it('does nothing when the container has no horizontal overflow', () => {
+ const fitted = makeContainer({ scrollWidth: 1000, clientWidth: 1000 })
+ const unbindFitted = bindPreviewHorizontalWheel(fitted)
+
+ const event = wheel(fitted, { deltaX: 120, deltaY: 0 })
+
+ expect(fitted.scrollLeft).toBe(0)
+ expect(event.defaultPrevented).toBe(false)
+ unbindFitted()
+ })
+
+ it('stops scrolling once unbound', () => {
+ unbind()
+
+ wheel(container, { deltaX: 120, deltaY: 0 })
+
+ expect(container.scrollLeft).toBe(0)
+ })
+
+ it('scrolls a child gesture, since the listener captures', () => {
+ const cell = document.createElement('td')
+ container.appendChild(cell)
+
+ wheel(cell, { deltaX: 80, deltaY: 0 })
+
+ expect(container.scrollLeft).toBe(80)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts
index cdb44a2d86d..3535ccbabba 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts
@@ -8,6 +8,73 @@ interface BindPreviewWheelZoomOptions {
onPan?: (event: WheelEvent) => void
}
+/**
+ * Horizontal component of a wheel gesture: a trackpad's own `deltaX`, or `deltaY`
+ * while Shift is held — the only horizontal gesture available on a mouse whose
+ * wheel reports `deltaY` alone.
+ */
+function horizontalDeltaOf(event: WheelEvent): number {
+ return event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
+}
+
+/**
+ * Vertical component still owed to the container once the horizontal one is taken.
+ * Shift *remaps* `deltaY` onto the horizontal axis, so that gesture has no vertical
+ * component left to spend; an ordinary diagonal trackpad pan does.
+ */
+function verticalDeltaOf(event: WheelEvent): number {
+ return event.deltaX !== 0 ? event.deltaY : 0
+}
+
+/**
+ * Rough pixel height of one wheel "line". A wheel delta is only in pixels when `deltaMode`
+ * says so — Firefox reports mouse wheels in lines — while scroll offsets are always pixels,
+ * so a three-line notch added raw would move the table three pixels.
+ */
+const WHEEL_LINE_HEIGHT_PX = 16
+
+/** Convert a wheel delta to pixels. `pageSize` is the container extent along that axis. */
+function toPixels(delta: number, event: WheelEvent, pageSize: number): number {
+ if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * WHEEL_LINE_HEIGHT_PX
+ if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * pageSize
+ return delta
+}
+
+/**
+ * Scroll `container` for a wheel gesture carrying a horizontal component. No-op when the
+ * gesture is purely vertical or the container has nothing to scroll sideways, leaving the
+ * event to scroll natively.
+ *
+ * Cancelling a wheel event is all-or-nothing, so once `preventDefault` is called this owes
+ * the container *both* axes — a diagonal pan that only had its `deltaX` applied would lose
+ * its vertical movement entirely.
+ */
+function applyHorizontalWheel(container: HTMLElement, event: WheelEvent): void {
+ const horizontalDelta = horizontalDeltaOf(event)
+ if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
+
+ event.preventDefault()
+ container.scrollLeft += toPixels(horizontalDelta, event, container.clientWidth)
+ container.scrollTop += toPixels(verticalDeltaOf(event), event, container.clientHeight)
+}
+
+/**
+ * Bind horizontal wheel gestures for a preview scroll container that has no zoom of its
+ * own — the tabular CSV/XLSX previews, whose table is wider than its frame. A mouse whose
+ * wheel reports only `deltaY` otherwise has no way to reach that overflow short of dragging
+ * the scrollbar. Unlike {@link bindPreviewWheelZoom} this leaves `ctrl`/`cmd`+wheel alone,
+ * so browser page zoom still works over a table.
+ */
+export function bindPreviewHorizontalWheel(container: HTMLElement): () => void {
+ const onWheel = (event: WheelEvent) => {
+ if (event.ctrlKey || event.metaKey) return
+ applyHorizontalWheel(container, event)
+ }
+
+ container.addEventListener('wheel', onWheel, { capture: true, passive: false })
+ return () => container.removeEventListener('wheel', onWheel, { capture: true })
+}
+
/**
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
@@ -34,11 +101,7 @@ export function bindPreviewWheelZoom(
return
}
- const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
- if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
-
- event.preventDefault()
- container.scrollLeft += horizontalDelta
+ applyHorizontalWheel(container, event)
}
container.addEventListener('wheel', onWheel, { capture: true, passive: false })
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts
new file mode 100644
index 00000000000..d5d941d26c0
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts
@@ -0,0 +1,21 @@
+'use client'
+
+import { useCallback, useRef } from 'react'
+import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
+
+/**
+ * Ref callback that gives a preview scroll container horizontal wheel scrolling.
+ *
+ * The tabular previews render a table wider than its frame, and a mouse whose wheel
+ * reports only `deltaY` has no native way to reach the overflow short of dragging the
+ * scrollbar. Binding is done through a ref callback rather than an effect so the
+ * listener attaches with the node and detaches when React passes `null`.
+ */
+export function useHorizontalWheelScroll() {
+ const unbindRef = useRef<(() => void) | null>(null)
+
+ return useCallback((node: HTMLDivElement | null) => {
+ unbindRef.current?.()
+ unbindRef.current = node ? bindPreviewHorizontalWheel(node) : null
+ }, [])
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
index 98be34a560c..3fbc5993826 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx
@@ -7,6 +7,7 @@ import { toError } from '@sim/utils/errors'
import type { WorkBook } from 'xlsx'
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
+import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
import { useDocPreviewBinary } from './use-doc-preview-binary'
@@ -29,6 +30,7 @@ export const XlsxPreview = memo(function XlsxPreview({
file: WorkspaceFileRecord
workspaceId: string
}) {
+ const scrollRef = useHorizontalWheelScroll()
const preview = useDocPreviewBinary(workspaceId, file)
const fileData = preview.data
@@ -130,7 +132,7 @@ export const XlsxPreview = memo(function XlsxPreview({
))}