mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(files): let a mouse wheel scroll CSV and XLSX previews horizontally (#6563)
* 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. * fix(files): keep the vertical component of a diagonal wheel pan Cancelling a wheel event is all-or-nothing, so applying only `scrollLeft` after `preventDefault` dropped a diagonal trackpad pan's `deltaY` entirely. Apply the vertical component too, except under Shift, which remaps `deltaY` onto the horizontal axis and so has none left to spend. * fix(files): normalize wheel deltas to pixels and import the hook absolutely A wheel delta is only in pixels when `deltaMode` says so — Firefox reports mouse wheels in lines — while scroll offsets always are, so a three-line notch moved the table three pixels. Convert line and page deltas before applying them.
This commit is contained in:
+3
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
|
||||
import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll'
|
||||
import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table'
|
||||
import { useCsvTruncationImport } from './csv-import'
|
||||
import { DataTable } from './data-table'
|
||||
@@ -19,6 +20,7 @@ export const CsvTablePreview = memo(function CsvTablePreview({
|
||||
file: WorkspaceFileRecord
|
||||
workspaceId: string
|
||||
}) {
|
||||
const scrollRef = useHorizontalWheelScroll()
|
||||
const version = Number(new Date(file.updatedAt)) || file.size
|
||||
const {
|
||||
data,
|
||||
@@ -42,7 +44,7 @@ export const CsvTablePreview = memo(function CsvTablePreview({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 flex-col overflow-auto p-6'>
|
||||
<div ref={scrollRef} className='flex flex-1 flex-col overflow-auto p-6'>
|
||||
<DataTable headers={data.headers} rows={data.rows} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className='min-h-0 flex-1 overflow-auto p-6'>
|
||||
<div ref={scrollRef} className='min-h-0 flex-1 overflow-auto p-6'>
|
||||
<DataTable headers={headers} rows={rows} />
|
||||
</div>
|
||||
)
|
||||
|
||||
+135
@@ -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)
|
||||
})
|
||||
})
|
||||
+68
-5
@@ -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 })
|
||||
|
||||
+21
@@ -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
|
||||
}, [])
|
||||
}
|
||||
@@ -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({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-1 overflow-auto p-6'>
|
||||
<div ref={scrollRef} className='flex-1 overflow-auto p-6'>
|
||||
<DataTable headers={currentSheet.headers} rows={currentSheet.rows} />
|
||||
{currentSheet.truncated && (
|
||||
<p className='mt-3 text-center text-[12px] text-[var(--text-muted)]'>
|
||||
|
||||
Reference in New Issue
Block a user