1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 | import { ArrayExt, StringExt } from '@lumino/algorithm';
|
11 |
|
12 | import { JSONExt, ReadonlyJSONObject } from '@lumino/coreutils';
|
13 |
|
14 | import { CommandRegistry } from '@lumino/commands';
|
15 |
|
16 | import { ElementExt } from '@lumino/domutils';
|
17 |
|
18 | import { Message } from '@lumino/messaging';
|
19 |
|
20 | import {
|
21 | ElementDataset,
|
22 | h,
|
23 | VirtualDOM,
|
24 | VirtualElement
|
25 | } from '@lumino/virtualdom';
|
26 |
|
27 | import { Widget } from './widget';
|
28 |
|
29 |
|
30 |
|
31 |
|
32 | export class CommandPalette extends Widget {
|
33 | |
34 |
|
35 |
|
36 |
|
37 |
|
38 | constructor(options: CommandPalette.IOptions) {
|
39 | super({ node: Private.createNode() });
|
40 | this.addClass('lm-CommandPalette');
|
41 |
|
42 | this.addClass('p-CommandPalette');
|
43 |
|
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 |
|
53 |
|
54 | dispose(): void {
|
55 | this._items.length = 0;
|
56 | this._results = null;
|
57 | super.dispose();
|
58 | }
|
59 |
|
60 | |
61 |
|
62 |
|
63 | readonly commands: CommandRegistry;
|
64 |
|
65 | |
66 |
|
67 |
|
68 | readonly renderer: CommandPalette.IRenderer;
|
69 |
|
70 | |
71 |
|
72 |
|
73 |
|
74 |
|
75 |
|
76 | get searchNode(): HTMLDivElement {
|
77 | return this.node.getElementsByClassName(
|
78 | 'lm-CommandPalette-search'
|
79 | )[0] as HTMLDivElement;
|
80 | }
|
81 |
|
82 | |
83 |
|
84 |
|
85 |
|
86 |
|
87 |
|
88 | get inputNode(): HTMLInputElement {
|
89 | return this.node.getElementsByClassName(
|
90 | 'lm-CommandPalette-input'
|
91 | )[0] as HTMLInputElement;
|
92 | }
|
93 |
|
94 | |
95 |
|
96 |
|
97 |
|
98 |
|
99 |
|
100 |
|
101 |
|
102 | get contentNode(): HTMLUListElement {
|
103 | return this.node.getElementsByClassName(
|
104 | 'lm-CommandPalette-content'
|
105 | )[0] as HTMLUListElement;
|
106 | }
|
107 |
|
108 | |
109 |
|
110 |
|
111 | get items(): ReadonlyArray<CommandPalette.IItem> {
|
112 | return this._items;
|
113 | }
|
114 |
|
115 | |
116 |
|
117 |
|
118 |
|
119 |
|
120 |
|
121 |
|
122 | addItem(options: CommandPalette.IItemOptions): CommandPalette.IItem {
|
123 |
|
124 | let item = Private.createItem(this.commands, options);
|
125 |
|
126 |
|
127 | this._items.push(item);
|
128 |
|
129 |
|
130 | this.refresh();
|
131 |
|
132 |
|
133 | return item;
|
134 | }
|
135 |
|
136 | |
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
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 |
|
152 |
|
153 |
|
154 |
|
155 |
|
156 |
|
157 |
|
158 | removeItem(item: CommandPalette.IItem): void {
|
159 | this.removeItemAt(this._items.indexOf(item));
|
160 | }
|
161 |
|
162 | |
163 |
|
164 |
|
165 |
|
166 |
|
167 |
|
168 |
|
169 |
|
170 | removeItemAt(index: number): void {
|
171 |
|
172 | let item = ArrayExt.removeAt(this._items, index);
|
173 |
|
174 |
|
175 | if (!item) {
|
176 | return;
|
177 | }
|
178 |
|
179 |
|
180 | this.refresh();
|
181 | }
|
182 |
|
183 | |
184 |
|
185 |
|
186 | clearItems(): void {
|
187 |
|
188 | if (this._items.length === 0) {
|
189 | return;
|
190 | }
|
191 |
|
192 |
|
193 | this._items.length = 0;
|
194 |
|
195 |
|
196 | this.refresh();
|
197 | }
|
198 |
|
199 | |
200 |
|
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 |
|
209 |
|
210 |
|
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 |
|
230 |
|
231 |
|
232 |
|
233 |
|
234 |
|
235 |
|
236 |
|
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 |
|
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 |
|
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 |
|
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 |
|
291 |
|
292 | protected onUpdateRequest(msg: Message): void {
|
293 |
|
294 | let query = this.inputNode.value;
|
295 | let contentNode = this.contentNode;
|
296 |
|
297 |
|
298 | let results = this._results;
|
299 | if (!results) {
|
300 |
|
301 | results = this._results = Private.search(this._items, query);
|
302 |
|
303 |
|
304 | this._activeIndex = query
|
305 | ? ArrayExt.findFirstIndex(results, Private.canActivate)
|
306 | : -1;
|
307 | }
|
308 |
|
309 |
|
310 | if (!query && results.length === 0) {
|
311 | VirtualDOM.render(null, contentNode);
|
312 | return;
|
313 | }
|
314 |
|
315 |
|
316 | if (query && results.length === 0) {
|
317 | let content = this.renderer.renderEmptyMessage({ query });
|
318 | VirtualDOM.render(content, contentNode);
|
319 | return;
|
320 | }
|
321 |
|
322 |
|
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 |
|
341 | VirtualDOM.render(content, contentNode);
|
342 |
|
343 |
|
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 |
|
354 |
|
355 | private _evtClick(event: MouseEvent): void {
|
356 |
|
357 | if (event.button !== 0) {
|
358 | return;
|
359 | }
|
360 |
|
361 |
|
362 | if ((event.target as HTMLElement).classList.contains('lm-close-icon')) {
|
363 | this.inputNode.value = '';
|
364 | this.refresh();
|
365 | return;
|
366 | }
|
367 |
|
368 |
|
369 | let index = ArrayExt.findFirstIndex(this.contentNode.children, node => {
|
370 | return node.contains(event.target as HTMLElement);
|
371 | });
|
372 |
|
373 |
|
374 | if (index === -1) {
|
375 | return;
|
376 | }
|
377 |
|
378 |
|
379 | event.preventDefault();
|
380 | event.stopPropagation();
|
381 |
|
382 |
|
383 | this._execute(index);
|
384 | }
|
385 |
|
386 | |
387 |
|
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:
|
395 | event.preventDefault();
|
396 | event.stopPropagation();
|
397 | this._execute(this._activeIndex);
|
398 | break;
|
399 | case 38:
|
400 | event.preventDefault();
|
401 | event.stopPropagation();
|
402 | this._activatePreviousItem();
|
403 | break;
|
404 | case 40:
|
405 | event.preventDefault();
|
406 | event.stopPropagation();
|
407 | this._activateNextItem();
|
408 | break;
|
409 | }
|
410 | }
|
411 |
|
412 | |
413 |
|
414 |
|
415 | private _activateNextItem(): void {
|
416 |
|
417 | if (!this._results || this._results.length === 0) {
|
418 | return;
|
419 | }
|
420 |
|
421 |
|
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 |
|
434 | this.update();
|
435 | }
|
436 |
|
437 | |
438 |
|
439 |
|
440 | private _activatePreviousItem(): void {
|
441 |
|
442 | if (!this._results || this._results.length === 0) {
|
443 | return;
|
444 | }
|
445 |
|
446 |
|
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 |
|
459 | this.update();
|
460 | }
|
461 |
|
462 | |
463 |
|
464 |
|
465 | private _execute(index: number): void {
|
466 |
|
467 | if (!this._results) {
|
468 | return;
|
469 | }
|
470 |
|
471 |
|
472 | let part = this._results[index];
|
473 | if (!part) {
|
474 | return;
|
475 | }
|
476 |
|
477 |
|
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 |
|
487 | if (!part.item.isEnabled) {
|
488 | return;
|
489 | }
|
490 |
|
491 |
|
492 | this.commands.execute(part.item.command, part.item.args);
|
493 |
|
494 |
|
495 | this.inputNode.value = '';
|
496 |
|
497 |
|
498 | this.refresh();
|
499 | }
|
500 |
|
501 | |
502 |
|
503 |
|
504 | private _toggleFocused(): void {
|
505 | let focused = document.activeElement === this.inputNode;
|
506 | this.toggleClass('lm-mod-focused', focused);
|
507 |
|
508 | this.toggleClass('p-mod-focused', focused);
|
509 |
|
510 | }
|
511 |
|
512 | |
513 |
|
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 |
|
526 |
|
527 | export namespace CommandPalette {
|
528 | |
529 |
|
530 |
|
531 | export interface IOptions {
|
532 | |
533 |
|
534 |
|
535 | commands: CommandRegistry;
|
536 |
|
537 | |
538 |
|
539 |
|
540 |
|
541 |
|
542 | renderer?: IRenderer;
|
543 | }
|
544 |
|
545 | |
546 |
|
547 |
|
548 | export interface IItemOptions {
|
549 | |
550 |
|
551 |
|
552 | category: string;
|
553 |
|
554 | |
555 |
|
556 |
|
557 | command: string;
|
558 |
|
559 | |
560 |
|
561 |
|
562 |
|
563 |
|
564 | args?: ReadonlyJSONObject;
|
565 |
|
566 | |
567 |
|
568 |
|
569 |
|
570 |
|
571 |
|
572 |
|
573 |
|
574 |
|
575 |
|
576 |
|
577 |
|
578 | rank?: number;
|
579 | }
|
580 |
|
581 | |
582 |
|
583 |
|
584 |
|
585 |
|
586 |
|
587 | export interface IItem {
|
588 | |
589 |
|
590 |
|
591 | readonly command: string;
|
592 |
|
593 | |
594 |
|
595 |
|
596 | readonly args: ReadonlyJSONObject;
|
597 |
|
598 | |
599 |
|
600 |
|
601 | readonly category: string;
|
602 |
|
603 | |
604 |
|
605 |
|
606 | readonly rank: number;
|
607 |
|
608 | |
609 |
|
610 |
|
611 | readonly label: string;
|
612 |
|
613 | |
614 |
|
615 |
|
616 | readonly caption: string;
|
617 |
|
618 | |
619 |
|
620 |
|
621 | readonly icon:
|
622 | | VirtualElement.IRenderer
|
623 | | undefined
|
624 |
|
625 | | string ;
|
626 |
|
627 | |
628 |
|
629 |
|
630 | readonly iconClass: string;
|
631 |
|
632 | |
633 |
|
634 |
|
635 | readonly iconLabel: string;
|
636 |
|
637 | |
638 |
|
639 |
|
640 | readonly className: string;
|
641 |
|
642 | |
643 |
|
644 |
|
645 | readonly dataset: CommandRegistry.Dataset;
|
646 |
|
647 | |
648 |
|
649 |
|
650 | readonly isEnabled: boolean;
|
651 |
|
652 | |
653 |
|
654 |
|
655 | readonly isToggled: boolean;
|
656 |
|
657 | |
658 |
|
659 |
|
660 | readonly isToggleable: boolean;
|
661 |
|
662 | |
663 |
|
664 |
|
665 | readonly isVisible: boolean;
|
666 |
|
667 | |
668 |
|
669 |
|
670 | readonly keyBinding: CommandRegistry.IKeyBinding | null;
|
671 | }
|
672 |
|
673 | |
674 |
|
675 |
|
676 | export interface IHeaderRenderData {
|
677 | |
678 |
|
679 |
|
680 | readonly category: string;
|
681 |
|
682 | |
683 |
|
684 |
|
685 | readonly indices: ReadonlyArray<number> | null;
|
686 | }
|
687 |
|
688 | |
689 |
|
690 |
|
691 | export interface IItemRenderData {
|
692 | |
693 |
|
694 |
|
695 | readonly item: IItem;
|
696 |
|
697 | |
698 |
|
699 |
|
700 | readonly indices: ReadonlyArray<number> | null;
|
701 |
|
702 | |
703 |
|
704 |
|
705 | readonly active: boolean;
|
706 | }
|
707 |
|
708 | |
709 |
|
710 |
|
711 | export interface IEmptyMessageRenderData {
|
712 | |
713 |
|
714 |
|
715 | query: string;
|
716 | }
|
717 |
|
718 | |
719 |
|
720 |
|
721 | export interface IRenderer {
|
722 | |
723 |
|
724 |
|
725 |
|
726 |
|
727 |
|
728 |
|
729 | renderHeader(data: IHeaderRenderData): VirtualElement;
|
730 |
|
731 | |
732 |
|
733 |
|
734 |
|
735 |
|
736 |
|
737 |
|
738 |
|
739 |
|
740 |
|
741 | renderItem(data: IItemRenderData): VirtualElement;
|
742 |
|
743 | |
744 |
|
745 |
|
746 |
|
747 |
|
748 |
|
749 |
|
750 | renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement;
|
751 | }
|
752 |
|
753 | |
754 |
|
755 |
|
756 | export class Renderer implements IRenderer {
|
757 | |
758 |
|
759 |
|
760 |
|
761 |
|
762 |
|
763 |
|
764 | renderHeader(data: IHeaderRenderData): VirtualElement {
|
765 | let content = this.formatHeader(data);
|
766 | return h.li(
|
767 | {
|
768 | className:
|
769 | 'lm-CommandPalette-header' +
|
770 |
|
771 | ' p-CommandPalette-header'
|
772 |
|
773 | },
|
774 | content
|
775 | );
|
776 | }
|
777 |
|
778 | |
779 |
|
780 |
|
781 |
|
782 |
|
783 |
|
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 |
|
814 |
|
815 |
|
816 |
|
817 |
|
818 |
|
819 | renderEmptyMessage(data: IEmptyMessageRenderData): VirtualElement {
|
820 | let content = this.formatEmptyMessage(data);
|
821 | return h.li(
|
822 | {
|
823 | className:
|
824 | 'lm-CommandPalette-emptyMessage' +
|
825 |
|
826 | ' p-CommandPalette-emptyMessage'
|
827 |
|
828 | },
|
829 | content
|
830 | );
|
831 | }
|
832 |
|
833 | |
834 |
|
835 |
|
836 |
|
837 |
|
838 |
|
839 |
|
840 | renderItemIcon(data: IItemRenderData): VirtualElement {
|
841 | let className = this.createIconClass(data);
|
842 |
|
843 |
|
844 | if (typeof data.item.icon === 'string') {
|
845 | return h.div({ className }, data.item.iconLabel);
|
846 | }
|
847 |
|
848 |
|
849 |
|
850 | return h.div({ className }, data.item.icon!, data.item.iconLabel);
|
851 | }
|
852 |
|
853 | |
854 |
|
855 |
|
856 |
|
857 |
|
858 |
|
859 |
|
860 | renderItemContent(data: IItemRenderData): VirtualElement {
|
861 | return h.div(
|
862 | {
|
863 | className:
|
864 | 'lm-CommandPalette-itemContent' +
|
865 |
|
866 | ' p-CommandPalette-itemContent'
|
867 |
|
868 | },
|
869 | this.renderItemLabel(data),
|
870 | this.renderItemCaption(data)
|
871 | );
|
872 | }
|
873 |
|
874 | |
875 |
|
876 |
|
877 |
|
878 |
|
879 |
|
880 |
|
881 | renderItemLabel(data: IItemRenderData): VirtualElement {
|
882 | let content = this.formatItemLabel(data);
|
883 | return h.div(
|
884 | {
|
885 | className:
|
886 | 'lm-CommandPalette-itemLabel' +
|
887 |
|
888 | ' p-CommandPalette-itemLabel'
|
889 |
|
890 | },
|
891 | content
|
892 | );
|
893 | }
|
894 |
|
895 | |
896 |
|
897 |
|
898 |
|
899 |
|
900 |
|
901 |
|
902 | renderItemCaption(data: IItemRenderData): VirtualElement {
|
903 | let content = this.formatItemCaption(data);
|
904 | return h.div(
|
905 | {
|
906 | className:
|
907 | 'lm-CommandPalette-itemCaption' +
|
908 |
|
909 | ' p-CommandPalette-itemCaption'
|
910 |
|
911 | },
|
912 | content
|
913 | );
|
914 | }
|
915 |
|
916 | |
917 |
|
918 |
|
919 |
|
920 |
|
921 |
|
922 |
|
923 | renderItemShortcut(data: IItemRenderData): VirtualElement {
|
924 | let content = this.formatItemShortcut(data);
|
925 | return h.div(
|
926 | {
|
927 | className:
|
928 | 'lm-CommandPalette-itemShortcut' +
|
929 |
|
930 | ' p-CommandPalette-itemShortcut'
|
931 |
|
932 | },
|
933 | content
|
934 | );
|
935 | }
|
936 |
|
937 | |
938 |
|
939 |
|
940 |
|
941 |
|
942 |
|
943 |
|
944 | createItemClass(data: IItemRenderData): string {
|
945 |
|
946 | let name = 'lm-CommandPalette-item';
|
947 |
|
948 | name += ' p-CommandPalette-item';
|
949 |
|
950 |
|
951 |
|
952 | if (!data.item.isEnabled) {
|
953 | name += ' lm-mod-disabled';
|
954 |
|
955 | name += ' p-mod-disabled';
|
956 |
|
957 | }
|
958 | if (data.item.isToggled) {
|
959 | name += ' lm-mod-toggled';
|
960 |
|
961 | name += ' p-mod-toggled';
|
962 |
|
963 | }
|
964 | if (data.active) {
|
965 | name += ' lm-mod-active';
|
966 |
|
967 | name += ' p-mod-active';
|
968 |
|
969 | }
|
970 |
|
971 |
|
972 | let extra = data.item.className;
|
973 | if (extra) {
|
974 | name += ` ${extra}`;
|
975 | }
|
976 |
|
977 |
|
978 | return name;
|
979 | }
|
980 |
|
981 | |
982 |
|
983 |
|
984 |
|
985 |
|
986 |
|
987 |
|
988 | createItemDataset(data: IItemRenderData): ElementDataset {
|
989 | return { ...data.item.dataset, command: data.item.command };
|
990 | }
|
991 |
|
992 | |
993 |
|
994 |
|
995 |
|
996 |
|
997 |
|
998 |
|
999 | createIconClass(data: IItemRenderData): string {
|
1000 | let name = 'lm-CommandPalette-itemIcon';
|
1001 |
|
1002 | name += ' p-CommandPalette-itemIcon';
|
1003 |
|
1004 | let extra = data.item.iconClass;
|
1005 | return extra ? `${name} ${extra}` : name;
|
1006 | }
|
1007 |
|
1008 | |
1009 |
|
1010 |
|
1011 |
|
1012 |
|
1013 |
|
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 |
|
1024 |
|
1025 |
|
1026 |
|
1027 |
|
1028 |
|
1029 | formatEmptyMessage(data: IEmptyMessageRenderData): h.Child {
|
1030 | return `No commands found that match '${data.query}'`;
|
1031 | }
|
1032 |
|
1033 | |
1034 |
|
1035 |
|
1036 |
|
1037 |
|
1038 |
|
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 |
|
1049 |
|
1050 |
|
1051 |
|
1052 |
|
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 |
|
1063 |
|
1064 |
|
1065 |
|
1066 |
|
1067 |
|
1068 | formatItemCaption(data: IItemRenderData): h.Child {
|
1069 | return data.item.caption;
|
1070 | }
|
1071 | }
|
1072 |
|
1073 | |
1074 |
|
1075 |
|
1076 | export const defaultRenderer = new Renderer();
|
1077 | }
|
1078 |
|
1079 |
|
1080 |
|
1081 |
|
1082 | namespace Private {
|
1083 | |
1084 |
|
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 |
|
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 |
|
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 |
|
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 |
|
1126 |
|
1127 | export interface IHeaderResult {
|
1128 | |
1129 |
|
1130 |
|
1131 | readonly type: 'header';
|
1132 |
|
1133 | |
1134 |
|
1135 |
|
1136 | readonly category: string;
|
1137 |
|
1138 | |
1139 |
|
1140 |
|
1141 | readonly indices: ReadonlyArray<number> | null;
|
1142 | }
|
1143 |
|
1144 | |
1145 |
|
1146 |
|
1147 | export interface IItemResult {
|
1148 | |
1149 |
|
1150 |
|
1151 | readonly type: 'item';
|
1152 |
|
1153 | |
1154 |
|
1155 |
|
1156 | readonly item: CommandPalette.IItem;
|
1157 |
|
1158 | |
1159 |
|
1160 |
|
1161 | readonly indices: ReadonlyArray<number> | null;
|
1162 | }
|
1163 |
|
1164 | |
1165 |
|
1166 |
|
1167 | export type SearchResult = IHeaderResult | IItemResult;
|
1168 |
|
1169 | |
1170 |
|
1171 |
|
1172 | export function search(
|
1173 | items: CommandPalette.IItem[],
|
1174 | query: string
|
1175 | ): SearchResult[] {
|
1176 |
|
1177 | let scores = matchItems(items, query);
|
1178 |
|
1179 |
|
1180 | scores.sort(scoreCmp);
|
1181 |
|
1182 |
|
1183 | return createResults(scores);
|
1184 | }
|
1185 |
|
1186 | |
1187 |
|
1188 |
|
1189 | export function canActivate(result: SearchResult): boolean {
|
1190 | return result.type === 'item' && result.item.isEnabled;
|
1191 | }
|
1192 |
|
1193 | |
1194 |
|
1195 |
|
1196 | function normalizeCategory(category: string): string {
|
1197 | return category.trim().replace(/\s+/g, ' ');
|
1198 | }
|
1199 |
|
1200 | |
1201 |
|
1202 |
|
1203 | function normalizeQuery(text: string): string {
|
1204 | return text.replace(/\s+/g, '').toLowerCase();
|
1205 | }
|
1206 |
|
1207 | |
1208 |
|
1209 |
|
1210 | const enum MatchType {
|
1211 | Label,
|
1212 | Category,
|
1213 | Split,
|
1214 | Default
|
1215 | }
|
1216 |
|
1217 | |
1218 |
|
1219 |
|
1220 | interface IScore {
|
1221 | |
1222 |
|
1223 |
|
1224 | matchType: MatchType;
|
1225 |
|
1226 | |
1227 |
|
1228 |
|
1229 | score: number;
|
1230 |
|
1231 | |
1232 |
|
1233 |
|
1234 | categoryIndices: number[] | null;
|
1235 |
|
1236 | |
1237 |
|
1238 |
|
1239 | labelIndices: number[] | null;
|
1240 |
|
1241 | |
1242 |
|
1243 |
|
1244 | item: CommandPalette.IItem;
|
1245 | }
|
1246 |
|
1247 | |
1248 |
|
1249 |
|
1250 | function matchItems(items: CommandPalette.IItem[], query: string): IScore[] {
|
1251 |
|
1252 | query = normalizeQuery(query);
|
1253 |
|
1254 |
|
1255 | let scores: IScore[] = [];
|
1256 |
|
1257 |
|
1258 | for (let i = 0, n = items.length; i < n; ++i) {
|
1259 |
|
1260 | let item = items[i];
|
1261 | if (!item.isVisible) {
|
1262 | continue;
|
1263 | }
|
1264 |
|
1265 |
|
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 |
|
1278 | let score = fuzzySearch(item, query);
|
1279 |
|
1280 |
|
1281 | if (!score) {
|
1282 | continue;
|
1283 | }
|
1284 |
|
1285 |
|
1286 |
|
1287 | if (!item.isEnabled) {
|
1288 | score.score += 1000;
|
1289 | }
|
1290 |
|
1291 |
|
1292 | scores.push(score);
|
1293 | }
|
1294 |
|
1295 |
|
1296 | return scores;
|
1297 | }
|
1298 |
|
1299 | |
1300 |
|
1301 |
|
1302 | function fuzzySearch(
|
1303 | item: CommandPalette.IItem,
|
1304 | query: string
|
1305 | ): IScore | null {
|
1306 |
|
1307 | let category = item.category.toLowerCase();
|
1308 | let label = item.label.toLowerCase();
|
1309 | let source = `${category} ${label}`;
|
1310 |
|
1311 |
|
1312 | let score = Infinity;
|
1313 | let indices: number[] | null = null;
|
1314 |
|
1315 |
|
1316 | let rgx = /\b\w/g;
|
1317 |
|
1318 |
|
1319 |
|
1320 | while (true) {
|
1321 |
|
1322 | let rgxMatch = rgx.exec(source);
|
1323 |
|
1324 |
|
1325 | if (!rgxMatch) {
|
1326 | break;
|
1327 | }
|
1328 |
|
1329 |
|
1330 | let match = StringExt.matchSumOfDeltas(source, query, rgxMatch.index);
|
1331 |
|
1332 |
|
1333 | if (!match) {
|
1334 | break;
|
1335 | }
|
1336 |
|
1337 |
|
1338 | if (match && match.score <= score) {
|
1339 | score = match.score;
|
1340 | indices = match.indices;
|
1341 | }
|
1342 | }
|
1343 |
|
1344 |
|
1345 | if (!indices || score === Infinity) {
|
1346 | return null;
|
1347 | }
|
1348 |
|
1349 |
|
1350 | let pivot = category.length + 1;
|
1351 |
|
1352 |
|
1353 | let j = ArrayExt.lowerBound(indices, pivot, (a, b) => a - b);
|
1354 |
|
1355 |
|
1356 | let categoryIndices = indices.slice(0, j);
|
1357 | let labelIndices = indices.slice(j);
|
1358 |
|
1359 |
|
1360 | for (let i = 0, n = labelIndices.length; i < n; ++i) {
|
1361 | labelIndices[i] -= pivot;
|
1362 | }
|
1363 |
|
1364 |
|
1365 | if (categoryIndices.length === 0) {
|
1366 | return {
|
1367 | matchType: MatchType.Label,
|
1368 | categoryIndices: null,
|
1369 | labelIndices,
|
1370 | score,
|
1371 | item
|
1372 | };
|
1373 | }
|
1374 |
|
1375 |
|
1376 | if (labelIndices.length === 0) {
|
1377 | return {
|
1378 | matchType: MatchType.Category,
|
1379 | categoryIndices,
|
1380 | labelIndices: null,
|
1381 | score,
|
1382 | item
|
1383 | };
|
1384 | }
|
1385 |
|
1386 |
|
1387 | return {
|
1388 | matchType: MatchType.Split,
|
1389 | categoryIndices,
|
1390 | labelIndices,
|
1391 | score,
|
1392 | item
|
1393 | };
|
1394 | }
|
1395 |
|
1396 | |
1397 |
|
1398 |
|
1399 | function scoreCmp(a: IScore, b: IScore): number {
|
1400 |
|
1401 | let m1 = a.matchType - b.matchType;
|
1402 | if (m1 !== 0) {
|
1403 | return m1;
|
1404 | }
|
1405 |
|
1406 |
|
1407 | let d1 = a.score - b.score;
|
1408 | if (d1 !== 0) {
|
1409 | return d1;
|
1410 | }
|
1411 |
|
1412 |
|
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 |
|
1428 | if (i1 !== i2) {
|
1429 | return i1 - i2;
|
1430 | }
|
1431 |
|
1432 |
|
1433 | let d2 = a.item.category.localeCompare(b.item.category);
|
1434 | if (d2 !== 0) {
|
1435 | return d2;
|
1436 | }
|
1437 |
|
1438 |
|
1439 | let r1 = a.item.rank;
|
1440 | let r2 = b.item.rank;
|
1441 | if (r1 !== r2) {
|
1442 | return r1 < r2 ? -1 : 1;
|
1443 | }
|
1444 |
|
1445 |
|
1446 | return a.item.label.localeCompare(b.item.label);
|
1447 | }
|
1448 |
|
1449 | |
1450 |
|
1451 |
|
1452 | function createResults(scores: IScore[]): SearchResult[] {
|
1453 |
|
1454 | let visited = new Array(scores.length);
|
1455 | ArrayExt.fill(visited, false);
|
1456 |
|
1457 |
|
1458 | let results: SearchResult[] = [];
|
1459 |
|
1460 |
|
1461 | for (let i = 0, n = scores.length; i < n; ++i) {
|
1462 |
|
1463 | if (visited[i]) {
|
1464 | continue;
|
1465 | }
|
1466 |
|
1467 |
|
1468 | let { item, categoryIndices } = scores[i];
|
1469 |
|
1470 |
|
1471 | let category = item.category;
|
1472 |
|
1473 |
|
1474 | results.push({ type: 'header', category, indices: categoryIndices });
|
1475 |
|
1476 |
|
1477 | for (let j = i; j < n; ++j) {
|
1478 |
|
1479 | if (visited[j]) {
|
1480 | continue;
|
1481 | }
|
1482 |
|
1483 |
|
1484 | let { item, labelIndices } = scores[j];
|
1485 |
|
1486 |
|
1487 | if (item.category !== category) {
|
1488 | continue;
|
1489 | }
|
1490 |
|
1491 |
|
1492 | results.push({ type: 'item', item, indices: labelIndices });
|
1493 |
|
1494 |
|
1495 | visited[j] = true;
|
1496 | }
|
1497 | }
|
1498 |
|
1499 |
|
1500 | return results;
|
1501 | }
|
1502 |
|
1503 | |
1504 |
|
1505 |
|
1506 | class CommandItem implements CommandPalette.IItem {
|
1507 | |
1508 |
|
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 |
|
1523 |
|
1524 | readonly category: string;
|
1525 |
|
1526 | |
1527 |
|
1528 |
|
1529 | readonly command: string;
|
1530 |
|
1531 | |
1532 |
|
1533 |
|
1534 | readonly args: ReadonlyJSONObject;
|
1535 |
|
1536 | |
1537 |
|
1538 |
|
1539 | readonly rank: number;
|
1540 |
|
1541 | |
1542 |
|
1543 |
|
1544 | get label(): string {
|
1545 | return this._commands.label(this.command, this.args);
|
1546 | }
|
1547 |
|
1548 | |
1549 |
|
1550 |
|
1551 | get icon():
|
1552 | | VirtualElement.IRenderer
|
1553 | | undefined
|
1554 |
|
1555 | | string {
|
1556 | return this._commands.icon(this.command, this.args);
|
1557 | }
|
1558 |
|
1559 | |
1560 |
|
1561 |
|
1562 | get iconClass(): string {
|
1563 | return this._commands.iconClass(this.command, this.args);
|
1564 | }
|
1565 |
|
1566 | |
1567 |
|
1568 |
|
1569 | get iconLabel(): string {
|
1570 | return this._commands.iconLabel(this.command, this.args);
|
1571 | }
|
1572 |
|
1573 | |
1574 |
|
1575 |
|
1576 | get caption(): string {
|
1577 | return this._commands.caption(this.command, this.args);
|
1578 | }
|
1579 |
|
1580 | |
1581 |
|
1582 |
|
1583 | get className(): string {
|
1584 | return this._commands.className(this.command, this.args);
|
1585 | }
|
1586 |
|
1587 | |
1588 |
|
1589 |
|
1590 | get dataset(): CommandRegistry.Dataset {
|
1591 | return this._commands.dataset(this.command, this.args);
|
1592 | }
|
1593 |
|
1594 | |
1595 |
|
1596 |
|
1597 | get isEnabled(): boolean {
|
1598 | return this._commands.isEnabled(this.command, this.args);
|
1599 | }
|
1600 |
|
1601 | |
1602 |
|
1603 |
|
1604 | get isToggled(): boolean {
|
1605 | return this._commands.isToggled(this.command, this.args);
|
1606 | }
|
1607 |
|
1608 | |
1609 |
|
1610 |
|
1611 | get isToggleable(): boolean {
|
1612 | return this._commands.isToggleable(this.command, this.args);
|
1613 | }
|
1614 |
|
1615 | |
1616 |
|
1617 |
|
1618 | get isVisible(): boolean {
|
1619 | return this._commands.isVisible(this.command, this.args);
|
1620 | }
|
1621 |
|
1622 | |
1623 |
|
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 | }
|