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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.24f9b6fb.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.24f9b6fb.min.js.map",
"assets/javascripts/bundle.js": "assets/javascripts/bundle.02fd1bf7.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.02fd1bf7.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.926ffd9e.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.926ffd9e.min.js.map",
"assets/stylesheets/app-palette.scss": "assets/stylesheets/app-palette.3f90c815.min.css",

View File

@ -190,7 +190,7 @@
{% endblock %}
</div>
{% block scripts %}
<script src="{{ 'assets/javascripts/bundle.24f9b6fb.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.02fd1bf7.min.js' | url }}"></script>
<script id="__lang" type="application/json">
{%- set translations = {} -%}
{%- for key in [

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)
)
}

View File

@ -32,8 +32,6 @@ $ms-ratio: $major-third;
// Variables: breakpoints
// ----------------------------------------------------------------------------
// stylelint-disable unit-whitelist
// Device-specific breakpoints
$break-devices: (
mobile: (
@ -51,8 +49,6 @@ $break-devices: (
)
);
// stylelint-enable unit-whitelist
// ----------------------------------------------------------------------------
// Variables: base colors
// ----------------------------------------------------------------------------