From bb513c83acd62b1bceee67b3497b6a0f3156d1dc Mon Sep 17 00:00:00 2001
From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Date: Mon, 27 Jan 2025 12:05:29 -0800
Subject: [PATCH] Desktop: Accessibility: Rich Text Editor: Make it possible to
edit code blocks with a keyboard or touchscreen (#11727)
---
.eslintignore | 4 +-
.gitignore | 4 +-
.../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 40 ++---
.../TinyMCE/utils/enableTextAreaTab.ts | 71 +++++++++
.../NoteBody/TinyMCE/utils/types.ts | 4 +
.../NoteBody/TinyMCE/utils/useContextMenu.ts | 62 +++++---
.../{openEditDialog.ts => useEditDialog.ts} | 141 ++++++++----------
.../utils/useEditDialogEventListeners.ts | 34 +++++
.../app-desktop/gui/NoteEditor/utils/types.ts | 3 +-
.../models/EditorCodeDialog.ts | 6 +
.../integration-tests/richTextEditor.spec.ts | 19 +++
11 files changed, 259 insertions(+), 129 deletions(-)
create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.ts
rename packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/{openEditDialog.ts => useEditDialog.ts} (52%)
create mode 100644 packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.ts
diff --git a/.eslintignore b/.eslintignore
index 51f997d19e..a93e5f4a19 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -250,13 +250,15 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVis
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
-packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
diff --git a/.gitignore b/.gitignore
index c71f0b5a24..950b8822c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -225,13 +225,15 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/utils/useRefocusOnVis
packages/app-desktop/gui/NoteEditor/NoteBody/PlainEditor/PlainEditor.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/styles/index.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/joplinCommandToTinyMceCommands.js
-packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/setupToolbarButtons.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.test.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.js
+packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useKeyboardRefocusHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.js
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
index 92c7924545..5e0ab8eb5e 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx
@@ -19,7 +19,6 @@ import { MarkupLanguage, MarkupToHtml } from '@joplin/renderer';
import BaseItem from '@joplin/lib/models/BaseItem';
import setupToolbarButtons from './utils/setupToolbarButtons';
import { plainTextToHtml } from '@joplin/lib/htmlUtils';
-import openEditDialog from './utils/openEditDialog';
import { themeStyle } from '@joplin/lib/theme';
import { loadScript } from '../../../utils/loadScript';
import bridge from '../../../../services/bridge';
@@ -42,6 +41,8 @@ import { hasProtocol } from '@joplin/utils/url';
import useTabIndenter from './utils/useTabIndenter';
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
import useDocument from '../../../hooks/useDocument';
+import useEditDialog from './utils/useEditDialog';
+import useEditDialogEventListeners from './utils/useEditDialogEventListeners';
const logger = Logger.create('TinyMCE');
@@ -72,14 +73,6 @@ function awfulInitHack(html: string): string {
return html === '
' ? '' : html;
}
-// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
-function findEditableContainer(node: any): any {
- while (node) {
- if (node.classList && node.classList.contains('joplin-editable')) return node;
- node = node.parentNode;
- }
- return null;
-}
let markupToHtml_ = new MarkupToHtml();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -130,19 +123,23 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
const { scrollToPercent } = useScroll({ editor, onScroll: props.onScroll });
- usePluginServiceRegistration(ref);
- useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml);
- useTabIndenter(editor, !props.tabMovesFocus);
- useKeyboardRefocusHandler(editor);
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- const dispatchDidUpdate = (editor: any) => {
+ const dispatchDidUpdate = useCallback((editor: Editor) => {
if (dispatchDidUpdateIID_) shim.clearTimeout(dispatchDidUpdateIID_);
dispatchDidUpdateIID_ = shim.setTimeout(() => {
dispatchDidUpdateIID_ = null;
if (editor && editor.getDoc()) editor.getDoc().dispatchEvent(new Event('joplin-noteDidUpdate'));
}, 10);
- };
+ }, []);
+
+ const editDialog = useEditDialog({ editor, markupToHtml, dispatchDidUpdate });
+ const editDialogRef = useRef(editDialog);
+ editDialogRef.current = editDialog;
+
+ useEditDialogEventListeners(editor, editDialog);
+ usePluginServiceRegistration(ref);
+ useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml, editDialog);
+ useTabIndenter(editor, !props.tabMovesFocus);
+ useKeyboardRefocusHandler(editor);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const insertResourcesIntoContent = useCallback(async (filePaths: string[] = null, options: any = null) => {
@@ -179,7 +176,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
props.onMessage({ channel: href });
}
}
- }, [editor, props.onMessage]);
+ }, [editor, props.onMessage, dispatchDidUpdate]);
useImperativeHandle(ref, () => {
return {
@@ -752,7 +749,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
tooltip: _('Code Block'),
icon: 'code-sample',
onAction: async function() {
- openEditDialog(editor, markupToHtml, dispatchDidUpdate, null);
+ editDialogRef.current.editNew();
},
});
@@ -819,11 +816,6 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
editor.addShortcut('Meta+Shift+9', '', () => editor.execCommand('InsertJoplinChecklist'));
// TODO: remove event on unmount?
- editor.on('DblClick', (event) => {
- const editable = findEditableContainer(event.target);
- if (editable) openEditDialog(editor, markupToHtml, dispatchDidUpdate, editable);
- });
-
editor.on('drop', (event) => {
// Prevent the message "Dropped file type is not supported" from showing up.
// It was added in TinyMCE 5.4 and doesn't apply since we do support
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.ts
new file mode 100644
index 0000000000..b38cce88bf
--- /dev/null
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/enableTextAreaTab.ts
@@ -0,0 +1,71 @@
+import Setting from '@joplin/lib/models/Setting';
+import { focus } from '@joplin/lib/utils/focusHandler';
+const taboverride = require('taboverride');
+
+export interface TextAreaTabHandler {
+ remove(): void;
+}
+
+const createTextAreaKeyListeners = () => {
+ let hasListeners = true;
+
+ // Selectively enable/disable taboverride based on settings -- remove taboverride
+ // when pressing tab if tab is expected to move focus.
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Tab') {
+ if (Setting.value('editor.tabMovesFocus')) {
+ taboverride.utils.removeListeners(event.currentTarget);
+ hasListeners = false;
+ } else {
+ // Prevent the default focus-changing behavior
+ event.preventDefault();
+ requestAnimationFrame(() => {
+ focus('openEditDialog::dialogTextArea_keyDown', event.target);
+ });
+ }
+ }
+ };
+
+ const onKeyUp = (event: KeyboardEvent) => {
+ if (event.key === 'Tab' && !hasListeners) {
+ taboverride.utils.addListeners(event.currentTarget);
+ hasListeners = true;
+ }
+ };
+
+ return { onKeyDown, onKeyUp };
+};
+
+// Allows pressing tab in a textarea to input an actual tab (instead of changing focus)
+// taboverride will take care of actually inserting the tab character, while the keydown
+// event listener will override the default behaviour, which is to focus the next field.
+const enableTextAreaTab = (textAreas: HTMLTextAreaElement[]): TextAreaTabHandler => {
+ type RemoveCallback = ()=> void;
+ const removeCallbacks: RemoveCallback[] = [];
+
+ for (const textArea of textAreas) {
+ const { onKeyDown, onKeyUp } = createTextAreaKeyListeners();
+ textArea.addEventListener('keydown', onKeyDown);
+ textArea.addEventListener('keyup', onKeyUp);
+
+ // Enable/disable taboverride **after** the listeners above.
+ // The custom keyup/keydown need to have higher precedence.
+ taboverride.set(textArea, true);
+
+ removeCallbacks.push(() => {
+ taboverride.set(textArea, false);
+ textArea.removeEventListener('keyup', onKeyUp);
+ textArea.removeEventListener('keydown', onKeyDown);
+ });
+ }
+
+ return {
+ remove: () => {
+ for (const callback of removeCallbacks) {
+ callback();
+ }
+ },
+ };
+};
+
+export default enableTextAreaTab;
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.ts
index 1bc27570b9..024e0b7e12 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.ts
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/types.ts
@@ -1,3 +1,5 @@
+import type { Editor } from 'tinymce';
+
// eslint-disable-next-line import/prefer-default-export
export enum TinyMceEditorEvents {
KeyUp = 'keyup',
@@ -14,3 +16,5 @@ export enum TinyMceEditorEvents {
ExecCommand = 'ExecCommand',
SetAttrib = 'SetAttrib',
}
+
+export type DispatchDidUpdateCallback = (editor: Editor)=> void;
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts
index b415f230ed..e62bfd27bc 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.ts
@@ -14,6 +14,9 @@ import Resource from '@joplin/lib/models/Resource';
import { TinyMceEditorEvents } from './types';
import { HtmlToMarkdownHandler, MarkupToHtmlHandler } from '../../../utils/types';
import { Editor } from 'tinymce';
+import { EditDialogControl } from './useEditDialog';
+import { Dispatch } from 'redux';
+import { _ } from '@joplin/lib/locale';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -52,23 +55,14 @@ interface ContextMenuActionOptions {
const contextMenuActionOptions: ContextMenuActionOptions = { current: null };
-// 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(editor: Editor, plugins: PluginStates, dispatch: Function, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler) {
+export default function(editor: Editor, plugins: PluginStates, dispatch: Dispatch, htmlToMd: HtmlToMarkdownHandler, mdToHtml: MarkupToHtmlHandler, editDialog: EditDialogControl) {
useEffect(() => {
if (!editor) return () => {};
const contextMenuItems = menuItems(dispatch, htmlToMd, mdToHtml);
const targetWindow = bridge().activeWindow();
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- function onContextMenu(event: ElectronEvent, params: any) {
- const element = contextMenuElement(editor, params.x, params.y);
- if (!element) return;
-
- event.preventDefault();
-
- const menu = new Menu();
-
+ const makeMainMenuItems = (element: Element) => {
let itemType: ContextMenuItemType = ContextMenuItemType.None;
let resourceId = '';
let linkToCopy = null;
@@ -103,29 +97,57 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Functio
mdToHtml,
};
+ const result = [];
for (const itemName in contextMenuItems) {
const item = contextMenuItems[itemName];
if (!item.isActive(itemType, contextMenuActionOptions.current)) continue;
- menu.append(new MenuItem({
+ result.push(new MenuItem({
label: item.label,
click: () => {
item.onAction(contextMenuActionOptions.current);
},
}));
}
+ return result;
+ };
+ const makeEditableMenuItems = (element: Element) => {
+ if (editDialog.isEditable(element)) {
+ return [
+ new MenuItem({
+ type: 'normal',
+ label: _('Edit'),
+ click: () => {
+ editDialog.editExisting(element);
+ },
+ }),
+ new MenuItem({ type: 'separator' }),
+ ];
+ }
+ return [];
+ };
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
+ function onContextMenu(event: ElectronEvent, params: any) {
+ const element = contextMenuElement(editor, params.x, params.y);
+ if (!element) return;
+
+ event.preventDefault();
+
+ const menu = new Menu();
+ const menuItems = [];
+
+ menuItems.push(...makeEditableMenuItems(element));
+ menuItems.push(...makeMainMenuItems(element));
const spellCheckerMenuItems = SpellCheckerService.instance().contextMenuItems(params.misspelledWord, params.dictionarySuggestions);
+ menuItems.push(...spellCheckerMenuItems);
+ menuItems.push(...menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu));
- for (const item of spellCheckerMenuItems) {
- menu.append(new MenuItem(item));
+ for (const item of menuItems) {
+ menu.append(item);
}
-
- for (const item of menuUtils.pluginContextMenuItems(plugins, MenuItemLocation.EditorContextMenu)) {
- menu.append(new MenuItem(item));
- }
-
menu.popup({ window: targetWindow });
}
@@ -136,5 +158,5 @@ export default function(editor: Editor, plugins: PluginStates, dispatch: Functio
targetWindow.webContents.off('context-menu', onContextMenu);
}
};
- }, [editor, plugins, dispatch, htmlToMd, mdToHtml]);
+ }, [editor, plugins, dispatch, htmlToMd, mdToHtml, editDialog]);
}
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.ts
similarity index 52%
rename from packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts
rename to packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.ts
index 844655db8a..a7bcebe490 100644
--- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialog.ts
@@ -1,89 +1,32 @@
+import { RefObject, useMemo } from 'react';
+import type { Editor } from 'tinymce';
+import { DispatchDidUpdateCallback, TinyMceEditorEvents } from './types';
+import { MarkupToHtmlHandler } from '../../../utils/types';
import { _ } from '@joplin/lib/locale';
+import enableTextAreaTab, { TextAreaTabHandler } from './enableTextAreaTab';
import { MarkupToHtml } from '@joplin/renderer';
-import { TinyMceEditorEvents } from './types';
-import { Editor } from 'tinymce';
-import Setting from '@joplin/lib/models/Setting';
-import { focus } from '@joplin/lib/utils/focusHandler';
-const taboverride = require('taboverride');
+
+interface Props {
+ editor: Editor;
+ markupToHtml: RefObject;
+ dispatchDidUpdate: DispatchDidUpdateCallback;
+}
+
+export interface EditDialogControl {
+ editNew: ()=> void;
+ editExisting: (elementInEditable: Node)=> void;
+ isEditable: (element: Node)=> boolean;
+}
interface SourceInfo {
openCharacters: string;
closeCharacters: string;
content: string;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
- node: any;
+ node: Element;
language: string;
}
-const createTextAreaKeyListeners = () => {
- let hasListeners = true;
-
- // Selectively enable/disable taboverride based on settings -- remove taboverride
- // when pressing tab if tab is expected to move focus.
- const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Tab') {
- if (Setting.value('editor.tabMovesFocus')) {
- taboverride.utils.removeListeners(event.currentTarget);
- hasListeners = false;
- } else {
- // Prevent the default focus-changing behavior
- event.preventDefault();
- requestAnimationFrame(() => {
- focus('openEditDialog::dialogTextArea_keyDown', event.target);
- });
- }
- }
- };
-
- const onKeyUp = (event: KeyboardEvent) => {
- if (event.key === 'Tab' && !hasListeners) {
- taboverride.utils.addListeners(event.currentTarget);
- hasListeners = true;
- }
- };
-
- return { onKeyDown, onKeyUp };
-};
-
-interface TextAreaTabHandler {
- remove(): void;
-}
-
-// Allows pressing tab in a textarea to input an actual tab (instead of changing focus)
-// taboverride will take care of actually inserting the tab character, while the keydown
-// event listener will override the default behaviour, which is to focus the next field.
-function enableTextAreaTab(document: Document): TextAreaTabHandler {
- type RemoveCallback = ()=> void;
- const removeCallbacks: RemoveCallback[] = [];
-
- const textAreas = document.querySelectorAll('.tox-textarea');
- for (const textArea of textAreas) {
- const { onKeyDown, onKeyUp } = createTextAreaKeyListeners();
- textArea.addEventListener('keydown', onKeyDown);
- textArea.addEventListener('keyup', onKeyUp);
-
- // Enable/disable taboverride **after** the listeners above.
- // The custom keyup/keydown need to have higher precedence.
- taboverride.set(textArea, true);
-
- removeCallbacks.push(() => {
- taboverride.set(textArea, false);
- textArea.removeEventListener('keyup', onKeyUp);
- textArea.removeEventListener('keydown', onKeyDown);
- });
- }
-
- return {
- remove: () => {
- for (const callback of removeCallbacks) {
- callback();
- }
- },
- };
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
-function findBlockSource(node: any): SourceInfo {
+function findBlockSource(node: Element): SourceInfo {
const sources = node.getElementsByClassName('joplin-source');
if (!sources.length) throw new Error('No source for node');
const source = sources[0];
@@ -127,11 +70,13 @@ function editableInnerHtml(html: string): string {
return editable[0].innerHTML;
}
-// 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 openEditDialog(editor: Editor, markupToHtml: any, dispatchDidUpdate: Function, editable: any) {
+function openEditDialog(
+ editor: Editor,
+ markupToHtml: RefObject,
+ dispatchDidUpdate: DispatchDidUpdateCallback,
+ editable: Element,
+) {
const source = editable ? findBlockSource(editable) : newBlockSource();
-
- const containerDocument = editor.getContainer().ownerDocument;
let tabHandler: TextAreaTabHandler|null = null;
editor.windowManager.open({
@@ -190,6 +135,40 @@ export default function openEditDialog(editor: Editor, markupToHtml: any, dispat
});
window.requestAnimationFrame(() => {
- tabHandler = enableTextAreaTab(containerDocument);
+ const containerDocument = editor.getContainer().ownerDocument;
+ const textAreas = containerDocument.querySelectorAll('.tox-textarea');
+ tabHandler = enableTextAreaTab([...textAreas]);
});
}
+
+const findEditableContainer = (node: Node) => {
+ if (node.nodeName.startsWith('#')) { // Not an element, e.g. #text
+ node = node.parentElement;
+ }
+ return (node as Element)?.closest('.joplin-editable');
+};
+
+const useEditDialog = ({
+ editor, markupToHtml, dispatchDidUpdate,
+}: Props): EditDialogControl => {
+ return useMemo(() => {
+ const edit = (editable: Element|null) => {
+ openEditDialog(editor, markupToHtml, dispatchDidUpdate, editable);
+ };
+
+ return {
+ isEditable: element => !!findEditableContainer(element),
+ editExisting: (element: Node) => {
+ const editable = findEditableContainer(element);
+ if (editable) {
+ edit(editable);
+ }
+ },
+ editNew: () => {
+ edit(null);
+ },
+ };
+ }, [editor, markupToHtml, dispatchDidUpdate]);
+};
+
+export default useEditDialog;
diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.ts
new file mode 100644
index 0000000000..ec729106b1
--- /dev/null
+++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useEditDialogEventListeners.ts
@@ -0,0 +1,34 @@
+import { Editor } from 'tinymce';
+import { EditDialogControl } from './useEditDialog';
+import { useEffect } from 'react';
+import { TinyMceEditorEvents } from './types';
+
+const useEditDialogEventListeners = (editor: Editor|null, editDialog: EditDialogControl) => {
+ useEffect(() => {
+ if (!editor) return () => {};
+
+ const dblClickHandler = (event: Event) => {
+ editDialog.editExisting(event.target as Node);
+ };
+
+ const keyDownHandler = (event: KeyboardEvent) => {
+ const hasModifiers = event.shiftKey || event.altKey || event.ctrlKey || event.metaKey;
+ if (event.code === 'Enter' && !event.isComposing && !hasModifiers) {
+ const selection = editor.selection.getNode();
+ if (editDialog.isEditable(selection)) {
+ editDialog.editExisting(selection);
+ event.preventDefault();
+ }
+ }
+ };
+
+ editor.on(TinyMceEditorEvents.KeyDown, keyDownHandler);
+ editor.on('DblClick', dblClickHandler);
+ return () => {
+ editor.off(TinyMceEditorEvents.KeyDown, keyDownHandler);
+ editor.off('DblClick', dblClickHandler);
+ };
+ }, [editor, editDialog]);
+};
+
+export default useEditDialogEventListeners;
diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts
index 8d01151fcc..ccd9cb684c 100644
--- a/packages/app-desktop/gui/NoteEditor/utils/types.ts
+++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts
@@ -110,8 +110,7 @@ export interface NoteBodyEditorProps {
htmlToMarkdown: HtmlToMarkdownHandler;
allAssets: (markupLanguage: MarkupLanguage, options: AllAssetsOptions)=> Promise;
disabled: boolean;
- // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
- dispatch: Function;
+ dispatch: Dispatch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteToolbar: any;
setLocalSearchResultCount(count: number): void;
diff --git a/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts b/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts
index c8e947650b..48a04be122 100644
--- a/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts
+++ b/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts
@@ -3,14 +3,20 @@ import { Locator, Page } from '@playwright/test';
export default class EditorCodeDialog {
private readonly dialog: Locator;
public readonly textArea: Locator;
+ public readonly okButton: Locator;
public constructor(page: Page) {
this.dialog = page.getByRole('dialog', { name: 'Edit' });
this.textArea = this.dialog.locator('textarea');
+ this.okButton = this.dialog.getByRole('button', { name: 'OK' });
}
public async waitFor() {
await this.dialog.waitFor();
await this.textArea.waitFor();
}
+
+ public async submit() {
+ await this.okButton.click();
+ }
}
diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts
index f36cc2a0dc..21402f03db 100644
--- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts
+++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts
@@ -144,6 +144,25 @@ test.describe('richTextEditor', () => {
await expect(editor.richTextEditor).toBeFocused();
});
+ test('double-clicking a code block should edit it', async ({ mainWindow }) => {
+ const mainScreen = await new MainScreen(mainWindow).setup();
+ await mainScreen.createNewNote('Testing code blocks');
+
+ const editor = mainScreen.noteEditor;
+ await editor.toggleEditorsButton.click();
+
+ // Make the code block
+ await editor.toggleCodeBlockButton.click();
+ const codeEditor = editor.richTextCodeEditor;
+ await codeEditor.textArea.fill('This is a test code block!');
+ await codeEditor.submit();
+
+ // Double-clicking the code block should open it
+ const renderedCode = editor.getRichTextFrameLocator().locator('pre.hljs', { hasText: 'This is a test code block!' });
+ await renderedCode.first().dblclick();
+ await codeEditor.waitFor();
+ });
+
test('disabling tab indentation should also disable it in code dialogs', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Testing code blocks');