From 9d28183e76ccb7f2e830f9c10d8244299f358177 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 4 Apr 2024 23:31:16 -0700 Subject: [PATCH] Add some app keyboard shortcuts --- src-ui/src/app/app.component.spec.ts | 14 +++++++ src-ui/src/app/app.component.ts | 15 ++++++- .../hotkey-dialog/hotkey-dialog.component.ts | 4 ++ .../document-detail.component.spec.ts | 39 +++++++++++++++++-- .../document-detail.component.ts | 38 +++++++++++++++++- .../src/app/services/hot-key.service.spec.ts | 16 ++++++++ src-ui/src/app/services/hot-key.service.ts | 7 +++- .../services/open-documents.service.spec.ts | 1 + .../app/services/open-documents.service.ts | 4 ++ 9 files changed, 132 insertions(+), 6 deletions(-) diff --git a/src-ui/src/app/app.component.spec.ts b/src-ui/src/app/app.component.spec.ts index 8dbd43d67..75f84d410 100644 --- a/src-ui/src/app/app.component.spec.ts +++ b/src-ui/src/app/app.component.spec.ts @@ -22,6 +22,7 @@ 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' +import { HotKeyService } from './services/hot-key.service' describe('AppComponent', () => { let component: AppComponent @@ -32,6 +33,7 @@ describe('AppComponent', () => { let toastService: ToastService let router: Router let settingsService: SettingsService + let hotKeyService: HotKeyService beforeEach(async () => { TestBed.configureTestingModule({ @@ -52,6 +54,7 @@ describe('AppComponent', () => { settingsService = TestBed.inject(SettingsService) toastService = TestBed.inject(ToastService) router = TestBed.inject(Router) + hotKeyService = TestBed.inject(HotKeyService) fixture = TestBed.createComponent(AppComponent) component = fixture.componentInstance }) @@ -141,4 +144,15 @@ describe('AppComponent', () => { fileStatusSubject.next(new FileStatus()) expect(toastSpy).toHaveBeenCalled() }) + + it('should support hotkeys', () => { + const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut') + const routerSpy = jest.spyOn(router, 'navigate') + component.ngOnInit() + expect(addShortcutSpy).toHaveBeenCalled() + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'h', ctrlKey: true }) + ) + expect(routerSpy).toHaveBeenCalledWith(['/dashboard']) + }) }) diff --git a/src-ui/src/app/app.component.ts b/src-ui/src/app/app.component.ts index e93fde30c..bc18c8f31 100644 --- a/src-ui/src/app/app.component.ts +++ b/src-ui/src/app/app.component.ts @@ -12,6 +12,7 @@ import { PermissionsService, PermissionType, } from './services/permissions.service' +import { HotKeyService } from './services/hot-key.service' @Component({ selector: 'pngx-root', @@ -31,7 +32,8 @@ export class AppComponent implements OnInit, OnDestroy { private tasksService: TasksService, public tourService: TourService, private renderer: Renderer2, - private permissionsService: PermissionsService + private permissionsService: PermissionsService, + private hotKeyService: HotKeyService ) { this.settings.updateAppearanceSettings() } @@ -123,6 +125,17 @@ export class AppComponent implements OnInit, OnDestroy { } }) + this.hotKeyService + .addShortcut({ keys: 'control.h', description: $localize`Dashboard` }) + .subscribe(() => { + this.router.navigate(['/dashboard']) + }) + this.hotKeyService + .addShortcut({ keys: 'control.d', description: $localize`Documents` }) + .subscribe(() => { + this.router.navigate(['/documents']) + }) + const prevBtnTitle = $localize`Prev` const nextBtnTitle = $localize`Next` const endBtnTitle = $localize`End` 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 index 53eaba6fd..f89d5be51 100644 --- 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 @@ -5,6 +5,10 @@ const SYMBOLS = { meta: '⌘', // ⌘ control: '⌃', // ⌃ shift: '⇧', // ⇧ + left: '←', // ← + right: '→', // → + up: '↑', // ↑ + down: '↓', // ↓ } @Component({ diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index b26ad9024..facced036 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -12,8 +12,12 @@ import { } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { By } from '@angular/platform-browser' -import { Router, ActivatedRoute, convertToParamMap } from '@angular/router' -import { RouterTestingModule } from '@angular/router/testing' +import { + Router, + ActivatedRoute, + convertToParamMap, + RouterModule, +} from '@angular/router' import { NgbModal, NgbModule, @@ -253,7 +257,7 @@ describe('DocumentDetailComponent', () => { DatePipe, ], imports: [ - RouterTestingModule.withRoutes(routes), + RouterModule.forRoot(routes), HttpClientTestingModule, NgbModule, NgSelectModule, @@ -1126,6 +1130,35 @@ describe('DocumentDetailComponent', () => { req.flush(true) }) + it('should support keyboard shortcuts', () => { + initNormally() + + jest.spyOn(component, 'hasNext').mockReturnValue(true) + const nextSpy = jest.spyOn(component, 'nextDoc') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true }) + ) + expect(nextSpy).toHaveBeenCalled() + + jest.spyOn(component, 'hasPrevious').mockReturnValue(true) + const prevSpy = jest.spyOn(component, 'previousDoc') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true }) + ) + expect(prevSpy).toHaveBeenCalled() + + jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true) + const saveSpy = jest.spyOn(component, 'save') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 's', ctrlKey: true }) + ) + expect(saveSpy).toHaveBeenCalled() + + const closeSpy = jest.spyOn(component, 'close') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' })) + expect(closeSpy).toHaveBeenCalled() + }) + function initNormally() { jest .spyOn(activatedRoute, 'paramMap', 'get') diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index db0d16f5a..115d8ac52 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -69,6 +69,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service import { PDFDocumentProxy } from '../common/pdf-viewer/typings' import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' +import { HotKeyService } from 'src/app/services/hot-key.service' enum DocumentDetailNavIDs { Details = 1, @@ -201,7 +202,8 @@ export class DocumentDetailComponent private permissionsService: PermissionsService, private userService: UserService, private customFieldsService: CustomFieldsService, - private http: HttpClient + private http: HttpClient, + private hotKeyService: HotKeyService ) { super() } @@ -455,6 +457,40 @@ export class DocumentDetailComponent }) } }) + + this.hotKeyService + .addShortcut({ + keys: 'control.arrowright', + description: $localize`Next document`, + }) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + if (this.hasNext()) this.nextDoc() + }) + + this.hotKeyService + .addShortcut({ + keys: 'control.arrowleft', + description: $localize`Previous document`, + }) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + if (this.hasPrevious()) this.previousDoc() + }) + + this.hotKeyService + .addShortcut({ keys: 'escape', description: $localize`Close document` }) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + this.close() + }) + + this.hotKeyService + .addShortcut({ keys: 'control.s', description: $localize`Save document` }) + .pipe(takeUntil(this.unsubscribeNotifier)) + .subscribe(() => { + if (this.openDocumentService.isDirty(this.document)) this.save() + }) } ngOnDestroy(): void { 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 bd7787e3d..0ac683992 100644 --- a/src-ui/src/app/services/hot-key.service.spec.ts +++ b/src-ui/src/app/services/hot-key.service.spec.ts @@ -68,4 +68,20 @@ describe('HotKeyService', () => { document.dispatchEvent(event) expect(modalSpy).not.toHaveBeenCalled() }) + + it('should dismiss all modals on escape but not fire event', () => { + const callback = jest.fn() + service + .addShortcut({ keys: 'escape', description: 'Escape' }) + .subscribe(callback) + const modalSpy = jest.spyOn(modalService, 'open') + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '?', shiftKey: true }) + ) + expect(modalSpy).toHaveBeenCalled() + const dismissAllSpy = jest.spyOn(modalService, 'dismissAll') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + expect(dismissAllSpy).toHaveBeenCalled() + expect(callback).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 493d52d73..84dd16758 100644 --- a/src-ui/src/app/services/hot-key.service.ts +++ b/src-ui/src/app/services/hot-key.service.ts @@ -53,8 +53,13 @@ export class HotKeyService { return } - e.preventDefault() this.modalService.dismissAll() + if (e.key === 'Escape' && this.modalService.hasOpenModals()) { + // If there is a modal open, just dismiss + return + } + + e.preventDefault() observer.next(e) } diff --git a/src-ui/src/app/services/open-documents.service.spec.ts b/src-ui/src/app/services/open-documents.service.spec.ts index 09341da62..21d5d91a8 100644 --- a/src-ui/src/app/services/open-documents.service.spec.ts +++ b/src-ui/src/app/services/open-documents.service.spec.ts @@ -135,6 +135,7 @@ describe('OpenDocumentsService', () => { expect(openDocumentsService.hasDirty()).toBeFalsy() openDocumentsService.setDirty(documents[0], true) expect(openDocumentsService.hasDirty()).toBeTruthy() + expect(openDocumentsService.isDirty(documents[0])).toBeTruthy() let openModal modalService.activeInstances.subscribe((instances) => { openModal = instances[0] diff --git a/src-ui/src/app/services/open-documents.service.ts b/src-ui/src/app/services/open-documents.service.ts index 363a51b03..33e98ce12 100644 --- a/src-ui/src/app/services/open-documents.service.ts +++ b/src-ui/src/app/services/open-documents.service.ts @@ -90,6 +90,10 @@ export class OpenDocumentsService { return this.dirtyDocuments.size > 0 } + isDirty(doc: Document): boolean { + return this.dirtyDocuments.has(doc.id) + } + closeDocument(doc: Document): Observable { let index = this.openDocuments.findIndex((d) => d.id == doc.id) if (index == -1) return of(true)