You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-04-18 19:42:23 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04389e6c87 | |||
| 20b5e02802 | |||
| 204b653422 | |||
| 222bb002c8 | |||
| af8eb30844 | |||
| cb009cb084 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '' });
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })}>
|
||||
|
||||
@@ -38,6 +38,9 @@ const useStyle = (themeId: number) => {
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
profileList: {
|
||||
flex: 1,
|
||||
},
|
||||
profileListItem: {
|
||||
paddingLeft: theme.margin,
|
||||
paddingRight: theme.margin,
|
||||
@@ -206,15 +209,15 @@ export default (props: Props) => {
|
||||
return (
|
||||
<View style={style.root}>
|
||||
<ScreenHeader title={_('Profiles')} showSaveButton={false} showSideMenuButton={false} showSearchButton={false} />
|
||||
<View>
|
||||
<FlatList
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
style={style.profileList}
|
||||
data={profiles}
|
||||
renderItem={renderProfileItem}
|
||||
keyExtractor={profile => profile.id}
|
||||
// Needed so that the list rerenders when its dependencies change:
|
||||
extraData={extraListItemData}
|
||||
contentContainerStyle={{ paddingBottom: 80 }}
|
||||
/>
|
||||
<FAB
|
||||
icon="plus"
|
||||
accessibilityLabel={_('New profile')}
|
||||
|
||||
@@ -46,12 +46,18 @@ export default class PluginRunner extends BasePluginRunner {
|
||||
return false;
|
||||
});
|
||||
|
||||
// On native mobile, pass a file path so the WebView can load the
|
||||
// script directly from the filesystem (avoids transferring the full
|
||||
// script text across the React Native bridge). On web, file:// URLs
|
||||
// are blocked by CSP so we pass the script text directly.
|
||||
const scriptFilePath = plugin.scriptText ? '' : `${plugin.baseDir}/index.js`;
|
||||
this.webviewRef.current.injectJS(`
|
||||
pluginBackgroundPage.runPlugin(
|
||||
${JSON.stringify(shim.injectedJs('pluginBackgroundPage'))},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
${JSON.stringify(scriptFilePath)},
|
||||
${JSON.stringify(messageChannelId)},
|
||||
${JSON.stringify(plugin.id)},
|
||||
${JSON.stringify(plugin.scriptText)},
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
@@ -186,6 +186,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
|
||||
html={html}
|
||||
injectedJavaScript={injectedJs}
|
||||
hasPluginScripts={true}
|
||||
allowFileAccessFromJs={true}
|
||||
onMessage={pluginRunner.onWebviewMessage}
|
||||
onLoadEnd={onLoadEnd}
|
||||
onLoadStart={onLoadStart}
|
||||
|
||||
@@ -26,14 +26,29 @@ export const stopPlugin = async (pluginId: string) => {
|
||||
delete loadedPlugins[pluginId];
|
||||
};
|
||||
|
||||
export const runPlugin = (
|
||||
pluginBackgroundScript: string, pluginScript: string, messageChannelId: string, pluginId: string,
|
||||
export const runPlugin = async (
|
||||
pluginBackgroundScript: string, scriptFilePath: string, messageChannelId: string, pluginId: string, scriptText = '',
|
||||
) => {
|
||||
if (loadedPlugins[pluginId]) {
|
||||
console.warn(`Plugin already running ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// When scriptText is provided (web), use it directly. Otherwise load
|
||||
// the plugin script from the filesystem (native mobile). We use
|
||||
// XMLHttpRequest because fetch() doesn't support file:// URLs on
|
||||
// Android WebView.
|
||||
let pluginScript = scriptText;
|
||||
if (!pluginScript) {
|
||||
pluginScript = await new Promise<string>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', `file://${scriptFilePath}`, true);
|
||||
xhr.onload = () => resolve(xhr.responseText);
|
||||
xhr.onerror = () => reject(new Error(`Failed to load plugin script: ${scriptFilePath}`));
|
||||
xhr.send();
|
||||
});
|
||||
}
|
||||
|
||||
const bodyHtml = '';
|
||||
const initialJavaScript = `
|
||||
"use strict";
|
||||
|
||||
@@ -404,6 +404,29 @@ describe('screens/Note', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should set title, body, and parent_id correctly when a note is created via share', async () => {
|
||||
const folder = await Folder.save({ title: 'Share target folder', parent_id: '' });
|
||||
const note = await Note.save({ parent_id: folder.id }, { provisional: true });
|
||||
|
||||
store.dispatch({
|
||||
type: 'NAV_GO',
|
||||
routeName: 'Note',
|
||||
noteId: note.id,
|
||||
sharedData: { title: 'Shared title', text: 'https://example.com' },
|
||||
});
|
||||
store.dispatch({ type: 'NOTE_UPDATE_ONE', note: { ...note }, provisional: true });
|
||||
|
||||
const { unmount } = render(<WrappedNoteScreen />);
|
||||
|
||||
await waitForNoteToMatch(note.id, {
|
||||
title: 'Shared title',
|
||||
body: 'https://example.com',
|
||||
parent_id: folder.id,
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should always start in edit mode for provisional notes regardless of noteVisiblePanes', async () => {
|
||||
store.dispatch({
|
||||
type: 'NOTE_VISIBLE_PANES_SET',
|
||||
|
||||
@@ -586,14 +586,18 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
|
||||
if (sharedData) {
|
||||
reg.logger().info('Received shared data');
|
||||
|
||||
// selectedFolderId can be null if no screens other than "All notes"
|
||||
// have been opened.
|
||||
const targetFolder = this.props.selectedFolderId ?? (await Folder.defaultFolder())?.id;
|
||||
if (targetFolder) {
|
||||
const activeFolder = await Folder.getValidActiveFolder();
|
||||
if (activeFolder) {
|
||||
logger.info('Sharing: handleShareData: Processing...');
|
||||
await handleShared(sharedData, targetFolder, this.props.dispatch);
|
||||
await handleShared(sharedData, activeFolder.id, this.props.dispatch);
|
||||
} else {
|
||||
reg.logger().info('Cannot handle share - default folder id is not set');
|
||||
reg.logger().warn('Cannot handle share - no valid active folder found');
|
||||
void this.dropdownAlert_({
|
||||
type: 'error',
|
||||
title: _('Cannot share'),
|
||||
message: _('No valid notebook is available. Please create or select a notebook and try again.'),
|
||||
});
|
||||
ShareExtension.close();
|
||||
}
|
||||
} else {
|
||||
logger.info('Sharing: received empty share data.');
|
||||
|
||||
@@ -5,9 +5,6 @@ import { WebViewControl } from '../../components/ExtendedWebView/types';
|
||||
import { RefObject } from 'react';
|
||||
import { OnMessageEvent } from '../../components/ExtendedWebView/types';
|
||||
import { Platform } from 'react-native';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('RNToWebViewMessenger');
|
||||
|
||||
const canUseOptimizedPostMessage = Platform.OS === 'web';
|
||||
|
||||
@@ -44,22 +41,10 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten
|
||||
|
||||
public onWebViewMessage = (event: OnMessageEvent) => {
|
||||
if (!this.hasBeenClosed()) {
|
||||
let data;
|
||||
if (canUseOptimizedPostMessage) {
|
||||
data = event.nativeEvent.data;
|
||||
void this.onMessage(event.nativeEvent.data);
|
||||
} else {
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data);
|
||||
} catch {
|
||||
logger.warn('Failed to parse message:', event.nativeEvent.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && typeof data.kind === 'string') {
|
||||
void this.onMessage(data);
|
||||
} else {
|
||||
logger.info('Unknown message format:', event.nativeEvent.data);
|
||||
void this.onMessage(JSON.parse(event.nativeEvent.data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,7 +304,10 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
|
||||
const fromShare = !!comp.props.sharedData;
|
||||
if (note) {
|
||||
const folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
let folder = Folder.byId(comp.props.folders, note.parent_id);
|
||||
if (!folder && note.parent_id) {
|
||||
folder = await Folder.load(note.parent_id);
|
||||
}
|
||||
comp.setState({
|
||||
lastSavedNote: { ...note },
|
||||
note: note,
|
||||
@@ -337,12 +340,24 @@ shared.reloadNote = async (comp: BaseNoteScreenComponent) => {
|
||||
shared.initState = async function(comp: BaseNoteScreenComponent) {
|
||||
const note = await shared.reloadNote(comp);
|
||||
|
||||
if (comp.props.sharedData) {
|
||||
if (comp.props.sharedData && note) {
|
||||
// Use the note returned by reloadNote directly to avoid a race condition where
|
||||
// comp.state.note is still the initial empty note (Note.new() with parent_id='')
|
||||
// because React hasn't flushed reloadNote's setState yet. Without this, the
|
||||
// scheduled save would overwrite parent_id with an empty string in the DB.
|
||||
const updatedNote = { ...note };
|
||||
const fieldsToSave: NoteEntity = { id: note.id };
|
||||
if (comp.props.sharedData.title) {
|
||||
this.noteComponent_change(comp, 'title', comp.props.sharedData.title);
|
||||
updatedNote.title = comp.props.sharedData.title;
|
||||
fieldsToSave.title = comp.props.sharedData.title;
|
||||
}
|
||||
if (comp.props.sharedData.text) {
|
||||
this.noteComponent_change(comp, 'body', comp.props.sharedData.text);
|
||||
updatedNote.body = comp.props.sharedData.text;
|
||||
fieldsToSave.body = comp.props.sharedData.text;
|
||||
}
|
||||
if (fieldsToSave.title !== undefined || fieldsToSave.body !== undefined) {
|
||||
await Note.save(fieldsToSave);
|
||||
comp.setState({ note: updatedNote, lastSavedNote: updatedNote });
|
||||
}
|
||||
if (comp.props.sharedData.resources) {
|
||||
for (let i = 0; i < comp.props.sharedData.resources.length; i++) {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -350,8 +350,16 @@ export default class PluginService extends BaseService {
|
||||
|
||||
logger.info(`Loading plugin from ${path}`);
|
||||
|
||||
const scriptText = await fsDriver.readFile(`${distPath}/index.js`);
|
||||
const manifestText = await fsDriver.readFile(`${distPath}/manifest.json`);
|
||||
// On mobile, plugin scripts are loaded directly by the WebView
|
||||
// from the filesystem, so we don't need to read them here.
|
||||
const indexPath = `${distPath}/index.js`;
|
||||
if (shim.mobilePlatform()) {
|
||||
if (!(await fsDriver.exists(indexPath))) {
|
||||
throw new Error(`Plugin bundle not found at: ${indexPath}`);
|
||||
}
|
||||
}
|
||||
const scriptText = shim.mobilePlatform() ? '' : await fsDriver.readFile(indexPath);
|
||||
const pluginId = makePluginId(filename(path));
|
||||
|
||||
return this.loadPlugin(distPath, manifestText, scriptText, pluginId);
|
||||
|
||||
Reference in New Issue
Block a user