1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-13 00:10:37 +02:00

Desktop: Accessibility: Improve note title focus handling (#10932)

This commit is contained in:
Henry Heino
2024-08-27 10:05:48 -07:00
committed by GitHub
parent 2afc2ca369
commit 74be949d33
14 changed files with 146 additions and 19 deletions

View File

@ -297,6 +297,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.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/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.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/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.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/useTabIndenter.js
@ -844,6 +845,7 @@ packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js packages/editor/CodeMirror/utils/setupVim.js

2
.gitignore vendored
View File

@ -274,6 +274,7 @@ packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/shouldPasteResources.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/types.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.js packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useContextMenu.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/useLinkTooltips.js
packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/utils/useScroll.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/useTabIndenter.js
@ -821,6 +822,7 @@ packages/editor/CodeMirror/utils/formatting/types.js
packages/editor/CodeMirror/utils/getSearchState.js packages/editor/CodeMirror/utils/getSearchState.js
packages/editor/CodeMirror/utils/growSelectionToNode.js packages/editor/CodeMirror/utils/growSelectionToNode.js
packages/editor/CodeMirror/utils/handlePasteEvent.js packages/editor/CodeMirror/utils/handlePasteEvent.js
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
packages/editor/CodeMirror/utils/isInSyntaxNode.js packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/searchExtension.js packages/editor/CodeMirror/utils/searchExtension.js
packages/editor/CodeMirror/utils/setupVim.js packages/editor/CodeMirror/utils/setupVim.js

View File

@ -4,14 +4,18 @@ export const declaration: CommandDeclaration = {
name: 'focusElement', name: 'focusElement',
}; };
export interface FocusElementOptions {
moveCursorToStart: boolean;
}
export const runtime = (): CommandRuntime => { export const runtime = (): CommandRuntime => {
return { return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
execute: async (_context: any, target: string) => { execute: async (_context: any, target: string, options?: FocusElementOptions) => {
if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody'); if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody', options);
if (target === 'noteList') return CommandService.instance().execute('focusElementNoteList'); if (target === 'noteList') return CommandService.instance().execute('focusElementNoteList');
if (target === 'sideBar') return CommandService.instance().execute('focusElementSideBar'); if (target === 'sideBar') return CommandService.instance().execute('focusElementSideBar');
if (target === 'noteTitle') return CommandService.instance().execute('focusElementNoteTitle'); if (target === 'noteTitle') return CommandService.instance().execute('focusElementNoteTitle', options);
throw new Error(`Invalid focus target: ${target}`); throw new Error(`Invalid focus target: ${target}`);
}, },
}; };

View File

