1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Desktop: Accessibility: Allow toggling between tab navigation and indentation (#11717)

This commit is contained in:
Henry Heino
2025-01-27 10:34:58 -08:00
committed by GitHub
parent cc1582d535
commit 662185816d
27 changed files with 382 additions and 49 deletions

View File

@@ -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

3
.gitignore vendored
View File

@@ -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

View File

@@ -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;

View File

@@ -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',
};
};

View File

@@ -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<Props> => {
['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,

View File

@@ -372,10 +372,12 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
spellcheckEnabled: Setting.value('editor.spellcheckBeta'),
keymap: keyboardMode,
indentWithTabs: true,
tabMovesFocus: props.tabMovesFocus,
editorLabel: _('Markdown editor'),
};
}, [
props.contentMarkupLanguage, props.disabled, props.keyboardMode, styles.globalTheme,
props.tabMovesFocus,
]);
// Update the editor's value

View File

@@ -132,7 +132,7 @@ const TinyMCE = (props: NoteBodyEditorProps, ref: any) => {
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

View File

@@ -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<HTMLTextAreaElement>('.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);
});
}

View File

@@ -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;

View File

@@ -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 <div style={emptyDivStyle} ref={containerRef}></div>;
}
function renderTagButton() {
return <ToolbarButton
themeId={props.themeId}
toolbarButtonInfo={props.setTagsToolbarButtonInfo}
/>;
}
function renderTagBar() {
const theme = themeStyle(props.themeId);
const noteIds = [formNote.id];
const instructions = <span onClick={() => { void CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>{_('Click to add tags...')}</span>;
const tagList = props.selectedNoteTags.length ? <TagList items={props.selectedNoteTags} /> : null;
return (
<div style={{ paddingLeft: 8, display: 'flex', flexDirection: 'row', alignItems: 'center' }}>{tagList}{instructions}</div>
);
}
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) {
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
{renderSearchBar()}
</div>
<div className="tag-bar" style={{ paddingLeft: theme.editorPaddingLeft, display: 'flex', flexDirection: 'row', alignItems: 'center', height: 40 }}>
{renderTagButton()}
{renderTagBar()}
</div>
<StatusBar
noteId={formNote.id}
setTagsToolbarButtonInfo={props.setTagsToolbarButtonInfo}
selectedNoteTags={props.selectedNoteTags}
/>
<WarningBanner bodyEditor={props.bodyEditor}/>
</div>
</div>
@@ -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'],

View File

@@ -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<StatusIndicatorProps> = props => {
const runCommand = useCallback(() => {
void CommandService.instance().execute(props.commandName);
}, [props.commandName]);
const keyshortcuts = KeymapService.instance().getAriaKeyShortcuts(props.commandName);
return <span
className={`status editor-status-indicator ${props.showWhenUnfocused ? '-show' : ''}`}
aria-live='polite'
>
<button
className='button'
aria-keyshortcuts={keyshortcuts}
onClick={runCommand}
>
{props.label}
</button>
</span>;
};
const StatusBar: React.FC<Props> = props => {
function renderTagButton() {
return <ToolbarButton
themeId={props.themeId}
toolbarButtonInfo={props.setTagsToolbarButtonInfo}
/>;
}
function renderTagBar() {
const theme = themeStyle(props.themeId);
const noteIds = [props.noteId];
const instructions = <span onClick={() => { void CommandService.instance().execute('setTags', noteIds); }} style={{ ...theme.clickableTextStyle, whiteSpace: 'nowrap' }}>{_('Click to add tags...')}</span>;
const tagList = props.selectedNoteTags.length ? <TagList items={props.selectedNoteTags} /> : null;
return <div className='tag-bar'>
{renderTagButton()}
<div className='content'>{tagList}{instructions}</div>
</div>;
}
const keyboardStatus = <StatusIndicator
commandName='toggleTabMovesFocus'
label={props.tabMovesFocus ? _('Tab moves focus') : _('Tab indents')}
showWhenUnfocused={props.tabMovesFocus}
/>;
return <div className='editor-status-bar'>
{renderTagBar()}
<div className='spacer'/>
{keyboardStatus}
</div>;
};
export default connect((state: AppState) => {
return {
themeId: state.settings.theme,
tabMovesFocus: state.settings['editor.tabMovesFocus'],
};
})(StatusBar);

View File

@@ -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";

View File

@@ -0,0 +1,13 @@
.editor-status-bar {
display: flex;
flex-direction: row;
> .spacer {
flex-grow: 1;
}
> .status {
align-self: end;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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 <tab> 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!');

View File

@@ -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'),

View File

@@ -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)) {

View File

@@ -11,6 +11,7 @@ const createEditorSettings = (themeId: number) => {
automatchBraces: false,
ignoreModifiers: false,
autocompleteMarkup: true,
tabMovesFocus: false,
keymap: EditorKeymap.Default,
language: EditorLanguageType.Markdown,

View File

@@ -165,6 +165,7 @@ export interface EditorSettings {
language: EditorLanguageType;
keymap: EditorKeymap;
tabMovesFocus: boolean;
katexEnabled: boolean;
spellcheckEnabled: boolean;

View File

@@ -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,

View File

@@ -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<Name extends EventName>(eventName: Name, callback: EventListenerCallback<Name>) {
eventManager.on(eventName, callback);
}

View File

@@ -160,3 +160,4 @@ Tebi
unwatcher
pedr
Slotozilla
keyshortcuts