diff --git a/.eslintignore b/.eslintignore index 2ea63469b..900887c04 100644 --- a/.eslintignore +++ b/.eslintignore @@ -299,6 +299,7 @@ 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/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/useWebViewApi.js packages/app-desktop/gui/NoteEditor/NoteEditor.js packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js diff --git a/.gitignore b/.gitignore index 0a45f28cf..90776906d 100644 --- a/.gitignore +++ b/.gitignore @@ -276,6 +276,7 @@ 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/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/useWebViewApi.js packages/app-desktop/gui/NoteEditor/NoteEditor.js packages/app-desktop/gui/NoteEditor/NoteTitle/NoteTitleBar.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx index 56e352698..a6553d62f 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx @@ -38,6 +38,7 @@ const md5 = require('md5'); const { clipboard } = require('electron'); const supportedLocales = require('./supportedLocales'); import { hasProtocol } from '@joplin/utils/url'; +import useTabIndenter from './utils/useTabIndenter'; const logger = Logger.create('TinyMCE'); @@ -128,6 +129,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { usePluginServiceRegistration(ref); useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml); + useTabIndenter(editor); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const dispatchDidUpdate = (editor: any) => { @@ -629,6 +631,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => { icons_url: 'gui/NoteEditor/NoteBody/TinyMCE/icons.js', plugins: 'noneditable link joplinLists hr searchreplace codesample table', noneditable_noneditable_class: 'joplin-editable', // Can be a regex too + iframe_aria_text: _('Rich Text editor. Press Escape then Tab to escape focus.'), // #p: Pad empty paragraphs with   to prevent them from being removed. // *[*]: Allow all elements and attributes -- we already filter in sanitize_html diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts new file mode 100644 index 000000000..46f4da8ce --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts @@ -0,0 +1,76 @@ +import { useEffect } from 'react'; +import type { Editor, EditorEvent } from 'tinymce'; + +const useTabIndenter = (editor: Editor) => { + useEffect(() => { + if (!editor) return () => {}; + + const canChangeIndentation = () => { + const selectionElement = editor.selection.getNode(); + // List items and tables have their own tab key handlers. + return !selectionElement.closest('li, table') && !editor.readonly; + }; + + const getSpacesBeforeSelectionRange = (maxLength: number) => { + const selectionRange = editor.selection.getRng(); + + let rangeStart = selectionRange.startOffset; + let outputRange = selectionRange.cloneRange(); + while (rangeStart >= 0) { + rangeStart--; + + const lastRange = outputRange.cloneRange(); + outputRange.setStart(outputRange.startContainer, Math.max(rangeStart, 0)); + const rangeContent = outputRange.toString(); + const isWhitespace = rangeContent.match(/^\s*$/); + if (!isWhitespace || rangeContent.length > maxLength) { + outputRange = lastRange; + break; + } + } + + return outputRange; + }; + + const indentLengthChars = 8; + let indentHtml = ''; + for (let i = 0; i < indentLengthChars; i++) { + indentHtml += ' '; + } + + let lastKeyWasEscape = false; + + const eventHandler = (event: EditorEvent) => { + if (!event.isDefaultPrevented() && event.key === 'Tab' && canChangeIndentation() && !lastKeyWasEscape) { + if (!event.shiftKey) { + editor.execCommand('mceInsertContent', false, indentHtml); + event.preventDefault(); + } else { + const selectionRange = editor.selection.getRng(); + if (selectionRange.collapsed) { + const spacesRange = getSpacesBeforeSelectionRange(indentLengthChars); + + const hasAtLeastOneSpace = spacesRange.toString().match(/^\s+$/); + if (hasAtLeastOneSpace) { + editor.selection.setRng(spacesRange); + editor.execCommand('Delete', false); + event.preventDefault(); + } + } + } + } else if (event.key === 'Escape' && !event.shiftKey && !event.altKey && !event.metaKey && !event.ctrlKey) { + // For accessibility, let Escape followed by tab escape the focus trap. + lastKeyWasEscape = true; + } else if (event.key !== 'Shift') { // Allows Esc->Shift+Tab to escape the focus trap. + lastKeyWasEscape = false; + } + }; + + editor.on('keydown', eventHandler); + return () => { + editor.off('keydown', eventHandler); + }; + }, [editor]); +}; + +export default useTabIndenter; diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts index 29f920e91..fe626e531 100644 --- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts +++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts @@ -82,5 +82,42 @@ test.describe('richTextEditor', () => { expect(await openPathResult).toContain(basename(pathToAttach)); }); + test('pressing Tab should indent', async ({ mainWindow }) => { + const mainScreen = new MainScreen(mainWindow); + await mainScreen.createNewNote('Testing tabs!'); + const editor = mainScreen.noteEditor; + + await editor.toggleEditorsButton.click(); + await editor.richTextEditor.click(); + + await mainWindow.keyboard.type('This is a'); + // Tab should add spaces + await mainWindow.keyboard.press('Tab'); + await mainWindow.keyboard.type('test.'); + + // Shift-tab should remove spaces + await mainWindow.keyboard.press('Tab'); + await mainWindow.keyboard.press('Tab'); + await mainWindow.keyboard.press('Shift+Tab'); + await mainWindow.keyboard.type('Test!'); + + // Escape then tab should move focus + await mainWindow.keyboard.press('Escape'); + await expect(editor.richTextEditor).toBeFocused(); + await mainWindow.keyboard.press('Tab'); + await expect(editor.richTextEditor).not.toBeFocused(); + + // After re-focusing the editor, Tab should indent again. + await mainWindow.keyboard.press('Shift+Tab'); + await expect(editor.richTextEditor).toBeFocused(); + await mainWindow.keyboard.type(' Another:'); + await mainWindow.keyboard.press('Tab'); + await mainWindow.keyboard.type('!'); + + // After switching back to the Markdown editor, + await expect(editor.toggleEditorsButton).not.toBeDisabled(); + await editor.toggleEditorsButton.click(); + await expect(editor.codeMirrorEditor).toHaveText('This is a test. Test! Another: !'); + }); });