Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/app-desktop/ElectronAppWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,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', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/app-desktop/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
51 changes: 50 additions & 1 deletion packages/app-desktop/bridge.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

import GlobalHotkeyInput from './GlobalHotkeyInput';

describe('GlobalHotkeyInput', () => {
test('should render current value with Change and Clear buttons', () => {
const onChange = jest.fn();
const { rerender } = render(<GlobalHotkeyInput value="" themeId={1} onChange={onChange} />);

// Empty state: shows "Not set", no Clear button
expect(screen.getByText('Not set')).toBeTruthy();
expect(screen.getByText('Change')).toBeTruthy();
expect(screen.queryByText('Clear')).toBeNull();

// With value: shows shortcut and Clear button
rerender(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);
expect(screen.getByText('CommandOrControl+Shift+J')).toBeTruthy();
expect(screen.getByText('Clear')).toBeTruthy();
});

test('should clear value when Clear is clicked', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="CommandOrControl+Shift+J" themeId={1} onChange={onChange} />);

fireEvent.click(screen.getByText('Clear'));
expect(onChange).toHaveBeenCalledWith({ value: '' });
});

test('should show ShortcutRecorder when Change is clicked', () => {
const onChange = jest.fn();
render(<GlobalHotkeyInput value="" themeId={1} onChange={onChange} />);

fireEvent.click(screen.getByText('Change'));
// ShortcutRecorder renders a Save button and a Cancel button
expect(screen.getByText('Save')).toBeTruthy();
expect(screen.getByText('Cancel')).toBeTruthy();
});
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's too many tests, please reduce them to something more sensible

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as React from 'react';
import { useState, useCallback } from 'react';
import { ShortcutRecorder } from '../../KeymapConfig/ShortcutRecorder';
import { _ } from '@joplin/lib/locale';

interface OnChangeEvent {
value: string;
}

interface Props {
value: string;
themeId: number;
onChange: (event: OnChangeEvent)=> void;
}

// A thin wrapper around ShortcutRecorder for the global hotkey setting.
// Toggles between a display view and the existing ShortcutRecorder component.
export default function GlobalHotkeyInput(props: Props) {
const [editing, setEditing] = useState(false);
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 });
setEditing(false);
}, [props.onChange]);

const onCancel = useCallback(() => {
setEditing(false);
}, []);

const onReset = useCallback(() => {
props.onChange({ value: '' });
setEditing(false);
}, [props.onChange]);

// Validation errors aren't critical for global shortcuts — log only.
const onError = useCallback((_event: { recorderError: Error }) => {
// No-op: ShortcutRecorder validates against the keymap (command
// conflicts), which doesn't apply to global hotkeys.
}, []);

if (editing) {
return (
<ShortcutRecorder
onSave={onSave}
onReset={onReset}
onCancel={onCancel}
onError={onError}
initialAccelerator={value}
commandName="globalHotkey"
themeId={props.themeId}
skipKeymapValidation
/>
);
}

return (
<div className="global-hotkey-input">
<span className="shortcut-display">
{value || _('Not set')}
</span>
<button
className="record-btn"
onClick={() => setEditing(true)}
type="button"
>
{_('Change')}
</button>
{value && (
<button
className="clear-btn"
onClick={() => props.onChange({ value: '' })}
type="button"
>
{_('Clear')}
</button>
)}
</div>
);
Comment on lines +40 to +52
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ShortcutRecorded already has these features. Is it not possible to use it for this too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done:)

}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
.global-hotkey-input {
display: flex;
align-items: center;
gap: 8px;

> .shortcut-display {
font-family: var(--joplin-font-family);
font-size: var(--joplin-font-size);
color: var(--joplin-color);
background-color: var(--joplin-background-color);
border: 1px solid var(--joplin-border-color4);
border-radius: 3px;
padding: 4px 10px;
min-width: 180px;
min-height: 1.6em;
outline: none;
}

> .record-btn,
> .clear-btn {
font-family: var(--joplin-font-family);
font-size: calc(var(--joplin-font-size) * 0.9);
color: var(--joplin-color);
background-color: var(--joplin-background-color);
border: 1px solid var(--joplin-border-color4);
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;

&:hover {
background-color: var(--joplin-background-color-hover3);
}
}
}
1 change: 1 addition & 0 deletions packages/app-desktop/gui/ConfigScreen/styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
@use "./setting-header.scss";
@use "./setting-tab-panel.scss";
@use "./setting-select-control.scss";
@use "./global-hotkey-input.scss";
9 changes: 7 additions & 2 deletions packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ 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;
}

export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId }: ShortcutRecorderProps) => {
export const ShortcutRecorder = ({ onSave, onReset, onCancel, onError, initialAccelerator, commandName, themeId, skipKeymapValidation }: ShortcutRecorderProps) => {
const styles = styles_(themeId);

const [accelerator, setAccelerator] = useState(initialAccelerator);
Expand All @@ -29,7 +32,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
Expand Down
19 changes: 19 additions & 0 deletions packages/lib/models/settings/builtInMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Loading