Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26d97558d4 | |||
| f11aaf62e8 | |||
| 9cced10c64 | |||
| a5457ad853 | |||
| a1795d47ce | |||
| 81b17b941b | |||
| 881e695c1c | |||
| 039e42af0d | |||
| 3d90494cc0 | |||
| 3ba5bf92eb | |||
| f79f488cbc | |||
| ddba0e8b77 | |||
| 9f73c7f07d | |||
| 17f1280d45 | |||
| 27b5b956c6 | |||
| 6b65e1772c | |||
| b81b48b46b | |||
| fd3bbf2122 | |||
| e9f2f98269 | |||
| 2565e57c50 | |||
| c8c3af6c9a |
@@ -56,6 +56,7 @@ and this project adheres to
|
||||
- ✨(summary) add dutch and german languages
|
||||
- 🔧(agents) make Silero VAD optional
|
||||
- 🚸(frontend) explain to a user they were ejected
|
||||
- ✨(frontend) add keyboard shortcuts display
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -31,7 +31,16 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { ShortcutHelpTooltip } from './ShortcutHelpTooltip'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { getShortcutById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { participantLayoutStore } from '@/stores/participantLayout'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -109,6 +118,26 @@ export const ParticipantTile: (
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const openShortcutDefault = getShortcutById('open-shortcuts')?.shortcut
|
||||
const openShortcutEffective =
|
||||
overrides.get('open-shortcuts') ?? openShortcutDefault
|
||||
const openShortcutLabel =
|
||||
formatShortcutLabel(openShortcutEffective) ??
|
||||
`${isMacintosh() ? '⌘' : 'Ctrl'} + /`
|
||||
|
||||
// Check if we should show the shortcut helper tooltip
|
||||
// Only show on the first tile when in grid layout (not in carousel/left column, not in focus mode)
|
||||
const { layoutType, firstGridTileTrackId } = useSnapshot(participantLayoutStore)
|
||||
const currentTrackId = isTrackReference(trackReference)
|
||||
? `${trackReference.participant.sid}-${trackReference.source}`
|
||||
: null
|
||||
|
||||
const shouldShowShortcutHelper =
|
||||
hasKeyboardFocus &&
|
||||
layoutType === 'grid' && // Only show in grid layout
|
||||
currentTrackId === firstGridTileTrackId // Only show on first tile
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
@@ -229,8 +258,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
{shouldShowShortcutHelper && (
|
||||
<ShortcutHelpTooltip
|
||||
triggerLabel={t('toolbarHint', {
|
||||
binding: openShortcutLabel,
|
||||
})}
|
||||
isVisible={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||
import { ShortcutCategorySection } from '@/features/shortcuts/components'
|
||||
import { useFocusTrap } from '@/features/shortcuts/useFocusTrap'
|
||||
import {
|
||||
closeShortcutHelp,
|
||||
shortcutHelpStore,
|
||||
toggleShortcutHelp,
|
||||
} from '@/stores/shortcutHelp'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
type ShortcutHelpTooltipProps = {
|
||||
triggerLabel: string
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
const containerStyle = css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
backgroundColor: 'rgba(0,0,0,0.65)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.25rem',
|
||||
fontSize: '0.875rem',
|
||||
minWidth: '200px',
|
||||
maxWidth: '320px',
|
||||
boxShadow: '0 4px 10px rgba(0,0,0,0.25)',
|
||||
zIndex: 10,
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
'&[data-visible="true"]': {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const triggerStyle = css({
|
||||
all: 'unset',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
gap: '0.5rem',
|
||||
outline: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid rgba(255,255,255,0.5)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
})
|
||||
|
||||
const panelStyle = css({
|
||||
overflow: 'hidden',
|
||||
maxHeight: '0px',
|
||||
opacity: 0,
|
||||
marginTop: '0',
|
||||
transition:
|
||||
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
|
||||
'&[data-open="true"]': {
|
||||
maxHeight: '400px',
|
||||
opacity: 1,
|
||||
marginTop: '0.35rem',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.25rem',
|
||||
},
|
||||
})
|
||||
|
||||
const emptyStateStyle = css({
|
||||
fontSize: '0.85rem',
|
||||
opacity: 0.8,
|
||||
})
|
||||
|
||||
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
|
||||
triggerLabel,
|
||||
isVisible = false,
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { isOpen } = useSnapshot(shortcutHelpStore)
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const isContainerVisible = isVisible || isOpen
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const wasOpenRef = useRef(false)
|
||||
|
||||
useFocusTrap(panelRef, { isActive: isOpen, fallbackRef: panelRef })
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return shortcutCatalog.reduce<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
item: (typeof shortcutCatalog)[number]
|
||||
effective: Shortcut | undefined
|
||||
}[]
|
||||
>
|
||||
>((acc, item) => {
|
||||
const effective = overrides.get(item.id) ?? item.shortcut
|
||||
acc[item.category] = acc[item.category] || []
|
||||
acc[item.category].push({ item, effective })
|
||||
return acc
|
||||
}, {})
|
||||
}, [overrides])
|
||||
|
||||
const entries = Object.entries(grouped)
|
||||
const hasItems = entries.length > 0
|
||||
|
||||
const getCategoryLabel = useCallback(
|
||||
(category: string) => t(`shortcutsPanel.categories.${category}`),
|
||||
[t]
|
||||
)
|
||||
|
||||
const getActionLabel = useCallback(
|
||||
(id: string) => t(`shortcutsPanel.actions.${id}`),
|
||||
[t]
|
||||
)
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
toggleShortcutHelp()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
closeShortcutHelp()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
panelRef.current?.focus()
|
||||
} else if (wasOpenRef.current && !isOpen && triggerRef.current) {
|
||||
triggerRef.current.focus()
|
||||
}
|
||||
wasOpenRef.current = isOpen
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<div data-visible={isContainerVisible} className={containerStyle}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="shortcut-help-panel"
|
||||
onClick={handleToggle}
|
||||
ref={triggerRef}
|
||||
className={triggerStyle}
|
||||
>
|
||||
<span>{triggerLabel}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})}
|
||||
>
|
||||
{isOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id="shortcut-help-panel"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="shortcut-help-title"
|
||||
data-open={isOpen}
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={panelStyle}
|
||||
>
|
||||
<h2 id="shortcut-help-title" className="sr-only">
|
||||
{t('shortcutsPanel.title')}
|
||||
</h2>
|
||||
{hasItems ? (
|
||||
entries.map(([category, items]) => (
|
||||
<ShortcutCategorySection
|
||||
key={category}
|
||||
category={category}
|
||||
items={items}
|
||||
getCategoryLabel={getCategoryLabel}
|
||||
getActionLabel={getActionLabel}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className={emptyStateStyle}>
|
||||
{t('shortcutsPanel.noShortcuts')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ToggleButton } from '@/primitives'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ChatToggle = ({
|
||||
onPress,
|
||||
@@ -18,6 +19,11 @@ export const ChatToggle = ({
|
||||
const { isChatOpen, toggleChat } = useSidePanel()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-chat',
|
||||
handler: async () => toggleChat(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
+18
-4
@@ -1,7 +1,10 @@
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||
import {
|
||||
appendShortcutLabel,
|
||||
getEffectiveShortcut,
|
||||
} from '@/features/shortcuts/utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||
@@ -16,8 +19,10 @@ import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||
import { getShortcutById } from '@/features/shortcuts/catalog'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { shortcutOverridesStore } from '@/stores/shortcutOverrides'
|
||||
|
||||
type ToggleDeviceStyleProps = {
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
@@ -85,10 +90,12 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const shortcutId =
|
||||
kind === 'audioinput' ? 'toggle-microphone' : 'toggle-camera'
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: deviceShortcut,
|
||||
shortcutId,
|
||||
handler: async () => await toggle(),
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
@@ -99,6 +106,13 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
|
||||
const deviceShortcut = useMemo(() => {
|
||||
const override = overrides.get(shortcutId)
|
||||
if (override) return override
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
return catalogItem?.shortcut
|
||||
}, [shortcutId, overrides])
|
||||
|
||||
const toggleLabel = useMemo(() => {
|
||||
const label = t(enabled ? 'disable' : 'enable', {
|
||||
keyPrefix: `selectDevice.${kind}`,
|
||||
|
||||
+9
@@ -17,6 +17,7 @@ import { SelectDevice } from './SelectDevice'
|
||||
import { SettingsButton } from './SettingsButton'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||
@@ -99,6 +100,14 @@ export const VideoDeviceControl = ({
|
||||
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||
const canPublishTrack = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'V', ctrlKey: true, shiftKey: true },
|
||||
handler: async () => {
|
||||
if (!canPublishTrack || cannotUseDevice) return
|
||||
await toggleWithProcessor()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
closeLowerHandToasts,
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
@@ -28,6 +29,19 @@ export const HandToggle = () => {
|
||||
setHasShownToast(false)
|
||||
}
|
||||
|
||||
const handleToggle = () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'raise-hand',
|
||||
handler: async () => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isHandRaised) return
|
||||
closeLowerHandToasts()
|
||||
@@ -68,10 +82,7 @@ export const HandToggle = () => {
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={() => {
|
||||
toggleRaisedHand()
|
||||
resetToastState()
|
||||
}}
|
||||
onPress={handleToggle}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
+6
@@ -6,6 +6,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
@@ -27,6 +28,11 @@ export const ParticipantsToggle = ({
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-participants',
|
||||
handler: async () => toggleParticipants(),
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
||||
import { Participant } from 'livekit-client'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export enum Emoji {
|
||||
@@ -41,6 +42,11 @@ export const ReactionsToggle = () => {
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'reaction',
|
||||
handler: async () => setIsVisible((prev) => !prev),
|
||||
})
|
||||
|
||||
const sendReaction = async (emoji: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { setCarouselLayout, resetLayout } from '@/stores/participantLayout'
|
||||
|
||||
const MIN_HEIGHT = 130
|
||||
const MIN_WIDTH = 140
|
||||
@@ -79,6 +80,15 @@ export function CarouselLayout({
|
||||
}
|
||||
}, [maxVisibleTiles, carouselOrientation])
|
||||
|
||||
// Update store when carousel layout is active
|
||||
React.useEffect(() => {
|
||||
setCarouselLayout()
|
||||
|
||||
return () => {
|
||||
resetLayout()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside
|
||||
key={carouselOrientation}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { mergeProps } from '@/utils/mergeProps'
|
||||
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
||||
import { useGridLayout } from '../../hooks/useGridLayout'
|
||||
import { PaginationControl } from '../controls/PaginationControl'
|
||||
import { setGridLayout, resetLayout } from '@/stores/participantLayout'
|
||||
import { isTrackReference } from '@livekit/components-core'
|
||||
|
||||
/** @public */
|
||||
export interface GridLayoutProps
|
||||
@@ -35,6 +37,11 @@ export interface GridLayoutProps
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
const getTrackId = (trackRef: TrackReferenceOrPlaceholder): string | null => {
|
||||
if (!isTrackReference(trackRef)) return null
|
||||
return `${trackRef.participant.sid}-${trackRef.source}`
|
||||
}
|
||||
|
||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
const gridEl = React.createRef<HTMLDivElement>()
|
||||
|
||||
@@ -50,6 +57,17 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
onRightSwipe: pagination.prevPage,
|
||||
})
|
||||
|
||||
// Update store when tracks change - set first tile ID for grid layout
|
||||
React.useEffect(() => {
|
||||
const firstTrack = pagination.tracks[0]
|
||||
const firstTrackId = firstTrack ? getTrackId(firstTrack) : null
|
||||
setGridLayout(firstTrackId)
|
||||
|
||||
return () => {
|
||||
resetLayout()
|
||||
}
|
||||
}, [pagination.tracks])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridEl}
|
||||
|
||||
@@ -12,17 +12,33 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { openShortcutHelp } from '@/stores/shortcutHelp'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
const { toggleParticipants, toggleChat, openScreenRecording } = useSidePanel()
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'F2' },
|
||||
shortcutId: 'open-shortcuts',
|
||||
handler: () => {
|
||||
openShortcutHelp()
|
||||
},
|
||||
})
|
||||
|
||||
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'focus-toolbar',
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
@@ -32,6 +48,34 @@ export function DesktopControlBar({
|
||||
firstButton?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-participants',
|
||||
handler: () => toggleParticipants(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'toggle-chat',
|
||||
handler: () => toggleChat(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'fullscreen',
|
||||
handler: () => {
|
||||
if (!isFullscreenAvailable) return
|
||||
toggleFullScreen()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'recording',
|
||||
handler: () => openScreenRecording(),
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcutId: 'open-shortcuts-settings',
|
||||
handler: () => openSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS),
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Heading } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAccountCircleLine,
|
||||
RiKeyboardBoxLine,
|
||||
RiNotification3Line,
|
||||
RiSettings3Line,
|
||||
RiSpeakerLine,
|
||||
@@ -23,6 +24,7 @@ import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import ShortcutTab from './tabs/ShortcutTab'
|
||||
import AccessibilityTab from './tabs/AccessibilityTab'
|
||||
|
||||
const tabsStyle = css({
|
||||
@@ -107,6 +109,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
</Tab>
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
|
||||
<RiKeyboardBoxLine />
|
||||
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
|
||||
</Tab>
|
||||
{isAdminOrOwner && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||
<Icon type="symbols" name="speech_to_text" />
|
||||
@@ -130,6 +136,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Edit and reset feature, not used yet
|
||||
*
|
||||
* This component handles edit and reset actions for keyboard shortcuts.
|
||||
* To use it, uncomment the import and usage in ShortcutTab.tsx
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
||||
import { removeOverride, setOverride } from '@/stores/shortcutOverrides'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' })
|
||||
|
||||
export interface ShortcutEditActionsProps {
|
||||
shortcutId: string
|
||||
actionLabel: string
|
||||
srShortcut: string
|
||||
hasOverride: boolean
|
||||
onConfirmationChange?: (message: string) => void
|
||||
}
|
||||
|
||||
export const ShortcutEditActions = ({
|
||||
shortcutId,
|
||||
actionLabel,
|
||||
srShortcut,
|
||||
hasOverride,
|
||||
onConfirmationChange,
|
||||
}: ShortcutEditActionsProps) => {
|
||||
const { t } = useTranslation(['settings'])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const handleStartEdit = useCallback(() => {
|
||||
setEditingId(shortcutId)
|
||||
}, [shortcutId])
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setEditingId(null)
|
||||
}, [])
|
||||
|
||||
const handleEditButtonClick = useCallback(() => {
|
||||
// If already in edit mode, cancel it
|
||||
if (editingId === shortcutId) {
|
||||
handleCancelEdit()
|
||||
return
|
||||
}
|
||||
// Otherwise, start edit mode
|
||||
handleStartEdit()
|
||||
}, [editingId, shortcutId, handleCancelEdit, handleStartEdit])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
removeOverride(shortcutId)
|
||||
const message = t('shortcutsEditor.resetConfirmation', {
|
||||
defaultValue: 'Shortcut reset',
|
||||
})
|
||||
onConfirmationChange?.(message)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onConfirmationChange?.('')
|
||||
timeoutRef.current = null
|
||||
}, 3000)
|
||||
}, [shortcutId, t, onConfirmationChange])
|
||||
|
||||
const handleKeyCapture = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
const { key, ctrlKey, shiftKey, altKey, metaKey } = e
|
||||
// Ignore modifier-only keys
|
||||
if (
|
||||
!key ||
|
||||
key === 'Control' ||
|
||||
key === 'Meta' ||
|
||||
key === 'Shift' ||
|
||||
key === 'Alt' ||
|
||||
key === 'Tab' ||
|
||||
key === 'Escape'
|
||||
)
|
||||
return
|
||||
const normalized: Shortcut = {
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
}
|
||||
setOverride(shortcutId, normalized)
|
||||
setEditingId(null)
|
||||
const message = t('shortcutsEditor.modifiedConfirmation', {
|
||||
defaultValue: 'Shortcut modified',
|
||||
})
|
||||
onConfirmationChange?.(message)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
onConfirmationChange?.('')
|
||||
timeoutRef.current = null
|
||||
}, 3000)
|
||||
},
|
||||
[shortcutId, t, onConfirmationChange]
|
||||
)
|
||||
|
||||
const handleEditButtonKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
// If already in edit mode, capture the key
|
||||
if (editingId === shortcutId) {
|
||||
handleKeyCapture(e)
|
||||
return
|
||||
}
|
||||
// Otherwise, if it's Enter or Space, start edit mode (like a click)
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleStartEdit()
|
||||
}
|
||||
},
|
||||
[editingId, shortcutId, handleKeyCapture, handleStartEdit]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const editButtonLabel =
|
||||
editingId === shortcutId
|
||||
? t('shortcutsEditor.capture')
|
||||
: t('shortcutsEditor.edit')
|
||||
const editButtonAriaLabel =
|
||||
editingId === shortcutId
|
||||
? t('shortcutsEditor.captureAria', {
|
||||
defaultValue: 'Press keys to set shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
: t('shortcutsEditor.editAria', {
|
||||
defaultValue: 'Edit shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
const resetButtonAriaLabel = t('shortcutsEditor.resetAria', {
|
||||
defaultValue: 'Reset shortcut for {{action}}',
|
||||
action: actionLabel,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t('shortcutsEditor.actionsGroupAria', {
|
||||
defaultValue: 'Actions for {{action}}',
|
||||
action: actionLabel,
|
||||
})}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.35rem',
|
||||
justifyContent: 'flex-end',
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonLink}
|
||||
onKeyDown={handleEditButtonKeyDown}
|
||||
onClick={handleEditButtonClick}
|
||||
aria-pressed={editingId === shortcutId}
|
||||
aria-label={editButtonAriaLabel}
|
||||
aria-describedby={`shortcut-${shortcutId}-description`}
|
||||
>
|
||||
{editButtonLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonLink}
|
||||
onClick={handleReset}
|
||||
aria-disabled={!hasOverride}
|
||||
disabled={!hasOverride}
|
||||
aria-label={resetButtonAriaLabel}
|
||||
aria-describedby={`shortcut-${shortcutId}-description`}
|
||||
style={{ opacity: !hasOverride ? 0.5 : 1 }}
|
||||
>
|
||||
{t('shortcutsEditor.reset')}
|
||||
</button>
|
||||
<span id={`shortcut-${shortcutId}-description`} className="sr-only">
|
||||
{t('shortcutsEditor.currentShortcut', {
|
||||
defaultValue: 'Current shortcut: {{shortcut}}',
|
||||
shortcut: srShortcut,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||
import { ShortcutRow } from '@/features/shortcuts/components'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
|
||||
const { t } = useTranslation(['settings', 'rooms'])
|
||||
const tRooms = useCallback(
|
||||
(key: string, options?: Record<string, unknown>) =>
|
||||
t(key, { ns: 'rooms', ...options }),
|
||||
[t]
|
||||
)
|
||||
useEffect(() => {
|
||||
loadShortcutOverrides()
|
||||
}, [])
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return shortcutCatalog.map((item) => {
|
||||
const override = overrides.get(item.id)
|
||||
const effectiveShortcut = override ?? item.shortcut
|
||||
return {
|
||||
item,
|
||||
override,
|
||||
effectiveShortcut,
|
||||
}
|
||||
})
|
||||
}, [overrides])
|
||||
|
||||
return (
|
||||
<TabPanel
|
||||
id={id}
|
||||
padding="md"
|
||||
flex
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<div className={text({ variant: 'h2' })}>{t('tabs.shortcuts')}</div>
|
||||
<div
|
||||
role="list"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
aria-label={t('shortcutsEditor.listLabel', {
|
||||
defaultValue: 'List of keyboard shortcuts',
|
||||
})}
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
maxHeight: '420px',
|
||||
overflowY: 'auto',
|
||||
paddingRight: '0.35rem',
|
||||
outline: 'none',
|
||||
'&:focus-visible': {
|
||||
outline: '2px solid rgba(255,255,255,0.5)',
|
||||
outlineOffset: '2px',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{rows.map(({ item, override, effectiveShortcut }) => (
|
||||
<ShortcutRow
|
||||
key={item.id}
|
||||
descriptor={item}
|
||||
effectiveShortcut={effectiveShortcut}
|
||||
override={override}
|
||||
actionLabel={tRooms(`shortcutsPanel.actions.${item.id}`)}
|
||||
customLabel={t('shortcutsEditor.custom')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShortcutTab
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { text } from '@/primitives/Text'
|
||||
|
||||
/**
|
||||
* Hook to manage confirmation messages for edit/reset actions
|
||||
* Use in the parent component to display confirmation messages
|
||||
*/
|
||||
export const useShortcutConfirmation = () => {
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string>('')
|
||||
|
||||
const confirmationElement = useMemo(() => {
|
||||
if (!confirmationMessage) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={text({ variant: 'smNote' })}
|
||||
>
|
||||
{confirmationMessage}
|
||||
</div>
|
||||
)
|
||||
}, [confirmationMessage])
|
||||
|
||||
return {
|
||||
confirmationMessage,
|
||||
setConfirmationMessage,
|
||||
ConfirmationMessage: confirmationElement,
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey {
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
SHORTCUTS = 'shortcuts',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Shortcut } from './types'
|
||||
|
||||
// Central list of current keyboard shortcuts. This will feed the future
|
||||
// tooltip/panel so there is a single source of truth for display and, later,
|
||||
// customization.
|
||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
| 'push-to-talk'
|
||||
| 'reaction'
|
||||
| 'fullscreen'
|
||||
| 'recording'
|
||||
| 'raise-hand'
|
||||
| 'toggle-chat'
|
||||
| 'toggle-participants'
|
||||
| 'open-shortcuts-settings'
|
||||
|
||||
export const getShortcutById = (id: ShortcutId) =>
|
||||
shortcutCatalog.find((item) => item.id === id)
|
||||
|
||||
export type ShortcutDescriptor = {
|
||||
id: ShortcutId
|
||||
category: ShortcutCategory
|
||||
shortcut?: Shortcut
|
||||
kind?: 'press' | 'longPress'
|
||||
code?: string // used when kind === 'longPress' (KeyboardEvent.code)
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
{
|
||||
id: 'open-shortcuts',
|
||||
category: 'navigation',
|
||||
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'F2' },
|
||||
},
|
||||
{
|
||||
id: 'toggle-microphone',
|
||||
category: 'media',
|
||||
shortcut: { key: 'd', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-camera',
|
||||
category: 'media',
|
||||
shortcut: { key: 'e', ctrlKey: true },
|
||||
},
|
||||
{
|
||||
id: 'push-to-talk',
|
||||
category: 'media',
|
||||
kind: 'longPress',
|
||||
code: 'KeyV',
|
||||
},
|
||||
{
|
||||
id: 'reaction',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'E', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'fullscreen',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'F', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'recording',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'L', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'raise-hand',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'H', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-chat',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'M', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'toggle-participants',
|
||||
category: 'interaction',
|
||||
shortcut: { key: 'P', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'open-shortcuts-settings',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'K', ctrlKey: true, altKey: true },
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { text } from '@/primitives/Text'
|
||||
|
||||
type ShortcutBadgeProps = {
|
||||
visualLabel: string
|
||||
isCustom?: boolean
|
||||
customLabel?: string
|
||||
srLabel?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const badgeStyle = css({
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingInline: '0.4rem',
|
||||
paddingBlock: '0.2rem',
|
||||
borderRadius: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: '5.5rem',
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
|
||||
visualLabel,
|
||||
isCustom = false,
|
||||
customLabel,
|
||||
srLabel,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
<span aria-hidden="true">{visualLabel}</span>
|
||||
{isCustom && customLabel && (
|
||||
<span
|
||||
className={text({ variant: 'smNote' })}
|
||||
style={{ marginLeft: '0.4rem' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
({customLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ShortcutDescriptor } from '../catalog'
|
||||
import { Shortcut } from '../types'
|
||||
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
|
||||
|
||||
type ShortcutCategorySectionProps = {
|
||||
category: string
|
||||
items: Array<{ item: ShortcutDescriptor; effective: Shortcut | undefined }>
|
||||
getCategoryLabel: (category: string) => string
|
||||
getActionLabel: (id: string) => string
|
||||
}
|
||||
|
||||
const sectionStyle = css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.35rem',
|
||||
mb: '0.5rem',
|
||||
})
|
||||
|
||||
const headingStyle = css({
|
||||
textTransform: 'capitalize',
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})
|
||||
|
||||
const listStyle = css({
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
})
|
||||
|
||||
const listItemStyle = css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.85rem',
|
||||
})
|
||||
|
||||
const badgeStyle = css({
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingInline: '0.35rem',
|
||||
paddingBlock: '0.15rem',
|
||||
borderRadius: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
})
|
||||
|
||||
export const ShortcutCategorySection: React.FC<
|
||||
ShortcutCategorySectionProps
|
||||
> = ({ category, items, getCategoryLabel, getActionLabel }) => {
|
||||
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting()
|
||||
|
||||
return (
|
||||
<section className={sectionStyle}>
|
||||
<h3
|
||||
id={`shortcut-section-${category}`}
|
||||
data-shortcuts-heading
|
||||
tabIndex={-1}
|
||||
className={headingStyle}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</h3>
|
||||
<ul
|
||||
aria-labelledby={`shortcut-section-${category}`}
|
||||
className={listStyle}
|
||||
>
|
||||
{items.map(({ item, effective }) => {
|
||||
const visualShortcut = formatVisual(
|
||||
effective,
|
||||
item.code,
|
||||
item.kind === 'longPress' ? getHoldTemplate('visual') : undefined
|
||||
)
|
||||
const srShortcut = formatForSR(effective, item.code)
|
||||
const actionLabel = getActionLabel(item.id)
|
||||
|
||||
return (
|
||||
<li key={item.id} className={listItemStyle}>
|
||||
<span>
|
||||
{actionLabel}
|
||||
<span className="sr-only">, {srShortcut}</span>
|
||||
</span>
|
||||
<span aria-hidden className={badgeStyle}>
|
||||
{visualShortcut}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { text } from '@/primitives/Text'
|
||||
import { ShortcutDescriptor } from '../catalog'
|
||||
import { Shortcut } from '../types'
|
||||
import { ShortcutBadge } from './ShortcutBadge'
|
||||
import { useShortcutFormatting } from '../hooks/useShortcutFormatting'
|
||||
|
||||
type ShortcutRowProps = {
|
||||
descriptor: ShortcutDescriptor
|
||||
effectiveShortcut?: Shortcut
|
||||
override?: Shortcut
|
||||
actionLabel: string
|
||||
customLabel: string
|
||||
}
|
||||
|
||||
const rowStyle = css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1.25fr auto',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.65rem 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
})
|
||||
|
||||
export const ShortcutRow: React.FC<ShortcutRowProps> = ({
|
||||
descriptor,
|
||||
effectiveShortcut,
|
||||
override,
|
||||
actionLabel,
|
||||
customLabel,
|
||||
}) => {
|
||||
const { formatVisual, formatForSR, getHoldTemplate } = useShortcutFormatting({
|
||||
namespace: 'rooms',
|
||||
})
|
||||
|
||||
const visualShortcut = formatVisual(
|
||||
effectiveShortcut,
|
||||
descriptor.code,
|
||||
descriptor.kind === 'longPress' ? getHoldTemplate('visual') : undefined
|
||||
)
|
||||
const srShortcut = formatForSR(effectiveShortcut, descriptor.code)
|
||||
const srCustomLabel = override ? ` (${customLabel})` : ''
|
||||
|
||||
return (
|
||||
<div role="listitem" className={rowStyle}>
|
||||
<div className={text({ variant: 'body' })}>{actionLabel}</div>
|
||||
<ShortcutBadge
|
||||
visualLabel={visualShortcut}
|
||||
isCustom={!!override}
|
||||
customLabel={customLabel}
|
||||
srLabel={`${srShortcut}${srCustomLabel}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ShortcutBadge } from './ShortcutBadge'
|
||||
export { ShortcutRow } from './ShortcutRow'
|
||||
export { ShortcutCategorySection } from './ShortcutCategorySection'
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Shortcut } from './types'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
|
||||
// Visible label for a shortcut (uses ⌘/Ctrl prefix when needed).
|
||||
export const formatShortcutLabel = (shortcut?: Shortcut) => {
|
||||
if (!shortcut) return '—'
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
if (!key) return '—'
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||
if (shortcut.altKey) parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||
if (shortcut.shiftKey) parts.push('Shift')
|
||||
parts.push(key)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
// SR-friendly label for a shortcut (reads “Control plus D”).
|
||||
export const formatShortcutLabelForSR = (
|
||||
shortcut: Shortcut | undefined,
|
||||
{
|
||||
controlLabel,
|
||||
commandLabel,
|
||||
plusLabel,
|
||||
noShortcutLabel,
|
||||
}: {
|
||||
controlLabel: string
|
||||
commandLabel: string
|
||||
plusLabel: string
|
||||
noShortcutLabel: string
|
||||
}
|
||||
) => {
|
||||
if (!shortcut) return noShortcutLabel
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
if (!key) return noShortcutLabel
|
||||
const ctrlWord = isMacintosh() ? commandLabel : controlLabel
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) parts.push(ctrlWord)
|
||||
if (shortcut.altKey) parts.push('Alt')
|
||||
if (shortcut.shiftKey) parts.push('Shift')
|
||||
parts.push(key)
|
||||
return parts.join(` ${plusLabel} `)
|
||||
}
|
||||
|
||||
// Extract displayable key name from KeyboardEvent.code (ex: KeyV -> V).
|
||||
export const getKeyLabelFromCode = (code?: string) => {
|
||||
if (!code) return ''
|
||||
if (code.startsWith('Key') && code.length === 4) return code.slice(3)
|
||||
if (code.startsWith('Digit') && code.length === 6) return code.slice(5)
|
||||
if (code === 'Space') return '␣'
|
||||
if (code.startsWith('Arrow')) return code.slice(5) // Up, Down, Left, Right
|
||||
return code
|
||||
}
|
||||
|
||||
// Long-press label (visual or SR), e.g. “Hold V”.
|
||||
export const formatLongPressLabel = (
|
||||
codeLabel: string,
|
||||
holdTemplate: string
|
||||
) => {
|
||||
if (!codeLabel) return holdTemplate.replace('{{key}}', '?')
|
||||
return holdTemplate.replace('{{key}}', codeLabel)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { useShortcutFormatting } from './useShortcutFormatting'
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Shortcut } from '../types'
|
||||
import {
|
||||
formatShortcutLabel,
|
||||
formatShortcutLabelForSR,
|
||||
formatLongPressLabel,
|
||||
getKeyLabelFromCode,
|
||||
} from '../formatLabels'
|
||||
|
||||
type UseShortcutFormattingOptions = {
|
||||
namespace?: string
|
||||
}
|
||||
|
||||
export const useShortcutFormatting = (
|
||||
options: UseShortcutFormattingOptions = {}
|
||||
) => {
|
||||
const { namespace = 'rooms' } = options
|
||||
const { t } = useTranslation(namespace)
|
||||
|
||||
const formatVisual = useCallback(
|
||||
(shortcut?: Shortcut, code?: string, holdTemplate?: string) => {
|
||||
if (code && holdTemplate) {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return formatLongPressLabel(label, holdTemplate)
|
||||
}
|
||||
return formatShortcutLabel(shortcut)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const formatForSR = useCallback(
|
||||
(shortcut?: Shortcut, code?: string) => {
|
||||
if (code) {
|
||||
const label = getKeyLabelFromCode(code)
|
||||
return formatLongPressLabel(
|
||||
label,
|
||||
t('shortcutsPanel.sr.hold', { key: '{{key}}' })
|
||||
)
|
||||
}
|
||||
return formatShortcutLabelForSR(shortcut, {
|
||||
controlLabel: t('shortcutsPanel.sr.control'),
|
||||
commandLabel: t('shortcutsPanel.sr.command'),
|
||||
plusLabel: t('shortcutsPanel.sr.plus'),
|
||||
noShortcutLabel: t('shortcutsPanel.sr.noShortcut'),
|
||||
})
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const getHoldTemplate = useCallback(
|
||||
(type: 'visual' | 'sr' = 'visual') => {
|
||||
return t(`shortcutsPanel.${type}.hold`, { key: '{{key}}' })
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
return {
|
||||
formatVisual,
|
||||
formatForSR,
|
||||
getHoldTemplate,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Shortcut = {
|
||||
key: string
|
||||
ctrlKey?: boolean
|
||||
shiftKey?: boolean
|
||||
altKey?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { RefObject, useEffect } from 'react'
|
||||
|
||||
type UseFocusTrapOptions = {
|
||||
isActive: boolean
|
||||
fallbackRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
const focusableSelector =
|
||||
'button:not([disabled]):not([hidden]):not([aria-hidden="true"]), [href]:not([disabled]):not([hidden]):not([aria-hidden="true"]), input:not([disabled]):not([hidden]):not([aria-hidden="true"]), select:not([disabled]):not([hidden]):not([aria-hidden="true"]), textarea:not([disabled]):not([hidden]):not([aria-hidden="true"]), [tabindex]:not([tabindex="-1"]):not([disabled]):not([hidden]):not([aria-hidden="true"])'
|
||||
|
||||
// Adds a simple focus trap on the given container: Tab/Shift+Tab loop inside.
|
||||
export const useFocusTrap = (
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
{ isActive, fallbackRef }: UseFocusTrapOptions
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
const focusable =
|
||||
container.querySelectorAll<HTMLElement>(focusableSelector)
|
||||
const fallback = fallbackRef?.current ?? container
|
||||
|
||||
if (focusable.length === 0) {
|
||||
e.preventDefault()
|
||||
fallback.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const active = document.activeElement
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (active === first || active === container) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (active === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
container.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [containerRef, fallbackRef, isActive])
|
||||
}
|
||||
@@ -3,19 +3,23 @@ import { useSnapshot } from 'valtio'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { formatShortcutKey } from './utils'
|
||||
import { loadShortcutOverrides } from '@/stores/shortcutOverrides'
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const shortcutsSnap = useSnapshot(keyboardShortcutsStore)
|
||||
|
||||
useEffect(() => {
|
||||
loadShortcutOverrides()
|
||||
// This approach handles basic shortcuts but isn't comprehensive.
|
||||
// Issues might occur. First draft.
|
||||
const onKeyDown = async (e: KeyboardEvent) => {
|
||||
const { key, metaKey, ctrlKey } = e
|
||||
const { key, metaKey, ctrlKey, shiftKey, altKey } = e
|
||||
if (!key) return
|
||||
const shortcutKey = formatShortcutKey({
|
||||
key,
|
||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut) return
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { keyboardShortcutsStore } from '@/stores/keyboardShortcuts'
|
||||
import { formatShortcutKey } from '@/features/shortcuts/utils'
|
||||
import {
|
||||
formatShortcutKey,
|
||||
getEffectiveShortcut,
|
||||
} from '@/features/shortcuts/utils'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { ShortcutId, getShortcutById } from './catalog'
|
||||
import {
|
||||
getOverride,
|
||||
loadShortcutOverrides,
|
||||
shortcutOverridesStore,
|
||||
} from '@/stores/shortcutOverrides'
|
||||
|
||||
export type useRegisterKeyboardShortcutProps = {
|
||||
shortcut?: Shortcut
|
||||
shortcutId?: ShortcutId
|
||||
fallbackShortcut?: Shortcut
|
||||
handler: () => Promise<void | boolean | undefined> | void
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useRegisterKeyboardShortcut = ({
|
||||
shortcut,
|
||||
shortcutId,
|
||||
fallbackShortcut,
|
||||
handler,
|
||||
isDisabled = false,
|
||||
}: useRegisterKeyboardShortcutProps) => {
|
||||
loadShortcutOverrides()
|
||||
const { overrides } = useSnapshot(shortcutOverridesStore)
|
||||
const previousKeyRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shortcut) return
|
||||
const formattedKey = formatShortcutKey(shortcut)
|
||||
let effectiveShortcut: Shortcut | undefined
|
||||
|
||||
if (shortcutId) {
|
||||
// Try override first, then fallback to catalog default
|
||||
effectiveShortcut = getOverride(shortcutId)
|
||||
if (!effectiveShortcut) {
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
effectiveShortcut = catalogItem?.shortcut
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to provided shortcuts if no shortcutId or catalog item found
|
||||
effectiveShortcut = effectiveShortcut || fallbackShortcut || shortcut
|
||||
|
||||
if (!effectiveShortcut) {
|
||||
// Clean up previous shortcut if exists
|
||||
if (previousKeyRef.current) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
previousKeyRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const formattedKey = formatShortcutKey(effectiveShortcut)
|
||||
|
||||
// Clean up previous shortcut if the key changed
|
||||
if (previousKeyRef.current && previousKeyRef.current !== formattedKey) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||
previousKeyRef.current = null
|
||||
} else {
|
||||
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||
previousKeyRef.current = formattedKey
|
||||
}
|
||||
}, [handler, shortcut, isDisabled])
|
||||
|
||||
// Cleanup function: remove shortcut when component unmounts or dependencies change
|
||||
return () => {
|
||||
if (previousKeyRef.current) {
|
||||
keyboardShortcutsStore.shortcuts.delete(previousKeyRef.current)
|
||||
previousKeyRef.current = null
|
||||
}
|
||||
}
|
||||
}, [handler, shortcutId, shortcut, fallbackShortcut, isDisabled, overrides])
|
||||
}
|
||||
|
||||
@@ -1,18 +1,50 @@
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
import { ShortcutId, ShortcutDescriptor } from './catalog'
|
||||
|
||||
export const CTRL = 'ctrl'
|
||||
|
||||
export const formatShortcutKey = (shortcut: Shortcut) => {
|
||||
if (shortcut.ctrlKey) return `${CTRL}+${shortcut.key.toUpperCase()}`
|
||||
return shortcut.key.toUpperCase()
|
||||
const parts = []
|
||||
if (shortcut.ctrlKey) parts.push(CTRL)
|
||||
if (shortcut.altKey) parts.push('alt')
|
||||
if (shortcut.shiftKey) parts.push('shift')
|
||||
parts.push(shortcut.key.toUpperCase())
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
export const appendShortcutLabel = (label: string, shortcut: Shortcut) => {
|
||||
if (!shortcut.key) return
|
||||
let formattedKeyLabel = shortcut.key.toLowerCase()
|
||||
const parts: string[] = []
|
||||
if (shortcut.ctrlKey) {
|
||||
formattedKeyLabel = `${isMacintosh() ? '⌘' : 'Ctrl'}+${formattedKeyLabel}`
|
||||
parts.push(isMacintosh() ? '⌘' : 'Ctrl')
|
||||
}
|
||||
if (shortcut.altKey) {
|
||||
parts.push(isMacintosh() ? '⌥' : 'Alt')
|
||||
}
|
||||
if (shortcut.shiftKey) {
|
||||
parts.push('Shift')
|
||||
}
|
||||
parts.push(shortcut.key.toLowerCase())
|
||||
const formattedKeyLabel = parts.join('+')
|
||||
return `${label} (${formattedKeyLabel})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective shortcut for a given shortcutId by checking overrides first,
|
||||
* then falling back to the catalog default.
|
||||
* @param shortcutId - The shortcut identifier
|
||||
* @param overrides - Map of shortcut overrides
|
||||
* @param getShortcutById - Function to lookup shortcuts from the catalog
|
||||
* @returns The effective shortcut (override if present, otherwise catalog default)
|
||||
*/
|
||||
export const getEffectiveShortcut = (
|
||||
shortcutId: ShortcutId,
|
||||
overrides: Map<string, Shortcut>,
|
||||
getShortcutById: (id: ShortcutId) => ShortcutDescriptor | undefined
|
||||
): Shortcut | undefined => {
|
||||
const override = overrides.get(shortcutId)
|
||||
if (override) return override
|
||||
const catalogItem = getShortcutById(shortcutId)
|
||||
return catalogItem?.shortcut
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
||||
"toolbarHint": "{{binding}}: Tastenkürzel anzeigen.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
@@ -593,6 +593,38 @@
|
||||
"muteParticipant": "{{name}} stummschalten",
|
||||
"fullScreen": "Vollbild"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Tastenkombinationen",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Medien",
|
||||
"interaction": "Interaktion"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
|
||||
"focus-toolbar": "Fokus auf die untere Symbolleiste",
|
||||
"toggle-microphone": "Mikrofon umschalten",
|
||||
"toggle-camera": "Kamera umschalten",
|
||||
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
|
||||
"reaction": "Reaktionspanel",
|
||||
"fullscreen": "Vollbild umschalten",
|
||||
"recording": "Aufnahmepanel umschalten",
|
||||
"raise-hand": "Hand heben oder senken",
|
||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
|
||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Steuerung",
|
||||
"command": "Befehl",
|
||||
"plus": "plus",
|
||||
"hold": "Halte {{key}} gedrückt",
|
||||
"noShortcut": "Kein Tastenkürzel"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Halte {{key}} gedrückt"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
||||
"stop": "Präsentation beenden",
|
||||
|
||||
@@ -114,6 +114,27 @@
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"transcription": "Transkription"
|
||||
"transcription": "Transkription",
|
||||
"shortcuts": "Tastenkürzel"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Tastenkürzel anpassen (lokal auf diesem Gerät).",
|
||||
"pressHint": "Drücken Sie die gewünschten Tasten, um ein Kürzel zu setzen.",
|
||||
"longPressHint": "Halten Sie eine Taste gedrückt, um ein Long-Press-Kürzel zu setzen.",
|
||||
"edit": "Bearbeiten",
|
||||
"capture": "Tasten drücken …",
|
||||
"reset": "Zurücksetzen",
|
||||
"custom": "benutzerdefiniert",
|
||||
"limitations": "Die Kürzel werden lokal gespeichert und gelten nicht zwingend für alle Aktionen.",
|
||||
"listLabel": "Liste der Tastenkürzel",
|
||||
"shortcutAria": "Tastenkürzel für {{action}}: {{shortcut}}",
|
||||
"customAria": "benutzerdefiniert",
|
||||
"editAria": "Tastenkürzel für {{action}} bearbeiten",
|
||||
"captureAria": "Tasten drücken, um Tastenkürzel für {{action}} festzulegen",
|
||||
"resetAria": "Tastenkürzel für {{action}} zurücksetzen",
|
||||
"actionsGroupAria": "Aktionen für {{action}}",
|
||||
"currentShortcut": "Aktuelles Tastenkürzel: {{shortcut}}",
|
||||
"modifiedConfirmation": "Tastenkürzel geändert",
|
||||
"resetConfirmation": "Tastenkürzel zurückgesetzt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
||||
"toolbarHint": "{{binding}} : show keyboard shortcuts.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
@@ -593,6 +593,38 @@
|
||||
"muteParticipant": "Mute {{name}}",
|
||||
"fullScreen": "Full screen"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Keyboard shortcuts",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Media",
|
||||
"interaction": "Interaction"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Open shortcuts help",
|
||||
"focus-toolbar": "Focus bottom toolbar",
|
||||
"toggle-microphone": "Toggle microphone",
|
||||
"toggle-camera": "Toggle camera",
|
||||
"push-to-talk": "Push-to-talk (hold to unmute)",
|
||||
"reaction": "Emoji reaction panel",
|
||||
"fullscreen": "Toggle fullscreen",
|
||||
"recording": "Toggle recording panel",
|
||||
"raise-hand": "Raise or lower hand",
|
||||
"toggle-chat": "Toggle chat",
|
||||
"toggle-participants": "Toggle participants",
|
||||
"open-shortcuts-settings": "Open shortcuts settings"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
"command": "Command",
|
||||
"plus": "plus",
|
||||
"hold": "Hold {{key}}",
|
||||
"noShortcut": "No shortcut"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Hold {{key}}"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
|
||||
"stop": "Stop presenting",
|
||||
|
||||
@@ -120,6 +120,27 @@
|
||||
"general": "General",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription"
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Shortcuts"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Customize keyboard shortcuts (local to this device).",
|
||||
"pressHint": "Press the desired keys to set a new shortcut.",
|
||||
"longPressHint": "Hold a key to set a long-press shortcut.",
|
||||
"edit": "Edit",
|
||||
"capture": "Press keys…",
|
||||
"reset": "Reset",
|
||||
"custom": "custom",
|
||||
"limitations": "Shortcuts are saved locally and may not apply to all actions yet.",
|
||||
"listLabel": "List of keyboard shortcuts",
|
||||
"shortcutAria": "Shortcut for {{action}}: {{shortcut}}",
|
||||
"customAria": "custom",
|
||||
"editAria": "Edit shortcut for {{action}}",
|
||||
"captureAria": "Press keys to set shortcut for {{action}}",
|
||||
"resetAria": "Reset shortcut for {{action}}",
|
||||
"actionsGroupAria": "Actions for {{action}}",
|
||||
"currentShortcut": "Current shortcut: {{shortcut}}",
|
||||
"modifiedConfirmation": "Shortcut modified",
|
||||
"resetConfirmation": "Shortcut reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options pour {{name}}",
|
||||
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
|
||||
"toolbarHint": "{{binding}} : voir les raccourcis clavier.",
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
@@ -593,6 +593,38 @@
|
||||
"muteParticipant": "Couper le micro de {{name}}",
|
||||
"fullScreen": "Plein écran"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Raccourcis clavier",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Média",
|
||||
"interaction": "Interaction"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Ouvrir l’aide des raccourcis",
|
||||
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
|
||||
"toggle-microphone": "Activer ou désactiver le micro",
|
||||
"toggle-camera": "Activer ou désactiver la caméra",
|
||||
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
|
||||
"reaction": "Panneau des réactions",
|
||||
"fullscreen": "Basculer en plein écran",
|
||||
"recording": "Basculer le panneau d’enregistrement",
|
||||
"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"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Contrôle",
|
||||
"command": "Commande",
|
||||
"plus": "plus",
|
||||
"hold": "Maintenir {{key}}",
|
||||
"noShortcut": "Aucun raccourci"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Maintenir {{key}}"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
|
||||
"stop": "Arrêter la présentation",
|
||||
|
||||
@@ -120,6 +120,27 @@
|
||||
"general": "Général",
|
||||
"notifications": "Notifications",
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription"
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Raccourcis"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Personnaliser les raccourcis clavier (local à cet appareil).",
|
||||
"pressHint": "Appuyez sur les touches souhaitées pour définir un raccourci.",
|
||||
"longPressHint": "Maintenez une touche pour définir un raccourci long.",
|
||||
"edit": "Modifier",
|
||||
"capture": "Appuyez sur les touches…",
|
||||
"reset": "Réinitialiser",
|
||||
"custom": "personnalisé",
|
||||
"limitations": "Les raccourcis sont enregistrés localement et peuvent ne pas s'appliquer à toutes les actions.",
|
||||
"listLabel": "Liste des raccourcis clavier",
|
||||
"shortcutAria": "Raccourci pour {{action}} : {{shortcut}}",
|
||||
"customAria": "personnalisé",
|
||||
"editAria": "Modifier le raccourci pour {{action}}",
|
||||
"captureAria": "Appuyez sur les touches pour définir le raccourci pour {{action}}",
|
||||
"resetAria": "Réinitialiser le raccourci pour {{action}}",
|
||||
"actionsGroupAria": "Actions pour {{action}}",
|
||||
"currentShortcut": "Raccourci actuel : {{shortcut}}",
|
||||
"modifiedConfirmation": "Raccourci modifié",
|
||||
"resetConfirmation": "Raccourci réinitialisé"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opties voor {{name}}",
|
||||
"toolbarHint": "F2: naar de werkbalk onderaan.",
|
||||
"toolbarHint": "{{binding}}: open de sneltoetsen.",
|
||||
"pin": {
|
||||
"enable": "Pinnen",
|
||||
"disable": "Losmaken"
|
||||
@@ -593,6 +593,38 @@
|
||||
"muteParticipant": "Demp {{name}}",
|
||||
"fullScreen": "Volledig scherm"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Sneltoetsen",
|
||||
"categories": {
|
||||
"navigation": "Navigatie",
|
||||
"media": "Media",
|
||||
"interaction": "Interactie"
|
||||
},
|
||||
"actions": {
|
||||
"open-shortcuts": "Sneltoetsenhulp openen",
|
||||
"focus-toolbar": "Focus op de onderste werkbalk",
|
||||
"toggle-microphone": "Microfoon aan/uit",
|
||||
"toggle-camera": "Camera aan/uit",
|
||||
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",
|
||||
"reaction": "Reactiepaneel",
|
||||
"fullscreen": "Volledig scherm wisselen",
|
||||
"recording": "Opnamepaneel wisselen",
|
||||
"raise-hand": "Hand opsteken of laten zakken",
|
||||
"toggle-chat": "Chat tonen/verbergen",
|
||||
"toggle-participants": "Deelnemers tonen/verbergen",
|
||||
"open-shortcuts-settings": "Sneltoets-instellingen openen"
|
||||
},
|
||||
"sr": {
|
||||
"control": "Control",
|
||||
"command": "Command",
|
||||
"plus": "plus",
|
||||
"hold": "Houd {{key}} ingedrukt",
|
||||
"noShortcut": "Geen sneltoets"
|
||||
},
|
||||
"visual": {
|
||||
"hold": "Houd {{key}} ingedrukt"
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Om niet oneindige uw scherm in zichzelf te delen, kunt u beter niet het hele scherm delen. Deel in plaats daarvan een tab of een ander venster.",
|
||||
"stop": "Stop met presenteren",
|
||||
|
||||
@@ -114,6 +114,27 @@
|
||||
"video": "Video",
|
||||
"general": "Algemeen",
|
||||
"notifications": "Meldingen",
|
||||
"transcription": "Transcriptie"
|
||||
"transcription": "Transcriptie",
|
||||
"shortcuts": "Sneltoetsen"
|
||||
},
|
||||
"shortcutsEditor": {
|
||||
"description": "Sneltoetsen aanpassen (lokaal op dit apparaat).",
|
||||
"pressHint": "Druk op de gewenste toetsen om een sneltoets in te stellen.",
|
||||
"longPressHint": "Houd een toets ingedrukt voor een long-press sneltoets.",
|
||||
"edit": "Bewerken",
|
||||
"capture": "Toetsen indrukken…",
|
||||
"reset": "Resetten",
|
||||
"custom": "aangepast",
|
||||
"limitations": "Sneltoetsen worden lokaal opgeslagen en gelden mogelijk niet voor alle acties.",
|
||||
"listLabel": "Lijst van sneltoetsen",
|
||||
"shortcutAria": "Sneltoets voor {{action}}: {{shortcut}}",
|
||||
"customAria": "aangepast",
|
||||
"editAria": "Sneltoets voor {{action}} bewerken",
|
||||
"captureAria": "Druk op toetsen om sneltoets voor {{action}} in te stellen",
|
||||
"resetAria": "Sneltoets voor {{action}} resetten",
|
||||
"actionsGroupAria": "Acties voor {{action}}",
|
||||
"currentShortcut": "Huidige sneltoets: {{shortcut}}",
|
||||
"modifiedConfirmation": "Sneltoets gewijzigd",
|
||||
"resetConfirmation": "Sneltoets gereset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type LayoutType = 'grid' | 'carousel' | null
|
||||
|
||||
type State = {
|
||||
layoutType: LayoutType
|
||||
firstGridTileTrackId: string | null
|
||||
}
|
||||
|
||||
export const participantLayoutStore = proxy<State>({
|
||||
layoutType: null,
|
||||
firstGridTileTrackId: null,
|
||||
})
|
||||
|
||||
export const setGridLayout = (firstTrackId: string | null) => {
|
||||
participantLayoutStore.layoutType = 'grid'
|
||||
participantLayoutStore.firstGridTileTrackId = firstTrackId
|
||||
}
|
||||
|
||||
export const setCarouselLayout = () => {
|
||||
participantLayoutStore.layoutType = 'carousel'
|
||||
participantLayoutStore.firstGridTileTrackId = null
|
||||
}
|
||||
|
||||
export const resetLayout = () => {
|
||||
participantLayoutStore.layoutType = null
|
||||
participantLayoutStore.firstGridTileTrackId = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export const shortcutHelpStore = proxy<State>({
|
||||
isOpen: false,
|
||||
})
|
||||
|
||||
export const openShortcutHelp = () => {
|
||||
shortcutHelpStore.isOpen = true
|
||||
}
|
||||
|
||||
export const closeShortcutHelp = () => {
|
||||
shortcutHelpStore.isOpen = false
|
||||
}
|
||||
|
||||
export const toggleShortcutHelp = () => {
|
||||
shortcutHelpStore.isOpen = !shortcutHelpStore.isOpen
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
const STORAGE_KEY = 'shortcuts:overrides'
|
||||
|
||||
type State = {
|
||||
overrides: Map<string, Shortcut>
|
||||
isLoaded: boolean
|
||||
}
|
||||
|
||||
export const shortcutOverridesStore = proxy<State>({
|
||||
overrides: new Map<string, Shortcut>(),
|
||||
isLoaded: false,
|
||||
})
|
||||
|
||||
const isValidShortcut = (value: unknown): value is Shortcut => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const shortcut = value as Record<string, unknown>
|
||||
if (typeof shortcut.key !== 'string' || shortcut.key.length === 0) {
|
||||
return false
|
||||
}
|
||||
if (shortcut.ctrlKey !== undefined && typeof shortcut.ctrlKey !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
shortcut.shiftKey !== undefined &&
|
||||
typeof shortcut.shiftKey !== 'boolean'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (shortcut.altKey !== undefined && typeof shortcut.altKey !== 'boolean') {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const loadShortcutOverrides = () => {
|
||||
if (shortcutOverridesStore.isLoaded) return
|
||||
shortcutOverridesStore.isLoaded = true
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
console.warn('Invalid shortcut overrides format in localStorage')
|
||||
return
|
||||
}
|
||||
Object.entries(parsed).forEach(([id, value]) => {
|
||||
if (isValidShortcut(value)) {
|
||||
shortcutOverridesStore.overrides.set(id, value)
|
||||
} else {
|
||||
console.warn(`Skipping invalid shortcut override for "${id}":`, value)
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('Failed to load shortcut overrides', e)
|
||||
}
|
||||
}
|
||||
|
||||
export const getOverride = (id: string): Shortcut | undefined => {
|
||||
return shortcutOverridesStore.overrides.get(id)
|
||||
}
|
||||
|
||||
const saveOverridesToStorage = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const overridesObj: Record<string, Shortcut> = {}
|
||||
shortcutOverridesStore.overrides.forEach((value, key) => {
|
||||
overridesObj[key] = value
|
||||
})
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overridesObj))
|
||||
} catch (e) {
|
||||
console.warn('Failed to save shortcut overrides', e)
|
||||
}
|
||||
}
|
||||
|
||||
export const setOverride = (id: string, shortcut: Shortcut) => {
|
||||
shortcutOverridesStore.overrides.set(id, shortcut)
|
||||
// Force reactivity by creating a new Map reference
|
||||
shortcutOverridesStore.overrides = new Map(shortcutOverridesStore.overrides)
|
||||
saveOverridesToStorage()
|
||||
}
|
||||
|
||||
export const removeOverride = (id: string) => {
|
||||
shortcutOverridesStore.overrides.delete(id)
|
||||
// Force reactivity by creating a new Map reference
|
||||
shortcutOverridesStore.overrides = new Map(shortcutOverridesStore.overrides)
|
||||
saveOverridesToStorage()
|
||||
}
|
||||
Reference in New Issue
Block a user