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 | |----------------------------------------------------------------------------*/
|
10 | import { ArrayExt, each } from '@lumino/algorithm';
|
11 |
|
12 | import { CommandRegistry } from '@lumino/commands';
|
13 |
|
14 | import { DisposableDelegate, IDisposable } from '@lumino/disposable';
|
15 |
|
16 | import { Selector } from '@lumino/domutils';
|
17 |
|
18 | import { 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 | */
|
29 | export 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 | */
|
123 | export 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 | */
|
192 | namespace 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 | }
|