aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/src/templates/assets/javascripts/browser/element
diff options
context:
space:
mode:
Diffstat (limited to 'src/templates/assets/javascripts/browser/element')
-rw-r--r--src/templates/assets/javascripts/browser/element/_/.eslintrc6
-rw-r--r--src/templates/assets/javascripts/browser/element/_/index.ts120
-rw-r--r--src/templates/assets/javascripts/browser/element/focus/index.ts81
-rw-r--r--src/templates/assets/javascripts/browser/element/index.ts27
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/_/index.ts86
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/content/index.ts76
-rw-r--r--src/templates/assets/javascripts/browser/element/offset/index.ts24
-rw-r--r--src/templates/assets/javascripts/browser/element/size/_/index.ts151
-rw-r--r--src/templates/assets/javascripts/browser/element/size/content/index.ts67
-rw-r--r--src/templates/assets/javascripts/browser/element/size/index.ts24
-rw-r--r--src/templates/assets/javascripts/browser/element/visibility/index.ts131
11 files changed, 793 insertions, 0 deletions
diff --git a/src/templates/assets/javascripts/browser/element/_/.eslintrc b/src/templates/assets/javascripts/browser/element/_/.eslintrc
new file mode 100644
index 00000000..16973760
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/_/.eslintrc
@@ -0,0 +1,6 @@
+{
+ "rules": {
+ "jsdoc/require-jsdoc": "off",
+ "jsdoc/require-returns-check": "off"
+ }
+}
diff --git a/src/templates/assets/javascripts/browser/element/_/index.ts b/src/templates/assets/javascripts/browser/element/_/index.ts
new file mode 100644
index 00000000..b7beb462
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/_/index.ts
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2016-2023 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
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve all elements matching the query selector
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns 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[] {
+ return Array.from(node.querySelectorAll<T>(selector))
+}
+
+/**
+ * Retrieve an element matching a query selector or throw a reference error
+ *
+ * Note that this function assumes that the element is present. If unsure if an
+ * element is existent, use the `getOptionalElement` function instead.
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns Element
+ */
+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 {
+ const el = getOptionalElement<T>(selector, node)
+ if (typeof el === "undefined")
+ throw new ReferenceError(
+ `Missing element: expected "${selector}" to be present`
+ )
+
+ /* Return element */
+ return el
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve an optional element matching the query selector
+ *
+ * @template T - Element type
+ *
+ * @param selector - Query selector
+ * @param node - Node of reference
+ *
+ * @returns Element or nothing
+ */
+export function getOptionalElement<T extends keyof HTMLElementTagNameMap>(
+ selector: T, node?: ParentNode
+): HTMLElementTagNameMap[T] | undefined
+
+export function getOptionalElement<T extends HTMLElement>(
+ selector: string, node?: ParentNode
+): T | undefined
+
+export function getOptionalElement<T extends HTMLElement>(
+ selector: string, node: ParentNode = document
+): T | undefined {
+ return node.querySelector<T>(selector) || undefined
+}
+
+/**
+ * Retrieve the currently active element
+ *
+ * @returns Element or nothing
+ */
+export function getActiveElement(): HTMLElement | undefined {
+ return document.activeElement instanceof HTMLElement
+ ? document.activeElement || undefined
+ : undefined
+}
diff --git a/src/templates/assets/javascripts/browser/element/focus/index.ts b/src/templates/assets/javascripts/browser/element/focus/index.ts
new file mode 100644
index 00000000..f31fe276
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/focus/index.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2016-2023 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,
+ debounceTime,
+ distinctUntilChanged,
+ fromEvent,
+ map,
+ merge,
+ shareReplay,
+ startWith
+} from "rxjs"
+
+import { getActiveElement } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Focus observable
+ *
+ * Previously, this observer used `focus` and `blur` events to determine whether
+ * an element is focused, but this doesn't work if there are focusable elements
+ * within the elements itself. A better solutions are `focusin` and `focusout`
+ * events, which bubble up the tree and allow for more fine-grained control.
+ *
+ * `debounceTime` is necessary, because when a focus change happens inside an
+ * element, the observable would first emit `false` and then `true` again.
+ */
+const observer$ = merge(
+ fromEvent(document.body, "focusin"),
+ fromEvent(document.body, "focusout")
+)
+ .pipe(
+ debounceTime(1),
+ startWith(undefined),
+ map(() => getActiveElement() || document.body),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch element focus
+ *
+ * @param el - Element
+ *
+ * @returns Element focus observable
+ */
+export function watchElementFocus(
+ el: HTMLElement
+): Observable<boolean> {
+ return observer$
+ .pipe(
+ map(active => el.contains(active)),
+ distinctUntilChanged()
+ )
+}
diff --git a/src/templates/assets/javascripts/browser/element/index.ts b/src/templates/assets/javascripts/browser/element/index.ts
new file mode 100644
index 00000000..50ce84b2
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/index.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2016-2023 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 "./_"
+export * from "./focus"
+export * from "./offset"
+export * from "./size"
+export * from "./visibility"
diff --git a/src/templates/assets/javascripts/browser/element/offset/_/index.ts b/src/templates/assets/javascripts/browser/element/offset/_/index.ts
new file mode 100644
index 00000000..6dd229d5
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/offset/_/index.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2016-2023 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,
+ auditTime,
+ fromEvent,
+ map,
+ merge,
+ startWith
+} from "rxjs"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Element offset
+ */
+export interface ElementOffset {
+ x: number /* Horizontal offset */
+ y: number /* Vertical offset */
+}
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element offset
+ *
+ * @param el - Element
+ *
+ * @returns Element offset
+ */
+export function getElementOffset(
+ el: HTMLElement
+): ElementOffset {
+ return {
+ x: el.offsetLeft,
+ y: el.offsetTop
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element offset
+ *
+ * @param el - Element
+ *
+ * @returns Element offset observable
+ */
+export function watchElementOffset(
+ el: HTMLElement
+): Observable<ElementOffset> {
+ return merge(
+ fromEvent(window, "load"),
+ fromEvent(window, "resize")
+ )
+ .pipe(
+ auditTime(0, animationFrameScheduler),
+ map(() => getElementOffset(el)),
+ startWith(getElementOffset(el))
+ )
+}
diff --git a/src/templates/assets/javascripts/browser/element/offset/content/index.ts b/src/templates/assets/javascripts/browser/element/offset/content/index.ts
new file mode 100644
index 00000000..557301a6
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/offset/content/index.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright (c) 2016-2023 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,
+ auditTime,
+ fromEvent,
+ map,
+ merge,
+ startWith
+} from "rxjs"
+
+import { ElementOffset } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element content offset (= scroll offset)
+ *
+ * @param el - Element
+ *
+ * @returns Element content offset
+ */
+export function getElementContentOffset(
+ el: HTMLElement
+): ElementOffset {
+ return {
+ x: el.scrollLeft,
+ y: el.scrollTop
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element content offset
+ *
+ * @param el - Element
+ *
+ * @returns Element content offset observable
+ */
+export function watchElementContentOffset(
+ el: HTMLElement
+): Observable<ElementOffset> {
+ return merge(
+ fromEvent(el, "scroll"),
+ fromEvent(window, "resize")
+ )
+ .pipe(
+ auditTime(0, animationFrameScheduler),
+ map(() => getElementContentOffset(el)),
+ startWith(getElementContentOffset(el))
+ )
+}
diff --git a/src/templates/assets/javascripts/browser/element/offset/index.ts b/src/templates/assets/javascripts/browser/element/offset/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/offset/index.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2016-2023 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 "./_"
+export * from "./content"
diff --git a/src/templates/assets/javascripts/browser/element/size/_/index.ts b/src/templates/assets/javascripts/browser/element/size/_/index.ts
new file mode 100644
index 00000000..35a5e68b
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/size/_/index.ts
@@ -0,0 +1,151 @@
+/*
+ * Copyright (c) 2016-2023 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,
+ defer,
+ filter,
+ finalize,
+ map,
+ merge,
+ of,
+ shareReplay,
+ startWith,
+ switchMap,
+ tap
+} from "rxjs"
+
+import { watchScript } from "../../../script"
+
+/* ----------------------------------------------------------------------------
+ * Types
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Element offset
+ */
+export interface ElementSize {
+ width: number /* Element width */
+ height: number /* Element height */
+}
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Resize observer entry subject
+ */
+const entry$ = new Subject<ResizeObserverEntry>()
+
+/**
+ * Resize observer observable
+ *
+ * This observable will create a `ResizeObserver` on the first subscription
+ * and will automatically terminate it when there are no more subscribers.
+ * It's quite important to centralize observation in a single `ResizeObserver`,
+ * as the performance difference can be quite dramatic, as the link shows.
+ *
+ * If the browser doesn't have a `ResizeObserver` implementation available, a
+ * polyfill is automatically downloaded from unpkg.com. This is also compatible
+ * with the built-in privacy plugin, which will download the polyfill and put
+ * it alongside the built site for self-hosting.
+ *
+ * @see https://bit.ly/3iIYfEm - Google Groups on performance
+ */
+const observer$ = defer(() => (
+ typeof ResizeObserver === "undefined"
+ ? watchScript("https://unpkg.com/resize-observer-polyfill")
+ : of(undefined)
+))
+ .pipe(
+ map(() => new ResizeObserver(entries => {
+ for (const entry of entries)
+ entry$.next(entry)
+ })),
+ switchMap(observer => merge(NEVER, of(observer))
+ .pipe(
+ finalize(() => observer.disconnect())
+ )
+ ),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element size
+ *
+ * @param el - Element
+ *
+ * @returns Element size
+ */
+export function getElementSize(
+ el: HTMLElement
+): ElementSize {
+ return {
+ width: el.offsetWidth,
+ height: el.offsetHeight
+ }
+}
+
+/* ------------------------------------------------------------------------- */
+
+/**
+ * Watch element size
+ *
+ * This function returns an observable that subscribes to a single internal
+ * instance of `ResizeObserver` upon subscription, and emit resize events until
+ * termination. Note that this function should not be called with the same
+ * element twice, as the first unsubscription will terminate observation.
+ *
+ * Sadly, we can't use the `DOMRect` objects returned by the observer, because
+ * we need the emitted values to be consistent with `getElementSize`, which will
+ * return the used values (rounded) and not actual values (unrounded). Thus, we
+ * use the `offset*` properties. See the linked GitHub issue.
+ *
+ * @see https://bit.ly/3m0k3he - GitHub issue
+ *
+ * @param el - Element
+ *
+ * @returns Element size observable
+ */
+export function watchElementSize(
+ el: HTMLElement
+): Observable<ElementSize> {
+ return observer$
+ .pipe(
+ tap(observer => observer.observe(el)),
+ switchMap(observer => entry$
+ .pipe(
+ filter(({ target }) => target === el),
+ finalize(() => observer.unobserve(el)),
+ map(() => getElementSize(el))
+ )
+ ),
+ startWith(getElementSize(el))
+ )
+}
diff --git a/src/templates/assets/javascripts/browser/element/size/content/index.ts b/src/templates/assets/javascripts/browser/element/size/content/index.ts
new file mode 100644
index 00000000..5ed388cf
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/size/content/index.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2016-2023 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 { ElementSize } from "../_"
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Retrieve element content size (= scroll width and height)
+ *
+ * @param el - Element
+ *
+ * @returns Element content size
+ */
+export function getElementContentSize(
+ el: HTMLElement
+): ElementSize {
+ return {
+ width: el.scrollWidth,
+ height: el.scrollHeight
+ }
+}
+
+/**
+ * Retrieve the overflowing container of an element, if any
+ *
+ * @param el - Element
+ *
+ * @returns Overflowing container or nothing
+ */
+export function getElementContainer(
+ el: HTMLElement
+): HTMLElement | undefined {
+ let parent = el.parentElement
+ while (parent)
+ if (
+ el.scrollWidth <= parent.scrollWidth &&
+ el.scrollHeight <= parent.scrollHeight
+ )
+ parent = (el = parent).parentElement
+ else
+ break
+
+ /* Return overflowing container */
+ return parent ? el : undefined
+}
diff --git a/src/templates/assets/javascripts/browser/element/size/index.ts b/src/templates/assets/javascripts/browser/element/size/index.ts
new file mode 100644
index 00000000..602ff2cf
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/size/index.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2016-2023 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 "./_"
+export * from "./content"
diff --git a/src/templates/assets/javascripts/browser/element/visibility/index.ts b/src/templates/assets/javascripts/browser/element/visibility/index.ts
new file mode 100644
index 00000000..1ffe0b8d
--- /dev/null
+++ b/src/templates/assets/javascripts/browser/element/visibility/index.ts
@@ -0,0 +1,131 @@
+/*
+ * Copyright (c) 2016-2023 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,
+ defer,
+ distinctUntilChanged,
+ filter,
+ finalize,
+ map,
+ merge,
+ of,
+ shareReplay,
+ switchMap,
+ tap
+} from "rxjs"
+
+import {
+ getElementContentSize,
+ getElementSize,
+ watchElementContentOffset
+} from "~/browser"
+
+/* ----------------------------------------------------------------------------
+ * Data
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Intersection observer entry subject
+ */
+const entry$ = new Subject<IntersectionObserverEntry>()
+
+/**
+ * Intersection observer observable
+ *
+ * This observable will create an `IntersectionObserver` on first subscription
+ * and will automatically terminate it when there are no more subscribers.
+ *
+ * @see https://bit.ly/3iIYfEm - Google Groups on performance
+ */
+const observer$ = defer(() => of(
+ new IntersectionObserver(entries => {
+ for (const entry of entries)
+ entry$.next(entry)
+ }, {
+ threshold: 0
+ })
+))
+ .pipe(
+ switchMap(observer => merge(NEVER, of(observer))
+ .pipe(
+ finalize(() => observer.disconnect())
+ )
+ ),
+ shareReplay(1)
+ )
+
+/* ----------------------------------------------------------------------------
+ * Functions
+ * ------------------------------------------------------------------------- */
+
+/**
+ * Watch element visibility
+ *
+ * @param el - Element
+ *
+ * @returns Element visibility observable
+ */
+export function watchElementVisibility(
+ el: HTMLElement
+): Observable<boolean> {
+ return observer$
+ .pipe(
+ tap(observer => observer.observe(el)),
+ switchMap(observer => entry$
+ .pipe(
+ filter(({ target }) => target === el),
+ finalize(() => observer.unobserve(el)),
+ map(({ isIntersecting }) => isIntersecting)
+ )
+ )
+ )
+}
+
+/**
+ * Watch element boundary
+ *
+ * This function returns an observable which emits whether the bottom content
+ * boundary (= scroll offset) of an element is within a certain threshold.
+ *
+ * @param el - Element
+ * @param threshold - Threshold
+ *
+ * @returns Element boundary observable
+ */
+export function watchElementBoundary(
+ el: HTMLElement, threshold = 16
+): Observable<boolean> {
+ return watchElementContentOffset(el)
+ .pipe(
+ map(({ y }) => {
+ const visible = getElementSize(el)
+ const content = getElementContentSize(el)
+ return y >= (
+ content.height - visible.height - threshold
+ )
+ }),
+ distinctUntilChanged()
+ )
+}