1
0
mirror of https://github.com/laurent22/joplin.git synced 2026-04-18 19:42:23 +02:00

Compare commits

..

32 Commits

Author SHA1 Message Date
Laurent Cozic 04389e6c87 Keep the index.js validation on mobile. 2026-04-14 16:13:57 +01:00
Laurent Cozic 20b5e02802 Fixed loading 2026-04-14 12:55:06 +01:00
Laurent Cozic 204b653422 update 2026-04-14 11:02:54 +01:00
Sriram Varun Kumar 222bb002c8 Mobile: Fixes #15061: Fix profile list not scrollable to last item on Manage Profiles screen (#15074) 2026-04-14 10:53:27 +01:00
Sriram Varun Kumar af8eb30844 Mobile: Fixes #15060: Fix shared note not persisted to active notebook (#15064) 2026-04-14 10:51:34 +01:00
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
Laurent Cozic fc212d0144 Add note on running yarn updateIgnored for TypeScript
Added a note about running 'yarn updateIgnored' after adding a new TypeScript file.
2026-04-13 18:38:14 +01:00
renovate[bot] 1bbd60318a fix(deps): update dependency prosemirror-gapcursor to v1.4.0 (#15069)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 15:56:00 +01:00
renovate[bot] 489b77af56 chore(deps): update dependency webpack-dev-server to v5.2.3 (#15078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 06:11:14 +00:00
renovate[bot] ff24ad7c9f chore(deps): update dependency @types/serviceworker to v0.0.176 (#15077)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-13 01:15:08 +00:00
Laurent Cozic 0021339f36 Desktop: Fixes #14208: OCR not working with Chinese and Norwegian language (#15076) 2026-04-12 16:08:05 +01:00
renovate[bot] f545726339 chore(deps): update dependency @types/serviceworker to v0.0.175 (#15067)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 13:12:06 +01:00
renovate[bot] 825a5d4bc0 chore(deps): update dependency sass to v1.96.0 (#15068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 13:11:53 +01:00
racehd 1a1f3cdd03 Doc: Note nginx webdav ext module requirement (#15050) 2026-04-11 11:38:43 +01:00
renovate[bot] 0d1b2aaa8b chore(deps): update contributor-assistant/github-action action to v2.6.1 (#15053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 11:36:46 +01:00
renovate[bot] 171b979bc4 chore(deps): update dependency xcodeproj to '< 1.27.1' (#15054)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-11 11:36:14 +01:00
Ashutosh Singh e19856a6d0 Desktop: Fixes #14852: Text copied from note viewer and pasted in an external rich text editor duplicates the codeblocks (#14860) 2026-04-11 11:21:46 +01:00
renovate[bot] 9a180fcd50 chore(deps): update dependency @types/react-redux to v7.1.34 (#15051)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-09 01:54:07 +00:00
Henry Heino 0473dd3116 MacOS: Fixes #15023: Fix undo/redo in plugins and the note titlebar (#15042) 2026-04-08 14:49:38 +01:00
Laurent Cozic cbf443de34 Desktop: Fix pasting in YesYouKan while Markdown editor is hidden (#15037) 2026-04-08 14:47:00 +01:00
Henry Heino 9a28c65baf Desktop: Resolves #14968: Fix freeze/crash when rapidly closing secondary windows (#15035) 2026-04-08 14:46:11 +01:00
Sriram Varun Kumar 623da377db Mobile: Fixes #15004: Fix back button disabled after navigating away from a deleted notebook (#15028) 2026-04-08 14:45:56 +01:00
Vishal Patel de6378473f Mobile: Add 'Go to start/end of note' toolbar buttons (#15015)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:41:00 +01:00
Sriram Varun Kumar 886a8cc1a1 Mobile: Fixes #13134: Fix Android markdown editor text replacement (characters disappearing during typing) (#15007) 2026-04-08 14:37:58 +01:00
Manvendra Kumar Singh 000d321f56 Desktop: Fixes #14946: Auto-generated note title retains inline markdown due to incomplete filtering (#14947) 2026-04-08 14:30:54 +01:00
Dipanshu Rawat f1e4545813 Desktop: Fixes #14788: Prevent sync panel from jumping (v2 with startup fix) (#14881) 2026-04-08 14:28:29 +01:00
Victor Gherardi e794176171 Mobile: Fixes #14804: Migrate expo-av to expo-audio (#14847) 2026-04-08 14:21:47 +01:00
renovate[bot] 04d0626ede Update dependency sass to v1.95.1 (#15047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 09:26:16 +00:00
renovate[bot] 4421bf8bde Update dependency sass to v1.95.0 (#15044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 08:57:41 +01:00
renovate[bot] f0eb41fe6c Update dependency @types/serviceworker to v0.0.173 (#15046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 08:57:31 +01:00
Joplin Bot 9e997503a1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2026-04-07 13:30:00 +00:00
renovate[bot] 3a6b4b12e7 Update dependency react-native-share to v12.2.2 (#15039)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-07 04:40:17 +00:00
67 changed files with 1159 additions and 397 deletions
+3
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
@@ -966,6 +968,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.test.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: contributor-assistant/github-action@v2.3.2
uses: contributor-assistant/github-action@v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
+3
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
@@ -939,6 +941,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.test.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
+1
View File
@@ -11,6 +11,7 @@
- Focus on testing essential behaviour and edge cases — avoid adding tests for every minor detail.
- Avoid duplicating code in tests; when testing the same logic with different inputs, use `test.each` or shared helpers instead of repeating similar test blocks.
- Do not make white space changes - do not add unnecessary new lines, or spaces to existing code, or wrap existing code.
- If you add a new TypeScript file, run `yarn updateIgnored` from the root.
## Full Documentation
+3
View File
@@ -102,6 +102,9 @@
},
"packageManager": "yarn@4.9.2",
"resolutions": {
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.0",
"@codemirror/language": "6.12.3",
"react-native-camera@4.2.1": "patch:react-native-camera@npm%3A4.2.1#./.yarn/patches/react-native-camera-npm-4.2.1-24b2600a7e.patch",
"eslint": "patch:eslint@8.57.1#./.yarn/patches/eslint-npm-8.39.0-d92bace04d.patch",
"app-builder-lib@24.4.0": "patch:app-builder-lib@npm%3A24.4.0#./.yarn/patches/app-builder-lib-npm-24.4.0-05322ff057.patch",
+1 -1
View File
@@ -338,7 +338,7 @@ describe('MdToHtml', () => {
for (const [tex, input] of tests) {
const html = await mdToHtml.render(input, null, { bodyOnly: true });
const opening = '<pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">';
const opening = '<pre class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">';
const closing = '</pre>';
// Remove any single leading and trailing newlines, those are included in data-joplin-source-open
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="joplin-editable joplin-abc-notation">
<pre class="joplin-source" data-abc-options="{&quot;responsive&quot;:&quot;resize&quot;}" data-joplin-language="abc" data-joplin-source-open="```abc&#10;" data-joplin-source-close="&#10;```&#10;">{responsive:'resize'}
<pre class="joplin-source" hidden data-abc-options="{&quot;responsive&quot;:&quot;resize&quot;}" data-joplin-language="abc" data-joplin-source-open="```abc&#10;" data-joplin-source-close="&#10;```&#10;">{responsive:'resize'}
---
K:F
!f!(fgag-g2c2)|</pre>
@@ -1,4 +1,4 @@
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="javascript" data-joplin-source-open="```javascript&#10;" data-joplin-source-close="&#10;```">function() {
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="javascript" data-joplin-source-open="```javascript&#10;" data-joplin-source-close="&#10;```">function() {
console.info('bonjour');
}</pre><pre class="hljs"><code><span class="hljs-keyword">function</span>(<span class="hljs-params"></span>) {
<span class="hljs-variable language_">console</span>.<span class="hljs-title function_">info</span>(<span class="hljs-string">&#x27;bonjour&#x27;</span>);
@@ -1,6 +1,6 @@
<div class="joplin-editable">
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
<div class="joplin-youtube-player-rendered">
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
</div>
@@ -1,7 +1,7 @@
<p>Link: <a data-from-md title='https://www.youtube.com/watch?v=iJqe9pC-z-Y' href='https://www.youtube.com/watch?v=iJqe9pC-z-Y' onclick='postMessage(&quot;https://www.youtube.com/watch?v=iJqe9pC-z-Y&quot;, { resourceId: &quot;&quot; }); return false;'>https://www.youtube.com/watch?v=iJqe9pC-z-Y</a></p>
<p>
<div class="joplin-editable">
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">https://www.youtube.com/watch?v=iJqe9pC-z-Y</span>
<div class="joplin-youtube-player-rendered">
<iframe src="https://www.youtube-nocookie.com/embed/iJqe9pC-z-Y" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
</div>
@@ -1 +1 @@
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="&quot;&gt;&lt;svg/onload=top.eval(atob(&quot;cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp&quot;))&gt;" data-joplin-source-open="```&quot;&gt;&lt;svg/onload=top.eval(atob(&quot;cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp&quot;))&gt;&#10;" data-joplin-source-close="&#10;```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="&quot;&gt;&lt;svg/onload=top.eval(atob(&quot;cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp&quot;))&gt;" data-joplin-source-open="```&quot;&gt;&lt;svg/onload=top.eval(atob(&quot;cmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWMoJ29wZW4gLW4gL1N5c3RlbS9BcHBsaWNhdGlvbnMvQ2FsY3VsYXRvci5hcHAvQ29udGVudHMvTWFjT1MvQ2FsY3VsYXRvcicp&quot;))&gt;&#10;" data-joplin-source-close="&#10;```">ts</pre><pre class="hljs"><code><span class="hljs-attribute">ts</span></code></pre></div>
@@ -1 +1 @@
<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="html" data-joplin-source-open="```html&#10;" data-joplin-source-close="&#10;```">&lt;a href=&quot;#&quot; onclick=&quot;leavethisalone&quot;&gt;testing fence&lt;/a&gt;</pre><pre class="hljs"><code><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;#&quot;</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">&quot;leavethisalone&quot;</span>&gt;</span>testing fence<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span></code></pre></div>
<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="html" data-joplin-source-open="```html&#10;" data-joplin-source-close="&#10;```">&lt;a href=&quot;#&quot; onclick=&quot;leavethisalone&quot;&gt;testing fence&lt;/a&gt;</pre><pre class="hljs"><code><span class="hljs-tag">&lt;<span class="hljs-name">a</span> <span class="hljs-attr">href</span>=<span class="hljs-string">&quot;#&quot;</span> <span class="hljs-attr">onclick</span>=<span class="hljs-string">&quot;leavethisalone&quot;</span>&gt;</span>testing fence<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span></code></pre></div>
@@ -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', () => {
+4
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');
}
+50 -1
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
@@ -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 })}>
@@ -94,10 +94,6 @@ const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded) => {
useEffect(() => {
if (!doc) return;
doc.open();
doc.write('<!DOCTYPE html><html><head></head><body></body></html>');
doc.close();
const cssUrls = [
'style.min.css',
];
@@ -11,6 +11,7 @@ const baseContext: Record<string, any> = {
noteIsMarkdown: true,
noteIsReadOnly: false,
richTextEditorVisible: false,
hasActivePluginEditor: false,
};
describe('editorCommandDeclarations', () => {
@@ -22,10 +22,10 @@ export const enabledCondition = (commandName: string) => {
const allowInViewerAndReadOnlyMode = worksInViewerAndReadOnlyMode.includes(commandName);
const editorPaneCondition = markdownEditorOnly
? 'markdownEditorPaneVisible'
? '(markdownEditorPaneVisible || hasActivePluginEditor)'
: allowInViewerAndReadOnlyMode
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible)'
: '(markdownEditorPaneVisible || richTextEditorVisible)';
? '(markdownEditorPaneVisible || richTextEditorVisible || markdownViewerPaneVisible || hasActivePluginEditor)'
: '(markdownEditorPaneVisible || richTextEditorVisible || hasActivePluginEditor)';
const output = [
// gotoAnythingVisible: Enable if the command palette (which is a modal dialog) is visible
+9 -8
View File
@@ -72,11 +72,8 @@ const SidebarComponent = (props: Props) => {
const syncButton = renderSynchronizeButton(props.syncStarted ? 'cancel' : 'sync');
// Show toggle when there are log lines or a completed timestamp
const hasContent = lines.length > 0 || completedTime;
// Toggle to show/hide sync log output
const toggleButton = hasContent ? (
const toggleButton = (
<button
className="sidebar-sync-toggle"
onClick={toggleSyncReport}
@@ -84,10 +81,14 @@ const SidebarComponent = (props: Props) => {
aria-label={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
title={syncReportExpanded ? _('Hide sync log') : _('Show sync log')}
>
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'up'}`} />
{!syncReportExpanded && completedTime ? <span className="timestamp">{_('Last sync: %s', completedTime)}</span> : ''}
<i className={`fas fa-caret-${syncReportExpanded ? 'down' : 'right'}`} />
{(completedTime || props.syncStarted) ? (
<span className="timestamp">
{props.syncStarted ? _('Last sync: In progress...') : _('Last sync: %s', completedTime)}
</span>
) : ''}
</button>
) : null;
);
// Sync log output, only visible when expanded
const syncReportComp = (syncReportExpanded && lines.length > 0) ? (
@@ -104,7 +105,7 @@ const SidebarComponent = (props: Props) => {
<StyledRoot className='sidebar _scrollbar2' role='navigation' aria-label={_('Sidebar')}>
<div style={{ flex: 1 }}><FolderAndTagList /></div>
<div style={{ flex: 0, padding: theme.mainPadding }}>
{toggleButton}
{(completedTime || props.syncStarted) ? toggleButton : null}
{syncReportComp}
{syncButton}
</div>
@@ -105,7 +105,7 @@ export const StyledSyncReport = styled.div`
opacity: 0.5;
display: flex;
flex-direction: column;
margin-left: 5px;
margin-left: 25px;
margin-right: 5px;
margin-bottom: 10px;
word-wrap: break-word;
@@ -1,23 +1,31 @@
.sidebar-sync-toggle {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--joplin-color2);
opacity: 0.5;
cursor: pointer;
padding: 4px 0;
width: 100%;
font-size: calc(var(--joplin-font-size) * 1.6);
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
>.timestamp {
font-size: 0.6em;
margin-left: 6px;
}
}
.sidebar-sync-toggle {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 4px 5px 4px 5px;
background: none;
border: none;
color: var(--joplin-color2);
opacity: 0.5;
cursor: pointer;
width: 100%;
font-size: calc(var(--joplin-font-size) * 1.6);
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
i {
width: 16px;
display: inline-flex;
justify-content: center;
font-size: calc(var(--joplin-toolbar-icon-size) * 0.8);
}
>.timestamp {
font-size: 0.6em;
margin-left: 4px;
}
}
@@ -373,7 +373,7 @@
ipc.focus = (event) => {
const dummyID = 'joplin-content-focus-dummy';
if (! document.getElementById(dummyID)) {
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#">Note viewer top</a></div>';
const focusDummy = '<div style="width: 0; height: 0; overflow: hidden"><a id="' + dummyID + '" href="#" aria-label="Note viewer top"></a></div>';
contentElement.insertAdjacentHTML("afterbegin", focusDummy);
}
const scrollTop = contentElement.scrollTop;
@@ -867,8 +867,19 @@
const wrapper = document.createElement('div');
wrapper.appendChild(range.cloneContents());
wrapper.querySelectorAll('style').forEach(s => s.remove());
// The hidden attribute on .joplin-source helps some apps, but many
// external editors (Word, Google Docs) ignore hidden/display:none
// when pasting clipboard HTML. Remove them explicitly as a fallback.
wrapper.querySelectorAll('.joplin-source').forEach(s => s.remove());
// Remove the accessibility focus dummy link container.
const focusDummy = wrapper.querySelector('#joplin-content-focus-dummy');
if (focusDummy && focusDummy.parentElement) focusDummy.parentElement.remove();
const inlineTags = new Set(['STRONG', 'EM', 'CODE', 'S', 'DEL', 'INS', 'MARK', 'SUP', 'SUB', 'U', 'SPAN', 'A']);
let node = range.commonAncestorContainer;
if (node.nodeType === Node.TEXT_NODE) node = node.parentElement;
+1 -1
View File
@@ -160,7 +160,7 @@
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@types/react-redux": "7.1.33",
"@types/react-redux": "7.1.34",
"@types/styled-components": "5.1.36",
"async-mutex": "0.5.0",
"axios": "^1.7.7",
+1 -1
View File
@@ -6,7 +6,7 @@ ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'xcodeproj', '< 1.27.1'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
@@ -37,6 +37,9 @@ const builtInCommandNames = [
'setTags',
EditorCommandType.ToggleSearch,
'hideKeyboard',
'-',
`editor.${EditorCommandType.GoDocStart}`,
`editor.${EditorCommandType.GoDocEnd}`,
];
@@ -13,6 +13,8 @@ const omitFromDefault: string[] = [
`editor.${EditorCommandType.DeleteLine}`,
`editor.${EditorCommandType.DuplicateLine}`,
`editor.${EditorCommandType.SortSelectedLines}`,
`editor.${EditorCommandType.GoDocStart}`,
`editor.${EditorCommandType.GoDocEnd}`,
];
// The "hide keyboard" button is only needed on iOS, so only show it there by default.
@@ -168,6 +168,16 @@ const declarations: CommandDeclaration[] = [
label: () => _('Link'),
iconName: 'material link',
},
{
name: `editor.${EditorCommandType.GoDocStart}`,
label: () => _('Go to start of note'),
iconName: 'material page-first',
},
{
name: `editor.${EditorCommandType.GoDocEnd}`,
label: () => _('Go to end of note'),
iconName: 'material page-last',
},
];
export default declarations;
@@ -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',
@@ -2,7 +2,7 @@ import * as React from 'react';
import { PrimaryButton, SecondaryButton } from '../buttons';
import { _ } from '@joplin/lib/locale';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Audio, InterruptionModeIOS } from 'expo-av';
import { AudioQuality, getRecordingPermissionsAsync, IOSOutputFormat, requestRecordingPermissionsAsync, setAudioModeAsync, type RecordingOptions, useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState } from 'expo-audio';
import Logger from '@joplin/utils/Logger';
import { OnFileSavedCallback, RecorderState } from './types';
import { Platform } from 'react-native';
@@ -11,7 +11,6 @@ import FsDriverWeb from '../../utils/fs-driver/fs-driver-rn.web';
import uuid from '@joplin/lib/uuid';
import RecordingControls from './RecordingControls';
import { Text } from 'react-native-paper';
import { AndroidAudioEncoder, AndroidOutputFormat, IOSAudioQuality, IOSOutputFormat, RecordingOptions } from 'expo-av/build/Audio';
import time from '@joplin/lib/time';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { formatMsToDurationCompat, msleep } from '@joplin/utils/time';
@@ -25,23 +24,21 @@ interface Props {
// Modified from the Expo default recording options to create
// .m4a recordings on both Android and iOS (rather than .3gp on Android).
const recordingOptions = (): RecordingOptions => ({
const recordingOptions: RecordingOptions = {
extension: '.m4a',
isMeteringEnabled: true,
sampleRate: 44100,
numberOfChannels: 2,
bitRate: 64000,
android: {
extension: '.m4a',
outputFormat: AndroidOutputFormat.MPEG_4,
audioEncoder: AndroidAudioEncoder.AAC,
sampleRate: 44100,
numberOfChannels: 2,
bitRate: 64000,
outputFormat: 'mpeg4',
audioEncoder: 'aac',
},
ios: {
extension: '.m4a',
audioQuality: IOSAudioQuality.MIN,
audioQuality: AudioQuality.MIN,
outputFormat: IOSOutputFormat.MPEG4AAC,
sampleRate: 44100,
numberOfChannels: 2,
bitRate: 64000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
@@ -56,14 +53,16 @@ const recordingOptions = (): RecordingOptions => ({
].find(type => MediaRecorder.isTypeSupported(type)) ?? 'audio/webm',
bitsPerSecond: 128000,
} : {},
});
};
const getRecordingFileName = (extension: string) => {
return `recording-${time.formatDateToLocal(new Date())}${extension}`;
};
const recordingToSaveData = async (recording: Audio.Recording) => {
let uri = recording.getURI();
const recordingToSaveData = async (recordingUri: string|null) => {
if (!recordingUri) throw new Error(_('Unable to access the recording file.'));
let uri = recordingUri;
let type: string|undefined;
let fileName;
@@ -73,7 +72,7 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
const fetchResult = await fetch(uri);
const blob = await fetchResult.blob();
type = recordingOptions().web.mimeType;
type = recordingOptions.web.mimeType;
const extension = `.${toFileExtension(type)}`;
fileName = getRecordingFileName(extension);
const file = new File([blob], fileName);
@@ -82,10 +81,9 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
await (shim.fsDriver() as FsDriverWeb).createReadOnlyVirtualFile(path, file);
uri = path;
} else {
const options = recordingOptions();
const extension = Platform.select({
android: options.android.extension,
ios: options.ios.extension,
android: recordingOptions.android.extension,
ios: recordingOptions.ios.extension,
default: '',
});
fileName = getRecordingFileName(extension);
@@ -95,75 +93,72 @@ const recordingToSaveData = async (recording: Audio.Recording) => {
};
const resetAudioMode = async () => {
await Audio.setAudioModeAsync({
// When enabled, iOS may use the small (phone call) speaker
// instead of the default one, so it's disabled when not recording:
allowsRecordingIOS: false,
playsInSilentModeIOS: false,
staysActiveInBackground: false,
await setAudioModeAsync({
allowsRecording: false,
allowsBackgroundRecording: false,
playsInSilentMode: false,
shouldPlayInBackground: false,
});
};
const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void) => {
const [permissionResponse, requestPermissions] = Audio.usePermissions();
const [recordingState, setRecordingState] = useState<RecorderState>(RecorderState.Idle);
const [error, setError] = useState('');
const [duration, setDuration] = useState(0);
const recorder = useExpoAudioRecorder(recordingOptions);
const recorderStatus = useAudioRecorderState(recorder, 100);
const isRecordingRef = useRef(false);
const recordingRef = useRef<Audio.Recording|null>(null);
const onStartRecording = useCallback(async () => {
try {
setRecordingState(RecorderState.Loading);
setError('');
if (permissionResponse?.status !== 'granted') {
const response = await requestPermissions();
const permissionResponse = await getRecordingPermissionsAsync();
if (permissionResponse.status !== 'granted') {
const response = await requestRecordingPermissionsAsync();
if (!response.granted) {
throw new Error(_('Missing permission to record audio.'));
}
// Work around "This experience is currently in the background, so the audio session could not be activated"
// See https://github.com/expo/expo/issues/21782
// May be resolved by migrating to expo-audio.
await msleep(500);
}
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
await setAudioModeAsync({
allowsRecording: true,
allowsBackgroundRecording: true,
playsInSilentMode: true,
shouldPlayInBackground: true,
// Fixes an issue where opening a recording in the iOS audio player
// breaks creating new recordings.
// See https://github.com/expo/expo/issues/31152#issuecomment-2341811087
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionMode: 'doNotMix',
});
await recorder.prepareToRecordAsync();
isRecordingRef.current = true;
recorder.record();
setRecordingState(RecorderState.Recording);
const recording = new Audio.Recording();
await recording.prepareToRecordAsync(recordingOptions());
recording.setOnRecordingStatusUpdate(status => {
setDuration(status.durationMillis);
});
recordingRef.current = recording;
await recording.startAsync();
} catch (error) {
logger.error('Error starting recording:', error);
setError(`Recording error: ${error}`);
setRecordingState(RecorderState.Error);
void recordingRef.current?.stopAndUnloadAsync();
recordingRef.current = null;
if (isRecordingRef.current) {
isRecordingRef.current = false;
void recorder.stop();
}
}
}, [permissionResponse, requestPermissions]);
}, [recorder]);
const onStopRecording = useCallback(async () => {
const recording = recordingRef.current;
recordingRef.current = null;
try {
setRecordingState(RecorderState.Processing);
await recording.stopAndUnloadAsync();
await recorder.stop();
isRecordingRef.current = false;
await resetAudioMode();
const saveEvent = await recordingToSaveData(recording);
const saveEvent = await recordingToSaveData(recorder.uri);
onFileSaved(saveEvent);
onDismiss();
} catch (error) {
@@ -171,25 +166,35 @@ const useAudioRecorder = (onFileSaved: OnFileSavedCallback, onDismiss: ()=> void
setError(`Save error: ${error}`);
setRecordingState(RecorderState.Error);
}
}, [onFileSaved, onDismiss]);
}, [onFileSaved, onDismiss, recorder]);
const onStartStopRecording = useCallback(async () => {
if (recordingState === RecorderState.Idle) {
await onStartRecording();
} else if (recordingState === RecorderState.Recording && recordingRef.current) {
} else if (recordingState === RecorderState.Recording) {
await onStopRecording();
}
}, [recordingState, onStartRecording, onStopRecording]);
useEffect(() => () => {
if (recordingRef.current) {
void recordingRef.current?.stopAndUnloadAsync();
recordingRef.current = null;
void resetAudioMode();
}
}, []);
if (isRecordingRef.current) {
isRecordingRef.current = false;
return { onStartStopRecording, error, duration, recordingState };
const stopRecorderOnCleanup = async () => {
try {
await recorder.stop();
} catch (error) {
logger.warn('Error stopping recorder during cleanup:', error);
}
await resetAudioMode();
};
void stopRecorderOnCleanup();
}
}, [recorder]);
return { onStartStopRecording, error, duration: recorderStatus.durationMillis, recordingState };
};
const AudioRecordingBanner: React.FC<Props> = props => {
+14 -15
View File
@@ -1,7 +1,4 @@
PODS:
- EXAV (16.0.8):
- ExpoModulesCore
- ReactCommon/turbomodule/core
- EXConstants (18.0.13):
- ExpoModulesCore
- EXImageLoader (6.0.0):
@@ -34,6 +31,8 @@ PODS:
- Yoga
- ExpoAsset (12.0.12):
- ExpoModulesCore
- ExpoAudio (1.1.1):
- ExpoModulesCore
- ExpoCamera (17.0.10):
- ExpoModulesCore
- ZXingObjC/OneD
@@ -72,9 +71,9 @@ PODS:
- ReactNativeDependencies
- Yoga
- FBLazyVector (0.81.6)
- hermes-engine (0.81.5):
- hermes-engine/Pre-built (= 0.81.5)
- hermes-engine/Pre-built (0.81.5)
- hermes-engine (0.81.6):
- hermes-engine/Pre-built (= 0.81.6)
- hermes-engine/Pre-built (0.81.6)
- JoplinCommonShareExtension (1.0.0)
- JoplinRNShareExtension (1.0.0):
- JoplinCommonShareExtension
@@ -154,7 +153,7 @@ PODS:
- React-utils
- ReactNativeDependencies
- Yoga
- React-Core-prebuilt (0.81.5):
- React-Core-prebuilt (0.81.6):
- ReactNativeDependencies
- React-Core/CoreModulesHeaders (0.81.6):
- hermes-engine
@@ -2134,7 +2133,7 @@ PODS:
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- SDWebImage/Core (5.21.5)
- SDWebImage/Core (5.21.7)
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
@@ -2169,11 +2168,11 @@ PODS:
- ZXingObjC/Core
DEPENDENCIES:
- EXAV (from `../node_modules/expo-av/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`)
- EXImageLoader (from `../node_modules/expo-image-loader/ios`)
- Expo (from `../node_modules/expo`)
- ExpoAsset (from `../node_modules/expo-asset/ios`)
- ExpoAudio (from `../node_modules/expo-audio/ios`)
- ExpoCamera (from `../node_modules/expo-camera/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`)
@@ -2293,8 +2292,6 @@ SPEC REPOS:
- ZXingObjC
EXTERNAL SOURCES:
EXAV:
:path: "../node_modules/expo-av/ios"
EXConstants:
:path: "../node_modules/expo-constants/ios"
EXImageLoader:
@@ -2303,6 +2300,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/expo"
ExpoAsset:
:path: "../node_modules/expo-asset/ios"
ExpoAudio:
:path: "../node_modules/expo-audio/ios"
ExpoCamera:
:path: "../node_modules/expo-camera/ios"
ExpoFileSystem:
@@ -2522,11 +2521,11 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
EXAV: b60fcf142fae6684d295bc28cd7cfcb3335570ea
EXConstants: fce59a631a06c4151602843667f7cfe35f81e271
EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05
Expo: 04993fbd7b06dc98ffac58da8847298470dc3db1
ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18
ExpoAudio: e4cfe3a2f3317b8487460685385a9867a07fb4fb
ExpoCamera: 6a326deb45ba840749652e4c15198317aa78497e
ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509
@@ -2534,7 +2533,7 @@ SPEC CHECKSUMS:
ExpoLocalAuthentication: 8a31808565da7af926dd9b595e98594d8b1553b6
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
FBLazyVector: 14ce6e3675cacb2683ad30272f04274a4ee5b67d
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
hermes-engine: 7219f6e751ad6ec7f3d7ec121830ee34dae40749
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
@@ -2546,7 +2545,7 @@ SPEC CHECKSUMS:
React: 348d1689d8686d034c5b7667dc45de86c6319dd1
React-callinvoker: 2c3b664f3482f5bc5560ea1edcbbe69748752f08
React-Core: 346787852200a732b187805344b8a350d464e004
React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599
React-Core-prebuilt: 721ab014acfaff1e4b8fc0d2f7d6f41ea9a706ed
React-CoreModules: 7e07391a1082d02c37f846a362f7574ab035933c
React-cxxreact: c50d278c785792a077a6b357aaabd9e5d09e9c6f
React-debug: 1b91785fec02ea76c793ead23bed1528d96b4262
@@ -2635,7 +2634,7 @@ SPEC CHECKSUMS:
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc
RNShare: 0e600372fb35783fe30d413efd28d11de2bf6cf0
RNSVG: cf9ae78f2edf2988242c71a6392d15ff7dd62522
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
WhisperVoiceTyping: 343ea840cbde2a5f3508f8b016ebcf1c089179ea
Yoga: 786fa7d9d2ff6060b4e688062243fa69c323d140
+29 -2
View File
@@ -76,14 +76,41 @@ jest.mock('@react-native-clipboard/clipboard', () => {
return { default: { getString: jest.fn(), setString: jest.fn() } };
});
jest.doMock('expo-audio', () => {
return {
AudioQuality: {
MIN: 'min',
},
IOSOutputFormat: {
MPEG4AAC: 'mpeg4aac',
},
getRecordingPermissionsAsync: jest.fn(async () => ({
status: 'granted',
granted: true,
})),
requestRecordingPermissionsAsync: jest.fn(async () => ({
status: 'granted',
granted: true,
})),
setAudioModeAsync: jest.fn(async () => null),
useAudioRecorder: jest.fn(() => ({
prepareToRecordAsync: jest.fn(async () => null),
record: jest.fn(),
stop: jest.fn(async () => null),
uri: null,
})),
useAudioRecorderState: jest.fn(() => ({
durationMillis: 0,
})),
};
});
const emptyMockPackages = [
'react-native-share',
'react-native-file-viewer',
'react-native-image-picker',
'@react-native-documents/picker',
'@joplin/react-native-saf-x',
'expo-av',
'expo-av/build/Audio',
'expo-image-manipulator',
];
for (const packageName of emptyMockPackages) {
+5 -5
View File
@@ -49,7 +49,7 @@
"deprecated-react-native-prop-types": "5.0.0",
"events": "3.3.0",
"expo": "54.0.31",
"expo-av": "16.0.8",
"expo-audio": "1.1.1",
"expo-camera": "17.0.10",
"expo-image-manipulator": "14.0.8",
"expo-local-authentication": "17.0.8",
@@ -77,7 +77,7 @@
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.6.2",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.2.1",
"react-native-share": "12.2.2",
"react-native-sqlite-storage": "6.0.1",
"react-native-svg": "15.15.1",
"react-native-url-polyfill": "2.0.0",
@@ -115,8 +115,8 @@
"@types/jest": "29.5.14",
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.172",
"@types/react-redux": "7.1.34",
"@types/serviceworker": "0.0.176",
"@types/tar-stream": "3.1.4",
"babel-jest": "29.7.0",
"babel-loader": "9.1.3",
@@ -146,7 +146,7 @@
"url-loader": "4.1.1",
"webpack": "5.97.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "5.2.2"
"webpack-dev-server": "5.2.3"
},
"engines": {
"node": ">=20"
+10 -6
View File
@@ -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.');
@@ -0,0 +1,48 @@
import appReducer from './appReducer';
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
const notesRoute = { type: 'NAV_GO', routeName: 'Notes', folderId: 'folder1' };
const settingsRoute = { type: 'NAV_GO', routeName: 'Config' };
const deletedFolderId = 'folder1';
const clearHistory = () => appReducer(appDefaultState, { type: 'NAV_GO', routeName: 'Notes', clearHistory: true });
// Simulates the exact scenario: navigate to a folder with no prior history, then delete it
const makeDeletedRouteWithEmptyHistory = () => {
let state = appReducer(appDefaultState, {
type: 'NAV_GO',
routeName: 'Notes',
folderId: deletedFolderId,
clearHistory: true,
});
state = appReducer(state, { type: 'FOLDER_DELETE', id: deletedFolderId });
return state;
};
describe('appReducer', () => {
test('historyCanGoBack is true after navigating from Notes to Settings', () => {
let state = clearHistory();
state = appReducer(state, notesRoute);
state = appReducer(state, settingsRoute);
expect(state.historyCanGoBack).toBe(true);
});
test('historyCanGoBack remains true after navigating away from a deleted route', () => {
let state = makeDeletedRouteWithEmptyHistory();
// Navigate forward (e.g. open Settings)
state = appReducer(state, settingsRoute);
expect(state.historyCanGoBack).toBe(true);
});
test('going back from Settings after folder deletion lands on DEFAULT_ROUTE', () => {
let state = makeDeletedRouteWithEmptyHistory();
state = appReducer(state, settingsRoute);
// Go back
state = appReducer(state, { type: 'NAV_BACK' });
expect(state.route).toEqual(DEFAULT_ROUTE);
});
});
+5 -1
View File
@@ -1,6 +1,6 @@
import reducer from '@joplin/lib/reducer';
import { AppState } from './types';
import appDefaultState from './appDefaultState';
import appDefaultState, { DEFAULT_ROUTE } from './appDefaultState';
import fastDeepEqual = require('fast-deep-equal');
import Logger from '@joplin/utils/Logger';
@@ -76,6 +76,10 @@ const appReducer = (state = appDefaultState, action: any) => {
if (currentRoute.isDeleted) {
// Do not add the item to the history, and remove the last item in the history if that is now the selected item
removeLatestFolderIfSelected(navHistory, action);
// Push DEFAULT_ROUTE so there's always a valid back target after deletion
if (!navHistory.length) {
navHistory.push(DEFAULT_ROUTE);
}
} else if (isDifferentRoute) {
navHistory.push(currentRoute);
}
@@ -144,4 +144,21 @@ describe('ProseMirror/commands', () => {
}],
});
});
test('goDocEnd should move the cursor to the end of the document', () => {
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
commands[EditorCommandType.GoDocEnd](editor.state, editor.dispatch, editor);
expect(editor.state.selection.from).toBe(editor.state.doc.content.size - 1);
});
test('goDocStart should move the cursor to the start of the document', () => {
const editor = createTestEditor({ html: '<p>First</p><p>Last</p>' });
moveCursorToEnd(editor);
commands[EditorCommandType.GoDocStart](editor.state, editor.dispatch, editor);
expect(editor.state.selection.from).toBe(1);
});
});
@@ -1,4 +1,4 @@
import { Command, EditorState, Transaction } from 'prosemirror-state';
import { Command, EditorState, TextSelection, Transaction } from 'prosemirror-state';
import { EditorCommandType } from '../../types';
import { redo, undo } from 'prosemirror-history';
import { autoJoin, selectAll, setBlockType, toggleMark } from 'prosemirror-commands';
@@ -247,8 +247,14 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
[EditorCommandType.InsertNewlineAndIndent]: null,
[EditorCommandType.SwapLineUp]: null,
[EditorCommandType.SwapLineDown]: null,
[EditorCommandType.GoDocEnd]: null,
[EditorCommandType.GoDocStart]: null,
[EditorCommandType.GoDocEnd]: (state, dispatch) => {
dispatch(state.tr.setSelection(TextSelection.atEnd(state.doc)).scrollIntoView());
return true;
},
[EditorCommandType.GoDocStart]: (state, dispatch) => {
dispatch(state.tr.setSelection(TextSelection.atStart(state.doc)).scrollIntoView());
return true;
},
[EditorCommandType.GoLineStart]: null,
[EditorCommandType.GoLineEnd]: null,
[EditorCommandType.GoLineUp]: null,
+9 -9
View File
@@ -20,7 +20,7 @@
"@types/jest": "29.5.14",
"@types/node": "18.19.130",
"@types/react": "19.1.10",
"@types/react-redux": "7.1.33",
"@types/react-redux": "7.1.34",
"@types/styled-components": "5.1.32",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
@@ -28,17 +28,17 @@
"typescript": "5.8.3"
},
"dependencies": {
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.1",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-markdown": "6.5.0",
"@codemirror/language": "6.10.4",
"@codemirror/language": "6.12.3",
"@codemirror/language-data": "6.3.1",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/lint": "6.9.2",
"@codemirror/search": "6.5.8",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.35.0",
"@codemirror/lint": "6.9.5",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.0",
"@joplin/fork-uslug": "^2.0.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3",
@@ -49,7 +49,7 @@
"prosemirror-commands": "1.7.1",
"prosemirror-dropcursor": "1.8.2",
"prosemirror-example-setup": "1.2.3",
"prosemirror-gapcursor": "1.3.2",
"prosemirror-gapcursor": "1.4.0",
"prosemirror-history": "1.5.0",
"prosemirror-inputrules": "1.5.0",
"prosemirror-keymap": "1.2.3",
@@ -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++) {
+37 -10
View File
@@ -224,17 +224,44 @@ const markdownUtils = {
if (!body) return '';
const spaceEntities = /&nbsp;/g;
body = body.replace(spaceEntities, ' ');
const lines = body.trim().split('\n');
const title = lines[0].trim();
const lines = body.split('\n');
let title = '';
const mdLinkRegex = /!?\[([^\]]+?)\]\(.+?\)/g;
const emptyMdLinkRegex = /!?\[\]\((.+?)\)/g;
const filterRegex = /^[# \n\t*`-]*/;
return title
.replace(filterRegex, '')
.replace(mdLinkRegex, '$1')
.replace(emptyMdLinkRegex, '$1')
.substring(0, 80);
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
title = trimmed;
break;
}
}
title = title.replace(/<\/?(ins|del|mark|sub|sup)>/g, '');
title = title.replace(/!?\[([^\]]*)\]\(([^()\n]+(?:\([^()\n]*\)[^()\n]*)*)\)/g, (_match, text, url) => {
return text || url;
});
const formattingPatterns = [
/(\*\*\*|___)(.*?)\1/g,
/(\*\*|__)(.*?)\1/g,
/(\*|_)(.*?)\1/g,
/(~~)(.*?)\1/g,
/(==)(.*?)\1/g,
/(\^)(.*?)\1/g,
];
let prev;
do {
prev = title;
for (const pattern of formattingPatterns) {
title = title.replace(pattern, '$2');
}
} while (title !== prev);
title = title.replace(/^\s*([-*+]|\d+[.)])\s+(\[[ xX]\]\s+)?/, '');
title = title.replace(/^(~{2,}|={2,})$/, '');
title = title.replace(/^[#>\-*`\s=]+/, '');
title = title.replace(/[#>\-*`\s=]+$/, '');
title = title.trim();
return title.substring(0, 80);
},
};
+9
View File
@@ -115,6 +115,15 @@ describe('markdownUtils', () => {
['These are [link1](one), [link2](two) and ![link3](three)', 'These are link1, link2 and link3'],
['No description link to [](https://joplinapp.org)', 'No description link to https://joplinapp.org'],
['&nbsp;\n\nThis is a test &nbsp; test.', 'This is a test test.'],
['***Example title***', 'Example title'],
['==~~Formatted title~~==', 'Formatted title'],
['<ins>Important note title</ins>', 'Important note title'],
['`Inline code title`', 'Inline code title'],
['<ins>~~==***Deeply formatted title***==~~</ins>', 'Deeply formatted title'],
['C++ <vector> usage guide', 'C++ <vector> usage guide'],
['C# programming basics', 'C# programming basics'],
['Understanding a ~ b relationship', 'Understanding a ~ b relationship'],
['***~~***', ''],
])('should create a default note title from the note body (%j -> %j)', (body, expectedTitle) => {
expect(markdownUtils.titleFromBody(body)).toBe(expectedTitle);
});
@@ -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 },
+11 -1
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
+4 -4
View File
@@ -53,8 +53,8 @@ const defaultKeymapItems = {
{ accelerator: 'F1', command: 'help' },
{ accelerator: 'Cmd+D', command: 'editor.deleteLine' },
{ accelerator: 'Shift+Cmd+D', command: 'editor.duplicateLine' },
{ accelerator: 'Cmd+Z', command: 'editor.undo' },
{ accelerator: 'Cmd+Y', command: 'editor.redo' },
{ accelerator: 'Cmd+Z', command: 'globalUndo' },
{ accelerator: 'Cmd+Shift+Z', command: 'globalRedo' },
{ accelerator: 'Cmd+[', command: 'editor.indentLess' },
{ accelerator: 'Cmd+]', command: 'editor.indentMore' },
{ accelerator: 'Cmd+/', command: 'editor.toggleComment' },
@@ -109,8 +109,8 @@ const defaultKeymapItems = {
{ accelerator: 'F1', command: 'help' },
{ accelerator: 'Ctrl+D', command: 'editor.deleteLine' },
{ accelerator: 'Shift+Ctrl+D', command: 'editor.duplicateLine' },
{ accelerator: 'Ctrl+Z', command: 'editor.undo' },
{ accelerator: 'Ctrl+Y', command: 'editor.redo' },
{ accelerator: 'Ctrl+Z', command: 'globalUndo' },
{ accelerator: 'Ctrl+Y', command: 'globalRedo' },
{ accelerator: 'Ctrl+[', command: 'editor.indentLess' },
{ accelerator: 'Ctrl+]', command: 'editor.indentMore' },
{ accelerator: 'Ctrl+/', command: 'editor.toggleComment' },
+20 -2
View File
@@ -1,4 +1,4 @@
import { toIso639Alpha3 } from '../../locale';
import { countryCodeOnly, languageCodeOnly, toIso639Alpha3 } from '../../locale';
import Resource from '../../models/Resource';
import Setting from '../../models/Setting';
import shim from '../../shim';
@@ -23,6 +23,24 @@ export const supportedMimeTypes = [
'image/x-portable-bitmap',
];
// Tesseract uses its own language codes that don't always match ISO 639-3.
// For example, the ISO 639-3 code for Chinese is "zho" but Tesseract uses
// "chi_sim" (Simplified) and "chi_tra" (Traditional), and Norwegian Bokmål
// is "nob" in ISO 639-3 but "nor" in Tesseract.
const iso639ToTesseractOverrides: Record<string, string> = {
'nob': 'nor',
};
const localeToTesseractLanguage = (locale: string): string => {
const lang = languageCodeOnly(locale);
if (lang === 'zh') {
const country = countryCodeOnly(locale).toUpperCase();
return country === 'TW' ? 'chi_tra' : 'chi_sim';
}
const alpha3 = toIso639Alpha3(locale);
return iso639ToTesseractOverrides[alpha3] || alpha3;
};
const resourceInfo = (resource: ResourceEntity) => {
return `${resource.id} (type ${resource.mime})`;
};
@@ -213,7 +231,7 @@ export default class OcrService {
};
try {
const language = toIso639Alpha3(Setting.value('locale'));
const language = localeToTesseractLanguage(Setting.value('locale'));
const processedResourceIds: string[] = [];
// Queue all resources for processing
@@ -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);
+1 -1
View File
@@ -535,7 +535,7 @@ export default class MdToHtml implements MarkupRenderer {
// The strings includes the last \n that is part of the fence,
// so we remove it because we need the exact code in the source block
const trimmedStr = this.removeLastNewLine(str);
const sourceBlockHtml = `<pre class="joplin-source" data-joplin-language="${htmlentities(lang)}" data-joplin-source-open="\`\`\`${htmlentities(lang)}&#10;" data-joplin-source-close="&#10;\`\`\`">${markdownIt.utils.escapeHtml(trimmedStr)}</pre>`;
const sourceBlockHtml = `<pre class="joplin-source" hidden data-joplin-language="${htmlentities(lang)}" data-joplin-source-open="\`\`\`${htmlentities(lang)}&#10;" data-joplin-source-close="&#10;\`\`\`">${markdownIt.utils.escapeHtml(trimmedStr)}</pre>`;
if (this.shouldSkipHighlighting(trimmedStr, lang)) {
outputCodeHtml = markdownIt.utils.escapeHtml(trimmedStr);
+1 -1
View File
@@ -73,7 +73,7 @@ const plugin = (markdownIt: MarkdownIt, ruleOptions: any) => {
return `
<div class="joplin-editable joplin-abc-notation">
<pre class="joplin-source" data-abc-options="${optionsHtml}" data-joplin-language="abc" data-joplin-source-open="\`\`\`abc&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${sourceContentHtml}</pre>
<pre class="joplin-source" hidden data-abc-options="${optionsHtml}" data-joplin-language="abc" data-joplin-source-open="\`\`\`abc&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${sourceContentHtml}</pre>
<pre class="joplin-rendered joplin-abc-notation-rendered">${contentHtml}</pre>
</div>
`;
@@ -68,7 +68,7 @@ const plugin = (markdownIt: MarkdownIt) => {
return `
<div class="joplin-editable">
<span class="joplin-source" data-joplin-source-open="" data-joplin-source-close="">${escapedUrl}</span>
<span class="joplin-source" hidden data-joplin-source-open="" data-joplin-source-close="">${escapedUrl}</span>
<div class="joplin-youtube-player-rendered">
<iframe src="${embedUrl}" title="YouTube video player" frameborder="0" allowfullscreen></iframe>
</div>
+1 -1
View File
@@ -143,7 +143,7 @@ function renderFountainScript(markdownIt: any, content: string) {
return `
<!-- joplin-metadata-print-title = false -->
<div class="fountain joplin-editable">
<pre class="joplin-source" data-joplin-language="fountain" data-joplin-source-open="\`\`\`fountain&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${markdownIt.utils.escapeHtml(content)}</pre>
<pre class="joplin-source" hidden data-joplin-language="fountain" data-joplin-source-open="\`\`\`fountain&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${markdownIt.utils.escapeHtml(content)}</pre>
${titlePageHtml}
<div class="page">
${result.html.script}
@@ -104,7 +104,7 @@ const plugin = (markdownIt: MarkdownIt, _ruleOptions: unknown) => {
// IMPORTANT: No whitespace between joplin-editable and joplin-source elements!
// The turndown joplinEditableBlockInfo function iterates childNodes and crashes
// on text nodes (whitespace) because they don't have classList.
return `<div class="joplin-editable joplin-frontmatter"><pre class="joplin-source" data-joplin-language="frontmatter" data-joplin-source-open="---&#10;" data-joplin-source-close="&#10;---&#10;">${contentHtml}</pre><div class="joplin-rendered joplin-frontmatter-rendered"><hr class="joplin-frontmatter-marker"/><pre class="hljs">${highlightedContent}</pre><hr class="joplin-frontmatter-marker"/></div></div>`;
return `<div class="joplin-editable joplin-frontmatter"><pre class="joplin-source" hidden data-joplin-language="frontmatter" data-joplin-source-open="---&#10;" data-joplin-source-close="&#10;---&#10;">${contentHtml}</pre><div class="joplin-rendered joplin-frontmatter-rendered"><hr class="joplin-frontmatter-marker"/><pre class="hljs">${highlightedContent}</pre><hr class="joplin-frontmatter-marker"/></div></div>`;
};
};
+2 -2
View File
@@ -337,7 +337,7 @@ export default {
} catch (error) {
outputHtml = renderKatexError(error, 'span');
}
return `<span class="joplin-editable"><span class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
return `<span class="joplin-editable"><span class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$" data-joplin-source-close="$">${markdownIt.utils.escapeHtml(latex)}</span>${outputHtml}</span>`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -355,7 +355,7 @@ export default {
outputHtml = renderKatexError(error, 'div');
}
return `<div class="joplin-editable"><pre class="joplin-source" data-joplin-language="katex" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
return `<div class="joplin-editable"><pre class="joplin-source" hidden data-joplin-language="katex" data-joplin-source-open="$$&#10;" data-joplin-source-close="&#10;$$&#10;">${markdownIt.utils.escapeHtml(latex)}</pre>${outputHtml}</div>`;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
+1 -1
View File
@@ -86,7 +86,7 @@ export default {
// See PR #4670 https://github.com/laurent22/joplin/pull/4670
return `
<div class="joplin-editable">
<pre class="joplin-source" data-joplin-language="mermaid" data-joplin-source-open="\`\`\`mermaid&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${contentHtml}</pre>
<pre class="joplin-source" hidden data-joplin-language="mermaid" data-joplin-source-open="\`\`\`mermaid&#10;" data-joplin-source-close="&#10;\`\`\`&#10;">${contentHtml}</pre>
${exportButtonMarkup}
<pre class="${cssClasses.join(' ')}">${contentHtml}</pre>
</div>
+1 -1
View File
@@ -61,7 +61,7 @@
"jest": "29.7.0",
"js-yaml": "4.1.1",
"rss": "1.2.2",
"sass": "1.94.3",
"sass": "1.96.0",
"sqlite3": "5.1.6",
"style-to-js": "1.1.21",
"ts-node": "10.9.2",
+2 -1
View File
@@ -214,6 +214,7 @@
"v3.6.6": true,
"v3.6.7": true,
"android-v3.6.15": true,
"ios-v13.6.4": true
"ios-v13.6.4": true,
"v3.6.8": true
}
}
+4
View File
@@ -1,5 +1,9 @@
# Joplin Desktop Changelog
## [v3.6.8](https://github.com/laurent22/joplin/releases/tag/v3.6.8) (Pre-release) - 2026-04-07T07:28:36Z
- Desktop: Fixed regression that prevented images from being pasted in editor ([#14750](https://github.com/laurent22/joplin/issues/14750))
## [v3.6.7](https://github.com/laurent22/joplin/releases/tag/v3.6.7) (Pre-release) - 2026-04-05T15:21:11Z
- Improved: Added fullscreen shortcut (Ctrl + Cmd + F) ([#14926](https://github.com/laurent22/joplin/issues/14926)) ([#9637](https://github.com/laurent22/joplin/issues/9637) by [@DevrG03](https://github.com/DevrG03))
+1 -1
View File
@@ -10,7 +10,7 @@ WebDAV-compatible services that are known to work with Joplin:
- [HiDrive](https://www.strato.fr/stockage-en-ligne/) from Strato. [Setup help](https://github.com/laurent22/joplin/issues/309)
- [InfiniCLOUD](https://infini-cloud.net/)
- [Mailbox.org WebDAV](https://kb.mailbox.org/en/private/drive/) [Setup help](https://userforum-en.mailbox.org/topic/2766-unable-to-sync-joplin-notes-with-mailbox-org-via-webdav#comment-10946)
- [Nginx WebDAV Module](https://nginx.org/en/docs/http/ngx_http_dav_module.html)
- [Nginx WebDAV Module](https://nginx.org/en/docs/http/ngx_http_dav_module.html), requires [http_dav_ext_module](https://github.com/arut/nginx-dav-ext-module)
- [Nextcloud](https://nextcloud.com/)
- [OwnCloud](https://owncloud.org/)
- [Seafile](https://www.seafile.com/)
+1 -1
View File
@@ -88,7 +88,7 @@
"browserify",
"codemirror",
"cspell",
"expo-av", // Must be updated with expo
"expo-audio", // Must be updated with expo
"file-loader",
"gradle",
"html-webpack-plugin",
+505 -195
View File
File diff suppressed because it is too large Load Diff