From 0338788be87cc26c23aa11fab0a35f241e978430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Mar=C3=ADn?= Date: Tue, 7 Feb 2023 02:28:43 +0100 Subject: [PATCH 1/2] feat: editor as directive --- .../src/main/ts/editor/editor.component.ts | 194 +----------------- .../src/main/ts/editor/editor.module.ts | 5 +- .../src/main/ts/public_api.ts | 3 +- .../src/main/ts/utils/Utils.ts | 5 +- 4 files changed, 17 insertions(+), 190 deletions(-) diff --git a/tinymce-angular-component/src/main/ts/editor/editor.component.ts b/tinymce-angular-component/src/main/ts/editor/editor.component.ts index 7fcec109..4e71d53e 100644 --- a/tinymce-angular-component/src/main/ts/editor/editor.component.ts +++ b/tinymce-angular-component/src/main/ts/editor/editor.component.ts @@ -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[0]; - -export const TINYMCE_SCRIPT_SRC = new InjectionToken('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: '', + 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(); - - 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); - } - }); - } - } - - private emitOnChange(editor: TinyMCEEditor) { - if (this.onChangeCallback) { - this.onChangeCallback(editor.getContent({ format: this.outputFormat })); - } - } } diff --git a/tinymce-angular-component/src/main/ts/editor/editor.module.ts b/tinymce-angular-component/src/main/ts/editor/editor.module.ts index 50f0ad66..d90ff9f9 100644 --- a/tinymce-angular-component/src/main/ts/editor/editor.module.ts +++ b/tinymce-angular-component/src/main/ts/editor/editor.module.ts @@ -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 {} diff --git a/tinymce-angular-component/src/main/ts/public_api.ts b/tinymce-angular-component/src/main/ts/public_api.ts index a00d0a8a..e783b011 100644 --- a/tinymce-angular-component/src/main/ts/public_api.ts +++ b/tinymce-angular-component/src/main/ts/public_api.ts @@ -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'; diff --git a/tinymce-angular-component/src/main/ts/utils/Utils.ts b/tinymce-angular-component/src/main/ts/utils/Utils.ts index 191f17f4..66ee0219 100644 --- a/tinymce-angular-component/src/main/ts/utils/Utils.ts +++ b/tinymce-angular-component/src/main/ts/utils/Utils.ts @@ -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 ) => fromEvent(editor as HasEventTargetAddRemove | ArrayLike>, eventName).pipe(takeUntil(destroy$)); -const bindHandlers = (ctx: EditorComponent, editor: any, destroy$: Subject): void => { +const bindHandlers = (ctx: EditorDirective, editor: any, destroy$: Subject): void => { const allowedEvents = getValidEvents(ctx); allowedEvents.forEach((eventName) => { const eventEmitter: EventEmitter = ctx[eventName]; @@ -40,7 +41,7 @@ const bindHandlers = (ctx: EditorComponent, editor: any, destroy$: Subject }); }; -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)[]; From 6d9671ebfd972d086325143ad3a49ac8d2b1b7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Mar=C3=ADn?= Date: Tue, 7 Feb 2023 02:29:29 +0100 Subject: [PATCH 2/2] feat: editor as directive --- .../src/main/ts/editor/editor.directive.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 tinymce-angular-component/src/main/ts/editor/editor.directive.ts diff --git a/tinymce-angular-component/src/main/ts/editor/editor.directive.ts b/tinymce-angular-component/src/main/ts/editor/editor.directive.ts new file mode 100644 index 00000000..102c1570 --- /dev/null +++ b/tinymce-angular-component/src/main/ts/editor/editor.directive.ts @@ -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[0]; + +export const TINYMCE_SCRIPT_SRC = new InjectionToken('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(); + + 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); + } + }); + } + } + + 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); + } + } +}