♻️(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:
Cyril
2026-07-13 17:11:59 +02:00
parent 620e860474
commit 09ec37d69a
4 changed files with 363 additions and 217 deletions
@@ -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,
}
}