Merge pull request #3662 from NoelDeMartin/MOBILE-3748

MOBILE-3748: RTE a11y improvements
main
Dani Palou 2023-05-08 15:17:34 +02:00 committed by GitHub
commit aaac4fbea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 158 additions and 65 deletions

View File

@ -1,7 +1,8 @@
<div class="core-rte-editor-container" (click)="focusRTE()" [class.toolbar-hidden]="toolbarHidden"> <div class="core-rte-editor-container" (click)="focusRTE()" [class.toolbar-hidden]="toolbarHidden">
<div [hidden]="!rteEnabled" #editor class="core-rte-editor" role="textbox" contenteditable="true" <div [hidden]="!rteEnabled" #editor class="core-rte-editor" role="textbox" contenteditable="true"
[attr.aria-labelledby]="ariaLabelledBy" [attr.data-placeholder-text]="placeholder" (focus)="showToolbar($event)" [attr.aria-labelledby]="ariaLabelledBy" [attr.data-placeholder-text]="placeholder" (focus)="showToolbar($event)"
(blur)="hideToolbar($event)"> (blur)="hideToolbar($event)" (keydown)="onKeyDown($event)" (keyup)="onChange()" (change)="onChange()" (paste)="onChange()"
(input)="onChange()">
</div> </div>
<ion-textarea [hidden]="rteEnabled" #textarea class="core-textarea" role="textbox" [attr.name]="name" ngControl="control" <ion-textarea [hidden]="rteEnabled" #textarea class="core-textarea" role="textbox" [attr.name]="name" ngControl="control"

View File

