rojects/source/components/context-menu/context-menu.ts

203 lines
4.3 KiB
TypeScript
Raw Normal View History

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 = `<span class="ctx-label">${this.label ?? ''}</span><span class="ctx-arrow">▶</span>`;
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;
}
}