@ -27,6 +27,7 @@ import useContextMenu from '../utils/useContextMenu';
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage'; import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
import Toolbar from '../Toolbar'; import Toolbar from '../Toolbar';
import useEditorSearchHandler from '../utils/useEditorSearchHandler'; import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
const logger = Logger.create('CodeMirror6'); const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message); const logDebug = (message: string) => logger.debug(message);
@ -354,6 +355,10 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
} }
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]); }, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
const onSelectPastBeginning = useCallback(() => {
void CommandService.instance().execute('focusElement', 'noteTitle');
}, []);
const editorSettings = useMemo((): EditorSettings => { const editorSettings = useMemo((): EditorSettings => {
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML; const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
@ -403,6 +408,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onEvent={onEditorEvent} onEvent={onEditorEvent}
onLogMessage={logDebug} onLogMessage={logDebug}
onEditorPaste={onEditorPaste} onEditorPaste={onEditorPaste}
onSelectPastBeginning={onSelectPastBeginning}
externalSearch={props.searchMarkers} externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch} useLocalSearch={props.useLocalSearch}
/> />

View File

@ -9,6 +9,7 @@ import Logger from '@joplin/utils/Logger';
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl'; import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
import { MarkupLanguage } from '@joplin/renderer'; import { MarkupLanguage } from '@joplin/renderer';
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
import { FocusElementOptions } from '../../../../../commands/focusElement';
const logger = Logger.create('CodeMirror 6 commands'); const logger = Logger.create('CodeMirror 6 commands');
@ -103,9 +104,15 @@ const useEditorCommands = (props: Props) => {
logger.warn('CodeMirror execCommand: unsupported command: ', value.name); logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
} }
}, },
'editor.focus': () => { 'editor.focus': (options?: FocusElementOptions) => {
if (props.visiblePanes.indexOf('editor') >= 0) { if (props.visiblePanes.indexOf('editor') >= 0) {
focus('useEditorCommands::editor.focus', editorRef.current.editor); focus('useEditorCommands::editor.focus', editorRef.current.editor);
if (options?.moveCursorToStart) {
editorRef.current.editor.dispatch({
selection: { anchor: 0 },
scrollIntoView: true,
});
}
} else { } else {
// If we just call focus() then the iframe is focused, // If we just call focus() then the iframe is focused,
// but not its content, such that scrolling up / down // but not its content, such that scrolling up / down

View File

@ -39,6 +39,7 @@ const { clipboard } = require('electron');
const supportedLocales = require('./supportedLocales'); const supportedLocales = require('./supportedLocales');
import { hasProtocol } from '@joplin/utils/url'; import { hasProtocol } from '@joplin/utils/url';
import useTabIndenter from './utils/useTabIndenter'; import useTabIndenter from './utils/useTabIndenter';
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
const logger = Logger.create('TinyMCE'); const logger = Logger.create('TinyMCE');
@ -130,6 +131,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
usePluginServiceRegistration(ref); usePluginServiceRegistration(ref);
useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml); useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml);
useTabIndenter(editor); useTabIndenter(editor);
useKeyboardRefocusHandler(editor);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const dispatchDidUpdate = (editor: any) => { const dispatchDidUpdate = (editor: any) => {
@ -218,6 +220,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
editor.insertContent(result.html); editor.insertContent(result.html);
} else if (cmd.name === 'editor.focus') { } else if (cmd.name === 'editor.focus') {
focus('TinyMCE::editor.focus', editor); focus('TinyMCE::editor.focus', editor);
if (cmd.value?.moveCursorToStart) {
editor.selection.placeCaretAt(0, 0);
editor.selection.setCursorLocation(
editor.dom.root,
0,
);
}
} else if (cmd.name === 'editor.execCommand') { } else if (cmd.name === 'editor.execCommand') {
if (!('ui' in cmd.value)) cmd.value.ui = false; if (!('ui' in cmd.value)) cmd.value.ui = false;
if (!('value' in cmd.value)) cmd.value.value = null; if (!('value' in cmd.value)) cmd.value.value = null;

View File

@ -0,0 +1,43 @@
import CommandService from '@joplin/lib/services/CommandService';
import { useEffect } from 'react';
import type { Editor, EditorEvent } from 'tinymce';
const useKeyboardRefocusHandler = (editor: Editor) => {
useEffect(() => {
if (!editor) return () => {};
const isAtBeginningOf = (element: Node, parent: HTMLElement) => {
if (!parent.contains(element)) return false;
while (
element &&
element.parentNode !== parent &&
element.parentNode?.firstChild === element
) {
element = element.parentNode;
}
return !!element && element.parentNode?.firstChild === element;
};
const eventHandler = (event: EditorEvent<KeyboardEvent>) => {
if (!event.isDefaultPrevented() && event.key === 'ArrowUp') {
const selection = editor.selection.getRng();
if (selection.startOffset === 0 &&
selection.collapsed &&
isAtBeginningOf(selection.startContainer, editor.dom.getRoot())
) {
event.preventDefault();
void CommandService.instance().execute('focusElement', 'noteTitle');
}
}
};
editor.on('keydown', eventHandler);
return () => {
editor.off('keydown', eventHandler);
};
}, [editor]);
};
export default useKeyboardRefocusHandler;

View File

@ -78,18 +78,13 @@ function styles_(props: Props) {
export default function NoteTitleBar(props: Props) { export default function NoteTitleBar(props: Props) {
const styles = styles_(props); const styles = styles_(props);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const onTitleKeydown: React.KeyboardEventHandler<HTMLInputElement> = useCallback((event) => {
const onTitleKeydown = useCallback((event: any) => { const titleElement = event.currentTarget;
const keyCode = event.keyCode; const selectionAtEnd = titleElement.selectionEnd === titleElement.value.length;
if ((event.key === 'ArrowDown' && selectionAtEnd) || event.key === 'Enter') {
if (keyCode === 9) { // TAB
event.preventDefault(); event.preventDefault();
const moveCursorToStart = event.key === 'ArrowDown';
if (event.shiftKey) { void CommandService.instance().execute('focusElement', 'noteBody', { moveCursorToStart });
void CommandService.instance().execute('focusElement', 'noteList');
} else {
void CommandService.instance().execute('focusElement', 'noteBody');
}
} }
}, []); }, []);

View File

@ -1,5 +1,6 @@
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService'; import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { FocusElementOptions } from '../../../commands/focusElement';
export const declaration: CommandDeclaration = { export const declaration: CommandDeclaration = {
name: 'focusElementNoteBody', name: 'focusElementNoteBody',
@ -10,8 +11,8 @@ export const declaration: CommandDeclaration = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const runtime = (comp: any): CommandRuntime => { export const runtime = (comp: any): CommandRuntime => {
return { return {
execute: async () => { execute: async (_context: unknown, options?: FocusElementOptions) => {
comp.editorRef.current.execCommand({ name: 'editor.focus' }); comp.editorRef.current.execCommand({ name: 'editor.focus', value: options });
}, },
enabledCondition: 'oneNoteSelected', enabledCondition: 'oneNoteSelected',
}; };

View File

@ -62,12 +62,19 @@ test.describe('main', () => {
'', '',
'Sum: $\\sum_{x=0}^{100} \\tan x$', 'Sum: $\\sum_{x=0}^{100} \\tan x$',
]; ];
let firstLine = true;
for (const line of noteText) { for (const line of noteText) {
if (line) { if (line) {
await mainWindow.keyboard.press('Shift+Tab'); if (!firstLine) {
// Remove any auto-indentation, but avoid pressing shift-tab at
// the beginning of the editor.
await mainWindow.keyboard.press('Shift+Tab');
}
await mainWindow.keyboard.type(line); await mainWindow.keyboard.type(line);
} }
await mainWindow.keyboard.press('Enter'); await mainWindow.keyboard.press('Enter');
firstLine = false;
} }
// Should render mermaid // Should render mermaid

View File

@ -119,5 +119,29 @@ test.describe('richTextEditor', () => {
await editor.toggleEditorsButton.click(); await editor.toggleEditorsButton.click();
await expect(editor.codeMirrorEditor).toHaveText('This is a test. Test! Another: !'); await expect(editor.codeMirrorEditor).toHaveText('This is a test. Test! Another: !');
}); });
test('should be possible to navigate between the note title and rich text editor with enter/down/up keys', async ({ mainWindow }) => {
const mainScreen = new MainScreen(mainWindow);
await mainScreen.createNewNote('Testing keyboard navigation!');
const editor = mainScreen.noteEditor;
await editor.toggleEditorsButton.click();
await editor.richTextEditor.waitFor();
await editor.noteTitleInput.click();
await expect(editor.noteTitleInput).toBeFocused();
await mainWindow.keyboard.press('End');
await mainWindow.keyboard.press('ArrowDown');
await expect(editor.richTextEditor).toBeFocused();
await mainWindow.keyboard.press('ArrowUp');
await expect(editor.noteTitleInput).toBeFocused();
await mainWindow.keyboard.press('Enter');
await expect(editor.noteTitleInput).not.toBeFocused();
await expect(editor.richTextEditor).toBeFocused();
});
}); });

View File

@ -31,6 +31,7 @@ import insertLineAfter from './editorCommands/insertLineAfter';
import handlePasteEvent from './utils/handlePasteEvent'; import handlePasteEvent from './utils/handlePasteEvent';
import biDirectionalTextExtension from './utils/biDirectionalTextExtension'; import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
import searchExtension from './utils/searchExtension'; import searchExtension from './utils/searchExtension';
import isCursorAtBeginning from './utils/isCursorAtBeginning';
const createEditor = ( const createEditor = (
parentElement: HTMLElement, props: EditorProps, parentElement: HTMLElement, props: EditorProps,
@ -176,12 +177,28 @@ const createEditor = (
return true; return true;
}), }),
keyCommand('Tab', insertOrIncreaseIndent, true), keyCommand('Tab', insertOrIncreaseIndent, true),
keyCommand('Shift-Tab', decreaseIndent, true), keyCommand('Shift-Tab', (view) => {
// When at the beginning of the editor, allow shift-tab to act
// normally.
if (isCursorAtBeginning(view.state)) {
return false;
}
return decreaseIndent(view);
}, true),
keyCommand('Mod-Enter', (_: EditorView) => { keyCommand('Mod-Enter', (_: EditorView) => {
insertLineAfter(_); insertLineAfter(_);
return true; return true;
}, true), }, true),
keyCommand('ArrowUp', (view: EditorView) => {
if (isCursorAtBeginning(view.state) && props.onSelectPastBeginning) {
props.onSelectPastBeginning();
return true;
}
return false;
}, true),
...standardKeymap, ...historyKeymap, ...searchKeymap, ...standardKeymap, ...historyKeymap, ...searchKeymap,
])); ]));

