Show keyboard shortcuts in button tooltips

pull/2867/head
Piotr Korzuszek 2 months ago
parent 260426e3d8
commit 97d9d6692f

@ -24,6 +24,7 @@ import { getSegColor as getSegColorRaw } from './util/colors';
import { useSegColors } from './contexts';
import { isExactDurationMatch } from './util/duration';
import useUserSettings from './hooks/useUserSettings';
import useActionTitle from './hooks/useActionTitle';
import { askForPlaybackRate, checkAppPath } from './dialogs';
import type { FormatTimecode, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentToExport, StateSegment } from './types';
import type { WaveformMode } from '../../common/types';
@ -358,6 +359,7 @@ function BottomBar({
}), [currentFrame]);
const { invertCutSegments, setInvertCutSegments, simpleMode, toggleSimpleMode, exportConfirmEnabled } = useUserSettings();
const actionTitle = useActionTitle();
const rotationStr = `${rotation}°`;
@ -414,7 +416,7 @@ function BottomBar({
<GiSoundWaves
style={{ fontSize: '1.6em', padding: '0 .1em', color: waveformMode != null ? primaryTextColor : undefined }}
role="button"
title={t('Show waveform')}
title={actionTitle(t('Show waveform'), 'toggleWaveformMode')}
onClick={() => toggleWaveformMode()}
/>
)}
@ -423,14 +425,14 @@ function BottomBar({
<FaImages
style={{ fontSize: '1.1em', padding: '0 .2em', color: showThumbnails ? primaryTextColor : undefined }}
role="button"
title={t('Show thumbnails')}
title={actionTitle(t('Show thumbnails'), 'toggleShowThumbnails')}
onClick={toggleShowThumbnails}
/>
<FaKey
style={{ fontSize: '1em', padding: '0 .2em', color: keyframesEnabled ? primaryTextColor : undefined }}
role="button"
title={t('Show keyframes')}
title={actionTitle(t('Show keyframes'), 'toggleShowKeyframes')}
onClick={toggleShowKeyframes}
/>
</>
@ -446,18 +448,18 @@ function BottomBar({
<FaStepBackward
size={16}
style={{ flexShrink: 0 }}
title={t('Jump to start of video')}
title={actionTitle(t('Jump to start of video'), 'jumpTimelineStart')}
role="button"
onClick={jumpTimelineStart}
/>
{renderJumpCutpointButton(-1)}
<SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} onClick={jumpCutStart} title={t('Jump to current segment\'s start time')} style={{ marginRight: 5 }} />
<SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} onClick={jumpCutStart} title={actionTitle(t('Jump to current segment\'s start time'), 'jumpCutStart')} style={{ marginRight: 5 }} />
</>
)}
<SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" onClick={setCutStart} title={t('Start current segment at current time')} style={{ marginRight: 5 }} />
<SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" onClick={setCutStart} title={actionTitle(t('Start current segment at current time'), 'setCutStart')} style={{ marginRight: 5 }} />
{!simpleMode && <CutTimeInput disabled={!isFileOpened} darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentCutSeg?.start} setCutTime={setCutTime} isStart formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
@ -465,7 +467,7 @@ function BottomBar({
<IoMdKey
size={25}
role="button"
title={t('Seek previous keyframe')}
title={actionTitle(t('Seek previous keyframe'), 'seekBackwardsKeyframe')}
style={{ flexShrink: 0, marginRight: 2, transform: mirrorTransform, ...keyframeStyle }}
onClick={() => seekClosestKeyframe(-1)}
/>
@ -476,7 +478,7 @@ function BottomBar({
style={{ flexShrink: 0, marginLeft: -6, marginRight: -4 }}
size={28}
role="button"
title={t('One frame back')}
title={actionTitle(t('One frame back'), 'seekPreviousFrame')}
onClick={() => shortStep(-1)}
/>
)}
@ -490,7 +492,7 @@ function BottomBar({
style={{ flexShrink: 0, marginRight: -6, marginLeft: -4 }}
size={28}
role="button"
title={t('One frame forward')}
title={actionTitle(t('One frame forward'), 'seekNextFrame')}
onClick={() => shortStep(1)}
/>
)}
@ -500,25 +502,25 @@ function BottomBar({
style={{ flexShrink: 0, marginLeft: 2, ...keyframeStyle }}
size={25}
role="button"
title={t('Seek next keyframe')}
title={actionTitle(t('Seek next keyframe'), 'seekForwardsKeyframe')}
onClick={() => seekClosestKeyframe(1)}
/>
)}
{!simpleMode && <CutTimeInput disabled={!isFileOpened} darkMode={darkMode} currentCutSeg={currentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentCutSeg?.end} setCutTime={setCutTime} formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('End current segment at current time')} style={{ marginLeft: 5 }} />
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={actionTitle(t('End current segment at current time'), 'setCutEnd')} style={{ marginLeft: 5 }} />
{!simpleMode && (
<>
<SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} onClick={jumpCutEnd} title={t('Jump to current segment\'s end time')} style={{ marginLeft: 5 }} />
<SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} onClick={jumpCutEnd} title={actionTitle(t('Jump to current segment\'s end time'), 'jumpCutEnd')} style={{ marginLeft: 5 }} />
{renderJumpCutpointButton(1)}
<FaStepForward
size={16}
style={{ flexShrink: 0 }}
title={t('Jump to end of video')}
title={actionTitle(t('Jump to end of video'), 'jumpTimelineEnd')}
role="button"
onClick={jumpTimelineEnd}
/>
@ -571,7 +573,7 @@ function BottomBar({
<div onClick={increaseRotation} role="button">
<MdRotate90DegreesCcw
style={{ fontSize: '1.3em', verticalAlign: 'middle', color: isRotationSet ? primaryTextColor : undefined }}
title={`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`}
title={actionTitle(`${t('Set output rotation. Current: ')} ${isRotationSet ? rotationStr : t('Don\'t modify')}`, 'increaseRotation')}
/>
<span style={{ textAlign: 'right', display: 'inline-block', fontSize: '.8em', marginLeft: '.1em' }}>{isRotationSet && rotationStr}</span>
</div>
@ -581,7 +583,7 @@ function BottomBar({
{!simpleMode && isFileOpened && (
<FaTrashAlt
title={t('Close file and clean up')}
title={actionTitle(t('Close file and clean up'), 'cleanupFilesDialog')}
style={{ fontSize: '1em', color: dangerColor }}
onClick={cleanupFilesDialog}
role="button"
@ -593,7 +595,7 @@ function BottomBar({
<IoIosCamera
role="button"
style={{ fontSize: '1.9em', verticalAlign: 'middle' }}
title={t('Capture frame')}
title={actionTitle(t('Capture frame'), 'captureSnapshot')}
onClick={captureSnapshot}
/>
@ -602,7 +604,7 @@ function BottomBar({
)}
{isFileOpened && (
<div role="button" onClick={toggleLoopSelectedSegments} title={t('Play selected segments in order')} style={loopSelectedSegmentsButtonStyle}>
<div role="button" onClick={toggleLoopSelectedSegments} title={actionTitle(t('Play selected segments in order'), 'toggleLoopSelectedSegments')} style={loopSelectedSegmentsButtonStyle}>
<PlayPauseMode />
</div>
)}

@ -15,6 +15,7 @@ import prettyBytes from 'pretty-bytes';
import useContextMenu from './hooks/useContextMenu';
import useUserSettings from './hooks/useUserSettings';
import useActionTitle from './hooks/useActionTitle';
import { saveColor, controlsBackground, primaryTextColor, darkModeTransition } from './colors';
import { useSegColors } from './contexts';
import { getSegmentTags } from './segments';
@ -382,6 +383,7 @@ function SegmentList({
const [draggingId, setDraggingId] = useState<UniqueIdentifier | undefined>();
const { invertCutSegments, simpleMode, darkMode, springAnimation } = useUserSettings();
const actionTitle = useActionTitle();
const getButtonColor = useCallback((seg: SegmentColorIndex | undefined, next?: boolean) => getSegColor(seg ? { segColorIndex: next ? seg.segColorIndex + 1 : seg.segColorIndex } : undefined).desaturate(0.3).lightness(darkMode ? 45 : 55).string(), [darkMode, getSegColor]);
const currentSegColor = useMemo(() => getButtonColor(currentCutSeg), [currentCutSeg, getButtonColor]);
@ -438,7 +440,7 @@ function SegmentList({
size={24}
style={{ ...buttonBaseStyle, background: nextSegmentColor }}
role="button"
title={t('Add segment')}
title={actionTitle(t('Add segment'), 'addSegment')}
onClick={addSegment}
/>
@ -446,7 +448,7 @@ function SegmentList({
size={24}
style={{ ...buttonBaseStyle, ...(cutSegments.length > 0 ? { backgroundColor: currentSegColor } : disabledButtonStyle) }}
role="button"
title={t('Remove cutpoint from segment {{segmentNumber}}', { segmentNumber: currentSegIndex + 1 })}
title={actionTitle(t('Remove cutpoint from segment {{segmentNumber}}', { segmentNumber: currentSegIndex + 1 }), 'removeCurrentCutpoint')}
onClick={() => removeSegment(currentSegIndex)}
/>
@ -454,7 +456,7 @@ function SegmentList({
<>
<FaSortNumericDown
size={16}
title={t('Change segment order')}
title={actionTitle(t('Change segment order'), 'reorderSegsByStartTime')}
role="button"
style={{ ...buttonBaseStyle, padding: 4, ...(cutSegments.length >= 2 ? { backgroundColor: currentSegColor } : disabledButtonStyle) }}
onClick={() => onReorderSegs(currentSegIndex)}
@ -462,7 +464,7 @@ function SegmentList({
<FaTag
size={16}
title={t('Label segment')}
title={actionTitle(t('Label segment'), 'labelCurrentSegment')}
role="button"
style={{ ...buttonBaseStyle, padding: 4, ...(cutSegments.length > 0 ? { backgroundColor: currentSegColor } : disabledButtonStyle) }}
onClick={() => onLabelSegment(currentSegIndex)}
@ -472,7 +474,7 @@ function SegmentList({
<AiOutlineSplitCells
size={22}
title={t('Split segment at cursor')}
title={actionTitle(t('Split segment at cursor'), 'splitCurrentSegment')}
role="button"
style={{ ...buttonBaseStyle, padding: 1, ...(firstSegmentAtCursor ? { backgroundColor: segAtCursorColor } : disabledButtonStyle) }}
onClick={splitCurrentSegment}
@ -481,7 +483,7 @@ function SegmentList({
{!invertCutSegments && (
<FaRegCheckCircle
size={22}
title={t('Invert segment selection')}
title={actionTitle(t('Invert segment selection'), 'invertSelectedSegments')}
role="button"
style={{ ...buttonBaseStyle, padding: 1, ...(cutSegments.length > 0 ? { backgroundColor: neutralButtonColor } : disabledButtonStyle) }}
onClick={onInvertSelectedSegments}
@ -639,7 +641,7 @@ function SegmentList({
<span style={{ fontSize: '.8em' }}>{getHeader()}</span>
<FaTimes
title={t('Close sidebar')}
title={actionTitle(t('Close sidebar'), 'toggleSegmentsList')}
style={{ fontSize: '1.1em', verticalAlign: 'middle', color: 'var(--gray-11)', cursor: 'pointer', padding: '.2em .3em' }}
role="button"
onClick={toggleSegmentsList}

@ -10,6 +10,7 @@ import ExportModeButton from './components/ExportModeButton';
import { withBlur } from './util';
import { primaryTextColor, controlsBackground, darkModeTransition } from './colors';
import useUserSettings from './hooks/useUserSettings';
import useActionTitle from './hooks/useActionTitle';
import styles from './TopMenu.module.css';
import OutDirSelector from './components/OutDirSelector';
@ -51,6 +52,7 @@ function TopMenu({
}) {
const { t } = useTranslation();
const { customOutDir, setCustomOutDir, simpleMode, outFormatLocked, setOutFormatLocked, darkMode } = useUserSettings();
const actionTitle = useActionTitle();
const workingDirButtonRef = useRef<HTMLButtonElement>(null);
const DarkMode = darkMode ? FaSun : FaMoon;
@ -99,7 +101,7 @@ function TopMenu({
{enabledStreamsFilter != null && (
<Button
onClick={withBlur(() => applyEnabledStreamsFilter())}
title={t('Toggle tracks using current filter')}
title={actionTitle(t('Toggle tracks using current filter'), 'toggleStripCurrentFilter')}
>
<FaFilter
style={{ fontSize: '.8em', verticalAlign: 'middle' }}
@ -137,12 +139,12 @@ function TopMenu({
)}
{!simpleMode && (
<Button onClick={toggleDarkMode} title={t('Toggle dark mode')}>
<Button onClick={toggleDarkMode} title={actionTitle(t('Toggle dark mode'), 'toggleDarkMode')}>
<DarkMode style={{ verticalAlign: 'middle', fontSize: '.9em' }} />
</Button>
)}
<Button onClick={toggleSettings}>
<Button onClick={toggleSettings} title={actionTitle(t('Settings'), 'toggleSettings')}>
<IoIosSettings style={{ fontSize: '1em', verticalAlign: 'bottom' }} />
</Button>
</div>

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import useUserSettings from '../hooks/useUserSettings';
import useActionTitle from '../hooks/useActionTitle';
import { withBlur } from '../util';
import Button from './Button';
@ -10,9 +11,10 @@ import Button from './Button';
function CaptureFormatButton({ showIcon = false, ...props }: { showIcon?: boolean } & Parameters<typeof Button>[0]) {
const { t } = useTranslation();
const { captureFormat, toggleCaptureFormat } = useUserSettings();
const actionTitle = useActionTitle();
return (
<Button
title={t('Capture frame format')}
title={actionTitle(t('Capture frame format'), 'toggleCaptureFormat')}
onClick={withBlur(toggleCaptureFormat)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}

@ -2,113 +2,15 @@ import type { HTMLAttributes } from 'react';
import { useMemo } from 'react';
import { useAppContext } from '../contexts';
import { getMetaKeyName } from '../util';
import { getKeyDisplayName } from '../util';
export default function Kbd({ code, ...props }: { code: string } & HTMLAttributes<HTMLElement>) {
const { keyboardLayoutMap } = useAppContext();
const keyName = useMemo(() => {
const map: Record<string, string> = {
Escape: 'Esc',
Digit1: '1',
Digit2: '2',
Digit3: '3',
Digit4: '4',
Digit5: '5',
Digit6: '6',
Digit7: '7',
Digit8: '8',
Digit9: '9',
Digit0: '0',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyR: 'R',
KeyT: 'T',
KeyY: 'Y',
KeyU: 'U',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
KeyA: 'A',
KeyS: 'S',
KeyD: 'D',
KeyF: 'F',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyK: 'K',
KeyL: 'L',
KeyZ: 'Z',
KeyX: 'X',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
KeyN: 'N',
KeyM: 'M',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Semicolon: ';',
Quote: '\'',
Backquote: '`',
Backslash: '\\',
Comma: ',',
Period: '.',
Slash: '/',
F1: 'F1',
F2: 'F2',
F3: 'F3',
F4: 'F4',
F5: 'F5',
F6: 'F6',
F7: 'F7',
F8: 'F8',
F9: 'F9',
F10: 'F10',
F11: 'F11',
F12: 'F12',
F13: 'F13',
F14: 'F14',
F15: 'F15',
F16: 'F16',
F17: 'F17',
F18: 'F18',
F19: 'F19',
F20: 'F20',
F21: 'F21',
F22: 'F22',
F23: 'F23',
F24: 'F24',
NumpadParenLeft: '(',
NumpadParenRight: ')',
PageUp: 'PgUp',
PageDown: 'PgDn',
ArrowUp: '↑',
ArrowLeft: '←',
ArrowRight: '→',
ArrowDown: '↓',
const keyName = useMemo(() => getKeyDisplayName(code, keyboardLayoutMap), [code, keyboardLayoutMap]);
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
MetaLeft: getMetaKeyName(),
MetaRight: getMetaKeyName(),
};
if (keyboardLayoutMap == null) {
return undefined;
}
return keyboardLayoutMap.get(code) ?? map[code] ?? code;
}, [code, keyboardLayoutMap]);
if (keyName == null) { // not yet loaded keyboard map
if (keyName == null) {
return null;
}

@ -3,6 +3,8 @@ import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import useActionTitle from '../hooks/useActionTitle';
function VolumeControl({ playbackVolume, setPlaybackVolume, onToggleMutedClick }: {
playbackVolume: number,
@ -12,6 +14,7 @@ function VolumeControl({ playbackVolume, setPlaybackVolume, onToggleMutedClick }
const [volumeControlVisible, setVolumeControlVisible] = useState(false);
const timeoutRef = useRef<number>();
const { t } = useTranslation();
const actionTitle = useActionTitle();
useEffect(() => {
const clear = () => clearTimeout(timeoutRef.current);
@ -49,7 +52,7 @@ function VolumeControl({ playbackVolume, setPlaybackVolume, onToggleMutedClick }
)}
<VolumeIcon
title={t('Mute preview? (will not affect output)')}
title={actionTitle(t('Mute preview? (will not affect output)'), 'toggleMuted')}
size={30}
role="button"
style={{ margin: '0 7px', color: 'var(--gray-12)', opacity: 0.7 }}

@ -0,0 +1,27 @@
import { useCallback, useMemo } from 'react';
import { useAppContext } from '../contexts';
import useUserSettings from './useUserSettings';
import { formatKeybinding } from '../util';
import type { KeyboardAction } from '../../../common/types';
export default function useActionTitle() {
const { keyboardLayoutMap } = useAppContext();
const { keyBindings } = useUserSettings();
const keyBindingByAction = useMemo(
() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])) as Partial<Record<KeyboardAction, { keys: string }>>,
[keyBindings],
);
const actionTitle = useCallback((title: string, action: KeyboardAction): string => {
const binding = keyBindingByAction[action];
if (binding == null) return title;
const formatted = formatKeybinding(binding.keys, keyboardLayoutMap);
if (formatted == null) return title;
return `${title} (${formatted})`;
}, [keyBindingByAction, keyboardLayoutMap]);
return actionTitle;
}

@ -431,6 +431,108 @@ export function getMetaKeyName() {
return i18n.t('Meta');
}
const keyCodeToDisplayName: Record<string, string> = {
Escape: 'Esc',
Digit1: '1',
Digit2: '2',
Digit3: '3',
Digit4: '4',
Digit5: '5',
Digit6: '6',
Digit7: '7',
Digit8: '8',
Digit9: '9',
Digit0: '0',
KeyQ: 'Q',
KeyW: 'W',
KeyE: 'E',
KeyR: 'R',
KeyT: 'T',
KeyY: 'Y',
KeyU: 'U',
KeyI: 'I',
KeyO: 'O',
KeyP: 'P',
KeyA: 'A',
KeyS: 'S',
KeyD: 'D',
KeyF: 'F',
KeyG: 'G',
KeyH: 'H',
KeyJ: 'J',
KeyK: 'K',
KeyL: 'L',
KeyZ: 'Z',
KeyX: 'X',
KeyC: 'C',
KeyV: 'V',
KeyB: 'B',
KeyN: 'N',
KeyM: 'M',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Semicolon: ';',
Quote: '\'',
Backquote: '`',
Backslash: '\\',
Comma: ',',
Period: '.',
Slash: '/',
F1: 'F1',
F2: 'F2',
F3: 'F3',
F4: 'F4',
F5: 'F5',
F6: 'F6',
F7: 'F7',
F8: 'F8',
F9: 'F9',
F10: 'F10',
F11: 'F11',
F12: 'F12',
F13: 'F13',
F14: 'F14',
F15: 'F15',
F16: 'F16',
F17: 'F17',
F18: 'F18',
F19: 'F19',
F20: 'F20',
F21: 'F21',
F22: 'F22',
F23: 'F23',
F24: 'F24',
NumpadParenLeft: '(',
NumpadParenRight: ')',
PageUp: 'PgUp',
PageDown: 'PgDn',
ArrowUp: '↑',
ArrowLeft: '←',
ArrowRight: '→',
ArrowDown: '↓',
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
};
export function getKeyDisplayName(code: string, keyboardLayoutMap: Map<string, string> | undefined): string | undefined {
if (code === 'MetaLeft' || code === 'MetaRight') return getMetaKeyName();
if (keyboardLayoutMap == null) return undefined;
return keyboardLayoutMap.get(code) ?? keyCodeToDisplayName[code] ?? code;
}
export function formatKeybinding(keys: string, keyboardLayoutMap: Map<string, string> | undefined): string | undefined {
const parts = splitKeyboardKeys(keys);
const names = parts.map((code) => getKeyDisplayName(code, keyboardLayoutMap));
if (names.some((n) => n == null)) return undefined;
return names.join('+');
}
export const getEnableImportChaptersOptions = (): Record<EnableImportChapters, string> => ({
always: i18n.t('Always'),
ask: i18n.t('Ask'),

Loading…
Cancel
Save