✨(frontend) add expandable shortcuts tooltip
show shortcut catalog in participant hint with open/close animation
This commit is contained in:
@@ -31,7 +31,7 @@ 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'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -229,9 +229,10 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
)}
|
||||
<ShortcutHelpTooltip
|
||||
triggerLabel={t('toolbarHint')}
|
||||
isVisible={hasKeyboardFocus}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useCallback, useEffect, useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { shortcutCatalog } from '@/features/shortcuts/catalog'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import {
|
||||
closeShortcutHelp,
|
||||
shortcutHelpStore,
|
||||
toggleShortcutHelp,
|
||||
} from '@/stores/shortcutHelp'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { Shortcut } from '@/features/shortcuts/types'
|
||||
|
||||
type ShortcutHelpTooltipProps = {
|
||||
triggerLabel: string
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
const formatShortcutLabel = (shortcut?: Shortcut) => {
|
||||
if (!shortcut) return '—'
|
||||
const key = shortcut.key?.toUpperCase()
|
||||
if (!key) return '—'
|
||||
if (shortcut.ctrlKey) return `${isMacintosh() ? '⌘' : 'Ctrl'}+${key}`
|
||||
return key
|
||||
}
|
||||
|
||||
const formatLongPressLabel = (code?: string) => {
|
||||
if (!code) return '—'
|
||||
if (code.startsWith('Key') && code.length === 4) {
|
||||
return `Hold ${code.slice(3)}`
|
||||
}
|
||||
return `Hold ${code}`
|
||||
}
|
||||
|
||||
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
|
||||
triggerLabel,
|
||||
isVisible = false,
|
||||
}) => {
|
||||
const { isOpen } = useSnapshot(shortcutHelpStore)
|
||||
const isContainerVisible = isVisible || isOpen
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
return shortcutCatalog.reduce<Record<string, typeof shortcutCatalog>>(
|
||||
(acc, item) => {
|
||||
acc[item.category] = acc[item.category] || []
|
||||
acc[item.category].push(item)
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-visible={isContainerVisible}
|
||||
className={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',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="shortcut-help-panel"
|
||||
onClick={handleToggle}
|
||||
className={css({
|
||||
all: 'unset',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span>{triggerLabel}</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={css({
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})}
|
||||
>
|
||||
{isOpen ? '−' : '+'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="shortcut-help-panel"
|
||||
role="group"
|
||||
aria-label="Keyboard shortcuts"
|
||||
data-open={isOpen}
|
||||
className={css({
|
||||
overflow: 'hidden',
|
||||
maxHeight: '0px',
|
||||
opacity: 0,
|
||||
mt: '0',
|
||||
transition:
|
||||
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
|
||||
'&[data-open="true"]': {
|
||||
maxHeight: '400px',
|
||||
opacity: 1,
|
||||
mt: '0.35rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div
|
||||
key={category}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
mb: '0.35rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
textTransform: 'capitalize',
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.8,
|
||||
})}
|
||||
>
|
||||
{category}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
fontSize: '0.85rem',
|
||||
})}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span
|
||||
className={css({
|
||||
fontFamily: 'monospace',
|
||||
backgroundColor: 'rgba(255,255,255,0.12)',
|
||||
paddingInline: '0.35rem',
|
||||
paddingBlock: '0.15rem',
|
||||
borderRadius: '6px',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{item.kind === 'longPress'
|
||||
? formatLongPressLabel(item.code)
|
||||
: formatShortcutLabel(item.shortcut)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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'
|
||||
|
||||
@@ -23,6 +24,14 @@ export function DesktopControlBar({
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: '?' },
|
||||
handler: () => {
|
||||
openShortcutHelp()
|
||||
},
|
||||
})
|
||||
|
||||
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'F2' },
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
| 'push-to-talk'
|
||||
@@ -30,6 +31,12 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
description:
|
||||
'Currently focuses the bottom toolbar; will open the shortcuts panel next.',
|
||||
},
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
label: 'Focus bottom toolbar',
|
||||
category: 'navigation',
|
||||
shortcut: { key: 'F2' },
|
||||
},
|
||||
{
|
||||
id: 'toggle-microphone',
|
||||
label: 'Toggle microphone',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user