fix(viewer): image pan/zoom, sort fixes, sidebar dot fixes (#3836)

* feat(file-viewer): add pan and zoom to image preview

* fix(viewer): fix sort key mapping, disable load-more on sort, hide status dots when menu open

* fix(file-viewer): prevent scroll bleed and zoom button micro-pans

* fix(file-viewer): use exponential zoom formula to prevent zero/negative multiplier
This commit is contained in:
Waleed
2026-03-29 12:40:29 -07:00
committed by GitHub
parent b3713642b2
commit 336c065234
4 changed files with 118 additions and 13 deletions

View File

@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { ZoomIn, ZoomOut } from 'lucide-react'
import { Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -432,17 +433,120 @@ const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFil
)
})
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
const ZOOM_WHEEL_SENSITIVITY = 0.005
const ZOOM_BUTTON_FACTOR = 1.2
const clampZoom = (z: number) => Math.min(Math.max(z, ZOOM_MIN), ZOOM_MAX)
const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
const [zoom, setZoom] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const isDragging = useRef(false)
const dragStart = useRef({ x: 0, y: 0 })
const offsetAtDragStart = useRef({ x: 0, y: 0 })
const offsetRef = useRef(offset)
offsetRef.current = offset
const containerRef = useRef<HTMLDivElement>(null)
const zoomIn = useCallback(() => setZoom((z) => clampZoom(z * ZOOM_BUTTON_FACTOR)), [])
const zoomOut = useCallback(() => setZoom((z) => clampZoom(z / ZOOM_BUTTON_FACTOR)), [])
useEffect(() => {
const el = containerRef.current
if (!el) return
const onWheel = (e: WheelEvent) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
setZoom((z) => clampZoom(z * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
} else {
setOffset((o) => ({ x: o.x - e.deltaX, y: o.y - e.deltaY }))
}
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return
isDragging.current = true
dragStart.current = { x: e.clientX, y: e.clientY }
offsetAtDragStart.current = offsetRef.current
if (containerRef.current) containerRef.current.style.cursor = 'grabbing'
e.preventDefault()
}, [])
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging.current) return
setOffset({
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
})
}, [])
const handleMouseUp = useCallback(() => {
isDragging.current = false
if (containerRef.current) containerRef.current.style.cursor = 'grab'
}, [])
useEffect(() => {
setZoom(1)
setOffset({ x: 0, y: 0 })
}, [file.key])
return (
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full rounded-md object-contain'
loading='eager'
/>
<div
ref={containerRef}
className='relative flex flex-1 cursor-grab overflow-hidden bg-[var(--surface-1)]'
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className='pointer-events-none absolute inset-0 flex items-center justify-center'
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
transformOrigin: 'center center',
}}
>
<img
src={serveUrl}
alt={file.name}
className='max-h-full max-w-full select-none rounded-md object-contain'
draggable={false}
loading='eager'
/>
</div>
<div
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-sm'
onMouseDown={(e) => e.stopPropagation()}
>
<button
type='button'
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom out'
>
<ZoomOut className='h-3.5 w-3.5' />
</button>
<span className='min-w-[3rem] text-center text-[11px] text-[var(--text-secondary)]'>
{Math.round(zoom * 100)}%
</span>
<button
type='button'
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className='flex h-6 w-6 items-center justify-center rounded text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-40'
aria-label='Zoom in'
>
<ZoomIn className='h-3.5 w-3.5' />
</button>
</div>
</div>
)
})

View File

@@ -185,7 +185,7 @@ export function Document({
? 'tokenCount'
: activeSort?.column === 'status'
? 'enabled'
: activeSort
: activeSort?.column === 'index'
? 'chunkIndex'
: undefined,
activeSort?.direction

View File

@@ -640,11 +640,12 @@ export default function Logs() {
}, [initializeFromURL])
const loadMoreLogs = useCallback(() => {
if (activeSort) return
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
if (!isFetching && hasNextPage) {
fetchNextPage()
}
}, [])
}, [activeSort])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -1144,7 +1145,7 @@ export default function Logs() {
onRowContextMenu={handleLogContextMenu}
isLoading={!logsQuery.data}
onLoadMore={loadMoreLogs}
hasMore={logsQuery.hasNextPage ?? false}
hasMore={!activeSort && (logsQuery.hasNextPage ?? false)}
isLoadingMore={logsQuery.isFetchingNextPage}
emptyMessage='No logs found'
overlay={sidebarOverlay}

View File

@@ -183,13 +183,13 @@ const SidebarTaskItem = memo(function SidebarTaskItem({
<div className='min-w-0 flex-1 truncate font-base text-[var(--text-body)]'>{task.name}</div>
{task.id !== 'new' && (
<div className='relative flex h-[18px] w-[18px] flex-shrink-0 items-center justify-center'>
{isActive && !isCurrentRoute && (
{isActive && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] animate-ping rounded-full bg-amber-400 opacity-30 group-hover:hidden' />
)}
{isActive && !isCurrentRoute && (
{isActive && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-amber-400 group-hover:hidden' />
)}
{!isActive && isUnread && !isCurrentRoute && (
{!isActive && isUnread && !isCurrentRoute && !isMenuOpen && (
<span className='absolute h-[7px] w-[7px] rounded-full bg-[var(--brand-accent)] group-hover:hidden' />
)}
<button