diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2024-06-19 19:50:55 -0300 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2024-06-20 10:01:05 -0300 |
commit | df5f6048e6a177b5ce3920d10a1249129c669a1f (patch) | |
tree | f94c984a6f21069c02e33a8b9184580916606041 /src | |
parent | e254ad385d33739d3e6b4a04d79325e2bca840b8 (diff) |
client: client modules
Diffstat (limited to 'src')
24 files changed, 4981 insertions, 4957 deletions
diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index 7e9e49a0..fd2ebdc7 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -673,7 +673,7 @@ export default { html.tag('script', { blocking: 'render', type: 'module', - src: to('staticJS.path', 'client.js'), + src: to('staticJS.path', 'client/index.js'), }), ]), diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js new file mode 100644 index 00000000..f06b707a --- /dev/null +++ b/src/static/js/client-util.js @@ -0,0 +1,122 @@ +/* eslint-env browser */ + +export function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +export 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); + } + } +} + +export function templateContent(el) { + if (el === null) { + return null; + } + + if (el?.nodeName !== 'TEMPLATE') { + throw new Error(`Expected a <template> element`); + } + + return el.content.cloneNode(true); +} + +// Curry-style, so multiple points can more conveniently be tested at once. +export function pointIsOverAnyOf(elements) { + return (clientX, clientY) => { + const element = document.elementFromPoint(clientX, clientY); + return elements.some(el => el.contains(element)); + }; +} + +export 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}`); +*/ + +export const openAlbum = d => rebase(`album/${d}`); +export const openArtTag = d => rebase(`tag/${d}`); +export const openArtist = d => rebase(`artist/${d}`); +export const openFlash = d => rebase(`flash/${d}`); +export const openGroup = d => rebase(`group/${d}`); +export const openTrack = d => rebase(`track/${d}`); + +// TODO: This should also use urlSpec. + +/* +export function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); +} +*/ + +// TODO: This should probably be imported from another file. +export function dispatchInternalEvent(event, eventName, ...args) { + const info = event[Symbol.for('hsmusic.clientInfo')]; + + if (!info) { + throw new Error(`Expected event to be stored on clientInfo`); + } + + const infoName = info.id; + + 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.error(`Uncaught error in listener for ${infoName}.${eventName}`); + console.error(error); + results.push(undefined); + } + } + + return results; +} diff --git a/src/static/js/client.js b/src/static/js/client.js deleted file mode 100644 index 21c3911a..00000000 --- a/src/static/js/client.js +++ /dev/null @@ -1,4956 +0,0 @@ -/* 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 {getColors} from '../shared-util/colors.js'; - -import { - accumulateSum, - atOffset, - empty, - filterMultipleArrays, - promiseWithResolvers, - stitchArrays, - withEntries, -} from '../shared-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.settings, - object.event, - ]) { - if (!obj) continue; - Object.preventExtensions(obj); - } - - if (object.session) { - const sessionSpecs = object.session; - - object.session = {}; - - for (const [key, spec] of Object.entries(sessionSpecs)) { - const hasSpec = - typeof spec === 'object' && spec !== null; - - const defaultValue = - (hasSpec - ? spec.default ?? null - : spec); - - let formatRead = value => value; - let formatWrite = value => value; - if (hasSpec && spec.type) { - switch (spec.type) { - case 'number': - formatRead = parseFloat; - formatWrite = String; - break; - - case 'boolean': - formatRead = Boolean; - formatWrite = String; - break; - - case 'string': - formatRead = String; - formatWrite = String; - break; - - case 'json': - formatRead = JSON.parse; - formatWrite = JSON.stringify; - break; - - default: - throw new Error(`Unknown type for session storage spec "${spec.type}"`); - } - } - - let getMaxLength = - (!hasSpec - ? () => Infinity - : typeof spec.maxLength === 'function' - ? (object.settings - ? () => spec.maxLength(object.settings) - : () => spec.maxLength()) - : () => spec.maxLength); - - const storageKey = `hsmusic.${infoKey}.${key}`; - - let fallbackValue = defaultValue; - - Object.defineProperty(object.session, key, { - get: () => { - let value; - try { - value = sessionStorage.getItem(storageKey) ?? defaultValue; - } catch (error) { - if (error instanceof DOMException) { - value = fallbackValue; - } else { - throw error; - } - } - - if (value === null) { - return null; - } - - return formatRead(value); - }, - - set: (value) => { - if (value !== null && value !== '') { - value = formatWrite(value); - } - - if (value === null) { - value = ''; - } - - const maxLength = getMaxLength(); - if (value.length > maxLength) { - console.warn( - `Requested to set session storage ${storageKey} ` + - `beyond maximum length ${maxLength}, ` + - `ignoring this value.`); - console.trace(); - return; - } - - let operation; - if (value === '') { - fallbackValue = null; - operation = () => { - sessionStorage.removeItem(storageKey); - }; - } else { - fallbackValue = value; - operation = () => { - sessionStorage.setItem(storageKey, value); - }; - } - - try { - operation(); - } catch (error) { - if (!(error instanceof DOMException)) { - 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); - } - } -} - -function templateContent(el) { - if (el === null) { - return null; - } - - if (el?.nodeName !== 'TEMPLATE') { - throw new Error(`Expected a <template> element`); - } - - return el.content.cloneNode(true); -} - -// 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 openArtTag = d => rebase(`tag/${d}`); -const openArtist = d => rebase(`artist/${d}`); -const openFlash = d => rebase(`flash/${d}`); -const openGroup = d => rebase(`group/${d}`); -const openTrack = d => rebase(`track/${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.error(`Uncaught error in listener for ${infoName}.${eventName}`); - console.error(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 <ol> or <ul>, and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - return pick(trackLinks).href; - } - - case 'track-in-album': { - const {albumDirectories, albumTrackDirectories} = state; - if (!albumDirectories || !albumTrackDirectories) return null; - - const albumDirectory = cssProp(a, '--album-directory'); - const albumIndex = albumDirectories.indexOf(albumDirectory); - const trackDirectories = albumTrackDirectories[albumIndex]; - - return openTrack(pick(trackDirectories)); - } - - case 'artist': { - const {artistDirectories} = state; - if (!artistDirectories) return null; - - return openArtist(pick(artistDirectories)); - } - - case 'artist-more-than-one-contrib': { - const {artistDirectories, artistNumContributions} = state; - if (!artistDirectories || !artistNumContributions) return null; - - const filteredArtistDirectories = - artistDirectories - .filter((_artist, index) => artistNumContributions[index] > 1); - - return openArtist(pick(filteredArtistDirectories)); - } - } -} - -function mutateNavigationLinkContent() { - const prependTitle = (el, prepend) => - el?.setAttribute('title', - (el.hasAttribute('title') - ? prepend + ' ' + el.getAttribute('title') - : prepend)); - - prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); - prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); - prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); -} - -function addNavigationKeyPressListeners() { - document.addEventListener('keypress', (event) => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - scriptedLinkInfo.nextNavLink?.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - scriptedLinkInfo.previousNavLink?.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - scriptedLinkInfo.randomNavLink?.click(); - } - } - }); -} - -function addRevealLinkClickListeners() { - const info = scriptedLinkInfo; - - for (const {revealLink, revealContainer} of stitchArrays({ - revealLink: Array.from(info.revealLinks ?? []), - revealContainer: Array.from(info.revealContainers ?? []), - })) { - revealLink.addEventListener('click', (event) => { - handleRevealLinkClicked(event, revealLink, revealContainer); - }); - } -} - -function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) { - if (revealContainer.classList.contains('revealed')) { - return; - } - - domEvent.preventDefault(); - revealContainer.classList.add('revealed'); - revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal')); -} - -clientSteps.getPageReferences.push(getScriptedLinkReferences); -clientSteps.addPageListeners.push(addRandomLinkListeners); -clientSteps.addPageListeners.push(addNavigationKeyPressListeners); -clientSteps.addPageListeners.push(addRevealLinkClickListeners); -clientSteps.mutatePageContent.push(mutateNavigationLinkContent); - -if ( - document.documentElement.dataset.urlKey === 'localized.listing' && - document.documentElement.dataset.urlValue0 === 'random' -) { - const dataLoadingLine = document.getElementById('data-loading-line'); - const dataLoadedLine = document.getElementById('data-loaded-line'); - const dataErrorLine = document.getElementById('data-error-line'); - - dataLoadingLine.style.display = 'block'; - - fetch(rebase('random-link-data.json', 'rebaseShared')) - .then(data => data.json()) - .then(data => { - const {state} = scriptedLinkInfo; - - Object.assign(state, { - albumDirectories: data.albumDirectories, - albumTrackDirectories: data.albumTrackDirectories, - artistDirectories: data.artistDirectories, - artistNumContributions: data.artistNumContributions, - }); - - dataLoadingLine.style.display = 'none'; - dataLoadedLine.style.display = 'block'; - }, () => { - dataLoadingLine.style.display = 'none'; - dataErrorLine.style.display = 'block'; - }) - .then(() => { - const {randomLinks} = scriptedLinkInfo; - for (const a of randomLinks) { - const href = determineRandomLinkHref(a); - if (!href) { - a.removeAttribute('href'); - } - } - }); -} - -// Links nested in summaries ------------------------------ - -const summaryNestedLinksInfo = initInfo('summaryNestedLinksInfo', { - summaries: null, - links: null, -}); - -function getSummaryNestedLinksReferences() { - const info = summaryNestedLinksInfo; - - info.summaries = - Array.from(document.getElementsByTagName('summary')); - - info.links = - info.summaries - .map(summary => - Array.from(summary.getElementsByTagName('a'))); - - filterMultipleArrays( - info.summaries, - info.links, - (_summary, links) => !empty(links)); -} - -function addSummaryNestedLinksPageListeners() { - const info = summaryNestedLinksInfo; - - for (const {summary, links} of stitchArrays({ - summary: info.summaries, - links: info.links, - })) { - for (const link of links) { - link.addEventListener('mouseover', () => { - link.classList.add('nested-hover'); - summary.classList.add('has-nested-hover'); - }); - - link.addEventListener('mouseout', () => { - link.classList.remove('nested-hover'); - summary.classList.remove('has-nested-hover'); - }); - } - } -} - -clientSteps.getPageReferences.push(getSummaryNestedLinksReferences); -clientSteps.getPageReferences.push(addSummaryNestedLinksPageListeners); - -// Tooltip-style hover (infrastructure) ------------------- - -const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', { - settings: { - // Hovering has two speed settings. The normal setting is used by default, - // and once a tooltip is displayed as a result of hover, the entire tooltip - // system will enter a "fast hover mode" - hovering will activate tooltips - // sooner. "Fast hover mode" is disabled after a sustained duration of not - // hovering over any hoverables; it's meant only to accelerate switching - // tooltips while still deciding, or getting a quick overview across more - // than one tooltip. - normalHoverInfoDelay: 400, - fastHoveringInfoDelay: 150, - endFastHoveringDelay: 500, - - // Focusing has a single speed setting, which is how long it will take to - // enter a functional "focus mode" (though it's not actually implemented - // in terms of this state). As soon as "focus mode" is entered, the tooltip - // for the current hoverable is displayed, and focusing another hoverable - // will cause the current tooltip to be swapped for that one immediately. - // "Focus mode" ends as soon as anything apart from a tooltip or hoverable - // is focused, and it will be necessary to wait on this delay again. - focusInfoDelay: 750, - - hideTooltipDelay: 500, - - // If a tooltip that's transitioning to hidden is hovered during the grace - // period (or the corresponding hoverable is hovered at any point in the - // transition), it'll cancel out of this animation immediately. - transitionHiddenDuration: 300, - inertGracePeriod: 100, - }, - - state: { - // These maps store a record for each registered element and related state - // and registration info, if applicable. - registeredTooltips: new Map(), - registeredHoverables: new Map(), - - // These are common across all tooltips, rather than stored individually, - // based on the principles that 1) only a single tooltip can be displayed - // at once, and 2) likewise, only a single hoverable can be hovered, - // focused, or otherwise active at once. - hoverTimeout: null, - focusTimeout: null, - touchTimeout: null, - hideTimeout: null, - transitionHiddenTimeout: null, - inertGracePeriodTimeout: null, - currentlyShownTooltip: null, - currentlyActiveHoverable: null, - currentlyTransitioningHiddenTooltip: null, - previouslyActiveHoverable: null, - tooltipWasJustHidden: false, - hoverableWasRecentlyTouched: false, - - // Fast hovering is a global mode which is activated as soon as any tooltip - // is displayed and turns off after a delay of no hoverables being hovered. - // Note that fast hovering may be turned off while hovering a tooltip, but - // it will never be turned off while idling over a hoverable. - fastHovering: false, - endFastHoveringTimeout: false, - - // These track the identifiers of current touches and a record of current - // identifiers that are "banished" by scrolling - that is, touches which - // existed while the page scrolled and were probably responsible for that - // scrolling. This is a bit loose (we can't actually tell which touches - // caused the page to scroll) but it's intended to keep scrolling the page - // from causing the current tooltip to be hidden. - currentTouchIdentifiers: new Set(), - touchIdentifiersBanishedByScrolling: new Set(), - - // This is a two-item array that tracks the direction we've already - // dynamically placed the current tooltip. If we *reposition* the tooltip - // (because its dimensions changed), we'll try to follow this anchor first. - dynamicTooltipAnchorDirection: null, - }, - - event: { - whenTooltipShows: [], - whenTooltipHides: [], - }, -}); - -// Adds DOM event listeners, so must be called during addPageListeners step. -function registerTooltipElement(tooltip) { - const {state} = hoverableTooltipInfo; - - if (!tooltip) - throw new Error(`Expected tooltip`); - - if (state.registeredTooltips.has(tooltip)) - throw new Error(`This tooltip is already registered`); - - // No state or registration info here. - state.registeredTooltips.set(tooltip, {}); - - tooltip.addEventListener('mouseenter', () => { - handleTooltipMouseEntered(tooltip); - }); - - tooltip.addEventListener('mouseleave', () => { - handleTooltipMouseLeft(tooltip); - }); - - tooltip.addEventListener('focusin', event => { - handleTooltipReceivedFocus(tooltip, event.relatedTarget); - }); - - tooltip.addEventListener('focusout', event => { - // This event gets activated for tabbing *between* links inside the - // tooltip, which is no good and certainly doesn't represent the focus - // leaving the tooltip. - if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; - - handleTooltipLostFocus(tooltip, event.relatedTarget); - }); -} - -// Adds DOM event listeners, so must be called during addPageListeners step. -function registerTooltipHoverableElement(hoverable, tooltip) { - const {state} = hoverableTooltipInfo; - - if (!hoverable || !tooltip) - if (hoverable) - throw new Error(`Expected hoverable and tooltip, got only hoverable`); - else - throw new Error(`Expected hoverable and tooltip, got neither`); - - if (!state.registeredTooltips.has(tooltip)) - throw new Error(`Register tooltip before registering hoverable`); - - if (state.registeredHoverables.has(hoverable)) - throw new Error(`This hoverable is already registered`); - - state.registeredHoverables.set(hoverable, {tooltip}); - - hoverable.addEventListener('mouseenter', () => { - handleTooltipHoverableMouseEntered(hoverable); - }); - - hoverable.addEventListener('mouseleave', () => { - handleTooltipHoverableMouseLeft(hoverable); - }); - - hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event); - }); - - hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event); - }); - - hoverable.addEventListener('touchend', event => { - handleTooltipHoverableTouchEnded(hoverable, event); - }); - - hoverable.addEventListener('click', event => { - handleTooltipHoverableClicked(hoverable, event); - }); -} - -function handleTooltipMouseEntered(tooltip) { - const {state} = hoverableTooltipInfo; - - if (state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(true); - return; - } - - if (state.currentlyShownTooltip !== tooltip) return; - - // Don't time out the current tooltip while hovering it. - - if (state.hideTimeout) { - clearTimeout(state.hideTimeout); - state.hideTimeout = null; - } -} - -function handleTooltipMouseLeft(tooltip) { - const {settings, state} = hoverableTooltipInfo; - - if (state.currentlyShownTooltip !== tooltip) return; - - // Start timing out the current tooltip when it's left. This could be - // canceled by mousing over a hoverable, or back over the tooltip again. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); - } -} - -function handleTooltipReceivedFocus(_tooltip) { - const {state} = hoverableTooltipInfo; - - // Cancel the tooltip-hiding timeout if it exists. The tooltip will never - // be hidden while it contains the focus anyway, but this ensures the timeout - // will be suitably reset when the tooltip loses focus. - if (state.hideTimeout) { - clearTimeout(state.hideTimeout); - state.hideTimeout = null; - } -} - -function handleTooltipLostFocus(_tooltip) { - // Hide the current tooltip right away when it loses focus. Specify intent - // to replace - while we don't strictly know if another tooltip is going to - // immediately replace it, the mode of navigating with tab focus (once one - // tooltip has been activated) is a "switch focus immediately" kind of - // interaction in its nature. - hideCurrentlyShownTooltip(true); -} - -function handleTooltipHoverableMouseEntered(hoverable) { - const {settings, state} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - // If this tooltip was transitioning to hidden, hovering should cancel that - // animation and show it immediately. - - if (tooltip === state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(true); - return; - } - - // Start a timer to show the corresponding tooltip, with the delay depending - // on whether fast hovering or not. This could be canceled by mousing out of - // the hoverable. - - const hoverTimeoutDelay = - (state.fastHovering - ? settings.fastHoveringInfoDelay - : settings.normalHoverInfoDelay); - - state.hoverTimeout = - setTimeout(() => { - state.hoverTimeout = null; - state.fastHovering = true; - showTooltipFromHoverable(hoverable); - }, hoverTimeoutDelay); - - // Don't stop fast hovering while over any hoverable. - if (state.endFastHoveringTimeout) { - clearTimeout(state.endFastHoveringTimeout); - state.endFastHoveringTimeout = null; - } - - // Don't time out the current tooltip while over any hoverable. - if (state.hideTimeout) { - clearTimeout(state.hideTimeout); - state.hideTimeout = null; - } -} - -function handleTooltipHoverableMouseLeft(_hoverable) { - const {settings, state} = hoverableTooltipInfo; - - // Don't show a tooltip when not over a hoverable! - if (state.hoverTimeout) { - clearTimeout(state.hoverTimeout); - state.hoverTimeout = null; - } - - // Start timing out fast hovering (if active) when not over a hoverable. - // This will only be canceled by mousing over another hoverable. - if (state.fastHovering && !state.endFastHoveringTimeout) { - state.endFastHoveringTimeout = - setTimeout(() => { - state.endFastHoveringTimeout = null; - state.fastHovering = false; - }, settings.endFastHoveringDelay); - } - - // Start timing out the current tooltip when mousing not over a hoverable. - // This could be canceled by mousing over another hoverable, or over the - // currently shown tooltip. - if (state.currentlyShownTooltip && !state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); - } -} - -function handleTooltipHoverableReceivedFocus(hoverable) { - const {settings, state} = hoverableTooltipInfo; - - // By default, display the corresponding tooltip after a delay. - - state.focusTimeout = - setTimeout(() => { - state.focusTimeout = null; - showTooltipFromHoverable(hoverable); - }, settings.focusInfoDelay); - - // If a tooltip was just hidden - which is almost certainly a result of the - // focus changing - then display this tooltip immediately, canceling the - // above timeout. - - if (state.tooltipWasJustHidden) { - clearTimeout(state.focusTimeout); - state.focusTimeout = null; - - showTooltipFromHoverable(hoverable); - } -} - -function handleTooltipHoverableLostFocus(hoverable, domEvent) { - const {state} = hoverableTooltipInfo; - - // Don't show a tooltip from focusing a hoverable if it isn't focused - // anymore! If another hoverable is receiving focus, that will be evaluated - // and set its own focus timeout after we clear the previous one here. - if (state.focusTimeout) { - clearTimeout(state.focusTimeout); - state.focusTimeout = null; - } - - // Unless focus is entering the tooltip itself, hide the tooltip immediately. - // This will set the tooltipWasJustHidden flag, which is detected by a newly - // focused hoverable, if applicable. Always specify intent to replace when - // navigating via tab focus. (Check `handleTooltipLostFocus` for details.) - if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { - hideCurrentlyShownTooltip(true); - } -} - -function handleTooltipHoverableTouchEnded(hoverable, domEvent) { - const {state} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - // Don't proceed if this hoverable's tooltip is already visible - in that - // case touching the hoverable again should behave just like a normal click. - if (state.currentlyShownTooltip === tooltip) { - // If the hoverable was *recently* touched - meaning that this is a second - // touchend in short succession - then just letting the click come through - // naturally would (depending on timing) not actually navigate anywhere, - // because we've deliberately banished the *first* touch from navigation. - // We do want the second touch to navigate, so clear that recently-touched - // state, allowing this touch's click to behave as normal. - if (state.hoverableWasRecentlyTouched) { - clearTimeout(state.touchTimeout); - state.touchTimeout = null; - state.hoverableWasRecentlyTouched = false; - } - - // Otherwise, this is just a second touch after enough time has passed - // that the one which showed the tooltip is no longer "recent", and we're - // not in any special state. The link will navigate to its page just like - // normal. - return; - } - - const touches = Array.from(domEvent.changedTouches); - const identifiers = touches.map(touch => touch.identifier); - - // Don't process touch events that were "banished" because the page was - // scrolled while those touches were active, and most likely as a result of - // them. - filterMultipleArrays(touches, identifiers, - (_touch, identifier) => - !state.touchIdentifiersBanishedByScrolling.has(identifier)); - - if (empty(touches)) return; - - // Don't proceed if none of the (just-ended) touches ended over the - // hoverable. - - const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]); - - const anyTouchEndedOverHoverable = - touches.some(({clientX, clientY}) => - pointIsOverThisHoverable(clientX, clientY)); - - if (!anyTouchEndedOverHoverable) { - return; - } - - if (state.touchTimeout) { - clearTimeout(state.touchTimeout); - state.touchTimeout = null; - } - - // Show the tooltip right away. - showTooltipFromHoverable(hoverable); - - // Set a state, for a brief but not instantaneous period, indicating that a - // hoverable was recently touched. The touchend event may precede the click - // event by some time, and we don't want to navigate away from the page as - // a result of the click event which this touch precipitated. - state.hoverableWasRecentlyTouched = true; - state.touchTimeout = - setTimeout(() => { - state.touchTimeout = null; - state.hoverableWasRecentlyTouched = false; - }, 1200); -} - -function handleTooltipHoverableClicked(hoverable) { - const {state} = hoverableTooltipInfo; - - // Don't navigate away from the page if the this hoverable was recently - // touched (and had its tooltip activated). That flag won't be set if its - // tooltip was already open before the touch. - if ( - state.currentlyActiveHoverable === hoverable && - state.hoverableWasRecentlyTouched - ) { - event.preventDefault(); - } -} - -function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { - const {state} = hoverableTooltipInfo; - - const { - currentlyShownTooltip: tooltip, - currentlyActiveHoverable: hoverable, - } = state; - - // If there's no tooltip, it can't possibly have focus. - if (!tooltip) return false; - - // If the tooltip literally contains (or is) the focused element, then that's - // the principle condition we're looking for. - if (tooltip.contains(focusElement)) return true; - - // If the hoverable *which opened the tooltip* is focused, then that also - // represents the tooltip being focused (in its currently shown state). - if (hoverable.contains(focusElement)) return true; - - return false; -} - -function beginTransitioningTooltipHidden(tooltip) { - const {settings, state} = hoverableTooltipInfo; - - if (state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(); - } - - cssProp(tooltip, { - 'display': 'block', - 'opacity': '0', - - 'transition-property': 'opacity', - 'transition-timing-function': - `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`, - 'transition-duration': - `${settings.transitionHiddenDuration / 1000}s`, - }); - - state.currentlyTransitioningHiddenTooltip = tooltip; - state.transitionHiddenTimeout = - setTimeout(() => { - endTransitioningTooltipHidden(); - }, settings.transitionHiddenDuration); -} - -function cancelTransitioningTooltipHidden(andShow = false) { - const {state} = hoverableTooltipInfo; - - endTransitioningTooltipHidden(); - - if (andShow) { - showTooltipFromHoverable(state.previouslyActiveHoverable); - } -} - -function endTransitioningTooltipHidden() { - const {state} = hoverableTooltipInfo; - const {currentlyTransitioningHiddenTooltip: tooltip} = state; - - if (!tooltip) return; - - cssProp(tooltip, { - 'display': null, - 'opacity': null, - 'transition-property': null, - 'transition-timing-function': null, - 'transition-duration': null, - }); - - state.currentlyTransitioningHiddenTooltip = null; - - if (state.inertGracePeriodTimeout) { - clearTimeout(state.inertGracePeriodTimeout); - state.inertGracePeriodTimeout = null; - } - - if (state.transitionHiddenTimeout) { - clearTimeout(state.transitionHiddenTimeout); - state.transitionHiddenTimeout = null; - } -} - -function hideCurrentlyShownTooltip(intendingToReplace = false) { - const {settings, state, event} = hoverableTooltipInfo; - const {currentlyShownTooltip: tooltip} = state; - - // If there was no tooltip to begin with, we're functionally in the desired - // state already, so return true. - if (!tooltip) return true; - - // Never hide the tooltip if it's focused. - if (currentlyShownTooltipHasFocus()) return false; - - state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); - - // If there's no intent to replace this tooltip, it's the last one currently - // apparent in the interaction, and should be hidden with a transition. - if (intendingToReplace) { - cssProp(tooltip, 'display', 'none'); - } else { - beginTransitioningTooltipHidden(state.currentlyShownTooltip); - } - - // Wait just a moment before making the tooltip inert. You might react - // (to the ghosting, or just to time passing) and realize you wanted - // to look at the tooltip after all - this delay gives a little buffer - // to second guess letting it disappear. - state.inertGracePeriodTimeout = - setTimeout(() => { - tooltip.inert = true; - }, settings.inertGracePeriod); - - state.previouslyActiveHoverable = state.currentlyActiveHoverable; - - state.currentlyShownTooltip = null; - state.currentlyActiveHoverable = null; - - state.dynamicTooltipAnchorDirection = null; - - // Set this for one tick of the event cycle. - state.tooltipWasJustHidden = true; - setTimeout(() => { - state.tooltipWasJustHidden = false; - }); - - dispatchInternalEvent(event, 'whenTooltipHides', { - tooltip, - }); - - return true; -} - -function showTooltipFromHoverable(hoverable) { - const {state, event} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - if (!hideCurrentlyShownTooltip(true)) return false; - - // Cancel out another tooltip that's transitioning hidden, if that's going - // on - it's a distraction that this tooltip is now replacing. - cancelTransitioningTooltipHidden(); - - hoverable.classList.add('has-visible-tooltip'); - - positionTooltipFromHoverableWithBrains(hoverable); - - // After a tooltip is shown, if we *didn't* specify an anchor, - // assume it was shown in its default position - generally presented - // as down and to the right. Successive repositioning will base on this. - state.dynamicTooltipAnchorDirection ??= ['down', 'right']; - - cssProp(tooltip, 'display', 'block'); - tooltip.inert = false; - - state.currentlyShownTooltip = tooltip; - state.currentlyActiveHoverable = hoverable; - - state.tooltipWasJustHidden = false; - - dispatchInternalEvent(event, 'whenTooltipShows', { - tooltip, - }); - - return true; -} - -function peekTooltipClientRect(tooltip) { - const oldDisplayStyle = cssProp(tooltip, 'display'); - cssProp(tooltip, 'display', 'block'); - - // Tooltips have a bit of padding that makes the interactive - // area wider, so that you're less likely to accidentally let - // the tooltip disappear (by hovering outside it). But this - // isn't visual at all, so for placement we only care about - // the content element. - const content = - tooltip.querySelector('.tooltip-content'); - - try { - return WikiRect.fromElement(content); - } finally { - cssProp(tooltip, 'display', oldDisplayStyle); - } -} - -function repositionCurrentTooltip() { - const {state} = hoverableTooltipInfo; - const {currentlyActiveHoverable} = state; - - if (!currentlyActiveHoverable) { - throw new Error(`No hoverable active to reposition tooltip from`); - } - - positionTooltipFromHoverableWithBrains(currentlyActiveHoverable); -} - -function positionTooltipFromHoverableWithBrains(hoverable) { - const {state} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - const anchorDirection = state.dynamicTooltipAnchorDirection; - - // Reset before doing anything else. We're going to adapt to - // its natural placement, adjusted by CSS, which otherwise - // could be obscured by a placement we've previously provided. - resetDynamicTooltipPositioning(tooltip); - - const opportunities = - getTooltipFromHoverablePlacementOpportunityAreas(hoverable); - - const tooltipRect = - peekTooltipClientRect(tooltip); - - // If the tooltip is already in the baseline containing area, - // prefer to keep it positioned naturally, adjusted by CSS - // instead of JavaScript. - - const {numBaselineRects, idealBaseline: baselineRect} = opportunities; - - if (baselineRect.contains(tooltipRect)) { - return; - } - - const tryDirection = (dir1, dir2, i) => { - selectedRect = opportunities[dir1][dir2][i]; - return !!selectedRect; - }; - - let selectedRect = null; - selectRect: { - if (anchorDirection) { - for (let i = 0; i < numBaselineRects; i++) { - if (tryDirection(...anchorDirection, i)) { - break selectRect; - } - } - } - - for (let i = 0; i < numBaselineRects; i++) { - for (const [dir1, dir2] of [ - ['right', 'down'], - ['left', 'down'], - ['right', 'up'], - ['left', 'up'], - ['down', 'right'], - ['down', 'left'], - ['up', 'right'], - ['up', 'left'], - ]) { - if (tryDirection(dir1, dir2, i)) { - state.dynamicTooltipAnchorDirection = [dir1, dir2]; - break selectRect; - } - } - } - - selectedRect = baselineRect; - } - - positionTooltip(tooltip, selectedRect.x, selectedRect.y); -} - -function positionTooltip(tooltip, x, y) { - // Imagine what it'd be like if the tooltip were positioned - // with zero left/top offset, and calculate its actual offsets - // based on that. - - cssProp(tooltip, { - left: `0`, - top: `0`, - }); - - const tooltipRect = - peekTooltipClientRect(tooltip); - - cssProp(tooltip, { - left: `${x - tooltipRect.x}px`, - top: `${y - tooltipRect.y}px`, - }); -} - -function resetDynamicTooltipPositioning(tooltip) { - cssProp(tooltip, { - left: null, - top: null, - }); -} - -function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { - const {state} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - const baselineRects = - getTooltipBaselineOpportunityAreas(tooltip); - - const hoverableRect = - WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10); - - const tooltipRect = - peekTooltipClientRect(tooltip); - - // Get placements relative to the hoverable. Make these available by key, - // allowing the caller to choose by preferred orientation. Each value is - // an array which corresponds to the baseline areas - placement closer to - // front of the array indicates stronger preference. Since not all relative - // placements cooperate with all baseline areas, any of these arrays may - // include (or be entirely made of) null. - - const keepIfFits = (rect) => - (rect?.fits(tooltipRect) - ? rect - : null); - - const prepareRegionRects = (relationalRect, direct) => - baselineRects - .map(rect => rect.intersectionWith(relationalRect)) - .map(direct) - .map(keepIfFits); - - const regionRects = { - left: - prepareRegionRects( - WikiRect.leftOf(hoverableRect), - rect => WikiRect.fromRect({ - x: rect.right, - y: rect.y, - width: -rect.width, - height: rect.height, - })), - - right: - prepareRegionRects( - WikiRect.rightOf(hoverableRect), - rect => rect), - - top: - prepareRegionRects( - WikiRect.above(hoverableRect), - rect => WikiRect.fromRect({ - x: rect.x, - y: rect.bottom, - width: rect.width, - height: -rect.height, - })), - - bottom: - prepareRegionRects( - WikiRect.beneath(hoverableRect), - rect => rect), - }; - - const neededVerticalOverlap = 30; - const neededHorizontalOverlap = 30; - - const upTopDown = - WikiRect.beneath( - hoverableRect.top + neededVerticalOverlap - tooltipRect.height); - - const downBottomUp = - WikiRect.above( - hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); - - // Please don't ask us to make this but horizontal? - const prepareVerticalOrientationRects = (regionRects) => { - const orientations = {}; - - const orientHorizontally = (rect, i) => { - if (!rect) return null; - - const regionRect = regionRects[i]; - if (regionRect.width > 0) { - return rect; - } else { - return WikiRect.fromRect({ - x: regionRect.right - tooltipRect.width, - y: rect.y, - width: rect.width, - height: rect.height, - }); - } - }; - - orientations.up = - regionRects - .map(rect => rect?.intersectionWith(upTopDown)) - .map(orientHorizontally) - .map(keepIfFits); - - orientations.down = - regionRects - .map(rect => rect?.intersectionWith(downBottomUp)) - .map(rect => - (rect - ? rect.intersectionWith(WikiRect.fromRect({ - x: rect.x, - y: rect.bottom - tooltipRect.height, - width: rect.width, - height: tooltipRect.height, - })) - : null)) - .map(orientHorizontally) - .map(keepIfFits); - - const centerRect = - WikiRect.fromRect({ - x: -Infinity, width: Infinity, - y: hoverableRect.top - + hoverableRect.height / 2 - - tooltipRect.height / 2, - height: tooltipRect.height, - }); - - orientations.center = - regionRects - .map(rect => rect?.intersectionWith(centerRect)) - .map(orientHorizontally) - .map(keepIfFits); - - return orientations; - }; - - const rightRightLeft = - WikiRect.leftOf( - hoverableRect.left - neededHorizontalOverlap + tooltipRect.width); - - const leftLeftRight = - WikiRect.rightOf( - hoverableRect.left + neededHorizontalOverlap - tooltipRect.width); - - // Oops. - const prepareHorizontalOrientationRects = (regionRects) => { - const orientations = {}; - - const orientVertically = (rect, i) => { - if (!rect) return null; - - const regionRect = regionRects[i]; - - if (regionRect.height > 0) { - return rect; - } else { - return WikiRect.fromRect({ - x: rect.x, - y: regionRect.bottom - tooltipRect.height, - width: rect.width, - height: rect.height, - }); - } - }; - - orientations.left = - regionRects - .map(rect => rect?.intersectionWith(leftLeftRight)) - .map(orientVertically) - .map(keepIfFits); - - orientations.right = - regionRects - .map(rect => rect?.intersectionWith(rightRightLeft)) - .map(rect => - (rect - ? rect.intersectionWith(WikiRect.fromRect({ - x: rect.right - tooltipRect.width, - y: rect.y, - width: rect.width, - height: tooltipRect.height, - })) - : null)) - .map(orientVertically) - .map(keepIfFits); - - // No analogous center because we don't actually use - // center alignment... - - return orientations; - }; - - const orientationRects = { - left: prepareVerticalOrientationRects(regionRects.left), - right: prepareVerticalOrientationRects(regionRects.right), - down: prepareHorizontalOrientationRects(regionRects.bottom), - up: prepareHorizontalOrientationRects(regionRects.top), - }; - - return { - numBaselineRects: baselineRects.length, - idealBaseline: baselineRects[0], - ...orientationRects, - }; -} - -function getTooltipBaselineOpportunityAreas(tooltip) { - // Returns multiple basic areas in order of preference, with front of the - // array representing greater preference. - - const {stickyContainers} = stickyHeadingInfo; - const results = []; - - const windowRect = - WikiRect.fromWindow().toInset(10); - - const workingRect = - WikiRect.fromRect(windowRect); - - const tooltipRect = - peekTooltipClientRect(tooltip); - - // As a baseline, always treat the window rect as fitting the tooltip. - results.unshift(WikiRect.fromRect(workingRect)); - - const containingParent = - getVisuallyContainingElement(tooltip); - - if (containingParent) { - const containingRect = - WikiRect.fromElement(containingParent); - - // Only respect a portion of the container's padding, giving - // the tooltip the impression of a "raised" element. - const padding = side => - 0.5 * - parseFloat(cssProp(containingParent, 'padding-' + side)); - - const insetContainingRect = - containingRect.toInset({ - left: padding('left'), - right: padding('right'), - top: padding('top'), - bottom: padding('bottom'), - }); - - workingRect.chopExtendingOutside(insetContainingRect); - - if (!workingRect.fits(tooltipRect)) { - return results; - } - - results.unshift(WikiRect.fromRect(workingRect)); - } - - // This currently assumes a maximum of one sticky container - // per visually containing element. - - const stickyContainer = - stickyContainers - .find(el => el.parentElement === containingParent); - - if (stickyContainer) { - const stickyRect = - stickyContainer.getBoundingClientRect() - - // Add some padding so the tooltip doesn't line up exactly - // with the edge of the sticky container. - const beneathStickyContainer = - WikiRect.beneath(stickyRect, 10); - - workingRect.chopExtendingOutside(beneathStickyContainer); - - if (!workingRect.fits(tooltipRect)) { - return results; - } - - results.unshift(WikiRect.fromRect(workingRect)); - } - - return results; -} - -function addHoverableTooltipPageListeners() { - const {state} = hoverableTooltipInfo; - - const getTouchIdentifiers = domEvent => - Array.from(domEvent.changedTouches) - .map(touch => touch.identifier) - .filter(identifier => typeof identifier !== 'undefined'); - - document.body.addEventListener('touchstart', domEvent => { - for (const identifier of getTouchIdentifiers(domEvent)) { - state.currentTouchIdentifiers.add(identifier); - } - }); - - window.addEventListener('scroll', () => { - for (const identifier of state.currentTouchIdentifiers) { - state.touchIdentifiersBanishedByScrolling.add(identifier); - } - }); - - document.body.addEventListener('touchend', domEvent => { - setTimeout(() => { - for (const identifier of getTouchIdentifiers(domEvent)) { - state.currentTouchIdentifiers.delete(identifier); - state.touchIdentifiersBanishedByScrolling.delete(identifier); - } - }); - }); - - const getHoverablesAndTooltips = () => [ - ...Array.from(state.registeredHoverables.keys()), - ...Array.from(state.registeredTooltips.keys()), - ]; - - document.body.addEventListener('touchend', domEvent => { - const touches = Array.from(domEvent.changedTouches); - const identifiers = touches.map(touch => touch.identifier); - - // Don't process touch events that were "banished" because the page was - // scrolled while those touches were active, and most likely as a result of - // them. - filterMultipleArrays(touches, identifiers, - (_touch, identifier) => - !state.touchIdentifiersBanishedByScrolling.has(identifier)); - - if (empty(touches)) return; - - const pointIsOverHoverableOrTooltip = - pointIsOverAnyOf(getHoverablesAndTooltips()); - - const anyTouchOverAnyHoverableOrTooltip = - touches.some(({clientX, clientY}) => - pointIsOverHoverableOrTooltip(clientX, clientY)); - - if (!anyTouchOverAnyHoverableOrTooltip) { - hideCurrentlyShownTooltip(); - } - }); - - document.body.addEventListener('click', domEvent => { - const {clientX, clientY} = domEvent; - - const pointIsOverHoverableOrTooltip = - pointIsOverAnyOf(getHoverablesAndTooltips()); - - if (!pointIsOverHoverableOrTooltip(clientX, clientY)) { - // Hide with "intent to replace" - we aren't actually going to replace - // the tooltip with a new one, but this intent indicates that it should - // be hidden right away, instead of showing. What we're really replacing, - // or rather removing, is the state of interacting with tooltips at all. - hideCurrentlyShownTooltip(true); - - // Part of that state is fast hovering, which should be canceled out. - state.fastHovering = false; - if (state.endFastHoveringTimeout) { - clearTimeout(state.endFastHoveringTimeout); - state.endFastHoveringTimeout = null; - } - - // Also cancel out of transitioning a tooltip hidden - this isn't caught - // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip - // doesn't count as "shown" anymore. - cancelTransitioningTooltipHidden(); - } - }); -} - -clientSteps.addPageListeners.push(addHoverableTooltipPageListeners); - -// Data & info card --------------------------------------- - -/* -function colorLink(a, color) { - console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); - return; - - // eslint-disable-next-line no-unreachable - const chroma = {}; - - if (color) { - const {primary, dim} = getColors(color, {chroma}); - a.style.setProperty('--primary-color', primary); - a.style.setProperty('--dim-color', dim); - } -} - -function link(a, type, {name, directory, color}) { - colorLink(a, color); - a.innerText = name; - a.href = getLinkHref(type, directory); -} - -function joinElements(type, elements) { - // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on - // strings. So instead, we'll pass the element's outer HTML's (which means - // the entire HTML of that element). - // - // That does mean this function returns a string, so always 8e sure to - // set innerHTML when using it (not appendChild). - - return list[type](elements.map((el) => el.outerHTML)); -} - -const infoCard = (() => { - const container = document.getElementById('info-card-container'); - - let cancelShow = false; - let hideTimeout = null; - let showing = false; - - container.addEventListener('mouseenter', cancelHide); - container.addEventListener('mouseleave', readyHide); - - function show(type, target) { - cancelShow = false; - - fetchData(type, target.dataset[type]).then((data) => { - // Manual DOM 'cuz we're laaaazy. - - if (cancelShow) { - return; - } - - showing = true; - - const rect = target.getBoundingClientRect(); - - container.style.setProperty('--primary-color', data.color); - - container.style.top = window.scrollY + rect.bottom + 'px'; - container.style.left = window.scrollX + rect.left + 'px'; - - // Use a short timeout to let a currently hidden (or not yet shown) - // info card teleport to the position set a8ove. (If it's currently - // shown, it'll transition to that position.) - setTimeout(() => { - container.classList.remove('hide'); - container.classList.add('show'); - }, 50); - - // 8asic details. - - const nameLink = container.querySelector('.info-card-name a'); - link(nameLink, 'track', data); - - const albumLink = container.querySelector('.info-card-album a'); - link(albumLink, 'album', data.album); - - const artistSpan = container.querySelector('.info-card-artists span'); - artistSpan.innerHTML = joinElements( - 'conjunction', - data.artists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - - const coverArtistParagraph = container.querySelector( - '.info-card-cover-artists' - ); - const coverArtistSpan = coverArtistParagraph.querySelector('span'); - if (data.coverArtists.length) { - coverArtistParagraph.style.display = 'block'; - coverArtistSpan.innerHTML = joinElements( - 'conjunction', - data.coverArtists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - } else { - coverArtistParagraph.style.display = 'none'; - } - - // Cover art. - - const [containerNoReveal, containerReveal] = [ - container.querySelector('.info-card-art-container.no-reveal'), - container.querySelector('.info-card-art-container.reveal'), - ]; - - const [containerShow, containerHide] = data.cover.warnings.length - ? [containerReveal, containerNoReveal] - : [containerNoReveal, containerReveal]; - - containerHide.style.display = 'none'; - containerShow.style.display = 'block'; - - const img = containerShow.querySelector('.info-card-art'); - img.src = rebase(data.cover.paths.small, 'rebaseMedia'); - - const imgLink = containerShow.querySelector('a'); - colorLink(imgLink, data.color); - imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); - - if (containerShow === containerReveal) { - const cw = containerShow.querySelector('.info-card-art-warnings'); - cw.innerText = list.unit(data.cover.warnings); - - const reveal = containerShow.querySelector('.reveal'); - reveal.classList.remove('revealed'); - } - }); - } - - function hide() { - container.classList.remove('show'); - container.classList.add('hide'); - cancelShow = true; - showing = false; - } - - function readyHide() { - if (!hideTimeout && showing) { - hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); - } - } - - function cancelHide() { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = null; - } - } - - return { - show, - hide, - readyHide, - cancelHide, - }; -})(); - -// Info cards are disa8led for now since they aren't quite ready for release, -// 8ut you can try 'em out 8y setting this localStorage flag! -// -// localStorage.tryInfoCards = true; -// -if (localStorage.tryInfoCards) { - addInfoCardLinkHandlers('track'); -} -*/ - -// Custom hash links -------------------------------------- - -const hashLinkInfo = initInfo('hashLinkInfo', { - links: null, - hrefs: null, - targets: null, - - state: { - highlightedTarget: null, - scrollingAfterClick: false, - concludeScrollingStateInterval: null, - }, - - event: { - beforeHashLinkScrolls: [], - whenHashLinkClicked: [], - }, -}); - -function getHashLinkReferences() { - const info = hashLinkInfo; - - info.links = - Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); - - info.hrefs = - info.links - .map(link => link.getAttribute('href')); - - info.targets = - info.hrefs - .map(href => document.getElementById(href.slice(1))); - - filterMultipleArrays( - info.links, - info.hrefs, - info.targets, - (_link, _href, target) => target); -} - -function processScrollingAfterHashLinkClicked() { - const {state} = hashLinkInfo; - - if (state.concludeScrollingStateInterval) return; - - let lastScroll = window.scrollY; - state.scrollingAfterClick = true; - state.concludeScrollingStateInterval = setInterval(() => { - if (Math.abs(window.scrollY - lastScroll) < 10) { - clearInterval(state.concludeScrollingStateInterval); - state.scrollingAfterClick = false; - state.concludeScrollingStateInterval = null; - } else { - lastScroll = window.scrollY; - } - }, 200); -} - -function addHashLinkListeners() { - // Instead of defining a scroll offset (to account for the sticky heading) - // in JavaScript, we interface with the CSS property 'scroll-margin-top'. - // This lets the scroll offset be consolidated where it makes sense, and - // sets an appropriate offset when (re)loading a page with hash for free! - - const info = hashLinkInfo; - const {state, event} = info; - - for (const {hashLink, href, target} of stitchArrays({ - hashLink: info.links, - href: info.hrefs, - target: info.targets, - })) { - hashLink.addEventListener('click', evt => { - if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { - return; - } - - // Don't do anything if the target element isn't actually visible! - if (target.offsetParent === null) { - return; - } - - // Allow event handlers to prevent scrolling. - const listenerResults = - dispatchInternalEvent(event, 'beforeHashLinkScrolls', { - link: hashLink, - target, - }); - - if (listenerResults.includes(false)) { - return; - } - - // Hide skipper box right away, so the layout is updated on time for the - // math operations coming up next. - const skipper = document.getElementById('skippers'); - skipper.style.display = 'none'; - setTimeout(() => skipper.style.display = ''); - - const box = target.getBoundingClientRect(); - const style = window.getComputedStyle(target); - - const scrollY = - window.scrollY - + box.top - - style['scroll-margin-top'].replace('px', ''); - - evt.preventDefault(); - history.pushState({}, '', href); - window.scrollTo({top: scrollY, behavior: 'smooth'}); - target.focus({preventScroll: true}); - - const maxScroll = - document.body.scrollHeight - - window.innerHeight; - - if (scrollY > maxScroll && target.classList.contains('content-heading')) { - if (state.highlightedTarget) { - state.highlightedTarget.classList.remove('highlight-hash-link'); - } - - target.classList.add('highlight-hash-link'); - state.highlightedTarget = target; - } - - processScrollingAfterHashLinkClicked(); - - dispatchInternalEvent(event, 'whenHashLinkClicked', { - link: hashLink, - target, - }); - }); - } - - for (const target of info.targets) { - target.addEventListener('animationend', evt => { - if (evt.animationName !== 'highlight-hash-link') return; - target.classList.remove('highlight-hash-link'); - if (target !== state.highlightedTarget) return; - state.highlightedTarget = null; - }); - } -} - -clientSteps.getPageReferences.push(getHashLinkReferences); -clientSteps.addPageListeners.push(addHashLinkListeners); - -// Sticky content heading --------------------------------- - -const stickyHeadingInfo = initInfo('stickyHeadingInfo', { - stickyContainers: null, - - stickySubheadingRows: null, - stickySubheadings: null, - - stickyCoverContainers: null, - stickyCoverTextAreas: null, - stickyCovers: null, - - contentContainers: null, - contentHeadings: null, - contentCovers: null, - contentCoversReveal: null, - - state: { - displayedHeading: null, - }, - - event: { - whenDisplayedHeadingChanges: [], - }, -}); - -function getStickyHeadingReferences() { - const info = stickyHeadingInfo; - - info.stickyContainers = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); - - info.stickyCoverContainers = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-heading-cover-container')); - - info.stickyCovers = - info.stickyCoverContainers - .map(el => el?.querySelector('.content-sticky-heading-cover')); - - info.stickyCoverTextAreas = - info.stickyCovers - .map(el => el?.querySelector('.image-text-area')); - - info.stickySubheadingRows = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-subheading-row')); - - info.stickySubheadings = - info.stickySubheadingRows - .map(el => el.querySelector('h2')); - - info.contentContainers = - info.stickyContainers - .map(el => el.parentElement); - - info.contentCovers = - info.contentContainers - .map(el => el.querySelector('#cover-art-container')); - - info.contentCoversReveal = - info.contentCovers - .map(el => el ? !!el.querySelector('.reveal') : null); - - info.contentHeadings = - info.contentContainers - .map(el => Array.from(el.querySelectorAll('.content-heading'))); -} - -function removeTextPlaceholderStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const hasTextArea = - info.stickyCoverTextAreas.map(el => !!el); - - const coverContainersWithTextArea = - info.stickyCoverContainers - .filter((_el, index) => hasTextArea[index]); - - for (const el of coverContainersWithTextArea) { - el.remove(); - } - - info.stickyCoverContainers = - info.stickyCoverContainers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCovers = - info.stickyCovers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCoverTextAreas = - info.stickyCoverTextAreas - .slice() - .fill(null); -} - -function addRevealClassToStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCoversWhichReveal = - info.stickyCovers - .filter((_el, index) => info.contentCoversReveal[index]); - - for (const el of stickyCoversWhichReveal) { - el.classList.add('content-sticky-heading-cover-needs-reveal'); - } -} - -function addRevealListenersForStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCovers = info.stickyCovers.slice(); - const contentCovers = info.contentCovers.slice(); - - filterMultipleArrays( - stickyCovers, - contentCovers, - (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); - - for (const {stickyCover, contentCover} of stitchArrays({ - stickyCover: stickyCovers, - contentCover: contentCovers, - })) { - // TODO: Janky - should use internal event instead of DOM event - contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { - stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); - }); - } -} - -function topOfViewInside(el, scroll = window.scrollY) { - return ( - scroll > el.offsetTop && - scroll < el.offsetTop + el.offsetHeight); -} - -function updateStickyCoverVisibility(index) { - const info = stickyHeadingInfo; - - const stickyCoverContainer = info.stickyCoverContainers[index]; - const contentCover = info.contentCovers[index]; - - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 4) { - stickyCoverContainer.classList.add('visible'); - } else { - stickyCoverContainer.classList.remove('visible'); - } - } -} - -function getContentHeadingClosestToStickySubheading(index) { - const info = stickyHeadingInfo; - - const contentContainer = info.contentContainers[index]; - - if (!topOfViewInside(contentContainer)) { - return null; - } - - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); - - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.getBoundingClientRect(); - - const stickyBottom = stickyRect.bottom + subheadingRect.height; - - // Iterate from bottom to top of the content area. - const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { - const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { - return heading; - } - } - - return null; -} - -function updateStickySubheadingContent(index) { - const info = stickyHeadingInfo; - const {event, state} = info; - - const closestHeading = getContentHeadingClosestToStickySubheading(index); - - if (state.displayedHeading === closestHeading) return; - - const stickySubheadingRow = info.stickySubheadingRows[index]; - - if (closestHeading) { - const stickySubheading = info.stickySubheadings[index]; - - // Array.from needed to iterate over a live array with for..of - for (const child of Array.from(stickySubheading.childNodes)) { - child.remove(); - } - - const textContainer = - templateContent( - closestHeading.querySelector('.content-heading-sticky-title')) ?? - closestHeading.querySelector('.content-heading-main-title') ?? - closestHeading; - - for (const child of textContainer.childNodes) { - if (child.tagName === 'A') { - for (const grandchild of child.childNodes) { - stickySubheading.appendChild(grandchild.cloneNode(true)); - } - } else { - stickySubheading.appendChild(child.cloneNode(true)); - } - } - - stickySubheadingRow.classList.add('visible'); - } else { - stickySubheadingRow.classList.remove('visible'); - } - - const oldDisplayedHeading = state.displayedHeading; - - state.displayedHeading = closestHeading; - - dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); -} - -function updateStickyHeadings(index) { - updateStickyCoverVisibility(index); - updateStickySubheadingContent(index); -} - -function initializeStateForStickyHeadings() { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } -} - -function addScrollListenerForStickyHeadings() { - document.addEventListener('scroll', () => { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } - }); -} - -clientSteps.getPageReferences.push(getStickyHeadingReferences); -clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); -clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); -clientSteps.initializeState.push(initializeStateForStickyHeadings); -clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); -clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); - -// Image overlay ------------------------------------------ - -// TODO: Update to clientSteps style. - -function addImageOverlayClickHandlers() { - const container = document.getElementById('image-overlay-container'); - - if (!container) { - console.warn(`#image-overlay-container missing, image overlay module disabled.`); - return; - } - - for (const link of document.querySelectorAll('.image-link')) { - if (link.closest('.no-image-preview')) { - continue; - } - - link.addEventListener('click', handleImageLinkClicked); - } - - const actionContainer = document.getElementById('image-overlay-action-container'); - - container.addEventListener('click', handleContainerClicked); - document.body.addEventListener('keydown', handleKeyDown); - - function handleContainerClicked(evt) { - // Only hide the image overlay if actually clicking the background. - if (evt.target !== container) { - return; - } - - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. - const rect = actionContainer.getBoundingClientRect(); - if (evt.clientY >= rect.top - 40) { - return; - } - - container.classList.remove('visible'); - } - - function handleKeyDown(evt) { - if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { - container.classList.remove('visible'); - } - } -} - -async function handleImageLinkClicked(evt) { - if (evt.metaKey || evt.shiftKey || evt.altKey) { - return; - } - - evt.preventDefault(); - - // Don't show the overlay if the image still needs to be revealed. - if (evt.target.closest('.reveal:not(.revealed)')) { - return; - } - - const container = document.getElementById('image-overlay-container'); - container.classList.add('visible'); - container.classList.remove('loaded'); - container.classList.remove('errored'); - - const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); - const mainImage = document.getElementById('image-overlay-image'); - const thumbImage = document.getElementById('image-overlay-image-thumb'); - - const {href: originalSrc} = evt.target.closest('a'); - - const { - src: embeddedSrc, - dataset: { - originalSize: originalFileSize, - thumbs: availableThumbList, - }, - } = evt.target.closest('a').querySelector('img'); - - updateFileSizeInformation(originalFileSize); - - let mainSrc = null; - let thumbSrc = null; - - if (availableThumbList) { - const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); - const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); - thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); - // Show the thumbnail size on each <img> element's data attributes. - // Y'know, just for debugging convenience. - mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; - thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; - } else { - mainSrc = originalSrc; - thumbSrc = null; - mainImage.dataset.displayingThumb = ''; - thumbImage.dataset.displayingThumb = ''; - } - - if (thumbSrc) { - thumbImage.src = thumbSrc; - thumbImage.style.display = null; - } else { - thumbImage.src = ''; - thumbImage.style.display = 'none'; - } - - for (const viewOriginal of allViewOriginal) { - viewOriginal.href = originalSrc; - } - - mainImage.addEventListener('load', handleMainImageLoaded); - mainImage.addEventListener('error', handleMainImageErrored); - - const showProgress = amount => { - cssProp(container, '--download-progress', `${amount * 100}%`); - }; - - showProgress(0.00); - - const response = - await fetchWithProgress(mainSrc, progress => { - if (progress === -1) { - // TODO: Indeterminate response progress cue - showProgress(0.00); - } else { - showProgress(0.20 + 0.80 * progress); - } - }); - - if (!response.status.toString().startsWith('2')) { - handleMainImageErrored(); - return; - } - - const blob = await response.blob(); - const blobSrc = URL.createObjectURL(blob); - - mainImage.src = blobSrc; - showProgress(1.00); - - function handleMainImageLoaded() { - container.classList.add('loaded'); - removeEventListeners(); - } - - function handleMainImageErrored() { - container.classList.add('errored'); - removeEventListeners(); - } - - function removeEventListeners() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - } -} - -function parseThumbList(availableThumbList) { - // Parse all the available thumbnail sizes! These are provided by the actual - // content generation on each image. - const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' - const availableSizes = - (availableThumbList || defaultThumbList) - .split(' ') - .map(part => part.split(':')) - .map(([thumb, length]) => ({thumb, length: parseInt(length)})) - .sort((a, b) => a.length - b.length); - - return availableSizes; -} - -function getPreferredThumbSize(availableThumbList) { - // Assuming a square, the image will be constrained to the lesser window - // dimension. Coefficient here matches CSS dimensions for image overlay. - const constrainedLength = Math.floor(Math.min( - 0.80 * window.innerWidth, - 0.80 * window.innerHeight)); - - // Match device pixel ratio, which is 2x for "retina" displays and certain - // device configurations. - const visualLength = window.devicePixelRatio * constrainedLength; - - const availableSizes = parseThumbList(availableThumbList); - - // Starting from the smallest dimensions, find (and return) the first - // available length which hits a "good enough" threshold - it's got to be - // at least that percent of the way to the actual displayed dimensions. - const goodEnoughThreshold = 0.90; - - // (The last item is skipped since we'd be falling back to it anyway.) - for (const {thumb, length} of availableSizes.slice(0, -1)) { - if (Math.floor(visualLength * goodEnoughThreshold) <= length) { - return {thumb, length}; - } - } - - // If none of the items in the list were big enough to hit the "good enough" - // threshold, just use the largest size available. - return availableSizes[availableSizes.length - 1]; -} - -function getSmallestThumbSize(availableThumbList) { - // Just snag the smallest size. This'll be used for displaying the "preview" - // as the bigger one is loading. - const availableSizes = parseThumbList(availableThumbList); - return availableSizes[0]; -} - -function updateFileSizeInformation(fileSize) { - const fileSizeWarningThreshold = 8 * 10 ** 6; - - const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); - const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); - - if (!fileSize) { - actionContentWithSize.classList.remove('visible'); - actionContentWithoutSize.classList.add('visible'); - return; - } - - actionContentWithoutSize.classList.remove('visible'); - actionContentWithSize.classList.add('visible'); - - const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); - const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); - const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); - const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); - const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); - - fileSize = parseInt(fileSize); - const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; - - if (fileSize > fileSizeWarningThreshold) { - fileSizeWarning.classList.add('visible'); - } else { - fileSizeWarning.classList.remove('visible'); - } - - if (fileSize > 10 ** 6) { - megabytesContainer.classList.add('visible'); - kilobytesContainer.classList.remove('visible'); - megabytesContent.innerText = round(6); - } else { - megabytesContainer.classList.remove('visible'); - kilobytesContainer.classList.add('visible'); - kilobytesContent.innerText = round(3); - } - - void fileSizeWarning; -} - -addImageOverlayClickHandlers(); - -// "Additional names" box --------------------------------- - -const additionalNamesBoxInfo = initInfo('additionalNamesBox', { - box: null, - links: null, - mainContentContainer: null, - - state: { - visible: false, - }, -}); - -function getAdditionalNamesBoxReferences() { - const info = additionalNamesBoxInfo; - - info.box = - document.getElementById('additional-names-box'); - - info.links = - document.querySelectorAll('a[href="#additional-names-box"]'); - - info.mainContentContainer = - document.querySelector('#content .main-content-container'); -} - -function addAdditionalNamesBoxInternalListeners() { - const info = additionalNamesBoxInfo; - - hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { - if (target === info.box) { - return false; - } - }); -} - -function addAdditionalNamesBoxListeners() { - const info = additionalNamesBoxInfo; - - for (const link of info.links) { - link.addEventListener('click', domEvent => { - handleAdditionalNamesBoxLinkClicked(domEvent); - }); - } -} - -function handleAdditionalNamesBoxLinkClicked(domEvent) { - const info = additionalNamesBoxInfo; - const {state} = info; - - domEvent.preventDefault(); - - if (!info.box || !info.mainContentContainer) return; - - const margin = - +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); - - const {top} = - (state.visible - ? info.box.getBoundingClientRect() - : info.mainContentContainer.getBoundingClientRect()); - - if (top + 20 < margin || top > 0.4 * window.innerHeight) { - if (!state.visible) { - toggleAdditionalNamesBox(); - } - - window.scrollTo({ - top: window.scrollY + top - margin, - behavior: 'smooth', - }); - } else { - toggleAdditionalNamesBox(); - } -} - -function toggleAdditionalNamesBox() { - const info = additionalNamesBoxInfo; - const {state} = info; - - state.visible = !state.visible; - info.box.style.display = - (state.visible - ? 'block' - : 'none'); -} - -clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences); -clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners); -clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners); - -// Group contributions table ------------------------------ - -// TODO: Update to clientSteps style. - -const groupContributionsTableInfo = - Array.from(document.querySelectorAll('#content dl')) - .filter(dl => dl.querySelector('a.group-contributions-sort-button')) - .map(dl => ({ - sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), - sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), - sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), - sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), - })); - -function sortGroupContributionsTableBy(info, sort) { - const [showThese, hideThese] = - (sort === 'count' - ? [info.sortingByCountElements, info.sortingByDurationElements] - : [info.sortingByDurationElements, info.sortingByCountElements]); - - for (const element of showThese) element.classList.add('visible'); - for (const element of hideThese) element.classList.remove('visible'); -} - -for (const info of groupContributionsTableInfo) { - info.sortingByCountLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'duration'); - }); - - info.sortingByDurationLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'count'); - }); -} - -// Generic links with tooltips ---------------------------- - -const textWithTooltipInfo = initInfo('textWithTooltipInfo', { - hoverables: null, - tooltips: null, -}); - -function getTextWithTooltipReferences() { - const info = textWithTooltipInfo; - - const spans = - Array.from(document.querySelectorAll('.text-with-tooltip')); - - info.hoverables = - spans.map(span => span.children[0]); - - info.tooltips = - spans.map(span => span.children[1]); -} - -function addTextWithTooltipPageListeners() { - const info = textWithTooltipInfo; - - for (const {hoverable, tooltip} of stitchArrays({ - hoverable: info.hoverables, - tooltip: info.tooltips, - })) { - registerTooltipElement(tooltip); - registerTooltipHoverableElement(hoverable, tooltip); - } -} - -clientSteps.getPageReferences.push(getTextWithTooltipReferences); -clientSteps.addPageListeners.push(addTextWithTooltipPageListeners); - -// Datetimestamp tooltips --------------------------------- - -const datetimestampTooltipInfo = initInfo('datetimestampTooltipInfo', { - hoverables: null, - tooltips: null, -}); - -function getDatestampTooltipReferences() { - const info = datetimestampTooltipInfo; - - const spans = - Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip')); - - info.hoverables = - spans.map(span => span.querySelector('time')); - - info.tooltips = - spans.map(span => span.querySelector('span.datetimestamp-tooltip')); -} - -function addDatestampTooltipPageListeners() { - const info = datetimestampTooltipInfo; - - for (const {hoverable, tooltip} of stitchArrays({ - hoverable: info.hoverables, - tooltip: info.tooltips, - })) { - registerTooltipElement(tooltip); - registerTooltipHoverableElement(hoverable, tooltip); - } -} - -clientSteps.getPageReferences.push(getDatestampTooltipReferences); -clientSteps.addPageListeners.push(addDatestampTooltipPageListeners); - -// Artist external link tooltips -------------------------- - -// These don't need to have tooltip events specially added as -// they're implemented with "text with tooltip" components. - -const artistExternalLinkTooltipInfo = initInfo('artistExternalLinkTooltipInfo', { - tooltips: null, - tooltipRows: null, - - settings: { - // This is the maximum distance, in CSS pixels, that the mouse - // can appear to be moving per second while still considered - // "idle". A greater value means higher tolerance for small - // movements. - maximumIdleSpeed: 40, - - // Leaving the mouse idle for this amount of time, over a single - // row of the tooltip, will cause a column of supplemental info - // to display. - mouseIdleShowInfoDelay: 1000, - - // If none of these tooltips are visible for this amount of time, - // the supplemental info column is hidden. It'll never disappear - // while a tooltip is actually visible. - hideInfoAfterTooltipHiddenDelay: 2250, - }, - - state: { - // This is shared by all tooltips. - showingTooltipInfo: false, - - mouseIdleTimeout: null, - hideInfoTimeout: null, - - mouseMovementPositions: [], - mouseMovementTimestamps: [], - }, -}); - -function getArtistExternalLinkTooltipPageReferences() { - const info = artistExternalLinkTooltipInfo; - - info.tooltips = - Array.from(document.getElementsByClassName('contribution-tooltip')); - - info.tooltipRows = - info.tooltips.map(tooltip => - Array.from(tooltip.getElementsByClassName('icon'))); -} - -function addArtistExternalLinkTooltipInternalListeners() { - const info = artistExternalLinkTooltipInfo; - - hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => { - const {state} = info; - - if (info.tooltips.includes(tooltip)) { - clearTimeout(state.hideInfoTimeout); - state.hideInfoTimeout = null; - } - }); - - hoverableTooltipInfo.event.whenTooltipHides.push(() => { - const {settings, state} = info; - - if (state.showingTooltipInfo) { - state.hideInfoTimeout = - setTimeout(() => { - state.hideInfoTimeout = null; - hideArtistExternalLinkTooltipInfo(); - }, settings.hideInfoAfterTooltipHiddenDelay); - } else { - clearTimeout(state.mouseIdleTimeout); - state.mouseIdleTimeout = null; - } - }); -} - -function addArtistExternalLinkTooltipPageListeners() { - const info = artistExternalLinkTooltipInfo; - - for (const tooltip of info.tooltips) { - tooltip.addEventListener('mousemove', domEvent => { - handleArtistExternalLinkTooltipMouseMoved(domEvent); - }); - - tooltip.addEventListener('mouseout', () => { - const {state} = info; - - clearTimeout(state.mouseIdleTimeout); - state.mouseIdleTimeout = null; - }); - } - - for (const tooltipRow of info.tooltipRows.flat()) { - tooltipRow.addEventListener('mouseover', () => { - const {state} = info; - - clearTimeout(state.mouseIdleTimeout); - state.mouseIdleTimeout = null; - }); - } -} - -function handleArtistExternalLinkTooltipMouseMoved(domEvent) { - const info = artistExternalLinkTooltipInfo; - const {settings, state} = info; - - if (state.showingTooltipInfo) { - return; - } - - // Clean out expired mouse movements - - const expiryTime = 1000; - - if (!empty(state.mouseMovementTimestamps)) { - const firstRecentMovementIndex = - state.mouseMovementTimestamps - .findIndex(value => Date.now() - value <= expiryTime); - - if (firstRecentMovementIndex === -1) { - state.mouseMovementTimestamps.splice(0); - state.mouseMovementPositions.splice(0); - } else if (firstRecentMovementIndex > 0) { - state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1); - state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1); - } - } - - const currentMovementDistance = - Math.sqrt(domEvent.movementX ** 2 + domEvent.movementY ** 2); - - state.mouseMovementTimestamps.push(Date.now()); - state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]); - - // We can't really compute speed without having - // at least two data points! - if (state.mouseMovementPositions.length < 2) { - return; - } - - const movementTravelDistances = - state.mouseMovementPositions.map((current, index, array) => { - if (index === 0) return 0; - - const previous = array[index - 1]; - const deltaX = current[0] - previous[0]; - const deltaY = current[1] - previous[1]; - return Math.sqrt(deltaX ** 2 + deltaY ** 2); - }); - - const totalTravelDistance = - accumulateSum(movementTravelDistances); - - // In seconds rather than milliseconds. - const timeSinceFirstMovement = - (Date.now() - state.mouseMovementTimestamps[0]) / 1000; - - const averageSpeed = - Math.floor(totalTravelDistance / timeSinceFirstMovement); - - if (averageSpeed > settings.maximumIdleSpeed) { - clearTimeout(state.mouseIdleTimeout); - state.mouseIdleTimeout = null; - } - - if (state.mouseIdleTimeout) { - return; - } - - state.mouseIdleTimeout = - setTimeout(() => { - state.mouseIdleTimeout = null; - showArtistExternalLinkTooltipInfo(); - }, settings.mouseIdleShowInfoDelay); -} - -function showArtistExternalLinkTooltipInfo() { - const info = artistExternalLinkTooltipInfo; - const {state} = info; - - state.showingTooltipInfo = true; - - for (const tooltip of info.tooltips) { - tooltip.classList.add('show-info'); - } - - repositionCurrentTooltip(); -} - -function hideArtistExternalLinkTooltipInfo() { - const info = artistExternalLinkTooltipInfo; - const {state} = info; - - state.showingTooltipInfo = false; - - for (const tooltip of info.tooltips) { - tooltip.classList.remove('show-info'); - } -} - -clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences); -clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners); -clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners); - -// Quick description -------------------------------------- - -const quickDescriptionInfo = initInfo('quickDescriptionInfo', { - quickDescriptionContainers: null, - - quickDescriptionsAreExpandable: null, - - expandDescriptionLinks: null, - collapseDescriptionLinks: null, -}); - -function getQuickDescriptionReferences() { - const info = quickDescriptionInfo; - - info.quickDescriptionContainers = - Array.from(document.querySelectorAll('#content .quick-description')); - - info.quickDescriptionsAreExpandable = - info.quickDescriptionContainers - .map(container => - container.querySelector('.quick-description-actions.when-expanded')); - - info.expandDescriptionLinks = - info.quickDescriptionContainers - .map(container => - container.querySelector('.quick-description-actions .expand-link')); - - info.collapseDescriptionLinks = - info.quickDescriptionContainers - .map(container => - container.querySelector('.quick-description-actions .collapse-link')); -} - -function addQuickDescriptionListeners() { - const info = quickDescriptionInfo; - - for (const { - isExpandable, - container, - expandLink, - collapseLink, - } of stitchArrays({ - isExpandable: info.quickDescriptionsAreExpandable, - container: info.quickDescriptionContainers, - expandLink: info.expandDescriptionLinks, - collapseLink: info.collapseDescriptionLinks, - })) { - if (!isExpandable) continue; - - expandLink.addEventListener('click', event => { - event.preventDefault(); - container.classList.add('expanded'); - container.classList.remove('collapsed'); - }); - - collapseLink.addEventListener('click', event => { - event.preventDefault(); - container.classList.add('collapsed'); - container.classList.remove('expanded'); - }); - } -} - -clientSteps.getPageReferences.push(getQuickDescriptionReferences); -clientSteps.addPageListeners.push(addQuickDescriptionListeners); - -// Internal search functionality -------------------------- - -const wikiSearchInfo = initInfo('wikiSearchInfo', { - state: { - worker: null, - - workerReadyPromise: null, - workerReadyPromiseResolvers: null, - - workerActionCounter: 0, - workerActionPromiseResolverMap: new Map(), - - downloads: Object.create(null), - }, - - event: { - whenWorkerAlive: [], - whenWorkerReady: [], - whenWorkerFailsToInitialize: [], - whenWorkerHasRuntimeError: [], - - whenDownloadBegins: [], - whenDownloadsBegin: [], - whenDownloadProgresses: [], - whenDownloadEnds: [], - }, -}); - -async function initializeSearchWorker() { - const {state} = wikiSearchInfo; - - if (state.worker) { - return await state.workerReadyPromise; - } - - state.worker = - new Worker( - import.meta.resolve('./search-worker.js'), - {type: 'module'}); - - state.worker.onmessage = handleSearchWorkerMessage; - - const {promise, resolve, reject} = promiseWithResolvers(); - - state.workerReadyPromiseResolvers = {resolve, reject}; - - return await (state.workerReadyPromise = promise); -} - -function handleSearchWorkerMessage(message) { - switch (message.data.kind) { - case 'status': - handleSearchWorkerStatusMessage(message); - break; - - case 'result': - handleSearchWorkerResultMessage(message); - break; - - case 'download-begun': - handleSearchWorkerDownloadBegunMessage(message); - break; - - case 'download-progress': - handleSearchWorkerDownloadProgressMessage(message); - break; - - case 'download-complete': - handleSearchWorkerDownloadCompleteMessage(message); - break; - - default: - console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`); - break; - } -} - -function handleSearchWorkerStatusMessage(message) { - const {state, event} = wikiSearchInfo; - - switch (message.data.status) { - case 'alive': - console.debug(`Search worker is alive, but not yet ready.`); - dispatchInternalEvent(event, 'whenWorkerAlive'); - break; - - case 'ready': - console.debug(`Search worker has loaded corpuses and is ready.`); - state.workerReadyPromiseResolvers.resolve(state.worker); - dispatchInternalEvent(event, 'whenWorkerReady'); - break; - - case 'setup-error': - console.debug(`Search worker failed to initialize.`); - state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker')); - dispatchInternalEvent(event, 'whenWorkerFailsToInitialize'); - break; - - case 'runtime-error': - console.debug(`Search worker had an uncaught runtime error.`); - dispatchInternalEvent(event, 'whenWorkerHasRuntimeError'); - break; - - default: - console.warn(`Unknown status "${message.data.status}" <- from search worker`); - break; - } -} - -function handleSearchWorkerResultMessage(message) { - const {state} = wikiSearchInfo; - const {id} = message.data; - - if (!id) { - console.warn(`Result without id <- from search worker:`, message.data); - return; - } - - if (!state.workerActionPromiseResolverMap.has(id)) { - console.warn(`Runaway result id <- from search worker:`, message.data); - return; - } - - const {resolve, reject} = - state.workerActionPromiseResolverMap.get(id); - - switch (message.data.status) { - case 'resolve': - resolve(message.data.value); - break; - - case 'reject': - reject(message.data.value); - break; - - default: - console.warn(`Unknown result status "${message.data.status}" <- from search worker`); - return; - } - - state.workerActionPromiseResolverMap.delete(id); -} - -function handleSearchWorkerDownloadBegunMessage(message) { - const {event} = wikiSearchInfo; - const {context: contextKey, keys} = message.data; - - const context = getSearchWorkerDownloadContext(contextKey, true); - - for (const key of keys) { - context[key] = 0.00; - - dispatchInternalEvent(event, 'whenDownloadBegins', { - context: contextKey, - key, - }); - } - - dispatchInternalEvent(event, 'whenDownloadsBegin', { - context: contextKey, - keys, - }); -} - -function handleSearchWorkerDownloadProgressMessage(message) { - const {event} = wikiSearchInfo; - const {context: contextKey, key, progress} = message.data; - - const context = getSearchWorkerDownloadContext(contextKey); - - context[key] = progress; - - dispatchInternalEvent(event, 'whenDownloadProgresses', { - context: contextKey, - key, - progress, - }); -} - -function handleSearchWorkerDownloadCompleteMessage(message) { - const {event} = wikiSearchInfo; - const {context: contextKey, key} = message.data; - - const context = getSearchWorkerDownloadContext(contextKey); - - context[key] = 1.00; - - dispatchInternalEvent(event, 'whenDownloadEnds', { - context: contextKey, - key, - }); -} - -function getSearchWorkerDownloadContext(context, initialize = false) { - const {state} = wikiSearchInfo; - - if (context in state.downloads) { - return state.downloads[context]; - } - - if (!initialize) { - return null; - } - - return state.downloads[context] = Object.create(null); -} - -async function postSearchWorkerAction(action, options) { - const {state} = wikiSearchInfo; - - const worker = await initializeSearchWorker(); - const id = ++state.workerActionCounter; - - const {promise, resolve, reject} = promiseWithResolvers(); - - state.workerActionPromiseResolverMap.set(id, {resolve, reject}); - - worker.postMessage({ - kind: 'action', - action: action, - id, - options, - }); - - return await promise; -} - -async function searchAll(query, options = {}) { - return await postSearchWorkerAction('search', { - query, - options, - }); -} - -// Sidebar search box ------------------------------------- - -const sidebarSearchInfo = initInfo('sidebarSearchInfo', { - pageContainer: null, - - searchSidebarColumn: null, - searchBox: null, - searchLabel: null, - searchInput: null, - - progressRule: null, - progressContainer: null, - progressLabel: null, - progressBar: null, - - failedRule: null, - failedContainer: null, - - resultsRule: null, - resultsContainer: null, - results: null, - - endSearchRule: null, - endSearchLine: null, - endSearchLink: null, - - preparingString: null, - loadingDataString: null, - searchingString: null, - failedString: null, - - noResultsString: null, - currentResultString: null, - endSearchString: null, - - albumResultKindString: null, - artistResultKindString: null, - groupResultKindString: null, - tagResultKindString: null, - - state: { - sidebarColumnShownForSearch: null, - - tidiedSidebar: null, - collapsedDetailsForTidiness: null, - - workerStatus: null, - searchStage: null, - - stoppedTypingTimeout: null, - stoppedScrollingTimeout: null, - - indexDownloadStatuses: Object.create(null), - }, - - session: { - activeQuery: { - type: 'string', - }, - - activeQueryResults: { - type: 'json', - maxLength: settings => settings.maxActiveResultsStorage, - }, - - repeatQueryOnReload: { - type: 'boolean', - default: false, - }, - - resultsScrollOffset: { - type: 'number', - }, - }, - - settings: { - stoppedTypingDelay: 800, - stoppedScrollingDelay: 200, - - maxActiveResultsStorage: 100000, - }, -}); - -function getSidebarSearchReferences() { - const info = sidebarSearchInfo; - - info.pageContainer = - document.getElementById('page-container'); - - info.searchBox = - document.querySelector('.wiki-search-sidebar-box'); - - if (!info.searchBox) { - return; - } - - info.searchLabel = - info.searchBox.querySelector('.wiki-search-label'); - - info.searchInput = - info.searchBox.querySelector('.wiki-search-input'); - - info.searchSidebarColumn = - info.searchBox.closest('.sidebar-column'); - - const findString = classPart => - info.searchBox.querySelector(`.wiki-search-${classPart}-string`); - - info.preparingString = - findString('preparing'); - - info.loadingDataString = - findString('loading-data'); - - info.searchingString = - findString('searching'); - - info.failedString = - findString('failed'); - - info.noResultsString = - findString('no-results'); - - info.currentResultString = - findString('current-result'); - - info.endSearchString = - findString('end-search'); - - info.albumResultKindString = - findString('album-result-kind'); - - info.artistResultKindString = - findString('artist-result-kind'); - - info.groupResultKindString = - findString('group-result-kind'); - - info.tagResultKindString = - findString('tag-result-kind'); -} - -function addSidebarSearchInternalListeners() { - const info = sidebarSearchInfo; - - if (!info.searchBox) return; - - wikiSearchInfo.event.whenWorkerAlive.push( - trackSidebarSearchWorkerAlive, - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenWorkerReady.push( - trackSidebarSearchWorkerReady, - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenWorkerFailsToInitialize.push( - trackSidebarSearchWorkerFailsToInitialize, - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenWorkerHasRuntimeError.push( - trackSidebarSearchWorkerHasRuntimeError, - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenDownloadsBegin.push( - trackSidebarSearchDownloadsBegin, - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenDownloadProgresses.push( - updateSidebarSearchStatus); - - wikiSearchInfo.event.whenDownloadEnds.push( - trackSidebarSearchDownloadEnds, - updateSidebarSearchStatus); -} - -function mutateSidebarSearchContent() { - const info = sidebarSearchInfo; - - if (!info.searchBox) return; - - // Progress section - - info.progressRule = - document.createElement('hr'); - - info.progressContainer = - document.createElement('div'); - - info.progressContainer.classList.add('wiki-search-progress-container'); - - cssProp(info.progressRule, 'display', 'none'); - cssProp(info.progressContainer, 'display', 'none'); - - info.progressLabel = - document.createElement('label'); - - info.progressLabel.classList.add('wiki-search-progress-label'); - info.progressLabel.htmlFor = 'wiki-search-progress-bar'; - - info.progressBar = - document.createElement('progress'); - - info.progressBar.classList.add('wiki-search-progress-bar'); - info.progressBar.id = 'wiki-search-progress-bar'; - - info.progressContainer.appendChild(info.progressLabel); - info.progressContainer.appendChild(info.progressBar); - - info.searchBox.appendChild(info.progressRule); - info.searchBox.appendChild(info.progressContainer); - - // Search failed section - - info.failedRule = - document.createElement('hr'); - - info.failedContainer = - document.createElement('div'); - - info.failedContainer.classList.add('wiki-search-failed-container'); - - { - const p = document.createElement('p'); - p.appendChild(templateContent(info.failedString)); - info.failedContainer.appendChild(p); - } - - cssProp(info.failedRule, 'display', 'none'); - cssProp(info.failedContainer, 'display', 'none'); - - info.searchBox.appendChild(info.failedRule); - info.searchBox.appendChild(info.failedContainer); - - // Results section - - info.resultsRule = - document.createElement('hr'); - - info.resultsContainer = - document.createElement('div'); - - info.resultsContainer.classList.add('wiki-search-results-container'); - - cssProp(info.resultsRule, 'display', 'none'); - cssProp(info.resultsContainer, 'display', 'none'); - - info.results = - document.createElement('div'); - - info.results.classList.add('wiki-search-results'); - - info.resultsContainer.appendChild(info.results); - - info.searchBox.appendChild(info.resultsRule); - info.searchBox.appendChild(info.resultsContainer); - - // End search section - - info.endSearchRule = - document.createElement('hr'); - - info.endSearchLine = - document.createElement('p'); - - info.endSearchLink = - document.createElement('a'); - - { - const p = info.endSearchLine; - const a = info.endSearchLink; - p.classList.add('wiki-search-end-search-line'); - a.setAttribute('href', '#'); - a.appendChild(templateContent(info.endSearchString)); - p.appendChild(a); - } - - cssProp(info.endSearchRule, 'display', 'none'); - cssProp(info.endSearchLine, 'display', 'none'); - - info.searchBox.appendChild(info.endSearchRule); - info.searchBox.appendChild(info.endSearchLine); -} - -function addSidebarSearchListeners() { - const info = sidebarSearchInfo; - - if (!info.searchInput) return; - - info.searchInput.addEventListener('change', domEvent => { - if (info.searchInput.value) { - activateSidebarSearch(info.searchInput.value); - } - }); - - info.searchInput.addEventListener('input', domEvent => { - const {settings, state} = info; - - if (!info.searchInput.value) { - clearSidebarSearch(); - return; - } - - if (state.stoppedTypingTimeout) { - clearTimeout(state.stoppedTypingTimeout); - } - - state.stoppedTypingTimeout = - setTimeout(() => { - activateSidebarSearch(info.searchInput.value); - }, settings.stoppedTypingDelay); - }); - - info.endSearchLink.addEventListener('click', domEvent => { - domEvent.preventDefault(); - clearSidebarSearch(); - possiblyHideSearchSidebarColumn(); - restoreSidebarSearchColumn(); - }); - - info.resultsContainer.addEventListener('scroll', () => { - const {settings, state} = info; - - if (state.stoppedScrollingTimeout) { - clearTimeout(state.stoppedScrollingTimeout); - } - - state.stoppedScrollingTimeout = - setTimeout(() => { - saveSidebarSearchResultsScrollOffset(); - }, settings.stoppedScrollingDelay); - }); -} - -function initializeSidebarSearchState() { - const info = sidebarSearchInfo; - const {session} = info; - - if (!info.searchInput) return; - - if (session.activeQuery) { - info.searchInput.value = session.activeQuery; - if (session.repeatQueryOnReload) { - activateSidebarSearch(session.activeQuery); - } else if (session.activeQueryResults) { - showSidebarSearchResults(session.activeQueryResults); - } - } -} - -function trackSidebarSearchWorkerAlive() { - const {state} = sidebarSearchInfo; - - state.workerStatus = 'alive'; -} - -function trackSidebarSearchWorkerReady() { - const {state} = sidebarSearchInfo; - - state.workerStatus = 'ready'; - state.searchStage = 'searching'; -} - -function trackSidebarSearchWorkerFailsToInitialize() { - const {state} = sidebarSearchInfo; - - state.workerStatus = 'failed'; - state.searchStage = 'failed'; -} - -function trackSidebarSearchWorkerHasRuntimeError() { - const {state} = sidebarSearchInfo; - - state.workerStatus = 'failed'; - state.searchStage = 'failed'; -} - -function trackSidebarSearchDownloadsBegin(event) { - const {state} = sidebarSearchInfo; - - if (event.context === 'search-indexes') { - for (const key of event.keys) { - state.indexDownloadStatuses[key] = 'active'; - } - } -} - -function trackSidebarSearchDownloadEnds(event) { - const {state} = sidebarSearchInfo; - - if (event.context === 'search-indexes') { - state.indexDownloadStatuses[event.key] = 'complete'; - - const statuses = Object.values(state.indexDownloadStatuses); - if (statuses.every(status => status === 'complete')) { - for (const key of Object.keys(state.indexDownloadStatuses)) { - delete state.indexDownloadStatuses[key]; - } - } - } -} - -async function activateSidebarSearch(query) { - const {session, settings, state} = sidebarSearchInfo; - - if (state.stoppedTypingTimeout) { - clearTimeout(state.stoppedTypingTimeout); - state.stoppedTypingTimeout = null; - } - - state.searchStage = - (state.workerStatus === 'ready' - ? 'searching' - : 'preparing'); - updateSidebarSearchStatus(); - - let results; - try { - results = await searchAll(query, {enrich: true}); - } catch (error) { - console.error(`There was an error performing a sidebar search:`); - console.error(error); - showSidebarSearchFailed(); - return; - } - - state.searchStage = 'complete'; - updateSidebarSearchStatus(); - - session.activeQuery = query; - session.activeQueryResults = results; - session.resultsScrollOffset = 0; - - showSidebarSearchResults(results); -} - -function clearSidebarSearch() { - const info = sidebarSearchInfo; - const {session, state} = info; - - if (state.stoppedTypingTimeout) { - clearTimeout(state.stoppedTypingTimeout); - state.stoppedTypingTimeout = null; - } - - info.searchBox.classList.remove('showing-results'); - info.searchSidebarColumn.classList.remove('search-showing-results'); - - info.searchInput.value = ''; - - state.searchStage = null; - - session.activeQuery = null; - session.activeQueryResults = null; - session.resultsScrollOffset = null; - - hideSidebarSearchResults(); -} - -function updateSidebarSearchStatus() { - const info = sidebarSearchInfo; - const {state} = info; - - if (state.searchStage === 'failed') { - hideSidebarSearchResults(); - showSidebarSearchFailed(); - - return; - } - - const searchIndexDownloads = - getSearchWorkerDownloadContext('search-indexes'); - - const downloadProgressValues = - Object.values(searchIndexDownloads ?? {}); - - if (downloadProgressValues.some(v => v < 1.00)) { - const total = Object.keys(state.indexDownloadStatuses).length; - const sum = accumulateSum(downloadProgressValues); - showSidebarSearchProgress( - sum / total, - templateContent(info.loadingDataString)); - - return; - } - - if (state.searchStage === 'preparing') { - showSidebarSearchProgress( - null, - templateContent(info.preparingString)); - - return; - } - - if (state.searchStage === 'searching') { - showSidebarSearchProgress( - null, - templateContent(info.searchingString)); - - return; - } - - hideSidebarSearchProgress(); -} - -function showSidebarSearchProgress(progress, label) { - const info = sidebarSearchInfo; - - cssProp(info.progressRule, 'display', null); - cssProp(info.progressContainer, 'display', null); - - if (progress === null) { - info.progressBar.removeAttribute('value'); - } else { - info.progressBar.value = progress; - } - - while (info.progressLabel.firstChild) { - info.progressLabel.firstChild.remove(); - } - - info.progressLabel.appendChild(label); -} - -function hideSidebarSearchProgress() { - const info = sidebarSearchInfo; - - cssProp(info.progressRule, 'display', 'none'); - cssProp(info.progressContainer, 'display', 'none'); -} - -function showSidebarSearchFailed() { - const info = sidebarSearchInfo; - const {state} = info; - - hideSidebarSearchProgress(); - hideSidebarSearchResults(); - - cssProp(info.failedRule, 'display', null); - cssProp(info.failedContainer, 'display', null); - - info.searchLabel.classList.add('disabled'); - info.searchInput.disabled = true; - - if (state.stoppedTypingTimeout) { - clearTimeout(state.stoppedTypingTimeout); - state.stoppedTypingTimeout = null; - } -} - -function showSidebarSearchResults(results) { - const info = sidebarSearchInfo; - - console.debug(`Showing search results:`, results); - - showSearchSidebarColumn(); - - const flatResults = - Object.entries(results) - .filter(([index]) => index === 'generic') - .flatMap(([index, results]) => results - .flatMap(({doc, id}) => ({ - index, - reference: id ?? null, - referenceType: (id ? id.split(':')[0] : null), - directory: (id ? id.split(':')[1] : null), - data: doc, - }))); - - info.searchBox.classList.add('showing-results'); - info.searchSidebarColumn.classList.add('search-showing-results'); - - while (info.results.firstChild) { - info.results.firstChild.remove(); - } - - cssProp(info.resultsRule, 'display', 'block'); - cssProp(info.resultsContainer, 'display', 'block'); - - if (empty(flatResults)) { - const p = document.createElement('p'); - p.classList.add('wiki-search-no-results'); - p.appendChild(templateContent(info.noResultsString)); - info.results.appendChild(p); - } - - for (const result of flatResults) { - const el = generateSidebarSearchResult(result); - if (!el) continue; - - info.results.appendChild(el); - } - - if (!empty(flatResults)) { - cssProp(info.endSearchRule, 'display', 'block'); - cssProp(info.endSearchLine, 'display', 'block'); - - tidySidebarSearchColumn(); - } - - restoreSidebarSearchResultsScrollOffset(); -} - -function generateSidebarSearchResult(result) { - const info = sidebarSearchInfo; - - const preparedSlots = { - color: - result.data.color ?? null, - - name: - result.data.name ?? result.data.primaryName ?? null, - - imageSource: - getSearchResultImageSource(result), - }; - - switch (result.referenceType) { - case 'album': { - preparedSlots.href = - openAlbum(result.directory); - - preparedSlots.kindString = - info.albumResultKindString; - - break; - } - - case 'artist': { - preparedSlots.href = - openArtist(result.directory); - - preparedSlots.kindString = - info.artistResultKindString; - - break; - } - - case 'group': { - preparedSlots.href = - openGroup(result.directory); - - preparedSlots.kindString = - info.groupResultKindString; - - break; - } - - case 'flash': { - preparedSlots.href = - openFlash(result.directory); - - break; - } - - case 'tag': { - preparedSlots.href = - openArtTag(result.directory); - - preparedSlots.kindString = - info.tagResultKindString; - - break; - } - - case 'track': { - preparedSlots.href = - openTrack(result.directory); - - break; - } - - default: - return null; - } - - return generateSidebarSearchResultTemplate(preparedSlots); -} - -function getSearchResultImageSource(result) { - const {artwork} = result.data; - if (!artwork) return null; - - return ( - rebase( - artwork.replace('<>', result.directory), - 'rebaseThumb')); -} - -function generateSidebarSearchResultTemplate(slots) { - const info = sidebarSearchInfo; - - const link = document.createElement('a'); - link.classList.add('wiki-search-result'); - - if (slots.href) { - link.setAttribute('href', slots.href); - } - - if (slots.color) { - cssProp(link, '--primary-color', slots.color); - - try { - const colors = getColors(slots.color, {chroma}); - cssProp(link, '--light-ghost-color', colors.lightGhost); - cssProp(link, '--deep-color', colors.deep); - } catch (error) { - console.warn(error); - } - } - - const imgContainer = document.createElement('span'); - imgContainer.classList.add('wiki-search-result-image-container'); - - if (slots.imageSource) { - const img = document.createElement('img'); - img.classList.add('wiki-search-result-image'); - img.setAttribute('src', slots.imageSource); - imgContainer.appendChild(img); - if (slots.imageSource.endsWith('.mini.jpg')) { - img.classList.add('has-warning'); - } - } else { - const placeholder = document.createElement('span'); - placeholder.classList.add('wiki-search-result-image-placeholder'); - imgContainer.appendChild(placeholder); - } - - link.appendChild(imgContainer); - - const text = document.createElement('span'); - text.classList.add('wiki-search-result-text-area'); - - if (slots.name) { - const span = document.createElement('span'); - span.classList.add('wiki-search-result-name'); - span.appendChild(document.createTextNode(slots.name)); - text.appendChild(span); - } - - let accentSpan = null; - - if (link.href) { - const here = location.href.replace(/\/$/, ''); - const there = link.href.replace(/\/$/, ''); - if (here === there) { - link.classList.add('current-result'); - accentSpan = document.createElement('span'); - accentSpan.classList.add('wiki-search-current-result-text'); - accentSpan.appendChild(templateContent(info.currentResultString)); - } - } - - if (!accentSpan && slots.kindString) { - accentSpan = document.createElement('span'); - accentSpan.classList.add('wiki-search-result-kind'); - accentSpan.appendChild(templateContent(slots.kindString)); - } - - if (accentSpan) { - text.appendChild(document.createTextNode(' ')); - text.appendChild(accentSpan); - } - - link.appendChild(text); - - link.addEventListener('click', () => { - saveSidebarSearchResultsScrollOffset(); - }); - - return link; -} - -function hideSidebarSearchResults() { - const info = sidebarSearchInfo; - - cssProp(info.resultsRule, 'display', 'none'); - cssProp(info.resultsContainer, 'display', 'none'); - - while (info.results.firstChild) { - info.results.firstChild.remove(); - } - - cssProp(info.endSearchRule, 'display', 'none'); - cssProp(info.endSearchLine, 'display', 'none'); -} - -function saveSidebarSearchResultsScrollOffset() { - const info = sidebarSearchInfo; - const {session} = info; - - session.resultsScrollOffset = info.resultsContainer.scrollTop; -} - -function restoreSidebarSearchResultsScrollOffset() { - const info = sidebarSearchInfo; - const {session} = info; - - if (session.resultsScrollOffset) { - info.resultsContainer.scrollTop = session.resultsScrollOffset; - } -} - -function showSearchSidebarColumn() { - const info = sidebarSearchInfo; - const {state} = info; - - if (!info.searchSidebarColumn) { - return; - } - - if (!info.searchSidebarColumn.classList.contains('initially-hidden')) { - return; - } - - info.searchSidebarColumn.classList.remove('initially-hidden'); - - if (info.searchSidebarColumn.id === 'sidebar-left') { - info.pageContainer.classList.add('showing-sidebar-left'); - } else if (info.searchSidebarColumn.id === 'sidebar-right') { - info.pageContainer.classList.add('showing-sidebar-right'); - } - - state.sidebarColumnShownForSearch = true; -} - -function possiblyHideSearchSidebarColumn() { - const info = sidebarSearchInfo; - const {state} = info; - - if (!info.searchSidebarColumn) { - return; - } - - if (!state.sidebarColumnShownForSearch) { - return; - } - - info.searchSidebarColumn.classList.add('initially-hidden'); - - if (info.searchSidebarColumn.id === 'sidebar-left') { - info.pageContainer.classList.remove('showing-sidebar-left'); - } else if (info.searchSidebarColumn.id === 'sidebar-right') { - info.pageContainer.classList.remove('showing-sidebar-right'); - } - - state.sidebarColumnShownForSearch = null; -} - -// This should be called after results are shown, since it checks the -// elements added to understand the current search state. -function tidySidebarSearchColumn() { - const info = sidebarSearchInfo; - const {state} = info; - - // Don't *re-tidy* the sidebar if we've already tidied it to display - // some results. This flag will get cleared if the search is dismissed - // altogether (and the pre-tidy state is restored). - if (state.tidiedSidebar) { - return; - } - - const here = location.href.replace(/\/$/, ''); - const currentPageIsResult = - Array.from(info.results.querySelectorAll('a')) - .some(link => { - const there = link.href.replace(/\/$/, ''); - return here === there; - }); - - // Don't tidy the sidebar if you've navigated to some other page than - // what's in the current result list. - if (!currentPageIsResult) { - return; - } - - state.tidiedSidebar = true; - state.collapsedDetailsForTidiness = []; - - for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) { - if (box === info.searchBox) { - continue; - } - - for (const details of box.getElementsByTagName('details')) { - if (details.open) { - details.removeAttribute('open'); - state.collapsedDetailsForTidiness.push(details); - } - } - } -} - -function restoreSidebarSearchColumn() { - const {state} = sidebarSearchInfo; - - if (!state.tidiedSidebar) { - return; - } - - for (const details of state.collapsedDetailsForTidiness) { - details.setAttribute('open', ''); - } - - state.collapsedDetailsForTidiness = []; - state.tidiedSidebar = null; -} - -clientSteps.getPageReferences.push(getSidebarSearchReferences); -clientSteps.addInternalListeners.push(addSidebarSearchInternalListeners); -clientSteps.mutatePageContent.push(mutateSidebarSearchContent); -clientSteps.addPageListeners.push(addSidebarSearchListeners); -clientSteps.initializeState.push(initializeSidebarSearchState); - -// Sticky commentary sidebar ------------------------------ - -const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', { - sidebar: null, - sidebarHeading: null, - - sidebarTrackLinks: null, - sidebarTrackDirectories: null, - - sidebarTrackSections: null, - sidebarTrackSectionStartIndices: null, - - state: { - currentTrackSection: null, - currentTrackLink: null, - justChangedTrackSection: false, - }, -}); - -function getAlbumCommentarySidebarReferences() { - const info = albumCommentarySidebarInfo; - - info.sidebar = - document.getElementById('sidebar-left'); - - info.sidebarHeading = - info.sidebar.querySelector('h1'); - - info.sidebarTrackLinks = - Array.from(info.sidebar.querySelectorAll('li a')); - - info.sidebarTrackDirectories = - info.sidebarTrackLinks - .map(el => el.getAttribute('href')?.slice(1) ?? null); - - info.sidebarTrackSections = - Array.from(info.sidebar.getElementsByTagName('details')); - - info.sidebarTrackSectionStartIndices = - info.sidebarTrackSections - .map(details => details.querySelector('ol, ul')) - .reduce( - (accumulator, _list, index, array) => - (empty(accumulator) - ? [0] - : [ - ...accumulator, - (accumulator[accumulator.length - 1] + - array[index - 1].querySelectorAll('li a').length), - ]), - []); -} - -function scrollAlbumCommentarySidebar() { - const info = albumCommentarySidebarInfo; - const {state} = info; - const {currentTrackLink, currentTrackSection} = state; - - if (!currentTrackLink) { - return; - } - - const {sidebar, sidebarHeading} = info; - - const scrollTop = sidebar.scrollTop; - - const headingRect = sidebarHeading.getBoundingClientRect(); - const sidebarRect = sidebar.getBoundingClientRect(); - - const stickyPadding = headingRect.height; - const sidebarViewportHeight = sidebarRect.height - stickyPadding; - - const linkRect = currentTrackLink.getBoundingClientRect(); - const sectionRect = currentTrackSection.getBoundingClientRect(); - - const sectionTopEdge = - sectionRect.top - (sidebarRect.top - scrollTop); - - const sectionHeight = - sectionRect.height; - - const sectionScrollTop = - sectionTopEdge - stickyPadding - 10; - - const linkTopEdge = - linkRect.top - (sidebarRect.top - scrollTop); - - const linkBottomEdge = - linkRect.bottom - (sidebarRect.top - scrollTop); - - const linkScrollTop = - linkTopEdge - stickyPadding - 5; - - const linkVisibleFromTopOfSection = - linkBottomEdge - sectionTopEdge > sidebarViewportHeight; - - const linkScrollBottom = - linkScrollTop - sidebarViewportHeight + linkRect.height + 20; - - const maxScrollInViewport = - scrollTop + stickyPadding + sidebarViewportHeight; - - const minScrollInViewport = - scrollTop + stickyPadding; - - if (linkBottomEdge > maxScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (linkTopEdge < minScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (state.justChangedTrackSection) { - if (sectionHeight < sidebarViewportHeight) { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } -} - -function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { - const info = albumCommentarySidebarInfo; - const {state} = info; - - const trackIndex = - (trackDirectory - ? info.sidebarTrackDirectories - .indexOf(trackDirectory) - : -1); - - const sectionIndex = - (trackIndex >= 0 - ? info.sidebarTrackSectionStartIndices - .findIndex((start, index, array) => - (index === array.length - 1 - ? true - : trackIndex < array[index + 1])) - : -1); - - const sidebarTrackLink = - (trackIndex >= 0 - ? info.sidebarTrackLinks[trackIndex] - : null); - - const sidebarTrackSection = - (sectionIndex >= 0 - ? info.sidebarTrackSections[sectionIndex] - : null); - - state.currentTrackLink?.classList?.remove('current'); - state.currentTrackLink = sidebarTrackLink; - state.currentTrackLink?.classList?.add('current'); - - if (sidebarTrackSection !== state.currentTrackSection) { - if (sidebarTrackSection && !sidebarTrackSection.open) { - if (state.currentTrackSection) { - state.currentTrackSection.open = false; - } - - sidebarTrackSection.open = true; - } - - state.currentTrackSection?.classList?.remove('current'); - state.currentTrackSection = sidebarTrackSection; - state.currentTrackSection?.classList?.add('current'); - state.justChangedTrackSection = true; - } else { - state.justChangedTrackSection = false; - } -} - -function addAlbumCommentaryInternalListeners() { - const info = albumCommentarySidebarInfo; - - const mainContentIndex = - (stickyHeadingInfo.contentContainers ?? []) - .findIndex(({id}) => id === 'content'); - - if (mainContentIndex === -1) return; - - stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { - if (index !== mainContentIndex) return; - if (hashLinkInfo.state.scrollingAfterClick) return; - - const trackDirectory = - (newHeading - ? newHeading.id - : null); - - markDirectoryAsCurrentForAlbumCommentary(trackDirectory); - scrollAlbumCommentarySidebar(); - }); - - hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { - const hash = link.getAttribute('href').slice(1); - if (!info.sidebarTrackDirectories.includes(hash)) return; - markDirectoryAsCurrentForAlbumCommentary(hash); - }); -} - -if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { - clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); - clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); -} - -// Run setup steps ---------------------------------------- - -for (const [key, steps] of Object.entries(clientSteps)) { - for (const step of steps) { - try { - step(); - } catch (error) { - console.error(`During ${key}, failed to run ${step.name}`); - console.error(error); - } - } -} diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js new file mode 100644 index 00000000..558ef06f --- /dev/null +++ b/src/static/js/client/additional-names-box.js @@ -0,0 +1,83 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {info as hashLinkInfo} from './hash-link.js'; + +export const info = { + id: 'additionalNamesBoxInfo', + + box: null, + links: null, + mainContentContainer: null, + + state: { + visible: false, + }, +}; + +export function getPageReferences() { + info.box = + document.getElementById('additional-names-box'); + + info.links = + document.querySelectorAll('a[href="#additional-names-box"]'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +export function addInternalListeners() { + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); +} + +export function addPageListeners() { + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const {state} = info; + + domEvent.preventDefault(); + + if (!info.box || !info.mainContentContainer) return; + + const margin = + +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); + + const {top} = + (state.visible + ? info.box.getBoundingClientRect() + : info.mainContentContainer.getBoundingClientRect()); + + if (top + 20 < margin || top > 0.4 * window.innerHeight) { + if (!state.visible) { + toggleAdditionalNamesBox(); + } + + window.scrollTo({ + top: window.scrollY + top - margin, + behavior: 'smooth', + }); + } else { + toggleAdditionalNamesBox(); + } +} + +export function toggleAdditionalNamesBox() { + const {state} = info; + + state.visible = !state.visible; + info.box.style.display = + (state.visible + ? 'block' + : 'none'); +} diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js new file mode 100644 index 00000000..c5eaf81b --- /dev/null +++ b/src/static/js/client/album-commentary-sidebar.js @@ -0,0 +1,212 @@ +/* eslint-env browser */ + +import {empty} from '../../shared-util/sugar.js'; + +import {info as hashLinkInfo} from './hash-link.js'; +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'albumCommentarySidebarInfo', + + sidebar: null, + sidebarHeading: null, + + sidebarTrackLinks: null, + sidebarTrackDirectories: null, + + sidebarTrackSections: null, + sidebarTrackSectionStartIndices: null, + + state: { + currentTrackSection: null, + currentTrackLink: null, + justChangedTrackSection: false, + }, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') { + return; + } + + info.sidebar = + document.getElementById('sidebar-left'); + + info.sidebarHeading = + info.sidebar.querySelector('h1'); + + info.sidebarTrackLinks = + Array.from(info.sidebar.querySelectorAll('li a')); + + info.sidebarTrackDirectories = + info.sidebarTrackLinks + .map(el => el.getAttribute('href')?.slice(1) ?? null); + + info.sidebarTrackSections = + Array.from(info.sidebar.getElementsByTagName('details')); + + info.sidebarTrackSectionStartIndices = + info.sidebarTrackSections + .map(details => details.querySelector('ol, ul')) + .reduce( + (accumulator, _list, index, array) => + (empty(accumulator) + ? [0] + : [ + ...accumulator, + (accumulator[accumulator.length - 1] + + array[index - 1].querySelectorAll('li a').length), + ]), + []); +} + +function scrollAlbumCommentarySidebar() { + const {state} = info; + const {currentTrackLink, currentTrackSection} = state; + + if (!currentTrackLink) { + return; + } + + const {sidebar, sidebarHeading} = info; + + const scrollTop = sidebar.scrollTop; + + const headingRect = sidebarHeading.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + + const stickyPadding = headingRect.height; + const sidebarViewportHeight = sidebarRect.height - stickyPadding; + + const linkRect = currentTrackLink.getBoundingClientRect(); + const sectionRect = currentTrackSection.getBoundingClientRect(); + + const sectionTopEdge = + sectionRect.top - (sidebarRect.top - scrollTop); + + const sectionHeight = + sectionRect.height; + + const sectionScrollTop = + sectionTopEdge - stickyPadding - 10; + + const linkTopEdge = + linkRect.top - (sidebarRect.top - scrollTop); + + const linkBottomEdge = + linkRect.bottom - (sidebarRect.top - scrollTop); + + const linkScrollTop = + linkTopEdge - stickyPadding - 5; + + const linkVisibleFromTopOfSection = + linkBottomEdge - sectionTopEdge > sidebarViewportHeight; + + const linkScrollBottom = + linkScrollTop - sidebarViewportHeight + linkRect.height + 20; + + const maxScrollInViewport = + scrollTop + stickyPadding + sidebarViewportHeight; + + const minScrollInViewport = + scrollTop + stickyPadding; + + if (linkBottomEdge > maxScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (linkTopEdge < minScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (state.justChangedTrackSection) { + if (sectionHeight < sidebarViewportHeight) { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } +} + +function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { + const {state} = info; + + const trackIndex = + (trackDirectory + ? info.sidebarTrackDirectories + .indexOf(trackDirectory) + : -1); + + const sectionIndex = + (trackIndex >= 0 + ? info.sidebarTrackSectionStartIndices + .findIndex((start, index, array) => + (index === array.length - 1 + ? true + : trackIndex < array[index + 1])) + : -1); + + const sidebarTrackLink = + (trackIndex >= 0 + ? info.sidebarTrackLinks[trackIndex] + : null); + + const sidebarTrackSection = + (sectionIndex >= 0 + ? info.sidebarTrackSections[sectionIndex] + : null); + + state.currentTrackLink?.classList?.remove('current'); + state.currentTrackLink = sidebarTrackLink; + state.currentTrackLink?.classList?.add('current'); + + if (sidebarTrackSection !== state.currentTrackSection) { + if (sidebarTrackSection && !sidebarTrackSection.open) { + if (state.currentTrackSection) { + state.currentTrackSection.open = false; + } + + sidebarTrackSection.open = true; + } + + state.currentTrackSection?.classList?.remove('current'); + state.currentTrackSection = sidebarTrackSection; + state.currentTrackSection?.classList?.add('current'); + state.justChangedTrackSection = true; + } else { + state.justChangedTrackSection = false; + } +} + +export function addInternalListeners() { + if (!info.sidebar) { + return; + } + + const mainContentIndex = + (stickyHeadingInfo.contentContainers ?? []) + .findIndex(({id}) => id === 'content'); + + if (mainContentIndex === -1) return; + + stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { + if (index !== mainContentIndex) return; + if (hashLinkInfo.state.scrollingAfterClick) return; + + const trackDirectory = + (newHeading + ? newHeading.id + : null); + + markDirectoryAsCurrentForAlbumCommentary(trackDirectory); + scrollAlbumCommentarySidebar(); + }); + + hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { + const hash = link.getAttribute('href').slice(1); + if (!info.sidebarTrackDirectories.includes(hash)) return; + markDirectoryAsCurrentForAlbumCommentary(hash); + }); +} diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js new file mode 100644 index 00000000..21ddfb91 --- /dev/null +++ b/src/static/js/client/artist-external-link-tooltip.js @@ -0,0 +1,196 @@ +/* eslint-env browser */ + +import {accumulateSum, empty} from '../../shared-util/sugar.js'; + +import {info as hoverableTooltipInfo, repositionCurrentTooltip} + from './hoverable-tooltip.js'; + +// These don't need to have tooltip events specially added as +// they're implemented with "text with tooltip" components. + +export const info = { + id: 'artistExternalLinkTooltipInfo', + + tooltips: null, + tooltipRows: null, + + settings: { + // This is the maximum distance, in CSS pixels, that the mouse + // can appear to be moving per second while still considered + // "idle". A greater value means higher tolerance for small + // movements. + maximumIdleSpeed: 40, + + // Leaving the mouse idle for this amount of time, over a single + // row of the tooltip, will cause a column of supplemental info + // to display. + mouseIdleShowInfoDelay: 1000, + + // If none of these tooltips are visible for this amount of time, + // the supplemental info column is hidden. It'll never disappear + // while a tooltip is actually visible. + hideInfoAfterTooltipHiddenDelay: 2250, + }, + + state: { + // This is shared by all tooltips. + showingTooltipInfo: false, + + mouseIdleTimeout: null, + hideInfoTimeout: null, + + mouseMovementPositions: [], + mouseMovementTimestamps: [], + }, +}; + +export function getPageReferences() { + info.tooltips = + Array.from(document.getElementsByClassName('contribution-tooltip')); + + info.tooltipRows = + info.tooltips.map(tooltip => + Array.from(tooltip.getElementsByClassName('icon'))); +} + +export function addInternalListeners() { + hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => { + const {state} = info; + + if (info.tooltips.includes(tooltip)) { + clearTimeout(state.hideInfoTimeout); + state.hideInfoTimeout = null; + } + }); + + hoverableTooltipInfo.event.whenTooltipHides.push(() => { + const {settings, state} = info; + + if (state.showingTooltipInfo) { + state.hideInfoTimeout = + setTimeout(() => { + state.hideInfoTimeout = null; + hideArtistExternalLinkTooltipInfo(); + }, settings.hideInfoAfterTooltipHiddenDelay); + } else { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + }); +} + +export function addPageListeners() { + for (const tooltip of info.tooltips) { + tooltip.addEventListener('mousemove', domEvent => { + handleArtistExternalLinkTooltipMouseMoved(domEvent); + }); + + tooltip.addEventListener('mouseout', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } + + for (const tooltipRow of info.tooltipRows.flat()) { + tooltipRow.addEventListener('mouseover', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } +} + +function handleArtistExternalLinkTooltipMouseMoved(domEvent) { + const {settings, state} = info; + + if (state.showingTooltipInfo) { + return; + } + + // Clean out expired mouse movements + + const expiryTime = 1000; + + if (!empty(state.mouseMovementTimestamps)) { + const firstRecentMovementIndex = + state.mouseMovementTimestamps + .findIndex(value => Date.now() - value <= expiryTime); + + if (firstRecentMovementIndex === -1) { + state.mouseMovementTimestamps.splice(0); + state.mouseMovementPositions.splice(0); + } else if (firstRecentMovementIndex > 0) { + state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1); + state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1); + } + } + + state.mouseMovementTimestamps.push(Date.now()); + state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]); + + // We can't really compute speed without having + // at least two data points! + if (state.mouseMovementPositions.length < 2) { + return; + } + + const movementTravelDistances = + state.mouseMovementPositions.map((current, index, array) => { + if (index === 0) return 0; + + const previous = array[index - 1]; + const deltaX = current[0] - previous[0]; + const deltaY = current[1] - previous[1]; + return Math.sqrt(deltaX ** 2 + deltaY ** 2); + }); + + const totalTravelDistance = + accumulateSum(movementTravelDistances); + + // In seconds rather than milliseconds. + const timeSinceFirstMovement = + (Date.now() - state.mouseMovementTimestamps[0]) / 1000; + + const averageSpeed = + Math.floor(totalTravelDistance / timeSinceFirstMovement); + + if (averageSpeed > settings.maximumIdleSpeed) { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + + if (state.mouseIdleTimeout) { + return; + } + + state.mouseIdleTimeout = + setTimeout(() => { + state.mouseIdleTimeout = null; + showArtistExternalLinkTooltipInfo(); + }, settings.mouseIdleShowInfoDelay); +} + +function showArtistExternalLinkTooltipInfo() { + const {state} = info; + + state.showingTooltipInfo = true; + + for (const tooltip of info.tooltips) { + tooltip.classList.add('show-info'); + } + + repositionCurrentTooltip(); +} + +function hideArtistExternalLinkTooltipInfo() { + const {state} = info; + + state.showingTooltipInfo = false; + + for (const tooltip of info.tooltips) { + tooltip.classList.remove('show-info'); + } +} diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js new file mode 100644 index 00000000..6e7b15b5 --- /dev/null +++ b/src/static/js/client/css-compatibility-assistant.js @@ -0,0 +1,22 @@ +/* eslint-env browser */ + +export const info = { + id: 'cssCompatibilityAssistantInfo', + + coverArtContainer: null, + coverArtImageDetails: null, +}; + +export function getPageReferences() { + info.coverArtContainer = + document.getElementById('cover-art-container'); + + info.coverArtImageDetails = + info.coverArtContainer?.querySelector('.image-details'); +} + +export function mutatePageContent() { + if (info.coverArtImageDetails) { + info.coverArtContainer.classList.add('has-image-details'); + } +} diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js new file mode 100644 index 00000000..46d1cd5b --- /dev/null +++ b/src/static/js/client/datetimestamp-tooltip.js @@ -0,0 +1,36 @@ +/* eslint-env browser */ + +// TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip? + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {registerTooltipElement, registerTooltipHoverableElement} + from './hoverable-tooltip.js'; + +export const info = { + id: 'datetimestampTooltipInfo', + + hoverables: null, + tooltips: null, +}; + +export function getPageReferences() { + const spans = + Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip')); + + info.hoverables = + spans.map(span => span.querySelector('time')); + + info.tooltips = + spans.map(span => span.querySelector('span.datetimestamp-tooltip')); +} + +export function addPageListeners() { + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js new file mode 100644 index 00000000..27035e29 --- /dev/null +++ b/src/static/js/client/hash-link.js @@ -0,0 +1,146 @@ +/* eslint-env browser */ + +import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; + +import {dispatchInternalEvent} from '../client-util.js'; + +export const info = { + id: 'hashLinkInfo', + + links: null, + hrefs: null, + targets: null, + + state: { + highlightedTarget: null, + scrollingAfterClick: false, + concludeScrollingStateInterval: null, + }, + + event: { + beforeHashLinkScrolls: [], + whenHashLinkClicked: [], + }, +}; + +export function getPageReferences() { + info.links = + Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); + + info.hrefs = + info.links + .map(link => link.getAttribute('href')); + + info.targets = + info.hrefs + .map(href => document.getElementById(href.slice(1))); + + filterMultipleArrays( + info.links, + info.hrefs, + info.targets, + (_link, _href, target) => target); +} + +function processScrollingAfterHashLinkClicked() { + const {state} = info; + + if (state.concludeScrollingStateInterval) return; + + let lastScroll = window.scrollY; + state.scrollingAfterClick = true; + state.concludeScrollingStateInterval = setInterval(() => { + if (Math.abs(window.scrollY - lastScroll) < 10) { + clearInterval(state.concludeScrollingStateInterval); + state.scrollingAfterClick = false; + state.concludeScrollingStateInterval = null; + } else { + lastScroll = window.scrollY; + } + }, 200); +} + +export function addPageListeners() { + // Instead of defining a scroll offset (to account for the sticky heading) + // in JavaScript, we interface with the CSS property 'scroll-margin-top'. + // This lets the scroll offset be consolidated where it makes sense, and + // sets an appropriate offset when (re)loading a page with hash for free! + + const {state, event} = info; + + for (const {hashLink, href, target} of stitchArrays({ + hashLink: info.links, + href: info.hrefs, + target: info.targets, + })) { + hashLink.addEventListener('click', evt => { + if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { + return; + } + + // Don't do anything if the target element isn't actually visible! + if (target.offsetParent === null) { + return; + } + + // Allow event handlers to prevent scrolling. + const listenerResults = + dispatchInternalEvent(event, 'beforeHashLinkScrolls', { + link: hashLink, + target, + }); + + if (listenerResults.includes(false)) { + return; + } + + // Hide skipper box right away, so the layout is updated on time for the + // math operations coming up next. + const skipper = document.getElementById('skippers'); + skipper.style.display = 'none'; + setTimeout(() => skipper.style.display = ''); + + const box = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + + const scrollY = + window.scrollY + + box.top + - style['scroll-margin-top'].replace('px', ''); + + evt.preventDefault(); + history.pushState({}, '', href); + window.scrollTo({top: scrollY, behavior: 'smooth'}); + target.focus({preventScroll: true}); + + const maxScroll = + document.body.scrollHeight + - window.innerHeight; + + if (scrollY > maxScroll && target.classList.contains('content-heading')) { + if (state.highlightedTarget) { + state.highlightedTarget.classList.remove('highlight-hash-link'); + } + + target.classList.add('highlight-hash-link'); + state.highlightedTarget = target; + } + + processScrollingAfterHashLinkClicked(); + + dispatchInternalEvent(event, 'whenHashLinkClicked', { + link: hashLink, + target, + }); + }); + } + + for (const target of info.targets) { + target.addEventListener('animationend', evt => { + if (evt.animationName !== 'highlight-hash-link') return; + target.classList.remove('highlight-hash-link'); + if (target !== state.highlightedTarget) return; + state.highlightedTarget = null; + }); + } +} diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js new file mode 100644 index 00000000..484f2ab0 --- /dev/null +++ b/src/static/js/client/hoverable-tooltip.js @@ -0,0 +1,1083 @@ +/* eslint-env browser */ + +import {empty, filterMultipleArrays} from '../../shared-util/sugar.js'; + +import {WikiRect} from '../rectangles.js'; + +import { + cssProp, + dispatchInternalEvent, + getVisuallyContainingElement, + pointIsOverAnyOf, +} from '../client-util.js'; + +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'hoverableTooltipInfo', + + settings: { + // Hovering has two speed settings. The normal setting is used by default, + // and once a tooltip is displayed as a result of hover, the entire tooltip + // system will enter a "fast hover mode" - hovering will activate tooltips + // sooner. "Fast hover mode" is disabled after a sustained duration of not + // hovering over any hoverables; it's meant only to accelerate switching + // tooltips while still deciding, or getting a quick overview across more + // than one tooltip. + normalHoverInfoDelay: 400, + fastHoveringInfoDelay: 150, + endFastHoveringDelay: 500, + + // Focusing has a single speed setting, which is how long it will take to + // enter a functional "focus mode" (though it's not actually implemented + // in terms of this state). As soon as "focus mode" is entered, the tooltip + // for the current hoverable is displayed, and focusing another hoverable + // will cause the current tooltip to be swapped for that one immediately. + // "Focus mode" ends as soon as anything apart from a tooltip or hoverable + // is focused, and it will be necessary to wait on this delay again. + focusInfoDelay: 750, + + hideTooltipDelay: 500, + + // If a tooltip that's transitioning to hidden is hovered during the grace + // period (or the corresponding hoverable is hovered at any point in the + // transition), it'll cancel out of this animation immediately. + transitionHiddenDuration: 300, + inertGracePeriod: 100, + }, + + state: { + // These maps store a record for each registered element and related state + // and registration info, if applicable. + registeredTooltips: new Map(), + registeredHoverables: new Map(), + + // These are common across all tooltips, rather than stored individually, + // based on the principles that 1) only a single tooltip can be displayed + // at once, and 2) likewise, only a single hoverable can be hovered, + // focused, or otherwise active at once. + hoverTimeout: null, + focusTimeout: null, + touchTimeout: null, + hideTimeout: null, + transitionHiddenTimeout: null, + inertGracePeriodTimeout: null, + currentlyShownTooltip: null, + currentlyActiveHoverable: null, + currentlyTransitioningHiddenTooltip: null, + previouslyActiveHoverable: null, + tooltipWasJustHidden: false, + hoverableWasRecentlyTouched: false, + + // Fast hovering is a global mode which is activated as soon as any tooltip + // is displayed and turns off after a delay of no hoverables being hovered. + // Note that fast hovering may be turned off while hovering a tooltip, but + // it will never be turned off while idling over a hoverable. + fastHovering: false, + endFastHoveringTimeout: false, + + // These track the identifiers of current touches and a record of current + // identifiers that are "banished" by scrolling - that is, touches which + // existed while the page scrolled and were probably responsible for that + // scrolling. This is a bit loose (we can't actually tell which touches + // caused the page to scroll) but it's intended to keep scrolling the page + // from causing the current tooltip to be hidden. + currentTouchIdentifiers: new Set(), + touchIdentifiersBanishedByScrolling: new Set(), + + // This is a two-item array that tracks the direction we've already + // dynamically placed the current tooltip. If we *reposition* the tooltip + // (because its dimensions changed), we'll try to follow this anchor first. + dynamicTooltipAnchorDirection: null, + }, + + event: { + whenTooltipShows: [], + whenTooltipHides: [], + }, +}; + +// Adds DOM event listeners, so must be called during addPageListeners step. +export function registerTooltipElement(tooltip) { + const {state} = info; + + if (!tooltip) + throw new Error(`Expected tooltip`); + + if (state.registeredTooltips.has(tooltip)) + throw new Error(`This tooltip is already registered`); + + // No state or registration info here. + state.registeredTooltips.set(tooltip, {}); + + tooltip.addEventListener('mouseenter', () => { + handleTooltipMouseEntered(tooltip); + }); + + tooltip.addEventListener('mouseleave', () => { + handleTooltipMouseLeft(tooltip); + }); + + tooltip.addEventListener('focusin', event => { + handleTooltipReceivedFocus(tooltip, event.relatedTarget); + }); + + tooltip.addEventListener('focusout', event => { + // This event gets activated for tabbing *between* links inside the + // tooltip, which is no good and certainly doesn't represent the focus + // leaving the tooltip. + if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; + + handleTooltipLostFocus(tooltip, event.relatedTarget); + }); +} + +// Adds DOM event listeners, so must be called during addPageListeners step. +export function registerTooltipHoverableElement(hoverable, tooltip) { + const {state} = info; + + if (!hoverable || !tooltip) + if (hoverable) + throw new Error(`Expected hoverable and tooltip, got only hoverable`); + else + throw new Error(`Expected hoverable and tooltip, got neither`); + + if (!state.registeredTooltips.has(tooltip)) + throw new Error(`Register tooltip before registering hoverable`); + + if (state.registeredHoverables.has(hoverable)) + throw new Error(`This hoverable is already registered`); + + state.registeredHoverables.set(hoverable, {tooltip}); + + hoverable.addEventListener('mouseenter', () => { + handleTooltipHoverableMouseEntered(hoverable); + }); + + hoverable.addEventListener('mouseleave', () => { + handleTooltipHoverableMouseLeft(hoverable); + }); + + hoverable.addEventListener('focusin', event => { + handleTooltipHoverableReceivedFocus(hoverable, event); + }); + + hoverable.addEventListener('focusout', event => { + handleTooltipHoverableLostFocus(hoverable, event); + }); + + hoverable.addEventListener('touchend', event => { + handleTooltipHoverableTouchEnded(hoverable, event); + }); + + hoverable.addEventListener('click', event => { + handleTooltipHoverableClicked(hoverable, event); + }); +} + +function handleTooltipMouseEntered(tooltip) { + const {state} = info; + + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(true); + return; + } + + if (state.currentlyShownTooltip !== tooltip) return; + + // Don't time out the current tooltip while hovering it. + + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipMouseLeft(tooltip) { + const {settings, state} = info; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Start timing out the current tooltip when it's left. This could be + // canceled by mousing over a hoverable, or back over the tooltip again. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function handleTooltipReceivedFocus(_tooltip) { + const {state} = info; + + // Cancel the tooltip-hiding timeout if it exists. The tooltip will never + // be hidden while it contains the focus anyway, but this ensures the timeout + // will be suitably reset when the tooltip loses focus. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipLostFocus(_tooltip) { + // Hide the current tooltip right away when it loses focus. Specify intent + // to replace - while we don't strictly know if another tooltip is going to + // immediately replace it, the mode of navigating with tab focus (once one + // tooltip has been activated) is a "switch focus immediately" kind of + // interaction in its nature. + hideCurrentlyShownTooltip(true); +} + +function handleTooltipHoverableMouseEntered(hoverable) { + const {settings, state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // If this tooltip was transitioning to hidden, hovering should cancel that + // animation and show it immediately. + + if (tooltip === state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(true); + return; + } + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. + + const hoverTimeoutDelay = + (state.fastHovering + ? settings.fastHoveringInfoDelay + : settings.normalHoverInfoDelay); + + state.hoverTimeout = + setTimeout(() => { + state.hoverTimeout = null; + state.fastHovering = true; + showTooltipFromHoverable(hoverable); + }, hoverTimeoutDelay); + + // Don't stop fast hovering while over any hoverable. + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Don't time out the current tooltip while over any hoverable. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipHoverableMouseLeft(_hoverable) { + const {settings, state} = info; + + // Don't show a tooltip when not over a hoverable! + if (state.hoverTimeout) { + clearTimeout(state.hoverTimeout); + state.hoverTimeout = null; + } + + // Start timing out fast hovering (if active) when not over a hoverable. + // This will only be canceled by mousing over another hoverable. + if (state.fastHovering && !state.endFastHoveringTimeout) { + state.endFastHoveringTimeout = + setTimeout(() => { + state.endFastHoveringTimeout = null; + state.fastHovering = false; + }, settings.endFastHoveringDelay); + } + + // Start timing out the current tooltip when mousing not over a hoverable. + // This could be canceled by mousing over another hoverable, or over the + // currently shown tooltip. + if (state.currentlyShownTooltip && !state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function handleTooltipHoverableReceivedFocus(hoverable) { + const {settings, state} = info; + + // By default, display the corresponding tooltip after a delay. + + state.focusTimeout = + setTimeout(() => { + state.focusTimeout = null; + showTooltipFromHoverable(hoverable); + }, settings.focusInfoDelay); + + // If a tooltip was just hidden - which is almost certainly a result of the + // focus changing - then display this tooltip immediately, canceling the + // above timeout. + + if (state.tooltipWasJustHidden) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + + showTooltipFromHoverable(hoverable); + } +} + +function handleTooltipHoverableLostFocus(hoverable, domEvent) { + const {state} = info; + + // Don't show a tooltip from focusing a hoverable if it isn't focused + // anymore! If another hoverable is receiving focus, that will be evaluated + // and set its own focus timeout after we clear the previous one here. + if (state.focusTimeout) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + } + + // Unless focus is entering the tooltip itself, hide the tooltip immediately. + // This will set the tooltipWasJustHidden flag, which is detected by a newly + // focused hoverable, if applicable. Always specify intent to replace when + // navigating via tab focus. (Check `handleTooltipLostFocus` for details.) + if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { + hideCurrentlyShownTooltip(true); + } +} + +function handleTooltipHoverableTouchEnded(hoverable, domEvent) { + const {state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't proceed if this hoverable's tooltip is already visible - in that + // case touching the hoverable again should behave just like a normal click. + if (state.currentlyShownTooltip === tooltip) { + // If the hoverable was *recently* touched - meaning that this is a second + // touchend in short succession - then just letting the click come through + // naturally would (depending on timing) not actually navigate anywhere, + // because we've deliberately banished the *first* touch from navigation. + // We do want the second touch to navigate, so clear that recently-touched + // state, allowing this touch's click to behave as normal. + if (state.hoverableWasRecentlyTouched) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + state.hoverableWasRecentlyTouched = false; + } + + // Otherwise, this is just a second touch after enough time has passed + // that the one which showed the tooltip is no longer "recent", and we're + // not in any special state. The link will navigate to its page just like + // normal. + return; + } + + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); + + if (empty(touches)) return; + + // Don't proceed if none of the (just-ended) touches ended over the + // hoverable. + + const pointIsOverThisHoverable = pointIsOverAnyOf([hoverable]); + + const anyTouchEndedOverHoverable = + touches.some(({clientX, clientY}) => + pointIsOverThisHoverable(clientX, clientY)); + + if (!anyTouchEndedOverHoverable) { + return; + } + + if (state.touchTimeout) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + } + + // Show the tooltip right away. + showTooltipFromHoverable(hoverable); + + // Set a state, for a brief but not instantaneous period, indicating that a + // hoverable was recently touched. The touchend event may precede the click + // event by some time, and we don't want to navigate away from the page as + // a result of the click event which this touch precipitated. + state.hoverableWasRecentlyTouched = true; + state.touchTimeout = + setTimeout(() => { + state.touchTimeout = null; + state.hoverableWasRecentlyTouched = false; + }, 1200); +} + +function handleTooltipHoverableClicked(hoverable) { + const {state} = info; + + // Don't navigate away from the page if the this hoverable was recently + // touched (and had its tooltip activated). That flag won't be set if its + // tooltip was already open before the touch. + if ( + state.currentlyActiveHoverable === hoverable && + state.hoverableWasRecentlyTouched + ) { + event.preventDefault(); + } +} + +export function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { + const {state} = info; + + const { + currentlyShownTooltip: tooltip, + currentlyActiveHoverable: hoverable, + } = state; + + // If there's no tooltip, it can't possibly have focus. + if (!tooltip) return false; + + // If the tooltip literally contains (or is) the focused element, then that's + // the principle condition we're looking for. + if (tooltip.contains(focusElement)) return true; + + // If the hoverable *which opened the tooltip* is focused, then that also + // represents the tooltip being focused (in its currently shown state). + if (hoverable.contains(focusElement)) return true; + + return false; +} + +export function beginTransitioningTooltipHidden(tooltip) { + const {settings, state} = info; + + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + } + + cssProp(tooltip, { + 'display': 'block', + 'opacity': '0', + + 'transition-property': 'opacity', + 'transition-timing-function': + `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`, + 'transition-duration': + `${settings.transitionHiddenDuration / 1000}s`, + }); + + state.currentlyTransitioningHiddenTooltip = tooltip; + state.transitionHiddenTimeout = + setTimeout(() => { + endTransitioningTooltipHidden(); + }, settings.transitionHiddenDuration); +} + +export function cancelTransitioningTooltipHidden(andShow = false) { + const {state} = info; + + endTransitioningTooltipHidden(); + + if (andShow) { + showTooltipFromHoverable(state.previouslyActiveHoverable); + } +} + +export function endTransitioningTooltipHidden() { + const {state} = info; + const {currentlyTransitioningHiddenTooltip: tooltip} = state; + + if (!tooltip) return; + + cssProp(tooltip, { + 'display': null, + 'opacity': null, + 'transition-property': null, + 'transition-timing-function': null, + 'transition-duration': null, + }); + + state.currentlyTransitioningHiddenTooltip = null; + + if (state.inertGracePeriodTimeout) { + clearTimeout(state.inertGracePeriodTimeout); + state.inertGracePeriodTimeout = null; + } + + if (state.transitionHiddenTimeout) { + clearTimeout(state.transitionHiddenTimeout); + state.transitionHiddenTimeout = null; + } +} + +export function hideCurrentlyShownTooltip(intendingToReplace = false) { + const {settings, state, event} = info; + const {currentlyShownTooltip: tooltip} = state; + + // If there was no tooltip to begin with, we're functionally in the desired + // state already, so return true. + if (!tooltip) return true; + + // Never hide the tooltip if it's focused. + if (currentlyShownTooltipHasFocus()) return false; + + state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); + + // If there's no intent to replace this tooltip, it's the last one currently + // apparent in the interaction, and should be hidden with a transition. + if (intendingToReplace) { + cssProp(tooltip, 'display', 'none'); + } else { + beginTransitioningTooltipHidden(state.currentlyShownTooltip); + } + + // Wait just a moment before making the tooltip inert. You might react + // (to the ghosting, or just to time passing) and realize you wanted + // to look at the tooltip after all - this delay gives a little buffer + // to second guess letting it disappear. + state.inertGracePeriodTimeout = + setTimeout(() => { + tooltip.inert = true; + }, settings.inertGracePeriod); + + state.previouslyActiveHoverable = state.currentlyActiveHoverable; + + state.currentlyShownTooltip = null; + state.currentlyActiveHoverable = null; + + state.dynamicTooltipAnchorDirection = null; + + // Set this for one tick of the event cycle. + state.tooltipWasJustHidden = true; + setTimeout(() => { + state.tooltipWasJustHidden = false; + }); + + dispatchInternalEvent(event, 'whenTooltipHides', { + tooltip, + }); + + return true; +} + +export function showTooltipFromHoverable(hoverable) { + const {state, event} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + if (!hideCurrentlyShownTooltip(true)) return false; + + // Cancel out another tooltip that's transitioning hidden, if that's going + // on - it's a distraction that this tooltip is now replacing. + cancelTransitioningTooltipHidden(); + + hoverable.classList.add('has-visible-tooltip'); + + positionTooltipFromHoverableWithBrains(hoverable); + + cssProp(tooltip, 'display', 'block'); + tooltip.inert = false; + + state.currentlyShownTooltip = tooltip; + state.currentlyActiveHoverable = hoverable; + + state.tooltipWasJustHidden = false; + + dispatchInternalEvent(event, 'whenTooltipShows', { + tooltip, + }); + + return true; +} + +export function peekTooltipClientRect(tooltip) { + const oldDisplayStyle = cssProp(tooltip, 'display'); + cssProp(tooltip, 'display', 'block'); + + // Tooltips have a bit of padding that makes the interactive + // area wider, so that you're less likely to accidentally let + // the tooltip disappear (by hovering outside it). But this + // isn't visual at all, so for placement we only care about + // the content element. + const content = + tooltip.querySelector('.tooltip-content'); + + try { + return WikiRect.fromElement(content); + } finally { + cssProp(tooltip, 'display', oldDisplayStyle); + } +} + +export function repositionCurrentTooltip() { + const {state} = info; + const {currentlyActiveHoverable} = state; + + if (!currentlyActiveHoverable) { + throw new Error(`No hoverable active to reposition tooltip from`); + } + + positionTooltipFromHoverableWithBrains(currentlyActiveHoverable); +} + +export function positionTooltipFromHoverableWithBrains(hoverable) { + const {state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + const anchorDirection = state.dynamicTooltipAnchorDirection; + + // Reset before doing anything else. We're going to adapt to + // its natural placement, adjusted by CSS, which otherwise + // could be obscured by a placement we've previously provided. + resetDynamicTooltipPositioning(tooltip); + + const opportunities = + getTooltipFromHoverablePlacementOpportunityAreas(hoverable); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // If the tooltip is already in the baseline containing area, + // prefer to keep it positioned naturally, adjusted by CSS + // instead of JavaScript. + + const {numBaselineRects, idealBaseline: baselineRect} = opportunities; + + if (baselineRect.contains(tooltipRect)) { + return; + } + + const tryDirection = (dir1, dir2, i) => { + selectedRect = opportunities[dir1][dir2][i]; + return !!selectedRect; + }; + + let selectedRect = null; + selectRect: { + if (anchorDirection) { + for (let i = 0; i < numBaselineRects; i++) { + if (tryDirection(...anchorDirection, i)) { + break selectRect; + } + } + } + + for (let i = 0; i < numBaselineRects; i++) { + for (const [dir1, dir2] of [ + ['right', 'down'], + ['left', 'down'], + ['right', 'up'], + ['left', 'up'], + ['down', 'right'], + ['down', 'left'], + ['up', 'right'], + ['up', 'left'], + ]) { + if (tryDirection(dir1, dir2, i)) { + state.dynamicTooltipAnchorDirection = [dir1, dir2]; + break selectRect; + } + } + } + + selectedRect = baselineRect; + } + + positionTooltip(tooltip, selectedRect.x, selectedRect.y); +} + +export function positionTooltip(tooltip, x, y) { + // Imagine what it'd be like if the tooltip were positioned + // with zero left/top offset, and calculate its actual offsets + // based on that. + + cssProp(tooltip, { + left: `0`, + top: `0`, + }); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + cssProp(tooltip, { + left: `${x - tooltipRect.x}px`, + top: `${y - tooltipRect.y}px`, + }); +} + +export function resetDynamicTooltipPositioning(tooltip) { + cssProp(tooltip, { + left: null, + top: null, + }); +} + +export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { + const {state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + const baselineRects = + getTooltipBaselineOpportunityAreas(tooltip); + + const hoverableRect = + WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // Get placements relative to the hoverable. Make these available by key, + // allowing the caller to choose by preferred orientation. Each value is + // an array which corresponds to the baseline areas - placement closer to + // front of the array indicates stronger preference. Since not all relative + // placements cooperate with all baseline areas, any of these arrays may + // include (or be entirely made of) null. + + const keepIfFits = (rect) => + (rect?.fits(tooltipRect) + ? rect + : null); + + const prepareRegionRects = (relationalRect, direct) => + baselineRects + .map(rect => rect.intersectionWith(relationalRect)) + .map(direct) + .map(keepIfFits); + + const regionRects = { + left: + prepareRegionRects( + WikiRect.leftOf(hoverableRect), + rect => WikiRect.fromRect({ + x: rect.right, + y: rect.y, + width: -rect.width, + height: rect.height, + })), + + right: + prepareRegionRects( + WikiRect.rightOf(hoverableRect), + rect => rect), + + top: + prepareRegionRects( + WikiRect.above(hoverableRect), + rect => WikiRect.fromRect({ + x: rect.x, + y: rect.bottom, + width: rect.width, + height: -rect.height, + })), + + bottom: + prepareRegionRects( + WikiRect.beneath(hoverableRect), + rect => rect), + }; + + const neededVerticalOverlap = 30; + const neededHorizontalOverlap = 30; + + const upTopDown = + WikiRect.beneath( + hoverableRect.top + neededVerticalOverlap - tooltipRect.height); + + const downBottomUp = + WikiRect.above( + hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); + + // Please don't ask us to make this but horizontal? + const prepareVerticalOrientationRects = (regionRects) => { + const orientations = {}; + + const orientHorizontally = (rect, i) => { + if (!rect) return null; + + const regionRect = regionRects[i]; + if (regionRect.width > 0) { + return rect; + } else { + return WikiRect.fromRect({ + x: regionRect.right - tooltipRect.width, + y: rect.y, + width: rect.width, + height: rect.height, + }); + } + }; + + orientations.up = + regionRects + .map(rect => rect?.intersectionWith(upTopDown)) + .map(orientHorizontally) + .map(keepIfFits); + + orientations.down = + regionRects + .map(rect => rect?.intersectionWith(downBottomUp)) + .map(rect => + (rect + ? rect.intersectionWith(WikiRect.fromRect({ + x: rect.x, + y: rect.bottom - tooltipRect.height, + width: rect.width, + height: tooltipRect.height, + })) + : null)) + .map(orientHorizontally) + .map(keepIfFits); + + const centerRect = + WikiRect.fromRect({ + x: -Infinity, width: Infinity, + y: hoverableRect.top + + hoverableRect.height / 2 + - tooltipRect.height / 2, + height: tooltipRect.height, + }); + + orientations.center = + regionRects + .map(rect => rect?.intersectionWith(centerRect)) + .map(orientHorizontally) + .map(keepIfFits); + + return orientations; + }; + + const rightRightLeft = + WikiRect.leftOf( + hoverableRect.left - neededHorizontalOverlap + tooltipRect.width); + + const leftLeftRight = + WikiRect.rightOf( + hoverableRect.left + neededHorizontalOverlap - tooltipRect.width); + + // Oops. + const prepareHorizontalOrientationRects = (regionRects) => { + const orientations = {}; + + const orientVertically = (rect, i) => { + if (!rect) return null; + + const regionRect = regionRects[i]; + + if (regionRect.height > 0) { + return rect; + } else { + return WikiRect.fromRect({ + x: rect.x, + y: regionRect.bottom - tooltipRect.height, + width: rect.width, + height: rect.height, + }); + } + }; + + orientations.left = + regionRects + .map(rect => rect?.intersectionWith(leftLeftRight)) + .map(orientVertically) + .map(keepIfFits); + + orientations.right = + regionRects + .map(rect => rect?.intersectionWith(rightRightLeft)) + .map(rect => + (rect + ? rect.intersectionWith(WikiRect.fromRect({ + x: rect.right - tooltipRect.width, + y: rect.y, + width: rect.width, + height: tooltipRect.height, + })) + : null)) + .map(orientVertically) + .map(keepIfFits); + + // No analogous center because we don't actually use + // center alignment... + + return orientations; + }; + + const orientationRects = { + left: prepareVerticalOrientationRects(regionRects.left), + right: prepareVerticalOrientationRects(regionRects.right), + down: prepareHorizontalOrientationRects(regionRects.bottom), + up: prepareHorizontalOrientationRects(regionRects.top), + }; + + return { + numBaselineRects: baselineRects.length, + idealBaseline: baselineRects[0], + ...orientationRects, + }; +} + +export function getTooltipBaselineOpportunityAreas(tooltip) { + // Returns multiple basic areas in order of preference, with front of the + // array representing greater preference. + + const {stickyContainers} = stickyHeadingInfo; + const results = []; + + const windowRect = + WikiRect.fromWindow().toInset(10); + + const workingRect = + WikiRect.fromRect(windowRect); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // As a baseline, always treat the window rect as fitting the tooltip. + results.unshift(WikiRect.fromRect(workingRect)); + + const containingParent = + getVisuallyContainingElement(tooltip); + + if (containingParent) { + const containingRect = + WikiRect.fromElement(containingParent); + + // Only respect a portion of the container's padding, giving + // the tooltip the impression of a "raised" element. + const padding = side => + 0.5 * + parseFloat(cssProp(containingParent, 'padding-' + side)); + + const insetContainingRect = + containingRect.toInset({ + left: padding('left'), + right: padding('right'), + top: padding('top'), + bottom: padding('bottom'), + }); + + workingRect.chopExtendingOutside(insetContainingRect); + + if (!workingRect.fits(tooltipRect)) { + return results; + } + + results.unshift(WikiRect.fromRect(workingRect)); + } + + // This currently assumes a maximum of one sticky container + // per visually containing element. + + const stickyContainer = + stickyContainers + .find(el => el.parentElement === containingParent); + + if (stickyContainer) { + const stickyRect = + stickyContainer.getBoundingClientRect() + + // Add some padding so the tooltip doesn't line up exactly + // with the edge of the sticky container. + const beneathStickyContainer = + WikiRect.beneath(stickyRect, 10); + + workingRect.chopExtendingOutside(beneathStickyContainer); + + if (!workingRect.fits(tooltipRect)) { + return results; + } + + results.unshift(WikiRect.fromRect(workingRect)); + } + + return results; +} + +export function addPageListeners() { + const {state} = info; + + const getTouchIdentifiers = domEvent => + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier) + .filter(identifier => typeof identifier !== 'undefined'); + + document.body.addEventListener('touchstart', domEvent => { + for (const identifier of getTouchIdentifiers(domEvent)) { + state.currentTouchIdentifiers.add(identifier); + } + }); + + window.addEventListener('scroll', () => { + for (const identifier of state.currentTouchIdentifiers) { + state.touchIdentifiersBanishedByScrolling.add(identifier); + } + }); + + document.body.addEventListener('touchend', domEvent => { + setTimeout(() => { + for (const identifier of getTouchIdentifiers(domEvent)) { + state.currentTouchIdentifiers.delete(identifier); + state.touchIdentifiersBanishedByScrolling.delete(identifier); + } + }); + }); + + const getHoverablesAndTooltips = () => [ + ...Array.from(state.registeredHoverables.keys()), + ...Array.from(state.registeredTooltips.keys()), + ]; + + document.body.addEventListener('touchend', domEvent => { + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); + + if (empty(touches)) return; + + const pointIsOverHoverableOrTooltip = + pointIsOverAnyOf(getHoverablesAndTooltips()); + + const anyTouchOverAnyHoverableOrTooltip = + touches.some(({clientX, clientY}) => + pointIsOverHoverableOrTooltip(clientX, clientY)); + + if (!anyTouchOverAnyHoverableOrTooltip) { + hideCurrentlyShownTooltip(); + } + }); + + document.body.addEventListener('click', domEvent => { + const {clientX, clientY} = domEvent; + + const pointIsOverHoverableOrTooltip = + pointIsOverAnyOf(getHoverablesAndTooltips()); + + if (!pointIsOverHoverableOrTooltip(clientX, clientY)) { + // Hide with "intent to replace" - we aren't actually going to replace + // the tooltip with a new one, but this intent indicates that it should + // be hidden right away, instead of showing. What we're really replacing, + // or rather removing, is the state of interacting with tooltips at all. + hideCurrentlyShownTooltip(true); + + // Part of that state is fast hovering, which should be canceled out. + state.fastHovering = false; + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Also cancel out of transitioning a tooltip hidden - this isn't caught + // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip + // doesn't count as "shown" anymore. + cancelTransitioningTooltipHidden(); + } + }); +} diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js new file mode 100644 index 00000000..8870a4d1 --- /dev/null +++ b/src/static/js/client/index.js @@ -0,0 +1,226 @@ +/* eslint-env browser */ + +import '../group-contributions-table.js'; +import '../image-overlay.js'; + +import * as additionalNamesBoxModule from './additional-names-box.js'; +import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; +import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js'; +import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js'; +import * as datetimestampTooltipModule from './datetimestamp-tooltip.js'; +import * as hashLinkModule from './hash-link.js'; +import * as hoverableTooltipModule from './hoverable-tooltip.js'; +import * as liveMousePositionModule from './live-mouse-position.js'; +import * as quickDescriptionModule from './quick-description.js'; +import * as scriptedLinkModule from './scripted-link.js'; +import * as sidebarSearchModule from './sidebar-search.js'; +import * as stickyHeadingModule from './sticky-heading.js'; +import * as summaryNestedLinkModule from './summary-nested-link.js'; +import * as textWithTooltipModule from './text-with-tooltip.js'; +import * as wikiSearchModule from './wiki-search.js'; + +export const modules = [ + additionalNamesBoxModule, + albumCommentarySidebarModule, + artistExternalLinkTooltipModule, + cssCompatibilityAssistantModule, + datetimestampTooltipModule, + hashLinkModule, + hoverableTooltipModule, + liveMousePositionModule, + quickDescriptionModule, + scriptedLinkModule, + sidebarSearchModule, + stickyHeadingModule, + summaryNestedLinkModule, + textWithTooltipModule, + wikiSearchModule, +]; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +for (const module of modules) { + const {info} = module; + + if (!info) { + throw new Error(`Module missing info`); + } + + const {id: infoKey} = info; + + if (!infoKey) { + throw new Error(`Module info missing id: ` + JSON.stringify(info)); + } + + clientInfo[infoKey] = info; + + for (const obj of [ + info, + info.state, + info.settings, + info.event, + ]) { + if (!obj) continue; + + if (obj !== info) { + obj[Symbol.for('hsmusic.clientInfo')] = info; + } + + Object.preventExtensions(obj); + } + + if (info.session) { + const sessionSpecs = info.session; + + info.session = {}; + + for (const [key, spec] of Object.entries(sessionSpecs)) { + const hasSpec = + typeof spec === 'object' && spec !== null; + + const defaultValue = + (hasSpec + ? spec.default ?? null + : spec); + + let formatRead = value => value; + let formatWrite = value => value; + if (hasSpec && spec.type) { + switch (spec.type) { + case 'number': + formatRead = parseFloat; + formatWrite = String; + break; + + case 'boolean': + formatRead = Boolean; + formatWrite = String; + break; + + case 'string': + formatRead = String; + formatWrite = String; + break; + + case 'json': + formatRead = JSON.parse; + formatWrite = JSON.stringify; + break; + + default: + throw new Error(`Unknown type for session storage spec "${spec.type}"`); + } + } + + let getMaxLength = + (!hasSpec + ? () => Infinity + : typeof spec.maxLength === 'function' + ? (info.settings + ? () => spec.maxLength(info.settings) + : () => spec.maxLength()) + : () => spec.maxLength); + + const storageKey = `hsmusic.${infoKey}.${key}`; + + let fallbackValue = defaultValue; + + Object.defineProperty(info.session, key, { + get: () => { + let value; + try { + value = sessionStorage.getItem(storageKey) ?? defaultValue; + } catch (error) { + if (error instanceof DOMException) { + value = fallbackValue; + } else { + throw error; + } + } + + if (value === null) { + return null; + } + + return formatRead(value); + }, + + set: (value) => { + if (value !== null && value !== '') { + value = formatWrite(value); + } + + if (value === null) { + value = ''; + } + + const maxLength = getMaxLength(); + if (value.length > maxLength) { + console.warn( + `Requested to set session storage ${storageKey} ` + + `beyond maximum length ${maxLength}, ` + + `ignoring this value.`); + console.trace(); + return; + } + + let operation; + if (value === '') { + fallbackValue = null; + operation = () => { + sessionStorage.removeItem(storageKey); + }; + } else { + fallbackValue = value; + operation = () => { + sessionStorage.setItem(storageKey, value); + }; + } + + try { + operation(); + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + } + }, + }); + } + + Object.preventExtensions(info.session); + } + + for (const key of Object.keys(clientSteps)) { + if (Object.hasOwn(module, key)) { + const fn = module[key]; + + Object.defineProperty(fn, 'name', { + value: `${infoKey}/${fn.name}`, + }); + + clientSteps[key].push(fn); + } + } +} + +for (const [key, steps] of Object.entries(clientSteps)) { + for (const step of steps) { + try { + step(); + } catch (error) { + // TODO: Be smarter about not running later steps for the same module! + // Or maybe not, since an error is liable to cause explosions anyway. + console.error(`During ${key}, failed to run ${step.name}`); + console.error(error); + } + } +} diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js new file mode 100644 index 00000000..36a28429 --- /dev/null +++ b/src/static/js/client/live-mouse-position.js @@ -0,0 +1,21 @@ +/* eslint-env browser */ + +export const info = { + id: 'liveMousePositionInfo', + + state: { + clientX: null, + clientY: null, + }, +}; + +export function addPageListeners() { + const {state} = info; + + document.body.addEventListener('mousemove', domEvent => { + Object.assign(state, { + clientX: domEvent.clientX, + clientY: domEvent.clientY, + }); + }); +} diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js new file mode 100644 index 00000000..cff82252 --- /dev/null +++ b/src/static/js/client/quick-description.js @@ -0,0 +1,62 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'quickDescriptionInfo', + + quickDescriptionContainers: null, + + quickDescriptionsAreExpandable: null, + + expandDescriptionLinks: null, + collapseDescriptionLinks: null, +}; + +export function getPageReferences() { + info.quickDescriptionContainers = + Array.from(document.querySelectorAll('#content .quick-description')); + + info.quickDescriptionsAreExpandable = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions.when-expanded')); + + info.expandDescriptionLinks = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions .expand-link')); + + info.collapseDescriptionLinks = + info.quickDescriptionContainers + .map(container => + container.querySelector('.quick-description-actions .collapse-link')); +} + +export function addPageListeners() { + for (const { + isExpandable, + container, + expandLink, + collapseLink, + } of stitchArrays({ + isExpandable: info.quickDescriptionsAreExpandable, + container: info.quickDescriptionContainers, + expandLink: info.expandDescriptionLinks, + collapseLink: info.collapseDescriptionLinks, + })) { + if (!isExpandable) continue; + + expandLink.addEventListener('click', event => { + event.preventDefault(); + container.classList.add('expanded'); + container.classList.remove('collapsed'); + }); + + collapseLink.addEventListener('click', event => { + event.preventDefault(); + container.classList.add('collapsed'); + container.classList.remove('expanded'); + }); + } +} diff --git a/src/static/js/client/scripted-link.js b/src/static/js/client/scripted-link.js new file mode 100644 index 00000000..5db86e13 --- /dev/null +++ b/src/static/js/client/scripted-link.js @@ -0,0 +1,275 @@ +/* eslint-env browser */ + +import {pick, stitchArrays} from '../../shared-util/sugar.js'; + +import { + cssProp, + rebase, + openAlbum, + openArtist, + openTrack, +} from '../client-util.js'; + +export const info = { + id: 'scriptedLinkInfo', + + randomLinks: null, + revealLinks: null, + revealContainers: null, + + nextNavLink: null, + previousNavLink: null, + randomNavLink: null, + + state: { + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, + }, +}; + +export function getPageReferences() { + info.randomLinks = + document.querySelectorAll('[data-random]'); + + info.revealLinks = + document.querySelectorAll('.reveal .image-outer-area > *'); + + info.revealContainers = + Array.from(info.revealLinks) + .map(link => link.closest('.reveal')); + + info.nextNavLink = + document.getElementById('next-button'); + + info.previousNavLink = + document.getElementById('previous-button'); + + info.randomNavLink = + document.getElementById('random-button'); +} + +export function addPageListeners() { + addRandomLinkListeners(); + addNavigationKeyPressListeners(); + addRevealLinkClickListeners(); +} + +function addRandomLinkListeners() { + for (const a of info.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} = info; + + 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 <ol> or <ul>, and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + return pick(trackLinks).href; + } + + case 'track-in-album': { + const {albumDirectories, albumTrackDirectories} = state; + if (!albumDirectories || !albumTrackDirectories) return null; + + const albumDirectory = cssProp(a, '--album-directory'); + const albumIndex = albumDirectories.indexOf(albumDirectory); + const trackDirectories = albumTrackDirectories[albumIndex]; + + return openTrack(pick(trackDirectories)); + } + + case 'artist': { + const {artistDirectories} = state; + if (!artistDirectories) return null; + + return openArtist(pick(artistDirectories)); + } + + case 'artist-more-than-one-contrib': { + const {artistDirectories, artistNumContributions} = state; + if (!artistDirectories || !artistNumContributions) return null; + + const filteredArtistDirectories = + artistDirectories + .filter((_artist, index) => artistNumContributions[index] > 1); + + return openArtist(pick(filteredArtistDirectories)); + } + } +} + +export function mutatePageContent() { + mutateNavigationLinkContent(); +} + +function mutateNavigationLinkContent() { + const prependTitle = (el, prepend) => + el?.setAttribute('title', + (el.hasAttribute('title') + ? prepend + ' ' + el.getAttribute('title') + : prepend)); + + prependTitle(info.nextNavLink, '(Shift+N)'); + prependTitle(info.previousNavLink, '(Shift+P)'); + prependTitle(info.randomNavLink, '(Shift+R)'); +} + +function addNavigationKeyPressListeners() { + document.addEventListener('keypress', (event) => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + info.nextNavLink?.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + info.previousNavLink?.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + info.randomNavLink?.click(); + } + } + }); +} + +function addRevealLinkClickListeners() { + for (const {revealLink, revealContainer} of stitchArrays({ + revealLink: Array.from(info.revealLinks ?? []), + revealContainer: Array.from(info.revealContainers ?? []), + })) { + revealLink.addEventListener('click', (event) => { + handleRevealLinkClicked(event, revealLink, revealContainer); + }); + } +} + +function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) { + if (revealContainer.classList.contains('revealed')) { + return; + } + + domEvent.preventDefault(); + revealContainer.classList.add('revealed'); + revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal')); +} + +if ( + document.documentElement.dataset.urlKey === 'localized.listing' && + document.documentElement.dataset.urlValue0 === 'random' +) { + const dataLoadingLine = document.getElementById('data-loading-line'); + const dataLoadedLine = document.getElementById('data-loaded-line'); + const dataErrorLine = document.getElementById('data-error-line'); + + dataLoadingLine.style.display = 'block'; + + fetch(rebase('random-link-data.json', 'rebaseShared')) + .then(data => data.json()) + .then(data => { + const {state} = info; + + Object.assign(state, { + albumDirectories: data.albumDirectories, + albumTrackDirectories: data.albumTrackDirectories, + artistDirectories: data.artistDirectories, + artistNumContributions: data.artistNumContributions, + }); + + dataLoadingLine.style.display = 'none'; + dataLoadedLine.style.display = 'block'; + }, () => { + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; + }) + .then(() => { + for (const a of info.randomLinks) { + const href = determineRandomLinkHref(a); + if (!href) { + a.removeAttribute('href'); + } + } + }); +} diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js new file mode 100644 index 00000000..fd571ac0 --- /dev/null +++ b/src/static/js/client/sidebar-search.js @@ -0,0 +1,903 @@ +/* eslint-env browser */ + +import {getColors} from '../../shared-util/colors.js'; +import {accumulateSum, empty} from '../../shared-util/sugar.js'; + +import { + cssProp, + openAlbum, + openArtist, + openArtTag, + openFlash, + openGroup, + openTrack, + rebase, + templateContent, +} from '../client-util.js'; + +import { + info as wikiSearchInfo, + getSearchWorkerDownloadContext, + searchAll, +} from './wiki-search.js'; + +export const info = { + id: 'sidebarSearchInfo', + + pageContainer: null, + + searchSidebarColumn: null, + searchBox: null, + searchLabel: null, + searchInput: null, + + progressRule: null, + progressContainer: null, + progressLabel: null, + progressBar: null, + + failedRule: null, + failedContainer: null, + + resultsRule: null, + resultsContainer: null, + results: null, + + endSearchRule: null, + endSearchLine: null, + endSearchLink: null, + + preparingString: null, + loadingDataString: null, + searchingString: null, + failedString: null, + + noResultsString: null, + currentResultString: null, + endSearchString: null, + + albumResultKindString: null, + artistResultKindString: null, + groupResultKindString: null, + tagResultKindString: null, + + state: { + sidebarColumnShownForSearch: null, + + tidiedSidebar: null, + collapsedDetailsForTidiness: null, + + workerStatus: null, + searchStage: null, + + stoppedTypingTimeout: null, + stoppedScrollingTimeout: null, + + indexDownloadStatuses: Object.create(null), + }, + + session: { + activeQuery: { + type: 'string', + }, + + activeQueryResults: { + type: 'json', + maxLength: settings => settings.maxActiveResultsStorage, + }, + + repeatQueryOnReload: { + type: 'boolean', + default: false, + }, + + resultsScrollOffset: { + type: 'number', + }, + }, + + settings: { + stoppedTypingDelay: 800, + stoppedScrollingDelay: 200, + + maxActiveResultsStorage: 100000, + }, +}; + +export function getPageReferences() { + info.pageContainer = + document.getElementById('page-container'); + + info.searchBox = + document.querySelector('.wiki-search-sidebar-box'); + + if (!info.searchBox) { + return; + } + + info.searchLabel = + info.searchBox.querySelector('.wiki-search-label'); + + info.searchInput = + info.searchBox.querySelector('.wiki-search-input'); + + info.searchSidebarColumn = + info.searchBox.closest('.sidebar-column'); + + const findString = classPart => + info.searchBox.querySelector(`.wiki-search-${classPart}-string`); + + info.preparingString = + findString('preparing'); + + info.loadingDataString = + findString('loading-data'); + + info.searchingString = + findString('searching'); + + info.failedString = + findString('failed'); + + info.noResultsString = + findString('no-results'); + + info.currentResultString = + findString('current-result'); + + info.endSearchString = + findString('end-search'); + + info.albumResultKindString = + findString('album-result-kind'); + + info.artistResultKindString = + findString('artist-result-kind'); + + info.groupResultKindString = + findString('group-result-kind'); + + info.tagResultKindString = + findString('tag-result-kind'); +} + +export function addInternalListeners() { + if (!info.searchBox) return; + + wikiSearchInfo.event.whenWorkerAlive.push( + trackSidebarSearchWorkerAlive, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerReady.push( + trackSidebarSearchWorkerReady, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerFailsToInitialize.push( + trackSidebarSearchWorkerFailsToInitialize, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenWorkerHasRuntimeError.push( + trackSidebarSearchWorkerHasRuntimeError, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadsBegin.push( + trackSidebarSearchDownloadsBegin, + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadProgresses.push( + updateSidebarSearchStatus); + + wikiSearchInfo.event.whenDownloadEnds.push( + trackSidebarSearchDownloadEnds, + updateSidebarSearchStatus); +} + +export function mutatePageContent() { + if (!info.searchBox) return; + + // Progress section + + info.progressRule = + document.createElement('hr'); + + info.progressContainer = + document.createElement('div'); + + info.progressContainer.classList.add('wiki-search-progress-container'); + + cssProp(info.progressRule, 'display', 'none'); + cssProp(info.progressContainer, 'display', 'none'); + + info.progressLabel = + document.createElement('label'); + + info.progressLabel.classList.add('wiki-search-progress-label'); + info.progressLabel.htmlFor = 'wiki-search-progress-bar'; + + info.progressBar = + document.createElement('progress'); + + info.progressBar.classList.add('wiki-search-progress-bar'); + info.progressBar.id = 'wiki-search-progress-bar'; + + info.progressContainer.appendChild(info.progressLabel); + info.progressContainer.appendChild(info.progressBar); + + info.searchBox.appendChild(info.progressRule); + info.searchBox.appendChild(info.progressContainer); + + // Search failed section + + info.failedRule = + document.createElement('hr'); + + info.failedContainer = + document.createElement('div'); + + info.failedContainer.classList.add('wiki-search-failed-container'); + + { + const p = document.createElement('p'); + p.appendChild(templateContent(info.failedString)); + info.failedContainer.appendChild(p); + } + + cssProp(info.failedRule, 'display', 'none'); + cssProp(info.failedContainer, 'display', 'none'); + + info.searchBox.appendChild(info.failedRule); + info.searchBox.appendChild(info.failedContainer); + + // Results section + + info.resultsRule = + document.createElement('hr'); + + info.resultsContainer = + document.createElement('div'); + + info.resultsContainer.classList.add('wiki-search-results-container'); + + cssProp(info.resultsRule, 'display', 'none'); + cssProp(info.resultsContainer, 'display', 'none'); + + info.results = + document.createElement('div'); + + info.results.classList.add('wiki-search-results'); + + info.resultsContainer.appendChild(info.results); + + info.searchBox.appendChild(info.resultsRule); + info.searchBox.appendChild(info.resultsContainer); + + // End search section + + info.endSearchRule = + document.createElement('hr'); + + info.endSearchLine = + document.createElement('p'); + + info.endSearchLink = + document.createElement('a'); + + { + const p = info.endSearchLine; + const a = info.endSearchLink; + p.classList.add('wiki-search-end-search-line'); + a.setAttribute('href', '#'); + a.appendChild(templateContent(info.endSearchString)); + p.appendChild(a); + } + + cssProp(info.endSearchRule, 'display', 'none'); + cssProp(info.endSearchLine, 'display', 'none'); + + info.searchBox.appendChild(info.endSearchRule); + info.searchBox.appendChild(info.endSearchLine); +} + +export function addPageListeners() { + if (!info.searchInput) return; + + info.searchInput.addEventListener('change', _domEvent => { + if (info.searchInput.value) { + activateSidebarSearch(info.searchInput.value); + } + }); + + info.searchInput.addEventListener('input', _domEvent => { + const {settings, state} = info; + + if (!info.searchInput.value) { + clearSidebarSearch(); + return; + } + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + } + + state.stoppedTypingTimeout = + setTimeout(() => { + activateSidebarSearch(info.searchInput.value); + }, settings.stoppedTypingDelay); + }); + + info.endSearchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + clearSidebarSearch(); + possiblyHideSearchSidebarColumn(); + restoreSidebarSearchColumn(); + }); + + info.resultsContainer.addEventListener('scroll', () => { + const {settings, state} = info; + + if (state.stoppedScrollingTimeout) { + clearTimeout(state.stoppedScrollingTimeout); + } + + state.stoppedScrollingTimeout = + setTimeout(() => { + saveSidebarSearchResultsScrollOffset(); + }, settings.stoppedScrollingDelay); + }); +} + +export function initializeState() { + const {session} = info; + + if (!info.searchInput) return; + + if (session.activeQuery) { + info.searchInput.value = session.activeQuery; + if (session.repeatQueryOnReload) { + activateSidebarSearch(session.activeQuery); + } else if (session.activeQueryResults) { + showSidebarSearchResults(session.activeQueryResults); + } + } +} + +function trackSidebarSearchWorkerAlive() { + const {state} = info; + + state.workerStatus = 'alive'; +} + +function trackSidebarSearchWorkerReady() { + const {state} = info; + + state.workerStatus = 'ready'; + state.searchStage = 'searching'; +} + +function trackSidebarSearchWorkerFailsToInitialize() { + const {state} = info; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchWorkerHasRuntimeError() { + const {state} = info; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchDownloadsBegin(event) { + const {state} = info; + + if (event.context === 'search-indexes') { + for (const key of event.keys) { + state.indexDownloadStatuses[key] = 'active'; + } + } +} + +function trackSidebarSearchDownloadEnds(event) { + const {state} = info; + + if (event.context === 'search-indexes') { + state.indexDownloadStatuses[event.key] = 'complete'; + + const statuses = Object.values(state.indexDownloadStatuses); + if (statuses.every(status => status === 'complete')) { + for (const key of Object.keys(state.indexDownloadStatuses)) { + delete state.indexDownloadStatuses[key]; + } + } + } +} + +async function activateSidebarSearch(query) { + const {session, state} = info; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + state.searchStage = + (state.workerStatus === 'ready' + ? 'searching' + : 'preparing'); + updateSidebarSearchStatus(); + + let results; + try { + results = await searchAll(query, {enrich: true}); + } catch (error) { + console.error(`There was an error performing a sidebar search:`); + console.error(error); + showSidebarSearchFailed(); + return; + } + + state.searchStage = 'complete'; + updateSidebarSearchStatus(); + + session.activeQuery = query; + session.activeQueryResults = results; + session.resultsScrollOffset = 0; + + showSidebarSearchResults(results); +} + +function clearSidebarSearch() { + const {session, state} = info; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + info.searchBox.classList.remove('showing-results'); + info.searchSidebarColumn.classList.remove('search-showing-results'); + + info.searchInput.value = ''; + + state.searchStage = null; + + session.activeQuery = null; + session.activeQueryResults = null; + session.resultsScrollOffset = null; + + hideSidebarSearchResults(); +} + +function updateSidebarSearchStatus() { + const {state} = info; + + if (state.searchStage === 'failed') { + hideSidebarSearchResults(); + showSidebarSearchFailed(); + + return; + } + + const searchIndexDownloads = + getSearchWorkerDownloadContext('search-indexes'); + + const downloadProgressValues = + Object.values(searchIndexDownloads ?? {}); + + if (downloadProgressValues.some(v => v < 1.00)) { + const total = Object.keys(state.indexDownloadStatuses).length; + const sum = accumulateSum(downloadProgressValues); + showSidebarSearchProgress( + sum / total, + templateContent(info.loadingDataString)); + + return; + } + + if (state.searchStage === 'preparing') { + showSidebarSearchProgress( + null, + templateContent(info.preparingString)); + + return; + } + + if (state.searchStage === 'searching') { + showSidebarSearchProgress( + null, + templateContent(info.searchingString)); + + return; + } + + hideSidebarSearchProgress(); +} + +function showSidebarSearchProgress(progress, label) { + cssProp(info.progressRule, 'display', null); + cssProp(info.progressContainer, 'display', null); + + if (progress === null) { + info.progressBar.removeAttribute('value'); + } else { + info.progressBar.value = progress; + } + + while (info.progressLabel.firstChild) { + info.progressLabel.firstChild.remove(); + } + + info.progressLabel.appendChild(label); +} + +function hideSidebarSearchProgress() { + cssProp(info.progressRule, 'display', 'none'); + cssProp(info.progressContainer, 'display', 'none'); +} + +function showSidebarSearchFailed() { + const {state} = info; + + hideSidebarSearchProgress(); + hideSidebarSearchResults(); + + cssProp(info.failedRule, 'display', null); + cssProp(info.failedContainer, 'display', null); + + info.searchLabel.classList.add('disabled'); + info.searchInput.disabled = true; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } +} + +function showSidebarSearchResults(results) { + console.debug(`Showing search results:`, results); + + showSearchSidebarColumn(); + + const flatResults = + Object.entries(results) + .filter(([index]) => index === 'generic') + .flatMap(([index, results]) => results + .flatMap(({doc, id}) => ({ + index, + reference: id ?? null, + referenceType: (id ? id.split(':')[0] : null), + directory: (id ? id.split(':')[1] : null), + data: doc, + }))); + + info.searchBox.classList.add('showing-results'); + info.searchSidebarColumn.classList.add('search-showing-results'); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.resultsRule, 'display', 'block'); + cssProp(info.resultsContainer, 'display', 'block'); + + if (empty(flatResults)) { + const p = document.createElement('p'); + p.classList.add('wiki-search-no-results'); + p.appendChild(templateContent(info.noResultsString)); + info.results.appendChild(p); + } + + for (const result of flatResults) { + const el = generateSidebarSearchResult(result); + if (!el) continue; + + info.results.appendChild(el); + } + + if (!empty(flatResults)) { + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); + + tidySidebarSearchColumn(); + } + + restoreSidebarSearchResultsScrollOffset(); +} + +function generateSidebarSearchResult(result) { + const preparedSlots = { + color: + result.data.color ?? null, + + name: + result.data.name ?? result.data.primaryName ?? null, + + imageSource: + getSearchResultImageSource(result), + }; + + switch (result.referenceType) { + case 'album': { + preparedSlots.href = + openAlbum(result.directory); + + preparedSlots.kindString = + info.albumResultKindString; + + break; + } + + case 'artist': { + preparedSlots.href = + openArtist(result.directory); + + preparedSlots.kindString = + info.artistResultKindString; + + break; + } + + case 'group': { + preparedSlots.href = + openGroup(result.directory); + + preparedSlots.kindString = + info.groupResultKindString; + + break; + } + + case 'flash': { + preparedSlots.href = + openFlash(result.directory); + + break; + } + + case 'tag': { + preparedSlots.href = + openArtTag(result.directory); + + preparedSlots.kindString = + info.tagResultKindString; + + break; + } + + case 'track': { + preparedSlots.href = + openTrack(result.directory); + + break; + } + + default: + return null; + } + + return generateSidebarSearchResultTemplate(preparedSlots); +} + +function getSearchResultImageSource(result) { + const {artwork} = result.data; + if (!artwork) return null; + + return ( + rebase( + artwork.replace('<>', result.directory), + 'rebaseThumb')); +} + +function generateSidebarSearchResultTemplate(slots) { + const link = document.createElement('a'); + link.classList.add('wiki-search-result'); + + if (slots.href) { + link.setAttribute('href', slots.href); + } + + if (slots.color) { + cssProp(link, '--primary-color', slots.color); + + try { + const colors = + getColors(slots.color, { + chroma: window.chroma, + }); + cssProp(link, '--light-ghost-color', colors.lightGhost); + cssProp(link, '--deep-color', colors.deep); + } catch (error) { + console.warn(error); + } + } + + const imgContainer = document.createElement('span'); + imgContainer.classList.add('wiki-search-result-image-container'); + + if (slots.imageSource) { + const img = document.createElement('img'); + img.classList.add('wiki-search-result-image'); + img.setAttribute('src', slots.imageSource); + imgContainer.appendChild(img); + if (slots.imageSource.endsWith('.mini.jpg')) { + img.classList.add('has-warning'); + } + } else { + const placeholder = document.createElement('span'); + placeholder.classList.add('wiki-search-result-image-placeholder'); + imgContainer.appendChild(placeholder); + } + + link.appendChild(imgContainer); + + const text = document.createElement('span'); + text.classList.add('wiki-search-result-text-area'); + + if (slots.name) { + const span = document.createElement('span'); + span.classList.add('wiki-search-result-name'); + span.appendChild(document.createTextNode(slots.name)); + text.appendChild(span); + } + + let accentSpan = null; + + if (link.href) { + const here = location.href.replace(/\/$/, ''); + const there = link.href.replace(/\/$/, ''); + if (here === there) { + link.classList.add('current-result'); + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-current-result-text'); + accentSpan.appendChild(templateContent(info.currentResultString)); + } + } + + if (!accentSpan && slots.kindString) { + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-result-kind'); + accentSpan.appendChild(templateContent(slots.kindString)); + } + + if (accentSpan) { + text.appendChild(document.createTextNode(' ')); + text.appendChild(accentSpan); + } + + link.appendChild(text); + + link.addEventListener('click', () => { + saveSidebarSearchResultsScrollOffset(); + }); + + return link; +} + +function hideSidebarSearchResults() { + cssProp(info.resultsRule, 'display', 'none'); + cssProp(info.resultsContainer, 'display', 'none'); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.endSearchRule, 'display', 'none'); + cssProp(info.endSearchLine, 'display', 'none'); +} + +function saveSidebarSearchResultsScrollOffset() { + const {session} = info; + + session.resultsScrollOffset = info.resultsContainer.scrollTop; +} + +function restoreSidebarSearchResultsScrollOffset() { + const {session} = info; + + if (session.resultsScrollOffset) { + info.resultsContainer.scrollTop = session.resultsScrollOffset; + } +} + +function showSearchSidebarColumn() { + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!info.searchSidebarColumn.classList.contains('initially-hidden')) { + return; + } + + info.searchSidebarColumn.classList.remove('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.add('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.add('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = true; +} + +function possiblyHideSearchSidebarColumn() { + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!state.sidebarColumnShownForSearch) { + return; + } + + info.searchSidebarColumn.classList.add('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.remove('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.remove('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = null; +} + +// This should be called after results are shown, since it checks the +// elements added to understand the current search state. +function tidySidebarSearchColumn() { + const {state} = info; + + // Don't *re-tidy* the sidebar if we've already tidied it to display + // some results. This flag will get cleared if the search is dismissed + // altogether (and the pre-tidy state is restored). + if (state.tidiedSidebar) { + return; + } + + const here = location.href.replace(/\/$/, ''); + const currentPageIsResult = + Array.from(info.results.querySelectorAll('a')) + .some(link => { + const there = link.href.replace(/\/$/, ''); + return here === there; + }); + + // Don't tidy the sidebar if you've navigated to some other page than + // what's in the current result list. + if (!currentPageIsResult) { + return; + } + + state.tidiedSidebar = true; + state.collapsedDetailsForTidiness = []; + + for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) { + if (box === info.searchBox) { + continue; + } + + for (const details of box.getElementsByTagName('details')) { + if (details.open) { + details.removeAttribute('open'); + state.collapsedDetailsForTidiness.push(details); + } + } + } +} + +function restoreSidebarSearchColumn() { + const {state} = info; + + if (!state.tidiedSidebar) { + return; + } + + for (const details of state.collapsedDetailsForTidiness) { + details.setAttribute('open', ''); + } + + state.collapsedDetailsForTidiness = []; + state.tidiedSidebar = null; +} diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js new file mode 100644 index 00000000..ae63eab5 --- /dev/null +++ b/src/static/js/client/sticky-heading.js @@ -0,0 +1,257 @@ +/* eslint-env browser */ + +import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {dispatchInternalEvent, templateContent} from '../client-util.js'; + +export const info = { + id: 'stickyHeadingInfo', + + stickyContainers: null, + + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCovers: null, + contentCoversReveal: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + }, +}; + +export function getPageReferences() { + info.stickyContainers = + Array.from(document.getElementsByClassName('content-sticky-heading-container')); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.parentElement); + + info.contentCovers = + info.contentContainers + .map(el => el.querySelector('#cover-art-container')); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); +} + +export function mutatePageContent() { + removeTextPlaceholderStickyHeadingCovers(); + addRevealClassToStickyHeadingCovers(); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStickyCoverVisibility(index) { + const stickyCoverContainer = info.stickyCoverContainers[index]; + const contentCover = info.contentCovers[index]; + + if (contentCover && stickyCoverContainer) { + if (contentCover.getBoundingClientRect().bottom < 4) { + stickyCoverContainer.classList.add('visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickySubheading = info.stickySubheadings[index]; + + if (stickySubheading.childNodes.length === 0) { + // Supply a non-breaking space to ensure correct basic line height. + stickySubheading.appendChild(document.createTextNode('\xA0')); + } + + const stickyContainer = info.stickyContainers[index]; + const stickyRect = stickyContainer.getBoundingClientRect(); + + // TODO: Should this compute with the subheading row instead of h2? + const subheadingRect = stickySubheading.getBoundingClientRect(); + + const stickyBottom = stickyRect.bottom + subheadingRect.height; + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const {event, state} = info; + + const closestHeading = getContentHeadingClosestToStickySubheading(index); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + const textContainer = + templateContent( + closestHeading.querySelector('.content-heading-sticky-title')) ?? + closestHeading.querySelector('.content-heading-main-title') ?? + closestHeading; + + for (const child of textContainer.childNodes) { + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); +} + +export function updateStickyHeadings(index) { + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +export function initializeState() { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +export function addPageListeners() { + addRevealListenersForStickyHeadingCovers(); + addScrollListenerForStickyHeadings(); +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js new file mode 100644 index 00000000..23857fa5 --- /dev/null +++ b/src/static/js/client/summary-nested-link.js @@ -0,0 +1,48 @@ +/* eslint-env browser */ + +import { + empty, + filterMultipleArrays, + stitchArrays, +} from '../../shared-util/sugar.js'; + +export const info = { + id: 'summaryNestedLinkInfo', + + summaries: null, + links: null, +}; + +export function getPageReferences() { + info.summaries = + Array.from(document.getElementsByTagName('summary')); + + info.links = + info.summaries + .map(summary => + Array.from(summary.getElementsByTagName('a'))); + + filterMultipleArrays( + info.summaries, + info.links, + (_summary, links) => !empty(links)); +} + +export function addPageListeners() { + for (const {summary, links} of stitchArrays({ + summary: info.summaries, + links: info.links, + })) { + for (const link of links) { + link.addEventListener('mouseover', () => { + link.classList.add('nested-hover'); + summary.classList.add('has-nested-hover'); + }); + + link.addEventListener('mouseout', () => { + link.classList.remove('nested-hover'); + summary.classList.remove('has-nested-hover'); + }); + } + } +} diff --git a/src/static/js/client/text-with-tooltip.js b/src/static/js/client/text-with-tooltip.js new file mode 100644 index 00000000..dd207e04 --- /dev/null +++ b/src/static/js/client/text-with-tooltip.js @@ -0,0 +1,34 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {registerTooltipElement, registerTooltipHoverableElement} + from './hoverable-tooltip.js'; + +export const info = { + id: 'textWithTooltipInfo', + + hoverables: null, + tooltips: null, +}; + +export function getPageReferences() { + const spans = + Array.from(document.querySelectorAll('.text-with-tooltip')); + + info.hoverables = + spans.map(span => span.children[0]); + + info.tooltips = + spans.map(span => span.children[1]); +} + +export function addPageListeners() { + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js new file mode 100644 index 00000000..2446c172 --- /dev/null +++ b/src/static/js/client/wiki-search.js @@ -0,0 +1,239 @@ +/* eslint-env browser */ + +import {promiseWithResolvers} from '../../shared-util/sugar.js'; + +import {dispatchInternalEvent} from '../client-util.js'; + +export const info = { + id: 'wikiSearchInfo', + + state: { + worker: null, + + workerReadyPromise: null, + workerReadyPromiseResolvers: null, + + workerActionCounter: 0, + workerActionPromiseResolverMap: new Map(), + + downloads: Object.create(null), + }, + + event: { + whenWorkerAlive: [], + whenWorkerReady: [], + whenWorkerFailsToInitialize: [], + whenWorkerHasRuntimeError: [], + + whenDownloadBegins: [], + whenDownloadsBegin: [], + whenDownloadProgresses: [], + whenDownloadEnds: [], + }, +}; + +export async function initializeSearchWorker() { + const {state} = info; + + if (state.worker) { + return await state.workerReadyPromise; + } + + state.worker = + new Worker( + import.meta.resolve('../search-worker.js'), + {type: 'module'}); + + state.worker.onmessage = handleSearchWorkerMessage; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerReadyPromiseResolvers = {resolve, reject}; + + return await (state.workerReadyPromise = promise); +} + +function handleSearchWorkerMessage(message) { + switch (message.data.kind) { + case 'status': + handleSearchWorkerStatusMessage(message); + break; + + case 'result': + handleSearchWorkerResultMessage(message); + break; + + case 'download-begun': + handleSearchWorkerDownloadBegunMessage(message); + break; + + case 'download-progress': + handleSearchWorkerDownloadProgressMessage(message); + break; + + case 'download-complete': + handleSearchWorkerDownloadCompleteMessage(message); + break; + + default: + console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`); + break; + } +} + +function handleSearchWorkerStatusMessage(message) { + const {state, event} = info; + + switch (message.data.status) { + case 'alive': + console.debug(`Search worker is alive, but not yet ready.`); + dispatchInternalEvent(event, 'whenWorkerAlive'); + break; + + case 'ready': + console.debug(`Search worker has loaded corpuses and is ready.`); + state.workerReadyPromiseResolvers.resolve(state.worker); + dispatchInternalEvent(event, 'whenWorkerReady'); + break; + + case 'setup-error': + console.debug(`Search worker failed to initialize.`); + state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker')); + dispatchInternalEvent(event, 'whenWorkerFailsToInitialize'); + break; + + case 'runtime-error': + console.debug(`Search worker had an uncaught runtime error.`); + dispatchInternalEvent(event, 'whenWorkerHasRuntimeError'); + break; + + default: + console.warn(`Unknown status "${message.data.status}" <- from search worker`); + break; + } +} + +function handleSearchWorkerResultMessage(message) { + const {state} = info; + const {id} = message.data; + + if (!id) { + console.warn(`Result without id <- from search worker:`, message.data); + return; + } + + if (!state.workerActionPromiseResolverMap.has(id)) { + console.warn(`Runaway result id <- from search worker:`, message.data); + return; + } + + const {resolve, reject} = + state.workerActionPromiseResolverMap.get(id); + + switch (message.data.status) { + case 'resolve': + resolve(message.data.value); + break; + + case 'reject': + reject(message.data.value); + break; + + default: + console.warn(`Unknown result status "${message.data.status}" <- from search worker`); + return; + } + + state.workerActionPromiseResolverMap.delete(id); +} + +function handleSearchWorkerDownloadBegunMessage(message) { + const {event} = info; + const {context: contextKey, keys} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey, true); + + for (const key of keys) { + context[key] = 0.00; + + dispatchInternalEvent(event, 'whenDownloadBegins', { + context: contextKey, + key, + }); + } + + dispatchInternalEvent(event, 'whenDownloadsBegin', { + context: contextKey, + keys, + }); +} + +function handleSearchWorkerDownloadProgressMessage(message) { + const {event} = info; + const {context: contextKey, key, progress} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = progress; + + dispatchInternalEvent(event, 'whenDownloadProgresses', { + context: contextKey, + key, + progress, + }); +} + +function handleSearchWorkerDownloadCompleteMessage(message) { + const {event} = info; + const {context: contextKey, key} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = 1.00; + + dispatchInternalEvent(event, 'whenDownloadEnds', { + context: contextKey, + key, + }); +} + +export function getSearchWorkerDownloadContext(context, initialize = false) { + const {state} = info; + + if (context in state.downloads) { + return state.downloads[context]; + } + + if (!initialize) { + return null; + } + + return state.downloads[context] = Object.create(null); +} + +export async function postSearchWorkerAction(action, options) { + const {state} = info; + + const worker = await initializeSearchWorker(); + const id = ++state.workerActionCounter; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerActionPromiseResolverMap.set(id, {resolve, reject}); + + worker.postMessage({ + kind: 'action', + action: action, + id, + options, + }); + + return await promise; +} + +export async function searchAll(query, options = {}) { + return await postSearchWorkerAction('search', { + query, + options, + }); +} diff --git a/src/static/js/group-contributions-table.js b/src/static/js/group-contributions-table.js new file mode 100644 index 00000000..72ad2327 --- /dev/null +++ b/src/static/js/group-contributions-table.js @@ -0,0 +1,35 @@ +/* eslint-env browser */ + +// TODO: Update to clientSteps style. + +const groupContributionsTableInfo = + Array.from(document.querySelectorAll('#content dl')) + .filter(dl => dl.querySelector('a.group-contributions-sort-button')) + .map(dl => ({ + sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), + sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), + sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), + sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), + })); + +function sortGroupContributionsTableBy(info, sort) { + const [showThese, hideThese] = + (sort === 'count' + ? [info.sortingByCountElements, info.sortingByDurationElements] + : [info.sortingByDurationElements, info.sortingByCountElements]); + + for (const element of showThese) element.classList.add('visible'); + for (const element of hideThese) element.classList.remove('visible'); +} + +for (const info of groupContributionsTableInfo) { + info.sortingByCountLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'duration'); + }); + + info.sortingByDurationLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'count'); + }); +} diff --git a/src/static/js/image-overlay.js b/src/static/js/image-overlay.js new file mode 100644 index 00000000..1eee60e5 --- /dev/null +++ b/src/static/js/image-overlay.js @@ -0,0 +1,256 @@ +/* eslint-env browser */ + +import {cssProp} from './client-util.js'; +import {fetchWithProgress} from './xhr-util.js'; + +// TODO: Update to clientSteps style. + +function addImageOverlayClickHandlers() { + const container = document.getElementById('image-overlay-container'); + + if (!container) { + console.warn(`#image-overlay-container missing, image overlay module disabled.`); + return; + } + + for (const link of document.querySelectorAll('.image-link')) { + if (link.closest('.no-image-preview')) { + continue; + } + + link.addEventListener('click', handleImageLinkClicked); + } + + const actionContainer = document.getElementById('image-overlay-action-container'); + + container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); + + function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== container) { + return; + } + + // If you clicked anything close to or beneath the action bar, don't hide + // the image overlay. + const rect = actionContainer.getBoundingClientRect(); + if (evt.clientY >= rect.top - 40) { + return; + } + + container.classList.remove('visible'); + } + + function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + container.classList.remove('visible'); + } + } +} + +async function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + + evt.preventDefault(); + + // Don't show the overlay if the image still needs to be revealed. + if (evt.target.closest('.reveal:not(.revealed)')) { + return; + } + + const container = document.getElementById('image-overlay-container'); + container.classList.add('visible'); + container.classList.remove('loaded'); + container.classList.remove('errored'); + + const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); + const mainImage = document.getElementById('image-overlay-image'); + const thumbImage = document.getElementById('image-overlay-image-thumb'); + + const {href: originalSrc} = evt.target.closest('a'); + + const { + src: embeddedSrc, + dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }, + } = evt.target.closest('a').querySelector('img'); + + updateFileSizeInformation(originalFileSize); + + let mainSrc = null; + let thumbSrc = null; + + if (availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); + mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); + // Show the thumbnail size on each <img> element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = originalSrc; + thumbSrc = null; + mainImage.dataset.displayingThumb = ''; + thumbImage.dataset.displayingThumb = ''; + } + + if (thumbSrc) { + thumbImage.src = thumbSrc; + thumbImage.style.display = null; + } else { + thumbImage.src = ''; + thumbImage.style.display = 'none'; + } + + for (const viewOriginal of allViewOriginal) { + viewOriginal.href = originalSrc; + } + + mainImage.addEventListener('load', handleMainImageLoaded); + mainImage.addEventListener('error', handleMainImageErrored); + + const showProgress = amount => { + cssProp(container, '--download-progress', `${amount * 100}%`); + }; + + showProgress(0.00); + + const response = + await fetchWithProgress(mainSrc, progress => { + if (progress === -1) { + // TODO: Indeterminate response progress cue + showProgress(0.00); + } else { + showProgress(0.20 + 0.80 * progress); + } + }); + + if (!response.status.toString().startsWith('2')) { + handleMainImageErrored(); + return; + } + + const blob = await response.blob(); + const blobSrc = URL.createObjectURL(blob); + + mainImage.src = blobSrc; + showProgress(1.00); + + function handleMainImageLoaded() { + container.classList.add('loaded'); + removeEventListeners(); + } + + function handleMainImageErrored() { + container.classList.add('errored'); + removeEventListeners(); + } + + function removeEventListeners() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); + const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); + + if (!fileSize) { + actionContentWithSize.classList.remove('visible'); + actionContentWithoutSize.classList.add('visible'); + return; + } + + actionContentWithoutSize.classList.remove('visible'); + actionContentWithSize.classList.add('visible'); + + const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); + const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); + const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); + const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); + const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + fileSizeWarning.classList.add('visible'); + } else { + fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + megabytesContainer.classList.add('visible'); + kilobytesContainer.classList.remove('visible'); + megabytesContent.innerText = round(6); + } else { + megabytesContainer.classList.remove('visible'); + kilobytesContainer.classList.add('visible'); + kilobytesContent.innerText = round(3); + } + + void fileSizeWarning; +} + +addImageOverlayClickHandlers(); diff --git a/src/static/js/info-card.js b/src/static/js/info-card.js new file mode 100644 index 00000000..1d9f7c86 --- /dev/null +++ b/src/static/js/info-card.js @@ -0,0 +1,181 @@ +/* eslint-env browser */ + +// Note: This is a super ancient chunk of code which isn't actually in use, +// so it's just commented out here. + +/* +function colorLink(a, color) { + console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); + return; + + // eslint-disable-next-line no-unreachable + const chroma = {}; + + if (color) { + const {primary, dim} = getColors(color, {chroma}); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name; + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map((el) => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then((data) => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements( + 'conjunction', + data.artists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + + const coverArtistParagraph = container.querySelector( + '.info-card-cover-artists' + ); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements( + 'conjunction', + data.coverArtists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [containerNoReveal, containerReveal] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal'), + ]; + + const [containerShow, containerHide] = data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]; + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide, + }; +})(); + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} +*/ + diff --git a/src/static/js/localization-nonsense.js b/src/static/js/localization-nonsense.js new file mode 100644 index 00000000..8b6d1ef0 --- /dev/null +++ b/src/static/js/localization-nonsense.js @@ -0,0 +1,30 @@ +// Another old, unused chunk of code. + +/* +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, + }; +} +*/ diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js new file mode 100644 index 00000000..cdab2cb8 --- /dev/null +++ b/src/static/js/rectangles.js @@ -0,0 +1,513 @@ +/* eslint-env browser */ + +import {info as liveMousePositionInfo} from './client/live-mouse-position.js'; + +export 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, + }); + } +} |