MOBILE-3592 editor: Migrate rich text editor
parent
fd0ea51096
commit
90a0f18480
|
@ -14,7 +14,7 @@
|
|||
<span [core-mark-required]="required">{{ field.name }}</span>
|
||||
<core-input-errors [control]="control"></core-input-errors>
|
||||
</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">
|
||||
</core-rich-text-editor> -->
|
||||
</core-rich-text-editor>
|
||||
</ion-item>
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<ion-icon *ngIf="showPrevButton" name="fas-chevron-left"></ion-icon>
|
||||
</ion-col>
|
||||
<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">
|
||||
<ng-container *ngFor="let tab of tabs">
|
||||
<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';
|
||||
description = '';
|
||||
lastScroll = 0;
|
||||
slideOpts = {
|
||||
slidesOpts = {
|
||||
initialSlide: 0,
|
||||
slidesPerView: 3,
|
||||
centerInsufficientSlides: true,
|
||||
|
@ -381,13 +381,12 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe
|
|||
protected async updateSlides(): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 { 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);
|
||||
|
|
|
@ -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.
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue