Squashed commit of the following:

commit 9b5b80380fc81f5a68828e22754f0e7d53b0dea0
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Feb 7 16:25:06 2021 +0100

    Refactored more stuff

commit 5a2108254f1222db7de08690e13c24e972ea19c0
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Feb 7 13:48:16 2021 +0100

    Refactored more stuff

commit b3a112f4bddefebcf9dbd1d0ffe240d86fc9aa08
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Feb 7 12:02:42 2021 +0100

    Refactored more stuff

commit bff323b6b81571021c0ac9be6f637de7728447a5
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sat Feb 6 18:14:52 2021 +0100

    Refactored search result list

commit 27b7e7e2da3b725797ad769e4411260ffd35b9f8
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sat Feb 6 17:12:36 2021 +0100

    Refactored more components

commit 3747e5ba6d084ed513a2659f48f161449b760076
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Jan 24 18:56:26 2021 +0100

    Implemented new architecture for several components

commit ea2851ab0f27113b080c2539a94a88dc0332be84
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Jan 24 14:53:42 2021 +0100

    Removed unnecessary height declaration for sidebars

commit 3c3f83ab4ef392dbabf1a11afba2556e529b1674
Merge: 91d239d8 13024179
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sun Jan 24 13:04:49 2021 +0100

    Merge branch 'master' into refactor/observable-architecture

commit 91d239d86649b9571b376011669bc73a7865b186
Author: squidfunk <martin.donath@squidfunk.com>
Date:   Sat Jan 9 13:11:04 2021 +0100

    Started refactoring observable architecture
