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:
parent
0998fc0ad7
commit
a8296e2e37
@ -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
9
.gitignore
vendored
@ -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
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
192
ElectronClient/gui/KeymapConfig/KeymapConfigScreen.tsx
Normal file
192
ElectronClient/gui/KeymapConfig/KeymapConfigScreen.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
85
ElectronClient/gui/KeymapConfig/ShortcutRecorder.tsx
Normal file
85
ElectronClient/gui/KeymapConfig/ShortcutRecorder.tsx
Normal 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>
|
||||
);
|
||||
};
|
86
ElectronClient/gui/KeymapConfig/styles/index.ts
Normal file
86
ElectronClient/gui/KeymapConfig/styles/index.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
36
ElectronClient/gui/KeymapConfig/utils/getLabel.ts
Normal file
36
ElectronClient/gui/KeymapConfig/utils/getLabel.ts
Normal 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;
|
34
ElectronClient/gui/KeymapConfig/utils/useCommandStatus.ts
Normal file
34
ElectronClient/gui/KeymapConfig/utils/useCommandStatus.ts
Normal 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;
|
70
ElectronClient/gui/KeymapConfig/utils/useKeymap.ts
Normal file
70
ElectronClient/gui/KeymapConfig/utils/useKeymap.ts
Normal 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;
|
@ -156,6 +156,12 @@ shared.settingsSections = createSelector(
|
||||
isScreen: true,
|
||||
});
|
||||
|
||||
output.push({
|
||||
name: 'keymap',
|
||||
metadatas: [],
|
||||
isScreen: true,
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user