Merge remote-tracking branch 'upstream/dev' into pr/desktop/fix-tinymce-crash-after-image-newline

pull/12090/head
Henry Heino 2025-04-14 09:41:21 -07:00
commit b13441a757
41 changed files with 775 additions and 325 deletions

View File

@ -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

5
.gitignore vendored
View File

@ -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

View File

@ -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', () => {

View File

@ -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'));

View File

@ -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 <iframe>s that use custom protocols to
// have isSecureOrigin: false, limiting which browser APIs are available.
ipcMode: Sentry.IPCMode.Classic,
};
if (this.autoUploadCrashDumps_) options.dsn = 'https://cceec550871b1e8a10fee4c7a28d5cf2@o4506576757522432.ingest.sentry.io/4506594281783296';

View File

@ -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,

View File

@ -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();
});
@ -1002,6 +1017,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
return true;
}
const lastNoteIdRef = useRef(props.noteId);
useEffect(() => {
if (!editor) return () => {};
@ -1015,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,
@ -1047,6 +1066,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
preprocessHtml(result.html),
].join('\n');
editor.setContent(htmlAndCss);
lastNoteIdRef.current = props.noteId;
if (lastOnChangeEventInfo.current.contentKey !== props.contentKey) {
// Need to clear UndoManager to avoid this problem:
@ -1096,7 +1116,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 () => {};

View File

@ -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;

View File

@ -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,

View File

@ -132,6 +132,7 @@ export interface NoteBodyEditorProps {
onDrop: DropHandler;
noteToolbarButtonInfos: ToolbarItem[];
plugins: PluginStates;
mathEnabled: boolean;
fontSize: number;
contentMaxWidth: number;
isSafeMode: boolean;

View File

@ -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() {

View File

@ -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();

View File

@ -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,
`
<form name="main-form">
<label>Test: <input type="checkbox" name="test" checked/></label>
</form>
`,
);
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,
}));
},
});
},
});

View File

@ -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();
@ -250,5 +253,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('');
});
});

View File

@ -1,15 +1,16 @@
import * as React from 'react';
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext } from 'react';
import { useRef, useImperativeHandle, forwardRef, useEffect, useMemo, useContext, useCallback } from 'react';
import useViewIsReady from './hooks/useViewIsReady';
import useThemeCss from './hooks/useThemeCss';
import useContentSize from './hooks/useContentSize';
import useSubmitHandler from './hooks/useSubmitHandler';
import useHtmlLoader from './hooks/useHtmlLoader';
import useWebviewToPluginMessages from './hooks/useWebviewToPluginMessages';
import useScriptLoader from './hooks/useScriptLoader';
import Logger from '@joplin/utils/Logger';
import { focus } from '@joplin/lib/utils/focusHandler';
import { WindowIdContext } from '../../gui/NewWindowOrIFrame';
import useSubmitHandler from './hooks/useSubmitHandler';
import useFormData from './hooks/useFormData';
const logger = Logger.create('UserWebview');
@ -33,38 +34,12 @@ export interface Props {
onReady?: Function;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function serializeForm(form: any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any = {};
const formData = new FormData(form);
for (const key of formData.keys()) {
output[key] = formData.get(key);
}
return output;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function serializeForms(document: any) {
const forms = document.getElementsByTagName('form');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const output: any = {};
let untitledIndex = 0;
for (const form of forms) {
const name = `${form.getAttribute('name')}` || (`form${untitledIndex++}`);
output[name] = serializeForm(form);
}
return output;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function UserWebview(props: Props, ref: any) {
const minWidth = props.minWidth ? props.minWidth : 200;
const minHeight = props.minHeight ? props.minHeight : 20;
const viewRef = useRef(null);
const viewRef = useRef<HTMLIFrameElement>(null);
const isReady = useViewIsReady(viewRef);
const cssFilePath = useThemeCss({ pluginId: props.pluginId, themeId: props.themeId });
@ -78,21 +53,22 @@ function UserWebview(props: Props, ref: any) {
return viewRef.current.contentWindow;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function postMessage(name: string, args: any = null) {
const postMessage = useCallback((name: string, args: unknown = null) => {
const win = frameWindow();
if (!win) return;
logger.debug('Got message', name, args);
win.postMessage({ target: 'webview', name, args }, '*');
}
}, []);
const { getFormData } = useFormData(viewRef, postMessage);
useImperativeHandle(ref, () => {
return {
formData: function() {
if (viewRef.current) {
return serializeForms(frameWindow().document);
return getFormData();
} else {
return null;
}
@ -101,34 +77,31 @@ function UserWebview(props: Props, ref: any) {
if (viewRef.current) focus('UserWebView::focus', viewRef.current);
},
};
});
}, [getFormData]);
const htmlHash = useHtmlLoader(
frameWindow(),
viewRef,
isReady,
postMessage,
props.html,
);
const contentSize = useContentSize(
frameWindow(),
viewRef,
htmlHash,
minWidth,
minHeight,
props.fitToContent,
isReady,
);
useSubmitHandler(
frameWindow(),
viewRef,
props.onSubmit,
props.onDismiss,
htmlHash,
);
const windowId = useContext(WindowIdContext);
useWebviewToPluginMessages(
frameWindow(),
viewRef,
isReady,
props.pluginId,
props.viewId,
@ -153,7 +126,7 @@ function UserWebview(props: Props, ref: any) {
style={style}
className={`plugin-user-webview ${props.fitToContent ? '-fit-to-content' : ''} ${props.borderBottom ? '-border-bottom' : ''}`}
ref={viewRef}
src="services/plugins/UserWebviewIndex.html"
src={`joplin-content://plugin-webview/${__dirname}/UserWebviewIndex.html`}
></iframe>;
}

View File

@ -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);
},

View File

@ -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,
});
});
});
})();

View File

@ -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<HTMLIFrameElement>, htmlHash: string, minWidth: number, minHeight: number) {
const [contentSize, setContentSize] = useState<Size>({
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;
}

View File

@ -0,0 +1,39 @@
import { RefObject, useMemo, useRef } from 'react';
import { PostMessage } from '../types';
import useMessageHandler from './useMessageHandler';
type FormDataRecord = Record<string, unknown>;
type FormDataListener = (formData: FormDataRecord)=> void;
const useFormData = (viewRef: RefObject<HTMLIFrameElement>, postMessage: PostMessage) => {
const formDataListenersRef = useRef<FormDataListener[]>([]);
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<FormDataRecord>(resolve => {
postMessage('serializeForms', null);
formDataListenersRef.current.push((data) => {
resolve(data);
});
});
},
};
}, [postMessage]);
};
export default useFormData;

View File

@ -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<HTMLIFrameElement>, 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

View File

@ -0,0 +1,27 @@
import { RefObject, useEffect, useRef } from 'react';
type OnMessage = (event: MessageEvent)=> void;
const useMessageHandler = (viewRef: RefObject<HTMLIFrameElement>, 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;

View File

@ -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]);
}

View File

@ -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<HTMLIFrameElement>, 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]);
});
}

View File

@ -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<HTMLIFrameElement>) {
// 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;
}

View File

@ -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<HTMLIFrameElement>, 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]);
}

View File

@ -0,0 +1 @@
export type PostMessage = (message: string, args: unknown)=> void;

View File

@ -1,3 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const contentProtocolName = 'joplin-content';

View File

@ -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) => {

View File

@ -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;

View File

@ -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> = 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> = props => {
accessibilityRole="checkbox"
accessibilityState={accessibilityState}
accessibilityLabel={props.accessibilityLabel ?? ''}
accessibilityHint={props.accessibilityHint}
// Web requires aria-checked
aria-checked={checked}
>

View File

@ -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<Props> = 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<Props> = 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 ? <Checkbox
style={checkboxStyle}
checked={checkboxChecked}
onChange={todoCheckbox_change}
accessibilityLabel={_('to-do: %s', noteTitle)}
/> : 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 (
<View
// context menu listeners need to be added to a parent view of the
// TouchableOpacity -- on web, TouchableOpacity registers a custom
// onContextMenu handler that can't be overridden.
{...contextMenuProps}
<MultiTouchableOpacity
containerProps={{
style: [selectionWrapperStyle, opacityStyle, styles.listItem],
}}
pressableProps={pressableProps}
onPress={onPress}
beforePressable={todoCheckbox}
>
<TouchableOpacity
activeOpacity={0.5}
onPress={onPress}
accessibilityRole='button'
accessibilityHint={props.noteSelectionEnabled ? '' : _('Opens note')}
aria-pressed={props.noteSelectionEnabled ? isSelected : undefined}
accessibilityState={{ selected: isSelected }}
{...onLongPressProps}
>
<View style={[selectionWrapperStyle, opacityStyle, listItemStyle]}>
{isTodo ? <Checkbox
style={checkboxStyle}
checked={checkboxChecked}
onChange={todoCheckbox_change}
accessibilityLabel={_('to-do: %s', noteTitle)}
/> : null }
<Text style={listItemTextStyle}>{noteTitle}</Text>
</View>
</TouchableOpacity>
</View>
<Text style={listItemTextStyle}>{noteTitle}</Text>
</MultiTouchableOpacity>
);
});

View File

@ -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> = 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 = (
<Pressable
accessibilityRole='button'
{...props.pressableProps}
onPress={props.onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
>
{props.children}
</Pressable>
);
const styles = useMemo(() => {
return StyleSheet.create({
container: { opacity: fadeAnim },
});
}, [fadeAnim]);
const containerProps = props.containerProps ?? {};
return (
<Animated.View {...containerProps} style={[styles.container, props.containerProps.style]}>
{props.beforePressable}
{button}
</Animated.View>
);
};
export default MultiTouchableOpacity;

View File

@ -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",

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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: '',
});
};

View File

@ -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<string> {
return this.store.getState().selectedNoteHash;
}
}

View File

@ -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

View File

@ -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"

View File

@ -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>`code`</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 <kbd>space</kbd> or <kbd>enter</kbd> 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 &gt; note, using the "Auto-format Markdown" setting.

View File

@ -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