UNPKG

@hoso/ngx-contextmenu

Version:

An Angular component to show a context menu on an arbitrary component

634 lines (622 loc) 23.7 kB
import { Overlay, ScrollStrategyOptions, OverlayContainer, FullscreenOverlayContainer, OverlayModule } from '@angular/cdk/overlay'; import { CommonModule } from '@angular/common'; import { EventEmitter, Directive, TemplateRef, ElementRef, Input, Output, InjectionToken, QueryList, Component, ChangeDetectorRef, Optional, Inject, ViewChild, ViewChildren, HostListener, Injectable, ViewEncapsulation, ContentChildren, NgModule } from '@angular/core'; import { Subscription, Subject } from 'rxjs'; import { first } from 'rxjs/operators'; import { ComponentPortal } from '@angular/cdk/portal'; import { ActiveDescendantKeyManager } from '@angular/cdk/a11y'; class ContextMenuItemDirective { constructor(template, elementRef) { this.template = template; this.elementRef = elementRef; this.divider = false; this.enabled = true; this.passive = false; this.visible = true; this.execute = new EventEmitter(); this.isActive = false; } get disabled() { return this.passive || this.divider || !this.evaluateIfFunction(this.enabled, this.currentItem); } evaluateIfFunction(value, item) { if (value instanceof Function) { return value(item); } return value; } setActiveStyles() { this.isActive = true; } setInactiveStyles() { this.isActive = false; } triggerExecute(item, $event) { if (!this.evaluateIfFunction(this.enabled, item)) { return; } this.execute.emit({ event: $event, item }); } } ContextMenuItemDirective.decorators = [ { type: Directive, args: [{ /* tslint:disable:directive-selector-type */ selector: '[contextMenuItem]', },] } ]; /** @nocollapse */ ContextMenuItemDirective.ctorParameters = () => [ { type: TemplateRef }, { type: ElementRef } ]; ContextMenuItemDirective.propDecorators = { subMenu: [{ type: Input }], divider: [{ type: Input }], enabled: [{ type: Input }], passive: [{ type: Input }], visible: [{ type: Input }], execute: [{ type: Output }] }; const CONTEXT_MENU_OPTIONS = new InjectionToken('CONTEXT_MENU_OPTIONS'); const ARROW_LEFT_KEYCODE = 37; class ContextMenuContentComponent { constructor(changeDetector, elementRef, options) { this.changeDetector = changeDetector; this.elementRef = elementRef; this.options = options; this.menuItems = []; this.isLeaf = false; this.execute = new EventEmitter(); this.openSubMenu = new EventEmitter(); this.closeLeafMenu = new EventEmitter(); this.closeAllMenus = new EventEmitter(); this.autoFocus = false; this.useBootstrap4 = false; this.subscription = new Subscription(); if (options) { this.autoFocus = options.autoFocus; this.useBootstrap4 = options.useBootstrap4; } } ngOnInit() { this.menuItems.forEach(menuItem => { menuItem.currentItem = this.item; this.subscription.add(menuItem.execute.subscribe(event => this.execute.emit(Object.assign(Object.assign({}, event), { menuItem })))); }); const queryList = new QueryList(); queryList.reset(this.menuItems); this._keyManager = new ActiveDescendantKeyManager(queryList).withWrap(); } ngAfterViewInit() { if (this.autoFocus) { setTimeout(() => this.focus()); } this.overlay.updatePosition(); } ngOnDestroy() { this.subscription.unsubscribe(); } focus() { if (this.autoFocus) { this.menuElement.nativeElement.focus(); } } stopEvent($event) { $event.stopPropagation(); } isMenuItemEnabled(menuItem) { return this.evaluateIfFunction(menuItem && menuItem.enabled); } isMenuItemVisible(menuItem) { return this.evaluateIfFunction(menuItem && menuItem.visible); } evaluateIfFunction(value) { if (value instanceof Function) { return value(this.item); } return value; } isDisabled(link) { return link.enabled && !link.enabled(this.item); } onKeyEvent(event) { if (!this.isLeaf) { return; } this._keyManager.onKeydown(event); } keyboardOpenSubMenu(event) { if (!this.isLeaf) { return; } this.cancelEvent(event); const menuItem = this.menuItems[this._keyManager.activeItemIndex]; if (menuItem) { this.onOpenSubMenu(menuItem); } } keyboardMenuItemSelect(event) { if (!this.isLeaf) { return; } this.cancelEvent(event); const menuItem = this.menuItems[this._keyManager.activeItemIndex]; if (menuItem) { this.onMenuItemSelect(menuItem, event); } } onCloseLeafMenu(event) { if (!this.isLeaf) { return; } this.cancelEvent(event); this.closeLeafMenu.emit({ exceptRootMenu: event.keyCode === ARROW_LEFT_KEYCODE, event }); } closeMenu(event) { if (event.type === 'click' && event.button === 2) { return; } this.closeAllMenus.emit({ event }); } onOpenSubMenu(menuItem, event) { const anchorElementRef = this.menuItemElements.toArray()[this._keyManager.activeItemIndex]; const anchorElement = anchorElementRef && anchorElementRef.nativeElement; this.openSubMenu.emit({ anchorElement, contextMenu: menuItem.subMenu, event, item: this.item, parentContextMenu: this }); } onMenuItemSelect(menuItem, event) { event.preventDefault(); event.stopPropagation(); this.onOpenSubMenu(menuItem, event); if (!menuItem.subMenu) { menuItem.triggerExecute(this.item, event); } } cancelEvent(event) { if (!event) { return; } const target = event.target; if (['INPUT', 'TEXTAREA', 'SELECT'].indexOf(target.tagName) > -1 || target.isContentEditable) { return; } event.preventDefault(); event.stopPropagation(); } } ContextMenuContentComponent.decorators = [ { type: Component, args: [{ selector: 'context-menu-content', template: ` <div class="dropdown open show ngx-contextmenu" [ngClass]="menuClass" tabindex="0" > <ul #menu class="dropdown-menu show" style="position: static; float: none;" tabindex="0" > <li #li *ngFor="let menuItem of menuItems; let i = index" [class.disabled]="!isMenuItemEnabled(menuItem)" [class.divider]="menuItem.divider" [class.dropdown-divider]="useBootstrap4 && menuItem.divider" [class.active]="menuItem.isActive && isMenuItemEnabled(menuItem)" [attr.role]="menuItem.divider ? 'separator' : undefined" > <a *ngIf="!menuItem.divider && !menuItem.passive" href [class.dropdown-item]="useBootstrap4" [class.active]="menuItem.isActive && isMenuItemEnabled(menuItem)" [class.disabled]="useBootstrap4 && !isMenuItemEnabled(menuItem)" [class.hasSubMenu]="!!menuItem.subMenu" (click)="onMenuItemSelect(menuItem, $event)" (mouseenter)="onOpenSubMenu(menuItem, $event)" > <ng-template [ngTemplateOutlet]="menuItem.template" [ngTemplateOutletContext]="{ $implicit: item }" ></ng-template> </a> <span (click)="stopEvent($event)" (contextmenu)="stopEvent($event)" class="passive" *ngIf="!menuItem.divider && menuItem.passive" [class.dropdown-item]="useBootstrap4" [class.disabled]="useBootstrap4 && !isMenuItemEnabled(menuItem)" > <ng-template [ngTemplateOutlet]="menuItem.template" [ngTemplateOutletContext]="{ $implicit: item }" ></ng-template> </span> </li> </ul> </div> `, styles: [` .passive { display: block; padding: 3px 20px; clear: both; font-weight: normal; line-height: @line-height-base; white-space: nowrap; } .hasSubMenu:before { content: '\u25B6'; float: right; } `] },] } ]; /** @nocollapse */ ContextMenuContentComponent.ctorParameters = () => [ { type: ChangeDetectorRef }, { type: ElementRef }, { type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [CONTEXT_MENU_OPTIONS,] }] } ]; ContextMenuContentComponent.propDecorators = { menuItems: [{ type: Input }], item: [{ type: Input }], event: [{ type: Input }], parentContextMenu: [{ type: Input }], menuClass: [{ type: Input }], overlay: [{ type: Input }], isLeaf: [{ type: Input }], execute: [{ type: Output }], openSubMenu: [{ type: Output }], closeLeafMenu: [{ type: Output }], closeAllMenus: [{ type: Output }], menuElement: [{ type: ViewChild, args: ['menu', { static: true },] }], menuItemElements: [{ type: ViewChildren, args: ['li',] }], onKeyEvent: [{ type: HostListener, args: ['window:keydown.ArrowDown', ['$event'],] }, { type: HostListener, args: ['window:keydown.ArrowUp', ['$event'],] }], keyboardOpenSubMenu: [{ type: HostListener, args: ['window:keydown.ArrowRight', ['$event'],] }], keyboardMenuItemSelect: [{ type: HostListener, args: ['window:keydown.Enter', ['$event'],] }, { type: HostListener, args: ['window:keydown.Space', ['$event'],] }], onCloseLeafMenu: [{ type: HostListener, args: ['window:keydown.Escape', ['$event'],] }, { type: HostListener, args: ['window:keydown.ArrowLeft', ['$event'],] }], closeMenu: [{ type: HostListener, args: ['document:click', ['$event'],] }, { type: HostListener, args: ['document:contextmenu', ['$event'],] }] }; class ContextMenuService { constructor(overlay, scrollStrategy) { this.overlay = overlay; this.scrollStrategy = scrollStrategy; this.isDestroyingLeafMenu = false; this.show = new Subject(); this.triggerClose = new Subject(); this.close = new Subject(); this.overlays = []; this.fakeElement = { getBoundingClientRect: () => ({ bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0, }) }; } openContextMenu(context) { const { anchorElement, event, parentContextMenu } = context; if (!parentContextMenu) { const mouseEvent = event; this.fakeElement.getBoundingClientRect = () => ({ bottom: mouseEvent.clientY, height: 0, left: mouseEvent.clientX, right: mouseEvent.clientX, top: mouseEvent.clientY, width: 0, }); this.closeAllContextMenus({ eventType: 'cancel', event }); const positionStrategy = this.overlay.position().connectedTo(new ElementRef(anchorElement || this.fakeElement), { originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }) .withFallbackPosition({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }) .withFallbackPosition({ originX: 'end', originY: 'top' }, { overlayX: 'start', overlayY: 'top' }) .withFallbackPosition({ originX: 'start', originY: 'top' }, { overlayX: 'end', overlayY: 'top' }) .withFallbackPosition({ originX: 'end', originY: 'center' }, { overlayX: 'start', overlayY: 'center' }) .withFallbackPosition({ originX: 'start', originY: 'center' }, { overlayX: 'end', overlayY: 'center' }); this.overlays = [this.overlay.create({ positionStrategy, panelClass: 'ngx-contextmenu', scrollStrategy: this.scrollStrategy.close(), })]; this.attachContextMenu(this.overlays[0], context); } else { const positionStrategy = this.overlay.position().connectedTo(new ElementRef(event ? event.target : anchorElement), { originX: 'end', originY: 'top' }, { overlayX: 'start', overlayY: 'top' }) .withFallbackPosition({ originX: 'start', originY: 'top' }, { overlayX: 'end', overlayY: 'top' }) .withFallbackPosition({ originX: 'end', originY: 'bottom' }, { overlayX: 'start', overlayY: 'bottom' }) .withFallbackPosition({ originX: 'start', originY: 'bottom' }, { overlayX: 'end', overlayY: 'bottom' }); const newOverlay = this.overlay.create({ positionStrategy, panelClass: 'ngx-contextmenu', scrollStrategy: this.scrollStrategy.close(), }); this.destroySubMenus(parentContextMenu); this.overlays = this.overlays.concat(newOverlay); this.attachContextMenu(newOverlay, context); } } attachContextMenu(overlay, context) { const { event, item, menuItems, menuClass } = context; const contextMenuContent = overlay.attach(new ComponentPortal(ContextMenuContentComponent)); contextMenuContent.instance.event = event; contextMenuContent.instance.item = item; contextMenuContent.instance.menuItems = menuItems; contextMenuContent.instance.overlay = overlay; contextMenuContent.instance.isLeaf = true; contextMenuContent.instance.menuClass = menuClass; overlay.contextMenu = contextMenuContent.instance; const subscriptions = new Subscription(); subscriptions.add(contextMenuContent.instance.execute.asObservable() .subscribe((executeEvent) => this.closeAllContextMenus(Object.assign({ eventType: 'execute' }, executeEvent)))); subscriptions.add(contextMenuContent.instance.closeAllMenus.asObservable() .subscribe((closeAllEvent) => this.closeAllContextMenus(Object.assign({ eventType: 'cancel' }, closeAllEvent)))); subscriptions.add(contextMenuContent.instance.closeLeafMenu.asObservable() .subscribe(closeLeafMenuEvent => this.destroyLeafMenu(closeLeafMenuEvent))); subscriptions.add(contextMenuContent.instance.openSubMenu.asObservable() .subscribe((subMenuEvent) => { this.destroySubMenus(contextMenuContent.instance); if (!subMenuEvent.contextMenu) { contextMenuContent.instance.isLeaf = true; return; } contextMenuContent.instance.isLeaf = false; this.show.next(subMenuEvent); })); contextMenuContent.onDestroy(() => { menuItems.forEach(menuItem => menuItem.isActive = false); subscriptions.unsubscribe(); }); contextMenuContent.changeDetectorRef.detectChanges(); } closeAllContextMenus(closeEvent) { if (this.overlays) { this.close.next(closeEvent); this.overlays.forEach((overlay, index) => { overlay.detach(); overlay.dispose(); }); } this.overlays = []; } getLastAttachedOverlay() { let overlay = this.overlays[this.overlays.length - 1]; while (this.overlays.length > 1 && overlay && !overlay.hasAttached()) { overlay.detach(); overlay.dispose(); this.overlays = this.overlays.slice(0, -1); overlay = this.overlays[this.overlays.length - 1]; } return overlay; } destroyLeafMenu({ exceptRootMenu, event } = {}) { if (this.isDestroyingLeafMenu) { return; } this.isDestroyingLeafMenu = true; setTimeout(() => { const overlay = this.getLastAttachedOverlay(); if (this.overlays.length > 1 && overlay) { overlay.detach(); overlay.dispose(); } if (!exceptRootMenu && this.overlays.length > 0 && overlay) { this.close.next({ eventType: 'cancel', event }); overlay.detach(); overlay.dispose(); } const newLeaf = this.getLastAttachedOverlay(); if (newLeaf) { newLeaf.contextMenu.isLeaf = true; } this.isDestroyingLeafMenu = false; }); } destroySubMenus(contextMenu) { const overlay = contextMenu.overlay; const index = this.overlays.indexOf(overlay); this.overlays.slice(index + 1).forEach(subMenuOverlay => { subMenuOverlay.detach(); subMenuOverlay.dispose(); }); } isLeafMenu(contextMenuContent) { const overlay = this.getLastAttachedOverlay(); return contextMenuContent.overlay === overlay; } } ContextMenuService.decorators = [ { type: Injectable } ]; /** @nocollapse */ ContextMenuService.ctorParameters = () => [ { type: Overlay }, { type: ScrollStrategyOptions } ]; class ContextMenuComponent { constructor(_contextMenuService, changeDetector, elementRef, options) { this._contextMenuService = _contextMenuService; this.changeDetector = changeDetector; this.elementRef = elementRef; this.options = options; this.menuClass = ""; this.autoFocus = false; this.useBootstrap4 = false; this.disabled = false; this.close = new EventEmitter(); this.open = new EventEmitter(); this.visibleMenuItems = []; this.links = []; this.subscription = new Subscription(); if (options) { this.autoFocus = options.autoFocus; this.useBootstrap4 = options.useBootstrap4; } this.subscription.add(_contextMenuService.show.subscribe(menuEvent => { this.onMenuEvent(menuEvent); })); } ngOnDestroy() { this.subscription.unsubscribe(); } onMenuEvent(menuEvent) { if (this.disabled) { return; } const { contextMenu, event, item } = menuEvent; if (contextMenu && contextMenu !== this) { return; } this.event = event; this.item = item; this.setVisibleMenuItems(); this._contextMenuService.openContextMenu(Object.assign(Object.assign({}, menuEvent), { menuItems: this.visibleMenuItems, menuClass: this.menuClass })); this._contextMenuService.close.asObservable().pipe(first()).subscribe(closeEvent => this.close.emit(closeEvent)); this.open.next(menuEvent); } isMenuItemVisible(menuItem) { return this.evaluateIfFunction(menuItem.visible); } setVisibleMenuItems() { this.visibleMenuItems = this.menuItems.filter(menuItem => this.isMenuItemVisible(menuItem)); } evaluateIfFunction(value) { if (value instanceof Function) { return value(this.item); } return value; } } ContextMenuComponent.decorators = [ { type: Component, args: [{ encapsulation: ViewEncapsulation.None, selector: 'context-menu', template: ` `, styles: [` .cdk-overlay-container { position: fixed; z-index: 1000; pointer-events: none; top: 0; left: 0; width: 100%; height: 100%; } .ngx-contextmenu.cdk-overlay-pane { position: absolute; pointer-events: auto; box-sizing: border-box; } `] },] } ]; /** @nocollapse */ ContextMenuComponent.ctorParameters = () => [ { type: ContextMenuService }, { type: ChangeDetectorRef }, { type: ElementRef }, { type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [CONTEXT_MENU_OPTIONS,] }] } ]; ContextMenuComponent.propDecorators = { menuClass: [{ type: Input }], autoFocus: [{ type: Input }], useBootstrap4: [{ type: Input }], disabled: [{ type: Input }], close: [{ type: Output }], open: [{ type: Output }], menuItems: [{ type: ContentChildren, args: [ContextMenuItemDirective,] }], menuElement: [{ type: ViewChild, args: ['menu', { static: false },] }] }; class ContextMenuAttachDirective { constructor(contextMenuService) { this.contextMenuService = contextMenuService; } onContextMenu(event) { if (!this.contextMenu.disabled) { this.contextMenuService.show.next({ contextMenu: this.contextMenu, event, item: this.contextMenuSubject, }); event.preventDefault(); event.stopPropagation(); } } } ContextMenuAttachDirective.decorators = [ { type: Directive, args: [{ selector: '[contextMenu]', },] } ]; /** @nocollapse */ ContextMenuAttachDirective.ctorParameters = () => [ { type: ContextMenuService } ]; ContextMenuAttachDirective.propDecorators = { contextMenuSubject: [{ type: Input }], contextMenu: [{ type: Input }], onContextMenu: [{ type: HostListener, args: ['contextmenu', ['$event'],] }] }; class ContextMenuModule { static forRoot(options) { return { ngModule: ContextMenuModule, providers: [ ContextMenuService, { provide: CONTEXT_MENU_OPTIONS, useValue: options, }, { provide: OverlayContainer, useClass: FullscreenOverlayContainer }, ], }; } } ContextMenuModule.decorators = [ { type: NgModule, args: [{ declarations: [ ContextMenuAttachDirective, ContextMenuComponent, ContextMenuContentComponent, ContextMenuItemDirective, ], entryComponents: [ ContextMenuContentComponent, ], exports: [ ContextMenuAttachDirective, ContextMenuComponent, ContextMenuItemDirective, ], imports: [ CommonModule, OverlayModule, ], },] } ]; /* * Public API Surface of ngx-contextmenu */ /** * Generated bundle index. Do not edit. */ export { ContextMenuAttachDirective, ContextMenuComponent, ContextMenuItemDirective, ContextMenuModule, ContextMenuService, CONTEXT_MENU_OPTIONS as ɵa, ContextMenuContentComponent as ɵb }; //# sourceMappingURL=hoso-ngx-contextmenu.js.map