diff --git a/.eslintignore b/.eslintignore
index 24a0bd1a93..7ffa660f61 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -266,6 +266,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHan
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
@@ -554,13 +555,16 @@ packages/app-desktop/services/plugins/UserWebview.js
packages/app-desktop/services/plugins/UserWebviewDialog.js
packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js
packages/app-desktop/services/plugins/hooks/useContentSize.js
+packages/app-desktop/services/plugins/hooks/useFormData.js
packages/app-desktop/services/plugins/hooks/useHtmlLoader.js
+packages/app-desktop/services/plugins/hooks/useMessageHandler.js
packages/app-desktop/services/plugins/hooks/useScriptLoader.js
packages/app-desktop/services/plugins/hooks/useSubmitHandler.js
packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
+packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
@@ -707,6 +711,7 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
+packages/app-mobile/components/buttons/MultiTouchableOpacity.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
diff --git a/.gitignore b/.gitignore
index ab0fe5484a..2a0b0a1620 100644
--- a/.gitignore
+++ b/.gitignore
@@ -241,6 +241,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHan
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useWebViewApi.js
packages/app-desktop/gui/NoteEditor/NoteEditor.js
packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js
@@ -529,13 +530,16 @@ packages/app-desktop/services/plugins/UserWebview.js
packages/app-desktop/services/plugins/UserWebviewDialog.js
packages/app-desktop/services/plugins/UserWebviewDialogButtonBar.js
packages/app-desktop/services/plugins/hooks/useContentSize.js
+packages/app-desktop/services/plugins/hooks/useFormData.js
packages/app-desktop/services/plugins/hooks/useHtmlLoader.js
+packages/app-desktop/services/plugins/hooks/useMessageHandler.js
packages/app-desktop/services/plugins/hooks/useScriptLoader.js
packages/app-desktop/services/plugins/hooks/useSubmitHandler.js
packages/app-desktop/services/plugins/hooks/useThemeCss.test.js
packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
+packages/app-desktop/services/plugins/types.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
@@ -682,6 +686,7 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/buttons/FloatingActionButton.js
packages/app-mobile/components/buttons/LabelledIconButton.js
+packages/app-mobile/components/buttons/MultiTouchableOpacity.js
packages/app-mobile/components/buttons/TextButton.js
packages/app-mobile/components/buttons/index.js
packages/app-mobile/components/getResponsiveValue.test.js
diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts
index 078ed82d29..004680dcba 100644
--- a/packages/app-desktop/ElectronAppWrapper.ts
+++ b/packages/app-desktop/ElectronAppWrapper.ts
@@ -212,7 +212,6 @@ export default class ElectronAppWrapper {
spellcheck: true,
enableRemoteModule: true,
},
- webviewTag: true,
// We start with a hidden window, which is then made visible depending on the showTrayIcon setting
// https://github.com/laurent22/joplin/issues/2031
//
@@ -706,10 +705,6 @@ export default class ElectronAppWrapper {
return true;
}
- public initializeCustomProtocolHandler(logger: LoggerWrapper) {
- this.customProtocolHandler_ ??= handleCustomProtocols(logger);
- }
-
// Electron's autoUpdater has to be init from the main process
public initializeAutoUpdaterService(logger: LoggerWrapper, devMode: boolean, includePreReleases: boolean) {
if (shim.isWindows() || shim.isMac()) {
@@ -748,6 +743,7 @@ export default class ElectronAppWrapper {
const alreadyRunning = await this.ensureSingleInstance();
if (alreadyRunning) return;
+ this.customProtocolHandler_ = handleCustomProtocols();
this.createWindow();
this.electronApp_.on('before-quit', () => {
diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts
index d43d25fb0e..1b3d75c01d 100644
--- a/packages/app-desktop/app.ts
+++ b/packages/app-desktop/app.ts
@@ -456,9 +456,6 @@ class Application extends BaseApplication {
bridge().openDevTools();
}
- bridge().electronApp().initializeCustomProtocolHandler(
- Logger.create('handleCustomProtocols'),
- );
this.protocolHandler_ = bridge().electronApp().getCustomProtocolHandler();
this.protocolHandler_.allowReadAccessToDirectory(__dirname); // App bundle directory
this.protocolHandler_.allowReadAccessToDirectory(Setting.value('cacheDir'));
diff --git a/packages/app-desktop/bridge.ts b/packages/app-desktop/bridge.ts
index ade7e0785c..7439a9f688 100644
--- a/packages/app-desktop/bridge.ts
+++ b/packages/app-desktop/bridge.ts
@@ -6,7 +6,6 @@ import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
import * as Sentry from '@sentry/electron/main';
-import { ErrorEvent } from '@sentry/types/types';
import { homedir } from 'os';
import { msleep } from '@joplin/utils/time';
import { pathExists, pathExistsSync, writeFileSync } from 'fs-extra';
@@ -101,9 +100,9 @@ export class Bridge {
if (logAttachment) hint.attachments = [logAttachment];
const date = (new Date()).toISOString().replace(/[:-]/g, '').split('.')[0];
- interface ErrorEventWithLog extends ErrorEvent {
+ type ErrorEventWithLog = (typeof event) & {
log: string[];
- }
+ };
const errorEventWithLog: ErrorEventWithLog = {
...event,
@@ -123,6 +122,10 @@ export class Bridge {
},
integrations: [Sentry.electronMinidumpIntegration()],
+
+ // Using the default ipcMode value causes ;
}
diff --git a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx
index 6c74eb419d..8d7b86b6db 100644
--- a/packages/app-desktop/services/plugins/UserWebviewDialog.tsx
+++ b/packages/app-desktop/services/plugins/UserWebviewDialog.tsx
@@ -46,9 +46,9 @@ export default function UserWebviewDialog(props: Props) {
const buttons: ButtonSpec[] = (props.buttons ? props.buttons : defaultButtons()).map((b: ButtonSpec) => {
return {
...b,
- onClick: () => {
+ onClick: async () => {
const response: DialogResult = { id: b.id };
- const formData = webviewRef.current.formData();
+ const formData = await webviewRef.current.formData();
if (formData && Object.keys(formData).length) response.formData = formData;
viewController().closeWithResponse(response);
},
diff --git a/packages/app-desktop/services/plugins/UserWebviewIndex.js b/packages/app-desktop/services/plugins/UserWebviewIndex.js
index b2d22f8492..8ad9681ea7 100644
--- a/packages/app-desktop/services/plugins/UserWebviewIndex.js
+++ b/packages/app-desktop/services/plugins/UserWebviewIndex.js
@@ -1,6 +1,43 @@
// This is the API that JS files loaded from the webview can see
const webviewApiPromises_ = {};
let viewMessageHandler_ = () => {};
+const postMessage = (message) => {
+ parent.postMessage(message, '*');
+};
+
+
+function serializeForm(form) {
+ const output = {};
+ const formData = new FormData(form);
+ for (const key of formData.keys()) {
+ output[key] = formData.get(key);
+ }
+ return output;
+}
+
+function serializeForms(document) {
+ const forms = document.getElementsByTagName('form');
+ const output = {};
+ let untitledIndex = 0;
+
+ for (const form of forms) {
+ const name = `${form.getAttribute('name')}` || (`form${untitledIndex++}`);
+ output[name] = serializeForm(form);
+ }
+
+ return output;
+}
+
+function watchElementSize(element, onChange) {
+ const emitSizeChange = () => {
+ onChange(element.getBoundingClientRect());
+ };
+ const observer = new ResizeObserver(emitSizeChange);
+ observer.observe(element);
+
+ // Initial size
+ requestAnimationFrame(emitSizeChange);
+}
// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
const webviewApi = {
@@ -11,7 +48,7 @@ const webviewApi = {
webviewApiPromises_[messageId] = { resolve, reject };
});
- window.postMessage({
+ postMessage({
target: 'postMessageService.message',
message: {
from: 'userWebview',
@@ -26,7 +63,7 @@ const webviewApi = {
onMessage: function(viewMessageHandler) {
viewMessageHandler_ = viewMessageHandler;
- window.postMessage({
+ postMessage({
target: 'postMessageService.registerViewMessageHandler',
});
},
@@ -90,14 +127,14 @@ const webviewApi = {
window.requestAnimationFrame(() => {
// eslint-disable-next-line no-console
console.debug('UserWebviewIndex: setting html callback', args.hash);
- window.postMessage({ target: 'UserWebview', message: 'htmlIsSet', hash: args.hash }, '*');
+ postMessage({ target: 'UserWebview', message: 'htmlIsSet', hash: args.hash });
});
},
setScript: (args) => {
const { script, key } = args;
- const scriptPath = `file://${script}`;
+ const scriptPath = `joplin-content://plugin-webview/${script}`;
const elementId = `joplin-script-${key}`;
if (addedScripts[elementId]) {
@@ -114,7 +151,7 @@ const webviewApi = {
if (!scripts) return;
for (let i = 0; i < scripts.length; i++) {
- const scriptPath = `file://${scripts[i]}`;
+ const scriptPath = `joplin-content://plugin-webview/${scripts[i]}`;
if (addedScripts[scriptPath]) continue;
addedScripts[scriptPath] = true;
@@ -123,6 +160,14 @@ const webviewApi = {
}
},
+ serializeForms: () => {
+ postMessage({
+ target: 'UserWebview',
+ message: 'serializedForms',
+ formData: serializeForms(document),
+ });
+ },
+
'postMessageService.response': (event) => {
const message = event.message;
const promise = webviewApiPromises_[message.responseId];
@@ -171,7 +216,33 @@ const webviewApi = {
window.requestAnimationFrame(() => {
// eslint-disable-next-line no-console
console.debug('UserWebViewIndex: calling isReady');
- window.postMessage({ target: 'UserWebview', message: 'ready' }, '*');
+ postMessage({ target: 'UserWebview', message: 'ready' });
+ });
+
+
+ const sendFormSubmit = () => {
+ postMessage({ target: 'UserWebview', message: 'form-submit' });
+ };
+ const sendDismiss = () => {
+ postMessage({ target: 'UserWebview', message: 'dismiss' });
+ };
+ document.addEventListener('submit', () => {
+ sendFormSubmit();
+ });
+ document.addEventListener('keydown', event => {
+ if (event.key === 'Enter' && event.target.tagName === 'INPUT' && event.target.type === 'text') {
+ sendFormSubmit();
+ } else if (event.key === 'Escape') {
+ sendDismiss();
+ }
+ });
+
+ watchElementSize(document.getElementById('joplin-plugin-content'), size => {
+ postMessage({
+ target: 'UserWebview',
+ message: 'updateContentSize',
+ size,
+ });
});
});
})();
diff --git a/packages/app-desktop/services/plugins/hooks/useContentSize.ts b/packages/app-desktop/services/plugins/hooks/useContentSize.ts
index 54382eb6e1..4a5156f6e9 100644
--- a/packages/app-desktop/services/plugins/hooks/useContentSize.ts
+++ b/packages/app-desktop/services/plugins/hooks/useContentSize.ts
@@ -1,4 +1,5 @@
-import { useEffect, useState } from 'react';
+import { RefObject, useState } from 'react';
+import useMessageHandler from './useMessageHandler';
interface Size {
width: number;
@@ -6,59 +7,29 @@ interface Size {
hash: string;
}
-// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
-export default function(frameWindow: any, htmlHash: string, minWidth: number, minHeight: number, fitToContent: boolean, isReady: boolean) {
+export default function(viewRef: RefObject, htmlHash: string, minWidth: number, minHeight: number) {
const [contentSize, setContentSize] = useState({
width: minWidth,
height: minHeight,
hash: '',
});
- function updateContentSize(hash: string) {
- if (!frameWindow) return;
-
- const rect = frameWindow.document.getElementById('joplin-plugin-content').getBoundingClientRect();
+ useMessageHandler(viewRef, event => {
+ if (event.data.message !== 'updateContentSize') return;
+ const rect = event.data.size;
let w = rect.width;
let h = rect.height;
if (w < minWidth) w = minWidth;
if (h < minHeight) h = minHeight;
- const newSize = { width: w, height: h, hash: hash };
+ const newSize = { width: w, height: h, hash: htmlHash };
setContentSize((current: Size) => {
- if (current.width === newSize.width && current.height === newSize.height && current.hash === hash) return current;
+ if (current.width === newSize.width && current.height === newSize.height && current.hash === htmlHash) return current;
return newSize;
});
- }
-
- useEffect(() => {
- updateContentSize(htmlHash);
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, [htmlHash]);
-
- useEffect(() => {
- if (!fitToContent || !isReady) return () => {};
-
- function onTick() {
- updateContentSize(htmlHash);
- }
-
- // The only reliable way to make sure that the iframe has the same dimensions
- // as its content is to poll the dimensions at regular intervals. Other methods
- // work most of the time but will fail in various edge cases. Most reliable way
- // is probably iframe-resizer package, but still with 40 unfixed bugs.
- //
- // Polling in our case is fine since this is only used when displaying plugin
- // dialogs, which should be short lived. updateContentSize() is also optimised
- // to do nothing when size hasn't changed.
- const updateFrameSizeIID = setInterval(onTick, 100);
-
- return () => {
- clearInterval(updateFrameSizeIID);
- };
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, [fitToContent, isReady, minWidth, minHeight, htmlHash]);
+ });
return contentSize;
}
diff --git a/packages/app-desktop/services/plugins/hooks/useFormData.ts b/packages/app-desktop/services/plugins/hooks/useFormData.ts
new file mode 100644
index 0000000000..337495b5a7
--- /dev/null
+++ b/packages/app-desktop/services/plugins/hooks/useFormData.ts
@@ -0,0 +1,39 @@
+import { RefObject, useMemo, useRef } from 'react';
+import { PostMessage } from '../types';
+import useMessageHandler from './useMessageHandler';
+
+type FormDataRecord = Record;
+type FormDataListener = (formData: FormDataRecord)=> void;
+
+const useFormData = (viewRef: RefObject, postMessage: PostMessage) => {
+ const formDataListenersRef = useRef([]);
+ useMessageHandler(viewRef, (event) => {
+ if (event.data.message === 'serializedForms') {
+ const formData = event.data.formData;
+ if (typeof formData !== 'object') {
+ throw new Error('Invalid formData result.');
+ }
+
+ const listeners = [...formDataListenersRef.current];
+ formDataListenersRef.current = [];
+ for (const listener of listeners) {
+ listener(formData);
+ }
+ }
+ });
+
+ return useMemo(() => {
+ return {
+ getFormData: () => {
+ return new Promise(resolve => {
+ postMessage('serializeForms', null);
+ formDataListenersRef.current.push((data) => {
+ resolve(data);
+ });
+ });
+ },
+ };
+ }, [postMessage]);
+};
+
+export default useFormData;
diff --git a/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts
index 8affdf09a2..a5c439a4d2 100644
--- a/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts
+++ b/packages/app-desktop/services/plugins/hooks/useHtmlLoader.ts
@@ -1,41 +1,31 @@
-import { useEffect, useState, useMemo } from 'react';
+import { useEffect, useState, useMemo, RefObject } from 'react';
+import useMessageHandler from './useMessageHandler';
const md5 = require('md5');
-// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
-export default function(frameWindow: any, isReady: boolean, postMessage: Function, html: string) {
+// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied, Old code before rule was applied
+export default function(viewRef: RefObject, isReady: boolean, postMessage: Function, html: string) {
const [loadedHtmlHash, setLoadedHtmlHash] = useState('');
const htmlHash = useMemo(() => {
return md5(html);
}, [html]);
- useEffect(() => {
- if (!frameWindow) return () => {};
+ useMessageHandler(viewRef, event => {
+ const data = event.data;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onMessage(event: any) {
- const data = event.data;
+ if (!data || data.target !== 'UserWebview') return;
- if (!data || data.target !== 'UserWebview') return;
+ // eslint-disable-next-line no-console
+ console.info('useHtmlLoader: message', data);
- // eslint-disable-next-line no-console
- console.info('useHtmlLoader: message', data);
-
- // We only update if the HTML that was loaded is the same as
- // the active one. Otherwise it means the content has been
- // changed between the moment it was set by the user and the
- // moment it was loaded in the view.
- if (data.message === 'htmlIsSet' && data.hash === htmlHash) {
- setLoadedHtmlHash(data.hash);
- }
+ // We only update if the HTML that was loaded is the same as
+ // the active one. Otherwise it means the content has been
+ // changed between the moment it was set by the user and the
+ // moment it was loaded in the view.
+ if (data.message === 'htmlIsSet' && data.hash === htmlHash) {
+ setLoadedHtmlHash(data.hash);
}
-
- frameWindow.addEventListener('message', onMessage);
-
- return () => {
- if (frameWindow.removeEventListener) frameWindow.removeEventListener('message', onMessage);
- };
- }, [frameWindow, htmlHash]);
+ });
useEffect(() => {
// eslint-disable-next-line no-console
diff --git a/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts b/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts
new file mode 100644
index 0000000000..7b38da0abd
--- /dev/null
+++ b/packages/app-desktop/services/plugins/hooks/useMessageHandler.ts
@@ -0,0 +1,27 @@
+import { RefObject, useEffect, useRef } from 'react';
+
+type OnMessage = (event: MessageEvent)=> void;
+
+const useMessageHandler = (viewRef: RefObject, onMessage: OnMessage) => {
+ const onMessageRef = useRef(onMessage);
+ onMessageRef.current = onMessage;
+
+ useEffect(() => {
+ function onMessage_(event: MessageEvent) {
+ if (event.source !== viewRef.current.contentWindow) {
+ return;
+ }
+
+ onMessageRef.current(event);
+ }
+
+ const containerWindow = (viewRef.current.getRootNode() as Document).defaultView;
+ containerWindow.addEventListener('message', onMessage_);
+
+ return () => {
+ containerWindow.removeEventListener('message', onMessage_);
+ };
+ }, [viewRef]);
+};
+
+export default useMessageHandler;
diff --git a/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts
index 37874827b8..4008fadb3f 100644
--- a/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts
+++ b/packages/app-desktop/services/plugins/hooks/useScriptLoader.ts
@@ -1,16 +1,23 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
+import bridge from '../../bridge';
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
export default function(postMessage: Function, isReady: boolean, scripts: string[], cssFilePath: string) {
- useEffect(() => {
- if (!isReady) return;
- postMessage('setScripts', { scripts: scripts });
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, [scripts, isReady]);
+ const protocolHandler = useMemo(() => {
+ return bridge().electronApp().getCustomProtocolHandler();
+ }, []);
useEffect(() => {
- if (!isReady || !cssFilePath) return;
+ if (!isReady) return () => {};
+ postMessage('setScripts', { scripts: scripts });
+ const { remove } = protocolHandler.allowReadAccessToFiles(scripts);
+ return remove;
+ }, [scripts, isReady, postMessage, protocolHandler]);
+
+ useEffect(() => {
+ if (!isReady || !cssFilePath) return () => {};
postMessage('setScript', { script: cssFilePath, key: 'themeCss' });
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, [isReady, cssFilePath]);
+ const { remove } = protocolHandler.allowReadAccessToFile(cssFilePath);
+ return remove;
+ }, [isReady, cssFilePath, postMessage, protocolHandler]);
}
diff --git a/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts
index 49a5b49198..d25eab8470 100644
--- a/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts
+++ b/packages/app-desktop/services/plugins/hooks/useSubmitHandler.ts
@@ -1,41 +1,14 @@
-import { useEffect } from 'react';
+import { RefObject } from 'react';
+import useMessageHandler from './useMessageHandler';
// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
-export default function(frameWindow: any, onSubmit: Function, onDismiss: Function, loadedHtmlHash: string) {
- const document = frameWindow && frameWindow.document ? frameWindow.document : null;
-
- useEffect(() => {
- if (!document) return () => {};
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onFormSubmit(event: any) {
- event.preventDefault();
- if (onSubmit) onSubmit();
+export default function(viewRef: RefObject, onSubmit: Function, onDismiss: Function) {
+ useMessageHandler(viewRef, event => {
+ const message = event.data?.message;
+ if (message === 'form-submit') {
+ onSubmit();
+ } else if (message === 'dismiss') {
+ onDismiss();
}
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onKeyDown(event: any) {
- if (event.key === 'Escape') {
- if (onDismiss) onDismiss();
- }
-
- if (event.key === 'Enter') {
- //
- // Disable enter key from submitting when a text area is in focus!
- // https://github.com/laurent22/joplin/issues/4766
- //
- if (document.activeElement.tagName !== 'TEXTAREA') {
- if (onSubmit) onSubmit();
- }
- }
- }
-
- document.addEventListener('submit', onFormSubmit);
- document.addEventListener('keydown', onKeyDown);
-
- return () => {
- if (document) document.removeEventListener('submit', onFormSubmit);
- if (document) document.removeEventListener('keydown', onKeyDown);
- };
- }, [document, loadedHtmlHash, onSubmit, onDismiss]);
+ });
}
diff --git a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts
index f3141d84cc..c28997fef3 100644
--- a/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts
+++ b/packages/app-desktop/services/plugins/hooks/useViewIsReady.ts
@@ -1,7 +1,7 @@
-import { useEffect, useState } from 'react';
+import { RefObject, useEffect, useState } from 'react';
+import useMessageHandler from './useMessageHandler';
-// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
-export default function useViewIsReady(viewRef: any) {
+export default function useViewIsReady(viewRef: RefObject) {
// Just checking if the iframe is ready is not sufficient because its content
// might not be ready (for example, IPC listeners might not be initialised).
// So we also listen to a custom "ready" message coming from the webview content
@@ -9,6 +9,18 @@ export default function useViewIsReady(viewRef: any) {
const [iframeReady, setIFrameReady] = useState(false);
const [iframeContentReady, setIFrameContentReady] = useState(false);
+ useMessageHandler(viewRef, event => {
+ const data = event.data;
+ if (!data || data.target !== 'UserWebview') return;
+
+ // eslint-disable-next-line no-console
+ console.debug('useViewIsReady: message', data);
+
+ if (data.message === 'ready') {
+ setIFrameContentReady(true);
+ }
+ });
+
useEffect(() => {
// eslint-disable-next-line no-console
console.debug('useViewIsReady ============== Setup Listeners');
@@ -19,20 +31,6 @@ export default function useViewIsReady(viewRef: any) {
setIFrameReady(true);
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onMessage(event: any) {
- const data = event.data;
-
- if (!data || data.target !== 'UserWebview') return;
-
- // eslint-disable-next-line no-console
- console.debug('useViewIsReady: message', data);
-
- if (data.message === 'ready') {
- setIFrameContentReady(true);
- }
- }
-
const iframeDocument = viewRef.current.contentWindow.document;
// eslint-disable-next-line no-console
@@ -42,20 +40,15 @@ export default function useViewIsReady(viewRef: any) {
onIFrameReady();
}
- viewRef.current.addEventListener('dom-ready', onIFrameReady);
- viewRef.current.addEventListener('load', onIFrameReady);
- viewRef.current.contentWindow.addEventListener('message', onMessage);
+ const view = viewRef.current;
+ view.addEventListener('dom-ready', onIFrameReady);
+ view.addEventListener('load', onIFrameReady);
return () => {
- if (viewRef.current) {
- viewRef.current.removeEventListener('dom-ready', onIFrameReady);
- viewRef.current.removeEventListener('load', onIFrameReady);
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- viewRef.current.contentWindow.removeEventListener('message', onMessage);
- }
+ view.removeEventListener('dom-ready', onIFrameReady);
+ view.removeEventListener('load', onIFrameReady);
};
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, []);
+ }, [viewRef]);
return iframeReady && iframeContentReady;
}
diff --git a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts
index aacf8758f5..779fc740bc 100644
--- a/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts
+++ b/packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.ts
@@ -1,8 +1,9 @@
import PostMessageService, { MessageResponse, ResponderComponentType } from '@joplin/lib/services/PostMessageService';
-import { useEffect } from 'react';
+import { RefObject, useEffect } from 'react';
-// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
-export default function(frameWindow: any, isReady: boolean, pluginId: string, viewId: string, windowId: string, postMessage: Function) {
+
+// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied, Old code before rule was applied
+export default function(webviewRef: RefObject, isReady: boolean, pluginId: string, viewId: string, windowId: string, postMessage: Function) {
useEffect(() => {
PostMessageService.instance().registerResponder(ResponderComponentType.UserWebview, viewId, windowId, (message: MessageResponse) => {
postMessage('postMessageService.response', { message });
@@ -15,12 +16,8 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
}, [viewId]);
useEffect(() => {
- if (!frameWindow) return () => {};
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onMessage_(event: any) {
-
- if (!event.data || !event.data.target) {
+ function onMessage_(event: MessageEvent) {
+ if (!event.data || event.source !== webviewRef.current.contentWindow) {
return;
}
@@ -38,11 +35,11 @@ export default function(frameWindow: any, isReady: boolean, pluginId: string, vi
}
}
- frameWindow.addEventListener('message', onMessage_);
+ const containerWindow = (webviewRef.current.getRootNode() as Document).defaultView;
+ containerWindow.addEventListener('message', onMessage_);
return () => {
- if (frameWindow?.removeEventListener) frameWindow.removeEventListener('message', onMessage_);
+ containerWindow.removeEventListener('message', onMessage_);
};
- // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
- }, [frameWindow, isReady, pluginId, windowId, viewId]);
+ }, [webviewRef, isReady, pluginId, windowId, viewId, postMessage]);
}
diff --git a/packages/app-desktop/services/plugins/types.ts b/packages/app-desktop/services/plugins/types.ts
new file mode 100644
index 0000000000..f7ed9fc593
--- /dev/null
+++ b/packages/app-desktop/services/plugins/types.ts
@@ -0,0 +1 @@
+export type PostMessage = (message: string, args: unknown)=> void;
diff --git a/packages/app-desktop/utils/customProtocols/constants.ts b/packages/app-desktop/utils/customProtocols/constants.ts
index 64ed9ee3e0..6a5853f54a 100644
--- a/packages/app-desktop/utils/customProtocols/constants.ts
+++ b/packages/app-desktop/utils/customProtocols/constants.ts
@@ -1,3 +1,2 @@
-
// eslint-disable-next-line import/prefer-default-export
export const contentProtocolName = 'joplin-content';
diff --git a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts
index a5a488df42..e066a635b6 100644
--- a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts
+++ b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.test.ts
@@ -19,7 +19,6 @@ jest.doMock('electron', () => {
};
});
-import Logger from '@joplin/utils/Logger';
import handleCustomProtocols from './handleCustomProtocols';
import { supportDir } from '@joplin/lib/testing/test-utils';
import { join } from 'path';
@@ -27,8 +26,7 @@ import { stat } from 'fs-extra';
import { toForwardSlashes } from '@joplin/utils/path';
const setUpProtocolHandler = () => {
- const logger = Logger.create('test-logger');
- const protocolHandler = handleCustomProtocols(logger);
+ const protocolHandler = handleCustomProtocols();
expect(handleProtocolMock).toHaveBeenCalledTimes(1);
@@ -56,9 +54,8 @@ const toAccessUrl = (path: string, { host = 'note-viewer' }: ExpectBlockedOption
const expectPathToBeBlocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
const url = toAccessUrl(filePath, options);
- await expect(
- async () => await onRequestListener(new Request(url)),
- ).rejects.toThrow(/Read access not granted for URL|Invalid or missing media access key|Media access denied/);
+ const response = await onRequestListener(new Request(url));
+ expect(response.status).toBe(403); // Forbidden
};
const expectPathToBeUnblocked = async (onRequestListener: ProtocolHandler, filePath: string, options?: ExpectBlockedOptions) => {
diff --git a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts
index 354be211ce..6bd1996a0a 100644
--- a/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts
+++ b/packages/app-desktop/utils/customProtocols/handleCustomProtocols.ts
@@ -3,7 +3,6 @@ import { dirname, resolve, normalize } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { contentProtocolName } from './constants';
import resolvePathWithinDir from '@joplin/lib/utils/resolvePathWithinDir';
-import { LoggerWrapper } from '@joplin/utils/Logger';
import * as fs from 'fs-extra';
import { createReadStream } from 'fs';
import { fromFilename } from '@joplin/lib/mime-utils';
@@ -17,6 +16,7 @@ export interface CustomProtocolHandler {
// note-viewer/ URLs
allowReadAccessToDirectory(path: string): void;
allowReadAccessToFile(path: string): AccessController;
+ allowReadAccessToFiles(paths: string[]): AccessController;
// file-media/ URLs
setMediaAccessEnabled(enabled: boolean): void;
@@ -124,6 +124,12 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
);
};
+const makeAccessDeniedResponse = (message: string) => {
+ return new Response(message, {
+ status: 403, // Forbidden
+ });
+};
+
// Creating a custom protocol allows us to isolate iframes by giving them
// different domain names from the main Joplin app.
//
@@ -134,10 +140,10 @@ const handleRangeRequest = async (request: Request, targetPath: string) => {
//
// TODO: Use Logger.create (doesn't work for now because Logger is only initialized
// in the main process.)
-const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler => {
- logger = {
- ...logger,
- debug: () => {},
+const handleCustomProtocols = (): CustomProtocolHandler => {
+ const logger = {
+ // Disabled for now
+ debug: (..._message: unknown[]) => {},
};
// Allow-listed files/directories for joplin-content://note-viewer/
@@ -155,14 +161,16 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
// See https://security.stackexchange.com/a/123723
if (pathname.startsWith('..')) {
- throw new Error(`Invalid URL (not absolute), ${request.url}`);
+ return new Response('Invalid URL (not absolute)', {
+ status: 400,
+ });
}
pathname = resolve(appBundleDirectory, pathname);
let canRead = false;
let mediaOnly = true;
- if (host === 'note-viewer') {
+ if (host === 'note-viewer' || host === 'plugin-webview') {
if (readableFiles.has(pathname)) {
canRead = true;
} else {
@@ -177,7 +185,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
mediaOnly = false;
} else if (host === 'file-media') {
if (!mediaAccessKey) {
- throw new Error('Media access denied. This must be enabled with .setMediaAccessEnabled');
+ return makeAccessDeniedResponse('Media access denied. This must be enabled with .setMediaAccessEnabled');
}
canRead = true;
@@ -185,14 +193,16 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
const accessKey = url.searchParams.get('access-key');
if (accessKey !== mediaAccessKey) {
- throw new Error(`Invalid or missing media access key (was ${accessKey}). An allow-listed ?access-key= parameter must be provided.`);
+ return makeAccessDeniedResponse('Invalid or missing media access key. An allow-listed ?access-key= parameter must be provided.');
}
} else {
- throw new Error(`Invalid URL ${request.url}`);
+ return new Response(`Invalid request URL (${request.url})`, {
+ status: 400,
+ });
}
if (!canRead) {
- throw new Error(`Read access not granted for URL ${request.url}`);
+ return makeAccessDeniedResponse(`Read access not granted for URL (${request.url})`);
}
const asFileUrl = pathToFileURL(pathname).toString();
@@ -214,7 +224,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
// This is an extra check to prevent loading text/html and arbitrary non-media content from the URL.
const contentType = response.headers.get('Content-Type');
if (!contentType || !contentType.match(/^(image|video|audio)\//)) {
- throw new Error(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
+ return makeAccessDeniedResponse(`Attempted to access non-media file from ${request.url}, which is media-only. Content type was ${contentType}.`);
}
}
@@ -222,7 +232,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
});
const appBundleDirectory = dirname(dirname(__dirname));
- return {
+ const result: CustomProtocolHandler = {
allowReadAccessToDirectory: (path: string) => {
path = resolve(appBundleDirectory, path);
logger.debug('protocol handler: Allow read access to directory', path);
@@ -250,6 +260,18 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
},
};
},
+ allowReadAccessToFiles: (paths: string[]) => {
+ const handles = paths.map(path => {
+ return result.allowReadAccessToFile(path);
+ });
+ return {
+ remove: () => {
+ for (const handle of handles) {
+ handle.remove();
+ }
+ },
+ };
+ },
setMediaAccessEnabled: (enabled: boolean) => {
if (enabled) {
mediaAccessKey ||= createSecureRandom();
@@ -263,6 +285,7 @@ const handleCustomProtocols = (logger: LoggerWrapper): CustomProtocolHandler =>
return mediaAccessKey || null;
},
};
+ return result;
};
export default handleCustomProtocols;
diff --git a/packages/app-mobile/components/Checkbox.tsx b/packages/app-mobile/components/Checkbox.tsx
index de7cc24628..d34383888b 100644
--- a/packages/app-mobile/components/Checkbox.tsx
+++ b/packages/app-mobile/components/Checkbox.tsx
@@ -1,11 +1,13 @@
import * as React from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
-import { TouchableHighlight, StyleSheet, TextStyle } from 'react-native';
+import { TouchableHighlight, StyleSheet, TextStyle, AccessibilityInfo } from 'react-native';
import Icon from './Icon';
+import { _ } from '@joplin/lib/locale';
interface Props {
checked: boolean;
accessibilityLabel?: string;
+ accessibilityHint?: string;
onChange?: (checked: boolean)=> void;
style?: TextStyle;
iconStyle?: TextStyle;
@@ -40,6 +42,15 @@ const Checkbox: React.FC = props => {
setChecked(checked => {
const newChecked = !checked;
props.onChange?.(newChecked);
+
+ // An .announceForAccessibility is necessary here because VoiceOver doesn't announce this
+ // change on its own, even though we change [accessibilityState].
+ if (newChecked) {
+ AccessibilityInfo.announceForAccessibility(_('Checked'));
+ } else {
+ AccessibilityInfo.announceForAccessibility(_('Unchecked'));
+ }
+
return newChecked;
});
}, [props.onChange]);
@@ -58,6 +69,7 @@ const Checkbox: React.FC = props => {
accessibilityRole="checkbox"
accessibilityState={accessibilityState}
accessibilityLabel={props.accessibilityLabel ?? ''}
+ accessibilityHint={props.accessibilityHint}
// Web requires aria-checked
aria-checked={checked}
>
diff --git a/packages/app-mobile/components/NoteItem.tsx b/packages/app-mobile/components/NoteItem.tsx
index d383e7744d..795212c19c 100644
--- a/packages/app-mobile/components/NoteItem.tsx
+++ b/packages/app-mobile/components/NoteItem.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { memo, useCallback, useMemo } from 'react';
import { connect } from 'react-redux';
-import { Text, View, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo, TouchableOpacity } from 'react-native';
+import { Text, StyleSheet, TextStyle, ViewStyle, AccessibilityInfo } from 'react-native';
import Checkbox from './Checkbox';
import Note from '@joplin/lib/models/Note';
import time from '@joplin/lib/time';
@@ -11,6 +11,7 @@ import { AppState } from '../utils/types';
import { Dispatch } from 'redux';
import { NoteEntity } from '@joplin/lib/services/database/types';
import useOnLongPressProps from '../utils/hooks/useOnLongPressProps';
+import MultiTouchableOpacity from './buttons/MultiTouchableOpacity';
interface Props {
dispatch: Dispatch;
@@ -31,18 +32,25 @@ const useStyles = (themeId: number) => {
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
alignItems: 'flex-start',
+ // backgroundColor: theme.backgroundColor,
+ };
+
+ const listItemPressable: ViewStyle = {
+ flexGrow: 1,
+ alignSelf: 'stretch',
+ };
+ const listItemPressableWithCheckbox: ViewStyle = {
+ ...listItemPressable,
+ paddingRight: theme.marginRight,
+ };
+ const listItemPressableWithoutCheckbox: ViewStyle = {
+ ...listItemPressable,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingTop: theme.itemMarginTop,
paddingBottom: theme.itemMarginBottom,
- // backgroundColor: theme.backgroundColor,
};
- const listItemWithCheckbox = { ...listItem };
- delete listItemWithCheckbox.paddingTop;
- delete listItemWithCheckbox.paddingBottom;
- delete listItemWithCheckbox.paddingLeft;
-
const listItemText: TextStyle = {
flex: 1,
color: theme.color,
@@ -62,7 +70,8 @@ const useStyles = (themeId: number) => {
listItem,
listItemText,
selectionWrapper,
- listItemWithCheckbox,
+ listItemPressableWithoutCheckbox,
+ listItemPressableWithCheckbox,
listItemTextWithCheckbox,
selectionWrapperSelected,
checkboxStyle: {
@@ -132,7 +141,6 @@ const NoteItemComponent: React.FC = memo(props => {
const checkboxChecked = !!Number(note.todo_completed);
const checkboxStyle = styles.checkboxStyle;
- const listItemStyle = isTodo ? styles.listItemWithCheckbox : styles.listItem;
const listItemTextStyle = isTodo ? styles.listItemTextWithCheckbox : styles.listItemText;
const opacityStyle = isTodo && checkboxChecked ? styles.checkedOpacityStyle : styles.uncheckedOpacityStyle;
const isSelected = props.noteSelectionEnabled && props.selectedNoteIds.includes(note.id);
@@ -140,41 +148,34 @@ const NoteItemComponent: React.FC = memo(props => {
const selectionWrapperStyle = isSelected ? styles.selectionWrapperSelected : styles.selectionWrapper;
const noteTitle = Note.displayTitle(note);
-
const selectDeselectLabel = isSelected ? _('Deselect') : _('Select');
const onLongPressProps = useOnLongPressProps({ onLongPress, actionDescription: selectDeselectLabel });
- const contextMenuProps = {
- // Web only.
- onContextMenu: onLongPressProps.onContextMenu,
+ const todoCheckbox = isTodo ? : null;
+
+ const pressableProps = {
+ style: isTodo ? styles.listItemPressableWithCheckbox : styles.listItemPressableWithoutCheckbox,
+ accessibilityHint: props.noteSelectionEnabled ? '' : _('Opens note'),
+ 'aria-pressed': props.noteSelectionEnabled ? isSelected : undefined,
+ accessibilityState: { selected: isSelected },
+ ...onLongPressProps,
};
return (
-
-
-
- {isTodo ? : null }
- {noteTitle}
-
-
-
+ {noteTitle}
+
);
});
diff --git a/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx b/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx
new file mode 100644
index 0000000000..6caeac37c2
--- /dev/null
+++ b/packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx
@@ -0,0 +1,68 @@
+import * as React from 'react';
+import { useCallback, useMemo, useRef } from 'react';
+import { Animated, StyleSheet, Pressable, ViewProps, PressableProps } from 'react-native';
+
+interface Props {
+ // Nodes that need to change opacity but shouldn't be included in the main touchable
+ beforePressable: React.ReactNode;
+ // Children of the main pressable
+ children: React.ReactNode;
+ onPress: ()=> void;
+
+ pressableProps?: PressableProps;
+ containerProps?: ViewProps;
+}
+
+// A TouchableOpacity that can contain multiple pressable items still within the region that
+// changes opacity
+const MultiTouchableOpacity: React.FC = props => {
+ // See https://blog.logrocket.com/react-native-touchable-vs-pressable-components/
+ // for more about animating Pressable buttons.
+ const fadeAnim = useRef(new Animated.Value(1)).current;
+
+ const animationDuration = 100; // ms
+ const onPressIn = useCallback(() => {
+ // Fade out.
+ Animated.timing(fadeAnim, {
+ toValue: 0.5,
+ duration: animationDuration,
+ useNativeDriver: true,
+ }).start();
+ }, [fadeAnim]);
+ const onPressOut = useCallback(() => {
+ // Fade in.
+ Animated.timing(fadeAnim, {
+ toValue: 1,
+ duration: animationDuration,
+ useNativeDriver: true,
+ }).start();
+ }, [fadeAnim]);
+
+ const button = (
+
+ {props.children}
+
+ );
+
+ const styles = useMemo(() => {
+ return StyleSheet.create({
+ container: { opacity: fadeAnim },
+ });
+ }, [fadeAnim]);
+
+ const containerProps = props.containerProps ?? {};
+ return (
+
+ {props.beforePressable}
+ {button}
+
+ );
+};
+
+export default MultiTouchableOpacity;
diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json
index 28194d2991..2254237367 100644
--- a/packages/app-mobile/package.json
+++ b/packages/app-mobile/package.json
@@ -92,7 +92,7 @@
"@babel/preset-env": "7.24.7",
"@babel/runtime": "7.24.7",
"@joplin/tools": "~3.3",
- "@js-draw/material-icons": "1.27.2",
+ "@js-draw/material-icons": "1.29.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@react-native/babel-preset": "0.74.86",
"@react-native/metro-config": "0.74.87",
@@ -118,7 +118,7 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jetifier": "2.0.0",
- "js-draw": "1.27.2",
+ "js-draw": "1.29.2",
"jsdom": "24.1.1",
"nodemon": "3.1.7",
"punycode": "2.3.1",
diff --git a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts
index 17d777a0bb..ee7e9a0f0a 100644
--- a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts
+++ b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.test.ts
@@ -64,4 +64,92 @@ describe('renumberSelectedLists', () => {
'# End',
].join('\n'));
});
+
+ it.each([
+ {
+ // Should handle the case where a single item is over-indented
+ before: [
+ '- This',
+ '- is',
+ '\t1. a',
+ '\t\t2. test',
+ '\t3. of',
+ '\t4. lists',
+ ].join('\n'),
+ after: [
+ '- This',
+ '- is',
+ '\t1. a',
+ '\t\t1. test',
+ '\t2. of',
+ '\t3. lists',
+ ].join('\n'),
+ },
+ {
+ // Should handle the case where multiple sublists need to be renumbered
+ before: [
+ '- This',
+ '- is',
+ '\t1. a',
+ '\t\t2. test',
+ '\t3. of',
+ '\t\t4. lists',
+ '\t\t5. lists',
+ '\t\t6. lists',
+ '\t7. lists',
+ '',
+ '',
+ '1. New list',
+ '\t3. Item',
+ ].join('\n'),
+ after: [
+ '- This',
+ '- is',
+ '\t1. a',
+ '\t\t1. test',
+ '\t2. of',
+ '\t\t1. lists',
+ '\t\t2. lists',
+ '\t\t3. lists',
+ '\t3. lists',
+ '',
+ '',
+ '1. New list',
+ '\t1. Item',
+ ].join('\n'),
+ },
+ {
+ before: [
+ '2. This',
+ '\t1. is',
+ '\t2. a',
+ '\t\t3. test',
+ '\t4. test',
+ '\t5. test',
+ '\t6. test',
+ ].join('\n'),
+ after: [
+ '2. This',
+ '\t1. is',
+ '\t2. a',
+ '\t\t1. test',
+ '\t3. test',
+ '\t4. test',
+ '\t5. test',
+ ].join('\n'),
+ },
+ ])('should handle nested lists (case %#)', async ({ before, after }) => {
+ const suffix = '\n\n# End';
+ before += suffix;
+ after += suffix;
+ const editor = await createTestEditor(
+ before,
+ EditorSelection.range(0, before.length - suffix.length),
+ ['OrderedList', 'ATXHeading1'],
+ );
+
+ editor.dispatch(renumberSelectedLists(editor.state));
+
+ expect(editor.state.doc.toString()).toBe(after);
+ });
});
diff --git a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts
index e67db80b3e..3ec3ca6260 100644
--- a/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts
+++ b/packages/editor/CodeMirror/markdown/utils/renumberSelectedLists.ts
@@ -49,11 +49,16 @@ const renumberSelectedLists = (state: EditorState): TransactionSpec => {
const indentation = match[1];
const indentationLen = tabsToSpaces(state, indentation).length;
- let targetIndentLen = tabsToSpaces(state, currentGroupIndentation).length;
- if (targetIndentLen < indentationLen) {
- listNumberStack.push({ nextListNumber, indentationLength: indentationLen });
+ let currentGroupIndentLength = tabsToSpaces(state, currentGroupIndentation).length;
+ const indentIncreased = indentationLen > currentGroupIndentLength;
+ const indentDecreased = indentationLen < currentGroupIndentLength;
+ if (indentIncreased) {
+ // Save the state of the previous group so that it can be restored later.
+ listNumberStack.push({
+ nextListNumber, indentationLength: currentGroupIndentLength,
+ });
nextListNumber = 1;
- } else if (targetIndentLen > indentationLen) {
+ } else if (indentDecreased) {
nextListNumber = parseInt(match[2], 10);
// Handle the case where we deindent multiple times. For example,
@@ -61,22 +66,20 @@ const renumberSelectedLists = (state: EditorState): TransactionSpec => {
// 1. test
// 1. test
// 2. test
- while (targetIndentLen > indentationLen) {
+ while (indentationLen < currentGroupIndentLength) {
const listNumberRecord = listNumberStack.pop();
if (!listNumberRecord) {
break;
} else {
- targetIndentLen = listNumberRecord.indentationLength;
+ currentGroupIndentLength = listNumberRecord.indentationLength;
nextListNumber = listNumberRecord.nextListNumber;
}
}
}
- if (targetIndentLen !== indentationLen) {
- currentGroupIndentation = indentation;
- }
+ currentGroupIndentation = indentation;
const from = line.to - filteredText.length;
const to = from + match[0].length;
diff --git a/packages/lib/commands/renderMarkup.ts b/packages/lib/commands/renderMarkup.ts
index a5cdb35a32..196d74fda9 100644
--- a/packages/lib/commands/renderMarkup.ts
+++ b/packages/lib/commands/renderMarkup.ts
@@ -1,6 +1,7 @@
import markupLanguageUtils from '../markupLanguageUtils';
import Setting from '../models/Setting';
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
+import shim from '../shim';
import { themeStyle } from '../theme';
import attachedResources from '../utils/attachedResources';
import { MarkupLanguage } from '@joplin/renderer';
@@ -12,8 +13,13 @@ export const declaration: CommandDeclaration = {
};
const getMarkupToHtml = () => {
+ // In the desktop app, resources accessed with file:// URLs can't be displayed in certain places (e.g. the note
+ // viewer and plugin WebViews). On mobile, however, joplin-content:// URLs don't work. As such, use different
+ // protocols on different platforms:
+ const protocol = shim.isElectron() ? 'joplin-content://note-viewer/' : 'file://';
+
return markupLanguageUtils.newMarkupToHtml({}, {
- resourceBaseUrl: `file://${Setting.value('resourceDir')}/`,
+ resourceBaseUrl: `${protocol}${Setting.value('resourceDir')}/`,
customCss: '',
});
};
diff --git a/packages/lib/services/plugins/api/JoplinWorkspace.ts b/packages/lib/services/plugins/api/JoplinWorkspace.ts
index 3b10a746ad..91c1565b84 100644
--- a/packages/lib/services/plugins/api/JoplinWorkspace.ts
+++ b/packages/lib/services/plugins/api/JoplinWorkspace.ts
@@ -197,4 +197,14 @@ export default class JoplinWorkspace {
return this.store.getState().selectedNoteIds.slice();
}
+ /**
+ * Gets the last hash (note section ID) from cross-note link targeting specific section.
+ * New hash is available after `onNoteSelectionChange()` is triggered.
+ * Example of cross-note link where `hello-world` is a hash: [Other Note Title](:/9bc9a5cb83f04554bf3fd3e41b4bb415#hello-world).
+ * Method returns empty value when a note was navigated with method other than cross-note link containing valid hash.
+ */
+ public async selectedNoteHash(): Promise {
+ return this.store.getState().selectedNoteHash;
+ }
+
}
diff --git a/packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.ts b/packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.ts
index 0f79c79c8b..ccb1f494c8 100644
--- a/packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.ts
+++ b/packages/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo.ts
@@ -8,6 +8,7 @@ const getDefaultPluginsInfo = (): DefaultPluginsInfo => {
settings: {
'path': `${Setting.value('homeDir')}`,
'createSubfolderPerProfile': true,
+ 'backupRetention': 7,
},
// Joplin Portable is more likely to run on a device with low write speeds
diff --git a/packages/onenote-converter/Cargo.lock b/packages/onenote-converter/Cargo.lock
index 5eb5a5a5ed..a613641fb7 100644
--- a/packages/onenote-converter/Cargo.lock
+++ b/packages/onenote-converter/Cargo.lock
@@ -356,9 +356,9 @@ checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346"
[[package]]
name = "log"
-version = "0.4.21"
+version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f"
[[package]]
name = "memchr"
diff --git a/readme/apps/rich_text_editor.md b/readme/apps/rich_text_editor.md
index 65954d130b..4a4239ace5 100644
--- a/readme/apps/rich_text_editor.md
+++ b/readme/apps/rich_text_editor.md
@@ -6,6 +6,8 @@ At its core, Joplin stores notes in [Markdown format](https://github.com/laurent
In some cases however, the extra markup format that appears in notes can be seen as a drawback. Bold text will `look **like this**` for example, and tables might not be particularly readable. For that reason, Joplin also features a Rich Text editor, which allows you to edit notes with a [WYSIWYG](https://en.wikipedia.org/wiki/WYSIWYG) editing experience. Bold text will "look **like this**" and tables will be more readable, among others.
+## Limitations
+
However **there is a catch**: in Joplin, notes, even when edited with this Rich Text editor, are **still Markdown** under the hood. This is generally a good thing, because it means you can switch at any time between Markdown and Rich Text editor, and the note is still readable. It is also good if you sync with the mobile application, which doesn't have a rich text editor. The catch is that since Markdown is used under the hood, it means the rich text editor has a number of limitations it inherits from that format:
- For a start, **most Markdown plugins will not be compatible**. If you open a Markdown note that makes use of such plugin in the Rich Text editor, it is likely you will lose the plugin special formatting. The only supported plugins are the "fenced" plugins - those that wrap a section of text in triple backticks (for example, KaTeX, Mermaid, etc. are working). You can see a plugin's compatibility on the Markdown config screen.
@@ -21,3 +23,24 @@ However **there is a catch**: in Joplin, notes, even when edited with this Rich
- All reference links (`[title][link-name]`) are converted to inline links (`[title](https://example.com)`) when Joplin saves changes from the Rich Text editor.
Those are the known limitations but if you notice any other issue not listed here, please let us know [in the forum](https://discourse.joplinapp.org/).
+
+## Markup autocompletion
+
+By default, the Rich Text Editor automatically replaces certain text patterns with formatted content. Replacements are applied after each pattern is typed.
+
+By default, the following patterns are replaced:
+
+- `**bold**`: Formats `bold` as **bold**.
+- `*italic*`: Formats `italic` as *italic*.
+- `==highlighted==`: Highlights `highlighted`.
+- `code`
: Formats `code` as inline code.
+- `$math$`: Auto-formats to inline math (using KaTeX math syntax). After rendering, equations can be edited by double-clicking or with the "edit" option in the right click menu.
+- `# Heading 1`: Creates a level 1 heading. The `#` should be at the start of the line.
+- `## Heading 2`: Creates a level 2 heading.
+- `## Heading 3`: Creates a level 3 heading.
+- `- List`: Creates a bulleted list.
+- `1. List`: Creates a numbered list.
+
+Most replacements require pressing the space or enter key after the closing formatting character. For example, typing `==test==` does not highlight "test", but pressing a space after the last `=` does.
+
+These replacements can be disabled in settings > note, using the "Auto-format Markdown" setting.
diff --git a/yarn.lock b/yarn.lock
index 64fbc962bf..5b7c21086f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8361,7 +8361,7 @@ __metadata:
"@joplin/renderer": ~3.3
"@joplin/tools": ~3.3
"@joplin/utils": ~3.3
- "@js-draw/material-icons": 1.27.2
+ "@js-draw/material-icons": 1.29.1
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.15
"@react-native-clipboard/clipboard": 1.14.3
"@react-native-community/datetimepicker": 8.2.0
@@ -8402,7 +8402,7 @@ __metadata:
jest: 29.7.0
jest-environment-jsdom: 29.7.0
jetifier: 2.0.0
- js-draw: 1.27.2
+ js-draw: 1.29.2
jsdom: 24.1.1
lodash: 4.17.21
md5: 2.3.0
@@ -9131,21 +9131,21 @@ __metadata:
languageName: node
linkType: hard
-"@js-draw/material-icons@npm:1.27.2":
- version: 1.27.2
- resolution: "@js-draw/material-icons@npm:1.27.2"
+"@js-draw/material-icons@npm:1.29.1":
+ version: 1.29.1
+ resolution: "@js-draw/material-icons@npm:1.29.1"
peerDependencies:
js-draw: ^1.0.1
- checksum: dcaac6c45d20df6542fe874e0cd6f7a40f77faaebe494f4eb45c6b1c3595223a01b36067549159341a18af018253ea5c1c99bd82c47060a8b33596263ae83026
+ checksum: e3de5520b4154228ab3d593ae06d7310f3e1a100e5f2507e8b8b317a51cc3566d907c252e2cc92b89274059e05b0a57eb69b2f27483cab45fcbc1ccae7bac0d2
languageName: node
linkType: hard
-"@js-draw/math@npm:^1.27.2":
- version: 1.27.2
- resolution: "@js-draw/math@npm:1.27.2"
+"@js-draw/math@npm:^1.29.2":
+ version: 1.29.2
+ resolution: "@js-draw/math@npm:1.29.2"
dependencies:
bezier-js: 6.1.3
- checksum: 021dce0af104890312cf4eeb8a645d89bb2eaf0d3cdc181cdfcb6bb7b90deeeae484f5bec06bc96ac1ebc4f83918eff9a63239d8165a493b3794fd36e92bd330
+ checksum: e8c1fa984a06d3f80351363c10c63d3f648df0de14a9a37f92cd4c1670dbecc1840cf9c837fdd7d436deb13648132288ef2bd8926d389100e0dc82ad2ba448cf
languageName: node
linkType: hard
@@ -31118,13 +31118,13 @@ __metadata:
languageName: node
linkType: hard
-"js-draw@npm:1.27.2":
- version: 1.27.2
- resolution: "js-draw@npm:1.27.2"
+"js-draw@npm:1.29.2":
+ version: 1.29.2
+ resolution: "js-draw@npm:1.29.2"
dependencies:
- "@js-draw/math": ^1.27.2
+ "@js-draw/math": ^1.29.2
"@melloware/coloris": 0.22.0
- checksum: 0dc5c1531ca7bd86dfe50817d00209d19836357fcdd58657cb83faa27a81d7f00327adcc5c668db17e026d7cec01c470a0da49d0163c2f83901bbcd3969adc74
+ checksum: 29937a44c048c3927f4851b8bac66ce71316ce36b2de2ab6bfa2d1a9c1f634f51a10126e3491a7de5559fc5af442822a6a66ebd892ac8d793923a9f9f958c8c9
languageName: node
linkType: hard