Skip to content

feat: Editor as directive #342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 9 additions & 185 deletions tinymce-angular-component/src/main/ts/editor/editor.component.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
/* eslint-disable @typescript-eslint/no-parameter-properties */
import { isPlatformBrowser, CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, forwardRef, Inject, Input, NgZone, OnDestroy, PLATFORM_ID, InjectionToken, Optional } from '@angular/core';
import { FormsModule, ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { getTinymce } from '../TinyMCE';
import { listenTinyMCEEvent, bindHandlers, isTextarea, mergePlugins, uuid, noop, isNullOrUndefined } from '../utils/Utils';
import { EventObj, Events } from './Events';
import { ScriptLoader } from '../utils/ScriptLoader';
import { Editor as TinyMCEEditor, TinyMCE } from 'tinymce';

type EditorOptions = Parameters<TinyMCE['init']>[0];

export const TINYMCE_SCRIPT_SRC = new InjectionToken<string>('TINYMCE_SCRIPT_SRC');
import {Component, forwardRef, Input} from '@angular/core';
import {NG_VALUE_ACCESSOR} from '@angular/forms';
import {isTextarea, uuid} from '../utils/Utils';
import {EditorDirective} from "./editor.directive";

const EDITOR_COMPONENT_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
@@ -22,116 +12,17 @@ const EDITOR_COMPONENT_VALUE_ACCESSOR = {

@Component({
selector: 'editor',
template: '<ng-template></ng-template>',
template: '',
styles: [ ':host { display: block; }' ],
providers: [ EDITOR_COMPONENT_VALUE_ACCESSOR ],
standalone: true,
imports: [ CommonModule, FormsModule ]
standalone: true
})
export class EditorComponent extends Events implements AfterViewInit, ControlValueAccessor, OnDestroy {

@Input() public cloudChannel = '6';
@Input() public apiKey = 'no-api-key';
@Input() public init: EditorOptions | undefined;
export class EditorComponent extends EditorDirective {
@Input() public id = '';
@Input() public initialValue: string | undefined;
@Input() public outputFormat: 'html' | 'text' | undefined;
@Input() public inline: boolean | undefined;
@Input() public tagName: string | undefined;
@Input() public plugins: string | undefined;
@Input() public toolbar: string | string[] | undefined;
@Input() public modelEvents = 'change input undo redo';
@Input() public allowedEvents: string | string[] | undefined;
@Input() public ignoreEvents: string | string[] | undefined;
@Input()
public set disabled(val) {
this._disabled = val;
if (this._editor && this._editor.initialized) {
if (typeof this._editor.mode?.set === 'function') {
this._editor.mode.set(val ? 'readonly' : 'design');
} else {
this._editor.setMode(val ? 'readonly' : 'design');
}
}
}

public get disabled() {
return this._disabled;
}

public get editor() {
return this._editor;
}

public ngZone: NgZone;

private _elementRef: ElementRef;
private _element: HTMLElement | undefined;
private _disabled: boolean | undefined;
private _editor: TinyMCEEditor | undefined;

private onTouchedCallback = noop;
private onChangeCallback: any;

private destroy$ = new Subject<void>();

public constructor(
elementRef: ElementRef,
ngZone: NgZone,
@Inject(PLATFORM_ID) private platformId: Object,
@Optional() @Inject(TINYMCE_SCRIPT_SRC) private tinymceScriptSrc?: string
) {
super();
this._elementRef = elementRef;
this.ngZone = ngZone;
}

public writeValue(value: string | null): void {
if (this._editor && this._editor.initialized) {
this._editor.setContent(isNullOrUndefined(value) ? '' : value);
} else {
this.initialValue = value === null ? undefined : value;
}
}

public registerOnChange(fn: (_: any) => void): void {
this.onChangeCallback = fn;
}

public registerOnTouched(fn: any): void {
this.onTouchedCallback = fn;
}

public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}

public ngAfterViewInit() {
if (isPlatformBrowser(this.platformId)) {
this.id = this.id || uuid('tiny-angular');
this.inline = this.inline !== undefined ? this.inline !== false : !!(this.init?.inline);
this.createElement();
if (getTinymce() !== null) {
this.initialise();
} else if (this._element && this._element.ownerDocument) {
// Caretaker note: the component might be destroyed before the script is loaded and its code is executed.
// This will lead to runtime exceptions if `initialise` will be called when the component has been destroyed.
ScriptLoader.load(this._element.ownerDocument, this.getScriptSrc())
.pipe(takeUntil(this.destroy$))
.subscribe(this.initialise);
}
}
}

public ngOnDestroy() {
this.destroy$.next();

if (getTinymce() !== null) {
getTinymce().remove(this._editor);
}
}

public createElement() {
protected override createElement() {
this.id = this.id || uuid('tiny-angular');
const tagName = typeof this.tagName === 'string' ? this.tagName : 'div';
this._element = document.createElement(this.inline ? tagName : 'textarea');
if (this._element) {
@@ -146,71 +37,4 @@ export class EditorComponent extends Events implements AfterViewInit, ControlVal
this._elementRef.nativeElement.appendChild(this._element);
}
}

public initialise = (): void => {
const finalInit: EditorOptions = {
...this.init,
selector: undefined,
target: this._element,
inline: this.inline,
readonly: this.disabled,
plugins: mergePlugins((this.init && this.init.plugins) as string, this.plugins),
toolbar: this.toolbar || (this.init && this.init.toolbar),
setup: (editor: TinyMCEEditor) => {
this._editor = editor;

listenTinyMCEEvent(editor, 'init', this.destroy$).subscribe(() => {
this.initEditor(editor);
});

bindHandlers(this, editor, this.destroy$);

if (this.init && typeof this.init.setup === 'function') {
this.init.setup(editor);
}
}
};

if (isTextarea(this._element)) {
this._element.style.visibility = '';
}

this.ngZone.runOutsideAngular(() => {
getTinymce().init(finalInit);
});
};

private getScriptSrc() {
return isNullOrUndefined(this.tinymceScriptSrc) ?
`https://cdn.tiny.cloud/1/${this.apiKey}/tinymce/${this.cloudChannel}/tinymce.min.js` :
this.tinymceScriptSrc;
}

private initEditor(editor: TinyMCEEditor) {
listenTinyMCEEvent(editor, 'blur', this.destroy$).subscribe(() => {
this.ngZone.run(() => this.onTouchedCallback());
});

listenTinyMCEEvent(editor, this.modelEvents, this.destroy$).subscribe(() => {
this.ngZone.run(() => this.emitOnChange(editor));
});

if (typeof this.initialValue === 'string') {
this.ngZone.run(() => {
editor.setContent(this.initialValue as string);
if (editor.getContent() !== this.initialValue) {
this.emitOnChange(editor);
}
if (this.onInitNgModel !== undefined) {
this.onInitNgModel.emit(editor as unknown as EventObj<any>);
}
});
}
}

private emitOnChange(editor: TinyMCEEditor) {
if (this.onChangeCallback) {
this.onChangeCallback(editor.getContent({ format: this.outputFormat }));
}
}
}
193 changes: 193 additions & 0 deletions tinymce-angular-component/src/main/ts/editor/editor.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/* eslint-disable @typescript-eslint/no-parameter-properties */
import { isPlatformBrowser } from '@angular/common';
import {AfterViewInit, ElementRef, forwardRef, Inject, Input, NgZone, OnDestroy, PLATFORM_ID, InjectionToken, Optional, Directive} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { getTinymce } from '../TinyMCE';
import { listenTinyMCEEvent, bindHandlers, isTextarea, mergePlugins, uuid, noop, isNullOrUndefined } from '../utils/Utils';
import { EventObj, Events } from './Events';
import { ScriptLoader } from '../utils/ScriptLoader';
import { Editor as TinyMCEEditor, TinyMCE } from 'tinymce';

type EditorOptions = Parameters<TinyMCE['init']>[0];

export const TINYMCE_SCRIPT_SRC = new InjectionToken<string>('TINYMCE_SCRIPT_SRC');

const EDITOR_DIRECTIVE_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EditorDirective),
multi: true
};

@Directive({
selector: '[editor]',
providers: [ EDITOR_DIRECTIVE_VALUE_ACCESSOR ],
standalone: true
})
export class EditorDirective extends Events implements AfterViewInit, ControlValueAccessor, OnDestroy {

@Input() public cloudChannel = '6';
@Input() public apiKey = 'no-api-key';
@Input() public init: EditorOptions | undefined;
@Input() public initialValue: string | undefined;
@Input() public outputFormat: 'html' | 'text' | undefined;
@Input() public inline: boolean | undefined;
@Input() public plugins: string | undefined;
@Input() public toolbar: string | string[] | undefined;
@Input() public modelEvents = 'change input undo redo';
@Input() public allowedEvents: string | string[] | undefined;
@Input() public ignoreEvents: string | string[] | undefined;
@Input()
public set disabled(val) {
this._disabled = val;
if (this._editor && this._editor.initialized) {
if (typeof this._editor.mode?.set === 'function') {
this._editor.mode.set(val ? 'readonly' : 'design');
} else {
(this._editor as any).setMode(val ? 'readonly' : 'design');
}
}
}

public get disabled() {
return this._disabled;
}

public get editor() {
return this._editor;
}

protected _element: HTMLElement | undefined;
private _disabled: boolean | undefined;
private _editor: TinyMCEEditor | undefined;

private onTouchedCallback = noop;
private onChangeCallback: any;

private destroy$ = new Subject<void>();

public constructor(
protected _elementRef: ElementRef,
public ngZone: NgZone,
@Inject(PLATFORM_ID) private platformId: Object,
@Optional() @Inject(TINYMCE_SCRIPT_SRC) private tinymceScriptSrc?: string
) {
super();
}

public writeValue(value: string | null): void {
if (this._editor && this._editor.initialized) {
this._editor.setContent(isNullOrUndefined(value) ? '' : value);
} else {
this.initialValue = value === null ? undefined : value;
}
}

public registerOnChange(fn: (_: any) => void): void {
this.onChangeCallback = fn;
}

public registerOnTouched(fn: any): void {
this.onTouchedCallback = fn;
}

public setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}

public ngAfterViewInit() {
if (isPlatformBrowser(this.platformId)) {
this.inline = this.inline !== undefined ? this.inline !== false : !!(this.init?.inline);
this.createElement();
if (getTinymce() !== null) {
this.initialise();
} else if (this._element && this._element.ownerDocument) {
// Caretaker note: the component might be destroyed before the script is loaded and its code is executed.
// This will lead to runtime exceptions if `initialise` will be called when the component has been destroyed.
ScriptLoader.load(this._element.ownerDocument, this.getScriptSrc())
.pipe(takeUntil(this.destroy$))
.subscribe(this.initialise);
}
}
}

protected createElement(){
this._element = this._elementRef.nativeElement;
}

public initialise = (): void => {
const finalInit: EditorOptions = {
...this.init,
selector: undefined,
target: this._element,
inline: this.inline,
readonly: this.disabled,
plugins: mergePlugins((this.init && this.init.plugins) as string, this.plugins),
toolbar: this.toolbar || (this.init && this.init.toolbar),
setup: (editor: TinyMCEEditor) => {
this._editor = editor;

listenTinyMCEEvent(editor, 'init', this.destroy$).subscribe(() => {
this.initEditor(editor);
});

bindHandlers(this, editor, this.destroy$);

if (this.init && typeof this.init.setup === 'function') {
this.init.setup(editor);
}
}
};

if (isTextarea(this._element)) {
this._element.style.visibility = '';
}

this.ngZone.runOutsideAngular(() => {
getTinymce().init(finalInit);
});
};

private getScriptSrc() {
return isNullOrUndefined(this.tinymceScriptSrc) ?
`https://cdn.tiny.cloud/1/${this.apiKey}/tinymce/${this.cloudChannel}/tinymce.min.js` :
this.tinymceScriptSrc;
}

private initEditor(editor: TinyMCEEditor) {
listenTinyMCEEvent(editor, 'blur', this.destroy$).subscribe(() => {
this.ngZone.run(() => this.onTouchedCallback());
});

listenTinyMCEEvent(editor, this.modelEvents, this.destroy$).subscribe(() => {
this.ngZone.run(() => this.emitOnChange(editor));
});

if (typeof this.initialValue === 'string') {
this.ngZone.run(() => {
editor.setContent(this.initialValue as string);
if (editor.getContent() !== this.initialValue) {
this.emitOnChange(editor);
}
if (this.onInitNgModel !== undefined) {
this.onInitNgModel.emit(editor as unknown as EventObj<any>);
}
});
}
}

private emitOnChange(editor: TinyMCEEditor) {
if (this.onChangeCallback) {
this.onChangeCallback(editor.getContent({ format: this.outputFormat }));
}
}

public ngOnDestroy() {
this.destroy$.next();

if (getTinymce() !== null) {
getTinymce().remove(this._editor);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { NgModule } from '@angular/core';

import { EditorComponent } from './editor.component';
import { EditorDirective } from "./editor.directive";

@NgModule({
imports: [ EditorComponent ],
exports: [ EditorComponent ]
imports: [ EditorDirective, EditorComponent ],
exports: [ EditorDirective, EditorComponent ]
})
export class EditorModule {}
3 changes: 2 additions & 1 deletion tinymce-angular-component/src/main/ts/public_api.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './editor/editor.module';
export { EditorComponent, TINYMCE_SCRIPT_SRC } from './editor/editor.component';
export { EditorDirective, TINYMCE_SCRIPT_SRC } from './editor/editor.directive';
export { EditorComponent } from './editor/editor.component';
5 changes: 3 additions & 2 deletions tinymce-angular-component/src/main/ts/utils/Utils.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import { takeUntil } from 'rxjs/operators';

import { EditorComponent } from '../editor/editor.component';
import { validEvents, Events } from '../editor/Events';
import { EditorDirective } from "../editor/editor.directive";

// Caretaker note: `fromEvent` supports passing JQuery-style event targets, the editor has `on` and `off` methods which
// will be invoked upon subscription and teardown.
@@ -22,7 +23,7 @@ const listenTinyMCEEvent = (
destroy$: Subject<void>
) => fromEvent(editor as HasEventTargetAddRemove<unknown> | ArrayLike<HasEventTargetAddRemove<unknown>>, eventName).pipe(takeUntil(destroy$));

const bindHandlers = (ctx: EditorComponent, editor: any, destroy$: Subject<void>): void => {
const bindHandlers = (ctx: EditorDirective, editor: any, destroy$: Subject<void>): void => {
const allowedEvents = getValidEvents(ctx);
allowedEvents.forEach((eventName) => {
const eventEmitter: EventEmitter<any> = ctx[eventName];
@@ -40,7 +41,7 @@ const bindHandlers = (ctx: EditorComponent, editor: any, destroy$: Subject<void>
});
};

const getValidEvents = (ctx: EditorComponent): (keyof Events)[] => {
const getValidEvents = (ctx: EditorDirective): (keyof Events)[] => {
const ignoredEvents = parseStringProperty(ctx.ignoreEvents, []);
const allowedEvents = parseStringProperty(ctx.allowedEvents, validEvents).filter(
(event) => validEvents.includes(event as (keyof Events)) && !ignoredEvents.includes(event)) as (keyof Events)[];