From 5c973b488c58a836082d8ae5e48ff42e830d7661 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 May 2024 07:54:32 -0300 Subject: content: client4.js -> client.js, site7.css -> site.css --- src/static/client.js | 3483 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 3483 insertions(+) create mode 100644 src/static/client.js (limited to 'src/static/client.js') diff --git a/src/static/client.js b/src/static/client.js new file mode 100644 index 00000000..729836b5 --- /dev/null +++ b/src/static/client.js @@ -0,0 +1,3483 @@ +/* eslint-env browser */ + +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. + +import {accumulateSum, empty, filterMultipleArrays, stitchArrays} + from '../util/sugar.js'; +import {fetchWithProgress} from './xhr-util.js'; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +function initInfo(infoKey, description) { + const object = {...description}; + + for (const obj of [ + object, + object.state, + object.setting, + object.event, + ]) { + if (!obj) continue; + Object.preventExtensions(obj); + } + + if (object.session) { + const sessionDefaults = object.session; + + object.session = {}; + + for (const [key, defaultValue] of Object.entries(sessionDefaults)) { + const storageKey = `hsmusic.${infoKey}.${key}`; + + let fallbackValue = defaultValue; + + Object.defineProperty(object.session, key, { + get: () => { + try { + return sessionStorage.getItem(storageKey) ?? defaultValue; + } catch (error) { + if (error instanceof DOMException) { + return fallbackValue; + } else { + throw error; + } + } + }, + + set: (value) => { + try { + sessionStorage.setItem(storageKey, value); + } catch (error) { + if (error instanceof DOMException) { + fallbackValue = value; + } else { + throw error; + } + } + }, + }); + } + + Object.preventExtensions(object.session); + } + + clientInfo[infoKey] = object; + + return object; +} + +// Localiz8tion nonsense ---------------------------------- + +/* +const language = document.documentElement.getAttribute('lang'); + +let list; +if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { + const getFormat = (type) => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit'), + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = (array) => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock, + }; +} +*/ + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, ...args) { + if (typeof args[0] === 'string' && args.length === 1) { + return getComputedStyle(el).getPropertyValue(args[0]).trim(); + } + + if (typeof args[0] === 'string' && args.length === 2) { + if (args[1] === null) { + el.style.removeProperty(args[0]); + } else { + el.style.setProperty(args[0], args[1]); + } + return; + } + + if (typeof args[0] === 'object') { + for (const [property, value] of Object.entries(args[0])) { + cssProp(el, property, value); + } + } +} + +// Curry-style, so multiple points can more conveniently be tested at once. +function pointIsOverAnyOf(elements) { + return (clientX, clientY) => { + const element = document.elementFromPoint(clientX, clientY); + return elements.some(el => el.contains(element)); + }; +} + +function getVisuallyContainingElement(child) { + let parent = child.parentElement; + + while (parent) { + if ( + cssProp(parent, 'overflow') === 'hidden' || + cssProp(parent, 'contain') === 'paint' + ) { + return parent; + } + + parent = parent.parentElement; + } + + return null; +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. + +/* +const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); +*/ + +const openAlbum = (d) => rebase(`album/${d}`); +const openTrack = (d) => rebase(`track/${d}`); +const openArtist = (d) => rebase(`artist/${d}`); + +// TODO: This should also use urlSpec. + +/* +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); +} +*/ + +function dispatchInternalEvent(event, eventName, ...args) { + const [infoName] = + Object.entries(clientInfo) + .find(pair => pair[1].event === event); + + if (!infoName) { + throw new Error(`Expected event to be stored on clientInfo`); + } + + const {[eventName]: listeners} = event; + + if (!listeners) { + throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); + } + + let results = []; + for (const listener of listeners) { + try { + results.push(listener(...args)); + } catch (error) { + console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); + console.debug(error); + results.push(undefined); + } + } + + return results; +} + +// Rectangle math ----------------------------------------- + +class WikiRect extends DOMRect { + // Useful constructors + + static fromWindow() { + const {clientWidth: width, clientHeight: height} = + document.documentElement; + + return Reflect.construct(this, [0, 0, width, height]); + } + + static fromElement(element) { + return this.fromRect(element.getBoundingClientRect()); + } + + static fromMouse() { + const {clientX, clientY} = liveMousePositionInfo.state; + + return WikiRect.fromRect({ + x: clientX, + y: clientY, + width: 0, + height: 0, + }); + } + + static fromElementUnderMouse(element) { + const mouseRect = WikiRect.fromMouse(); + + const rects = + Array.from(element.getClientRects()) + .map(rect => WikiRect.fromRect(rect)); + + const rectUnderMouse = + rects.find(rect => rect.contains(mouseRect)); + + if (rectUnderMouse) { + return rectUnderMouse; + } else { + return rects[0]; + } + } + + static leftOf(origin, offset = 0) { + // Returns a rectangle representing everywhere to the left of the provided + // point or rectangle (with no top or bottom bounds), towards negative x. + // If an offset is provided, this is added onto the origin. + + return this.#past(origin, offset, { + origin: 'x', + extent: 'width', + edge: 'left', + direction: -Infinity, + construct: from => + [from, -Infinity, -Infinity, Infinity], + }); + } + + static rightOf(origin, offset = 0) { + // Returns a rectangle representing everywhere to the right of the + // provided point or rectangle (with no top or bottom bounds), towards + // positive x. If an offset is provided, this is added onto the origin. + + return this.#past(origin, offset, { + origin: 'x', + extent: 'width', + edge: 'right', + direction: Infinity, + construct: from => + [from, -Infinity, Infinity, Infinity], + }); + } + + static above(origin, offset = 0) { + // Returns a rectangle representing everywhere above the provided point + // or rectangle (with no left or right bounds), towards negative y. + // If an offset is provided, this is added onto the origin. + + return this.#past(origin, offset, { + origin: 'y', + extent: 'height', + edge: 'top', + direction: -Infinity, + construct: from => + [-Infinity, from, Infinity, -Infinity], + }); + } + + static beneath(origin, offset = 0) { + // Returns a rectangle representing everywhere beneath the provided point + // or rectangle (with no left or right bounds), towards positive y. + // If an offset is provided, this is added onto the origin. + + return this.#past(origin, offset, { + origin: 'y', + extent: 'height', + edge: 'bottom', + direction: Infinity, + construct: from => + [-Infinity, from, Infinity, Infinity], + }); + } + + // Constructor helpers + + static #past(origin, offset, opts) { + if (!isFinite(offset)) { + throw new TypeError(`Didn't expect infinite offset`); + } + + const {direction, edge} = opts; + + if (typeof origin === 'object') { + const {origin: originProperty, extent: extentProperty} = opts; + + const normalized = + WikiRect.fromRect(origin).toNormalized(); + + if (normalized[extentProperty] === direction) { + throw new TypeError(`Provided rectangle already extends to ${edge} edge`); + } + + if (normalized[extentProperty] === -direction) { + return this.#past(normalized[originProperty], offset, opts); + } + + if (normalized.y === direction) { + throw new TypeError(`Provided rectangle already starts at ${edge} edge`); + } + + return this.#past(normalized[edge], offset, opts); + } + + const {construct} = opts; + + if (origin === direction) { + throw new TypeError(`Provided point is already at ${edge} edge`); + } + + return Reflect.construct(this, construct(origin + offset)).toNormalized(); + } + + // Predicates + + static rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}) { + // Indicate that, in this context, it's meaningless to provide + // a finite extent starting at an infinite origin and going towards + // or away from zero (i.e. a rectangle along a cardinal edge). + + if (!isFinite(origin) && isFinite(extent) && extent !== 0) { + throw new TypeError(`Didn't expect infinite origin paired with finite extent`); + } + } + + static rejectInfiniteOriginZeroExtent({origin, extent}) { + // Indicate that, in this context, it's meaningless to provide + // a zero extent at an infinite origin (i.e. a cardinal edge). + + if (!isFinite(origin) && extent === 0) { + throw new TypeError(`Didn't expect infinite origin paired with zero extent`); + } + } + + static rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}) { + // Indicate that, in this context, it's meaningless to provide + // an infinite extent going in the same direction as its infinite + // origin (an area "infinitely past" a cardinal edge). + + if (!isFinite(origin) && origin === extent) { + throw new TypeError(`Didn't expect non-opposing infinite origin and extent`); + } + } + + // Transformations + + static normalizeOriginExtent({origin, extent}) { + // Varying behavior based on inputs: + // + // - For finite origin and finite extent, flip the orientation + // (if necessary) so that extent is positive. + // - For finite origin and infinite extent (i.e. an origin up to + // a cardinal edge), leave as-is. + // - For infinite origin and infinite extent, flip the orientation + // (if necessary) so origin is negative and extent is positive. + // - For infinite origin and zero extent (i.e. a cardinal edge), + // leave as-is. + // - For all other cases, error. + // + + this.rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}); + this.rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}); + + if (isFinite(origin) && isFinite(extent) && extent < 0) { + return {origin: origin + extent, extent: -extent}; + } + + if (!isFinite(origin) && !isFinite(extent)) { + return {origin: -Infinity, extent: Infinity}; + } + + return {origin, extent}; + } + + toNormalized() { + const {origin: newX, extent: newWidth} = + WikiRect.normalizeOriginExtent({ + origin: this.x, + extent: this.width, + }); + + const {origin: newY, extent: newHeight} = + WikiRect.normalizeOriginExtent({ + origin: this.y, + extent: this.height, + }); + + return Reflect.construct(this.constructor, [newX, newY, newWidth, newHeight]); + } + + static intersectionFromOriginsExtents(...entries) { + // An intersection is the common subsection across two or more regions. + + const [first, second, ...rest] = entries; + + if (entries.length >= 3) { + return this.intersection(first, this.intersection(second, ...rest)); + } + + if (entries.length === 2) { + if (first === null || second === null) { + return null; + } + + this.rejectInfiniteOriginZeroExtent(first); + this.rejectInfiniteOriginZeroExtent(second); + + const {origin: origin1, extent: extent1} = this.normalizeOriginExtent(first); + const {origin: origin2, extent: extent2} = this.normalizeOriginExtent(second); + + // After normalizing, *each* region will be one of these: + // + // - Finite origin, finite extent + // (a standard region, bounded on both sides) + // - Finite origin, infinite extent + // (everything to one direction of a given origin) + // - Infinite origin, infinite extent + // (everything everywhere) + // + // So we need to handle any *combination* of these kinds of regions. + + // If either origin is infinite, that region represents everywhere, + // so it'll never limit the region of the other. + + if (!isFinite(origin1)) { + return {origin: origin2, extent: extent2}; + } + + if (!isFinite(origin2)) { + return {origin: origin1, extent: extent1}; + } + + // If neither origin is infinite, both regions are bounded on at least + // one side, and may limit the other accordingly. Find the minimum and + // maximum points in each region, letting Infinity propagate through, + // which represents no boundary in that direction. + + const minimum1 = Math.min(origin1, origin1 + extent1); + const minimum2 = Math.min(origin2, origin2 + extent2); + const maximum1 = Math.max(origin1, origin1 + extent1); + const maximum2 = Math.max(origin2, origin2 + extent2); + + // Now get the maximum of the regions' minimums, and the minimum of the + // regions' maximums. These are the limits of the new region; computing + // with minimums and maximums in this way "polarizes" the limits, so we + // can perform specific polarized math in the following steps. + // + // Infinity will also propagate here, but with some important + // restricitons: only maxOfMinimums can be positive Infinity, and only + // minOfMaximums can be negative Infinity; and if either is Infinity, + // the other is not, since otherwise we'd be working with two everywhere + // regions, and would've just returned an everywhere region above. + + const maxOfMinimums = Math.max(minimum1, minimum2); + const minOfMaximums = Math.min(maximum1, maximum2); + + // Now check if the maximum of minimums is greater than the minimum of + // maximums. If so, the regions don't have any overlap - one region + // limits the overlap to end before the other region starts. This works + // because we've polarized the limits above! + + if (maxOfMinimums > minOfMaximums) { + return null; + } + + // Otherwise there's at least some overlap, even if it's just one point + // (i.e. one ends exactly where the other begins). We have to take care + // of infinities in particular, now. As mentioned above, only one of the + // points will be infinity (at most). So the origin is the non-infinite + // point, and the extent is in the direction of the infinite point. + + if (minOfMaximums === -Infinity) { + return {origin: maxOfMinimums, extent: -Infinity}; + } + + if (maxOfMinimums === Infinity) { + return {origin: minOfMaximums, extent: Infinity}; + } + + // If neither point is infinity, we're working with two regions that are + // both bounded on both sides, so the overlapping region is just the + // region constrained by the limits above. Since these are polarized, + // start from maxOfMinimums and extend to minOfMaximums, resulting in + // a standard, already-normalized region. + + return { + origin: maxOfMinimums, + extent: minOfMaximums - maxOfMinimums, + }; + } + + if (entries.length === 1) { + return first; + } + + throw new TypeError(`Expected at least one {origin, extent} entry`); + } + + intersectionWith(rect) { + const horizontalIntersection = + WikiRect.intersectionFromOriginsExtents( + {origin: this.x, extent: this.width}, + {origin: rect.x, extent: rect.width}); + + const verticalIntersection = + WikiRect.intersectionFromOriginsExtents( + {origin: this.y, extent: this.height}, + {origin: rect.y, extent: rect.height}); + + if (!horizontalIntersection) return null; + if (!verticalIntersection) return null; + + const {origin: x, extent: width} = horizontalIntersection; + const {origin: y, extent: height} = verticalIntersection; + + return Reflect.construct(this.constructor, [x, y, width, height]); + } + + chopExtendingOutside(rect) { + this.intersectionWith(rect).writeOnto(this); + } + + static insetOriginExtent({origin, extent, start = 0, end = 0}) { + const normalized = + this.normalizeOriginExtent({origin, extent}); + + // If this would crush the bounds past each other, just return + // the halfway point. + if (extent < start + end) { + return {origin: origin + (start + end) / 2, extent: 0}; + } + + return { + origin: normalized.origin + start, + extent: normalized.extent - start - end, + }; + } + + toInset(arg1, arg2) { + if (typeof arg1 === 'number' && typeof arg2 === 'number') { + return this.toInset({ + left: arg2, + right: arg2, + top: arg1, + bottom: arg1, + }); + } else if (typeof arg1 === 'number') { + return this.toInset({ + left: arg1, + right: arg1, + top: arg1, + bottom: arg1, + }); + } + + const {top, left, bottom, right} = arg1; + + const {origin: x, extent: width} = + WikiRect.insetOriginExtent({ + origin: this.x, + extent: this.width, + start: left, + end: right, + }); + + const {origin: y, extent: height} = + WikiRect.insetOriginExtent({ + origin: this.y, + extent: this.height, + start: top, + end: bottom, + }); + + return Reflect.construct(this.constructor, [x, y, width, height]); + } + + static extendOriginExtent({origin, extent, start = 0, end = 0}) { + const normalized = + this.normalizeOriginExtent({origin, extent}); + + return { + origin: normalized.origin - start, + extent: normalized.extent + start + end, + }; + } + + toExtended(arg1, arg2) { + if (typeof arg1 === 'number' && typeof arg2 === 'number') { + return this.toExtended({ + left: arg2, + right: arg2, + top: arg1, + bottom: arg1, + }); + } else if (typeof arg1 === 'number') { + return this.toExtended({ + left: arg1, + right: arg1, + top: arg1, + bottom: arg1, + }); + } + + const {top, left, bottom, right} = arg1; + + const {origin: x, extent: width} = + WikiRect.extendOriginExtent({ + origin: this.x, + extent: this.width, + start: left, + end: right, + }); + + const {origin: y, extent: height} = + WikiRect.extendOriginExtent({ + origin: this.y, + extent: this.height, + start: top, + end: bottom, + }); + + return Reflect.construct(this.constructor, [x, y, width, height]); + } + + // Comparisons + + equals(rect) { + const rectNormalized = WikiRect.fromRect(rect).toNormalized(); + const thisNormalized = this.toNormalized(); + + return ( + rectNormalized.x === thisNormalized.x && + rectNormalized.y === thisNormalized.y && + rectNormalized.width === thisNormalized.width && + rectNormalized.height === thisNormalized.height + ); + } + + contains(rect) { + return !!this.intersectionWith(rect)?.equals(rect); + } + + containedWithin(rect) { + return !!this.intersectionWith(rect)?.equals(this); + } + + fits(rect) { + const rectNormalized = WikiRect.fromRect(rect).toNormalized(); + const thisNormalized = this.toNormalized(); + + return ( + (!isFinite(this.width) || rectNormalized.width <= thisNormalized.width) && + (!isFinite(this.height) || rectNormalized.height <= thisNormalized.height) + ); + } + + fitsWithin(rect) { + const rectNormalized = WikiRect.fromRect(rect).toNormalized(); + const thisNormalized = this.toNormalized(); + + return ( + (!isFinite(rect.width) || thisNormalized.width <= rectNormalized.width) && + (!isFinite(rect.height) || thisNormalized.height <= rectNormalized.height) + ); + } + + // Interfacing utilities + + static fromRect(rect) { + return Reflect.construct(this, [rect.x, rect.y, rect.width, rect.height]); + } + + writeOnto(destination) { + Object.assign(destination, { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }); + } +} + +// CSS compatibility-assistant ---------------------------- + +const cssCompatibilityAssistantInfo = clientInfo.cssCompatibilityAssistantInfo = { + coverArtContainer: null, + coverArtImageDetails: null, +}; + +function getCSSCompatibilityAssistantInfoReferences() { + const info = cssCompatibilityAssistantInfo; + + info.coverArtContainer = + document.getElementById('cover-art-container'); + + info.coverArtImageDetails = + info.coverArtContainer?.querySelector('.image-details'); +} + +function mutateCSSCompatibilityContent() { + const info = cssCompatibilityAssistantInfo; + + if (info.coverArtImageDetails) { + info.coverArtContainer.classList.add('has-image-details'); + } +} + +clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences); +clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent); + +// Ever-updating mouse position helper -------------------- + +const liveMousePositionInfo = initInfo('liveMousePositionInfo', { + state: { + clientX: null, + clientY: null, + }, +}); + +function addLiveMousePositionPageListeners() { + const info = liveMousePositionInfo; + const {state} = info; + + document.body.addEventListener('mousemove', domEvent => { + Object.assign(state, { + clientX: domEvent.clientX, + clientY: domEvent.clientY, + }); + }); +} + +clientSteps.addPageListeners.push(addLiveMousePositionPageListeners); + +// JS-based links ----------------------------------------- + +const scriptedLinkInfo = initInfo('scriptedLinkInfo', { + randomLinks: null, + revealLinks: null, + revealContainers: null, + + nextNavLink: null, + previousNavLink: null, + randomNavLink: null, + + state: { + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, + }, +}); + +function getScriptedLinkReferences() { + scriptedLinkInfo.randomLinks = + document.querySelectorAll('[data-random]'); + + scriptedLinkInfo.revealLinks = + document.querySelectorAll('.reveal .image-outer-area > *'); + + scriptedLinkInfo.revealContainers = + Array.from(scriptedLinkInfo.revealLinks) + .map(link => link.closest('.reveal')); + + scriptedLinkInfo.nextNavLink = + document.getElementById('next-button'); + + scriptedLinkInfo.previousNavLink = + document.getElementById('previous-button'); + + scriptedLinkInfo.randomNavLink = + document.getElementById('random-button'); +} + +function addRandomLinkListeners() { + for (const a of scriptedLinkInfo.randomLinks ?? []) { + a.addEventListener('click', domEvent => { + handleRandomLinkClicked(a, domEvent); + }); + } +} + +function handleRandomLinkClicked(a, domEvent) { + const href = determineRandomLinkHref(a); + + if (!href) { + domEvent.preventDefault(); + return; + } + + setTimeout(() => { + a.href = '#' + }); + + a.href = href; +} + +function determineRandomLinkHref(a) { + const {state} = scriptedLinkInfo; + + const trackDirectoriesFromAlbumDirectories = albumDirectories => + albumDirectories + .map(directory => state.albumDirectories.indexOf(directory)) + .map(index => state.albumTrackDirectories[index]) + .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); + + switch (a.dataset.random) { + case 'album': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + return openAlbum(pick(albumDirectories)); + } + + case 'track': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + albumDirectories); + + return openTrack(pick(trackDirectories)); + } + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const listAlbumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + return openAlbum(pick(listAlbumDirectories)); + } + + case 'track-in-group-dl': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const listAlbumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + listAlbumDirectories); + + return openTrack(pick(trackDirectories)); + } + + case 'track-in-sidebar': { + // Note that the container for track links may be
    or