mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-31 04:37:57 +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])
|
}, [containerRef])
|
||||||
|
|
||||||
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
||||||
const wheelShortcutSR = t(
|
|
||||||
isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -102,7 +99,6 @@ export const ScreenShareZoomControls = ({
|
|||||||
borderRadius: '2rem',
|
borderRadius: '2rem',
|
||||||
padding: '0.5rem',
|
padding: '0.5rem',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
overflow: 'hidden',
|
|
||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
transition: 'opacity 200ms linear',
|
transition: 'opacity 200ms linear',
|
||||||
_hover: {
|
_hover: {
|
||||||
@@ -113,55 +109,71 @@ export const ScreenShareZoomControls = ({
|
|||||||
<span className={srOnly}>
|
<span className={srOnly}>
|
||||||
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||||
</span>
|
</span>
|
||||||
{isZoomed && (
|
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
||||||
<>
|
trick keeps overflow:hidden from clipping focus rings. */}
|
||||||
<Button
|
<div
|
||||||
size="sm"
|
className={css({
|
||||||
variant="primaryTextDark"
|
display: 'flex',
|
||||||
square
|
alignItems: 'center',
|
||||||
tooltip={t('fitToWindow')}
|
overflow: 'hidden',
|
||||||
aria-label={t('fitToWindow')}
|
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
||||||
onPress={onResetZoom}
|
padding: '3px',
|
||||||
>
|
margin: '-3px',
|
||||||
<RiFullscreenExitLine size={20} />
|
})}
|
||||||
</Button>
|
style={{
|
||||||
<Button
|
maxWidth: isZoomed ? '12rem' : '0',
|
||||||
size="sm"
|
opacity: isZoomed ? 1 : 0,
|
||||||
variant="primaryTextDark"
|
}}
|
||||||
square
|
aria-hidden={!isZoomed}
|
||||||
tooltip={t('zoomOutWithShortcut', { shortcut: wheelShortcutVisual })}
|
>
|
||||||
aria-label={t('zoomOutWithShortcut', { shortcut: wheelShortcutSR })}
|
<Button
|
||||||
isDisabled={!canZoomOut}
|
size="sm"
|
||||||
onPress={onZoomOut}
|
variant="primaryTextDark"
|
||||||
>
|
square
|
||||||
<RiZoomOutLine size={20} />
|
tooltip={t('fitToWindow')}
|
||||||
</Button>
|
aria-label={t('fitToWindow')}
|
||||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
isDisabled={!isZoomed}
|
||||||
<span
|
onPress={onResetZoom}
|
||||||
aria-hidden="true"
|
>
|
||||||
className={css({
|
<RiFullscreenExitLine size={20} />
|
||||||
color: 'white',
|
</Button>
|
||||||
fontSize: '0.8125rem',
|
<Button
|
||||||
fontWeight: 500,
|
size="sm"
|
||||||
minWidth: '3.25rem',
|
variant="primaryTextDark"
|
||||||
textAlign: 'center',
|
square
|
||||||
userSelect: 'none',
|
tooltip={t('zoomOutWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||||
display: 'flex',
|
aria-label={t('zoomOut')}
|
||||||
alignItems: 'center',
|
isDisabled={!isZoomed || !canZoomOut}
|
||||||
justifyContent: 'center',
|
onPress={onZoomOut}
|
||||||
padding: '0 0.25rem',
|
>
|
||||||
})}
|
<RiZoomOutLine size={20} />
|
||||||
>
|
</Button>
|
||||||
{zoomPercentage} %
|
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||||
</span>
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
square
|
square
|
||||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||||
aria-label={t('zoomInWithShortcut', { shortcut: wheelShortcutSR })}
|
aria-label={t('zoomIn')}
|
||||||
isDisabled={!canZoomIn}
|
isDisabled={!canZoomIn}
|
||||||
onPress={onZoomIn}
|
onPress={onZoomIn}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ export const ScreenShareZoomableVideo = ({
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
const announce = useScreenReaderAnnounce()
|
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 prevZoomRef = useRef(zoom.zoomPercentage)
|
||||||
const hasAnnouncedPanHint = useRef(false)
|
const hasAnnouncedPanHint = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,50 +51,35 @@ export const ScreenShareZoomableVideo = ({
|
|||||||
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||||
}, [tileRef, zoom.handleKeyDown])
|
}, [tileRef, zoom.handleKeyDown])
|
||||||
|
|
||||||
// Native wheel listener so we can use { passive: false } and preventDefault.
|
// Native wheel listener with { passive: false } so preventDefault works.
|
||||||
// React onWheel is passive — Ctrl+scroll would zoom the whole browser page.
|
|
||||||
const zoomSurfaceRef = useRef<HTMLDivElement>(null)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = zoomSurfaceRef.current
|
const el = zoom.surfaceElRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||||
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||||
}, [zoom.handleWheel])
|
}, [zoom.handleWheel, zoom.surfaceElRef])
|
||||||
|
|
||||||
let panCursor: React.CSSProperties['cursor'] = 'default'
|
|
||||||
if (zoom.isZoomed) {
|
|
||||||
panCursor = zoom.isDragging ? 'grabbing' : 'grab'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Pan/zoom surface - Ctrl+wheel to zoom, drag when zoomed. */}
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
|
||||||
<div
|
<div
|
||||||
ref={zoomSurfaceRef}
|
ref={zoom.surfaceElRef}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
})}
|
})}
|
||||||
style={{
|
{...zoom.moveProps}
|
||||||
cursor: panCursor,
|
|
||||||
}}
|
|
||||||
onMouseDown={zoom.handlePanStart}
|
|
||||||
onMouseMove={zoom.handlePanMove}
|
|
||||||
onMouseUp={zoom.handlePanEnd}
|
|
||||||
onMouseLeave={zoom.handlePanEnd}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={zoom.transformElRef}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
transform: `scale(${zoom.zoomLevel}) translate(${zoom.panOffset.x}%, ${zoom.panOffset.y}%)`,
|
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
transition: zoom.isDragging ? 'none' : 'transform 150ms ease-out',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScreenShareVideoTrack
|
<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
|
* Manages zoom and pan state for a remote screen share.
|
||||||
const ZOOM_STEP = 0.1
|
*
|
||||||
const WHEEL_ZOOM_SPEED = 0.002
|
* Performance: zoom/pan live in refs and are applied imperatively to the DOM
|
||||||
const WHEEL_ZOOM_FOCUS_BLEND = 0.3
|
* (via transformElRef / surfaceElRef) so the hot path (drag, wheel) never
|
||||||
const PAN_STEP = 5
|
* triggers a React re-render. A useSyncExternalStore snapshot is flushed only
|
||||||
const PAN_CLAMP_HALF = 50
|
* 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 {
|
// The consumer binds these to the inner transform div and the outer drag surface.
|
||||||
x: number
|
const transformElRef = useRef<HTMLDivElement | null>(null)
|
||||||
y: number
|
const surfaceElRef = useRef<HTMLDivElement | null>(null)
|
||||||
}
|
|
||||||
|
|
||||||
export function useScreenShareZoom() {
|
// Snapshot store: subscribers are notified only on explicit flush() calls.
|
||||||
const [zoomLevel, setZoomLevel] = useState(MIN_ZOOM)
|
const snapshotRef = useRef<ZoomSnapshot>(
|
||||||
const [panOffset, setPanOffset] = useState<PanOffset>({ x: 0, y: 0 })
|
buildZoomSnapshot(MIN_ZOOM, { x: 0, y: 0 }, false)
|
||||||
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)),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
)
|
||||||
|
const listenersRef = useRef(new Set<() => void>())
|
||||||
|
|
||||||
const clampZoom = useCallback((value: number) => {
|
const subscribe = useCallback((cb: () => void) => {
|
||||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
|
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(() => {
|
const zoomIn = useCallback(() => {
|
||||||
setZoomLevel((prev) => {
|
const next = clampZoom(zoomRef.current + ZOOM_STEP)
|
||||||
const next = clampZoom(prev + ZOOM_STEP)
|
zoomRef.current = next
|
||||||
setPanOffset((pan) => clampPan(pan, next))
|
panRef.current = clampPan(panRef.current, next)
|
||||||
return next
|
applyTransform(true)
|
||||||
})
|
applyCursor()
|
||||||
}, [clampZoom, clampPan])
|
flush()
|
||||||
|
}, [applyTransform, applyCursor, flush])
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
const zoomOut = useCallback(() => {
|
||||||
setZoomLevel((prev) => {
|
const next = clampZoom(zoomRef.current - ZOOM_STEP)
|
||||||
const next = clampZoom(prev - ZOOM_STEP)
|
zoomRef.current = next
|
||||||
if (next <= MIN_ZOOM) {
|
panRef.current =
|
||||||
setPanOffset({ x: 0, y: 0 })
|
next <= MIN_ZOOM ? { x: 0, y: 0 } : clampPan(panRef.current, next)
|
||||||
return MIN_ZOOM
|
applyTransform(true)
|
||||||
}
|
applyCursor()
|
||||||
setPanOffset((pan) => clampPan(pan, next))
|
flush()
|
||||||
return next
|
}, [applyTransform, applyCursor, flush])
|
||||||
})
|
|
||||||
}, [clampZoom, clampPan])
|
|
||||||
|
|
||||||
const resetZoom = useCallback(() => {
|
const resetZoom = useCallback(() => {
|
||||||
setZoomLevel(MIN_ZOOM)
|
zoomRef.current = MIN_ZOOM
|
||||||
setPanOffset({ x: 0, y: 0 })
|
panRef.current = { x: 0, y: 0 }
|
||||||
}, [])
|
applyTransform(true)
|
||||||
|
applyCursor()
|
||||||
// Arrow key panning (keyboard a11y).
|
flush()
|
||||||
const panBy = useCallback(
|
}, [applyTransform, applyCursor, flush])
|
||||||
(dx: number, dy: number) => {
|
|
||||||
setPanOffset((pan) =>
|
|
||||||
clampPan({ x: pan.x + dx, y: pan.y + dy }, zoomLevel)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
[zoomLevel, clampPan]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// Must be attached with { passive: false } so preventDefault() blocks
|
||||||
|
// the browser's native Ctrl+scroll page zoom.
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
if (!e.ctrlKey && !e.metaKey) return
|
if (!e.ctrlKey && !e.metaKey) return
|
||||||
@@ -86,89 +120,95 @@ export function useScreenShareZoom() {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const target = e.currentTarget as HTMLElement
|
const target = e.currentTarget as HTMLElement
|
||||||
|
const prev = zoomRef.current
|
||||||
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
||||||
const rect = target.getBoundingClientRect()
|
const next = clampZoom(prev + delta)
|
||||||
const cursorXPercent =
|
|
||||||
((e.clientX - rect.left) / rect.width) * 100 - PAN_CLAMP_HALF
|
|
||||||
const cursorYPercent =
|
|
||||||
((e.clientY - rect.top) / rect.height) * 100 - PAN_CLAMP_HALF
|
|
||||||
|
|
||||||
setZoomLevel((prev) => {
|
if (next <= MIN_ZOOM) {
|
||||||
const next = clampZoom(prev + delta)
|
zoomRef.current = MIN_ZOOM
|
||||||
|
panRef.current = { x: 0, y: 0 }
|
||||||
if (next <= MIN_ZOOM) {
|
} else {
|
||||||
setPanOffset({ x: 0, y: 0 })
|
const { cursorXPercent, cursorYPercent } =
|
||||||
return MIN_ZOOM
|
getCursorPercentsFromWheelEvent(e, target)
|
||||||
}
|
zoomRef.current = next
|
||||||
|
panRef.current = getWheelPanOffset({
|
||||||
setPanOffset((pan) => {
|
pan: panRef.current,
|
||||||
const zoomRatio = next / prev
|
prevZoom: prev,
|
||||||
return clampPan(
|
nextZoom: next,
|
||||||
{
|
cursorXPercent,
|
||||||
x:
|
cursorYPercent,
|
||||||
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
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return next
|
applyTransform(false)
|
||||||
})
|
applyCursor()
|
||||||
|
flush()
|
||||||
},
|
},
|
||||||
[clampZoom, clampPan]
|
[applyTransform, applyCursor, flush]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePanStart = useCallback(
|
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
// here because moveProps is on the zoom surface, while focus is on the tile
|
||||||
if (!isZoomed) return
|
// container, see handleKeyDown below.
|
||||||
|
const { moveProps } = useMove({
|
||||||
e.preventDefault()
|
onMoveStart() {
|
||||||
isDraggingRef.current = true
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
setIsDragging(true)
|
draggingRef.current = true
|
||||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
applyCursor()
|
||||||
panStart.current = { ...panOffset }
|
flush()
|
||||||
},
|
},
|
||||||
[isZoomed, panOffset]
|
onMove(e: MoveMoveEvent) {
|
||||||
)
|
if (zoomRef.current <= MIN_ZOOM) return
|
||||||
|
|
||||||
const handlePanMove = useCallback(
|
const el = surfaceElRef.current
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
if (!el) return
|
||||||
if (!isDraggingRef.current) return
|
|
||||||
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||||
const deltaXPercent =
|
e.deltaX,
|
||||||
((e.clientX - dragStart.current.x) / rect.width) * 100
|
e.deltaY,
|
||||||
const deltaYPercent =
|
el
|
||||||
((e.clientY - dragStart.current.y) / rect.height) * 100
|
|
||||||
|
|
||||||
setPanOffset(
|
|
||||||
clampPan(
|
|
||||||
{
|
|
||||||
x: panStart.current.x + deltaXPercent,
|
|
||||||
y: panStart.current.y + deltaYPercent,
|
|
||||||
},
|
|
||||||
zoomLevel
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(() => {
|
// Attached to the tile container (not the zoom surface) where keyboard
|
||||||
isDraggingRef.current = false
|
// focus lives. Arrows pan, +/-/0 zoom.
|
||||||
setIsDragging(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent) => {
|
(e: KeyboardEvent) => {
|
||||||
|
const isZoomed = zoomRef.current > MIN_ZOOM
|
||||||
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
if (!isZoomed && e.key !== '+' && e.key !== '=') return
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -203,25 +243,18 @@ export function useScreenShareZoom() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isZoomed, panBy, zoomIn, zoomOut, resetZoom]
|
[panBy, zoomIn, zoomOut, resetZoom]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoomLevel,
|
...snapshot,
|
||||||
zoomPercentage: Math.round(zoomLevel * 100),
|
transformElRef,
|
||||||
panOffset,
|
surfaceElRef,
|
||||||
isZoomed,
|
moveProps,
|
||||||
isDragging,
|
|
||||||
canZoomIn,
|
|
||||||
canZoomOut,
|
|
||||||
zoomIn,
|
zoomIn,
|
||||||
zoomOut,
|
zoomOut,
|
||||||
resetZoom,
|
resetZoom,
|
||||||
panBy,
|
|
||||||
handleWheel,
|
handleWheel,
|
||||||
handlePanStart,
|
|
||||||
handlePanMove,
|
|
||||||
handlePanEnd,
|
|
||||||
handleKeyDown,
|
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