Created
December 2, 2022 12:30
-
-
Save Artawower/61673cf1b0c9991cebd439adbb22ce34 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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