mirror of https://github.com/laurent22/joplin.git
Merge remote-tracking branch 'upstream/dev' into pr/desktop/fix-tinymce-crash-after-image-newline
commit
b13441a757
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 () => {};
|
||||
|
|
|
@ -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;
|
|
@ -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,
|
||||
|
|
|
@ -132,6 +132,7 @@ export interface NoteBodyEditorProps {
|
|||
onDrop: DropHandler;
|
||||
noteToolbarButtonInfos: ToolbarItem[];
|
||||
plugins: PluginStates;
|
||||
mathEnabled: boolean;
|
||||
fontSize: number;
|
||||
contentMaxWidth: number;
|
||||
isSafeMode: boolean;
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
|
@ -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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export type PostMessage = (message: string, args: unknown)=> void;
|
|
@ -1,3 +1,2 @@
|
|||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const contentProtocolName = 'joplin-content';
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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: '',
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 > note, using the "Auto-format Markdown" setting.
|
||||
|
|
30
yarn.lock
30
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
|
||||
|
||||
|
|
Loading…
Reference in New Issue