@ryusei/code
Version:
<div align="center"> <a href="https://code.ryuseijs.com"> <img alt="RyuseiCode" src="https://code.ryuseijs.com/images/svg/logo.svg" width="70"> </a>
394 lines (347 loc) • 10.3 kB
text/typescript
import { ContextMenuButtonSettings, ContextMenuGroupData } from '@ryusei/code';
import { UIComponent } from '../../classes/UIComponent/UIComponent';
import {
CLASS_CONTEXT_MENU,
CLASS_CONTEXT_MENU_BUTTON,
CLASS_CONTEXT_MENU_GROUP,
CLASS_CONTEXT_MENU_ITEM,
CLASS_CONTEXT_MENU_LABEL,
CLASS_CONTEXT_MENU_LIST,
CLASS_CONTEXT_MENU_SHORTCUT,
} from '../../constants/classes';
import { MAIN_CONTEXT_MENU_ID } from '../../constants/context-menu';
import {
EVENT_BLUR,
EVENT_CONTEXT_MENU_CLICKED,
EVENT_CONTEXT_MENU_CLOSED,
EVENT_CONTEXT_MENU_OPENED,
EVENT_READONLY,
EVENT_SCROLLER_SCROLL,
EVENT_WINDOW_SCROLL,
} from '../../constants/events';
import { IDLE } from '../../constants/selection-states';
import {
activeElement,
assert,
assign,
attr,
create,
div,
forOwn,
height,
isHTMLElement,
min,
normalizeKey,
prevent,
queryAll,
rect,
styles,
text,
unit,
} from '../../utils';
/**
* The margin from the menu to the right of the window.
*
* @since 0.1.0
*/
const MARGIN_RIGHT = 5;
/**
* The margin from the menu to the bottom of the window.
*
* @since 0.1.0
*/
const MARGIN_BOTTOM = 5;
/**
* The class for creating a context menu replacing the native one.
*
* @since 0.1.0
*/
export class ContextMenu extends UIComponent<ContextMenuGroupData> {
/**
* The index of the current menu item.
*/
private index = -1;
/**
* Holds buttons that are currently displayed.
* This may be null when the menu is hidden.
*/
buttons: Record<string, HTMLButtonElement> | null;
/**
* Listens some events.
*/
protected listen(): void {
super.listen();
const { elements } = this;
this.bind( elements.editor, 'mousedown', this.onMouseDown, this );
this.bind( document, 'contextmenu', this.onContextMenu, this );
this.bind( window, 'keydown', this.onKeydown, this );
this.on( [ EVENT_BLUR, EVENT_SCROLLER_SCROLL, EVENT_WINDOW_SCROLL ], this.hide, this );
this.bind( elements.root, 'focusin', () => {
if ( ! this.contains( activeElement() ) && ! this.wrapper.contains( activeElement() ) ) {
this.hide();
}
} );
}
/**
* Creates the context menu elements.
*
* @link https://www.w3.org/TR/wai-aria-1.2/#menu
*/
protected create(): void {
this.wrapper = div( { class: CLASS_CONTEXT_MENU, role: 'menu' }, this.elements.overlay );
}
/**
* Called when the mouse button is clicked.
* If the button number is 2, which means a right click,
* displays the menu and moves it at the cursor location, otherwise hides the menu.
*
* @param e - A MouseEvent object.
*/
private onMouseDown( e: MouseEvent ): void {
if ( e.button === 2 ) {
this.show( MAIN_CONTEXT_MENU_ID );
this.move( e.clientX, e.clientY );
} else {
this.hide();
}
}
/**
* Called when the contextmenu event of the document is fired.
* Since the context menu may scroll the scroller or the window,
* displaying the menu at this moment is too early.
*
* @param e - An Event object.
*/
private onContextMenu( e: Event ): void {
if ( this.isActive() ) {
return prevent( e );
}
if ( this.contains( e.target ) ) {
const { Selection } = this;
if ( ! Selection.is( IDLE ) ) {
this.View.jump( Selection.focus[ 0 ] );
requestAnimationFrame( () => {
const { rect } = this.Caret;
this.show( MAIN_CONTEXT_MENU_ID );
this.move( rect.left, rect.bottom );
} );
}
prevent( e, true );
}
}
/**
* Called when the window receives the keydown.
*
* @param e - A KeyboardEvent object.
*/
private onKeydown( e: KeyboardEvent ): void {
if ( this.isActive() ) {
const key = normalizeKey( e.key );
const arrowUp = key === 'ArrowUp';
if ( key === 'ArrowDown' || arrowUp ) {
this.focus( arrowUp );
prevent( e );
}
}
}
/**
* Sets focus on the menu item in order.
*
* @param backwards - Whether to decrement or increment the menu index.
*/
private focus( backwards: boolean ): void {
const buttons = queryAll<HTMLButtonElement>( this.wrapper, `.${ CLASS_CONTEXT_MENU_BUTTON }` );
const { length } = buttons;
if ( length ) {
this.index += backwards ? -1 : 1;
if ( this.index < 0 ) {
this.index = length - 1;
} else if ( this.index >= length ) {
this.index = 0;
}
buttons[ this.index ].focus();
}
}
/**
* Moves the menu to the provided client coordinates.
*
* @param clientX - A client x coordinate.
* @param clientY - A client y coordinate.
*/
private move( clientX: number, clientY: number ): void {
const { wrapper, wrapper: { clientWidth } } = this;
const { documentElement } = document;
const rootRect = rect( this.elements.root );
if ( clientX + clientWidth > documentElement.clientWidth - MARGIN_RIGHT ) {
clientX -= clientWidth;
}
clientY = min( clientY, height( documentElement ) - height( wrapper ) - MARGIN_BOTTOM );
styles( wrapper, {
top : unit( clientY - rootRect.top ),
left: unit( clientX - rootRect.left ),
} );
}
/**
* Checks whether the editor contains the passed element/event target or not.
*
* @param target - An EventTarget object that is an Element instance in most cases.
*
* @return `true` if the editor contains the target, or otherwise `false`.
*/
private contains( target: EventTarget | Element ): boolean {
return isHTMLElement( target ) && this.elements.editor.contains( target );
}
/**
* Creates elements for menu items.
*
* @param group - A group ID.
*/
private build( group: string ): void {
const { lists, elm } = this.groups[ group ];
text( elm, '' );
forOwn( lists, ( settings, key ) => {
const list = create( 'ul', [ CLASS_CONTEXT_MENU_LIST, `${ CLASS_CONTEXT_MENU_LIST }--${ key }` ], elm );
settings = settings.map( settings => {
settings.parent = create( 'li', CLASS_CONTEXT_MENU_ITEM, list );
return settings;
} );
const buttons = this.createButtons<ContextMenu>( settings, null, this, CLASS_CONTEXT_MENU_BUTTON );
forOwn( buttons, ( button, id ) => {
const buttonSettings = this.findSettings( settings, id );
assert( buttonSettings );
attr( button, { role: 'menuitem' } );
this.bind( button, 'click', () => {
this.emit( EVENT_CONTEXT_MENU_CLICKED, this, group, id, button );
this.hide();
} );
if ( buttonSettings.disableOnReadOnly ) {
button.disabled = this.Editor.readOnly;
this.on( EVENT_READONLY, ( e, readOnly ) => { button.disabled = readOnly } );
}
this.bind( button, 'mouseover', () => {
button.focus();
} );
} );
this.buttons = assign( {}, this.buttons, buttons );
} );
}
/**
* Finds the each button settings from the array of settings.
*
* @param settings - An array with settings.
* @param id - A button ID to find.
*
* @return The found button settings.
*/
private findSettings( settings: ContextMenuButtonSettings[], id: string ): ContextMenuButtonSettings {
for ( let i = 0; i < settings.length; i++ ) {
if ( settings[ i ].id === id ) {
return settings[ i ];
}
}
}
/**
* Registers a menu item or items.
*
* @example
*
* Registers a new item to the "edit" list in the "main" context menu:
* ```ts
* const ryuseiCode = new RyuseiCode();
* ryuseiCode.apply( 'textarea' );
*
* const { ContextMenu } = ryuseiCode.Editor.Components;
*
* ContextMenu.register( 'main', 'edit', {
* id : 'myButton',
* html: 'Click Me',
* click() {
* console.log( 'Clicked! );
* },
* } );
* ```
*
* Registers a new list and items to the the "main" context menu:
* ```ts
* const ryuseiCode = new RyuseiCode();
* ryuseiCode.apply( 'textarea' );
*
* const { ContextMenu } = ryuseiCode.Editor.Components;
*
* ContextMenu.register( 'main', 'my-list', [
* {
* id : 'button1',
* html: 'Button 1',
* click() {
* console.log( 'You clicked the Button 1' );
* },
* },
* {
* id : 'button2',
* html: 'Button 2',
* click() {
* console.log( 'You clicked the Button 2' );
* },
* },
* ] );
* ```
*
* Registers a new group:
* ```ts
* const ryuseiCode = new RyuseiCode();
* ryuseiCode.apply( 'textarea' );
*
* const { ContextMenu } = ryuseiCode.Editor.Components;
*
* ContextMenu.register( 'my-context-menu', 'my-list', [
* ...
* ] );
*
* ContextMenu.show( 'my-context-menu' );
* ```
*
* @param group - A group ID. If it does not exist, a new group will be generated.
* @param list - A list ID.
* @param settings - An menu item or items.
*/
register( group: string, list: string, settings: ContextMenuButtonSettings[] ): void {
const { groups } = this;
if ( ! groups[ group ] ) {
groups[ group ] = {
elm : div( [ CLASS_CONTEXT_MENU_GROUP, `${ CLASS_CONTEXT_MENU_GROUP }--${ group }` ] ),
lists: {},
};
}
settings.forEach( settings => {
const label = this.i18n[ settings.i18n || settings.id ];
const shortcut = settings.shortcut ? this.Keymap.getShortcut( settings.shortcut ) : '';
settings.html = settings.html || `<span class="${ CLASS_CONTEXT_MENU_LABEL }">${ label }</span>`
+ ( shortcut ? `<span class="${ CLASS_CONTEXT_MENU_SHORTCUT }">${ shortcut }</span>` : '' );
} );
const { lists } = groups[ group ];
lists[ list ] = ( lists[ list ] || [] ).concat( settings );
}
/**
* Displays the specified context menu.
*
* @param group - A group ID.
*/
show( group: string ): void {
if ( this.groups[ group ] ) {
this.build( group );
super.show( group );
this.index = -1;
this.emit( EVENT_CONTEXT_MENU_OPENED );
}
}
/**
* Hides the context menu.
*/
hide(): void {
if ( this.isActive() ) {
super.hide();
this.buttons = null;
this.emit( EVENT_CONTEXT_MENU_CLOSED );
}
}
}