diff --git a/src/lib/editor.component.scss b/src/lib/editor.component.scss index 588b258f..ed7b6b36 100644 --- a/src/lib/editor.component.scss +++ b/src/lib/editor.component.scss @@ -5,7 +5,6 @@ border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.2); position: relative; - overflow: hidden; } .NgxEditor--Disabled { diff --git a/src/lib/editor.component.spec.ts b/src/lib/editor.component.spec.ts index 0dbe1395..71d08599 100644 --- a/src/lib/editor.component.spec.ts +++ b/src/lib/editor.component.spec.ts @@ -4,7 +4,6 @@ import { DebugElement } from '@angular/core'; import { NgxEditorComponent } from './editor.component'; import { MenuModule } from './modules/menu/menu.module'; -// import { BubbleComponent } from './components/bubble/bubble.component'; import Editor from './Editor'; describe('NgxEditorComponent', () => { @@ -17,8 +16,7 @@ describe('NgxEditorComponent', () => { MenuModule ], declarations: [ - NgxEditorComponent, - // BubbleComponent + NgxEditorComponent ] }).compileComponents(); }); diff --git a/src/lib/editor.component.ts b/src/lib/editor.component.ts index 61ae5b0c..f2c65b7b 100644 --- a/src/lib/editor.component.ts +++ b/src/lib/editor.component.ts @@ -2,16 +2,18 @@ import { Component, ViewChild, ElementRef, forwardRef, OnDestroy, ViewEncapsulation, OnInit, Output, EventEmitter, - Input, Renderer2, SimpleChanges, OnChanges, Injector, + Input, Renderer2, SimpleChanges, + OnChanges, Injector, AfterViewInit, } from '@angular/core'; import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms'; -import { createCustomElement } from '@angular/elements'; +import { createCustomElement, NgElement, WithProperties } from '@angular/elements'; import { Subscription } from 'rxjs'; import * as plugins from './plugins'; import { toHTML } from './parsers'; import Editor from './Editor'; import { ImageViewComponent } from './components/image-view/image-view.component'; +import { FloatingMenuComponent } from './modules/menu/floating-menu/floating-menu.component'; @Component({ selector: 'ngx-editor', @@ -25,7 +27,7 @@ import { ImageViewComponent } from './components/image-view/image-view.component encapsulation: ViewEncapsulation.None }) -export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnChanges, OnDestroy { +export class NgxEditorComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnChanges, OnDestroy { constructor( private renderer: Renderer2, private injector: Injector @@ -89,12 +91,19 @@ export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnChang } private registerCustomElements(): void { - const imgViewExists = customElements.get('ngx-image-view'); + const imgViewComponent = customElements.get('ngx-image-view'); - if (!imgViewExists) { + if (!imgViewComponent) { const ImageViewElement = createCustomElement(ImageViewComponent, { injector: this.injector }); customElements.define('ngx-image-view', ImageViewElement); } + + const floatingMenuComponent = customElements.get('ngx-floating-menu'); + + if (!floatingMenuComponent) { + const FloatingMenuElement = createCustomElement(FloatingMenuComponent, { injector: this.injector }); + customElements.define('ngx-floating-menu', FloatingMenuElement); + } } private registerPlugins(): void { @@ -121,6 +130,15 @@ export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnChang this.editor.registerPlugin(plugins.image(this.injector)); } + private createFloatingMenu(): void { + type FLoatingMenuElement = NgElement & WithProperties; + const floatingMenu = this.renderer.createElement('ngx-floating-menu') as FLoatingMenuElement; + + floatingMenu.editor = this.editor; + + this.renderer.appendChild(this.editor.view.dom.parentElement, floatingMenu); + } + ngOnInit(): void { if (!this.editor) { throw new Error('NgxEditor: Required editor instance'); @@ -131,13 +149,17 @@ export class NgxEditorComponent implements ControlValueAccessor, OnInit, OnChang this.renderer.appendChild(this.ngxEditor.nativeElement, this.editor.el); - const contentChangeSubscription = this.editor.valueChange.subscribe(jsonDoc => { + const contentChangeSubscription = this.editor.valueChanges.subscribe(jsonDoc => { this.handleChange(jsonDoc); }); this.subscriptions.push(contentChangeSubscription); } + ngAfterViewInit(): void { + this.createFloatingMenu(); + } + ngOnChanges(changes: SimpleChanges): void { if (changes?.placeholder && !changes.placeholder.isFirstChange()) { this.setPlaceholder(changes.placeholder.currentValue); diff --git a/src/lib/icons/index.ts b/src/lib/icons/index.ts index ddc44436..63603dac 100644 --- a/src/lib/icons/index.ts +++ b/src/lib/icons/index.ts @@ -57,6 +57,11 @@ class Icon { `; } + static getPath(name: keyof typeof icons): string { + const path = icons[name] || ''; + return path; + } + } export default Icon; diff --git a/src/lib/modules/menu/floating-menu/floating-menu.component.html b/src/lib/modules/menu/floating-menu/floating-menu.component.html new file mode 100644 index 00000000..b54d7e24 --- /dev/null +++ b/src/lib/modules/menu/floating-menu/floating-menu.component.html @@ -0,0 +1,11 @@ + + +
+ +
+
+
+
\ No newline at end of file diff --git a/src/lib/modules/menu/floating-menu/floating-menu.component.scss b/src/lib/modules/menu/floating-menu/floating-menu.component.scss new file mode 100644 index 00000000..3ed1c356 --- /dev/null +++ b/src/lib/modules/menu/floating-menu/floating-menu.component.scss @@ -0,0 +1,57 @@ +:host { + position: absolute; + z-index: 20; + margin-bottom: 0.35rem; + display: flex; + border-radius: 4px; + background-color: #000; + color: white; + display: flex; + height: 1.85rem; + padding: 0.2rem 0.3rem; + visibility: hidden; +} + +.NgxFloatingMenu__Icon { + height: 1.8rem; + width: 1.8rem; + transition: 0.3s ease-in-out; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + + svg { + fill: white; + } + + &:hover { + background-color: #636262; + } + + + .NgxFloatingMenu__Icon { + margin-left: 0.3rem; + } +} + +.NgxFloatingMenu__Icon--Active { + background-color: white; + + svg { + fill: black; + } + + &:hover { + background-color: #636262; + + svg { + fill: white; + } + } +} + +.NgxFloatingMenu__Seperator { + border-left: 1px solid white; + height: 100%; + margin: 0 5px; +} diff --git a/src/lib/modules/menu/floating-menu/floating-menu.component.spec.ts b/src/lib/modules/menu/floating-menu/floating-menu.component.spec.ts new file mode 100644 index 00000000..399157a1 --- /dev/null +++ b/src/lib/modules/menu/floating-menu/floating-menu.component.spec.ts @@ -0,0 +1,37 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FloatingMenuComponent } from './floating-menu.component'; +import { SanitizeHtmlPipe } from '../../../pipes/sanitize/sanitize-html.pipe'; +import Editor from '../../../Editor'; + +describe('FloatingMenuComponent', () => { + let component: FloatingMenuComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + FloatingMenuComponent + ], + providers: [ + SanitizeHtmlPipe + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FloatingMenuComponent); + component = fixture.componentInstance; + component.editor = new Editor(); + fixture.detectChanges(); + }); + + afterEach(() => { + component.editor.destroy(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/lib/modules/menu/floating-menu/floating-menu.component.ts b/src/lib/modules/menu/floating-menu/floating-menu.component.ts new file mode 100644 index 00000000..43737000 --- /dev/null +++ b/src/lib/modules/menu/floating-menu/floating-menu.component.ts @@ -0,0 +1,214 @@ +import { + Component, ElementRef, HostBinding, + HostListener, Input, OnDestroy, OnInit +} from '@angular/core'; +import { SafeHtml } from '@angular/platform-browser'; +import { NodeSelection } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { asyncScheduler, fromEvent, Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; + +import Editor from '../../../Editor'; +import Icon from '../../../icons'; +import { TBItems } from '../../../types'; +import { SanitizeHtmlPipe } from '../../../pipes/sanitize/sanitize-html.pipe'; +import { ToggleCommands } from '../MenuCommands'; + +interface BubblePosition { + bottom: number; + left: number; +} + +@Component({ + selector: 'ngx-floating-menu', + templateUrl: './floating-menu.component.html', + styleUrls: ['./floating-menu.component.scss'] +}) +export class FloatingMenuComponent implements OnInit, OnDestroy { + @Input() editor: Editor; + @HostBinding('style') get display(): Partial { + if (!this.showMenu) { + return { + visibility: 'hidden' + }; + } + + return { + visibility: 'visible', + bottom: this.posBottom + 'px', + left: this.posLeft + 'px', + }; + } + + private posLeft = 0; + private posBottom = 0; + private showMenu = false; + private updateSubscription: Subscription; + private dragging = false; + private view: EditorView; + private resizeSubscription: Subscription; + execulableItems: TBItems[] = []; + activeItems: TBItems[] = []; + + constructor(private el: ElementRef, private sanitizeHTML: SanitizeHtmlPipe) { } + + @HostListener('document:mousedown') onMouseDown(): void { + this.dragging = true; + } + + @HostListener('document:keydown') onKeyDown(): void { + this.dragging = true; + } + + @HostListener('document:mouseup') onMouseUp(): void { + this.dragging = false; + this.useUpdate(); + } + + @HostListener('document:keyup') onKeyUp(): void { + this.dragging = false; + this.useUpdate(); + } + + private useUpdate(): void { + if (!this.view) { + return; + } + this.update(this.view); + } + + get toolbar(): TBItems[][] { + return [ + ['bold', 'italic', 'underline', 'strike'], + ['ordered_list', 'bullet_list', 'blockquote', 'code'] + ]; + } + + get toggleCommands(): TBItems[] { + return [ + 'bold', 'italic', 'underline', 'strike', + 'ordered_list', 'bullet_list', 'blockquote', 'code' + ]; + } + + getIcon(name: TBItems): SafeHtml { + const icon = Icon.getPath(name); + return this.sanitizeHTML.transform(icon); + } + + private calculateBubblePosition(view: EditorView): BubblePosition { + const { state: { selection } } = view; + const { from } = selection; + + const bubble = this.el.nativeElement; + + // These are in screen coordinates + const start = view.coordsAtPos(from); + + // The box in which the tooltip is positioned, to use as base + const box = view.dom.getBoundingClientRect(); + + let left = start.left - box.left; + + const overflowsRight = ( + box.right < (start.left + bubble.getBoundingClientRect().width) || + bubble.getBoundingClientRect().right > box.right + ); + + if (overflowsRight) { + left = box.width - bubble.getBoundingClientRect().width; + } + + if (left < 0) { + left = 0; + } + + return { + left, + bottom: box.bottom - start.top + }; + } + + private update(view: EditorView): void { + const { state } = view; + const { selection } = state; + const { empty } = selection; + + + if (selection instanceof NodeSelection) { + if (selection.node.type.name === 'image') { + this.showMenu = false; + return; + } + } + + const hasFocus = this.view.hasFocus(); + + if (!hasFocus || empty || this.dragging) { + this.showMenu = false; + return; + } + + const { bottom, left } = this.calculateBubblePosition(this.view); + + this.posLeft = left; + this.posBottom = bottom; + this.showMenu = true; + } + + onClick(e: MouseEvent, commandName: TBItems): void { + e.preventDefault(); + if (e.button !== 0) { + return; + } + + const { state, dispatch } = this.view; + + const command = ToggleCommands[commandName]; + command.toggle()(state, dispatch); + + this.showMenu = false; + } + + private findActiveAndDisabledItems(view: EditorView): void { + this.activeItems = []; + this.execulableItems = []; + const { state } = view; + + this.toggleCommands.forEach(toolbarItem => { + const command = ToggleCommands[toolbarItem]; + + const isActive = command.isActive(state); + if (isActive) { + this.activeItems.push(toolbarItem); + } + + const canExecute = command.canExecute(state); + + if (canExecute) { + this.execulableItems.push(toolbarItem); + } + }); + } + + + ngOnInit(): void { + this.updateSubscription = this.editor.update + .subscribe((view) => { + this.view = view; + this.update(view); + this.findActiveAndDisabledItems(view); + }); + + this.resizeSubscription = fromEvent(window, 'resize').pipe( + throttleTime(500, asyncScheduler, { leading: true, trailing: true }) + ).subscribe(() => { + this.useUpdate(); + }); + } + + ngOnDestroy(): void { + this.updateSubscription.unsubscribe(); + this.resizeSubscription.unsubscribe(); + } +} diff --git a/src/lib/modules/menu/menu.module.ts b/src/lib/modules/menu/menu.module.ts index ec18c7f7..cbae1c22 100644 --- a/src/lib/modules/menu/menu.module.ts +++ b/src/lib/modules/menu/menu.module.ts @@ -12,6 +12,7 @@ import { ImageComponent } from './image/image.component'; import { ColorPickerComponent } from './color-picker/color-picker.component'; import { SanitizeHtmlPipe } from '../../pipes/sanitize/sanitize-html.pipe'; +import { FloatingMenuComponent } from './floating-menu/floating-menu.component'; @NgModule({ imports: [ @@ -29,9 +30,11 @@ import { SanitizeHtmlPipe } from '../../pipes/sanitize/sanitize-html.pipe'; DropdownComponent, ImageComponent, ColorPickerComponent, + FloatingMenuComponent ], providers: [ MenuService, + SanitizeHtmlPipe ], exports: [ MenuComponent