Feature: global search, keyboard shortcuts / hotkey support (#6449)

This commit is contained in:
shamoon
2024-05-02 09:15:56 -07:00
committed by GitHub
parent 40289cd714
commit c6e7d06bb7
51 changed files with 2970 additions and 683 deletions

View File

@@ -26,6 +26,7 @@ import { TagComponent } from '../tag/tag.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { HotKeyService } from 'src/app/services/hot-key.service'
const items: Tag[] = [
{
@@ -53,6 +54,7 @@ let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
let fixture: ComponentFixture<FilterableDropdownComponent>
let hotkeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -72,6 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
],
}).compileComponents()
hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
selectionModel = new FilterableDropdownSelectionModel()
@@ -577,4 +580,14 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
})
it('should support shortcut keys', () => {
component.items = items
component.icon = 'tag-fill'
component.shortcutKey = 't'
fixture.detectChanges()
const openSpy = jest.spyOn(component.dropdown, 'open')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
expect(openSpy).toHaveBeenCalled()
})
})

View File

@@ -5,14 +5,17 @@ import {
Output,
ElementRef,
ViewChild,
OnInit,
OnDestroy,
} from '@angular/core'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
import { MatchingModel } from 'src/app/data/matching-model'
import { Subject } from 'rxjs'
import { Subject, filter, take, takeUntil } from 'rxjs'
import { SelectionDataItem } from 'src/app/services/rest/document.service'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { HotKeyService } from 'src/app/services/hot-key.service'
export interface ChangedItems {
itemsToAdd: MatchingModel[]
@@ -322,7 +325,7 @@ export class FilterableDropdownSelectionModel {
templateUrl: './filterable-dropdown.component.html',
styleUrls: ['./filterable-dropdown.component.scss'],
})
export class FilterableDropdownComponent {
export class FilterableDropdownComponent implements OnDestroy, OnInit {
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChild('dropdown') dropdown: NgbDropdown
@ViewChild('buttonItems') buttonItems: ElementRef
@@ -419,6 +422,9 @@ export class FilterableDropdownComponent {
@Input()
documentCounts: SelectionDataItem[]
@Input()
shortcutKey: string
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@@ -427,12 +433,39 @@ export class FilterableDropdownComponent {
private keyboardIndex: number
constructor(private filterPipe: FilterPipe) {
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private filterPipe: FilterPipe,
private hotkeyService: HotKeyService
) {
this.selectionModelChange.subscribe((updatedModel) => {
this.modelIsDirty = updatedModel.isDirty()
})
}
ngOnInit(): void {
if (this.shortcutKey) {
this.hotkeyService
.addShortcut({
keys: this.shortcutKey,
description: $localize`Open ${this.title} filter`,
})
.pipe(
takeUntil(this.unsubscribeNotifier),
filter(() => !this.disabled)
)
.subscribe(() => {
this.dropdown.open()
})
}
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
applyClicked() {
if (this.selectionModel.isDirty()) {
this.dropdown.close()

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,38 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
const SYMBOLS = {
meta: '&#8984;', // ⌘
control: '&#8963;', // ⌃
shift: '&#8679;', // ⇧
left: '&#8592;', // ←
right: '&#8594;', // →
up: '&#8593;', // ↑
down: '&#8595;', // ↓
}
@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(' + ')
}
}