1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Add keyboard shortcut editor (#3525)

This commit is contained in:
Anjula Karunarathne 2020-09-06 17:30:25 +05:30 committed by GitHub
parent 0998fc0ad7
commit a8296e2e37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 762 additions and 109 deletions

View File

@ -62,6 +62,9 @@ Modules/TinyMCE/langs/
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
CliClient/app/LinkSelector.js
CliClient/build/LinkSelector.js
CliClient/tests-build/synchronizer_LockHandler.js
CliClient/tests-build/synchronizer_MigrationHandler.js
CliClient/tests/synchronizer_LockHandler.js
CliClient/tests/synchronizer_MigrationHandler.js
ElectronClient/commands/focusElement.js
@ -70,6 +73,12 @@ ElectronClient/commands/stopExternalEditing.js
ElectronClient/global.d.js
ElectronClient/gui/ErrorBoundary.js
ElectronClient/gui/Header/commands/focusSearch.js
ElectronClient/gui/KeymapConfig/KeymapConfigScreen.js
ElectronClient/gui/KeymapConfig/ShortcutRecorder.js
ElectronClient/gui/KeymapConfig/styles/index.js
ElectronClient/gui/KeymapConfig/utils/getLabel.js
ElectronClient/gui/KeymapConfig/utils/useCommandStatus.js
ElectronClient/gui/KeymapConfig/utils/useKeymap.js
ElectronClient/gui/MainScreen/commands/editAlarm.js
ElectronClient/gui/MainScreen/commands/exportPdf.js
ElectronClient/gui/MainScreen/commands/hideModalMessage.js

9
.gitignore vendored
View File

@ -53,6 +53,9 @@ Tools/commit_hook.txt
# AUTO-GENERATED - EXCLUDED TYPESCRIPT BUILD
CliClient/app/LinkSelector.js
CliClient/build/LinkSelector.js
CliClient/tests-build/synchronizer_LockHandler.js
CliClient/tests-build/synchronizer_MigrationHandler.js
CliClient/tests/synchronizer_LockHandler.js
CliClient/tests/synchronizer_MigrationHandler.js
ElectronClient/commands/focusElement.js
@ -61,6 +64,12 @@ ElectronClient/commands/stopExternalEditing.js
ElectronClient/global.d.js
ElectronClient/gui/ErrorBoundary.js
ElectronClient/gui/Header/commands/focusSearch.js
ElectronClient/gui/KeymapConfig/KeymapConfigScreen.js
ElectronClient/gui/KeymapConfig/ShortcutRecorder.js
ElectronClient/gui/KeymapConfig/styles/index.js
ElectronClient/gui/KeymapConfig/utils/getLabel.js
ElectronClient/gui/KeymapConfig/utils/useCommandStatus.js
ElectronClient/gui/KeymapConfig/utils/useKeymap.js
ElectronClient/gui/MainScreen/commands/editAlarm.js
ElectronClient/gui/MainScreen/commands/exportPdf.js
ElectronClient/gui/MainScreen/commands/hideModalMessage.js

View File

@ -115,6 +115,7 @@ describe('services_KeymapService', () => {
{ command: 'focusElementNoteTitle', accelerator: 'Option+Shift+Cmd+T' },
{ command: 'focusElementNoteBody', accelerator: 'Ctrl+Option+Shift+Cmd+B' },
];
testCases_Darwin.forEach(({ command, accelerator }) => {
keymapService.setAccelerator(command, accelerator);
expect(keymapService.getAccelerator(command)).toEqual(accelerator);
@ -131,6 +132,7 @@ describe('services_KeymapService', () => {
{ command: 'focusElementNoteTitle', accelerator: 'Ctrl+Alt+Shift+T' },
{ command: 'focusElementNoteBody', accelerator: 'Ctrl+Alt+Shift+B' },
];
testCases_Linux.forEach(({ command, accelerator }) => {
keymapService.setAccelerator(command, accelerator);
expect(keymapService.getAccelerator(command)).toEqual(accelerator);
@ -138,10 +140,10 @@ describe('services_KeymapService', () => {
});
});
describe('resetAccelerator', () => {
describe('getDefaultAccelerator', () => {
beforeEach(() => keymapService.initialize());
it('should reset the Accelerator', () => {
it('should return the default accelerator', () => {
const testCases = [
{ command: 'newNote', accelerator: 'Ctrl+Alt+Shift+N' },
{ command: 'synchronize', accelerator: null /* Disabled */ },
@ -154,26 +156,22 @@ describe('services_KeymapService', () => {
];
testCases.forEach(({ command, accelerator }) => {
// Remember the default Accelerator value
const prevAccelerator = keymapService.getAccelerator(command);
// Remember the real default Accelerator value
const defaultAccelerator = keymapService.getAccelerator(command);
// Update the Accelerator,
// Update the Accelerator and then retrieve the default accelerator
keymapService.setAccelerator(command, accelerator);
expect(keymapService.getAccelerator(command)).toEqual(accelerator);
// and then reset it..
keymapService.resetAccelerator(command);
expect(keymapService.getAccelerator(command)).toEqual(prevAccelerator);
expect(keymapService.getDefaultAccelerator(command)).toEqual(defaultAccelerator);
});
});
});
describe('setKeymap', () => {
describe('overrideKeymap', () => {
beforeEach(() => keymapService.initialize());
it('should update the keymap', () => {
keymapService.initialize('darwin');
const customKeymap_Darwin = [
const customKeymapItems_Darwin = [
{ command: 'newNote', accelerator: 'Option+Shift+Cmd+N' },
{ command: 'synchronize', accelerator: 'F11' },
{ command: 'textBold', accelerator: 'Shift+F5' },
@ -187,14 +185,14 @@ describe('services_KeymapService', () => {
{ command: 'focusElementNoteList', accelerator: 'Shift+Cmd+S' /* Default of focusElementSideBar */ },
];
expect(() => keymapService.setKeymap(customKeymap_Darwin)).not.toThrow();
customKeymap_Darwin.forEach(({ command, accelerator }) => {
expect(() => keymapService.overrideKeymap(customKeymapItems_Darwin)).not.toThrow();
customKeymapItems_Darwin.forEach(({ command, accelerator }) => {
// Also check if keymap is updated or not
expect(keymapService.getAccelerator(command)).toEqual(accelerator);
});
keymapService.initialize('win32');
const customKeymap_Win32 = [
const customKeymapItems_Win32 = [
{ command: 'newNote', accelerator: 'Ctrl+Alt+Shift+N' },
{ command: 'synchronize', accelerator: 'F11' },
{ command: 'textBold', accelerator: 'Shift+F5' },
@ -208,8 +206,8 @@ describe('services_KeymapService', () => {
{ command: 'focusElementNoteList', accelerator: 'Ctrl+Shift+S' /* Default of focusElementSideBar */ },
];
expect(() => keymapService.setKeymap(customKeymap_Win32)).not.toThrow();
customKeymap_Win32.forEach(({ command, accelerator }) => {
expect(() => keymapService.overrideKeymap(customKeymapItems_Win32)).not.toThrow();
customKeymapItems_Win32.forEach(({ command, accelerator }) => {
// Also check if keymap is updated or not
expect(keymapService.getAccelerator(command)).toEqual(accelerator);
});
@ -240,30 +238,30 @@ describe('services_KeymapService', () => {
];
for (let i = 0; i < customKeymaps.length; i++) {
const customKeymap = customKeymaps[i];
expect(() => keymapService.setKeymap(customKeymap)).toThrow();
const customKeymapItems = customKeymaps[i];
expect(() => keymapService.overrideKeymap(customKeymapItems)).toThrow();
}
});
it('should throw when the provided Accelerators are invalid', () => {
// Only one test case is provided since KeymapService.validateAccelerator() is already tested
const customKeymap = [
const customKeymapItems = [
{ command: 'gotoAnything', accelerator: 'Ctrl+Shift+G' },
{ command: 'print', accelerator: 'Alt+P' },
{ command: 'focusElementNoteTitle', accelerator: 'Ctrl+Alt+Shift+J+O+P+L+I+N' },
];
expect(() => keymapService.setKeymap(customKeymap)).toThrow();
expect(() => keymapService.overrideKeymap(customKeymapItems)).toThrow();
});
it('should throw when the provided commands are invalid', () => {
const customKeymap = [
const customKeymapItems = [
{ command: 'totallyInvalidCommand', accelerator: 'Ctrl+Shift+G' },
{ command: 'print', accelerator: 'Alt+P' },
{ command: 'focusElementNoteTitle', accelerator: 'Ctrl+Alt+Shift+J' },
];
expect(() => keymapService.setKeymap(customKeymap)).toThrow();
expect(() => keymapService.overrideKeymap(customKeymapItems)).toThrow();
});
it('should throw when duplicate accelerators are provided', () => {
@ -281,14 +279,8 @@ describe('services_KeymapService', () => {
];
for (let i = 0; i < customKeymaps_Darwin.length; i++) {
const customKeymap = customKeymaps_Darwin[i];
const defaultAccelerators = customKeymap.map(({ command }) => keymapService.getAccelerator(command));
expect(() => keymapService.setKeymap(customKeymap)).toThrow();
// All items should be reset to default values
for (let j = 0; j < customKeymap.length; j++) {
expect(keymapService.getAccelerator(customKeymap[j].command)).toEqual(defaultAccelerators[j]);
}
const customKeymapItems = customKeymaps_Darwin[i];
expect(() => keymapService.overrideKeymap(customKeymapItems)).toThrow();
}
const customKeymaps_Linux = [
@ -305,14 +297,8 @@ describe('services_KeymapService', () => {
];
for (let i = 0; i < customKeymaps_Linux.length; i++) {
const customKeymap = customKeymaps_Linux[i];
const defaultAccelerators = customKeymap.map(({ command }) => keymapService.getAccelerator(command));
expect(() => keymapService.setKeymap(customKeymap)).toThrow();
for (let j = 0; j < customKeymap.length; j++) {
expect(keymapService.getAccelerator(customKeymap[j].command)).toEqual(defaultAccelerators[j]);
}
const customKeymapItems = customKeymaps_Linux[i];
expect(() => keymapService.overrideKeymap(customKeymapItems)).toThrow();
}
});
});

View File

@ -30,7 +30,7 @@ const PluginManager = require('lib/services/PluginManager');
const RevisionService = require('lib/services/RevisionService');
const MigrationService = require('lib/services/MigrationService');
const CommandService = require('lib/services/CommandService').default;
const KeymapService = require('lib/services/KeymapService.js').default;
const KeymapService = require('lib/services/KeymapService').default;
const TemplateUtils = require('lib/TemplateUtils');
const CssUtils = require('lib/CssUtils');
const resourceEditWatcherReducer = require('lib/services/ResourceEditWatcher/reducer').default;
@ -110,6 +110,8 @@ class Application extends BaseApplication {
this.commandService_commandsEnabledStateChange = this.commandService_commandsEnabledStateChange.bind(this);
CommandService.instance().on('commandsEnabledStateChange', this.commandService_commandsEnabledStateChange);
KeymapService.instance().on('keymapChange', this.refreshMenu.bind(this));
}
commandService_commandsEnabledStateChange() {
@ -569,7 +571,7 @@ class Application extends BaseApplication {
const toolsItemsWindowsLinux = toolsItemsFirst.concat([{
label: _('Options'),
visible: !shim.isMac(),
accelerator: shim.isMac() ? null : keymapService.getAccelerator('config'),
accelerator: !shim.isMac() && keymapService.getAccelerator('config'),
click: () => {
this.dispatch({
type: 'NAV_GO',
@ -631,7 +633,7 @@ class Application extends BaseApplication {
}, {
label: _('Preferences...'),
visible: shim.isMac() ? true : false,
accelerator: shim.isMac() ? keymapService.getAccelerator('config') : null,
accelerator: shim.isMac() && keymapService.getAccelerator('config'),
click: () => {
this.dispatch({
type: 'NAV_GO',
@ -680,7 +682,7 @@ class Application extends BaseApplication {
}, {
label: _('Hide %s', 'Joplin'),
platforms: ['darwin'],
accelerator: shim.isMac() ? keymapService.getAccelerator('hideApp') : null,
accelerator: shim.isMac() && keymapService.getAccelerator('hideApp'),
click: () => { bridge().electronApp().hide(); },
}, {
type: 'separator',
@ -700,7 +702,7 @@ class Application extends BaseApplication {
newNotebookItem, {
label: _('Close Window'),
platforms: ['darwin'],
accelerator: shim.isMac() ? keymapService.getAccelerator('closeWindow') : null,
accelerator: shim.isMac() && keymapService.getAccelerator('closeWindow'),
selector: 'performClose:',
}, {
type: 'separator',
@ -1093,7 +1095,7 @@ class Application extends BaseApplication {
const keymapService = KeymapService.instance();
try {
await KeymapService.instance().loadKeymap(`${dir}/keymap-desktop.json`);
await keymapService.loadCustomKeymap(`${dir}/keymap-desktop.json`);
} catch (err) {
bridge().showErrorMessageBox(err.message);
}

View File

@ -10,6 +10,7 @@ const shared = require('lib/components/shared/config-shared.js');
const ConfigMenuBar = require('./ConfigMenuBar.min.js');
const { EncryptionConfigScreen } = require('./EncryptionConfigScreen.min');
const { ClipperConfigScreen } = require('./ClipperConfigScreen.min');
const { KeymapConfigScreen } = require('./KeymapConfig/KeymapConfigScreen');
class ConfigScreenComponent extends React.Component {
constructor() {
@ -68,6 +69,7 @@ class ConfigScreenComponent extends React.Component {
screenFromName(screenName) {
if (screenName === 'encryption') return <EncryptionConfigScreen theme={this.props.theme}/>;
if (screenName === 'server') return <ClipperConfigScreen theme={this.props.theme}/>;
if (screenName === 'keymap') return <KeymapConfigScreen themeId={this.props.theme}/>;
throw new Error(`Invalid screen name: ${screenName}`);
}

View File

@ -0,0 +1,192 @@
import * as React from 'react';
import { useState } from 'react';
import KeymapService, { KeymapItem } from '../../lib/services/KeymapService';
import { ShortcutRecorder } from './ShortcutRecorder';
import getLabel from './utils/getLabel';
import useKeymap from './utils/useKeymap';
import useCommandStatus from './utils/useCommandStatus';
import styles_ from './styles';
const { bridge } = require('electron').remote.require('./bridge');
const { shim } = require('lib/shim');
const { _ } = require('lib/locale');
const keymapService = KeymapService.instance();
export interface KeymapConfigScreenProps {
themeId: number
}
export const KeymapConfigScreen = ({ themeId }: KeymapConfigScreenProps) => {
const styles = styles_(themeId);
const [filter, setFilter] = useState('');
const [keymapItems, keymapError, overrideKeymapItems, setAccelerator, resetAccelerator] = useKeymap();
const [recorderError, setRecorderError] = useState<Error>(null);
const [editing, enableEditing, disableEditing] = useCommandStatus();
const [hovering, enableHovering, disableHovering] = useCommandStatus();
const handleSave = (event: { commandName: string, accelerator: string }) => {
const { commandName, accelerator } = event;
setAccelerator(commandName, accelerator);
disableEditing(commandName);
};
const handleReset = (event: { commandName: string }) => {
const { commandName } = event;
resetAccelerator(commandName);
disableEditing(commandName);
};
const handleCancel = (event: { commandName: string }) => {
const { commandName } = event;
disableEditing(commandName);
};
const handleError = (event: { recorderError: Error }) => {
const { recorderError } = event;
setRecorderError(recorderError);
};
const handleImport = async () => {
const filePath = bridge().showOpenDialog({
properties: ['openFile'],
defaultPath: 'keymap-desktop',
filters: [{ name: 'Joplin Keymaps (keymap-desktop.json)', extensions: ['json'] }],
});
if (filePath) {
const actualFilePath = filePath[0];
try {
const keymapFile = await shim.fsDriver().readFile(actualFilePath, 'utf-8');
overrideKeymapItems(JSON.parse(keymapFile));
} catch (err) {
bridge().showErrorMessageBox(`${_('An unexpected error occured while importing the keymap!')}\n${err.message}`);
}
}
};
const handleExport = async () => {
const filePath = bridge().showSaveDialog({
defaultPath: 'keymap-desktop',
filters: [{ name: 'Joplin Keymaps (keymap-desktop.json)', extensions: ['json'] }],
});
if (filePath) {
try {
// KeymapService is already synchronized with the in-state keymap
await keymapService.saveCustomKeymap(filePath);
} catch (err) {
bridge().showErrorMessageBox(err.message);
}
}
};
const renderAccelerator = (accelerator: string) => {
return (
<div>
{accelerator.split('+').map(part => <kbd style={styles.kbd} key={part}>{part}</kbd>).reduce(
(accumulator, part) => (accumulator.length ? [...accumulator, ' + ', part] : [part]),
[]
)}
</div>
);
};
const renderStatus = (commandName: string) => {
if (editing[commandName]) {
return (recorderError && <i className="fa fa-exclamation-triangle" title={recorderError.message} />);
} else if (hovering[commandName]) {
return (<i className="fa fa-pen" />);
} else {
return null;
}
};
const renderError = (error: Error) => {
return (
<div style={styles.warning}>
<p style={styles.text}>
<span>
{error.message}
</span>
</p>
</div>
);
};
const renderKeymapRow = ({ command, accelerator }: KeymapItem) => {
const handleClick = () => enableEditing(command);
const handleMouseEnter = () => enableHovering(command);
const handleMouseLeave = () => disableHovering(command);
const cellContent =
<div style={styles.tableCell} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
{editing[command] ?
<ShortcutRecorder
onSave={handleSave}
onReset={handleReset}
onCancel={handleCancel}
onError={handleError}
initialAccelerator={accelerator || '' /* Because accelerator is null if disabled */}
commandName={command}
themeId={themeId}
/> :
<div style={styles.tableCellContent} onClick={handleClick}>
{accelerator
? renderAccelerator(accelerator)
: <div style={styles.disabled}>{_('Disabled')}</div>
}
</div>
}
<div style={styles.tableCellStatus} onClick={handleClick}>
{renderStatus(command)}
</div>
</div>;
return (
<tr key={command}>
<td style={styles.tableCommandColumn}>
{getLabel(command)}
</td>
<td style={styles.tableShortcutColumn}>
{cellContent}
</td>
</tr>
);
};
return (
<div>
{keymapError && renderError(keymapError)}
<div style={styles.container}>
<div style={styles.actionsContainer}>
<input
value={filter}
onChange={event => setFilter(event.target.value)}
placeholder={_('Search...')}
style={styles.filterInput}
/>
<button style={styles.inlineButton} onClick={handleImport}>{_('Import')}</button>
<button style={styles.inlineButton} onClick={handleExport}>{_('Export')}</button>
</div>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.tableCommandColumn}>{_('Command')}</th>
<th style={styles.tableShortcutColumn}>{_('Keyboard Shortcut')}</th>
</tr>
</thead>
<tbody>
{keymapItems.filter(({ command }) => {
const filterLowerCase = filter.toLowerCase();
return (command.toLowerCase().includes(filterLowerCase) || getLabel(command).toLowerCase().includes(filterLowerCase));
}).map(item => renderKeymapRow(item))}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -0,0 +1,85 @@
import * as React from 'react';
import { useState, useEffect, KeyboardEvent } from 'react';
import KeymapService from '../../lib/services/KeymapService';
import styles_ from './styles';
const { _ } = require('lib/locale');
const keymapService = KeymapService.instance();
export interface ShortcutRecorderProps {
onSave: (event: { commandName: string, accelerator: string }) => void,
onReset: (event: { commandName: string }) => void,
onCancel: (event: { commandName: string }) => void,
onError: (event: { recorderError: Error }) => void,
initialAccelerator: string
commandName: string,
themeId: number
}
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
const styles = styles_(themeId);
const [accelerator, setAccelerator] = useState(initialAccelerator);
const [saveAllowed, setSaveAllowed] = useState(true);
useEffect(() => {
try {
// Only perform validations if there's an accelerator provided
// Otherwise performing a save means that it's going to be disabled
if (accelerator) {
keymapService.validateAccelerator(accelerator);
keymapService.validateKeymap({ accelerator, command: commandName });
}
// Discard previous errors
onError({ recorderError: null });
setSaveAllowed(true);
} catch (recorderError) {
onError({ recorderError });
setSaveAllowed(false);
}
}, [accelerator]);
const handleKeydown = (event: KeyboardEvent<HTMLDivElement>) => {
event.preventDefault();
const newAccelerator = keymapService.domToElectronAccelerator(event);
switch (newAccelerator) {
case 'Enter':
if (saveAllowed) return onSave({ commandName, accelerator });
break;
case 'Escape':
return onCancel({ commandName });
case 'Backspace':
case 'Delete':
return setAccelerator('');
default:
setAccelerator(newAccelerator);
}
};
return (
<div style={styles.recorderContainer}>
<input
value={accelerator}
placeholder={_('Press the shortcut')}
onKeyDown={handleKeydown}
style={styles.recorderInput}
title={_('Press the shortcut and then press ENTER. Or, press BACKSPACE to clear the shortcut.')}
readOnly
autoFocus
/>
<button style={styles.inlineButton} disabled={!saveAllowed} onClick={() => onSave({ commandName, accelerator })}>
{_('Save')}
</button>
<button style={styles.inlineButton} onClick={() => onReset({ commandName })}>
{_('Restore')}
</button>
<button style={styles.inlineButton} onClick={() => onCancel({ commandName })}>
{_('Cancel')}
</button>
</div>
);
};

View File

@ -0,0 +1,86 @@
const { buildStyle } = require('lib/theme');
export default function styles(themeId: number) {
return buildStyle('KeymapConfigScreen', themeId, (theme: any) => {
return {
container: {
...theme.containerStyle,
padding: 16,
},
actionsContainer: {
display: 'flex',
flexDirection: 'row',
},
recorderContainer: {
padding: 2,
flexGrow: 1,
},
filterInput: {
...theme.inputStyle,
flexGrow: 1,
minHeight: 29,
alignSelf: 'center',
},
recorderInput: {
...theme.inputStyle,
minHeight: 29,
},
label: {
...theme.textStyle,
alignSelf: 'center',
marginRight: 10,
},
table: {
...theme.containerStyle,
marginTop: 16,
overflow: 'auto',
width: '100%',
},
tableShortcutColumn: {
...theme.textStyle,
width: '60%',
},
tableCommandColumn: {
...theme.textStyle,
width: 'auto',
},
tableCell: {
display: 'flex',
flexDirection: 'row',
},
tableCellContent: {
flexGrow: 1,
alignSelf: 'center',
},
tableCellStatus: {
height: '100%',
alignSelf: 'center',
},
kbd: {
fontFamily: 'sans-serif',
border: '1px solid',
borderRadius: 4,
backgroundColor: theme.raisedBackgroundColor,
padding: 2,
paddingLeft: 6,
paddingRight: 6,
},
disabled: {
color: theme.colorFaded,
fontStyle: 'italic',
},
inlineButton: {
...theme.buttonStyle,
marginLeft: 12,
},
warning: {
...theme.textStyle,
backgroundColor: theme.warningBackgroundColor,
paddingLeft: 16,
paddingRight: 16,
paddingTop: 2,
paddingBottom: 2,
},
};
});
}

View File

@ -0,0 +1,36 @@
import CommandService from '../../../lib/services/CommandService';
const { _ } = require('lib/locale');
const { shim } = require('lib/shim');
const commandService = CommandService.instance();
const getLabel = (commandName: string) => {
if (commandService.exists(commandName)) return commandService.label(commandName);
// Some commands are not registered in CommandService at the moment
// Following hard-coded labels are used as a workaround
switch (commandName) {
case 'quit':
return _('Quit');
case 'insertTemplate':
return _('Insert template');
case 'zoomActualSize':
return _('Actual Size');
case 'gotoAnything':
return _('Goto Anything...');
case 'help':
return _('Website and documentation');
case 'hideApp':
return _('Hide Joplin');
case 'closeWindow':
return _('Close Window');
case 'config':
return shim.isMac() ? _('Preferences') : _('Options');
default:
throw new Error(`Command: ${commandName} is unknown`);
}
};
export default getLabel;

View File

@ -0,0 +1,34 @@
import { useState } from 'react';
import KeymapService from '../../../lib/services/KeymapService';
const keymapService = KeymapService.instance();
interface CommandStatus {
[commandName: string]: boolean
}
const useCommandStatus = (): [CommandStatus, (commandName: string) => void, (commandName: string) => void] => {
const [status, setStatus] = useState<CommandStatus>(() =>
keymapService.getCommandNames().reduce((accumulator: CommandStatus, command: string) => {
accumulator[command] = false;
return accumulator;
}, {})
);
const disableStatus = (commandName: string) => setStatus(prevStatus => ({ ...prevStatus, [commandName]: false }));
const enableStatus = (commandName: string) => setStatus(prevStatus => {
// Falsify all the commands; Only one command should be truthy at any given time
const newStatus = Object.keys(prevStatus).reduce((accumulator: CommandStatus, command: string) => {
accumulator[command] = false;
return accumulator;
}, {});
// Make the appropriate command truthful
newStatus[commandName] = true;
return newStatus;
});
return [status, enableStatus, disableStatus];
};
export default useCommandStatus;

View File

@ -0,0 +1,70 @@
import { useState, useEffect } from 'react';
import KeymapService, { KeymapItem } from '../../../lib/services/KeymapService';
const keymapService = KeymapService.instance();
// This custom hook provides a synchronized snapshot of the keymap residing at KeymapService
// All the logic regarding altering and interacting with the keymap is isolated from the components
const useKeymap = (): [
KeymapItem[],
Error,
(keymapItems: KeymapItem[]) => void,
(commandName: string, accelerator: string) => void,
(commandName: string) => void
] => {
const [keymapItems, setKeymapItems] = useState<KeymapItem[]>(() => keymapService.getKeymapItems());
const [keymapError, setKeymapError] = useState<Error>(null);
const setAccelerator = (commandName: string, accelerator: string) => {
setKeymapItems(prevKeymap => {
const newKeymap = [...prevKeymap];
newKeymap.find(item => item.command === commandName).accelerator = accelerator || null /* Disabled */;
return newKeymap;
});
};
const resetAccelerator = (commandName: string) => {
const defaultAccelerator = keymapService.getDefaultAccelerator(commandName);
setKeymapItems(prevKeymap => {
const newKeymap = [...prevKeymap];
newKeymap.find(item => item.command === commandName).accelerator = defaultAccelerator;
return newKeymap;
});
};
const overrideKeymapItems = (customKeymapItems: KeymapItem[]) => {
const oldKeymapItems = [...customKeymapItems];
keymapService.initialize(); // Start with a fresh keymap
try {
// First, try to update the in-memory keymap of KeymapService
// This function will throw if there are any issues with the new custom keymap
keymapService.overrideKeymap(customKeymapItems);
// Then, update the state with the data from KeymapService
// Side-effect: Changes will also be saved to the disk
setKeymapItems(keymapService.getKeymapItems());
} catch (err) {
// oldKeymapItems includes even the unchanged keymap items
// However, it is not an issue because the logic accounts for such scenarios
keymapService.overrideKeymap(oldKeymapItems);
throw err;
}
};
useEffect(() => {
try {
keymapService.overrideKeymap(keymapItems);
keymapService.saveCustomKeymap();
setKeymapError(null);
} catch (err) {
setKeymapError(err);
}
}, [keymapItems]);
return [keymapItems, keymapError, overrideKeymapItems, setAccelerator, resetAccelerator];
};
export default useKeymap;

View File

@ -156,6 +156,12 @@ shared.settingsSections = createSelector(
isScreen: true,
});
output.push({
name: 'keymap',
metadatas: [],
isScreen: true,
});
return output;
}
);

View File

@ -1258,6 +1258,7 @@ class Setting extends BaseModel {
if (name === 'revisionService') return _('Note History');
if (name === 'encryption') return _('Encryption');
if (name === 'server') return _('Web Clipper');
if (name === 'keymap') return _('Keyboard Shortcuts');
return name;
}
@ -1277,6 +1278,7 @@ class Setting extends BaseModel {
if (name === 'revisionService') return 'fas fa-history';
if (name === 'encryption') return 'fas fa-key';
if (name === 'server') return 'far fa-hand-scissors';
if (name === 'keymap') return 'fa fa-keyboard';
return name;
}

View File

@ -157,6 +157,7 @@ export default class CommandService extends BaseService {
options = {
mustExist: true,
runtimeMustBeRegistered: false,
...options,
};
const command = this.commands_[name];
@ -248,6 +249,17 @@ export default class CommandService extends BaseService {
return command.runtime.title(command.runtime.props);
}
label(commandName:string):string {
const command = this.commandByName(commandName);
if (!command) throw new Error(`Command: ${commandName} is not declared`);
return command.declaration.label();
}
exists(commandName:string):boolean {
const command = this.commandByName(commandName, { mustExist: false });
return !!command;
}
private extractExecuteArgs(command:Command, executeArgs:any) {
if (executeArgs) return executeArgs;
if (!command.runtime) throw new Error(`Command: ${command.declaration.name}: Runtime is not defined - make sure it has been registered.`);
@ -281,7 +293,7 @@ export default class CommandService extends BaseService {
};
if (command.declaration.role) item.role = command.declaration.role;
if (this.keymapService.hasAccelerator(commandName)) {
if (this.keymapService.acceleratorExists(commandName)) {
item.accelerator = this.keymapService.getAccelerator(commandName);
}

View File

@ -1,6 +1,9 @@
const fs = require('fs-extra');
import { KeyboardEvent } from 'react';
const BaseService = require('lib/services/BaseService');
const { shim } = require('lib/shim.js');
const eventManager = require('lib/eventManager');
const { shim } = require('lib/shim');
const { _ } = require('lib/locale');
const keysRegExp = /^([0-9A-Z)!@#$%^&*(:+<_>?~{|}";=,\-./`[\\\]']|F1*[1-9]|F10|F2[0-4]|Plus|Space|Tab|Backspace|Delete|Insert|Return|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape|Esc|VolumeUp|VolumeDown|VolumeMute|MediaNextTrack|MediaPreviousTrack|MediaStop|MediaPlayPause|PrintScreen)$/;
const modifiersRegExp = {
@ -8,7 +11,7 @@ const modifiersRegExp = {
default: /^(Ctrl|Alt|AltGr|Shift|Super)$/,
};
const defaultKeymap = {
const defaultKeymapItems = {
darwin: [
{ accelerator: 'Cmd+N', command: 'newNote' },
{ accelerator: 'Cmd+T', command: 'newTodo' },
@ -86,135 +89,192 @@ interface Keymap {
export default class KeymapService extends BaseService {
private keymap: Keymap;
private defaultKeymap: KeymapItem[];
private platform: string;
private customKeymapPath: string;
private defaultKeymapItems: KeymapItem[];
constructor() {
super();
// Automatically initialized for the current platform
// By default, initialize for the current platform
// Manual initialization allows testing for other platforms
this.initialize();
}
initialize(platform: string = shim.platformName()) {
this.keysRegExp = keysRegExp;
this.platform = platform;
switch (platform) {
case 'darwin':
this.defaultKeymap = defaultKeymap.darwin;
this.defaultKeymapItems = defaultKeymapItems.darwin;
this.modifiersRegExp = modifiersRegExp.darwin;
break;
default:
this.defaultKeymap = defaultKeymap.default;
this.defaultKeymapItems = defaultKeymapItems.default;
this.modifiersRegExp = modifiersRegExp.default;
}
this.keymap = {};
for (let i = 0; i < this.defaultKeymap.length; i++) {
// Make a copy of the KeymapItem before assigning it
// Otherwise we're going to mess up the defaultKeymap array
this.keymap[this.defaultKeymap[i].command] = { ...this.defaultKeymap[i] };
for (let i = 0; i < this.defaultKeymapItems.length; i++) {
// Keep the original defaultKeymapItems array untouched
// Makes it possible to retrieve the original accelerator later, if needed
this.keymap[this.defaultKeymapItems[i].command] = { ...this.defaultKeymapItems[i] };
}
}
async loadKeymap(keymapPath: string) {
this.keymapPath = keymapPath; // Used for saving changes later..
async loadCustomKeymap(customKeymapPath: string) {
this.customKeymapPath = customKeymapPath; // Useful for saving the changes later
if (await fs.exists(keymapPath)) {
this.logger().info(`Loading keymap: ${keymapPath}`);
if (await shim.fsDriver().exists(customKeymapPath)) {
this.logger().info(`KeymapService: Loading keymap from file: ${customKeymapPath}`);
try {
const keymapFile = await fs.readFile(keymapPath, 'utf-8');
this.setKeymap(JSON.parse(keymapFile));
const customKeymapFile = await shim.fsDriver().readFile(customKeymapPath, 'utf-8');
// Custom keymaps are supposed to contain an array of keymap items
this.overrideKeymap(JSON.parse(customKeymapFile));
} catch (err) {
const msg = err.message ? err.message : '';
throw new Error(`Failed to load keymap: ${keymapPath}\n${msg}`);
const message = err.message || '';
throw new Error(`${_('Error loading the keymap from file: %s', customKeymapPath)}\n${message}`);
}
}
}
hasAccelerator(command: string) {
return !!this.keymap[command];
async saveCustomKeymap(customKeymapPath: string = this.customKeymapPath) {
this.logger().info(`KeymapService: Saving keymap to file: ${customKeymapPath}`);
try {
// Only the customized keymap items should be saved to the disk
const customKeymapItems = this.getCustomKeymapItems();
await shim.fsDriver().writeFile(customKeymapPath, JSON.stringify(customKeymapItems, null, 2), 'utf-8');
// Refresh the menu items so that the changes are reflected
eventManager.emit('keymapChange');
} catch (err) {
const message = err.message || '';
throw new Error(`${_('Error saving the keymap to file: %s', customKeymapPath)}\n${message}`);
}
}
getAccelerator(command: string) {
const item = this.keymap[command];
if (!item) throw new Error(`KeymapService: "${command}" command does not exist!`);
else return item.accelerator;
acceleratorExists(command: string) {
return !!this.keymap[command];
}
setAccelerator(command: string, accelerator: string) {
this.keymap[command].accelerator = accelerator;
}
resetAccelerator(command: string) {
const defaultItem = this.defaultKeymap.find((item => item.command === command));
getAccelerator(command: string) {
const item = this.keymap[command];
if (!item) throw new Error(`KeymapService: "${command}" command does not exist!`);
if (!defaultItem) throw new Error(`KeymapService: "${command}" command does not exist!`);
else this.setAccelerator(command, defaultItem.accelerator);
return item.accelerator;
}
setKeymap(customKeymap: KeymapItem[]) {
for (let i = 0; i < customKeymap.length; i++) {
const item = customKeymap[i];
getDefaultAccelerator(command: string) {
const defaultItem = this.defaultKeymapItems.find((item => item.command === command));
if (!defaultItem) throw new Error(`KeymapService: "${command}" command does not exist!`);
try {
this.validateKeymapItem(item); // Throws if there are any issues in the keymap item
this.setAccelerator(item.command, item.accelerator);
} catch (err) {
throw new Error(`Keymap item ${JSON.stringify(item)} is invalid: ${err.message}`);
return defaultItem.accelerator;
}
getCommandNames() {
return Object.keys(this.keymap);
}
getKeymapItems() {
return Object.values(this.keymap);
}
getCustomKeymapItems() {
const customkeymapItems: KeymapItem[] = [];
this.defaultKeymapItems.forEach(({ command, accelerator }) => {
const currentAccelerator = this.getAccelerator(command);
// Only the customized/changed keymap items are neccessary for the custom keymap
// Customizations can be merged with the original keymap at the runtime
if (this.getAccelerator(command) !== accelerator) {
customkeymapItems.push({ command, accelerator: currentAccelerator });
}
}
});
return customkeymapItems;
}
getDefaultKeymapItems() {
return [...this.defaultKeymapItems];
}
overrideKeymap(customKeymapItems: KeymapItem[]) {
try {
this.validateKeymap(); // Throws whenever there are duplicate Accelerators used in the keymap
for (let i = 0; i < customKeymapItems.length; i++) {
const item = customKeymapItems[i];
// Validate individual custom keymap items
// Throws if there are any issues in the keymap item
this.validateKeymapItem(item);
this.setAccelerator(item.command, item.accelerator);
}
// Validate the entire keymap for duplicates
// Throws whenever there are duplicate Accelerators used in the keymap
this.validateKeymap();
} catch (err) {
this.initialize();
throw new Error(`Keymap configuration contains duplicates\n${err.message}`);
this.initialize(); // Discard all the changes if there are any issues
throw err;
}
}
private validateKeymapItem(item: KeymapItem) {
if (!item.hasOwnProperty('command')) {
throw new Error('"command" property is missing');
throw new Error(_('Keymap item %s is missing the required "command" property.', JSON.stringify(item)));
} else if (!this.keymap.hasOwnProperty(item.command)) {
throw new Error(`"${item.command}" is not a valid command`);
throw new Error(_('Keymap item %s is invalid because %s is not a valid command.', JSON.stringify(item), item.command));
}
if (!item.hasOwnProperty('accelerator')) {
throw new Error('"accelerator" property is missing');
throw new Error(_('Keymap item %s is missing the required "accelerator" property.', JSON.stringify(item)));
} else if (item.accelerator !== null) {
try {
this.validateAccelerator(item.accelerator);
} catch (err) {
throw new Error(`"${item.accelerator}" is not a valid accelerator`);
} catch {
throw new Error(_('Keymap item %s is invalid because %s is not a valid accelerator.', JSON.stringify(item), item.accelerator));
}
}
}
private validateKeymap() {
validateKeymap(proposedKeymapItem: KeymapItem = null) {
const usedAccelerators = new Set();
// Validate as if the proposed change is already present in the current keymap
// Helpful for detecting any errors that'll occur, when the proposed change is performed on the keymap
if (proposedKeymapItem) usedAccelerators.add(proposedKeymapItem.accelerator);
for (const item of Object.values(this.keymap)) {
const itemAccelerator = item.accelerator;
const [itemAccelerator, itemCommand] = [item.accelerator, item.command];
if (proposedKeymapItem && itemCommand === proposedKeymapItem.command) continue; // Ignore the original accelerator
if (usedAccelerators.has(itemAccelerator)) {
const originalItem = Object.values(this.keymap).find(_item => _item.accelerator == item.accelerator);
throw new Error(
`Accelerator "${itemAccelerator}" can't be used for both "${item.command}" and "${originalItem.command}" commands\n` +
'You have to change the accelerator for any of above commands'
);
} else if (itemAccelerator !== null) {
const originalItem = (proposedKeymapItem && proposedKeymapItem.accelerator === itemAccelerator)
? proposedKeymapItem
: Object.values(this.keymap).find(_item => _item.accelerator == itemAccelerator);
throw new Error(_(
'Accelerator "%s" is used for "%s" and "%s" commands. This may lead to unexpected behaviour.',
itemAccelerator,
originalItem.command,
itemCommand
));
} else if (itemAccelerator) {
usedAccelerators.add(itemAccelerator);
}
}
}
private validateAccelerator(accelerator: string) {
validateAccelerator(accelerator: string) {
let keyFound = false;
const parts = accelerator.split('+');
const isValid = parts.every((part, index) => {
const isKey = this.keysRegExp.test(part);
const isKey = keysRegExp.test(part);
const isModifier = this.modifiersRegExp.test(part);
if (isKey) {
@ -228,7 +288,69 @@ export default class KeymapService extends BaseService {
return isKey || isModifier;
});
if (!isValid) throw new Error(`Accelerator invalid: ${accelerator}`);
if (!isValid) throw new Error(_('Accelerator "%s" is not valid.', accelerator));
}
domToElectronAccelerator(event: KeyboardEvent<HTMLDivElement>) {
const parts = [];
const { key, ctrlKey, metaKey, altKey, shiftKey } = event;
// First, the modifiers
if (ctrlKey) parts.push('Ctrl');
switch (this.platform) {
case 'darwin':
if (altKey) parts.push('Option');
if (shiftKey) parts.push('Shift');
if (metaKey) parts.push('Cmd');
break;
default:
if (altKey) parts.push('Alt');
if (shiftKey) parts.push('Shift');
}
// Finally, the key
const electronKey = KeymapService.domToElectronKey(key);
if (electronKey) parts.push(electronKey);
return parts.join('+');
}
static domToElectronKey(domKey: string) {
let electronKey;
if (/^([a-z])$/.test(domKey)) {
electronKey = domKey.toUpperCase();
} else if (/^Arrow(Up|Down|Left|Right)|Audio(VolumeUp|VolumeDown|VolumeMute)$/.test(domKey)) {
electronKey = domKey.slice(5);
} else {
switch (domKey) {
case ' ':
electronKey = 'Space';
break;
case '+':
electronKey = 'Plus';
break;
case 'MediaTrackNext':
electronKey = 'MediaNextTrack';
break;
case 'MediaTrackPrevious':
electronKey = 'MediaPreviousTrack';
break;
default:
electronKey = domKey;
}
}
if (keysRegExp.test(electronKey)) return electronKey;
else return null;
}
public on(eventName: string, callback: Function) {
eventManager.on(eventName, callback);
}
public off(eventName: string, callback: Function) {
eventManager.off(eventName, callback);
}
static instance() {

View File

@ -1,4 +1,5 @@
const { Logger } = require('lib/logger.js');
const KeymapService = require('lib/services/KeymapService').default;
class PluginManager {
constructor() {
@ -80,6 +81,8 @@ class PluginManager {
menuItems() {
let output = [];
const keymapService = KeymapService.instance();
for (const name in this.plugins_) {
const menuItems = this.plugins_[name].Class.manifest.menuItems;
if (!menuItems) continue;
@ -91,10 +94,7 @@ class PluginManager {
itemName: item.name,
});
};
if (item.accelerator instanceof Function) {
item.accelerator = item.accelerator();
}
item.accelerator = keymapService.getAccelerator(name);
}
output = output.concat(menuItems);