Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand All @@ -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>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* 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
Expand All @@ -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 })
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}, [])
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Expand Down Expand Up @@ -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)]'>
Expand Down
Loading