lossless-cut/src/components/KeyboardShortcuts.jsx

654 wiersze
22 KiB
JavaScript

import React, { memo, Fragment, useEffect, useMemo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { SearchInput, PlusIcon, 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]);
const addKeyDown = useCallback((character) => setKeysDown((old) => [...new Set([...old, character])]), []);
useEffect(() => {
if (!isShown) return undefined;
const mousetrap = new Mousetrap();
function handleKey(character, modifiers, e) {
if (['keydown', 'keypress'].includes(e.type)) {
addKeyDown(character);
}
e.preventDefault();
}
const handleKeyOrig = mousetrap.handleKey;
mousetrap.handleKey = handleKey;
return () => {
mousetrap.handleKey = handleKeyOrig;
};
}, [addKeyDown, 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()}
>
{isShown ? (
<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>}
<div>
{!keysDown.includes('esc') && <Button iconBefore={PlusIcon} onClick={() => addKeyDown('esc')}>ESC</Button>}
{keysDown.length > 0 && <Button intent="warning" iconBefore={UndoIcon} onClick={() => setKeysDown([])}>{t('Start over')}</Button>}
</div>
</div>
) : <div />}
</Dialog>
);
});
const rowStyle = { display: 'flex', alignItems: 'center', margin: '6px 0' };
const KeyboardShortcuts = memo(({
keyBindings, setKeyBindings, resetKeyBindings, currentCutSeg,
}) => {
const { t } = useTranslation();
const { actionsMap, extraLinesPerCategory } = useMemo(() => {
const playbackCategory = t('Playback');
const selectivePlaybackCategory = t('Playback/preview segments only');
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');
const streamsCategory = t('Tracks');
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: {
toggleLastCommands: {
name: t('Last ffmpeg commands'),
},
toggleKeyboardShortcuts: {
name: t('Keyboard & mouse shortcuts'),
},
// playbackCategory
togglePlayResetSpeed: {
name: t('Play/pause'),
category: playbackCategory,
},
togglePlayNoResetSpeed: {
name: t('Play/pause (no reset speed)'),
category: playbackCategory,
},
play: {
name: t('Play'),
category: playbackCategory,
},
pause: {
name: t('Pause'),
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,
},
increaseVolume: {
name: t('Increase audio volume'),
category: playbackCategory,
},
decreaseVolume: {
name: t('Decrease audio volume'),
category: playbackCategory,
},
reloadFile: {
name: t('Reload current media'),
category: playbackCategory,
},
// selectivePlaybackCategory
togglePlayOnlyCurrentSegment: {
name: t('Play current segment once'),
category: selectivePlaybackCategory,
},
toggleLoopOnlyCurrentSegment: {
name: t('Loop current segment'),
category: selectivePlaybackCategory,
},
toggleLoopStartEndOnlyCurrentSegment: {
name: t('Loop beginning and end of current segment'),
category: selectivePlaybackCategory,
},
toggleLoopSelectedSegments: {
name: t('Play selected segments in order'),
category: selectivePlaybackCategory,
},
// seekingCategory
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,
},
seekBackwardsKeyframe: {
name: t('Seek previous keyframe'),
category: seekingCategory,
},
seekForwardsKeyframe: {
name: t('Seek next keyframe'),
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 current segment\'s start time'),
category: seekingCategory,
before: <SegmentCutpointButton currentCutSeg={currentCutSeg} side="start" Icon={FaStepBackward} style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
jumpCutEnd: {
name: t('Jump to current segment\'s end time'),
category: seekingCategory,
before: <SegmentCutpointButton currentCutSeg={currentCutSeg} side="end" Icon={FaStepForward} style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
jumpTimelineStart: {
name: t('Jump to start of video'),
category: seekingCategory,
},
jumpTimelineEnd: {
name: t('Jump to end of video'),
category: seekingCategory,
},
goToTimecode: {
name: t('Seek to timecode'),
category: seekingCategory,
},
// segmentsAndCutpointsCategory
addSegment: {
name: t('Add cut segment'),
category: segmentsAndCutpointsCategory,
},
removeCurrentSegment: {
name: t('Remove current segment'),
category: segmentsAndCutpointsCategory,
},
setCutStart: {
name: t('Start current segment at current time'),
category: segmentsAndCutpointsCategory,
before: <SetCutpointButton currentCutSeg={currentCutSeg} side="start" style={{ verticalAlign: 'middle', marginRight: 5 }} />,
},
setCutEnd: {
name: t('End current segment at current time'),
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,
},
duplicateCurrentSegment: {
name: t('Duplicate current segment'),
category: segmentsAndCutpointsCategory,
},
jumpPrevSegment: {
name: t('Jump to previous segment'),
category: segmentsAndCutpointsCategory,
},
jumpNextSegment: {
name: t('Jump to next segment'),
category: segmentsAndCutpointsCategory,
},
reorderSegsByStartTime: {
name: t('Reorder segments by start time'),
category: segmentsAndCutpointsCategory,
},
invertAllSegments: {
name: t('Invert all segments on timeline'),
category: segmentsAndCutpointsCategory,
},
fillSegmentsGaps: {
name: t('Fill gaps between segments'),
category: segmentsAndCutpointsCategory,
},
shiftAllSegmentTimes: {
name: t('Shift all segments on timeline'),
category: segmentsAndCutpointsCategory,
},
alignSegmentTimesToKeyframes: {
name: t('Align segment times to keyframes'),
category: segmentsAndCutpointsCategory,
},
createFixedDurationSegments: {
name: t('Create fixed duration segments'),
category: segmentsAndCutpointsCategory,
},
createNumSegments: {
name: t('Create num segments'),
category: segmentsAndCutpointsCategory,
},
createRandomSegments: {
name: t('Create random segments'),
category: segmentsAndCutpointsCategory,
},
shuffleSegments: {
name: t('Shuffle segments order'),
category: segmentsAndCutpointsCategory,
},
combineOverlappingSegments: {
name: t('Combine overlapping segments'),
category: segmentsAndCutpointsCategory,
},
combineSelectedSegments: {
name: t('Combine selected segments'),
},
clearSegments: {
name: t('Clear all segments'),
category: segmentsAndCutpointsCategory,
},
toggleSegmentsList: {
name: t('Show sidebar'),
category: segmentsAndCutpointsCategory,
},
selectOnlyCurrentSegment: {
name: t('Select only this segment'),
category: segmentsAndCutpointsCategory,
},
deselectAllSegments: {
name: t('Deselect all segments'),
category: segmentsAndCutpointsCategory,
},
selectAllSegments: {
name: t('Select all segments'),
category: segmentsAndCutpointsCategory,
},
toggleCurrentSegmentSelected: {
name: t('Toggle current segment selected'),
category: segmentsAndCutpointsCategory,
},
invertSelectedSegments: {
name: t('Invert selected segments'),
category: segmentsAndCutpointsCategory,
},
removeSelectedSegments: {
name: t('Remove selected segments'),
category: segmentsAndCutpointsCategory,
},
// streamsCategory
toggleStreamsSelector: {
name: t('Edit tracks / metadata tags'),
category: streamsCategory,
},
extractAllStreams: {
name: t('Extract all tracks'),
category: streamsCategory,
},
// zoomOperationsCategory
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,
},
// outputCategory
export: {
name: t('Export segment(s)'),
category: outputCategory,
},
captureSnapshot: {
name: t('Capture snapshot'),
category: outputCategory,
},
captureSnapshotAsCoverArt: {
name: t('Set current frame as cover art'),
category: outputCategory,
},
extractCurrentSegmentFramesAsImages: {
name: t('Extract frames from current segment as image files'),
category: outputCategory,
},
extractSelectedSegmentsFramesAsImages: {
name: t('Extract frames from selected segments as image files'),
category: outputCategory,
},
cleanupFilesDialog: {
name: t('Delete source file'),
category: outputCategory,
},
convertFormatBatch: {
name: t('Batch convert files to supported format'),
category: outputCategory,
},
convertFormatCurrentFile: {
name: t('Convert current file to supported format'),
category: outputCategory,
},
fixInvalidDuration: {
name: t('Fix incorrect duration'),
category: outputCategory,
},
// batchFilesCategory
batchPreviousFile: {
name: t('Previous file'),
category: batchFilesCategory,
},
batchNextFile: {
name: t('Next file'),
category: batchFilesCategory,
},
batchOpenSelectedFile: {
name: t('Open selected file'),
category: batchFilesCategory,
},
closeBatch: {
name: t('Close batch'),
category: batchFilesCategory,
},
concatBatch: {
name: t('Merge/concatenate files'),
category: batchFilesCategory,
},
// otherCategory
toggleKeyframeCutMode: {
name: t('Cut mode'),
category: otherCategory,
},
toggleCaptureFormat: {
name: t('Capture frame format'),
category: otherCategory,
},
toggleStripAudio: {
name: t('Keep or discard audio tracks'),
category: otherCategory,
},
increaseRotation: {
name: t('Change rotation'),
category: otherCategory,
},
setStartTimeOffset: {
name: t('Set custom start offset/timecode'),
category: otherCategory,
},
undo: {
name: t('Undo'),
category: otherCategory,
},
redo: {
name: t('Redo'),
category: otherCategory,
},
copySegmentsToClipboard: {
name: t('Copy selected segments times to clipboard'),
category: otherCategory,
},
closeActiveScreen: {
name: t('Close current screen'),
category: otherCategory,
},
closeCurrentFile: {
name: t('Close current file'),
category: otherCategory,
},
quit: {
name: t('Quit LosslessCut'),
category: otherCategory,
},
},
};
}, [currentCutSeg, t]);
useEffect(() => {
// cleanup invalid bindings, to prevent renamed actions from blocking user to rebind
const validBindings = keyBindings.filter(({ action }) => actionsMap[action]);
if (validBindings.length !== keyBindings.length) {
console.log(`Auto deleting ${keyBindings.length - validBindings.length} invalid key binding(s)`);
setKeyBindings(validBindings);
}
}, [actionsMap, keyBindings, setKeyBindings]);
const [creatingBinding, setCreatingBinding] = useState();
const [searchQuery, setSearchQuery] = useState('');
const actionEntries = useMemo(() => Object.entries(actionsMap).filter(([, { name }]) => !searchQuery || name.toLowerCase().includes(searchQuery.toLowerCase())), [actionsMap, searchQuery]);
const categoriesWithActions = useMemo(() => Object.entries(groupBy(actionEntries, ([, { category }]) => category)), [actionEntries]);
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 onResetClick = useCallback(() => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('Are you sure?'))) return;
resetKeyBindings();
}, [resetKeyBindings, 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>
<SearchInput value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search" width="100%" />
</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}>
<div>
{beforeContent}
<Text title={action} marginRight={10}>{actionName}</Text>
<div style={{ fontSize: '.8em', opacity: 0.4 }} title={t('API action name: {{action}}', { action })}>{action}</div>
</div>
<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>
))}
{bindingsForThisAction.length === 0 && <Text color="muted">{t('No binding')}</Text>}
</div>
<IconButton title={t('Bind new key to action')} appearance="minimal" intent="success" icon={AddIcon} onClick={() => onAddBindingClick(action)} />
</div>
);
})}
{extraLinesPerCategory[category]}
</div>
))}
</div>
<Button intent="danger" onClick={onResetClick}>{t('Reset')}</Button>
<CreateBinding actionsMap={actionsMap} action={creatingBinding} setCreatingBinding={setCreatingBinding} onNewKeyBindingConfirmed={onNewKeyBindingConfirmed} />
</>
);
});
const KeyboardShortcutsDialog = memo(({
isShown, onHide, keyBindings, setKeyBindings, resetKeyBindings, 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} resetKeyBindings={resetKeyBindings} /> : <div />}
</Dialog>
);
});
export default KeyboardShortcutsDialog;