♻️(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]) }, [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,
}
}