Add hotkey help dialog, auto-meta key for macOS

This commit is contained in:
shamoon 2024-04-04 22:04:20 -07:00
parent 5bb577ab20
commit 2a818162d7
10 changed files with 182 additions and 15 deletions

View File

@ -21,6 +21,7 @@ import { ToastService, Toast } from './services/toast.service'
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { FileDropComponent } from './components/file-drop/file-drop.component' import { FileDropComponent } from './components/file-drop/file-drop.component'
import { NgxFileDropModule } from 'ngx-file-drop' import { NgxFileDropModule } from 'ngx-file-drop'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
describe('AppComponent', () => { describe('AppComponent', () => {
let component: AppComponent let component: AppComponent
@ -41,6 +42,7 @@ describe('AppComponent', () => {
TourNgBootstrapModule, TourNgBootstrapModule,
RouterTestingModule.withRoutes(routes), RouterTestingModule.withRoutes(routes),
NgxFileDropModule, NgxFileDropModule,
NgbModalModule,
], ],
}).compileComponents() }).compileComponents()

View File

@ -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 { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { DocumentHistoryComponent } from './components/document-history/document-history.component' import { DocumentHistoryComponent } from './components/document-history/document-history.component'
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component' import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
import { import {
airplane, airplane,
archive, archive,
@ -482,6 +483,7 @@ function initializeApp(settings: SettingsService) {
SplitConfirmDialogComponent, SplitConfirmDialogComponent,
DocumentHistoryComponent, DocumentHistoryComponent,
GlobalSearchComponent, GlobalSearchComponent,
HotkeyDialogComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -7,7 +7,6 @@
autocomplete="off" spellcheck="false" autocomplete="off" spellcheck="false"
[(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)" ngbDropdownAnchor> [(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)" ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle"> <div class="position-absolute top-50 end-0 translate-middle">
<span class="badge d-none d-lg-inline text-muted position-absolute top-50 start-100 translate-middle ms-n4 fw-normal">⌘K</span>
@if (loading) { @if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div> <div class="spinner-border spinner-border-sm text-muted mt-1"></div>
} }

View File

@ -87,12 +87,11 @@ export class GlobalSearchComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
this.hotkeyService.addShortcut({ keys: 'meta.k' }).subscribe(() => { this.hotkeyService
this.searchInput.nativeElement.focus() .addShortcut({ keys: 'control.k', description: $localize`Global search` })
}) .subscribe(() => {
this.hotkeyService.addShortcut({ keys: 'control.k' }).subscribe(() => { this.searchInput.nativeElement.focus()
this.searchInput.nativeElement.focus() })
})
} }
private search(query: string) { private search(query: string) {

View File

@ -0,0 +1,23 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<table class="table">
<tbody>
@for (key of hotkeys.entries(); track key[0]) {
<tr>
<td>{{ key[1] }}</td>
<td class="d-flex justify-content-end">
<kbd [innerHTML]="formatKey(key[0])"></kbd>
@if (key[0].includes('control')) {
&nbsp;(macOS&nbsp;<kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="modal-footer">
</div>

View File

@ -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<HotkeyDialogComponent>
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('&#8963; + a') // ⌃ + a
expect(component.formatKey('control.a', true)).toEqual('&#8984; + a') // ⌘ + a
})
})

View File

@ -0,0 +1,34 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
const SYMBOLS = {
meta: '&#8984;', // ⌘
control: '&#8963;', // ⌃
shift: '&#8679;', // ⇧
}
@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<string, string> = 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(' + ')
}
}

View File

@ -3,19 +3,23 @@ import { EventManager } from '@angular/platform-browser'
import { DOCUMENT } from '@angular/common' import { DOCUMENT } from '@angular/common'
import { HotKeyService } from './hot-key.service' import { HotKeyService } from './hot-key.service'
import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
describe('HotKeyService', () => { describe('HotKeyService', () => {
let service: HotKeyService let service: HotKeyService
let eventManager: EventManager let eventManager: EventManager
let document: Document let document: Document
let modalService: NgbModal
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [HotKeyService, EventManager], providers: [HotKeyService, EventManager],
imports: [NgbModalModule],
}) })
service = TestBed.inject(HotKeyService) service = TestBed.inject(HotKeyService)
eventManager = TestBed.inject(EventManager) eventManager = TestBed.inject(EventManager)
document = TestBed.inject(DOCUMENT) document = TestBed.inject(DOCUMENT)
modalService = TestBed.inject(NgbModal)
}) })
it('should support adding a shortcut', () => { it('should support adding a shortcut', () => {
@ -38,4 +42,30 @@ describe('HotKeyService', () => {
//coverage //coverage
observable.unsubscribe() 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()
})
}) })

View File

@ -1,45 +1,88 @@
import { DOCUMENT } from '@angular/common' import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core' import { Inject, Injectable } from '@angular/core'
import { EventManager } from '@angular/platform-browser' import { EventManager } from '@angular/platform-browser'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Observable } from 'rxjs' import { Observable } from 'rxjs'
import { HotkeyDialogComponent } from '../components/common/hotkey-dialog/hotkey-dialog.component'
export interface ShortcutOptions { export interface ShortcutOptions {
element?: any element?: any
keys: string keys: string
description?: string
} }
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class HotKeyService { export class HotKeyService {
defaults: Partial<ShortcutOptions> = { private defaults: Partial<ShortcutOptions> = {
element: this.document, element: this.document,
} }
private hotkeys: Map<string, string> = new Map()
constructor( constructor(
private eventManager: EventManager, 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) { public addShortcut(options: ShortcutOptions) {
const merged = { ...this.defaults, ...options } const optionsWithDefaults = { ...this.defaults, ...options }
const event = `keydown.${merged.keys}` const event = `keydown.${optionsWithDefaults.keys}`
if (optionsWithDefaults.description) {
this.hotkeys.set(
optionsWithDefaults.keys,
optionsWithDefaults.description
)
}
return new Observable((observer) => { 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() e.preventDefault()
this.modalService.dismissAll()
observer.next(e) observer.next(e)
} }
const dispose = this.eventManager.addEventListener( const dispose = this.eventManager.addEventListener(
merged.element, optionsWithDefaults.element,
event, event,
handler handler
) )
let disposeMeta
if (event.includes('control')) {
disposeMeta = this.eventManager.addEventListener(
optionsWithDefaults.element,
event.replace('control', 'meta'),
handler
)
}
return () => { return () => {
dispose() dispose()
if (disposeMeta) disposeMeta()
this.hotkeys.delete(optionsWithDefaults.keys)
} }
}) })
} }
private openHelpModal() {
const modal = this.modalService.open(HotkeyDialogComponent)
modal.componentInstance.hotkeys = this.hotkeys
}
} }