View File

@ -0,0 +1,8 @@
import { EditorState } from '@codemirror/state';
const isCursorAtBeginning = (state: EditorState) => {
const selection = state.selection;
return selection.ranges.length === 1 && selection.main.empty && selection.main.anchor === 0;
};
export default isCursorAtBeginning;

View File

@ -169,6 +169,7 @@ export interface EditorSettings {
export type LogMessageCallback = (message: string)=> void; export type LogMessageCallback = (message: string)=> void;
export type OnEventCallback = (event: EditorEvent)=> void; export type OnEventCallback = (event: EditorEvent)=> void;
export type PasteFileCallback = (data: File)=> Promise<void>; export type PasteFileCallback = (data: File)=> Promise<void>;
type OnScrollPastBeginningCallback = ()=> void;
export interface EditorProps { export interface EditorProps {
settings: EditorSettings; settings: EditorSettings;
@ -176,6 +177,7 @@ export interface EditorProps {
// If null, paste and drag-and-drop will not work for resources unless handled elsewhere. // If null, paste and drag-and-drop will not work for resources unless handled elsewhere.
onPasteFile: PasteFileCallback|null; onPasteFile: PasteFileCallback|null;
onSelectPastBeginning?: OnScrollPastBeginningCallback;
onEvent: OnEventCallback; onEvent: OnEventCallback;
onLogMessage: LogMessageCallback; onLogMessage: LogMessageCallback;
} }