You've already forked joplin
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:
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
20
packages/app-desktop/commands/toggleTabMovesFocus.ts
Normal file
20
packages/app-desktop/commands/toggleTabMovesFocus.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
88
packages/app-desktop/gui/NoteEditor/StatusBar.tsx
Normal file
88
packages/app-desktop/gui/NoteEditor/StatusBar.tsx
Normal 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);
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
.editor-status-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
> .spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> .status {
|
||||
align-self: end;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
14
packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss
Normal file
14
packages/app-desktop/gui/NoteEditor/styles/tag-bar.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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!');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -11,6 +11,7 @@ const createEditorSettings = (themeId: number) => {
|
||||
automatchBraces: false,
|
||||
ignoreModifiers: false,
|
||||
autocompleteMarkup: true,
|
||||
tabMovesFocus: false,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
language: EditorLanguageType.Markdown,
|
||||
|
||||
@@ -165,6 +165,7 @@ export interface EditorSettings {
|
||||
language: EditorLanguageType;
|
||||
|
||||
keymap: EditorKeymap;
|
||||
tabMovesFocus: boolean;
|
||||
|
||||
katexEnabled: boolean;
|
||||
spellcheckEnabled: boolean;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -160,3 +160,4 @@ Tebi
|
||||
unwatcher
|
||||
pedr
|
||||
Slotozilla
|
||||
keyshortcuts
|
||||
|
||||
Reference in New Issue
Block a user