From 251106a292059cb19105b440fe53559121f436a3 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Thu, 6 Nov 2025 14:27:13 +0800 Subject: [PATCH] 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 --- package.json | 2 - src/main/configStore.ts | 283 +++++++++++++----- src/renderer/src/App.tsx | 66 ++-- src/renderer/src/NoFileLoaded.tsx | 7 +- .../src/components/Checkbox.module.css | 2 +- .../src/components/KeyboardShortcuts.tsx | 35 +-- src/renderer/src/components/Settings.tsx | 4 +- src/renderer/src/hooks/useKeyboard.ts | 121 ++++++-- src/renderer/src/hooks/useTimelineScroll.ts | 9 +- src/renderer/src/util.ts | 111 +++++++ types.ts | 1 + yarn.lock | 16 - 12 files changed, 482 insertions(+), 175 deletions(-) diff --git a/package.json b/package.json index 9f98a708..6d49176f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/configStore.ts b/src/main/configStore.ts index 884d1e33..d1c239c3 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -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 = { + /* 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); + } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fbfc916d..794c8352 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 boolean) | ((a: { keyup?: boolean | undefined }) => void)> = { + const ret: Record 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: diff --git a/src/renderer/src/NoFileLoaded.tsx b/src/renderer/src/NoFileLoaded.tsx index 171c1024..c18b29a7 100644 --- a/src/renderer/src/NoFileLoaded.tsx +++ b/src/renderer/src/NoFileLoaded.tsx @@ -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) => ( - {key.toUpperCase()}{i < split.length - 1 && {' + '}} + {formatKeyboardKey(key)}{i < split.length - 1 && {' + '}} )); } @@ -70,7 +71,7 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy
- , or {segmentMouseModifierKey}+ to set cutpoints + , or {getModifier(segmentMouseModifierKey)}+ to set cutpoints
e.stopPropagation()}> diff --git a/src/renderer/src/components/Checkbox.module.css b/src/renderer/src/components/Checkbox.module.css index 09bbd4e4..304386ff 100644 --- a/src/renderer/src/components/Checkbox.module.css +++ b/src/renderer/src/components/Checkbox.module.css @@ -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 { diff --git a/src/renderer/src/components/KeyboardShortcuts.tsx b/src/renderer/src/components/KeyboardShortcuts.tsx index 2e9ec310..0c16556e 100644 --- a/src/renderer/src/components/KeyboardShortcuts.tsx +++ b/src/renderer/src/components/KeyboardShortcuts.tsx @@ -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 keys.map((key, i) => ( {i > 0 && } - {key.toUpperCase()} + {formatKeyboardKey(key)} )); -// 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:
{text}
- {getModifier(modifier).map((v) => {v})} + {getModifier(modifier)} {wheelText}
diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index bfed4568..ec8433d6 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -42,8 +42,8 @@ function ModifierKeySetting({ text, value, setValue }: { text: string, value: Mo {text} diff --git a/src/renderer/src/hooks/useKeyboard.ts b/src/renderer/src/hooks/useKeyboard.ts index 1bdad856..f1d0ad61 100644 --- a/src/renderer/src/hooks/useKeyboard.ts +++ b/src/renderer/src/hooks/useKeyboard.ts @@ -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(['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); - 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]); }; diff --git a/src/renderer/src/hooks/useTimelineScroll.ts b/src/renderer/src/hooks/useTimelineScroll.ts index ae7f3880..e978c8be 100644 --- a/src/renderer/src/hooks/useTimelineScroll.ts +++ b/src/renderer/src/hooks/useTimelineScroll.ts @@ -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]; diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 4794f8a0..20654719 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -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 = { + 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, diff --git a/types.ts b/types.ts index a8af856c..d7b8dd46 100644 --- a/types.ts +++ b/types.ts @@ -51,6 +51,7 @@ export type WaveformMode = 'big-waveform' | 'waveform'; export interface Config { + version: number, captureFormat: CaptureFormat, customOutDir: string | undefined, keyframeCut: boolean, diff --git a/yarn.lock b/yarn.lock index 2870980e..b6807e9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"