export abstract class ContextMenuItem { readonly parent: ContextMenuDirectory | null; constructor( parent: ContextMenuDirectory | null ) { this.parent = parent; } abstract createEl(): HTMLElement; } export class ContextMenuSeparator extends ContextMenuItem { createEl(): HTMLElement { const el = document.createElement( 'div' ); el.className = 'ctx-separator'; return el; } } export class ContextMenuReadOnlyEntry extends ContextMenuItem { label: string; constructor( parent: ContextMenuDirectory | null, label: string ) { super( parent ); this.label = label; } createEl(): HTMLElement { const el = document.createElement( 'div' ); el.className = 'ctx-readonly'; el.textContent = this.label; return el; } } export class ContextMenuEntry extends ContextMenuItem { label: string; _action: () => void; noClose: boolean; constructor( parent: ContextMenuDirectory | null, label: string, action: () => void, noClose = false ) { super( parent ); this.label = label; this._action = action; this.noClose = noClose; } createEl(): HTMLElement { const el = document.createElement( 'div' ); el.className = 'ctx-entry'; el.textContent = this.label; el.addEventListener( 'click', ( e ) => { e.stopPropagation(); this._action(); if ( ! this.noClose ) { let dir: ContextMenuDirectory = this.parent; while ( dir.parent ) dir = dir.parent; dir.close(); } } ); return el; } } export class ContextMenuDirectory extends ContextMenuItem { label: string | null; items: ContextMenuItem[] = []; _menuEl: HTMLElement = null; _closeTimer: number = 0; constructor( parent: ContextMenuDirectory | null, label: string | null = null ) { super( parent ); this.label = label; } add( item: ContextMenuItem ): this { this.items.push( item ); return this; } show( x: number, y: number ): void { this.clearCloseUpwards(); if ( this._menuEl ) { return; } const menu = document.createElement( 'div' ); menu.className = 'ctx-menu'; menu.style.cssText = 'position:fixed;visibility:hidden'; for ( const item of this.items ) { menu.appendChild( item.createEl() ); } document.body.appendChild( menu ); this._menuEl = menu; const w = menu.offsetWidth; const h = menu.offsetHeight; const vw = window.innerWidth; const vh = window.innerHeight; const left = ( x + w > vw && x - w >= 0 ) ? x - w : x; const top = ( y + h > vh && y - h >= 0 ) ? y - h : y; menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.style.visibility = ''; menu.addEventListener( 'mouseenter', () => this.clearCloseUpwards() ); menu.addEventListener( 'mouseleave', () => this.scheduleClose() ); if ( ! this.parent ) { const onOutside = ( e: MouseEvent ) => { if ( ! menu.contains( e.target as Node ) ) { this.close(); document.removeEventListener( 'click', onOutside, { capture: true } ); } }; setTimeout( () => document.addEventListener( 'click', onOutside, { capture: true } ), 0 ); } } close(): void { for ( const item of this.items ) { if ( item instanceof ContextMenuDirectory ) item.close(); } this._menuEl?.remove(); this._menuEl = null; } scheduleClose(): void { this._closeTimer = window.setTimeout( () => this.close(), 150 ); } clearClose(): void { clearTimeout( this._closeTimer ); } clearCloseUpwards(): void { let dir: ContextMenuDirectory = this; while ( dir ) { dir.clearClose(); dir = dir.parent; } } createEl(): HTMLElement { const el = document.createElement( 'div' ); el.className = 'ctx-entry ctx-entry-dir'; el.innerHTML = `${this.label ?? ''}`; el.addEventListener( 'mouseenter', () => { if ( this.parent ) { for ( const item of this.parent.items ) { if ( item instanceof ContextMenuDirectory && item !== this ) item.close(); } } const rect = el.getBoundingClientRect(); this.show( rect.right, rect.top ); } ); el.addEventListener( 'mouseleave', () => this.scheduleClose() ); return el; } }