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 @@
+
+
+
+
+ @for (key of hotkeys.entries(); track key[0]) {
+
+ {{ key[1] }} |
+
+
+ @if (key[0].includes('control')) {
+ (macOS )
+ }
+ |
+
+ }
+
+
+
+
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
+ }
}