From fcdcdbd5403e9bd21b832fd9a90abb88fc6b5d5b Mon Sep 17 00:00:00 2001 From: JH Date: Mon, 7 Mar 2022 12:34:15 -0800 Subject: [PATCH] feat: support for sequence hotkeys added --- .../hotkeys/src/lib/hotkeys-shortcut.pipe.ts | 8 +- .../hotkeys/src/lib/hotkeys.directive.ts | 11 +- .../ngneat/hotkeys/src/lib/hotkeys.service.ts | 104 +++++++++++++++++- src/app/app.component.html | 11 ++ src/app/app.component.ts | 13 +++ 5 files changed, 138 insertions(+), 9 deletions(-) diff --git a/projects/ngneat/hotkeys/src/lib/hotkeys-shortcut.pipe.ts b/projects/ngneat/hotkeys/src/lib/hotkeys-shortcut.pipe.ts index fb4e9fd..9e3e9a6 100644 --- a/projects/ngneat/hotkeys/src/lib/hotkeys-shortcut.pipe.ts +++ b/projects/ngneat/hotkeys/src/lib/hotkeys-shortcut.pipe.ts @@ -1,4 +1,5 @@ import { Pipe, PipeTransform } from '@angular/core'; + import { hostPlatform } from './utils/platform'; const symbols = { @@ -36,15 +37,18 @@ export class HotkeysShortcutPipe implements PipeTransform { this.symbols = this.getPlatformSymbols(platform); } - transform(value: string, separator = ' + '): any { + transform(value: string, dotSeparator = ' + ', thenSeparator = ' then '): any { if (!value) { return ''; } return value + .split('>') + .join(thenSeparator) .split('.') .map(c => c.toLowerCase()) .map(c => this.symbols[c] || c) - .join(separator); + .join(dotSeparator) + .split('>'); } private getPlatformSymbols(platform): any { diff --git a/projects/ngneat/hotkeys/src/lib/hotkeys.directive.ts b/projects/ngneat/hotkeys/src/lib/hotkeys.directive.ts index b0603a8..4c2e721 100644 --- a/projects/ngneat/hotkeys/src/lib/hotkeys.directive.ts +++ b/projects/ngneat/hotkeys/src/lib/hotkeys.directive.ts @@ -21,12 +21,13 @@ export class HotkeysDirective implements OnChanges, OnDestroy { constructor(private hotkeysService: HotkeysService, private elementRef: ElementRef) {} @Input() hotkeys: string; + @Input() isSequence: boolean = false; @Input() hotkeysGroup: string; @Input() hotkeysOptions: Partial = {}; @Input() hotkeysDescription: string; @Output() - hotkey = new EventEmitter(); + hotkey = new EventEmitter(); ngOnChanges(changes: SimpleChanges): void { this.deleteHotkeys(); @@ -58,9 +59,11 @@ export class HotkeysDirective implements OnChanges, OnDestroy { private setHotkeys(hotkeys: Hotkey | Hotkey[]) { const coercedHotkeys = coerceArray(hotkeys); this.subscription = merge( - coercedHotkeys.map(hotkey => - this.hotkeysService.addShortcut({ ...hotkey, element: this.elementRef.nativeElement }) - ) + coercedHotkeys.map(hotkey => { + return this.isSequence + ? this.hotkeysService.addSequenceShortcut({ ...hotkey, element: this.elementRef.nativeElement }) + : this.hotkeysService.addShortcut({ ...hotkey, element: this.elementRef.nativeElement }); + }) ) .pipe(mergeAll()) .subscribe(e => this.hotkey.next(e)); diff --git a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts index ad7d2b9..d519ad9 100644 --- a/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts +++ b/projects/ngneat/hotkeys/src/lib/hotkeys.service.ts @@ -1,11 +1,11 @@ import { DOCUMENT } from '@angular/common'; import { Inject, Injectable } from '@angular/core'; import { EventManager } from '@angular/platform-browser'; -import { Observable, of, Subject } from 'rxjs'; +import { EMPTY, fromEvent, Observable, of, Subject, Subscription } from 'rxjs'; +import { debounceTime, filter, mergeMap, takeUntil, tap } from 'rxjs/operators'; -import { hostPlatform, normalizeKeys } from './utils/platform'; import { coerceArray } from './utils/array'; -import { filter, takeUntil } from 'rxjs/operators'; +import { hostPlatform, normalizeKeys } from './utils/platform'; export type AllowInElement = 'INPUT' | 'TEXTAREA' | 'SELECT'; interface Options { @@ -26,6 +26,17 @@ export interface HotkeyGroup { export type Hotkey = Partial & { keys: string }; export type HotkeyCallback = (event: KeyboardEvent, keys: string, target: HTMLElement) => void; +interface HotkeySummary { + hotkey: Hotkey; + subject: Subject; +} + +interface SequenceSummary { + subscription: Subscription; + observer: Observable; + hotkeyMap: Map; +} + @Injectable({ providedIn: 'root' }) export class HotkeysService { private readonly hotkeys = new Map(); @@ -40,6 +51,8 @@ export class HotkeysService { preventDefault: true }; private callbacks: HotkeyCallback[] = []; + private sequenceMaps = new Map(); + private sequenceDebounce: number = 250; constructor(private eventManager: EventManager, @Inject(DOCUMENT) private document) {} @@ -50,6 +63,10 @@ export class HotkeysService { getShortcuts(): HotkeyGroup[] { const hotkeys = Array.from(this.hotkeys.values()); const groups: HotkeyGroup[] = []; + const sequenceKeys = Array.from(this.sequenceMaps.values()) + .map(s => [s.hotkeyMap].reduce((_acc, val) => [...val.values()], [])) + .reduce((_x, y) => y, []) + .map(h => h.hotkey); for (const hotkey of hotkeys) { if (!hotkey.showInHelpMenu) { @@ -66,9 +83,79 @@ export class HotkeysService { group.hotkeys.push({ keys: normalizedKeys, description: hotkey.description }); } + for (const hotkey of sequenceKeys) { + if (!hotkey.showInHelpMenu) { + continue; + } + + let group = groups.find(g => g.group === hotkey.group); + if (!group) { + group = { group: hotkey.group, hotkeys: [] }; + groups.push(group); + } + + const normalizedKeys = normalizeKeys(hotkey.keys, hostPlatform()); + group.hotkeys.push({ keys: normalizedKeys, description: hotkey.description }); + } + return groups; } + addSequenceShortcut(options: Hotkey): Observable { + const getObserver = (element: HTMLElement, eventName: string) => { + let sequence = ''; + return fromEvent(element, eventName).pipe( + tap(e => (sequence = `${sequence}${sequence ? '>' : ''}${e.key}`)), + debounceTime(this.sequenceDebounce), + mergeMap(() => { + const resultSequence = sequence; + sequence = ''; + const summary = this.sequenceMaps.get(element); + if (summary.hotkeyMap.has(resultSequence)) { + const hotkeySummary = summary.hotkeyMap.get(resultSequence); + hotkeySummary.subject.next(hotkeySummary.hotkey); + return of(hotkeySummary.hotkey); + } else { + return EMPTY; + } + }) + ); + }; + + const mergedOptions = { ...this.defaults, ...options }; + let normalizedKeys = normalizeKeys(mergedOptions.keys, hostPlatform()); + + if (this.sequenceMaps.has(mergedOptions.element)) { + const sequenceSummary = this.sequenceMaps.get(mergedOptions.element); + + if (sequenceSummary.hotkeyMap.has(normalizedKeys)) { + console.error('Duplicated shortcut'); + return of(null); + } + + const hotkeySummary = { + subject: new Subject(), + hotkey: mergedOptions + }; + + sequenceSummary.hotkeyMap.set(normalizedKeys, hotkeySummary); + return hotkeySummary.subject.asObservable(); + } else { + const observer = getObserver(mergedOptions.element, mergedOptions.trigger); + const subscription = observer.subscribe(); + + const hotkeySummary = { + subject: new Subject(), + hotkey: mergedOptions + }; + const hotkeyMap = new Map([[normalizedKeys, hotkeySummary]]); + const sequenceSummary = { subscription, observer, hotkeyMap }; + this.sequenceMaps.set(mergedOptions.element, sequenceSummary); + + return hotkeySummary.subject.asObservable(); + } + } + addShortcut(options: Hotkey): Observable { const mergedOptions = { ...this.defaults, ...options }; const normalizedKeys = normalizeKeys(mergedOptions.keys, hostPlatform()); @@ -112,9 +199,20 @@ export class HotkeysService { coercedHotkeys.forEach(hotkey => { this.hotkeys.delete(hotkey); this.dispose.next(hotkey); + + this.sequenceMaps.forEach(v => { + v.hotkeyMap.delete(hotkey); + if (v.hotkeyMap.size === 0) { + v.subscription.unsubscribe(); + } + }); }); } + setSequenceDebounce(debounce: number): void { + this.sequenceDebounce = debounce; + } + onShortcut(callback: HotkeyCallback): () => void { this.callbacks.push(callback); diff --git a/src/app/app.component.html b/src/app/app.component.html index aee3032..ce07a61 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -16,4 +16,15 @@ hotkeysGroup="Source code browsing" [hotkeysOptions]="{ allowIn: ['input'] }" /> + + diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f851cfd..fe25edd 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -25,6 +25,19 @@ export class AppComponent implements AfterViewInit { this.hotkeys.registerHelpModal(helpFcn); + this.hotkeys + .addSequenceShortcut({ + keys: 'g>t', + description: 'In Code Test', + preventDefault: false, + group: 'Sequencing' + }) + .subscribe(e => { + console.log('Test Sequence:', e); + + this.hotkeys.removeShortcuts('g>t'); + }); + this.hotkeys .addShortcut({ keys: 'meta.g',