remove mousetrap

use native keydown/keyup event
use `code`. this makes keybindings independent of keyboard layout
also don't trigger hotkeys when some UI element or dialog is focused!

fixes #2515
pull/2575/head
Mikael Finstad 8 months ago
parent 7643152bb2
commit 251106a292
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -66,7 +66,6 @@
"@types/luxon": "^3.4.2",
"@types/mime-types": "^2.1.4",
"@types/morgan": "^1.9.9",
"@types/mousetrap": "^1.6.15",
"@types/node": "20",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
@ -106,7 +105,6 @@
"luxon": "^3.5.0",
"mitt": "^3.0.1",
"mkdirp": "^1.0.3",
"mousetrap": "^1.6.5",
"nanoid": "^5.0.9",
"p-map": "^5.5.0",
"p-retry": "^6.2.0",

@ -3,6 +3,7 @@ import Store from 'electron-store';
import electron from 'electron';
import { join, dirname } from 'node:path';
import { pathExists } from 'fs-extra';
import assert from 'node:assert';
import { KeyBinding, Config } from '../../types.js';
import logger from './logger.js';
@ -12,88 +13,89 @@ const { app } = electron;
const defaultKeyBindings: KeyBinding[] = [
{ keys: 'plus', action: 'addSegment' },
{ keys: 'space', action: 'togglePlayResetSpeed' },
{ keys: 'k', action: 'togglePlayNoResetSpeed' },
{ keys: 'j', action: 'reducePlaybackRate' },
{ keys: 'shift+j', action: 'reducePlaybackRateMore' },
{ keys: 'l', action: 'increasePlaybackRate' },
{ keys: 'shift+l', action: 'increasePlaybackRateMore' },
{ keys: 'z', action: 'timelineToggleComfortZoom' },
{ keys: 'shift+z', action: 'makeCursorTimeZero' },
{ keys: ',', action: 'seekPreviousFrame' },
{ keys: '.', action: 'seekNextFrame' },
{ keys: 'c', action: 'captureSnapshot' },
{ keys: 'i', action: 'setCutStart' },
{ keys: 'o', action: 'setCutEnd' },
{ keys: 'backspace', action: 'removeCurrentCutpoint' },
{ keys: 'd', action: 'cleanupFilesDialog' },
{ keys: 'b', action: 'splitCurrentSegment' },
{ keys: 'r', action: 'increaseRotation' },
{ keys: 'g', action: 'goToTimecode' },
{ keys: 't', action: 'toggleStripAll' },
{ keys: 'shift+t', action: 'toggleStripCurrentFilter' },
{ keys: 'left', action: 'seekBackwards' },
{ keys: 'ctrl+shift+left', action: 'seekBackwards2' },
{ keys: 'ctrl+left', action: 'seekBackwardsPercent' },
{ keys: 'command+left', action: 'seekBackwardsPercent' },
{ keys: 'alt+left', action: 'seekBackwardsKeyframe' },
{ keys: 'shift+left', action: 'jumpCutStart' },
{ keys: 'right', action: 'seekForwards' },
{ keys: 'ctrl+shift+right', action: 'seekForwards2' },
{ keys: 'ctrl+right', action: 'seekForwardsPercent' },
{ keys: 'command+right', action: 'seekForwardsPercent' },
{ keys: 'alt+right', action: 'seekForwardsKeyframe' },
{ keys: 'shift+right', action: 'jumpCutEnd' },
{ keys: 'ctrl+home', action: 'jumpTimelineStart' },
{ keys: 'ctrl+end', action: 'jumpTimelineEnd' },
{ keys: 'pageup', action: 'jumpFirstSegment' },
{ keys: 'up', action: 'jumpPrevSegment' },
{ keys: 'shift+alt+pageup', action: 'jumpSeekFirstSegment' },
{ keys: 'shift+alt+up', action: 'jumpSeekPrevSegment' },
{ keys: 'ctrl+up', action: 'timelineZoomIn' },
{ keys: 'command+up', action: 'timelineZoomIn' },
{ keys: 'shift+up', action: 'batchPreviousFile' },
{ keys: 'ctrl+shift+up', action: 'batchOpenPreviousFile' },
{ keys: 'pagedown', action: 'jumpLastSegment' },
{ keys: 'down', action: 'jumpNextSegment' },
{ keys: 'shift+alt+pagedown', action: 'jumpSeekLastSegment' },
{ keys: 'shift+alt+down', action: 'jumpSeekNextSegment' },
{ keys: 'ctrl+down', action: 'timelineZoomOut' },
{ keys: 'command+down', action: 'timelineZoomOut' },
{ keys: 'shift+down', action: 'batchNextFile' },
{ keys: 'ctrl+shift+down', action: 'batchOpenNextFile' },
{ keys: 'shift+enter', action: 'batchOpenSelectedFile' },
{ keys: 'ShiftLeft+Equal', action: 'addSegment' },
{ keys: 'Space', action: 'togglePlayResetSpeed' },
{ keys: 'KeyK', action: 'togglePlayNoResetSpeed' },
{ keys: 'KeyJ', action: 'reducePlaybackRate' },
{ keys: 'ShiftLeft+KeyJ', action: 'reducePlaybackRateMore' },
{ keys: 'KeyL', action: 'increasePlaybackRate' },
{ keys: 'ShiftLeft+KeyL', action: 'increasePlaybackRateMore' },
{ keys: 'KeyZ', action: 'timelineToggleComfortZoom' },
{ keys: 'ShiftLeft+KeyZ', action: 'makeCursorTimeZero' },
{ keys: 'Comma', action: 'seekPreviousFrame' },
{ keys: 'Period', action: 'seekNextFrame' },
{ keys: 'KeyC', action: 'captureSnapshot' },
{ keys: 'KeyI', action: 'setCutStart' },
{ keys: 'KeyO', action: 'setCutEnd' },
{ keys: 'Backspace', action: 'removeCurrentCutpoint' },
{ keys: 'KeyD', action: 'cleanupFilesDialog' },
{ keys: 'KeyB', action: 'splitCurrentSegment' },
{ keys: 'KeyR', action: 'increaseRotation' },
{ keys: 'KeyG', action: 'goToTimecode' },
{ keys: 'KeyT', action: 'toggleStripAll' },
{ keys: 'ShiftLeft+KeyT', action: 'toggleStripCurrentFilter' },
{ keys: 'ArrowLeft', action: 'seekBackwards' },
{ keys: 'ControlLeft+ShiftLeft+ArrowLeft', action: 'seekBackwards2' },
{ keys: 'ControlLeft+ArrowLeft', action: 'seekBackwardsPercent' },
{ keys: 'MetaLeft+ArrowLeft', action: 'seekBackwardsPercent' },
{ keys: 'AltLeft+ArrowLeft', action: 'seekBackwardsKeyframe' },
{ keys: 'ShiftLeft+ArrowLeft', action: 'jumpCutStart' },
{ keys: 'ArrowRight', action: 'seekForwards' },
{ keys: 'ControlLeft+ShiftLeft+ArrowRight', action: 'seekForwards2' },
{ keys: 'ControlLeft+ArrowRight', action: 'seekForwardsPercent' },
{ keys: 'MetaLeft+ArrowRight', action: 'seekForwardsPercent' },
{ keys: 'AltLeft+ArrowRight', action: 'seekForwardsKeyframe' },
{ keys: 'ShiftLeft+ArrowRight', action: 'jumpCutEnd' },
{ keys: 'ControlLeft+Home', action: 'jumpTimelineStart' },
{ keys: 'ControlLeft+End', action: 'jumpTimelineEnd' },
{ keys: 'PageUp', action: 'jumpFirstSegment' },
{ keys: 'ArrowUp', action: 'jumpPrevSegment' },
{ keys: 'ShiftLeft+AltLeft+PageUp', action: 'jumpSeekFirstSegment' },
{ keys: 'ShiftLeft+AltLeft+ArrowUp', action: 'jumpSeekPrevSegment' },
{ keys: 'ControlLeft+ArrowUp', action: 'timelineZoomIn' },
{ keys: 'MetaLeft+ArrowUp', action: 'timelineZoomIn' },
{ keys: 'ShiftLeft+ArrowUp', action: 'batchPreviousFile' },
{ keys: 'ControlLeft+ShiftLeft+ArrowUp', action: 'batchOpenPreviousFile' },
{ keys: 'PageDown', action: 'jumpLastSegment' },
{ keys: 'ArrowDown', action: 'jumpNextSegment' },
{ keys: 'ShiftLeft+AltLeft+PageDown', action: 'jumpSeekLastSegment' },
{ keys: 'ShiftLeft+AltLeft+ArrowDown', action: 'jumpSeekNextSegment' },
{ keys: 'ControlLeft+ArrowDown', action: 'timelineZoomOut' },
{ keys: 'MetaLeft+ArrowDown', action: 'timelineZoomOut' },
{ keys: 'ShiftLeft+ArrowDown', action: 'batchNextFile' },
{ keys: 'ControlLeft+ShiftLeft+ArrowDown', action: 'batchOpenNextFile' },
{ keys: 'ShiftLeft+Enter', action: 'batchOpenSelectedFile' },
// https://github.com/mifi/lossless-cut/issues/610
{ keys: 'ctrl+z', action: 'undo' },
{ keys: 'command+z', action: 'undo' },
{ keys: 'ctrl+shift+z', action: 'redo' },
{ keys: 'command+shift+z', action: 'redo' },
{ keys: 'ControlLeft+KeyZ', action: 'undo' },
{ keys: 'MetaLeft+KeyZ', action: 'undo' },
{ keys: 'ControlLeft+ShiftLeft+KeyZ', action: 'redo' },
{ keys: 'MetaLeft+ShiftLeft+KeyZ', action: 'redo' },
{ keys: 'ctrl+c', action: 'copySegmentsToClipboard' },
{ keys: 'command+c', action: 'copySegmentsToClipboard' },
{ keys: 'ControlLeft+KeyC', action: 'copySegmentsToClipboard' },
{ keys: 'MetaLeft+KeyC', action: 'copySegmentsToClipboard' },
{ keys: 'f', action: 'toggleFullscreenVideo' },
{ keys: 'KeyF', action: 'toggleFullscreenVideo' },
{ keys: 'enter', action: 'labelCurrentSegment' },
{ keys: 'Enter', action: 'labelCurrentSegment' },
{ keys: 'e', action: 'export' },
{ keys: 'shift+/', action: 'toggleKeyboardShortcuts' },
{ keys: 'escape', action: 'closeActiveScreen' },
{ keys: 'KeyE', action: 'export' },
{ keys: 'ShiftLeft+Slash', action: 'toggleKeyboardShortcuts' },
{ keys: 'Escape', action: 'closeActiveScreen' },
{ keys: 'alt+up', action: 'increaseVolume' },
{ keys: 'alt+down', action: 'decreaseVolume' },
{ keys: 'm', action: 'toggleMuted' },
{ keys: 'AltLeft+ArrowUp', action: 'increaseVolume' },
{ keys: 'AltLeft+ArrowDown', action: 'decreaseVolume' },
{ keys: 'KeyM', action: 'toggleMuted' },
];
const defaults: Config = {
version: 1,
captureFormat: 'jpeg',
customOutDir: undefined,
keyframeCut: true,
@ -245,4 +247,141 @@ export async function init({ customConfigDir }: { customConfigDir: string | unde
logger.info('Migrating cleanupChoices.closeFile');
set('cleanupChoices', { ...cleanupChoices, closeFile: true });
}
const configVersion: number = store.get('version');
// todo remove after a while
if (configVersion === 1) {
const keyBindings: KeyBinding[] = store.get('keyBindings');
const newBindings = keyBindings.map(({ keys: keysStr, action }) => {
try {
const keysOrig = keysStr.split('+');
assert(keysOrig.length > 0 && keysOrig.every((k) => k.length > 0), 'Invalid keys');
const map: Record<string, string> = {
/* eslint-disable quote-props */
'esc': 'Escape',
'1': 'Digit1',
'2': 'Digit2',
'3': 'Digit3',
'4': 'Digit4',
'5': 'Digit5',
'6': 'Digit6',
'7': 'Digit7',
'8': 'Digit8',
'9': 'Digit9',
'0': 'Digit0',
'-': 'Minus',
'=': 'Equal',
'backspace': 'Backspace',
'tab': 'Tab',
'q': 'KeyQ',
'w': 'KeyW',
'e': 'KeyE',
'r': 'KeyR',
't': 'KeyT',
'y': 'KeyY',
'u': 'KeyU',
'i': 'KeyI',
'o': 'KeyO',
'p': 'KeyP',
'[': 'BracketLeft',
']': 'BracketRight',
'enter': 'Enter',
'a': 'KeyA',
's': 'KeyS',
'd': 'KeyD',
'f': 'KeyF',
'g': 'KeyG',
'h': 'KeyH',
'j': 'KeyJ',
'k': 'KeyK',
'l': 'KeyL',
';': 'Semicolon',
'\'': 'Quote',
'`': 'Backquote',
'\\': 'Backslash',
'z': 'KeyZ',
'x': 'KeyX',
'c': 'KeyC',
'v': 'KeyV',
'b': 'KeyB',
'n': 'KeyN',
'm': 'KeyM',
',': 'Comma',
'.': 'Period',
'/': 'Slash',
'*': 'NumpadMultiply',
'space': 'Space',
'capslock': 'CapsLock',
'f1': 'F1',
'f2': 'F2',
'f3': 'F3',
'f4': 'F4',
'f5': 'F5',
'f6': 'F6',
'f7': 'F7',
'f8': 'F8',
'f9': 'F9',
'f10': 'F10',
'pause': 'Pause',
'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',
'help': 'Help',
'numlock': 'NumLock',
'home': 'Home',
'up': 'ArrowUp',
'pageup': 'PageUp',
'left': 'ArrowLeft',
'right': 'ArrowRight',
'end': 'End',
'down': 'ArrowDown',
'pagedown': 'PageDown',
'ins': 'Insert',
'del': 'Delete',
// modifiers
'ctrl': 'ControlLeft',
'shift': 'ShiftLeft',
'alt': 'AltLeft',
'meta': 'MetaLeft',
/* eslint-enable quote-props */
};
const newKeys = keysOrig.flatMap((k) => {
if (k === 'plus') return ['shift', '='];
if (k === 'command') return ['meta'];
if (k === 'option') return ['alt'];
return [k];
}).map((k) => {
const mapped = map[k.toLowerCase()];
assert(mapped != null, `Unknown key: ${k}`);
return mapped;
});
return { keys: newKeys.join('+'), action };
} catch (err) {
logger.error('Failed to migrate old keyboard binding', keysStr, action, err);
return { keys: keysStr, action };
}
});
set('keyBindings', newBindings);
logger.info('Migrated config to version 2');
set('version', 2);
}
}

@ -997,6 +997,7 @@ function App() {
}, (err) => i18n.t('Unable to delete file: {{message}}', { message: err instanceof Error ? err.message : String(err) }));
}, [batchListRemoveFile, clearSegments, filePath, previewFilePath, projectFileSavePath, resetState, setWorking, withErrorHandling]);
// todo convert to Dialog component
const askForCleanupChoices = useCallback(async () => {
const trashResponse = await showCleanupFilesDialog(cleanupChoices);
if (!trashResponse) return undefined; // Canceled
@ -1928,20 +1929,12 @@ function App() {
await openYouTubeChaptersDialog(formatYouTube(cutSegments));
}
function seekReset() {
seekAccelerationRef.current = 1;
}
function seekRel2({ keyup, amount }: { keyup: boolean | undefined, amount: number }) {
if (keyup) {
seekReset();
return;
}
function seekRelAccelerated(amount: number) {
seekRel(seekAccelerationRef.current * amount);
seekAccelerationRef.current *= keyboardSeekAccFactor;
}
const ret: Record<MainKeyboardAction, ((a: { keyup?: boolean | undefined }) => boolean) | ((a: { keyup?: boolean | undefined }) => void)> = {
const ret: Record<MainKeyboardAction, (() => boolean) | (() => void)> = {
// NOTE: Do not change these keys because users have bound keys by these names in their config files
// For actions, see also KeyboardShortcuts.jsx
togglePlayNoResetSpeed: () => togglePlay(),
@ -1968,12 +1961,12 @@ function App() {
selectSegmentsAtCursor,
increaseRotation,
goToTimecode: () => { goToTimecode(); return false; },
seekBackwards: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardNormalSeekSpeed }),
seekBackwards2: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardSeekSpeed2 }),
seekBackwards3: ({ keyup }) => seekRel2({ keyup, amount: -1 * keyboardSeekSpeed3 }),
seekForwards: ({ keyup }) => seekRel2({ keyup, amount: keyboardNormalSeekSpeed }),
seekForwards2: ({ keyup }) => seekRel2({ keyup, amount: keyboardSeekSpeed2 }),
seekForwards3: ({ keyup }) => seekRel2({ keyup, amount: keyboardSeekSpeed3 }),
seekBackwards: () => seekRelAccelerated(-1 * keyboardNormalSeekSpeed),
seekBackwards2: () => seekRelAccelerated(-1 * keyboardSeekSpeed2),
seekBackwards3: () => seekRelAccelerated(-1 * keyboardSeekSpeed3),
seekForwards: () => seekRelAccelerated(keyboardNormalSeekSpeed),
seekForwards2: () => seekRelAccelerated(keyboardSeekSpeed2),
seekForwards3: () => seekRelAccelerated(keyboardSeekSpeed3),
seekBackwardsPercent: () => { seekRelPercent(-0.01); return false; },
seekForwardsPercent: () => { seekRelPercent(0.01); return false; },
seekBackwardsKeyframe: () => seekClosestKeyframe(-1),
@ -2063,15 +2056,9 @@ function App() {
openDirDialog,
toggleSettings,
openSendReportDialog: () => { openSendReportDialogWithState(); },
detectBlackScenes: ({ keyup }) => {
if (keyup) detectBlackScenes();
},
detectSilentScenes: ({ keyup }) => {
if (keyup) detectSilentScenes();
},
detectSceneChanges: ({ keyup }) => {
if (keyup) detectSceneChanges();
},
detectBlackScenes: () => { detectBlackScenes(); return false; },
detectSilentScenes: () => { detectSilentScenes(); return false; },
detectSceneChanges: () => { detectSceneChanges(); return false; },
readAllKeyframes,
createSegmentsFromKeyframes,
toggleWaveformMode,
@ -2087,11 +2074,30 @@ function App() {
const getKeyboardAction = useCallback((action: MainKeyboardAction) => mainActions[action], [mainActions]);
const onKeyPress = useCallback(({ action, keyup }: { action: KeyboardAction, keyup?: boolean | undefined }) => {
const onKeyUp = useCallback(({ action }: { action: KeyboardAction }) => {
function seekReset() {
seekAccelerationRef.current = 1;
}
const keyUpActions = {
seekBackwards: () => seekReset(),
seekBackwards2: () => seekReset(),
seekBackwards3: () => seekReset(),
seekForwards: () => seekReset(),
seekForwards2: () => seekReset(),
seekForwards3: () => seekReset(),
};
const fn = keyUpActions[action as keyof typeof keyUpActions];
if (fn == null) return true;
fn();
return true;
}, [seekAccelerationRef]);
const onKeyDown = useCallback(({ action }: { action: KeyboardAction }) => {
function tryMainActions(mainAction: MainKeyboardAction) {
const fn = getKeyboardAction(mainAction);
if (!fn) return { match: false };
const bubble = fn({ keyup });
const bubble = fn();
if (bubble === undefined) return { match: true };
return { match: true, bubble };
}
@ -2111,7 +2117,7 @@ function App() {
}
if (concatSheetOpen || keyboardShortcutsVisible || genericDialog != null) {
return true; // don't allow any further hotkeys
return true; // don't allow any further hotkeys, but bubble
}
if (exportConfirmOpen) {
@ -2130,7 +2136,7 @@ function App() {
return true; // bubble the event
}, [closeExportConfirm, concatSheetOpen, exportConfirmOpen, genericDialog, getKeyboardAction, keyboardShortcutsVisible, onExportConfirm, toggleKeyboardShortcuts]);
useKeyboard({ keyBindings, onKeyPress });
useKeyboard({ keyBindings, onKeyDown, onKeyUp });
useEffect(() => {
// eslint-disable-next-line unicorn/prefer-add-event-listener
@ -2291,7 +2297,7 @@ function App() {
key,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async () => {
fn({ keyup: true });
fn();
},
] as const),
// also called from menu:

@ -8,7 +8,8 @@ import SimpleModeButton from './components/SimpleModeButton';
import useUserSettings from './hooks/useUserSettings';
import { StateSegment } from './types';
import { KeyBinding } from '../../../types';
import { splitKeyboardKeys } from './util';
import { formatKeyboardKey, splitKeyboardKeys } from './util';
import { getModifier } from './hooks/useTimelineScroll';
const electron = window.require('electron');
@ -18,7 +19,7 @@ function Keys({ keys }: { keys: string | undefined }) {
}
const split = splitKeyboardKeys(keys);
return split.map((key, i) => (
<Fragment key={key}><kbd>{key.toUpperCase()}</kbd>{i < split.length - 1 && <span style={{ fontSize: '.7em', marginLeft: '-.2em', marginRight: '-.2em' }}>{' + '}</span>}</Fragment>
<Fragment key={key}><kbd>{formatKeyboardKey(key)}</kbd>{i < split.length - 1 && <span style={{ fontSize: '.7em', marginLeft: '-.2em', marginRight: '-.2em' }}>{' + '}</span>}</Fragment>
));
}
@ -70,7 +71,7 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy
</div>
<div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }}>
<Trans><SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" style={{ verticalAlign: 'middle' }} /> <SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="end" style={{ verticalAlign: 'middle' }} />, <Keys keys={keyBindingByAction['setCutStart']?.keys} /> <Keys keys={keyBindingByAction['setCutEnd']?.keys} /> or <span><kbd style={{ marginRight: '.1em' }}>{segmentMouseModifierKey}</kbd></span>+<FaMouse style={{ marginRight: '.1em', verticalAlign: 'middle' }} /> to set cutpoints</Trans>
<Trans><SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" style={{ verticalAlign: 'middle' }} /> <SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="end" style={{ verticalAlign: 'middle' }} />, <Keys keys={keyBindingByAction['setCutStart']?.keys} /> <Keys keys={keyBindingByAction['setCutEnd']?.keys} /> or <span><kbd style={{ marginRight: '.1em' }}>{getModifier(segmentMouseModifierKey)}</kbd></span>+<FaMouse style={{ marginRight: '.1em', verticalAlign: 'middle' }} /> to set cutpoints</Trans>
</div>
<div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }} role="button" onClick={(e) => e.stopPropagation()}>

@ -18,7 +18,7 @@
background-color: var(--gray-3);
}
.CheckboxRoot:focus {
box-shadow: 0 0 0 2px var(--gray-1);
box-shadow: 0 0 0 .1em var(--gray-10);
}
.CheckboxIndicator {

@ -1,7 +1,6 @@
import { memo, Fragment, useEffect, useMemo, useCallback, useState, ReactNode, SetStateAction, Dispatch, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { FaMouse, FaPlus, FaStepForward, FaStepBackward, FaUndo, FaTrash, FaSave } from 'react-icons/fa';
import Mousetrap from 'mousetrap';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import uniq from 'lodash/uniq';
@ -13,7 +12,7 @@ import SegmentCutpointButton from './SegmentCutpointButton';
import { getModifier } from '../hooks/useTimelineScroll';
import { KeyBinding, KeyboardAction, ModifierKey } from '../../../../types';
import { StateSegment } from '../types';
import { splitKeyboardKeys } from '../util';
import { allModifiers, altModifiers, controlModifiers, formatKeyboardKey, metaModifiers, shiftModifiers, splitKeyboardKeys } from '../util';
import * as Dialog from './Dialog';
import Warning from './Warning';
import Button, { DialogButton } from './Button';
@ -29,25 +28,18 @@ type ActionsMap = Record<KeyboardAction, { name: string, category?: Category, be
const renderKeys = (keys: string[]) => keys.map((key, i) => (
<Fragment key={key}>
{i > 0 && <FaPlus style={{ verticalAlign: 'middle', fontSize: '.4em', opacity: 0.8, marginLeft: '.4em', marginRight: '.4em' }} />}
<kbd>{key.toUpperCase()}</kbd>
<kbd>{formatKeyboardKey(key)}</kbd>
</Fragment>
));
// From https://craig.is/killing/mice
// For modifier keys you can use shift, ctrl, alt, or meta.
// You can substitute option for alt and command for meta.
const allModifiers = new Set(['shift', 'ctrl', 'alt', 'meta']);
function fixKeys(keys: string[]) {
const replaced = keys.map((key) => {
if (key === 'option') return 'alt';
if (key === 'command') return 'meta';
return key;
});
const uniqed = uniq(replaced);
const uniqed = uniq(keys);
const nonModifierKeys = keys.filter((key) => !allModifiers.has(key));
if (nonModifierKeys.length === 0) return []; // only modifiers is invalid
if (nonModifierKeys.length > 1) return []; // can only have one non-modifier
return orderBy(uniqed, [(key) => key !== 'shift', (key) => key !== 'ctrl', (key) => key !== 'alt', (key) => key !== 'meta', (key) => key]);
return orderBy(uniqed, [(key) => !shiftModifiers.has(key), (key) => !controlModifiers.has(key), (key) => !altModifiers.has(key), (key) => !metaModifiers.has(key), (key) => key]);
}
// eslint-disable-next-line react/display-name
@ -78,18 +70,17 @@ const CreateBinding = memo(({
useEffect(() => {
if (!isShown) return undefined;
const mousetrap = new Mousetrap();
function handleKey(character: string, _modifiers: unknown, e: { type: string, preventDefault: () => void }) {
if (['keydown', 'keypress'].includes(e.type)) {
addKeyDown(character);
}
const handleKeyDown = (e: KeyboardEvent) => {
// console.log(e)
addKeyDown(e.code);
e.preventDefault();
}
const handleKeyOrig = mousetrap.handleKey;
mousetrap.handleKey = handleKey;
e.stopPropagation();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
mousetrap.handleKey = handleKeyOrig;
document.removeEventListener('keydown', handleKeyDown);
};
}, [addKeyDown, isShown]);
@ -151,7 +142,7 @@ function WheelModifier({ text, wheelText, modifier }: { text: string, wheelText:
<div style={{ ...rowStyle, alignItems: 'center' }}>
<span>{text}</span>
<div style={{ flexGrow: 1 }} />
{getModifier(modifier).map((v) => <kbd key={v} style={{ marginRight: '.7em' }}>{v}</kbd>)}
<kbd style={{ marginRight: '.7em' }}>{getModifier(modifier)}</kbd>
<FaMouse style={{ marginRight: '.3em' }} />
<span>{wheelText}</span>
</div>

@ -42,8 +42,8 @@ function ModifierKeySetting({ text, value, setValue }: { text: string, value: Mo
<KeyCell>{text}</KeyCell>
<td>
<Select value={value} onChange={(e) => setValue(e.target.value as ModifierKey)}>
{Object.entries(getModifierKeyNames()).map(([key, values]) => (
<option key={key} value={key}>{values.join(' / ')}</option>
{Object.entries(getModifierKeyNames()).map(([key, name]) => (
<option key={key} value={key}>{name}</option>
))}
</Select>
</td>

@ -1,47 +1,122 @@
import { useEffect, useRef } from 'react';
// mousetrap seems to be the only lib properly handling layouts that require shift to be pressed to get a particular key #520
// Also document.addEventListener needs custom handling of modifier keys or C will be triggered by CTRL+C, etc
import Mousetrap from 'mousetrap';
import { KeyBinding, KeyboardAction } from '../../../../types';
import { allModifiers, altModifiers, controlModifiers, metaModifiers, shiftModifiers } from '../util';
/* Keyboard testing points (when making large changes):
- ctrl/cmd + c/v should work in inputs
- Keyboard actions should not trigger when focus is inside a dialog, or when focusing inputs, switches etc.
- Clicking on the video are or some empty portions of the app should focus the document body again (to allow keyboard shortcuts to work)
- Test different keyboard layout: chinese, french. should work because the key code is the same.
- Reset LosslessCut settings (delete config.json file to get the default keyboard layout)
- Go to timecode (`g` shortcut): shouldn't insert the letter `g` into input box. This also applies to all the detect* actions
- Seek (autorepeat) and acceleration factor should reset after keyup
- Bind keyboard action dialog should not close when its key binding (shift+slash) is triggered
// for all dialog actions (e.g. detectSceneChanges) we must use keyup, or we risk having the button press inserted into the dialog's input element right after the dialog opens
// todo use keyup for most events?
const keyupActions = new Set<KeyboardAction>(['seekBackwards', 'seekForwards', 'seekBackwards2', 'seekForwards2', 'seekBackwards3', 'seekForwards3', 'detectBlackScenes', 'detectSilentScenes', 'detectSceneChanges']);
See also https://github.com/mifi/lossless-cut/issues/2515
*/
interface StoredAction { action: KeyboardAction, keyup?: boolean }
export default ({ keyBindings, onKeyPress: onKeyPressProp }: {
export default ({ keyBindings, onKeyDown: onKeyDownProp, onKeyUp: onKeyUpProp }: {
keyBindings: KeyBinding[],
onKeyPress: ((a: { action: KeyboardAction, keyup?: boolean | undefined }) => boolean) | ((a: { action: KeyboardAction, keyup?: boolean | undefined }) => void),
onKeyDown: ((a: { action: KeyboardAction }) => boolean) | ((a: { action: KeyboardAction }) => void),
onKeyUp: ((a: { action: KeyboardAction }) => boolean) | ((a: { action: KeyboardAction }) => void),
}) => {
const onKeyPressRef = useRef<(a: StoredAction) => void>();
// optimization to prevent re-binding all the time:
const onKeyDownRef = useRef<(a: StoredAction) => void>();
useEffect(() => {
onKeyDownRef.current = onKeyDownProp;
}, [onKeyDownProp]);
const onKeyUpRef = useRef<(a: StoredAction) => void>();
useEffect(() => {
onKeyPressRef.current = onKeyPressProp;
}, [onKeyPressProp]);
onKeyUpRef.current = onKeyUpProp;
}, [onKeyUpProp]);
useEffect(() => {
const mousetrap = new Mousetrap();
const keyBindingsByKeyCode = keyBindings.reduce((acc, kb) => {
kb.keys.split('+').forEach((key) => {
if (!acc[key]) acc[key] = [];
acc[key].push(kb);
});
return acc;
}, {} as Record<string, KeyBinding[]>);
function onKeyPress(params: StoredAction) {
if (onKeyPressRef.current) return onKeyPressRef.current(params);
return true;
function onKeyDown(params: StoredAction) {
if (onKeyDownRef.current == null) return true;
return onKeyDownRef.current(params);
}
keyBindings.forEach(({ action, keys }) => {
mousetrap.bind(keys, () => onKeyPress({ action }));
function onKeyUp(params: StoredAction) {
if (onKeyUpRef.current == null) return true;
return onKeyUpRef.current(params);
}
const handleKeyEvent = (e: KeyboardEvent, isKeyUp: boolean) => {
// console.log(e)
// Only capture key events when focus is on document body
// because we don't allow focus to anything else
// except for inputs, buttons, dialogs etc, which we want allowed to handle keys normally.
if (e.target !== document.body) {
return;
}
// Alternatively, this is how mousetrap does it:
// ignore when focus is inside inputs / textareas / selects / contentEditable,
// including elements inside shadow DOM. use composedPath() when available.
/* const path = (typeof e.composedPath === 'function' ? e.composedPath() : [e.target]) as EventTarget[];
const isEditableInPath = path.some((node) => {
if (!(node instanceof Element)) return false;
const tag = node.tagName;
return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || (node as HTMLElement).isContentEditable;
});
if (isEditableInPath) return;
*/
if (keyupActions.has(action)) {
mousetrap.bind(keys, () => onKeyPress({ action, keyup: true }), 'keyup');
if (allModifiers.has(e.code)) {
return; // ignore pure modifier key events
}
});
const ctrl = e.ctrlKey;
const shift = e.shiftKey;
const alt = e.altKey;
const meta = e.metaKey;
const matchingKeyBindings = (keyBindingsByKeyCode[e.code] ?? []).filter((kb) => {
const kbKeys = new Set(kb.keys.split('+'));
if ((controlModifiers.intersection(kbKeys).size > 0) !== ctrl) return false;
if ((shiftModifiers.intersection(kbKeys).size > 0) !== shift) return false;
if ((altModifiers.intersection(kbKeys).size > 0) !== alt) return false;
if ((metaModifiers.intersection(kbKeys).size > 0) !== meta) return false;
return true;
});
if (matchingKeyBindings.length === 0) {
return;
}
const bubble = matchingKeyBindings.every((kb) => (
isKeyUp ? onKeyUp({ action: kb.action }) : onKeyDown({ action: kb.action })
));
console.log('handled', isKeyUp ? 'keyup' : 'keydown', e.code, { bubble });
if (!bubble) {
e.preventDefault();
}
};
const handleKeyDown = (e: KeyboardEvent) => handleKeyEvent(e, false);
const handleKeyUp = (e: KeyboardEvent) => handleKeyEvent(e, true);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
return () => {
mousetrap.reset();
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
};
}, [keyBindings]);
};

@ -3,6 +3,7 @@ import { t } from 'i18next';
import normalizeWheel from './normalizeWheel';
import { ModifierKey } from '../../../../types';
import { getMetaKeyName } from '../util';
export const keyMap = {
ctrl: 'ctrlKey',
@ -12,10 +13,10 @@ export const keyMap = {
} as const;
export const getModifierKeyNames = () => ({
ctrl: [t('Ctrl')],
shift: [t('Shift')],
alt: [t('Alt')],
meta: [t('⌘ Cmd'), t('⊞ Win')],
ctrl: t('Ctrl'),
shift: t('Shift'),
alt: t('Alt'),
meta: getMetaKeyName(),
});
export const getModifier = (key: ModifierKey) => getModifierKeyNames()[key];

@ -491,6 +491,117 @@ export const mediaSourceQualities = ['HD', 'SD', 'OG']; // OG is original
export const splitKeyboardKeys = (keys: string) => keys.split('+');
// source: https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
// copy([...new Set([temp1, temp2, temp3].map((t) => t.querySelectorAll('tr td:nth-child(3) code:first-child')).flatMap((l) => [...l]).map((code) => code.innerText.replace(/"/g, '')))].join('\n'))
export const shiftModifiers = new Set(['ShiftLeft', 'ShiftRight']);
export const controlModifiers = new Set(['ControlLeft', 'ControlRight']);
export const altModifiers = new Set(['AltLeft', 'AltRight']);
export const metaModifiers = new Set(['MetaLeft', 'MetaRight']);
export const allModifiers = new Set([...shiftModifiers, ...controlModifiers, ...altModifiers, ...metaModifiers]);
export function getMetaKeyName() {
if (isMac) return i18n.t('⌘ Cmd');
if (isWindows) return i18n.t('⊞ Win');
return i18n.t('Meta');
}
export function formatKeyboardKey(key: string) {
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: '↓',
ControlLeft: 'Ctrl',
ControlRight: 'Ctrl',
ShiftLeft: 'Shift',
ShiftRight: 'Shift',
AltLeft: 'Alt',
AltRight: 'Alt',
MetaLeft: getMetaKeyName(),
MetaRight: getMetaKeyName(),
};
return map[key] || key;
}
export function shootConfetti(options?: confetti.Options) {
confetti({
particleCount: 30,

@ -51,6 +51,7 @@ export type WaveformMode = 'big-waveform' | 'waveform';
export interface Config {
version: number,
captureFormat: CaptureFormat,
customOutDir: string | undefined,
keyframeCut: boolean,

@ -2806,13 +2806,6 @@ __metadata:
languageName: node
linkType: hard
"@types/mousetrap@npm:^1.6.15":
version: 1.6.15
resolution: "@types/mousetrap@npm:1.6.15"
checksum: 10/58ec552218108f8e5f0d40ee81579aafbdb00c3af0ef4b6d3fb193a8a11a2d37aa84c6e20524120b7f218c2a87dac24f426ee3b23e1269d42f8145a1a37c9eca
languageName: node
linkType: hard
"@types/ms@npm:*":
version: 0.7.31
resolution: "@types/ms@npm:0.7.31"
@ -8256,7 +8249,6 @@ __metadata:
"@types/luxon": "npm:^3.4.2"
"@types/mime-types": "npm:^2.1.4"
"@types/morgan": "npm:^1.9.9"
"@types/mousetrap": "npm:^1.6.15"
"@types/node": "npm:20"
"@types/react": "npm:^18.2.66"
"@types/react-dom": "npm:^18.2.22"
@ -8310,7 +8302,6 @@ __metadata:
mitt: "npm:^3.0.1"
mkdirp: "npm:^1.0.3"
morgan: "npm:^1.10.0"
mousetrap: "npm:^1.6.5"
nanoid: "npm:^5.0.9"
p-map: "npm:^5.5.0"
p-retry: "npm:^6.2.0"
@ -8783,13 +8774,6 @@ __metadata:
languageName: node
linkType: hard
"mousetrap@npm:^1.6.5":
version: 1.6.5
resolution: "mousetrap@npm:1.6.5"
checksum: 10/2f55e715c8e106fa96135f8f3bc7fe71a3c497e803a451f357ae70598c3994a88c2907780f7abf51df4268c26cfb23f94db8687cd37154d65a0cc74cb530d438
languageName: node
linkType: hard
"ms@npm:2.0.0":
version: 2.0.0
resolution: "ms@npm:2.0.0"

Loading…
Cancel
Save