♻️(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
committed by Ovgodd
parent 8d5736811c
commit 6cbedbd2eb
11 changed files with 567 additions and 238 deletions
@@ -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,
}
}
+28 -1
View File
@@ -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]
+10 -2
View File
@@ -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": {
+10 -2
View File
@@ -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": {
+10 -2
View File
@@ -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": {
+10 -2
View File
@@ -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": {