From 9638cab9eaa825ad9ec665198b52b85896874245 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 03:46:55 -0700 Subject: [PATCH 01/12] Desktop: Rich Text Editor: Add KaTeX to supported auto-replacements (#12081) --- .eslintignore | 1 + .gitignore | 1 + packages/app-desktop/commands/index.ts | 8 +-- .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 49 ++++++++++++------- .../TinyMCE/utils/useTextPatternsLookup.ts | 40 +++++++++++++++ .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 5 +- .../app-desktop/gui/NoteEditor/utils/types.ts | 1 + readme/apps/rich_text_editor.md | 23 +++++++++ 8 files changed, 106 insertions(+), 22 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.ts diff --git a/.eslintignore b/.eslintignore index 24a0bd1a93..34c16a619e 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 diff --git a/.gitignore b/.gitignore index ab0fe5484a..f0e643e35d 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 diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 3161eceedd..d1801e1dac 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -6,10 +6,10 @@ import * as exportDeletionLog from './exportDeletionLog'; import * as exportFolders from './exportFolders'; import * as exportNotes from './exportNotes'; import * as focusElement from './focusElement'; -import * as openSecondaryAppInstance from './openSecondaryAppInstance'; -import * as openPrimaryAppInstance from './openPrimaryAppInstance'; import * as openNoteInNewWindow from './openNoteInNewWindow'; +import * as openPrimaryAppInstance from './openPrimaryAppInstance'; import * as openProfileDirectory from './openProfileDirectory'; +import * as openSecondaryAppInstance from './openSecondaryAppInstance'; import * as replaceMisspelling from './replaceMisspelling'; import * as restoreNoteRevision from './restoreNoteRevision'; import * as startExternalEditing from './startExternalEditing'; @@ -30,10 +30,10 @@ const index: any[] = [ exportFolders, exportNotes, focusElement, - openSecondaryAppInstance, - openPrimaryAppInstance, openNoteInNewWindow, + openPrimaryAppInstance, openProfileDirectory, + openSecondaryAppInstance, replaceMisspelling, restoreNoteRevision, startExternalEditing, diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 8d51ddc8ae..5a16f4b523 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -43,6 +43,7 @@ import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler'; import useDocument from '../../../hooks/useDocument'; import useEditDialog from './utils/useEditDialog'; import useEditDialogEventListeners from './utils/useEditDialogEventListeners'; +import useTextPatternsLookup from './utils/useTextPatternsLookup'; const logger = Logger.create('TinyMCE'); @@ -654,6 +655,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // Create and setup the editor // ----------------------------------------------------------------------------------------- + const textPatternsLookupRef = useTextPatternsLookup({ enabled: props.enableTextPatterns, enableMath: props.mathEnabled }); useEffect(() => { if (!scriptLoaded) return; if (!editorContainer) return; @@ -740,25 +742,38 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { // button to work. See https://github.com/tinymce/tinymce/issues/5026. forecolor: { inline: 'span', styles: { color: '%value' }, remove_similar: true }, }, - text_patterns: props.enableTextPatterns ? [ - // See https://www.tiny.cloud/docs/tinymce/latest/content-behavior-options/#text_patterns - // for the default value - { start: '==', end: '==', format: 'joplinHighlight' }, - { start: '`', end: '`', format: 'code' }, - { start: '*', end: '*', format: 'italic' }, - { start: '**', end: '**', format: 'bold' }, - { start: '#', format: 'h1' }, - { start: '##', format: 'h2' }, - { start: '###', format: 'h3' }, - { start: '####', format: 'h4' }, - { start: '#####', format: 'h5' }, - { start: '######', format: 'h6' }, - { start: '1.', cmd: 'InsertOrderedList' }, - { start: '*', cmd: 'InsertUnorderedList' }, - { start: '-', cmd: 'InsertUnorderedList' }, - ] : [], + text_patterns: [], + text_patterns_lookup: () => textPatternsLookupRef.current(), setup: (editor: Editor) => { + editor.addCommand('joplinMath', async () => { + const katex = editor.selection.getContent(); + const md = `$${katex}$`; + + // Save and clear the selection -- when this command is activated by a text pattern, + // TinyMCE: + // 1. Adjusts the selection just before calling the command to include the to-be-formatted text. + // 2. Calls the command. + // 3. Removes the "$" characters and restores the selection. + // + // As a result, the selection needs to be saved and restored. + const mathSelection = editor.selection.getBookmark(); + + const result = await markupToHtml.current(MarkupLanguage.Markdown, md, { bodyOnly: true }); + + // Replace the math... + const finalSelection = editor.selection.getBookmark(); + editor.selection.moveToBookmark(mathSelection); + editor.selection.setContent(result.html); + editor.selection.moveToBookmark(finalSelection); // ...then move the selection back. + + // Fire update events + editor.fire(TinyMceEditorEvents.JoplinChange); + dispatchDidUpdate(editor); + // The last replacement seems to need to be manually added to the undo history + editor.undoManager.add(); + }); + editor.addCommand('joplinAttach', () => { insertResourcesIntoContentRef.current(); }); diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.ts new file mode 100644 index 0000000000..9dc57701b2 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTextPatternsLookup.ts @@ -0,0 +1,40 @@ +import { useRef } from 'react'; + +interface TextPatternOptions { + enabled: boolean; + enableMath: boolean; +} + +const useTextPatternsLookup = ({ enabled, enableMath }: TextPatternOptions) => { + const getTextPatterns = () => { + if (!enabled) return []; + + return [ + // See https://www.tiny.cloud/docs/tinymce/latest/content-behavior-options/#text_patterns + // for the default TinyMCE text patterns + { start: '==', end: '==', format: 'joplinHighlight' }, + // Only replace math if math rendering is enabled. + enableMath && { start: '$', end: '$', cmd: 'joplinMath' }, + { start: '`', end: '`', format: 'code' }, + { start: '*', end: '*', format: 'italic' }, + { start: '**', end: '**', format: 'bold' }, + { start: '#', format: 'h1' }, + { start: '##', format: 'h2' }, + { start: '###', format: 'h3' }, + { start: '####', format: 'h4' }, + { start: '#####', format: 'h5' }, + { start: '######', format: 'h6' }, + { start: '1.', cmd: 'InsertOrderedList' }, + { start: '*', cmd: 'InsertUnorderedList' }, + { start: '-', cmd: 'InsertUnorderedList' }, + ].filter(pattern => !!pattern); + }; + + // Store the lookup callback in a ref so that the editor doesn't need to be reloaded + // to use the new patterns: + const patternLookupRef = useRef(getTextPatterns); + patternLookupRef.current = getTextPatterns; + return patternLookupRef; +}; + +export default useTextPatternsLookup; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 30f4a2f730..c139a591e4 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -423,6 +423,7 @@ function NoteEditorContent(props: NoteEditorProps) { const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords); + const markupLanguage = formNote.markup_language; const editorProps: NoteBodyEditorProps = { ref: editorRef, contentKey: formNote.id, @@ -432,7 +433,7 @@ function NoteEditorContent(props: NoteEditorProps) { onWillChange: onBodyWillChange, onMessage: onMessage, content: formNote.body, - contentMarkupLanguage: formNote.markup_language, + contentMarkupLanguage: markupLanguage, contentOriginalCss: formNote.originalCss, resourceInfos: resourceInfos, resourceDirectory: Setting.value('resourceDir'), @@ -457,6 +458,8 @@ function NoteEditorContent(props: NoteEditorProps) { onDrop: onDrop, noteToolbarButtonInfos: props.toolbarButtonInfos, plugins: props.plugins, + // KaTeX isn't supported in HTML notes + mathEnabled: markupLanguage === MarkupLanguage.Markdown && Setting.value('markdown.plugin.katex'), fontSize: Setting.value('style.editor.fontSize'), contentMaxWidth: props.contentMaxWidth, scrollbarSize: props.scrollbarSize, diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index e886c476bf..8a139226dc 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -132,6 +132,7 @@ export interface NoteBodyEditorProps { onDrop: DropHandler; noteToolbarButtonInfos: ToolbarItem[]; plugins: PluginStates; + mathEnabled: boolean; fontSize: number; contentMaxWidth: number; isSafeMode: boolean; 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. From 527627b8bb3c654ee9c630ac5ad37f2c8343d488 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 03:49:03 -0700 Subject: [PATCH 02/12] Desktop: Plugins: Prevent plugin dialogs, panels, and editors from accessing the main JavaScript context (#12083) --- .eslintignore | 3 + .gitignore | 3 + packages/app-desktop/ElectronAppWrapper.ts | 5 +- packages/app-desktop/app.ts | 3 - packages/app-desktop/bridge.ts | 9 +- .../services/plugins/UserWebview.tsx | 57 ++++--------- .../services/plugins/UserWebviewIndex.js | 83 +++++++++++++++++-- .../services/plugins/hooks/useContentSize.ts | 47 ++--------- .../services/plugins/hooks/useFormData.ts | 39 +++++++++ .../services/plugins/hooks/useHtmlLoader.ts | 42 ++++------ .../plugins/hooks/useMessageHandler.ts | 27 ++++++ .../services/plugins/hooks/useScriptLoader.ts | 25 ++++-- .../plugins/hooks/useSubmitHandler.ts | 47 +++-------- .../services/plugins/hooks/useViewIsReady.ts | 49 +++++------ .../hooks/useWebviewToPluginMessages.ts | 23 +++-- .../app-desktop/services/plugins/types.ts | 1 + .../utils/customProtocols/constants.ts | 1 - .../handleCustomProtocols.test.ts | 9 +- .../customProtocols/handleCustomProtocols.ts | 49 ++++++++--- packages/lib/commands/renderMarkup.ts | 8 +- 20 files changed, 300 insertions(+), 230 deletions(-) create mode 100644 packages/app-desktop/services/plugins/hooks/useFormData.ts create mode 100644 packages/app-desktop/services/plugins/hooks/useMessageHandler.ts create mode 100644 packages/app-desktop/services/plugins/types.ts diff --git a/.eslintignore b/.eslintignore index 34c16a619e..7a0702e09d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -555,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 diff --git a/.gitignore b/.gitignore index f0e643e35d..eb28468f58 100644 --- a/.gitignore +++ b/.gitignore @@ -530,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 diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index 078ed82d29..67257d6010 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -706,10 +706,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 +744,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/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..39e2d45388 --- /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(event.data.formData); + } + } + }); + + return useMemo(() => { + return { + getFormData: () => { + return new Promise(resolve => { + postMessage('getFormData', 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/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: '', }); }; From fd486e298adde9f806f39868c579f1eae4042230 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 04:12:26 -0700 Subject: [PATCH 03/12] Desktop: Rich Text Editor: Fix editor content not updated in some cases when switching notes (#12084) --- .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 9 ++++-- .../models/NoteEditorScreen.ts | 6 +++- .../integration-tests/richTextEditor.spec.ts | 28 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 5a16f4b523..10a3f7e629 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -1017,6 +1017,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { return true; } + const lastNoteIdRef = useRef(props.noteId); useEffect(() => { if (!editor) return () => {}; @@ -1030,7 +1031,10 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { const loadContent = async () => { const resourcesEqual = resourceInfosEqual(lastOnChangeEventInfo.current.resourceInfos, props.resourceInfos); - if (lastOnChangeEventInfo.current.content !== props.content || !resourcesEqual) { + // Use nextOnChangeEventInfo's noteId -- lastOnChangeEventInfo can be slightly out-of-date. + const differentNoteId = lastNoteIdRef.current !== props.noteId; + const differentContent = lastOnChangeEventInfo.current.content !== props.content; + if (differentNoteId || differentContent || !resourcesEqual) { const result = await props.markupToHtml( props.contentMarkupLanguage, props.content, @@ -1053,6 +1057,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { const offsetBookmarkId = 2; const bookmark = editor.selection.getBookmark(offsetBookmarkId); editor.setContent(awfulInitHack(result.html)); + lastNoteIdRef.current = props.noteId; if (lastOnChangeEventInfo.current.contentKey !== props.contentKey) { // Need to clear UndoManager to avoid this problem: @@ -1101,7 +1106,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { cancelled = true; }; // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - }, [editor, props.themeId, props.scrollbarSize, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey, props.contentMarkupLanguage, props.whiteBackgroundNoteRendering]); + }, [editor, props.noteId, props.themeId, props.scrollbarSize, props.markupToHtml, props.allAssets, props.content, props.resourceInfos, props.contentKey, props.contentMarkupLanguage, props.whiteBackgroundNoteRendering]); useEffect(() => { if (!editor) return () => {}; diff --git a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts index 4ffe0d2cc5..fed766a16a 100644 --- a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts +++ b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts @@ -80,7 +80,11 @@ export default class NoteEditorPage { // We use frameLocator(':scope') to convert the richTextEditor Locator into // a FrameLocator. (:scope selects the locator itself). // https://playwright.dev/docs/api/class-framelocator - return this.richTextEditor.frameLocator(':scope'); + return this.richTextEditor.contentFrame(); + } + + public getRichTextEditorBody() { + return this.richTextEditor.contentFrame().locator('body'); } public focusCodeMirrorEditor() { diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts index 21402f03db..9529907589 100644 --- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts +++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts @@ -212,5 +212,33 @@ test.describe('richTextEditor', () => { await expect(editor.noteTitleInput).not.toBeFocused(); await expect(editor.richTextEditor).toBeFocused(); }); + + test('note should have correct content even if opened quickly after last edit', async ({ mainWindow }) => { + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.createNewNote('Test 1'); + await mainScreen.createNewNote('Test 2'); + const test1Header = mainScreen.noteList.getNoteItemByTitle('Test 1'); + const test2Header = mainScreen.noteList.getNoteItemByTitle('Test 2'); + + const editor = mainScreen.noteEditor; + await editor.toggleEditorsButton.click(); + await editor.richTextEditor.waitFor(); + + const editorBody = editor.getRichTextEditorBody(); + const setEditorText = async (targetText: string) => { + await editorBody.pressSequentially(targetText); + await expect(editorBody).toHaveText(targetText); + }; + + await test1Header.click(); + await expect(editorBody).toHaveText(''); + await setEditorText('Test 1'); + + await test2Header.click(); + // Previously, after switching to note 2, the "Test 1" text would remain present in the + // editor. + await expect(editorBody).toHaveText(''); + }); + }); From 90f622b3e687678eb2fd968bd6c9aec4a5744eb8 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 04:12:34 -0700 Subject: [PATCH 04/12] Chore: Testing: Make Rich Text Editor attachment test more reliable (#12085) --- packages/app-desktop/integration-tests/richTextEditor.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts index 9529907589..d736aa0bc2 100644 --- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts +++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts @@ -62,6 +62,9 @@ test.describe('richTextEditor', () => { await setFilePickerResponse(electronApp, [pathToAttach]); await editor.attachFileButton.click(); + // Wait for it to render + await expect(editor.getNoteViewerFrameLocator().getByText('test-file.txt')).toBeVisible(); + // Switch to the RTE await editor.toggleEditorsButton.click(); await editor.richTextEditor.waitFor(); From 5876d57845c4c8c41ec3e0fec84d8d1a31a792e9 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 15:04:08 -0700 Subject: [PATCH 05/12] Chore: Desktop: Disable WebView tag in window config (#12086) --- packages/app-desktop/ElectronAppWrapper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index 67257d6010..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 // From a8b18e9ab09d8cd6ee481b1229dcd83cce090a6d Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sat, 12 Apr 2025 15:04:19 -0700 Subject: [PATCH 06/12] Mobile: Update to js-draw v1.29.2 (#12074) --- packages/app-mobile/package.json | 4 ++-- yarn.lock | 30 +++++++++++++++--------------- 2 files changed, 17 insertions(+), 17 deletions(-) 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/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 From 3d15e6476229bb3c684c5426e32e3ecd90daa625 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sun, 13 Apr 2025 11:28:56 -0700 Subject: [PATCH 07/12] Desktop: Fix returning form data from plugin dialogs (#12092) --- .../integration-tests/pluginApi.spec.ts | 21 ++++++++ .../resources/test-plugins/dialogs.js | 51 +++++++++++++++++++ .../services/plugins/UserWebviewDialog.tsx | 4 +- .../services/plugins/hooks/useFormData.ts | 4 +- 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 packages/app-desktop/integration-tests/resources/test-plugins/dialogs.js diff --git a/packages/app-desktop/integration-tests/pluginApi.spec.ts b/packages/app-desktop/integration-tests/pluginApi.spec.ts index 99b77b9ff7..adbaaa5027 100644 --- a/packages/app-desktop/integration-tests/pluginApi.spec.ts +++ b/packages/app-desktop/integration-tests/pluginApi.spec.ts @@ -23,6 +23,27 @@ test.describe('pluginApi', () => { await editor.expectToHaveText('PASS'); }); + test('should return form data from the dialog API', async ({ startAppWithPlugins }) => { + const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']); + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.createNewNote('First note'); + + const editor = mainScreen.noteEditor; + await editor.expectToHaveText(''); + + await mainScreen.goToAnything.runCommand(app, 'showTestDialog'); + // Wait for the iframe to load + const dialogContent = mainScreen.dialog.locator('iframe').contentFrame(); + await dialogContent.locator('form').waitFor(); + + // Submitting the dialog should include form data in the output + await mainScreen.dialog.getByRole('button', { name: 'Okay' }).click(); + await editor.expectToHaveText(JSON.stringify({ + id: 'ok', + hasFormData: true, + })); + }); + test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => { const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']); const mainScreen = await new MainScreen(mainWindow).setup(); diff --git a/packages/app-desktop/integration-tests/resources/test-plugins/dialogs.js b/packages/app-desktop/integration-tests/resources/test-plugins/dialogs.js new file mode 100644 index 0000000000..a6251c7776 --- /dev/null +++ b/packages/app-desktop/integration-tests/resources/test-plugins/dialogs.js @@ -0,0 +1,51 @@ +// Allows referencing the Joplin global: +/* eslint-disable no-undef */ + +// Allows the `joplin-manifest` block comment: +/* eslint-disable multiline-comment-style */ + +/* joplin-manifest: +{ + "id": "org.joplinapp.plugins.example.dialogs", + "manifest_version": 1, + "app_min_version": "3.1", + "name": "JS Bundle test", + "description": "JS Bundle Test plugin", + "version": "1.0.0", + "author": "", + "homepage_url": "https://joplinapp.org" +} +*/ + +joplin.plugins.register({ + onStart: async function() { + const dialogs = joplin.views.dialogs; + const dialogHandle = await dialogs.create('test-dialog'); + await dialogs.setHtml( + dialogHandle, + ` +
+ +
+ `, + ); + await dialogs.setButtons(dialogHandle, [ + { + id: 'ok', + title: 'Okay', + }, + ]); + await joplin.commands.register({ + name: 'showTestDialog', + label: 'showTestDialog', + iconName: 'fas fa-drum', + execute: async () => { + const result = await joplin.views.dialogs.open(dialogHandle); + await joplin.commands.execute('editor.setText', JSON.stringify({ + id: result.id, + hasFormData: !!result.formData, + })); + }, + }); + }, +}); 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/hooks/useFormData.ts b/packages/app-desktop/services/plugins/hooks/useFormData.ts index 39e2d45388..337495b5a7 100644 --- a/packages/app-desktop/services/plugins/hooks/useFormData.ts +++ b/packages/app-desktop/services/plugins/hooks/useFormData.ts @@ -17,7 +17,7 @@ const useFormData = (viewRef: RefObject, postMessage: PostMes const listeners = [...formDataListenersRef.current]; formDataListenersRef.current = []; for (const listener of listeners) { - listener(event.data.formData); + listener(formData); } } }); @@ -26,7 +26,7 @@ const useFormData = (viewRef: RefObject, postMessage: PostMes return { getFormData: () => { return new Promise(resolve => { - postMessage('getFormData', null); + postMessage('serializeForms', null); formDataListenersRef.current.push((data) => { resolve(data); }); From 5389e590571ad4e72f7f80b2a492a87fd532f0aa Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sun, 13 Apr 2025 11:29:18 -0700 Subject: [PATCH 08/12] Desktop,Mobile: Markdown Editor: Fix numbered sublist renumbering (#12091) --- .../utils/renumberSelectedLists.test.ts | 88 +++++++++++++++++++ .../markdown/utils/renumberSelectedLists.ts | 21 +++-- 2 files changed, 100 insertions(+), 9 deletions(-) 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; From cb3c9b46076cf63867f50476dd666c416c7381d7 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Mon, 14 Apr 2025 09:20:51 +0100 Subject: [PATCH 09/12] Desktop: Resolves #12095: By default keep 7 days of backup --- .../services/plugins/defaultPlugins/desktopDefaultPluginsInfo.ts | 1 + 1 file changed, 1 insertion(+) 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 From bd49f3b2805787577c372a3d84e08c2f78a6d720 Mon Sep 17 00:00:00 2001 From: Dan Serbyn Date: Mon, 14 Apr 2025 14:42:24 +0200 Subject: [PATCH 10/12] Plugins: expose hash from clicked cross-note link (#12094) --- packages/lib/services/plugins/api/JoplinWorkspace.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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; + } + } From 62ca6cb70be8d38574a8c7c3416fc10ba7f4a4ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 15:25:50 +0100 Subject: [PATCH 11/12] Update Rust crate log to v0.4.25 (#12100) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/onenote-converter/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 9871717de41e48850ccc9855a678a6e80a6bd466 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:28:35 -0700 Subject: [PATCH 12/12] iOS: Accessibility: Fix to-do completion status can't be changed from the note list (#12101) --- .eslintignore | 1 + .gitignore | 1 + packages/app-mobile/components/Checkbox.tsx | 14 +++- packages/app-mobile/components/NoteItem.tsx | 77 ++++++++++--------- .../buttons/MultiTouchableOpacity.tsx | 68 ++++++++++++++++ 5 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 packages/app-mobile/components/buttons/MultiTouchableOpacity.tsx diff --git a/.eslintignore b/.eslintignore index 7a0702e09d..7ffa660f61 100644 --- a/.eslintignore +++ b/.eslintignore @@ -711,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 eb28468f58..2a0b0a1620 100644 --- a/.gitignore +++ b/.gitignore @@ -686,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-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;