UNPKG

10 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3/*-----------------------------------------------------------------------------
4| Copyright (c) 2014-2017, PhosphorJS Contributors
5|
6| Distributed under the terms of the BSD 3-Clause License.
7|
8| The full license is in the file LICENSE, distributed with this software.
9|----------------------------------------------------------------------------*/
10import { ArrayExt, each } from '@lumino/algorithm';
11
12import { CommandRegistry } from '@lumino/commands';
13
14import { DisposableDelegate, IDisposable } from '@lumino/disposable';
15
16import { Selector } from '@lumino/domutils';
17
18import { Menu } from './menu';
19
20/**
21 * An object which implements a universal context menu.
22 *
23 * #### Notes
24 * The items shown in the context menu are determined by CSS selector
25 * matching against the DOM hierarchy at the site of the mouse click.
26 * This is similar in concept to how keyboard shortcuts are matched
27 * in the command registry.
28 */
29export class ContextMenu {
30 /**
31 * Construct a new context menu.
32 *
33 * @param options - The options for initializing the menu.
34 */
35 constructor(options: ContextMenu.IOptions) {
36 const { groupByTarget, sortBySelector, ...others } = options;
37 this.menu = new Menu(others);
38 this._groupByTarget = groupByTarget !== false;
39 this._sortBySelector = sortBySelector !== false;
40 }
41
42 /**
43 * The menu widget which displays the matched context items.
44 */
45 readonly menu: Menu;
46
47 /**
48 * Add an item to the context menu.
49 *
50 * @param options - The options for creating the item.
51 *
52 * @returns A disposable which will remove the item from the menu.
53 */
54 addItem(options: ContextMenu.IItemOptions): IDisposable {
55 // Create an item from the given options.
56 let item = Private.createItem(options, this._idTick++);
57
58 // Add the item to the internal array.
59 this._items.push(item);
60
61 // Return a disposable which will remove the item.
62 return new DisposableDelegate(() => {
63 ArrayExt.removeFirstOf(this._items, item);
64 });
65 }
66
67 /**
68 * Open the context menu in response to a `'contextmenu'` event.
69 *
70 * @param event - The `'contextmenu'` event of interest.
71 *
72 * @returns `true` if the menu was opened, or `false` if no items
73 * matched the event and the menu was not opened.
74 *
75 * #### Notes
76 * This method will populate the context menu with items which match
77 * the propagation path of the event, then open the menu at the mouse
78 * position indicated by the event.
79 */
80 open(event: MouseEvent): boolean {
81 // Clear the current contents of the context menu.
82 this.menu.clearItems();
83
84 // Bail early if there are no items to match.
85 if (this._items.length === 0) {
86 return false;
87 }
88
89 // Find the matching items for the event.
90 let items = Private.matchItems(
91 this._items,
92 event,
93 this._groupByTarget,
94 this._sortBySelector
95 );
96
97 // Bail if there are no matching items.
98 if (!items || items.length === 0) {
99 return false;
100 }
101
102 // Add the filtered items to the menu.
103 each(items, item => {
104 this.menu.addItem(item);
105 });
106
107 // Open the context menu at the current mouse position.
108 this.menu.open(event.clientX, event.clientY);
109
110 // Indicate success.
111 return true;
112 }
113
114 private _groupByTarget: boolean = true;
115 private _idTick = 0;
116 private _items: Private.IItem[] = [];
117 private _sortBySelector: boolean = true;
118}
119
120/**
121 * The namespace for the `ContextMenu` class statics.
122 */
123export namespace ContextMenu {
124 /**
125 * An options object for initializing a context menu.
126 */
127 export interface IOptions {
128 /**
129 * The command registry to use with the context menu.
130 */
131 commands: CommandRegistry;
132
133 /**
134 * A custom renderer for use with the context menu.
135 */
136 renderer?: Menu.IRenderer;
137
138 /**
139 * Whether to sort by selector and rank or only rank.
140 *
141 * Default true.
142 */
143 sortBySelector?: boolean;
144
145 /**
146 * Whether to group items following the DOM hierarchy.
147 *
148 * Default true.
149 *
150 * #### Note
151 * If true, when the mouse event occurs on element `span` within `div.top`,
152 * the items matching `div.top` will be shown before the ones matching `body`.
153 */
154 groupByTarget?: boolean;
155 }
156
157 /**
158 * An options object for creating a context menu item.
159 */
160 export interface IItemOptions extends Menu.IItemOptions {
161 /**
162 * The CSS selector for the context menu item.
163 *
164 * The context menu item will only be displayed in the context menu
165 * when the selector matches a node on the propagation path of the
166 * contextmenu event. This allows the menu item to be restricted to
167 * user-defined contexts.
168 *
169 * The selector must not contain commas.
170 */
171 selector: string;
172
173 /**
174 * The rank for the item.
175 *
176 * The rank is used as a tie-breaker when ordering context menu
177 * items for display. Items are sorted in the following order:
178 * 1. Depth in the DOM tree (deeper is better)
179 * 2. Selector specificity (higher is better)
180 * 3. Rank (lower is better)
181 * 4. Insertion order
182 *
183 * The default rank is `Infinity`.
184 */
185 rank?: number;
186 }
187}
188
189/**
190 * The namespace for the module implementation details.
191 */
192namespace Private {
193 /**
194 * A normalized item for a context menu.
195 */
196 export interface IItem extends Menu.IItemOptions {
197 /**
198 * The selector for the item.
199 */
200 selector: string;
201
202 /**
203 * The rank for the item.
204 */
205 rank: number;
206
207 /**
208 * The tie-breaking id for the item.
209 */
210 id: number;
211 }
212
213 /**
214 * Create a normalized context menu item from an options object.
215 */
216 export function createItem(
217 options: ContextMenu.IItemOptions,
218 id: number
219 ): IItem {
220 let selector = validateSelector(options.selector);
221 let rank = options.rank !== undefined ? options.rank : Infinity;
222 return { ...options, selector, rank, id };
223 }
224
225 /**
226 * Find the items which match a context menu event.
227 *
228 * The results are sorted by DOM level, specificity, and rank.
229 */
230 export function matchItems(
231 items: IItem[],
232 event: MouseEvent,
233 groupByTarget: boolean,
234 sortBySelector: boolean
235 ): IItem[] | null {
236 // Look up the target of the event.
237 let target = event.target as Element | null;
238
239 // Bail if there is no target.
240 if (!target) {
241 return null;
242 }
243
244 // Look up the current target of the event.
245 let currentTarget = event.currentTarget as Element | null;
246
247 // Bail if there is no current target.
248 if (!currentTarget) {
249 return null;
250 }
251
252 // There are some third party libraries that cause the `target` to
253 // be detached from the DOM before lumino can process the event.
254 // If that happens, search for a new target node by point. If that
255 // node is still dangling, bail.
256 if (!currentTarget.contains(target)) {
257 target = document.elementFromPoint(event.clientX, event.clientY);
258 if (!target || !currentTarget.contains(target)) {
259 return null;
260 }
261 }
262
263 // Set up the result array.
264 let result: IItem[] = [];
265
266 // Copy the items array to allow in-place modification.
267 let availableItems: Array<IItem | null> = items.slice();
268
269 // Walk up the DOM hierarchy searching for matches.
270 while (target !== null) {
271 // Set up the match array for this DOM level.
272 let matches: IItem[] = [];
273
274 // Search the remaining items for matches.
275 for (let i = 0, n = availableItems.length; i < n; ++i) {
276 // Fetch the item.
277 let item = availableItems[i];
278
279 // Skip items which are already consumed.
280 if (!item) {
281 continue;
282 }
283
284 // Skip items which do not match the element.
285 if (!Selector.matches(target, item.selector)) {
286 continue;
287 }
288
289 // Add the matched item to the result for this DOM level.
290 matches.push(item);
291
292 // Mark the item as consumed.
293 availableItems[i] = null;
294 }
295
296 // Sort the matches for this level and add them to the results.
297 if (matches.length !== 0) {
298 if (groupByTarget) {
299 matches.sort(sortBySelector ? itemCmp : itemCmpRank);
300 }
301 result.push(...matches);
302 }
303
304 // Stop searching at the limits of the DOM range.
305 if (target === currentTarget) {
306 break;
307 }
308
309 // Step to the parent DOM level.
310 target = target.parentElement;
311 }
312
313 if (!groupByTarget) {
314 result.sort(sortBySelector ? itemCmp : itemCmpRank);
315 }
316
317 // Return the matched and sorted results.
318 return result;
319 }
320
321 /**
322 * Validate the selector for a menu item.
323 *
324 * This returns the validated selector, or throws if the selector is
325 * invalid or contains commas.
326 */
327 function validateSelector(selector: string): string {
328 if (selector.indexOf(',') !== -1) {
329 throw new Error(`Selector cannot contain commas: ${selector}`);
330 }
331 if (!Selector.isValid(selector)) {
332 throw new Error(`Invalid selector: ${selector}`);
333 }
334 return selector;
335 }
336
337 /**
338 * A sort comparison function for a context menu item by ranks.
339 */
340 function itemCmpRank(a: IItem, b: IItem): number {
341 // Sort based on rank.
342 let r1 = a.rank;
343 let r2 = b.rank;
344 if (r1 !== r2) {
345 return r1 < r2 ? -1 : 1; // Infinity-safe
346 }
347
348 // When all else fails, sort by item id.
349 return a.id - b.id;
350 }
351
352 /**
353 * A sort comparison function for a context menu item by selectors and ranks.
354 */
355 function itemCmp(a: IItem, b: IItem): number {
356 // Sort first based on selector specificity.
357 let s1 = Selector.calculateSpecificity(a.selector);
358 let s2 = Selector.calculateSpecificity(b.selector);
359 if (s1 !== s2) {
360 return s2 - s1;
361 }
362
363 // If specificities are equal
364 return itemCmpRank(a, b);
365 }
366}