Add hotkey help dialog, auto-meta key for macOS
This commit is contained in:
parent
5bb577ab20
commit
2a818162d7
@ -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()
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
||||||
|
@ -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')) {
|
||||||
|
(macOS <kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
</div>
|
@ -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('⌃ + a') // ⌃ + a
|
||||||
|
expect(component.formatKey('control.a', true)).toEqual('⌘ + a') // ⌘ + a
|
||||||
|
})
|
||||||
|
})
|
@ -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<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(' + ')
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user