diff --git a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html index 49011943d..6903fb29f 100644 --- a/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html +++ b/src/addons/userprofilefield/textarea/component/addon-user-profile-field-textarea.html @@ -14,7 +14,7 @@ {{ field.name }} - + \ No newline at end of file diff --git a/src/addons/userprofilefield/textarea/textarea.module.ts b/src/addons/userprofilefield/textarea/textarea.module.ts index a3352bc9d..d42d37ab1 100644 --- a/src/addons/userprofilefield/textarea/textarea.module.ts +++ b/src/addons/userprofilefield/textarea/textarea.module.ts @@ -23,7 +23,7 @@ import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profi import { AddonUserProfileFieldTextareaComponent } from './component/textarea'; import { CoreComponentsModule } from '@components/components.module'; import { CoreDirectivesModule } from '@directives/directives.module'; -// @todo import { CoreEditorComponentsModule } from '@core/editor/components/components.module'; +import { CoreEditorComponentsModule } from '@features/editor/components/components.module'; @NgModule({ declarations: [ @@ -37,7 +37,7 @@ import { CoreDirectivesModule } from '@directives/directives.module'; ReactiveFormsModule, CoreComponentsModule, CoreDirectivesModule, - // CoreEditorComponentsModule, + CoreEditorComponentsModule, ], providers: [ { diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index fd8cbfd53..a1cb8137a 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -6,7 +6,7 @@ - { this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0); - this.slideOpts.slidesPerView = Math.min(this.maxSlides, this.numTabsShown); - this.slidesSwiper.params.slidesPerView = this.slideOpts.slidesPerView; + this.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) }; this.calculateTabBarHeight(); await this.slides!.update(); - if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slideOpts.slidesPerView) { + if (!this.hasSliddenToInitial && this.selectedIndex && this.selectedIndex >= this.slidesOpts.slidesPerView) { this.hasSliddenToInitial = true; this.shouldSlideToInitial = true; @@ -637,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe window.removeEventListener('resize', this.resizeFunction); } this.stackEventsSubscription?.unsubscribe(); + this.languageChangedSubscription.unsubscribe(); } } diff --git a/src/core/features/editor/components/components.module.ts b/src/core/features/editor/components/components.module.ts new file mode 100644 index 000000000..b57a73036 --- /dev/null +++ b/src/core/features/editor/components/components.module.ts @@ -0,0 +1,42 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreEditorRichTextEditorComponent } from './rich-text-editor/rich-text-editor'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + CoreEditorRichTextEditorComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + ], + providers: [ + ], + exports: [ + CoreEditorRichTextEditorComponent, + ], + entryComponents: [ + CoreEditorRichTextEditorComponent, + ], +}) +export class CoreEditorComponentsModule {} diff --git a/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html new file mode 100644 index 000000000..a409f709c --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html @@ -0,0 +1,113 @@ + + + + + + + + + + {{ infoMessage | translate }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 3 + + + + + 4 + + + + + 5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss new file mode 100644 index 000000000..5f641ead3 --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.scss @@ -0,0 +1,181 @@ +:host { + height: 40vh; + overflow: hidden; + min-height: 200px; /* Just in case vh is not supported */ + min-height: 40vh; + width: 100%; + display: flex; + flex-direction: column; + // @include darkmode() { + // background-color: $gray-darker; + // } + + .core-rte-editor-container { + max-height: calc(100% - 46px); + display: flex; + flex-direction: column; + flex-grow: 1; + &.toolbar-hidden { + max-height: 100%; + } + + .core-rte-info-message { + padding: 5px; + border-top: 1px solid var(--ion-color-secondary); + background: white; + flex-shrink: 1; + font-size: 1.4rem; + + .icon { + color: var(--ion-color-secondary); + } + } + } + + .core-rte-editor, .core-textarea { + padding: 2px; + margin: 2px; + width: 100%; + resize: none; + background-color: white; + flex-grow: 1; + // @include darkmode() { + // background-color: var(--gray-darker); + // color: var(--white); + // } + } + + .core-rte-editor { + flex-grow: 1; + flex-shrink: 1; + -webkit-user-select: auto !important; + word-wrap: break-word; + overflow-x: hidden; + overflow-y: auto; + cursor: text; + img { + // @include padding(null, null, null, 2px); + max-width: 95%; + width: auto; + } + &:empty:before { + content: attr(data-placeholder-text); + display: block; + color: var(--gray-light); + font-weight: bold; + + // @include darkmode() { + // color: $gray; + // } + } + + // Make empty elements selectable (to move the cursor). + *:empty:after { + content: '\200B'; + } + } + + .core-textarea { + flex-grow: 1; + flex-shrink: 1; + position: relative; + + textarea { + margin: 0 !important; + padding: 0; + height: 100% !important; + width: 100% !important; + resize: none; + overflow-x: hidden; + overflow-y: auto; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + } + + div.core-rte-toolbar { + display: flex; + width: 100%; + z-index: 1; + flex-grow: 0; + flex-shrink: 0; + background-color: var(--white); + + // @include darkmode() { + // background-color: $black; + // } + // @include padding(5px, null); + border-top: 1px solid var(--gray); + + ion-slides { + width: 240px; + flex-grow: 1; + flex-shrink: 1; + } + + button { + display: flex; + justify-content: center; + align-items: center; + width: 36px; + height: 36px; + padding-right: 6px; + padding-left: 6px; + margin: 0 auto; + font-size: 18px; + background-color: var(--white); + border-radius: 4px; + // @include core-transition(background-color, 200ms); + color: var(--ion-text-color); + cursor: pointer; + + // @include darkmode() { + // background-color: $black; + // color: $core-dark-text-color; + // } + + &.toolbar-button-enable { + width: 100%; + } + + &:active, &[aria-pressed="true"] { + background-color: var(--gray); + // @include darkmode() { + // background-color: $gray-dark; + // } + } + + &.toolbar-arrow { + width: 28px; + flex-grow: 0; + flex-shrink: 0; + opacity: 1; + // @include core-transition(opacity, 200ms); + + &:active { + background-color: var(--white); + // @include darkmode() { + // background-color: $black; + // } + } + + &.toolbar-arrow-hidden { + opacity: 0; + } + } + } + + &.toolbar-hidden { + visibility: none; + height: 0; + border: none; + } + } +} + +:host-context(.keyboard-is-open) { + min-height: 200px; +} \ No newline at end of file diff --git a/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts new file mode 100644 index 000000000..ac8e1a15b --- /dev/null +++ b/src/core/features/editor/components/rich-text-editor/rich-text-editor.ts @@ -0,0 +1,1057 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + ElementRef, + OnInit, + AfterContentInit, + OnDestroy, + Optional, +} from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { IonTextarea, IonContent, IonSlides } from '@ionic/angular'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreFilepool } from '@services/filepool'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Platform, Translate } from '@singletons'; +import { CoreEventFormActionData, CoreEventObserver, CoreEvents } from '@singletons/events'; +import { CoreEditorOffline } from '../../services/editor-offline'; + +/** + * Component to display a rich text editor if enabled. + * + * If enabled, this component will show a rich text editor. Otherwise it'll show a regular textarea. + * + * Example: + * + */ +@Component({ + selector: 'core-rich-text-editor', + templateUrl: 'core-editor-rich-text-editor.html', + styleUrls: ['rich-text-editor.scss'], +}) +export class CoreEditorRichTextEditorComponent implements OnInit, AfterContentInit, OnDestroy { + + // Based on: https://github.com/judgewest2000/Ionic3RichText/ + // @todo: Anchor button, fullscreen... + // @todo: Textarea height is not being updated when editor is resized. Height is calculated if any css is changed. + + @Input() placeholder = ''; // Placeholder to set in textarea. + @Input() control?: FormControl; // Form control. + @Input() name = 'core-rich-text-editor'; // Name to set to the textarea. + @Input() component?: string; // The component to link the files to. + @Input() componentId?: number; // An ID to use in conjunction with the component. + @Input() autoSave?: boolean | string; // Whether to auto-save the contents in a draft. Defaults to true. + @Input() contextLevel?: string; // The context level of the text. + @Input() contextInstanceId?: number; // The instance ID related to the context. + @Input() elementId?: string; // An ID to set to the element. + @Input() draftExtraParams?: Record; // Extra params to identify the draft. + @Output() contentChanged: EventEmitter; + + @ViewChild('editor') editor?: ElementRef; // WYSIWYG editor. + @ViewChild('textarea') textarea?: IonTextarea; // Textarea editor. + @ViewChild('toolbar') toolbar?: ElementRef; + @ViewChild(IonSlides) toolbarSlides?: IonSlides; + + protected readonly DRAFT_AUTOSAVE_FREQUENCY = 30000; + protected readonly RESTORE_MESSAGE_CLEAR_TIME = 6000; + protected readonly SAVE_MESSAGE_CLEAR_TIME = 2000; + + protected element: HTMLDivElement; + protected editorElement?: HTMLDivElement; + protected kbHeight = 0; // Last known keyboard height. + protected minHeight = 200; // Minimum height of the editor. + + protected valueChangeSubscription?: Subscription; + protected keyboardObserver?: CoreEventObserver; + protected resetObserver?: CoreEventObserver; + protected initHeightInterval?: number; + protected isCurrentView = true; + protected toolbarButtonWidth = 40; + protected toolbarArrowWidth = 28; + protected pageInstance: string; + protected autoSaveInterval?: number; + protected hideMessageTimeout?: number; + protected lastDraft = ''; + protected draftWasRestored = false; + protected originalContent?: string; + protected resizeFunction?: () => Promise; + protected selectionChangeFunction?: () => void; + protected languageChangedSubscription?: Subscription; + + rteEnabled = false; + isPhone = false; + toolbarHidden = false; + toolbarArrows = false; + toolbarPrevHidden = true; + toolbarNextHidden = false; + canScanQR = false; + infoMessage?: string; + direction = 'ltr'; + toolbarStyles = { + strong: 'false', + em: 'false', + u: 'false', + strike: 'false', + p: 'false', + h3: 'false', + h4: 'false', + h5: 'false', + ul: 'false', + ol: 'false', + }; + + slidesOpts = { + initialSlide: 0, + slidesPerView: 6, + centerInsufficientSlides: true, + }; + + constructor( + @Optional() protected content: IonContent, + elementRef: ElementRef, + ) { + this.contentChanged = new EventEmitter(); + this.element = elementRef.nativeElement as HTMLDivElement; + this.pageInstance = 'app_' + Date.now(); // Generate a "unique" ID based on timestamp. + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.canScanQR = CoreUtils.instance.canScanQR(); + this.isPhone = Platform.instance.is('mobile') && !Platform.instance.is('tablet'); + this.toolbarHidden = this.isPhone; + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + } + + /** + * Init editor. + */ + async ngAfterContentInit(): Promise { + this.rteEnabled = await CoreDomUtils.instance.isRichTextEditorEnabled(); + + // Setup the editor. + this.editorElement = this.editor?.nativeElement as HTMLDivElement; + this.setContent(this.control?.value); + this.originalContent = this.control?.value; + this.lastDraft = this.control?.value; + this.editorElement.onchange = this.onChange.bind(this); + this.editorElement.onkeyup = this.onChange.bind(this); + this.editorElement.onpaste = this.onChange.bind(this); + this.editorElement.oninput = this.onChange.bind(this); + this.editorElement.onkeydown = this.moveCursor.bind(this); + + // Use paragraph on enter. + document.execCommand('DefaultParagraphSeparator', false, 'p'); + + let i = 0; + this.initHeightInterval = window.setInterval(async () => { + const height = await this.maximizeEditorSize(); + if (i >= 5 || height != 0) { + clearInterval(this.initHeightInterval); + } + i++; + }, 750); + + this.setListeners(); + this.updateToolbarButtons(); + + if (this.elementId) { + // Prepend elementId with 'id_' like in web. Don't use a setter for this because the value shouldn't change. + this.elementId = 'id_' + this.elementId; + this.element.setAttribute('id', this.elementId); + } + + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + + if (this.shouldAutoSaveDrafts()) { + this.restoreDraft(); + + this.autoSaveDrafts(); + + this.deleteDraftOnSubmitOrCancel(); + } + } + + /** + * Set listeners and observers. + */ + protected setListeners(): void { + // 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) => { + 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. + this.control?.setValue(this.lastDraft, { emitEvent: false }); + + return; + } + + // Apply the new content. + this.setContent(newValue); + this.originalContent = newValue; + this.infoMessage = undefined; + + // Save a draft so the original content is saved. + this.lastDraft = newValue; + CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newValue, + newValue, + ); + }); + + this.resizeFunction = this.maximizeEditorSize.bind(this); + this.selectionChangeFunction = this.updateToolbarStyles.bind(this); + window.addEventListener('resize', this.resizeFunction!); + document.addEventListener('selectionchange', this.selectionChangeFunction!); + + this.keyboardObserver = CoreEvents.on(CoreEvents.KEYBOARD_CHANGE, (kbHeight: number) => { + this.kbHeight = kbHeight; + this.maximizeEditorSize(); + }); + + // Change the side when the language changes. + this.languageChangedSubscription = Translate.instance.onLangChange.subscribe(() => { + setTimeout(() => { + this.direction = Platform.instance.isRTL ? 'rtl' : 'ltr'; + }); + }); + } + + /** + * Resize editor to maximize the space occupied. + * + * @return Resolved with calculated editor size. + */ + protected maximizeEditorSize(): Promise { + // this.content.resize(); + + const deferred = CoreUtils.instance.promiseDefer(); + + setTimeout(async () => { + let contentVisibleHeight = await CoreDomUtils.instance.getContentHeight(this.content); + if (!CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + contentVisibleHeight -= this.kbHeight; + } + + if (contentVisibleHeight <= 0) { + deferred.resolve(0); + + return; + } + + setTimeout(async () => { + // Editor is ready, adjust Height if needed. + let height; + + if (CoreApp.instance.isAndroid()) { + // In Android we ignore the keyboard height because it is not part of the web view. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.getSurroundingHeight(this.element); + } else if (CoreApp.instance.isIOS() && this.kbHeight > 0 && CoreApp.instance.getPlatformMajorVersion() < 12) { + // Keyboard open in iOS 11 or previous. The window height changes when the keyboard is open. + height = window.innerHeight - this.getSurroundingHeight(this.element); + + if (this.element.getBoundingClientRect().top < 40) { + // In iOS sometimes the editor is placed below the status bar. Move the scroll a bit so it doesn't happen. + window.scrollTo(window.scrollX, window.scrollY - 40); + } + + } else { + // Header is fixed, use the content to calculate the editor height. + const contentHeight = await CoreDomUtils.instance.getContentHeight(this.content); + height = contentHeight - this.kbHeight - this.getSurroundingHeight(this.element); + } + + if (height > this.minHeight) { + this.element.style.height = CoreDomUtils.instance.formatPixelsSize(height - 1); + } else { + this.element.style.height = ''; + } + + deferred.resolve(height); + }, 100); + }, 100); + + return deferred.promise; + } + + /** + * Get the height of the surrounding elements from the current to the top element. + * + * @param element Directive DOM element to get surroundings elements from. + * @return Surrounding height in px. + */ + protected getSurroundingHeight(element: HTMLElement): number { + let height = 0; + + while (element.parentElement?.tagName != 'ION-CONTENT') { + const parent = element.parentElement!; + if (element.tagName && element.tagName != 'CORE-LOADING') { + for (let x = 0; x < parent.children.length; x++) { + const child = parent.children[x]; + if (child.tagName && child != element) { + height += CoreDomUtils.instance.getElementHeight(child, false, true, true); + } + } + } + element = parent; + } + + const computedStyle = getComputedStyle(element); + height += CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'paddingBottom'); + + if (element.parentElement?.tagName == 'ION-CONTENT') { + const cs2 = getComputedStyle(element); + + height -= CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingTop') + + CoreDomUtils.instance.getComputedStyleMeasure(cs2, 'paddingBottom'); + } + + return height; + } + + /** + * On change function to sync with form data. + */ + onChange(): void { + if (this.rteEnabled) { + if (!this.editorElement) { + return; + } + + if (this.isNullOrWhiteSpace(this.editorElement.innerText)) { + this.clearText(); + } else { + // The textarea and the form control must receive the original URLs. + this.restoreExternalContent(); + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.editorElement.innerHTML, { emitEvent: false }); + this.control?.markAsDirty(); + if (this.textarea) { + this.textarea.value = this.editorElement.innerHTML; + } + // Treat URLs again for the editor. + this.treatExternalContent(); + } + } else { + if (!this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(this.textarea.value || '')) { + this.clearText(); + } else { + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(this.textarea.value, { emitEvent: false }); + this.control?.markAsDirty(); + } + } + + 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. + * @return 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. + * + * @param parent Parent where to set the position. + * @param chars Number of chars where to place the caret. If not defined it will go to the end. + */ + protected setCurrentCursorPosition(parent: Node, chars?: number): void { + /** + * Loops round all the child text nodes within the supplied node and sets a range from the start of the initial node to + * the characters. + * + * @param node Node where to start. + * @param range Previous calculated range. + * @param chars Object with counting of characters (input-output param). + * @return Selection range. + */ + const setRange = (node: Node, range: Range, chars: { count: number }): Range => { + if (chars.count === 0) { + range.setEnd(node, 0); + } else if (node && chars.count > 0) { + if (node.hasChildNodes()) { + // Navigate through children. + for (let lp = 0; lp < node.childNodes.length; lp++) { + range = setRange(node.childNodes[lp], range, chars); + + if (chars.count === 0) { + break; + } + } + } else if ((node.textContent || '').length < chars.count) { + // Jump this node. + // @todo: empty nodes will be omitted. + chars.count -= (node.textContent || '').length; + } else { + // The cursor will be placed in this element. + range.setEnd(node, chars.count); + chars.count = 0; + } + } + + return range; + }; + + let range = document.createRange(); + if (typeof chars === 'undefined') { + // Select all so it will go to the end. + range.selectNode(parent); + range.selectNodeContents(parent); + } else if (chars < 0 || chars > (parent.textContent || '').length) { + return; + } else { + range.selectNode(parent); + range.setStart(parent, 0); + range = setRange(parent, range, { count: chars }); + } + + if (range) { + const selection = window.getSelection(); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + + /** + * Toggle from rte editor to textarea syncing values. + * + * @param event The event. + */ + async toggleEditor(event: Event): Promise { + this.stopBubble(event); + + this.setContent(this.control?.value || ''); + + this.rteEnabled = !this.rteEnabled; + + // Set focus and cursor at the end. + // Modify the DOM directly so the keyboard stays open. + if (this.rteEnabled) { + // Update tags for a11y. + this.replaceTags('b', 'strong'); + this.replaceTags('i', 'em'); + this.editorElement?.removeAttribute('hidden'); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.setAttribute('hidden', ''); + this.editorElement?.focus(); + } else { + this.editorElement?.setAttribute('hidden', ''); + const textareaInputElement = await this.textarea?.getInputElement(); + textareaInputElement?.removeAttribute('hidden'); + this.textarea?.setFocus(); + } + } + + /** + * Treat elements that can contain external content. + * We only search for images because the editor should receive unfiltered text, so the multimedia filter won't be applied. + * Treating videos and audios in here is complex, so if a user manually adds one he won't be able to play it in the editor. + */ + protected treatExternalContent(): void { + if (!CoreSites.instance.isLoggedIn() || !this.editorElement) { + // Only treat external content if the user is logged in. + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + const siteId = CoreSites.instance.getCurrentSiteId(); + const canDownloadFiles = CoreSites.instance.getCurrentSite()!.canDownloadFiles(); + elements.forEach(async (el) => { + if (el.getAttribute('data-original-src')) { + // Already treated. + return; + } + + const url = el.src; + + if (!url || !CoreUrlUtils.instance.isDownloadableUrl(url) || + (!canDownloadFiles && CoreUrlUtils.instance.isPluginFileUrl(url))) { + // Nothing to treat. + return; + } + + // Check if it's downloaded. + const finalUrl = await CoreFilepool.instance.getSrcByUrl(siteId, url, this.component, this.componentId); + + // Check again if it's already treated, this function can be called concurrently more than once. + if (!el.getAttribute('data-original-src')) { + el.setAttribute('data-original-src', el.src); + el.setAttribute('src', finalUrl); + } + }); + } + + /** + * Reverts changes made by treatExternalContent. + */ + protected restoreExternalContent(): void { + if (!this.editorElement) { + return; + } + + const elements = Array.from(this.editorElement.querySelectorAll('img')); + elements.forEach((el) => { + const originalUrl = el.getAttribute('data-original-src'); + if (originalUrl) { + el.setAttribute('src', originalUrl); + el.removeAttribute('data-original-src'); + } + }); + } + + /** + * Check if text is empty. + * + * @param value text + */ + protected isNullOrWhiteSpace(value: string | null): boolean { + if (value == null || typeof value == 'undefined') { + return true; + } + + value = value.replace(/[\n\r]/g, ''); + value = value.split(' ').join(''); + + return value.length === 0; + } + + /** + * Set the content of the textarea and the editor element. + * + * @param value New content. + */ + protected setContent(value: string | null): void { + if (!this.editorElement || !this.textarea) { + return; + } + + if (this.isNullOrWhiteSpace(value)) { + this.editorElement.innerHTML = ''; + this.textarea.value = ''; + } else { + this.editorElement.innerHTML = value!; + this.textarea.value = value; + this.treatExternalContent(); + } + } + + /** + * Clear the text. + */ + clearText(): void { + this.setContent(null); + + // Don't emit event so our valueChanges doesn't get notified by this change. + this.control?.setValue(null, { emitEvent: false }); + + setTimeout(() => { + if (this.rteEnabled && this.editorElement) { + this.setCurrentCursorPosition(this.editorElement); + } + }, 1); + } + + /** + * Execute an action over the selected text. + * API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand + * + * @param event Event data + * @param command Command to execute. + * @param parameters If parameters is set to block, a formatBlock command will be performed. Otherwise it will switch the + * toolbar styles button when set. + */ + buttonAction(event: Event, command: string, parameters?: string): void { + this.stopBubble(event); + + if (!command) { + return; + } + + if (parameters == 'block') { + document.execCommand('formatBlock', false, '<' + command + '>'); + + return; + } + + if (parameters) { + this.toolbarStyles[parameters] = this.toolbarStyles[parameters] == 'true' ? 'false' : 'true'; + } + + document.execCommand(command, false); + + // Modern browsers are using non a11y tags, so replace them. + if (command == 'bold') { + this.replaceTags('b', 'strong'); + } else if (command == 'italic') { + this.replaceTags('i', 'em'); + } + } + + /** + * Replace tags for a11y. + * + * @param originTag Origin tag to be replaced. + * @param destinationTag Destination tag to replace. + */ + protected replaceTags(originTag: string, destinationTag: string): void { + if (!this.editorElement) { + return; + } + + const elems = Array.from(this.editorElement.getElementsByTagName(originTag)); + + elems.forEach((elem) => { + const newElem = document.createElement(destinationTag); + newElem.innerHTML = elem.innerHTML; + + if (elem.hasAttributes()) { + const attrs = Array.from(elem.attributes); + attrs.forEach((attr) => { + newElem.setAttribute(attr.name, attr.value); + }); + } + + elem.parentNode?.replaceChild(newElem, elem); + }); + + this.onChange(); + } + + /** + * Focus editor when click the area. + */ + focusRTE(): void { + if (this.rteEnabled) { + this.editorElement?.focus(); + } else { + this.textarea?.setFocus(); + } + } + + /** + * Hide the toolbar in phone mode. + */ + hideToolbar(event: Event): void { + this.stopBubble(event); + + if (this.isPhone) { + this.toolbarHidden = true; + } + } + + /** + * Show the toolbar. + */ + showToolbar(event: Event): void { + this.stopBubble(event); + + this.editorElement?.focus(); + this.toolbarHidden = false; + } + + /** + * Stop event default and propagation. + * + * @param event Event. + */ + stopBubble(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + } + + /** + * When a button is clicked first we should stop event propagation, but it has some cases to not. + * + * @param event Event. + */ + mouseDownAction(event: Event): void { + const selection = window.getSelection()?.toString(); + + // When RTE is focused with a whole paragraph in desktop the stopBubble will not fire click. + if (CoreApp.instance.isMobile() || !this.rteEnabled || document.activeElement != this.editorElement || selection == '') { + this.stopBubble(event); + } + } + + /** + * Method that shows the next toolbar buttons. + */ + async toolbarNext(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarNextHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) + this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Method that shows the previous toolbar buttons. + */ + async toolbarPrev(event: Event): Promise { + this.stopBubble(event); + + if (!this.toolbarPrevHidden) { + const currentIndex = await this.toolbarSlides?.getActiveIndex(); + this.toolbarSlides?.slideTo((currentIndex || 0) - this.slidesOpts.slidesPerView); + } + + await this.updateToolbarArrows(); + } + + /** + * Update the number of toolbar buttons displayed. + */ + async updateToolbarButtons(): Promise { + if (!this.isCurrentView || !this.toolbar || !this.toolbarSlides) { + // Don't calculate if component isn't in current view, the calculations are wrong. + return; + } + + const length = await this.toolbarSlides.length(); + + const width = CoreDomUtils.instance.getElementWidth(this.toolbar.nativeElement); + + if (!width) { + // Width is not available yet, try later. + setTimeout(this.updateToolbarButtons.bind(this), 100); + + return; + } + + if (width > length * this.toolbarButtonWidth) { + this.slidesOpts = { ...this.slidesOpts, slidesPerView: length }; + this.toolbarArrows = false; + } else { + const slidesPerView = Math.floor((width - this.toolbarArrowWidth * 2) / this.toolbarButtonWidth); + this.slidesOpts = { ...this.slidesOpts, slidesPerView }; + this.toolbarArrows = true; + } + + await this.toolbarSlides.update(); + + await this.updateToolbarArrows(); + } + + /** + * Show or hide next/previous toolbar arrows. + */ + async updateToolbarArrows(): Promise { + if (!this.toolbarSlides) { + return; + } + + const currentIndex = await this.toolbarSlides.getActiveIndex(); + const length = await this.toolbarSlides.length(); + this.toolbarPrevHidden = currentIndex <= 0; + this.toolbarNextHidden = currentIndex + this.slidesOpts.slidesPerView >= length; + } + + /** + * Update highlighted toolbar styles. + */ + updateToolbarStyles(): void { + const node = window.getSelection()?.focusNode; + if (!node) { + return; + } + + let element = node.nodeType == 1 ? node as HTMLElement : node.parentElement; + const styles = {}; + + while (element != null && element !== this.editorElement) { + const tagName = element.tagName.toLowerCase(); + if (this.toolbarStyles[tagName]) { + styles[tagName] = 'true'; + } + element = element.parentElement; + } + + for (const tagName in this.toolbarStyles) { + this.toolbarStyles[tagName] = 'false'; + } + + if (element === this.editorElement) { + Object.assign(this.toolbarStyles, styles); + } + } + + /** + * Check if should auto save drafts. + * + * @return {boolean} Whether it should auto save drafts. + */ + protected shouldAutoSaveDrafts(): boolean { + return !!CoreSites.instance.getCurrentSite() && + (typeof this.autoSave == 'undefined' || CoreUtils.instance.isTrueOrOne(this.autoSave)) && + typeof this.contextLevel != 'undefined' && + typeof this.contextInstanceId != 'undefined' && + typeof this.elementId != 'undefined'; + } + + /** + * Restore a draft if there is any. + * + * @return Promise resolved when done. + */ + protected async restoreDraft(): Promise { + try { + const entry = await CoreEditorOffline.instance.resumeDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + this.originalContent, + ); + + if (typeof entry == 'undefined') { + // No draft found. + return; + } + + let draftText = entry.drafttext || ''; + + // Revert untouched editor contents to an empty string. + if (draftText == '' || draftText == '' || draftText == '' || + draftText == ' ' || draftText == ' ') { + draftText = ''; + } + + if (draftText !== '' && this.control && draftText != this.control.value) { + // Restore the draft. + this.control.setValue(draftText, { emitEvent: false }); + this.setContent(draftText); + this.lastDraft = draftText; + this.draftWasRestored = true; + this.originalContent = entry.originalcontent; + + if (entry.drafttext != entry.originalcontent) { + // Notify the user. + this.showMessage('core.editor.textrecovered', this.RESTORE_MESSAGE_CLEAR_TIME); + } + } + } catch (error) { + // Ignore errors, shouldn't happen. + } + } + + /** + * Automatically save drafts every certain time. + */ + protected autoSaveDrafts(): void { + this.autoSaveInterval = window.setInterval(async () => { + if (!this.control) { + return; + } + + const newText = this.control.value; + + if (this.lastDraft == newText) { + // Text hasn't changed, nothing to save. + return; + } + + try { + await CoreEditorOffline.instance.saveDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + this.pageInstance, + newText, + this.originalContent, + ); + + // Draft saved, notify the user. + this.lastDraft = newText; + this.showMessage('core.editor.autosavesucceeded', this.SAVE_MESSAGE_CLEAR_TIME); + } catch (error) { + // Error saving draft. + } + }, this.DRAFT_AUTOSAVE_FREQUENCY); + } + + /** + * Delete the draft when the form is submitted or cancelled. + */ + protected deleteDraftOnSubmitOrCancel(): void { + this.resetObserver = CoreEvents.on(CoreEvents.FORM_ACTION, async (data: CoreEventFormActionData) => { + const form = this.element.closest('form'); + + if (data.form && form && data.form == form) { + try { + await CoreEditorOffline.instance.deleteDraft( + this.contextLevel || '', + this.contextInstanceId || 0, + this.elementId || '', + this.draftExtraParams || {}, + ); + } catch (error) { + // Error deleting draft. Shouldn't happen. + } + } + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Show a message. + * + * @param message Identifier of the message to display. + * @param timeout Number of milliseconds when to remove the message. + */ + protected showMessage(message: string, timeout: number): void { + clearTimeout(this.hideMessageTimeout); + + this.infoMessage = message; + + this.hideMessageTimeout = window.setTimeout(() => { + this.hideMessageTimeout = undefined; + this.infoMessage = undefined; + }, timeout); + } + + /** + * Scan a QR code and put its text in the editor. + * + * @param event Event data + * @return Promise resolved when done. + */ + async scanQR(event: Event): Promise { + this.stopBubble(event); + + // Scan for a QR code. + const text = await CoreUtils.instance.scanQR(); + + if (text) { + document.execCommand('insertText', false, text); + } + // this.content.resize(); // Resize content, otherwise the content height becomes 1 for some reason. + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + + this.updateToolbarButtons(); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.valueChangeSubscription?.unsubscribe(); + this.languageChangedSubscription?.unsubscribe(); + window.removeEventListener('resize', this.resizeFunction!); + document.removeEventListener('selectionchange', this.selectionChangeFunction!); + clearInterval(this.initHeightInterval); + clearInterval(this.autoSaveInterval); + clearTimeout(this.hideMessageTimeout); + this.resetObserver?.off(); + this.keyboardObserver?.off(); + } + +} diff --git a/src/core/features/editor/editor.module.ts b/src/core/features/editor/editor.module.ts new file mode 100644 index 000000000..012860b7c --- /dev/null +++ b/src/core/features/editor/editor.module.ts @@ -0,0 +1,35 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { CoreEditorComponentsModule } from './components/components.module'; +import { SITE_SCHEMA } from './services/database/editor'; + +@NgModule({ + declarations: [ + ], + imports: [ + CoreEditorComponentsModule, + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [SITE_SCHEMA], + multi: true, + }, + ], +}) +export class CoreEditorModule {} diff --git a/src/core/features/editor/lang/en.json b/src/core/features/editor/lang/en.json new file mode 100644 index 000000000..508c6ddb0 --- /dev/null +++ b/src/core/features/editor/lang/en.json @@ -0,0 +1,17 @@ +{ + "autosavesucceeded": "Draft saved.", + "bold": "Bold", + "clear": "Clear formatting", + "h3": "Heading (large)", + "h4": "Heading (medium)", + "h5": "Heading (small)", + "hidetoolbar": "Hide toolbar", + "italic": "Italic", + "orderedlist": "Ordered list", + "p": "Paragraph", + "strike": "Strike through", + "textrecovered": "A draft version of this text was automatically restored.", + "toggle": "Toggle editor", + "underline": "Underline", + "unorderedlist": "Unordered list" +} \ No newline at end of file diff --git a/src/core/features/editor/services/database/editor.ts b/src/core/features/editor/services/database/editor.ts new file mode 100644 index 000000000..1a0eac575 --- /dev/null +++ b/src/core/features/editor/services/database/editor.ts @@ -0,0 +1,93 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreEditorOffline service. + */ +export const DRAFT_TABLE = 'editor_draft'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreEditorProvider', + version: 1, + tables: [ + { + name: DRAFT_TABLE, + columns: [ + { + name: 'contextlevel', + type: 'TEXT', + }, + { + name: 'contextinstanceid', + type: 'INTEGER', + }, + { + name: 'elementid', + type: 'TEXT', + }, + { + name: 'extraparams', // Moodle web uses a page hash built with URL. App will use some params stringified. + type: 'TEXT', + }, + { + name: 'drafttext', + type: 'TEXT', + notNull: true, + }, + { + name: 'pageinstance', + type: 'TEXT', + notNull: true, + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true, + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true, + }, + { + name: 'originalcontent', + type: 'TEXT', + }, + ], + primaryKeys: ['contextlevel', 'contextinstanceid', 'elementid', 'extraparams'], + }, + ], +}; + +/** + * Primary data to identify a stored draft. + */ +export type CoreEditorDraftPrimaryData = { + contextlevel: string; // Context level. + contextinstanceid: number; // The instance ID related to the context. + elementid: string; // Element ID. + extraparams: string; // Extra params stringified. +}; + +/** + * Draft data stored. + */ +export type CoreEditorDraft = CoreEditorDraftPrimaryData & { + drafttext?: string; // Draft text stored. + pageinstance?: string; // Unique identifier to prevent storing data from several sources at the same time. + timecreated?: number; // Time created. + timemodified?: number; // Time modified. + originalcontent?: string; // Original content of the editor. +}; diff --git a/src/core/features/editor/services/editor-offline.ts b/src/core/features/editor/services/editor-offline.ts new file mode 100644 index 000000000..f9dda9ea3 --- /dev/null +++ b/src/core/features/editor/services/editor-offline.ts @@ -0,0 +1,239 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; + +import { CoreSites } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreEditorDraft, CoreEditorDraftPrimaryData, DRAFT_TABLE } from './database/editor'; + +/** + * Service with features regarding rich text editor in offline. + */ +@Injectable({ providedIn: 'root' }) +export class CoreEditorOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreEditorOfflineProvider'); + } + + /** + * Delete a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + await db.deleteRecords(DRAFT_TABLE, params); + } catch (error) { + // Ignore errors, probably no draft stored. + } + } + + /** + * Return an object with the draft primary data converted to the right format. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @return Object with the fixed primary data. + */ + protected fixDraftPrimaryData( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + ): CoreEditorDraftPrimaryData { + + return { + contextlevel: contextLevel, + contextinstanceid: contextInstanceId, + elementid: elementId, + extraparams: CoreUtils.instance.sortAndStringify(extraParams || {}), + }; + } + + /** + * Get a draft from DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async getDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + siteId?: string, + ): Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const params = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + return db.getRecord(DRAFT_TABLE, params); + } + + /** + * Get draft to resume it. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the draft data. Undefined if no draft stored. + */ + async resumeDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + originalContent?: string, + siteId?: string, + ): Promise { + + try { + // Check if there is a draft stored. + const entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + // There is a draft stored. Update its page instance. + try { + const db = await CoreSites.instance.getSiteDb(siteId); + + entry.pageinstance = pageInstance; + entry.timemodified = Date.now(); + + if (originalContent && entry.originalcontent != originalContent) { + entry.originalcontent = originalContent; + entry.drafttext = ''; // "Discard" the draft. + } + + await db.insertRecord(DRAFT_TABLE, entry); + } catch (error) { + // Ignore errors saving the draft. It shouldn't happen. + } + + return entry; + } catch (error) { + // No draft stored. Store an empty draft to save the pageinstance. + await this.saveDraft( + contextLevel, + contextInstanceId, + elementId, + extraParams, + pageInstance, + '', + originalContent, + siteId, + ); + } + } + + /** + * Save a draft in DB. + * + * @param contextLevel Context level. + * @param contextInstanceId The instance ID related to the context. + * @param elementId Element ID. + * @param extraParams Object with extra params to identify the draft. + * @param pageInstance Unique identifier to prevent storing data from several sources at the same time. + * @param draftText The text to store. + * @param originalContent Original content of the editor. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async saveDraft( + contextLevel: string, + contextInstanceId: number, + elementId: string, + extraParams: Record, + pageInstance: string, + draftText: string, + originalContent?: string, + siteId?: string, + ): Promise { + + let timecreated = Date.now(); + let entry: CoreEditorDraft | undefined; + + // Check if there is a draft already stored. + try { + entry = await this.getDraft(contextLevel, contextInstanceId, elementId, extraParams, siteId); + + timecreated = entry.timecreated || timecreated; + } catch (error) { + // No draft already stored. + } + + if (entry) { + if (entry.pageinstance != pageInstance) { + this.logger.warn(`Discarding draft because of pageinstance. Context '${contextLevel}' '${contextInstanceId}', ` + + `element '${elementId}'`); + + throw new CoreError('Draft was discarded because it was modified in another page.'); + } + + if (!originalContent) { + // Original content not set, use the one in the entry. + originalContent = entry.originalcontent; + } + } + + const db = await CoreSites.instance.getSiteDb(siteId); + + const data: CoreEditorDraft = this.fixDraftPrimaryData(contextLevel, contextInstanceId, elementId, extraParams); + + data.drafttext = (draftText || '').trim(); + data.pageinstance = pageInstance; + data.timecreated = timecreated; + data.timemodified = Date.now(); + if (originalContent) { + data.originalcontent = originalContent; + } + + await db.insertRecord(DRAFT_TABLE, data); + } + +} + +export class CoreEditorOffline extends makeSingleton(CoreEditorOfflineProvider) {} diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index e7ae51d23..fa85d4366 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -20,7 +20,7 @@ import { Md5 } from 'ts-md5'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; -import { CoreEvents } from '@singletons/events'; +import { CoreEventFormAction, CoreEvents } from '@singletons/events'; import { CoreFile } from '@services/file'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text'; @@ -1717,7 +1717,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'cancel', + action: CoreEventFormAction.CANCEL, form: formRef.nativeElement, }, siteId); } @@ -1735,7 +1735,7 @@ export class CoreDomUtilsProvider { } CoreEvents.trigger(CoreEvents.FORM_ACTION, { - action: 'submit', + action: CoreEventFormAction.SUBMIT, form: formRef.nativeElement || formRef, online: !!online, }, siteId); diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 4d45a790b..eae75d94f 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -249,3 +249,17 @@ export type CoreEventUserDeletedData = CoreEventSiteData & { // eslint-disable-next-line @typescript-eslint/no-explicit-any params: any; // Params sent to the WS that failed. }; + +export enum CoreEventFormAction { + CANCEL = 'cancel', + SUBMIT = 'submit', +} + +/** + * Data passed to FORM_ACTION event. + */ +export type CoreEventFormActionData = CoreEventSiteData & { + action: CoreEventFormAction; // Action performed. + form: HTMLElement; // Form element. + online?: boolean; // Whether the data was sent to server or not. Only when submitting. +};