diff --git a/src/assets/javascripts/application.js b/src/assets/javascripts/application.js index e9cae4b5c9ca71ab55f1365509bedd4eeb653f79..63621d3342a7a6722ccca1e5c90743c255ca522d 100644 --- a/src/assets/javascripts/application.js +++ b/src/assets/javascripts/application.js @@ -257,9 +257,9 @@ function initialize(config) { // eslint-disable-line func-style "[data-md-component=header]"))) /* Component: link blurring for table of contents */ - new Material.Event.MatchMedia("(min-width: 960px)", - new Material.Event.Listener(window, "scroll", - new Material.Nav.Blur("[data-md-component=toc] [href]"))) + // new Material.Event.MatchMedia("(min-width: 960px)", + // new Material.Event.Listener(window, "scroll", + // new Material.Nav.Blur("[data-md-component=toc] [href]"))) /* Component: collapsible elements for navigation */ const collapsibles = diff --git a/src/assets/javascripts/base/component.ts b/src/assets/javascripts/base/component.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5d2b001ed8c95f34e892083cc90e19db3125319 --- /dev/null +++ b/src/assets/javascripts/base/component.ts @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2016-2018 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/Observable" + +import "rxjs/add/observable/empty" +import "rxjs/add/observable/of" +import "rxjs/add/operator/scan" +import "rxjs/add/operator/switchMap" + +import { merge } from "lodash" + +import { Toggle } from "./toggle" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Component state + */ +export interface ComponentState { + active: boolean /* Whether the component is active */ +} + +/** + * Component lifecycle hooks + */ +export interface Component< + T extends ComponentState, /* Component state */ + V /* Component value */ +> { + initial: T /* Initial state */ + create: (state: T) => T /* Creation hook */ + destroy: (state: T) => T /* Destruction hook */ + update: (state: T) => Observable< + Readonly<V> + > /* Update hook */ +} + +/* ---------------------------------------------------------------------------- + * Lifecycle hooks + * ------------------------------------------------------------------------- */ + +/** + * No-op creation lifecycle hook + * + * @param state - Component state + * + * @return Altered state + */ +export function create<T extends ComponentState>(state: T) { + return state +} + +/** + * No-op destruction lifecycle hook + * + * @param state - Component state + * + * @return Altered state + */ +export function destroy<T extends ComponentState>(state: T) { + return state +} + +/** + * No-op update lifecycle hook + * + * @param state - Component state + * + * @return Altered state + */ +export function update<T extends ComponentState, V>(state: T) { + return Observable.empty<V>() +} + +/* ---------------------------------------------------------------------------- + * Functions + * ------------------------------------------------------------------------- */ + +/** + * Bind a component's lifecycle to a toggle + * + * @param component - Component + * @param toggle$ - Toggle observable + * + * @return Component observable + */ +export function bind<T extends ComponentState, V>( + component: Component<T, V>, toggle$?: Observable<Toggle> +) { + const { initial, create: c, destroy: d, update: u } = component + return (toggle$ || Observable.of({ active: true })) + + /* Initialize component and handle state change */ + .scan<Toggle, T>((state, { active }) => { + return (active ? c : d)(merge(state, { active })) + }, initial) + + /* Return update component lifecycle hook */ + .switchMap(state => { + return state.active ? u(state) : Observable.empty<V>() + }) +} diff --git a/src/assets/javascripts/base/toggle.ts b/src/assets/javascripts/base/toggle.ts new file mode 100644 index 0000000000000000000000000000000000000000..43d8ea6334afb387f05225bf9ff99d0d704a2777 --- /dev/null +++ b/src/assets/javascripts/base/toggle.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2016-2018 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 + * ------------------------------------------------------------------------- */ + +/** + * Toggle + */ +export interface Toggle { + active: boolean /* Whether the toggle is active */ +} diff --git a/src/assets/javascripts/components/nav-blur.ts b/src/assets/javascripts/components/nav-blur.ts new file mode 100644 index 0000000000000000000000000000000000000000..5164c3f850f80bb0bd7ffc5791c957a20fc533f7 --- /dev/null +++ b/src/assets/javascripts/components/nav-blur.ts @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2016-2018 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/Observable" + +import "rxjs/add/observable/empty" +import "rxjs/add/observable/of" +import "rxjs/add/operator/switchMap" + +import { + Component, + ComponentState, + create, + destroy +} from "../base/component" +import * as viewport from "../device/viewport" + +/* ---------------------------------------------------------------------------- + * Types + * ------------------------------------------------------------------------- */ + +/** + * Component state + */ +export interface NavBlurState extends ComponentState { + anchors: HTMLAnchorElement[] /* Anchor elements */ + targets: HTMLElement[] /* Target elements */ + index: number /* Current index */ + offset: number /* Current offset */ + direction: boolean /* Current scroll direction */ +} + +/** + * Component value + */ +export interface NavBlur { + anchor: HTMLAnchorElement /* Active anchor */ + target: HTMLElement /* Active target */ +} + +/* ---------------------------------------------------------------------------- + * Values + * ------------------------------------------------------------------------- */ + +/** + * Initial values for component state + */ +export const initial: NavBlurState = { + active: false, /* Whether the component is active */ + anchors: [], /* Anchor elements */ + targets: [], /* Target elements */ + index: 0, /* Current index */ + offset: 0, /* Current offset */ + direction: false /* Current scroll direction */ +} + +/* ---------------------------------------------------------------------------- + * Lifecycle hooks + * ------------------------------------------------------------------------- */ + +/** + * Blur links within the table of contents above current page y-offset + * + * @param state - Component state + * + * @return Altered state + */ +export function update(state: NavBlurState) { + const { anchors, targets } = state + return viewport.offset() + .switchMap(({ y }) => { + const direction = (state.offset - y < 0) + const index = state.index + + /* Exit when there are no anchors/targets */ + if (targets.length === 0) + return Observable.empty<NavBlur>() + + /* Hack: reset index if direction changed to catch very fast scrolling, + because otherwise we would have to register a timer and that sucks */ + if (state.direction !== direction) + state.index = direction + ? state.index = 0 + : state.index = anchors.length - 1 + + /* Scroll direction is down */ + if (state.offset <= y) { + for (let i = state.index + 1; i < anchors.length; i++) { + if (targets[i].offsetTop - (56 + 24) <= y) { // TODO: Refactor value determination + if (i > 0) + anchors[i - 1].dataset.mdState = "blur" + state.index = i + } else { + break + } + } + + /* Scroll direction is up */ + } else { + for (let i = state.index; i >= 0; i--) { + if (targets[i].offsetTop - (56 + 24) > y) { + if (i > 0) + anchors[i - 1].dataset.mdState = "" + } else { + state.index = i + break + } + } + } + + /* Remember current offset and direction for next iteration */ + state.offset = y + state.direction = direction + + /* Emit current anchor and target if index changed */ + return state.index !== index + ? Observable.of<NavBlur>({ + anchor: anchors[state.index], + target: targets[state.index] + }) + : Observable.empty<NavBlur>() + }) +} + +/** + * Component factory method + * + * @param els - Anchor elements + * + * @return Component + */ +export function factory( + els: ArrayLike<HTMLAnchorElement> +): Component<NavBlurState, NavBlur> { + const anchors = Array.from(els) + const targets: HTMLElement[] = anchors.reduce((result, anchor) => [ + ...result, document.getElementById(anchor.hash.substring(1)) || [] + ], []) + return { + initial: { ...initial, anchors, targets }, create, destroy, update + } +} diff --git a/src/assets/javascripts/device/breakpoint.ts b/src/assets/javascripts/device/breakpoint.ts index 780ca8d053a1258cc18e0ebe0839ee32e0939cc9..041da8295c7f7b5eaafae1e2aad122422c6d4ac1 100644 --- a/src/assets/javascripts/device/breakpoint.ts +++ b/src/assets/javascripts/device/breakpoint.ts @@ -25,6 +25,8 @@ import { Observable } from "rxjs/Observable" import "rxjs/add/observable/fromEventPattern" +import { Toggle } from "../base/toggle" + /* ---------------------------------------------------------------------------- * Functions * ------------------------------------------------------------------------- */ @@ -35,10 +37,10 @@ import "rxjs/add/observable/fromEventPattern" * @param media - Media query */ export function query(media: MediaQueryList) { - const media$ = new BehaviorSubject<boolean>(media.matches) - Observable.fromEventPattern<boolean>(next => + const media$ = new BehaviorSubject<Toggle>({ active: media.matches }) + Observable.fromEventPattern<Toggle>(next => media.addListener( - (mq: MediaQueryList) => next(mq.matches) + (mq: MediaQueryList) => next({ active: mq.matches }) )) .subscribe(media$) return media$ diff --git a/tests/unit/device/breakpoint.spec.ts b/tests/unit/device/breakpoint.spec.ts index ed0a7bfe29abae3460761185f7c0ca51114e20bc..bcaf6909fcc6cda3e8e901c3479e864458d18e8e 100644 --- a/tests/unit/device/breakpoint.spec.ts +++ b/tests/unit/device/breakpoint.spec.ts @@ -56,8 +56,8 @@ describe("device", () => { /* Media query for testing purposes */ const media = window.matchMedia("(min-width: 480px)") - /* Test: it should return a behavior subject */ - it("should return a behavior subject", () => { + /* Test: it should return a subject */ + it("should return a subject", () => { const media$ = query(media) expect(media$).toEqual(jasmine.any(Subject)) }) @@ -67,8 +67,8 @@ describe("device", () => { const media$ = query(media) media$ .take(1) - .subscribe(initial => { - expect(initial).toEqual(true) + .subscribe(({ active }) => { + expect(active).toEqual(true) done() }, done.fail) }) @@ -80,8 +80,8 @@ describe("device", () => { .take(2) .toArray() .subscribe(([initial, changed]) => { - expect(initial).toEqual(true) - expect(changed).toEqual(false) + expect(initial.active).toEqual(true) + expect(changed.active).toEqual(false) done() }, done.fail) requestAnimationFrame(() => { @@ -93,8 +93,8 @@ describe("device", () => { /* Create a subject for a minimum media query */ describe(".from", () => { - /* Test: it should return a behavior subject */ - it("should return a behavior subject", () => { + /* Test: it should return a subject */ + it("should return a subject", () => { const media$ = from(480) expect(media$).toEqual(jasmine.any(Subject)) }) @@ -104,8 +104,8 @@ describe("device", () => { const media$ = from(480) media$ .take(1) - .subscribe(initial => { - expect(initial).toEqual(true) + .subscribe(({ active }) => { + expect(active).toEqual(true) done() }, done.fail) }) @@ -117,8 +117,8 @@ describe("device", () => { .take(2) .toArray() .subscribe(([initial, changed]) => { - expect(initial).toEqual(true) - expect(changed).toEqual(false) + expect(initial.active).toEqual(true) + expect(changed.active).toEqual(false) done() }, done.fail) requestAnimationFrame(() => { @@ -130,8 +130,8 @@ describe("device", () => { /* Create a subject for a maximum media query */ describe(".to", () => { - /* Test: it should return a behavior subject */ - it("should return a behavior subject", () => { + /* Test: it should return a subject */ + it("should return a subject", () => { const media$ = to(479) expect(media$).toEqual(jasmine.any(Subject)) }) @@ -141,8 +141,8 @@ describe("device", () => { const media$ = to(479) media$ .take(1) - .subscribe(initial => { - expect(initial).toEqual(false) + .subscribe(({ active }) => { + expect(active).toEqual(false) done() }, done.fail) }) @@ -154,8 +154,8 @@ describe("device", () => { .take(2) .toArray() .subscribe(([initial, changed]) => { - expect(initial).toEqual(false) - expect(changed).toEqual(true) + expect(initial.active).toEqual(false) + expect(changed.active).toEqual(true) done() }, done.fail) requestAnimationFrame(() => { diff --git a/tests/unit/device/viewport.spec.ts b/tests/unit/device/viewport.spec.ts index 49fdcc1cad3801540ffae9cfc6f2a4ad286aec98..b8dd44aa8b6c8a427d104aca2b7adfe3b3985612 100644 --- a/tests/unit/device/viewport.spec.ts +++ b/tests/unit/device/viewport.spec.ts @@ -55,8 +55,8 @@ describe("device", () => { /* Create a subject emitting changes in viewport offset */ describe(".offset", () => { - /* Test: it should return a behavior subject */ - it("should return a behavior subject", () => { + /* Test: it should return a subject */ + it("should return a subject", () => { const offset$ = offset() expect(offset$).toEqual(jasmine.any(Subject)) }) @@ -99,8 +99,8 @@ describe("device", () => { /* Create a subject emitting changes in viewport size */ describe(".size", () => { - /* Test: it should return a behavior subject */ - it("should return a behavior subject", () => { + /* Test: it should return a subject */ + it("should return a subject", () => { const size$ = size() expect(size$).toEqual(jasmine.any(Subject)) }) diff --git a/tsconfig.json b/tsconfig.json index 9b6438a16e4bcd79287887e0e7ab2e43768fbee3..b4761f04a1fee05465ea8e70611766ca029297cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,7 @@ "removeComments": true, "sourceMap": true, "strictFunctionTypes": true, + "strictNullChecks": true, "stripInternal": true, "target": "es5" },