This commit is contained in:
squidfunk 2021-02-07 16:27:51 +01:00
parent 130241791d
commit ae867d484b
119 changed files with 2270 additions and 3821 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

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

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,14 +1,14 @@
{
"assets/javascripts/bundle.js": "assets/javascripts/bundle.e9c9f54f.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.e9c9f54f.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.53cc9318.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.53cc9318.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.9c0e82ba.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.9c0e82ba.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.cb6bc1d0.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.cb6bc1d0.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.c462ebf7.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.c462ebf7.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.39b8e14a.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.39b8e14a.min.css.map"
"assets/javascripts/bundle.js": "assets/javascripts/bundle.0168bc18.min.js",
"assets/javascripts/bundle.js.map": "assets/javascripts/bundle.0168bc18.min.js.map",
"assets/javascripts/vendor.js": "assets/javascripts/vendor.2c98d838.min.js",
"assets/javascripts/vendor.js.map": "assets/javascripts/vendor.2c98d838.min.js.map",
"assets/javascripts/worker/search.js": "assets/javascripts/worker/search.cc7c7442.min.js",
"assets/javascripts/worker/search.js.map": "assets/javascripts/worker/search.cc7c7442.min.js.map",
"assets/stylesheets/main.css": "assets/stylesheets/main.9c987ffa.min.css",
"assets/stylesheets/main.css.map": "assets/stylesheets/main.9c987ffa.min.css.map",
"assets/stylesheets/overrides.css": "assets/stylesheets/overrides.45bf1a32.min.css",
"assets/stylesheets/overrides.css.map": "assets/stylesheets/overrides.45bf1a32.min.css.map",
"assets/stylesheets/palette.css": "assets/stylesheets/palette.00fa6fd5.min.css",
"assets/stylesheets/palette.css.map": "assets/stylesheets/palette.00fa6fd5.min.css.map"
}

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,3 +1,3 @@
@-webkit-keyframes tx-heart{0%,40%,80%,100%{transform:scale(1)}20%,60%{transform:scale(1.15)}}@keyframes tx-heart{0%,40%,80%,100%{transform:scale(1)}20%,60%{transform:scale(1.15)}}.md-typeset figure>p+figcaption{margin-top:-1.2rem}.md-typeset .twitter{color:#00acee}.md-typeset .tx-video{width:auto}.md-typeset .tx-video__inner{position:relative;width:100%;height:0;padding-bottom:56.138%}.md-typeset .tx-video iframe{position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden;border:none}.md-typeset .tx-heart{-webkit-animation:tx-heart 1000ms infinite;animation:tx-heart 1000ms infinite}.md-typeset .tx-insiders{color:#e91e63}.md-typeset .tx-insiders-button{font-weight:400}.md-typeset .tx-insiders-count{font-weight:700}.md-typeset .tx-insiders-list{margin:2em 0;overflow:auto}.md-typeset .tx-insiders-list__item{display:block;float:left;width:3rem;height:3rem;margin:.2rem;overflow:hidden;border-radius:100%;transform:scale(1);transition:color 125ms,transform 125ms}.md-typeset .tx-insiders-list__item img{display:block;width:100%;height:auto;-webkit-filter:grayscale(100%);filter:grayscale(100%);transition:-webkit-filter 125ms;transition:filter 125ms;transition:filter 125ms, -webkit-filter 125ms}.md-typeset .tx-insiders-list__item:focus,.md-typeset .tx-insiders-list__item:hover{transform:scale(1.1)}.md-typeset .tx-insiders-list__item:focus img,.md-typeset .tx-insiders-list__item:hover img{-webkit-filter:grayscale(0%);filter:grayscale(0%)}.md-typeset .tx-insiders-list__item--private{color:var(--md-default-fg-color--lighter);font-weight:700;font-size:1.2rem;line-height:3rem;text-align:center;background:var(--md-default-fg-color--lightest)}.md-typeset .tx-switch button{cursor:pointer;transition:opacity 250ms}.md-typeset .tx-switch button:focus,.md-typeset .tx-switch button:hover{opacity:.75}.md-typeset .tx-switch button>code{display:block;color:var(--md-primary-bg-color);background-color:var(--md-primary-fg-color)}.md-typeset .tx-columns ol,.md-typeset .tx-columns ul{-moz-columns:2;columns:2}@media screen and (max-width: 29.9375em){.md-typeset .tx-columns ol,.md-typeset .tx-columns ul{-moz-columns:initial;columns:initial}}.md-typeset .tx-columns li{-moz-column-break-inside:avoid;break-inside:avoid}.md-announce a,.md-announce a:focus,.md-announce a:hover{color:currentColor}.md-announce strong{white-space:nowrap}.md-announce .twitter{margin-left:.2em}.tx-content__footer{margin-top:1rem;text-align:center}.tx-content__footer a{display:inline-block;color:#e91e63;transition:transform 250ms cubic-bezier(0.1, 0.7, 0.1, 1),color 125ms}.tx-content__footer a:focus,.tx-content__footer a:hover{transform:scale(1.2)}.tx-content__footer hr{display:inline-block;width:2rem;margin:1em;vertical-align:middle;background-color:currentColor;border:none}.tx-container{padding-top:1rem;background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(0, 0%, 100%, 1)' /></svg>") no-repeat bottom,linear-gradient(to bottom, var(--md-primary-fg-color), #a63fd9 99%, var(--md-default-bg-color) 99%)}[data-md-color-scheme=slate] .tx-container{background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1123 258'><path d='M1124,2c0,0 0,256 0,256l-1125,0l0,-48c0,0 16,5 55,5c116,0 197,-92 325,-92c121,0 114,46 254,46c140,0 214,-167 572,-166Z' style='fill: hsla(232, 15%, 21%, 1)' /></svg>") no-repeat bottom,linear-gradient(to bottom, var(--md-primary-fg-color), #a63fd9 99%, var(--md-default-bg-color) 99%)}.tx-hero{margin:0 .8rem;color:var(--md-primary-bg-color)}.tx-hero h1{margin-bottom:1rem;color:currentColor;font-weight:700}@media screen and (max-width: 29.9375em){.tx-hero h1{font-size:1.4rem}}.tx-hero__content{padding-bottom:6rem}@media screen and (min-width: 60em){.tx-hero{display:flex;align-items:stretch}.tx-hero__content{max-width:19rem;margin-top:3.5rem;padding-bottom:14vw}.tx-hero__image{order:1;width:38rem;transform:translateX(4rem)}}@media screen and (min-width: 76.25em){.tx-hero__image{transform:translateX(8rem)}}.tx-hero .md-button{margin-top:.5rem;margin-right:.5rem;color:var(--md-primary-bg-color)}.tx-hero .md-button:focus,.tx-hero .md-button:hover{color:var(--md-default-bg-color);background-color:var(--md-accent-fg-color);border-color:var(--md-accent-fg-color)}.tx-hero .md-button--primary{color:#894da8;background-color:var(--md-primary-bg-color);border-color:var(--md-primary-bg-color)}
/*# sourceMappingURL=overrides.c462ebf7.min.css.map*/
/*# sourceMappingURL=overrides.45bf1a32.min.css.map*/

View File

@ -39,10 +39,10 @@
{% endif %}
{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.cb6bc1d0.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/main.9c987ffa.min.css' | url }}">
{% if config.theme.palette %}
{% set palette = config.theme.palette %}
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.39b8e14a.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/palette.00fa6fd5.min.css' | url }}">
{% if palette.primary %}
{% import "partials/palette.html" as map %}
{% set primary = map.primary(
@ -131,7 +131,7 @@
{% if page and page.meta and page.meta.hide %}
{% set hidden = "hidden" if "navigation" in page.meta.hide %}
{% endif %}
<div class="md-sidebar md-sidebar--primary" data-md-component="navigation" {{ hidden }}>
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" {{ hidden }}>
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
{% include "partials/nav.html" %}
@ -143,7 +143,7 @@
{% if page and page.meta and page.meta.hide %}
{% set hidden = "hidden" if "toc" in page.meta.hide %}
{% endif %}
<div class="md-sidebar md-sidebar--secondary" data-md-component="toc" {{ hidden }}>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" {{ hidden }}>
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
{% include "partials/toc.html" %}
@ -152,7 +152,7 @@
</div>
{% endif %}
{% endblock %}
<div class="md-content">
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
{% block content %}
{% if page.edit_url %}
@ -182,11 +182,18 @@
{% block footer %}
{% include "partials/footer.html" %}
{% endblock %}
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
</div>
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.53cc9318.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.e9c9f54f.min.js' | url }}"></script>
{%- set translations = {} -%}
{% block config %}
{%- set app = {
"base": base_url,
"features": features,
"translations": {},
"search": "assets/javascripts/worker/search.cc7c7442.min.js" | url,
} -%}
{%- set translations = app.translations -%}
{%- for key in [
"clipboard.copy",
"clipboard.copied",
@ -204,19 +211,13 @@
] -%}
{%- set _ = translations.update({ key: lang.t(key) }) -%}
{%- endfor -%}
<script id="__lang" type="application/json">
{{- translations | tojson -}}
</script>
{% block config %}{% endblock %}
<script>
app = initialize({
base: "{{ base_url }}",
features: {{ features or [] | tojson }},
search: Object.assign({
worker: "{{ 'assets/javascripts/worker/search.9c0e82ba.min.js' | url }}"
}, typeof search !== "undefined" && search)
})
<script id="__config" type="application/json">
{{- app | tojson -}}
</script>
{% endblock %}
{% block scripts %}
<script src="{{ 'assets/javascripts/vendor.2c98d838.min.js' | url }}"></script>
<script src="{{ 'assets/javascripts/bundle.0168bc18.min.js' | url }}"></script>
{% for path in config["extra_javascript"] %}
<script src="{{ path | url }}"></script>
{% endfor %}

View File

@ -22,7 +22,7 @@
<meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ config.site_description }}">
<meta name="twitter:image" content="{{ image }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/overrides.c462ebf7.min.css' | url }}">
<link rel="stylesheet" href="{{ 'assets/stylesheets/overrides.45bf1a32.min.css' | url }}">
{% endblock %}
{% block announce %}
<a href="https://twitter.com/squidfunk">

View File

@ -11,7 +11,7 @@
{% include ".icons/material/magnify.svg" %}
{% include ".icons/material/arrow-left.svg" %}
</label>
<button type="reset" class="md-search__icon md-icon" aria-label="{{ lang.t('search.reset') }}" data-md-component="search-reset" tabindex="-1">
<button type="reset" class="md-search__icon md-icon" aria-label="{{ lang.t('search.reset') }}" tabindex="-1">
{% include ".icons/material/close.svg" %}
</button>
</form>

View File

@ -2,7 +2,7 @@
This file was automatically generated - do not edit
-#}
{% import "partials/language.html" as lang with context %}
<a href="{{ config.repo_url }}" title="{{ lang.t('source.link.title') }}" class="md-source">
<a href="{{ config.repo_url }}" title="{{ lang.t('source.link.title') }}" class="md-source" data-md-component="source">
<div class="md-source__icon md-icon">
{% set icon = config.theme.icon.repo or "fontawesome/brands/git-alt" %}
{% include ".icons/" ~ icon ~ ".svg" %}

View File

@ -12,7 +12,7 @@
<span class="md-nav__icon md-icon"></span>
{{ lang.t("toc.title") }}
</label>
<ul class="md-nav__list" data-md-scrollfix>
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
{% for toc_item in toc %}
{% include "partials/toc-item.html" %}
{% endfor %}

View File

@ -86,8 +86,6 @@
"ts-node": "^9.1.1",
"tsconfig-paths-webpack-plugin": "^3.3.0",
"tslib": "^2.1.0",
"tslint": "^6.1.3",
"tslint-sonarts": "^1.9.0",
"typescript": "^4.1.3",
"webpack": "^4.44.2",
"webpack-assets-manifest": "3.1.1",

View File

@ -0,0 +1,127 @@
/*
* 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 { getElementOrThrow, getLocation } from "~/browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Feature flag
*/
export type Feature =
| "header.autohide" /* Hide header */
| "navigation.tabs" /* Tabs navigation */
| "navigation.instant" /* Instant loading */
/* ------------------------------------------------------------------------- */
/**
* Translation
*/
export type Translation =
| "clipboard.copy" /* Copy to clipboard */
| "clipboard.copied" /* Copied to clipboard */
| "search.config.lang" /* Search language */
| "search.config.pipeline" /* Search pipeline */
| "search.config.separator" /* Search separator */
| "search.placeholder" /* Search */
| "search.result.placeholder" /* Type to start searching */
| "search.result.none" /* No matching documents */
| "search.result.one" /* 1 matching document */
| "search.result.other" /* # matching documents */
| "search.result.more.one" /* 1 more on this page */
| "search.result.more.other" /* # more on this page */
| "search.result.term.missing" /* Missing */
/**
* Translations
*/
export type Translations = Record<Translation, string>
/* ------------------------------------------------------------------------- */
/**
* Configuration
*/
export interface Config {
base: string /* Base URL */
features: Feature[] /* Feature flags */
translations: Translations /* Translations */
search: string /* Search worker URL */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Retrieve global configuration and make base URL absolute
*/
let config: Config = JSON.parse(getElementOrThrow("#__config").textContent!)
config.base = new URL(config.base, getLocation())
.toString()
.replace(/\/$/, "")
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Retrieve global configuration
*
* @return Global configuration
*/
export function configuration(): Config {
return config
}
/**
* Check whether a feature is enabled
*
* @param feature - Feature
*
* @returns Test result
*/
export function flag(feature: Feature): boolean {
return config.features.includes(feature)
}
/**
* Retrieve the translation for the given key
*
* @param key - Key to be translated
* @param value - Value to be replaced
*
* @return Translation
*/
export function translation(
key: Translation, value?: string | number
): string {
if (typeof config.translations[key] === "undefined") {
throw new ReferenceError(`Invalid translation: ${key}`)
}
return typeof value !== "undefined"
? config.translations[key].replace("#", value.toString())
: config.translations[key]
}

View File

@ -20,25 +20,29 @@
* IN THE SOFTWARE.
*/
import { Observable, fromEvent } from "rxjs"
import { mapTo } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch search reset
* Set focusable property
*
* @param el - Search reset element
*
* @return Search reset observable
* @param el - Element
* @param value - Tabindex value
*/
export function watchSearchReset(
el: HTMLElement
): Observable<void> {
return fromEvent(el, "click")
.pipe(
mapTo(undefined)
)
export function setFocusable(
el: HTMLElement, value = 0
): void {
el.setAttribute("tabindex", value.toString())
}
/**
* Reset focusable property
*
* @param el - Element
*/
export function resetFocusable(
el: HTMLElement
): void {
el.removeAttribute("tabindex")
}

View File

@ -25,23 +25,23 @@
* ------------------------------------------------------------------------- */
/**
* Set anchor blur
* Set anchor state
*
* @param el - Anchor element
* @param value - Whether the anchor is blurred
* @param state - Anchor state
*/
export function setAnchorBlur(
el: HTMLElement, value: boolean
export function setAnchorState(
el: HTMLElement, state: "blur"
): void {
el.setAttribute("data-md-state", value ? "blur" : "")
el.setAttribute("data-md-state", state)
}
/**
* Reset anchor blur
* Reset anchor state
*
* @param el - Anchor element
*/
export function resetAnchorBlur(
export function resetAnchorState(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")

View File

@ -20,37 +20,43 @@
* IN THE SOFTWARE.
*/
import { Observable } from "rxjs"
import { map, shareReplay, take } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Helper types
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch options
* Set dialog message
*
* @param el - Dialog element
* @param value - Dialog message
*/
interface WatchOptions {
location$: Observable<URL> /* Location observable */
export function setDialogMessage(
el: HTMLElement, value: string
): void {
el.firstElementChild!.innerHTML = value
}
/* ------------------------------------------------------------------------- */
/**
* Watch location base
* Set dialog state
*
* @return Location base observable
* @param el - Dialog element
* @param state - Dialog state
*/
export function watchLocationBase(
base: string, { location$ }: WatchOptions
): Observable<string> {
return location$
.pipe(
take(1),
map(({ href }) => new URL(base, href)
.toString()
.replace(/\/$/, "")
),
shareReplay({ bufferSize: 1, refCount: true })
)
export function setDialogState(
el: HTMLElement, state: "open"
): void {
el.setAttribute("data-md-state", state)
}
/**
* Reset dialog state
*
* @param el - Dialog element
*/
export function resetDialogState(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")
}

View File

@ -25,23 +25,23 @@
* ------------------------------------------------------------------------- */
/**
* Set header shadow
* Set header state
*
* @param el - Header element
* @param value - Whether the shadow is shown
* @param state - Header state
*/
export function setHeaderShadow(
el: HTMLElement, value: boolean
export function setHeaderState(
el: HTMLElement, state: "shadow" | "hidden"
): void {
el.setAttribute("data-md-state", value ? "shadow" : "")
el.setAttribute("data-md-state", state)
}
/**
* Reset header shadow
* Reset header state
*
* @param el - Header element
*/
export function resetHeaderShadow(
export function resetHeaderState(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")

View File

@ -21,4 +21,4 @@
*/
export * from "./_"
export * from "./react"
export * from "./title"

View File

@ -25,23 +25,23 @@
* ------------------------------------------------------------------------- */
/**
* Set header title active
* Set header title state
*
* @param el - Header title element
* @param value - Whether the title is shown
* @param state - Header title state
*/
export function setHeaderTitleActive(
el: HTMLElement, value: boolean
export function setHeaderTitleState(
el: HTMLElement, state: "active"
): void {
el.setAttribute("data-md-state", value ? "active" : "")
el.setAttribute("data-md-state", state)
}
/**
* Reset header title active
* Reset header title state
*
* @param el - Header title element
*/
export function resetHeaderTitleActive(
export function resetHeaderTitleState(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")

View File

@ -20,9 +20,11 @@
* IN THE SOFTWARE.
*/
export * from "./code"
export * from "./details"
export * from "./script"
export * from "./scrollfix"
export * from "./_"
export * from "./anchor"
export * from "./dialog"
export * from "./header"
export * from "./search"
export * from "./sidebar"
export * from "./source"
export * from "./table"
export * from "./tabs"

View File

@ -0,0 +1,24 @@
/*
* 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.
*/
export * from "./query"
export * from "./result"

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { translate } from "utilities"
import { translation } from "~/_"
/* ----------------------------------------------------------------------------
* Functions
@ -46,5 +46,5 @@ export function setSearchQueryPlaceholder(
export function resetSearchQueryPlaceholder(
el: HTMLInputElement
): void {
el.placeholder = translate("search.placeholder")
el.placeholder = translation("search.placeholder")
}

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { translate } from "utilities"
import { translation } from "~/_"
/* ----------------------------------------------------------------------------
* Functions
@ -39,17 +39,17 @@ export function setSearchResultMeta(
/* No results */
case 0:
el.textContent = translate("search.result.none")
el.textContent = translation("search.result.none")
break
/* One result */
case 1:
el.textContent = translate("search.result.one")
el.textContent = translation("search.result.one")
break
/* Multiple result */
default:
el.textContent = translate("search.result.other", value)
el.textContent = translation("search.result.other", value)
}
}
@ -61,7 +61,7 @@ export function setSearchResultMeta(
export function resetSearchResultMeta(
el: HTMLElement
): void {
el.textContent = translate("search.result.placeholder")
el.textContent = translation("search.result.placeholder")
}
/* ------------------------------------------------------------------------- */

View File

@ -55,17 +55,15 @@ export function resetSidebarOffset(
* This function doesn't set the height of the actual sidebar, but of its first
* child the `.md-sidebar__scrollwrap` element in order to mitigiate jittery
* sidebars when the footer is scrolled into view. At some point we switched
* from `absolute` / `fixed` positioning to `sticky` positioning, which greatly
* reduced jitter in some browsers (respectively Firefox and Safari) when
* from `absolute` / `fixed` positioning to `sticky` positioning, significantly
* reducing jitter in some browsers (respectively Firefox and Safari) when
* scrolling from the top. However, top-aligned sticky positioning means that
* the sidebar snaps to the bottom when the end of the container is reached.
* This is what leads to the mentioned jitter, as the sidebar's height may be
* updated to slowly.
* updated too slowly.
*
* By setting the height of the sidebar to zero (while preserving `padding`),
* and the height on its first element, this behaviour can be mitigiated. We
* must assume that the top- and bottom offset (`padding`) are equal, as the
* `offsetBottom` value is `undefined`.
* This behaviour can be mitigiated by setting the height of the sidebar to `0`
* while preserving the padding, and the height on its first element.
*
* @param el - Sidebar element
* @param value - Sidebar height

View File

@ -0,0 +1,49 @@
/*
* 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.
*/
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set repository facts
*
* @param el - Repository element
* @param child - Repository facts element
*/
export function setSourceFacts(
el: HTMLElement, child: Element
): void {
el.lastElementChild!.appendChild(child)
}
/**
* Set repository state
*
* @param el - Repository element
* @param state - Repository state
*/
export function setSourceState(
el: HTMLElement, state: "done"
): void {
el.lastElementChild!.setAttribute("data-md-state", state)
}

View File

@ -25,23 +25,23 @@
* ------------------------------------------------------------------------- */
/**
* Set tabs hidden
* Set tabs state
*
* @param el - Tabs element
* @param value - Whether the element is hidden
* @param state - Tabs state
*/
export function setTabsHidden(
el: HTMLElement, value: boolean
export function setTabsState(
el: HTMLElement, state: "hidden"
): void {
el.setAttribute("data-md-state", value ? "hidden" : "")
el.setAttribute("data-md-state", state)
}
/**
* Reset tabs hidden
* Reset tabs state
*
* @param el - Tabs element
*/
export function resetTabsHidden(
export function resetTabsState(
el: HTMLElement
): void {
el.removeAttribute("data-md-state")

View File

@ -34,6 +34,14 @@
*
* @return Element or nothing
*/
export function getElement<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T]
export function getElement<T extends HTMLElement>(
selector: string, node?: ParentNode
): T
export function getElement<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T | undefined {
@ -50,6 +58,14 @@ export function getElement<T extends HTMLElement>(
*
* @return Element
*/
export function getElementOrThrow<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T]
export function getElementOrThrow<T extends HTMLElement>(
selector: string, node?: ParentNode
): T
export function getElementOrThrow<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T {
@ -82,6 +98,14 @@ export function getActiveElement(): HTMLElement | undefined {
*
* @return Elements
*/
export function getElements<T extends keyof HTMLElementTagNameMap>(
selector: T, node?: ParentNode
): HTMLElementTagNameMap[T][]
export function getElements<T extends HTMLElement>(
selector: string, node?: ParentNode
): T[]
export function getElements<T extends HTMLElement>(
selector: string, node: ParentNode = document
): T[] {

View File

@ -23,5 +23,5 @@
export * from "./_"
export * from "./focus"
export * from "./offset"
export * from "./select"
export * from "./selection"
export * from "./size"

View File

@ -80,7 +80,7 @@ const observer$ = defer(() => of(
finalize(() => resize.disconnect())
)
),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
/* ----------------------------------------------------------------------------
@ -101,6 +101,20 @@ export function getElementSize(el: HTMLElement): ElementSize {
}
}
/**
* Retrieve element content size, i.e. including overflowing content
*
* @param el - Element
*
* @return Element size
*/
export function getElementContentSize(el: HTMLElement): ElementSize {
return {
width: el.scrollWidth,
height: el.scrollHeight
}
}
/* ------------------------------------------------------------------------- */
/**

View File

@ -51,40 +51,6 @@ export function setLocation(url: URL): void {
/* ------------------------------------------------------------------------- */
/**
* Check whether a URL is a local link or a file (except `.html`)
*
* @param url - URL or HTML anchor element
* @param ref - Reference URL
*
* @return Test result
*/
export function isLocalLocation(
url: URL | HTMLAnchorElement,
ref: URL | Location = location
): boolean {
return url.host === ref.host
&& /^(?:\/[\w-]+)*(?:\/?|\.html)$/i.test(url.pathname)
}
/**
* Check whether a URL is an anchor link on the current page
*
* @param url - URL or HTML anchor element
* @param ref - Reference URL
*
* @return Test result
*/
export function isAnchorLocation(
url: URL | HTMLAnchorElement,
ref: URL | Location = location
): boolean {
return url.pathname === ref.pathname
&& url.hash.length > 0
}
/* ------------------------------------------------------------------------- */
/**
* Watch location
*

View File

@ -20,10 +20,11 @@
* IN THE SOFTWARE.
*/
import { Observable, fromEvent } from "rxjs"
import { filter, map, share, startWith } from "rxjs/operators"
import { Observable, fromEvent, of } from "rxjs"
import { filter, map, share, startWith, switchMap } from "rxjs/operators"
import { createElement } from "browser"
import { getElement } from "~/browser/element"
/* ----------------------------------------------------------------------------
* Functions
@ -71,3 +72,15 @@ export function watchLocationHash(): Observable<string> {
share()
)
}
/**
* Watch location target
*
* @return Location target observable
*/
export function watchLocationTarget(): Observable<HTMLElement> {
return watchLocationHash()
.pipe(
switchMap(id => of(getElement(`[id="${id}"]`)!))
)
}

View File

@ -21,5 +21,4 @@
*/
export * from "./_"
export * from "./base"
export * from "./hash"

View File

@ -20,8 +20,8 @@
* IN THE SOFTWARE.
*/
import { Observable } from "rxjs"
import { shareReplay, startWith } from "rxjs/operators"
import { Observable, fromEvent, merge } from "rxjs"
import { filter, map, mapTo, startWith } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
@ -36,11 +36,24 @@ import { shareReplay, startWith } from "rxjs/operators"
*/
export function watchMedia(query: string): Observable<boolean> {
const media = matchMedia(query)
return new Observable<boolean>(subscriber => {
media.addListener(ev => subscriber.next(ev.matches))
})
return fromEvent<MediaQueryListEvent>(media, "change")
.pipe(
startWith(media.matches),
shareReplay({ bufferSize: 1, refCount: true })
map(ev => ev.matches),
startWith(media.matches)
)
}
/**
* Watch print mode, cross-browser
*
* @return Print observable
*/
export function watchPrint(): Observable<void> {
return merge(
watchMedia("print").pipe(filter(Boolean)), /* Webkit */
fromEvent(window, "beforeprint") /* IE, FF */
)
.pipe(
mapTo(undefined)
)
}

View File

@ -27,7 +27,7 @@ import {
shareReplay
} from "rxjs/operators"
import { Header } from "components"
import { Header } from "~/components"
import {
ViewportOffset,
@ -58,8 +58,8 @@ export interface Viewport {
* Watch at options
*/
interface WatchAtOptions {
header$: Observable<Header> /* Header observable */
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
@ -78,7 +78,7 @@ export function watchViewport(): Observable<Viewport> {
])
.pipe(
map(([offset, size]) => ({ offset, size })),
shareReplay({ bufferSize: 1, refCount: true })
shareReplay(1)
)
}
@ -91,7 +91,7 @@ export function watchViewport(): Observable<Viewport> {
* @return Viewport observable
*/
export function watchViewportAt(
el: HTMLElement, { header$, viewport$ }: WatchAtOptions
el: HTMLElement, { viewport$, header$ }: WatchAtOptions
): Observable<Viewport> {
const size$ = viewport$
.pipe(

View File

@ -20,17 +20,6 @@
* IN THE SOFTWARE.
*/
import { EMPTY, Observable, of } from "rxjs"
import {
distinctUntilChanged,
map,
scan,
shareReplay,
switchMap
} from "rxjs/operators"
import { getElement, replaceElement } from "browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
@ -38,130 +27,31 @@ import { getElement, replaceElement } from "browser"
/**
* Component
*/
export type Component =
export type ComponentType =
| "announce" /* Announcement bar */
| "container" /* Container */
| "content" /* Content */
| "header" /* Header */
| "header-title" /* Header title */
| "main" /* Main area */
| "navigation" /* Navigation */
| "search" /* Search */
| "search-query" /* Search input */
| "search-reset" /* Search reset */
| "search-result" /* Search results */
| "skip" /* Skip link */
| "tabs" /* Tabs */
| "source" /* Repository information */
| "tabs" /* Navigation tabs */
| "toc" /* Table of contents */
/**
* Component map
* A component
*
* @template T - Component type
* @template U - Reference type
*/
export type ComponentMap = {
[P in Component]?: HTMLElement
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Component map observable
*/
let components$: Observable<ComponentMap>
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up bindings to components with given names
*
* This function will maintain bindings to the elements identified by the given
* names in-between document switches and update the elements in-place.
*
* @param names - Component names
* @param options - Options
*/
export function setupComponents(
names: Component[], { document$ }: WatchOptions
): void {
components$ = document$
.pipe(
/* Build component map */
map(document => names.reduce<ComponentMap>((components, name) => {
const el = getElement(`[data-md-component=${name}]`, document)
return {
...components,
...typeof el !== "undefined" ? { [name]: el } : {}
}
}, {})),
/* Re-compute component map on document switch */
scan((prev, next) => {
for (const name of names) {
switch (name) {
/* Top-level components: update */
case "announce":
case "header-title":
case "container":
case "skip":
if (name in prev && typeof prev[name] !== "undefined") {
replaceElement(prev[name]!, next[name]!)
prev[name] = next[name]
}
break
/* All other components: rebind */
default:
if (typeof next[name] !== "undefined")
prev[name] = getElement(`[data-md-component=${name}]`)
else
delete prev[name]
}
}
return prev
}),
/* Convert to hot observable */
shareReplay({ bufferSize: 1, refCount: true })
)
}
/**
* Retrieve a component
*
* The returned observable will only re-emit if the element changed, i.e. if
* it was replaced from a document which was switched to.
*
* @template T - Element type
*
* @param name - Component name
*
* @return Component observable
*/
export function useComponent<T extends HTMLElement>(
name: Component
): Observable<T> {
return components$
.pipe(
switchMap(components => (
typeof components[name] !== "undefined"
? of(components[name] as T)
: EMPTY
)),
distinctUntilChanged()
)
}
export type Component<
T extends {} = {},
U extends HTMLElement = HTMLElement
> =
T & {
ref: U /* Component reference */
}

View File

@ -20,28 +20,26 @@
* IN THE SOFTWARE.
*/
import { Observable, OperatorFunction, of, pipe } from "rxjs"
import {
distinctUntilKeyChanged,
map,
switchMap
} from "rxjs/operators"
import { Observable, merge } from "rxjs"
import { Viewport, watchViewportAt } from "browser"
import { getElements, Viewport } from "~/browser"
import { Header } from "../../header"
import { applyTabs } from "../react"
import { Component } from "../../_"
import { CodeBlock, mountCodeBlock } from "../code"
import { Details, mountDetails } from "../details"
import { DataTable, mountDataTable } from "../table"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Tabs
* Content
*/
export interface Tabs {
hidden: boolean /* Whether the tabs are hidden */
}
export type Content =
| CodeBlock
| DataTable
| Details
/* ----------------------------------------------------------------------------
* Helper types
@ -51,9 +49,9 @@ export interface Tabs {
* Mount options
*/
interface MountOptions {
header$: Observable<Header> /* Header observable */
target$: Observable<HTMLElement> /* Location target observable */
viewport$: Observable<Viewport> /* Viewport observable */
screen$: Observable<boolean> /* Media screen observable */
print$: Observable<void> /* Print observable */
}
/* ----------------------------------------------------------------------------
@ -61,35 +59,28 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount tabs from source observable
* Mount content
*
* @param el - Content element
* @param options - Options
*
* @return Operator function
* @return Content component observable
*/
export function mountTabs(
{ header$, viewport$, screen$ }: MountOptions
): OperatorFunction<HTMLElement, Tabs> {
return pipe(
switchMap(el => screen$
.pipe(
switchMap(screen => {
export function mountContent(
el: HTMLElement, { target$, viewport$, print$ }: MountOptions
): Observable<Component<Content>> {
return merge(
/* [screen +]: Mount tabs above screen breakpoint */
if (screen) {
return watchViewportAt(el, { header$, viewport$ })
.pipe(
map(({ offset: { y } }) => ({ hidden: y >= 10 })),
distinctUntilKeyChanged("hidden"),
applyTabs(el)
)
/* Code blocks */
...getElements("pre > code", el)
.map(child => mountCodeBlock(child, { viewport$ })),
/* [screen -]: Unmount tabs below screen breakpoint */
} else {
return of({ hidden: true })
}
})
)
)
/* Data tables */
...getElements("table:not([class])", el)
.map(child => mountDataTable(child)),
/* Details */
...getElements("details", el)
.map(child => mountDetails(child, { target$, print$ }))
)
}

View File

@ -0,0 +1,154 @@
/*
* 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 ClipboardJS from "clipboard"
import { Observable, Subject } from "rxjs"
import {
distinctUntilKeyChanged,
finalize,
map,
tap,
withLatestFrom
} from "rxjs/operators"
import { resetFocusable, setFocusable } from "~/actions"
import {
getElementContentSize,
getElementSize,
Viewport,
watchMedia
} from "~/browser"
import { renderClipboardButton } from "~/templates"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Code block
*/
export interface CodeBlock {
scroll: boolean /* Code block overflows */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Global index for Clipboard.js integration
*/
let index = 0
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch code block
*
* @param el - Code block element
* @param options - Options
*
* @return Code block observable
*/
export function watchCodeBlock(
el: HTMLElement, { viewport$ }: WatchOptions
): Observable<CodeBlock> {
return viewport$
.pipe(
distinctUntilKeyChanged("size"),
map(() => {
const visible = getElementSize(el)
const content = getElementContentSize(el)
return {
scroll: content.width > visible.width
}
}),
distinctUntilKeyChanged("scroll")
)
}
/**
* Mount code block
*
* This function ensures that overflowing code blocks are focusable by keyboard,
* so they can be scrolled without a mouse to improve on accessibility.
*
* @param el - Code block element
* @param options - Options
*
* @return Code block component observable
*/
export function mountCodeBlock(
el: HTMLElement, options: MountOptions
): Observable<Component<CodeBlock>> {
const internal$ = new Subject<CodeBlock>()
internal$
.pipe(
withLatestFrom(watchMedia("(hover)"))
)
.subscribe(([{ scroll }, hover]) => {
if (scroll && hover)
setFocusable(el)
else
resetFocusable(el)
})
/* Inject button for Clipboard.js integration */
if (ClipboardJS.isSupported()) {
const parent = el.closest("pre")!
parent.id = `__code_${index++}`
parent.insertBefore(
renderClipboardButton(parent.id),
el
)
}
/* Create and return component */
return watchCodeBlock(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -20,47 +20,45 @@
* IN THE SOFTWARE.
*/
import { OperatorFunction, pipe } from "rxjs"
import { Observable, Subject } from "rxjs"
import {
distinctUntilKeyChanged,
filter,
finalize,
map,
switchMap
mapTo,
mergeWith,
tap
} from "rxjs/operators"
import { WorkerHandler, setToggle } from "browser"
import {
SearchMessage,
SearchMessageType,
SearchQueryMessage,
SearchTransformFn
} from "integrations"
import {
applySearchQuery,
watchSearchQuery
} from "../react"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search query
* Details
*/
export interface SearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
export interface Details {}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<void> /* Print observable */
}
/**
* Mount options
*/
interface MountOptions {
transform?: SearchTransformFn /* Transformation function */
target$: Observable<HTMLElement> /* Location target observable */
print$: Observable<void> /* Print observable */
}
/* ----------------------------------------------------------------------------
@ -68,46 +66,50 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount search query from source observable
* Watch details
*
* @param handler - Worker handler
* @param el - Details element
* @param options - Options
*
* @return Operator function
* @return Details observable
*/
export function mountSearchQuery(
{ tx$ }: WorkerHandler<SearchMessage>, options: MountOptions = {}
): OperatorFunction<HTMLInputElement, SearchQuery> {
return pipe(
switchMap(el => {
const query$ = watchSearchQuery(el, options)
/* Subscribe worker to search query */
query$
.pipe(
distinctUntilKeyChanged("value"),
map(({ value }): SearchQueryMessage => ({
type: SearchMessageType.QUERY,
data: value
}))
)
.subscribe(tx$.next.bind(tx$))
/* Toggle search on focus */
query$
.pipe(
distinctUntilKeyChanged("focus")
)
.subscribe(({ focus }) => {
if (focus)
setToggle("search", focus)
})
/* Return search query */
return query$
.pipe(
applySearchQuery(el)
)
})
)
export function watchDetails(
el: HTMLDetailsElement, { target$, print$ }: WatchOptions
): Observable<Details> {
return target$
.pipe(
map(target => target.closest("details:not([open])")!),
filter(details => el === details),
mergeWith(print$),
mapTo(el)
)
}
/**
* Mount details
*
* This function ensures that `details` tags are opened prior to printing, so
* the whole content of the page is included and on anchor jumps.
*
* @param el - Details element
* @param options - Options
*
* @return Details component observable
*/
export function mountDetails(
el: HTMLDetailsElement, options: MountOptions
): Observable<Component<Details>> {
const internal$ = new Subject<Details>()
internal$.subscribe(() => {
el.setAttribute("open", "")
el.scrollIntoView()
})
/* Create and return component */
return watchDetails(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
mapTo({ ref: el })
)
}

View File

@ -21,5 +21,5 @@
*/
export * from "./_"
export * from "./react"
export * from "./set"
export * from "./code"
export * from "./table"

View File

@ -20,51 +20,48 @@
* IN THE SOFTWARE.
*/
import { Observable } from "rxjs"
import { map } from "rxjs/operators"
import { Observable, of } from "rxjs"
import {
createElement,
getElements,
replaceElement
} from "browser"
import { renderTable } from "templates"
import { createElement, replaceElement } from "~/browser"
import { renderTable } from "~/templates"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Helper types
* Types
* ------------------------------------------------------------------------- */
/**
* Mount options
* Data table
*/
interface MountOptions {
document$: Observable<Document> /* Document observable */
}
export interface DataTable {}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Sentinel for replacement
*/
const sentinel = createElement("table")
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch all `table` elements
* Mount data table
*
* This function will re-render all tables by wrapping them to improve overflow
* scrolling on smaller screen sizes.
* @param el - Data table element
*
* @param options - Options
* @return Data table component observable
*/
export function patchTables(
{ document$ }: MountOptions
): void {
const sentinel = createElement("table")
document$
.pipe(
map(() => getElements<HTMLTableElement>("table:not([class])"))
)
.subscribe(els => {
for (const el of els) {
replaceElement(el, sentinel)
replaceElement(sentinel, renderTable(el))
}
})
export function mountDataTable(
el: HTMLElement
): Observable<Component<DataTable>> {
replaceElement(el, sentinel)
replaceElement(sentinel, renderTable(el))
/* Create and return component */
return of({ ref: el })
}

View File

@ -22,50 +22,56 @@
import {
Observable,
OperatorFunction,
Subject,
noop,
pipe
merge,
animationFrameScheduler,
of
} from "rxjs"
import {
distinctUntilKeyChanged,
delay,
finalize,
map,
observeOn,
switchMap,
tap
} from "rxjs/operators"
import { Viewport } from "browser"
import { useComponent } from "../../_"
import { Header } from "../../header"
import {
applyHeaderShadow,
watchMain
} from "../react"
resetDialogState,
setDialogMessage,
setDialogState
} from "~/actions"
import { Component } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Main area
* Dialog
*/
export interface Main {
offset: number /* Main area top offset */
height: number /* Main area visible height */
active: boolean /* Scrolled past top offset */
export interface Dialog {
message: string /* Dialog message */
open: boolean /* Dialog is visible */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
message$: Subject<string> /* Message subject */
}
/**
* Mount options
*/
interface MountOptions {
header$: Observable<Header> /* Header observable */
viewport$: Observable<Viewport> /* Viewport observable */
message$: Subject<string> /* Message subject */
}
/* ----------------------------------------------------------------------------
@ -73,38 +79,59 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount main area from source observable
*
* The header must be connected to the main area observable outside of the
* operator function, as the header will persist in-between document switches
* while the main area is replaced. However, the header observable must be
* passed to this function, so we connect both via a long-living subject.
* Watch dialog
*
* @param el - Dialog element
* @param options - Options
*
* @return Operator function
* @return Dialog observable
*/
export function mountMain(
{ header$, viewport$ }: MountOptions
): OperatorFunction<HTMLElement, Main> {
const main$ = new Subject<Main>()
/* Connect to main area observable via long-living subject */
useComponent("header")
export function watchDialog(
_el: HTMLElement, { message$ }: WatchOptions
): Observable<Dialog> {
return message$
.pipe(
switchMap(header => main$
switchMap(message => merge(
of(true),
of(false).pipe(delay(2000))
)
.pipe(
distinctUntilKeyChanged("active"),
applyHeaderShadow(header)
map(open => ({ message, open }))
)
)
)
.subscribe(noop)
/* Return operator */
return pipe(
switchMap(el => watchMain(el, { header$, viewport$ })),
tap(main => main$.next(main)),
finalize(() => main$.complete())
)
}
/**
* Mount dialog
*
* @param el - Dialog element
* @param options - Options
*
* @return Dialog component observable
*/
export function mountDialog(
el: HTMLElement, { message$ }: MountOptions
): Observable<Component<Dialog>> {
const internal$ = new Subject<Dialog>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe(({ message, open }) => {
setDialogMessage(el, message)
if (open)
setDialogState(el, "open")
else
resetDialogState(el)
})
/* Create and return component */
return watchDialog(el, { message$ })
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -20,46 +20,30 @@
* IN THE SOFTWARE.
*/
import { Observable, OperatorFunction, combineLatest, pipe } from "rxjs"
import { Observable, defer, of, Subject, animationFrameScheduler } from "rxjs"
import {
combineLatestWith,
distinctUntilChanged,
filter,
distinctUntilKeyChanged,
map,
startWith,
switchMap,
zipWith
observeOn,
shareReplay
} from "rxjs/operators"
import {
Viewport,
getElement,
watchViewportAt
} from "browser"
import { resetHeaderState, setHeaderState } from "~/actions"
import { Viewport, watchElementSize } from "~/browser"
import { useComponent } from "../../_"
import {
applyHeaderType,
watchHeader
} from "../react"
import { Component } from "../../_"
import { Main } from "../../main"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Header type
*/
export type HeaderType =
| "site" /* Header shows site title */
| "page" /* Header shows page title */
/* ------------------------------------------------------------------------- */
/**
* Header
*/
export interface Header {
type: HeaderType /* Header type */
sticky: boolean /* Header stickyness */
height: number /* Header visible height */
}
@ -72,8 +56,9 @@ export interface Header {
* Mount options
*/
interface MountOptions {
document$: Observable<Document> /* Document observable */
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
}
/* ----------------------------------------------------------------------------
@ -81,42 +66,69 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount header from source observable
* Watch header
*
* @param el - Header element
*
* @return Header observable
*/
export function watchHeader(
el: HTMLElement
): Observable<Header> {
return defer(() => {
const styles = getComputedStyle(el)
return of(
styles.position === "sticky" ||
styles.position === "-webkit-sticky"
)
})
.pipe(
combineLatestWith(watchElementSize(el)),
map(([sticky, { height }]) => ({
sticky,
height: sticky ? height : 0
})),
distinctUntilChanged((a, b) => (
a.sticky === b.sticky &&
a.height === b.height
))
)
}
/**
* Mount header
*
* The header must be connected to the main area observable outside of the
* operator function, as the header will persist in-between document switches
* while the main area is replaced. However, the header observable must be
* passed to this function, so we connect both via a long-living subject.
*
* @param el - Header element
* @param options - Options
*
* @return Operator function
* @return Header component observable
*/
export function mountHeader(
{ document$, viewport$ }: MountOptions
): OperatorFunction<HTMLElement, Header> {
return pipe(
switchMap(el => {
const header$ = watchHeader(el, { document$ })
el: HTMLElement, { header$, main$ }: MountOptions
): Observable<Component<Header>> {
const internal$ = new Subject<Main>()
internal$
.pipe(
distinctUntilKeyChanged("active"),
observeOn(animationFrameScheduler)
)
.subscribe(({ active }) => {
if (active)
setHeaderState(el, "shadow")
else
resetHeaderState(el)
})
/* Compute whether the header should switch to page header */
const type$ = useComponent("main")
.pipe(
map(main => getElement("h1, h2, h3, h4, h5, h6", main)!),
filter(hx => typeof hx !== "undefined"),
zipWith(useComponent("header-title")),
switchMap(([hx, title]) => watchViewportAt(hx, { header$, viewport$ })
.pipe(
map(({ offset: { y } }) => {
return y >= hx.offsetHeight ? "page" : "site"
}),
distinctUntilChanged(),
applyHeaderType(title)
)
),
startWith<HeaderType>("site")
)
/* Combine into single observable */
return combineLatest([header$, type$])
.pipe(
map(([header, type]): Header => ({ type, ...header }))
)
})
)
/* Connect to long-living subject and return component */
main$.subscribe(main => internal$.next(main))
return header$
.pipe(
map(state => ({ ref: el, ...state })),
shareReplay(1)
)
}

View File

@ -21,5 +21,4 @@
*/
export * from "./_"
export * from "./react"
export * from "./set"
export * from "./title"

View File

@ -1,128 +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,
animationFrameScheduler,
of,
pipe
} from "rxjs"
import {
distinctUntilChanged,
finalize,
map,
observeOn,
shareReplay,
switchMap,
tap
} from "rxjs/operators"
import { watchElementSize } from "browser"
import { Header, HeaderType } from "../_"
import {
resetHeaderTitleActive,
setHeaderTitleActive
} from "../set"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch header
*
* @param el - Header element
*
* @return Header observable
*/
export function watchHeader(
el: HTMLElement, { document$ }: WatchOptions
): Observable<Omit<Header, "type">> {
return document$
.pipe(
map(() => {
const styles = getComputedStyle(el)
return [
"sticky", /* Modern browsers */
"-webkit-sticky" /* Safari */
].includes(styles.position)
}),
distinctUntilChanged(),
switchMap(sticky => {
if (sticky) {
return watchElementSize(el)
.pipe(
map(({ height }) => ({
sticky: true,
height
}))
)
} else {
return of({
sticky: false,
height: 0
})
}
}),
shareReplay({ bufferSize: 1, refCount: true })
)
}
/* ------------------------------------------------------------------------- */
/**
* Apply header title type
*
* @param el - Header title element
*
* @return Operator function
*/
export function applyHeaderType(
el: HTMLElement
): MonoTypeOperatorFunction<HeaderType> {
return pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(type => {
setHeaderTitleActive(el, type === "page")
}),
/* Reset on complete or error */
finalize(() => {
resetHeaderTitleActive(el)
})
)
}

View File

@ -0,0 +1,135 @@
/*
* 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, animationFrameScheduler, Subject } from "rxjs"
import {
distinctUntilKeyChanged,
finalize,
map,
observeOn,
tap
} from "rxjs/operators"
import {
resetHeaderTitleState,
setHeaderTitleState
} from "~/actions"
import {
Viewport,
getElementOrThrow,
getElementSize,
watchViewportAt
} from "~/browser"
import { Component } from "../../_"
import { Header } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Header
*/
export interface HeaderTitle {
active: boolean /* User scrolled past first headline */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch header title
*
* @param el - Heading element
* @param options - Options
*
* @return Header title observable
*/
export function watchHeaderTitle(
el: HTMLHeadingElement, { viewport$, header$ }: WatchOptions
): Observable<HeaderTitle> {
return watchViewportAt(el, { header$, viewport$ })
.pipe(
map(({ offset: { y } }) => {
const { height } = getElementSize(el)
return {
active: y >= height
}
}),
distinctUntilKeyChanged("active")
)
}
/**
* Mount header title
*
* @param el - Header title element
* @param options - Options
*
* @return Header title component observable
*/
export function mountHeaderTitle(
el: HTMLElement, options: MountOptions
): Observable<Component<HeaderTitle>> {
const internal$ = new Subject<HeaderTitle>()
internal$
.pipe(
observeOn(animationFrameScheduler),
)
.subscribe(({ active }) => {
if (active)
setHeaderTitleState(el, "active")
else
resetHeaderTitleState(el)
})
/* Create and return component */
const headline = getElementOrThrow<HTMLHeadingElement>("article h1")
return watchHeaderTitle(headline, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -21,10 +21,12 @@
*/
export * from "./_"
export * from "./content"
export * from "./dialog"
export * from "./header"
export * from "./main"
export * from "./navigation"
export * from "./search"
export * from "./shared"
export * from "./sidebar"
export * from "./source"
export * from "./tabs"
export * from "./toc"

View File

@ -20,6 +20,107 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./react"
export * from "./set"
import {
Observable,
combineLatest
} from "rxjs"
import {
distinctUntilChanged,
distinctUntilKeyChanged,
map,
shareReplay,
switchMap
} from "rxjs/operators"
import { Viewport, watchElementSize } from "~/browser"
import { Header } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Main area
*/
export interface Main {
offset: number /* Main area top offset */
height: number /* Main area visible height */
active: boolean /* User scrolled past header */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch main area
*
* This function returns an observable that computes the visual parameters of
* the main area which depends on the viewport vertical offset and height, as
* well as the height of the header element, if the header is fixed.
*
* @param el - Main area element
* @param options - Options
*
* @return Main area observable
*/
export function watchMain(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Main> {
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
map(({ height }) => height),
distinctUntilChanged()
)
/* Compute the main area's top and bottom borders */
const border$ = adjust$
.pipe(
switchMap(() => watchElementSize(el)
.pipe(
map(({ height }) => ({
top: el.offsetTop,
bottom: el.offsetTop + height
})),
distinctUntilKeyChanged("bottom")
)
)
)
/* Compute the main area's offset, visible height and if we scrolled past */
return combineLatest([adjust$, border$, viewport$])
.pipe(
map(([header, { top, bottom }, { offset: { y }, size: { height } }]) => {
height = Math.max(0, height
- Math.max(0, top - y, header)
- Math.max(0, height + y - bottom)
)
return {
offset: top - header,
height,
active: top - header <= y
}
}),
distinctUntilChanged((a, b) => (
a.offset === b.offset &&
a.height === b.height &&
a.active === b.active
)),
shareReplay(1)
)
}

View File

@ -1,149 +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,
animationFrameScheduler,
combineLatest,
pipe
} from "rxjs"
import {
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
switchMap,
tap
} from "rxjs/operators"
import { Viewport, watchElementSize } from "browser"
import { Header } from "../../header"
import { Main } from "../_"
import {
resetHeaderShadow,
setHeaderShadow
} from "../set"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
header$: Observable<Header> /* Header observable */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch main area
*
* This function returns an observable that computes the visual parameters of
* the main area which depends on the viewport vertical offset and height, as
* well as the height of the header element, if the header is fixed.
*
* @param el - Main area element
* @param options - Options
*
* @return Main area observable
*/
export function watchMain(
el: HTMLElement, { header$, viewport$ }: WatchOptions
): Observable<Main> {
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
map(({ height }) => height),
distinctUntilChanged()
)
/* Compute the main area's top and bottom borders */
const border$ = adjust$
.pipe(
switchMap(() => watchElementSize(el)
.pipe(
map(({ height }) => ({
top: el.offsetTop,
bottom: el.offsetTop + height
})),
distinctUntilKeyChanged("bottom")
)
)
)
/* Compute the main area's offset, visible height and if we scrolled past */
return combineLatest([adjust$, border$, viewport$])
.pipe(
map(([header, { top, bottom }, { offset: { y }, size: { height } }]) => {
height = Math.max(0, height
- Math.max(0, top - y, header)
- Math.max(0, height + y - bottom)
)
return {
offset: top - header,
height,
active: top - header <= y
}
}),
distinctUntilChanged<Main>((a, b) => {
return a.offset === b.offset
&& a.height === b.height
&& a.active === b.active
})
)
}
/* ------------------------------------------------------------------------- */
/**
* Apply header shadow
*
* @param el - Header element
*
* @return Operator function
*/
export function applyHeaderShadow(
el: HTMLElement
): MonoTypeOperatorFunction<Main> {
return pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(({ active }) => {
setHeaderShadow(el, active)
}),
/* Reset on complete or error */
finalize(() => {
resetHeaderShadow(el)
})
)
}

View File

@ -1,110 +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, OperatorFunction, of, pipe } from "rxjs"
import { map, switchMap } from "rxjs/operators"
import { Viewport } from "browser"
import { Header } from "../header"
import { Main } from "../main"
import {
Sidebar,
applySidebar,
watchSidebar
} from "../shared"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Navigation for [screen -]
*/
interface NavigationBelowScreen {} // tslint:disable-line
/**
* Navigation for [screen +]
*/
interface NavigationAboveScreen {
sidebar: Sidebar /* Sidebar */
}
/* ------------------------------------------------------------------------- */
/**
* Navigation
*/
export type Navigation =
| NavigationBelowScreen
| NavigationAboveScreen
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
viewport$: Observable<Viewport> /* Viewport observable */
screen$: Observable<boolean> /* Screen media observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount navigation from source observable
*
* @param options - Options
*
* @return Operator function
*/
export function mountNavigation(
{ header$, main$, viewport$, screen$ }: MountOptions
): OperatorFunction<HTMLElement, Navigation> {
return pipe(
switchMap(el => screen$
.pipe(
switchMap(screen => {
/* [screen +]: Mount navigation in sidebar */
if (screen) {
return watchSidebar(el, { main$, viewport$ })
.pipe(
applySidebar(el, { header$ }),
map(sidebar => ({ sidebar }))
)
/* [screen -]: Mount navigation in drawer */
} else {
return of({})
}
})
)
)
)
}

View File

@ -20,60 +20,70 @@
* IN THE SOFTWARE.
*/
import { Observable, OperatorFunction, combineLatest, pipe } from "rxjs"
import {
filter,
Observable,
Subject,
combineLatest,
fromEvent,
merge,
defer
} from "rxjs"
import {
delay,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
mapTo,
sample,
startWith,
switchMap,
take
takeLast,
takeUntil,
tap
} from "rxjs/operators"
import { WorkerHandler } from "browser"
import {
SearchMessage,
SearchResult,
isSearchQueryMessage,
isSearchReadyMessage
} from "integrations/search"
resetSearchQueryPlaceholder,
setSearchQueryPlaceholder
} from "~/actions"
import {
getElementOrThrow,
setElementFocus,
setToggle,
watchElementFocus
} from "~/browser"
import {
SearchTransformFn,
defaultTransform,
SearchWorker,
SearchQueryMessage,
SearchMessageType,
setupSearchWorker,
} from "~/integrations"
import { configuration } from "~/_"
import { SearchQuery } from "../query"
import { Component } from "../../_"
import { mountSearchQuery, SearchQuery } from "../query"
import { mountSearchResult, SearchResult } from "../result"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search status
*/
export type SearchStatus =
| "waiting" /* Search waiting for initialization */
| "ready" /* Search ready */
/* ------------------------------------------------------------------------- */
/**
* Search
*/
export interface Search {
status: SearchStatus /* Search status */
query: SearchQuery /* Search query */
result: SearchResult[] /* Search result list */
}
export type Search =
| SearchQuery
| SearchResult
/* ----------------------------------------------------------------------------
* Helper types
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Mount options
*
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
reset$: Observable<void> /* Search reset observable */
result$: Observable<SearchResult[]> /* Search result observable */
function fetchSearchIndex() {
}
/* ----------------------------------------------------------------------------
@ -81,46 +91,57 @@ interface MountOptions {
* ------------------------------------------------------------------------- */
/**
* Mount search from source observable
* Mount search
*
* @param handler - Worker handler
* @param options - Options
* @param el - Search element
*
* @return Operator function
* @return Search component observable
*/
export function mountSearch(
{ rx$, tx$ }: WorkerHandler<SearchMessage>,
{ query$, reset$, result$ }: MountOptions
): OperatorFunction<HTMLElement, Search> {
return pipe(
switchMap(() => {
el: HTMLElement
): Observable<Component<Search>> {
/* Compute search status */
const status$ = rx$
.pipe(
filter(isSearchReadyMessage),
mapTo<SearchStatus>("ready"),
startWith("waiting")
) as Observable<SearchStatus>
const searchQueryEl = getElementOrThrow<HTMLInputElement>("[data-md-component=search-query]", el)
const searchResultEl = getElementOrThrow("[data-md-component=search-result]", el)
/* Re-emit the latest query when search is ready */
tx$
.pipe(
filter(isSearchQueryMessage),
sample(status$),
take(1)
)
.subscribe(tx$.next.bind(tx$))
const config = configuration()
/* Combine into single observable */
return combineLatest([status$, query$, result$, reset$])
.pipe(
map(([status, query, result]) => ({
status,
query,
result
}))
)
})
// TODO: determine correct BASE URL -> may change on instant loading!
const index$ = defer(() => fetch(`${config.base}/search/search_index.json`, {
credentials: "same-origin"
}).then(res => res.json()))
// TODO: shouldnt be necessary, as it's done from config?
const worker$ = setupSearchWorker(config.search, {
index$
})
// TODO: hand transformFn to
// __search.transform -> search transform
// __search.index -> search index
const query$ = mountSearchQuery(searchQueryEl, worker$)
const result$ = mountSearchResult(searchResultEl, worker$, { query$ })
return merge(
query$,
result$
// /* Search query */
// ...getElements("[data-md-component=search-query]", el)
// .map(child => mountSearchQuery(child, worker$)),
// /* Search result */
// ...getElements("[data-md-component=search-query]", el)
// .map(child => mountSearchResult(child, worker$)),
)
// /* Create and return component */
// return watchSearchQuery(el, transform)
// .pipe(
// tap(internal$),
// finalize(() => internal$.complete()),
// map(state => ({ ref: el, ...state }))
// )
}

View File

@ -22,5 +22,4 @@
export * from "./_"
export * from "./query"
export * from "./reset"
export * from "./result"

View File

@ -20,6 +20,145 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./react"
export * from "./set"
import {
Observable,
Subject,
combineLatest,
fromEvent,
merge
} from "rxjs"
import {
delay,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
startWith,
takeLast,
takeUntil,
tap
} from "rxjs/operators"
import {
resetSearchQueryPlaceholder,
setSearchQueryPlaceholder
} from "~/actions"
import {
setElementFocus,
setToggle,
watchElementFocus
} from "~/browser"
import {
defaultTransform,
SearchWorker,
SearchQueryMessage,
SearchMessageType
} from "~/integrations"
import { Component } from "../../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search query
*/
export interface SearchQuery {
value: string /* Query value */
focus: boolean /* Query focus */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch search query
*
* Note that the focus event which triggers re-reading the current query value
* is delayed by `1ms` so the input's empty state is allowed to propagate.
*
* @param el - Search query element
* @param transform - Transformation function
*
* @return Search query observable
*/
export function watchSearchQuery(
el: HTMLInputElement
): Observable<SearchQuery> {
const fn = __search?.transform || defaultTransform
/* Intercept focus and input events */
const focus$ = watchElementFocus(el)
const value$ = merge(
fromEvent(el, "keyup"),
fromEvent(el, "focus").pipe(delay(1))
)
.pipe(
map(() => fn(el.value)),
startWith(fn(el.value)),
distinctUntilChanged()
)
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ value, focus }))
)
}
/**
* Mount search query
*
* @param el - Search query element
* @param worker - Search worker
* @param transform - Transformation function
*
* @return Search query component observable
*/
export function mountSearchQuery(
el: HTMLInputElement, { tx$ }: SearchWorker
): Observable<Component<SearchQuery, HTMLInputElement>> {
const internal$ = new Subject<SearchQuery>()
/* Handle value changes */
internal$
.pipe(
distinctUntilKeyChanged("value"),
map(({ value }): SearchQueryMessage => ({
type: SearchMessageType.QUERY,
data: value
}))
)
.subscribe(tx$.next.bind(tx$))
/* Handle focus changes */
internal$
.pipe(
distinctUntilKeyChanged("focus")
)
.subscribe(({ focus }) => {
if (focus) {
setToggle("search", focus)
setSearchQueryPlaceholder(el, "")
} else {
resetSearchQueryPlaceholder(el)
}
})
/* Handle reset */
fromEvent(el.form!, "reset")
.pipe(
takeUntil(internal$.pipe(takeLast(1)))
)
.subscribe(() => setElementFocus(el))
/* Create and return component */
return watchSearchQuery(el)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -1,129 +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,
combineLatest,
fromEvent,
merge,
pipe
} from "rxjs"
import {
delay,
distinctUntilChanged,
finalize,
map,
startWith,
tap
} from "rxjs/operators"
import { watchElementFocus } from "browser"
import { SearchTransformFn, defaultTransform } from "integrations"
import { SearchQuery } from "../_"
import {
resetSearchQueryPlaceholder,
setSearchQueryPlaceholder
} from "../set"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
transform?: SearchTransformFn /* Transformation function */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch search query
*
* Note that the focus event which triggers re-reading the current query value
* is delayed by `1ms` so the input's empty state is allowed to propagate.
*
* @param el - Search query element
* @param options - Options
*
* @return Search query observable
*/
export function watchSearchQuery(
el: HTMLInputElement, { transform }: WatchOptions = {}
): Observable<SearchQuery> {
const fn = transform || defaultTransform
/* Intercept keyboard events */
const value$ = merge(
fromEvent(el, "keyup"),
fromEvent(el, "focus").pipe(delay(1))
)
.pipe(
map(() => fn(el.value)),
startWith(fn(el.value)),
distinctUntilChanged()
)
/* Intercept focus events */
const focus$ = watchElementFocus(el)
/* Combine into single observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ value, focus }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Apply search query
*
* @param el - Search query element
*
* @return Operator function
*/
export function applySearchQuery(
el: HTMLInputElement
): MonoTypeOperatorFunction<SearchQuery> {
return pipe(
/* Hide placeholder when search is focused */
tap(({ focus }) => {
if (focus) {
setSearchQueryPlaceholder(el, "")
} else {
resetSearchQueryPlaceholder(el)
}
}),
/* Reset on complete or error */
finalize(() => {
resetSearchQueryPlaceholder(el)
})
)
}

View File

@ -1,57 +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 { OperatorFunction, pipe } from "rxjs"
import {
mapTo,
startWith,
switchMap,
switchMapTo,
tap
} from "rxjs/operators"
import { setElementFocus } from "browser"
import { useComponent } from "../../../_"
import { watchSearchReset } from "../react"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search reset from source observable
*
* @return Operator function
*/
export function mountSearchReset(): OperatorFunction<HTMLElement, void> {
return pipe(
switchMap(el => watchSearchReset(el)
.pipe(
switchMapTo(useComponent("search-query")),
tap(setElementFocus),
mapTo(undefined)
)
),
startWith(undefined)
)
}

View File

@ -1,101 +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, OperatorFunction, pipe } from "rxjs"
import {
distinctUntilChanged,
filter,
map,
mapTo,
startWith,
switchMap
} from "rxjs/operators"
import { WorkerHandler, watchElementOffset } from "browser"
import {
SearchMessage,
SearchResult,
isSearchReadyMessage,
isSearchResultMessage
} from "integrations"
import { SearchQuery } from "../../query"
import { applySearchResult } from "../react"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search result from source observable
*
* @param handler - Worker handler
* @param options - Options
*
* @return Operator function
*/
export function mountSearchResult(
{ rx$ }: WorkerHandler<SearchMessage>, { query$ }: MountOptions
): OperatorFunction<HTMLElement, SearchResult[]> {
return pipe(
switchMap(el => {
const container = el.parentElement!
/* Compute if search is ready */
const ready$ = rx$
.pipe(
filter(isSearchReadyMessage),
mapTo(true)
)
/* Compute whether there are more search results to fetch */
const fetch$ = watchElementOffset(container)
.pipe(
map(({ y }) => {
return y >= container.scrollHeight - container.offsetHeight - 16
}),
distinctUntilChanged(),
filter(Boolean)
)
/* Apply search results */
return rx$
.pipe(
filter(isSearchResultMessage),
map(({ data }) => data),
applySearchResult(el, { query$, ready$, fetch$ }),
startWith([])
)
})
)
}

View File

@ -20,6 +20,113 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./react"
export * from "./set"
import { Observable, Subject } from "rxjs"
import {
filter,
finalize,
map,
startWith,
tap,
withLatestFrom
} from "rxjs/operators"
import {
addToSearchResultList,
resetSearchResultList,
resetSearchResultMeta,
setSearchResultMeta
} from "~/actions"
import { getElementOrThrow } from "~/browser"
import {
SearchResult as SearchResultData,
SearchWorker,
isSearchResultMessage
} from "~/integrations"
import { renderSearchResult } from "~/templates"
import { Component } from "../../_"
import { SearchQuery } from "../query"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search result
*/
export interface SearchResult {
data: SearchResultData[] /* Search result data */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount search result list
*
* @param el - Search result list element
* @param worker - Search worker
* @param options - Options
*
* @return Search result list component observable
*/
export function mountSearchResult(
el: HTMLElement, { rx$ }: SearchWorker, { query$ }: MountOptions
): Observable<Component<SearchResult>> {
const internal$ = new Subject<SearchResult>()
/* Update search result metadata */
const meta = getElementOrThrow(":first-child", el)
internal$
.pipe(
withLatestFrom(query$)
)
.subscribe(([{ data }, { value }]) => {
if (value)
setSearchResultMeta(meta, data.length)
else
resetSearchResultMeta(meta)
})
/* Update search result list */
const list = getElementOrThrow(":last-child", el)
internal$
.subscribe(({ data }) => {
resetSearchResultList(list)
/* Compute thresholds and search results */
const thresholds = [...data.map(([best]) => best.score), 0]
for (let index = 0; index < data.length; index++)
addToSearchResultList(list, renderSearchResult(
data[index++], thresholds[index]
))
})
/* Filter search result list */
const result$ = rx$
.pipe(
filter(isSearchResultMessage),
map(({ data }) => ({ data })),
startWith({ data: [] })
)
/* Create and return component */
return result$
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -1,129 +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,
animationFrameScheduler,
pipe
} from "rxjs"
import {
finalize,
map,
mapTo,
observeOn,
scan,
switchMap,
withLatestFrom
} from "rxjs/operators"
import { getElementOrThrow } from "browser"
import { SearchResult } from "integrations/search"
import { renderSearchResult } from "templates"
import { SearchQuery } from "../../query"
import {
addToSearchResultList,
resetSearchResultList,
resetSearchResultMeta,
setSearchResultMeta
} from "../set"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Apply options
*/
interface ApplyOptions {
query$: Observable<SearchQuery> /* Search query observable */
ready$: Observable<boolean> /* Search ready observable */
fetch$: Observable<boolean> /* Result fetch observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Apply search results
*
* This function will perform a lazy rendering of the search results, depending
* on the vertical offset of the search result container. When the scroll offset
* reaches the bottom of the element, more results are fetched and rendered.
*
* @param el - Search result element
* @param options - Options
*
* @return Operator function
*/
export function applySearchResult(
el: HTMLElement, { query$, ready$, fetch$ }: ApplyOptions
): MonoTypeOperatorFunction<SearchResult[]> {
const list = getElementOrThrow(".md-search-result__list", el)
const meta = getElementOrThrow(".md-search-result__meta", el)
return pipe(
/* Apply search result metadata */
withLatestFrom(query$, ready$),
map(([result, query]) => {
if (query.value) {
setSearchResultMeta(meta, result.length)
} else {
resetSearchResultMeta(meta)
}
return result
}),
/* Apply search result list */
switchMap(result => {
const thresholds = [...result.map(([best]) => best.score), 0]
return fetch$
.pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
scan(index => {
const container = el.parentElement!
while (index < result.length) {
addToSearchResultList(list, renderSearchResult(
result[index++], thresholds[index]
))
if (container.scrollHeight - container.offsetHeight > 16)
break
}
return index
}, 0),
/* Re-map to search result */
mapTo(result),
/* Reset on complete or error */
finalize(() => {
resetSearchResultList(list)
})
)
}
)
)
}

View File

@ -1,33 +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.
*/
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Sidebar
*/
export interface Sidebar {
height: number /* Sidebar height */
lock: boolean /* Sidebar lock */
}

View File

@ -21,11 +21,10 @@
*/
import {
MonoTypeOperatorFunction,
Observable,
Subject,
animationFrameScheduler,
combineLatest,
pipe
combineLatest
} from "rxjs"
import {
distinctUntilChanged,
@ -36,17 +35,29 @@ import {
withLatestFrom
} from "rxjs/operators"
import { Viewport } from "browser"
import { Header } from "../../../header"
import { Main } from "../../../main"
import { Sidebar } from "../_"
import {
resetSidebarHeight,
resetSidebarOffset,
setSidebarHeight,
setSidebarOffset
} from "../set"
} from "~/actions"
import { Viewport } from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
import { Main } from "../main"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Sidebar
*/
export interface Sidebar {
height: number /* Sidebar height */
locked: boolean /* User scrolled past header */
}
/* ----------------------------------------------------------------------------
* Helper types
@ -56,15 +67,17 @@ import {
* Watch options
*/
interface WatchOptions {
main$: Observable<Main> /* Main area observable */
viewport$: Observable<Viewport> /* Viewport observable */
main$: Observable<Main> /* Main area observable */
}
/**
* Apply options
* Mount options
*/
interface ApplyOptions {
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
}
/* ----------------------------------------------------------------------------
@ -85,10 +98,11 @@ interface ApplyOptions {
* @return Sidebar observable
*/
export function watchSidebar(
el: HTMLElement, { main$, viewport$ }: WatchOptions
el: HTMLElement, { viewport$, main$ }: WatchOptions
): Observable<Sidebar> {
const adjust = el.parentElement!.offsetTop
- el.parentElement!.parentElement!.offsetTop
const adjust =
el.parentElement!.offsetTop -
el.parentElement!.parentElement!.offsetTop
/* Compute the sidebar's available height and if it should be locked */
return combineLatest([main$, viewport$])
@ -99,51 +113,53 @@ export function watchSidebar(
- adjust
return {
height,
lock: y >= offset + adjust
locked: y >= offset + adjust
}
}),
distinctUntilChanged<Sidebar>((a, b) => {
return a.height === b.height
&& a.lock === b.lock
})
distinctUntilChanged((a, b) => (
a.height === b.height &&
a.locked === b.locked
))
)
}
/* ------------------------------------------------------------------------- */
/**
* Apply sidebar
* Mount sidebar
*
* @param el - Sidebar element
* @param options - Options
*
* @return Operator function
* @return Sidebar component observable
*/
export function applySidebar(
el: HTMLElement, { header$ }: ApplyOptions
): MonoTypeOperatorFunction<Sidebar> {
return pipe(
export function mountSidebar(
el: HTMLElement, { header$, ...options }: MountOptions
): Observable<Component<Sidebar>> {
const internal$ = new Subject<Sidebar>()
internal$
.pipe(
observeOn(animationFrameScheduler),
withLatestFrom(header$)
)
.subscribe({
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
withLatestFrom(header$),
tap(([{ height, lock }, { height: offset }]) => {
setSidebarHeight(el, height)
/* Update height and offset */
next([{ height }, { height: offset }]) {
setSidebarHeight(el, height)
setSidebarOffset(el, offset)
},
/* Set offset in locked state depending on header height */
if (lock)
setSidebarOffset(el, offset)
else
resetSidebarOffset(el)
}),
/* Reset on complete */
complete() {
resetSidebarOffset(el)
resetSidebarHeight(el)
}
})
/* Re-map to sidebar */
map(([sidebar]) => sidebar),
/* Reset on complete or error */
finalize(() => {
resetSidebarOffset(el)
resetSidebarHeight(el)
})
)
/* Create and return component */
return watchSidebar(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -0,0 +1,129 @@
/*
* 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, Subject, defer, of, NEVER } from "rxjs"
import {
catchError,
filter,
finalize,
map,
shareReplay,
tap
} from "rxjs/operators"
import { setSourceFacts, setSourceState } from "~/actions"
import { renderSourceFacts } from "~/templates"
import { hash } from "~/utilities"
import { Component } from "../../_"
import {
fetchSourceFacts,
SourceFacts
} from "../facts"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Repository information
*/
export interface Source {
facts: SourceFacts /* Repository facts */
}
/* ----------------------------------------------------------------------------
* Data
* ------------------------------------------------------------------------- */
/**
* Repository facts observable
*/
let fetch$: Observable<Source>
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch repository information
*
* @param el - Repository information element
*
* @return Repository information observable
*/
export function watchSource(
el: HTMLAnchorElement
): Observable<Source> {
const digest = hash(el.href).toString()
/* Fetch repository facts once */
return fetch$ ||= defer(() => {
const data = sessionStorage.getItem(digest)
if (data) {
return of(JSON.parse(data))
} else {
const value$ = fetchSourceFacts(el.href)
value$.subscribe(value => {
try {
sessionStorage.setItem(digest, JSON.stringify(value))
} catch (err) {
/* Uncritical, just swallow */
}
})
/* Return value */
return value$
}
})
.pipe(
catchError(() => NEVER),
filter(facts => facts.length > 0),
map(facts => ({ facts })),
shareReplay(1)
)
}
/**
* Mount repository information
*
* @param el - Repository information element
*
* @return Repository information component observable
*/
export function mountSource(
el: HTMLAnchorElement
): Observable<Component<Source>> {
const internal$ = new Subject<Source>()
internal$.subscribe(({ facts }) => {
setSourceFacts(el, renderSourceFacts(facts))
setSourceState(el, "done")
})
/* Create and return component */
return watchSource(el)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -20,57 +20,49 @@
* IN THE SOFTWARE.
*/
import { Observable, combineLatest } from "rxjs"
import { distinctUntilKeyChanged, map } from "rxjs/operators"
import { NEVER, Observable } from "rxjs"
import { Viewport, getElements } from "browser"
import { fetchSourceFactsFromGitHub } from "../github"
import { fetchSourceFactsFromGitLab } from "../gitlab"
/* ----------------------------------------------------------------------------
* Helper types
* Types
* ------------------------------------------------------------------------- */
/**
* Mount options
* Repository facts
*/
interface MountOptions {
document$: Observable<Document> /* Document observable */
viewport$: Observable<Viewport> /* Viewport observable */
}
export type SourceFacts = string[]
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch all `code` elements
* Fetch repository facts
*
* This function will make overflowing code blocks focusable via keyboard, so
* they can be scrolled without a mouse.
* @param url - Repository URL
*
* @param options - Options
* @return Repository facts observable
*/
export function patchCodeBlocks(
{ document$, viewport$ }: MountOptions
): void {
const els$ = document$
.pipe(
map(() => getElements<HTMLTableElement>("pre > code"))
)
export function fetchSourceFacts(
url: string
): Observable<SourceFacts> {
const [type] = url.match(/(git(?:hub|lab))/i) || []
switch (type.toLowerCase()) {
/* Observe viewport size only */
const size$ = viewport$
.pipe(
distinctUntilKeyChanged("size")
)
/* GitHub repository */
case "github":
const [, user, repo] = url.match(/^.+github\.com\/([^\/]+)\/?([^\/]+)?/i)!
return fetchSourceFactsFromGitHub(user, repo)
/* Make overflowing elements focusable */
combineLatest([els$, size$])
.subscribe(([els]) => {
for (const el of els) {
if (el.scrollWidth > el.clientWidth)
el.setAttribute("tabindex", "0")
else
el.removeAttribute("tabindex")
}
})
/* GitLab repository */
case "gitlab":
const [, base, slug] = url.match(/^.+?([^\/]*gitlab[^\/]+)\/(.+?)\/?$/i)!
return fetchSourceFactsFromGitLab(base, slug)
/* Everything else */
default:
return NEVER
}
}

View File

@ -26,25 +26,24 @@ import {
defaultIfEmpty,
filter,
map,
share,
switchMap
} from "rxjs/operators"
import { round } from "utilities"
import { round } from "~/utilities"
import { SourceFacts } from ".."
import { SourceFacts } from "../_"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch GitHub source facts
* Fetch GitHub repository facts
*
* @param user - GitHub user
* @param repo - GitHub repository
*
* @return Source facts observable
* @return Repository facts observable
*/
export function fetchSourceFactsFromGitHub(
user: string, repo?: string
@ -74,7 +73,6 @@ export function fetchSourceFactsFromGitHub(
]
}
}),
defaultIfEmpty([]),
share()
defaultIfEmpty([])
)
}

View File

@ -26,25 +26,24 @@ import {
defaultIfEmpty,
filter,
map,
share,
switchMap
} from "rxjs/operators"
import { round } from "utilities"
import { round } from "~/utilities"
import { SourceFacts } from ".."
import { SourceFacts } from "../_"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Fetch GitLab source facts
* Fetch GitLab repository facts
*
* @param base - GitLab base
* @param project - GitLab project
*
* @return Source facts observable
* @return Repository facts observable
*/
export function fetchSourceFactsFromGitLab(
base: string, project: string
@ -58,7 +57,6 @@ export function fetchSourceFactsFromGitLab(
`${round(star_count)} Stars`,
`${round(forks_count)} Forks`
])),
defaultIfEmpty([]),
share()
defaultIfEmpty([])
)
}

View File

@ -21,5 +21,5 @@
*/
export * from "./_"
export * from "./react"
export * from "./set"
export * from "./github"
export * from "./gitlab"

View File

@ -20,4 +20,5 @@
* IN THE SOFTWARE.
*/
export * from "./sidebar"
export * from "./_"
export * from "./facts"

View File

@ -20,6 +20,115 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./react"
export * from "./set"
import { Observable, animationFrameScheduler, Subject } from "rxjs"
import {
distinctUntilKeyChanged,
finalize,
map,
observeOn,
tap
} from "rxjs/operators"
import { resetTabsState, setTabsState } from "~/actions"
import { Viewport, watchViewportAt } from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Navigation tabs
*/
export interface Tabs {
hidden: boolean /* User scrolled past tabs */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch navigation tabs
*
* @param el - Navigation tabs element
* @param options - Options
*
* @return Navigation tabs observable
*/
export function watchTabs(
el: HTMLElement, { viewport$, header$ }: WatchOptions
): Observable<Tabs> {
return watchViewportAt(el, { header$, viewport$ })
.pipe(
map(({ offset: { y } }) => {
return {
hidden: y >= 10
}
}),
distinctUntilKeyChanged("hidden")
)
}
/**
* Mount navigation tabs
*
* @param el - Navigation tabs element
* @param options - Options
*
* @return Navigation tabs component observable
*/
export function mountTabs(
el: HTMLElement, options: MountOptions
): Observable<Component<Tabs>> {
const internal$ = new Subject<Tabs>()
internal$
.pipe(
observeOn(animationFrameScheduler)
)
.subscribe({
/* Update state */
next({ hidden }) {
if (hidden)
setTabsState(el, "hidden")
else
resetTabsState(el)
},
/* Reset on complete */
complete() {
resetTabsState(el)
}
})
/* Create and return component */
return watchTabs(el, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -1,63 +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,
animationFrameScheduler,
pipe
} from "rxjs"
import { finalize, observeOn, tap } from "rxjs/operators"
import { Tabs } from "../_"
import {
resetTabsHidden,
setTabsHidden
} from "../set"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Apply tabs
*
* @param el - Tabs element
*
* @return Operator function
*/
export function applyTabs(
el: HTMLElement
): MonoTypeOperatorFunction<Tabs> {
return pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(({ hidden }) => {
setTabsHidden(el, hidden)
}),
/* Reset on complete or error */
finalize(() => {
resetTabsHidden(el)
})
)
}

View File

@ -1,136 +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,
OperatorFunction,
combineLatest,
of,
pipe
} from "rxjs"
import { map, switchMap } from "rxjs/operators"
import { Viewport, getElements } from "browser"
import { Header } from "../../header"
import { Main } from "../../main"
import {
Sidebar,
applySidebar,
watchSidebar
} from "../../shared"
import {
AnchorList,
applyAnchorList,
watchAnchorList
} from "../anchor"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Table of contents for [tablet -]
*/
interface TableOfContentsBelowTablet {} // tslint:disable-line
/**
* Table of contents for [tablet +]
*/
interface TableOfContentsAboveTablet {
sidebar: Sidebar /* Sidebar */
anchors: AnchorList /* Anchor list */
}
/* ------------------------------------------------------------------------- */
/**
* Table of contents
*/
export type TableOfContents =
| TableOfContentsBelowTablet
| TableOfContentsAboveTablet
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main area observable */
viewport$: Observable<Viewport> /* Viewport observable */
tablet$: Observable<boolean> /* Tablet media observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Mount table of contents from source observable
*
* @param options - Options
*
* @return Operator function
*/
export function mountTableOfContents(
{ header$, main$, viewport$, tablet$ }: MountOptions
): OperatorFunction<HTMLElement, TableOfContents> {
return pipe(
switchMap(el => tablet$
.pipe(
switchMap(tablet => {
/* [tablet +]: Mount table of contents in sidebar */
if (tablet) {
const els = getElements<HTMLAnchorElement>(".md-nav__link", el)
/* Watch and apply sidebar */
const sidebar$ = watchSidebar(el, { main$, viewport$ })
.pipe(
applySidebar(el, { header$ })
)
/* Watch and apply anchor list (scroll spy) */
const anchors$ = watchAnchorList(els, { header$, viewport$ })
.pipe(
applyAnchorList(els)
)
/* Combine into single hot observable */
return combineLatest([sidebar$, anchors$])
.pipe(
map(([sidebar, anchors]) => ({ sidebar, anchors }))
)
/* [tablet -]: Unmount table of contents */
} else {
return of({})
}
})
)
)
)
}

View File

@ -1,246 +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,
animationFrameScheduler,
combineLatest,
pipe
} from "rxjs"
import {
bufferCount,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
scan,
startWith,
switchMap,
tap
} from "rxjs/operators"
import { Viewport, getElement, watchElementSize } from "browser"
import { Header } from "../../../header"
import { AnchorList } from "../_"
import {
resetAnchorActive,
resetAnchorBlur,
setAnchorActive,
setAnchorBlur
} from "../set"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
header$: Observable<Header> /* Header observable */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch anchor list
*
* This is effectively a scroll-spy implementation which will account for the
* fixed header and automatically re-calculate anchor offsets when the viewport
* is resized. The returned observable will only emit if the anchor list needs
* to be repainted.
*
* This implementation tracks an anchor element's entire path starting from its
* level up to the top-most anchor element, e.g. `[h3, h2, h1]`. Although the
* Material theme currently doesn't make use of this information, it enables
* the styling of the entire hierarchy through customization.
*
* Note that the current anchor is the last item of the `prev` anchor list.
*
* @param els - Anchor elements
* @param options - Options
*
* @return Anchor list observable
*/
export function watchAnchorList(
els: HTMLAnchorElement[], { header$, viewport$ }: WatchOptions
): Observable<AnchorList> {
const table = new Map<HTMLAnchorElement, HTMLElement>()
for (const el of els) {
const id = decodeURIComponent(el.hash.substring(1))
const target = getElement(`[id="${id}"]`)
if (typeof target !== "undefined")
table.set(el, target)
}
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
map(header => 24 + header.height)
)
/* Compute partition of previous and next anchors */
const partition$ = watchElementSize(document.body)
.pipe(
distinctUntilKeyChanged("height"),
/* Build index to map anchor paths to vertical offsets */
map(() => {
let path: HTMLAnchorElement[] = []
return [...table].reduce((index, [anchor, target]) => {
while (path.length) {
const last = table.get(path[path.length - 1])!
if (last.tagName >= target.tagName) {
path.pop()
} else {
break
}
}
/* If the current anchor is hidden, continue with its parent */
let offset = target.offsetTop
while (!offset && target.parentElement) {
target = target.parentElement
offset = target.offsetTop
}
/* Map reversed anchor path to vertical offset */
return index.set(
[...path = [...path, anchor]].reverse(),
offset
)
}, new Map<HTMLAnchorElement[], number>())
}),
/* Re-compute partition when viewport offset changes */
switchMap(index => combineLatest([adjust$, viewport$])
.pipe(
scan(([prev, next], [adjust, { offset: { y } }]) => {
/* Look forward */
while (next.length) {
const [, offset] = next[0]
if (offset - adjust < y) {
prev = [...prev, next.shift()!]
} else {
break
}
}
/* Look backward */
while (prev.length) {
const [, offset] = prev[prev.length - 1]
if (offset - adjust >= y) {
next = [prev.pop()!, ...next]
} else {
break
}
}
/* Return partition */
return [prev, next]
}, [[], [...index]]),
distinctUntilChanged((a, b) => {
return a[0] === b[0]
&& a[1] === b[1]
})
)
)
)
/* Compute and return anchor list migrations */
return partition$
.pipe(
map(([prev, next]) => ({
prev: prev.map(([path]) => path),
next: next.map(([path]) => path)
})),
/* Extract anchor list migrations */
startWith({ prev: [], next: [] }),
bufferCount(2, 1),
map(([a, b]) => {
/* Moving down */
if (a.prev.length < b.prev.length) {
return {
prev: b.prev.slice(Math.max(0, a.prev.length - 1), b.prev.length),
next: []
}
/* Moving up */
} else {
return {
prev: b.prev.slice(-1),
next: b.next.slice(0, b.next.length - a.next.length)
}
}
})
)
}
/* ------------------------------------------------------------------------- */
/**
* Apply anchor list
*
* @param els - Anchor elements
*
* @return Operator function
*/
export function applyAnchorList(
els: HTMLAnchorElement[]
): MonoTypeOperatorFunction<AnchorList> {
return pipe(
/* Defer repaint to next animation frame */
observeOn(animationFrameScheduler),
tap(({ prev, next }) => {
/* Look forward */
for (const [el] of next) {
resetAnchorActive(el)
resetAnchorBlur(el)
}
/* Look backward */
prev.forEach(([el], index) => {
setAnchorActive(el, index === prev.length - 1)
setAnchorBlur(el, true)
})
}),
/* Reset on complete or error */
finalize(() => {
for (const el of els) {
resetAnchorActive(el)
resetAnchorBlur(el)
}
})
)
}

View File

@ -20,5 +20,253 @@
* IN THE SOFTWARE.
*/
export * from "./_"
export * from "./anchor"
import {
Observable,
Subject,
animationFrameScheduler,
combineLatest
} from "rxjs"
import {
bufferCount,
distinctUntilChanged,
distinctUntilKeyChanged,
finalize,
map,
observeOn,
scan,
startWith,
switchMap,
tap
} from "rxjs/operators"
import {
resetAnchorActive,
resetAnchorState,
setAnchorActive,
setAnchorState
} from "~/actions"
import {
getElement,
getElements,
Viewport,
watchElementSize
} from "~/browser"
import { Component } from "../_"
import { Header } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Table of contents
*/
export interface TableOfContents {
prev: HTMLAnchorElement[][] /* Anchors (previous) */
next: HTMLAnchorElement[][] /* Anchors (next) */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Watch options
*/
interface WatchOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/**
* Mount options
*/
interface MountOptions {
viewport$: Observable<Viewport> /* Viewport observable */
header$: Observable<Header> /* Header observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch table of contents
*
* This is effectively a scroll spy implementation which will account for the
* fixed header and automatically re-calculate anchor offsets when the viewport
* is resized. The returned observable will only emit if the table of contents
* needs to be repainted.
*
* This implementation tracks an anchor element's entire path starting from its
* level up to the top-most anchor element, e.g. `[h3, h2, h1]`. Although the
* Material theme currently doesn't make use of this information, it enables
* the styling of the entire hierarchy through customization.
*
* Note that the current anchor is the last item of the `prev` anchor list.
*
* @param anchors - Anchor elements
* @param options - Options
*
* @return Table of contents observable
*/
export function watchTableOfContents(
anchors: HTMLAnchorElement[], { viewport$, header$ }: WatchOptions
): Observable<TableOfContents> {
const table = new Map<HTMLAnchorElement, HTMLElement>()
for (const anchor of anchors) {
const id = decodeURIComponent(anchor.hash.substring(1))
const target = getElement(`[id="${id}"]`)
if (typeof target !== "undefined")
table.set(anchor, target)
}
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
map(header => 24 + header.height)
)
/* Compute partition of previous and next anchors */
const partition$ = watchElementSize(document.body)
.pipe(
distinctUntilKeyChanged("height"),
/* Build index to map anchor paths to vertical offsets */
map(() => {
let path: HTMLAnchorElement[] = []
return [...table].reduce((index, [anchor, target]) => {
while (path.length) {
const last = table.get(path[path.length - 1])!
if (last.tagName >= target.tagName) {
path.pop()
} else {
break
}
}
/* If the current anchor is hidden, continue with its parent */
let offset = target.offsetTop
while (!offset && target.parentElement) {
target = target.parentElement
offset = target.offsetTop
}
/* Map reversed anchor path to vertical offset */
return index.set(
[...path = [...path, anchor]].reverse(),
offset
)
}, new Map<HTMLAnchorElement[], number>())
}),
/* Re-compute partition when viewport offset changes */
switchMap(index => combineLatest([adjust$, viewport$])
.pipe(
scan(([prev, next], [adjust, { offset: { y } }]) => {
/* Look forward */
while (next.length) {
const [, offset] = next[0]
if (offset - adjust < y) {
prev = [...prev, next.shift()!]
} else {
break
}
}
/* Look backward */
while (prev.length) {
const [, offset] = prev[prev.length - 1]
if (offset - adjust >= y) {
next = [prev.pop()!, ...next]
} else {
break
}
}
/* Return partition */
return [prev, next]
}, [[], [...index]]),
distinctUntilChanged((a, b) => (
a[0] === b[0] &&
a[1] === b[1]
))
)
)
)
/* Compute and return anchor list migrations */
return partition$
.pipe(
map(([prev, next]) => ({
prev: prev.map(([path]) => path),
next: next.map(([path]) => path)
})),
/* Extract anchor list migrations */
startWith({ prev: [], next: [] }),
bufferCount(2, 1),
map(([a, b]) => {
/* Moving down */
if (a.prev.length < b.prev.length) {
return {
prev: b.prev.slice(Math.max(0, a.prev.length - 1), b.prev.length),
next: []
}
/* Moving up */
} else {
return {
prev: b.prev.slice(-1),
next: b.next.slice(0, b.next.length - a.next.length)
}
}
})
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount table of contents
*
* @param el - Anchor list element
* @param options - Options
*
* @return Table of contents component observable
*/
export function mountTableOfContents(
el: HTMLElement, options: MountOptions
): Observable<Component<TableOfContents>> {
const internal$ = new Subject<TableOfContents>()
internal$
.pipe(
observeOn(animationFrameScheduler),
)
.subscribe(({ prev, next }) => {
/* Look forward */
for (const [anchor] of next) {
resetAnchorActive(anchor)
resetAnchorState(anchor)
}
/* Look backward */
for (const [index, [anchor]] of prev.entries()) {
setAnchorActive(anchor, index === prev.length - 1)
setAnchorState(anchor, "blur")
}
})
/* Create and return component */
const anchors = getElements<HTMLAnchorElement>("[href^=\\#]", el)
return watchTableOfContents(anchors, options)
.pipe(
tap(internal$),
finalize(() => internal$.complete()),
map(state => ({ ref: el, ...state }))
)
}

View File

@ -20,483 +20,121 @@
* IN THE SOFTWARE.
*/
// DISCLAIMER: this file is still WIP. There're some refactoring opportunities
// which must be tackled after we gathered some feedback on v5.
// tslint:disable
import "focus-visible"
import { merge, NEVER, Observable, Subject } from "rxjs"
import { switchMap } from "rxjs/operators"
import {
merge,
combineLatest,
animationFrameScheduler,
fromEvent,
from,
defer,
of,
NEVER
} from "rxjs"
import {
delay,
switchMap,
tap,
filter,
withLatestFrom,
observeOn,
take,
shareReplay,
catchError,
map,
bufferCount,
distinctUntilKeyChanged,
mapTo,
startWith,
combineLatestWith,
distinctUntilChanged
} from "rxjs/operators"
import {
watchToggle,
setToggle,
getElementOrThrow,
getElements,
watchLocationTarget,
watchMedia,
watchDocument,
watchLocation,
watchLocationHash,
watchViewport,
isLocalLocation,
setLocationHash,
watchLocationBase,
getElement
} from "browser"
watchPrint,
watchViewport
} from "./browser"
import {
mountContent,
mountDialog,
mountHeader,
mountMain,
mountNavigation,
mountHeaderTitle,
mountSearch,
mountSidebar,
mountSource,
mountTableOfContents,
mountTabs,
useComponent,
setupComponents,
mountSearchQuery,
mountSearchReset,
mountSearchResult
} from "components"
watchHeader,
watchMain
} from "./components"
import {
setupClipboard,
setupDialog,
setupKeyboard,
setupInstantLoading,
setupSearchWorker,
SearchIndex,
SearchIndexPipeline
} from "integrations"
import {
patchCodeBlocks,
patchTables,
patchDetails,
patchScrollfix,
patchSource,
patchScripts
} from "patches"
import { isConfig } from "utilities"
setupClipboardJS
} from "./integrations"
import { translation } from "./_"
/* ------------------------------------------------------------------------- */
/* ----------------------------------------------------------------------------
* Program
* ------------------------------------------------------------------------- */
/* Denote that JavaScript is available */
/* Yay, JavaScript is available */
document.documentElement.classList.remove("no-js")
document.documentElement.classList.add("js")
/* Test for iOS */
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/g))
document.documentElement.classList.add("ios")
/* Set up subjects */
const target$ = watchLocationTarget()
/**
* Set scroll lock
*
* @param el - Scrollable element
* @param value - Vertical offset
*/
export function setScrollLock(
el: HTMLElement, value: number
): void {
el.setAttribute("data-md-state", "lock")
el.style.top = `-${value}px`
}
/**
* Reset scroll lock
*
* @param el - Scrollable element
*/
export function resetScrollLock(
el: HTMLElement
): void {
const value = -1 * parseInt(el.style.top, 10)
el.removeAttribute("data-md-state")
el.style.top = ""
if (value)
window.scrollTo(0, value)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Initialize Material for MkDocs
*
* @param config - Configuration
*/
export function initialize(config: unknown) {
if (!isConfig(config))
throw new SyntaxError(`Invalid configuration: ${JSON.stringify(config)}`)
/* Set up subjects */
const document$ = watchDocument()
const location$ = watchLocation()
/* Set up user interface observables */
const base$ = watchLocationBase(config.base, { location$ })
const hash$ = watchLocationHash()
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
/* ----------------------------------------------------------------------- */
/* Set up component bindings */
setupComponents([
"announce", /* Announcement bar */
"container", /* Container */
"header", /* Header */
"header-title", /* Header title */
"main", /* Main area */
"navigation", /* Navigation */
"search", /* Search */
"search-query", /* Search input */
"search-reset", /* Search reset */
"search-result", /* Search results */
"skip", /* Skip link */
"tabs", /* Tabs */
"toc" /* Table of contents */
], { document$ })
const keyboard$ = setupKeyboard()
// Hack: only make code blocks focusable on non-touch devices
if (matchMedia("(hover)").matches)
patchCodeBlocks({ document$, viewport$ })
patchDetails({ document$, hash$ })
patchScripts({ document$ })
patchSource({ document$ })
patchTables({ document$ })
/* Force 1px scroll offset to trigger overflow scrolling */
patchScrollfix({ document$ })
/* Set up clipboard and dialog */
const dialog$ = setupDialog()
const clipboard$ = setupClipboard({ document$, dialog$ })
/* ----------------------------------------------------------------------- */
/* Create header observable */
const header$ = useComponent("header")
.pipe(
mountHeader({ document$, viewport$ }),
shareReplay({ bufferSize: 1, refCount: true })
)
const main$ = useComponent("main")
.pipe(
mountMain({ header$, viewport$ }),
shareReplay({ bufferSize: 1, refCount: true })
)
/* ----------------------------------------------------------------------- */
const navigation$ = useComponent("navigation")
.pipe(
mountNavigation({ header$, main$, viewport$, screen$ }),
shareReplay({ bufferSize: 1, refCount: true }) // shareReplay because there might be late subscribers
)
const toc$ = useComponent("toc")
.pipe(
mountTableOfContents({ header$, main$, viewport$, tablet$ }),
shareReplay({ bufferSize: 1, refCount: true })
)
const tabs$ = useComponent("tabs")
.pipe(
mountTabs({ header$, viewport$, screen$ }),
shareReplay({ bufferSize: 1, refCount: true })
)
/* ----------------------------------------------------------------------- */
/* Search worker - only if search is present */
const worker$ = useComponent("search")
.pipe(
switchMap(() => defer(() => {
const index = config.search && config.search.index
? config.search.index
: undefined
/* Fetch index if it wasn't passed explicitly */
const index$ = (
typeof index !== "undefined"
? from(index)
: base$
.pipe(
switchMap(base => fetch(`${base}/search/search_index.json`, {
credentials: "same-origin"
}).then(res => res.json())) // SearchIndex
)
)
return of(setupSearchWorker(config.search.worker, {
base$, index$
}))
}))
)
/* ----------------------------------------------------------------------- */
/* Mount search query */
const search$ = worker$
.pipe(
switchMap(worker => {
const query$ = useComponent<HTMLInputElement>("search-query")
.pipe(
mountSearchQuery(worker, { transform: config.search.transform }),
shareReplay({ bufferSize: 1, refCount: true })
)
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset(),
shareReplay({ bufferSize: 1, refCount: true })
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(worker, { query$ }),
shareReplay({ bufferSize: 1, refCount: true })
)
return useComponent("search")
.pipe(
mountSearch(worker, { query$, reset$, result$ }),
)
}),
catchError(() => {
useComponent("search")
.subscribe(el => el.hidden = true) // TODO: Hack
return NEVER
}),
shareReplay({ bufferSize: 1, refCount: true })
)
/* ----------------------------------------------------------------------- */
// // put into search...
hash$
.pipe(
tap(() => setToggle("search", false)),
delay(125), // ensure that it runs after the body scroll reset...
)
.subscribe(hash => setLocationHash(`#${hash}`))
// TODO: scroll restoration must be centralized
combineLatest([
watchToggle("search"),
tablet$,
])
.pipe(
withLatestFrom(viewport$),
switchMap(([[toggle, tablet], { offset: { y }}]) => {
const active = toggle && !tablet
return document$
.pipe(
delay(active ? 400 : 100),
observeOn(animationFrameScheduler),
tap(({ body }) => active
? setScrollLock(body, y)
: resetScrollLock(body)
)
)
})
)
.subscribe()
/* ----------------------------------------------------------------------- */
/* Always close drawer on click */
fromEvent<MouseEvent>(document.body, "click")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
filter(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a") // TODO: abstract as link click?
if (el && isLocalLocation(el)) {
return true
}
}
return false
})
)
.subscribe(() => {
setToggle("drawer", false)
})
/* Enable instant loading, if not on file:// protocol */
if (
config.features.includes("navigation.instant") &&
location.protocol !== "file:"
) {
const dom = new DOMParser()
/* Fetch sitemap and extract URL whitelist */
base$
.pipe(
switchMap(base => from(fetch(`${base}/sitemap.xml`)
.then(res => res.text())
.then(text => dom.parseFromString(text, "text/xml"))
)),
withLatestFrom(base$),
map(([document, base]) => {
const urls = getElements("loc", document)
.map(node => node.textContent!)
// Hack: This is a temporary fix to normalize instant loading lookup
// on localhost and Netlify previews. If this approach proves to be
// suitable, we'll refactor URL whitelisting anyway. We take the two
// shortest URLs and determine the common prefix to isolate the
// domain. If there're no two domains, we just leave it as-is, as
// there isn't anything to be loaded anway.
if (urls.length > 1) {
const [a, b] = urls.sort((a, b) => a.length - b.length)
/* Determine common prefix */
let index = 0
if (a === b)
index = a.length
else
while (a.charAt(index) === b.charAt(index))
index++
/* Replace common prefix (i.e. base) with effective base */
for (let i = 0; i < urls.length; i++)
urls[i] = urls[i].replace(a.slice(0, index), `${base}/`)
}
return urls
})
)
.subscribe(urls => {
setupInstantLoading(urls, { document$, location$, viewport$ })
})
}
/* ----------------------------------------------------------------------- */
// Make indeterminate toggles indeterminate to expand navigation on screen
document$.subscribe(() => {
const toggles = getElements<HTMLInputElement>("[data-md-state=indeterminate]")
for (const toggle of toggles) {
toggle.dataset.mdState = ""
toggle.indeterminate = true
toggle.checked = false
}
})
// Auto hide header - this is still experimental, so there might be some
// opportunities for refactoring, but we'll address them when this feature
// got some feedback from the community.
if (config.features.includes("header.autohide")) {
// Threshold for header-hiding - always show if scrolled less than 400px.
// Also, search is not allowed to be active. Maybe make this dynamic.
const threshold$ = viewport$
.pipe(
map(({ offset }) => offset.y > 400),
combineLatestWith(watchToggle("search")),
map(([threshold, search]) => threshold && !search),
distinctUntilChanged()
)
// Scroll direction (true = down, false = up) + inflection point
const direction$ = viewport$
.pipe(
map(({ offset }) => offset.y),
bufferCount(2, 1),
map(([a, b]) => [a < b, b] as const),
distinctUntilKeyChanged(0)
)
// When the threshold is exceeded, and the search is not active, subscribe
// to the direction observable (always do a new subscription), and track
// scroll progress.
const hide$ = threshold$
.pipe(
switchMap(active => !active
? of(false)
: direction$
.pipe(
combineLatestWith(viewport$),
filter(([[, y], { offset }]) => Math.abs(y - offset.y) > 100),
map(([[direction]]) => direction)
)
),
distinctUntilChanged()
)
// Set header state depending on main state. There's still some possibility
// for improvement, as the page seems to jump when focusing the unfocused
// search. This would mean we would need to delay the focus/change event
// until the header is focussed, which we need to address in a refactoring.
hide$
.pipe(
combineLatestWith(main$),
map(([hide, main]) => main.active
? hide ? "hidden" : "shadow"
: ""
),
combineLatestWith(useComponent("header"))
)
.subscribe(([state, el]) => {
el.setAttribute("data-md-state", state)
})
}
/* ----------------------------------------------------------------------- */
const state = {
/* Browser observables */
document$,
location$,
viewport$,
/* Component observables */
header$,
main$,
navigation$,
search$,
tabs$,
toc$,
/* Integration observables */
clipboard$,
keyboard$,
dialog$
}
/* Subscribe to all observables */
merge(...Object.values(state))
.subscribe()
return state
/* Set up user interface observables */
const viewport$ = watchViewport()
const tablet$ = watchMedia("(min-width: 960px)")
const screen$ = watchMedia("(min-width: 1220px)")
const print$ = watchPrint()
// these elements MUST be available
const header = getElementOrThrow("[data-md-component=header]")
const main = getElementOrThrow("[data-md-component=main]")
const header$ = watchHeader(header)
const main$ = watchMain(main, { header$: header$, viewport$ })
/* Setup Clipboard.js integration */
const message$ = new Subject<string>()
setupClipboardJS()
.subscribe(() => message$.next(translation("clipboard.copied")))
// TODO: watchElements + general mount function that takes a factory...
// + a toggle function (optionally)
const app$ = merge(
/* Content */
...getElements("[data-md-component=content]")
.map(child => mountContent(child, { target$, viewport$, print$ })),
/* Dialog */
...getElements("[data-md-component=dialog]")
.map(child => mountDialog(child, { message$ })),
/* Header */
...getElements("[data-md-component=header]")
.map(child => mountHeader(child, { viewport$, header$, main$ })),
/* Header title */
...getElements("[data-md-component=header-title]")
.map(child => mountHeaderTitle(child, { viewport$, header$ })),
/* Search */
...getElements("[data-md-component=search]")
.map(child => mountSearch(child)),
/* Sidebar */
...getElements("[data-md-component=sidebar]")
.map(child => child.getAttribute("data-md-type") === "navigation"
? at(screen$, () => mountSidebar(child, { viewport$, header$, main$ }))
: at(tablet$, () => mountSidebar(child, { viewport$, header$, main$ }))
),
/* Repository information */
...getElements("[data-md-component=source]")
.map(child => mountSource(child as HTMLAnchorElement)),
/* Navigation tabs */
...getElements("[data-md-component=tabs]")
.map(child => mountTabs(child, { viewport$, header$ })),
/* Table of contents */
...getElements("[data-md-component=toc]")
.map(child => mountTableOfContents(child, { viewport$, header$ })),
)
app$.subscribe(console.log)
/* ------------------------------------------------------------------------- */
// put this somewhere else
function at<T>(
toggle$: Observable<boolean>, factory: () => Observable<T>
) {
return toggle$
.pipe(
switchMap(active => active ? factory() : NEVER),
)
}

View File

@ -20,75 +20,29 @@
* IN THE SOFTWARE.
*/
import * as ClipboardJS from "clipboard"
import { NEVER, Observable, Subject } from "rxjs"
import { mapTo, share, tap } from "rxjs/operators"
import { getElements } from "browser"
import { renderClipboardButton } from "templates"
import { translate } from "utilities"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
document$: Observable<Document> /* Document observable */
dialog$: Subject<string> /* Dialog subject */
}
import ClipboardJS from "clipboard"
import { NEVER, Observable } from "rxjs"
import { share } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up clipboard
* Set up Clipboard.js integration
*
* This function implements the Clipboard.js integration and injects a button
* into all code blocks when the document changes.
*
* @param options - Options
*
* @return Clipboard observable
* @return Clipboard.js event observable
*/
export function setupClipboard(
{ document$, dialog$ }: SetupOptions
): Observable<ClipboardJS.Event> {
export function setupClipboardJS(): Observable<ClipboardJS.Event> {
if (!ClipboardJS.isSupported())
return NEVER
/* Inject 'copy-to-clipboard' buttons */
document$.subscribe(() => {
const blocks = getElements("pre > code")
blocks.forEach((block, index) => {
const parent = block.parentElement!
parent.id = `__code_${index}`
parent.insertBefore(
renderClipboardButton(parent.id),
block
)
})
})
/* Initialize clipboard */
const clipboard$ = new Observable<ClipboardJS.Event>(subscriber => {
new ClipboardJS(".md-clipboard").on("success", ev => subscriber.next(ev))
/* Initialize Clipboard.js */
return new Observable<ClipboardJS.Event>(subscriber => {
new ClipboardJS("[data-clipboard-target], [data-clipboard-text]")
.on("success", ev => subscriber.next(ev))
})
.pipe(
share()
)
/* Display notification for clipboard event */
clipboard$
.pipe(
tap(ev => ev.clearSelection()),
mapTo(translate("clipboard.copied"))
)
.subscribe(dialog$)
/* Return clipboard */
return clipboard$
}

View File

@ -1,91 +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 { Subject, animationFrameScheduler, noop, of } from "rxjs"
import {
delay,
map,
observeOn,
switchMap,
tap
} from "rxjs/operators"
import { createElement } from "browser"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
duration?: number /* Display duration (default: 2s) */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up dialog
*
* @param options - Options
*
* @return Dialog observable
*/
export function setupDialog(
{ duration }: SetupOptions = {}
): Subject<string> {
const dialog$ = new Subject<string>()
/* Create dialog */
const dialog = createElement("div") // TODO: improve scoping
dialog.classList.add("md-dialog", "md-typeset")
/* Display dialog */
dialog$
.pipe(
switchMap(text => of(document.body) // useComponent("container")
.pipe(
map(container => container.appendChild(dialog)),
observeOn(animationFrameScheduler),
delay(1), // Strangley it doesnt work when we push things to the new animation frame...
tap(el => {
el.innerHTML = text
el.setAttribute("data-md-state", "open")
}),
delay(duration || 2000),
tap(el => el.removeAttribute("data-md-state")),
delay(400),
tap(el => {
el.innerHTML = ""
el.remove()
})
)
)
)
.subscribe(noop)
/* Return dialog */
return dialog$
}

View File

@ -21,7 +21,4 @@
*/
export * from "./clipboard"
export * from "./dialog"
export * from "./instant"
export * from "./keyboard"
export * from "./search"

View File

@ -1,273 +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 { NEVER, Observable, Subject, from, fromEvent, merge, of } from "rxjs"
import {
bufferCount,
catchError,
debounceTime,
distinctUntilChanged,
distinctUntilKeyChanged,
filter,
map,
sample,
share,
skip,
switchMap
} from "rxjs/operators"
import {
Viewport,
ViewportOffset,
getElement,
isAnchorLocation,
isLocalLocation,
replaceElement,
setLocation,
setLocationHash,
setToggle,
setViewportOffset
} from "browser"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* History state
*/
interface State {
url: URL /* State URL */
offset?: ViewportOffset /* State viewport offset */
}
/* ------------------------------------------------------------------------- */
/**
* Setup options
*/
interface SetupOptions {
document$: Subject<Document> /* Document subject */
location$: Subject<URL> /* Location subject */
viewport$: Observable<Viewport> /* Viewport observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up instant loading
*
* When fetching, theoretically, we could use `responseType: "document"`, but
* since all MkDocs links are relative, we need to make sure that the current
* location matches the document we just loaded. Otherwise any relative links
* in the document could use the old location.
*
* This is the reason why we need to synchronize history events and the process
* of fetching the document for navigation changes (except `popstate` events):
*
* 1. Fetch document via `XMLHTTPRequest`
* 2. Set new location via `history.pushState`
* 3. Parse and emit fetched document
*
* For `popstate` events, we must not use `history.pushState`, or the forward
* history will be irreversibly overwritten. In case the request fails, the
* location change is dispatched regularly.
*
* @param options - Options
*/
export function setupInstantLoading(
urls: string[], { document$, viewport$, location$ }: SetupOptions
): void {
/* Disable automatic scroll restoration */
if ("scrollRestoration" in history)
history.scrollRestoration = "manual"
/* Hack: ensure that reloads restore viewport offset */
fromEvent(window, "beforeunload")
.subscribe(() => {
history.scrollRestoration = "auto"
})
/* Hack: ensure absolute favicon link to omit 404s on document switch */
const favicon = getElement<HTMLLinkElement>(`link[rel="shortcut icon"]`)
if (typeof favicon !== "undefined")
favicon.href = favicon.href // tslint:disable-line no-self-assignment
/* Intercept link clicks and convert to state change */
const state$ = fromEvent<MouseEvent>(document.body, "click")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
switchMap(ev => {
if (ev.target instanceof HTMLElement) {
const el = ev.target.closest("a")
if (
el && !el.target &&
isLocalLocation(el) &&
urls.includes(el.href)
) {
if (!isAnchorLocation(el))
ev.preventDefault()
return of(el)
}
}
return NEVER
}),
map(el => ({ url: new URL(el.href) })),
share<State>()
)
/* Always close search on link click */
state$.subscribe(() => {
setToggle("search", false)
})
/* Filter state changes to dispatch */
const push$ = state$
.pipe(
filter(({ url }) => !isAnchorLocation(url)),
share()
)
/* Intercept popstate events (history back and forward) */
const pop$ = fromEvent<PopStateEvent>(window, "popstate")
.pipe(
filter(ev => ev.state !== null),
map(ev => ({
url: new URL(location.href),
offset: ev.state
})),
share<State>()
)
/* Emit location change */
merge(push$, pop$)
.pipe(
distinctUntilChanged((prev, next) => prev.url.href === next.url.href),
map(({ url }) => url)
)
.subscribe(location$)
/* Fetch document on location change */
const ajax$ = location$
.pipe(
distinctUntilKeyChanged("pathname"),
skip(1),
switchMap(url => from(fetch(url.href, {
credentials: "same-origin"
}).then(res => res.text()))
.pipe(
catchError(() => {
setLocation(url)
return NEVER
})
)
),
share()
)
/* Set new location as soon as the document was fetched */
push$
.pipe(
sample(ajax$)
)
.subscribe(({ url }) => {
history.pushState({}, "", url.toString())
})
/* Parse and emit document */
const dom = new DOMParser()
ajax$
.pipe(
map(response => dom.parseFromString(response, "text/html"))
)
.subscribe(document$)
/* Intercept instant loading */
const instant$ = merge(push$, pop$)
.pipe(
sample(document$)
)
// TODO: this must be combined with search scroll restoration on mobile
instant$.subscribe(({ url, offset }) => {
if (url.hash && !offset) {
setLocationHash(url.hash)
} else {
setViewportOffset(offset || { y: 0 })
}
})
/* Replace document metadata */
document$
.pipe(
skip(1) // Skip initial
)
.subscribe(({ title, head }) => {
document.title = title
/* Replace meta tags */
for (const selector of [
`link[rel="canonical"]`,
`meta[name="author"]`,
`meta[name="description"]`
]) {
const next = getElement(selector, head)
const prev = getElement(selector, document.head)
if (
typeof next !== "undefined" &&
typeof prev !== "undefined"
) {
replaceElement(prev, next)
}
}
/* Finished, dispatch document switch event */
document.dispatchEvent(new CustomEvent("DOMContentSwitch"))
})
/* Debounce update of viewport offset */
viewport$
.pipe(
debounceTime(250),
distinctUntilKeyChanged("offset")
)
.subscribe(({ offset }) => {
history.replaceState(offset, "")
})
/* Set viewport offset from history */
merge(state$, pop$)
.pipe(
bufferCount(2, 1),
filter(([prev, next]) => {
return prev.url.pathname === next.url.pathname
&& !isAnchorLocation(next.url)
}),
map(([, state]) => state)
)
.subscribe(({ offset }) => {
setViewportOffset(offset || { y: 0 })
})
}

View File

@ -1,199 +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 } from "rxjs"
import {
filter,
map,
share,
withLatestFrom
} from "rxjs/operators"
import {
Key,
getActiveElement,
getElement,
getElements,
getToggle,
isSusceptibleToKeyboard,
setElementFocus,
setElementSelection,
setToggle,
watchKeyboard
} from "browser"
import { useComponent } from "components"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Keyboard mode
*/
export type KeyboardMode =
| "global" /* Global */
| "search" /* Search is open */
/* ------------------------------------------------------------------------- */
/**
* Keyboard
*/
export interface Keyboard extends Key {
mode: KeyboardMode /* Keyboard mode */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Set up keyboard
*
* This function will set up 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(): Observable<Keyboard> {
const keyboard$ = watchKeyboard()
.pipe(
map<Key, Keyboard>(key => ({
mode: getToggle("search") ? "search" : "global",
...key
})),
filter(({ mode }) => {
if (mode === "global") {
const active = getActiveElement()
if (typeof active !== "undefined")
return !isSusceptibleToKeyboard(active)
}
return true
}),
share()
)
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search"),
withLatestFrom(
useComponent("search-query"),
useComponent("search-result")
)
)
.subscribe(([key, query, result]) => {
const active = getActiveElement()
switch (key.type) {
/* Enter: prevent form submission */
case "Enter":
if (active === query)
key.claim()
break
/* Escape or Tab: close search */
case "Escape":
case "Tab":
setToggle("search", false)
setElementFocus(query, false)
break
/* Vertical arrows: select previous or next search result */
case "ArrowUp":
case "ArrowDown":
if (typeof active === "undefined") {
setElementFocus(query)
} else {
const els = [query, ...getElements(
":not(details) > [href], summary, details[open] [href]",
result
)]
const i = Math.max(0, (
Math.max(0, els.indexOf(active)) + els.length + (
key.type === "ArrowUp" ? -1 : +1
)
) % els.length)
setElementFocus(els[i])
}
/* Prevent scrolling of page */
key.claim()
break
/* All other keys: hand to search query */
default:
if (query !== getActiveElement())
setElementFocus(query)
}
})
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global"),
withLatestFrom(useComponent("search-query"))
)
.subscribe(([key, query]) => {
switch (key.type) {
/* Open search and select query */
case "f":
case "s":
case "/":
setElementFocus(query)
setElementSelection(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
}
})
/* Return keyboard */
return keyboard$
}

View File

@ -101,9 +101,7 @@ export interface SearchMetadata {
/**
* Search result
*/
export type SearchResult = Array<
SearchDocument & SearchMetadata
> // tslint:disable-line
export type SearchResult = Array<SearchDocument & SearchMetadata>
/* ----------------------------------------------------------------------------
* Functions

View File

@ -20,8 +20,7 @@
* IN THE SOFTWARE.
*/
// @ts-ignore
import * as escapeHTML from "escape-html"
import escapeHTML from "escape-html"
import { SearchIndexDocument } from "../_"

View File

@ -21,15 +21,10 @@
*/
import { Observable, Subject, asyncScheduler } from "rxjs"
import {
map,
observeOn,
share,
withLatestFrom
} from "rxjs/operators"
import { map, observeOn, share } from "rxjs/operators"
import { WorkerHandler, watchWorker } from "browser"
import { translate } from "utilities"
import { configuration, translation } from "~/_"
import { WorkerHandler, watchWorker } from "~/browser"
import { SearchIndex, SearchIndexPipeline } from "../../_"
import {
@ -39,6 +34,15 @@ import {
isSearchResultMessage
} from "../message"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search worker
*/
export type SearchWorker = WorkerHandler<SearchMessage>
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
@ -48,11 +52,10 @@ import {
*/
interface SetupOptions {
index$: Observable<SearchIndex> /* Search index observable */
base$: Observable<string> /* Location base observable */
}
/* ----------------------------------------------------------------------------
* Functions
* Helper functions
* ------------------------------------------------------------------------- */
/**
@ -68,14 +71,16 @@ function setupSearchIndex(
/* Override default language with value from translation */
if (config.lang.length === 1 && config.lang[0] === "en")
config.lang = [translate("search.config.lang")]
config.lang = [
translation("search.config.lang")
]
/* Override default separator with value from translation */
if (config.separator === "[\\s\\-]+")
config.separator = translate("search.config.separator")
config.separator = translation("search.config.separator")
/* Set pipeline from translation */
const pipeline = translate("search.config.pipeline")
const pipeline = translation("search.config.pipeline")
.split(/\s*,\s*/)
.filter(Boolean) as SearchIndexPipeline
@ -84,7 +89,7 @@ function setupSearchIndex(
}
/* ----------------------------------------------------------------------------
* Helper functions
* Functions
* ------------------------------------------------------------------------- */
/**
@ -97,23 +102,23 @@ function setupSearchIndex(
* @param url - Worker URL
* @param options - Options
*
* @return Worker handler
* @return Search worker
*/
export function setupSearchWorker(
url: string, { index$, base$ }: SetupOptions
): WorkerHandler<SearchMessage> {
url: string, { index$ }: SetupOptions
): SearchWorker {
const config = configuration()
const worker = new Worker(url)
/* Create communication channels and resolve relative links */
const tx$ = new Subject<SearchMessage>()
const rx$ = watchWorker(worker, { tx$ })
.pipe(
withLatestFrom(base$),
map(([message, base]) => {
map(message => {
if (isSearchResultMessage(message)) {
for (const result of message.data)
for (const document of result)
document.location = `${base}/${document.location}`
document.location = `${config.base}/${document.location}`
}
return message
}),
@ -131,6 +136,6 @@ export function setupSearchWorker(
)
.subscribe(tx$.next.bind(tx$))
/* Return worker handler */
/* Return search worker */
return { tx$, rx$ }
}

View File

@ -1,94 +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, fromEvent, merge } from "rxjs"
import {
filter,
map,
switchMapTo,
tap
} from "rxjs/operators"
import {
getElement,
getElements,
watchMedia
} from "browser"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Patch options
*/
interface PatchOptions {
document$: Observable<Document> /* Document observable */
hash$: Observable<string> /* Location hash observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch all `details` elements
*
* This function will ensure that all `details` tags are opened prior to
* printing, so the whole content of the page is included, and on anchor jumps.
*
* @param options - Options
*/
export function patchDetails(
{ document$, hash$ }: PatchOptions
): void {
const els$ = document$
.pipe(
map(() => getElements<HTMLDetailsElement>("details"))
)
/* Open all details before printing */
merge(
watchMedia("print").pipe(filter(Boolean)), /* Webkit */
fromEvent(window, "beforeprint") /* IE, FF */
)
.pipe(
switchMapTo(els$)
)
.subscribe(els => {
for (const el of els)
el.setAttribute("open", "")
})
/* Open parent details and fix anchor jump */
hash$
.pipe(
map(id => getElement(`[id="${id}"]`)!),
filter(el => typeof el !== "undefined"),
tap(el => {
const details = el.closest("details")
if (details && !details.open)
details.setAttribute("open", "")
})
)
.subscribe(el => el.scrollIntoView())
}

View File

@ -1,96 +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 { EMPTY, Observable, noop, of } from "rxjs"
import {
concatMap,
map,
skip,
switchMap,
withLatestFrom
} from "rxjs/operators"
import {
createElement,
getElements,
replaceElement
} from "browser"
import { useComponent } from "components"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Patch options
*/
interface PatchOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch all `script` elements
*
* This function must be run after a document switch, which means the first
* emission must be ignored.
*
* @param options - Options
*/
export function patchScripts(
{ document$ }: PatchOptions
): void {
const els$ = document$
.pipe(
skip(1),
withLatestFrom(useComponent("container")),
map(([, el]) => getElements<HTMLScriptElement>("script", el))
)
/* Evaluate all scripts via replacement in order */
els$
.pipe(
switchMap(els => of(...els)),
concatMap(el => {
const script = createElement("script")
if (el.src) {
script.src = el.src
replaceElement(el, script)
/* Complete when script is loaded */
return new Observable(observer => {
script.onload = () => observer.complete()
})
/* Complete immediately */
} else {
script.textContent = el.textContent!
replaceElement(el, script)
return EMPTY
}
})
)
.subscribe(noop)
}

View File

@ -1,104 +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 { NEVER, Observable, fromEvent, iif, merge } from "rxjs"
import { map, mapTo, shareReplay, switchMap } from "rxjs/operators"
import { getElements } from "browser"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Patch options
*/
interface PatchOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Check whether the given device is an Apple device
*
* @return Test result
*/
function isAppleDevice(): boolean {
return /(iPad|iPhone|iPod)/.test(navigator.userAgent)
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch all elements with `data-md-scrollfix` attributes
*
* This is a year-old patch which ensures that overflow scrolling works at the
* top and bottom of containers on iOS by ensuring a `1px` scroll offset upon
* the start of a touch event.
*
* @see https://bit.ly/2SCtAOO - Original source
*
* @param options - Options
*/
export function patchScrollfix(
{ document$ }: PatchOptions
): void {
const els$ = document$
.pipe(
map(() => getElements("[data-md-scrollfix]")),
shareReplay({ bufferSize: 1, refCount: true })
)
/* Remove marker attribute, so we'll only add the fix once */
els$.subscribe(els => {
for (const el of els)
el.removeAttribute("data-md-scrollfix")
})
/* Patch overflow scrolling on touch start */
iif(isAppleDevice, els$, NEVER)
.pipe(
switchMap(els => merge(...els.map(el => (
fromEvent(el, "touchstart")
.pipe(
mapTo(el)
)
))))
)
.subscribe(el => {
const top = el.scrollTop
/* We're at the top of the container */
if (top === 0) {
el.scrollTop = 1
/* We're at the bottom of the container */
} else if (top + el.offsetHeight === el.scrollHeight) {
el.scrollTop = top - 1
}
})
}

View File

@ -1,118 +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 { NEVER, Observable } from "rxjs"
import { catchError, filter, map, switchMap } from "rxjs/operators"
import { getElementOrThrow, getElements } from "browser"
import { renderSource } from "templates"
import { cache, hash } from "utilities"
import { fetchSourceFactsFromGitHub } from "./github"
import { fetchSourceFactsFromGitLab } from "./gitlab"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Source facts
*/
export type SourceFacts = string[]
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Patch options
*/
interface PatchOptions {
document$: Observable<Document> /* Document observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
/**
* Fetch source facts
*
* @param url - Source repository URL
*
* @return Source facts observable
*/
function fetchSourceFacts(
url: string
): Observable<SourceFacts> {
const [type] = url.match(/(git(?:hub|lab))/i) || []
switch (type.toLowerCase()) {
/* GitHub repository */
case "github":
const [, user, repo] = url.match(/^.+github\.com\/([^\/]+)\/?([^\/]+)?/i)!
return fetchSourceFactsFromGitHub(user, repo)
/* GitLab repository */
case "gitlab":
const [, base, slug] = url.match(/^.+?([^\/]*gitlab[^\/]+)\/(.+?)\/?$/i)!
return fetchSourceFactsFromGitLab(base, slug)
/* Everything else */
default:
return NEVER
}
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Patch elements containing repository information
*
* This function will retrieve the URL from the repository link and try to
* query data from integrated source code platforms like GitHub or GitLab.
*
* @param options - Options
*/
export function patchSource(
{ document$ }: PatchOptions
): void {
document$
.pipe(
map(() => getElementOrThrow<HTMLAnchorElement>(".md-source[href]")),
switchMap(({ href }) => (
cache(`${hash(href)}`, () => fetchSourceFacts(href))
)),
filter(facts => facts.length > 0),
catchError(() => NEVER)
)
.subscribe(facts => {
for (const el of getElements(".md-source__repository")) {
if (!el.hasAttribute("data-md-state")) {
el.setAttribute("data-md-state", "done")
el.appendChild(renderSource(facts))
}
}
})
}

View File

@ -20,7 +20,8 @@
* IN THE SOFTWARE.
*/
import { h, translate } from "utilities"
import { translation } from "~/_"
import { h } from "~/utilities"
/* ----------------------------------------------------------------------------
* Functions
@ -37,7 +38,7 @@ export function renderClipboardButton(id: string) {
return (
<button
class="md-clipboard md-icon"
title={translate("clipboard.copy")}
title={translation("clipboard.copy")}
data-clipboard-target={`#${id} > code`}
></button>
)

View File

@ -20,12 +20,13 @@
* IN THE SOFTWARE.
*/
import { translation } from "~/_"
import {
SearchDocument,
SearchMetadata,
SearchResult
} from "integrations/search"
import { h, translate, truncate } from "utilities"
} from "~/integrations/search"
import { h, truncate } from "~/utilities"
/* ----------------------------------------------------------------------------
* Helper types
@ -84,7 +85,7 @@ function renderSearchDocument(
}
{teaser > 0 && missing.length > 0 &&
<p class="md-search-result__terms">
{translate("search.result.term.missing")}: {...missing}
{translation("search.result.term.missing")}: {...missing}
</p>
}
</article>
@ -130,8 +131,8 @@ export function renderSearchResult(
<details class="md-search-result__more">
<summary tabIndex={-1}>
{more.length > 0 && more.length === 1
? translate("search.result.more.one")
: translate("search.result.more.other", more.length)
? translation("search.result.more.one")
: translation("search.result.more.other", more.length)
}
</summary>
{...more.map(section => renderSearchDocument(section, Flag.TEASER))}

Some files were not shown because too many files have changed in this diff Show More