@ -108,6 +108,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
protected resizeListener?: CoreEventObserver; protected resizeListener?: CoreEventObserver;
protected domPromise?: CoreCancellablePromise<void>; protected domPromise?: CoreCancellablePromise<void>;
protected buttonsDomPromise?: CoreCancellablePromise<void>; protected buttonsDomPromise?: CoreCancellablePromise<void>;
protected shortcutCommands?: Record<string, EditorCommand>;
rteEnabled = false; rteEnabled = false;
isPhone = false; isPhone = false;
@ -171,11 +172,6 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
this.setContent(this.control?.value); this.setContent(this.control?.value);
this.originalContent = this.control?.value; this.originalContent = this.control?.value;
this.lastDraft = this.control?.value; this.lastDraft = this.control?.value;
this.editorElement.onchange = () => this.onChange();
this.editorElement.onkeyup = () => this.onChange();
this.editorElement.onpaste = () => this.onChange();
this.editorElement.oninput = () => this.onChange();
this.editorElement.onkeydown = event => this.moveCursor(event);
// Use paragraph on enter. // Use paragraph on enter.
document.execCommand('DefaultParagraphSeparator', false, 'p'); document.execCommand('DefaultParagraphSeparator', false, 'p');
@ -274,6 +270,26 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
}); });
} }
/**
* Handle keydown events in the editor.
*
* @param event Event
*/
onKeyDown(event: KeyboardEvent): void {
this.onChange();
const shortcutId = this.getShortcutId(event);
const commands = this.getShortcutCommands();
const command = commands[shortcutId];
if (!command) {
return;
}
this.stopBubble(event);
this.executeCommand(command);
}
/** /**
* Resize editor to maximize the space occupied. * Resize editor to maximize the space occupied.
*/ */
@ -374,64 +390,6 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
this.contentChanged.emit(this.control?.value); this.contentChanged.emit(this.control?.value);
} }
/**
* On key down function to move the cursor.
* https://stackoverflow.com/questions/6249095/how-to-set-caretcursor-position-in-contenteditable-element-div
*
* @param event The event.
*/
moveCursor(event: KeyboardEvent): void {
if (!this.rteEnabled || !this.editorElement) {
return;
}
if (event.key != 'ArrowLeft' && event.key != 'ArrowRight') {
return;
}
this.stopBubble(event);
const move = event.key === 'ArrowLeft' ? -1 : +1;
const cursor = this.getCurrentCursorPosition(this.editorElement);
this.setCurrentCursorPosition(this.editorElement, cursor + move);
}
/**
* Returns the number of chars from the beggining where is placed the cursor.
*
* @param parent Parent where to get the position from.
* @returns Position in chars.
*/
protected getCurrentCursorPosition(parent: Node): number {
const selection = window.getSelection();
let charCount = -1;
if (selection?.focusNode && parent.contains(selection.focusNode)) {
let node: Node | null = selection.focusNode;
charCount = selection.focusOffset;
while (node) {
if (node.isSameNode(parent)) {
break;
}
if (node.previousSibling) {
node = node.previousSibling;
charCount += (node.textContent || '').length;
} else {
node = node.parentNode;
if (node === null) {
break;
}
}
}
}
return charCount;
}
/** /**
* Set the caret position on the character number. * Set the caret position on the character number.
* *
@ -446,6 +404,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param node Node where to start. * @param node Node where to start.
* @param range Previous calculated range. * @param range Previous calculated range.
* @param chars Object with counting of characters (input-output param). * @param chars Object with counting of characters (input-output param).
* @param chars.count Count of characters.
* @returns Selection range. * @returns Selection range.
*/ */
const setRange = (node: Node, range: Range, chars: { count: number }): Range => { const setRange = (node: Node, range: Range, chars: { count: number }): Range => {
@ -641,7 +600,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
} }
/** /**
* Execute an action over the selected text. * Execute an action over the selected text when a button is activated.
* API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand * API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
* *
* @param event Event data * @param event Event data
@ -660,6 +619,18 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
return; return;
} }
this.executeCommand({ name: command, parameters });
}
/**
* Execute an action over the selected text.
* API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
*
* @param command Editor command.
* @param command.name Command name.
* @param command.parameters Command parameters.
*/
protected executeCommand({ name: command, parameters }: EditorCommand): void {
if (parameters == 'block') { if (parameters == 'block') {
document.execCommand('formatBlock', false, '<' + command + '>'); document.execCommand('formatBlock', false, '<' + command + '>');
@ -1061,6 +1032,10 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @returns Promise resolved when done. * @returns Promise resolved when done.
*/ */
async scanQR(event: Event): Promise<void> { async scanQR(event: Event): Promise<void> {
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return;
}
this.stopBubble(event); this.stopBubble(event);
// Scan for a QR code. // Scan for a QR code.
@ -1117,4 +1092,121 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
this.buttonsDomPromise?.cancel(); this.buttonsDomPromise?.cancel();
} }
/**
* Get commands triggered by keyboard shortcuts.
*
* @returns Commands dictionary indexed by their corresponding keyboard shortcut id.
*/
getShortcutCommands(): Record<string, EditorCommand> {
if (!this.shortcutCommands) {
const isIOS = CorePlatform.isIOS();
const metaKey = isIOS ? 'metaKey' : 'ctrlKey';
const shiftKey = isIOS ? 'ctrlKey' : 'shiftKey';
// Same shortcuts as TinyMCE:
// @see https://www.tiny.cloud/docs/advanced/keyboard-shortcuts/
const shortcuts: { code: string; modifiers: (keyof KeyboardShortcut)[]; command: EditorCommand }[] = [
{
code: 'KeyB',
modifiers: [metaKey],
command: {
name: 'bold',
parameters: 'strong',
},
},
{
code: 'KeyI',
modifiers: [metaKey],
command: {
name: 'italic',
parameters: 'em',
},
},
{
code: 'KeyU',
modifiers: [metaKey],
command: {
name: 'underline',
parameters: 'u',
},
},
{
code: 'Digit3',
modifiers: ['altKey', shiftKey],
command: {
name: 'h3',
parameters: 'block',
},
},
{
code: 'Digit4',
modifiers: ['altKey', shiftKey],
command: {
name: 'h4',
parameters: 'block',
},
},
{
code: 'Digit5',
modifiers: ['altKey', shiftKey],
command: {
name: 'h5',
parameters: 'block',
},
},
{
code: 'Digit7',
modifiers: ['altKey', shiftKey],
command: {
name: 'p',
parameters: 'block',
},
},
];
this.shortcutCommands = shortcuts.reduce((shortcuts, { code, modifiers, command }) => {
const id = this.getShortcutId({
code: code,
altKey: modifiers.includes('altKey'),
metaKey: modifiers.includes('metaKey'),
shiftKey: modifiers.includes('shiftKey'),
ctrlKey: modifiers.includes('ctrlKey'),
});
shortcuts[id] = command;
return shortcuts;
}, {} as Record<string, EditorCommand>);
}
return this.shortcutCommands;
}
/**
* Get a unique identifier for a given keyboard shortcut.
*
* @param shortcut Shortcut.
* @returns Identifier.
*/
protected getShortcutId(shortcut: KeyboardShortcut): string {
return (shortcut.altKey ? '1' : '0')
+ (shortcut.metaKey ? '1' : '0')
+ (shortcut.shiftKey ? '1' : '0')
+ (shortcut.ctrlKey ? '1' : '0')
+ shortcut.code;
}
}
/**
* Combination
*/
type KeyboardShortcut = Pick<KeyboardEvent, 'code' | 'altKey' | 'metaKey' | 'ctrlKey' | 'shiftKey'>;
/**
* Editor command.
*/
interface EditorCommand {
name: string;
parameters?: string;
} }