1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-14 11:22:36 +02:00

Compare commits

...

1 Commits

Author SHA1 Message Date
Ashutosh Singh
cb009cb084 Desktop: Fixes #11805: Add global shortcut to show/hide Joplin (#15013)
Co-authored-by: Ashutoshx7 <ashutoshx7@gmail.com>
2026-04-14 10:03:01 +01:00
11 changed files with 180 additions and 6 deletions

View File

@@ -201,6 +201,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js

2
.gitignore vendored
View File

@@ -174,6 +174,8 @@ packages/app-desktop/gui/ConfigScreen/ButtonBar.js
packages/app-desktop/gui/ConfigScreen/ConfigScreen.js
packages/app-desktop/gui/ConfigScreen/Sidebar.js
packages/app-desktop/gui/ConfigScreen/controls/FontSearch.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.test.js
packages/app-desktop/gui/ConfigScreen/controls/GlobalHotkeyInput.js
packages/app-desktop/gui/ConfigScreen/controls/MissingPasswordHelpLink.js
packages/app-desktop/gui/ConfigScreen/controls/SettingComponent.js
packages/app-desktop/gui/ConfigScreen/controls/SettingDescription.js

View File

@@ -939,6 +939,7 @@ export default class ElectronAppWrapper {
this.electronApp_.on('before-quit', () => {
this.appLogger_.info('[appClose] before-quit event fired, setting willQuitApp_ = true');
this.willQuitApp_ = true;
bridge().unregisterGlobalHotkey();
});
this.electronApp_.on('window-all-closed', () => {

View File

@@ -150,6 +150,10 @@ class Application extends BaseApplication {
bridge().extraAllowedOpenExtensions = Setting.value('linking.extraAllowedExtensions');
}
if ((action.type === 'SETTING_UPDATE_ONE' && action.key === 'globalHotkey') || action.type === 'SETTING_UPDATE_ALL') {
bridge().updateGlobalHotkey(Setting.value('globalHotkey'));
}
if (['EVENT_NOTE_ALARM_FIELD_CHANGE', 'NOTE_DELETE'].indexOf(action.type) >= 0) {
await AlarmService.updateNoteNotification(action.id, action.type === 'NOTE_DELETE');
}

View File

@@ -1,7 +1,7 @@
import ElectronAppWrapper from './ElectronAppWrapper';
import shim, { MessageBoxType } from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage, Menu, MenuItemConstructorOptions, MenuItem, BrowserWindowConstructorOptions, FileFilter, SaveDialogOptions, globalShortcut } from 'electron';
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
@@ -46,6 +46,7 @@ export class Bridge {
private extraAllowedExtensions_: string[] = [];
private onAllowedExtensionsChangeListener_: OnAllowedExtensionsChange = ()=>{};
private registeredGlobalHotkey_ = '';
public constructor(electronWrapper: ElectronAppWrapper, appId: string, appName: string, rootProfileDir: string, autoUploadCrashDumps: boolean, altInstanceId: string) {
this.electronWrapper_ = electronWrapper;
@@ -207,6 +208,54 @@ export class Bridge {
this.onAllowedExtensionsChangeListener_ = listener;
}
public updateGlobalHotkey(accelerator: string) {
// Skip if the accelerator hasn't changed
if (accelerator === this.registeredGlobalHotkey_) return;
// Unregister the previous shortcut (only Joplin's own)
this.unregisterGlobalHotkey();
if (!accelerator) return;
try {
const registered = globalShortcut.register(accelerator, () => {
const win = this.mainWindow();
if (!win) return;
if (win.isVisible() && win.isFocused()) {
win.hide();
} else {
if (win.isMinimized()) win.restore();
win.show();
// eslint-disable-next-line no-restricted-properties
win.focus();
}
});
if (registered) {
this.registeredGlobalHotkey_ = accelerator;
} else {
// eslint-disable-next-line no-console
console.warn(`Bridge: Failed to register global shortcut: ${accelerator}`);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Bridge: Error registering global shortcut "${accelerator}":`, error);
}
}
public unregisterGlobalHotkey() {
if (this.registeredGlobalHotkey_) {
try {
globalShortcut.unregister(this.registeredGlobalHotkey_);
} catch (error) {
// eslint-disable-next-line no-console
console.warn('Bridge: Error removing global shortcut:', error);
}
this.registeredGlobalHotkey_ = '';
}
}
public async captureException(error: unknown) {
Sentry.captureException(error);
// We wait to give the "beforeSend" event handler time to process the crash dump and write

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import GlobalHotkeyInput from './GlobalHotkeyInput';
describe('GlobalHotkeyInput', () => {
test('should render ShortcutRecorder with Save and Restore buttons', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
// ShortcutRecorder is always visible with its built-in buttons
expect(screen.getByText('Save')).toBeTruthy();
expect(screen.getByText('Restore')).toBeTruthy();
expect(screen.getByText('Cancel')).toBeTruthy();
});
test('should clear value when Restore is clicked', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
fireEvent.click(screen.getByText('Restore'));
expect(onChange).toHaveBeenCalledWith({ value: '' });
});
});

View File

@@ -0,0 +1,53 @@
import * as React from 'react';
import { useCallback } from 'react';
import { ShortcutRecorder } from '../../KeymapConfig/ShortcutRecorder';
interface OnChangeEvent {
value: string;
}
interface Props {
value: string;
themeId: number;
onChange: (event: OnChangeEvent)=> void;
}
// A thin wrapper around ShortcutRecorder for the global hotkey setting.
// Reuses ShortcutRecorder directly instead of maintaining a separate display mode.
export default function GlobalHotkeyInput(props: Props) {
const value = props.value || '';
const onSave = useCallback((event: { commandName: string; accelerator: string }) => {
// Normalize platform-specific modifiers to CommandOrControl for
// consistent cross-platform storage.
const accelerator = event.accelerator
.replace(/\bCmd\b/, 'CommandOrControl')
.replace(/\bCtrl\b/, 'CommandOrControl');
props.onChange({ value: accelerator });
}, [props.onChange]);
const onReset = useCallback(() => {
props.onChange({ value: '' });
}, [props.onChange]);
// No-op: global hotkeys don't have a separate editing mode to cancel out of.
const onCancel = useCallback(() => {}, []);
// No-op: ShortcutRecorder validates against the keymap (command
// conflicts), which doesn't apply to global hotkeys.
const onError = useCallback((_event: { recorderError: Error }) => {}, []);
return (
<ShortcutRecorder
onSave={onSave}
onReset={onReset}
onCancel={onCancel}
onError={onError}
initialAccelerator={value}
commandName="globalHotkey"
themeId={props.themeId}
skipKeymapValidation
autoFocus={false}
/>
);
}

View File

@@ -3,6 +3,7 @@ import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useCallback, useId } from 'react';
import control_PluginsStates from './plugins/PluginsStates';
import control_GlobalHotkeyInput from './GlobalHotkeyInput';
import bridge from '../../../services/bridge';
import { _ } from '@joplin/lib/locale';
import Button, { ButtonLevel, ButtonSize } from '../../Button/Button';
@@ -11,8 +12,10 @@ import * as pathUtils from '@joplin/lib/path-utils';
import SettingLabel from './SettingLabel';
import SettingDescription from './SettingDescription';
const settingKeyToControl: Record<string, typeof control_PluginsStates> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Each control component has different prop types
const settingKeyToControl: Record<string, React.FC<any>> = {
'plugins.states': control_PluginsStates,
'globalHotkey': control_GlobalHotkeyInput,
};
export interface UpdateSettingValueEvent {

View File

@@ -15,9 +15,14 @@ export interface ShortcutRecorderProps {
initialAccelerator: string;
commandName: string;
themeId: number;
// When true, skip keymap conflict validation (useful for global hotkeys
// that aren't part of the internal command keymap).
skipKeymapValidation?: boolean;
// Controls whether the input auto-focuses on mount. Defaults to true.
autoFocus?: boolean;
}
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId, skipKeymapValidation, autoFocus = true }: ShortcutRecorderProps) => {
const styles = styles_(themeId);
const [accelerator, setAccelerator] = useState(initialAccelerator);
@@ -29,7 +34,9 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
// Otherwise performing a save means that it's going to be disabled
if (accelerator) {
keymapService.validateAccelerator(accelerator);
keymapService.validateKeymap({ accelerator, command: commandName });
if (!skipKeymapValidation) {
keymapService.validateKeymap({ accelerator, command: commandName });
}
}
// Discard previous errors
@@ -86,7 +93,7 @@ export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAc
onKeyDown={handleKeyDown}
readOnly
autoFocus
autoFocus={autoFocus}
/>
<button style={styles.inlineButton} disabled={!saveAllowed} onClick={() => onSave({ commandName, accelerator })}>

View File

@@ -1177,6 +1177,25 @@ const builtInMetadata = (Setting: typeof SettingType) => {
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon'), show: settings => !!settings['showTrayIcon'] },
'globalHotkey': {
value: '',
type: SettingItemType.String,
section: 'application',
public: true,
appTypes: [AppType.Desktop],
label: () => _('Global shortcut to show/hide Joplin'),
description: () => _('A system-wide keyboard shortcut that toggles the Joplin window. Works even when Joplin is not focused. Example: CommandOrControl+Shift+J. Leave empty to disable.'),
storage: SettingStorage.File,
isGlobal: true,
autoSave: true,
// Electron's globalShortcut API does not yet work under Wayland,
// so we hide this option when running on a Wayland session.
show: () => {
if (platform !== 'linux') return true;
return process.env.XDG_SESSION_TYPE !== 'wayland' && !process.env.WAYLAND_DISPLAY;
},
},
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
'keychain.supported': { value: -1, type: SettingItemType.Int, public: false },

View File

@@ -162,7 +162,17 @@ export default class ExternalEditWatcher {
return;
}
let noteContent = await shim.fsDriver().readFile(path, 'utf-8');
let noteContent: string;
try {
noteContent = await shim.fsDriver().readFile(path, 'utf-8');
} catch (error) {
if (error.code === 'ENOENT') {
this.logger().warn(`ExternalEditWatcher: Watched file no longer exists: ${path}`);
void this.stopWatching(id);
return;
}
throw error;
}
// In some very rare cases, the "change" event is going to be emitted but the file will be empty.
// This is likely to be the editor that first clears the file, then writes the content to it, so if