mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-11 13:39:03 +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:
@@ -38,12 +38,13 @@ export const ScreenShareZoomControls = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const hadFocusInCollapsibleRef = useRef(false)
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
||||
const wasThisTileFullscreen = useRef(false)
|
||||
const [isFullscreenAvailable] = useState(
|
||||
() => typeof document !== 'undefined' && document.fullscreenEnabled
|
||||
)
|
||||
const isFullscreenAvailable = document.fullscreenEnabled
|
||||
|
||||
// Covers Esc and browser UI exits, not just the toolbar button.
|
||||
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||
@@ -65,6 +66,15 @@ export const ScreenShareZoomControls = ({
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}, [announce, t, containerRef])
|
||||
|
||||
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
||||
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
||||
// control of that group still reachable.
|
||||
useEffect(() => {
|
||||
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
||||
hadFocusInCollapsibleRef.current = false
|
||||
zoomInButtonRef.current?.focus()
|
||||
}, [isZoomed])
|
||||
|
||||
const toggleFullScreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
@@ -78,7 +88,7 @@ export const ScreenShareZoomControls = ({
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
const wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
|
||||
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +109,6 @@ export const ScreenShareZoomControls = ({
|
||||
borderRadius: '2rem',
|
||||
padding: '0.5rem',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 200ms linear',
|
||||
_hover: {
|
||||
@@ -110,54 +119,81 @@ 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: wheelShortcut })}
|
||||
aria-label={t('zoomOut')}
|
||||
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}
|
||||
onFocus={() => {
|
||||
hadFocusInCollapsibleRef.current = true
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// Disabling a focused button blurs it with no relatedTarget, so the
|
||||
// flag must survive that case for the effect above to rescue focus.
|
||||
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
||||
}}
|
||||
>
|
||||
<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
|
||||
ref={zoomInButtonRef}
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcut })}
|
||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcutVisual })}
|
||||
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,37 @@ 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',
|
||||
// Leaves the browser's native pinch-zoom available on touch devices
|
||||
// while still routing single-pointer drags to useMove for panning.
|
||||
touchAction: 'pinch-zoom',
|
||||
})}
|
||||
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,174 +1,260 @@
|
||||
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 {
|
||||
FULL_PICTURE_RATIO,
|
||||
MIN_ZOOM,
|
||||
PAN_STEP,
|
||||
WHEEL_ZOOM_SPEED,
|
||||
ZOOM_STEP,
|
||||
type PanOffset,
|
||||
type ZoomSnapshot,
|
||||
buildZoomSnapshot,
|
||||
clampPan,
|
||||
clampZoom,
|
||||
getCursorFromZoomState,
|
||||
getCursorPercentsFromWheelEvent,
|
||||
getPanDeltaPercentsFromMove,
|
||||
getPictureRatio,
|
||||
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).
|
||||
* The wheel listener (non-passive) zooms on Ctrl/Cmd+scroll and pans on a
|
||||
* two-finger trackpad scroll once zoomed.
|
||||
* 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 zoomIn = useCallback(() => {
|
||||
setZoomLevel((prev) => {
|
||||
const next = clampZoom(prev + ZOOM_STEP)
|
||||
setPanOffset((pan) => clampPan(pan, next))
|
||||
return next
|
||||
})
|
||||
}, [clampZoom, clampPan])
|
||||
const getSnapshot = useCallback(() => snapshotRef.current, [])
|
||||
|
||||
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 snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
|
||||
const resetZoom = useCallback(() => {
|
||||
setZoomLevel(MIN_ZOOM)
|
||||
setPanOffset({ x: 0, y: 0 })
|
||||
const flush = useCallback(() => {
|
||||
snapshotRef.current = buildZoomSnapshot(
|
||||
zoomRef.current,
|
||||
panRef.current,
|
||||
draggingRef.current
|
||||
)
|
||||
listenersRef.current.forEach((cb) => cb())
|
||||
}, [])
|
||||
|
||||
// Arrow key panning (keyboard a11y).
|
||||
const panBy = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
setPanOffset((pan) =>
|
||||
clampPan({ x: pan.x + dx, y: pan.y + dy }, zoomLevel)
|
||||
)
|
||||
const applyTransform = useCallback(() => {
|
||||
const el = transformElRef.current
|
||||
if (!el) return
|
||||
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
|
||||
el.style.cursor = getCursorFromZoomState(
|
||||
zoomRef.current,
|
||||
draggingRef.current
|
||||
)
|
||||
}, [])
|
||||
|
||||
const setZoom = useCallback(
|
||||
(next: number) => {
|
||||
zoomRef.current = next
|
||||
panRef.current =
|
||||
next <= MIN_ZOOM
|
||||
? { x: 0, y: 0 }
|
||||
: clampPan(panRef.current, next, readPictureRatio())
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
flush()
|
||||
},
|
||||
[zoomLevel, clampPan]
|
||||
[applyTransform, applyCursor, flush, readPictureRatio]
|
||||
)
|
||||
|
||||
const zoomIn = useCallback(
|
||||
() => setZoom(clampZoom(zoomRef.current + ZOOM_STEP)),
|
||||
[setZoom]
|
||||
)
|
||||
|
||||
const zoomOut = useCallback(
|
||||
() => setZoom(clampZoom(zoomRef.current - ZOOM_STEP)),
|
||||
[setZoom]
|
||||
)
|
||||
|
||||
const resetZoom = useCallback(() => setZoom(MIN_ZOOM), [setZoom])
|
||||
|
||||
// Must be attached with { passive: false } so preventDefault() blocks
|
||||
// the browser's native Ctrl+scroll page zoom. Trackpad pinch arrives
|
||||
// here as a wheel event with ctrl/cmd already set.
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const prev = zoomRef.current
|
||||
const delta = -e.deltaY * WHEEL_ZOOM_SPEED
|
||||
const next = clampZoom(prev + delta)
|
||||
|
||||
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,
|
||||
ratio: readPictureRatio(),
|
||||
})
|
||||
}
|
||||
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
flush()
|
||||
return
|
||||
}
|
||||
|
||||
// Two-finger trackpad scroll: pan only once zoomed, otherwise leave
|
||||
// the event alone so the page can still scroll.
|
||||
if (zoomRef.current <= MIN_ZOOM) return
|
||||
|
||||
const el = surfaceElRef.current
|
||||
if (!el) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
})
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[clampZoom, clampPan]
|
||||
)
|
||||
|
||||
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 }
|
||||
},
|
||||
[isZoomed, panOffset]
|
||||
)
|
||||
|
||||
const handlePanMove = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current) 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,
|
||||
readPictureRatio()
|
||||
)
|
||||
applyTransform()
|
||||
},
|
||||
[zoomLevel, clampPan]
|
||||
[applyTransform, applyCursor, flush, readPictureRatio]
|
||||
)
|
||||
|
||||
const handlePanEnd = useCallback(() => {
|
||||
isDraggingRef.current = false
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
// 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()
|
||||
},
|
||||
onMove(e: MoveMoveEvent) {
|
||||
if (zoomRef.current <= MIN_ZOOM) return
|
||||
|
||||
const el = surfaceElRef.current
|
||||
if (!el) return
|
||||
|
||||
const { deltaXPercent, deltaYPercent } = getPanDeltaPercentsFromMove(
|
||||
e.deltaX,
|
||||
e.deltaY,
|
||||
el
|
||||
)
|
||||
|
||||
panRef.current = clampPan(
|
||||
{
|
||||
x: panRef.current.x + deltaXPercent,
|
||||
y: panRef.current.y + deltaYPercent,
|
||||
},
|
||||
zoomRef.current,
|
||||
readPictureRatio()
|
||||
)
|
||||
|
||||
applyTransform()
|
||||
// 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()
|
||||
}
|
||||
},
|
||||
onMoveEnd() {
|
||||
draggingRef.current = false
|
||||
applyTransform()
|
||||
applyCursor()
|
||||
flush()
|
||||
},
|
||||
})
|
||||
|
||||
const panBy = useCallback(
|
||||
(dx: number, dy: number) => {
|
||||
panRef.current = clampPan(
|
||||
{ x: panRef.current.x + dx, y: panRef.current.y + dy },
|
||||
zoomRef.current,
|
||||
readPictureRatio()
|
||||
)
|
||||
applyTransform()
|
||||
flush()
|
||||
},
|
||||
[applyTransform, flush, readPictureRatio]
|
||||
)
|
||||
|
||||
// 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 +289,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,154 @@
|
||||
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 PAN_STEP = 5
|
||||
|
||||
// Half of a 100 % axis. Geometry, not a tunable: it is both the centre the
|
||||
// cursor offset is measured from and the half extent the pan is clamped
|
||||
// against, so the two stay consistent by construction.
|
||||
export const HALF_EXTENT_PERCENT = 50
|
||||
|
||||
export interface PanOffset {
|
||||
x: number
|
||||
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
|
||||
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 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) * HALF_EXTENT_PERCENT)
|
||||
const maxPanY = Math.max(0, (ratio.y - 1 / zoom) * HALF_EXTENT_PERCENT)
|
||||
return {
|
||||
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,
|
||||
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'
|
||||
}
|
||||
|
||||
// Keep the content point under the cursor anchored while zooming. With
|
||||
// `scale(z) translate(pan%)`, a point at `offset` from the center renders at
|
||||
// `z * (offset + pan)`, so holding it still gives:
|
||||
// pan' = pan + cursor * (1 / zoom' - 1 / zoom).
|
||||
export const getWheelPanOffset = ({
|
||||
pan,
|
||||
prevZoom,
|
||||
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(
|
||||
{
|
||||
x: pan.x + cursorXPercent * panShift,
|
||||
y: pan.y + cursorYPercent * panShift,
|
||||
},
|
||||
nextZoom,
|
||||
ratio
|
||||
)
|
||||
}
|
||||
|
||||
// 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 - HALF_EXTENT_PERCENT,
|
||||
cursorYPercent:
|
||||
((e.clientY - rect.top) / rect.height) * 100 - HALF_EXTENT_PERCENT,
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ export type ShortcutId =
|
||||
| 'recording'
|
||||
| 'reaction'
|
||||
| 'fullscreen'
|
||||
| 'zoom-in'
|
||||
| 'zoom-out'
|
||||
| 'zoom-reset'
|
||||
| 'zoom-pan'
|
||||
|
||||
export const getShortcutDescriptorById = (id: ShortcutId) =>
|
||||
shortcutCatalog.find((item) => item.id === id)
|
||||
@@ -24,7 +28,7 @@ export type ShortcutDescriptor = {
|
||||
id: ShortcutId
|
||||
category: ShortcutCategory
|
||||
shortcut?: Shortcut
|
||||
kind?: 'press' | 'longPress'
|
||||
kind?: 'press' | 'longPress' | 'arrows'
|
||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||
description?: string
|
||||
}
|
||||
@@ -86,4 +90,27 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
// Screen share zoom keys are unmodified, so they are bound on the focused
|
||||
// tile instead of being registered globally. They are listed here so the
|
||||
// shortcuts panel stays exhaustive.
|
||||
{
|
||||
id: 'zoom-in',
|
||||
category: 'interaction',
|
||||
shortcut: { key: '+' },
|
||||
},
|
||||
{
|
||||
id: 'zoom-out',
|
||||
category: 'interaction',
|
||||
shortcut: { key: '-' },
|
||||
},
|
||||
{
|
||||
id: 'zoom-reset',
|
||||
category: 'interaction',
|
||||
shortcut: { key: '0' },
|
||||
},
|
||||
{
|
||||
id: 'zoom-pan',
|
||||
category: 'interaction',
|
||||
kind: 'arrows',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ export const formatShortcutLabelForSR = (
|
||||
shiftLabel,
|
||||
plusLabel,
|
||||
noShortcutLabel,
|
||||
keyLabels,
|
||||
}: {
|
||||
controlLabel: string
|
||||
commandLabel: string
|
||||
@@ -33,10 +34,12 @@ export const formatShortcutLabelForSR = (
|
||||
shiftLabel: string
|
||||
plusLabel: string
|
||||
noShortcutLabel: string
|
||||
// Spelled-out names for keys screen readers may skip or mispronounce.
|
||||
keyLabels?: Record<string, string>
|
||||
}
|
||||
) => {
|
||||
if (!shortcut) return noShortcutLabel
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
const key = keyLabels?.[shortcut.key] ?? shortcut.key?.toUpperCase()
|
||||
if (!key) return noShortcutLabel
|
||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||
const altWord = isMacintosh() ? optionLabel : altLabel
|
||||
|
||||
@@ -12,6 +12,9 @@ export const useShortcutFormatting = () => {
|
||||
|
||||
const formatVisual = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||
if (kind === 'arrows') {
|
||||
return t('shortcutsPanel.visual.arrows')
|
||||
}
|
||||
if (code && kind === 'longPress') {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return t('shortcutsPanel.visual.hold', { key: label || '?' })
|
||||
@@ -23,6 +26,9 @@ export const useShortcutFormatting = () => {
|
||||
|
||||
const formatForSR = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, kind?: string) => {
|
||||
if (kind === 'arrows') {
|
||||
return t('shortcutsPanel.sr.arrows')
|
||||
}
|
||||
if (code && kind === 'longPress') {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return t('shortcutsPanel.sr.hold', { key: label || '?' })
|
||||
@@ -35,6 +41,10 @@ export const useShortcutFormatting = () => {
|
||||
shiftLabel: t('shortcutsPanel.sr.shift'),
|
||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||
keyLabels: {
|
||||
'+': t('shortcutsPanel.sr.plusKey'),
|
||||
'-': t('shortcutsPanel.sr.minusKey'),
|
||||
},
|
||||
})
|
||||
},
|
||||
[t]
|
||||
|
||||
@@ -793,7 +793,11 @@
|
||||
"raise-hand": "Hand heben oder senken",
|
||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen",
|
||||
"zoom-in": "In einen geteilten Bildschirm hineinzoomen",
|
||||
"zoom-out": "Aus einem geteilten Bildschirm herauszoomen",
|
||||
"zoom-reset": "Zoom des geteilten Bildschirms zurücksetzen",
|
||||
"zoom-pan": "Im gezoomten geteilten Bildschirm navigieren"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Steuerung",
|
||||
@@ -803,10 +807,14 @@
|
||||
"shift": "Umschalt",
|
||||
"plus": "plus",
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"arrows": "Pfeiltasten",
|
||||
"plusKey": "Plus-Taste",
|
||||
"minusKey": "Minus-Taste",
|
||||
"noShortcut": "Kein Tastenkürzel"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Halte {{key}} gedrückt"
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -793,7 +793,11 @@
|
||||
"raise-hand": "Raise or lower hand",
|
||||
"toggle-chat": "Toggle chat",
|
||||
"toggle-participants": "Toggle participants",
|
||||
"open-shortcuts-settings": "Open shortcuts settings"
|
||||
"open-shortcuts-settings": "Open shortcuts settings",
|
||||
"zoom-in": "Zoom in on a shared screen",
|
||||
"zoom-out": "Zoom out on a shared screen",
|
||||
"zoom-reset": "Reset the shared screen zoom",
|
||||
"zoom-pan": "Move around a zoomed shared screen"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
@@ -803,10 +807,14 @@
|
||||
"shift": "Shift",
|
||||
"plus": "plus",
|
||||
"hold": "Hold {{key}}",
|
||||
"arrows": "Arrow keys",
|
||||
"plusKey": "Plus key",
|
||||
"minusKey": "Minus key",
|
||||
"noShortcut": "No shortcut"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Hold {{key}}"
|
||||
"hold": "Hold {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -793,7 +793,11 @@
|
||||
"raise-hand": "Lever ou baisser la main",
|
||||
"toggle-chat": "Afficher/Masquer le chat",
|
||||
"toggle-participants": "Afficher/Masquer les participants",
|
||||
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis"
|
||||
"open-shortcuts-settings": "Ouvrir les réglages des raccourcis",
|
||||
"zoom-in": "Zoomer sur un écran partagé",
|
||||
"zoom-out": "Dézoomer sur un écran partagé",
|
||||
"zoom-reset": "Réinitialiser le zoom de l’écran partagé",
|
||||
"zoom-pan": "Se déplacer dans un écran partagé zoomé"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Contrôle",
|
||||
@@ -803,10 +807,14 @@
|
||||
"shift": "Majuscule",
|
||||
"plus": "plus",
|
||||
"hold": "Maintenir {{key}}",
|
||||
"arrows": "Touches fléchées",
|
||||
"plusKey": "Touche plus",
|
||||
"minusKey": "Touche moins",
|
||||
"noShortcut": "Aucun raccourci"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Maintenir {{key}}"
|
||||
"hold": "Maintenir {{key}}",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
@@ -793,7 +793,11 @@
|
||||
"raise-hand": "Hand opsteken of laten zakken",
|
||||
"toggle-chat": "Chat tonen/verbergen",
|
||||
"toggle-participants": "Deelnemers tonen/verbergen",
|
||||
"open-shortcuts-settings": "Sneltoets-instellingen openen"
|
||||
"open-shortcuts-settings": "Sneltoets-instellingen openen",
|
||||
"zoom-in": "Inzoomen op een gedeeld scherm",
|
||||
"zoom-out": "Uitzoomen op een gedeeld scherm",
|
||||
"zoom-reset": "Zoom van het gedeelde scherm herstellen",
|
||||
"zoom-pan": "Navigeren in een ingezoomd gedeeld scherm"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
@@ -803,10 +807,14 @@
|
||||
"shift": "Shift",
|
||||
"plus": "plus",
|
||||
"hold": "Houd {{key}} ingedrukt",
|
||||
"arrows": "Pijltoetsen",
|
||||
"plusKey": "Plus-toets",
|
||||
"minusKey": "Min-toets",
|
||||
"noShortcut": "Geen sneltoets"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Houd {{key}} ingedrukt"
|
||||
"hold": "Houd {{key}} ingedrukt",
|
||||
"arrows": "↑ ↓ ← →"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
|
||||
Reference in New Issue
Block a user