mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 11:58:53 +00:00
♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates
use react-aria useMove for pan, zoom/pan refs for DOM updates,
This commit is contained in:
@@ -79,9 +79,6 @@ export const ScreenShareZoomControls = ({
|
||||
}, [containerRef])
|
||||
|
||||
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
||||
const wheelShortcutSR = t(
|
||||
isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut'
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -102,7 +99,6 @@ export const ScreenShareZoomControls = ({
|
||||
borderRadius: '2rem',
|
||||
padding: '0.5rem',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 200ms linear',
|
||||
_hover: {
|
||||
@@ -113,55 +109,71 @@ export const ScreenShareZoomControls = ({
|
||||
<span className={srOnly}>
|
||||
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||
</span>
|
||||
{isZoomed && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fitToWindow')}
|
||||
aria-label={t('fitToWindow')}
|
||||
onPress={onResetZoom}
|
||||
>
|
||||
<RiFullscreenExitLine size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomOutWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||
aria-label={t('zoomOutWithShortcut', { shortcut: wheelShortcutSR })}
|
||||
isDisabled={!canZoomOut}
|
||||
onPress={onZoomOut}
|
||||
>
|
||||
<RiZoomOutLine size={20} />
|
||||
</Button>
|
||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
minWidth: '3.25rem',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 0.25rem',
|
||||
})}
|
||||
>
|
||||
{zoomPercentage} %
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
||||
trick keeps overflow:hidden from clipping focus rings. */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
||||
padding: '3px',
|
||||
margin: '-3px',
|
||||
})}
|
||||
style={{
|
||||
maxWidth: isZoomed ? '12rem' : '0',
|
||||
opacity: isZoomed ? 1 : 0,
|
||||
}}
|
||||
aria-hidden={!isZoomed}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fitToWindow')}
|
||||
aria-label={t('fitToWindow')}
|
||||
isDisabled={!isZoomed}
|
||||
onPress={onResetZoom}
|
||||
>
|
||||
<RiFullscreenExitLine size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomOutWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||
aria-label={t('zoomOut')}
|
||||
isDisabled={!isZoomed || !canZoomOut}
|
||||
onPress={onZoomOut}
|
||||
>
|
||||
<RiZoomOutLine size={20} />
|
||||
</Button>
|
||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
minWidth: '3.25rem',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 0.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{zoomPercentage} %
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||
aria-label={t('zoomInWithShortcut', { shortcut: wheelShortcutSR })}
|
||||
aria-label={t('zoomIn')}
|
||||
isDisabled={!canZoomIn}
|
||||
onPress={onZoomIn}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,8 @@ export const ScreenShareZoomableVideo = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
// Single SR announcement per zoom change (buttons only expose the action label).
|
||||
// SR announcement: announce zoom level on change, with a one-time pan hint
|
||||
// on the first zoom above 100 % per session.
|
||||
const prevZoomRef = useRef(zoom.zoomPercentage)
|
||||
const hasAnnouncedPanHint = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -50,50 +51,35 @@ export const ScreenShareZoomableVideo = ({
|
||||
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||
}, [tileRef, zoom.handleKeyDown])
|
||||
|
||||
// Native wheel listener so we can use { passive: false } and preventDefault.
|
||||
// React onWheel is passive — Ctrl+scroll would zoom the whole browser page.
|
||||
const zoomSurfaceRef = useRef<HTMLDivElement>(null)
|
||||
// Native wheel listener with { passive: false } so preventDefault works.
|
||||
useEffect(() => {
|
||||
const el = zoomSurfaceRef.current
|
||||
const el = zoom.surfaceElRef.current
|
||||
if (!el) return
|
||||
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||
}, [zoom.handleWheel])
|
||||
|
||||
let panCursor: React.CSSProperties['cursor'] = 'default'
|
||||
if (zoom.isZoomed) {
|
||||
panCursor = zoom.isDragging ? 'grabbing' : 'grab'
|
||||
}
|
||||
}, [zoom.handleWheel, zoom.surfaceElRef])
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pan/zoom surface - Ctrl+wheel to zoom, drag when zoomed. */}
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
ref={zoomSurfaceRef}
|
||||
ref={zoom.surfaceElRef}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
})}
|
||||
style={{
|
||||
cursor: panCursor,
|
||||
}}
|
||||
onMouseDown={zoom.handlePanStart}
|
||||
onMouseMove={zoom.handlePanMove}
|
||||
onMouseUp={zoom.handlePanEnd}
|
||||
onMouseLeave={zoom.handlePanEnd}
|
||||
{...zoom.moveProps}
|
||||
>
|
||||
<div
|
||||
ref={zoom.transformElRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
transform: `scale(${zoom.zoomLevel}) translate(${zoom.panOffset.x}%, ${zoom.panOffset.y}%)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: zoom.isDragging ? 'none' : 'transform 150ms ease-out',
|
||||
}}
|
||||
>
|
||||
<ScreenShareVideoTrack
|
||||
|
||||
@@ -1,83 +1,117 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
||||
import { useMove } from 'react-aria'
|
||||
import type { MoveMoveEvent } from '@react-types/shared'
|
||||
import {
|
||||
MIN_ZOOM,
|
||||
PAN_STEP,
|
||||
WHEEL_ZOOM_SPEED,
|
||||
ZOOM_STEP,
|
||||
type PanOffset,
|
||||
type ZoomSnapshot,
|
||||
buildZoomSnapshot,
|
||||
clampPan,
|
||||
clampZoom,
|
||||
getCursorFromZoomState,
|
||||
getCursorPercentsFromWheelEvent,
|
||||
getPanDeltaPercentsFromMove,
|
||||
getWheelPanOffset,
|
||||
getZoomTransform,
|
||||
} from '../utils/screenShareZoom'
|
||||
|
||||
const MIN_ZOOM = 1
|
||||
const MAX_ZOOM = 4
|
||||
const ZOOM_STEP = 0.1
|
||||
const WHEEL_ZOOM_SPEED = 0.002
|
||||
const WHEEL_ZOOM_FOCUS_BLEND = 0.3
|
||||
const PAN_STEP = 5
|
||||
const PAN_CLAMP_HALF = 50
|
||||
/**
|
||||
* Manages zoom and pan state for a remote screen share.
|
||||
*
|
||||
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
||||
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
|
||||
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
|
||||
* when the toolbar UI needs to update (zoom level change, drag end).
|
||||
*
|
||||
* Drag/touch panning is handled by react-aria's useMove (moveProps).
|
||||
* Ctrl/Cmd + wheel zoom is a native listener (must be non-passive to
|
||||
* preventDefault and block browser page zoom).
|
||||
* Arrow key panning and +/-/0 zoom are on a keydown listener attached to the
|
||||
* tile container (which has tabIndex=0 and focus).
|
||||
*/
|
||||
export const useScreenShareZoom = () => {
|
||||
const zoomRef = useRef(MIN_ZOOM)
|
||||
const panRef = useRef<PanOffset>({ x: 0, y: 0 })
|
||||
const draggingRef = useRef(false)
|
||||
|
||||
interface PanOffset {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
// The consumer binds these to the inner transform div and the outer drag surface.
|
||||
const transformElRef = useRef<HTMLDivElement | null>(null)
|
||||
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
export function useScreenShareZoom() {
|
||||
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
|
||||
const [panOffset, setPanOffset] = useState<PanOffset>({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// Ref for event handlers; state drives cursor re-renders during drag.
|
||||
const isDraggingRef = useRef(false)
|
||||
const dragStart = useRef<PanOffset>({ x: 0, y: 0 })
|
||||
const panStart = useRef<PanOffset>({ x: 0, y: 0 })
|
||||
|
||||
const isZoomed = zoomLevel > MIN_ZOOM
|
||||
const canZoomIn = zoomLevel < MAX_ZOOM
|
||||
const canZoomOut = zoomLevel > MIN_ZOOM
|
||||
|
||||
const clampPan = useCallback(
|
||||
(pan: PanOffset, zoom: number): PanOffset => {
|
||||
// Max pan in %, keeps content within the visible area at a given zoom level.
|
||||
const maxPan = ((zoom - 1) / zoom) * PAN_CLAMP_HALF
|
||||
return {
|
||||
x: Math.max(-maxPan, Math.min(maxPan, pan.x)),
|
||||
y: Math.max(-maxPan, Math.min(maxPan, pan.y)),
|
||||
}
|
||||
},
|
||||
[]
|
||||
// Snapshot store: subscribers are notified only on explicit flush() calls.
|
||||
const snapshotRef = useRef<ZoomSnapshot>(
|
||||
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
|
||||
)
|
||||
const listenersRef = useRef(new Set<() => void>())
|
||||
|
||||
const clampZoom = useCallback((value: number) => {
|
||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
||||
const subscribe = useCallback((cb: () => void) => {
|
||||
listenersRef.current.add(cb)
|
||||
return () => {
|
||||
listenersRef.current.delete(cb)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getSnapshot = useCallback(() => snapshotRef.current, [])
|
||||
|
||||
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const flush = useCallback(() => {
|
||||
snapshotRef.current = buildZoomSnapshot(
|
||||
zoomRef.current,
|
||||
panRef.current,
|
||||
draggingRef.current
|
||||
)
|
||||
listenersRef.current.forEach((cb) => cb())
|
||||
}, [])
|
||||
|
||||
const applyTransform = useCallback((transition: boolean) => {
|
||||
const el = transformElRef.current
|
||||
if (!el) return
|
||||
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
|
||||
el.style.transition = transition ? 'transform 150ms ease-out' : 'none'
|
||||
}, [])
|
||||
|
||||
const applyCursor = useCallback(() => {
|
||||
const el = surfaceElRef.current
|
||||
if (!el) return
|
||||
el.style.cursor = getCursorFromZoomState(
|
||||
zoomRef.current,
|
||||
draggingRef.current
|
||||
)
|
||||
}, [])
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomLevel((prev) => {
|
||||
const next = clampZoom(prev + ZOOM_STEP)
|
||||
setPanOffset((pan) => clampPan(pan, next))
|
||||
return next
|
||||
})
|
||||
}, [clampZoom, clampPan])
|
||||
const next = clampZoom(zoomRef.current + ZOOM_STEP)
|
||||
zoomRef.current = next
|
||||
panRef.current = clampPan(panRef.current, next)
|
||||
applyTransform(true)
|
||||
applyCursor()
|
||||
flush()
|
||||
}, [applyTransform, applyCursor, flush])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomLevel((prev) => {
|
||||
const next = clampZoom(prev - ZOOM_STEP)
|
||||
if (next <= MIN_ZOOM) {
|
||||
setPanOffset({ x: 0, y: 0 })
|
||||
return MIN_ZOOM
|
||||
}
|
||||
setPanOffset((pan) => clampPan(pan, next))
|
||||
return next
|
||||
})
|
||||
}, [clampZoom, clampPan])
|
||||
const next = clampZoom(zoomRef.current - ZOOM_STEP)
|
||||
zoomRef.current = next
|
||||
panRef.current =
|
||||
next <= MIN_ZOOM ? { x: 0, y: 0 } : clampPan(panRef.current, next)
|
||||
applyTransform(true)
|
||||
applyCursor()
|
||||
flush()
|
||||
}, [applyTransform, applyCursor, flush])
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoomLevel(MIN_ZOOM)
|
||||
setPanOffset({ x: 0, y: 0 })
|
||||
}, [])
|
||||
|
||||
// Arrow key panning (keyboard a11y).
|
||||
const panBy = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
setPanOffset((pan) =>
|
||||
clampPan({ x: pan.x + dx, y: pan.y + dy }, zoomLevel)
|
||||
)
|
||||
},
|
||||
[zoomLevel, clampPan]
|
||||
)
|
||||
zoomRef.current = MIN_ZOOM
|
||||
panRef.current = { x: 0, y: 0 }
|
||||
applyTransform(true)
|
||||
applyCursor()
|
||||
flush()
|
||||
}, [applyTransform, applyCursor, flush])
|
||||
|
||||
// Must be attached with { passive: false } so preventDefault() blocks
|
||||
// the browser's native Ctrl+scroll page zoom.
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
@@ -86,89 +120,95 @@ export function useScreenShareZoom() {
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const prev = zoomRef.current
|
||||
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
||||
const rect = target.getBoundingClientRect()
|
||||
const cursorXPercent =
|
||||
((e.clientX - rect.left) / rect.width) * 100 - PAN_CLAMP_HALF
|
||||
const cursorYPercent =
|
||||
((e.clientY - rect.top) / rect.height) * 100 - PAN_CLAMP_HALF
|
||||
const next = clampZoom(prev + delta)
|
||||
|
||||
setZoomLevel((prev) => {
|
||||
const next = clampZoom(prev + delta)
|
||||
|
||||
if (next <= MIN_ZOOM) {
|
||||
setPanOffset({ x: 0, y: 0 })
|
||||
return MIN_ZOOM
|
||||
}
|
||||
|
||||
setPanOffset((pan) => {
|
||||
const zoomRatio = next / prev
|
||||
return clampPan(
|
||||
{
|
||||
x:
|
||||
pan.x +
|
||||
(cursorXPercent - pan.x) *
|
||||
(1 - 1 / zoomRatio) *
|
||||
WHEEL_ZOOM_FOCUS_BLEND,
|
||||
y:
|
||||
pan.y +
|
||||
(cursorYPercent - pan.y) *
|
||||
(1 - 1 / zoomRatio) *
|
||||
WHEEL_ZOOM_FOCUS_BLEND,
|
||||
},
|
||||
next
|
||||
)
|
||||
if (next <= MIN_ZOOM) {
|
||||
zoomRef.current = MIN_ZOOM
|
||||
panRef.current = { x: 0, y: 0 }
|
||||
} else {
|
||||
const { cursorXPercent, cursorYPercent } =
|
||||
getCursorPercentsFromWheelEvent(e, target)
|
||||
zoomRef.current = next
|
||||
panRef.current = getWheelPanOffset({
|
||||
pan: panRef.current,
|
||||
prevZoom: prev,
|
||||
nextZoom: next,
|
||||
cursorXPercent,
|
||||
cursorYPercent,
|
||||
})
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
applyTransform(false)
|
||||
applyCursor()
|
||||
flush()
|
||||
},
|
||||
[clampZoom, clampPan]
|
||||
[applyTransform, applyCursor, flush]
|
||||
)
|
||||
|
||||
const handlePanStart = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isZoomed) return
|
||||
|
||||
e.preventDefault()
|
||||
isDraggingRef.current = true
|
||||
setIsDragging(true)
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
panStart.current = { ...panOffset }
|
||||
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
||||
// here because moveProps is on the zoom surface, while focus is on the tile
|
||||
// container, see handleKeyDown below.
|
||||
const { moveProps } = useMove({
|
||||
onMoveStart() {
|
||||
if (zoomRef.current <= MIN_ZOOM) return
|
||||
draggingRef.current = true
|
||||
applyCursor()
|
||||
flush()
|
||||
},
|
||||
[isZoomed, panOffset]
|
||||
)
|
||||
onMove(e: MoveMoveEvent) {
|
||||
if (zoomRef.current <= MIN_ZOOM) return
|
||||
|
||||
const handlePanMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current) return
|
||||
const el = surfaceElRef.current
|
||||
if (!el) return
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const deltaXPercent =
|
||||
((e.clientX - dragStart.current.x) / rect.width) * 100
|
||||
const deltaYPercent =
|
||||
((e.clientY - dragStart.current.y) / rect.height) * 100
|
||||
|
||||
setPanOffset(
|
||||
clampPan(
|
||||
{
|
||||
x: panStart.current.x + deltaXPercent,
|
||||
y: panStart.current.y + deltaYPercent,
|
||||
},
|
||||
zoomLevel
|
||||
)
|
||||
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||
e.deltaX,
|
||||
e.deltaY,
|
||||
el
|
||||
)
|
||||
|
||||
panRef.current = clampPan(
|
||||
{
|
||||
x: panRef.current.x + deltaXPercent,
|
||||
y: panRef.current.y + deltaYPercent,
|
||||
},
|
||||
zoomRef.current
|
||||
)
|
||||
|
||||
applyTransform(false)
|
||||
// Mouse drag: skip flush (imperative-only) to avoid re-renders per frame.
|
||||
// Keyboard: flush so the toolbar reflects the updated position.
|
||||
if (e.pointerType === 'keyboard') {
|
||||
flush()
|
||||
}
|
||||
},
|
||||
[zoomLevel, clampPan]
|
||||
onMoveEnd() {
|
||||
draggingRef.current = false
|
||||
applyTransform(true)
|
||||
applyCursor()
|
||||
flush()
|
||||
},
|
||||
})
|
||||
|
||||
const panBy = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
panRef.current = clampPan(
|
||||
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
|
||||
zoomRef.current
|
||||
)
|
||||
applyTransform(true)
|
||||
flush()
|
||||
},
|
||||
[applyTransform, flush]
|
||||
)
|
||||
|
||||
const handlePanEnd = useCallback(() => {
|
||||
isDraggingRef.current = false
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// Attached to the tile container (not the zoom surface) where keyboard
|
||||
// focus lives. Arrows pan, +/-/0 zoom.
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const isZoomed = zoomRef.current > MIN_ZOOM
|
||||
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
||||
|
||||
switch (e.key) {
|
||||
@@ -203,25 +243,18 @@ export function useScreenShareZoom() {
|
||||
break
|
||||
}
|
||||
},
|
||||
[isZoomed, panBy, zoomIn, zoomOut, resetZoom]
|
||||
[panBy, zoomIn, zoomOut, resetZoom]
|
||||
)
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
zoomPercentage: Math.round(zoomLevel * 100),
|
||||
panOffset,
|
||||
isZoomed,
|
||||
isDragging,
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
...snapshot,
|
||||
transformElRef,
|
||||
surfaceElRef,
|
||||
moveProps,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetZoom,
|
||||
panBy,
|
||||
handleWheel,
|
||||
handlePanStart,
|
||||
handlePanMove,
|
||||
handlePanEnd,
|
||||
handleKeyDown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
export const MIN_ZOOM = 1
|
||||
export const MAX_ZOOM = 4
|
||||
export const ZOOM_STEP = 0.1
|
||||
export const WHEEL_ZOOM_SPEED = 0.002
|
||||
export const WHEEL_ZOOM_FOCUS_BLEND = 0.3
|
||||
export const PAN_STEP = 5
|
||||
export const PAN_CLAMP_HALF = 50
|
||||
|
||||
export interface PanOffset {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ZoomSnapshot {
|
||||
zoomLevel: number
|
||||
zoomPercentage: number
|
||||
panOffset: PanOffset
|
||||
isZoomed: boolean
|
||||
isDragging: boolean
|
||||
canZoomIn: boolean
|
||||
canZoomOut: boolean
|
||||
}
|
||||
|
||||
export const clampZoom = (value: number) => {
|
||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
||||
}
|
||||
|
||||
// Restrict pan so the content edge never passes the viewport center.
|
||||
export const clampPan = (pan: PanOffset, zoom: number): PanOffset => {
|
||||
const maxPan = ((zoom - 1) / zoom) * PAN_CLAMP_HALF
|
||||
return {
|
||||
x: Math.max(-maxPan, Math.min(maxPan, pan.x)),
|
||||
y: Math.max(-maxPan, Math.min(maxPan, pan.y)),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildZoomSnapshot = (
|
||||
zoom: number,
|
||||
pan: PanOffset,
|
||||
dragging: boolean
|
||||
): ZoomSnapshot => {
|
||||
return {
|
||||
zoomLevel: zoom,
|
||||
zoomPercentage: Math.round(zoom * 100),
|
||||
panOffset: pan,
|
||||
isZoomed: zoom > MIN_ZOOM,
|
||||
isDragging: dragging,
|
||||
canZoomIn: zoom < MAX_ZOOM,
|
||||
canZoomOut: zoom > MIN_ZOOM,
|
||||
}
|
||||
}
|
||||
|
||||
export const getZoomTransform = (zoom: number, pan: PanOffset) => {
|
||||
return `scale(${zoom}) translate(${pan.x}%, ${pan.y}%)`
|
||||
}
|
||||
|
||||
export const getCursorFromZoomState = (zoom: number, dragging: boolean) => {
|
||||
if (zoom <= MIN_ZOOM) return 'default'
|
||||
return dragging ? 'grabbing' : 'grab'
|
||||
}
|
||||
|
||||
// Blend the current pan toward the cursor position so the zoom "tracks" the pointer.
|
||||
export const getWheelPanOffset = ({
|
||||
pan,
|
||||
prevZoom,
|
||||
nextZoom,
|
||||
cursorXPercent,
|
||||
cursorYPercent,
|
||||
}: {
|
||||
pan: PanOffset
|
||||
prevZoom: number
|
||||
nextZoom: number
|
||||
cursorXPercent: number
|
||||
cursorYPercent: number
|
||||
}): PanOffset => {
|
||||
const zoomRatio = nextZoom / prevZoom
|
||||
return clampPan(
|
||||
{
|
||||
x:
|
||||
pan.x +
|
||||
(cursorXPercent - pan.x) * (1 - 1 / zoomRatio) * WHEEL_ZOOM_FOCUS_BLEND,
|
||||
y:
|
||||
pan.y +
|
||||
(cursorYPercent - pan.y) * (1 - 1 / zoomRatio) * WHEEL_ZOOM_FOCUS_BLEND,
|
||||
},
|
||||
nextZoom
|
||||
)
|
||||
}
|
||||
|
||||
// Convert cursor pixel position to a % offset from the surface center.
|
||||
export const getCursorPercentsFromWheelEvent = (
|
||||
e: WheelEvent,
|
||||
target: HTMLElement
|
||||
) => {
|
||||
const rect = target.getBoundingClientRect()
|
||||
return {
|
||||
cursorXPercent:
|
||||
((e.clientX - rect.left) / rect.width) * 100 - PAN_CLAMP_HALF,
|
||||
cursorYPercent:
|
||||
((e.clientY - rect.top) / rect.height) * 100 - PAN_CLAMP_HALF,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert useMove pixel deltas to % of the surface dimensions.
|
||||
export const getPanDeltaPercentsFromMove = (
|
||||
deltaX: number,
|
||||
deltaY: number,
|
||||
surface: HTMLElement
|
||||
) => {
|
||||
const rect = surface.getBoundingClientRect()
|
||||
return {
|
||||
deltaXPercent: (deltaX / rect.width) * 100,
|
||||
deltaYPercent: (deltaY / rect.height) * 100,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user