Skip to content

Instantly share code, notes, and snippets.

@Artawower
Created December 2, 2022 12:30
Show Gist options
  • Save Artawower/61673cf1b0c9991cebd439adbb22ce34 to your computer and use it in GitHub Desktop.
Save Artawower/61673cf1b0c9991cebd439adbb22ce34 to your computer and use it in GitHub Desktop.
import {
Directive,
ElementRef,
EmbeddedViewRef,
EventEmitter,
HostListener,
Injectable,
Input,
OnDestroy,
OnInit,
Output,
Renderer2,
TemplateRef,
ViewContainerRef,
} from '@angular/core';
import { fromEvent, Subject, takeUntil } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class ContextMenuService {
public closed$: Subject<void> = new Subject<void>();
public close(): void {
this.closed$.next();
}
}
@Directive({
selector: '[appContextClick]',
})
export class ContextClickDirective implements OnDestroy, OnInit {
private contextMenu: HTMLHtmlElement;
private containerView: EmbeddedViewRef<any>;
private destroyed$: Subject<void> = new Subject<void>();
private closeIconTemplate: HTMLElement;
@Input()
appContextClick: TemplateRef<any>;
@Output()
contextMenuOpened: EventEmitter<void> = new EventEmitter();
@Input()
public contextOnSelection: boolean;
@Input()
public contextMenuEvent = 'contextmenu';
@Input()
public contextCloseOnScroll = true;
@Input()
public contextCloseOnClick: boolean = true;
@Input()
public contextAttachToElement: boolean = false;
@Input()
// TODO: грязный костыль, в нормальной версии нужно будет добавить контейнер,
// к которому можно опционально прикрепить контекстное меню
public contextYOffset: number = 0;
@Input()
public contextXOffset: number = 0;
@Input()
public contextCloseIcon = false;
constructor(
private readonly viewRef: ViewContainerRef,
private renderer: Renderer2,
private hostElement: ElementRef,
private contextMenuService: ContextMenuService
) {}
ngOnInit(): void {
this.watchProvidedEvent();
this.watchContextMenuEvents();
}
private watchProvidedEvent(): void {
fromEvent<MouseEvent>(this.hostElement.nativeElement, this.contextMenuEvent)
.pipe(takeUntil(this.destroyed$))
.subscribe((event) => {
event.preventDefault();
event.stopImmediatePropagation();
if (!this.contextOnSelection || window.getSelection().toString()?.length) {
this.createContextMenu(event);
}
});
}
private watchContextMenuEvents(): void {
this.contextMenuService.closed$.pipe(takeUntil(this.destroyed$)).subscribe(() => {
this.close();
});
}
@HostListener('document:scroll', ['$event'])
onScroll() {
if (this.contextCloseOnScroll) {
this.close();
}
}
@HostListener('document:mousewheel', ['$event'])
onMouseWheel() {
if (this.contextCloseOnScroll) {
this.close();
}
}
@HostListener('click', ['$event'])
clickInside(event: MouseEvent) {
if (this.contextMenu) {
event.stopPropagation();
event.preventDefault();
}
if (this.contextCloseOnClick) {
this.close();
}
}
@HostListener('document:click', ['$event', '$event.target'])
clickOutside(event: MouseEvent, targetEl: HTMLHtmlElement) {
if (!this.contextMenu) {
return;
}
const clickedInside = this.contextMenu.contains(targetEl);
if (!clickedInside) {
this.close();
event.stopPropagation();
event.preventDefault();
}
}
private createContextMenu(event: MouseEvent): void {
this.close();
const [x, y] = this.contextAttachToElement
? [
this.hostElement.nativeElement.getBoundingClientRect().left,
this.hostElement.nativeElement.getBoundingClientRect().top,
]
: [event.pageX, event.pageY];
this.contextMenu = this.renderer.createElement('div');
this.renderer.addClass(this.contextMenu, 'context-menu');
this.renderer.setStyle(this.contextMenu, 'left', `${x + this.contextXOffset}px`);
this.renderer.setStyle(this.contextMenu, 'top', `${y + this.contextYOffset}px`);
this.renderer.setStyle(this.contextMenu, 'opacity', '0');
this.renderer.appendChild(document.body, this.contextMenu);
this.containerView = this.viewRef.createEmbeddedView(this.appContextClick);
this.containerView.rootNodes.forEach((node) => this.contextMenu.appendChild(node));
this.createCloseIcon();
this.containerView.detectChanges();
this.alignContextMenuAfterRender(x, y);
if (this.contextCloseOnClick) {
fromEvent(this.contextMenu, 'click')
.pipe(takeUntil(this.destroyed$))
.subscribe((event) => {
this.close();
event.stopPropagation();
event.preventDefault();
});
}
this.contextMenuOpened.emit();
}
private alignContextMenuAfterRender(x: number, y: number): void {
setTimeout(() => {
this.renderer.setStyle(this.contextMenu, 'opacity', '1');
this.renderer.setStyle(
this.contextMenu,
'left',
`${x + this.contextXOffset - this.contextMenu.offsetWidth / 2}px`
);
const undisplayedOffsetHeight = window.innerHeight - (this.contextMenu.offsetTop + this.contextMenu.offsetHeight);
// TODO: проработать кейс когда попап больше отображаемого экрана
if (undisplayedOffsetHeight < 0) {
this.renderer.setStyle(
this.contextMenu,
'top',
`${this.contextMenu.offsetTop + undisplayedOffsetHeight + this.contextYOffset}px`
);
}
this.containerView.detectChanges();
});
}
private createCloseIcon(): void {
if (!this.contextCloseIcon) {
return;
}
this.closeIconTemplate = this.renderer.createElement('div');
this.closeIconTemplate.classList.add('context-menu-close-icon');
this.contextMenu.appendChild(this.closeIconTemplate);
fromEvent(this.closeIconTemplate, 'click')
.pipe(takeUntil(this.destroyed$))
.subscribe((event) => {
this.close();
event.stopPropagation();
event.preventDefault();
});
}
private close(): void {
if (this.contextMenu) {
this.renderer.removeChild(document.body, this.contextMenu);
}
if (this.containerView) {
this.containerView.rootNodes.forEach((node) => this.contextMenu.removeChild(node));
this.containerView.destroy();
this.containerView = null;
}
}
ngOnDestroy(): void {
this.close();
this.destroyed$.next();
this.destroyed$.complete();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment