MOBILE-2317 rte: Initial implementation
parent
ba1267410e
commit
61a477afd5
|
@ -36,6 +36,7 @@ import { CoreLocalFileComponent } from './local-file/local-file';
|
||||||
import { CoreSitePickerComponent } from './site-picker/site-picker';
|
import { CoreSitePickerComponent } from './site-picker/site-picker';
|
||||||
import { CoreTabsComponent } from './tabs/tabs';
|
import { CoreTabsComponent } from './tabs/tabs';
|
||||||
import { CoreTabComponent } from './tabs/tab';
|
import { CoreTabComponent } from './tabs/tab';
|
||||||
|
import { CoreRichTextEditorComponent } from './rich-text-editor/rich-text-editor';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -57,7 +58,8 @@ import { CoreTabComponent } from './tabs/tab';
|
||||||
CoreLocalFileComponent,
|
CoreLocalFileComponent,
|
||||||
CoreSitePickerComponent,
|
CoreSitePickerComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
CoreTabComponent
|
CoreTabComponent,
|
||||||
|
CoreRichTextEditorComponent
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
CoreContextMenuPopoverComponent,
|
CoreContextMenuPopoverComponent,
|
||||||
|
@ -86,7 +88,8 @@ import { CoreTabComponent } from './tabs/tab';
|
||||||
CoreLocalFileComponent,
|
CoreLocalFileComponent,
|
||||||
CoreSitePickerComponent,
|
CoreSitePickerComponent,
|
||||||
CoreTabsComponent,
|
CoreTabsComponent,
|
||||||
CoreTabComponent
|
CoreTabComponent,
|
||||||
|
CoreRichTextEditorComponent
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CoreComponentsModule {}
|
export class CoreComponentsModule {}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
*[core-mark-required] {
|
|
||||||
.core-input-required-asterisk, .icon.core-input-required-asterisk {
|
.core-input-required-asterisk, .icon.core-input-required-asterisk {
|
||||||
color: $red !important;
|
color: $red !important;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
|
@ -6,4 +5,3 @@
|
||||||
line-height: 100%;
|
line-height: 100%;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
<div [hidden]="!rteEnabled">
|
||||||
|
<div #editor contenteditable="true" class="core-rte-editor" tappable [attr.data-placeholder-text]="placeholder">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand -->
|
||||||
|
<div #decorate class="formatOptions">
|
||||||
|
<button data-command="bold"><strong>B</strong></button>
|
||||||
|
<button data-command="italic"><i>I</i></button>
|
||||||
|
<button data-command="underline"><u>U</u></button>
|
||||||
|
<button data-command="formatBlock|<p>">Normal</button>
|
||||||
|
<button data-command="formatBlock|<h1>">H1</button>
|
||||||
|
<button data-command="formatBlock|<h2>">H2</button>
|
||||||
|
<button data-command="formatBlock|<h3>">H3</button>
|
||||||
|
<button data-command="formatBlock|<pre>">Pre</button>
|
||||||
|
<button data-command="insertOrderedList">OL</button>
|
||||||
|
<button data-command="insertUnorderedList">UL</button>
|
||||||
|
<button data-command="removeFormat">Tx</button>
|
||||||
|
<button (click)="toggleEditor($event)">Toggle Editor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div [hidden]="rteEnabled">
|
||||||
|
<ion-textarea #textarea class="core-textarea" [placeholder]="placeholder" ngControl="control" (ionChange)="onChange($event)"></ion-textarea>
|
||||||
|
<div class="formatOptions">
|
||||||
|
<button tappable (click)="toggleEditor($event)">Toggle Editor</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
core-rich-text-editor {
|
||||||
|
height: 40vh;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 30vh;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-rte-editor, .core-textarea {
|
||||||
|
padding: 2px;
|
||||||
|
margin: 2px;
|
||||||
|
width: 100%;
|
||||||
|
resize: none;
|
||||||
|
background-color: $white;
|
||||||
|
flex-grow: 1;
|
||||||
|
* {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-rte-editor {
|
||||||
|
-webkit-user-select: auto !important;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
cursor: text;
|
||||||
|
img {
|
||||||
|
padding-left: 2px;
|
||||||
|
max-width: 95%;
|
||||||
|
}
|
||||||
|
&:empty:before {
|
||||||
|
content: attr(data-placeholder-text);
|
||||||
|
display: block;
|
||||||
|
color: $gray-light;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.core-textarea textarea {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0;
|
||||||
|
height: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
resize: none;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.formatOptions {
|
||||||
|
background: $gray-dark;
|
||||||
|
margin: 5px 1px 15px 1px;
|
||||||
|
text-align: center;
|
||||||
|
flex-grow: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1;
|
||||||
|
button {
|
||||||
|
background: $gray-dark;
|
||||||
|
color: $white;
|
||||||
|
font-size: 1.1em;
|
||||||
|
height: 35px;
|
||||||
|
min-width: 30px;
|
||||||
|
padding-left: 1px;
|
||||||
|
padding-right: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,194 @@
|
||||||
|
// (C) Copyright 2015 Martin Dougiamas
|
||||||
|
//
|
||||||
|
// 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 } from '@angular/core';
|
||||||
|
import { TextInput } from 'ionic-angular';
|
||||||
|
import { CoreDomUtilsProvider } from '../../providers/utils/dom';
|
||||||
|
import { FormControl } from '@angular/forms';
|
||||||
|
import { Keyboard } from '@ionic-native/keyboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive to display a rich text editor if enabled.
|
||||||
|
*
|
||||||
|
* If enabled, this directive will show a rich text editor. Otherwise it'll show a regular textarea.
|
||||||
|
*
|
||||||
|
* This directive requires an OBJECT model. The text written in the editor or textarea will be stored inside
|
||||||
|
* a "text" property in that object. This is to ensure 2-way data-binding, since using a string as a model
|
||||||
|
* could be easily broken.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* <core-rich-text-editor model="newpost" placeholder="{{ 'mma.mod_forum.message' | translate }}" scroll-handle="mmaScrollHandle">
|
||||||
|
* </core-rich-text-editor>
|
||||||
|
*
|
||||||
|
* In the example above, the text written in the editor will be stored in newpost.text.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'core-rich-text-editor',
|
||||||
|
templateUrl: 'rich-text-editor.html'
|
||||||
|
})
|
||||||
|
export class CoreRichTextEditorComponent {
|
||||||
|
// Based on: https://github.com/judgewest2000/Ionic3RichText/
|
||||||
|
// @todo: Resize, images, anchor button, fullscreen...
|
||||||
|
|
||||||
|
@Input() placeholder?: string = ""; // Placeholder to set in textarea.
|
||||||
|
@Input() control: FormControl; // Form control.
|
||||||
|
@Output() public contentChanged: EventEmitter<string>;
|
||||||
|
|
||||||
|
@ViewChild('editor') editor: ElementRef; // WYSIWYG editor.
|
||||||
|
@ViewChild('textarea') textarea: TextInput; // Textarea editor.
|
||||||
|
@ViewChild('decorate') decorate: ElementRef; // Buttons.
|
||||||
|
|
||||||
|
rteEnabled: boolean = false;
|
||||||
|
uniqueId = `rte{Math.floor(Math.random() * 1000000)}`;
|
||||||
|
editorElement: HTMLDivElement;
|
||||||
|
|
||||||
|
constructor(private domUtils: CoreDomUtilsProvider, private keyboard: Keyboard) {
|
||||||
|
this.contentChanged = new EventEmitter<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Init editor
|
||||||
|
*/
|
||||||
|
ngAfterContentInit() {
|
||||||
|
this.domUtils.isRichTextEditorEnabled().then((enabled) => {
|
||||||
|
this.rteEnabled = !!enabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup the editor.
|
||||||
|
this.editorElement = this.editor.nativeElement as HTMLDivElement;
|
||||||
|
this.editorElement.innerHTML = this.control.value;
|
||||||
|
this.textarea.value = this.control.value;
|
||||||
|
this.control.setValue(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);
|
||||||
|
|
||||||
|
// Setup button actions.
|
||||||
|
let buttons = (this.decorate.nativeElement as HTMLDivElement).getElementsByTagName('button');
|
||||||
|
for (let i = 0; i < buttons.length; i++) {
|
||||||
|
let button = buttons[i],
|
||||||
|
command = button.getAttribute('data-command');
|
||||||
|
|
||||||
|
if (command) {
|
||||||
|
if (command.includes('|')) {
|
||||||
|
let parameter = command.split('|')[1];
|
||||||
|
command = command.split('|')[0];
|
||||||
|
|
||||||
|
button.addEventListener('click', ($event) => {
|
||||||
|
this.buttonAction($event, command, parameter);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
button.addEventListener('click', ($event) => {
|
||||||
|
this.buttonAction($event, command);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On change function to sync with form data.
|
||||||
|
*/
|
||||||
|
onChange($event) {
|
||||||
|
if (this.rteEnabled) {
|
||||||
|
if (this.isNullOrWhiteSpace(this.editorElement.innerText)) {
|
||||||
|
this.clearText();
|
||||||
|
} else {
|
||||||
|
this.control.setValue(this.editorElement.innerHTML);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.isNullOrWhiteSpace(this.textarea.value)) {
|
||||||
|
this.clearText();
|
||||||
|
} else {
|
||||||
|
this.control.setValue(this.textarea.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.contentChanged.emit(this.control.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle from rte editor to textarea syncing values.
|
||||||
|
*/
|
||||||
|
toggleEditor($event) {
|
||||||
|
$event.preventDefault();
|
||||||
|
$event.stopPropagation();
|
||||||
|
|
||||||
|
if (this.isNullOrWhiteSpace(this.control.value)) {
|
||||||
|
this.clearText();
|
||||||
|
} else {
|
||||||
|
this.editorElement.innerHTML = this.control.value;
|
||||||
|
this.textarea.value = this.control.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rteEnabled = !this.rteEnabled;
|
||||||
|
|
||||||
|
// Set focus and cursor at the end.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.rteEnabled) {
|
||||||
|
this.editorElement.focus();
|
||||||
|
let range = document.createRange();
|
||||||
|
range.selectNodeContents(this.editorElement);
|
||||||
|
range.collapse(false);
|
||||||
|
let sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
} else {
|
||||||
|
this.textarea.setFocus();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
this.keyboard.show();
|
||||||
|
}, 1);
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if text is empty.
|
||||||
|
* @param {string} value text
|
||||||
|
*/
|
||||||
|
private isNullOrWhiteSpace(value: string) {
|
||||||
|
if (value == null || typeof value == "undefined") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
value = value.replace(/[\n\r]/g, '');
|
||||||
|
value = value.split(' ').join('');
|
||||||
|
|
||||||
|
return value.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the text.
|
||||||
|
*/
|
||||||
|
clearText() {
|
||||||
|
this.editorElement.innerHTML = '<p></p>';
|
||||||
|
this.textarea.value = '';
|
||||||
|
this.control.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an action over the selected text.
|
||||||
|
* API docs: https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand
|
||||||
|
*
|
||||||
|
* @param {any} $event Event data
|
||||||
|
* @param {string} command Command to execute.
|
||||||
|
* @param {any} [parameters] Parameters of the command.
|
||||||
|
*/
|
||||||
|
private buttonAction($event: any, command: string, parameters: any = null) {
|
||||||
|
$event.preventDefault();
|
||||||
|
$event.stopPropagation();
|
||||||
|
document.execCommand(command, false, parameters);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue