UNPKG

42 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, StringExt } from '@lumino/algorithm';
11
12import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils';
13
14import { CommandRegistry } from '@lumino/commands';
15
16import { ElementExt } from '@lumino/domutils';
17
18import { Message } from '@lumino/messaging';
19
20import {
21 ElementDataset,
22 h,
23 VirtualDOM,
24 VirtualElement
25} from '@lumino/virtualdom';
26
27import { Widget } from './widget';
28
29/**
30 * A widget which displays command items as a searchable palette.
31 */
32export class CommandPalette extends Widget {
33 /**
34 * Construct a new command palette.
35 *
36 * @param options - The options for initializing the palette.
37 */
38 constructor(options: CommandPalette.IOptions) {
39 super({ node: Private.createNode() });
40 this.addClass('lm-CommandPalette');
41 /* <DEPRECATED> */
42 this.addClass('p-CommandPalette');
43 /* </DEPRECATED> */
44 this.setFlag(Widget.Flag.DisallowLayout);
45 this.commands = options.commands;
46 this.renderer = options.renderer || CommandPalette.defaultRenderer;
47 this.commands.commandChanged.connect(this._onGenericChange, this);
48 this.commands.keyBindingChanged.connect(this._onGenericChange, this);
49 }
50
51 /**
52 * Dispose of the resources held by the widget.
53 */
54 dispose(): void {
55 this._items.length = 0;
56 this._results = null;
57 super.dispose();
58 }
59
60 /**
61 * The command registry used by the command palette.
62 */
63 readonly commands: CommandRegistry;
64
65 /**
66 * The renderer used by the command palette.
67 */
68 readonly renderer: CommandPalette.IRenderer;
69
70 /**
71 * The command palette search node.
72 *
73 * #### Notes
74 * This is the node which contains the search-related elements.
75 */
76 get searchNode(): HTMLDivElement {
77 return this.node.getElementsByClassName(
78 'lm-CommandPalette-search'
79 )[0] as HTMLDivElement;
80 }
81
82 /**
83 * The command palette input node.
84 *
85 * #### Notes
86 * This is the actual input node for the search area.
87 */
88 get inputNode(): HTMLInputElement {
89 return this.node.getElementsByClassName(
90 'lm-CommandPalette-input'
91 )[0] as HTMLInputElement;
92 }
93
94 /**
95 * The command palette content node.
96 *
97 * #### Notes
98 * This is the node which holds the command item nodes.
99 *
100 * Modifying this node directly can lead to undefined behavior.
101 */
102 get contentNode(): HTMLUListElement {
103 return this.node.getElementsByClassName(
104 'lm-CommandPalette-content'
105 )[0] as HTMLUListElement;
106 }
107
108 /**
109 * A read-only array of the command items in the palette.
110 */
111 get items(): ReadonlyArray<CommandPalette.IItem> {
112 return this._items;
113 }
114
115 /**
116 * Add a command item to the command palette.
117 *
118 * @param options - The options for creating the command item.
119 *
120 * @returns The command item added to the palette.
121 */
122 addItem(options: CommandPalette.IItemOptions): CommandPalette.IItem {
123 // Create a new command item for the options.
124 let item = Private.createItem(this.commands, options);
125
126 // Add the item to the array.
127 this._items.push(item);
128
129 // Refresh the search results.
130 this.refresh();
131
132 // Return the item added to the palette.
133 return item;
134 }
135
136 /**
137 * Adds command items to the command palette.
138 *
139 * @param items - An array of options for creating each command item.
140 *
141 * @returns The command items added to the palette.
142 */
143 addItems(items: CommandPalette.IItemOptions[]): CommandPalette.IItem[] {
144 const newItems = items.map(item => Private.createItem(this.commands, item));
145 newItems.forEach(item => this._items.push(item));
146 this.refresh();
147 return newItems;
148 }
149
150 /**
151 * Remove an item from the command palette.
152 *
153 * @param item - The item to remove from the palette.
154 *
155 * #### Notes
156 * This is a no-op if the item is not in the palette.
157 */
158 removeItem(item: CommandPalette.IItem): void {
159 this.removeItemAt(this._items.indexOf(item));
160 }
161
162 /**
163 * Remove the item at a given index from the command palette.
164 *
165 * @param index - The index of the item to remove.
166 *
167 * #### Notes
168 * This is a no-op if the index is out of range.
169 */
170 removeItemAt(index: number): void {
171 // Remove the item from the array.
172 let item = ArrayExt.removeAt(this._items, index);
173
174 // Bail if the index is out of range.
175 if (!item) {
176 return;
177 }
178
179 // Refresh the search results.
180 this.refresh();
181 }
182
183 /**
184 * Remove all items from the command palette.
185 */
186 clearItems(): void {
187 // Bail if there is nothing to remove.
188 if (this._items.length === 0) {
189 return;
190 }
191
192 // Clear the array of items.
193 this._items.length = 0;
194
195 // Refresh the search results.
196 this.refresh();
197 }
198
199 /**
200 * Clear the search results and schedule an update.
201 *
202 * #### Notes
203 * This should be called whenever the search results of the palette
204 * should be updated.
205 *
206 * This is typically called automatically by the palette as needed,
207 * but can be called manually if the input text is programatically
208 * changed.
209 *
210 * The rendered results are updated asynchronously.
211 */
212 refresh(): void {
213 this._results = null;
214 if (this.inputNode.value !== '') {
215 let clear = this.node.getElementsByClassName(
216 'lm-close-icon'
217 )[0] as HTMLInputElement;
218 clear.style.display = 'inherit';
219 } else {
220 let clear = this.node.getElementsByClassName(
221 'lm-close-icon'
222 )[0] as HTMLInputElement;
223 clear.style.display = 'none';
224 }
225 this.update();
226 }
227
228 /**
229 * Handle the DOM events for the command palette.
230 *
231 * @param event - The DOM event sent to the command palette.
232 *
233 * #### Notes
234 * This method implements the DOM `EventListener` interface and is
235 * called in response to events on the command palette's DOM node.
236 * It should not be called directly by user code.
237 */
238 handleEvent(event: Event): void {
239 switch (event.type) {
240 case 'click':
241 this._evtClick(event as MouseEvent);
242 break;
243 case 'keydown':
244 this._evtKeyDown(event as KeyboardEvent);
245 break;
246 case 'input':
247 this.refresh();
248 break;
249 case 'focus':
250 case 'blur':
251 this._toggleFocused();
252 break;
253 }
254 }
255
256 /**
257 * A message handler invoked on a `'before-attach'` message.
258 */
259 protected onBeforeAttach(msg: Message): void {
260 this.node.addEventListener('click', this);
261 this.node.addEventListener('keydown', this);
262 this.node.addEventListener('input', this);
263 this.node.addEventListener('focus', this, true);
264 this.node.addEventListener('blur', this, true);
265 }
266
267 /**
268 * A message handler invoked on an `'after-detach'` message.
269 */
270 protected onAfterDetach(msg: Message): void {
271 this.node.removeEventListener('click', this);
272 this.node.removeEventListener('keydown', this);
273 this.node.removeEventListener('input', this);
274 this.node.removeEventListener('focus', this, true);
275 this.node.removeEventListener('blur', this, true);
276 }
277
278 /**
279 * A message handler invoked on an `'activate-request'` message.
280 */
281 protected onActivateRequest(msg: Message): void {
282 if (this.isAttached) {
283 let input = this.inputNode;
284 input.focus();
285 input.select();
286 }
287 }
288
289 /**
290 * A message handler invoked on an `'update-request'` message.
291 */
292 protected onUpdateRequest(msg: Message): void {
293 // Fetch the current query text and content node.
294 let query = this.inputNode.value;
295 let contentNode = this.contentNode;
296
297 // Ensure the search results are generated.
298 let results = this._results;
299 if (!results) {
300 // Generate and store the new search results.
301 results = this._results = Private.search(this._items, query);
302
303 // Reset the active index.
304 this._activeIndex = query
305 ? ArrayExt.findFirstIndex(results, Private.canActivate)
306 : -1;
307 }
308
309 // If there is no query and no results, clear the content.
310 if (!query && results.length === 0) {
311 VirtualDOM.render(null, contentNode);
312 return;
313 }
314
315 // If the is a query but no results, render the empty message.
316 if (query && results.length === 0) {
317 let content = this.renderer.renderEmptyMessage({ query });
318 VirtualDOM.render(content, contentNode);
319 return;
320 }
321
322 // Create the render content for the search results.
323 let renderer = this.renderer;
324 let activeIndex = this._activeIndex;
325 let content = new Array<VirtualElement>(results.length);
326 for (let i = 0, n = results.length; i < n; ++i) {
327 let result = results[i];
328 if (result.type === 'header') {
329 let indices = result.indices;
330 let category = result.category;
331 content[i] = renderer.renderHeader({ category, indices });
332 } else {
333 let item = result.item;
334 let indices = result.indices;
335 let active = i === activeIndex;
336 content[i] = renderer.renderItem({ item, indices, active });
337 }
338 }
339
340 // Render the search result content.
341 VirtualDOM.render(content, contentNode);
342
343 // Adjust the scroll position as needed.
344 if (activeIndex < 0 || activeIndex >= results.length) {
345 contentNode.scrollTop = 0;
346 } else {
347 let element = contentNode.children[activeIndex];
348 ElementExt.scrollIntoViewIfNeeded(contentNode, element);
349 }
350 }
351
352 /**
353 * Handle the `'click'` event for the command palette.
354 */
355 private _evtClick(event: MouseEvent): void {
356 // Bail if the click is not the left button.
357 if (event.button !== 0) {
358 return;
359 }
360
361 // Clear input if the target is clear button
362 if ((event.target as HTMLElement).classList.contains('lm-close-icon')) {
363 this.inputNode.value = '';
364 this.refresh();
365 return;
366 }
367
368 // Find the index of the item which was clicked.
369 let index = ArrayExt.findFirstIndex(this.contentNode.children, node => {
370 return node.contains(event.target as HTMLElement);
371 });
372
373 // Bail if the click was not on an item.
374 if (index === -1) {
375 return;
376 }
377
378 // Kill the event when a content item is clicked.
379 event.preventDefault();
380 event.stopPropagation();
381
382 // Execute the item if possible.
383 this._execute(index);
384 }
385
386 /**
387 * Handle the `'keydown'` event for the command palette.
388 */
389 private _evtKeyDown(event: KeyboardEvent): void {
390 if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
391 return;
392 }
393 switch (event.keyCode) {
394 case 13: // Enter
395 event.preventDefault();
396 event.stopPropagation();
397 this._execute(this._activeIndex);
398 break;
399 case 38: // Up Arrow
400 event.preventDefault();
401 event.stopPropagation();
402 this._activatePreviousItem();
403 break;
404 case 40: // Down Arrow
405 event.preventDefault();
406 event.stopPropagation();
407 this._activateNextItem();
408 break;
409 }
410 }
411
412 /**
413 * Activate the next enabled command item.
414 */
415 private _activateNextItem(): void {
416 // Bail if there are no search results.
417 if (!this._results || this._results.length === 0) {
418 return;
419 }
420
421 // Find the next enabled item index.
422 let ai = this._activeIndex;
423 let n = this._results.length;
424 let start = ai < n - 1 ? ai + 1 : 0;
425 let stop = start === 0 ? n - 1 : start - 1;
426 this._activeIndex = ArrayExt.findFirstIndex(
427 this._results,
428 Private.canActivate,
429 start,
430 stop
431 );
432
433 // Schedule an update of the items.
434 this.update();
435 }
436
437 /**
438 * Activate the previous enabled command item.
439 */
440 private _activatePreviousItem(): void {
441 // Bail if there are no search results.
442 if (!this._results || this._results.length === 0) {
443 return;
444 }
445
446 // Find the previous enabled item index.
447 let ai = this._activeIndex;
448 let n = this._results.length;
449 let start = ai <= 0 ? n - 1 : ai - 1;
450 let stop = start === n - 1 ? 0 : start + 1;
451 this._activeIndex = ArrayExt.findLastIndex(
452 this._results,
453 Private.canActivate,
454 start,
455 stop
456 );
457
458 // Schedule an update of the items.
459 this.update();
460 }
461
462 /**
463 * Execute the command item at the given index, if possible.
464 */
465 private _execute(index: number): void {
466 // Bail if there are no search results.
467 if (!this._results) {
468 return;
469 }
470
471 // Bail if the index is out of range.
472 let part = this._results[index];
473 if (!part) {
474 return;
475 }
476
477 // Update the search text if the item is a header.
478 if (part.type === 'header') {
479 let input = this.inputNode;
480 input.value = `${part.category.toLowerCase()} `;
481 input.focus();
482 this.refresh();
483 return;
484 }
485
486 // Bail if item is not enabled.
487 if (!part.item.isEnabled) {
488 return;
489 }
490
491 // Execute the item.
492 this.commands.execute(part.item.command, part.item.args);
493
494 // Clear the query text.
495 this.inputNode.value = '';
496
497 // Refresh the search results.
498 this.refresh();
499 }
500
501 /**
502 * Toggle the focused modifier based on the input node focus state.
503 */
504 private _toggleFocused(): void {
505 let focused = document.activeElement === this.inputNode;
506 this.toggleClass('lm-mod-focused', focused);
507 /* <DEPRECATED> */
508 this.toggleClass('p-mod-focused', focused);
509 /* </DEPRECATED> */
510 }
511
512 /**
513 * A signal handler for generic command changes.
514 */
515 private _onGenericChange(): void {
516 this.refresh();
517 }
518
519 private _activeIndex = -1;
520 private _items: CommandPalette.IItem[] = [];
521 private _results: Private.SearchResult[] | null = null;
522}
523
524/**
525 * The namespace for the `CommandPalette` class statics.
526 */
527export namespace CommandPalette {
528 /**
529 * An options object for creating a command palette.
530 */
531 export interface IOptions {
532 /**
533 * The command registry for use with the command palette.
534 */
535 commands: CommandRegistry;
536
537 /**
538 * A custom renderer for use with the command palette.
539 *
540 * The default is a shared renderer instance.
541 */
542 renderer?: IRenderer;
543 }
544
545 /**
546 * An options object for creating a command item.
547 */
548 export interface IItemOptions {
549 /**
550 * The category for the item.
551 */
552 category: string;
553
554 /**
555 * The command to execute when the item is triggered.
556 */
557 command: string;
558
559 /**
560 * The arguments for the command.
561 *
562 * The default value is an empty object.
563 */
564 args?: ReadonlyJSONObject;
565
566 /**
567 * The rank for the command item.
568 *
569 * The rank is used as a tie-breaker when ordering command items
570 * for display. Items are sorted in the following order:
571 * 1. Text match (lower is better)
572 * 2. Category (locale order)
573 * 3. Rank (lower is better)
574 * 4. Label (locale order)
575 *
576 * The default rank is `Infinity`.
577 */
578 rank?: number;
579 }
580
581 /**
582 * An object which represents an item in a command palette.
583 *
584 * #### Notes
585 * Item objects are created automatically by a command palette.
586 */
587 export interface IItem {
588 /**
589 * The command to execute when the item is triggered.
590 */
591 readonly command: string;
592
593 /**
594 * The arguments for the command.
595 */
596 readonly args: ReadonlyJSONObject;
597
598 /**
599 * The category for the command item.
600 */
601 readonly category: string;
602
603 /**
604 * The rank for the command item.
605 */
606 readonly rank: number;
607
608 /**
609 * The display label for the command item.
610 */
611 readonly label: string;
612
613 /**
614 * The display caption for the command item.
615 */
616 readonly caption: string;
617
618 /**
619 * The icon renderer for the command item.
620 */
621 readonly icon:
622 | VirtualElement.IRenderer
623 | undefined
624 /* <DEPRECATED> */
625 | string /* </DEPRECATED> */;
626
627 /**
628 * The icon class for the command item.
629 */
630 readonly iconClass: string;
631
632 /**
633 * The icon label for the command item.
634 */
635 readonly iconLabel: string;
636
637 /**
638 * The extra class name for the command item.
639 */
640 readonly className: string;
641
642 /**
643 * The dataset for the command item.
644 */
645 readonly dataset: CommandRegistry.Dataset;
646
647 /**
648 * Whether the command item is enabled.
649 */
650 readonly isEnabled: boolean;
651
652 /**
653 * Whether the command item is toggled.
654 */
655 readonly isToggled: boolean;
656
657 /**
658 * Whether the command item is toggleable.
659 */
660 readonly isToggleable: boolean;
661
662 /**
663 * Whether the command item is visible.
664 */
665 readonly isVisible: boolean;
666
667 /**
668 * The key binding for the command item.
669 */
670 readonly keyBinding: CommandRegistry.IKeyBinding | null;
671 }
672
673 /**
674 * The render data for a command palette header.
675 */
676 export interface IHeaderRenderData {
677 /**
678 * The category of the header.
679 */
680 readonly category: string;
681
682 /**
683 * The indices of the matched characters in the category.
684 */
685 readonly indices: ReadonlyArray<number> | null;
686 }
687
688 /**
689 * The render data for a command palette item.
690 */
691 export interface IItemRenderData {
692 /**
693 * The command palette item to render.
694 */
695 readonly item: IItem;
696
697 /**
698 * The indices of the matched characters in the label.
699 */
700 readonly indices: ReadonlyArray<number> | null;
701
702 /**
703 * Whether the item is the active item.
704 */
705 readonly active: boolean;
706 }
707
708 /**
709 * The render data for a command palette empty message.
710 */
711 export interface IEmptyMessageRenderData {
712 /**
713 * The query which failed to match any commands.
714 */
715 query: string;
716 }
717
718 /**
719 * A renderer for use with a command palette.
720 */
721 export interface IRenderer {
722 /**
723 * Render the virtual element for a command palette header.
724 *
725 * @param data - The data to use for rendering the header.
726 *
727 * @returns A virtual element representing the header.
728 */
729 renderHeader(data: IHeaderRenderData): VirtualElement;
730
731 /**
732 * Render the virtual element for a command palette item.
733 *
734 * @param data - The data to use for rendering the item.
735 *
736 * @returns A virtual element representing the item.
737 *
738 * #### Notes
739 * The command palette will not render invisible items.
740 */
741 renderItem(data: IItemRenderData): VirtualElement;
742
743 /**
744 * Render the empty results message for a command palette.
745 *
746 * @param data - The data to use for rendering the message.
747 *
748 * @returns A virtual element representing the message.
749 */
750 renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement;
751 }
752
753 /**
754 * The default implementation of `IRenderer`.
755 */
756 export class Renderer implements IRenderer {
757 /**
758 * Render the virtual element for a command palette header.
759 *
760 * @param data - The data to use for rendering the header.
761 *
762 * @returns A virtual element representing the header.
763 */
764 renderHeader(data: IHeaderRenderData): VirtualElement {
765 let content = this.formatHeader(data);
766 return h.li(
767 {
768 className:
769 'lm-CommandPalette-header' +
770 /* <DEPRECATED> */
771 ' p-CommandPalette-header'
772 /* </DEPRECATED> */
773 },
774 content
775 );
776 }
777
778 /**
779 * Render the virtual element for a command palette item.
780 *
781 * @param data - The data to use for rendering the item.
782 *
783 * @returns A virtual element representing the item.
784 */
785 renderItem(data: IItemRenderData): VirtualElement {
786 let className = this.createItemClass(data);
787 let dataset = this.createItemDataset(data);
788 if (data.item.isToggleable) {
789 return h.li(
790 {
791 className,
792 dataset,
793 role: 'checkbox',
794 'aria-checked': `${data.item.isToggled}`
795 },
796 this.renderItemIcon(data),
797 this.renderItemContent(data),
798 this.renderItemShortcut(data)
799 );
800 }
801 return h.li(
802 {
803 className,
804 dataset
805 },
806 this.renderItemIcon(data),
807 this.renderItemContent(data),
808 this.renderItemShortcut(data)
809 );
810 }
811
812 /**
813 * Render the empty results message for a command palette.
814 *
815 * @param data - The data to use for rendering the message.
816 *
817 * @returns A virtual element representing the message.
818 */
819 renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement {
820 let content = this.formatEmptyMessage(data);
821 return h.li(
822 {
823 className:
824 'lm-CommandPalette-emptyMessage' +
825 /* <DEPRECATED> */
826 ' p-CommandPalette-emptyMessage'
827 /* </DEPRECATED> */
828 },
829 content
830 );
831 }
832
833 /**
834 * Render the icon for a command palette item.
835 *
836 * @param data - The data to use for rendering the icon.
837 *
838 * @returns A virtual element representing the icon.
839 */
840 renderItemIcon(data: IItemRenderData): VirtualElement {
841 let className = this.createIconClass(data);
842
843 /* <DEPRECATED> */
844 if (typeof data.item.icon === 'string') {
845 return h.div({ className }, data.item.iconLabel);
846 }
847 /* </DEPRECATED> */
848
849 // if data.item.icon is undefined, it will be ignored
850 return h.div({ className }, data.item.icon!, data.item.iconLabel);
851 }
852
853 /**
854 * Render the content for a command palette item.
855 *
856 * @param data - The data to use for rendering the content.
857 *
858 * @returns A virtual element representing the content.
859 */
860 renderItemContent(data: IItemRenderData): VirtualElement {
861 return h.div(
862 {
863 className:
864 'lm-CommandPalette-itemContent' +
865 /* <DEPRECATED> */
866 ' p-CommandPalette-itemContent'
867 /* </DEPRECATED> */
868 },
869 this.renderItemLabel(data),
870 this.renderItemCaption(data)
871 );
872 }
873
874 /**
875 * Render the label for a command palette item.
876 *
877 * @param data - The data to use for rendering the label.
878 *
879 * @returns A virtual element representing the label.
880 */
881 renderItemLabel(data: IItemRenderData): VirtualElement {
882 let content = this.formatItemLabel(data);
883 return h.div(
884 {
885 className:
886 'lm-CommandPalette-itemLabel' +
887 /* <DEPRECATED> */
888 ' p-CommandPalette-itemLabel'
889 /* </DEPRECATED> */
890 },
891 content
892 );
893 }
894
895 /**
896 * Render the caption for a command palette item.
897 *
898 * @param data - The data to use for rendering the caption.
899 *
900 * @returns A virtual element representing the caption.
901 */
902 renderItemCaption(data: IItemRenderData): VirtualElement {
903 let content = this.formatItemCaption(data);
904 return h.div(
905 {
906 className:
907 'lm-CommandPalette-itemCaption' +
908 /* <DEPRECATED> */
909 ' p-CommandPalette-itemCaption'
910 /* </DEPRECATED> */
911 },
912 content
913 );
914 }
915
916 /**
917 * Render the shortcut for a command palette item.
918 *
919 * @param data - The data to use for rendering the shortcut.
920 *
921 * @returns A virtual element representing the shortcut.
922 */
923 renderItemShortcut(data: IItemRenderData): VirtualElement {
924 let content = this.formatItemShortcut(data);
925 return h.div(
926 {
927 className:
928 'lm-CommandPalette-itemShortcut' +
929 /* <DEPRECATED> */
930 ' p-CommandPalette-itemShortcut'
931 /* </DEPRECATED> */
932 },
933 content
934 );
935 }
936
937 /**
938 * Create the class name for the command palette item.
939 *
940 * @param data - The data to use for the class name.
941 *
942 * @returns The full class name for the command palette item.
943 */
944 createItemClass(data: IItemRenderData): string {
945 // Set up the initial class name.
946 let name = 'lm-CommandPalette-item';
947 /* <DEPRECATED> */
948 name += ' p-CommandPalette-item';
949 /* </DEPRECATED> */
950
951 // Add the boolean state classes.
952 if (!data.item.isEnabled) {
953 name += ' lm-mod-disabled';
954 /* <DEPRECATED> */
955 name += ' p-mod-disabled';
956 /* </DEPRECATED> */
957 }
958 if (data.item.isToggled) {
959 name += ' lm-mod-toggled';
960 /* <DEPRECATED> */
961 name += ' p-mod-toggled';
962 /* </DEPRECATED> */
963 }
964 if (data.active) {
965 name += ' lm-mod-active';
966 /* <DEPRECATED> */
967 name += ' p-mod-active';
968 /* </DEPRECATED> */
969 }
970
971 // Add the extra class.
972 let extra = data.item.className;
973 if (extra) {
974 name += ` ${extra}`;
975 }
976
977 // Return the complete class name.
978 return name;
979 }
980
981 /**
982 * Create the dataset for the command palette item.
983 *
984 * @param data - The data to use for creating the dataset.
985 *
986 * @returns The dataset for the command palette item.
987 */
988 createItemDataset(data: IItemRenderData): ElementDataset {
989 return { ...data.item.dataset, command: data.item.command };
990 }
991
992 /**
993 * Create the class name for the command item icon.
994 *
995 * @param data - The data to use for the class name.
996 *
997 * @returns The full class name for the item icon.
998 */
999 createIconClass(data: IItemRenderData): string {
1000 let name = 'lm-CommandPalette-itemIcon';
1001 /* <DEPRECATED> */
1002 name += ' p-CommandPalette-itemIcon';
1003 /* </DEPRECATED> */
1004 let extra = data.item.iconClass;
1005 return extra ? `${name} ${extra}` : name;
1006 }
1007
1008 /**
1009 * Create the render content for the header node.
1010 *
1011 * @param data - The data to use for the header content.
1012 *
1013 * @returns The content to add to the header node.
1014 */
1015 formatHeader(data: IHeaderRenderData): h.Child {
1016 if (!data.indices || data.indices.length === 0) {
1017 return data.category;
1018 }
1019 return StringExt.highlight(data.category, data.indices, h.mark);
1020 }
1021
1022 /**
1023 * Create the render content for the empty message node.
1024 *
1025 * @param data - The data to use for the empty message content.
1026 *
1027 * @returns The content to add to the empty message node.
1028 */
1029 formatEmptyMessage(data: IEmptyMessageRenderData): h.Child {
1030 return `No commands found that match '${data.query}'`;
1031 }
1032
1033 /**
1034 * Create the render content for the item shortcut node.
1035 *
1036 * @param data - The data to use for the shortcut content.
1037 *
1038 * @returns The content to add to the shortcut node.
1039 */
1040 formatItemShortcut(data: IItemRenderData): h.Child {
1041 let kb = data.item.keyBinding;
1042 return kb
1043 ? kb.keys.map(CommandRegistry.formatKeystroke).join(', ')
1044 : null;
1045 }
1046
1047 /**
1048 * Create the render content for the item label node.
1049 *
1050 * @param data - The data to use for the label content.
1051 *
1052 * @returns The content to add to the label node.
1053 */
1054 formatItemLabel(data: IItemRenderData): h.Child {
1055 if (!data.indices || data.indices.length === 0) {
1056 return data.item.label;
1057 }
1058 return StringExt.highlight(data.item.label, data.indices, h.mark);
1059 }
1060
1061 /**
1062 * Create the render content for the item caption node.
1063 *
1064 * @param data - The data to use for the caption content.
1065 *
1066 * @returns The content to add to the caption node.
1067 */
1068 formatItemCaption(data: IItemRenderData): h.Child {
1069 return data.item.caption;
1070 }
1071 }
1072
1073 /**
1074 * The default `Renderer` instance.
1075 */
1076 export const defaultRenderer = new Renderer();
1077}
1078
1079/**
1080 * The namespace for the module implementation details.
1081 */
1082namespace Private {
1083 /**
1084 * Create the DOM node for a command palette.
1085 */
1086 export function createNode(): HTMLDivElement {
1087 let node = document.createElement('div');
1088 let search = document.createElement('div');
1089 let wrapper = document.createElement('div');
1090 let input = document.createElement('input');
1091 let content = document.createElement('ul');
1092 let clear = document.createElement('button');
1093 search.className = 'lm-CommandPalette-search';
1094 wrapper.className = 'lm-CommandPalette-wrapper';
1095 input.className = 'lm-CommandPalette-input';
1096 clear.className = 'lm-close-icon';
1097
1098 content.className = 'lm-CommandPalette-content';
1099 /* <DEPRECATED> */
1100 search.classList.add('p-CommandPalette-search');
1101 wrapper.classList.add('p-CommandPalette-wrapper');
1102 input.classList.add('p-CommandPalette-input');
1103 content.classList.add('p-CommandPalette-content');
1104 /* </DEPRECATED> */
1105 input.spellcheck = false;
1106 wrapper.appendChild(input);
1107 wrapper.appendChild(clear);
1108 search.appendChild(wrapper);
1109 node.appendChild(search);
1110 node.appendChild(content);
1111 return node;
1112 }
1113
1114 /**
1115 * Create a new command item from a command registry and options.
1116 */
1117 export function createItem(
1118 commands: CommandRegistry,
1119 options: CommandPalette.IItemOptions
1120 ): CommandPalette.IItem {
1121 return new CommandItem(commands, options);
1122 }
1123
1124 /**
1125 * A search result object for a header label.
1126 */
1127 export interface IHeaderResult {
1128 /**
1129 * The discriminated type of the object.
1130 */
1131 readonly type: 'header';
1132
1133 /**
1134 * The category for the header.
1135 */
1136 readonly category: string;
1137
1138 /**
1139 * The indices of the matched category characters.
1140 */
1141 readonly indices: ReadonlyArray<number> | null;
1142 }
1143
1144 /**
1145 * A search result object for a command item.
1146 */
1147 export interface IItemResult {
1148 /**
1149 * The discriminated type of the object.
1150 */
1151 readonly type: 'item';
1152
1153 /**
1154 * The command item which was matched.
1155 */
1156 readonly item: CommandPalette.IItem;
1157
1158 /**
1159 * The indices of the matched label characters.
1160 */
1161 readonly indices: ReadonlyArray<number> | null;
1162 }
1163
1164 /**
1165 * A type alias for a search result item.
1166 */
1167 export type SearchResult = IHeaderResult | IItemResult;
1168
1169 /**
1170 * Search an array of command items for fuzzy matches.
1171 */
1172 export function search(
1173 items: CommandPalette.IItem[],
1174 query: string
1175 ): SearchResult[] {
1176 // Fuzzy match the items for the query.
1177 let scores = matchItems(items, query);
1178
1179 // Sort the items based on their score.
1180 scores.sort(scoreCmp);
1181
1182 // Create the results for the search.
1183 return createResults(scores);
1184 }
1185
1186 /**
1187 * Test whether a result item can be activated.
1188 */
1189 export function canActivate(result: SearchResult): boolean {
1190 return result.type === 'item' && result.item.isEnabled;
1191 }
1192
1193 /**
1194 * Normalize a category for a command item.
1195 */
1196 function normalizeCategory(category: string): string {
1197 return category.trim().replace(/\s+/g, ' ');
1198 }
1199
1200 /**
1201 * Normalize the query text for a fuzzy search.
1202 */
1203 function normalizeQuery(text: string): string {
1204 return text.replace(/\s+/g, '').toLowerCase();
1205 }
1206
1207 /**
1208 * An enum of the supported match types.
1209 */
1210 const enum MatchType {
1211 Label,
1212 Category,
1213 Split,
1214 Default
1215 }
1216
1217 /**
1218 * A text match score with associated command item.
1219 */
1220 interface IScore {
1221 /**
1222 * The numerical type for the text match.
1223 */
1224 matchType: MatchType;
1225
1226 /**
1227 * The numerical score for the text match.
1228 */
1229 score: number;
1230
1231 /**
1232 * The indices of the matched category characters.
1233 */
1234 categoryIndices: number[] | null;
1235
1236 /**
1237 * The indices of the matched label characters.
1238 */
1239 labelIndices: number[] | null;
1240
1241 /**
1242 * The command item associated with the match.
1243 */
1244 item: CommandPalette.IItem;
1245 }
1246
1247 /**
1248 * Perform a fuzzy match on an array of command items.
1249 */
1250 function matchItems(items: CommandPalette.IItem[], query: string): IScore[] {
1251 // Normalize the query text to lower case with no whitespace.
1252 query = normalizeQuery(query);
1253
1254 // Create the array to hold the scores.
1255 let scores: IScore[] = [];
1256
1257 // Iterate over the items and match against the query.
1258 for (let i = 0, n = items.length; i < n; ++i) {
1259 // Ignore items which are not visible.
1260 let item = items[i];
1261 if (!item.isVisible) {
1262 continue;
1263 }
1264
1265 // If the query is empty, all items are matched by default.
1266 if (!query) {
1267 scores.push({
1268 matchType: MatchType.Default,
1269 categoryIndices: null,
1270 labelIndices: null,
1271 score: 0,
1272 item
1273 });
1274 continue;
1275 }
1276
1277 // Run the fuzzy search for the item and query.
1278 let score = fuzzySearch(item, query);
1279
1280 // Ignore the item if it is not a match.
1281 if (!score) {
1282 continue;
1283 }
1284
1285 // Penalize disabled items.
1286 // TODO - push disabled items all the way down in sort cmp?
1287 if (!item.isEnabled) {
1288 score.score += 1000;
1289 }
1290
1291 // Add the score to the results.
1292 scores.push(score);
1293 }
1294
1295 // Return the final array of scores.
1296 return scores;
1297 }
1298
1299 /**
1300 * Perform a fuzzy search on a single command item.
1301 */
1302 function fuzzySearch(
1303 item: CommandPalette.IItem,
1304 query: string
1305 ): IScore | null {
1306 // Create the source text to be searched.
1307 let category = item.category.toLowerCase();
1308 let label = item.label.toLowerCase();
1309 let source = `${category} ${label}`;
1310
1311 // Set up the match score and indices array.
1312 let score = Infinity;
1313 let indices: number[] | null = null;
1314
1315 // The regex for search word boundaries
1316 let rgx = /\b\w/g;
1317
1318 // Search the source by word boundary.
1319 // eslint-disable-next-line no-constant-condition
1320 while (true) {
1321 // Find the next word boundary in the source.
1322 let rgxMatch = rgx.exec(source);
1323
1324 // Break if there is no more source context.
1325 if (!rgxMatch) {
1326 break;
1327 }
1328
1329 // Run the string match on the relevant substring.
1330 let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index);
1331
1332 // Break if there is no match.
1333 if (!match) {
1334 break;
1335 }
1336
1337 // Update the match if the score is better.
1338 if (match && match.score <= score) {
1339 score = match.score;
1340 indices = match.indices;
1341 }
1342 }
1343
1344 // Bail if there was no match.
1345 if (!indices || score === Infinity) {
1346 return null;
1347 }
1348
1349 // Compute the pivot index between category and label text.
1350 let pivot = category.length + 1;
1351
1352 // Find the slice index to separate matched indices.
1353 let j = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b);
1354
1355 // Extract the matched category and label indices.
1356 let categoryIndices = indices.slice(0, j);
1357 let labelIndices = indices.slice(j);
1358
1359 // Adjust the label indices for the pivot offset.
1360 for (let i = 0, n = labelIndices.length; i < n; ++i) {
1361 labelIndices[i] -= pivot;
1362 }
1363
1364 // Handle a pure label match.
1365 if (categoryIndices.length === 0) {
1366 return {
1367 matchType: MatchType.Label,
1368 categoryIndices: null,
1369 labelIndices,
1370 score,
1371 item
1372 };
1373 }
1374
1375 // Handle a pure category match.
1376 if (labelIndices.length === 0) {
1377 return {
1378 matchType: MatchType.Category,
1379 categoryIndices,
1380 labelIndices: null,
1381 score,
1382 item
1383 };
1384 }
1385
1386 // Handle a split match.
1387 return {
1388 matchType: MatchType.Split,
1389 categoryIndices,
1390 labelIndices,
1391 score,
1392 item
1393 };
1394 }
1395
1396 /**
1397 * A sort comparison function for a match score.
1398 */
1399 function scoreCmp(a: IScore, b: IScore): number {
1400 // First compare based on the match type
1401 let m1 = a.matchType - b.matchType;
1402 if (m1 !== 0) {
1403 return m1;
1404 }
1405
1406 // Otherwise, compare based on the match score.
1407 let d1 = a.score - b.score;
1408 if (d1 !== 0) {
1409 return d1;
1410 }
1411
1412 // Find the match index based on the match type.
1413 let i1 = 0;
1414 let i2 = 0;
1415 switch (a.matchType) {
1416 case MatchType.Label:
1417 i1 = a.labelIndices![0];
1418 i2 = b.labelIndices![0];
1419 break;
1420 case MatchType.Category:
1421 case MatchType.Split:
1422 i1 = a.categoryIndices![0];
1423 i2 = b.categoryIndices![0];
1424 break;
1425 }
1426
1427 // Compare based on the match index.
1428 if (i1 !== i2) {
1429 return i1 - i2;
1430 }
1431
1432 // Otherwise, compare by category.
1433 let d2 = a.item.category.localeCompare(b.item.category);
1434 if (d2 !== 0) {
1435 return d2;
1436 }
1437
1438 // Otherwise, compare by rank.
1439 let r1 = a.item.rank;
1440 let r2 = b.item.rank;
1441 if (r1 !== r2) {
1442 return r1 < r2 ? -1 : 1; // Infinity safe
1443 }
1444
1445 // Finally, compare by label.
1446 return a.item.label.localeCompare(b.item.label);
1447 }
1448
1449 /**
1450 * Create the results from an array of sorted scores.
1451 */
1452 function createResults(scores: IScore[]): SearchResult[] {
1453 // Set up an array to track which scores have been visited.
1454 let visited = new Array(scores.length);
1455 ArrayExt.fill(visited, false);
1456
1457 // Set up the search results array.
1458 let results: SearchResult[] = [];
1459
1460 // Iterate over each score in the array.
1461 for (let i = 0, n = scores.length; i < n; ++i) {
1462 // Ignore a score which has already been processed.
1463 if (visited[i]) {
1464 continue;
1465 }
1466
1467 // Extract the current item and indices.
1468 let { item, categoryIndices } = scores[i];
1469
1470 // Extract the category for the current item.
1471 let category = item.category;
1472
1473 // Add the header result for the category.
1474 results.push({ type: 'header', category, indices: categoryIndices });
1475
1476 // Find the rest of the scores with the same category.
1477 for (let j = i; j < n; ++j) {
1478 // Ignore a score which has already been processed.
1479 if (visited[j]) {
1480 continue;
1481 }
1482
1483 // Extract the data for the current score.
1484 let { item, labelIndices } = scores[j];
1485
1486 // Ignore an item with a different category.
1487 if (item.category !== category) {
1488 continue;
1489 }
1490
1491 // Create the item result for the score.
1492 results.push({ type: 'item', item, indices: labelIndices });
1493
1494 // Mark the score as processed.
1495 visited[j] = true;
1496 }
1497 }
1498
1499 // Return the final results.
1500 return results;
1501 }
1502
1503 /**
1504 * A concrete implementation of `CommandPalette.IItem`.
1505 */
1506 class CommandItem implements CommandPalette.IItem {
1507 /**
1508 * Construct a new command item.
1509 */
1510 constructor(
1511 commands: CommandRegistry,
1512 options: CommandPalette.IItemOptions
1513 ) {
1514 this._commands = commands;
1515 this.category = normalizeCategory(options.category);
1516 this.command = options.command;
1517 this.args = options.args || JSONExt.emptyObject;
1518 this.rank = options.rank !== undefined ? options.rank : Infinity;
1519 }
1520
1521 /**
1522 * The category for the command item.
1523 */
1524 readonly category: string;
1525
1526 /**
1527 * The command to execute when the item is triggered.
1528 */
1529 readonly command: string;
1530
1531 /**
1532 * The arguments for the command.
1533 */
1534 readonly args: ReadonlyJSONObject;
1535
1536 /**
1537 * The rank for the command item.
1538 */
1539 readonly rank: number;
1540
1541 /**
1542 * The display label for the command item.
1543 */
1544 get label(): string {
1545 return this._commands.label(this.command, this.args);
1546 }
1547
1548 /**
1549 * The icon renderer for the command item.
1550 */
1551 get icon():
1552 | VirtualElement.IRenderer
1553 | undefined
1554 /* <DEPRECATED> */
1555 | string /* </DEPRECATED> */ {
1556 return this._commands.icon(this.command, this.args);
1557 }
1558
1559 /**
1560 * The icon class for the command item.
1561 */
1562 get iconClass(): string {
1563 return this._commands.iconClass(this.command, this.args);
1564 }
1565
1566 /**
1567 * The icon label for the command item.
1568 */
1569 get iconLabel(): string {
1570 return this._commands.iconLabel(this.command, this.args);
1571 }
1572
1573 /**
1574 * The display caption for the command item.
1575 */
1576 get caption(): string {
1577 return this._commands.caption(this.command, this.args);
1578 }
1579
1580 /**
1581 * The extra class name for the command item.
1582 */
1583 get className(): string {
1584 return this._commands.className(this.command, this.args);
1585 }
1586
1587 /**
1588 * The dataset for the command item.
1589 */
1590 get dataset(): CommandRegistry.Dataset {
1591 return this._commands.dataset(this.command, this.args);
1592 }
1593
1594 /**
1595 * Whether the command item is enabled.
1596 */
1597 get isEnabled(): boolean {
1598 return this._commands.isEnabled(this.command, this.args);
1599 }
1600
1601 /**
1602 * Whether the command item is toggled.
1603 */
1604 get isToggled(): boolean {
1605 return this._commands.isToggled(this.command, this.args);
1606 }
1607
1608 /**
1609 * Whether the command item is toggleable.
1610 */
1611 get isToggleable(): boolean {
1612 return this._commands.isToggleable(this.command, this.args);
1613 }
1614
1615 /**
1616 * Whether the command item is visible.
1617 */
1618 get isVisible(): boolean {
1619 return this._commands.isVisible(this.command, this.args);
1620 }
1621
1622 /**
1623 * The key binding for the command item.
1624 */
1625 get keyBinding(): CommandRegistry.IKeyBinding | null {
1626 let { command, args } = this;
1627 return (
1628 ArrayExt.findLastValue(this._commands.keyBindings, kb => {
1629 return kb.command === command && JSONExt.deepEqual(kb.args, args);
1630 }) || null
1631 );
1632 }
1633
1634 private _commands: CommandRegistry;
1635 }
1636}