Improved overall structure

This commit is contained in:
squidfunk 2020-02-20 14:44:41 +01:00
parent 9b0410962d
commit 3aa251fb03
26 changed files with 241 additions and 224 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -20,9 +20,9 @@
* IN THE SOFTWARE.
*/
import { NEVER, Observable, OperatorFunction, pipe } from "rxjs"
import { Observable, OperatorFunction, pipe } from "rxjs"
import {
catchError,
filter,
map,
shareReplay,
switchMap
@ -31,7 +31,7 @@ import {
import {
Header,
Viewport,
getElementOrThrow,
getElement,
paintHeaderTitle,
watchViewportAt
} from "observables"
@ -67,15 +67,15 @@ export function mountHeaderTitle(
return pipe(
switchMap(el => useComponent("main")
.pipe(
map(main => getElementOrThrow("h1, h2, h3, h4, h5, h6", main)),
switchMap(headline => watchViewportAt(headline, { header$, viewport$ })
map(main => getElement("h1, h2, h3, h4, h5, h6", main)!),
filter(hx => typeof hx !== "undefined"),
switchMap(hx => watchViewportAt(hx, { header$, viewport$ })
.pipe(
map(({ offset: { y } }) => y >= headline.offsetHeight),
map(({ offset: { y } }) => y >= hx.offsetHeight),
paintHeaderTitle(el)
)
),
shareReplay(1),
catchError(() => NEVER)
shareReplay(1)
)
)
)

View File

@ -20,17 +20,11 @@
* IN THE SOFTWARE.
*/
import { OperatorFunction, combineLatest, pipe } from "rxjs"
import { Observable, OperatorFunction, combineLatest, pipe } from "rxjs"
import { map, shareReplay, switchMap } from "rxjs/operators"
import { SearchResult } from "integrations/search"
import { SearchQuery, WorkerHandler } from "observables"
import { SearchMessage } from "workers"
import { useComponent } from "../../_"
import { mountSearchQuery } from "../query"
import { mountSearchReset } from "../reset"
import { mountSearchResult } from "../result"
import { SearchQuery } from "observables"
/* ----------------------------------------------------------------------------
* Types
@ -44,6 +38,19 @@ export interface Search {
result: SearchResult[] /* Search result list */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
query$: Observable<SearchQuery> /* Search query observable */
reset$: Observable<void> /* Search reset observable */
result$: Observable<SearchResult[]> /* Search result observable */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -51,42 +58,18 @@ export interface Search {
/**
* Mount search from source observable
*
* @param handler - Worker handler
* @param options - Options
*
* @return Search observable
*/
export function mountSearch(
handler: WorkerHandler<SearchMessage>
{ query$, reset$, result$ }: MountOptions
): OperatorFunction<HTMLElement, Search> {
return pipe(
switchMap(() => {
/* Mount search query */
const query$ = useComponent<HTMLInputElement>("search-query")
.pipe(
mountSearchQuery(handler),
shareReplay(1)
)
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset()
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(handler, { query$ })
)
/* Combine into a single hot observable */
return combineLatest([query$, result$, reset$])
.pipe(
map(([query, result]) => ({ query, result })),
shareReplay(1)
)
})
switchMap(() => combineLatest([query$, result$, reset$])
.pipe(
map(([query, result]) => ({ query, result })),
shareReplay(1)
))
)
}

View File

@ -41,6 +41,17 @@ import {
SearchQueryMessage
} from "workers"
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
transform?(value: string): string /* Transformation function */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
@ -49,16 +60,17 @@ import {
* Mount search query from source observable
*
* @param handler - Worker handler
* @param options - Options
*
* @return Operator function
*/
export function mountSearchQuery(
{ tx$ }: WorkerHandler<SearchMessage>
{ tx$ }: WorkerHandler<SearchMessage>, options: MountOptions = {}
): OperatorFunction<HTMLInputElement, SearchQuery> {
const toggle$ = useToggle("search")
return pipe(
switchMap(el => {
const query$ = watchSearchQuery(el)
const query$ = watchSearchQuery(el, options)
/* Subscribe worker to search query */
query$

View File

@ -29,7 +29,6 @@ import "../stylesheets/app-palette.scss"
import { values } from "ramda"
import {
merge,
of,
combineLatest,
animationFrameScheduler
} from "rxjs"
@ -40,7 +39,9 @@ import {
filter,
withLatestFrom,
observeOn,
take
take,
mapTo,
shareReplay
} from "rxjs/operators"
import {
@ -52,9 +53,9 @@ import {
watchLocation,
watchLocationHash,
watchViewport,
watchKeyboard,
watchToggleMap,
useToggle
useToggle,
getElement
} from "./observables"
import { setupSearchWorker } from "./workers"
@ -69,7 +70,10 @@ import {
mountTabs,
useComponent,
watchComponentMap,
mountHeaderTitle
mountHeaderTitle,
mountSearchQuery,
mountSearchReset,
mountSearchResult
} from "components"
import { setupClipboard } from "./integrations/clipboard"
import { setupKeyboard } from "./integrations/keyboard"
@ -80,7 +84,7 @@ import {
patchSource
} from "patches"
import { isConfig } from "utilities"
import { renderDialog } from "templates/dialog"
import { setupDialog } from "integrations/dialog"
/* ------------------------------------------------------------------------- */
@ -153,11 +157,34 @@ export function initialize(config: unknown) {
/* ----------------------------------------------------------------------- */
/* Mount search query */
const query$ = useComponent<HTMLInputElement>("search-query")
.pipe(
mountSearchQuery(worker),
shareReplay(1) // TODO: this must be put onto EVERY component!
)
/* Mount search reset */
const reset$ = useComponent("search-reset")
.pipe(
mountSearchReset()
)
/* Mount search result */
const result$ = useComponent("search-result")
.pipe(
mountSearchResult(worker, { query$ })
)
/* ----------------------------------------------------------------------- */
const search$ = useComponent("search")
.pipe(
mountSearch(worker)
mountSearch({ query$, reset$, result$ })
)
/* ----------------------------------------------------------------------- */
const navigation$ = useComponent("navigation")
.pipe(
mountNavigation({ header$, main$, viewport$, screen$ })
@ -178,7 +205,6 @@ export function initialize(config: unknown) {
mountHero({ header$, viewport$ })
)
// TODO: make this part of mountHeader!?
const title$ = useComponent("header-title")
.pipe(
mountHeaderTitle({ header$, viewport$ })
@ -196,57 +222,38 @@ export function initialize(config: unknown) {
if (navigator.userAgent.match(/(iPad|iPhone|iPod)/g))
patchScrollfix({ document$ })
// snackbar for copy to clipboard
const dialog = renderDialog("Copied to Clipboard")
setupClipboard({ document$ })
.pipe(
switchMap(ev => {
ev.clearSelection()
return useComponent("container")
.pipe(
tap(el => el.appendChild(dialog)), // only set text on dialog... render once...
observeOn(animationFrameScheduler),
tap(() => dialog.dataset.mdState = "open"),
delay(2000),
tap(() => dialog.dataset.mdState = ""),
delay(400),
tap(() => dialog.remove())
)
})
)
.subscribe()
/* Setup clipboard and dialog */
const dialog$ = setupDialog()
const clipboard$ = setupClipboard({ document$, dialog$ })
/* ----------------------------------------------------------------------- */
// Close drawer and search on hash change
// put into navigation...
hash$.subscribe(x => {
useToggle("drawer").subscribe(el => {
setToggle(el, false)
})
})
// TODO:
// put into search...
hash$
.pipe(
switchMap(hash => {
return useToggle("search")
.pipe(
filter(x => x.checked), // only active
tap(toggle => setToggle(toggle, false)),
delay(125), // ensure that it runs after the body scroll reset...
tap(() => {
location.hash = " "
location.hash = hash
}) // encapsulate this...
)
})
switchMap(hash => useToggle("search")
.pipe(
filter(x => x.checked), // only active
tap(toggle => setToggle(toggle, false)),
delay(125), // ensure that it runs after the body scroll reset...
mapTo(hash)
)
)
)
.subscribe()
.subscribe(hash => {
getElement(hash)!.scrollIntoView()
})
// Scroll lock
// Scroll lock // document -> document$ => { body } !?
// put into search...
const toggle$ = useToggle("search")
combineLatest([
toggle$.pipe(switchMap(watchToggle)),
@ -256,13 +263,13 @@ export function initialize(config: unknown) {
withLatestFrom(viewport$),
switchMap(([[toggle, tablet], { offset: { y }}]) => {
const active = toggle && !tablet
return of(document.body)
return document$
.pipe(
delay(active ? 400 : 100),
delay(active ? 400 : 100), // TOOD: directly combine this with the hash!
observeOn(animationFrameScheduler),
tap(el => active
? setScrollLock(el, y)
: resetScrollLock(el)
tap(({ body }) => active
? setScrollLock(body, y)
: resetScrollLock(body)
)
)
})
@ -283,18 +290,21 @@ export function initialize(config: unknown) {
link.style.visibility = "visible"
})
// build a notification component! feed txt into it...
/* ----------------------------------------------------------------------- */
const state = {
search$,
clipboard$,
location$,
hash$,
keyboard$,
dialog$,
main$,
navigation$,
toc$,
tabs$,
hero$,
title$
title$ // TODO: header title
}
const { ...rest } = state

View File

@ -21,11 +21,12 @@
*/
import * as ClipboardJS from "clipboard"
import { NEVER, Observable, fromEventPattern } from "rxjs"
import { shareReplay } from "rxjs/operators"
import { NEVER, Observable, Subject, fromEventPattern } from "rxjs"
import { mapTo, shareReplay, tap } from "rxjs/operators"
import { getElements } from "observables"
import { renderClipboard } from "templates"
import { translate } from "utilities"
/* ----------------------------------------------------------------------------
* Helper types
@ -36,6 +37,7 @@ import { renderClipboard } from "templates"
*/
interface SetupOptions {
document$: Observable<Document> /* Document observable */
dialog$: Subject<string> /* Dialog subject */
}
/* ----------------------------------------------------------------------------
@ -53,7 +55,7 @@ interface SetupOptions {
* @return Clipboard observable
*/
export function setupClipboard(
{ document$ }: SetupOptions
{ document$, dialog$ }: SetupOptions
): Observable<ClipboardJS.Event> {
if (!ClipboardJS.isSupported())
return NEVER
@ -74,9 +76,15 @@ export function setupClipboard(
new ClipboardJS(".md-clipboard").on("success", next)
})
// TODO: integrate rendering of dialog
/* Display notification upon clipboard copy */
clipboard$
.pipe(
tap(ev => ev.clearSelection()),
mapTo(translate("clipboard.copied"))
)
.subscribe(dialog$)
/* */
/* Return clipboard as hot observable */
return clipboard$
.pipe(
shareReplay(1)

View File

@ -20,17 +20,26 @@
* IN THE SOFTWARE.
*/
import { h } from "utilities"
import { Subject, animationFrameScheduler } from "rxjs"
import {
delay,
map,
observeOn,
switchMap,
tap
} from "rxjs/operators"
import { useComponent } from "components"
/* ----------------------------------------------------------------------------
* Data
* Types
* ------------------------------------------------------------------------- */
/**
* CSS classes
* Setup options
*/
const css = {
container: "md-dialog md-typeset"
interface SetupOptions {
duration?: number /* Display duration (default: 2s) */
}
/* ----------------------------------------------------------------------------
@ -38,18 +47,44 @@ const css = {
* ------------------------------------------------------------------------- */
/**
* Render a dismissable dialog
* Setup dialog
*
* @param text - Dialog text
* @param options - Options
*
* @return Element
* @return Dialog observable
*/
export function renderDialog(
text: string
): HTMLElement {
return (
<div class={css.container}>
{text}
</div>
)
export function setupDialog(
{ duration }: SetupOptions = {}
): Subject<string> {
const dialog$ = new Subject<string>()
/* Create dialog */
const dialog = document.createElement("div") // TODO: improve scoping
dialog.classList.add("md-dialog", "md-typeset")
/* Display dialog */
dialog$
.pipe(
switchMap(text => useComponent("container")
.pipe(
map(container => container.appendChild(dialog)),
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()
/* Return dialog subject */
return dialog$
}

View File

@ -198,6 +198,6 @@ export function setupKeyboard(): Observable<Keyboard> {
}
})
/* Return keyboard observable */
/* Return keyboard */
return keyboard$
}

View File

@ -51,6 +51,6 @@ export function watchLocation(): Subject<string> {
)
.subscribe(location$)
/* Return subject */
/* Return location subject */
return location$
}

View File

@ -21,7 +21,7 @@
*/
import { Observable, fromEvent } from "rxjs"
import { filter, map, share } from "rxjs/operators"
import { filter, map, share, startWith } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Functions
@ -36,22 +36,6 @@ export function getLocationHash(): string {
return location.hash
}
/**
* Set location hash
*
* This will force a reset of the location hash, inducing an anchor jump if
* the hash matches the `id` of an element. It is implemented outside of the
* whole RxJS architecture using `setTimeout` to keep it plain and simple.
*
* @param value - Location hash
*/
export function setLocationHash(value: string): void {
location.hash = ""
setTimeout(() => {
location.hash = value
}, 1)
}
/* ------------------------------------------------------------------------- */
/**
@ -63,6 +47,7 @@ export function watchLocationHash(): Observable<string> {
return fromEvent<HashChangeEvent>(window, "hashchange")
.pipe(
map(getLocationHash),
startWith(getLocationHash()),
filter(hash => hash.length > 0),
share()
)

View File

@ -20,8 +20,8 @@
* IN THE SOFTWARE.
*/
import { Observable, combineLatest, fromEvent, merge } from "rxjs"
import { map, shareReplay, startWith } from "rxjs/operators"
import { Observable, fromEvent } from "rxjs"
import { map, startWith } from "rxjs/operators"
/* ----------------------------------------------------------------------------
* Types

View File

@ -99,7 +99,7 @@ export function watchNavigationLayer(
)))
)
/* Return previous and next layer */
/* Return previous and next layer as hot observable */
return layer$
.pipe(
map(next => ({ next })),

View File

@ -21,20 +21,17 @@
*/
import { identity } from "ramda"
import { NEVER, Observable, fromEvent, merge, of } from "rxjs"
import { Observable, fromEvent, merge } from "rxjs"
import {
catchError,
filter,
map,
switchMap,
switchMapTo,
tap
} from "rxjs/operators"
import {
getElementOrThrow,
getElement,
getElements,
getLocationHash,
setLocationHash,
watchMedia
} from "observables"
@ -72,8 +69,8 @@ export function patchDetails(
/* Open all details before printing */
merge(
watchMedia("print").pipe(filter(identity)), // Webkit
fromEvent(window, "beforeprint") // IE, FF
watchMedia("print").pipe(filter(identity)), /* Webkit */
fromEvent(window, "beforeprint") /* IE, FF */
)
.pipe(
switchMapTo(els$)
@ -83,20 +80,16 @@ export function patchDetails(
el.setAttribute("open", "")
})
/* Open parent details before anchor jump */
merge(hash$, of(getLocationHash()))
/* Open parent details and fix anchor jump */
hash$
.pipe(
filter(hash => !!hash.length),
switchMap(hash => of(getElementOrThrow<HTMLElement>(hash))
.pipe(
map(el => [el.closest("details")!, hash] as const),
filter(([el]) => el && !el.open)
)
),
catchError(() => NEVER)
)
.subscribe(([el, hash]) => {
el.setAttribute("open", "")
setLocationHash(hash)
map(hash => getElement(hash)!),
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

@ -58,7 +58,10 @@ export function patchScrollfix(
.pipe(
map(() => getElements("[data-md-scrollfix]")),
switchMap(els => merge(...els.map(el => (
fromEvent(el, "touchstart").pipe(mapTo(el))
fromEvent(el, "touchstart")
.pipe(
mapTo(el)
)
))))
)
.subscribe(el => {

View File

@ -23,12 +23,7 @@
import { Repo, User } from "github-types"
import { Observable, of } from "rxjs"
import { ajax } from "rxjs/ajax"
import {
filter,
pluck,
shareReplay,
switchMap
} from "rxjs/operators"
import { filter, pluck, switchMap } from "rxjs/operators"
import { round } from "utilities"
@ -75,7 +70,6 @@ export function fetchSourceFactsFromGitHub(
`${round(public_repos || 0)} Repositories`
])
}
}),
shareReplay(1)
})
)
}

View File

@ -23,12 +23,7 @@
import { ProjectSchema } from "gitlab"
import { Observable } from "rxjs"
import { ajax } from "rxjs/ajax"
import {
filter,
map,
pluck,
shareReplay
} from "rxjs/operators"
import { filter, map, pluck } from "rxjs/operators"
import { round } from "utilities"
@ -59,7 +54,6 @@ export function fetchSourceFactsFromGitLab(
map(({ star_count, forks_count }: ProjectSchema) => ([
`${round(star_count)} Stars`,
`${round(forks_count)} Forks`
])),
shareReplay(1)
]))
)
}

View File

@ -52,15 +52,15 @@ interface MountOptions {
export function patchTables(
{ document$ }: MountOptions
): void {
const placeholder = document.createElement("table")
const sentinel = document.createElement("table")
document$
.pipe(
map(() => getElements<HTMLTableElement>("table:not([class])"))
)
.subscribe(els => {
for (const el of els) {
el.replaceWith(placeholder)
placeholder.replaceWith(renderTable(el))
el.replaceWith(sentinel)
sentinel.replaceWith(renderTable(el))
}
})
}

View File

@ -21,7 +21,6 @@
*/
export * from "./clipboard"
export * from "./dialog"
export * from "./search"
export * from "./source"
export * from "./table"

View File

@ -20,7 +20,7 @@
* IN THE SOFTWARE.
*/
import { SourceFacts } from "integrations/source"
import { SourceFacts } from "patches/source"
import { h } from "utilities"
/* ----------------------------------------------------------------------------
@ -49,7 +49,9 @@ const css = {
export function renderSource(
facts: SourceFacts
): HTMLElement {
const children = facts.map(fact => <li class={css.fact}>{fact}</li>)
const children = facts.map(fact => (
<li class={css.fact}>{fact}</li>
))
return (
<ul class={css.facts}>
{children}

View File

@ -61,7 +61,7 @@ export function cache<T>(
}
})
/* Return value observable */
/* Return value */
return value$
}
})

View File

@ -81,14 +81,13 @@ export function truncate(value: string, n: number): string {
/**
* Round a number for display with source facts
*
* This is a reverse engineered implementation of GitHub's weird rounding
* algorithm for stars, forks and all other numbers. While all numbers below
* `1,000` are returned as-is, bigger numbers are converted to fixed numbers
* in the following way:
* This is a reverse engineered version of GitHub's weird rounding algorithm
* for stars, forks and all other numbers. While all numbers below `1,000` are
* returned as-is, bigger numbers are converted to fixed numbers:
*
* - `1,049` => `1k`
* - `1,050` => `1,1k`
* - `1,949` => `1,9k`
* - `1,050` => `1.1k`
* - `1,949` => `1.9k`
* - `1,950` => `2k`
*
* @param value - Original value