mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 20:27:01 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fce967ac12 | |||
| 09ec37d69a | |||
| 620e860474 | |||
| e3dd317c8a | |||
| 66221eb17f | |||
| bb2a666856 | |||
| e37806242d | |||
| 7c140df11a | |||
| 3abafab65e | |||
| 87b49caf6b | |||
| bd193f0e66 | |||
| 55316851c9 | |||
| e3271f6e0f | |||
| aefdcee54d |
@@ -34,6 +34,10 @@ and this project adheres to
|
|||||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
- ♿️(frontend) focus side panel container on open #1452
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) add screen share zoom controls #1498
|
||||||
|
|
||||||
## [1.23.0] - 2026-07-08
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { MutedMicIndicator } from './MutedMicIndicator'
|
|||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
|
import { ScreenShareZoomableVideo } from './ScreenShareZoomableVideo'
|
||||||
import { ParticipantName } from './ParticipantName'
|
import { ParticipantName } from './ParticipantName'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -55,6 +56,8 @@ interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
|||||||
disableTileControls?: boolean
|
disableTileControls?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOUSE_IDLE_TIME = 3000
|
||||||
|
|
||||||
export const ParticipantTile: (
|
export const ParticipantTile: (
|
||||||
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
props: ParticipantTileExtendedProps & React.RefAttributes<HTMLDivElement>
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||||
@@ -109,14 +112,43 @@ export const ParticipantTile: (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
|
const isRemoteScreenShare =
|
||||||
|
isScreenShare && !trackReference.participant.isLocal
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||||
|
|
||||||
|
// Hover + idle tracking for the focus overlay (pin, effects, mute buttons).
|
||||||
|
const [isTileHovered, setIsTileHovered] = React.useState(false)
|
||||||
|
const [isIdle, setIsIdle] = React.useState(false)
|
||||||
|
const idleTimerRef = React.useRef<number | null>(null)
|
||||||
|
|
||||||
|
const handleTileMouseMove = React.useCallback(() => {
|
||||||
|
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||||
|
idleTimerRef.current = window.setTimeout(
|
||||||
|
() => setIsIdle(true),
|
||||||
|
MOUSE_IDLE_TIME
|
||||||
|
)
|
||||||
|
setIsIdle(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isOverlayVisible = hasKeyboardFocus || (isTileHovered && !isIdle)
|
||||||
|
|
||||||
|
// tileRef: fullscreen target. setRefs merges it with the forwarded ref on the same node.
|
||||||
|
const tileRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
if (typeof ref === 'function') ref(node)
|
||||||
|
else if (ref)
|
||||||
|
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||||
|
},
|
||||||
|
[ref]
|
||||||
|
)
|
||||||
|
|
||||||
const participantName = getParticipantName(trackReference.participant)
|
const participantName = getParticipantName(trackReference.participant)
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
...elementProps,
|
...elementProps,
|
||||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': t('containerLabel', { name: participantName }),
|
'aria-label': t('containerLabel', { name: participantName }),
|
||||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
@@ -134,30 +166,63 @@ export const ParticipantTile: (
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isVideoTrack =
|
||||||
|
isTrackReference(trackReference) &&
|
||||||
|
(trackReference.publication?.kind === 'video' ||
|
||||||
|
trackReference.source === Track.Source.Camera ||
|
||||||
|
trackReference.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
let trackMedia: React.ReactNode = null
|
||||||
|
if (isVideoTrack) {
|
||||||
|
if (isRemoteScreenShare) {
|
||||||
|
trackMedia = (
|
||||||
|
<ScreenShareZoomableVideo
|
||||||
|
trackRef={trackReference}
|
||||||
|
tileRef={tileRef}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
trackMedia = (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
manageSubscription={autoManageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (isTrackReference(trackReference)) {
|
||||||
|
trackMedia = (
|
||||||
|
<AudioTrack
|
||||||
|
trackRef={trackReference}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
<div
|
||||||
|
ref={setRefs}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
{...interactiveProps}
|
||||||
|
onMouseEnter={() => setIsTileHovered(true)}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setIsTileHovered(false)
|
||||||
|
setIsIdle(false)
|
||||||
|
if (idleTimerRef.current) {
|
||||||
|
window.clearTimeout(idleTimerRef.current)
|
||||||
|
idleTimerRef.current = null
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseMove={handleTileMouseMove}
|
||||||
|
>
|
||||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||||
<FullScreenShareWarning trackReference={trackReference} />
|
<FullScreenShareWarning trackReference={trackReference} />
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<>
|
<>
|
||||||
{isTrackReference(trackReference) &&
|
{trackMedia}
|
||||||
(trackReference.publication?.kind === 'video' ||
|
|
||||||
trackReference.source === Track.Source.Camera ||
|
|
||||||
trackReference.source === Track.Source.ScreenShare) ? (
|
|
||||||
<VideoTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
manageSubscription={autoManageSubscription}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
isTrackReference(trackReference) && (
|
|
||||||
<AudioTrack
|
|
||||||
trackRef={trackReference}
|
|
||||||
onSubscriptionStatusChanged={handleSubscribe}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
participant={trackReference.participant}
|
participant={trackReference.participant}
|
||||||
@@ -236,7 +301,7 @@ export const ParticipantTile: (
|
|||||||
{!disableMetadata && !disableTileControls && (
|
{!disableMetadata && !disableTileControls && (
|
||||||
<ParticipantTileFocus
|
<ParticipantTileFocus
|
||||||
trackRef={trackReference}
|
trackRef={trackReference}
|
||||||
hasKeyboardFocus={hasKeyboardFocus}
|
isVisible={isOverlayVisible}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ParticipantContextIfNeeded>
|
</ParticipantContextIfNeeded>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { css } from '@/styled-system/css'
|
|||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import {
|
import {
|
||||||
RiFullscreenLine,
|
|
||||||
RiImageCircleAiFill,
|
RiImageCircleAiFill,
|
||||||
RiMicLine,
|
RiMicLine,
|
||||||
RiMicOffLine,
|
RiMicOffLine,
|
||||||
@@ -15,41 +14,13 @@ import {
|
|||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSidePanel } from '../hooks/useSidePanel'
|
import { useSidePanel } from '../hooks/useSidePanel'
|
||||||
import { useFullScreen } from '../hooks/useFullScreen'
|
|
||||||
import { type Participant, Track } from 'livekit-client'
|
import { type Participant, Track } from 'livekit-client'
|
||||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
|
|
||||||
const ZoomButton = ({
|
|
||||||
trackRef,
|
|
||||||
}: {
|
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
|
||||||
trackRef,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isFullscreenAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primaryTextDark"
|
|
||||||
square
|
|
||||||
tooltip={t('fullScreen')}
|
|
||||||
onPress={() => toggleFullScreen()}
|
|
||||||
>
|
|
||||||
<RiFullscreenLine />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FocusButton = ({
|
const FocusButton = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
}: {
|
}: {
|
||||||
@@ -127,26 +98,17 @@ const MuteButton = ({ participant }: { participant: Participant }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MOUSE_IDLE_TIME = 3000
|
|
||||||
|
|
||||||
export const ParticipantTileFocus = ({
|
export const ParticipantTileFocus = ({
|
||||||
trackRef,
|
trackRef,
|
||||||
hasKeyboardFocus,
|
isVisible,
|
||||||
}: {
|
}: {
|
||||||
trackRef: TrackReferenceOrPlaceholder
|
trackRef: TrackReferenceOrPlaceholder
|
||||||
hasKeyboardFocus: boolean
|
isVisible: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const [hovered, setHovered] = useState(false)
|
|
||||||
const [opacity, setOpacity] = useState(0)
|
const [opacity, setOpacity] = useState(0)
|
||||||
|
|
||||||
const idleTimerRef = useRef<number | null>(null)
|
|
||||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
|
||||||
|
|
||||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
// Wait for next frame to ensure element is mounted
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setOpacity(0.6)
|
setOpacity(0.6)
|
||||||
})
|
})
|
||||||
@@ -155,24 +117,14 @@ export const ParticipantTileFocus = ({
|
|||||||
}
|
}
|
||||||
}, [isVisible])
|
}, [isVisible])
|
||||||
|
|
||||||
const handleMouseMove = () => {
|
|
||||||
if (idleTimerRef.current) {
|
|
||||||
window.clearTimeout(idleTimerRef.current)
|
|
||||||
}
|
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
|
||||||
setIsIdleRef(true)
|
|
||||||
}, MOUSE_IDLE_TIME)
|
|
||||||
setIsIdleRef(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const participant = trackRef.participant
|
const participant = trackRef.participant
|
||||||
|
|
||||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||||
const isLocal = trackRef.participant.isLocal
|
|
||||||
|
|
||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Pointer-events: none so this overlay doesn't block the zoom surface below.
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -183,11 +135,9 @@ export const ParticipantTileFocus = ({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
})}
|
})}
|
||||||
aria-hidden={!isVisible}
|
aria-hidden={!isVisible}
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
>
|
>
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<div
|
<div
|
||||||
@@ -197,6 +147,7 @@ export const ParticipantTileFocus = ({
|
|||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
pointerEvents: 'auto',
|
||||||
_hover: {
|
_hover: {
|
||||||
opacity: '0.95 !important',
|
opacity: '0.95 !important',
|
||||||
},
|
},
|
||||||
@@ -213,7 +164,7 @@ export const ParticipantTileFocus = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<FocusButton trackRef={trackRef} />
|
<FocusButton trackRef={trackRef} />
|
||||||
{!isScreenShare ? (
|
{!isScreenShare && (
|
||||||
<>
|
<>
|
||||||
{participant.isLocal ? (
|
{participant.isLocal ? (
|
||||||
<EffectsButton />
|
<EffectsButton />
|
||||||
@@ -221,8 +172,6 @@ export const ParticipantTileFocus = ({
|
|||||||
canMute && <MuteButton participant={participant} />
|
canMute && <MuteButton participant={participant} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
|
||||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { VideoTrack } from '@livekit/components-react'
|
||||||
|
import { type TrackReference } from '@livekit/components-core'
|
||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface ScreenShareVideoTrackProps {
|
||||||
|
trackRef: TrackReference
|
||||||
|
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
||||||
|
manageSubscription?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zoom/pan updates the wrapper transform only; skip VideoTrack re-renders.
|
||||||
|
export const ScreenShareVideoTrack = memo(
|
||||||
|
({
|
||||||
|
trackRef,
|
||||||
|
onSubscriptionStatusChanged,
|
||||||
|
manageSubscription,
|
||||||
|
}: ScreenShareVideoTrackProps) => (
|
||||||
|
<VideoTrack
|
||||||
|
trackRef={trackRef}
|
||||||
|
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
||||||
|
manageSubscription={manageSubscription}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ScreenShareVideoTrack.displayName = 'ScreenShareVideoTrack'
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import {
|
||||||
|
RiCollapseDiagonalLine,
|
||||||
|
RiExpandDiagonalLine,
|
||||||
|
RiFullscreenExitLine,
|
||||||
|
RiZoomInLine,
|
||||||
|
RiZoomOutLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { isMacintosh } from '@/utils/livekit'
|
||||||
|
import { srOnly } from '@/styles/a11y'
|
||||||
|
|
||||||
|
interface ScreenShareZoomControlsProps {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
isZoomed: boolean
|
||||||
|
zoomPercentage: number
|
||||||
|
canZoomIn: boolean
|
||||||
|
canZoomOut: boolean
|
||||||
|
onZoomIn: () => void
|
||||||
|
onZoomOut: () => void
|
||||||
|
onResetZoom: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenShareZoomControls = ({
|
||||||
|
containerRef,
|
||||||
|
isZoomed,
|
||||||
|
zoomPercentage,
|
||||||
|
canZoomIn,
|
||||||
|
canZoomOut,
|
||||||
|
onZoomIn,
|
||||||
|
onZoomOut,
|
||||||
|
onResetZoom,
|
||||||
|
}: ScreenShareZoomControlsProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Covers Esc and browser UI exits, not just the toolbar button.
|
||||||
|
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const isThisTileFullscreen =
|
||||||
|
document.fullscreenElement === containerRef.current
|
||||||
|
setIsFullscreen(isThisTileFullscreen)
|
||||||
|
|
||||||
|
if (isThisTileFullscreen) {
|
||||||
|
wasThisTileFullscreen.current = true
|
||||||
|
announce(t('fullScreenEntered'), 'assertive')
|
||||||
|
} else if (wasThisTileFullscreen.current) {
|
||||||
|
wasThisTileFullscreen.current = false
|
||||||
|
announce(t('fullScreenExited'), 'assertive')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
|
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||||
|
}, [announce, t, containerRef])
|
||||||
|
|
||||||
|
const toggleFullScreen = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === containerRef.current) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
// Tile container so zoom controls stay visible in fullscreen.
|
||||||
|
await containerRef.current?.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling fullscreen:', error)
|
||||||
|
}
|
||||||
|
}, [containerRef])
|
||||||
|
|
||||||
|
const wheelShortcutVisual = isMacintosh() ? '⌘+scroll' : 'Ctrl+scroll'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '12px',
|
||||||
|
right: '12px',
|
||||||
|
zIndex: 2,
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={0}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('toolbarLabel')}
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primaryDark.50',
|
||||||
|
borderRadius: '2rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: 0.7,
|
||||||
|
transition: 'opacity 200ms linear',
|
||||||
|
_hover: {
|
||||||
|
opacity: 0.95,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className={srOnly}>
|
||||||
|
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||||
|
</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('zoomIn')}
|
||||||
|
isDisabled={!canZoomIn}
|
||||||
|
onPress={onZoomIn}
|
||||||
|
>
|
||||||
|
<RiZoomInLine size={20} />
|
||||||
|
</Button>
|
||||||
|
{isFullscreenAvailable && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primaryTextDark"
|
||||||
|
square
|
||||||
|
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||||
|
onPress={toggleFullScreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<RiCollapseDiagonalLine size={20} />
|
||||||
|
) : (
|
||||||
|
<RiExpandDiagonalLine size={20} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { type TrackReference } from '@livekit/components-core'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useScreenShareZoom } from '../hooks/useScreenShareZoom'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
import { ScreenShareZoomControls } from './ScreenShareZoomControls'
|
||||||
|
import { ScreenShareVideoTrack } from './ScreenShareVideoTrack'
|
||||||
|
|
||||||
|
interface ScreenShareZoomableVideoProps {
|
||||||
|
trackRef: TrackReference
|
||||||
|
tileRef: React.RefObject<HTMLDivElement | null>
|
||||||
|
onSubscriptionStatusChanged: (subscribed: boolean) => void
|
||||||
|
manageSubscription?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScreenShareZoomableVideo = ({
|
||||||
|
trackRef,
|
||||||
|
tileRef,
|
||||||
|
onSubscriptionStatusChanged,
|
||||||
|
manageSubscription,
|
||||||
|
}: ScreenShareZoomableVideoProps) => {
|
||||||
|
const zoom = useScreenShareZoom()
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
if (prevZoomRef.current === zoom.zoomPercentage) return
|
||||||
|
const wasAtDefault = prevZoomRef.current <= 100
|
||||||
|
prevZoomRef.current = zoom.zoomPercentage
|
||||||
|
|
||||||
|
if (wasAtDefault && zoom.isZoomed && !hasAnnouncedPanHint.current) {
|
||||||
|
hasAnnouncedPanHint.current = true
|
||||||
|
announce(t('panHint', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
} else {
|
||||||
|
announce(t('currentZoomLevel', { level: zoom.zoomPercentage }), 'polite')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!zoom.isZoomed) hasAnnouncedPanHint.current = false
|
||||||
|
}, [zoom.zoomPercentage, zoom.isZoomed, announce, t])
|
||||||
|
|
||||||
|
// Attach keyboard listener on the tile container (has tabIndex=0).
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tileRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
return () => el.removeEventListener('keydown', zoom.handleKeyDown)
|
||||||
|
}, [tileRef, zoom.handleKeyDown])
|
||||||
|
|
||||||
|
// Native wheel listener with { passive: false } so preventDefault works.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = zoom.surfaceElRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener('wheel', zoom.handleWheel, { passive: false })
|
||||||
|
return () => el.removeEventListener('wheel', zoom.handleWheel)
|
||||||
|
}, [zoom.handleWheel, zoom.surfaceElRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={zoom.surfaceElRef}
|
||||||
|
className={css({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
})}
|
||||||
|
{...zoom.moveProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={zoom.transformElRef}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScreenShareVideoTrack
|
||||||
|
trackRef={trackRef}
|
||||||
|
onSubscriptionStatusChanged={onSubscriptionStatusChanged}
|
||||||
|
manageSubscription={manageSubscription}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ScreenShareZoomControls
|
||||||
|
containerRef={tileRef}
|
||||||
|
isZoomed={zoom.isZoomed}
|
||||||
|
zoomPercentage={zoom.zoomPercentage}
|
||||||
|
canZoomIn={zoom.canZoomIn}
|
||||||
|
canZoomOut={zoom.canZoomOut}
|
||||||
|
onZoomIn={zoom.zoomIn}
|
||||||
|
onZoomOut={zoom.zoomOut}
|
||||||
|
onResetZoom={zoom.resetZoom}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { RiFullscreenExitLine, RiFullscreenLine } from '@remixicon/react'
|
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
|
||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
@@ -20,12 +20,12 @@ export const FullScreenMenuItem = () => {
|
|||||||
>
|
>
|
||||||
{isCurrentlyFullscreen ? (
|
{isCurrentlyFullscreen ? (
|
||||||
<>
|
<>
|
||||||
<RiFullscreenExitLine size={20} />
|
<RiCollapseDiagonalLine size={20} />
|
||||||
{t('fullscreen.exit')}
|
{t('fullscreen.exit')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RiFullscreenLine size={20} />
|
<RiExpandDiagonalLine size={20} />
|
||||||
{t('fullscreen.enter')}
|
{t('fullscreen.enter')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// 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 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(() => {
|
||||||
|
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(() => {
|
||||||
|
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(() => {
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTransform(false)
|
||||||
|
applyCursor()
|
||||||
|
flush()
|
||||||
|
},
|
||||||
|
[applyTransform, applyCursor, flush]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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]
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(-PAN_STEP, 0)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, PAN_STEP)
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
panBy(0, -PAN_STEP)
|
||||||
|
break
|
||||||
|
case '+':
|
||||||
|
case '=':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomIn()
|
||||||
|
break
|
||||||
|
case '-':
|
||||||
|
e.preventDefault()
|
||||||
|
zoomOut()
|
||||||
|
break
|
||||||
|
case '0':
|
||||||
|
e.preventDefault()
|
||||||
|
resetZoom()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[panBy, zoomIn, zoomOut, resetZoom]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
transformElRef,
|
||||||
|
surfaceElRef,
|
||||||
|
moveProps,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
handleWheel,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -668,6 +668,24 @@
|
|||||||
"muteParticipant": "{{name}} stummschalten",
|
"muteParticipant": "{{name}} stummschalten",
|
||||||
"fullScreen": "Vollbild"
|
"fullScreen": "Vollbild"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Zoom-Steuerung für Bildschirmfreigabe",
|
||||||
|
"zoomIn": "Vergrößern",
|
||||||
|
"zoomInWithShortcut": "Vergrößern ({{shortcut}})",
|
||||||
|
"zoomOut": "Verkleinern",
|
||||||
|
"zoomOutWithShortcut": "Verkleinern ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Steuerung plus Mausrad",
|
||||||
|
"wheelShortcutMac": "Befehl plus Mausrad",
|
||||||
|
"wheelShortcutHint": "Tastenkürzel: Steuerung plus Mausrad zum Vergrößern oder Verkleinern.",
|
||||||
|
"wheelShortcutHintMac": "Tastenkürzel: Befehl plus Mausrad zum Vergrößern oder Verkleinern.",
|
||||||
|
"fitToWindow": "An Fenster anpassen",
|
||||||
|
"fullScreen": "Vollbild",
|
||||||
|
"exitFullScreen": "Vollbild beenden",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Mit der Maus ziehen oder Pfeiltasten zum Navigieren verwenden.",
|
||||||
|
"fullScreenEntered": "Vollbild aktiviert",
|
||||||
|
"fullScreenExited": "Vollbild deaktiviert"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Tastenkürzel",
|
"title": "Tastenkürzel",
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
@@ -667,6 +667,24 @@
|
|||||||
"muteParticipant": "Mute {{name}}",
|
"muteParticipant": "Mute {{name}}",
|
||||||
"fullScreen": "Full screen"
|
"fullScreen": "Full screen"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Screen share zoom controls",
|
||||||
|
"zoomIn": "Zoom in",
|
||||||
|
"zoomInWithShortcut": "Zoom in ({{shortcut}})",
|
||||||
|
"zoomOut": "Zoom out",
|
||||||
|
"zoomOutWithShortcut": "Zoom out ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Control plus scroll wheel",
|
||||||
|
"wheelShortcutMac": "Command plus scroll wheel",
|
||||||
|
"wheelShortcutHint": "Shortcut: Control plus scroll wheel to zoom in or out.",
|
||||||
|
"wheelShortcutHintMac": "Shortcut: Command plus scroll wheel to zoom in or out.",
|
||||||
|
"fitToWindow": "Fit to window",
|
||||||
|
"fullScreen": "Full screen",
|
||||||
|
"exitFullScreen": "Exit full screen",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Drag with mouse or use arrow keys to navigate.",
|
||||||
|
"fullScreenEntered": "Full screen enabled",
|
||||||
|
"fullScreenExited": "Full screen disabled"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Keyboard shortcuts",
|
"title": "Keyboard shortcuts",
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
@@ -667,6 +667,24 @@
|
|||||||
"muteParticipant": "Couper le micro de {{name}}",
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
"fullScreen": "Plein écran"
|
"fullScreen": "Plein écran"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Contrôles de zoom du partage d'écran",
|
||||||
|
"zoomIn": "Zoomer",
|
||||||
|
"zoomInWithShortcut": "Zoomer ({{shortcut}})",
|
||||||
|
"zoomOut": "Dézoomer",
|
||||||
|
"zoomOutWithShortcut": "Dézoomer ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Contrôle plus molette",
|
||||||
|
"wheelShortcutMac": "Commande plus molette",
|
||||||
|
"wheelShortcutHint": "Raccourci : Contrôle plus molette pour zoomer ou dézoomer.",
|
||||||
|
"wheelShortcutHintMac": "Raccourci : Commande plus molette pour zoomer ou dézoomer.",
|
||||||
|
"fitToWindow": "Ajuster à la fenêtre",
|
||||||
|
"fullScreen": "Plein écran",
|
||||||
|
"exitFullScreen": "Quitter le plein écran",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Glissez avec la souris ou utilisez les touches fléchées pour naviguer.",
|
||||||
|
"fullScreenEntered": "Plein écran activé",
|
||||||
|
"fullScreenExited": "Plein écran désactivé"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Raccourcis clavier",
|
"title": "Raccourcis clavier",
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
@@ -667,6 +667,24 @@
|
|||||||
"muteParticipant": "Demp {{name}}",
|
"muteParticipant": "Demp {{name}}",
|
||||||
"fullScreen": "Volledig scherm"
|
"fullScreen": "Volledig scherm"
|
||||||
},
|
},
|
||||||
|
"screenShareZoom": {
|
||||||
|
"toolbarLabel": "Zoombediening voor schermdeling",
|
||||||
|
"zoomIn": "Inzoomen",
|
||||||
|
"zoomInWithShortcut": "Inzoomen ({{shortcut}})",
|
||||||
|
"zoomOut": "Uitzoomen",
|
||||||
|
"zoomOutWithShortcut": "Uitzoomen ({{shortcut}})",
|
||||||
|
"wheelShortcut": "Control plus scrollwiel",
|
||||||
|
"wheelShortcutMac": "Command plus scrollwiel",
|
||||||
|
"wheelShortcutHint": "Sneltoets: Control plus scrollwiel om in of uit te zoomen.",
|
||||||
|
"wheelShortcutHintMac": "Sneltoets: Command plus scrollwiel om in of uit te zoomen.",
|
||||||
|
"fitToWindow": "Aanpassen aan venster",
|
||||||
|
"fullScreen": "Volledig scherm",
|
||||||
|
"exitFullScreen": "Volledig scherm verlaten",
|
||||||
|
"currentZoomLevel": "Zoom {{level}} %",
|
||||||
|
"panHint": "Zoom {{level}} %. Sleep met de muis of gebruik de pijltjestoetsen om te navigeren.",
|
||||||
|
"fullScreenEntered": "Volledig scherm ingeschakeld",
|
||||||
|
"fullScreenExited": "Volledig scherm uitgeschakeld"
|
||||||
|
},
|
||||||
"shortcutsPanel": {
|
"shortcutsPanel": {
|
||||||
"title": "Sneltoetsen",
|
"title": "Sneltoetsen",
|
||||||
"categories": {
|
"categories": {
|
||||||
|
|||||||
Reference in New Issue
Block a user