@dotglitch/ngx-ctx-menu
Version:
Angular context menu that works with templates
679 lines (673 loc) • 54.9 kB
JavaScript
import * as i0 from '@angular/core';
import { TemplateRef, Component, Optional, Inject, EventEmitter, Input, Output, HostListener, Directive } from '@angular/core';
import * as i2$1 from '@angular/common';
import { NgTemplateOutlet, NgIf, NgForOf, CommonModule } from '@angular/common';
import * as i3 from '@angular/platform-browser';
import { createApplication } from '@angular/platform-browser';
import * as i1 from '@angular/material/dialog';
import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog';
import * as i4 from '@angular/material/icon';
import { MatIconModule } from '@angular/material/icon';
import * as i5 from '@angular/material/progress-spinner';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import * as i2 from '@angular/cdk/portal';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { firstValueFrom } from 'rxjs';
const calcMenuItemBounds = async (menuItems, dataObj) => {
const data = {
data: dataObj,
items: menuItems,
config: {},
id: null
};
return calcComponentBounds(ContextMenuComponent, data);
};
const calcComponentBounds = async (component, data) => {
// Forcibly bootstrap the ctx menu outside of the client application's zone.
const app = await createApplication({
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data }
]
});
const del = document.createElement("div");
del.style.position = "absolute";
del.style.left = '-1000vw';
document.body.append(del);
const base = app.bootstrap(component, del);
const { instance } = base;
await firstValueFrom(app.isStable);
const el = instance.viewContainer?.element?.nativeElement;
const rect = el.getBoundingClientRect();
app.destroy();
del.remove();
return rect;
};
class TemplateWrapper {
constructor(dialogRef, _data, viewContainer) {
this.dialogRef = dialogRef;
this._data = _data;
this.viewContainer = viewContainer;
this.data = _data.data;
this.template = _data.template;
// TODO: This is probably invalid
this.templateType = this.template instanceof TemplateRef ? "template" : "component";
if (this.templateType == "component") {
this.componentPortal = new ComponentPortal(this.template);
}
}
}
/** @nocollapse */ TemplateWrapper.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: TemplateWrapper, deps: [{ token: i1.MatDialogRef, optional: true }, { token: MAT_DIALOG_DATA }, { token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Component });
/** @nocollapse */ TemplateWrapper.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.0", type: TemplateWrapper, isStandalone: true, selector: "ngx-ctx-menu-template-container", ngImport: i0, template: `
<ng-container *ngIf="templateType == 'template'; else portalOutlet">
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ '$implicit': data, dialog: dialogRef }"
/>
</ng-container>
<ng-template #portalOutlet [cdkPortalOutlet]="componentPortal" ></ng-template>
`, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: PortalModule }, { kind: "directive", type: i2.CdkPortalOutlet, selector: "[cdkPortalOutlet]", inputs: ["cdkPortalOutlet"], outputs: ["attached"], exportAs: ["cdkPortalOutlet"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: TemplateWrapper, decorators: [{
type: Component,
args: [{
selector: 'ngx-ctx-menu-template-container',
template: `
<ng-container *ngIf="templateType == 'template'; else portalOutlet">
<ng-container
[ngTemplateOutlet]="template"
[ngTemplateOutletContext]="{ '$implicit': data, dialog: dialogRef }"
/>
</ng-container>
<ng-template #portalOutlet [cdkPortalOutlet]="componentPortal" ></ng-template>
`,
imports: [NgTemplateOutlet, PortalModule, NgIf],
standalone: true
}]
}], ctorParameters: function () { return [{ type: i1.MatDialogRef, decorators: [{
type: Optional
}] }, { type: undefined, decorators: [{
type: Inject,
args: [MAT_DIALOG_DATA]
}] }, { type: i0.ViewContainerRef }]; } });
class ContextMenuComponent {
constructor(viewContainer, sanitizer, _data, dialog, // optional only for the purpose of estimating dimensions
dialogRef, changeDetector) {
this.viewContainer = viewContainer;
this.sanitizer = sanitizer;
this._data = _data;
this.dialog = dialog;
this.dialogRef = dialogRef;
this.changeDetector = changeDetector;
this.closeSignal = new EventEmitter();
// Check if there are any slashes or dots -- that will clearly exclude it from being a mat icon
this.matIconRx = /[\/\.]/i;
this.showIconColumn = true;
this.showShortcutColumn = true;
// Defaults are set before @Input() hooks evaluate
this.data = this._data?.data;
this.parentCords = this._data?.parentCords;
this.items = this._data?.items;
this.config = this._data?.config;
this.id = this._data?.id;
}
ngOnInit() {
this.items?.forEach(i => {
if (typeof i == "string")
return;
// Set defaults
i['_disabled'] = false;
i['_visible'] = true;
if (i.label)
try {
i['_formattedLabel'] = this.formatLabel(i.label);
}
catch (e) {
console.warn(e);
}
if (typeof i.isDisabled == "function")
try {
i['_disabled'] = i.isDisabled(this.data || {});
}
catch (e) {
console.warn(e);
}
if (typeof i.isVisible == "function")
try {
i['_visible'] = i.isVisible(this.data || {});
}
catch (e) {
console.warn(e);
}
if (typeof i.linkTemplate == "function")
try {
i['_link'] = i.linkTemplate(this.data || {});
}
catch (e) {
console.warn(e);
}
});
// Show the icon column if there are any items with an icon
this.showIconColumn = !!this.items.find(i => typeof i == "object" &&
typeof i['icon'] == "string" &&
i['icon'].length > 2);
this.showShortcutColumn = !!this.items.find(i => typeof i == "object" &&
typeof i['shortcut'] == "string" &&
i['shortcut'].length > 2);
// setTimeout(() => {
// this.closeOnLeave = true
// }, 300);
}
ngAfterViewInit() {
if (this.parentCords) {
this.selfCords = this.viewContainer?.element?.nativeElement?.getBoundingClientRect();
this.changeDetector.detectChanges();
}
}
/**
*
* @param item
* @param evt
* @returns
*/
async onMenuItemClick(item, row, hideBackdrop = false) {
if (typeof item == 'string')
return null;
if (item.separator)
return null;
// If cache is enabled, only load if we don't have any children.
const forceLoad = (item.cacheResolvedChildren ? !item.children : true);
if (item.childrenResolver && forceLoad) {
item['_isResolving'] = true;
item.children = await item.childrenResolver(this.data);
item['_isResolving'] = false;
}
if (!item.childTemplate && !item.children) {
if (item.action) {
item.action(this.data);
this.close();
}
// If no action, this is simply a text item.
return null;
}
// Need X pos, Y pos, width and height
const bounds = row.getBoundingClientRect();
const cords = {
top: null,
left: null,
bottom: null,
right: null
};
// Set position coordinates
const { width, height } = await (item.childTemplate
? calcComponentBounds(TemplateWrapper, { template: item.childTemplate })
: calcMenuItemBounds(item.children, this.data));
if (bounds.y + height > window.innerHeight)
cords.bottom = "0px";
if (bounds.x + bounds.width + width > window.innerWidth)
cords.left = ((bounds.x - width)) + "px";
if (!cords.bottom)
cords.top = bounds.y + "px";
if (!cords.left)
cords.left = bounds.x + bounds.width + "px";
const component = item.children ? ContextMenuComponent : TemplateWrapper;
const dialogRef = this.dialog.open(component, {
position: cords,
panelClass: ["ngx-ctx-menu", "ngx-app-menu"].concat(this.config?.customClass || []),
backdropClass: "ngx-ctx-menu-backdrop",
hasBackdrop: !hideBackdrop,
data: {
data: this.data,
parentCords: this.viewContainer?.element?.nativeElement?.getBoundingClientRect(),
items: item.children,
template: item.childTemplate,
config: this.config
}
});
let _s = dialogRef
.afterClosed()
.subscribe((result) => {
if (result != -1) {
if (result && typeof item.action == 'function')
item.action(result);
this.close();
}
else {
item['_selfclose'] = Date.now();
}
_s.unsubscribe();
});
return dialogRef;
}
/**
*
* @param label
* @returns
*/
formatLabel(label) {
return label.replace(/_([a-z0-9])_/i, (match, group) => `<u>${group}</u>`);
}
/**
* Close the context menu under these circumstances
*/
// @HostListener("window:resize", ['event'])
// @HostListener("window:blur", ['event'])
close() {
this.closeSignal.emit();
this.dialogRef?.close();
}
/**
* Check if the dialog is clipping offscreen
* if so, move it back into view.
*/
onResize() {
const el = this.viewContainer?.element?.nativeElement;
if (!el)
return;
const { width, height, x, y } = el.getBoundingClientRect();
const target = document.querySelector(".ngx-ctx-menu,.ngx-app-menu");
if (!target)
return;
// Move back into view if we're clipping outside of the bottom
if (y + height > window.innerHeight) {
const newTop = (window.innerHeight - (height + (this.config.edgePadding || 12))) + "px";
target.style['margin-top'] = newTop;
}
// Move back into view if we're clipping off the right
if (x + width > window.innerWidth) {
const newLeft = (window.innerWidth - (width + (this.config.edgePadding || 12))) + "px";
target.style['margin-left'] = newLeft;
}
}
}
/** @nocollapse */ ContextMenuComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: ContextMenuComponent, deps: [{ token: i0.ViewContainerRef }, { token: i3.DomSanitizer }, { token: MAT_DIALOG_DATA, optional: true }, { token: i1.MatDialog, optional: true }, { token: i1.MatDialogRef, optional: true }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
/** @nocollapse */ ContextMenuComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.0", type: ContextMenuComponent, isStandalone: true, selector: "ngx-ctx-menu", inputs: { data: "data", parentCords: "parentCords", items: "items", config: "config", id: "id" }, outputs: { closeSignal: "closeSignal" }, host: { listeners: { "window:resize": "onResize()" } }, ngImport: i0, template: "<table>\n <tbody>\n <ng-container *ngFor=\"let item of items\">\n <ng-container>\n\n <!-- A row with a click action -->\n <tr #row\n *ngIf=\"item != 'separator' && item.separator != true && item['_visible']\"\n [class.disabled]=\"item['_disabled']\"\n (click)=\"!item['_disabled'] && onMenuItemClick(item, row)\"\n [class.hover]=\"item['children'] && row['hover']\"\n (pointerenter)=\"row['hover'] = true;\"\n (pointerleave)=\"row['hover'] = false\"\n >\n <!-- (item['children']?.length > 0 || item['childTemplate']) && onHover(item, row); closeOnLeave=true -->\n <td class=\"icon\" *ngIf=\"showIconColumn\">\n <img *ngIf=\"matIconRx.test(item.icon); else matIcon\" [src]=\"item.icon\" />\n <ng-template #matIcon>\n <mat-icon [fontIcon]=\"item.icon\"></mat-icon>\n </ng-template>\n </td>\n\n <!-- 'Normal' action based item -->\n <ng-container>\n <td class=\"label\"\n [style.padding-left]=\"showIconColumn ? 0 : '16px'\"\n >\n <a\n [attr.target]=\"item.linkTarget\"\n [attr.href]=\"(item['_link'] || item.link) ? sanitizer.bypassSecurityTrustUrl(item['_link'] || item.link) : undefined\"\n >\n <ng-container\n *ngIf=\"$any(item.labelTemplate)?.prototype; else labelTemplate\"\n [ngTemplateOutlet]=\"$any(item).labelTemplate\"\n [ngTemplateOutletContext]=\"{ '$implicit': data, 'dialog': dialogRef }\"\n />\n\n <ng-template #labelTemplate>\n <ng-container *ngIf=\"!$any(item)?.labelTemplate\">\n <div [innerHTML]=\"item['_formattedLabel']\"></div>\n </ng-container>\n <ng-container *ngIf=\"$any(item)?.labelTemplate\">\n {{$any(item)?.labelTemplate(data || {})}}\n </ng-container>\n </ng-template>\n </a>\n </td>\n </ng-container>\n\n <td class=\"shortcut\" *ngIf=\"showShortcutColumn\">\n {{item.shortcutLabel}}\n </td>\n <td style=\"min-width: 16px\">\n <mat-icon *ngIf=\"\n (item.children && item.children.length > 0) ||\n item.childTemplate ||\n (item.childrenResolver && !item['_isResolving'])\n \"\n sytle=\"transform: translateY(2px)\"\n >\n chevron_right\n </mat-icon>\n\n <mat-progress-spinner *ngIf=\"item['_isResolving']\" mode=\"indeterminate\" [diameter]=\"20\" style=\"margin-right: 4px\"/>\n </td>\n </tr>\n\n <tr *ngIf=\"item != 'separator' && item.separator == true\" class=\"disabled separator\">\n <td class=\"center\" [attr.colspan]=\"2 + (showIconColumn ? 1 : 0) + (showShortcutColumn ? 1 : 0)\">\n <span class=\"hr\">\n {{item['label'] || ''}}\n </span>\n </td>\n </tr>\n <tr *ngIf=\"item == 'separator'\" class=\"disabled separator\">\n <td [attr.colspan]=\"2 + (showIconColumn ? 1 : 0) + (showShortcutColumn ? 1 : 0)\">\n <hr/>\n </td>\n </tr>\n </ng-container>\n </ng-container>\n </tbody>\n</table>\n\n<!-- <div *ngIf=\"true\" class=\"backdrop\"></div> -->\n<!-- <div\n *ngIf=\"parentCords && this.selfCords\"\n class=\"backdrop parent\"\n [style.top]=\"(parentCords.y - selfCords.y + 6) + 'px'\"\n [style.left]=\"(parentCords.x - selfCords.x + 12) + 'px'\"\n [style.width]=\"(parentCords.width) + 'px'\"\n [style.height]=\"(parentCords.height) + 'px'\"\n>\n</div> -->\n<!-- <ng-container *ngIf=\"parentCords && selfCords\">\n <div #top\n class=\"backdrop-outer\"\n [style.bottom]=\"(parentCords.y - selfCords.y)*-1 + parentCords.height + 'px'\"\n style=\"background: #f003;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n <div #right\n class=\"backdrop-outer\"\n [style.left]=\"((parentCords.x - selfCords.x) + parentCords.width) + 'px'\"\n style=\"background: #0f03;\"\n (pointerenter)=\"onLeave()\"\n >\n <div>px: {{parentCords.x}}</div>\n <div>py: {{parentCords.y}}</div>\n <div>pw: {{parentCords.width}}</div>\n <div>ph: {{parentCords.height}}</div>\n <div>sx: {{selfCords.x}}</div>\n <div>sy: {{selfCords.y}}</div>\n <div>sw: {{selfCords.width}}</div>\n <div>sh: {{selfCords.height}}</div>\n </div>\n <div #bottom\n class=\"backdrop-outer\"\n [style.top]=\"((parentCords.y + parentCords.height - selfCords.y)) + 'px'\"\n style=\"background: #00f3;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n <div #left\n class=\"backdrop-outer\"\n [style.right]=\"((parentCords.x - selfCords.x)*-1 + parentCords.width + 32) + 'px'\"\n style=\"background: #fff3;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n</ng-container> -->\n", styles: ["::ng-deep .cdk-overlay-container .ngx-ctx-menu{--mdc-dialog-container-color: var(--ngx-ctx-menu-background-color, #2f2f2f)}::ng-deep .cdk-overlay-container .ngx-ctx-menu .mdc-dialog__container{transform-origin:top left}::ng-deep .cdk-overlay-container .ngx-ctx-menu .mdc-dialog--open .mdc-dialog__container{transform:none}::ng-deep .cdk-overlay-pane.ngx-ctx-menu .mat-mdc-dialog-surface{overflow:visible}:host{-webkit-user-select:none;user-select:none;z-index:1;position:relative;display:block;overflow:hidden auto}table{border-spacing:0;border-radius:5px;padding:4px 0}tr{color:var(--ngx-ctx-menu-text-color, #ccc);font-size:14px;cursor:pointer;transition:background-color 75ms ease,color 75ms ease}tr:not(.disabled):hover{background-color:var(--ngx-ctx-menu-hover-background-color, #94ebeb);color:var(--ngx-ctx-menu-hover-text-color, #000)}tr:not(.disabled):hover a{color:var(--ngx-ctx-menu-hover-text-color, #000)}tr:not(.separator){height:36px}tr.disabled .label{color:var(--ngx-ctx-menu-disabled-text-color, #919191)}tr .center{text-align:center}tr a{outline:0;display:flex;align-items:center;gap:10px;justify-content:space-between;height:100%;width:100%}tr .label{min-width:100px}.hr{height:1px;text-align:center;position:relative}.hr:before,.hr:after{content:\"\";background:var(--ngx-ctx-menu-separator-color, #2a2a2a);display:block;position:absolute;top:0;bottom:0;height:1px;margin:auto;width:300px}.hr:before{right:calc(100% + 4px)}.hr:after{left:calc(100% + 4px)}hr{background:var(--ngx-ctx-menu-separator-color, #2a2a2a);border:0;height:1px;margin:0}.icon{width:24px;height:24px;padding-left:10px}.icon mat-icon{transform:translateY(2px)}.shortcut{color:var(--ngx-ctx-menu-shortcut-text-color, #848484);text-align:end;padding-right:10px;padding-left:12px}.label{height:var(--ngx-ctx-menu-item-height, 30px)}td{vertical-align:middle}\n"], dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatProgressSpinnerModule }, { kind: "component", type: i5.MatProgressSpinner, selector: "mat-progress-spinner, mat-spinner", inputs: ["color", "mode", "value", "diameter", "strokeWidth"], exportAs: ["matProgressSpinner"] }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: ContextMenuComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-ctx-menu', imports: [
NgIf,
NgForOf,
NgTemplateOutlet,
MatIconModule,
MatProgressSpinnerModule
], standalone: true, template: "<table>\n <tbody>\n <ng-container *ngFor=\"let item of items\">\n <ng-container>\n\n <!-- A row with a click action -->\n <tr #row\n *ngIf=\"item != 'separator' && item.separator != true && item['_visible']\"\n [class.disabled]=\"item['_disabled']\"\n (click)=\"!item['_disabled'] && onMenuItemClick(item, row)\"\n [class.hover]=\"item['children'] && row['hover']\"\n (pointerenter)=\"row['hover'] = true;\"\n (pointerleave)=\"row['hover'] = false\"\n >\n <!-- (item['children']?.length > 0 || item['childTemplate']) && onHover(item, row); closeOnLeave=true -->\n <td class=\"icon\" *ngIf=\"showIconColumn\">\n <img *ngIf=\"matIconRx.test(item.icon); else matIcon\" [src]=\"item.icon\" />\n <ng-template #matIcon>\n <mat-icon [fontIcon]=\"item.icon\"></mat-icon>\n </ng-template>\n </td>\n\n <!-- 'Normal' action based item -->\n <ng-container>\n <td class=\"label\"\n [style.padding-left]=\"showIconColumn ? 0 : '16px'\"\n >\n <a\n [attr.target]=\"item.linkTarget\"\n [attr.href]=\"(item['_link'] || item.link) ? sanitizer.bypassSecurityTrustUrl(item['_link'] || item.link) : undefined\"\n >\n <ng-container\n *ngIf=\"$any(item.labelTemplate)?.prototype; else labelTemplate\"\n [ngTemplateOutlet]=\"$any(item).labelTemplate\"\n [ngTemplateOutletContext]=\"{ '$implicit': data, 'dialog': dialogRef }\"\n />\n\n <ng-template #labelTemplate>\n <ng-container *ngIf=\"!$any(item)?.labelTemplate\">\n <div [innerHTML]=\"item['_formattedLabel']\"></div>\n </ng-container>\n <ng-container *ngIf=\"$any(item)?.labelTemplate\">\n {{$any(item)?.labelTemplate(data || {})}}\n </ng-container>\n </ng-template>\n </a>\n </td>\n </ng-container>\n\n <td class=\"shortcut\" *ngIf=\"showShortcutColumn\">\n {{item.shortcutLabel}}\n </td>\n <td style=\"min-width: 16px\">\n <mat-icon *ngIf=\"\n (item.children && item.children.length > 0) ||\n item.childTemplate ||\n (item.childrenResolver && !item['_isResolving'])\n \"\n sytle=\"transform: translateY(2px)\"\n >\n chevron_right\n </mat-icon>\n\n <mat-progress-spinner *ngIf=\"item['_isResolving']\" mode=\"indeterminate\" [diameter]=\"20\" style=\"margin-right: 4px\"/>\n </td>\n </tr>\n\n <tr *ngIf=\"item != 'separator' && item.separator == true\" class=\"disabled separator\">\n <td class=\"center\" [attr.colspan]=\"2 + (showIconColumn ? 1 : 0) + (showShortcutColumn ? 1 : 0)\">\n <span class=\"hr\">\n {{item['label'] || ''}}\n </span>\n </td>\n </tr>\n <tr *ngIf=\"item == 'separator'\" class=\"disabled separator\">\n <td [attr.colspan]=\"2 + (showIconColumn ? 1 : 0) + (showShortcutColumn ? 1 : 0)\">\n <hr/>\n </td>\n </tr>\n </ng-container>\n </ng-container>\n </tbody>\n</table>\n\n<!-- <div *ngIf=\"true\" class=\"backdrop\"></div> -->\n<!-- <div\n *ngIf=\"parentCords && this.selfCords\"\n class=\"backdrop parent\"\n [style.top]=\"(parentCords.y - selfCords.y + 6) + 'px'\"\n [style.left]=\"(parentCords.x - selfCords.x + 12) + 'px'\"\n [style.width]=\"(parentCords.width) + 'px'\"\n [style.height]=\"(parentCords.height) + 'px'\"\n>\n</div> -->\n<!-- <ng-container *ngIf=\"parentCords && selfCords\">\n <div #top\n class=\"backdrop-outer\"\n [style.bottom]=\"(parentCords.y - selfCords.y)*-1 + parentCords.height + 'px'\"\n style=\"background: #f003;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n <div #right\n class=\"backdrop-outer\"\n [style.left]=\"((parentCords.x - selfCords.x) + parentCords.width) + 'px'\"\n style=\"background: #0f03;\"\n (pointerenter)=\"onLeave()\"\n >\n <div>px: {{parentCords.x}}</div>\n <div>py: {{parentCords.y}}</div>\n <div>pw: {{parentCords.width}}</div>\n <div>ph: {{parentCords.height}}</div>\n <div>sx: {{selfCords.x}}</div>\n <div>sy: {{selfCords.y}}</div>\n <div>sw: {{selfCords.width}}</div>\n <div>sh: {{selfCords.height}}</div>\n </div>\n <div #bottom\n class=\"backdrop-outer\"\n [style.top]=\"((parentCords.y + parentCords.height - selfCords.y)) + 'px'\"\n style=\"background: #00f3;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n <div #left\n class=\"backdrop-outer\"\n [style.right]=\"((parentCords.x - selfCords.x)*-1 + parentCords.width + 32) + 'px'\"\n style=\"background: #fff3;\"\n (pointerenter)=\"onLeave()\"\n ></div>\n</ng-container> -->\n", styles: ["::ng-deep .cdk-overlay-container .ngx-ctx-menu{--mdc-dialog-container-color: var(--ngx-ctx-menu-background-color, #2f2f2f)}::ng-deep .cdk-overlay-container .ngx-ctx-menu .mdc-dialog__container{transform-origin:top left}::ng-deep .cdk-overlay-container .ngx-ctx-menu .mdc-dialog--open .mdc-dialog__container{transform:none}::ng-deep .cdk-overlay-pane.ngx-ctx-menu .mat-mdc-dialog-surface{overflow:visible}:host{-webkit-user-select:none;user-select:none;z-index:1;position:relative;display:block;overflow:hidden auto}table{border-spacing:0;border-radius:5px;padding:4px 0}tr{color:var(--ngx-ctx-menu-text-color, #ccc);font-size:14px;cursor:pointer;transition:background-color 75ms ease,color 75ms ease}tr:not(.disabled):hover{background-color:var(--ngx-ctx-menu-hover-background-color, #94ebeb);color:var(--ngx-ctx-menu-hover-text-color, #000)}tr:not(.disabled):hover a{color:var(--ngx-ctx-menu-hover-text-color, #000)}tr:not(.separator){height:36px}tr.disabled .label{color:var(--ngx-ctx-menu-disabled-text-color, #919191)}tr .center{text-align:center}tr a{outline:0;display:flex;align-items:center;gap:10px;justify-content:space-between;height:100%;width:100%}tr .label{min-width:100px}.hr{height:1px;text-align:center;position:relative}.hr:before,.hr:after{content:\"\";background:var(--ngx-ctx-menu-separator-color, #2a2a2a);display:block;position:absolute;top:0;bottom:0;height:1px;margin:auto;width:300px}.hr:before{right:calc(100% + 4px)}.hr:after{left:calc(100% + 4px)}hr{background:var(--ngx-ctx-menu-separator-color, #2a2a2a);border:0;height:1px;margin:0}.icon{width:24px;height:24px;padding-left:10px}.icon mat-icon{transform:translateY(2px)}.shortcut{color:var(--ngx-ctx-menu-shortcut-text-color, #848484);text-align:end;padding-right:10px;padding-left:12px}.label{height:var(--ngx-ctx-menu-item-height, 30px)}td{vertical-align:middle}\n"] }]
}], ctorParameters: function () { return [{ type: i0.ViewContainerRef }, { type: i3.DomSanitizer }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [MAT_DIALOG_DATA]
}] }, { type: i1.MatDialog, decorators: [{
type: Optional
}] }, { type: i1.MatDialogRef, decorators: [{
type: Optional
}] }, { type: i0.ChangeDetectorRef }]; }, propDecorators: { data: [{
type: Input
}], parentCords: [{
type: Input
}], items: [{
type: Input
}], config: [{
type: Input
}], id: [{
type: Input
}], closeSignal: [{
type: Output
}], onResize: [{
type: HostListener,
args: ["window:resize"]
}] } });
const getPosition = (el, config = {}, bounds) => {
// Bounds of the popup owner
const src = !!el['nodeName']
? el.getBoundingClientRect()
: {
// It's a pointer event, so we'll take the X and Y from the pointer.
x: el['clientX'],
y: el['clientY'],
// Set a default tiny size, so we don't divide by zero.
width: 0.0001,
height: 0.0001
};
// Popup bounds
const { width, height } = bounds;
const winh = window.innerHeight;
const winw = window.innerWidth;
const cords = {
top: null,
left: null
};
if (config?.position == "left" || config?.position == "right" || !config?.position) {
switch (config?.alignment) {
case "end": {
// vertically bind to bottom
cords.top = src.y + src.height - height;
break;
}
case "afterend": {
// vertically bind below bottom
cords.top = src.y + src.height;
break;
}
case "beforestart": {
// vertically bind above top
cords.top = src.y - height;
break;
}
case "start": {
// vertically bind to top
cords.top = src.y;
break;
}
case "center":
default: {
// vertically center
cords.top = (src.y + (src.height / 2)) - (height / 2);
break;
}
}
// Apply bounds to prevent the dialog from being cut-off screen
// Lower bound
cords.top = Math.max(config?.edgePadding || 0, cords.top);
// Upper bound
cords.top = Math.min(winh - height, cords.top);
if (config?.position == "left") {
cords.left = src.x - (width + (config?.arrowSize || 0) + (config?.arrowPadding || 0));
}
if (config?.position == "right" || !config?.position) {
cords.left = src.x + (src.width + (config?.arrowSize || 0) + (config?.arrowPadding || 0));
}
// Lower bound
cords.left = Math.max(config?.edgePadding || 0, cords.left);
// Upper bound
cords.left = Math.min(winw - width, cords.left);
}
else if (config?.position == "top" || config?.position == "bottom") {
switch (config?.alignment) {
case "end": {
// vertically bind to right
cords.left = src.x + src.width - width;
break;
}
case "afterend": {
// vertically bind past right
cords.left = src.x + src.width;
break;
}
case "beforestart": {
// vertically bind before left
cords.left = src.x - width;
break;
}
case "start": {
// vertically bind to left
cords.left = src.x;
break;
}
case "center":
default: {
// vertically center
cords.left = (src.x + (src.width / 2)) - (width / 2);
break;
}
}
// Apply bounds to prevent the dialog from being cut-off screen
// Lower bound
cords.left = Math.max(config?.edgePadding || 0, cords.left);
// Upper bound
cords.left = Math.min(winw - width, cords.left);
if (config?.position == "top") {
cords.top = src.y - (height + (config?.arrowSize || 0) + (config?.arrowPadding || 0));
}
if (config?.position == "bottom") {
cords.top = src.y + (src.height + (config?.arrowSize || 0) + (config?.arrowPadding || 0));
}
// Lower bound
cords.top = Math.max(config?.edgePadding || 0, cords.top);
// Upper bound
cords.top = Math.min(winh - height, cords.top);
}
// Assign unit
cords.top = cords.top + 'px';
cords.left = cords.left + 'px';
return cords;
};
class NgxAppMenuDirective {
constructor(dialog, viewContainer) {
this.dialog = dialog;
this.viewContainer = viewContainer;
/**
* Configuration for opening the app menu
*/
this.config = {};
}
ngAfterViewInit() {
const el = this.viewContainer.element.nativeElement;
if (!this.config?.trigger) {
el.onclick = this.openDialog.bind(this);
}
else {
const triggers = Array.isArray(this.config.trigger) ? this.config.trigger : [this.config.trigger];
triggers.forEach(t => {
if (t == "contextmenu") {
el.addEventListener(t, (e) => {
e.preventDefault();
this.openDialog(e);
});
}
else {
el.addEventListener(t, this.openDialog.bind(this));
}
});
}
}
// Needs to be public so we can manually open the dialog
async openDialog(evt) {
const el = this.viewContainer.element.nativeElement;
const cords = getPosition(el, this.config, await calcMenuItemBounds(this.menuItems, this.data));
const specificId = crypto.randomUUID();
el.classList.add("ngx-app-menu-open");
// Create the context menu
let _s = this.dialog.open(ContextMenuComponent, {
data: {
data: this.data,
items: this.menuItems,
// dialog: this.dialog
config: this.config,
id: specificId
},
panelClass: ["ngx-app-menu", "ngx-ctx-menu", 'ngx-' + specificId].concat(this.config?.customClass || []),
position: cords,
backdropClass: "ngx-app-menu-backdrop"
})
.afterClosed() // What a stupid thing to make an observable.
.subscribe(() => {
_s.unsubscribe();
el.classList.remove("ngx-app-menu-open");
});
}
}
/** @nocollapse */ NgxAppMenuDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: NgxAppMenuDirective, deps: [{ token: i1.MatDialog }, { token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Directive });
/** @nocollapse */ NgxAppMenuDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "15.2.0", type: NgxAppMenuDirective, isStandalone: true, selector: "[ngx-app-menu]", inputs: { menuItems: ["ngx-app-menu", "menuItems"], data: ["ngx-app-menu-context", "data"], config: ["ngx-app-menu-config", "config"] }, ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: NgxAppMenuDirective, decorators: [{
type: Directive,
args: [{
selector: '[ngx-app-menu]',
standalone: true
}]
}], ctorParameters: function () { return [{ type: i1.MatDialog }, { type: i0.ViewContainerRef }]; }, propDecorators: { menuItems: [{
type: Input,
args: ["ngx-app-menu"]
}], data: [{
type: Input,
args: ["ngx-app-menu-context"]
}], config: [{
type: Input,
args: ["ngx-app-menu-config"]
}] } });
class NgxContextMenuDirective {
constructor(dialog, viewContainer) {
this.dialog = dialog;
this.viewContainer = viewContainer;
/**
* Configuration for opening the app menu
*/
this.config = {};
}
// Needs to be public so we can manually open the dialog
async onContextMenu(evt) {
const el = this.viewContainer.element.nativeElement;
el.classList.add("ngx-app-menu-open");
return openContextMenu(this.dialog, this.menuItems, this.data, evt, this.config)
.finally(() => {
el.classList.remove("ngx-app-menu-open");
});
}
}
/** @nocollapse */ NgxContextMenuDirective.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: NgxContextMenuDirective, deps: [{ token: i1.MatDialog }, { token: i0.ViewContainerRef }], target: i0.ɵɵFactoryTarget.Directive });
/** @nocollapse */ NgxContextMenuDirective.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "15.2.0", type: NgxContextMenuDirective, isStandalone: true, selector: "[ngx-ctx-menu]", inputs: { data: ["ngx-ctx-menu-context", "data"], menuItems: ["ngx-ctx-menu", "menuItems"], config: ["ngx-ctx-menu-config", "config"] }, host: { listeners: { "contextmenu": "onContextMenu($event)" } }, providers: [
MatDialog
], ngImport: i0 });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: NgxContextMenuDirective, decorators: [{
type: Directive,
args: [{
selector: '[ngx-ctx-menu]',
providers: [
MatDialog
],
standalone: true
}]
}], ctorParameters: function () { return [{ type: i1.MatDialog }, { type: i0.ViewContainerRef }]; }, propDecorators: { data: [{
type: Input,
args: ["ngx-ctx-menu-context"]
}], menuItems: [{
type: Input,
args: ["ngx-ctx-menu"]
}], config: [{
type: Input,
args: ["ngx-ctx-menu-config"]
}], onContextMenu: [{
type: HostListener,
args: ['contextmenu', ['$event']]
}] } });
// Helper to open the context menu without using the directive.
const openContextMenu = async (dialog, menuItems, data, evt, config = {}) => {
evt.preventDefault();
evt.stopPropagation();
const cords = getPosition(evt, config, await calcMenuItemBounds(menuItems, data));
const specificId = crypto.randomUUID();
if (!config.alignment)
config.alignment = "start";
return new Promise(res => {
dialog.open(ContextMenuComponent, {
data: {
data: data,
items: menuItems,
config: config,
id: specificId
},
panelClass: ["ngx-ctx-menu", 'ngx-' + specificId].concat(config?.customClass || []),
position: cords,
backdropClass: "ngx-ctx-menu-backdrop"
})
.afterClosed()
.subscribe(s => {
res(s);
});
});
};
const calcTooltipBounds = async (template, data) => {
const args = {
data: data || {},
template,
config: {},
selfCords: { left: "0px", top: "0px" },
ownerCords: { x: 0, y: 0, width: 0, height: 0 },
id: null
};
// Forcibly bootstrap the ctx menu outside of the client application's zone.
const app = await createApplication({
providers: [
{ provide: MAT_DIALOG_DATA, useValue: args }
]
});
const del = document.createElement("div");
del.style.position = "absolute";
del.style.left = '-1000vw';
document.body.append(del);
const base = app.bootstrap(TooltipComponent, del);
const { instance } = base;
await firstValueFrom(app.isStable);
const el = instance.viewContainer?.element?.nativeElement;
const rect = el.getBoundingClientRect();
app.destroy();
del.remove();
return rect;
};
class TooltipComponent {
constructor(viewContainer, _data, dialog, // optional only for the purpose of estimating dimensions
dialogRef) {
this.viewContainer = viewContainer;
this._data = _data;
this.dialog = dialog;
this.dialogRef = dialogRef;
this.hasBootstrapped = false;
this.pointerIsOnVoid = false;
this.coverRectCords = {
top: 0,
left: 0,
height: 0,
width: 0
};
// Defaults are set before @Input() hooks evaluate
this.data = this.data || this._data?.data || {};
this.config = this.config || this._data?.config;
this.template = this.template || this._data?.template;
this.ownerCords = this.ownerCords || this._data?.ownerCords;
this.selfCords = this.selfCords || this._data?.selfCords;
}
ngOnInit() {
const selfY = parseInt(this.selfCords.top.replace('px', ''));
const selfX = parseInt(this.selfCords.left.replace('px', ''));
this.coverRectCords = {
top: this.ownerCords.y - selfY - 16,
left: this.ownerCords.x - selfX - 16,
height: this.ownerCords.height + 32,
width: this.ownerCords.width + 32
};
if (this.template instanceof TemplateRef)
this.isTemplate = true;
else if (typeof this.template == "function")
this.isTemplate = false;
else
throw new Error("Unrecognized template object provided.");
// TODO: resolve the event hook with the .void element
setTimeout(() => {
this.hasBootstrapped = true;
if (this.pointerIsOnVoid)
this.dialogRef.close();
}, 10);
}
/**
* Close the tooltip if these actions occur
*/
onClose() {
this.dialogRef?.close();
}
onPointerLeave() {
this.dialogRef?.close();
}
}
/** @nocollapse */ TooltipComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: TooltipComponent, deps: [{ token: i0.ViewContainerRef }, { token: MAT_DIALOG_DATA, optional: true }, { token: i1.MatDialog, optional: true }, { token: i1.MatDialogRef, optional: true }], target: i0.ɵɵFactoryTarget.Component });
/** @nocollapse */ TooltipComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.0", type: TooltipComponent, isStandalone: true, selector: "ngx-tooltip", inputs: { data: "data", config: "config", ownerCords: "ownerCords", selfCords: "selfCords", template: "template" }, host: { listeners: { "window:resize": "onClose()", "window:blur": "onClose()", "pointerleave": "onPointerLeave()" } }, ngImport: i0, template: "<!-- Mouse event blocker for pointer leave -->\n<div\n *ngIf=\"coverRectCords\"\n class=\"owner-mask\"\n [style.top]=\"coverRectCords.top + 'px'\"\n [style.left]=\"coverRectCords.left + 'px'\"\n [style.height]=\"coverRectCords.height + 'px'\"\n [style.width]=\"coverRectCords.width + 'px'\"\n style=\"z-index: -1;\"\n></div>\n\n<div class=\"void\"\n (pointerenter)=\"pointerIsOnVoid = true; hasBootstrapped && dialogRef.close()\"\n (pointerleave)=\"pointerIsOnVoid = false\"\n (pointerdown)=\"hasBootstrapped && dialogRef.close()\"\n></div>\n\n<div class=\"container\">\n <ng-container\n *ngIf=\"isTemplate == false\"\n [ngComponentOutlet]=\"$any(template)\"\n >\n </ng-container>\n\n <ng-container\n *ngIf=\"isTemplate == true\"\n >\n <ng-container\n [ngTemplateOutlet]=\"$any(template)\"\n [ngTemplateOutletContext]=\"{ '$implicit': data }\"\n ></ng-container>\n </ng-container>\n</div>\n", styles: ["::ng-deep .cdk-overlay-container .ngx-tooltip{--mdc-dialog-container-color: var(--ngx-tooltip-background-color, #2f2f2f)}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog__container{transform-origin:top left}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog--open .mdc-dialog__container{transform:none}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog__surface{overflow:visible;background-color:#0000}::ng-deep .cdk-overlay-container .context-menu-backdrop.cdk-overlay-backdrop-showing{opacity:0}::ng-deep .cdk-overlay-pane.ngx-tooltip .mat-dialog-container{padding:0}:host{min-width:2px;min-height:2px;display:block}.void,.owner-mask{position:absolute}.void{top:-100vh;right:-100vw;bottom:-100vh;left:-100vw;z-index:-2}.container{width:100%;height:100%;background:var(--ngx-tooltip-background-color, #333);border-radius:6px;overflow:hidden}\n"], dependencies: [{ kind: "ngmodule", type:
// NgIf,
// NgTemplateOutlet,
// NgComponentOutlet,
CommonModule }, { kind: "directive", type: i2$1.NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }, { kind: "directive", type: i2$1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2$1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.0", ngImport: i0, type: TooltipComponent, decorators: [{
type: Component,
args: [{ selector: 'ngx-tooltip', imports: [
// NgIf,
// NgTemplateOutlet,
// NgComponentOutlet,
CommonModule,
], standalone: true, template: "<!-- Mouse event blocker for pointer leave -->\n<div\n *ngIf=\"coverRectCords\"\n class=\"owner-mask\"\n [style.top]=\"coverRectCords.top + 'px'\"\n [style.left]=\"coverRectCords.left + 'px'\"\n [style.height]=\"coverRectCords.height + 'px'\"\n [style.width]=\"coverRectCords.width + 'px'\"\n style=\"z-index: -1;\"\n></div>\n\n<div class=\"void\"\n (pointerenter)=\"pointerIsOnVoid = true; hasBootstrapped && dialogRef.close()\"\n (pointerleave)=\"pointerIsOnVoid = false\"\n (pointerdown)=\"hasBootstrapped && dialogRef.close()\"\n></div>\n\n<div class=\"container\">\n <ng-container\n *ngIf=\"isTemplate == false\"\n [ngComponentOutlet]=\"$any(template)\"\n >\n </ng-container>\n\n <ng-container\n *ngIf=\"isTemplate == true\"\n >\n <ng-container\n [ngTemplateOutlet]=\"$any(template)\"\n [ngTemplateOutletContext]=\"{ '$implicit': data }\"\n ></ng-container>\n </ng-container>\n</div>\n", styles: ["::ng-deep .cdk-overlay-container .ngx-tooltip{--mdc-dialog-container-color: var(--ngx-tooltip-background-color, #2f2f2f)}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog__container{transform-origin:top left}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog--open .mdc-dialog__container{transform:none}::ng-deep .cdk-overlay-container .ngx-tooltip .mdc-dialog__surface{overflow:visible;background-color:#0000}::ng-deep .cdk-overlay-container .context-menu-backdrop.cdk-overlay-backdrop-showing{opacity:0}::ng-deep .cdk-overlay-pane.ngx-tooltip .mat-dialog-container{padding:0}:host{min-width:2px;min-height:2px;display:block}.void,.owner-mask{position:absolute}.void{top:-100vh;right:-100vw;bottom:-100vh;left:-100vw;z-index:-2}.container{width:100%;height:100%;background:var(--ngx-tooltip-background-color, #333);border-radius:6px;overflow:hidden}\n"] }]
}], ctorParameters: function () { return [{ type: i0.ViewContainerRef }, { type: undefined, decorators: [{
type: Optional