From 662185816d92ade2e2d78089351a6bdd5d8ce1a3 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:34:58 -0800 Subject: [PATCH] Desktop: Accessibility: Allow toggling between tab navigation and indentation (#11717) --- .eslintignore | 3 + .gitignore | 3 + packages/app-desktop/commands/index.ts | 2 + .../commands/toggleTabMovesFocus.ts | 20 +++++ packages/app-desktop/gui/MenuBar.tsx | 10 +++ .../NoteBody/CodeMirror/v6/CodeMirror.tsx | 2 + .../NoteEditor/NoteBody/TinyMCE/TinyMCE.tsx | 2 +- .../NoteBody/TinyMCE/utils/openEditDialog.ts | 86 ++++++++++++++---- .../NoteBody/TinyMCE/utils/useTabIndenter.ts | 6 +- .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 32 ++----- .../app-desktop/gui/NoteEditor/StatusBar.tsx | 88 +++++++++++++++++++ .../app-desktop/gui/NoteEditor/style.scss | 3 + .../NoteEditor/styles/editor-status-bar.scss | 13 +++ .../styles/editor-status-indicator.scss | 18 ++++ .../gui/NoteEditor/styles/tag-bar.scss | 14 +++ .../app-desktop/gui/NoteEditor/utils/types.ts | 2 + packages/app-desktop/gui/menuCommandNames.ts | 1 + .../models/EditorCodeDialog.ts | 16 ++++ .../models/NoteEditorScreen.ts | 28 +++++- .../integration-tests/richTextEditor.spec.ts | 50 +++++++++++ .../components/NoteEditor/NoteEditor.tsx | 2 + packages/editor/CodeMirror/createEditor.ts | 8 ++ .../testUtil/createEditorSettings.ts | 1 + packages/editor/types.ts | 1 + .../lib/models/settings/builtInMetadata.ts | 10 +++ packages/lib/services/KeymapService.ts | 9 ++ packages/tools/cspell/dictionary4.txt | 1 + 27 files changed, 382 insertions(+), 49 deletions(-) create mode 100644 packages/app-desktop/commands/toggleTabMovesFocus.ts create mode 100644 packages/app-desktop/gui/NoteEditor/StatusBar.tsx create mode 100644 packages/app-desktop/gui/NoteEditor/styles/editor-status-bar.scss create mode 100644 packages/app-desktop/gui/NoteEditor/styles/editor-status-indicator.scss create mode 100644 packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss create mode 100644 packages/app-desktop/integration-tests/models/EditorCodeDialog.ts diff --git a/.eslintignore b/.eslintignore index 6f4b65cfa3..51f997d19e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -170,6 +170,7 @@ packages/app-desktop/commands/switchProfile2.js packages/app-desktop/commands/switchProfile3.js packages/app-desktop/commands/toggleExternalEditing.js packages/app-desktop/commands/toggleSafeMode.js +packages/app-desktop/commands/toggleTabMovesFocus.js packages/app-desktop/gui/Button/Button.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js @@ -263,6 +264,7 @@ 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 +packages/app-desktop/gui/NoteEditor/StatusBar.js packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js @@ -502,6 +504,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js packages/app-desktop/integration-tests/main.spec.js packages/app-desktop/integration-tests/markdownEditor.spec.js packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js +packages/app-desktop/integration-tests/models/EditorCodeDialog.js packages/app-desktop/integration-tests/models/GoToAnything.js packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js diff --git a/.gitignore b/.gitignore index 5aeb7099fe..c71f0b5a24 100644 --- a/.gitignore +++ b/.gitignore @@ -145,6 +145,7 @@ packages/app-desktop/commands/switchProfile2.js packages/app-desktop/commands/switchProfile3.js packages/app-desktop/commands/toggleExternalEditing.js packages/app-desktop/commands/toggleSafeMode.js +packages/app-desktop/commands/toggleTabMovesFocus.js packages/app-desktop/gui/Button/Button.js packages/app-desktop/gui/ClipperConfigScreen.js packages/app-desktop/gui/ConfigScreen/ButtonBar.js @@ -238,6 +239,7 @@ 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 +packages/app-desktop/gui/NoteEditor/StatusBar.js packages/app-desktop/gui/NoteEditor/WarningBanner/BannerContent.js packages/app-desktop/gui/NoteEditor/WarningBanner/WarningBanner.js packages/app-desktop/gui/NoteEditor/commands/focusElementNoteBody.js @@ -477,6 +479,7 @@ packages/app-desktop/integration-tests/goToAnything.spec.js packages/app-desktop/integration-tests/main.spec.js packages/app-desktop/integration-tests/markdownEditor.spec.js packages/app-desktop/integration-tests/models/ChangeAppLayoutScreen.js +packages/app-desktop/integration-tests/models/EditorCodeDialog.js packages/app-desktop/integration-tests/models/GoToAnything.js packages/app-desktop/integration-tests/models/MainScreen.js packages/app-desktop/integration-tests/models/NoteEditorScreen.js diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 60d3a94394..d1a5aff332 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -18,6 +18,7 @@ import * as switchProfile2 from './switchProfile2'; import * as switchProfile3 from './switchProfile3'; import * as toggleExternalEditing from './toggleExternalEditing'; import * as toggleSafeMode from './toggleSafeMode'; +import * as toggleTabMovesFocus from './toggleTabMovesFocus'; const index: any[] = [ copyDevCommand, @@ -39,6 +40,7 @@ const index: any[] = [ switchProfile3, toggleExternalEditing, toggleSafeMode, + toggleTabMovesFocus, ]; export default index; diff --git a/packages/app-desktop/commands/toggleTabMovesFocus.ts b/packages/app-desktop/commands/toggleTabMovesFocus.ts new file mode 100644 index 0000000000..d82a792dbb --- /dev/null +++ b/packages/app-desktop/commands/toggleTabMovesFocus.ts @@ -0,0 +1,20 @@ +import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import { DesktopCommandContext } from '../services/commands/types'; +import Setting from '@joplin/lib/models/Setting'; + +export const declaration: CommandDeclaration = { + name: 'toggleTabMovesFocus', + label: () => _('Toggle editor tab key navigation'), + iconName: 'fas fa-keyboard', +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (_context: DesktopCommandContext, enabled: boolean = null) => { + const newValue = enabled ?? !Setting.value('editor.tabMovesFocus'); + Setting.setValue('editor.tabMovesFocus', newValue); + }, + enabledCondition: 'oneNoteSelected', + }; +}; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 9068dfbe22..9d58e87803 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -165,6 +165,7 @@ interface Props { showNoteCounts: boolean; uncompletedTodosOnTop: boolean; showCompletedTodos: boolean; + tabMovesFocus: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied pluginMenuItems: any[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -256,6 +257,7 @@ function useMenuStates(menu: any, props: Props) { menuItemSetChecked('showNoteCounts', props.showNoteCounts); menuItemSetChecked('uncompletedTodosOnTop', props.uncompletedTodosOnTop); menuItemSetChecked('showCompletedTodos', props.showCompletedTodos); + menuItemSetChecked('toggleTabMovesFocus', props.tabMovesFocus); } timeoutId = setTimeout(scheduleUpdate, 150); @@ -276,6 +278,7 @@ function useMenuStates(menu: any, props: Props) { props['notes.sortOrder.reverse'], // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied props['folders.sortOrder.reverse'], + props.tabMovesFocus, props.noteListRendererId, props.showNoteCounts, props.uncompletedTodosOnTop, @@ -824,6 +827,12 @@ function useMenu(props: Props) { }, }, separator(), + { + ...menuItemDic['toggleTabMovesFocus'], + label: Setting.settingMetadata('editor.tabMovesFocus').label(), + type: 'checkbox', + }, + separator(), { label: _('Actual Size'), click: () => { @@ -1145,6 +1154,7 @@ const mapStateToProps = (state: AppState): Partial => { ['folders.sortOrder.field']: state.settings['folders.sortOrder.field'], ['notes.sortOrder.reverse']: state.settings['notes.sortOrder.reverse'], ['folders.sortOrder.reverse']: state.settings['folders.sortOrder.reverse'], + tabMovesFocus: state.settings['editor.tabMovesFocus'], pluginSettings: state.settings['plugins.states'], showNoteCounts: state.settings.showNoteCounts, uncompletedTodosOnTop: state.settings.uncompletedTodosOnTop, diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx index 15a49345bd..a20a0343a2 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -372,10 +372,12 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef { usePluginServiceRegistration(ref); useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml); - useTabIndenter(editor); + useTabIndenter(editor, !props.tabMovesFocus); useKeyboardRefocusHandler(editor); // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts index 7e50f7cf36..844655db8a 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/openEditDialog.ts @@ -1,6 +1,8 @@ import { _ } from '@joplin/lib/locale'; 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'); @@ -13,27 +15,71 @@ interface SourceInfo { language: string; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -function dialogTextArea_keyDown(event: any) { - if (event.key === 'Tab') { - window.requestAnimationFrame(() => focus('openEditDialog::dialogTextArea_keyDown', event.target)); - } +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(enable: boolean) { - const textAreas = document.getElementsByClassName('tox-textarea'); - for (const textArea of textAreas) { - taboverride.set(textArea, enable); +function enableTextAreaTab(document: Document): TextAreaTabHandler { + type RemoveCallback = ()=> void; + const removeCallbacks: RemoveCallback[] = []; - if (enable) { - textArea.addEventListener('keydown', dialogTextArea_keyDown); - } else { - textArea.removeEventListener('keydown', dialogTextArea_keyDown); - } + 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 @@ -82,9 +128,12 @@ function editableInnerHtml(html: string): string { } // 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: any, markupToHtml: any, dispatchDidUpdate: Function, editable: any) { +export default function openEditDialog(editor: Editor, markupToHtml: any, dispatchDidUpdate: Function, editable: any) { const source = editable ? findBlockSource(editable) : newBlockSource(); + const containerDocument = editor.getContainer().ownerDocument; + let tabHandler: TextAreaTabHandler|null = null; + editor.windowManager.open({ title: _('Edit'), size: 'large', @@ -113,7 +162,7 @@ export default function openEditDialog(editor: any, markupToHtml: any, dispatchD dispatchDidUpdate(editor); }, onClose: () => { - enableTextAreaTab(false); + tabHandler?.remove(); }, body: { type: 'panel', @@ -124,12 +173,11 @@ export default function openEditDialog(editor: any, markupToHtml: any, dispatchD label: 'Language', // Katex is a special case with special opening/closing tags // and we don't currently handle switching the language in this case. - disabled: source.language === 'katex', + enabled: source.language !== 'katex', }, { type: 'textarea', name: 'codeTextArea', - value: source.content, }, ], }, @@ -142,6 +190,6 @@ export default function openEditDialog(editor: any, markupToHtml: any, dispatchD }); window.requestAnimationFrame(() => { - enableTextAreaTab(true); + tabHandler = enableTextAreaTab(containerDocument); }); } diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts index 46f4da8ce0..9fe94d805c 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useTabIndenter.ts @@ -1,9 +1,9 @@ import { useEffect } from 'react'; import type { Editor, EditorEvent } from 'tinymce'; -const useTabIndenter = (editor: Editor) => { +const useTabIndenter = (editor: Editor, enabled: boolean) => { useEffect(() => { - if (!editor) return () => {}; + if (!editor || !enabled) return () => {}; const canChangeIndentation = () => { const selectionElement = editor.selection.getNode(); @@ -70,7 +70,7 @@ const useTabIndenter = (editor: Editor) => { return () => { editor.off('keydown', eventHandler); }; - }, [editor]); + }, [editor, enabled]); }; export default useTabIndenter; diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 7b44a01ff9..a15bba3438 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -16,13 +16,11 @@ import useFolder from './utils/useFolder'; import styles_ from './styles'; import { NoteEditorProps, FormNote, OnChangeEvent, NoteBodyEditorProps, AllAssetsOptions, NoteBodyEditorRef } from './utils/types'; import CommandService from '@joplin/lib/services/CommandService'; -import ToolbarButton from '../ToolbarButton/ToolbarButton'; import Button, { ButtonLevel } from '../Button/Button'; import eventManager, { EventName } from '@joplin/lib/eventManager'; import { AppState } from '../../app.reducer'; import ToolbarButtonUtils, { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import { _, _n } from '@joplin/lib/locale'; -import TagList from '../TagList'; import NoteTitleBar from './NoteTitle/NoteTitleBar'; import markupLanguageUtils from '@joplin/lib/utils/markupLanguageUtils'; import Setting from '@joplin/lib/models/Setting'; @@ -59,6 +57,7 @@ import PluginService from '@joplin/lib/services/plugins/PluginService'; import WebviewController from '@joplin/lib/services/plugins/WebviewController'; import AsyncActionQueue, { IntervalType } from '@joplin/lib/AsyncActionQueue'; import useResourceUnwatcher from './utils/useResourceUnwatcher'; +import StatusBar from './StatusBar'; const debounce = require('debounce'); @@ -440,24 +439,6 @@ function NoteEditorContent(props: NoteEditorProps) { return
; } - function renderTagButton() { - return ; - } - - function renderTagBar() { - const theme = themeStyle(props.themeId); - const noteIds = [formNote.id]; - const instructions = { void CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>{_('Click to add tags...')}; - const tagList = props.selectedNoteTags.length ? : null; - - return ( -
{tagList}{instructions}
- ); - } - const searchMarkers = useSearchMarkers(showLocalSearch, localSearchMarkerOptions, props.searches, props.selectedSearchId, props.highlightedWords); const editorProps: NoteBodyEditorProps = { @@ -488,6 +469,7 @@ function NoteEditorContent(props: NoteEditorProps) { searchMarkers: searchMarkers, visiblePanes: props.noteVisiblePanes || ['editor', 'viewer'], keyboardMode: Setting.value('editor.keyboardMode'), + tabMovesFocus: props.tabMovesFocus, locale: Setting.value('locale'), onDrop: onDrop, noteToolbarButtonInfos: props.toolbarButtonInfos, @@ -690,10 +672,11 @@ function NoteEditorContent(props: NoteEditorProps) {
{renderSearchBar()}
-
- {renderTagButton()} - {renderTagBar()} -
+ @@ -750,6 +733,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => { ], whenClauseContext)[0] as ToolbarButtonInfo, contentMaxWidth: state.settings['style.editor.contentMaxWidth'], scrollbarSize: state.settings['style.scrollbarSize'], + tabMovesFocus: state.settings['editor.tabMovesFocus'], isSafeMode: state.settings.isSafeMode, useCustomPdfViewer: false, syncUserId: state.settings['sync.userId'], diff --git a/packages/app-desktop/gui/NoteEditor/StatusBar.tsx b/packages/app-desktop/gui/NoteEditor/StatusBar.tsx new file mode 100644 index 0000000000..aa40bb2df1 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/StatusBar.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import ToolbarButton from '../ToolbarButton/ToolbarButton'; +import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; +import CommandService from '@joplin/lib/services/CommandService'; +import { themeStyle } from '@joplin/lib/theme'; +import { AppState } from '../../app.reducer'; +import { connect } from 'react-redux'; +import { TagEntity } from '@joplin/lib/services/database/types'; +import TagList from '../TagList'; +import { _ } from '@joplin/lib/locale'; +import { useCallback } from 'react'; +import KeymapService from '@joplin/lib/services/KeymapService'; + +interface Props { + themeId: number; + tabMovesFocus: boolean; + noteId: string; + setTagsToolbarButtonInfo: ToolbarButtonInfo; + selectedNoteTags: TagEntity[]; +} + +interface StatusIndicatorProps { + commandName: string; + showWhenUnfocused: boolean; + // Even if not visible, [label] should reflect the current state + // of the indicator. + label: string; +} + +const StatusIndicator: React.FC = props => { + const runCommand = useCallback(() => { + void CommandService.instance().execute(props.commandName); + }, [props.commandName]); + + const keyshortcuts = KeymapService.instance().getAriaKeyShortcuts(props.commandName); + return + + ; +}; + +const StatusBar: React.FC = props => { + function renderTagButton() { + return ; + } + + function renderTagBar() { + const theme = themeStyle(props.themeId); + const noteIds = [props.noteId]; + const instructions = { void CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>{_('Click to add tags...')}; + const tagList = props.selectedNoteTags.length ? : null; + + return
+ {renderTagButton()} +
{tagList}{instructions}
+
; + } + + const keyboardStatus = ; + + return
+ {renderTagBar()} +
+ {keyboardStatus} +
; +}; + +export default connect((state: AppState) => { + return { + themeId: state.settings.theme, + tabMovesFocus: state.settings['editor.tabMovesFocus'], + }; +})(StatusBar); diff --git a/packages/app-desktop/gui/NoteEditor/style.scss b/packages/app-desktop/gui/NoteEditor/style.scss index 148613c99e..c00640b6d1 100644 --- a/packages/app-desktop/gui/NoteEditor/style.scss +++ b/packages/app-desktop/gui/NoteEditor/style.scss @@ -7,3 +7,6 @@ @use "./styles/note-editor-viewer-row.scss"; @use "./styles/revision-viewer-root.scss"; @use "./styles/revision-viewer-title.scss"; +@use "./styles/tag-bar.scss"; +@use "./styles/editor-status-bar.scss"; +@use "./styles/editor-status-indicator.scss"; diff --git a/packages/app-desktop/gui/NoteEditor/styles/editor-status-bar.scss b/packages/app-desktop/gui/NoteEditor/styles/editor-status-bar.scss new file mode 100644 index 0000000000..d2c290227b --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/styles/editor-status-bar.scss @@ -0,0 +1,13 @@ + +.editor-status-bar { + display: flex; + flex-direction: row; + + > .spacer { + flex-grow: 1; + } + + > .status { + align-self: end; + } +} diff --git a/packages/app-desktop/gui/NoteEditor/styles/editor-status-indicator.scss b/packages/app-desktop/gui/NoteEditor/styles/editor-status-indicator.scss new file mode 100644 index 0000000000..1477d01a41 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/styles/editor-status-indicator.scss @@ -0,0 +1,18 @@ + +.editor-status-indicator { + width: 0; + overflow: hidden; + + &:has(> :focus-visible), &.-show { + width: unset; + overflow: visible; + } + + > .button { + font-size: var(--joplin-font-size-small); + background-color: var(--joplin-background-color-active3); + color: var(--joplin-color); + border: none; + padding: 4px; + } +} \ No newline at end of file diff --git a/packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss b/packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss new file mode 100644 index 0000000000..c6442df159 --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss @@ -0,0 +1,14 @@ +.tag-bar { + padding-left: var(--joplin-editor-padding-left); + display: flex; + flex-direction: row; + align-items: center; + height: 40px; + + > .content { + padding-left: 8px; + display: flex; + flex-direction: row; + align-items: center; + } +} diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index b21d8a1207..8d01151fcc 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -49,6 +49,7 @@ export interface NoteEditorProps { watchedResources: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied highlightedWords: any[]; + tabMovesFocus: boolean; plugins: PluginStates; toolbarButtonInfos: ToolbarItem[]; setTagsToolbarButtonInfo: ToolbarButtonInfo; @@ -121,6 +122,7 @@ export interface NoteBodyEditorProps { searchMarkers: SearchMarkers; visiblePanes: string[]; keyboardMode: string; + tabMovesFocus: boolean; resourceInfos: ResourceInfos; resourceDirectory: string; locale: string; diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index 5da82f1843..db7e5c0fc9 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -42,6 +42,7 @@ export default function() { 'togglePerFolderSortOrder', 'toggleSideBar', 'toggleVisiblePanes', + 'toggleTabMovesFocus', 'editor.deleteLine', 'editor.duplicateLine', // We cannot put the undo/redo commands in the menu because they are diff --git a/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts b/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts new file mode 100644 index 0000000000..c8e947650b --- /dev/null +++ b/packages/app-desktop/integration-tests/models/EditorCodeDialog.ts @@ -0,0 +1,16 @@ +import { Locator, Page } from '@playwright/test'; + +export default class EditorCodeDialog { + private readonly dialog: Locator; + public readonly textArea: Locator; + + public constructor(page: Page) { + this.dialog = page.getByRole('dialog', { name: 'Edit' }); + this.textArea = this.dialog.locator('textarea'); + } + + public async waitFor() { + await this.dialog.waitFor(); + await this.textArea.waitFor(); + } +} diff --git a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts index 0fe5cc88d7..4ffe0d2cc5 100644 --- a/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts +++ b/packages/app-desktop/integration-tests/models/NoteEditorScreen.ts @@ -1,17 +1,25 @@ - -import { Locator, Page } from '@playwright/test'; +import { ElectronApplication, Locator, Page } from '@playwright/test'; import { expect } from '../util/test'; +import activateMainMenuItem from '../util/activateMainMenuItem'; +import EditorCodeDialog from './EditorCodeDialog'; export default class NoteEditorPage { public readonly codeMirrorEditor: Locator; public readonly noteViewerContainer: Locator; public readonly richTextEditor: Locator; public readonly noteTitleInput: Locator; + + public readonly richTextCodeEditor: EditorCodeDialog; + public readonly attachFileButton: Locator; + public readonly toggleCodeBlockButton: Locator; public readonly toggleEditorsButton: Locator; public readonly toggleEditorLayoutButton: Locator; + private readonly disableTabNavigationButton: Locator; + public readonly editorSearchInput: Locator; public readonly viewerSearchInput: Locator; + private readonly containerLocator: Locator; public constructor(page: Page) { @@ -20,12 +28,16 @@ export default class NoteEditorPage { this.richTextEditor = this.containerLocator.locator('iframe[title="Rich Text Area"]'); this.noteTitleInput = this.containerLocator.locator('.title-input'); this.attachFileButton = this.containerLocator.getByRole('button', { name: 'Attach file' }); + this.toggleCodeBlockButton = this.containerLocator.getByRole('button', { name: 'Code Block' }); this.toggleEditorsButton = this.containerLocator.getByRole('button', { name: 'Toggle editors' }); this.toggleEditorLayoutButton = this.containerLocator.getByRole('button', { name: 'Toggle editor layout' }); this.noteViewerContainer = this.containerLocator.locator('iframe[src$="note-viewer/index.html"]'); // The editor and viewer have slightly different search UI this.editorSearchInput = this.containerLocator.getByPlaceholder('Find'); this.viewerSearchInput = this.containerLocator.getByPlaceholder('Search...'); + this.disableTabNavigationButton = this.containerLocator.getByRole('button', { name: 'Tab moves focus' }); + + this.richTextCodeEditor = new EditorCodeDialog(page); } public toolbarButtonLocator(title: string) { @@ -75,6 +87,18 @@ export default class NoteEditorPage { return this.codeMirrorEditor.click(); } + public async enableTabNavigation(electronApp: ElectronApplication) { + await expect(this.disableTabNavigationButton).not.toBeVisible(); + await activateMainMenuItem(electronApp, 'Tab moves focus'); + await expect(this.disableTabNavigationButton).toBeVisible(); + } + + public async disableTabNavigation(electronApp: ElectronApplication) { + await expect(this.disableTabNavigationButton).toBeVisible(); + await activateMainMenuItem(electronApp, 'Tab moves focus'); + await expect(this.disableTabNavigationButton).not.toBeVisible(); + } + public async waitFor() { await this.noteTitleInput.waitFor(); await this.toggleEditorsButton.waitFor(); diff --git a/packages/app-desktop/integration-tests/richTextEditor.spec.ts b/packages/app-desktop/integration-tests/richTextEditor.spec.ts index 8333dcdb3e..f36cc2a0dc 100644 --- a/packages/app-desktop/integration-tests/richTextEditor.spec.ts +++ b/packages/app-desktop/integration-tests/richTextEditor.spec.ts @@ -120,6 +120,56 @@ test.describe('richTextEditor', () => { await expect(editor.codeMirrorEditor).toHaveText('This is a test. Test! Another: !'); }); + test('should be possible to disable tab indentation from the menu', async ({ mainWindow, electronApp }) => { + const mainScreen = await new MainScreen(mainWindow).setup(); + await mainScreen.createNewNote('Testing keyboard navigation!'); + + const editor = mainScreen.noteEditor; + await editor.toggleEditorsButton.click(); + await editor.richTextEditor.click(); + + await editor.enableTabNavigation(electronApp); + await mainWindow.keyboard.type('This is a'); + + // Tab should navigate + await expect(editor.richTextEditor).toBeFocused(); + await mainWindow.keyboard.press('Tab'); + await expect(editor.richTextEditor).not.toBeFocused(); + + await editor.disableTabNavigation(electronApp); + + // Tab should not navigate + await editor.richTextEditor.click(); + await mainWindow.keyboard.press('Tab'); + await expect(editor.richTextEditor).toBeFocused(); + }); + + 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'); + + const editor = mainScreen.noteEditor; + await editor.toggleEditorsButton.click(); + await editor.richTextEditor.click(); + + await editor.toggleCodeBlockButton.click(); + const codeEditor = editor.richTextCodeEditor; + await codeEditor.waitFor(); + + // Initially, pressing in the textarea should add a tab + await codeEditor.textArea.click(); + await mainWindow.keyboard.press('Tab'); + await expect(codeEditor.textArea).toHaveValue('\t'); + await expect(codeEditor.textArea).toBeFocused(); + + await editor.enableTabNavigation(electronApp); + + // After enabling tab navigation, pressing tab should navigate. + await expect(codeEditor.textArea).toBeFocused(); + await mainWindow.keyboard.press('Tab'); + await expect(codeEditor.textArea).not.toBeFocused(); + }); + test('should be possible to navigate between the note title and rich text editor with enter/down/up keys', async ({ mainWindow }) => { const mainScreen = await new MainScreen(mainWindow).setup(); await mainScreen.createNewNote('Testing keyboard navigation!'); diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index 82b07afb6c..678671feef 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -347,6 +347,8 @@ function NoteEditor(props: Props, ref: any) { ignoreModifiers: false, autocompleteMarkup: Setting.value('editor.autocompleteMarkup'), + // For now, mobile CodeMirror uses its built-in focus toggle shortcut. + tabMovesFocus: false, indentWithTabs: true, editorLabel: _('Markdown editor'), diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index 9be00d56f9..ae98976557 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -181,6 +181,10 @@ const createEditor = ( keyCommand('Mod-]', increaseIndent), keyCommand('Mod-k', showLinkEditor), keyCommand('Tab', (view: EditorView) => { + if (settings.tabMovesFocus) { + return false; + } + if (settings.autocompleteMarkup) { return insertOrIncreaseIndent(view); } @@ -188,6 +192,10 @@ const createEditor = ( return insertTab(view); }, true), keyCommand('Shift-Tab', (view) => { + if (settings.tabMovesFocus) { + return false; + } + // When at the beginning of the editor, allow shift-tab to act // normally. if (isCursorAtBeginning(view.state)) { diff --git a/packages/editor/CodeMirror/testUtil/createEditorSettings.ts b/packages/editor/CodeMirror/testUtil/createEditorSettings.ts index a4902fc913..042bf64a3a 100644 --- a/packages/editor/CodeMirror/testUtil/createEditorSettings.ts +++ b/packages/editor/CodeMirror/testUtil/createEditorSettings.ts @@ -11,6 +11,7 @@ const createEditorSettings = (themeId: number) => { automatchBraces: false, ignoreModifiers: false, autocompleteMarkup: true, + tabMovesFocus: false, keymap: EditorKeymap.Default, language: EditorLanguageType.Markdown, diff --git a/packages/editor/types.ts b/packages/editor/types.ts index 2eb360a7d4..ce0957d531 100644 --- a/packages/editor/types.ts +++ b/packages/editor/types.ts @@ -165,6 +165,7 @@ export interface EditorSettings { language: EditorLanguageType; keymap: EditorKeymap; + tabMovesFocus: boolean; katexEnabled: boolean; spellcheckEnabled: boolean; diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts index f8947f8ecd..2205646151 100644 --- a/packages/lib/models/settings/builtInMetadata.ts +++ b/packages/lib/models/settings/builtInMetadata.ts @@ -683,6 +683,16 @@ const builtInMetadata = (Setting: typeof SettingType) => { appTypes: [AppType.Mobile], label: () => 'buttons included in the editor toolbar', }, + 'editor.tabMovesFocus': { + value: false, + type: SettingItemType.Bool, + public: false, + section: 'note', + appTypes: [AppType.Desktop], + label: () => _('Tab moves focus'), + storage: SettingStorage.File, + isGlobal: true, + }, 'notes.columns': { value: defaultListColumns(), public: false, diff --git a/packages/lib/services/KeymapService.ts b/packages/lib/services/KeymapService.ts index 5445a3ac8b..6980ba6ece 100644 --- a/packages/lib/services/KeymapService.ts +++ b/packages/lib/services/KeymapService.ts @@ -62,6 +62,7 @@ const defaultKeymapItems = { { accelerator: 'Option+Cmd+3', command: 'switchProfile3' }, { accelerator: 'Option+Cmd+Backspace', command: 'permanentlyDeleteNote' }, { accelerator: 'Option+Cmd+N', command: 'openNoteInNewWindow' }, + { accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' }, ], default: [ { accelerator: 'Ctrl+N', command: 'newNote' }, @@ -110,6 +111,7 @@ const defaultKeymapItems = { { accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' }, { accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' }, { accelerator: 'Ctrl+Alt+N', command: 'openNoteInNewWindow' }, + { accelerator: 'Ctrl+M', command: 'toggleTabMovesFocus' }, ], }; @@ -417,6 +419,13 @@ export default class KeymapService extends BaseService { return parts.join('+'); } + // Electron and aria-keyshortcuts have slightly different formats for accelerators. + // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts + public getAriaKeyShortcuts(commandName: string) { + const electronAccelerator = this.getAccelerator(commandName); + return electronAccelerator.replace('Ctrl', 'Control'); + } + public on(eventName: Name, callback: EventListenerCallback) { eventManager.on(eventName, callback); } diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 768216df36..92b620e94b 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -160,3 +160,4 @@ Tebi unwatcher pedr Slotozilla +keyshortcuts