mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -185,7 +185,7 @@ export function Document({
|
||||
? 'tokenCount'
|
||||
: activeSort?.column === 'status'
|
||||
? 'enabled'
|
||||
: activeSort
|
||||
: activeSort?.column === 'index'
|
||||
? 'chunkIndex'
|
||||
: undefined,
|
||||
activeSort?.direction
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user