From 2a818162d7f7eda6d38abac80ea9cc908011a748 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:04:20 -0700 Subject: [PATCH] Add hotkey help dialog, auto-meta key for macOS --- src-ui/src/app/app.component.spec.ts | 2 + src-ui/src/app/app.module.ts | 2 + .../global-search.component.html | 1 - .../global-search/global-search.component.ts | 11 ++-- .../hotkey-dialog.component.html | 23 ++++++++ .../hotkey-dialog.component.scss | 0 .../hotkey-dialog.component.spec.ts | 35 +++++++++++ .../hotkey-dialog/hotkey-dialog.component.ts | 34 +++++++++++ .../src/app/services/hot-key.service.spec.ts | 30 ++++++++++ src-ui/src/app/services/hot-key.service.ts | 59 ++++++++++++++++--- 10 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html create mode 100644 src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss create mode 100644 src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts create mode 100644 src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 80fbdfa5f..8dbd43d67 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -21,6 +21,7 @@ import { ToastService, Toast } from './services/toast.service' import { SettingsService } from './services/settings.service' import { FileDropComponent } from './components/file-drop/file-drop.component' import { NgxFileDropModule } from 'ngx-file-drop' +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap' describe('AppComponent', () => { let component: AppComponent @@ -41,6 +42,7 @@ describe('AppComponent', () => { TourNgBootstrapModule, RouterTestingModule.withRoutes(routes), NgxFileDropModule, + NgbModalModule, ], }).compileComponents() diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 849312aea..e5b7bb497 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -121,6 +121,7 @@ import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/ import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' import { DocumentHistoryComponent } from './components/document-history/document-history.component' import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component' +import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component' import { airplane, archive, @@ -482,6 +483,7 @@ function initializeApp(settings: SettingsService) { SplitConfirmDialogComponent, DocumentHistoryComponent, GlobalSearchComponent, + HotkeyDialogComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.html b/src-ui/src/app/components/app-frame/global-search/global-search.component.html index 84cade906..93a704d62 100644 --- a/src-ui/src/app/components/app-frame/global-search/global-search.component.html +++ b/src-ui/src/app/components/app-frame/global-search/global-search.component.html @@ -7,7 +7,6 @@ autocomplete="off" spellcheck="false" [(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)" ngbDropdownAnchor>
- ⌘K @if (loading) {
} diff --git a/src-ui/src/app/components/app-frame/global-search/global-search.component.ts b/src-ui/src/app/components/app-frame/global-search/global-search.component.ts index f7d87d8e8..213243056 100644 --- a/src-ui/src/app/components/app-frame/global-search/global-search.component.ts +++ b/src-ui/src/app/components/app-frame/global-search/global-search.component.ts @@ -87,12 +87,11 @@ export class GlobalSearchComponent implements OnInit { } ngOnInit() { - this.hotkeyService.addShortcut({ keys: 'meta.k' }).subscribe(() => { - this.searchInput.nativeElement.focus() - }) - this.hotkeyService.addShortcut({ keys: 'control.k' }).subscribe(() => { - this.searchInput.nativeElement.focus() - }) + this.hotkeyService + .addShortcut({ keys: 'control.k', description: $localize`Global search` }) + .subscribe(() => { + this.searchInput.nativeElement.focus() + }) } private search(query: string) { diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html new file mode 100644 index 000000000..c98a96ee0 --- /dev/null +++ b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.html @@ -0,0 +1,23 @@ + + + diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts new file mode 100644 index 000000000..a47e51692 --- /dev/null +++ b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.spec.ts @@ -0,0 +1,35 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' + +import { HotkeyDialogComponent } from './hotkey-dialog.component' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' + +describe('HotkeyDialogComponent', () => { + let component: HotkeyDialogComponent + let fixture: ComponentFixture + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [HotkeyDialogComponent], + providers: [NgbActiveModal], + }).compileComponents() + + fixture = TestBed.createComponent(HotkeyDialogComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should support close', () => { + const closeSpy = jest.spyOn(component.activeModal, 'close') + component.close() + expect(closeSpy).toHaveBeenCalled() + }) + + it('should format keys', () => { + expect(component.formatKey('control.a')).toEqual('⌃ + a') // ⌃ + a + expect(component.formatKey('control.a', true)).toEqual('⌘ + a') // ⌘ + a + }) +}) diff --git a/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts new file mode 100644 index 000000000..53eaba6fd --- /dev/null +++ b/src-ui/src/app/components/common/hotkey-dialog/hotkey-dialog.component.ts @@ -0,0 +1,34 @@ +import { Component } from '@angular/core' +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' + +const SYMBOLS = { + meta: '⌘', // ⌘ + control: '⌃', // ⌃ + shift: '⇧', // ⇧ +} + +@Component({ + selector: 'pngx-hotkey-dialog', + templateUrl: './hotkey-dialog.component.html', + styleUrl: './hotkey-dialog.component.scss', +}) +export class HotkeyDialogComponent { + public title: string = $localize`Keyboard shortcuts` + public hotkeys: Map = new Map() + + constructor(public activeModal: NgbActiveModal) {} + + public close(): void { + this.activeModal.close() + } + + public formatKey(key: string, macOS: boolean = false): string { + if (macOS) { + key = key.replace('control', 'meta') + } + return key + .split('.') + .map((k) => SYMBOLS[k] || k) + .join(' + ') + } +} diff --git a/src-ui/src/app/services/hot-key.service.spec.ts b/src-ui/src/app/services/hot-key.service.spec.ts index 0162d860e..bd7787e3d 100644 --- a/src-ui/src/app/services/hot-key.service.spec.ts +++ b/src-ui/src/app/services/hot-key.service.spec.ts @@ -3,19 +3,23 @@ import { EventManager } from '@angular/platform-browser' import { DOCUMENT } from '@angular/common' import { HotKeyService } from './hot-key.service' +import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap' describe('HotKeyService', () => { let service: HotKeyService let eventManager: EventManager let document: Document + let modalService: NgbModal beforeEach(() => { TestBed.configureTestingModule({ providers: [HotKeyService, EventManager], + imports: [NgbModalModule], }) service = TestBed.inject(HotKeyService) eventManager = TestBed.inject(EventManager) document = TestBed.inject(DOCUMENT) + modalService = TestBed.inject(NgbModal) }) it('should support adding a shortcut', () => { @@ -38,4 +42,30 @@ describe('HotKeyService', () => { //coverage observable.unsubscribe() }) + + it('should support adding a shortcut with a description, show modal', () => { + const addEventListenerSpy = jest.spyOn(eventManager, 'addEventListener') + service + .addShortcut({ keys: 'control.a', description: 'Select all' }) + .subscribe() + expect(addEventListenerSpy).toHaveBeenCalled() + const modalSpy = jest.spyOn(modalService, 'open') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '?', shiftKey: true }) + ) + expect(modalSpy).toHaveBeenCalled() + }) + + it('should ignore keydown events from input elements that dont have a modifier key', () => { + // constructor adds a shortcut for shift.? + const modalSpy = jest.spyOn(modalService, 'open') + const input = document.createElement('input') + const textArea = document.createElement('textarea') + const event = new KeyboardEvent('keydown', { key: '?', shiftKey: true }) + jest.spyOn(event, 'target', 'get').mockReturnValue(input) + document.dispatchEvent(event) + jest.spyOn(event, 'target', 'get').mockReturnValue(textArea) + document.dispatchEvent(event) + expect(modalSpy).not.toHaveBeenCalled() + }) }) diff --git a/src-ui/src/app/services/hot-key.service.ts b/src-ui/src/app/services/hot-key.service.ts index a08556b43..493d52d73 100644 --- a/src-ui/src/app/services/hot-key.service.ts +++ b/src-ui/src/app/services/hot-key.service.ts @@ -1,45 +1,88 @@ import { DOCUMENT } from '@angular/common' import { Inject, Injectable } from '@angular/core' import { EventManager } from '@angular/platform-browser' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { Observable } from 'rxjs' +import { HotkeyDialogComponent } from '../components/common/hotkey-dialog/hotkey-dialog.component' export interface ShortcutOptions { element?: any keys: string + description?: string } @Injectable({ providedIn: 'root', }) export class HotKeyService { - defaults: Partial = { + private defaults: Partial = { element: this.document, } + private hotkeys: Map = new Map() + constructor( private eventManager: EventManager, - @Inject(DOCUMENT) private document: Document - ) {} + @Inject(DOCUMENT) private document: Document, + private modalService: NgbModal + ) { + this.addShortcut({ keys: 'shift.?' }).subscribe(() => { + this.openHelpModal() + }) + } - addShortcut(options: ShortcutOptions) { - const merged = { ...this.defaults, ...options } - const event = `keydown.${merged.keys}` + public addShortcut(options: ShortcutOptions) { + const optionsWithDefaults = { ...this.defaults, ...options } + const event = `keydown.${optionsWithDefaults.keys}` + + if (optionsWithDefaults.description) { + this.hotkeys.set( + optionsWithDefaults.keys, + optionsWithDefaults.description + ) + } return new Observable((observer) => { - const handler = (e) => { + const handler = (e: KeyboardEvent) => { + if ( + !(e.altKey || e.metaKey || e.ctrlKey) && + (e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement) + ) { + // Ignore keydown events from input elements that dont have a modifier key + return + } + e.preventDefault() + this.modalService.dismissAll() observer.next(e) } const dispose = this.eventManager.addEventListener( - merged.element, + optionsWithDefaults.element, event, handler ) + let disposeMeta + if (event.includes('control')) { + disposeMeta = this.eventManager.addEventListener( + optionsWithDefaults.element, + event.replace('control', 'meta'), + handler + ) + } + return () => { dispose() + if (disposeMeta) disposeMeta() + this.hotkeys.delete(optionsWithDefaults.keys) } }) } + + private openHelpModal() { + const modal = this.modalService.open(HotkeyDialogComponent) + modal.componentInstance.hotkeys = this.hotkeys + } }