Improved keyboard handlers and added prev/next hotkeys

This commit is contained in:
squidfunk
2020-02-20 10:07:50 +01:00
parent 297a63313d
commit 9b0410962d
13 changed files with 165 additions and 200 deletions

View File

@@ -79,7 +79,7 @@ import {
patchScrollfix,
patchSource
} from "patches"
import { takeIf, not, isConfig } from "utilities"
import { isConfig } from "utilities"
import { renderDialog } from "templates/dialog"
/* ------------------------------------------------------------------------- */
@@ -108,7 +108,6 @@ export function initialize(config: unknown) {
const document$ = watchDocument()
const location$ = watchLocation()
const hash$ = watchLocationHash()
const keyboard$ = watchKeyboard()
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
@@ -187,7 +186,7 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
setupKeyboard({ keyboard$ })
const keyboard$ = setupKeyboard()
patchTables({ document$ })
patchDetails({ document$, hash$ })
@@ -215,10 +214,7 @@ export function initialize(config: unknown) {
)
})
)
.subscribe()
// TODO: general keyboard handler...
// put into main!?
.subscribe()
/* ----------------------------------------------------------------------- */
@@ -279,8 +275,7 @@ export function initialize(config: unknown) {
// TODO: experimental. necessary!?
keyboard$
.pipe(
takeIf(not(toggle$.pipe(switchMap(watchToggle)))),
filter(key => ["Tab"].includes(key.type)),
filter(key => key.mode === "global" && ["Tab"].includes(key.type)),
take(1)
)
.subscribe(() => {

View File

@@ -21,30 +21,46 @@
*/
import { Observable } from "rxjs"
import { switchMap, withLatestFrom } from "rxjs/operators"
import {
filter,
map,
share,
switchMap,
withLatestFrom
} from "rxjs/operators"
import { useComponent } from "components"
import {
Key,
getActiveElement,
getElement,
getElements,
isSusceptibleToKeyboard,
setElementFocus,
setToggle,
useToggle,
watchKeyboard,
watchToggle
} from "observables"
import { not, takeIf } from "utilities"
/* ----------------------------------------------------------------------------
* Helper types
* Types
* ------------------------------------------------------------------------- */
/**
* Setup options
* Keyboard mode
*/
interface SetupOptions {
keyboard$: Observable<Key> /* Keyboard observable */
export type KeyboardMode =
| "global" /* Global */
| "search" /* Search is open */
/* ------------------------------------------------------------------------- */
/**
* Keyboard
*/
export interface Keyboard extends Key {
mode: KeyboardMode /* Keyboard mode */
}
/* ----------------------------------------------------------------------------
@@ -54,17 +70,44 @@ interface SetupOptions {
/**
* Setup keyboard
*
* This function will setup the keyboard handlers and ensure that keys are
* correctly propagated. Currently there are two modes:
*
* - `global`: This mode is active when the search is closed. It is intended
* to assign hotkeys to specific functions of the site. Currently the search,
* previous and next page can be triggered.
*
* - `search`: This mode is active when the search is open. It maps certain
* navigational keys to offer search results that can be entirely navigated
* through keyboard input.
*
* The keyboard observable is returned and can be used to monitor the keyboard
* in order toassign further hotkeys to custom functions.
*
* @return Keyboard observable
*/
export function setupKeyboard(
{ keyboard$ }: SetupOptions
): Observable<Key> {
/* Setup keyboard handlers in search mode */
export function setupKeyboard(): Observable<Keyboard> {
const toggle$ = useToggle("search")
const search$ = toggle$
.pipe(
switchMap(watchToggle)
)
/* Setup keyboard and determine mode */
const keyboard$ = watchKeyboard()
.pipe(
withLatestFrom(search$),
map(([key, toggle]): Keyboard => ({
mode: toggle ? "search" : "global",
...key
})),
share()
)
/* Setup search keyboard handlers */
keyboard$
.pipe(
takeIf(toggle$.pipe(switchMap(watchToggle))),
filter(({ mode }) => mode === "search"),
withLatestFrom(
toggle$,
useComponent("search-query"),
@@ -114,23 +157,43 @@ export function setupKeyboard(
}
})
/* Setup general keyboard handlers */
/* Setup global keyboard handlers */
keyboard$
.pipe(
takeIf(not(toggle$.pipe(switchMap(watchToggle)))),
filter(({ mode }) => {
if (mode === "global") {
const active = getActiveElement()
if (typeof active !== "undefined")
return !isSusceptibleToKeyboard(active)
}
return false
}),
withLatestFrom(useComponent("search-query"))
)
.subscribe(([key, query]) => {
const active = getActiveElement()
switch (key.type) {
/* [s]earch / [f]ind: open search */
case "s":
/* Open search */
case "f":
if (!(active && isSusceptibleToKeyboard(active))) {
setElementFocus(query)
key.claim()
}
case "s":
setElementFocus(query)
key.claim()
break
/* Go to previous page */
case "p":
case ",":
const prev = getElement("[href][rel=prev]")
if (typeof prev !== "undefined")
prev.click()
break
/* Go to next page */
case "n":
case ".":
const next = getElement("[href][rel=next]")
if (typeof next !== "undefined")
next.click()
break
}
})

View File

@@ -32,6 +32,8 @@ import {
switchMap
} from "rxjs/operators"
import { getLocation } from "../../location"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
@@ -64,7 +66,7 @@ export function watchDocumentSwitch(
): Observable<Document> {
return location$
.pipe(
startWith(location.href),
startWith(getLocation()),
map(url => url.replace(/#[^#]+$/, "")),
distinctUntilChanged(),
skip(1),

View File

@@ -1,85 +0,0 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable, defer, of } from "rxjs"
import { map } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Invert the value of a toggle observable
*
* @param toggle$ - Toggle observable
*
* @return Inverted toggle observable
*/
export function not(
toggle$: Observable<boolean>
): Observable<boolean> {
return toggle$
.pipe(
map(active => !active)
)
}
/**
* Cache the last value emitted by an observable in session storage
*
* If the key is not found in session storage, the factory is executed and the
* latest value emitted will automatically be persisted to sessions storage.
* Note that the values emitted by the returned observable must be serializable
* as `JSON`, or data will be lost.
*
* @template T - Value type
*
* @param key - Cache key
* @param factory - Observable factory
*
* @return Value observable
*/
export function cache<T>(
key: string, factory: () => Observable<T>
): Observable<T> {
return defer(() => {
const data = sessionStorage.getItem(key)
if (data) {
return of(JSON.parse(data) as T)
/* Retrieve value from observable factory and write to storage */
} else {
const value$ = factory()
value$
.subscribe(value => {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch (err) {
/* Uncritical, just swallow */
}
})
/* Return value observable */
return value$
}
})
}

View File

@@ -20,5 +20,49 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./operators"
import { Observable, defer, of } from "rxjs"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Cache the last value emitted by an observable in session storage
*
* If the key is not found in session storage, the factory is executed and the
* latest value emitted will automatically be persisted to sessions storage.
* Note that the values emitted by the returned observable must be serializable
* as `JSON`, or data will be lost.
*
* @template T - Value type
*
* @param key - Cache key
* @param factory - Observable factory
*
* @return Value observable
*/
export function cache<T>(
key: string, factory: () => Observable<T>
): Observable<T> {
return defer(() => {
const data = sessionStorage.getItem(key)
if (data) {
return of(JSON.parse(data) as T)
/* Retrieve value from observable factory and write to storage */
} else {
const value$ = factory()
value$
.subscribe(value => {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch (err) {
/* Uncritical, just swallow */
}
})
/* Return value observable */
return value$
}
})
}

View File

@@ -1,50 +0,0 @@
/*
* Copyright (c) 2016-2020 Martin Donath <martin.donath@squidfunk.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { MonoTypeOperatorFunction, Observable, pipe } from "rxjs"
import { filter, map, withLatestFrom } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Toggle emission with another observable
*
* While this could also be implemented using window operators, it may lead to
* an unnecessary increase in bundle size, so we use operators we use anyway.
*
* @template T - Value type
*
* @param toggle$ - Toggle observable
*
* @return Operator function
*/
export function takeIf<T>(
toggle$: Observable<boolean>
): MonoTypeOperatorFunction<T> {
return pipe(
withLatestFrom(toggle$),
filter(([, active]) => active),
map(([value]) => value)
)
}