MOBILE-3592 editor: Migrate rich text editor
parent
fd0ea51096
commit
90a0f18480
|
@ -14,7 +14,7 @@
|
||||||
<span [core-mark-required]="required">{{ field.name }}</span>
|
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||||
<core-input-errors [control]="control"></core-input-errors>
|
<core-input-errors [control]="control"></core-input-errors>
|
||||||
</ion-label>
|
</ion-label>
|
||||||
<!-- @todo <core-rich-text-editor item-content [control]="control" [placeholder]="field.name" [autoSave]="true"
|
<core-rich-text-editor item-content [control]="control" [placeholder]="field.name" [autoSave]="true"
|
||||||
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [elementId]="modelName">
|
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [elementId]="modelName">
|
||||||
</core-rich-text-editor> -->
|
</core-rich-text-editor>
|
||||||
</ion-item>
|
</ion-item>
|
|
@ -23,7 +23,7 @@ import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profi
|
||||||
import { AddonUserProfileFieldTextareaComponent } from './component/textarea';
|
import { AddonUserProfileFieldTextareaComponent } from './component/textarea';
|
||||||
import { CoreComponentsModule } from '@components/components.module';
|
import { CoreComponentsModule } from '@components/components.module';
|
||||||
import { CoreDirectivesModule } from '@directives/directives.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({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -37,7 +37,7 @@ import { CoreDirectivesModule } from '@directives/directives.module';
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
CoreComponentsModule,
|
CoreComponentsModule,
|
||||||
CoreDirectivesModule,
|
CoreDirectivesModule,
|
||||||
// CoreEditorComponentsModule,
|
CoreEditorComponentsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
|
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
<ion-col class="ion-no-padding" size="10">
|
<ion-col class="ion-no-padding" size="10">
|
||||||
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slideOpts" [dir]="direction" role="tablist"
|
<ion-slides (ionSlideDidChange)="slideChanged()" [options]="slidesOpts" [dir]="direction" role="tablist"
|
||||||
[attr.aria-label]="description" aria-hidden="false">
|
[attr.aria-label]="description" aria-hidden="false">
|
||||||
<ng-container *ngFor="let tab of tabs">
|
<ng-container *ngFor="let tab of tabs">
|
||||||
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide"
|
<ion-slide [hidden]="!hideUntil" [attr.aria-selected]="selected == tab.id" class="tab-slide"
|
||||||
|
|
|
@ -82,7 +82,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
|
||||||
direction = 'ltr';
|
direction = 'ltr';
|
||||||
description = '';
|
description = '';
|
||||||
lastScroll = 0;
|
lastScroll = 0;
|
||||||
slideOpts = {
|
slidesOpts = {
|
||||||
initialSlide: 0,
|
initialSlide: 0,
|
||||||
slidesPerView: 3,
|
slidesPerView: 3,
|
||||||
centerInsufficientSlides: true,
|
centerInsufficientSlides: true,
|
||||||
|
@ -381,13 +381,12 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
|
||||||
protected async updateSlides(): Promise<void> {
|
protected async updateSlides(): Promise<void> {
|
||||||
this.numTabsShown = this.tabs.reduce((prev: number, current: CoreTab) => current.enabled ? prev + 1 : prev, 0);
|
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.slidesOpts = { ...this.slidesOpts, slidesPerView: Math.min(this.maxSlides, this.numTabsShown) };
|
||||||
this.slidesSwiper.params.slidesPerView = this.slideOpts.slidesPerView;
|
|
||||||
|
|
||||||
this.calculateTabBarHeight();
|
this.calculateTabBarHeight();
|
||||||
await this.slides!.update();
|
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.hasSliddenToInitial = true;
|
||||||
this.shouldSlideToInitial = true;
|
this.shouldSlideToInitial = true;
|
||||||
|
|
||||||
|
@ -637,6 +636,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
|
||||||
window.removeEventListener('resize', this.resizeFunction);
|
window.removeEventListener('resize', this.resizeFunction);
|
||||||
}
|
}
|
||||||
this.stackEventsSubscription?.unsubscribe();
|
this.stackEventsSubscription?.unsubscribe();
|
||||||
|
this.languageChangedSubscription.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {}
|
|
@ -0,0 +1,113 @@
|
||||||
|
<div class="core-rte-editor-container" (click)="focusRTE()" [class.toolbar-hidden]="toolbarHidden">
|
||||||
|
<div [hidden]="!rteEnabled" #editor contenteditable="true" class="core-rte-editor" button (focus)="showToolbar($event)"
|
||||||
|
(longPress)="showToolbar($event)" (blur)="hideToolbar($event)" [attr.data-placeholder-text]="placeholder" role="textbox">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-textarea [hidden]="rteEnabled" #textarea class="core-textarea" [placeholder]="placeholder" [attr.name]="name"
|
||||||
|
ngControl="control" (ionChange)="onChange()" (focus)="showToolbar($event)" (longPress)="showToolbar($event)"
|
||||||
|
(blur)="hideToolbar($event)" role="textbox">
|
||||||
|
</ion-textarea>
|
||||||
|
|
||||||
|
<div class="core-rte-info-message" *ngIf="infoMessage">
|
||||||
|
<ion-icon name="fas-info-circle"></ion-icon>
|
||||||
|
{{ infoMessage | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div #toolbar class="core-rte-toolbar" [class.toolbar-hidden]="toolbarHidden">
|
||||||
|
<button *ngIf="toolbarArrows" class="toolbar-arrow" [class.toolbar-arrow-hidden]="toolbarPrevHidden"
|
||||||
|
(click)="toolbarPrev($event)" (mousedown)="mouseDownAction($event)">
|
||||||
|
<ion-icon name="fas-chevron-left"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<ion-slides [options]="slidesOpts" [dir]="direction" (ionSlideDidChange)="updateToolbarArrows()">
|
||||||
|
<!-- https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand -->
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.strong" [title]="'core.editor.bold' | translate"
|
||||||
|
(click)="buttonAction($event, 'bold', 'strong')" (mousedown)="mouseDownAction($event)">
|
||||||
|
<core-icon name="fas-bold"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.em" (click)="buttonAction($event, 'italic', 'em')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]=" 'core.editor.italic' | translate">
|
||||||
|
<core-icon name="fas-italic"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.u" (click)="buttonAction($event, 'underline', 'u')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]="'core.editor.underline' | translate">
|
||||||
|
<core-icon name="fas-underline"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.strike" [title]="'core.editor.strike' | translate"
|
||||||
|
(click)="buttonAction($event, 'strikethrough', 'strike')" (mousedown)="mouseDownAction($event)">
|
||||||
|
<core-icon name="fas-strikethrough"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.p" (click)="buttonAction($event, 'p', 'block')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]="'core.editor.p' | translate">
|
||||||
|
<core-icon name="fas-paragraph"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.h3" (click)="buttonAction($event, 'h3', 'block')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]="'core.editor.h3' | translate">
|
||||||
|
<core-icon name="fas-heading"></core-icon>3
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.h4" (click)="buttonAction($event, 'h4', 'block')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]="'core.editor.h4' | translate">
|
||||||
|
<core-icon name="fas-heading"></core-icon>4
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.h5" (click)="buttonAction($event, 'h5', 'block')"
|
||||||
|
(mousedown)="mouseDownAction($event)" [title]="'core.editor.h5' | translate">
|
||||||
|
<core-icon name="fas-heading"></core-icon>5
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.ul" (mousedown)="mouseDownAction($event)"
|
||||||
|
(click)="buttonAction($event, 'insertUnorderedList')" [title]="'core.editor.unorderedlist' | translate">
|
||||||
|
<core-icon name="fas-list-ul"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" [attr.aria-pressed]="toolbarStyles.ol" (mousedown)="mouseDownAction($event)"
|
||||||
|
(click)="buttonAction($event, 'insertOrderedList')" [title]="'core.editor.orderedlist' | translate">
|
||||||
|
<core-icon name="fas-list-ol"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [disabled]="!rteEnabled" (click)="buttonAction($event, 'removeFormat')" (mousedown)="mouseDownAction($event)"
|
||||||
|
[title]="'core.editor.clear' | translate">
|
||||||
|
<core-icon name="fas-eraser"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide *ngIf="canScanQR">
|
||||||
|
<button [disabled]="!rteEnabled" (click)="scanQR($event)" (mousedown)="stopBubble($event)"
|
||||||
|
[title]="'core.scanqr' | translate">
|
||||||
|
<core-icon name="fas-qrcode"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide>
|
||||||
|
<button [attr.aria-pressed]="!rteEnabled" (click)="toggleEditor($event)" (mousedown)="mouseDownAction($event)"
|
||||||
|
[title]=" 'core.editor.toggle' | translate">
|
||||||
|
<core-icon name="fas-code"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
<ion-slide *ngIf="isPhone">
|
||||||
|
<button (click)="hideToolbar($event)" (mousedown)="mouseDownAction($event)"
|
||||||
|
[title]=" 'core.editor.hidetoolbar' | translate">
|
||||||
|
<core-icon name="fas-times"></core-icon>
|
||||||
|
</button>
|
||||||
|
</ion-slide>
|
||||||
|
</ion-slides>
|
||||||
|
<button *ngIf="toolbarArrows" class="toolbar-arrow" [class.toolbar-arrow-hidden]="toolbarNextHidden"
|
||||||
|
(click)="toolbarNext($event)" (mousedown)="mouseDownAction($event)">
|
||||||
|
<ion-icon name="fas-chevron-right"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
|
@ -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;
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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 {}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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.
|
||||||
|
};
|
|
@ -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<string, unknown>,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<CoreEditorDraft> {
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
pageInstance: string,
|
||||||
|
originalContent?: string,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<CoreEditorDraft | undefined> {
|
||||||
|
|
||||||
|
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<string, unknown>,
|
||||||
|
pageInstance: string,
|
||||||
|
draftText: string,
|
||||||
|
originalContent?: string,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
|
||||||
|
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) {}
|
|
@ -20,7 +20,7 @@ import { Md5 } from 'ts-md5';
|
||||||
|
|
||||||
import { CoreApp } from '@services/app';
|
import { CoreApp } from '@services/app';
|
||||||
import { CoreConfig } from '@services/config';
|
import { CoreConfig } from '@services/config';
|
||||||
import { CoreEvents } from '@singletons/events';
|
import { CoreEventFormAction, CoreEvents } from '@singletons/events';
|
||||||
import { CoreFile } from '@services/file';
|
import { CoreFile } from '@services/file';
|
||||||
import { CoreWSExternalWarning } from '@services/ws';
|
import { CoreWSExternalWarning } from '@services/ws';
|
||||||
import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text';
|
import { CoreTextUtils, CoreTextErrorObject } from '@services/utils/text';
|
||||||
|
@ -1717,7 +1717,7 @@ export class CoreDomUtilsProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
|
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
|
||||||
action: 'cancel',
|
action: CoreEventFormAction.CANCEL,
|
||||||
form: formRef.nativeElement,
|
form: formRef.nativeElement,
|
||||||
}, siteId);
|
}, siteId);
|
||||||
}
|
}
|
||||||
|
@ -1735,7 +1735,7 @@ export class CoreDomUtilsProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
|
CoreEvents.trigger(CoreEvents.FORM_ACTION, {
|
||||||
action: 'submit',
|
action: CoreEventFormAction.SUBMIT,
|
||||||
form: formRef.nativeElement || formRef,
|
form: formRef.nativeElement || formRef,
|
||||||
online: !!online,
|
online: !!online,
|
||||||
}, siteId);
|
}, siteId);
|
||||||
|
|
|
@ -249,3 +249,17 @@ export type CoreEventUserDeletedData = CoreEventSiteData & {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
params: any; // Params sent to the WS that failed.
|
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.
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue