@lumino/widgets
Version:
Lumino Widgets
370 lines (318 loc) • 10.1 kB
text/typescript
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
/*-----------------------------------------------------------------------------
| Copyright (c) 2014-2017, PhosphorJS Contributors
|
| Distributed under the terms of the BSD 3-Clause License.
|
| The full license is in the file LICENSE, distributed with this software.
|----------------------------------------------------------------------------*/
import { ArrayExt } from '@lumino/algorithm';
import { CommandRegistry } from '@lumino/commands';
import { DisposableDelegate, IDisposable } from '@lumino/disposable';
import { Selector } from '@lumino/domutils';
import { Menu } from './menu';
/**
* An object which implements a universal context menu.
*
* #### Notes
* The items shown in the context menu are determined by CSS selector
* matching against the DOM hierarchy at the site of the mouse click.
* This is similar in concept to how keyboard shortcuts are matched
* in the command registry.
*/
export class ContextMenu {
/**
* Construct a new context menu.
*
* @param options - The options for initializing the menu.
*/
constructor(options: ContextMenu.IOptions) {
const { groupByTarget, sortBySelector, ...others } = options;
this.menu = new Menu(others);
this._groupByTarget = groupByTarget !== false;
this._sortBySelector = sortBySelector !== false;
}
/**
* The menu widget which displays the matched context items.
*/
readonly menu: Menu;
/**
* Add an item to the context menu.
*
* @param options - The options for creating the item.
*
* @returns A disposable which will remove the item from the menu.
*/
addItem(options: ContextMenu.IItemOptions): IDisposable {
// Create an item from the given options.
let item = Private.createItem(options, this._idTick++);
// Add the item to the internal array.
this._items.push(item);
// Return a disposable which will remove the item.
return new DisposableDelegate(() => {
ArrayExt.removeFirstOf(this._items, item);
});
}
/**
* Open the context menu in response to a `'contextmenu'` event.
*
* @param event - The `'contextmenu'` event of interest.
*
* @returns `true` if the menu was opened, or `false` if no items
* matched the event and the menu was not opened.
*
* #### Notes
* This method will populate the context menu with items which match
* the propagation path of the event, then open the menu at the mouse
* position indicated by the event.
*/
open(event: MouseEvent): boolean {
// Prior to any DOM modifications update the window data.
Menu.saveWindowData();
// Clear the current contents of the context menu.
this.menu.clearItems();
// Bail early if there are no items to match.
if (this._items.length === 0) {
return false;
}
// Find the matching items for the event.
let items = Private.matchItems(
this._items,
event,
this._groupByTarget,
this._sortBySelector
);
// Bail if there are no matching items.
if (!items || items.length === 0) {
return false;
}
// Add the filtered items to the menu.
for (const item of items) {
this.menu.addItem(item);
}
// Open the context menu at the current mouse position.
this.menu.open(event.clientX, event.clientY);
// Indicate success.
return true;
}
private _groupByTarget: boolean = true;
private _idTick = 0;
private _items: Private.IItem[] = [];
private _sortBySelector: boolean = true;
}
/**
* The namespace for the `ContextMenu` class statics.
*/
export namespace ContextMenu {
/**
* An options object for initializing a context menu.
*/
export interface IOptions {
/**
* The command registry to use with the context menu.
*/
commands: CommandRegistry;
/**
* A custom renderer for use with the context menu.
*/
renderer?: Menu.IRenderer;
/**
* Whether to sort by selector and rank or only rank.
*
* Default true.
*/
sortBySelector?: boolean;
/**
* Whether to group items following the DOM hierarchy.
*
* Default true.
*
* #### Note
* If true, when the mouse event occurs on element `span` within `div.top`,
* the items matching `div.top` will be shown before the ones matching `body`.
*/
groupByTarget?: boolean;
}
/**
* An options object for creating a context menu item.
*/
export interface IItemOptions extends Menu.IItemOptions {
/**
* The CSS selector for the context menu item.
*
* The context menu item will only be displayed in the context menu
* when the selector matches a node on the propagation path of the
* contextmenu event. This allows the menu item to be restricted to
* user-defined contexts.
*
* The selector must not contain commas.
*/
selector: string;
/**
* The rank for the item.
*
* The rank is used as a tie-breaker when ordering context menu
* items for display. Items are sorted in the following order:
* 1. Depth in the DOM tree (deeper is better)
* 2. Selector specificity (higher is better)
* 3. Rank (lower is better)
* 4. Insertion order
*
* The default rank is `Infinity`.
*/
rank?: number;
}
}
/**
* The namespace for the module implementation details.
*/
namespace Private {
/**
* A normalized item for a context menu.
*/
export interface IItem extends Menu.IItemOptions {
/**
* The selector for the item.
*/
selector: string;
/**
* The rank for the item.
*/
rank: number;
/**
* The tie-breaking id for the item.
*/
id: number;
}
/**
* Create a normalized context menu item from an options object.
*/
export function createItem(
options: ContextMenu.IItemOptions,
id: number
): IItem {
let selector = validateSelector(options.selector);
let rank = options.rank !== undefined ? options.rank : Infinity;
return { ...options, selector, rank, id };
}
/**
* Find the items which match a context menu event.
*
* The results are sorted by DOM level, specificity, and rank.
*/
export function matchItems(
items: IItem[],
event: MouseEvent,
groupByTarget: boolean,
sortBySelector: boolean
): IItem[] | null {
// Look up the target of the event.
let target = event.target as Element | null;
// Bail if there is no target.
if (!target) {
return null;
}
// Look up the current target of the event.
let currentTarget = event.currentTarget as Element | null;
// Bail if there is no current target.
if (!currentTarget) {
return null;
}
// There are some third party libraries that cause the `target` to
// be detached from the DOM before lumino can process the event.
// If that happens, search for a new target node by point. If that
// node is still dangling, bail.
if (!currentTarget.contains(target)) {
target = document.elementFromPoint(event.clientX, event.clientY);
if (!target || !currentTarget.contains(target)) {
return null;
}
}
// Set up the result array.
let result: IItem[] = [];
// Copy the items array to allow in-place modification.
let availableItems: Array<IItem | null> = items.slice();
// Walk up the DOM hierarchy searching for matches.
while (target !== null) {
// Set up the match array for this DOM level.
let matches: IItem[] = [];
// Search the remaining items for matches.
for (let i = 0, n = availableItems.length; i < n; ++i) {
// Fetch the item.
let item = availableItems[i];
// Skip items which are already consumed.
if (!item) {
continue;
}
// Skip items which do not match the element.
if (!Selector.matches(target, item.selector)) {
continue;
}
// Add the matched item to the result for this DOM level.
matches.push(item);
// Mark the item as consumed.
availableItems[i] = null;
}
// Sort the matches for this level and add them to the results.
if (matches.length !== 0) {
if (groupByTarget) {
matches.sort(sortBySelector ? itemCmp : itemCmpRank);
}
result.push(...matches);
}
// Stop searching at the limits of the DOM range.
if (target === currentTarget) {
break;
}
// Step to the parent DOM level.
target = target.parentElement;
}
if (!groupByTarget) {
result.sort(sortBySelector ? itemCmp : itemCmpRank);
}
// Return the matched and sorted results.
return result;
}
/**
* Validate the selector for a menu item.
*
* This returns the validated selector, or throws if the selector is
* invalid or contains commas.
*/
function validateSelector(selector: string): string {
if (selector.indexOf(',') !== -1) {
throw new Error(`Selector cannot contain commas: ${selector}`);
}
if (!Selector.isValid(selector)) {
throw new Error(`Invalid selector: ${selector}`);
}
return selector;
}
/**
* A sort comparison function for a context menu item by ranks.
*/
function itemCmpRank(a: IItem, b: IItem): number {
// Sort based on rank.
let r1 = a.rank;
let r2 = b.rank;
if (r1 !== r2) {
return r1 < r2 ? -1 : 1; // Infinity-safe
}
// When all else fails, sort by item id.
return a.id - b.id;
}
/**
* A sort comparison function for a context menu item by selectors and ranks.
*/
function itemCmp(a: IItem, b: IItem): number {
// Sort first based on selector specificity.
let s1 = Selector.calculateSpecificity(a.selector);
let s2 = Selector.calculateSpecificity(b.selector);
if (s1 !== s2) {
return s2 - s1;
}
// If specificities are equal
return itemCmpRank(a, b);
}
}