From c1e28224aa2e1450031669d9a7e359b63e04687d Mon Sep 17 00:00:00 2001 From: Volpeon Date: Wed, 22 Jul 2026 19:40:59 +0200 Subject: Init --- src/scripts/lib/dom.ts | 59 ++++++ src/scripts/lib/keyboardNav.ts | 272 +++++++++++++++++++++++++ src/scripts/lib/lightbox.ts | 450 +++++++++++++++++++++++++++++++++++++++++ src/scripts/lib/siteHeader.ts | 37 ++++ src/scripts/lib/tabbar.ts | 12 ++ src/scripts/lib/toc.ts | 14 ++ src/scripts/script.ts | 17 ++ 7 files changed, 861 insertions(+) create mode 100644 src/scripts/lib/dom.ts create mode 100644 src/scripts/lib/keyboardNav.ts create mode 100644 src/scripts/lib/lightbox.ts create mode 100644 src/scripts/lib/siteHeader.ts create mode 100644 src/scripts/lib/tabbar.ts create mode 100644 src/scripts/lib/toc.ts create mode 100644 src/scripts/script.ts (limited to 'src/scripts') diff --git a/src/scripts/lib/dom.ts b/src/scripts/lib/dom.ts new file mode 100644 index 0000000..a88686a --- /dev/null +++ b/src/scripts/lib/dom.ts @@ -0,0 +1,59 @@ +function isAbove(a: HTMLElement, b: HTMLElement) { + return a.offsetTop + a.clientHeight < b.offsetTop; +} + +export function getVerticalNeighbor( + dir: -1 | 1, + items: Iterable, + current: HTMLElement, +) { + let candidates: HTMLElement[] = []; + + if (dir === -1) { + let adjacentY = Number.MIN_SAFE_INTEGER; + + for (const item of items) { + if (!isAbove(item, current)) { + continue; + } + + if (item.offsetTop > adjacentY) { + adjacentY = item.offsetTop; + candidates = [item]; + } else if (item.offsetTop === adjacentY) { + candidates.push(item); + } + } + } else { + let adjacentY = Number.MAX_SAFE_INTEGER; + + for (const item of items) { + if (!isAbove(current, item)) { + continue; + } + + if (item.offsetTop < adjacentY) { + adjacentY = item.offsetTop; + candidates = [item]; + } else if (item.offsetTop === adjacentY) { + candidates.push(item); + } + } + } + + const currentX = current.offsetLeft + 0.5 * current.clientWidth; + let match = candidates[0]; + let bestDist = Number.MAX_SAFE_INTEGER; + + for (const candidate of candidates) { + const candidateX = candidate.offsetLeft + 0.5 * candidate.clientWidth; + const dist = Math.abs(currentX - candidateX); + + if (dist < bestDist) { + bestDist = dist; + match = candidate; + } + } + + return match; +} diff --git a/src/scripts/lib/keyboardNav.ts b/src/scripts/lib/keyboardNav.ts new file mode 100644 index 0000000..4c79a08 --- /dev/null +++ b/src/scripts/lib/keyboardNav.ts @@ -0,0 +1,272 @@ +import { getVerticalNeighbor } from "./dom.ts"; + +function focusPrev( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const prev = i > 0 ? items[i - 1] : wrap ? items.at(-1) : undefined; + + if (!prev || prev === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + prev.removeAttribute("tabindex"); + } + prev.focus(); + return true; +} + +function focusNext( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + const i = items.indexOf(current); + + const next = i < items.length - 1 ? items[i + 1] : wrap ? items[0] : undefined; + + if (!next || next === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + next.removeAttribute("tabindex"); + } + next.focus(); + return true; +} + +function focusUp( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = [...owner.querySelectorAll(itemSelector)]; + let neighbor: HTMLElement | undefined = getVerticalNeighbor(-1, items, current); + + if (!neighbor && wrap) { + neighbor = items.at(-1); + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusDown( + owner: HTMLElement, + itemSelector: string, + wrap: boolean, + stateful: boolean, + current: HTMLElement, +) { + const items = owner.querySelectorAll(itemSelector); + let neighbor = getVerticalNeighbor(1, items, current); + + if (!neighbor && wrap) { + neighbor = items[0]; + } + + if (!neighbor || neighbor === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + neighbor.removeAttribute("tabindex"); + } + neighbor.focus(); + return true; +} + +function focusFirst( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const first = cards[0]; + + if (!first || first === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + first.removeAttribute("tabindex"); + } + first.focus(); + return true; +} + +function focusLast( + owner: HTMLElement, + itemSelector: string, + stateful: boolean, + current: HTMLElement, +) { + const cards = [...owner.querySelectorAll(itemSelector)]; + const last = cards.at(-1); + + if (!last || last === current) { + return false; + } + + if (stateful) { + current.setAttribute("tabindex", "-1"); + last.removeAttribute("tabindex"); + } + last.focus(); + return true; +} + +function onKeyDown( + owner: HTMLElement, + itemSelector: string, + horizontal: boolean, + vertical: boolean, + wrap: boolean, + stateful: boolean, + e: KeyboardEvent, +) { + let handled = false; + + if (!e.currentTarget) { + return; + } + + switch (e.key) { + case "ArrowLeft": { + if (horizontal) { + handled = focusPrev(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowRight": { + if (horizontal) { + handled = focusNext(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowUp": { + if (vertical) { + handled = focusUp(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "ArrowDown": { + if (vertical) { + handled = focusDown(owner, itemSelector, wrap, stateful, e.currentTarget as HTMLElement); + } + break; + } + + case "Home": { + handled = focusFirst(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + + case "End": { + handled = focusLast(owner, itemSelector, stateful, e.currentTarget as HTMLElement); + break; + } + } + + if (handled) { + e.stopPropagation(); + e.preventDefault(); + } +} + +export function mountKeyboardNav() { + const keyboardNavEls = document.querySelectorAll(".js-keyboard-nav"); + + for (const keyboardNavEl of keyboardNavEls) { + const itemSelector = keyboardNavEl.dataset.keyboardNavItem ?? ""; + + if (!itemSelector) { + console.error("Keyboard nav: No item class provided"); + continue; + } + + let horizontal = false, + vertical = false, + wrap = false, + stateful = false; + + switch (keyboardNavEl.dataset.keyboardNavMode) { + case "grid": { + horizontal = true; + vertical = true; + stateful = true; + break; + } + + case "nav": { + horizontal = true; + wrap = true; + break; + } + + case "menu": { + vertical = true; + wrap = true; + break; + } + + case "list": { + vertical = true; + wrap = true; + stateful = true; + break; + } + } + + const onKeyDown_ = (e: KeyboardEvent) => { + onKeyDown(keyboardNavEl, itemSelector, horizontal, vertical, wrap, stateful, e); + }; + + const itemEls = [...keyboardNavEl.querySelectorAll(itemSelector)].filter((el) => + el.matches("a, button, input"), + ); + let focused = false; + + for (const itemEl of itemEls) { + itemEl.addEventListener("keydown", onKeyDown_); + if (!itemEl.classList.contains("is-selected")) { + itemEl.setAttribute("tabindex", "-1"); + } else { + focused = true; + } + } + + if (!focused) { + itemEls[0]?.removeAttribute("tabindex"); + } + } +} diff --git a/src/scripts/lib/lightbox.ts b/src/scripts/lib/lightbox.ts new file mode 100644 index 0000000..ac5b581 --- /dev/null +++ b/src/scripts/lib/lightbox.ts @@ -0,0 +1,450 @@ +import Alpine from "alpinejs"; + +export interface LightboxItem { + thumbnail?: string; + src: string; + srcset?: string; + alt: string; +} + +function touchParams(e: TouchEvent) { + const [t0, t1] = e.touches; + + if (e.touches.length > 2 || !t0) { + return; + } + + let x = t0.clientX; + let y = t0.clientY; + let dist = 0; + + if (t1) { + dist = Math.hypot(x - t1.clientX, y - t1.clientY); + x = (x + t1.clientX) / 2; + y = (y + t1.clientY) / 2; + } + + return { x, y, dist }; +} + +export function mountLightbox() { + const minZoom = 0.9; + + Alpine.data("lightbox", () => ({ + _zoom: minZoom, + _offsetX: 0, + _offsetY: 0, + + loading: false, + inertia: false, + items: [], + current: 0, + dragging: false, + touchStart: { + x: 0, + y: 0, + distance: 0, + transformOrigin: { x: 0, y: 0 }, + zoom: 1, + offsetX: 0, + offsetY: 0, + timestamp: 0, + }, + velocity: { + measure: false, + prevOffsetX: 0, + prevOffsetY: 0, + timestamp: 0, + x: 0, + y: 0, + }, + + get zoom() { + return this._zoom; + }, + set zoom(value) { + this._zoom = Math.min(this.maxZoom, Math.max(minZoom, value)); + /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign + this.offsetX = this.offsetX; + /* oxlint-disable-next-line eslint/no-self-assign */ // deno-lint-ignore no-self-assign + this.offsetY = this.offsetY; + }, + + get offsetX() { + return this._offsetX; + }, + set offsetX(value) { + const limit = this.offsetLimit; + this._offsetX = Math.min(limit.x, Math.max(-limit.x, value)); + }, + + get offsetY() { + return this._offsetY; + }, + set offsetY(value) { + const limit = this.offsetLimit; + this._offsetY = Math.min(limit.y, Math.max(-limit.y, value)); + }, + + get offsetLimit(): { x: number; y: number } { + if (!this.$refs.img) { + return { x: 0, y: 0 }; + } + + const th = this.$refs.thumbnails?.clientHeight ?? 0; + const cw = this.$root.clientWidth; + const ch = this.$root.clientHeight - th; + const x = 0.5 * Math.max(0, this.$refs.img.clientWidth * this.zoom - cw); + const y = 0.5 * Math.max(0, this.$refs.img.clientHeight * this.zoom - ch); + + return { x, y }; + }, + + get open(): boolean { + return !!this.$root.hasAttribute("open"); + }, + + get currentItem(): LightboxItem { + return this.items[this.current] ?? { src: "", alt: "" }; + }, + + get transformOrigin(): { x: number; y: number } { + const img = this.$refs.img; + + if (!img) { + return { x: this.offsetX, y: this.offsetY }; + } + + return { + x: img.offsetLeft + img.clientWidth / 2 + this.offsetX, + y: img.offsetTop + img.clientHeight / 2 + this.offsetY, + }; + }, + + get naturalZoom() { + const img = this.$refs.img; + return img ? img.naturalWidth / img.clientWidth : 1; + }, + + get maxZoom() { + return Math.max(this.naturalZoom, 5); + }, + + get zoomedFull() { + return Math.abs(this.zoom - this.naturalZoom) < 0.1; + }, + + init() { + const triggerEls = document.querySelectorAll("a[data-lightbox]"); + const galleries = new Map(); + + for (const triggerEl of triggerEls) { + const triggerImgEl = triggerEl.querySelector("img"); + + const key = triggerEl.dataset.lightbox ?? ""; + const gallery = galleries.get(key); + const item: LightboxItem = { + thumbnail: triggerImgEl?.src, + src: triggerEl.href, + srcset: triggerEl.dataset.lightboxSrcset, + alt: "", + }; + + if (gallery) { + gallery.push(item); + } else { + galleries.set(key, [item]); + } + + triggerEl.style.cursor = "zoom-in"; + triggerEl.addEventListener("click", (e) => { + e.preventDefault(); + this.show(galleries.get(key) ?? [], item.src); + }); + } + + this.$watch("currentItem.src", () => { + this.loading = true; + this.reset(); + }); + }, + + show(items: LightboxItem[], src?: string) { + const current = items.findIndex((item) => item.src === src); + this.items = items; + this.current = current === -1 ? 0 : current; + this.reset(); + + (this.$root).showModal(); + }, + + close() { + (this.$root).close(); + }, + + reset() { + this.stopVelocity(); + this.stopInertia(); + this.dragging = false; + this._zoom = minZoom; + this._offsetX = 0; + this._offsetY = 0; + }, + + prev() { + this.current = this.current > 0 ? this.current - 1 : this.items.length - 1; + }, + + next() { + this.current = this.current < this.items.length - 1 ? this.current + 1 : 0; + }, + + toggleZoom(x?: number, y?: number) { + const zoom = this.zoomedFull ? minZoom : this.naturalZoom; + const deltaZoom = zoom / this.zoom - 1; + + this.zoom = zoom; + + if (x !== undefined && y !== undefined) { + const transformOrigin = this.transformOrigin; + const offsetX = transformOrigin.x - x; + const offsetY = transformOrigin.y - y; + + this.offsetX += offsetX * deltaZoom; + this.offsetY += offsetY * deltaZoom; + } + }, + + measureVelocity(loop?: boolean) { + if (!loop) { + this.inertia = false; + + if (this.velocity.measure) { + return; + } else { + this.velocity.prevOffsetX = this.offsetX; + this.velocity.prevOffsetY = this.offsetY; + this.velocity.x = 0; + this.velocity.y = 0; + this.velocity.measure = true; + } + } + if (!this.velocity.measure) { + return; + } + + const deltaX = this.offsetX - this.velocity.prevOffsetX; + const deltaY = this.offsetY - this.velocity.prevOffsetY; + let window = 1; + + if ( + Math.sqrt(deltaX ** 2 + deltaY ** 2) < + Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2) + ) { + window = 6; + } + + this.velocity.x -= this.velocity.x / window; + this.velocity.x += deltaX / window; + this.velocity.y -= this.velocity.y / window; + this.velocity.y += deltaY / window; + + this.velocity.prevOffsetX = this.offsetX; + this.velocity.prevOffsetY = this.offsetY; + + requestAnimationFrame(this.measureVelocity.bind(this, true)); + }, + + stopVelocity() { + this.velocity.measure = false; + }, + + runInertia(loop?: boolean, timestamp: DOMHighResTimeStamp = performance.now()) { + const len = Math.sqrt(this.velocity.x ** 2 + this.velocity.y ** 2); + + if (!loop) { + this.stopVelocity(); + + if (this.inertia) { + return; + } else if (len > 5) { + this.inertia = true; + this.velocity.timestamp = timestamp; + } + } + + if (!this.inertia) { + this.stopInertia(); + return; + } + + if (loop) { + const norm = { x: this.velocity.x / len, y: this.velocity.y / len }; + const elapsed = timestamp - this.velocity.timestamp; + const friction = 30 * (elapsed / 1000); + const decel = { x: norm.x * friction, y: norm.y * friction }; + const decelLen = Math.sqrt(decel.x ** 2 + decel.y ** 2); + + if (len < decelLen) { + this.stopInertia(); + return; + } + + this.offsetX += this.velocity.x; + this.offsetY += this.velocity.y; + this.velocity.x -= decel.x; + this.velocity.y -= decel.y; + this.velocity.timestamp = timestamp; + } + + requestAnimationFrame(this.runInertia.bind(this, true)); + }, + + stopInertia() { + this.inertia = false; + }, + + handleClose() { + this.stopVelocity(); + this.stopInertia(); + }, + + handleScroll(e: WheelEvent) { + this.stopInertia(); + + const direction = e.deltaY > 0 ? -1 : 1; + + const transformOrigin = this.transformOrigin; + const offsetX = transformOrigin.x - e.clientX; + const offsetY = transformOrigin.y - e.clientY; + + const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.zoom * (1 + direction * 0.1))); + const deltaZoom = zoom / this.zoom - 1; + + this.zoom = zoom; + this.offsetX += offsetX * deltaZoom; + this.offsetY += offsetY * deltaZoom; + }, + + handleMouseDown(e: MouseEvent) { + this.measureVelocity(); + + if (e.buttons === 1) { + this.dragging = true; + } + }, + + handleMouseMove(e: MouseEvent) { + if (!this.dragging) { + return; + } + + this.offsetX += e.movementX; + this.offsetY += e.movementY; + }, + + handleMouseUp() { + this.dragging = false; + this.runInertia(); + }, + + handleDoubleClick(e: MouseEvent) { + this.toggleZoom(e.clientX, e.clientY); + }, + + handleTouchStart(e: TouchEvent) { + const params = touchParams(e); + + if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { + return; + } + + e.preventDefault(); + + const now = Date.now(); + + if (e.touches.length === 1) { + if ( + now - this.touchStart.timestamp < 200 && + Math.sqrt((params.x - this.touchStart.x) ** 2 + (params.y - this.touchStart.y) ** 2) < 20 + ) { + this.toggleZoom(params.x, params.y); + } + } + + this.touchStart.x = params.x; + this.touchStart.y = params.y; + this.touchStart.distance = params.dist; + this.touchStart.transformOrigin = this.transformOrigin; + this.touchStart.zoom = this.zoom; + this.touchStart.offsetX = this.offsetX; + this.touchStart.offsetY = this.offsetY; + this.touchStart.timestamp = now; + + this.dragging = false; + + if (e.touches.length === 1) { + this.measureVelocity(); + } else { + this.stopVelocity(); + } + }, + + handleTouchMove(e: TouchEvent) { + const params = touchParams(e); + + if (!params || (e.touches.length === 1 && e.target !== this.$refs.img)) { + return; + } + + e.preventDefault(); + + const deltaX = params.x - this.touchStart.x; + const deltaY = params.y - this.touchStart.y; + const deltaDist = this.touchStart.distance ? params.dist / this.touchStart.distance : 1; + + const offsetX = this.touchStart.transformOrigin.x - this.touchStart.x; + const offsetY = this.touchStart.transformOrigin.y - this.touchStart.y; + + const zoom = Math.min(this.maxZoom, Math.max(minZoom, this.touchStart.zoom * deltaDist)); + const deltaZoom = zoom / this.touchStart.zoom - 1; + + this.zoom = zoom; + this.offsetX = this.touchStart.offsetX + deltaX + offsetX * deltaZoom; + this.offsetY = this.touchStart.offsetY + deltaY + offsetY * deltaZoom; + }, + + handleTouchEnd(e: TouchEvent) { + this.handleTouchStart(e); + + if (!e.touches.length) { + this.runInertia(); + } + }, + + handleKeyDown(e: KeyboardEvent) { + if (!this.open || e.defaultPrevented) { + return; + } + + this.stopInertia(); + + switch (e.key) { + case "Left": + case "ArrowLeft": + this.prev(); + break; + + case "Right": + case "ArrowRight": + this.next(); + break; + + default: + return; + } + + e.preventDefault(); + }, + })); +} diff --git a/src/scripts/lib/siteHeader.ts b/src/scripts/lib/siteHeader.ts new file mode 100644 index 0000000..0d9ec43 --- /dev/null +++ b/src/scripts/lib/siteHeader.ts @@ -0,0 +1,37 @@ +export function mountSiteHeader() { + const headerEl = document.querySelector(".c-site-header"); + + if (headerEl) { + const navbarEl = headerEl?.querySelector(".o-navbar--static-white"); + const logoEl = headerEl?.querySelector(".c-logo--static-white"); + const buttonEls = headerEl?.querySelectorAll(".o-button--static-white"); + + let settings: IntersectionObserverInit; + + const boundingRect = headerEl.getBoundingClientRect(); + + if (boundingRect.top > 0) { + settings = { + rootMargin: "1px 0px -100% 0px", + }; + } else { + settings = { + root: document.documentElement, + rootMargin: "-1px 0px 0px 0px", + threshold: [1], + }; + } + + const observer = new IntersectionObserver(([e]) => { + const scroll = e?.isIntersecting; + headerEl.classList.toggle("is-scroll", scroll); + navbarEl?.classList.toggle("o-navbar--static-white", !scroll); + logoEl?.classList.toggle("c-logo--static-white", !scroll); + for (const buttonEl of buttonEls) { + buttonEl.classList.toggle("o-button--static-white", !scroll); + } + }, settings); + + observer.observe(headerEl); + } +} diff --git a/src/scripts/lib/tabbar.ts b/src/scripts/lib/tabbar.ts new file mode 100644 index 0000000..6a3d3d3 --- /dev/null +++ b/src/scripts/lib/tabbar.ts @@ -0,0 +1,12 @@ +export function mountTabbar() { + const tabbarEls = document.querySelectorAll(".o-tabbar__tabs"); + + for (const tabbarEl of tabbarEls) { + const selectedItemEl = tabbarEl.querySelector(".o-tabbar__tab.is-selected"); + + if (selectedItemEl) { + tabbarEl.scrollLeft = + selectedItemEl.offsetLeft - 0.5 * tabbarEl.clientWidth + 0.5 * selectedItemEl.clientWidth; + } + } +} diff --git a/src/scripts/lib/toc.ts b/src/scripts/lib/toc.ts new file mode 100644 index 0000000..958f209 --- /dev/null +++ b/src/scripts/lib/toc.ts @@ -0,0 +1,14 @@ +export function mountToc() { + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const id = entry.target.getAttribute("id"); + document + .querySelector(`.o-toc__item[href="#${id}"]`) + ?.classList.toggle("is-selected", entry.intersectionRatio > 0); + } + }); + + document.querySelectorAll("section[id]").forEach((section) => { + observer.observe(section); + }); +} diff --git a/src/scripts/script.ts b/src/scripts/script.ts new file mode 100644 index 0000000..02fb7ff --- /dev/null +++ b/src/scripts/script.ts @@ -0,0 +1,17 @@ +/// +/// + +import { mountKeyboardNav } from "./lib/keyboardNav.ts"; +import { mountLightbox } from "./lib/lightbox.ts"; +import { mountSiteHeader } from "./lib/siteHeader.ts"; +import { mountTabbar } from "./lib/tabbar.ts"; +import { mountToc } from "./lib/toc.ts"; + +document.body.classList.remove("t-no-js"); +document.body.classList.add("t-js"); + +mountKeyboardNav(); +mountLightbox(); +mountTabbar(); +mountSiteHeader(); +mountToc(); -- cgit v1.3.1