fixup! ♻️(frontend) refactor screen share zoom pan with useMove and imperative DOM updates

This commit is contained in:
Ovgodd
2026-09-02 09:39:51 +02:00
parent 7b95c3e6df
commit 0e0485b694
2 changed files with 74 additions and 14 deletions
@@ -2,6 +2,7 @@ import { useCallback, useRef, useSyncExternalStore } from 'react'
import { useMove } from 'react-aria'
import type { MoveMoveEvent } from '@react-types/shared'
import {
FULL_PICTURE_RATIO,
MIN_ZOOM,
PAN_STEP,
WHEEL_ZOOM_SPEED,
@@ -14,6 +15,7 @@ import {
getCursorFromZoomState,
getCursorPercentsFromWheelEvent,
getPanDeltaPercentsFromMove,
getPictureRatio,
getWheelPanOffset,
getZoomTransform,
} from '../utils/screenShareZoom'
@@ -73,6 +75,22 @@ export const useScreenShareZoom = () => {
el.style.transform = getZoomTransform(zoomRef.current, panRef.current)
}, [])
// The video is letterboxed inside the surface by object-fit: contain, so the
// pan bounds depend on how much of the surface the picture actually covers.
// Read live rather than cached: both the tile and the shared resolution can
// change at any time.
const readPictureRatio = useCallback(() => {
const surface = surfaceElRef.current
const video = transformElRef.current?.querySelector('video')
if (!surface || !video) return FULL_PICTURE_RATIO
return getPictureRatio(
surface.clientWidth,
surface.clientHeight,
video.videoWidth,
video.videoHeight
)
}, [])
const applyCursor = useCallback(() => {
const el = surfaceElRef.current
if (!el) return
@@ -85,21 +103,23 @@ export const useScreenShareZoom = () => {
const zoomIn = useCallback(() => {
const next = clampZoom(zoomRef.current + ZOOM_STEP)
zoomRef.current = next
panRef.current = clampPan(panRef.current, next)
panRef.current = clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush])
}, [applyTransform, applyCursor, flush, readPictureRatio])
const zoomOut = useCallback(() => {
const next = clampZoom(zoomRef.current - ZOOM_STEP)
zoomRef.current = next
panRef.current =
next <= MIN_ZOOM ? { x: 0, y: 0 } : clampPan(panRef.current, next)
next <= MIN_ZOOM
? { x: 0, y: 0 }
: clampPan(panRef.current, next, readPictureRatio())
applyTransform()
applyCursor()
flush()
}, [applyTransform, applyCursor, flush])
}, [applyTransform, applyCursor, flush, readPictureRatio])
const resetZoom = useCallback(() => {
zoomRef.current = MIN_ZOOM
@@ -136,6 +156,7 @@ export const useScreenShareZoom = () => {
nextZoom: next,
cursorXPercent,
cursorYPercent,
ratio: readPictureRatio(),
})
}
@@ -143,7 +164,7 @@ export const useScreenShareZoom = () => {
applyCursor()
flush()
},
[applyTransform, applyCursor, flush]
[applyTransform, applyCursor, flush, readPictureRatio]
)
// useMove handles mouse drag + touch pan. Keyboard arrows are not handled
@@ -173,7 +194,8 @@ export const useScreenShareZoom = () => {
x: panRef.current.x + deltaXPercent,
y: panRef.current.y + deltaYPercent,
},
zoomRef.current
zoomRef.current,
readPictureRatio()
)
applyTransform()
@@ -195,12 +217,13 @@ export const useScreenShareZoom = () => {
(dx: number, dy: number) => {
panRef.current = clampPan(
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
zoomRef.current
zoomRef.current,
readPictureRatio()
)
applyTransform()
flush()
},
[applyTransform, flush]
[applyTransform, flush, readPictureRatio]
)
// Attached to the tile container (not the zoom surface) where keyboard
@@ -10,6 +10,14 @@ export interface PanOffset {
y: number
}
// Fraction of the surface each axis of the picture covers, in [0, 1].
export interface PictureRatio {
x: number
y: number
}
export const FULL_PICTURE_RATIO: PictureRatio = { x: 1, y: 1 }
export interface ZoomSnapshot {
zoomLevel: number
zoomPercentage: number
@@ -24,15 +32,41 @@ export const clampZoom = (value: number) => {
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value))
}
// Restrict pan so the picture always covers the view.
export const clampPan = (pan: PanOffset, zoom: number): PanOffset => {
const maxPan = ((zoom - 1) / zoom) * PAN_CLAMP_HALF
// Restrict pan so the picture always covers the view. Pan is a % of the
// surface, in which object-fit: contain letterboxes the picture: its half
// extent is `ratio * 50` against a view half extent of 50, and scaling by
// `zoom` must keep `zoom * (ratio * 50 - |pan|) >= 50`. An axis whose picture
// is still smaller than the view is pinned to 0, keeping the bars symmetric.
export const clampPan = (
pan: PanOffset,
zoom: number,
ratio: PictureRatio
): PanOffset => {
const maxPanX = Math.max(0, (ratio.x - 1 / zoom) * PAN_CLAMP_HALF)
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * PAN_CLAMP_HALF)
return {
x: Math.max(-maxPan, Math.min(maxPan, pan.x)),
y: Math.max(-maxPan, Math.min(maxPan, pan.y)),
x: Math.max(-maxPanX, Math.min(maxPanX, pan.x)),
y: Math.max(-maxPanY, Math.min(maxPanY, pan.y)),
}
}
// Per-axis fraction of the surface covered by an object-fit: contain picture.
export const getPictureRatio = (
surfaceWidth: number,
surfaceHeight: number,
videoWidth: number,
videoHeight: number
): PictureRatio => {
if (!surfaceWidth || !surfaceHeight || !videoWidth || !videoHeight) {
return FULL_PICTURE_RATIO
}
const surfaceRatio = surfaceWidth / surfaceHeight
const videoRatio = videoWidth / videoHeight
return surfaceRatio > videoRatio
? { x: videoRatio / surfaceRatio, y: 1 }
: { x: 1, y: surfaceRatio / videoRatio }
}
export const buildZoomSnapshot = (
zoom: number,
pan: PanOffset,
@@ -68,12 +102,14 @@ export const getWheelPanOffset = ({
nextZoom,
cursorXPercent,
cursorYPercent,
ratio,
}: {
pan: PanOffset
prevZoom: number
nextZoom: number
cursorXPercent: number
cursorYPercent: number
ratio: PictureRatio
}): PanOffset => {
const panShift = 1 / nextZoom - 1 / prevZoom
return clampPan(
@@ -81,7 +117,8 @@ export const getWheelPanOffset = ({
x: pan.x + cursorXPercent * panShift,
y: pan.y + cursorYPercent * panShift,
},
nextZoom
nextZoom,
ratio
)
}