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