Implement dynamic keyboard mapping #254

with UI to control mappings
pull/982/head
Mikael Finstad 2022-02-20 17:23:18 +08:00
rodzic 8f7eeb4fb2
commit 3e89e60981
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
11 zmienionych plików z 707 dodań i 275 usunięć

Wyświetl plik

@ -50,6 +50,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
- Import/export segments: MP4/MKV chapter marks, Text file, YouTube, CSV, CUE, XML (DaVinci, Final Cut Pro) and more
- MKV/MP4 embedded chapters marks editor
- View subtitles
- Customizable keyboard hotkeys
## Example lossless use cases

Wyświetl plik

@ -7,6 +7,61 @@ const { pathExists } = require('fs-extra');
const { app } = electron;
const defaultKeyBindings = [
{ 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: ',', action: 'seekPreviousFrame' },
{ keys: '.', action: 'seekNextFrame' },
{ keys: 'c', action: 'captureSnapshot' },
{ keys: 'i', action: 'setCutStart' },
{ keys: 'o', action: 'setCutEnd' },
{ keys: 'backspace', action: 'removeCurrentSegment' },
{ keys: 'd', action: 'cleanupFilesDialog' },
{ keys: 'b', action: 'splitCurrentSegment' },
{ keys: 'r', action: 'increaseRotation' },
{ keys: 'g', action: 'goToTimecode' },
{ keys: 'left', action: 'seekBackwards' },
{ 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+right', action: 'seekForwardsPercent' },
{ keys: 'command+right', action: 'seekForwardsPercent' },
{ keys: 'alt+right', action: 'seekForwardsKeyframe' },
{ keys: 'shift+right', action: 'jumpCutEnd' },
{ keys: 'up', action: 'selectPrevSegment' },
{ keys: 'ctrl+up', action: 'timelineZoomIn' },
{ keys: 'command+up', action: 'timelineZoomIn' },
{ keys: 'shift+up', action: 'batchPreviousFile' },
{ keys: 'down', action: 'selectNextSegment' },
{ keys: 'ctrl+down', action: 'timelineZoomOut' },
{ keys: 'command+down', action: 'timelineZoomOut' },
{ keys: 'shift+down', action: 'batchNextFile' },
// 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: 'enter', action: 'labelCurrentSegment' },
{ keys: 'e', action: 'export' },
{ keys: 'h', action: 'toggleHelp' },
{ keys: 'escape', action: 'closeActiveScreen' },
];
const defaults = {
captureFormat: 'jpeg',
customOutDir: undefined,
@ -42,6 +97,7 @@ const defaults = {
safeOutputFileName: true,
windowBounds: undefined,
enableAutoHtml5ify: true,
keyBindings: defaultKeyBindings,
};
// For portable app: https://github.com/mifi/lossless-cut/issues/645

Wyświetl plik

@ -9,7 +9,6 @@ import useDebounceOld from 'react-use/lib/useDebounce'; // Want to phase out thi
import { useDebounce } from 'use-debounce';
import i18n from 'i18next';
import { useTranslation } from 'react-i18next';
import Mousetrap from 'mousetrap';
import JSON5 from 'json5';
import fromPairs from 'lodash/fromPairs';
@ -23,6 +22,7 @@ import useUserPreferences from './hooks/useUserPreferences';
import useFfmpegOperations from './hooks/useFfmpegOperations';
import useKeyframes from './hooks/useKeyframes';
import useWaveform from './hooks/useWaveform';
import useKeyboard from './hooks/useKeyboard';
import NoFileLoaded from './NoFileLoaded';
import Canvas from './Canvas';
import TopMenu from './TopMenu';
@ -39,6 +39,7 @@ import VolumeControl from './components/VolumeControl';
import SubtitleControl from './components/SubtitleControl';
import BatchFilesList from './components/BatchFilesList';
import ConcatDialog from './components/ConcatDialog';
import KeyboardShortcuts from './components/KeyboardShortcuts';
import Loading from './components/Loading';
import { loadMifiLink } from './mifi';
@ -63,7 +64,7 @@ import {
} from './util';
import { formatDuration } from './util/duration';
import { adjustRate } from './util/rate-calculator';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog } from './dialogs';
import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog } from './dialogs';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags } from './segments';
@ -147,6 +148,7 @@ const App = memo(() => {
const [helpVisible, setHelpVisible] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [tunerVisible, setTunerVisible] = useState();
const [keyboardShortcutsVisible, setKeyboardShortcutsVisible] = useState(false);
const [mifiLink, setMifiLink] = useState();
const [alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles] = useState(false);
@ -195,7 +197,7 @@ const App = memo(() => {
const isCustomFormatSelected = fileFormat !== detectedFileFormat;
const {
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify, segmentsToChaptersOnly, setSegmentsToChaptersOnly,
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify, segmentsToChaptersOnly, setSegmentsToChaptersOnly, keyBindings, setKeyBindings,
} = useUserPreferences();
const {
@ -327,7 +329,7 @@ const App = memo(() => {
}, [canvasPlayerEnabled]);
const comfortZoom = isDurationValid(duration) ? Math.max(duration / 100, 1) : undefined;
const toggleComfortZoom = useCallback(() => {
const timelineToggleComfortZoom = useCallback(() => {
if (!comfortZoom) return;
setZoom((prevZoom) => {
@ -1017,14 +1019,14 @@ const App = memo(() => {
setBatchFiles([]);
}, [askBeforeClose]);
const removeBatchFile = useCallback((path) => setBatchFiles((existingBatch) => existingBatch.filter((existingFile) => existingFile.path !== path)), []);
const batchRemoveFile = useCallback((path) => setBatchFiles((existingBatch) => existingBatch.filter((existingFile) => existingFile.path !== path)), []);
const cleanupFiles = useCallback(async () => {
const cleanupFilesDialog = useCallback(async () => {
if (!isFileOpened) return;
let trashResponse = cleanupChoices;
if (!cleanupChoices.dontShowAgain) {
trashResponse = await cleanupFilesDialog(cleanupChoices);
trashResponse = await showCleanupFilesDialog(cleanupChoices);
console.log('trashResponse', trashResponse);
if (!trashResponse) return; // Cancelled
setCleanupChoices(trashResponse); // Store for next time
@ -1037,7 +1039,7 @@ const App = memo(() => {
resetState();
removeBatchFile(savedPaths.filePath);
batchRemoveFile(savedPaths.filePath);
if (!trashResponse.tmpFiles && !trashResponse.projectFile && !trashResponse.sourceFile) return;
@ -1051,7 +1053,7 @@ const App = memo(() => {
} finally {
setWorking();
}
}, [previewFilePath, filePath, edlFilePath, cleanupChoices, isFileOpened, resetState, removeBatchFile, setWorking]);
}, [previewFilePath, filePath, edlFilePath, cleanupChoices, isFileOpened, resetState, batchRemoveFile, setWorking]);
const inverseOrNormalSegments = useMemo(() => (invertCutSegments ? inverseCutSegments : apparentCutSegments),
[invertCutSegments, inverseCutSegments, apparentCutSegments]);
@ -1253,7 +1255,7 @@ const App = memo(() => {
else await onExportConfirm();
}, [filePath, haveInvalidSegs, segmentsToExport, exportConfirmEnabled, onExportConfirm]);
const capture = useCallback(async () => {
const captureSnapshot = useCallback(async () => {
if (!filePath) return;
try {
@ -1467,31 +1469,178 @@ const App = memo(() => {
const seekAccelerationRef = useRef(1);
useEffect(() => {
if (concatDialogVisible) return () => {};
const userOpenSingleFile = useCallback(async ({ path: pathIn, isLlcProject }) => {
let path = pathIn;
let projectPath;
function onKeyPress() {
if (exportConfirmVisible) onExportConfirm();
else onExportPress();
// Open .llc AND media referenced within
if (isLlcProject) {
console.log('Loading LLC project', path);
const project = await loadLlcProject(path);
const { mediaFileName } = project;
console.log({ mediaFileName });
if (!mediaFileName) return;
projectPath = path;
path = pathJoin(dirname(path), mediaFileName);
}
// Because Apple is being nazi about the ability to open "copy protected DVD files"
const disallowVob = isMasBuild;
if (disallowVob && /\.vob$/i.test(path)) {
toast.fire({ icon: 'error', text: 'Unfortunately .vob files are not supported in the App Store version of LosslessCut due to Apple restrictions' });
return;
}
const mousetrap = new Mousetrap();
mousetrap.bind('e', onKeyPress);
return () => mousetrap.reset();
}, [exportConfirmVisible, onExportConfirm, onExportPress, concatDialogVisible]);
if (!(await pathExists(path))) {
errorToast(i18n.t('The media you tried to open does not exist'));
return;
}
useEffect(() => {
function onEscPress() {
if (!(await havePermissionToReadFile(path))) {
errorToast(i18n.t('You do not have permission to access this file'));
return;
}
const { newCustomOutDir, cancel } = await ensureOutDirAccessible(path);
if (cancel) return;
await loadMedia({ filePath: path, customOutDir: newCustomOutDir, projectPath });
}, [ensureOutDirAccessible, loadMedia]);
const batchOpenSingleFile = useCallback(async (path) => {
if (workingRef.current) return;
if (filePath === path) return;
try {
setWorking(i18n.t('Loading file'));
await userOpenSingleFile({ path });
} catch (err) {
handleError(err);
} finally {
setWorking();
}
}, [userOpenSingleFile, setWorking, filePath]);
const batchFileJump = useCallback((direction) => {
const pathIndex = batchFiles.findIndex(({ path }) => path === filePath);
if (pathIndex === -1) return;
const nextFile = batchFiles[pathIndex + direction];
if (!nextFile) return;
batchOpenSingleFile(nextFile.path);
}, [filePath, batchFiles, batchOpenSingleFile]);
const goToTimecode = useCallback(async () => {
if (!filePath) return;
const timeCode = await promptTimeOffset({
initialValue: formatDuration({ seconds: commandedTimeRef.current }),
title: i18n.t('Seek to timecode'),
});
if (timeCode === undefined) return;
seekAbs(timeCode);
}, [filePath, seekAbs]);
const onKeyPress = useCallback(({ action, keyup }) => {
function seekReset() {
seekAccelerationRef.current = 1;
}
// NOTE: Do not change these keys because users have bound keys by these names
// For actions, see also KeyboardShortcuts.jsx
const mainActions = {
togglePlayNoResetSpeed: () => togglePlay(),
togglePlayResetSpeed: () => togglePlay(true),
reducePlaybackRate: () => changePlaybackRate(-1),
reducePlaybackRateMore: () => changePlaybackRate(-1, 2.0),
increasePlaybackRate: () => changePlaybackRate(1),
increasePlaybackRateMore: () => changePlaybackRate(1, 2.0),
timelineToggleComfortZoom,
captureSnapshot,
setCutStart,
setCutEnd,
cleanupFilesDialog,
splitCurrentSegment,
increaseRotation,
goToTimecode,
seekBackwards() {
if (keyup) {
seekReset();
return;
}
seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current * -1);
seekAccelerationRef.current *= keyboardSeekAccFactor;
},
seekForwards() {
if (keyup) {
seekReset();
return;
}
seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current);
seekAccelerationRef.current *= keyboardSeekAccFactor;
},
seekBackwardsPercent: () => { seekRelPercent(-0.01); return false; },
seekForwardsPercent: () => { seekRelPercent(0.01); return false; },
seekBackwardsKeyframe: () => seekClosestKeyframe(-1),
seekForwardsKeyframe: () => seekClosestKeyframe(1),
seekPreviousFrame: () => shortStep(-1),
seekNextFrame: () => shortStep(1),
selectPrevSegment: () => jumpSeg(-1),
selectNextSegment: () => jumpSeg(1),
jumpCutStart,
jumpCutEnd,
timelineZoomIn: () => { zoomRel(1); return false; },
timelineZoomOut: () => { zoomRel(-1); return false; },
batchPreviousFile: () => batchFileJump(-1),
batchNextFile: () => batchFileJump(1),
closeBatch,
removeCurrentSegment: () => removeCutSegment(currentSegIndexSafe),
undo: () => cutSegmentsHistory.back(),
redo: () => cutSegmentsHistory.forward(),
labelCurrentSegment: () => { onLabelSegmentPress(currentSegIndexSafe); return false; },
addSegment: () => addCutSegment(),
toggleHelp: () => { toggleHelp(); return false; },
export: onExportPress,
};
function tryMainActions() {
const fn = mainActions[action];
if (!fn) return { match: false };
const bubble = fn();
return { match: true, bubble };
}
if (isDev) console.log('key event', action);
// always allow
if (action === 'closeActiveScreen') {
closeExportConfirm();
setHelpVisible(false);
setSettingsVisible(false);
return false;
}
const mousetrap = new Mousetrap();
mousetrap.bind('h', toggleHelp);
mousetrap.bind('escape', onEscPress);
return () => mousetrap.reset();
}, [closeExportConfirm, toggleHelp]);
if (concatDialogVisible || keyboardShortcutsVisible) {
return true; // don't allow any further hotkeys
}
if (exportConfirmVisible) {
if (action === 'export') {
onExportConfirm();
return false;
}
return true; // don't allow any other hotkeys because we are at export confirm
}
// allow main actions
const { match, bubble } = tryMainActions();
if (match) return bubble;
return true; // bubble the event
}, [addCutSegment, batchFileJump, captureSnapshot, changePlaybackRate, cleanupFilesDialog, closeBatch, closeExportConfirm, concatDialogVisible, currentSegIndexSafe, cutSegmentsHistory, exportConfirmVisible, goToTimecode, increaseRotation, jumpCutEnd, jumpCutStart, jumpSeg, keyboardNormalSeekSpeed, keyboardSeekAccFactor, keyboardShortcutsVisible, onExportConfirm, onExportPress, onLabelSegmentPress, removeCutSegment, seekClosestKeyframe, seekRel, seekRelPercent, setCutEnd, setCutStart, shortStep, splitCurrentSegment, timelineToggleComfortZoom, toggleHelp, togglePlay, zoomRel]);
useKeyboard({ keyBindings, onKeyPress });
useEffect(() => {
document.ondragover = dragPreventer;
@ -1549,72 +1698,14 @@ const App = memo(() => {
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
}, [externalStreamFiles, setCopyStreamIdsForPath]);
const userOpenSingleFile = useCallback(async ({ path: pathIn, isLlcProject }) => {
let path = pathIn;
let projectPath;
// Open .llc AND media referenced within
if (isLlcProject) {
console.log('Loading LLC project', path);
const project = await loadLlcProject(path);
const { mediaFileName } = project;
console.log({ mediaFileName });
if (!mediaFileName) return;
projectPath = path;
path = pathJoin(dirname(path), mediaFileName);
}
// Because Apple is being nazi about the ability to open "copy protected DVD files"
const disallowVob = isMasBuild;
if (disallowVob && /\.vob$/i.test(path)) {
toast.fire({ icon: 'error', text: 'Unfortunately .vob files are not supported in the App Store version of LosslessCut due to Apple restrictions' });
return;
}
if (!(await pathExists(path))) {
errorToast(i18n.t('The media you tried to open does not exist'));
return;
}
if (!(await havePermissionToReadFile(path))) {
errorToast(i18n.t('You do not have permission to access this file'));
return;
}
const { newCustomOutDir, cancel } = await ensureOutDirAccessible(path);
if (cancel) return;
await loadMedia({ filePath: path, customOutDir: newCustomOutDir, projectPath });
}, [ensureOutDirAccessible, loadMedia]);
const checkFileOpened = useCallback(() => {
if (isFileOpened) return true;
toast.fire({ icon: 'info', title: i18n.t('You need to open a media file first') });
return false;
}, [isFileOpened]);
const batchOpenSingleFile = useCallback(async (path) => {
if (workingRef.current) return;
if (filePath === path) return;
try {
setWorking(i18n.t('Loading file'));
await userOpenSingleFile({ path });
} catch (err) {
handleError(err);
} finally {
setWorking();
}
}, [userOpenSingleFile, setWorking, filePath]);
const batchFilePaths = useMemo(() => batchFiles.map((f) => f.path), [batchFiles]);
const batchFileJump = useCallback((direction) => {
const pathIndex = batchFiles.findIndex(({ path }) => path === filePath);
if (pathIndex === -1) return;
const nextFile = batchFiles[pathIndex + direction];
if (!nextFile) return;
batchOpenSingleFile(nextFile.path);
}, [filePath, batchFiles, batchOpenSingleFile]);
const batchLoadPaths = useCallback((newPaths, append) => {
setBatchFiles((existingFiles) => {
const mapPathsToFiles = (paths) => paths.map((path) => ({ path, name: basename(path) }));
@ -1741,120 +1832,6 @@ const App = memo(() => {
}
}, [customOutDir, filePath, html5ifyAndLoad, hasVideo, hasAudio, rememberConvertToSupportedFormat, setWorking]);
const goToTimecode = useCallback(async () => {
if (!filePath) return;
const timeCode = await promptTimeOffset({
initialValue: formatDuration({ seconds: commandedTimeRef.current }),
title: i18n.t('Seek to timecode'),
});
if (timeCode === undefined) return;
seekAbs(timeCode);
}, [filePath, seekAbs]);
// TODO split up?
useEffect(() => {
if (exportConfirmVisible || concatDialogVisible) return () => {};
const togglePlayNoReset = () => togglePlay();
const togglePlayReset = () => togglePlay(true);
const reducePlaybackRate = () => changePlaybackRate(-1);
const reducePlaybackRateMore = () => changePlaybackRate(-1, 2.0);
const increasePlaybackRate = () => changePlaybackRate(1);
const increasePlaybackRateMore = () => changePlaybackRate(1, 2.0);
function seekBackwards() {
seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current * -1);
seekAccelerationRef.current *= keyboardSeekAccFactor;
}
function seekForwards() {
seekRel(keyboardNormalSeekSpeed * seekAccelerationRef.current);
seekAccelerationRef.current *= keyboardSeekAccFactor;
}
const seekReset = () => {
seekAccelerationRef.current = 1;
};
const seekBackwardsPercent = () => { seekRelPercent(-0.01); return false; };
const seekForwardsPercent = () => { seekRelPercent(0.01); return false; };
const seekBackwardsKeyframe = () => seekClosestKeyframe(-1);
const seekForwardsKeyframe = () => seekClosestKeyframe(1);
const seekBackwardsShort = () => shortStep(-1);
const seekForwardsShort = () => shortStep(1);
const jumpPrevSegment = () => jumpSeg(-1);
const jumpNextSegment = () => jumpSeg(1);
const zoomIn = () => { zoomRel(1); return false; };
const zoomOut = () => { zoomRel(-1); return false; };
const batchPreviousFile = () => batchFileJump(-1);
const batchNextFile = () => batchFileJump(1);
// 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
const mousetrap = new Mousetrap();
// mousetrap.bind(':', () => console.log('test'));
mousetrap.bind('plus', () => addCutSegment());
mousetrap.bind('space', () => togglePlayReset());
mousetrap.bind('k', () => togglePlayNoReset());
mousetrap.bind('j', () => reducePlaybackRate());
mousetrap.bind('shift+j', () => reducePlaybackRateMore());
mousetrap.bind('l', () => increasePlaybackRate());
mousetrap.bind('shift+l', () => increasePlaybackRateMore());
mousetrap.bind('z', () => toggleComfortZoom());
mousetrap.bind(',', () => seekBackwardsShort());
mousetrap.bind('.', () => seekForwardsShort());
mousetrap.bind('c', () => capture());
mousetrap.bind('i', () => setCutStart());
mousetrap.bind('o', () => setCutEnd());
mousetrap.bind('backspace', () => removeCutSegment(currentSegIndexSafe));
mousetrap.bind('d', () => cleanupFiles());
mousetrap.bind('b', () => splitCurrentSegment());
mousetrap.bind('r', () => increaseRotation());
mousetrap.bind('g', () => goToTimecode());
mousetrap.bind('left', () => seekBackwards());
mousetrap.bind('left', () => seekReset(), 'keyup');
mousetrap.bind(['ctrl+left', 'command+left'], () => seekBackwardsPercent());
mousetrap.bind('alt+left', () => seekBackwardsKeyframe());
mousetrap.bind('shift+left', () => jumpCutStart());
mousetrap.bind('right', () => seekForwards());
mousetrap.bind('right', () => seekReset(), 'keyup');
mousetrap.bind(['ctrl+right', 'command+right'], () => seekForwardsPercent());
mousetrap.bind('alt+right', () => seekForwardsKeyframe());
mousetrap.bind('shift+right', () => jumpCutEnd());
mousetrap.bind('up', () => jumpPrevSegment());
mousetrap.bind(['ctrl+up', 'command+up'], () => zoomIn());
mousetrap.bind(['shift+up'], () => batchPreviousFile());
mousetrap.bind('down', () => jumpNextSegment());
mousetrap.bind(['ctrl+down', 'command+down'], () => zoomOut());
mousetrap.bind(['shift+down'], () => batchNextFile());
// https://github.com/mifi/lossless-cut/issues/610
Mousetrap.bind(['ctrl+z', 'command+z'], (e) => {
e.preventDefault();
cutSegmentsHistory.back();
});
Mousetrap.bind(['ctrl+shift+z', 'command+shift+z'], (e) => {
e.preventDefault();
cutSegmentsHistory.forward();
});
mousetrap.bind(['enter'], () => {
onLabelSegmentPress(currentSegIndexSafe);
return false;
});
return () => mousetrap.reset();
}, [
addCutSegment, capture, changePlaybackRate, togglePlay, removeCutSegment,
setCutEnd, setCutStart, seekRel, seekRelPercent, shortStep, cleanupFiles, jumpSeg,
seekClosestKeyframe, zoomRel, toggleComfortZoom, splitCurrentSegment, exportConfirmVisible, concatDialogVisible,
increaseRotation, jumpCutStart, jumpCutEnd, cutSegmentsHistory, keyboardSeekAccFactor,
keyboardNormalSeekSpeed, onLabelSegmentPress, currentSegIndexSafe, batchFileJump, goToTimecode,
]);
const onVideoError = useCallback(async () => {
const { error } = videoRef.current;
if (!error) return;
@ -2091,6 +2068,8 @@ const App = memo(() => {
setTunerVisible(type);
}, []);
const onKeyboardShortcutsDialogRequested = useCallback(() => setKeyboardShortcutsVisible(true), []);
useEffect(() => {
if (!isStoreBuild) loadMifiLink().then(setMifiLink);
}, []);
@ -2203,7 +2182,7 @@ const App = memo(() => {
width={leftBarWidth}
batchFiles={batchFiles}
batchOpenSingleFile={batchOpenSingleFile}
removeBatchFile={removeBatchFile}
batchRemoveFile={batchRemoveFile}
closeBatch={closeBatch}
onMergeFilesClick={onMergeFilesClick}
onBatchConvertToSupportedFormatClick={batchConvertFormat}
@ -2345,7 +2324,7 @@ const App = memo(() => {
setZoom={setZoom}
invertCutSegments={invertCutSegments}
setInvertCutSegments={setInvertCutSegments}
toggleComfortZoom={toggleComfortZoom}
timelineToggleComfortZoom={timelineToggleComfortZoom}
simpleMode={simpleMode}
toggleSimpleMode={toggleSimpleMode}
hasVideo={hasVideo}
@ -2354,9 +2333,9 @@ const App = memo(() => {
areWeCutting={areWeCutting}
autoMerge={autoMerge}
increaseRotation={increaseRotation}
cleanupFiles={cleanupFiles}
cleanupFilesDialog={cleanupFilesDialog}
renderCaptureFormatButton={renderCaptureFormatButton}
capture={capture}
captureSnapshot={captureSnapshot}
onExportPress={onExportPress}
enabledSegments={enabledSegments}
exportConfirmEnabled={exportConfirmEnabled}
@ -2431,7 +2410,7 @@ const App = memo(() => {
visible={helpVisible}
onTogglePress={toggleHelp}
ffmpegCommandLog={ffmpegCommandLog}
currentCutSeg={currentCutSeg}
onKeyboardShortcutsDialogRequested={onKeyboardShortcutsDialogRequested}
/>
<Sheet visible={settingsVisible} onClosePress={toggleSettings} style={{ background: 'white', color: 'black' }}>
@ -2475,12 +2454,15 @@ const App = memo(() => {
AutoExportToggler={AutoExportToggler}
renderCaptureFormatButton={renderCaptureFormatButton}
onTunerRequested={onTunerRequested}
onKeyboardShortcutsDialogRequested={onKeyboardShortcutsDialogRequested}
/>
</Table.Body>
</Table>
</Sheet>
<ConcatDialog isShown={batchFiles.length > 0 && concatDialogVisible} onHide={() => setConcatDialogVisible(false)} initialPaths={batchFilePaths} onConcat={mergeFiles} segmentsToChapters={segmentsToChapters} setSegmentsToChapters={setSegmentsToChapters} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} preserveMetadataOnMerge={preserveMetadataOnMerge} setPreserveMetadataOnMerge={setPreserveMetadataOnMerge} preserveMovData={preserveMovData} setPreserveMovData={setPreserveMovData} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} />
</div>
</ThemeProvider>
);

Wyświetl plik

@ -27,9 +27,9 @@ const zoomOptions = Array(13).fill().map((unused, z) => 2 ** z);
const leftRightWidth = 100;
const BottomBar = memo(({
zoom, setZoom, invertCutSegments, setInvertCutSegments, toggleComfortZoom, simpleMode, toggleSimpleMode,
isRotationSet, rotation, areWeCutting, increaseRotation, cleanupFiles, renderCaptureFormatButton,
capture, onExportPress, enabledSegments, hasVideo, autoMerge, exportConfirmEnabled, toggleExportConfirmEnabled,
zoom, setZoom, invertCutSegments, setInvertCutSegments, timelineToggleComfortZoom, simpleMode, toggleSimpleMode,
isRotationSet, rotation, areWeCutting, increaseRotation, cleanupFilesDialog, renderCaptureFormatButton,
captureSnapshot, onExportPress, enabledSegments, hasVideo, autoMerge, exportConfirmEnabled, toggleExportConfirmEnabled,
seekAbs, currentSegIndexSafe, cutSegments, currentCutSeg, setCutStart, setCutEnd,
setCurrentSegIndex, cutStartTimeManual, setCutStartTimeManual, cutEndTimeManual, setCutEndTimeManual,
duration, jumpCutEnd, jumpCutStart, startTimeOffset, setCutTime, currentApparentCutSeg,
@ -302,7 +302,7 @@ const BottomBar = memo(({
</motion.div>
</div>
<div role="button" style={{ marginRight: 5, marginLeft: 10 }} title={t('Zoom')} onClick={toggleComfortZoom}>{Math.floor(zoom)}x</div>
<div role="button" style={{ marginRight: 5, marginLeft: 10 }} title={t('Zoom')} onClick={timelineToggleComfortZoom}>{Math.floor(zoom)}x</div>
<Select height={20} style={{ flexBasis: 85, flexGrow: 0 }} value={zoomOptions.includes(zoom) ? zoom.toString() : ''} title={t('Zoom')} onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
<option key="" value="" disabled>{t('Zoom')}</option>
@ -337,7 +337,7 @@ const BottomBar = memo(({
title={t('Close file and clean up')}
style={{ padding: '5px 10px' }}
size={16}
onClick={cleanupFiles}
onClick={cleanupFilesDialog}
role="button"
/>
)}
@ -350,7 +350,7 @@ const BottomBar = memo(({
style={{ paddingLeft: 5, paddingRight: 15 }}
size={25}
title={t('Capture frame')}
onClick={capture}
onClick={captureSnapshot}
/>
</>
)}

Wyświetl plik

@ -1,9 +1,8 @@
import React, { memo } from 'react';
import { FaStepBackward, FaStepForward } from 'react-icons/fa';
import { FaKeyboard } from 'react-icons/fa';
import { useTranslation, Trans } from 'react-i18next';
import { Button } from 'evergreen-ui';
import SetCutpointButton from './components/SetCutpointButton';
import SegmentCutpointButton from './components/SegmentCutpointButton';
import CopyClipboardButton from './components/CopyClipboardButton';
import { primaryTextColor } from './colors';
import Sheet from './Sheet';
@ -12,13 +11,12 @@ const electron = window.require('electron');
const { githubLink } = electron.remote.require('./constants');
const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSeg }) => {
const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, onKeyboardShortcutsDialogRequested }) => {
const { t } = useTranslation();
return (
<Sheet visible={visible} onClosePress={onTogglePress} style={{ background: '#6b6b6b', color: 'white' }}>
<div className="help-sheet">
<p><Trans><b>Note:</b> Keyframe cut and Merge cuts buttons have been moved to the export panel (press Export to see it.)</Trans></p>
<h1>{t('Common problems')}</h1>
<p>
{t('Lossless cutting is not an exact science. For some codecs and files it just works. For others you may need to trial and error depending on the codec, keyframes etc to get the best cut.')}
@ -36,63 +34,7 @@ const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSe
<span style={{ color: primaryTextColor, cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(githubLink)}>{githubLink}</span>
</p>
<h1>{t('Keyboard & mouse shortcuts')}</h1>
<div><kbd>H</kbd> {t('Show/hide help screen')}</div>
<h2>{t('Playback')}</h2>
<div><kbd>SPACE</kbd>, <kbd>k</kbd> {t('Play/pause')}</div>
<div><kbd>J</kbd> {t('Slow down playback')}</div>
<div><kbd>L</kbd> {t('Speed up playback')}</div>
<div><kbd>SHIFT</kbd> + <kbd>J</kbd> {t('Slow down playback more')}</div>
<div><kbd>SHIFT</kbd> + <kbd>L</kbd> {t('Speed up playback more')}</div>
<h2>{t('Seeking')}</h2>
<div><kbd>,</kbd> {t('Step backward 1 frame')}</div>
<div><kbd>.</kbd> {t('Step forward 1 frame')}</div>
<div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> {t('Seek to previous keyframe')}</div>
<div><kbd>ALT</kbd> / <kbd>OPT</kbd> + <kbd></kbd> {t('Seek to next keyframe')}</div>
<div><kbd></kbd> {t('Seek backward 1 sec')}</div>
<div><kbd></kbd> {t('Seek forward 1 sec')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Seek backward 1% of timeline at current zoom')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Seek forward 1% of timeline at current zoom')}</div>
<div style={{ lineHeight: 1.7 }}><SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} style={{ verticalAlign: 'middle' }} />, <kbd>SHIFT</kbd> + <kbd></kbd> {t('Jump to cut start')}</div>
<div style={{ lineHeight: 1.7 }}><SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} style={{ verticalAlign: 'middle' }} />, <kbd>SHIFT</kbd> + <kbd></kbd> {t('Jump to cut end')}</div>
<div><kbd>G</kbd> {t('Seek to timecode')}</div>
<h2>{t('Segments and cut points')}</h2>
<div style={{ lineHeight: 1.7 }}><SetCutpointButton currentCutSeg={currentCutSeg} side="start" style={{ verticalAlign: 'middle' }} />, <kbd>I</kbd> {t('Mark in / cut start point for current segment')}</div>
<div style={{ lineHeight: 1.7 }}><SetCutpointButton currentCutSeg={currentCutSeg} side="end" style={{ verticalAlign: 'middle' }} />, <kbd>O</kbd> {t('Mark out / cut end point for current segment')}</div>
<div><kbd>+</kbd> {t('Add cut segment')}</div>
<div><kbd>BACKSPACE</kbd> {t('Remove current segment')}</div>
<div><kbd>ENTER</kbd> {t('Label current segment')}</div>
<div><kbd></kbd> {t('Select previous segment')}</div>
<div><kbd></kbd> {t('Select next segment')}</div>
<div><kbd>B</kbd> {t('Split segment at cursor')}</div>
<h2>{t('Timeline/zoom operations')}</h2>
<div><kbd>Z</kbd> {t('Toggle zoom between 1x and a calculated comfortable zoom level')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Zoom in timeline')}</div>
<div><kbd>CTRL</kbd> / <kbd>CMD</kbd> + <kbd></kbd> {t('Zoom out timeline')}</div>
<div><kbd>CTRL</kbd> <i>+ {t('Mouse scroll/wheel up/down')}</i> - {t('Zoom in/out timeline')}</div>
<div><i>{t('Mouse scroll/wheel left/right')}</i> - {t('Pan timeline')}</div>
<h2>{t('Other operations')}</h2>
<div><kbd>R</kbd> {t('Change rotation')}</div>
<h2>{t('Output actions')}</h2>
<div><kbd>E</kbd> {t('Export segment(s)')}</div>
<div><kbd>C</kbd> {t('Capture snapshot')}</div>
<div><kbd>D</kbd> {t('Delete source file')}</div>
<h2>{t('Batch file list')}</h2>
<div><kbd>SHIFT</kbd> + <kbd></kbd> {t('Previous file')}</div>
<div><kbd>SHIFT</kbd> + <kbd></kbd> {t('Next file')}</div>
<p style={{ fontWeight: 'bold' }}>{t('Hover mouse over buttons in the main interface to see which function they have')}</p>
<Button iconBefore={() => <FaKeyboard />} onClick={onKeyboardShortcutsDialogRequested}>{t('Keyboard & mouse shortcuts')}</Button>
<h1 style={{ marginTop: 40 }}>{t('Last ffmpeg commands')}</h1>
{ffmpegCommandLog.length > 0 ? (

Wyświetl plik

@ -1,5 +1,5 @@
import React, { memo, useCallback, useMemo } from 'react';
import { FaYinYang } from 'react-icons/fa';
import { FaYinYang, FaKeyboard } from 'react-icons/fa';
import { Button, Table, NumericalIcon, KeyIcon, FolderCloseIcon, DocumentIcon, TimeIcon, Checkbox, Select } from 'evergreen-ui';
import { useTranslation } from 'react-i18next';
@ -39,6 +39,7 @@ const Settings = memo(({
enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction,
hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode,
enableTransferTimestamps, setEnableTransferTimestamps, enableAutoHtml5ify, setEnableAutoHtml5ify,
onKeyboardShortcutsDialogRequested,
}) => {
const { t } = useTranslation();
@ -79,6 +80,13 @@ const Settings = memo(({
</Table.TextCell>
</Row>
<Row>
<KeyCell>{t('Keyboard & mouse shortcuts')}</KeyCell>
<Table.TextCell>
<Button iconBefore={() => <FaKeyboard />} onClick={onKeyboardShortcutsDialogRequested}>{t('Keyboard & mouse shortcuts')}</Button>
</Table.TextCell>
</Row>
<Row>
<KeyCell>
{t('Working directory')}<br />

Wyświetl plik

@ -7,7 +7,7 @@ import BatchFile from './BatchFile';
import useNativeMenu from '../hooks/useNativeMenu';
import { timelineBackground, controlsBackground } from '../colors';
const BatchFilesList = memo(({ filePath, width, batchFiles, batchOpenSingleFile, removeBatchFile, closeBatch, onMergeFilesClick, onBatchConvertToSupportedFormatClick }) => {
const BatchFilesList = memo(({ filePath, width, batchFiles, batchOpenSingleFile, batchRemoveFile, closeBatch, onMergeFilesClick, onBatchConvertToSupportedFormatClick }) => {
const { t } = useTranslation();
const contextMenuTemplate = useMemo(() => [
@ -37,7 +37,7 @@ const BatchFilesList = memo(({ filePath, width, batchFiles, batchOpenSingleFile,
<div style={{ overflowX: 'hidden', overflowY: 'auto' }}>
{batchFiles.map(({ path, name }) => (
<BatchFile key={path} path={path} name={name} filePath={filePath} onOpen={batchOpenSingleFile} onDelete={removeBatchFile} />
<BatchFile key={path} path={path} name={name} filePath={filePath} onOpen={batchOpenSingleFile} onDelete={batchRemoveFile} />
))}
</div>
</motion.div>

Wyświetl plik

@ -0,0 +1,404 @@
import React, { memo, Fragment, useEffect, useMemo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, InlineAlert, UndoIcon, Paragraph, TakeActionIcon, IconButton, Button, DeleteIcon, AddIcon, Heading, Text, Dialog } from 'evergreen-ui';
import { FaMouse, FaPlus, FaStepForward, FaStepBackward } from 'react-icons/fa';
import Mousetrap from 'mousetrap';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import uniq from 'lodash/uniq';
import SetCutpointButton from './SetCutpointButton';
import SegmentCutpointButton from './SegmentCutpointButton';
const renderKeys = (keys) => keys.map((key, i) => (
<Fragment key={key}>
{i > 0 && <FaPlus size={8} style={{ marginLeft: 4, marginRight: 4, color: 'rgba(0,0,0,0.5)' }} />}
<kbd>{key.toUpperCase()}</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 = ['shift', 'ctrl', 'alt', 'meta'];
function fixKeys(keys) {
const replaced = keys.map((key) => {
if (key === 'option') return 'alt';
if (key === 'command') return 'meta';
return key;
});
const uniqed = uniq(replaced);
const nonModifierKeys = keys.filter((key) => !allModifiers.includes(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]);
}
const CreateBinding = memo(({
actionsMap, action, setCreatingBinding, onNewKeyBindingConfirmed,
}) => {
const { t } = useTranslation();
const [keysDown, setKeysDown] = useState([]);
const validKeysDown = useMemo(() => fixKeys(keysDown), [keysDown]);
const isShown = action != null;
useEffect(() => {
if (isShown) setKeysDown([]);
}, [isShown]);
useEffect(() => {
if (!isShown) return undefined;
const mousetrap = new Mousetrap();
function handleKey(character, modifiers, e) {
if (['keydown', 'keypress'].includes(e.type)) {
setKeysDown((old) => [...new Set([...old, character])]);
}
e.preventDefault();
}
const handleKeyOrig = mousetrap.handleKey;
mousetrap.handleKey = handleKey;
return () => {
mousetrap.handleKey = handleKeyOrig;
};
}, [isShown]);
const isComboInvalid = validKeysDown.length === 0 && keysDown.length > 0;
return (
<Dialog
title={t('Bind new key to action')}
isShown={action != null}
confirmLabel={t('Save')}
cancelLabel={t('Cancel')}
onCloseComplete={() => setCreatingBinding()}
onConfirm={() => onNewKeyBindingConfirmed(action, keysDown)}
onCancel={() => setCreatingBinding()}
>
{action ? (
<div style={{ color: 'black' }}>
<Paragraph marginBottom={10}><TakeActionIcon verticalAlign="middle" marginRight={5} /> {actionsMap[action].name} <span style={{ color: 'rgba(0,0,0,0.5)' }}>({action})</span></Paragraph>
<Paragraph>{t('Please press your desired key combination. Make sure it doesn\'t conflict with any other binding or system hotkeys.')}</Paragraph>
<div style={{ margin: '20px 0' }}>{renderKeys(validKeysDown.length > 0 ? validKeysDown : keysDown)}</div>
{isComboInvalid && <InlineAlert marginBottom={20} intent="danger">{t('Combination is invalid')}</InlineAlert>}
{keysDown.length > 0 && <div><Button intent="warning" iconBefore={UndoIcon} onClick={() => setKeysDown([])}>{t('Start over')}</Button></div>}
</div>
) : <div />}
</Dialog>
);
});
const rowStyle = { display: 'flex', alignItems: 'flex-start', margin: '6px 0' };
const KeyboardShortcuts = memo(({
keyBindings, setKeyBindings, currentCutSeg,
}) => {
const { t } = useTranslation();
const { actionsMap, extraLinesPerCategory } = useMemo(() => {
const playbackCategory = t('Playback');
const seekingCategory = t('Seeking');
const segmentsAndCutpointsCategory = t('Segments and cut points');
const zoomOperationsCategory = t('Timeline/zoom operations');
const outputCategory = t('Output actions');
const batchFilesCategory = t('Batch file list');
const otherCategory = t('Other operations');
return {
extraLinesPerCategory: {
[zoomOperationsCategory]: [
<div key="1" style={{ ...rowStyle, alignItems: 'center' }}>
<Text>{t('Zoom in/out timeline')}</Text>
<div style={{ flexGrow: 1 }} />
<FaMouse style={{ marginRight: 3 }} />
<Text>{t('Mouse scroll/wheel up/down')}</Text>
</div>,
<div key="2" style={{ ...rowStyle, alignItems: 'center' }}>
<Text>{t('Pan timeline')}</Text>
<div style={{ flexGrow: 1 }} />
<FaMouse style={{ marginRight: 3 }} />
<Text>{t('Mouse scroll/wheel left/right')}</Text>
</div>,
],
},
actionsMap: {
toggleHelp: {
name: t('Show/hide help screen'),
},
togglePlayResetSpeed: {
name: t('Play/pause'),
category: playbackCategory,
},
togglePlayNoResetSpeed: {
name: t('Play/pause (no reset speed)'),
category: playbackCategory,
},
increasePlaybackRate: {
name: t('Speed up playback'),
category: playbackCategory,
},
reducePlaybackRate: {
name: t('Slow down playback'),
category: playbackCategory,
},
increasePlaybackRateMore: {
name: t('Speed up playback more'),
category: playbackCategory,
},
reducePlaybackRateMore: {
name: t('Slow down playback more'),
category: playbackCategory,
},
seekPreviousFrame: {
name: t('Step backward 1 frame'),
category: seekingCategory,
},
seekNextFrame: {
name: t('Step forward 1 frame'),
category: seekingCategory,
},
seekBackwards: {
name: t('Seek backward 1 sec'),
category: seekingCategory,
},
seekForwards: {
name: t('Seek forward 1 sec'),
category: seekingCategory,
},
seekBackwardsPercent: {
name: t('Seek backward 1% of timeline at current zoom'),
category: seekingCategory,
},
seekForwardsPercent: {
name: t('Seek forward 1% of timeline at current zoom'),
category: seekingCategory,
},
jumpCutStart: {
name: t('Jump to cut start'),
category: seekingCategory,
before: <SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
jumpCutEnd: {
name: t('Jump to cut end'),
category: seekingCategory,
before: <SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
goToTimecode: {
name: t('Seek to timecode'),
category: seekingCategory,
},
addSegment: {
name: t('Add cut segment'),
category: segmentsAndCutpointsCategory,
},
removeCurrentSegment: {
name: t('Remove current segment'),
category: segmentsAndCutpointsCategory,
},
setCutStart: {
name: t('Mark in / cut start point for current segment'),
category: segmentsAndCutpointsCategory,
before: <SetCutpointButton currentCutSeg={currentCutSeg} side="start" style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
setCutEnd: {
name: t('Mark out / cut end point for current segment'),
category: segmentsAndCutpointsCategory,
before: <SetCutpointButton currentCutSeg={currentCutSeg} side="end" style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
labelCurrentSegment: {
name: t('Label current segment'),
category: segmentsAndCutpointsCategory,
},
splitCurrentSegment: {
name: t('Split segment at cursor'),
category: segmentsAndCutpointsCategory,
},
selectPrevSegment: {
name: t('Select previous segment'),
category: segmentsAndCutpointsCategory,
},
selectNextSegment: {
name: t('Select next segment'),
category: segmentsAndCutpointsCategory,
},
timelineZoomIn: {
name: t('Zoom in timeline'),
category: zoomOperationsCategory,
},
timelineZoomOut: {
name: t('Zoom out timeline'),
category: zoomOperationsCategory,
},
timelineToggleComfortZoom: {
name: t('Toggle zoom between 1x and a calculated comfortable zoom level'),
category: zoomOperationsCategory,
},
export: {
name: t('Export segment(s)'),
category: outputCategory,
},
captureSnapshot: {
name: t('Capture snapshot'),
category: outputCategory,
},
cleanupFilesDialog: {
name: t('Delete source file'),
category: outputCategory,
},
batchPreviousFile: {
name: t('Previous file'),
category: batchFilesCategory,
},
batchNextFile: {
name: t('Next file'),
category: batchFilesCategory,
},
closeBatch: {
name: t('Close batch'),
category: batchFilesCategory,
},
increaseRotation: {
name: t('Change rotation'),
category: otherCategory,
},
undo: {
name: t('Undo'),
category: otherCategory,
},
redo: {
name: t('Redo'),
category: otherCategory,
},
closeActiveScreen: {
name: t('Close current screen'),
category: otherCategory,
},
},
};
}, [currentCutSeg, t]);
const [creatingBinding, setCreatingBinding] = useState();
const categoriesWithActions = useMemo(() => Object.entries(groupBy(Object.entries(actionsMap), ([, { category }]) => category)), [actionsMap]);
const onDeleteBindingClick = useCallback(({ action, keys }) => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('Are you sure?'))) return;
console.log('delete key binding', action, keys);
setKeyBindings((existingBindings) => existingBindings.filter((existingBinding) => !(existingBinding.keys === keys && existingBinding.action === action)));
}, [setKeyBindings, t]);
const onAddBindingClick = useCallback((action) => {
setCreatingBinding(action);
}, []);
const stringifyKeys = (keys) => keys.join('+');
const onNewKeyBindingConfirmed = useCallback((action, keys) => {
const fixedKeys = fixKeys(keys);
if (fixedKeys.length === 0) return;
const keysStr = stringifyKeys(fixedKeys);
console.log('new key binding', action, keysStr);
setKeyBindings((existingBindings) => {
const haveDuplicate = existingBindings.some((existingBinding) => existingBinding.keys === keysStr);
if (haveDuplicate) {
console.log('trying to add duplicate');
return existingBindings;
}
console.log('saving key binding');
setCreatingBinding();
return [...existingBindings, { action, keys: keysStr }];
});
}, [setKeyBindings]);
return (
<>
<div style={{ color: 'black' }}>
<div>
<Alert marginBottom={20}>{t('Hover mouse over buttons in the main interface to see which function they have')}</Alert>
</div>
{categoriesWithActions.map(([category, actionsInCategory]) => (
<div key={category}>
{category !== 'undefined' && <Heading marginTop={30} marginBottom={14}>{category}</Heading>}
{actionsInCategory.map(([action, actionObj]) => {
const actionName = (actionObj && actionObj.name) || action;
const beforeContent = actionObj && actionObj.before;
const bindingsForThisAction = keyBindings.filter((keyBinding) => keyBinding.action === action);
return (
<div key={action} style={rowStyle}>
{beforeContent}
<Text title={action} marginRight={10}>{actionName}</Text>
<div style={{ flexGrow: 1 }} />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
{bindingsForThisAction.map(({ keys }) => (
<div key={keys} style={{ display: 'flex', alignItems: 'center' }}>
{renderKeys(keys.split('+'))}
<IconButton title={t('Remove key binding')} appearance="minimal" intent="danger" icon={DeleteIcon} onClick={() => onDeleteBindingClick({ action, keys })} />
</div>
))}
</div>
<IconButton title={t('Bind new key to action')} appearance="minimal" intent="success" icon={AddIcon} onClick={() => onAddBindingClick(action)} />
</div>
);
})}
{extraLinesPerCategory[category]}
</div>
))}
</div>
<CreateBinding actionsMap={actionsMap} action={creatingBinding} setCreatingBinding={setCreatingBinding} onNewKeyBindingConfirmed={onNewKeyBindingConfirmed} />
</>
);
});
const KeyboardShortcutsDialog = memo(({
isShown, onHide, keyBindings, setKeyBindings, currentCutSeg,
}) => {
const { t } = useTranslation();
return (
<Dialog
title={t('Keyboard & mouse shortcuts')}
isShown={isShown}
confirmLabel={t('Done')}
hasCancel={false}
onCloseComplete={onHide}
onConfirm={onHide}
topOffset="3vh"
>
{isShown ? <KeyboardShortcuts keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} /> : <div />}
</Dialog>
);
});
export default KeyboardShortcutsDialog;

Wyświetl plik

@ -264,7 +264,7 @@ const CleanupChoices = ({ cleanupChoicesInitial, onChange: onChangeProp }) => {
);
};
export async function cleanupFilesDialog(cleanupChoicesIn = {}) {
export async function showCleanupFilesDialog(cleanupChoicesIn = {}) {
let cleanupChoices = cleanupChoicesIn;
const { value } = await ReactSwal.fire({

Wyświetl plik

@ -0,0 +1,35 @@
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';
const keyupActions = ['seekBackwards', 'seekForwards'];
export default ({ keyBindings, onKeyPress: onKeyPressProp }) => {
const onKeyPressRef = useRef();
// optimization to prevent re-binding all the time:
useEffect(() => {
onKeyPressRef.current = onKeyPressProp;
}, [onKeyPressProp]);
useEffect(() => {
const mousetrap = new Mousetrap();
function onKeyPress(...args) {
if (onKeyPressRef.current) return onKeyPressRef.current(...args);
return true;
}
keyBindings.forEach(({ action, keys }) => {
mousetrap.bind(keys, () => onKeyPress({ action }));
if (keyupActions.includes(action)) {
mousetrap.bind(keys, () => onKeyPress({ action, keyup: true }), 'keyup');
}
});
return () => mousetrap.reset();
}, [keyBindings]);
};

Wyświetl plik

@ -92,6 +92,8 @@ export default () => {
useEffect(() => safeSetConfig('enableAutoHtml5ify', enableAutoHtml5ify), [enableAutoHtml5ify]);
const [segmentsToChaptersOnly, setSegmentsToChaptersOnly] = useState(configStore.get('segmentsToChaptersOnly'));
useEffect(() => safeSetConfig('segmentsToChaptersOnly', segmentsToChaptersOnly), [segmentsToChaptersOnly]);
const [keyBindings, setKeyBindings] = useState(configStore.get('keyBindings'));
useEffect(() => safeSetConfig('keyBindings', keyBindings), [keyBindings]);
// NOTE! This useEffect must be placed after all usages of firstUpdateRef.current (safeSetConfig)
useEffect(() => {
@ -170,5 +172,7 @@ export default () => {
setEnableAutoHtml5ify,
segmentsToChaptersOnly,
setSegmentsToChaptersOnly,
keyBindings,
setKeyBindings,
};
};