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

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