From a5763a39365424d956cdc3620fe2b880082f3893 Mon Sep 17 00:00:00 2001 From: sibiraj-s Date: Sun, 3 Jan 2021 18:03:29 +0530 Subject: [PATCH] refactor: decouple editor logic from angular --- demo/src/app/app.component.html | 6 +- demo/src/app/app.component.scss | 29 ++- demo/src/app/app.component.ts | 31 ++- demo/src/app/app.module.ts | 24 +-- .../custom-menu/custom-menu.component.ts | 13 +- src/lib/Editor.ts | 160 ++++++++++++++ .../components/bubble/bubble.component.scss | 2 +- src/lib/components/bubble/bubble.component.ts | 36 ++-- src/lib/editor.component.html | 9 +- src/lib/editor.component.scss | 2 +- src/lib/editor.component.ts | 200 +++++------------- src/lib/editor.module.ts | 33 ++- src/lib/editor.service.ts | 95 +-------- .../color-picker/color-picker.component.ts | 13 +- .../menu/dropdown/dropdown.component.ts | 8 +- src/lib/modules/menu/image/image.component.ts | 8 +- src/lib/modules/menu/link/link.component.ts | 8 +- src/lib/modules/menu/menu.component.html | 6 +- src/lib/modules/menu/menu.component.scss | 3 - src/lib/modules/menu/menu.component.ts | 87 ++++++-- src/lib/modules/menu/menu.module.ts | 9 +- .../menu/menu.service.ts} | 4 +- .../menu}/shared.service.spec.ts | 8 +- .../simple-command.component.ts | 8 +- src/lib/parsers.ts | 2 +- src/lib/utils/isNil.ts | 5 + src/lib/validators.ts | 8 +- src/plugins/placeholder.ts | 5 +- src/public_api.ts | 2 + 29 files changed, 456 insertions(+), 368 deletions(-) create mode 100644 src/lib/Editor.ts rename src/lib/{services/shared/shared.service.ts => modules/menu/menu.service.ts} (89%) rename src/lib/{services/shared => modules/menu}/shared.service.spec.ts (55%) create mode 100644 src/lib/utils/isNil.ts diff --git a/demo/src/app/app.component.html b/demo/src/app/app.component.html index 98a058b5..41e9db21 100644 --- a/demo/src/app/app.component.html +++ b/demo/src/app/app.component.html @@ -16,7 +16,9 @@
- + + +
@@ -27,7 +29,7 @@ - + diff --git a/demo/src/app/app.component.scss b/demo/src/app/app.component.scss index b80ba334..d0a485b2 100644 --- a/demo/src/app/app.component.scss +++ b/demo/src/app/app.component.scss @@ -35,12 +35,29 @@ } } -.CodeMirror { - border: 1px solid #eee; - height: auto; - margin-bottom: 0.7rem; +.editor { + border: 2px solid rgba(0, 0, 0, 0.2); + border-radius: 4px; - pre { - white-space: pre !important; + .NgxEditor__MenuBar { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); + } + + .NgxEditor { + border-top-left-radius: 0; + border-top-right-radius: 0; + border: none; + } + + .CodeMirror { + border: 1px solid #eee; + height: auto; + margin-bottom: 0.7rem; + + pre { + white-space: pre !important; + } } } diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index 3fff4d17..b935a52d 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -1,10 +1,14 @@ -import { Component, ViewEncapsulation } from '@angular/core'; +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; import { AbstractControl, FormControl, FormGroup } from '@angular/forms'; import { EditorView } from 'prosemirror-view'; import { environment } from '../environments/environment'; -import { Validators } from 'ngx-editor'; + +import { Validators, Editor, Toolbar } from 'ngx-editor'; import jsonDoc from './doc'; +import schema from './schema'; +import plugins from './plugins'; +import nodeViews from './nodeviews'; @Component({ selector: 'app-root', @@ -13,11 +17,24 @@ import jsonDoc from './doc'; encapsulation: ViewEncapsulation.None }) -export class AppComponent { +export class AppComponent implements OnInit { isProdMode = environment.production; + editorView: EditorView; editordoc = jsonDoc; + editor: Editor; + toolbar: Toolbar = [ + ['bold', 'italic'], + ['underline', 'strike'], + ['code', 'blockquote'], + ['ordered_list', 'bullet_list'], + [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], + ['link', 'image'], + ['text_color', 'background_color'], + ['align_left', 'align_center', 'align_right', 'align_justify'], + ]; + form = new FormGroup({ editorContent: new FormControl(jsonDoc, Validators.required()) }); @@ -29,4 +46,12 @@ export class AppComponent { init(view: EditorView): void { this.editorView = view; } + + ngOnInit(): void { + this.editor = new Editor({ + schema, + plugins, + nodeViews + }); + } } diff --git a/demo/src/app/app.module.ts b/demo/src/app/app.module.ts index f046c99b..e5f48a9a 100644 --- a/demo/src/app/app.module.ts +++ b/demo/src/app/app.module.ts @@ -1,14 +1,11 @@ import { CommonModule } from '@angular/common'; import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { AppComponent } from './app.component'; import { NgxEditorModule } from 'ngx-editor'; -import schema from './schema'; -import plugins from './plugins'; -import nodeViews from './nodeviews'; import { CustomMenuComponent } from './components/custom-menu/custom-menu.component'; @NgModule({ @@ -17,28 +14,13 @@ import { CustomMenuComponent } from './components/custom-menu/custom-menu.compon BrowserModule, FormsModule, ReactiveFormsModule, - NgxEditorModule.forRoot({ - schema, - plugins, - nodeViews, - menu: { - toolbar: [ - ['bold', 'italic'], - ['underline', 'strike'], - ['code', 'blockquote'], - ['ordered_list', 'bullet_list'], - [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], - ['link', 'image'], - ['text_color', 'background_color'], - ['align_left', 'align_center', 'align_right', 'align_justify'], - ] - } - }), + NgxEditorModule ], declarations: [ AppComponent, CustomMenuComponent, ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], bootstrap: [AppComponent] }) diff --git a/demo/src/app/components/custom-menu/custom-menu.component.ts b/demo/src/app/components/custom-menu/custom-menu.component.ts index 3c2c00ef..e2cc65a3 100644 --- a/demo/src/app/components/custom-menu/custom-menu.component.ts +++ b/demo/src/app/components/custom-menu/custom-menu.component.ts @@ -3,6 +3,7 @@ import { setBlockType } from 'prosemirror-commands'; import { EditorState, Plugin, PluginKey, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; +import { Editor } from 'ngx-editor'; import { isNodeActive } from 'ngx-editor/helpers'; @Component({ @@ -13,13 +14,13 @@ import { isNodeActive } from 'ngx-editor/helpers'; export class CustomMenuComponent implements OnInit { constructor() { } - @Input() editorView: EditorView; + @Input() editor: Editor; isActive = false; isDisabled = false; onClick(e: MouseEvent): void { e.preventDefault(); - const { state, dispatch } = this.editorView; + const { state, dispatch } = this.editor.view; this.execute(state, dispatch); } @@ -41,6 +42,8 @@ export class CustomMenuComponent implements OnInit { } ngOnInit(): void { + const { view } = this.editor; + const plugin = new Plugin({ key: new PluginKey(`custom-menu-codemirror`), view: () => { @@ -50,10 +53,10 @@ export class CustomMenuComponent implements OnInit { } }); - const newState = this.editorView.state.reconfigure({ - plugins: this.editorView.state.plugins.concat([plugin]) + const newState = view.state.reconfigure({ + plugins: view.state.plugins.concat([plugin]) }); - this.editorView.updateState(newState); + view.updateState(newState); } } diff --git a/src/lib/Editor.ts b/src/lib/Editor.ts new file mode 100644 index 00000000..0280463c --- /dev/null +++ b/src/lib/Editor.ts @@ -0,0 +1,160 @@ +import { Schema, Node as ProsemirrorNode } from 'prosemirror-model'; +import { EditorState, Plugin, Transaction } from 'prosemirror-state'; +import { Decoration, EditorView, NodeView } from 'prosemirror-view'; +import { Subject } from 'rxjs'; + +import { + editable as editablePlugin, + placeholder as placeholderPlugin +} from 'ngx-editor/plugins'; + +import defautlSchema from './schema'; +import { parseContent } from './parsers'; +import isNil from './utils/isNil'; + +type Content = string | Record | null; +type JSONDoc = Record | null; + +interface NodeViews { + [name: string]: ( + node: ProsemirrorNode, + view: EditorView, + getPos: () => number, + decorations: Decoration[] + ) => NodeView; +} + +interface Options { + content?: Content; + enabled?: boolean; + placeholder?: string; + schema?: Schema; + plugins?: Plugin[]; + nodeViews?: NodeViews; +} + +class Editor { + view: EditorView; + options: Options; + el: DocumentFragment; + + onContentChange = new Subject(); + onFocus = new Subject(); + onBlur = new Subject(); + onUpdate = new Subject(); + + constructor(options: Options) { + this.options = options; + this.createEditor(options); + } + + get schema(): Schema { + return this.options.schema || defautlSchema; + } + + setContent(content: Content): void { + if (isNil(content)) { + return; + } + + const { state } = this.view; + const { tr, doc } = state; + + const newDoc = parseContent(content, this.schema); + + tr.replaceWith(0, state.doc.content.size, newDoc); + + // don't emit if both content is same + if (doc.eq(tr.doc)) { + return; + } + + if (!tr.docChanged) { + return; + } + + this.view.dispatch(tr); + } + + private handleTransactions(tr: Transaction): void { + const { state } = this.view.state.applyTransaction(tr); + this.view.updateState(state); + + this.onUpdate.next(); + + if (!tr.docChanged) { + return; + } + + const json = state.doc.toJSON(); + this.onContentChange.next(json); + } + + private createEditor(options: Options): void { + const { content, plugins, nodeViews, enabled } = options; + const schema = this.schema; + + const editable = enabled ?? true; + const placeholder = options.placeholder ?? ''; + + const doc = parseContent(content, schema); + this.el = document.createDocumentFragment(); + + this.view = new EditorView(this.el, { + editable: () => editable, + state: EditorState.create({ + doc, + schema, + plugins: [ + editablePlugin(), + placeholderPlugin(placeholder), + ...plugins + ], + }), + nodeViews, + dispatchTransaction: this.handleTransactions.bind(this), + handleDOMEvents: { + focus: () => { + this.onFocus.next(); + return false; + }, + blur: () => { + this.onBlur.next(); + return false; + } + }, + attributes: { + class: 'NgxEditor__Content' + }, + }); + } + + registerPlugin(plugin: Plugin): void { + const { state } = this.view; + const plugins = [...state.plugins, plugin]; + + const newState = state.reconfigure({ plugins }); + this.view.updateState(newState); + } + + enable(): void { + const { dispatch, state: { tr } } = this.view; + dispatch(tr.setMeta('UPDATE_EDITABLE', true)); + } + + disable(): void { + const { dispatch, state: { tr } } = this.view; + dispatch(tr.setMeta('UPDATE_EDITABLE', false)); + } + + setPlaceholder(placeholder: string): void { + const { dispatch, state: { tr } } = this.view; + dispatch(tr.setMeta('UPDATE_PLACEHOLDER', placeholder)); + } + + destroy(): void { + this.view.destroy(); + } +} + +export default Editor; diff --git a/src/lib/components/bubble/bubble.component.scss b/src/lib/components/bubble/bubble.component.scss index 637bdf4c..de538b8e 100644 --- a/src/lib/components/bubble/bubble.component.scss +++ b/src/lib/components/bubble/bubble.component.scss @@ -9,7 +9,7 @@ $light-gray: #f1f1f1; padding: 0.3rem; margin-bottom: 0.3rem; transform: translateX(-50%); - display: flex; + display: none; align-items: center; &::before, diff --git a/src/lib/components/bubble/bubble.component.ts b/src/lib/components/bubble/bubble.component.ts index c7247a34..1ea29365 100644 --- a/src/lib/components/bubble/bubble.component.ts +++ b/src/lib/components/bubble/bubble.component.ts @@ -1,5 +1,5 @@ import { - Component, ElementRef, OnDestroy, + Component, ElementRef, Input, OnDestroy, OnInit, Renderer2 } from '@angular/core'; import { NodeSelection } from 'prosemirror-state'; @@ -10,7 +10,7 @@ import { Subscription } from 'rxjs'; import { calculateBubblePos, getSelectionMarks, isLinkActive } from 'ngx-editor/helpers'; import { removeLink } from 'ngx-editor/commands'; -import { SharedService } from '../../services/shared/shared.service'; +import Editor from '../../Editor'; @Component({ selector: 'ngx-bubble', @@ -18,33 +18,29 @@ import { SharedService } from '../../services/shared/shared.service'; styleUrls: ['./bubble.component.scss'] }) export class BubbleComponent implements OnInit, OnDestroy { + @Input() editor: Editor; + private view: EditorView; + private updateSubscription: Subscription; activeLinkItem: Mark; - private pluginUpdateSubscription: Subscription; constructor( - private sharedService: SharedService, private el: ElementRef, private renderer: Renderer2 - ) { - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { - this.view = view; - this.update(view); - }); - } + ) { } - private setDomPosition(view: EditorView): void { + private setDomPosition(): void { // Otherwise, reposition it and update its content this.showBubble(); - const { bottom, left } = calculateBubblePos(view, this.el.nativeElement); + const { bottom, left } = calculateBubblePos(this.view, this.el.nativeElement); this.renderer.setStyle(this.el.nativeElement, 'left', `${left}px`); this.renderer.setStyle(this.el.nativeElement, 'bottom', `${bottom}px`); } private showBubble(): void { - this.renderer.setStyle(this.el.nativeElement, 'display', ''); + this.renderer.setStyle(this.el.nativeElement, 'display', 'flex'); } private hideBubble(): void { @@ -57,8 +53,8 @@ export class BubbleComponent implements OnInit, OnDestroy { this.view.focus(); } - private update(view: EditorView): void { - const { state } = view; + private update(): void { + const { state } = this.view; const { schema, selection } = state; if (!schema.marks.link) { @@ -71,7 +67,7 @@ export class BubbleComponent implements OnInit, OnDestroy { } } - const hasFocus = view.hasFocus(); + const hasFocus = this.view.hasFocus(); const isActive = isLinkActive(state); const linkMarks: Mark[] = getSelectionMarks(state).filter(mark => mark.type === schema.marks.link); @@ -85,14 +81,18 @@ export class BubbleComponent implements OnInit, OnDestroy { this.activeLinkItem = linkItem; // update dom position - this.setDomPosition(view); + this.setDomPosition(); } ngOnInit(): void { + this.view = this.editor.view; + this.editor.onUpdate.subscribe(() => { + this.update(); + }); } ngOnDestroy(): void { - this.pluginUpdateSubscription.unsubscribe(); + this.updateSubscription.unsubscribe(); } } diff --git a/src/lib/editor.component.html b/src/lib/editor.component.html index a40d5d31..a9f36707 100644 --- a/src/lib/editor.component.html +++ b/src/lib/editor.component.html @@ -1,10 +1,3 @@
- - - +
diff --git a/src/lib/editor.component.scss b/src/lib/editor.component.scss index 27e1b722..506bdee1 100644 --- a/src/lib/editor.component.scss +++ b/src/lib/editor.component.scss @@ -6,7 +6,7 @@ $light-gray: #f1f1f1; color: black; background-clip: padding-box; border-radius: 4px; - border: 2px solid rgba(0, 0, 0, 0.2); + border: 1px solid rgba(0, 0, 0, 0.2); position: relative; overflow: hidden; } diff --git a/src/lib/editor.component.ts b/src/lib/editor.component.ts index 5d6dbfd8..62f522db 100644 --- a/src/lib/editor.component.ts +++ b/src/lib/editor.component.ts @@ -1,19 +1,14 @@ import { Component, ViewChild, ElementRef, - forwardRef, OnDestroy, ViewEncapsulation, OnInit, - Output, EventEmitter, Input, TemplateRef, - OnChanges, SimpleChanges, + forwardRef, OnDestroy, ViewEncapsulation, + OnInit, Output, EventEmitter, + Input, Renderer2, SimpleChanges, OnChanges, } from '@angular/core'; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; +import { Subscription } from 'rxjs'; -import { EditorState, Plugin, PluginKey, Transaction } from 'prosemirror-state'; -import { EditorView } from 'prosemirror-view'; - -import { NgxEditorService, NgxEditorServiceConfig } from './editor.service'; -import { SharedService } from './services/shared/shared.service'; -import { Toolbar } from './types'; -import { editable as editablePlugin, placeholder as placeholderPlugin } from 'ngx-editor/plugins'; -import { parseValue, toHTML } from './parsers'; +import { toHTML } from './parsers'; +import Editor from './Editor'; @Component({ selector: 'ngx-editor', @@ -27,45 +22,29 @@ import { parseValue, toHTML } from './parsers'; encapsulation: ViewEncapsulation.None }) -export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnDestroy, OnChanges { - @ViewChild('ngxEditor', { static: true }) ngxEditor: ElementRef; - - view: EditorView; - private onChange: (value: Record | string) => void; - private onTouched: () => void; +export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnChanges, OnDestroy { - private config: NgxEditorServiceConfig; - private editorInitialized = false; + constructor(private renderer: Renderer2) { } + @ViewChild('ngxEditor', { static: true }) ngxEditor: ElementRef; + private subscriptions: Subscription[] = []; + @Input() editor: Editor; @Input() outputFormat: 'doc' | 'html'; - @Input() customMenuRef: TemplateRef; - @Input() placeholder = 'Type here...'; - @Input() editable = true; - @Output() init = new EventEmitter(); + @Input() placeholder = 'Type Here...'; + @Input() enabled = true; + @Output() focusOut = new EventEmitter(); @Output() focusIn = new EventEmitter(); - constructor( - ngxEditorService: NgxEditorService, - private sharedService: SharedService, - ) { - this.config = ngxEditorService.config; - } - - get toolbar(): Toolbar { - return this.config.menu?.toolbar; - } + private onChange: (value: Record | string) => void = () => { }; + private onTouched: () => void = () => { }; writeValue(value: Record | string | null): void { - if (!this.editorInitialized) { - return; - } - if (!this.outputFormat && typeof value === 'string') { this.outputFormat = 'html'; } - this.updateContent(value); + this.editor.setContent(value); } registerOnChange(fn: any): void { @@ -76,137 +55,70 @@ export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnDestr this.onTouched = fn; } - private updateContent(value: Record | string): void { - try { - const { state } = this.view; - const { tr, doc } = state; - - const newDoc = parseValue(value, this.config.schema); - tr.replaceWith(0, state.doc.content.size, newDoc) - .setMeta('PREVENT_ONCHANGE', true); - - // don't emit if both content is same - if (doc !== null && doc.eq(tr.doc)) { - return; - } - - if (!tr.docChanged) { - return; - } - - this.view.dispatch(tr); - } catch (err) { - console.error('Unable to update document.', err); - } - } - - private handleTransactions(tr: Transaction): void { - const { state } = this.view.state.applyTransaction(tr); - this.view.updateState(state); - - if (!tr.docChanged || !this.onChange || tr.getMeta('PREVENT_ONCHANGE')) { - return; - } - - const json = state.doc.toJSON(); - + private handleChange(jsonDoc: Record | null): void { if (this.outputFormat === 'html') { - const html = toHTML(json, this.config.schema); + const html = toHTML(jsonDoc, this.editor.schema); this.onChange(html); return; } - this.onChange(json); + this.onChange(jsonDoc); } - private createUpdateWatcherPlugin(): Plugin { - const plugin = new Plugin({ - key: new PluginKey('ngx-update-watcher'), - view: () => { - return { - update: (view: EditorView) => this.sharedService.plugin.update.next(view), - destroy: () => this.sharedService.plugin.destroy.next() - }; - } - }); - - return plugin; - } + ngOnInit(): void { + if (!this.editor) { + throw new Error('NGXEditor: Required Editor instance'); + } - private filterBuiltIns(plugin: Plugin): boolean { - const pluginKey: string = (plugin as any).key; - if (/^(editable|placeholder)\$/.test(pluginKey)) { - return false; + if (this.enabled) { + this.editor.enable(); + } else { + this.editor.disable(); } - return true; - } + this.editor.setPlaceholder(this.placeholder); - private createEditor(): void { - const { schema, plugins, nodeViews } = this.config; - - this.view = new EditorView(this.ngxEditor.nativeElement, { - state: EditorState.create({ - doc: null, - schema, - plugins: [ - ...plugins.filter((plugin) => this.filterBuiltIns(plugin)), - this.createUpdateWatcherPlugin(), - placeholderPlugin(this.placeholder), - editablePlugin(this.editable) - ] - }), - nodeViews, - dispatchTransaction: this.handleTransactions.bind(this), - handleDOMEvents: { - focus: () => { - this.focusIn.emit(); - return false; - }, - blur: () => { - this.onTouched(); - this.focusOut.emit(); - return false; - } - }, - attributes: { - class: 'NgxEditor__Content' - }, - }); + this.renderer.appendChild(this.ngxEditor.nativeElement, this.editor.el); - this.editorInitialized = true; - this.sharedService.view = this.view; - this.sharedService.setCustomMenuRef(this.customMenuRef); - this.init.emit(this.view); - } + const contentChangeSubscription = this.editor.onContentChange.subscribe(jsonDoc => { + this.handleChange(jsonDoc); + }); - private setPlaceholder(newPlaceholder?: string): void { - const { dispatch, state: { tr } } = this.view; - const placeholder = newPlaceholder ?? this.placeholder; - dispatch(tr.setMeta('UPDATE_PLACEHOLDER', placeholder)); - } + const blurSubscription = this.editor.onBlur.subscribe(() => { + this.focusOut.emit(); + this.onTouched(); + }); - private updateEditable(edit: boolean): void { - const { dispatch, state: { tr } } = this.view; - dispatch(tr.setMeta('UPDATE_EDITABLE', edit)); - } + const focusScbscription = this.editor.onFocus.subscribe(() => { + this.focusIn.emit(); + }); - ngOnInit(): void { - this.createEditor(); - this.setPlaceholder(); + this.subscriptions.push( + contentChangeSubscription, + blurSubscription, + focusScbscription + ); } ngOnChanges(changes: SimpleChanges): void { if (changes?.placeholder && !changes.placeholder.isFirstChange()) { - this.setPlaceholder(changes.placeholder.currentValue); + this.editor.setPlaceholder(changes.placeholder.currentValue); } if (changes?.editable && !changes.editable.isFirstChange()) { - this.updateEditable(changes.editable.currentValue); + if (!changes.editable.currentValue) { + this.editor.disable(); + } else { + this.editor.enable(); + } } } ngOnDestroy(): void { - this.view.destroy(); + this.subscriptions.forEach(subscription => { + subscription.unsubscribe(); + }); + + this.editor.destroy(); } } diff --git a/src/lib/editor.module.ts b/src/lib/editor.module.ts index 27b80290..2f1cb890 100644 --- a/src/lib/editor.module.ts +++ b/src/lib/editor.module.ts @@ -4,11 +4,11 @@ import { CommonModule } from '@angular/common'; import { NgxEditorConfig } from './types'; import { NgxEditorComponent } from './editor.component'; -import { NgxEditorServiceConfig, provideMyServiceOptions } from './editor.service'; +import { NgxEditorService, NgxEditorServiceConfig, provideMyServiceOptions } from './editor.service'; import { MenuModule } from './modules/menu/menu.module'; import { BubbleComponent } from './components/bubble/bubble.component'; -import { SharedService } from './services/shared/shared.service'; +import { MenuComponent } from './modules/menu/menu.component'; const NGX_EDITOR_CONFIG_TOKEN = new InjectionToken('NgxEditorConfig'); @@ -17,14 +17,15 @@ const NGX_EDITOR_CONFIG_TOKEN = new InjectionToken('NgxEditorCo CommonModule, MenuModule, ], - providers: [ - SharedService - ], + providers: [], declarations: [ NgxEditorComponent, - BubbleComponent + BubbleComponent, + ], + exports: [ + NgxEditorComponent, + MenuComponent ], - exports: [NgxEditorComponent], entryComponents: [BubbleComponent] }) @@ -46,4 +47,22 @@ export class NgxEditorModule { ] }; } + + static forChild(config: NgxEditorConfig): ModuleWithProviders { + return { + ngModule: NgxEditorModule, + providers: [ + { + provide: NGX_EDITOR_CONFIG_TOKEN, + useValue: config + }, + { + provide: NgxEditorServiceConfig, + useFactory: provideMyServiceOptions, + deps: [NGX_EDITOR_CONFIG_TOKEN] + }, + NgxEditorService, + ] + }; + } } diff --git a/src/lib/editor.service.ts b/src/lib/editor.service.ts index 2e595cf0..41622789 100644 --- a/src/lib/editor.service.ts +++ b/src/lib/editor.service.ts @@ -1,59 +1,12 @@ import { Injectable, Optional } from '@angular/core'; -import { Schema } from 'prosemirror-model'; -import { Plugin } from 'prosemirror-state'; -import { Menu, NgxEditorConfig, NodeViews, Toolbar } from './types'; +import { NgxEditorConfig} from './types'; import Locals from './Locals'; -import { schema } from './schema'; - -const DEFAULT_TOOLBAR: Toolbar = [ - ['bold', 'italic'], - ['code', 'blockquote'], - ['underline', 'strike'], - ['ordered_list', 'bullet_list'], - [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], - ['link', 'image'], - ['text_color', 'background_color'], - ['align_left', 'align_center', 'align_right', 'align_justify'], -]; - -const DEFAULT_COLOR_PRESETS = [ - '#b60205', - '#d93f0b', - '#fbca04', - '#0e8a16', - '#006b75', - '#1d76db', - '#0052cc', - '#5319e7', - '#e99695', - '#f9d0c4', - '#fef2c0', - '#c2e0c6', - '#bfdadc', - '#c5def5', - '#bfd4f2', - '#d4c5f9' -]; - -const DEFAULT_MENU: Menu = { - toolbar: DEFAULT_TOOLBAR, - colorPresets: [] -}; - -const DEFAULT_SCHEMA = schema; -const DEFAULT_PLUGINS: Plugin[] = [ -]; - @Injectable({ providedIn: 'root' }) export class NgxEditorServiceConfig { - public plugins: Plugin[] = DEFAULT_PLUGINS; - public nodeViews: NodeViews = {}; - public schema: Schema = DEFAULT_SCHEMA; - public menu = DEFAULT_MENU; public locals = {}; } @@ -70,56 +23,10 @@ export class NgxEditorService { get locals(): Locals { return new Locals(this.config.locals); } - - get menu(): Menu { - return this.config.menu; - } - - get colorPresets(): string[][] { - const col = 8; - const colors: string[][] = []; - - const { colorPresets } = this.config.menu; - const allColors = colorPresets.length ? colorPresets : DEFAULT_COLOR_PRESETS; - - allColors.forEach((color, index) => { - const row = Math.floor(index / col); - - if (!colors[row]) { - colors.push([]); - } - - colors[row].push(color); - }); - - return colors; - } } export const provideMyServiceOptions = (config?: NgxEditorConfig): NgxEditorServiceConfig => { - let menu: Menu; - - if (config.menu !== null) { - if (!config.menu) { - menu = DEFAULT_MENU; - } else if (Array.isArray(config.menu)) { - menu = { - ...DEFAULT_MENU, - toolbar: config.menu, - }; - } else { - menu = { - ...DEFAULT_MENU, - ...config.menu, - }; - } - } - return { - plugins: config?.plugins ?? DEFAULT_PLUGINS, - nodeViews: config?.nodeViews ?? {}, - menu, - schema: config?.schema ?? DEFAULT_SCHEMA, locals: config.locals ?? {} }; }; diff --git a/src/lib/modules/menu/color-picker/color-picker.component.ts b/src/lib/modules/menu/color-picker/color-picker.component.ts index f0241512..f7e59beb 100644 --- a/src/lib/modules/menu/color-picker/color-picker.component.ts +++ b/src/lib/modules/menu/color-picker/color-picker.component.ts @@ -7,7 +7,7 @@ import { Subscription } from 'rxjs'; import Icon from '../../../icons'; import { NgxEditorService } from '../../../editor.service'; -import { SharedService } from '../../../services/shared/shared.service'; +import { MenuService } from '../menu.service'; import { TextColor, TextBackgroundColor } from '../MenuCommands'; type Command = typeof TextColor | typeof TextBackgroundColor; @@ -18,15 +18,16 @@ type Command = typeof TextColor | typeof TextBackgroundColor; styleUrls: ['./color-picker.component.scss'] }) export class ColorPickerComponent implements OnDestroy { + @Input() presets: string[][]; constructor( private el: ElementRef, - private sharedService: SharedService, + private menuService: MenuService, private ngxeService: NgxEditorService ) { - this.editorView = this.sharedService.view; + this.editorView = this.menuService.view; - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { + this.pluginUpdateSubscription = this.menuService.plugin.update.subscribe((view: EditorView) => { this.update(view); }); } @@ -39,10 +40,6 @@ export class ColorPickerComponent implements OnDestroy { return !this.canExecute; } - get presets(): string[][] { - return this.ngxeService.colorPresets; - } - get title(): string { return this.getLabel(this.type === 'text_color' ? 'text_color' : 'background_color'); } diff --git a/src/lib/modules/menu/dropdown/dropdown.component.ts b/src/lib/modules/menu/dropdown/dropdown.component.ts index 5fc678b2..f3beeefc 100644 --- a/src/lib/modules/menu/dropdown/dropdown.component.ts +++ b/src/lib/modules/menu/dropdown/dropdown.component.ts @@ -3,7 +3,7 @@ import { EditorView } from 'prosemirror-view'; import { Subscription } from 'rxjs'; import { NgxEditorService } from '../../../editor.service'; -import { SharedService } from '../../../services/shared/shared.service'; +import { MenuService } from '../menu.service'; import { SimpleCommands } from '../MenuCommands'; @Component({ @@ -27,12 +27,12 @@ export class DropdownComponent implements OnInit, OnDestroy { constructor( private ngxeService: NgxEditorService, - private sharedService: SharedService, + private menuService: MenuService, private el: ElementRef ) { - this.editorView = this.sharedService.view; + this.editorView = this.menuService.view; - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { + this.pluginUpdateSubscription = this.menuService.plugin.update.subscribe((view: EditorView) => { this.update(view); }); } diff --git a/src/lib/modules/menu/image/image.component.ts b/src/lib/modules/menu/image/image.component.ts index be6e5455..2ed23b20 100644 --- a/src/lib/modules/menu/image/image.component.ts +++ b/src/lib/modules/menu/image/image.component.ts @@ -5,7 +5,7 @@ import { EditorView } from 'prosemirror-view'; import { Subscription } from 'rxjs'; import { NgxEditorService } from '../../../editor.service'; -import { SharedService } from '../../../services/shared/shared.service'; +import { MenuService } from '../menu.service'; import Icon from '../../../icons'; import { Image as ImageCommand } from '../MenuCommands'; @@ -34,11 +34,11 @@ export class ImageComponent implements OnDestroy { constructor( private el: ElementRef, private ngxeService: NgxEditorService, - private sharedService: SharedService + private menuService: MenuService ) { - this.editorView = this.sharedService.view; + this.editorView = this.menuService.view; - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { + this.pluginUpdateSubscription = this.menuService.plugin.update.subscribe((view: EditorView) => { this.update(view); }); } diff --git a/src/lib/modules/menu/link/link.component.ts b/src/lib/modules/menu/link/link.component.ts index e2df35b6..73fca703 100644 --- a/src/lib/modules/menu/link/link.component.ts +++ b/src/lib/modules/menu/link/link.component.ts @@ -4,7 +4,7 @@ import { EditorView } from 'prosemirror-view'; import { Subscription } from 'rxjs'; import { NgxEditorService } from '../../../editor.service'; -import { SharedService } from '../../../services/shared/shared.service'; +import { MenuService } from '../menu.service'; import Icon from '../../../icons'; import { Link as LinkCommand } from '../MenuCommands'; @@ -35,11 +35,11 @@ export class LinkComponent implements OnDestroy { constructor( private el: ElementRef, private ngxeService: NgxEditorService, - private sharedService: SharedService + private menuService: MenuService ) { - this.editorView = this.sharedService.view; + this.editorView = this.menuService.view; - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { + this.pluginUpdateSubscription = this.menuService.plugin.update.subscribe((view: EditorView) => { this.update(view); }); } diff --git a/src/lib/modules/menu/menu.component.html b/src/lib/modules/menu/menu.component.html index 1e8ad489..ea36fb73 100644 --- a/src/lib/modules/menu/menu.component.html +++ b/src/lib/modules/menu/menu.component.html @@ -24,10 +24,12 @@ - + - + diff --git a/src/lib/modules/menu/menu.component.scss b/src/lib/modules/menu/menu.component.scss index 20ff5e55..d9ab8949 100644 --- a/src/lib/modules/menu/menu.component.scss +++ b/src/lib/modules/menu/menu.component.scss @@ -9,8 +9,6 @@ $light-gray-1: #e8f0fe; $medium-gray: #ddd; $medium-gray-2: #ccc; -$menubar-border-color: $medium-gray; - $menu-item-border-radius: 2px; $menu-item-hover-bg-color: $light-gray; $menu-item-active-bg-color: $light-gray-1; @@ -26,7 +24,6 @@ $menubar-text-padding: 0 $menu-item-spacing; .NgxEditor__MenuBar { display: flex; padding: $menubar-padding; - border-bottom: 1px solid $menubar-border-color; cursor: default; height: $menubar-height; } diff --git a/src/lib/modules/menu/menu.component.ts b/src/lib/modules/menu/menu.component.ts index 80dd21b4..d9c2c240 100644 --- a/src/lib/modules/menu/menu.component.ts +++ b/src/lib/modules/menu/menu.component.ts @@ -1,25 +1,59 @@ -import { Component, Input, OnDestroy, TemplateRef, ViewEncapsulation } from '@angular/core'; -import { EditorView } from 'prosemirror-view'; +import { + Component, Input, OnDestroy, + OnInit, TemplateRef, ViewEncapsulation +} from '@angular/core'; import { Subscription } from 'rxjs'; -import { ToolbarItem } from '../../types'; +import { Toolbar, ToolbarItem } from '../../types'; -import { SharedService } from '../../services/shared/shared.service'; +import { MenuService } from './menu.service'; +import Editor from '../../Editor'; + +const DEFAULT_TOOLBAR: Toolbar = [ + ['bold', 'italic'], + ['code', 'blockquote'], + ['underline', 'strike'], + ['ordered_list', 'bullet_list'], + [{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }], + ['link', 'image'], + ['text_color', 'background_color'], + ['align_left', 'align_center', 'align_right', 'align_justify'], +]; + +const DEFAULT_COLOR_PRESETS = [ + '#b60205', + '#d93f0b', + '#fbca04', + '#0e8a16', + '#006b75', + '#1d76db', + '#0052cc', + '#5319e7', + '#e99695', + '#f9d0c4', + '#fef2c0', + '#c2e0c6', + '#bfdadc', + '#c5def5', + '#bfd4f2', + '#d4c5f9' +]; @Component({ - selector: 'ngx-menu', + selector: 'ngx-editor-menu', templateUrl: './menu.component.html', styleUrls: ['./menu.component.scss'], encapsulation: ViewEncapsulation.None }) -export class MenuComponent implements OnDestroy { - @Input() toolbar: any; - @Input() editorView: EditorView; +export class MenuComponent implements OnInit, OnDestroy { + @Input() toolbar: any = DEFAULT_TOOLBAR; + @Input() colorPresets: string[] = DEFAULT_COLOR_PRESETS; @Input() disabled = false; + @Input() editor: Editor; + @Input() customMenuRef: TemplateRef = null; - customMenuRef: TemplateRef = null; - customMenuRefSubscription: Subscription; + private updateSubscription: Subscription; simpleCommands = [ 'bold', 'italic', @@ -33,10 +67,23 @@ export class MenuComponent implements OnDestroy { dropdownContainerClass = ['NgxEditor__Dropdown']; seperatorClass = ['NgxEditor__Seperator']; - constructor(private sharedService: SharedService) { - this.customMenuRefSubscription = this.sharedService.customMenuRefChange.subscribe((ref) => { - this.customMenuRef = ref; + constructor(private menuService: MenuService) { } + + get presets(): string[][] { + const col = 8; + const colors: string[][] = []; + + this.colorPresets.forEach((color, index) => { + const row = Math.floor(index / col); + + if (!colors[row]) { + colors.push([]); + } + + colors[row].push(color); }); + + return colors; } isDropDown(item: ToolbarItem): boolean { @@ -47,7 +94,19 @@ export class MenuComponent implements OnDestroy { return false; } + ngOnInit(): void { + if (!this.editor) { + throw new Error('NgxEditor: Required editor instance'); + } + + this.menuService.view = this.editor.view; + + this.updateSubscription = this.editor.onUpdate.subscribe(() => { + this.menuService.plugin.update.next(this.editor.view); + }); + } + ngOnDestroy(): void { - this.customMenuRefSubscription.unsubscribe(); + this.updateSubscription.unsubscribe(); } } diff --git a/src/lib/modules/menu/menu.module.ts b/src/lib/modules/menu/menu.module.ts index bdd5f466..e0dfaaa4 100644 --- a/src/lib/modules/menu/menu.module.ts +++ b/src/lib/modules/menu/menu.module.ts @@ -2,6 +2,8 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; +import { MenuService } from './menu.service'; + import { MenuComponent } from './menu.component'; import { SimpleCommandComponent } from './simple-command/simple-command.component'; import { LinkComponent } from './link/link.component'; @@ -28,7 +30,12 @@ import { SanitizeHtmlPipe } from '../../pipes/sanitize/sanitize-html.pipe'; ImageComponent, ColorPickerComponent ], - exports: [MenuComponent], + providers: [ + MenuService, + ], + exports: [ + MenuComponent + ], }) export class MenuModule { } diff --git a/src/lib/services/shared/shared.service.ts b/src/lib/modules/menu/menu.service.ts similarity index 89% rename from src/lib/services/shared/shared.service.ts rename to src/lib/modules/menu/menu.service.ts index f0540efa..8091556f 100644 --- a/src/lib/services/shared/shared.service.ts +++ b/src/lib/modules/menu/menu.service.ts @@ -5,13 +5,13 @@ import { Subject } from 'rxjs'; @Injectable({ providedIn: 'root' }) -export class SharedService { +export class MenuService { #view: EditorView; customMenuRefChange: Subject> = new Subject>(); plugin = { update: new Subject(), - destroy: new Subject() + destroy: new Subject() }; constructor() { } diff --git a/src/lib/services/shared/shared.service.spec.ts b/src/lib/modules/menu/shared.service.spec.ts similarity index 55% rename from src/lib/services/shared/shared.service.spec.ts rename to src/lib/modules/menu/shared.service.spec.ts index 3da5345f..beab8b28 100644 --- a/src/lib/services/shared/shared.service.spec.ts +++ b/src/lib/modules/menu/shared.service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from '@angular/core/testing'; -import { SharedService } from './shared.service'; +import { MenuService } from './menu.service'; -describe('SharedService', () => { - let service: SharedService; +describe('MenuService', () => { + let service: MenuService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(SharedService); + service = TestBed.inject(MenuService); }); it('should be created', () => { diff --git a/src/lib/modules/menu/simple-command/simple-command.component.ts b/src/lib/modules/menu/simple-command/simple-command.component.ts index 4b25a2fd..8d1d2f12 100644 --- a/src/lib/modules/menu/simple-command/simple-command.component.ts +++ b/src/lib/modules/menu/simple-command/simple-command.component.ts @@ -5,7 +5,7 @@ import { Subscription } from 'rxjs'; import { SimpleCommands } from '../MenuCommands'; import Icon from '../../../icons'; import { NgxEditorService } from '../../../editor.service'; -import { SharedService } from '../../../services/shared/shared.service'; +import { MenuService } from '../menu.service'; @Component({ selector: 'ngx-simple-command', @@ -22,11 +22,11 @@ export class SimpleCommandComponent implements OnInit, OnDestroy { constructor( private ngxeService: NgxEditorService, - private sharedService: SharedService + private menuService: MenuService ) { - this.editorView = this.sharedService.view; + this.editorView = this.menuService.view; - this.pluginUpdateSubscription = this.sharedService.plugin.update.subscribe((view: EditorView) => { + this.pluginUpdateSubscription = this.menuService.plugin.update.subscribe((view: EditorView) => { this.update(view); }); } diff --git a/src/lib/parsers.ts b/src/lib/parsers.ts index dd8e5ea4..1bb059fe 100644 --- a/src/lib/parsers.ts +++ b/src/lib/parsers.ts @@ -24,7 +24,7 @@ export const toDoc = (html: string, inputSchema?: Schema): Record = return DOMParser.fromSchema(schema).parse(el).toJSON(); }; -export const parseValue = (value: string | Record | null, schema: Schema): ProsemirrorNode => { +export const parseContent = (value: string | Record | null, schema: Schema): ProsemirrorNode => { if (!value) { return null; } diff --git a/src/lib/utils/isNil.ts b/src/lib/utils/isNil.ts new file mode 100644 index 00000000..d4305844 --- /dev/null +++ b/src/lib/utils/isNil.ts @@ -0,0 +1,5 @@ +const isNil = (val: any): boolean => { + return typeof val === 'undefined' || val === null; +}; + +export default isNil; diff --git a/src/lib/validators.ts b/src/lib/validators.ts index 8e8c4645..e81ab562 100644 --- a/src/lib/validators.ts +++ b/src/lib/validators.ts @@ -1,7 +1,7 @@ import { AbstractControl, ValidatorFn } from '@angular/forms'; import { Schema} from 'prosemirror-model'; -import { parseValue } from './parsers'; +import { parseContent } from './parsers'; import defaultSchema from './schema'; type ValidationErrors = Record; @@ -24,7 +24,7 @@ export class Validators { return (control: AbstractControl): ValidationErrors | null => { const schema = userSchema || defaultSchema; - const doc = parseValue(control.value, schema); + const doc = parseContent(control.value, schema); const isEmpty = doc.childCount === 1 && doc?.firstChild?.isTextblock @@ -43,7 +43,7 @@ export class Validators { static maxLength(maxLength: number, userSchema?: Schema): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const schema = userSchema || defaultSchema; - const doc = parseValue(control.value, schema); + const doc = parseContent(control.value, schema); const value = doc.textContent; @@ -64,7 +64,7 @@ export class Validators { return (control: AbstractControl): ValidationErrors | null => { const schema = userSchema || defaultSchema; - const doc = parseValue(control.value, schema); + const doc = parseContent(control.value, schema); const value = doc.textContent; diff --git a/src/plugins/placeholder.ts b/src/plugins/placeholder.ts index 6ec8a11f..79c26f66 100644 --- a/src/plugins/placeholder.ts +++ b/src/plugins/placeholder.ts @@ -1,15 +1,14 @@ import { Plugin, EditorState, PluginKey, Transaction } from 'prosemirror-state'; import { DecorationSet, Decoration } from 'prosemirror-view'; -const DEFAULT_PLACEHOLDER = 'Type Here...'; const PLACEHOLDER_CLASSNAME = 'NgxEditor__Placeholder'; -const placeholderPlugin = (text: string = DEFAULT_PLACEHOLDER): Plugin => { +const placeholderPlugin = (text?: string): Plugin => { return new Plugin({ key: new PluginKey('placeholder'), state: { init(): string { - return text; + return text ?? ''; }, apply(tr: Transaction, previousVal: string): string { const placeholder = tr.getMeta('UPDATE_PLACEHOLDER') ?? previousVal; diff --git a/src/public_api.ts b/src/public_api.ts index f46d4a54..91020852 100644 --- a/src/public_api.ts +++ b/src/public_api.ts @@ -1,4 +1,5 @@ export * from './lib/editor.component'; +export * from './lib/modules/menu/menu.component'; export * from './lib/editor.module'; export * from './lib/schema'; @@ -6,3 +7,4 @@ export * from './lib/validators'; export * from './lib/types'; export * from './lib/parsers'; +export { default as Editor } from './lib/Editor';