mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-02 12:47:41 +02:00
Desktop: Accessibility: Improve note title focus handling (#10932)
This commit is contained in:
parent
2afc2ca369
commit
74be949d33
@ -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/types.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/useScroll.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/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/searchExtension.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -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/types.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/useScroll.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/growSelectionToNode.js
|
||||
packages/editor/CodeMirror/utils/handlePasteEvent.js
|
||||
packages/editor/CodeMirror/utils/isCursorAtBeginning.js
|
||||
packages/editor/CodeMirror/utils/isInSyntaxNode.js
|
||||
packages/editor/CodeMirror/utils/searchExtension.js
|
||||
packages/editor/CodeMirror/utils/setupVim.js
|
||||
|
@ -4,14 +4,18 @@ export const declaration: CommandDeclaration = {
|
||||
name: 'focusElement',
|
||||
};
|
||||
|
||||
export interface FocusElementOptions {
|
||||
moveCursorToStart: boolean;
|
||||
}
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
execute: async (_context: any, target: string) => {
|
||||
if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody');
|
||||
execute: async (_context: any, target: string, options?: FocusElementOptions) => {
|
||||
if (target === 'noteBody') return CommandService.instance().execute('focusElementNoteBody', options);
|
||||
if (target === 'noteList') return CommandService.instance().execute('focusElementNoteList');
|
||||
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}`);
|
||||
},
|
||||
};
|
||||
|
@ -27,6 +27,7 @@ import useContextMenu from '../utils/useContextMenu';
|
||||
import useWebviewIpcMessage from '../utils/useWebviewIpcMessage';
|
||||
import Toolbar from '../Toolbar';
|
||||
import useEditorSearchHandler from '../utils/useEditorSearchHandler';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
|
||||
const logger = Logger.create('CodeMirror6');
|
||||
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]);
|
||||
|
||||
const onSelectPastBeginning = useCallback(() => {
|
||||
void CommandService.instance().execute('focusElement', 'noteTitle');
|
||||
}, []);
|
||||
|
||||
const editorSettings = useMemo((): EditorSettings => {
|
||||
const isHTMLNote = props.contentMarkupLanguage === MarkupToHtml.MARKUP_LANGUAGE_HTML;
|
||||
|
||||
@ -403,6 +408,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
onEvent={onEditorEvent}
|
||||
onLogMessage={logDebug}
|
||||
onEditorPaste={onEditorPaste}
|
||||
onSelectPastBeginning={onSelectPastBeginning}
|
||||
externalSearch={props.searchMarkers}
|
||||
useLocalSearch={props.useLocalSearch}
|
||||
/>
|
||||
|
@ -9,6 +9,7 @@ import Logger from '@joplin/utils/Logger';
|
||||
import CodeMirrorControl from '@joplin/editor/CodeMirror/CodeMirrorControl';
|
||||
import { MarkupLanguage } from '@joplin/renderer';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import { FocusElementOptions } from '../../../../../commands/focusElement';
|
||||
|
||||
const logger = Logger.create('CodeMirror 6 commands');
|
||||
|
||||
@ -103,9 +104,15 @@ const useEditorCommands = (props: Props) => {
|
||||
logger.warn('CodeMirror execCommand: unsupported command: ', value.name);
|
||||
}
|
||||
},
|
||||
'editor.focus': () => {
|
||||
'editor.focus': (options?: FocusElementOptions) => {
|
||||
if (props.visiblePanes.indexOf('editor') >= 0) {
|
||||
focus('useEditorCommands::editor.focus', editorRef.current.editor);
|
||||
if (options?.moveCursorToStart) {
|
||||
editorRef.current.editor.dispatch({
|
||||
selection: { anchor: 0 },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If we just call focus() then the iframe is focused,
|
||||
// but not its content, such that scrolling up / down
|
||||
|
@ -39,6 +39,7 @@ const { clipboard } = require('electron');
|
||||
const supportedLocales = require('./supportedLocales');
|
||||
import { hasProtocol } from '@joplin/utils/url';
|
||||
import useTabIndenter from './utils/useTabIndenter';
|
||||
import useKeyboardRefocusHandler from './utils/useKeyboardRefocusHandler';
|
||||
|
||||
const logger = Logger.create('TinyMCE');
|
||||
|
||||
@ -130,6 +131,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
usePluginServiceRegistration(ref);
|
||||
useContextMenu(editor, props.plugins, props.dispatch, props.htmlToMarkdown, props.markupToHtml);
|
||||
useTabIndenter(editor);
|
||||
useKeyboardRefocusHandler(editor);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const dispatchDidUpdate = (editor: any) => {
|
||||
@ -218,6 +220,13 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
|
||||
editor.insertContent(result.html);
|
||||
} else if (cmd.name === 'editor.focus') {
|
||||
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') {
|
||||
if (!('ui' in cmd.value)) cmd.value.ui = false;
|
||||
if (!('value' in cmd.value)) cmd.value.value = null;
|
||||
|
@ -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;
|
@ -78,18 +78,13 @@ function styles_(props: Props) {
|
||||
export default function NoteTitleBar(props: Props) {
|
||||
const styles = styles_(props);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const onTitleKeydown = useCallback((event: any) => {
|
||||
const keyCode = event.keyCode;
|
||||
|
||||
if (keyCode === 9) { // TAB
|
||||
const onTitleKeydown: React.KeyboardEventHandler<HTMLInputElement> = useCallback((event) => {
|
||||
const titleElement = event.currentTarget;
|
||||
const selectionAtEnd = titleElement.selectionEnd === titleElement.value.length;
|
||||
if ((event.key === 'ArrowDown' && selectionAtEnd) || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.shiftKey) {
|
||||
void CommandService.instance().execute('focusElement', 'noteList');
|
||||
} else {
|
||||
void CommandService.instance().execute('focusElement', 'noteBody');
|
||||
}
|
||||
const moveCursorToStart = event.key === 'ArrowDown';
|
||||
void CommandService.instance().execute('focusElement', 'noteBody', { moveCursorToStart });
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommandRuntime, CommandDeclaration } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { FocusElementOptions } from '../../../commands/focusElement';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
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
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
return {
|
||||
execute: async () => {
|
||||
comp.editorRef.current.execCommand({ name: 'editor.focus' });
|
||||
execute: async (_context: unknown, options?: FocusElementOptions) => {
|
||||
comp.editorRef.current.execCommand({ name: 'editor.focus', value: options });
|
||||
},
|
||||
enabledCondition: 'oneNoteSelected',
|
||||
};
|
||||
|
@ -62,12 +62,19 @@ test.describe('main', () => {
|
||||
'',
|
||||
'Sum: $\\sum_{x=0}^{100} \\tan x$',
|
||||
];
|
||||
let firstLine = true;
|
||||
for (const line of noteText) {
|
||||
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.press('Enter');
|
||||
firstLine = false;
|
||||
}
|
||||
|
||||
// Should render mermaid
|
||||
|
@ -119,5 +119,29 @@ test.describe('richTextEditor', () => {
|
||||
await editor.toggleEditorsButton.click();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -31,6 +31,7 @@ import insertLineAfter from './editorCommands/insertLineAfter';
|
||||
import handlePasteEvent from './utils/handlePasteEvent';
|
||||
import biDirectionalTextExtension from './utils/biDirectionalTextExtension';
|
||||
import searchExtension from './utils/searchExtension';
|
||||
import isCursorAtBeginning from './utils/isCursorAtBeginning';
|
||||
|
||||
const createEditor = (
|
||||
parentElement: HTMLElement, props: EditorProps,
|
||||
@ -176,12 +177,28 @@ const createEditor = (
|
||||
return 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) => {
|
||||
insertLineAfter(_);
|
||||
return true;
|
||||
}, true),
|
||||
|
||||
keyCommand('ArrowUp', (view: EditorView) => {
|
||||
if (isCursorAtBeginning(view.state) && props.onSelectPastBeginning) {
|
||||
props.onSelectPastBeginning();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, true),
|
||||
|
||||
...standardKeymap, ...historyKeymap, ...searchKeymap,
|
||||
]));
|
||||
|
||||
|
8
packages/editor/CodeMirror/utils/isCursorAtBeginning.ts
Normal file
8
packages/editor/CodeMirror/utils/isCursorAtBeginning.ts
Normal 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;
|
@ -169,6 +169,7 @@ export interface EditorSettings {
|
||||
export type LogMessageCallback = (message: string)=> void;
|
||||
export type OnEventCallback = (event: EditorEvent)=> void;
|
||||
export type PasteFileCallback = (data: File)=> Promise<void>;
|
||||
type OnScrollPastBeginningCallback = ()=> void;
|
||||
|
||||
export interface EditorProps {
|
||||
settings: EditorSettings;
|
||||
@ -176,6 +177,7 @@ export interface EditorProps {
|
||||
|
||||
// If null, paste and drag-and-drop will not work for resources unless handled elsewhere.
|
||||
onPasteFile: PasteFileCallback|null;
|
||||
onSelectPastBeginning?: OnScrollPastBeginningCallback;
|
||||
onEvent: OnEventCallback;
|
||||
onLogMessage: LogMessageCallback;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user