MOBILE-4565 rte: Fix rte focus and blur problems

main
Pau Ferrer Ocaña 2024-04-22 14:37:29 +02:00
parent 64ce8c78f6
commit f9ddfb48c9
6 changed files with 72 additions and 38 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,8 +1,7 @@
<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)" (keydown)="onKeyDown($event)" (keyup)="onChange()" (change)="onChange()" (paste)="onChange()" (blur)="hideToolbar($event)" (keydown)="onKeyDown($event)">
(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

@ -23,6 +23,18 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
border: 1px solid var(--stroke);
border-radius: var(--mdl-shape-borderRadius-md);
&:focus-within {
border-color: var(--a11y-focus-color);
border-width: 2px;
outline: none !important;
}
.core-rte-editor, .core-textarea, textarea {
outline: none !important;
}
.core-rte-editor-container { .core-rte-editor-container {
max-height: calc(100% - 46px); max-height: calc(100% - 46px);
@ -105,7 +117,6 @@
flex-grow: 0; flex-grow: 0;
flex-shrink: 0; flex-shrink: 0;
background-color: var(--toobar-background); background-color: var(--toobar-background);
padding-top: 5px;
border-top: 1px solid var(--stroke); border-top: 1px solid var(--stroke);
swiper-container { swiper-container {
@ -122,7 +133,7 @@
height: 36px; height: 36px;
padding-right: 6px; padding-right: 6px;
padding-left: 6px; padding-left: 6px;
margin: 0 auto; margin: 2px auto;
font-size: 18px; font-size: 18px;
background-color: var(--toobar-background); background-color: var(--toobar-background);
border-radius: var(--mdl-shape-borderRadius-xs); border-radius: var(--mdl-shape-borderRadius-xs);

View File

@ -115,6 +115,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
protected keyboardObserver?: CoreEventObserver; protected keyboardObserver?: CoreEventObserver;
protected resetObserver?: CoreEventObserver; protected resetObserver?: CoreEventObserver;
protected labelObserver?: MutationObserver; protected labelObserver?: MutationObserver;
protected contentObserver?: MutationObserver;
protected initHeightInterval?: number; protected initHeightInterval?: number;
protected isCurrentView = true; protected isCurrentView = true;
protected toolbarButtonWidth = 44; protected toolbarButtonWidth = 44;
@ -126,7 +127,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
protected draftWasRestored = false; protected draftWasRestored = false;
protected originalContent?: string; protected originalContent?: string;
protected resizeFunction?: () => Promise<number>; protected resizeFunction?: () => Promise<number>;
protected selectionChangeFunction?: () => void; protected selectionChangeFunction = (): void => this.updateToolbarStyles();
protected languageChangedSubscription?: Subscription; protected languageChangedSubscription?: Subscription;
protected resizeListener?: CoreEventObserver; protected resizeListener?: CoreEventObserver;
protected domPromise?: CoreCancellablePromise<void>; protected domPromise?: CoreCancellablePromise<void>;
@ -226,6 +227,15 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
} }
ionItem.classList.add('item-rte'); ionItem.classList.add('item-rte');
if (this.editorElement) {
const debounceMutation = CoreUtils.debounce(() => {
this.onChange();
}, 20);
this.contentObserver = new MutationObserver(debounceMutation);
this.contentObserver.observe(this.editorElement, { childList: true, subtree: true, characterData: true });
}
const label = ionItem.querySelector('ion-label'); const label = ionItem.querySelector('ion-label');
if (!label) { if (!label) {
@ -253,7 +263,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
protected setListeners(): void { protected setListeners(): void {
// Listen for changes on the control to update the editor (if it is updated from outside of this component). // Listen for changes on the control to update the editor (if it is updated from outside of this component).
this.valueChangeSubscription = this.control?.valueChanges.subscribe((newValue) => { this.valueChangeSubscription = this.control?.valueChanges.subscribe((newValue) => {
if (this.draftWasRestored && this.originalContent == newValue) { if (this.draftWasRestored && this.originalContent === newValue) {
// A draft was restored and the content hasn't changed in the site. Use the draft value instead of this one. // A draft was restored and the content hasn't changed in the site. Use the draft value instead of this one.
this.control?.setValue(this.lastDraft, { emitEvent: false }); this.control?.setValue(this.lastDraft, { emitEvent: false });
@ -282,7 +292,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
this.windowResized(); this.windowResized();
}, 50); }, 50);
document.addEventListener('selectionchange', this.selectionChangeFunction = () => this.updateToolbarStyles()); document.addEventListener('selectionchange', this.selectionChangeFunction);
this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => { this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, () => {
// Opening or closing the keyboard also calls the resize function, but sometimes the resize is called too soon. // Opening or closing the keyboard also calls the resize function, but sometimes the resize is called too soon.
@ -304,8 +314,6 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param event Event * @param event Event
*/ */
onKeyDown(event: KeyboardEvent): void { onKeyDown(event: KeyboardEvent): void {
this.onChange();
const shortcutId = this.getShortcutId(event); const shortcutId = this.getShortcutId(event);
const commands = this.getShortcutCommands(); const commands = this.getShortcutCommands();
const command = commands[shortcutId]; const command = commands[shortcutId];
@ -364,7 +372,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
// Get first children with content, not fixed. // Get first children with content, not fixed.
let scrollContentHeight = 0; let scrollContentHeight = 0;
while (scrollContentHeight == 0 && content?.children) { while (scrollContentHeight === 0 && content?.children) {
const children = Array.from(content.children) const children = Array.from(content.children)
.filter((element) => element.slot !== 'fixed' && !element.classList.contains('core-loading-container')); .filter((element) => element.slot !== 'fixed' && !element.classList.contains('core-loading-container'));
@ -489,7 +497,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param event The event. * @param event The event.
*/ */
async toggleEditor(event: Event): Promise<void> { async toggleEditor(event: Event): Promise<void> {
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -581,7 +589,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @returns If value is null only a white space. * @returns If value is null only a white space.
*/ */
protected isNullOrWhiteSpace(value: string | null | undefined): boolean { protected isNullOrWhiteSpace(value: string | null | undefined): boolean {
if (value == null || value === undefined) { if (value === null || value === undefined) {
return true; return true;
} }
@ -602,10 +610,17 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
} }
if (this.isNullOrWhiteSpace(value)) { if (this.isNullOrWhiteSpace(value)) {
this.editorElement.innerHTML = '<p></p>'; // Avoid loops.
if (this.editorElement.innerHTML !== '<p></p>') {
this.editorElement.innerHTML = '<p></p>';
}
this.textarea.value = ''; this.textarea.value = '';
} else { } else {
this.editorElement.innerHTML = value || ''; value = value || '';
// Avoid loops.
if (this.editorElement.innerHTML !== value) {
this.editorElement.innerHTML = value;
}
this.textarea.value = value; this.textarea.value = value;
this.treatExternalContent(); this.treatExternalContent();
} }
@ -637,7 +652,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* toolbar styles button when set. * toolbar styles button when set.
*/ */
buttonAction(event: Event, command: string, parameters?: string): void { buttonAction(event: Event, command: string, parameters?: string): void {
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -659,7 +674,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param command.parameters Command parameters. * @param command.parameters Command parameters.
*/ */
protected executeCommand({ name: command, parameters }: EditorCommand): void { protected executeCommand({ name: command, parameters }: EditorCommand): void {
if (parameters == 'block') { if (parameters === 'block') {
// eslint-disable-next-line deprecation/deprecation // eslint-disable-next-line deprecation/deprecation
document.execCommand('formatBlock', false, '<' + command + '>'); document.execCommand('formatBlock', false, '<' + command + '>');
@ -676,7 +691,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
// Modern browsers are using non a11y tags, so replace them. // Modern browsers are using non a11y tags, so replace them.
if (command === 'bold') { if (command === 'bold') {
this.replaceTags(['b'], ['strong']); this.replaceTags(['b'], ['strong']);
} else if (command == 'italic') { } else if (command === 'italic') {
this.replaceTags(['i'], ['em']); this.replaceTags(['i'], ['em']);
} }
} }
@ -715,14 +730,14 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param event Event. * @param event Event.
* @param force If true it will not check the target of the event. * @param force If true it will not check the target of the event.
*/ */
hideToolbar(event: Event, force = false): void { hideToolbar(event: FocusEvent | KeyboardEvent | MouseEvent, force = false): void {
if (!force && event.target && this.element.contains(event.target as HTMLElement)) { if (!force && event.target && this.element.contains(event.target as HTMLElement)) {
// Do not hide if clicked inside the editor area, except forced. // Do not hide if clicked inside the editor area, except forced.
return; return;
} }
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -748,7 +763,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
/** /**
* Show the toolbar. * Show the toolbar.
*/ */
showToolbar(event: Event): void { showToolbar(event: FocusEvent): void {
this.updateToolbarButtons(); this.updateToolbarButtons();
this.element.classList.add('ion-touched'); this.element.classList.add('ion-touched');
@ -779,14 +794,14 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* @param event Event. * @param event Event.
*/ */
downAction(event: Event): void { downAction(event: Event): void {
if (event.type == 'keydown' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keydown' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
const selection = window.getSelection()?.toString(); const selection = window.getSelection()?.toString();
// When RTE is focused with a whole paragraph in desktop the stopBubble will not fire click. // When RTE is focused with a whole paragraph in desktop the stopBubble will not fire click.
if (CorePlatform.isMobile() || !this.rteEnabled || document.activeElement != this.editorElement || selection == '') { if (CorePlatform.isMobile() || !this.rteEnabled || document.activeElement != this.editorElement || selection === '') {
this.stopBubble(event); this.stopBubble(event);
} }
} }
@ -795,7 +810,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* Method that shows the next toolbar buttons. * Method that shows the next toolbar buttons.
*/ */
async toolbarNext(event: Event): Promise<void> { async toolbarNext(event: Event): Promise<void> {
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -813,7 +828,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* Method that shows the previous toolbar buttons. * Method that shows the previous toolbar buttons.
*/ */
async toolbarPrev(event: Event): Promise<void> { async toolbarPrev(event: Event): Promise<void> {
if (event.type == 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -831,7 +846,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
* Update the number of toolbar buttons displayed. * Update the number of toolbar buttons displayed.
*/ */
async updateToolbarButtons(): Promise<void> { async updateToolbarButtons(): Promise<void> {
if (!this.isCurrentView || !this.toolbar || !this.toolbarSlides || this.element.offsetParent == null) { if (!this.isCurrentView || !this.toolbar || !this.toolbarSlides || this.element.offsetParent === null) {
// Don't calculate if component isn't in current view, the calculations are wrong. // Don't calculate if component isn't in current view, the calculations are wrong.
return; return;
} }
@ -879,15 +894,18 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
*/ */
updateToolbarStyles(): void { updateToolbarStyles(): void {
const node = window.getSelection()?.focusNode; const node = window.getSelection()?.focusNode;
if (!node) {
if (!node || !this.element.contains(node)) {
return; return;
} }
let element = node.nodeType == 1 ? node as HTMLElement : node.parentElement; let element = node.nodeType === 1 ? node as HTMLElement : node.parentElement;
const styles = {}; const styles = {};
while (element != null && element !== this.editorElement) { while (element !== null && element !== this.editorElement) {
const tagName = element.tagName.toLowerCase(); const tagName = element.tagName.toLowerCase();
if (this.toolbarStyles[tagName]) { if (this.toolbarStyles[tagName]) {
styles[tagName] = 'true'; styles[tagName] = 'true';
} }
@ -906,7 +924,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
/** /**
* Check if should auto save drafts. * Check if should auto save drafts.
* *
* @returns {boolean} Whether it should auto save drafts. * @returns Whether it should auto save drafts.
*/ */
protected shouldAutoSaveDrafts(): boolean { protected shouldAutoSaveDrafts(): boolean {
return !!CoreSites.getCurrentSite() && return !!CoreSites.getCurrentSite() &&
@ -943,8 +961,8 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
let draftText = entry.drafttext || ''; let draftText = entry.drafttext || '';
// Revert untouched editor contents to an empty string. // Revert untouched editor contents to an empty string.
if (draftText == '<p></p>' || draftText == '<p><br></p>' || draftText == '<br>' || if (draftText === '<p></p>' || draftText === '<p><br></p>' || draftText === '<br>' ||
draftText == '<p>&nbsp;</p>' || draftText == '<p><br>&nbsp;</p>') { draftText === '<p>&nbsp;</p>' || draftText === '<p><br>&nbsp;</p>') {
draftText = ''; draftText = '';
} }
@ -977,7 +995,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
const newText = this.control.value ?? ''; const newText = this.control.value ?? '';
if (this.lastDraft == newText) { if (this.lastDraft === newText) {
// Text hasn't changed, nothing to save. // Text hasn't changed, nothing to save.
return; return;
} }
@ -1009,7 +1027,7 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
this.resetObserver = CoreEvents.on(CoreEvents.FORM_ACTION, async (data: CoreEventFormActionData) => { this.resetObserver = CoreEvents.on(CoreEvents.FORM_ACTION, async (data: CoreEventFormActionData) => {
const form = this.element.closest('form'); const form = this.element.closest('form');
if (data.form && form && data.form == form) { if (data.form && form && data.form === form) {
try { try {
await CoreEditorOffline.deleteDraft( await CoreEditorOffline.deleteDraft(
this.contextLevel || ContextLevel.SYSTEM, this.contextLevel || ContextLevel.SYSTEM,
@ -1048,7 +1066,7 @@ 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)) { if (event.type === 'keyup' && !this.isValidKeyboardKey(<KeyboardEvent>event)) {
return; return;
} }
@ -1097,14 +1115,20 @@ export class CoreEditorRichTextEditorComponent implements OnInit, AfterViewInit,
ngOnDestroy(): void { ngOnDestroy(): void {
this.valueChangeSubscription?.unsubscribe(); this.valueChangeSubscription?.unsubscribe();
this.languageChangedSubscription?.unsubscribe(); this.languageChangedSubscription?.unsubscribe();
this.selectionChangeFunction && document.removeEventListener('selectionchange', this.selectionChangeFunction);
document.removeEventListener('selectionchange', this.selectionChangeFunction);
clearInterval(this.initHeightInterval); clearInterval(this.initHeightInterval);
clearInterval(this.autoSaveInterval); clearInterval(this.autoSaveInterval);
clearTimeout(this.hideMessageTimeout); clearTimeout(this.hideMessageTimeout);
this.resetObserver?.off(); this.resetObserver?.off();
this.keyboardObserver?.off(); this.keyboardObserver?.off();
this.labelObserver?.disconnect();
this.resizeListener?.off(); this.resizeListener?.off();
this.labelObserver?.disconnect();
this.contentObserver?.disconnect();
this.domPromise?.cancel(); this.domPromise?.cancel();
this.buttonsDomPromise?.cancel(); this.buttonsDomPromise?.cancel();
} }

View File

@ -235,7 +235,7 @@ export class CoreLoginSitePage implements OnInit {
/** /**
* Validate Url. * Validate Url.
* *
* @returns {ValidatorFn} Validation results. * @returns Validation results.
*/ */
protected moodleUrlValidator(): ValidatorFn { protected moodleUrlValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {

View File

@ -1129,7 +1129,7 @@ td {
@include core-focus-background(); @include core-focus-background();
} }
:not(.hydrated):not(.native-input) { // Not an ionic component. :not(.hydrated):not(.native-input):not(.native-textarea) { // Not an ionic component.
@include core-focus-outline(); @include core-focus-outline();
} }