203 lines
4.3 KiB
TypeScript
203 lines
4.3 KiB
TypeScript
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;
|
|
}
|
|
}
|