Restructured project

This commit is contained in:
squidfunk
2019-12-22 16:52:28 +01:00
parent d1928cc31f
commit e04387902c
45 changed files with 533 additions and 270 deletions

View File

@@ -27,9 +27,9 @@ import { Observable, defer, of } from "rxjs"
* ------------------------------------------------------------------------- */
/**
* Header
* Header state
*/
export interface Header {
export interface HeaderState {
sticky: boolean /* Header stickyness */
height: number /* Header visible height */
}
@@ -46,11 +46,11 @@ export interface Header {
*
* @param el - Header element
*
* @return Header observable
* @return Header state observable
*/
export function watchHeader(
el: HTMLElement
): Observable<Header> {
): Observable<HeaderState> {
return defer(() => {
const sticky = getComputedStyle(el)
.getPropertyValue("position") === "fixed"

View File

@@ -30,7 +30,7 @@ import {
import { Agent, ViewportOffset } from "utilities"
import { Header } from "../_"
import { HeaderState } from "../_"
/* ----------------------------------------------------------------------------
* Helper types
@@ -40,7 +40,7 @@ import { Header } from "../_"
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
header$: Observable<HeaderState> /* Header state observable */
}
/* ----------------------------------------------------------------------------
@@ -59,7 +59,7 @@ interface Options {
*
* @return Viewport offset observable
*/
export function watchHeaderOffsetToTopOf(
export function watchViewportOffsetFromTopOf(
el: HTMLElement, { viewport }: Agent, { header$ }: Options
): Observable<ViewportOffset> {
@@ -91,7 +91,7 @@ export function watchHeaderOffsetToTopOf(
*
* @return Viewport offset observable
*/
export function watchHeaderOffsetToBottomOf(
export function watchViewportOffsetFromBottomOf(
el: HTMLElement, { viewport }: Agent, { header$ }: Options
): Observable<ViewportOffset> {

View File

@@ -34,7 +34,7 @@ import {
import { resetHeaderShadow, setHeaderShadow } from "actions"
import { Main } from "../../main"
import { MainState } from "../../main"
/* ----------------------------------------------------------------------------
* Functions
@@ -49,7 +49,7 @@ import { Main } from "../../main"
*/
export function paintHeaderShadow(
el: HTMLElement
): MonoTypeOperatorFunction<Main> {
): MonoTypeOperatorFunction<MainState> {
return pipe(
distinctUntilKeyChanged("active"),

View File

@@ -26,16 +26,16 @@ import { map, shareReplay } from "rxjs/operators"
import { switchMapIf } from "extensions"
import { Agent, paintHidden } from "utilities"
import { Header, watchHeaderOffsetToTopOf } from "../header"
import { HeaderState, watchViewportOffsetFromTopOf } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Hero
* Hero state
*/
export interface Hero {
export interface HeroState {
hidden: boolean /* Whether the hero is hidden */
}
@@ -47,7 +47,7 @@ export interface Hero {
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
header$: Observable<HeaderState> /* Header state observable */
}
/* ----------------------------------------------------------------------------
@@ -55,32 +55,47 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup hero from source observable
* Watch hero
*
* @param el - Hero element
* @param agent - Agent
* @param options - Options
*
* @return Hero state
*/
export function watchHero(
el: HTMLElement, agent: Agent, { header$ }: Options
): Observable<HeroState> {
/* Watch and paint visibility */
const hidden$ = watchViewportOffsetFromTopOf(el, agent, { header$ })
.pipe(
paintHidden(el, 20)
)
/* Combine into a single hot observable */
return hidden$
.pipe(
map(hidden => ({ hidden }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount hero from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupHero(
agent: Agent, { header$ }: Options
): OperatorFunction<HTMLElement, Hero> {
export function mountHero(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, HeroState> {
const { media } = agent
return pipe(
switchMapIf(media.screen$, el => {
/* Watch and paint visibility */
const hidden$ = watchHeaderOffsetToTopOf(el, agent, { header$ })
.pipe(
paintHidden(el, 20)
)
/* Combine into a single hot observable */
return hidden$
.pipe(
map(hidden => ({ hidden }))
)
}),
switchMapIf(media.screen$, el => watchHero(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -31,16 +31,16 @@ import {
import { Agent } from "utilities"
import { Header } from "../../header"
import { HeaderState } from "../../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Main area
* Main area state
*/
export interface Main {
export interface MainState {
offset: number /* Main area top offset */
height: number /* Main area visible height */
active: boolean /* Scrolled past top offset */
@@ -54,7 +54,7 @@ export interface Main {
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
header$: Observable<HeaderState> /* Header state observable */
}
/* ----------------------------------------------------------------------------
@@ -62,64 +62,78 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup main area from source observable
* Watch main area
*
* This function returns an observable that computes the visual parameters of
* the main area from the viewport height and vertical offset, as well as the
* height of the header element. The height of the main area is corrected by
* the height of the header (if fixed) and footer element.
* the main area which depends on the viewport height and vertical offset, as
* well as the height of the header element, if the header is fixed.
*
* @param el - Main area element
* @param agent - Agent
* @param options - Options
*
* @return Main area state observable
*/
export function watchMain(
el: HTMLElement, { viewport }: Agent, { header$ }: Options
): Observable<MainState> {
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
pluck("height")
)
/* Compute the main area's visible height */
const height$ = combineLatest([
viewport.offset$,
viewport.size$,
adjust$
])
.pipe(
map(([{ y }, { height }, adjust]) => {
const top = el.offsetTop
const bottom = el.offsetHeight + top
return height
- Math.max(0, top - y, adjust)
- Math.max(0, height + y - bottom)
}),
distinctUntilChanged()
)
/* Compute whether the viewport offset is past the main area's top */
const active$ = combineLatest([viewport.offset$, adjust$])
.pipe(
map(([{ y }, adjust]) => y >= el.offsetTop - adjust),
distinctUntilChanged()
)
/* Combine into a single hot observable */
return combineLatest([height$, adjust$, active$])
.pipe(
map(([height, adjust, active]) => ({
offset: el.offsetTop - adjust,
height,
active
}))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount main area from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupMain(
{ viewport }: Agent, { header$ }: Options
): OperatorFunction<HTMLElement, Main> {
export function mountMain(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, MainState> {
return pipe(
switchMap(el => {
/* Compute necessary adjustment for header */
const adjust$ = header$
.pipe(
pluck("height")
)
/* Compute the main area's visible height */
const height$ = combineLatest([
viewport.offset$,
viewport.size$,
adjust$
])
.pipe(
map(([{ y }, { height }, adjust]) => {
const top = el.offsetTop
const bottom = el.offsetHeight + top
return height
- Math.max(0, top - y, adjust)
- Math.max(0, height + y - bottom)
}),
distinctUntilChanged()
)
/* Compute whether the viewport offset is past the main area's top */
const active$ = combineLatest([viewport.offset$, adjust$])
.pipe(
map(([{ y }, adjust]) => y >= el.offsetTop - adjust),
distinctUntilChanged()
)
/* Combine into a single hot observable */
return combineLatest([height$, adjust$, active$])
.pipe(
map(([height, adjust, active]) => ({
offset: el.offsetTop - adjust,
height,
active
}))
)
}),
switchMap(el => watchMain(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -45,16 +45,16 @@ import {
} from "actions"
import { Agent } from "utilities"
import { Main } from "../main"
import { MainState } from "../_"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Sidebar
* Sidebar state
*/
export interface Sidebar {
export interface SidebarState {
height: number /* Sidebar height */
lock: boolean /* Sidebar lock */
}
@@ -67,7 +67,7 @@ export interface Sidebar {
* Options
*/
interface Options {
main$: Observable<Main> /* Main area observable */
main$: Observable<MainState> /* Main area state observable */
}
/* ----------------------------------------------------------------------------
@@ -78,19 +78,19 @@ interface Options {
* Watch sidebar
*
* This function returns an observable that computes the visual parameters of
* the given element (a sidebar) from the vertical viewport offset, as well as
* the height of the main area. When the page is scrolled beyond the header,
* the sidebar is locked and fills the remaining space.
* the sidebar which depends on the vertical viewport offset, as well as the
* height of the main area. When the page is scrolled beyond the header, the
* sidebar is locked and fills the remaining space.
*
* @param el - Sidebar element
* @param agent - Agent
* @param options - Options
*
* @return Sidebar observable
* @return Sidebar state observable
*/
export function watchSidebar(
el: HTMLElement, { viewport }: Agent, { main$ }: Options
): Observable<Sidebar> {
): Observable<SidebarState> {
/* Adjust for internal main area offset */
const adjust = parseFloat(
@@ -116,7 +116,7 @@ export function watchSidebar(
return combineLatest([height$, lock$])
.pipe(
map(([height, lock]) => ({ height, lock })),
distinctUntilChanged<Sidebar>(equals),
distinctUntilChanged<SidebarState>(equals),
shareReplay(1)
)
}
@@ -132,7 +132,7 @@ export function watchSidebar(
*/
export function paintSidebar(
el: HTMLElement
): MonoTypeOperatorFunction<Sidebar> {
): MonoTypeOperatorFunction<SidebarState> {
return pipe(
/* Defer repaint to next animation frame */

View File

@@ -27,8 +27,8 @@ import { switchMapIf } from "extensions"
import { Agent } from "utilities"
import {
Main,
Sidebar,
MainState,
SidebarState,
paintSidebar,
watchSidebar
} from "../../main"
@@ -38,10 +38,10 @@ import {
* ------------------------------------------------------------------------- */
/**
* Navigation
* Navigation state
*/
export interface Navigation {
sidebar: Sidebar /* Sidebar */
export interface NavigationState {
sidebar: SidebarState /* Sidebar state */
}
/* ----------------------------------------------------------------------------
@@ -52,7 +52,7 @@ export interface Navigation {
* Options
*/
interface Options {
main$: Observable<Main> /* Main observable */
main$: Observable<MainState> /* Main area state observable */
}
/* ----------------------------------------------------------------------------
@@ -60,32 +60,47 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup navigation from source observable
* Watch navigation
*
* @param el - Navigation element
* @param agent - Agent
* @param options - Options
*
* @return Navigation state observable
*/
export function watchNavigation(
el: HTMLElement, agent: Agent, { main$ }: Options
): Observable<NavigationState> {
/* Watch and paint sidebar */
const sidebar$ = watchSidebar(el, agent, { main$ })
.pipe(
paintSidebar(el)
)
/* Combine into a single hot observable */
return sidebar$
.pipe(
map(sidebar => ({ sidebar }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount navigation from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupNavigation(
agent: Agent, { main$ }: Options
): OperatorFunction<HTMLElement, Navigation> {
export function mountNavigation(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, NavigationState> {
const { media } = agent
return pipe(
switchMapIf(media.screen$, el => {
/* Watch and paint sidebar */
const sidebar$ = watchSidebar(el, agent, { main$ })
.pipe(
paintSidebar(el)
)
/* Combine into a single hot observable */
return sidebar$
.pipe(
map(sidebar => ({ sidebar }))
)
}),
switchMapIf(media.screen$, el => watchNavigation(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -20,7 +20,6 @@
* IN THE SOFTWARE.
*/
// tslint:disable-next-line
// export * from "./query"
export * from "./query"
export * from "./reset"
export * from "./result"

View File

@@ -19,3 +19,71 @@
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
import { Observable, combineLatest, fromEvent } from "rxjs"
import {
distinctUntilChanged,
map,
shareReplay,
startWith
} from "rxjs/operators"
import { watchElementFocus } from "utilities"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Search query state
*/
export interface SearchQueryState {
value: string /* Query value */
focus: boolean /* Query focus state */
}
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Options
*/
interface Options {
prepare(value: string): string /* Preparation function */
}
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch search query
*
* @param el - Search query element
* @param options - Options
*
* @return Search query state observable
*/
export function watchSearchQuery(
el: HTMLInputElement, { prepare }: Options
): Observable<SearchQueryState> {
/* Intercept keyboard events */
const value$ = fromEvent(el, "keyup")
.pipe(
map(() => prepare(el.value)),
startWith(""),
distinctUntilChanged()
)
/* Intercept focus events */
const focus$ = watchElementFocus(el)
/* Combine into a single hot observable */
return combineLatest([value$, focus$])
.pipe(
map(([value, focus]) => ({ value, focus })),
shareReplay(1)
)
}

View File

@@ -36,9 +36,9 @@ import { mapTo } from "rxjs/operators"
*/
export function watchSearchReset(
el: HTMLElement
): Observable<boolean> {
): Observable<void> {
return fromEvent(el, "click")
.pipe(
mapTo(true)
mapTo(undefined)
)
}

View File

@@ -52,34 +52,53 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup search result from source observable
* Watch search result
*
* @param el - Search result element
* @param agent - Agent
* @param options - Options
*
* @return Search result state observable
*/
export function watchSearchResult(
el: HTMLElement, agent: Agent, { result$, query$ }: Options
): Observable<SearchResult[]> {
const container = el.parentElement!
/* Compute whether there are more search results elements */
const render$ = watchElementOffset(container, agent)
.pipe(
map(({ y }) => y >= container.scrollHeight - container.offsetHeight - 16),
distinctUntilChanged(),
filter(identity)
)
// combine into search result observable...
/* Paint search results */
return result$
.pipe(
tap(x => { console.log("watchSearchResult", x) }),
paintSearchResultMeta(el, { query$ }),
paintSearchResultList(el, { render$ })
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount search result from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupSearchResult(
agent: Agent, { result$, query$ }: Options
export function mountSearchResult(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, SearchResult[]> {
return pipe(
switchMap(el => {
const parent = el.parentElement!
/* Compute whether more elements need to be rendered */
const render$ = watchElementOffset(parent, agent)
.pipe(
map(({ y }) => y >= parent.scrollHeight - parent.offsetHeight - 16),
distinctUntilChanged(),
filter(identity)
)
/* Paint search results */
return result$
.pipe(
paintSearchResultMeta(el, { query$ }),
paintSearchResultList(el, { render$ })
)
})
switchMap(el => watchSearchResult(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -68,7 +68,7 @@ interface Options {
export function paintSearchResultList(
el: HTMLElement, { render$ }: Options
): MonoTypeOperatorFunction<SearchResult[]> {
const parent = el.parentElement!
const container = el.parentElement!
const list = getElement(".md-search-result__list", el)!
return pipe(
switchMap(result => render$
@@ -79,7 +79,7 @@ export function paintSearchResultList(
scan(index => {
while (index < result.length) {
addToSearchResultList(list, renderSearchResult(result[index++]))
if (parent.scrollHeight - parent.offsetHeight > 16)
if (container.scrollHeight - container.offsetHeight > 16)
break
}
return index

View File

@@ -26,16 +26,16 @@ import { map, shareReplay } from "rxjs/operators"
import { switchMapIf } from "extensions"
import { Agent, paintHidden } from "utilities"
import { Header, watchHeaderOffsetToTopOf } from "../header"
import { HeaderState, watchViewportOffsetFromTopOf } from "../header"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Tabs
* Tabs state
*/
export interface Tabs {
export interface TabsState {
hidden: boolean /* Whether the tabs are hidden */
}
@@ -47,7 +47,7 @@ export interface Tabs {
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
header$: Observable<HeaderState> /* Header state observable */
}
/* ----------------------------------------------------------------------------
@@ -55,32 +55,50 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup tabs from source observable
* Watch tabs
*
* This function returns an observable that computes the visual parameters of
* the tabs, currently only denoting whether the tabs are hidden or not.
*
* @param el - Tabs element
* @param agent - Agent
* @param options - Options
*
* @return Tabs state
*/
export function watchTabs(
el: HTMLElement, agent: Agent, { header$ }: Options
): Observable<TabsState> {
/* Watch and paint visibility */
const hidden$ = watchViewportOffsetFromTopOf(el, agent, { header$ })
.pipe(
paintHidden(el, 8)
)
/* Combine into a single hot observable */
return hidden$
.pipe(
map(hidden => ({ hidden }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount tabs from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupTabs(
agent: Agent, { header$ }: Options
): OperatorFunction<HTMLElement, Tabs> {
export function mountTabs(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, TabsState> {
const { media } = agent
return pipe(
switchMapIf(media.screen$, el => {
/* Watch and paint visibility */
const hidden$ = watchHeaderOffsetToTopOf(el, agent, { header$ })
.pipe(
paintHidden(el, 8)
)
/* Combine into a single hot observable */
return hidden$
.pipe(
map(hidden => ({ hidden }))
)
}),
switchMapIf(media.screen$, el => watchTabs(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -26,10 +26,10 @@ import { map, shareReplay } from "rxjs/operators"
import { switchMapIf } from "extensions"
import { Agent, getElements } from "utilities"
import { Header } from "../../header"
import { HeaderState } from "../../header"
import {
Main,
Sidebar,
MainState,
SidebarState,
paintSidebar,
watchSidebar
} from "../../main"
@@ -44,10 +44,10 @@ import {
* ------------------------------------------------------------------------- */
/**
* Table of contents
* Table of contents state
*/
export interface TableOfContents {
sidebar: Sidebar /* Sidebar */
export interface TableOfContentsState {
sidebar: SidebarState /* Sidebar state */
anchors: AnchorList /* Anchor list */
}
@@ -59,8 +59,8 @@ export interface TableOfContents {
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
main$: Observable<Main> /* Main observable */
header$: Observable<HeaderState> /* Header state observable */
main$: Observable<MainState> /* Main area state observable */
}
/* ----------------------------------------------------------------------------
@@ -68,39 +68,54 @@ interface Options {
* ------------------------------------------------------------------------- */
/**
* Setup table of contents from source observable
* Watch table of contents
*
* @param el - Table of contents element
* @param agent - Agent
* @param options - Options
*
* @return Table of contents state observable
*/
export function watchTableOfContents(
el: HTMLElement, agent: Agent, { header$, main$ }: Options
): Observable<TableOfContentsState> {
/* Watch and paint sidebar */
const sidebar$ = watchSidebar(el, agent, { main$ })
.pipe(
paintSidebar(el)
)
/* Watch and paint anchor list (scroll spy) */
const els = getElements<HTMLAnchorElement>(".md-nav__link", el)
const anchors$ = watchAnchorList(els, agent, { header$ })
.pipe(
paintAnchorList(els)
)
/* Combine into a single hot observable */
return combineLatest([sidebar$, anchors$])
.pipe(
map(([sidebar, anchors]) => ({ sidebar, anchors }))
)
}
/* ------------------------------------------------------------------------- */
/**
* Mount table of contents from source observable
*
* @param agent - Agent
* @param options - Options
*
* @return Operator function
*/
export function setupTableOfContents(
agent: Agent, { header$, main$ }: Options
): OperatorFunction<HTMLElement, TableOfContents> {
export function mountTableOfContents(
agent: Agent, options: Options
): OperatorFunction<HTMLElement, TableOfContentsState> {
const { media } = agent
return pipe(
switchMapIf(media.tablet$, el => {
/* Watch and paint sidebar */
const sidebar$ = watchSidebar(el, agent, { main$ })
.pipe(
paintSidebar(el)
)
/* Watch and paint anchor list (scroll spy) */
const els = getElements<HTMLAnchorElement>(".md-nav__link", el)
const anchors$ = watchAnchorList(els, agent, { header$ })
.pipe(
paintAnchorList(els)
)
/* Combine into a single hot observable */
return combineLatest([sidebar$, anchors$])
.pipe(
map(([sidebar, anchors]) => ({ sidebar, anchors }))
)
}),
switchMapIf(media.tablet$, el => watchTableOfContents(el, agent, options)),
shareReplay(1)
)
}

View File

@@ -47,7 +47,7 @@ import {
} from "actions"
import { Agent, getElement } from "utilities"
import { Header } from "../../header"
import { HeaderState } from "../../header"
/* ----------------------------------------------------------------------------
* Types
@@ -69,7 +69,7 @@ export interface AnchorList {
* Options
*/
interface Options {
header$: Observable<Header> /* Header observable */
header$: Observable<HeaderState> /* Header state observable */
}
/* ----------------------------------------------------------------------------

View File

@@ -21,4 +21,3 @@
*/
export * from "./_"
export * from "./anchor"