diff options
Diffstat (limited to 'src/static')
38 files changed, 8832 insertions, 3670 deletions
diff --git a/src/static/client4.js b/src/static/client4.js deleted file mode 100644 index 729836b5..00000000 --- a/src/static/client4.js +++ /dev/null @@ -1,3483 +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 {accumulateSum, empty, filterMultipleArrays, stitchArrays} - from '../util/sugar.js'; -import {fetchWithProgress} from './xhr-util.js'; - -const clientInfo = window.hsmusicClientInfo = Object.create(null); - -const clientSteps = { - getPageReferences: [], - addInternalListeners: [], - mutatePageContent: [], - initializeState: [], - addPageListeners: [], -}; - -function initInfo(infoKey, description) { - const object = {...description}; - - for (const obj of [ - object, - object.state, - object.setting, - object.event, - ]) { - if (!obj) continue; - Object.preventExtensions(obj); - } - - if (object.session) { - const sessionDefaults = object.session; - - object.session = {}; - - for (const [key, defaultValue] of Object.entries(sessionDefaults)) { - const storageKey = `hsmusic.${infoKey}.${key}`; - - let fallbackValue = defaultValue; - - Object.defineProperty(object.session, key, { - get: () => { - try { - return sessionStorage.getItem(storageKey) ?? defaultValue; - } catch (error) { - if (error instanceof DOMException) { - return fallbackValue; - } else { - throw error; - } - } - }, - - set: (value) => { - try { - sessionStorage.setItem(storageKey, value); - } catch (error) { - if (error instanceof DOMException) { - fallbackValue = value; - } else { - throw error; - } - } - }, - }); - } - - Object.preventExtensions(object.session); - } - - clientInfo[infoKey] = object; - - return object; -} - -// Localiz8tion nonsense ---------------------------------- - -/* -const language = document.documentElement.getAttribute('lang'); - -let list; -if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { - const getFormat = (type) => { - const formatter = new Intl.ListFormat(language, {type}); - return formatter.format.bind(formatter); - }; - - list = { - conjunction: getFormat('conjunction'), - disjunction: getFormat('disjunction'), - unit: getFormat('unit'), - }; -} else { - // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. - // We use the same mock for every list 'cuz we don't have any of the - // necessary CLDR info to appropri8tely distinguish 8etween them. - const arbitraryMock = (array) => array.join(', '); - - list = { - conjunction: arbitraryMock, - disjunction: arbitraryMock, - unit: arbitraryMock, - }; -} -*/ - -// Miscellaneous helpers ---------------------------------- - -function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; - } -} - -function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -function cssProp(el, ...args) { - if (typeof args[0] === 'string' && args.length === 1) { - return getComputedStyle(el).getPropertyValue(args[0]).trim(); - } - - if (typeof args[0] === 'string' && args.length === 2) { - if (args[1] === null) { - el.style.removeProperty(args[0]); - } else { - el.style.setProperty(args[0], args[1]); - } - return; - } - - if (typeof args[0] === 'object') { - for (const [property, value] of Object.entries(args[0])) { - cssProp(el, property, value); - } - } -} - -// Curry-style, so multiple points can more conveniently be tested at once. -function pointIsOverAnyOf(elements) { - return (clientX, clientY) => { - const element = document.elementFromPoint(clientX, clientY); - return elements.some(el => el.contains(element)); - }; -} - -function getVisuallyContainingElement(child) { - let parent = child.parentElement; - - while (parent) { - if ( - cssProp(parent, 'overflow') === 'hidden' || - cssProp(parent, 'contain') === 'paint' - ) { - return parent; - } - - parent = parent.parentElement; - } - - return null; -} - -// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to -// separ8te the tooling around that into common-shared code too. - -/* -const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); -*/ - -const openAlbum = (d) => rebase(`album/${d}`); -const openTrack = (d) => rebase(`track/${d}`); -const openArtist = (d) => rebase(`artist/${d}`); - -// TODO: This should also use urlSpec. - -/* -function fetchData(type, directory) { - return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( - (res) => res.json() - ); -} -*/ - -function dispatchInternalEvent(event, eventName, ...args) { - const [infoName] = - Object.entries(clientInfo) - .find(pair => pair[1].event === event); - - if (!infoName) { - throw new Error(`Expected event to be stored on clientInfo`); - } - - const {[eventName]: listeners} = event; - - if (!listeners) { - throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); - } - - let results = []; - for (const listener of listeners) { - try { - results.push(listener(...args)); - } catch (error) { - console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); - console.debug(error); - results.push(undefined); - } - } - - return results; -} - -// Rectangle math ----------------------------------------- - -class WikiRect extends DOMRect { - // Useful constructors - - static fromWindow() { - const {clientWidth: width, clientHeight: height} = - document.documentElement; - - return Reflect.construct(this, [0, 0, width, height]); - } - - static fromElement(element) { - return this.fromRect(element.getBoundingClientRect()); - } - - static fromMouse() { - const {clientX, clientY} = liveMousePositionInfo.state; - - return WikiRect.fromRect({ - x: clientX, - y: clientY, - width: 0, - height: 0, - }); - } - - static fromElementUnderMouse(element) { - const mouseRect = WikiRect.fromMouse(); - - const rects = - Array.from(element.getClientRects()) - .map(rect => WikiRect.fromRect(rect)); - - const rectUnderMouse = - rects.find(rect => rect.contains(mouseRect)); - - if (rectUnderMouse) { - return rectUnderMouse; - } else { - return rects[0]; - } - } - - static leftOf(origin, offset = 0) { - // Returns a rectangle representing everywhere to the left of the provided - // point or rectangle (with no top or bottom bounds), towards negative x. - // If an offset is provided, this is added onto the origin. - - return this.#past(origin, offset, { - origin: 'x', - extent: 'width', - edge: 'left', - direction: -Infinity, - construct: from => - [from, -Infinity, -Infinity, Infinity], - }); - } - - static rightOf(origin, offset = 0) { - // Returns a rectangle representing everywhere to the right of the - // provided point or rectangle (with no top or bottom bounds), towards - // positive x. If an offset is provided, this is added onto the origin. - - return this.#past(origin, offset, { - origin: 'x', - extent: 'width', - edge: 'right', - direction: Infinity, - construct: from => - [from, -Infinity, Infinity, Infinity], - }); - } - - static above(origin, offset = 0) { - // Returns a rectangle representing everywhere above the provided point - // or rectangle (with no left or right bounds), towards negative y. - // If an offset is provided, this is added onto the origin. - - return this.#past(origin, offset, { - origin: 'y', - extent: 'height', - edge: 'top', - direction: -Infinity, - construct: from => - [-Infinity, from, Infinity, -Infinity], - }); - } - - static beneath(origin, offset = 0) { - // Returns a rectangle representing everywhere beneath the provided point - // or rectangle (with no left or right bounds), towards positive y. - // If an offset is provided, this is added onto the origin. - - return this.#past(origin, offset, { - origin: 'y', - extent: 'height', - edge: 'bottom', - direction: Infinity, - construct: from => - [-Infinity, from, Infinity, Infinity], - }); - } - - // Constructor helpers - - static #past(origin, offset, opts) { - if (!isFinite(offset)) { - throw new TypeError(`Didn't expect infinite offset`); - } - - const {direction, edge} = opts; - - if (typeof origin === 'object') { - const {origin: originProperty, extent: extentProperty} = opts; - - const normalized = - WikiRect.fromRect(origin).toNormalized(); - - if (normalized[extentProperty] === direction) { - throw new TypeError(`Provided rectangle already extends to ${edge} edge`); - } - - if (normalized[extentProperty] === -direction) { - return this.#past(normalized[originProperty], offset, opts); - } - - if (normalized.y === direction) { - throw new TypeError(`Provided rectangle already starts at ${edge} edge`); - } - - return this.#past(normalized[edge], offset, opts); - } - - const {construct} = opts; - - if (origin === direction) { - throw new TypeError(`Provided point is already at ${edge} edge`); - } - - return Reflect.construct(this, construct(origin + offset)).toNormalized(); - } - - // Predicates - - static rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}) { - // Indicate that, in this context, it's meaningless to provide - // a finite extent starting at an infinite origin and going towards - // or away from zero (i.e. a rectangle along a cardinal edge). - - if (!isFinite(origin) && isFinite(extent) && extent !== 0) { - throw new TypeError(`Didn't expect infinite origin paired with finite extent`); - } - } - - static rejectInfiniteOriginZeroExtent({origin, extent}) { - // Indicate that, in this context, it's meaningless to provide - // a zero extent at an infinite origin (i.e. a cardinal edge). - - if (!isFinite(origin) && extent === 0) { - throw new TypeError(`Didn't expect infinite origin paired with zero extent`); - } - } - - static rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}) { - // Indicate that, in this context, it's meaningless to provide - // an infinite extent going in the same direction as its infinite - // origin (an area "infinitely past" a cardinal edge). - - if (!isFinite(origin) && origin === extent) { - throw new TypeError(`Didn't expect non-opposing infinite origin and extent`); - } - } - - // Transformations - - static normalizeOriginExtent({origin, extent}) { - // Varying behavior based on inputs: - // - // - For finite origin and finite extent, flip the orientation - // (if necessary) so that extent is positive. - // - For finite origin and infinite extent (i.e. an origin up to - // a cardinal edge), leave as-is. - // - For infinite origin and infinite extent, flip the orientation - // (if necessary) so origin is negative and extent is positive. - // - For infinite origin and zero extent (i.e. a cardinal edge), - // leave as-is. - // - For all other cases, error. - // - - this.rejectInfiniteOriginNonZeroFiniteExtent({origin, extent}); - this.rejectNonOpposingInfiniteOriginInfiniteExtent({origin, extent}); - - if (isFinite(origin) && isFinite(extent) && extent < 0) { - return {origin: origin + extent, extent: -extent}; - } - - if (!isFinite(origin) && !isFinite(extent)) { - return {origin: -Infinity, extent: Infinity}; - } - - return {origin, extent}; - } - - toNormalized() { - const {origin: newX, extent: newWidth} = - WikiRect.normalizeOriginExtent({ - origin: this.x, - extent: this.width, - }); - - const {origin: newY, extent: newHeight} = - WikiRect.normalizeOriginExtent({ - origin: this.y, - extent: this.height, - }); - - return Reflect.construct(this.constructor, [newX, newY, newWidth, newHeight]); - } - - static intersectionFromOriginsExtents(...entries) { - // An intersection is the common subsection across two or more regions. - - const [first, second, ...rest] = entries; - - if (entries.length >= 3) { - return this.intersection(first, this.intersection(second, ...rest)); - } - - if (entries.length === 2) { - if (first === null || second === null) { - return null; - } - - this.rejectInfiniteOriginZeroExtent(first); - this.rejectInfiniteOriginZeroExtent(second); - - const {origin: origin1, extent: extent1} = this.normalizeOriginExtent(first); - const {origin: origin2, extent: extent2} = this.normalizeOriginExtent(second); - - // After normalizing, *each* region will be one of these: - // - // - Finite origin, finite extent - // (a standard region, bounded on both sides) - // - Finite origin, infinite extent - // (everything to one direction of a given origin) - // - Infinite origin, infinite extent - // (everything everywhere) - // - // So we need to handle any *combination* of these kinds of regions. - - // If either origin is infinite, that region represents everywhere, - // so it'll never limit the region of the other. - - if (!isFinite(origin1)) { - return {origin: origin2, extent: extent2}; - } - - if (!isFinite(origin2)) { - return {origin: origin1, extent: extent1}; - } - - // If neither origin is infinite, both regions are bounded on at least - // one side, and may limit the other accordingly. Find the minimum and - // maximum points in each region, letting Infinity propagate through, - // which represents no boundary in that direction. - - const minimum1 = Math.min(origin1, origin1 + extent1); - const minimum2 = Math.min(origin2, origin2 + extent2); - const maximum1 = Math.max(origin1, origin1 + extent1); - const maximum2 = Math.max(origin2, origin2 + extent2); - - // Now get the maximum of the regions' minimums, and the minimum of the - // regions' maximums. These are the limits of the new region; computing - // with minimums and maximums in this way "polarizes" the limits, so we - // can perform specific polarized math in the following steps. - // - // Infinity will also propagate here, but with some important - // restricitons: only maxOfMinimums can be positive Infinity, and only - // minOfMaximums can be negative Infinity; and if either is Infinity, - // the other is not, since otherwise we'd be working with two everywhere - // regions, and would've just returned an everywhere region above. - - const maxOfMinimums = Math.max(minimum1, minimum2); - const minOfMaximums = Math.min(maximum1, maximum2); - - // Now check if the maximum of minimums is greater than the minimum of - // maximums. If so, the regions don't have any overlap - one region - // limits the overlap to end before the other region starts. This works - // because we've polarized the limits above! - - if (maxOfMinimums > minOfMaximums) { - return null; - } - - // Otherwise there's at least some overlap, even if it's just one point - // (i.e. one ends exactly where the other begins). We have to take care - // of infinities in particular, now. As mentioned above, only one of the - // points will be infinity (at most). So the origin is the non-infinite - // point, and the extent is in the direction of the infinite point. - - if (minOfMaximums === -Infinity) { - return {origin: maxOfMinimums, extent: -Infinity}; - } - - if (maxOfMinimums === Infinity) { - return {origin: minOfMaximums, extent: Infinity}; - } - - // If neither point is infinity, we're working with two regions that are - // both bounded on both sides, so the overlapping region is just the - // region constrained by the limits above. Since these are polarized, - // start from maxOfMinimums and extend to minOfMaximums, resulting in - // a standard, already-normalized region. - - return { - origin: maxOfMinimums, - extent: minOfMaximums - maxOfMinimums, - }; - } - - if (entries.length === 1) { - return first; - } - - throw new TypeError(`Expected at least one {origin, extent} entry`); - } - - intersectionWith(rect) { - const horizontalIntersection = - WikiRect.intersectionFromOriginsExtents( - {origin: this.x, extent: this.width}, - {origin: rect.x, extent: rect.width}); - - const verticalIntersection = - WikiRect.intersectionFromOriginsExtents( - {origin: this.y, extent: this.height}, - {origin: rect.y, extent: rect.height}); - - if (!horizontalIntersection) return null; - if (!verticalIntersection) return null; - - const {origin: x, extent: width} = horizontalIntersection; - const {origin: y, extent: height} = verticalIntersection; - - return Reflect.construct(this.constructor, [x, y, width, height]); - } - - chopExtendingOutside(rect) { - this.intersectionWith(rect).writeOnto(this); - } - - static insetOriginExtent({origin, extent, start = 0, end = 0}) { - const normalized = - this.normalizeOriginExtent({origin, extent}); - - // If this would crush the bounds past each other, just return - // the halfway point. - if (extent < start + end) { - return {origin: origin + (start + end) / 2, extent: 0}; - } - - return { - origin: normalized.origin + start, - extent: normalized.extent - start - end, - }; - } - - toInset(arg1, arg2) { - if (typeof arg1 === 'number' && typeof arg2 === 'number') { - return this.toInset({ - left: arg2, - right: arg2, - top: arg1, - bottom: arg1, - }); - } else if (typeof arg1 === 'number') { - return this.toInset({ - left: arg1, - right: arg1, - top: arg1, - bottom: arg1, - }); - } - - const {top, left, bottom, right} = arg1; - - const {origin: x, extent: width} = - WikiRect.insetOriginExtent({ - origin: this.x, - extent: this.width, - start: left, - end: right, - }); - - const {origin: y, extent: height} = - WikiRect.insetOriginExtent({ - origin: this.y, - extent: this.height, - start: top, - end: bottom, - }); - - return Reflect.construct(this.constructor, [x, y, width, height]); - } - - static extendOriginExtent({origin, extent, start = 0, end = 0}) { - const normalized = - this.normalizeOriginExtent({origin, extent}); - - return { - origin: normalized.origin - start, - extent: normalized.extent + start + end, - }; - } - - toExtended(arg1, arg2) { - if (typeof arg1 === 'number' && typeof arg2 === 'number') { - return this.toExtended({ - left: arg2, - right: arg2, - top: arg1, - bottom: arg1, - }); - } else if (typeof arg1 === 'number') { - return this.toExtended({ - left: arg1, - right: arg1, - top: arg1, - bottom: arg1, - }); - } - - const {top, left, bottom, right} = arg1; - - const {origin: x, extent: width} = - WikiRect.extendOriginExtent({ - origin: this.x, - extent: this.width, - start: left, - end: right, - }); - - const {origin: y, extent: height} = - WikiRect.extendOriginExtent({ - origin: this.y, - extent: this.height, - start: top, - end: bottom, - }); - - return Reflect.construct(this.constructor, [x, y, width, height]); - } - - // Comparisons - - equals(rect) { - const rectNormalized = WikiRect.fromRect(rect).toNormalized(); - const thisNormalized = this.toNormalized(); - - return ( - rectNormalized.x === thisNormalized.x && - rectNormalized.y === thisNormalized.y && - rectNormalized.width === thisNormalized.width && - rectNormalized.height === thisNormalized.height - ); - } - - contains(rect) { - return !!this.intersectionWith(rect)?.equals(rect); - } - - containedWithin(rect) { - return !!this.intersectionWith(rect)?.equals(this); - } - - fits(rect) { - const rectNormalized = WikiRect.fromRect(rect).toNormalized(); - const thisNormalized = this.toNormalized(); - - return ( - (!isFinite(this.width) || rectNormalized.width <= thisNormalized.width) && - (!isFinite(this.height) || rectNormalized.height <= thisNormalized.height) - ); - } - - fitsWithin(rect) { - const rectNormalized = WikiRect.fromRect(rect).toNormalized(); - const thisNormalized = this.toNormalized(); - - return ( - (!isFinite(rect.width) || thisNormalized.width <= rectNormalized.width) && - (!isFinite(rect.height) || thisNormalized.height <= rectNormalized.height) - ); - } - - // Interfacing utilities - - static fromRect(rect) { - return Reflect.construct(this, [rect.x, rect.y, rect.width, rect.height]); - } - - writeOnto(destination) { - Object.assign(destination, { - x: this.x, - y: this.y, - width: this.width, - height: this.height, - }); - } -} - -// CSS compatibility-assistant ---------------------------- - -const cssCompatibilityAssistantInfo = clientInfo.cssCompatibilityAssistantInfo = { - coverArtContainer: null, - coverArtImageDetails: null, -}; - -function getCSSCompatibilityAssistantInfoReferences() { - const info = cssCompatibilityAssistantInfo; - - info.coverArtContainer = - document.getElementById('cover-art-container'); - - info.coverArtImageDetails = - info.coverArtContainer?.querySelector('.image-details'); -} - -function mutateCSSCompatibilityContent() { - const info = cssCompatibilityAssistantInfo; - - if (info.coverArtImageDetails) { - info.coverArtContainer.classList.add('has-image-details'); - } -} - -clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences); -clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent); - -// Ever-updating mouse position helper -------------------- - -const liveMousePositionInfo = initInfo('liveMousePositionInfo', { - state: { - clientX: null, - clientY: null, - }, -}); - -function addLiveMousePositionPageListeners() { - const info = liveMousePositionInfo; - const {state} = info; - - document.body.addEventListener('mousemove', domEvent => { - Object.assign(state, { - clientX: domEvent.clientX, - clientY: domEvent.clientY, - }); - }); -} - -clientSteps.addPageListeners.push(addLiveMousePositionPageListeners); - -// JS-based links ----------------------------------------- - -const scriptedLinkInfo = initInfo('scriptedLinkInfo', { - randomLinks: null, - revealLinks: null, - revealContainers: null, - - nextNavLink: null, - previousNavLink: null, - randomNavLink: null, - - state: { - albumDirectories: null, - albumTrackDirectories: null, - artistDirectories: null, - artistNumContributions: null, - }, -}); - -function getScriptedLinkReferences() { - scriptedLinkInfo.randomLinks = - document.querySelectorAll('[data-random]'); - - scriptedLinkInfo.revealLinks = - document.querySelectorAll('.reveal .image-outer-area > *'); - - scriptedLinkInfo.revealContainers = - Array.from(scriptedLinkInfo.revealLinks) - .map(link => link.closest('.reveal')); - - scriptedLinkInfo.nextNavLink = - document.getElementById('next-button'); - - scriptedLinkInfo.previousNavLink = - document.getElementById('previous-button'); - - scriptedLinkInfo.randomNavLink = - document.getElementById('random-button'); -} - -function addRandomLinkListeners() { - for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', domEvent => { - handleRandomLinkClicked(a, domEvent); - }); - } -} - -function handleRandomLinkClicked(a, domEvent) { - const href = determineRandomLinkHref(a); - - if (!href) { - domEvent.preventDefault(); - return; - } - - setTimeout(() => { - a.href = '#' - }); - - a.href = href; -} - -function determineRandomLinkHref(a) { - const {state} = scriptedLinkInfo; - - const trackDirectoriesFromAlbumDirectories = albumDirectories => - albumDirectories - .map(directory => state.albumDirectories.indexOf(directory)) - .map(index => state.albumTrackDirectories[index]) - .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); - - switch (a.dataset.random) { - case 'album': { - const {albumDirectories} = state; - if (!albumDirectories) return null; - - return openAlbum(pick(albumDirectories)); - } - - case 'track': { - const {albumDirectories} = state; - if (!albumDirectories) return null; - - const trackDirectories = - trackDirectoriesFromAlbumDirectories( - albumDirectories); - - return openTrack(pick(trackDirectories)); - } - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const listAlbumDirectories = - albumLinks - .map(a => cssProp(a, '--album-directory')); - - return openAlbum(pick(listAlbumDirectories)); - } - - case 'track-in-group-dl': { - const {albumDirectories} = state; - if (!albumDirectories) return null; - - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const listAlbumDirectories = - albumLinks - .map(a => cssProp(a, '--album-directory')); - - const trackDirectories = - trackDirectoriesFromAlbumDirectories( - listAlbumDirectories); - - return openTrack(pick(trackDirectories)); - } - - case 'track-in-sidebar': { - // Note that the container for track links may be <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'); - } - } - }); -} - -// 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(), - }, - - 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; - - // 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); - - 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 positionTooltipFromHoverableWithBrains(hoverable) { - const {state} = hoverableTooltipInfo; - const {tooltip} = state.registeredHoverables.get(hoverable); - - // 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; - } - - let selectedRect = null; - for (let i = 0; i < numBaselineRects; i++) { - selectedRect = opportunities.right.down[i]; - if (selectedRect) break; - - selectedRect = opportunities.left.down[i]; - if (selectedRect) break; - - selectedRect = opportunities.right.up[i]; - if (selectedRect) break; - - selectedRect = opportunities.left.up[i]; - if (selectedRect) break; - } - - 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; - - // Please don't ask us to make this but horizontal? - const prepareVerticalOrientationRects = (regionRects) => { - const orientations = {}; - - const upTopDown = - WikiRect.beneath( - hoverableRect.top + neededVerticalOverlap - tooltipRect.height); - - const downBottomUp = - WikiRect.above( - hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); - - 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 orientationRects = { - left: prepareVerticalOrientationRects(regionRects.left), - right: prepareVerticalOrientationRects(regionRects.right), - }; - - 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 = - closestHeading.querySelector('.content-heading-main-title') - // Just for compatibility with older builds of the site. - ?? 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('icons-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'); - } -} - -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); - -// 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.warn(`During ${key}, failed to run ${step.name}`); - console.debug(error); - } - } -} diff --git a/src/static/site-basic.css b/src/static/css/site-basic.css index 586f37b5..586f37b5 100644 --- a/src/static/site-basic.css +++ b/src/static/css/site-basic.css diff --git a/src/static/site7.css b/src/static/css/site.css index c23acffb..a9ed90c6 100644 --- a/src/static/site7.css +++ b/src/static/css/site.css @@ -40,6 +40,9 @@ body { body::before { content: ""; +} + +body::before, .wallpaper-part { position: fixed; top: 0; left: 0; @@ -58,7 +61,7 @@ body::before { #page-container { max-width: 1100px; - margin: 0 auto 40px; + margin: 0 auto 38px; padding: 15px 0; } @@ -73,10 +76,25 @@ body::before { height: unset; } +@property --banner-shine { + syntax: '<percentage>'; + initial-value: 0%; + inherits: false; +} + #banner { margin: 10px 0; width: 100%; position: relative; + + --banner-shine: 4%; + -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white)); + transition: --banner-shine 0.8s; +} + +#banner:hover { + --banner-shine: 35%; + transition-delay: 0.3s; } #banner::after { @@ -158,10 +176,9 @@ body::before { } .sidebar-column { - flex: 1 1 20%; + flex: 1 1 35%; min-width: 150px; max-width: 250px; - flex-basis: 250px; align-self: flex-start; } @@ -172,6 +189,20 @@ body::before { flex-grow: 1; } +.sidebar-column.initially-hidden { + display: none; +} + +.sidebar-column.always-content-column { + /* duplicated in thin & medium media query */ + position: static !important; + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; +} + .sidebar-multiple { display: flex; flex-direction: column; @@ -221,12 +252,22 @@ body::before { /* Design & Appearance - Layout elements */ +:root { + color-scheme: dark; +} + body { background: black; } body::before { - background-image: url("../media/bg.jpg"); + /* This is where the basic background-image rule + * gets applied... but the path *to* that media file + * isn't part of the CSS itself anymore! + */ +} + +body::before, .wallpaper-part { background-position: center; background-size: cover; opacity: 0.5; @@ -235,7 +276,11 @@ body::before { #page-container { background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); color: #ffffff; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + border-bottom: 2px solid #fff1; + box-shadow: + 0 0 40px #0008, + 0 2px 15px -3px #2221, + 0 2px 6px 2px #1113; } #skippers > * { @@ -255,6 +300,11 @@ body::before { font-weight: 800; } +#page-container:not(.showing-sidebar-left) #skippers .skipper[data-for=sidebar-left], +#page-container:not(.showing-sidebar-right) #skippers .skipper[data-for=sidebar-right] { + display: none; +} + #banner { background: black; background-color: var(--dim-color); @@ -320,6 +370,11 @@ body::before { margin: 0; } +.sidebar h2:first-child { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + .sidebar h3 { font-size: 1.1em; font-style: oblique; @@ -371,6 +426,42 @@ body::before { padding-left: 10px; } +.sidebar details.has-tree-list[open] summary { + font-weight: 800; +} + +.sidebar dl.tree-list { + margin-top: 0.25em; + line-height: 1.25em; + padding-left: 15px; +} + +.sidebar dl.tree-list dt { + display: list-item; + list-style-type: disc; + padding-left: 0; + margin-left: 20px; +} + +.sidebar dl.tree-list dl { + padding-left: 15px; +} + +.sidebar dl.tree-list dd { + margin-left: 0; +} + +.sidebar dl.tree-list dt.current a { + font-weight: 800; + border-bottom: 1px solid; +} + +.sidebar .times-used { + opacity: 0.7; + font-size: 0.9em; + cursor: default; +} + .sidebar li.current { font-weight: 800; } @@ -388,14 +479,49 @@ body::before { padding-left: 5px; } +.sidebar > details.current summary span b { + font-weight: 800; +} + +summary > span b { + font-weight: normal; + color: var(--primary-color); +} + summary > span:hover { cursor: pointer; text-decoration: underline; text-decoration-color: var(--primary-color); } -summary .group-name { - color: var(--primary-color); +summary > span:hover a { + text-decoration: none !important; +} + +summary > span:hover:has(a:hover), +summary > span:hover:has(a.nested-hover), +summary.has-nested-hover > span { + text-decoration: none !important; +} + +summary > span:hover:has(a:hover) a, +summary > span:hover:has(a.nested-hover) a, +summary.has-nested-hover > span a { + text-decoration: underline !important; +} + +summary.underline-white > span:hover { + text-decoration-color: white; +} + +/* This link isn't supposed to be underlined *at all* + * when the summary (and not link) is hovered, but + * for some reason Safari is still applying its colored + * and dotted(!) underline. Get around the apparent + * effect by just making it white. + */ +summary.underline-white > span:hover a:not(:hover) { + text-decoration-color: white; } .sidebar > details ul, @@ -432,6 +558,420 @@ summary .group-name { font-weight: normal; } +.sidebar-column.search-showing-results { + position: sticky; + top: 5px; + align-self: flex-start !important; /* pls */ +} + +.sidebar-box-joiner { + width: 0; + margin-left: auto; + margin-right: auto; + border-right: 1px dashed var(--primary-color); + height: 10px; +} + +.sidebar-box-joiner + .sidebar { + margin-top: 0 !important; +} + +.track-release-sidebar-box { + --content-padding: 3px; +} + +.track-release-sidebar-box h1 { + margin: 0; + font-weight: normal; + font-size: 0.9em; + font-style: oblique; +} + +.track-release-sidebar-box + .track-release-sidebar-box, +.track-release-sidebar-box + .track-list-sidebar-box, +.track-list-sidebar-box + .track-release-sidebar-box { + margin-top: 5px !important; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.track-release-sidebar-box:has(+ .track-list-sidebar-box), +.track-list-sidebar-box:has(+ .track-release-sidebar-box) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.track-list-sidebar-box summary { + padding-left: 20px !important; + text-indent: -15px !important; +} + +.track-list-sidebar-box .track-section-range { + white-space: nowrap; +} + +.wiki-search-sidebar-box { + padding: 1px 0 0 0; + + z-index: 100; + max-height: calc(100vh - 20px); + + display: flex; + flex-direction: column; + + background-color: #000000c0; + + -webkit-backdrop-filter: + brightness(1.2) blur(4px); + + backdrop-filter: + brightness(1.2) blur(4px); +} + +.wiki-search-sidebar-box.showing-results { + box-shadow: + 0 4px 16px -8px var(--primary-color), + 0 10px 6px var(--bg-black-color), + 0 6px 4px #00000040; +} + +/* This is to say, any sidebar that's *not* + * the first sidebar after the search box. + */ +.wiki-search-sidebar-box.showing-results + .sidebar ~ .sidebar { + margin-top: 5px; +} + +.wiki-search-sidebar-box.showing-results ~ .sidebar:not(:hover) { + opacity: 0.8; + filter: brightness(0.7); +} + +.wiki-search-label { + width: calc(100% - 4px); + padding: 2px 4px; + margin: 2px 2px 3px 2px; + box-sizing: border-box; + + display: flex; + flex-direction: row; + + background: transparent; + border: 1px solid var(--dim-color); + border-radius: 3px; +} + +.wiki-search-label::before { + display: inline-block; + padding-left: 3px; + padding-right: 3px; + margin-right: 3px; + width: 1.8em; + text-align: center; + content: '\1f50d\fe0e'; +} + +.wiki-search-input { + background: transparent; + border: transparent; + color: inherit; + flex-grow: 1; +} + +.wiki-search-input::-webkit-search-cancel-button { + filter: grayscale(1) invert(1); +} + +.wiki-search-label.disabled { + opacity: 0.6; +} + +.wiki-search-label.disabled, +.wiki-search-input[disabled] { + cursor: not-allowed; +} + +.wiki-search-label:not(.disabled):hover, +.wiki-search-label:focus-within { + background: var(--light-ghost-color); +} + +.wiki-search-label:focus-within { + border-color: var(--primary-color); +} + +.wiki-search-label:focus-within::before { + opacity: 0.7; +} + +.wiki-search-input:focus { + border: none; + outline: none; +} + +.wiki-search-input::placeholder { + color: var(--primary-color); + font-style: oblique; +} + +.wiki-search-input:focus::placeholder { + color: var(--dim-color); +} + +.wiki-search-sidebar-box hr { + border-color: var(--primary-color); + border-style: none none dotted none; + margin-top: 3px; + margin-bottom: 3px; +} + +.wiki-search-progress-container { + padding: 2px 6px 4px 6px; + display: flex; + flex-direction: row; +} + +.wiki-search-progress-label { + font-size: 0.9em; + font-style: oblique; + cursor: default; + margin-right: 1ch; +} + +.wiki-search-progress-bar { + flex-grow: 1; +} + +.wiki-search-failed-container { + padding: 2px 3px 4px 6px; +} + +.wiki-search-failed-container p { + margin: 0; +} + +.wiki-search-results-container { + margin-bottom: 0; + padding: 2px; +} + +.wiki-search-no-results { + font-size: 0.9em; + padding: 2px 3px 4px 6px; + cursor: default; +} + +.wiki-search-filter-container { + padding: 4px; +} + +.wiki-search-filter-link { + display: inline-block; + margin: 2px; + padding: 2px 4px; + border: 2px solid transparent; + border-radius: 4px; +} + +.wiki-search-filter-link:where(.active.shown) { + animation: + 0.15s ease 0.00s forwards normal show-filter, + 0.60s linear 0.15s infinite alternate blink-filter; +} + +.wiki-search-filter-link:where(.active:not(.shown)) { + animation: + 0.00s linear 0.00s forwards normal show-filter, + 0.60s linear 0.00s infinite alternate blink-filter; +} + +.wiki-search-filter-link:where(:not(.active).hidden) { + /* We can't just reverse the show-filter animation, + * because that won't actually start it over again. + */ + animation: + 0.15s ease 0.00s forwards reverse show-filter-the-sequel; +} + +.wiki-search-filter-link.active-from-query { + background: var(--primary-color); + border-color: var(--primary-color); + color: #000a; + animation: none; +} + +.wiki-search-filter-link.active-from-query::after { + content: "I"; + color: black; + font-family: monospace; + font-weight: 800; + font-size: 1.2em; + margin-left: 0.5ch; + vertical-align: middle; + animation: 1s steps(2, jump-none) 0.6s infinite blink-caret; +} + +@keyframes show-filter { + from { + background: transparent; + border-color: transparent; + color: var(--primary-color); + } + + to { + background: var(--primary-color); + border-color: var(--primary-color); + color: black; + } +} + +/* Exactly the same as show-filter above. */ +@keyframes show-filter-the-sequel { + from { + background: transparent; + border-color: transparent; + color: var(--primary-color); + } + + to { + background: var(--primary-color); + border-color: var(--primary-color); + color: black; + } +} + +@keyframes blink-filter { + to { + background: color-mix(in srgb, var(--primary-color) 90%, transparent); + } +} + +@keyframes blink-caret { + from { opacity: 0; } + to { opacity: 1; } +} + +.wiki-search-result { + position: relative; + display: flex; + padding: 4px 3px 4px 6px; +} + +.wiki-search-result:hover { + text-decoration: none !important; +} + +.wiki-search-result::before { + content: ''; + position: absolute; + top: -2px; + bottom: -2px; + left: 0; + right: 0; + + border: 1.5px solid var(--primary-color); + border-radius: 4px; + display: none; +} + +.wiki-search-result.current-result { + background: var(--light-ghost-color); + border-top: 1px solid var(--dim-color); + border-bottom: 1px solid var(--dim-color); +} + +.wiki-search-result:hover::before, +.wiki-search-result:focus::before { + display: block; + background: var(--light-ghost-color); +} + +.wiki-search-result.current-result:hover { + background: none; + border-color: transparent; +} + +.wiki-search-result.current-result:hover .wiki-search-current-result-text { + filter: saturate(0.8) brightness(1.4); +} + +.wiki-search-result-text-area { + align-self: center; + flex-grow: 1; + min-width: 0; + overflow-wrap: break-word; + padding-bottom: 2px; +} + +.wiki-search-result-name { + margin-right: 0.25em; +} + +.wiki-search-result:hover .wiki-search-result-name { + text-decoration: underline; +} + +.wiki-search-current-result-text, +.wiki-search-result-kind { + font-style: oblique; + opacity: 0.9; + display: inline-block; +} + +.wiki-search-result-image-container { + align-self: flex-start; + flex-shrink: 0; + margin-right: 6px; + border-radius: 2px; + overflow: hidden; + + background-color: var(--deep-color); + border: 2px solid var(--deep-color); +} + +.wiki-search-results:not(:has(.wiki-search-result-image)) .wiki-search-result-image-container { + display: none; +} + +.wiki-search-result-image, +.wiki-search-result-image-placeholder { + display: block; + width: 1.8em; + height: 1.8em; + aspect-ratio: 1 / 1; + border-radius: 1.5px; +} + +.wiki-search-result-image-placeholder { + background-color: #0004; + box-shadow: 0 1px 3px -1px #0008 inset; +} + +.wiki-search-result-image.has-warning { + filter: blur(2px) brightness(0.8); +} + +.wiki-search-end-search-line { + text-align: center; + margin-top: 6px; + margin-bottom: 2px; +} + +.wiki-search-end-search-line a { + display: inline-block; + font-style: oblique; + opacity: 0.9; + padding: 3px 6px 4px 6px; + border-radius: 4px; + border: 1.5px solid transparent; +} + +.wiki-search-end-search-line a:hover { + opacity: 1; + background: var(--light-ghost-color); + border-color: var(--deep-color); +} + #content { overflow-wrap: anywhere; } @@ -462,6 +1002,10 @@ a.current { font-weight: 800; } +a.series { + font-style: oblique; +} + a:not([href]) { cursor: default; } @@ -470,18 +1014,49 @@ a:not([href]):hover { text-decoration: none; } +a .normal-content { + color: white; +} + .external-link:not(.from-content) { white-space: nowrap; } .external-link.indicate-external::after { content: '\00a0➚'; + font-style: normal; } .external-link.indicate-external:hover::after { color: white; } +.image-media-link::after { + content: ''; + display: inline-block; + width: 22px; + height: 1em; + + background-color: var(--primary-color); + + /* mask-image is set in content JavaScript, + * because we can't identify the correct nor + * absolute path to the file from CSS. + */ + + mask-repeat: no-repeat; + mask-position: calc(100% - 2px); + vertical-align: text-bottom; +} + +.image-media-link:hover::after { + background-color: white; +} + +.nav-link { + display: inline-block; +} + .nav-main-links .nav-link.current > span.nav-link-content > a { font-weight: 800; } @@ -496,17 +1071,97 @@ a:not([href]):hover { font-weight: 800; } -.nav-links-hierarchical .nav-link:not(:first-child)::before { +.nav-links-hierarchical .nav-link + .nav-link::before, +.nav-links-hierarchical .nav-link + .blockwrap .nav-link::before { content: "\0020/\0020"; } -#header .chronology .heading, -#header .chronology .buttons { +.series-nav-links { + display: inline-block; +} + +.series-nav-links:not(:first-child)::before { + content: "\00a0»\00a0"; + font-weight: normal; +} + +.series-nav-links:not(:last-child)::after { + content: ",\00a0"; +} + +.series-nav-links + .series-nav-links::before { + content: ""; +} + +.dot-switcher > span:not(:first-child) { + display: inline-block; white-space: nowrap; } +/* Yeah, all this stuff only applies to elements of the dot switcher + * besides the first, which will necessarily have a bullet point at left. + */ +.dot-switcher *:where(.dot-switcher > span:not(:first-child) > *) { + display: inline-block; + white-space: wrap; + text-align: left; + vertical-align: top; +} + +.dot-switcher > span:not(:first-child)::before { + content: "\0020\00b7\0020"; + white-space: pre; + font-weight: 800; +} + +.dot-switcher > span.current { + font-weight: 800; +} + +.dot-switcher.intrapage > span:not(.current) a { + text-decoration: underline; + text-decoration-style: dotted; +} + +.dot-switcher.intrapage > span.current a { + /* Keeping cursor: pointer (the default) is intentional here. */ + text-decoration: none !important; +} + #secondary-nav { text-align: center; + + /* Default to visible. It'll automatically be hidden + * in layouts where the sidebar is visible. + */ + display: block; +} + +#secondary-nav.album-secondary-nav { + display: flex; + justify-content: space-around; + padding-left: 7.5% !important; + padding-right: 7.5% !important; + flex-wrap: wrap; + line-height: 1.4; +} + +#secondary-nav.album-secondary-nav.with-previous-next .group-with-series { + width: 100%; +} + +#secondary-nav.album-secondary-nav.with-previous-next > * { + margin-left: 5px; + margin-right: 5px; +} + +#secondary-nav.album-secondary-nav .group-nav-links .dot-switcher, +#secondary-nav.album-secondary-nav .series-nav-links .dot-switcher { + white-space: nowrap; +} + +.inert-previous-next-link { + opacity: 0.7; } .nowrap { @@ -532,7 +1187,11 @@ a:not([href]):hover { } .text-with-tooltip.datetimestamp .text-with-tooltip-interaction-cue, -.text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue { +.text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue, +.text-with-tooltip.commentary-date .text-with-tooltip-interaction-cue, +.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue, +.text-with-tooltip.rerelease .text-with-tooltip-interaction-cue, +.text-with-tooltip.first-release .text-with-tooltip-interaction-cue { cursor: default; } @@ -549,7 +1208,17 @@ a:not([href]):hover { text-decoration: none !important; } +.text-with-tooltip.wiki-edits > .hoverable { + white-space: nowrap; +} + +.isolate-tooltip-z-indexing > * { + position: relative; + z-index: -1; +} + .tooltip { + font-size: 1rem; position: absolute; z-index: 3; left: -10px; @@ -557,7 +1226,12 @@ a:not([href]):hover { display: none; } -li:not(:first-child:last-child) .tooltip, +.cover-artwork .tooltip, +#sidebar .tooltip { + font-size: 0.9rem; +} + +li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), .offset-tooltips > :not(:first-child:last-child) .tooltip { left: 14px; } @@ -578,30 +1252,47 @@ li:not(:first-child:last-child) .tooltip, box-shadow: 0 3px 4px 4px #000000aa, 0 -2px 4px -2px var(--primary-color) inset; + + text-indent: 0; } -.icons-tooltip { +.contribution-tooltip { padding: 3px 6px 6px 6px; left: -34px; } .datetimestamp-tooltip, -.missing-duration-tooltip { +.missing-duration-tooltip, +.commentary-date-tooltip, +.rerelease-tooltip, +.first-release-tooltip, +.content-tooltip { padding: 3px 4px 2px 2px; left: -10px; } -.thing-name-tooltip { +.thing-name-tooltip, +.wiki-edits-tooltip { padding: 3px 4px 2px 2px; - left: -6px !important; + left: -6px; +} - /* Terrifying? - * https://stackoverflow.com/a/64424759/4633828 - */ - margin-right: -120px; +.thing-name-tooltip .tooltip-content, +.wiki-edits-tooltip .tooltip-content { + font-size: 0.85em; +} + +.thing-name-tooltip .tooltip-content { + width: max-content; + max-width: 120px; } -.icons-tooltip .tooltip-content { +.wiki-edits-tooltip .tooltip-content { + width: max-content; + max-width: 200px; +} + +.contribution-tooltip .tooltip-content { padding: 6px 2px 2px 2px; -webkit-user-select: none; @@ -612,78 +1303,184 @@ li:not(:first-child:last-child) .tooltip, display: grid; grid-template-columns: - [icon-start] auto [icon-end domain-start] auto [domain-end]; + [icon-start] 26px [icon-end handle-start] auto [handle-end platform-start] auto [platform-end]; } -.icons-tooltip .icon { +.contribution-tooltip .external-link { + display: grid; + grid-column-start: icon-start; + grid-column-end: handle-end; + grid-template-columns: subgrid; + + height: 1.4em; +} + +.contribution-tooltip .chronology-link { + display: grid; + grid-column-start: icon-start; + grid-column-end: handle-end; + grid-template-columns: subgrid; + + height: 1.2em; +} + +.contribution-tooltip .external-icon, +.contribution-tooltip .chronology-symbol { grid-column-start: icon-start; grid-column-end: icon-end; } -.icons-tooltip .icon-platform { +.contribution-tooltip .external-icon svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.contribution-tooltip .chronology-symbol { + text-align: center; +} + +.contribution-tooltip .external-handle, +.contribution-tooltip .chronology-text { + grid-column-start: handle-start; + grid-column-end: handle-end; + + width: max-content; + max-width: 200px; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.contribution-tooltip .external-handle { + padding-right: 8px; +} + +.contribution-tooltip .chronology-text { + padding-right: 6px; +} + +.contribution-tooltip .chronology-text, +.contribution-tooltip .chronology-info { + font-size: 0.85em; +} + +.contribution-tooltip .tooltip-divider, +.tooltip-content hr.cute { + grid-column-start: icon-start; + grid-column-end: platform-end; + border-top: 1px dotted var(--primary-color); +} + +/* Don't mind me... */ +.tooltip-content .tooltip-divider, +.tooltip-content hr.cute { + margin-top: 3px; + margin-bottom: 4px; +} + +.contribution-tooltip .external-platform, +.contribution-tooltip .chronology-info { display: none; - grid-column-start: domain-start; - grid-column-end: domain-end; + grid-column-start: platform-start; + grid-column-end: platform-end; - --icon-platform-opacity: 0.8; - padding-right: 4px; + --external-platform-opacity: 0.8; opacity: 0.8; + padding-right: 4px; + + white-space: nowrap; } -.icons-tooltip.show-info .icon-platform { +.contribution-tooltip.show-info .external-platform, +.contribution-tooltip.show-info .chronology-info { display: inline; - animation: icon-platform 0.2s forwards linear; + animation: external-platform 0.2s forwards linear; } -@keyframes icon-platform { +@keyframes external-platform { from { opacity: 0; } to { - opacity: var(--icon-platform-opacity); + opacity: var(--external-platform-opacity); } } -.icons-tooltip .icon:hover + .icon-platform { - --icon-platform-opacity: 1; +.contribution-tooltip .external-link:hover, +.contribution-tooltip .chronology-link:hover { + filter: brightness(1.4); + text-decoration: none; +} + +.contribution-tooltip .external-link:hover .external-handle, +.contribution-tooltip .chronology-link:hover .chronology-text { + text-decoration: underline; +} + +.contribution-tooltip .external-link:hover + .external-platform, +.contribution-tooltip .chronology-link:hover + .chronology-info { + --external-platform-opacity: 1; text-decoration: underline; text-decoration-color: #ffffffaa; } .datetimestamp-tooltip .tooltip-content, -.missing-duration-tooltip .tooltip-content { +.missing-duration-tooltip .tooltip-content, +.commentary-date-tooltip .tooltip-content { padding: 5px 6px; white-space: nowrap; font-size: 0.9em; } -.thing-name-tooltip .tooltip-content { +.thing-name-tooltip .tooltip-content, +.wiki-edits-tooltip .tooltip-content { padding: 3px 4.5px; } -.icons { - font-style: normal; - white-space: nowrap; +.rerelease-tooltip .tooltip-content, +.first-release-tooltip .tooltip-content { + padding: 3px 4.5px; + width: 260px; + font-size: 0.9em; } -.icons a:hover { - filter: brightness(1.4); +.content-tooltip-guy .hoverable a { + text-decoration-color: transparent; + text-decoration-style: dotted; } -.icons a { - padding: 0 3px; +.content-tooltip-guy { + display: inline-block; +} + +.content-tooltip-guy.has-link .text-with-tooltip-interaction-cue { + text-decoration-color: var(--primary-color); +} + +.content-tooltip .tooltip-content { + padding: 3px 4.5px; + width: 240px; +} + +.cover-artwork .content-tooltip { + font-size: 0.85rem; + padding: 2px 3px; + width: 220px; } -.icon { +.external-icon { display: inline-block; + padding: 0 3px; width: 24px; height: 1em; position: relative; } -.icon > svg { +.external-icon svg { width: 24px; height: 24px; top: -0.25em; @@ -691,25 +1488,8 @@ li:not(:first-child:last-child) .tooltip, fill: var(--primary-color); } -.icon.has-text { - display: block; - width: unset; - height: 1.4em; -} - -.icon.has-text > svg { - width: 18px; - height: 18px; - top: -0.1em; -} - -.icon.has-text > .icon-text { - margin-left: 24px; - padding-right: 8px; -} - -.rerelease, -.other-group-accent { +.other-group-accent, +.rerelease-line { opacity: 0.7; font-style: oblique; } @@ -722,6 +1502,62 @@ li:not(:first-child:last-child) .tooltip, color: var(--page-primary-color); } +s.spoiler { + display: inline-block; + color: transparent; + text-decoration: underline; + text-decoration-color: white; + text-decoration-style: dashed; + text-decoration-skip: none; + text-decoration-skip-ink: none; +} + +s.spoiler::selection { + color: black; + background: white; +} + +s.spoiler::-moz-selection { + color: black; + background: white; +} + +span.path, code.filename { + font-size: 0.95em; + font-family: "courier new", monospace; + font-weight: 800; + background: #ccc3; + + padding: 0.05em 0.5ch; + border: 1px solid #ccce; + border-radius: 2px; + line-height: 1.4; +} + +.image-details code.filename { + margin-left: -0.4ch; + opacity: 0.8; +} + +.image-details code.filename:hover { + opacity: 1; + cursor: text; +} + +span.path i { + display: inline-block; + font-style: normal; +} + +span.path i::before { + content: "\0020/\0020"; + color: #ccc; +} + +progress { + accent-color: var(--primary-color); +} + .content-columns { columns: 2; } @@ -739,12 +1575,16 @@ p .current { font-weight: 800; } -#cover-art-container { +hr.cute, +#content hr.cute, +.sidebar hr.cute { + border-color: var(--primary-color); + border-style: none none dotted none; +} + +.cover-artwork { font-size: 0.8em; border: 2px solid var(--primary-color); - box-shadow: - 0 2px 14px -6px var(--primary-color), - 0 0 12px 12px #00000080; border-radius: 0 0 4px 4px; background: var(--bg-black-color); @@ -753,33 +1593,64 @@ p .current { backdrop-filter: blur(3px); } -#cover-art-container:has(.image-details), -#cover-art-container.has-image-details { +.cover-artwork:has(.image-details), +.cover-artwork.has-image-details { border-radius: 0 0 6px 6px; } -#cover-art-container:not(:has(.image-details)), -#cover-art-container:not(.has-image-details) { +.cover-artwork:not(:has(.image-details)), +.cover-artwork:not(.has-image-details) { /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied * if we've got tags/details visible. But it's okay, because we only * need to apply it if it *doesn't* - that's when the rounded border - * of #cover-art-container needs to cut off its child image-container + * of the .cover-artwork needs to cut off its child .image-container * (which has a background that otherwise causes sharp corners). */ overflow: hidden; } -#cover-art-container .image-container { - /* Border is handled on the cover-art-container. */ +#artwork-column .cover-artwork { + box-shadow: + 0 2px 14px -6px var(--primary-color), + 0 0 12px 12px #00000080; +} + +#artwork-column .cover-artwork:not(:first-child), +#artwork-column .cover-artwork-joiner { + margin-left: 30px; + margin-right: 5px; +} + +#artwork-column .cover-artwork:first-child + .cover-artwork-joiner, +#artwork-column .cover-artwork.attached-artwork-is-main-artwork, +#artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner { + margin-left: 17.5px; + margin-right: 17.5px; +} + +.cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) { + margin-top: 20px; +} + +#artwork-column .cover-artwork:last-child:not(:first-child) { + margin-bottom: 25px; +} + +.cover-artwork .image-container { + /* Border is handled on the .cover-artwork. */ border: none; - border-radius: 0; + border-radius: 0 !important; } -#cover-art-container .image-details { +.cover-artwork .image-details { border-top-color: var(--deep-color); } -#cover-art-container .image { +.cover-artwork .image-details + .image-details { + border-top-color: var(--primary-color); +} + +.cover-artwork .image { display: block; width: 100%; height: 100%; @@ -788,33 +1659,146 @@ p .current { .image-details { display: block; - padding: 6px 9px 4px 9px; margin-top: 0; margin-bottom: 0; + + /* Styles below only apply for first image-details. */ + + margin-left: 0; + margin-right: 0; + padding-left: 9px; + padding-right: 9px; + + padding-top: 6px; + padding-bottom: 4px; + border-top: 1px dashed var(--dim-color); } -ul.image-details li { +.image-details + .image-details { + display: block; + + margin-left: 6px; + margin-right: 6px; + padding-left: 3px; + padding-right: 3px; + + padding-top: 4px; + padding-bottom: 4px; + + border-top: 1px dotted var(--primary-color); +} + +.image-details:last-child { + margin-bottom: 2px; +} + +ul.image-details.art-tag-details { + padding-bottom: 0; +} + +ul.image-details.art-tag-details li { display: inline-block; - margin: 0; } -#cover-art-container ul li:not(:last-child)::after { +ul.image-details.art-tag-details li:not(:last-child)::after { content: " \00b7 "; } +p.image-details.illustrator-details { + text-align: center; + font-style: oblique; +} + +p.image-details.origin-details { + margin-bottom: 2px; +} + +p.image-details.origin-details .origin-details { + display: block; + margin-top: 0.25em; +} + +.cover-artwork-joiner { + z-index: -2; +} + +.cover-artwork-joiner::after { + content: ""; + display: block; + width: 0; + height: 15px; + margin-left: auto; + margin-right: auto; + border-right: 3px solid var(--primary-color); +} + +.cover-artwork-joiner + .cover-artwork { + margin-top: 0 !important; +} + +.album-art-info { + font-size: 0.8em; + border: 2px solid var(--deep-color); + + margin: 10px min(15px, 1vw) 15px; + + background: var(--bg-black-color); + padding: 6px; + border-radius: 5px; + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +.album-art-info p { + margin: 0; +} + .commentary-entry-heading { margin-left: 15px; - padding-left: 5px; - max-width: 625px; + padding-left: calc(5px + 1.25ch); + text-indent: -1.25ch; + margin-right: min(calc(8vw - 35px), 45px); padding-bottom: 0.2em; - border-bottom: 1px dotted var(--primary-color); + + border-bottom: 1px solid var(--dim-color); } .commentary-entry-accent { font-style: oblique; } +.commentary-entry-heading .commentary-date { + display: inline-block; + text-indent: 0; +} + +.commentary-entry-heading.dated .commentary-entry-heading-text { + margin-right: 0.75ch; +} + +.commentary-entry-heading .hoverable { + box-shadow: 1px 2px 6px 5px #04040460; +} + +.commentary-entry-body summary { + list-style-position: outside; +} + +.commentary-entry-body summary > span { + color: var(--primary-color); +} + +.inherited-commentary-section { + clear: right; + margin-top: 1em; + margin-right: min(4vw, 60px); + border: 2px solid var(--deep-color); + border-radius: 4px; + background: #ffffff07; +} + .commentary-art { float: right; width: 30%; @@ -829,13 +1813,42 @@ ul.image-details li { box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important; } +.lyrics-switcher { + padding-left: 20px; +} + +.lyrics-switcher > span:not(:first-child)::before { + content: "\0020\00b7\0020"; + font-weight: 800; +} + +.lyrics-entry { + padding-left: 40px; +} + +.lyrics-entry .lyrics-details, +.lyrics-entry .origin-details { + font-size: 0.9em; + font-style: oblique; +} + +.lyrics-entry .lyrics-details { + margin-bottom: 0; +} + +.lyrics-entry .origin-details { + margin-top: 0.25em; +} + .js-hide, .js-show-once-data, .js-hide-once-data { display: none; } -.content-image-container { +.content-image-container, +.content-video-container, +.content-audio-container { margin-top: 1em; margin-bottom: 1em; } @@ -846,6 +1859,25 @@ ul.image-details li { margin-bottom: 1.5em; } +.content-image-container.align-full { + width: 100%; +} + +a.align-center, img.align-center, audio.align-center, video.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +a.align-full, img.align-full, video.align-full { + width: 100%; +} + +center { + margin-top: 1em; + margin-bottom: 1em; +} + .content-image { display: inline-block !important; } @@ -897,11 +1929,6 @@ h1 { font-size: 2em; } -html[data-url-key="localized.home"] #content h1 { - text-align: center; - font-size: 2.5em; -} - #content.flash-index h2 { text-align: center; font-size: 2.5em; @@ -919,13 +1946,27 @@ html[data-url-key="localized.home"] #content h1 { margin-bottom: 0.25em; } +#content.top-index.has-subtitle h1 { + margin-bottom: 0.35em; +} + +#content.top-index h2.page-subtitle { + font-size: 1.8em; + margin-top: 0.35em; + margin-bottom: 0.5em; +} + .quick-info { text-align: center; + padding-left: calc(var(--responsive-padding-ratio) * 100%); + padding-right: calc(var(--responsive-padding-ratio) * 100%); + line-height: 1.25em; } ul.quick-info { list-style: none; padding-left: 0; + padding-right: 0; } ul.quick-info li { @@ -937,10 +1978,118 @@ ul.quick-info li:not(:last-child)::after { font-weight: 800; } -.carousel-container + .quick-info { +.carousel-container + .quick-info, +.carousel-container + .quick-description { margin-top: 25px; } +.gallery-set-switcher { + text-align: center; +} + +.gallery-view-switcher { + margin-left: auto; + margin-right: auto; + text-align: center; + line-height: 1.4; +} + +#content.top-index section { + margin-bottom: 1.5em; +} + +.expandable-gallery-section .section-expando { + margin-top: 1em; + margin-bottom: 2em; + + display: flex; + flex-direction: row; + justify-content: space-around; +} + +.expandable-gallery-section .section-expando-content { + text-align: center; + line-height: 1.5; +} + +.expandable-gallery-section .section-expando-toggle { + text-decoration: underline; + text-decoration-style: dotted; +} + +.expandable-gallery-section.expanded .section-content-below-cut { + animation: expand-gallery-section 0.8s forwards; +} + +@keyframes expand-gallery-section { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.quick-description:not(.has-external-links-only) { + --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06); + margin-left: auto; + margin-right: auto; + padding-left: calc(0.40 * var(--clamped-padding-ratio) * 100%); + padding-right: calc(0.40 * var(--clamped-padding-ratio) * 100%); + max-width: 500px; + + padding-top: 0.25em; + padding-bottom: 0.75em; + border-left: 1px solid var(--dim-color); + border-right: 1px solid var(--dim-color); + line-height: 1.25em; +} + +.quick-description.has-external-links-only { + padding-left: 12%; + padding-right: 12%; +} + +.quick-description.has-content-only { + padding-bottom: 0.5em; +} + +.quick-description p { + text-align: center; +} + +.quick-description > blockquote { + margin-left: 0 !important; + margin-right: 0 !important; +} + +.quick-description .description-content.long hr ~ p { + text-align: left; +} + +.quick-description > .description-content :first-child { + margin-top: 0; +} + +.quick-description > .quick-description-actions, +.quick-description.has-content-only .description-content :last-child { + margin-bottom: 0; +} + +.quick-description:not(.collapsed) .description-content.short, +.quick-description:not(.collapsed) .quick-description-actions.when-collapsed, +.quick-description:not(.expanded) .description-content.long, +.quick-description:not(.expanded) .quick-description-actions.when-expanded { + display: none; +} + +.quick-description .quick-description-actions .expand-link, +.quick-description .quick-description-actions .collapse-link { + text-decoration: underline; + text-decoration-style: dotted; +} + #intro-menu { margin: 24px 0; padding: 10px; @@ -960,7 +2109,6 @@ ul.quick-info li:not(:last-child)::after { li .by { font-style: oblique; - max-width: 600px; } li .by a { @@ -976,8 +2124,8 @@ p code { #content blockquote { margin-left: 40px; - max-width: 600px; - margin-right: 0; + margin-right: min(8vw, 75px); + width: auto; } #content blockquote blockquote { @@ -997,8 +2145,23 @@ p code { margin-bottom: 0; } +#content blockquote h2 { + font-size: 1em; + font-weight: 800; +} + +#content blockquote h3 { + font-size: 1em; + font-weight: normal; + font-style: oblique; +} + +main { + --responsive-padding-ratio: 0.10; +} + main.long-content { - --long-content-padding-ratio: 0.10; + --long-content-padding-ratio: var(--responsive-padding-ratio); } main.long-content .main-content-container, @@ -1009,13 +2172,19 @@ main.long-content > h1 { dl dt { padding-left: 40px; - max-width: 600px; } dl dt { + /* Heads up, this affects the measurement + * for dl dt which are .content-heading! + */ margin-bottom: 2px; } +dl dt[id]:not(.content-heading) { + --custom-scroll-offset: calc(2.5em - 2px); +} + dl dd { margin-bottom: 1em; } @@ -1031,46 +2200,111 @@ ul > li.has-details { margin-left: -17px; } -.album-group-list dt { +li .origin-details { + display: block; + margin-left: 2ch; + font-size: 0.9em; + font-style: oblique; +} + +.album-group-list dt, +.group-series-list dt { font-style: oblique; padding-left: 0; } -.album-group-list dd { +.album-group-list dd, +.group-series-list dd { margin-left: 0; } -.group-chronology-link { +.album-group-list li { + padding-left: 1.5ch; + text-indent: -1.5ch; +} + +.album-group-list li > * { + text-indent: 0; +} + +.album-group-list blockquote { + max-width: 540px; + margin-bottom: 9px; + margin-top: 3px; +} + +.album-group-list blockquote p:first-child { + margin-top: 0; +} + +.album-group-list blockquote p:last-child { + margin-bottom: 0; +} + +.group-chronology-link, +.series-chronology-link { font-style: oblique; } +.group-chronology-link a, +.series-chronology-link a { + font-style: normal; +} + +.group-view-switcher { + margin-left: 1ch; +} + #content hr { border: 1px inset #808080; - width: 100%; +} + +#content hr.split { + color: #808080; } #content hr.split::before { content: "(split)"; - color: #808080; } -#content hr.split { +#content hr.main-separator { + color: var(--dim-color); + clear: none; + margin-top: -0.25em; + margin-bottom: 1.75em; +} + +#content hr.main-separator::before { + content: "♦"; + font-size: 1.2em; +} + +#content hr.split, +#content hr.main-separator { position: relative; overflow: hidden; border: none; } -#content hr.split::after { +#content hr.split::after, +#content hr.main-separator::after { display: inline-block; content: ""; - border: 1px inset #808080; - width: 100%; + width: calc(100% - min(calc(8vw - 35px), 45px)); position: absolute; top: 50%; - margin-top: -2px; margin-left: 10px; } +#content hr.split::after { + border: 1px inset currentColor; + margin-top: -2px; +} + +#content hr.main-separator::after { + border-bottom: 1px solid currentColor; +} + li > ul { margin-top: 5px; } @@ -1146,6 +2380,14 @@ html[data-url-key="localized.albumCommentary"] .content-heading-accent { display: inline-block; } +html[data-url-key="localized.albumCommentary"] p.track-info { + margin-left: 20px; +} + +html[data-url-key="localized.groupInfo"] .by a { + color: var(--page-primary-color); +} + html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { @@ -1177,19 +2419,9 @@ h1 a[href="#additional-names-box"]:hover { --custom-scroll-offset: calc(0.5em - 2px); margin: 1em 0 1em -10px; - padding: 15px 20px 10px 20px; - width: max-content; max-width: min(60vw, 600px); - border: 1px dotted var(--primary-color); - border-radius: 6px; - - background: - linear-gradient(var(--bg-color), var(--bg-color)), - linear-gradient(#000000bb, #000000bb), - var(--primary-color); - - box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + padding: 15px 20px 10px 20px; display: none; } @@ -1226,6 +2458,203 @@ h1 a[href="#additional-names-box"]:hover { vertical-align: text-bottom; } +#content.top-index #additional-names-box { + margin-left: auto; + margin-right: auto; + margin-bottom: 2em; +} + +#content.top-index #additional-names-box { + text-align: center; + margin-bottom: 0.75em; +} + +/* Specific pages - homepage */ + +html[data-url-key="localized.home"] #content h1 { + text-align: center; + font-size: 2.5em; +} + +html[data-language-code="preview-en"][data-url-key="localized.home"] #content h1::after { + font-family: cursive; + display: block; + content: "(Preview Build)"; + font-size: 0.8em; +} + +/* Specific pages - art tag gallery */ + +html[data-url-key="localized.artTagGallery"] #descends-from-line { + margin-bottom: 0.25em; +} + +html[data-url-key="localized.artTagGallery"] #descendants-line { + margin-top: 0.25em; +} + +html[data-url-key="localized.artTagGallery"] #descends-from-line a, +html[data-url-key="localized.artTagGallery"] #descendants-line a { + display: inline-block; +} + + +html[data-url-key="localized.artTagGallery"] #featured-direct-line, +html[data-url-key="localized.artTagGallery"] #featured-indirect-line, +html[data-url-key="localized.artTagGallery"] #showing-direct-line, +html[data-url-key="localized.artTagGallery"] #showing-indirect-line { + display: none; +} + +html[data-url-key="localized.artTagGallery"] #showing-all-line a, +html[data-url-key="localized.artTagGallery"] #showing-direct-line a, +html[data-url-key="localized.artTagGallery"] #showing-indirect-line a { + text-decoration: underline; + text-decoration-style: dotted; +} + +/* Specific pages - "Art Tag Network" listing */ + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd) { + margin-left: 20px; + margin-bottom: 0; + padding-left: 10px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd):not(:last-child) { + padding-bottom: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line { + padding-left: 10px; + margin-left: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line a { + text-decoration: underline; + text-decoration-style: dotted; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] .network-tag-stat { + display: inline-block; + text-align: right; + min-width: 5ch; + margin-right: 2px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-top-dl > dt:has(.network-tag.with-stat:not([style*="display: none"])) { + padding-left: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt + dt:has(+ dd) { + padding-top: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt:has(+ dd) .network-tag-stat { + text-align: center; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt { + padding-left: 10px; + margin-left: 20px; + margin-bottom: 0; + padding-bottom: 2px; + max-width: unset; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).even, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).even { + border-left: 1px solid #eaeaea; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).odd, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).odd { + border-left: 1px solid #7b7b7b; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt { + position: relative; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).odd::after, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).odd::after { + content: ""; + display: block; + width: 7px; + height: 7px; + background: #7b7b7b; + position: absolute; + bottom: -4px; + left: -4px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).even::after, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).even::after { + content: ""; + display: block; + width: 6px; + height: 6px; + background: #eaeaea; + position: absolute; + bottom: -3px; + left: -3px; + border-bottom-right-radius: 6px; + border-top-left-radius: 3px; +} + +/* "Drops" */ + +.drop { + padding: 15px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + --drop-shadow: 0 -2px 6px -1px var(--dim-color) inset; + box-shadow: var(--drop-shadow); +} + +.drop.shiny { + cursor: default; +} + +@supports (box-shadow: 1px 1px 1px color-mix(in srgb, blue, 40% red)) { + @property --drop-shine { + syntax: '<percentage>'; + initial-value: 0%; + inherits: false; + } + + .drop.shiny { + cursor: default; + transition: --drop-shine 0.2s; + } + + .drop.shiny:hover { + --drop-shine: 100%; + + box-shadow: + var(--drop-shadow), + 0 2px 4px -0.5px color-mix(in srgb, var(--primary-color), calc(100% - var(--drop-shine)) transparent); + } +} + +.commentary-drop { + margin-top: 25px; + margin-bottom: 15px; + margin-left: 20px; + padding: 10px 20px; + max-width: min(60vw, 300px); +} + /* Images */ .image-container { @@ -1244,6 +2673,46 @@ h1 a[href="#additional-names-box"]:hover { color: white; } +/* Videos and audios (in content) get a lite version of image-container. */ +.content-video-container, +.content-audio-container { + width: fit-content; + max-width: 100%; + background-color: var(--dark-color); + border: 2px solid var(--primary-color); + border-radius: 2.5px 2.5px 3px 3px; + padding: 5px; +} + +.content-video-container video, +.content-audio-container audio { + display: block; + max-width: 100%; +} + +.content-video-container.align-center, +.content-audio-container.align-center { + margin-left: auto; + margin-right: auto; +} + +.content-video-container.align-full, +.content-audio-container.align-full { + width: 100%; +} + +.content-audio-container .filename { + color: white; + font-family: monospace; + display: block; + font-size: 0.9em; + padding-left: 1ch; + padding-right: 1ch; + padding-bottom: 0.25em; + margin-bottom: 0.5em; + border-bottom: 1px solid #fff4; +} + .image-text-area { position: absolute; top: 0; @@ -1298,6 +2767,12 @@ img { object-fit: cover; } +p > img { + max-width: 100%; + object-fit: contain; + height: auto; +} + .image-inner-area::after { content: ""; display: block; @@ -1313,7 +2788,8 @@ img { 6px -6px 2px -4px white inset; } -img.pixelate, .pixelate img { +img.pixelate, .pixelate img, +video.pixelate, .pixelate video { image-rendering: crisp-edges; } @@ -1348,7 +2824,6 @@ img.pixelate, .pixelate img { font-size: 1.6em; opacity: 0.8; - background-image: url("warning.svg"); } .reveal-interaction { @@ -1528,6 +3003,10 @@ img.pixelate, .pixelate img { max-width: 200px; } +.grid-name-marker { + color: white; +} + .grid-actions { display: flex; flex-direction: row; @@ -1839,6 +3318,13 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } +dl dt.content-heading { + /* Basic margin-bottom for dt is 2px, + * so just subtract 3px from that. + */ + margin-bottom: -1px; +} + h3.content-heading { clear: both; } @@ -1879,14 +3365,32 @@ h3.content-heading { ); } +.content-sticky-heading-root { + width: calc(100% + 2 * var(--content-padding)); + margin: calc(-1 * var(--content-padding)); + margin-bottom: 0; +} + +.content-sticky-heading-anchor, .content-sticky-heading-container { + width: 100%; +} + +.content-sticky-heading-root:not([inert]) { position: sticky; top: 0; +} - margin: calc(-1 * var(--content-padding)); - margin-bottom: calc(0.5 * var(--content-padding)); +.content-sticky-heading-anchor:not(:where(.content-sticky-heading-root[inert]) *) { + position: relative; +} + +.content-sticky-heading-container:not(:where(.content-sticky-heading-root[inert]) *) { + position: absolute; +} - transform: translateY(-5px); +.content-sticky-heading-root[inert] { + visibility: hidden; } main.long-content .content-sticky-heading-container { @@ -1926,9 +3430,60 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r grid-template-columns: 1fr min(40%, 400px); } +.content-sticky-heading-container.cover-visible .content-sticky-heading-row { + grid-template-columns: 1fr min(40%, 90px); +} + +.content-sticky-heading-root.has-cover { + padding-right: min(40%, 400px); +} + .content-sticky-heading-row h1 { + position: relative; margin: 0; padding-right: 20px; + line-height: 1.4; +} + +.content-sticky-heading-row h1 .reference-collapsed-heading { + position: absolute; + white-space: nowrap; + visibility: hidden; +} + +.content-sticky-heading-container.collapse h1 { + white-space: nowrap; + overflow-wrap: normal; + + animation: collapse-sticky-heading 0.35s forwards; + text-overflow: ellipsis; + overflow-x: hidden; +} + +@keyframes collapse-sticky-heading { + from { + height: var(--uncollapsed-heading-height); + } + + 99.9% { + height: var(--collapsed-heading-height); + } + + to { + height: auto; + } +} + +.content-sticky-heading-container h1 a { + transition: text-decoration-color 0.35s; +} + +.content-sticky-heading-container h1 a:not([href]) { + color: inherit; + cursor: text; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-color: transparent; } .content-sticky-heading-cover-container { @@ -1956,7 +3511,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: transform 0.35s, opacity 0.30s; } -.content-sticky-heading-cover .image-container { +.content-sticky-heading-cover .cover-artwork { border-width: 1px; border-radius: 1.25px; box-shadow: none; @@ -2032,40 +3587,40 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r /* Sticky sidebar */ -.sidebar-column.sidebar.sticky-column, -.sidebar-column.sidebar.sticky-last, -.sidebar-multiple.sticky-last > .sidebar:last-child, -.sidebar-multiple.sticky-column { - position: sticky; - top: 10px; -} - -.sidebar-multiple.sticky-last { +.sidebar-column:not(.sticky-column) { align-self: stretch; } -.sidebar-multiple.sticky-column { +.sidebar-column.sticky-column { + position: sticky; + top: 10px; align-self: flex-start; + max-height: calc(100vh - 20px); + display: flex; + flex-direction: column; } -.sidebar-column.sidebar.sticky-column { - max-height: calc(100vh - 20px); - align-self: start; - padding-bottom: 0; - box-sizing: border-box; - flex-basis: 275px; - padding-top: 0; +.sidebar-multiple.sticky-column .sidebar:last-child { + flex-shrink: 1; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--dim-color) var(--dark-color); +} + +.wiki-search-sidebar-box .wiki-search-results-container { overflow-y: scroll; scrollbar-width: thin; - scrollbar-color: var(--dark-color); + scrollbar-color: var(--dim-color) var(--dark-color); } -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { +.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar, +.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar { background: var(--dark-color); width: 12px; } -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { +.sidebar-column.sticky-column .sidebar:last-child::-webkit-scrollbar-thumb, +.wiki-search-sidebar-box .wiki-search-results-container::-webkit-scrollbar-thumb { transition: background 0.2s; background: rgba(255, 255, 255, 0.2); border: 3px solid transparent; @@ -2101,6 +3656,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r left: 0; right: 0; bottom: 0; + z-index: 4000; background: rgba(0, 0, 0, 0.8); color: white; @@ -2149,31 +3705,34 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r backdrop-filter: blur(3px); } -#image-overlay-image-container { +#image-overlay-image-area { display: block; - position: relative; overflow: hidden; width: 80vmin; - height: 80vmin; margin-left: auto; margin-right: auto; } +#image-overlay-image-layout { + display: block; + position: relative; + margin: 4px 3px; + background: rgba(0, 0, 0, 0.65); +} + #image-overlay-image, #image-overlay-image-thumb { - display: inline-block; - object-fit: contain; + display: block; width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.65); + height: auto; } #image-overlay-image { position: absolute; - top: 3px; - left: 3px; - width: calc(100% - 6px); - height: calc(100% - 4px); +} + +#image-overlay-container.no-thumb #image-overlay-image { + position: static; } #image-overlay-image-thumb { @@ -2187,7 +3746,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: opacity 0.25s; } -#image-overlay-image-container::after { +#image-overlay-image-area::after { content: ""; display: block; position: absolute; @@ -2200,18 +3759,18 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: 0.25s; } -#image-overlay-container.loaded #image-overlay-image-container::after { +#image-overlay-container.loaded #image-overlay-image-area::after { width: 100%; background: white; opacity: 0; } -#image-overlay-container.errored #image-overlay-image-container::after { +#image-overlay-container.errored #image-overlay-image-area::after { width: 100%; background: red; } -#image-overlay-container:not(.visible) #image-overlay-image-container::after { +#image-overlay-container:not(.visible) #image-overlay-image-area::after { width: 0 !important; } @@ -2239,20 +3798,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r font-size: 0.9em; } -/* important easter egg mode */ - -html[data-language-code="preview-en"][data-url-key="localized.home"] #content - h1::after { - font-family: cursive; - display: block; - content: "(Preview Build)"; - font-size: 0.8em; -} - /* Layout - Wide (most computers) */ -@media (min-width: 900px) { - #page-container:not(.has-zero-sidebars) #secondary-nav { +@media (min-width: 850px) { + #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible), + #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) { display: none; } } @@ -2264,20 +3814,21 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content * if so desired. */ -@media (min-width: 600px) and (max-width: 899.98px) { +@media (min-width: 600px) and (max-width: 849.98px) { /* Medium layout is mainly defined (to the user) by hiding the sidebar, so * don't apply the similar layout change of widening the long-content area * if this page doesn't have a sidebar to hide in the first place. */ - #page-container:not(.has-zero-sidebars) main.long-content { - --long-content-padding-ratio: 0.06; + #page-container.showing-sidebar-left main, + #page-container.showing-sidebar-right main { + --responsive-padding-ratio: 0.06; } } /* Layout - Wide or Medium */ @media (min-width: 600px) { - .content-sticky-heading-container { + .content-sticky-heading-root { /* Safari doesn't always play nicely with position: sticky, * this seems to fix images sometimes displaying above the * position: absolute subheading (h2) child @@ -2291,22 +3842,32 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Cover art floats to the right. It's positioned in HTML beneath the * heading, so pull it up a little to "float" on top. */ - #cover-art-container { + #artwork-column { float: right; width: 40%; - max-width: 400px; + min-width: 220px; + max-width: 280px; margin: -60px 0 10px 20px; position: relative; z-index: 2; } - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+7)) { + /* ...Except on top-indexes, where cover art is displayed prominently + * between the heading and subheading. + */ + #content.top-index #artwork-column { + float: none; + margin: 2em auto 2.5em auto; + max-width: 375px; + } + + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:not(:nth-child(n+7)) { flex-basis: 23%; margin: 15px; } - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+7) { + html[data-url-key="localized.home"] #page-container.showing-sidebar-left .grid-listing > .grid-item:nth-child(n+7) { flex-basis: 18%; margin: 10px; } @@ -2314,15 +3875,14 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Layout - Medium or Thin */ -@media (max-width: 899.98px) { +@media (max-width: 849.98px) { .sidebar.collapsible, + .sidebar-box-joiner.collapsible, .sidebar-column.all-boxes-collapsible { display: none; } - #secondary-nav { - display: block; - } + /* Duplicated for "sidebars in content column" */ .layout-columns { flex-direction: column; @@ -2333,6 +3893,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content } .sidebar-column { + position: static !important; max-width: unset !important; flex-basis: unset !important; margin-right: 0 !important; @@ -2344,11 +3905,53 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content display: none; } + .wiki-search-sidebar-box { + max-height: max(245px, 60vh, calc(100vh - 205px)); + } + + /* End duplicated for "sidebars in content column" */ + .grid-listing > .grid-item { flex-basis: 40%; } } +/* Layout - "sidebars in content column" + * This is the same code as immediately above, for medium and + * thin layouts, but can be opted into by the page itself + * instead of through a media query. + */ + +#page-container.sidebars-in-content-column +.layout-columns { + flex-direction: column; +} + +#page-container.sidebars-in-content-column +.layout-columns > *:not(:last-child) { + margin-bottom: 10px; +} + +#page-container.sidebars-in-content-column +.sidebar-column { + position: static !important; + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; +} + +#page-container.sidebars-in-content-column +.sidebar .news-entry:not(.first-news-entry) { + display: none; +} + +#page-container.sidebars-in-content-column +.wiki-search-sidebar-box { + max-height: max(245px, 60vh, calc(100vh - 205px)); +} + /* Layout - Thin (phones) */ @media (max-width: 600px) { @@ -2356,16 +3959,22 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content columns: 1; } - main.long-content { - --long-content-padding-ratio: 0.02; + main { + --responsive-padding-ratio: 0.02; } - #cover-art-container { + #artwork-column { margin: 25px 0 5px 0; width: 100%; max-width: unset; } + #artwork-column .cover-artwork:not(:first-child), + #artwork-column .cover-artwork-joiner { + margin-left: 30px; + margin-right: 30px; + } + #additional-names-box { width: unset; max-width: unset; @@ -2377,7 +3986,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Show sticky heading above cover art */ - .content-sticky-heading-container { + .content-sticky-heading-root { z-index: 2; } diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js new file mode 100644 index 00000000..71112313 --- /dev/null +++ b/src/static/js/client-util.js @@ -0,0 +1,129 @@ +/* eslint-env browser */ + +export function rebase(href, rebaseKey = 'rebaseLocalized') { + let result = document.documentElement.dataset[rebaseKey] || './'; + + if (!result.endsWith('/')) { + result += '/'; + } + + if (href.startsWith('/')) { + href = href.slice(1); + } + + result += href; + + return result; +} + +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/additional-names-box.js b/src/static/js/client/additional-names-box.js new file mode 100644 index 00000000..195ba25d --- /dev/null +++ b/src/static/js/client/additional-names-box.js @@ -0,0 +1,150 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {info as hashLinkInfo} from './hash-link.js'; +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'additionalNamesBoxInfo', + + box: null, + + links: null, + stickyHeadingLink: null, + + contentContainer: 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.stickyHeadingLink = + document.querySelector( + '.content-sticky-heading-container' + + ' ' + + 'a[href="#additional-names-box"]' + + ':not(:where([inert] *))'); + + info.contentContainer = + document.querySelector('#content'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +export function addInternalListeners() { + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); + + stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => { + const {state} = info; + + if (!info.stickyHeadingLink) return; + + const container = stickyHeadingInfo.contentContainers[index]; + if (container !== info.contentContainer) return; + + if (stuck) { + if (!state.visible) { + info.stickyHeadingLink.removeAttribute('href'); + + if (info.stickyHeadingLink.hasAttribute('title')) { + info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title'); + info.stickyHeadingLink.removeAttribute('title'); + } + } + } else { + info.stickyHeadingLink.setAttribute('href', '#additional-names-box'); + + const {restoreTitle} = info.stickyHeadingLink.dataset; + if (restoreTitle) { + info.stickyHeadingLink.setAttribute('title', restoreTitle); + delete info.stickyHeadingLink.dataset.restoreTitle; + } + } + }); +} + +export function addPageListeners() { + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const {state} = info; + + domEvent.preventDefault(); + + if (!domEvent.target.hasAttribute('href')) return; + 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()); + + const {bottom, height} = + (state.visible + ? info.box.getBoundingClientRect() + : {bottom: null}); + + const boxFitsInFrame = + (height + ? height < window.innerHeight - margin - 60 + : null); + + const worthScrolling = + top + 20 < margin || + + (height && boxFitsInFrame + ? top > 0.7 * window.innerHeight + : height && !boxFitsInFrame + ? top > 0.4 * window.innerHeight + : top > 0.5 * window.innerHeight) || + + (bottom && boxFitsInFrame + ? bottom > window.innerHeight - 20 + : false); + + if (worthScrolling) { + 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/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js new file mode 100644 index 00000000..fd40d1a2 --- /dev/null +++ b/src/static/js/client/art-tag-gallery-filter.js @@ -0,0 +1,151 @@ +/* eslint-env browser */ + +export const info = { + id: 'artTagGalleryFilterInfo', + + featuredAllLine: null, + showingAllLine: null, + showingAllLink: null, + + featuredDirectLine: null, + showingDirectLine: null, + showingDirectLink: null, + + featuredIndirectLine: null, + showingIndirectLine: null, + showingIndirectLink: null, + + gridItems: null, + gridItemsOnlyFeaturedIndirectly: null, + gridItemsFeaturedDirectly: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') { + return; + } + + info.featuredAllLine = + document.getElementById('featured-all-line'); + + info.featuredDirectLine = + document.getElementById('featured-direct-line'); + + info.featuredIndirectLine = + document.getElementById('featured-indirect-line'); + + info.showingAllLine = + document.getElementById('showing-all-line'); + + info.showingDirectLine = + document.getElementById('showing-direct-line'); + + info.showingIndirectLine = + document.getElementById('showing-indirect-line'); + + info.showingAllLink = + info.showingAllLine?.querySelector('a') ?? null; + + info.showingDirectLink = + info.showingDirectLine?.querySelector('a') ?? null; + + info.showingIndirectLink = + info.showingIndirectLine?.querySelector('a') ?? null; + + info.gridItems = + Array.from( + document.querySelectorAll('#content .grid-listing .grid-item')); + + info.gridItemsOnlyFeaturedIndirectly = + info.gridItems + .filter(gridItem => gridItem.classList.contains('featured-indirectly')); + + info.gridItemsFeaturedDirectly = + info.gridItems + .filter(gridItem => !gridItem.classList.contains('featured-indirectly')); +} + +function filterArtTagGallery(showing) { + let gridItemsToShow; + + switch (showing) { + case 'all': + gridItemsToShow = info.gridItems; + break; + + case 'direct': + gridItemsToShow = info.gridItemsFeaturedDirectly; + break; + + case 'indirect': + gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly; + break; + } + + for (const gridItem of info.gridItems) { + if (gridItemsToShow.includes(gridItem)) { + gridItem.style.removeProperty('display'); + } else { + gridItem.style.display = 'none'; + } + } +} + +export function addPageListeners() { + const orderShowing = [ + 'all', + 'direct', + 'indirect', + ]; + + const orderFeaturedLines = [ + info.featuredAllLine, + info.featuredDirectLine, + info.featuredIndirectLine, + ]; + + const orderShowingLines = [ + info.showingAllLine, + info.showingDirectLine, + info.showingIndirectLine, + ]; + + const orderShowingLinks = [ + info.showingAllLink, + info.showingDirectLink, + info.showingIndirectLink, + ]; + + for (let index = 0; index < orderShowing.length; index++) { + if (!orderShowingLines[index]) continue; + + let nextIndex = index; + do { + if (nextIndex === orderShowing.length) { + nextIndex = 0; + } else { + nextIndex++; + } + } while (!orderShowingLinks[nextIndex]); + + const currentFeaturedLine = orderFeaturedLines[index]; + const currentShowingLine = orderShowingLines[index]; + const currentShowingLink = orderShowingLinks[index]; + + const nextFeaturedLine = orderFeaturedLines[nextIndex]; + const nextShowingLine = orderShowingLines[nextIndex]; + const nextShowing = orderShowing[nextIndex]; + + currentShowingLink.addEventListener('click', event => { + event.preventDefault(); + + currentFeaturedLine.style.display = 'none'; + currentShowingLine.style.display = 'none'; + + nextFeaturedLine.style.display = 'block'; + nextShowingLine.style.display = 'block'; + + filterArtTagGallery(nextShowing); + }); + } +} diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js new file mode 100644 index 00000000..44e10c11 --- /dev/null +++ b/src/static/js/client/art-tag-network.js @@ -0,0 +1,147 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {atOffset, stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'artTagNetworkInfo', + + noneStatLink: null, + totalUsesStatLink: null, + directUsesStatLink: null, + descendantsStatLink: null, + leavesStatLink: null, + + tagsWithoutStats: null, + tagsWithStats: null, + + totalUsesStats: null, + directUsesStats: null, + descendantsStats: null, + leavesStats: null, +}; + +export function getPageReferences() { + if ( + document.documentElement.dataset.urlKey !== 'localized.listing' || + document.documentElement.dataset.urlValue0 !== 'tags/network' + ) { + return; + } + + info.noneStatLink = + document.getElementById('network-stat-none'); + + info.totalUsesStatLink = + document.getElementById('network-stat-total-uses'); + + info.directUsesStatLink = + document.getElementById('network-stat-direct-uses'); + + info.descendantsStatLink = + document.getElementById('network-stat-descendants'); + + info.leavesStatLink = + document.getElementById('network-stat-leaves'); + + info.tagsWithoutStats = + document.querySelectorAll('.network-tag:not(.with-stat)'); + + info.tagsWithStats = + document.querySelectorAll('.network-tag.with-stat'); + + info.totalUsesStats = + Array.from(document.getElementsByClassName('network-tag-total-uses-stat')); + + info.directUsesStats = + Array.from(document.getElementsByClassName('network-tag-direct-uses-stat')); + + info.descendantsStats = + Array.from(document.getElementsByClassName('network-tag-descendants-stat')); + + info.leavesStats = + Array.from(document.getElementsByClassName('network-tag-leaves-stat')); +} + +export function addPageListeners() { + if (!info.noneStatLink) return; + + const linkOrder = [ + info.noneStatLink, + info.totalUsesStatLink, + info.directUsesStatLink, + info.descendantsStatLink, + info.leavesStatLink, + ]; + + const statsOrder = [ + null, + info.totalUsesStats, + info.directUsesStats, + info.descendantsStats, + info.leavesStats, + ]; + + const stitched = + stitchArrays({ + link: linkOrder, + stats: statsOrder, + }); + + for (const [index, {link}] of stitched.entries()) { + const next = atOffset(stitched, index, +1, {wrap: true}); + + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + cssProp(link, 'display', 'none'); + cssProp(next.link, 'display', null); + + if (next.stats === null) { + hideArtTagNetworkStats(); + } else { + showArtTagNetworkStats(next.stats); + } + }); + } +} + +function showArtTagNetworkStats(stats) { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', 'none'); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', null); + } + + const allStats = [ + ...info.totalUsesStats, + ...info.directUsesStats, + ...info.descendantsStats, + ...info.leavesStats, + ]; + + const otherStats = + allStats + .filter(stat => !stats.includes(stat)); + + for (const statElement of otherStats) { + cssProp(statElement, 'display', 'none'); + } + + for (const statElement of stats) { + cssProp(statElement, 'display', null); + } +} + +function hideArtTagNetworkStats() { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', null); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', 'none'); + } +} 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..aa637cc4 --- /dev/null +++ b/src/static/js/client/css-compatibility-assistant.js @@ -0,0 +1,30 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'cssCompatibilityAssistantInfo', + + coverArtworks: null, + coverArtworkImageDetails: null, +}; + +export function getPageReferences() { + info.coverArtworks = + Array.from(document.querySelectorAll('.cover-artwork')); + + info.coverArtworkImageDetails = + info.coverArtworks + .map(artwork => artwork.querySelector('.image-details')); +} + +export function mutatePageContent() { + stitchArrays({ + coverArtwork: info.coverArtworks, + imageDetails: info.coverArtworkImageDetails, + }).forEach(({coverArtwork, imageDetails}) => { + if (imageDetails) { + coverArtwork.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/dragged-link.js b/src/static/js/client/dragged-link.js new file mode 100644 index 00000000..56021e7f --- /dev/null +++ b/src/static/js/client/dragged-link.js @@ -0,0 +1,62 @@ +/* eslint-env browser */ + +export const info = { + id: `draggedLinkInfo`, + + state: { + latestDraggedLink: null, + observedLinks: new WeakSet(), + }, +}; + +export function getPageReferences() { + // First start handling all the links that currently exist. + + for (const a of document.getElementsByTagName('a')) { + observeLink(a); + addDragListener(a); + } + + // Then add a mutation observer to track new links. + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeName !== 'A') continue; + observeLink(node); + } + } + }); + + observer.observe(document.body, { + subtree: true, + childList: true, + }); +} + +export function getLatestDraggedLink() { + const {state} = info; + + if (state.latestDraggedLink) { + return state.latestDraggedLink.deref() ?? null; + } else { + return null; + } +} + +function observeLink(link) { + const {state} = info; + + if (state.observedLinks.has(link)) return; + + state.observedLinks.add(link); + addDragListener(link); +} + +function addDragListener(link) { + const {state} = info; + + link.addEventListener('dragstart', _domEvent => { + state.latestDraggedLink = new WeakRef(link); + }); +} diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js new file mode 100644 index 00000000..dc83e8b7 --- /dev/null +++ b/src/static/js/client/expandable-gallery-section.js @@ -0,0 +1,77 @@ +/* eslint-env browser */ + +// TODO: Combine this and quick-description.js + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'expandableGallerySectionInfo', + + sections: null, + + sectionContentBelowCut: null, + + sectionExpandoToggles: null, + + sectionExpandCues: null, + sectionCollapseCues: null, +}; + +export function getPageReferences() { + info.sections = + Array.from(document.querySelectorAll('.expandable-gallery-section')) + .filter(section => section.querySelector('.section-expando-toggle')); + + info.sectionContentBelowCut = + info.sections + .map(section => section.querySelector('.section-content-below-cut')); + + info.sectionExpandoToggles = + info.sections + .map(section => section.querySelector('.section-expando-toggle')); + + info.sectionExpandCues = + info.sections + .map(section => section.querySelector('.section-expand-cue')); + + info.sectionCollapseCues = + info.sections + .map(section => section.querySelector('.section-collapse-cue')); +} + +export function addPageListeners() { + for (const { + section, + contentBelowCut, + expandoToggle, + expandCue, + collapseCue, + } of stitchArrays({ + section: info.sections, + contentBelowCut: info.sectionContentBelowCut, + expandoToggle: info.sectionExpandoToggles, + expandCue: info.sectionExpandCues, + collapseCue: info.sectionCollapseCues, + })) { + expandoToggle.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + const collapsed = + cssProp(contentBelowCut, 'display') === 'none'; + + if (collapsed) { + section.classList.add('expanded'); + cssProp(contentBelowCut, 'display', null); + cssProp(expandCue, 'display', 'none'); + cssProp(collapseCue, 'display', null); + } else { + section.classList.remove('expanded'); + cssProp(contentBelowCut, 'display', 'none'); + cssProp(expandCue, 'display', null); + cssProp(collapseCue, 'display', 'none'); + } + }); + } +} 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..89119a47 --- /dev/null +++ b/src/static/js/client/hoverable-tooltip.js @@ -0,0 +1,1102 @@ +/* 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', domEvent => { + handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget); + }); + + tooltip.addEventListener('focusout', domEvent => { + // 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(domEvent.relatedTarget)) return; + + handleTooltipLostFocus(tooltip, domEvent.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', domEvent => { + handleTooltipHoverableReceivedFocus(hoverable, domEvent); + }); + + hoverable.addEventListener('focusout', domEvent => { + handleTooltipHoverableLostFocus(hoverable, domEvent); + }); + + hoverable.addEventListener('touchend', domEvent => { + handleTooltipHoverableTouchEnded(hoverable, domEvent); + }); + + hoverable.addEventListener('click', domEvent => { + handleTooltipHoverableClicked(hoverable, domEvent); + }); +} + +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, domEvent) { + 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 + ) { + domEvent.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'); + + const isolator = + hoverable.closest('.isolate-tooltip-z-indexing > *'); + + if (isolator) { + for (const child of isolator.parentElement.children) { + cssProp(child, 'z-index', null); + } + + cssProp(isolator, 'z-index', '1'); + } + + 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 [ + ['down', 'right'], + ['down', 'left'], + ['right', 'down'], + ['left', 'down'], + ['right', 'up'], + ['left', 'up'], + ['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 mutatePageContent() { + for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) { + if (isolatorRoot.firstElementChild) { + cssProp(isolatorRoot.firstElementChild, 'z-index', '1'); + } + } +} + +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/image-overlay.js b/src/static/js/client/image-overlay.js new file mode 100644 index 00000000..e9e2708d --- /dev/null +++ b/src/static/js/client/image-overlay.js @@ -0,0 +1,385 @@ +/* eslint-env browser */ + +import {getColors} from '../../shared-util/colors.js'; + +import {cssProp} from '../client-util.js'; +import {fetchWithProgress} from '../xhr-util.js'; + +export const info = { + id: 'imageOverlayInfo', + + container: null, + actionContainer: null, + + viewOriginalLinks: null, + mainImage: null, + thumbImage: null, + + actionContentWithoutSize: null, + actionContentWithSize: null, + + megabytesContainer: null, + kilobytesContainer: null, + megabytesContent: null, + kilobytesContent: null, + fileSizeWarning: null, + + links: null, +}; + +export function getPageReferences() { + info.container = + document.getElementById('image-overlay-container'); + + if (!info.container) return; + + info.actionContainer = + document.getElementById('image-overlay-action-container'); + + info.viewOriginalLinks = + document.getElementsByClassName('image-overlay-view-original'); + + info.mainImage = + document.getElementById('image-overlay-image'); + + info.thumbImage = + document.getElementById('image-overlay-image-thumb'); + + info.actionContentWithoutSize = + document.getElementById('image-overlay-action-content-without-size'); + + info.actionContentWithSize = + document.getElementById('image-overlay-action-content-with-size'); + + info.megabytesContainer = + document.getElementById('image-overlay-file-size-megabytes'); + + info.kilobytesContainer = + document.getElementById('image-overlay-file-size-kilobytes'); + + info.megabytesContent = + info.megabytesContainer.querySelector('.image-overlay-file-size-count'); + + info.kilobytesContent = + info.kilobytesContainer.querySelector('.image-overlay-file-size-count'); + + info.fileSizeWarning = + document.getElementById('image-overlay-file-size-warning'); + + const linkQuery = [ + '.image-link', + '.image-media-link', + ].join(', '); + + info.links = + Array.from(document.querySelectorAll(linkQuery)) + .filter(link => !link.closest('.no-image-preview')); +} + +export function addPageListeners() { + if (!info.container) return; + + for (const link of info.links) { + link.addEventListener('click', handleImageLinkClicked); + } + + info.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 !== info.container) { + return; + } + + // If you clicked anything near the action bar, don't hide the + // image overlay. + const rect = info.actionContainer.getBoundingClientRect(); + if ( + evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 && + evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20 + ) { + return; + } + + info.container.classList.remove('visible'); +} + +function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + info.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; + } + + info.container.classList.add('visible'); + info.container.classList.remove('loaded'); + info.container.classList.remove('errored'); + + const details = getImageLinkDetails(evt.target); + + updateImageOverlayColors(details); + updateFileSizeInformation(details.originalFileSize); + + for (const link of info.viewOriginalLinks) { + link.href = details.originalSrc; + } + + await loadOverlayImage(details); +} + +function getImageLinkDetails(imageLink) { + const a = imageLink.closest('a'); + const img = a.querySelector('img'); + + const details = { + originalSrc: + a.href, + + embeddedSrc: + img?.src ?? + a.dataset.embedSrc, + + originalFileSize: + img?.dataset.originalSize ?? + a.dataset.originalSize ?? + null, + + availableThumbList: + img?.dataset.thumbs ?? + a.dataset.thumbs ?? + null, + + dimensions: + img?.dataset.dimensions?.split('x') ?? + a.dataset.dimensions?.split('x') ?? + null, + + color: + cssProp(imageLink, '--primary-color'), + }; + + Object.assign(details, getImageSources(details)); + + return details; +} + +function getImageSources(details) { + if (details.availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(details.availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(details.availableThumbList); + return { + mainSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`), + thumbSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`), + mainThumb: `${mainThumb}:${mainLength}`, + thumbThumb: `${smallThumb}:${smallLength}`, + }; + } else { + return { + mainSrc: details.originalSrc, + thumbSrc: null, + mainThumb: '', + thumbThumb: '', + }; + } +} + +function updateImageOverlayColors(details) { + if (details.color) { + let colors; + try { + colors = + getColors(details.color, { + chroma: window.chroma, + }); + } catch (error) { + console.warn(error); + return; + } + + cssProp(info.container, { + '--primary-color': colors.primary, + '--deep-color': colors.deep, + '--deep-ghost-color': colors.deepGhost, + '--bg-black-color': colors.bgBlack, + }); + } else { + cssProp(info.container, { + '--primary-color': null, + '--deep-color': null, + '--deep-ghost-color': null, + '--bg-black-color': null, + }); + } +} + +async function loadOverlayImage(details) { + if (details.thumbSrc) { + info.thumbImage.src = details.thumbSrc; + info.thumbImage.style.display = null; + info.container.classList.remove('no-thumb'); + } else { + info.thumbImage.src = ''; + info.thumbImage.style.display = 'none'; + info.container.classList.add('no-thumb'); + } + + // Show the thumbnail size on each <img> element's data attributes. + // Y'know, just for debugging convenience. + info.mainImage.dataset.displayingThumb = details.mainThumb; + info.thumbImage.dataset.displayingThumb = details.thumbThumb; + + if (details.dimensions) { + info.mainImage.width = details.dimensions[0]; + info.mainImage.height = details.dimensions[1]; + info.thumbImage.width = details.dimensions[0]; + info.thumbImage.height = details.dimensions[1]; + cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/')); + } else { + info.mainImage.removeAttribute('width'); + info.mainImage.removeAttribute('height'); + info.thumbImage.removeAttribute('width'); + info.thumbImage.removeAttribute('height'); + cssProp(info.thumbImage, 'aspect-ratio', null); + } + + info.mainImage.addEventListener('load', handleMainImageLoaded); + info.mainImage.addEventListener('error', handleMainImageErrored); + + const showProgress = amount => { + cssProp(info.container, '--download-progress', `${amount * 100}%`); + }; + + showProgress(0.00); + + const response = + await fetchWithProgress(details.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); + + info.mainImage.src = blobSrc; + showProgress(1.00); + + function handleMainImageLoaded() { + info.container.classList.add('loaded'); + removeEventListeners(); + } + + function handleMainImageErrored() { + info.container.classList.add('errored'); + removeEventListeners(); + } + + function removeEventListeners() { + info.mainImage.removeEventListener('load', handleMainImageLoaded); + info.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; + + if (!fileSize) { + info.actionContentWithSize.classList.remove('visible'); + info.actionContentWithoutSize.classList.add('visible'); + return; + } + + info.actionContentWithoutSize.classList.remove('visible'); + info.actionContentWithSize.classList.add('visible'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + info.fileSizeWarning.classList.add('visible'); + } else { + info.fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + info.megabytesContainer.classList.add('visible'); + info.kilobytesContainer.classList.remove('visible'); + info.megabytesContent.innerText = round(6); + } else { + info.megabytesContainer.classList.remove('visible'); + info.kilobytesContainer.classList.add('visible'); + info.kilobytesContent.innerText = round(3); + } +} diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js new file mode 100644 index 00000000..aeb9264a --- /dev/null +++ b/src/static/js/client/index.js @@ -0,0 +1,237 @@ +/* eslint-env browser */ + +import '../group-contributions-table.js'; + +import * as additionalNamesBoxModule from './additional-names-box.js'; +import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; +import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js'; +import * as artTagNetworkModule from './art-tag-network.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 draggedLinkModule from './dragged-link.js'; +import * as expandableGallerySectionModule from './expandable-gallery-section.js'; +import * as hashLinkModule from './hash-link.js'; +import * as hoverableTooltipModule from './hoverable-tooltip.js'; +import * as imageOverlayModule from './image-overlay.js'; +import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.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, + artTagGalleryFilterModule, + artTagNetworkModule, + artistExternalLinkTooltipModule, + cssCompatibilityAssistantModule, + datetimestampTooltipModule, + draggedLinkModule, + expandableGallerySectionModule, + hashLinkModule, + hoverableTooltipModule, + imageOverlayModule, + intrapageDotSwitcherModule, + 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/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js new file mode 100644 index 00000000..d06bc5a6 --- /dev/null +++ b/src/static/js/client/intrapage-dot-switcher.js @@ -0,0 +1,82 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'intrapageDotSwitcherInfo', + + // Each is a two-level array, by switcher. + // This is an evil data structure. + switcherSpans: null, + switcherLinks: null, + switcherTargets: null, +}; + +export function getPageReferences() { + const switchers = + Array.from(document.querySelectorAll('.dot-switcher.intrapage')); + + info.switcherSpans = + switchers + .map(switcher => switcher.querySelectorAll(':scope > span')) + .map(spans => Array.from(spans)); + + info.switcherLinks = + info.switcherSpans + .map(spans => spans + .map(span => span.querySelector(':scope > a'))); + + info.switcherTargets = + info.switcherLinks + .map(links => links + .map(link => { + const targetID = link.getAttribute('data-target-id'); + const target = document.getElementById(targetID); + if (target) { + return target; + } else { + console.warn( + `An intrapage dot switcher option is targetting an ID that doesn't exist, #${targetID}`, + link); + link.setAttribute('inert', ''); + return null; + } + })); +} + +export function addPageListeners() { + for (const {links, spans, targets} of stitchArrays({ + spans: info.switcherSpans, + links: info.switcherLinks, + targets: info.switcherTargets, + })) { + for (const [index, {span, link, target}] of stitchArrays({ + span: spans, + link: links, + target: targets, + }).entries()) { + const otherSpans = + [...spans.slice(0, index), ...spans.slice(index + 1)]; + + const otherTargets = + [...targets.slice(0, index), ...targets.slice(index + 1)]; + + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + for (const otherSpan of otherSpans) { + otherSpan.classList.remove('current'); + } + + for (const otherTarget of otherTargets) { + cssProp(otherTarget, 'display', 'none'); + } + + span.classList.add('current'); + cssProp(target, 'display', 'block'); + }); + } + } +} 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..6a7a6023 --- /dev/null +++ b/src/static/js/client/quick-description.js @@ -0,0 +1,64 @@ +/* eslint-env browser */ + +// TODO: Combine this and expandable-gallery-section.js + +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..8b8d8a13 --- /dev/null +++ b/src/static/js/client/scripted-link.js @@ -0,0 +1,285 @@ +/* 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) => { + if (!el) return; + if (!el.hasAttribute('href')) return; + + 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) => { + const {tagName} = document.activeElement ?? {}; + if (tagName === 'INPUT' || tagName === 'TEXTAREA') { + return; + } + + 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..eae1e74e --- /dev/null +++ b/src/static/js/client/sidebar-search.js @@ -0,0 +1,1351 @@ +/* eslint-env browser */ + +import {getColors} from '../../shared-util/colors.js'; +import {accumulateSum, empty, unique} from '../../shared-util/sugar.js'; + +import { + cssProp, + openAlbum, + openArtist, + openArtTag, + openFlash, + openGroup, + openTrack, + rebase, + templateContent, +} from '../client-util.js'; + +import {getLatestDraggedLink} from './dragged-link.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, + + filterContainer: null, + albumFilterLink: null, + artistFilterLink: null, + flashFilterLink: null, + groupFilterLink: null, + tagFilterLink: null, + trackFilterLink: null, + + resultsRule: null, + resultsContainer: null, + results: null, + + endSearchRule: null, + endSearchLine: null, + endSearchLink: null, + + standbyInputPlaceholder: null, + + preparingString: null, + loadingDataString: null, + searchingString: null, + failedString: null, + + noResultsString: null, + currentResultString: null, + endSearchString: null, + + albumResultKindString: null, + artistResultKindString: null, + groupResultKindString: null, + tagResultKindString: null, + + albumResultFilterString: null, + artistResultFilterString: null, + flashResultFilterString: null, + groupResultFilterString: null, + tagResultFilterString: null, + trackResultFilterString: null, + + state: { + sidebarColumnShownForSearch: null, + + tidiedSidebar: null, + collapsedDetailsForTidiness: null, + + recallingRecentSearch: null, + recallingRecentSearchFromMouse: null, + + currentValue: null, + + workerStatus: null, + searchStage: null, + + stoppedTypingTimeout: null, + stoppedScrollingTimeout: null, + focusFirstResultTimeout: null, + dismissChangeEventTimeout: null, + + indexDownloadStatuses: Object.create(null), + }, + + session: { + activeQuery: { + type: 'string', + }, + + activeQueryResults: { + type: 'json', + maxLength: settings => settings.maxActiveResultsStorage, + }, + + activeFilterType: { + type: 'string', + }, + + repeatQueryOnReload: { + type: 'boolean', + default: false, + }, + + resultsScrollOffset: { + type: 'number', + }, + }, + + settings: { + stoppedTypingDelay: 800, + stoppedScrollingDelay: 200, + + pressDownToFocusFirstResultLatency: 500, + dismissChangeEventAfterFocusingFirstResultLatency: 50, + + 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'); + + info.standbyInputPlaceholder = + info.searchInput.placeholder; + + 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'); + + info.albumResultFilterString = + findString('album-result-filter'); + + info.artistResultFilterString = + findString('artist-result-filter'); + + info.flashResultFilterString = + findString('flash-result-filter'); + + info.groupResultFilterString = + findString('group-result-filter'); + + info.tagResultFilterString = + findString('tag-result-filter'); + + info.trackResultFilterString = + findString('track-result-filter'); +} + +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); + + // Filter section + + info.filterContainer = + document.createElement('div'); + + info.filterContainer.classList.add('wiki-search-filter-container'); + + cssProp(info.filterContainer, 'display', 'none'); + + forEachFilter((type, _filterLink) => { + // TODO: It's probably a sin to access `session` during this step LOL + const {session} = info; + + const filterLink = document.createElement('a'); + + filterLink.href = '#'; + filterLink.classList.add('wiki-search-filter-link'); + + if (session.activeFilterType === type) { + filterLink.classList.add('active'); + } + + const string = info[type + 'ResultFilterString']; + filterLink.appendChild(templateContent(string)); + + info[type + 'FilterLink'] = filterLink; + + info.filterContainer.appendChild(filterLink); + }); + + info.searchBox.appendChild(info.filterContainer); + + // 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('mousedown', _domEvent => { + const {state} = info; + + if (state.recallingRecentSearch) { + state.recallingRecentSearchFromMouse = true; + } + }); + + info.searchInput.addEventListener('focus', _domEvent => { + const {session, state} = info; + + if (state.recallingRecentSearch) { + info.searchInput.value = session.activeQuery; + info.searchInput.placeholder = info.standbyInputPlaceholder; + showSidebarSearchResults(session.activeQueryResults); + state.recallingRecentSearch = false; + } + }); + + info.searchLabel.addEventListener('click', domEvent => { + const {state} = info; + + if (state.recallingRecentSearchFromMouse) { + if (info.searchInput.selectionStart === info.searchInput.selectionEnd) { + info.searchInput.select(); + } + + state.recallingRecentSearchFromMouse = false; + return; + } + + const inputRect = info.searchInput.getBoundingClientRect(); + if (domEvent.clientX < inputRect.left - 3) { + info.searchInput.select(); + } + }); + + info.searchInput.addEventListener('change', _domEvent => { + const {state} = info; + + if (state.dismissChangeEventTimeout) { + state.dismissChangeEventTimeout = null; + clearTimeout(state.dismissChangeEventTimeout); + return; + } + + activateSidebarSearch(info.searchInput.value); + }); + + info.searchInput.addEventListener('input', _domEvent => { + const {settings, state} = info; + + if (!info.searchInput.value) { + clearSidebarSearch(); // ...but don't clear filter + return; + } + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + } + + state.stoppedTypingTimeout = + setTimeout(() => { + state.stoppedTypingTimeout = null; + activateSidebarSearch(info.searchInput.value); + }, settings.stoppedTypingDelay); + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + } + }); + + info.searchInput.addEventListener('drop', handleDroppedIntoSearchInput); + + info.searchInput.addEventListener('keydown', domEvent => { + const {settings, state} = info; + + if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') { + domEvent.preventDefault(); + } + + if (domEvent.key === 'ArrowDown') { + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + } + + state.focusFirstResultTimeout = + setTimeout(() => { + state.focusFirstResultTimeout = null; + }, settings.pressDownToFocusFirstResultLatency); + + activateSidebarSearch(info.searchInput.value); + } else { + focusFirstSidebarSearchResult(); + } + } + }); + + document.addEventListener('selectionchange', _domEvent => { + const {state} = info; + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + } + }); + + info.endSearchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + clearSidebarSearch(); + clearSidebarFilter(); + possiblyHideSearchSidebarColumn(); + restoreSidebarSearchColumn(); + }); + + forEachFilter((type, filterLink) => { + filterLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + toggleSidebarSearchFilter(type); + }); + }); + + 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) { + if (session.repeatQueryOnReload) { + info.searchInput.value = session.activeQuery; + activateSidebarSearch(session.activeQuery); + } else if (session.activeQueryResults) { + considerRecallingRecentSidebarSearch(); + } + } +} + +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]; + } + } + } +} + +function forEachFilter(callback) { + const filterOrder = [ + 'track', + 'album', + 'artist', + 'group', + 'flash', + 'tag', + ]; + + for (const type of filterOrder) { + callback(type, info[type + 'FilterLink']); + } +} + +async function activateSidebarSearch(query) { + const {session, state} = info; + + if (!query) { + return; + } + + 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); + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + focusFirstSidebarSearchResult(); + } +} + +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 clearSidebarFilter() { + const {session} = info; + + toggleSidebarSearchFilter(session.activeFilterType); + + forEachFilter((_type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + }); +} + +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) { + const {session} = info; + + console.debug(`Showing search results:`, tidyResults(results)); + + showSearchSidebarColumn(); + + info.searchBox.classList.add('showing-results'); + info.searchSidebarColumn.classList.add('search-showing-results'); + + let filterType = session.activeFilterType; + let shownAnyResults = + fillResultElements(results, {filterType: session.activeFilterType}); + + showFilterElements(results); + + if (!shownAnyResults) { + shownAnyResults = toggleSidebarSearchFilter(filterType); + filterType = null; + } + + if (shownAnyResults) { + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); + + tidySidebarSearchColumn(); + } else { + const p = document.createElement('p'); + p.classList.add('wiki-search-no-results'); + p.appendChild(templateContent(info.noResultsString)); + info.results.appendChild(p); + } + + restoreSidebarSearchResultsScrollOffset(); +} + +function tidyResults(results) { + const tidiedResults = + results.results.map(({doc, id}) => ({ + reference: id ?? null, + referenceType: (id ? id.split(':')[0] : null), + directory: (id ? id.split(':')[1] : null), + data: doc, + })); + + return tidiedResults; +} + +function fillResultElements(results, { + filterType = null, +} = {}) { + const tidiedResults = tidyResults(results); + + const filteredResults = + (filterType + ? tidiedResults.filter(result => result.referenceType === filterType) + : tidiedResults); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.resultsRule, 'display', 'block'); + cssProp(info.resultsContainer, 'display', 'block'); + + if (empty(filteredResults)) { + return false; + } + + for (const result of filteredResults) { + const el = generateSidebarSearchResult(result); + if (!el) continue; + + info.results.appendChild(el); + } + + return true; +} + +function showFilterElements(results) { + const {queriedKind} = results; + + const tidiedResults = tidyResults(results); + + const allReferenceTypes = + unique(tidiedResults.map(result => result.referenceType)); + + let shownAny = false; + + forEachFilter((type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + + if (allReferenceTypes.includes(type)) { + shownAny = true; + cssProp(filterLink, 'display', null); + + if (queriedKind) { + filterLink.setAttribute('inert', 'inert'); + } else { + filterLink.removeAttribute('inert'); + } + + if (type === queriedKind) { + filterLink.classList.add('active-from-query'); + } else { + filterLink.classList.remove('active-from-query'); + } + } else { + cssProp(filterLink, 'display', 'none'); + } + }); + + if (shownAny) { + cssProp(info.filterContainer, 'display', null); + } else { + cssProp(info.filterContainer, 'display', 'none'); + } +} + +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(); + }); + + link.addEventListener('keydown', domEvent => { + if (domEvent.key === 'ArrowDown') { + const elem = link.nextElementSibling; + if (elem) { + domEvent.preventDefault(); + elem.focus({focusVisible: true}); + } + } else if (domEvent.key === 'ArrowUp') { + domEvent.preventDefault(); + const elem = link.previousElementSibling; + if (elem) { + elem.focus({focusVisible: true}); + } else { + info.searchInput.focus(); + } + } + }); + + return link; +} + +function hideSidebarSearchResults() { + cssProp(info.filterContainer, 'display', 'none'); + + 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 focusFirstSidebarSearchResult() { + const {settings, state} = info; + + const elem = info.results.firstChild; + if (!elem?.classList.contains('wiki-search-result')) { + return; + } + + if (state.dismissChangeEventTimeout) { + clearTimeout(state.dismissChangeEventTimeout); + } + + state.dismissChangeEventTimeout = + setTimeout(() => { + state.dismissChangeEventTimeout = null; + }, settings.dismissChangeEventAfterFocusingFirstResultLatency); + + elem.focus({focusVisible: true}); +} + +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 toggleSidebarSearchFilter(toggleType) { + const {session} = info; + + if (!toggleType) return null; + + let shownAnyResults = null; + + forEachFilter((type, filterLink) => { + if (type === toggleType) { + const filterActive = filterLink.classList.toggle('active'); + const filterType = (filterActive ? type : null); + + if (cssProp(filterLink, 'display') !== 'none') { + filterLink.classList.add(filterActive ? 'shown' : 'hidden'); + } + + if (session.activeQueryResults) { + shownAnyResults = + fillResultElements(session.activeQueryResults, {filterType}); + } + + session.activeFilterType = filterType; + } else { + filterLink.classList.remove('active'); + } + }); + + return shownAnyResults; +} + +function restoreSidebarSearchColumn() { + const {state} = info; + + if (!state.tidiedSidebar) { + return; + } + + for (const details of state.collapsedDetailsForTidiness) { + details.setAttribute('open', ''); + } + + state.collapsedDetailsForTidiness = []; + state.tidiedSidebar = null; + + info.searchInput.placeholder = info.standbyInputPlaceholder; +} + +function considerRecallingRecentSidebarSearch() { + const {session, state} = info; + + if (document.documentElement.dataset.urlKey === 'localized.home') { + return forgetRecentSidebarSearch(); + } + + info.searchInput.placeholder = session.activeQuery; + state.recallingRecentSearch = true; +} + +function forgetRecentSidebarSearch() { + const {session} = info; + + session.activeQuery = null; + session.activeQueryResults = null; + + clearSidebarFilter(); +} + +async function handleDroppedIntoSearchInput(domEvent) { + const itemByType = type => + Array.from(domEvent.dataTransfer.items) + .find(item => item.type === type); + + const textItem = itemByType('text/plain'); + + if (!textItem) return; + + domEvent.preventDefault(); + + const getAssTring = item => + new Promise(res => item.getAsString(res)) + .then(string => string.trim()); + + const timer = Date.now(); + + let droppedText = + await getAssTring(textItem); + + if (Date.now() - timer > 500) return; + if (!droppedText) return; + + let droppedURL; + try { + droppedURL = new URL(droppedText); + } catch { + droppedURL = null; + } + + if (droppedURL) matchLink: { + const isDroppedURL = a => + a.toString() === droppedURL.toString(); + + const matchingLinks = + Array.from(document.getElementsByTagName('a')) + .filter(a => + isDroppedURL(new URL(a.href, document.documentURI))); + + const latestDraggedLink = getLatestDraggedLink(); + + if (!matchingLinks.includes(latestDraggedLink)) { + break matchLink; + } + + let matchedLink = latestDraggedLink; + + if (matchedLink.querySelector('.normal-content')) { + matchedLink = matchedLink.cloneNode(true); + for (const node of matchedLink.querySelectorAll('.normal-content')) { + node.remove(); + } + } + + droppedText = matchedLink.innerText; + } + + if (droppedText.includes('-')) splitDashes: { + if (droppedURL) break splitDashes; + if (droppedText.includes(' ')) break splitDashes; + + const parts = droppedText.split('-'); + if (parts.length === 2) break splitDashes; + + droppedText = parts.join(' '); + } + + info.searchInput.value = droppedText; + activateSidebarSearch(info.searchInput.value); +} diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js new file mode 100644 index 00000000..b65574d0 --- /dev/null +++ b/src/static/js/client/sticky-heading.js @@ -0,0 +1,345 @@ +/* eslint-env browser */ + +import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {cssProp, dispatchInternalEvent, templateContent} + from '../client-util.js'; + +export const info = { + id: 'stickyHeadingInfo', + + stickyRoots: null, + + stickyContainers: null, + staticContainers: null, + + stickyHeadingRows: null, + stickyHeadings: null, + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCoverColumns: null, + contentCovers: null, + contentCoversReveal: null, + + referenceCollapsedHeading: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + whenStuckStatusChanges: [], + }, +}; + +export function getPageReferences() { + info.stickyRoots = + Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])')); + + info.stickyContainers = + info.stickyRoots + .map(el => el.querySelector('.content-sticky-heading-container')); + + info.staticContainers = + info.stickyRoots + .map(el => el.nextElementSibling); + + 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.stickyHeadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-row')); + + info.stickyHeadings = + info.stickyHeadingRows + .map(el => el.querySelector('h1')); + + 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.closest('.content-sticky-heading-root').parentElement); + + info.contentCoverColumns = + info.contentContainers + .map(el => el.querySelector('#artwork-column')); + + info.contentCovers = + info.contentCoverColumns + .map(el => el ? el.querySelector('.cover-artwork') : null); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); + + info.referenceCollapsedHeading = + info.stickyHeadings + .map(el => el.querySelector('.reference-collapsed-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 updateStuckStatus(index) { + const {event} = info; + + const contentContainer = info.contentContainers[index]; + const stickyContainer = info.stickyContainers[index]; + + const wasStuck = stickyContainer.classList.contains('stuck'); + const stuck = topOfViewInside(contentContainer); + + if (stuck === wasStuck) return; + + if (stuck) { + stickyContainer.classList.add('stuck'); + } else { + stickyContainer.classList.remove('stuck'); + } + + dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck); +} + +function updateCollapseStatus(index) { + const stickyContainer = info.stickyContainers[index]; + const staticContainer = info.staticContainers[index]; + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + + const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect(); + const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect(); + + if ( + staticContainer.getBoundingClientRect().bottom < 4 || + staticContainer.getBoundingClientRect().top < -80 + ) { + if (!stickyContainer.classList.contains('collapse')) { + stickyContainer.classList.add('collapse'); + cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px'); + cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px'); + } + } else { + stickyContainer.classList.remove('collapse'); + } +} + +function updateStickyCoverVisibility(index) { + const stickyCoverContainer = info.stickyCoverContainers[index]; + const stickyContainer = info.stickyContainers[index]; + const contentCoverColumn = info.contentCoverColumns[index]; + + if (contentCoverColumn && stickyCoverContainer) { + if (contentCoverColumn.getBoundingClientRect().bottom < 4) { + stickyCoverContainer.classList.add('visible'); + stickyContainer.classList.add('cover-visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + stickyContainer.classList.remove('cover-visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickyHeadingRow = info.stickyHeadingRows[index]; + const stickyRect = stickyHeadingRow.getBoundingClientRect(); + + // Subheadings only appear when the sticky heading is collapsed, + // so the used bottom edge should always be *as though* it's only + // displaying one line of text. Subtract the current discrepancy. + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + const correctBottomEdge = + stickyHeading.getBoundingClientRect().height - + referenceCollapsedHeading.getBoundingClientRect().height; + + const stickyBottom = + (stickyRect.bottom + - correctBottomEdge); + + // 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 + 40) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const {event, state} = info; + + const stickyContainer = info.stickyContainers[index]; + + const closestHeading = + (stickyContainer.classList.contains('collapse') + ? getContentHeadingClosestToStickySubheading(index) + : null); + + 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) { + updateStuckStatus(index); + updateCollapseStatus(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/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/lazy-loading.js b/src/static/js/lazy-loading.js index 1df56f08..1df56f08 100644 --- a/src/static/lazy-loading.js +++ b/src/static/js/lazy-loading.js 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/module-import-shims.js b/src/static/js/module-import-shims.js new file mode 100644 index 00000000..e7e1e0cc --- /dev/null +++ b/src/static/js/module-import-shims.js @@ -0,0 +1,27 @@ +export const loadDependency = { + async fromWindow(modulePath) { + globalThis.window = {}; + + await import(modulePath); + + const exports = globalThis.window; + + delete globalThis.window; + + return exports; + }, + + async fromModuleExports(modulePath) { + globalThis.exports = {}; + globalThis.module = {exports: globalThis.exports}; + + await import(modulePath); + + const exports = globalThis.exports; + + delete globalThis.module; + delete globalThis.exports; + + return exports; + }, +}; diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js new file mode 100644 index 00000000..b00ed98e --- /dev/null +++ b/src/static/js/rectangles.js @@ -0,0 +1,555 @@ +/* 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, + }); + } + + // Other utilities + + #display = null; + + display() { + if (!this.#display) { + this.#display = document.createElement('div'); + document.body.appendChild(this.#display); + } + + Object.assign(this.#display.style, { + position: 'fixed', + background: '#000c', + border: '3px solid var(--primary-color)', + borderRadius: '4px', + top: this.top + 'px', + left: this.left + 'px', + width: this.width + 'px', + height: this.height + 'px', + pointerEvents: 'none', + }); + + let i = 0; + const int = setInterval(() => { + i++; + if (i >= 3) clearInterval(int); + if (!this.#display) return; + + this.#display.style.display = 'none'; + setTimeout(() => { + this.#display.style.display = ''; + }, 200); + }, 600); + } + + hide() { + if (this.#display) { + this.#display.remove(); + this.#display = null; + } + } +} diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js new file mode 100644 index 00000000..e32b4ad5 --- /dev/null +++ b/src/static/js/search-worker.js @@ -0,0 +1,655 @@ +/* eslint-env worker */ + +import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js'; + +import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js'; + +import { + empty, + groupArray, + promiseWithResolvers, + stitchArrays, + unique, + withEntries, +} from '../shared-util/sugar.js'; + +import {loadDependency} from './module-import-shims.js'; +import {fetchWithProgress} from './xhr-util.js'; + +// Will be loaded from dependencies. +let decompress; +let unpack; + +let idb; + +let status = null; +let indexes = null; + +onmessage = handleWindowMessage; +onerror = handleRuntimeError; +onunhandledrejection = handleRuntimeError; +postStatus('alive'); + +Promise.all([ + loadDependencies(), + loadDatabase(), +]).then(main) + .then( + () => { + postStatus('ready'); + }, + error => { + console.error(`Search worker setup error:`, error); + postStatus('setup-error'); + }); + +async function loadDependencies() { + const {compressJSON} = + await loadDependency.fromWindow('../lib/compress-json/bundle.min.js'); + + const msgpackr = + await loadDependency.fromModuleExports('../lib/msgpackr/index.js'); + + ({decompress} = compressJSON); + ({unpack} = msgpackr); +} + +async function promisifyIDBRequest(request) { + const {promise, resolve, reject} = promiseWithResolvers(); + + request.addEventListener('success', () => resolve(request.result)); + request.addEventListener('error', () => reject(request.error)); + + return promise; +} + +async function* iterateIDBObjectStore(store, query) { + const request = + store.openCursor(query); + + let promise, resolve, reject; + let cursor; + + request.onsuccess = () => { + cursor = request.result; + if (cursor) { + resolve({done: false, value: [cursor.key, cursor.value]}); + } else { + resolve({done: true}); + } + }; + + request.onerror = () => { + reject(request.error); + }; + + do { + ({promise, resolve, reject} = promiseWithResolvers()); + + const result = await promise; + + if (result.done) { + return; + } + + yield result.value; + + cursor.continue(); + } while (true); +} + +async function loadCachedIndexFromIDB() { + if (!idb) return null; + + const transaction = + idb.transaction(['indexes'], 'readwrite'); + + const store = + transaction.objectStore('indexes'); + + const result = {}; + + for await (const [key, object] of iterateIDBObjectStore(store)) { + result[key] = object; + } + + return result; +} + +async function loadDatabase() { + const request = + globalThis.indexedDB.open('hsmusicSearchDatabase', 4); + + request.addEventListener('upgradeneeded', () => { + const idb = request.result; + + idb.createObjectStore('indexes', { + keyPath: 'key', + }); + }); + + try { + idb = await promisifyIDBRequest(request); + } catch { + console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`); + console.warn(request.error); + idb = null; + } +} + +function rebase(path) { + return `/search-data/` + path; +} + +async function prepareIndexData() { + return Promise.all([ + fetch(rebase('index.json')) + .then(resp => resp.json()), + + loadCachedIndexFromIDB(), + ]).then( + ([indexData, idbIndexData]) => + ({indexData, idbIndexData})); +} + +function fetchIndexes(keysNeedingFetch) { + if (!empty(keysNeedingFetch)) { + postMessage({ + kind: 'download-begun', + context: 'search-indexes', + keys: keysNeedingFetch, + }); + } + + return ( + keysNeedingFetch.map(key => + fetchWithProgress( + rebase(key + '.json.msgpack'), + progress => { + postMessage({ + kind: 'download-progress', + context: 'search-indexes', + progress: progress / 1.00, + key, + }); + }).then(response => { + postMessage({ + kind: 'download-complete', + context: 'search-indexes', + key, + }); + + return response; + }))); +} + +async function main() { + const prepareIndexDataPromise = prepareIndexData(); + + indexes = + withEntries(searchSpec, entries => entries + .map(([key, descriptor]) => [ + key, + makeSearchIndex(descriptor, {FlexSearch}), + ])); + + const {indexData, idbIndexData} = await prepareIndexDataPromise; + + const keysNeedingFetch = + (idbIndexData + ? Object.keys(indexData) + .filter(key => + indexData[key].md5 !== + idbIndexData[key]?.md5) + : Object.keys(indexData)); + + const keysFromCache = + Object.keys(indexData) + .filter(key => !keysNeedingFetch.includes(key)) + + const cacheArrayBufferPromises = + keysFromCache + .map(key => idbIndexData[key]) + .map(({cachedBinarySource}) => + cachedBinarySource.arrayBuffer()); + + const fetchPromises = + fetchIndexes(keysNeedingFetch); + + const fetchBlobPromises = + fetchPromises + .map(promise => promise + .then(response => response.blob())); + + const fetchArrayBufferPromises = + fetchBlobPromises + .map(promise => promise + .then(blob => blob.arrayBuffer())); + + function arrayBufferToJSON(data) { + data = new Uint8Array(data); + data = unpack(data); + data = decompress(data); + return data; + } + + function importIndexes(keys, jsons) { + stitchArrays({key: keys, json: jsons}) + .forEach(({key, json}) => { + importIndex(key, json); + }); + } + + if (idb) { + console.debug(`Reusing indexes from search cache:`, keysFromCache); + console.debug(`Fetching indexes anew:`, keysNeedingFetch); + } + + await Promise.all([ + async () => { + const cacheArrayBuffers = + await Promise.all(cacheArrayBufferPromises); + + const cacheJSONs = + cacheArrayBuffers + .map(arrayBufferToJSON); + + importIndexes(keysFromCache, cacheJSONs); + }, + + async () => { + const fetchArrayBuffers = + await Promise.all(fetchArrayBufferPromises); + + const fetchJSONs = + fetchArrayBuffers + .map(arrayBufferToJSON); + + importIndexes(keysNeedingFetch, fetchJSONs); + }, + + async () => { + if (!idb) return; + + const fetchBlobs = + await Promise.all(fetchBlobPromises); + + const transaction = + idb.transaction(['indexes'], 'readwrite'); + + const store = + transaction.objectStore('indexes'); + + for (const {key, blob} of stitchArrays({ + key: keysNeedingFetch, + blob: fetchBlobs, + })) { + const value = { + key, + md5: indexData[key].md5, + cachedBinarySource: blob, + }; + + try { + await promisifyIDBRequest(store.put(value)); + } catch (error) { + console.warn(`Error saving ${key} to internal search cache:`, value); + console.warn(error); + continue; + } + } + }, + ].map(fn => fn())); +} + +function importIndex(indexKey, indexData) { + // If this fails, it's because an outdated index was cached. + // TODO: If this fails, try again once with a cache busting url. + for (const [key, value] of Object.entries(indexData)) { + indexes[indexKey].import(key, JSON.stringify(value)); + } +} + +function handleRuntimeError() { + postStatus('runtime-error'); +} + +function handleWindowMessage(message) { + switch (message.data.kind) { + case 'action': + handleWindowActionMessage(message); + break; + + default: + console.warn(`Unknown message kind -> to search worker:`, message.data); + break; + } +} + +async function handleWindowActionMessage(message) { + const {id} = message.data; + + if (!id) { + console.warn(`Action without id -> to search worker:`, message.data); + return; + } + + if (status !== 'ready') { + return postActionResult(id, 'reject', 'not ready'); + } + + let value; + + switch (message.data.action) { + case 'search': + value = await performSearchAction(message.data.options); + break; + + default: + console.warn(`Unknown action "${message.data.action}" -> to search worker:`, message.data); + return postActionResult(id, 'reject', 'unknown action'); + } + + await postActionResult(id, 'resolve', value); +} + +function postStatus(newStatus) { + status = newStatus; + postMessage({ + kind: 'status', + status: newStatus, + }); +} + +function postActionResult(id, status, value) { + postMessage({ + kind: 'result', + id, + status, + value, + }); +} + +function performSearchAction({query, options}) { + const {queriedKind} = processTerms(query); + const genericResults = queryGenericIndex(query, options); + const verbatimResults = queryVerbatimIndex(query, options); + + const verbatimIDs = + new Set(verbatimResults?.map(result => result.id)); + + const commonResults = + (verbatimResults && genericResults + ? genericResults + .filter(({id}) => verbatimIDs.has(id)) + : verbatimResults ?? genericResults); + + return { + results: commonResults, + queriedKind, + }; +} + +const interestingFieldCombinations = [ + ['primaryName', 'parentName', 'groups'], + ['primaryName', 'parentName'], + ['primaryName', 'groups', 'contributors'], + ['primaryName', 'groups', 'artTags'], + ['primaryName', 'groups'], + ['primaryName', 'contributors'], + ['primaryName', 'artTags'], + ['parentName', 'groups', 'artTags'], + ['parentName', 'artTags'], + ['groups', 'contributors'], + ['groups', 'artTags'], + + // This prevents just matching *everything* tagged "john" if you + // only search "john", but it actually supports matching more than + // *two* tags at once: "john rose lowas" works! This is thanks to + // flexsearch matching multiple field values in a single query. + ['artTags', 'artTags'], + + ['contributors', 'parentName'], + ['contributors', 'groups'], + ['primaryName', 'contributors'], + ['primaryName'], +]; + +function queryGenericIndex(query, options) { + return queryIndex({ + indexKey: 'generic', + termsKey: 'genericTerms', + }, query, options); +} + +function queryVerbatimIndex(query, options) { + return queryIndex({ + indexKey: 'verbatim', + termsKey: 'verbatimTerms', + }, query, options); +} + +function queryIndex({termsKey, indexKey}, query, options) { + const interestingFields = + unique(interestingFieldCombinations.flat()); + + const {[termsKey]: terms, queriedKind} = + processTerms(query); + + if (empty(terms)) return null; + + const particles = + particulate(terms); + + const groupedParticles = + groupArray(particles, ({length}) => length); + + const queriesBy = keys => + (groupedParticles.get(keys.length) ?? []) + .flatMap(permutations) + .map(values => values.map(({terms}) => terms.join(' '))) + .map(values => + stitchArrays({ + field: keys, + query: values, + })); + + const boilerplate = queryBoilerplate(indexes[indexKey]); + + const particleResults = + Object.fromEntries( + interestingFields.map(field => [ + field, + Object.fromEntries( + particles.flat() + .map(({terms}) => terms.join(' ')) + .map(query => [ + query, + new Set( + boilerplate + .query(query, { + ...options, + field, + limit: Infinity, + }) + .fieldResults[field]), + ])), + ])); + + let matchedResults = new Set(); + + for (const interestingFieldCombination of interestingFieldCombinations) { + for (const query of queriesBy(interestingFieldCombination)) { + const [firstQueryFieldLine, ...restQueryFieldLines] = query; + + const commonAcrossFields = + new Set( + particleResults + [firstQueryFieldLine.field] + [firstQueryFieldLine.query]); + + for (const currQueryFieldLine of restQueryFieldLines) { + const tossResults = new Set(commonAcrossFields); + + const keepResults = + particleResults + [currQueryFieldLine.field] + [currQueryFieldLine.query]; + + for (const result of keepResults) { + tossResults.delete(result); + } + + for (const result of tossResults) { + commonAcrossFields.delete(result); + } + } + + for (const result of commonAcrossFields) { + matchedResults.add(result); + } + } + } + + matchedResults = Array.from(matchedResults); + + const filteredResults = + (queriedKind + ? matchedResults.filter(id => id.split(':')[0] === queriedKind) + : matchedResults); + + const constitutedResults = + boilerplate.constitute(filteredResults); + + return constitutedResults; +} + +function processTerms(query) { + const kindTermSpec = [ + {kind: 'album', terms: ['album', 'albums']}, + {kind: 'artist', terms: ['artist', 'artists']}, + {kind: 'flash', terms: ['flash', 'flashes']}, + {kind: 'group', terms: ['group', 'groups']}, + {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']}, + {kind: 'track', terms: ['track', 'tracks']}, + ]; + + const genericTerms = []; + const verbatimTerms = []; + let queriedKind = null; + + const termRegexp = + new RegExp( + String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` + + String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` + + String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` + + String.raw`|[^\s\-]+`, + 'gi'); + + for (const match of query.matchAll(termRegexp)) { + const {groups} = match; + + if (groups.kind && !queriedKind) { + queriedKind = + kindTermSpec + .find(({terms}) => terms.includes(groups.kind.toLowerCase())) + .kind; + + continue; + } + + const verbatim = groups.regularVerbatim || groups.curlyVerbatim; + if (verbatim) { + verbatimTerms.push(verbatim); + continue; + } + + genericTerms.push(match[0]); + } + + return {genericTerms, verbatimTerms, queriedKind}; +} + +function particulate(terms) { + if (empty(terms)) return []; + + const results = []; + + for (let slice = 1; slice <= 2; slice++) { + if (slice === terms.length) { + break; + } + + const front = terms.slice(0, slice); + const back = terms.slice(slice); + + results.push(... + particulate(back) + .map(result => [ + {terms: front}, + ...result + ])); + } + + results.push([{terms}]); + + return results; +} + +// This function doesn't even come close to "performant", +// but it only operates on small data here. +function permutations(array) { + switch (array.length) { + case 0: + return []; + + case 1: + return [array]; + + default: + return array.flatMap((item, index) => { + const behind = array.slice(0, index); + const ahead = array.slice(index + 1); + return ( + permutations([...behind, ...ahead]) + .map(rest => [item, ...rest])); + }); + } +} + +function queryBoilerplate(index) { + const idToDoc = {}; + + return { + idToDoc, + + constitute: (ids) => + Array.from(ids) + .map(id => ({id, doc: idToDoc[id]})), + + query: (query, options) => { + const rawResults = + index.search(query, options); + + const fieldResults = + Object.fromEntries( + rawResults + .map(({field, result}) => [ + field, + result.map(result => + (typeof result === 'string' + ? result + : result.id)), + ])); + + Object.assign( + idToDoc, + Object.fromEntries( + rawResults + .flatMap(({result}) => result) + .map(({id, doc}) => [id, doc]))); + + return {rawResults, fieldResults}; + }, + }; +} diff --git a/src/static/xhr-util.js b/src/static/js/xhr-util.js index 8a43072c..8a43072c 100644 --- a/src/static/xhr-util.js +++ b/src/static/js/xhr-util.js diff --git a/src/static/icons.svg b/src/static/misc/icons.svg index 8c9a80a9..87cb0169 100644 --- a/src/static/icons.svg +++ b/src/static/misc/icons.svg @@ -34,7 +34,9 @@ <path d="M 21.67012,2.5595806 C 21.281556,2.6425058 21.182046,2.7041073 20.355164,3.3769857 20.042418,3.6304997 19.575668,4.0072168 19.319784,4.2109758 18.793802,4.6327094 18.656383,4.7724975 18.535549,5.0165344 L 18.452624,5.1894925 17.308257,5.9926819 C 16.678026,6.4357391 16.092811,6.8479955 16.005148,6.9095971 L 15.848774,7.0209537 15.69714,6.9190742 15.545505,6.8148255 13.022211,6.4428469 C 11.633807,6.239088 10.475225,6.0661298 10.449162,6.059022 10.408885,6.0519141 10.378084,5.9997897 10.321221,5.8434166 10.226449,5.585164 10.001367,5.1468453 9.9326572,5.0852438 9.8639478,5.0212729 9.3237497,4.7014188 9.2858411,4.7014188 9.2479324,4.7014188 9.2479324,4.7037881 9.2811025,4.6066472 9.3024261,4.5474149 9.3355962,4.5189835 9.4327371,4.4763363 9.5725252,4.4123654 9.5843716,4.3886725 9.6341267,4.0190633 9.6578196,3.8484744 9.6554503,3.8129351 9.6246495,3.76318 9.5938488,3.7181635 9.5891102,3.6684084 9.6009566,3.5072966 L 9.6175417,3.305907 9.5322472,3.2656291 C 9.4777536,3.2395669 9.4351063,3.1945504 9.4066749,3.1329488 9.3403347,2.9836836 9.1365758,2.7917711 8.9541405,2.7041073 8.7977673,2.6306593 8.7835516,2.6282901 8.4850211,2.6282901 8.2007063,2.6282901 8.1675362,2.6330286 8.0395945,2.6922609 7.7837112,2.8130947 7.5491515,3.0926709 7.5112429,3.3201227 7.4993964,3.3983093 7.4828114,3.4243715 7.430687,3.4456951 7.2885296,3.5049274 7.2766831,3.5404667 7.2766831,3.8792752 7.2766831,4.2346687 7.2885296,4.2725773 7.430687,4.3389174 7.4828114,4.3626103 7.5254586,4.3934111 7.5254586,4.4099961 7.5254586,4.4242119 7.5301972,4.4526434 7.5373051,4.4692284 7.5467822,4.4952906 7.4614878,4.5284606 7.1724344,4.6042779 6.9639369,4.6611409 6.7649165,4.7251117 6.7270079,4.7488046 6.6748835,4.7796054 6.6346056,4.8506841 6.5493111,5.071028 6.3858301,5.4785459 6.3052742,5.7770765 6.246042,6.1917022 6.1702247,6.712946 6.2010255,6.9356593 6.3905687,7.2223434 6.4308466,7.2839449 6.4616474,7.3360693 6.4569088,7.3408079 6.4521702,7.3455464 6.2081334,7.5113967 5.9143414,7.7104171 5.2106623,8.1890137 4.0354944,9.0680203 3.9738929,9.1627919 3.8909677,9.2907335 3.7535489,11.00373 3.7203789,12.370811 L 3.7037938,12.993934 3.3104917,13.344589 C 2.7157999,13.870571 2.2229876,14.344429 1.5548478,15.022046 0.79193642,15.796804 0.78008997,15.81102 0.73744275,16.017148 0.69716482,16.21143 0.64504044,16.801383 0.65925618,16.924586 0.66873334,16.998034 0.64977902,17.038312 0.51946807,17.227855 0.05982581,17.888887 -0.09180875,18.675491 0.05271794,19.620838 0.19013676,20.51406 0.77772068,21.321988 1.3842589,21.44993 1.4742919,21.468884 3.966785,21.475992 10.354391,21.475992 H 19.196581 L 19.362431,21.378851 C 19.748626,21.146661 20.293562,20.663326 20.646587,20.241592 21.184415,19.597145 21.698551,18.587827 21.961543,17.661435 22.205579,16.803752 22.309828,16.038471 22.321675,14.995984 L 22.331152,14.379968 22.66996,14.135932 C 23.07274,13.844509 23.108279,13.813708 23.127233,13.728414 23.148557,13.626534 23.10354,13.493854 22.913997,13.095813 22.473309,12.174159 21.947327,11.607899 21.288664,11.342539 20.926163,11.195643 19.736779,11.032162 18.739308,10.989514 18.29862,10.97056 18.33179,10.994253 18.327052,10.695723 18.324682,10.463532 18.286774,10.321375 18.210956,10.266881 18.168309,10.23608 18.056953,10.212387 17.83187,10.183956 17.656543,10.162632 17.509647,10.143678 17.504908,10.141309 17.502539,10.13657 17.488323,10.044168 17.476477,9.9351804 17.443307,9.6698199 17.348535,9.2788871 17.237178,8.9448172 17.185054,8.7955519 17.144776,8.6699796 17.144776,8.6676103 17.144776,8.6628717 17.258502,8.5989009 17.400659,8.5254529 17.540447,8.4496356 18.128031,8.13452 18.706138,7.824143 L 19.760472,7.260252 H 19.959493 C 20.06848,7.260252 20.222484,7.243667 20.30304,7.2223434 20.419135,7.1915426 21.146507,6.8906428 22.890304,6.1514243 23.018246,6.0969306 23.243328,5.9476653 23.394963,5.8197237 23.641369,5.6088569 23.859344,5.2558327 23.949377,4.9170242 24.010978,4.6824645 24.018086,4.2180836 23.961223,3.9858932 23.781157,3.2466747 23.129603,2.6496137 22.395123,2.5477342 22.151086,2.5121948 21.859663,2.5169334 21.67012,2.5595806 Z M 22.357214,2.9268206 C 22.786055,2.99553 23.179358,3.2703676 23.418656,3.6636698 23.631892,4.0214326 23.688755,4.5331992 23.553705,4.9146549 23.40444,5.3387578 23.006399,5.7273214 22.594143,5.8505245 22.414077,5.9050181 22.018406,5.9239724 21.831232,5.8884331 21.317096,5.7865536 20.838499,5.3671893 20.672649,4.874377 20.644217,4.7843439 20.615786,4.6232322 20.606309,4.490552 L 20.592093,4.2631001 20.53523,4.4052576 C 20.504429,4.4834441 20.471259,4.6137551 20.464151,4.6966802 L 20.449936,4.8435762 19.620684,5.4145751 18.791433,5.9832047 18.772478,5.8813252 C 18.717985,5.5899026 18.810387,5.2202933 18.99993,4.9928415 19.158672,4.800929 20.33621,3.8105658 21.018565,3.2964298 21.475838,2.9481442 21.854925,2.8438954 22.357214,2.9268206 Z M 8.9778334,3.2822141 C 9.2147624,3.3177534 9.4303678,3.3746164 9.4493221,3.4054172 9.4801229,3.452803 9.4706457,3.6447155 9.4303678,3.7750264 9.3948284,3.8982295 9.3948284,3.8982295 9.3071647,3.8911216 9.2289781,3.8840138 9.2171317,3.8745366 9.1815923,3.7773957 9.1436837,3.6778855 9.1342065,3.6684084 9.032327,3.649454 8.8878004,3.6233919 8.8238295,3.6423462 8.7717051,3.7300099 8.7480122,3.7702878 8.7029957,3.8129351 8.6698257,3.8271508 8.6034856,3.8508437 8.3404944,3.8555823 8.2078141,3.8342587 8.1296276,3.8200429 8.1154118,3.8081965 8.0940882,3.7323792 8.0822418,3.6849934 8.0703953,3.5760061 8.0703953,3.4883423 8.0703953,3.2585212 8.0751339,3.2561519 8.4755439,3.2561519 8.6579792,3.2561519 8.8854311,3.2679984 8.9778334,3.2822141 Z M 9.0773436,3.943246 C 9.0962979,4.000109 9.1270986,4.0380176 9.179223,4.0593412 9.269256,4.0996191 9.269256,4.1043577 9.2052852,4.310486 9.1602687,4.4550126 9.1484223,4.4715977 9.0583892,4.5142449 8.925709,4.5734771 8.7574894,4.5711079 8.6129627,4.5095063 8.4755439,4.4502741 8.4281581,4.3768261 8.4092038,4.1920215 L 8.394988,4.0688184 8.5466226,4.0522333 C 8.7385351,4.0285404 8.8309374,3.9906318 8.8735846,3.9100759 8.9020161,3.8603209 8.9233397,3.8484744 8.9802027,3.853213 9.0370656,3.8579516 9.0560199,3.8769059 9.0773436,3.943246 Z M 7.8287277,5.0497044 C 8.3120629,5.5306703 9.0844514,5.6207033 9.326119,5.2274012 9.3971977,5.111306 9.4019363,5.1089367 9.4753843,5.1302603 9.6791432,5.1871233 9.6815125,5.1894925 9.6815125,5.3624507 9.6815125,5.5093467 9.7123133,5.7794458 9.743114,5.8979103 9.7549605,5.9500346 9.7502219,5.9500346 9.4753843,5.9642504 9.2976875,5.9737275 9.1318372,5.9974204 9.0181113,6.0305905 8.9209704,6.059022 8.5466226,6.2248723 8.1912291,6.3978304 7.6747239,6.6489752 7.5349358,6.7105767 7.5136121,6.6868838 7.4662263,6.6347594 7.210343,6.2153951 7.2198202,6.2059179 7.2269281,6.2011794 7.2885296,6.2343494 7.3596083,6.2793659 L 7.4899192,6.3622911 7.4970271,6.1419471 C 7.5088736,5.8647402 7.4685956,5.5140853 7.3927784,5.2250319 7.3619776,5.1018288 7.340654,4.9952108 7.3477618,4.9881029 7.357239,4.980995 7.4330563,4.9620407 7.52072,4.947825 7.6083837,4.9312399 7.6818317,4.9193935 7.6865703,4.9170242 7.6889396,4.9170242 7.7529104,4.9762564 7.8287277,5.0497044 Z M 18.542657,5.7249521 C 18.542657,5.895541 18.59952,6.1419471 18.668229,6.2864738 L 18.722723,6.4049383 16.448205,7.790973 C 15.19485,8.5515151 14.16184,9.1675305 14.152363,9.155684 14.140516,9.1462068 14.126301,9.0727589 14.119193,8.992203 14.102608,8.8216141 14.149993,8.6747181 14.251873,8.5775772 14.320582,8.5112371 18.495271,5.6064876 18.526072,5.6041183 18.535549,5.601749 18.542657,5.658612 18.542657,5.7249521 Z M 12.595739,6.7982405 C 14.100238,7.0138458 15.353593,7.1939119 15.379655,7.2010198 15.405717,7.2057583 15.445995,7.2247127 15.469688,7.2412977 15.507597,7.2697292 15.476796,7.2981606 15.166419,7.5161353 L 14.822872,7.7601722 13.25914,7.5445668 C 12.273516,7.4095173 11.662239,7.3360693 11.610115,7.3455464 11.517712,7.3621315 11.328169,7.480596 11.306845,7.5350896 11.299738,7.554044 11.3258,7.6914628 11.363708,7.8407281 11.456111,8.1984908 11.52482,8.6841953 11.543774,9.1130368 11.560359,9.4471067 11.55799,9.4636917 11.515343,9.4636917 11.437156,9.4636917 10.195648,9.2978414 10.183802,9.2883642 10.179063,9.2812564 10.160109,9.1296218 10.141155,8.9519251 10.079553,8.3193246 9.9326572,7.6914628 9.6886204,7.0019994 9.6175417,6.7982405 9.5535708,6.6015894 9.546463,6.56605 9.5275087,6.4618013 9.5914795,6.4073076 9.7383755,6.4073076 9.8047156,6.4073076 11.09124,6.5826351 12.595739,6.7982405 Z M 13.247294,7.8881139 C 13.870417,7.9734083 14.384553,8.0468563 14.389292,8.0515949 14.39403,8.0563334 14.292151,8.1321507 14.164209,8.2198145 14.02916,8.3122168 13.915434,8.4046191 13.898849,8.4425277 13.839616,8.5681001 13.711675,8.9400786 13.602687,9.2978414 13.541086,9.4992311 13.484223,9.6721892 13.474746,9.6816664 13.467638,9.6911435 13.164369,9.6650814 12.801867,9.6248034 12.230869,9.5584633 12.145574,9.5442476 12.15979,9.5134468 12.169267,9.4921232 12.211914,9.3736587 12.256931,9.2480863 12.42515,8.7576433 12.380134,8.3027396 12.121881,7.9023296 12.069757,7.8217737 12.02711,7.750695 12.02711,7.7459565 12.02711,7.7246328 12.117143,7.73411 13.247294,7.8881139 Z M 16.846245,9.0111573 C 16.933909,9.2338706 17.040527,9.6698199 17.073697,9.9328111 17.087913,10.053645 17.09739,10.157894 17.092652,10.160263 17.085544,10.16974 16.405558,10.086815 16.322632,10.067861 16.272877,10.056014 16.272877,10.053645 16.310786,9.9446576 16.339217,9.8617324 16.348695,9.7290522 16.351064,9.4423681 L 16.353433,9.0561738 16.542976,8.9519251 C 16.644856,8.8950621 16.741997,8.8476763 16.758582,8.8476763 16.772797,8.8476763 16.813075,8.9211243 16.846245,9.0111573 Z M 15.983824,9.6224341 C 15.971977,9.7290522 15.950654,9.8617324 15.934069,9.9138568 15.910376,9.9920434 15.89616,10.008628 15.844036,10.008628 15.737418,10.006259 14.938967,9.8949025 14.922382,9.8783175 14.884473,9.8427781 14.986353,9.776438 15.46258,9.5252932 L 15.971977,9.2575635 15.988562,9.3428579 C 15.99804,9.3902437 15.99567,9.5158161 15.983824,9.6224341 Z M 14.06233,10.162632 C 16.152043,10.416146 17.872148,10.624644 17.883994,10.624644 17.893472,10.624644 17.902949,10.693353 17.902949,10.778648 V 10.932652 H 17.635219 C 17.29878,10.932652 17.23007,10.965822 17.038158,11.209858 16.943386,11.333062 16.888893,11.382817 16.853353,11.382817 16.824922,11.382817 16.052533,11.30463 15.140357,11.207489 L 13.477115,11.034531 13.339696,10.911328 C 12.915593,10.532241 12.406196,10.297682 11.768857,10.186325 11.337646,10.112877 10.366237,10.008628 10.103246,10.008628 10.065337,10.008628 10.060599,9.9849355 10.060599,9.8404088 V 9.6745585 L 10.162478,9.6887743 C 10.216972,9.6958821 11.972616,9.9091182 14.06233,10.162632 Z M 10.084292,10.420885 C 11.261829,10.501441 11.59116,10.541719 12.055541,10.660183 12.380134,10.740739 12.522291,10.802341 12.716573,10.93739 13.081444,11.190904 13.446314,11.6387 13.66192,12.098342 13.749583,12.285516 13.853832,12.5722 13.841986,12.584047 13.82777,12.600632 10.003736,12.259454 9.9895202,12.242869 9.980043,12.233392 9.9445037,12.13862 9.9065951,12.034371 9.7146826,11.479957 9.3877205,11.046377 9.015742,10.849726 8.8190909,10.747847 8.8214602,10.747847 8.378403,10.890004 7.8026655,11.077178 6.956829,11.43968 6.1749633,11.835351 5.8172005,12.015417 5.6134416,12.122035 4.8718538,12.522445 4.7462814,12.591155 4.7439121,12.591155 4.8126215,12.531922 4.9002853,12.456105 5.5636865,12.008309 5.8669556,11.816397 6.776763,11.247767 7.7766033,10.762063 8.6508714,10.465901 L 8.9588791,10.361653 9.2550403,10.373499 C 9.4185213,10.380607 9.7904998,10.401931 10.084292,10.420885 Z M 18.779586,11.382817 C 19.575668,11.425464 20.70345,11.574729 21.042258,11.683716 21.478207,11.823505 21.883356,12.157574 22.212687,12.648017 22.33589,12.832822 22.577558,13.256925 22.615467,13.356435 22.63916,13.415667 22.63679,13.420406 22.575189,13.420406 22.489894,13.420406 19.556713,13.13846 19.551975,13.131353 19.549605,13.126614 19.523543,13.029473 19.495112,12.911009 19.334,12.266562 18.893312,11.745318 18.251234,11.43968 18.151724,11.392294 18.068799,11.352016 18.068799,11.347277 18.068799,11.344908 18.118554,11.344908 18.182525,11.349647 18.244127,11.354385 18.511856,11.368601 18.779586,11.382817 Z M 15.384394,11.643439 C 16.990772,11.797442 17.050004,11.80692 17.424352,11.991724 17.886364,12.216807 18.234649,12.541399 18.409977,12.90627 18.549765,13.197693 18.594781,13.403821 18.608997,13.832662 L 18.620844,14.185687 18.516595,14.171471 C 18.459732,14.164363 17.33195,14.060114 16.014625,13.939281 L 13.614534,13.718937 V 13.562563 13.40619 L 13.946235,13.091075 C 14.247134,12.80676 14.277935,12.768851 14.277935,12.695403 14.277935,12.517707 14.031529,11.875629 13.846724,11.577098 L 13.799339,11.501281 H 13.858571 C 13.891741,11.501281 14.578835,11.565252 15.384394,11.643439 Z M 9.0394349,12.8731 C 9.4114134,13.598103 9.5132929,14.531603 9.3237497,15.469842 9.1531608,16.31094 8.6840414,17.092805 8.124889,17.462415 7.8239891,17.663804 7.6486617,17.718298 7.3003761,17.720667 7.0658163,17.720667 6.9899991,17.71119 6.8596881,17.666174 6.5469418,17.559556 6.2531499,17.313149 6.0185902,16.960125 5.7011053,16.483898 5.5399936,15.960285 5.4997157,15.27556 L 5.4831306,15.019677 5.6205494,14.924905 C 5.6963667,14.872781 5.7674454,14.825395 5.7769226,14.823026 5.7863997,14.818287 5.7958769,14.927274 5.7958769,15.062324 5.7958769,15.199743 5.8029848,15.327684 5.8100926,15.349008 5.8219391,15.379809 5.8645863,15.386917 6.0209595,15.386917 6.1275775,15.386917 6.2649963,15.394025 6.3242286,15.401132 L 6.4308466,15.415348 6.4687553,15.574091 C 6.4877096,15.659385 6.5137718,15.756526 6.5208796,15.787327 L 6.5374647,15.84419 6.2270877,15.832343 C 5.878802,15.818127 5.8906485,15.81102 5.9427729,15.981608 L 5.9688351,16.074011 H 6.2957971 6.6227591 L 6.7080536,16.24223 C 6.8691653,16.564454 7.0895092,16.820337 7.3216997,16.950648 L 7.4377949,17.019358 7.3453926,17.038312 C 7.210343,17.066743 6.8644267,17.059635 6.7672858,17.026465 6.6914685,17.000403 6.6890992,17.002772 6.7293772,17.033573 6.8170409,17.099913 7.0397541,17.170992 7.2056045,17.180469 7.3998862,17.194685 7.684201,17.128345 7.8524206,17.031204 7.994578,16.948279 8.2386149,16.713719 8.3760337,16.528914 8.9138625,15.801542 9.1128829,14.64059 8.8617382,13.702352 8.8048752,13.491485 8.6485021,13.133722 8.5466226,12.979718 L 8.4968675,12.90627 8.6816721,12.77359 C 8.7811823,12.700142 8.8759539,12.64091 8.8925389,12.63854 8.9067547,12.63854 8.9730948,12.745158 9.0394349,12.8731 Z M 11.778334,13.396713 C 12.581523,13.448837 13.25914,13.491485 13.285203,13.491485 13.32785,13.491485 13.330219,13.512808 13.330219,13.977189 V 14.465263 L 13.218862,14.451047 C 13.154892,14.443939 12.941656,14.427354 12.740266,14.413139 12.446474,14.391815 12.373026,14.394184 12.356441,14.417877 12.346964,14.434462 12.330379,14.526864 12.320902,14.624005 12.309055,14.759055 12.313794,14.806441 12.337487,14.830134 12.365918,14.853826 13.022211,14.913059 13.268618,14.913059 13.311265,14.913059 13.313634,14.927274 13.299418,15.202112 13.282833,15.47695 13.1928,16.287247 13.173846,16.308571 13.169107,16.313309 12.955871,16.303832 12.699988,16.289616 12.318532,16.265923 12.22613,16.265923 12.202437,16.291985 12.185852,16.308571 12.157421,16.400973 12.140836,16.493375 12.117143,16.630794 12.117143,16.671072 12.140836,16.694765 12.162159,16.716088 12.323271,16.735043 12.619432,16.751628 12.868208,16.765843 13.074336,16.782429 13.079074,16.787167 13.098029,16.806121 12.92744,17.483738 12.818452,17.820178 L 12.707096,18.158986 12.562569,18.156617 C 12.484383,18.156617 12.264039,18.14714 12.074495,18.135293 11.820981,18.121077 11.719102,18.123447 11.688301,18.142401 11.633807,18.17794 11.503497,18.47884 11.520082,18.526226 11.539036,18.573612 11.603007,18.58072 12.081603,18.599674 12.311424,18.606782 12.500968,18.623367 12.500968,18.632844 12.500968,18.687338 12.140836,19.355477 11.984462,19.592406 L 11.799658,19.871983 11.25709,19.867244 10.712154,19.862506 10.593689,20.009401 C 10.363868,20.293716 10.370976,20.312671 10.686091,20.317409 10.804556,20.319778 11.008315,20.326886 11.136257,20.336364 L 11.373186,20.350579 11.200227,20.499845 C 10.998838,20.672803 10.759539,20.836284 10.508395,20.976072 L 10.328329,21.073213 8.6911493,21.068474 7.0516006,21.061366 7.2624674,20.900255 C 8.4708053,19.983339 9.4635378,18.17794 9.8686864,16.156936 9.9989974,15.50775 10.053491,14.995984 10.08903,14.053006 L 10.117462,13.301942 H 10.216972 C 10.273835,13.301942 10.975145,13.344589 11.778334,13.396713 Z M 20.608678,14.216487 21.880987,14.330213 21.892833,14.424985 C 21.899941,14.479479 21.909418,14.631113 21.916526,14.763793 L 21.926003,15.00783 H 21.817016 C 21.755414,15.00783 21.556394,14.995984 21.376328,14.981768 21.110967,14.962814 21.039889,14.962814 21.018565,14.988876 20.973549,15.036262 20.952225,15.351377 20.990134,15.384547 21.006719,15.396394 21.222324,15.422456 21.46873,15.439041 L 21.914157,15.467473 21.897572,15.735202 C 21.883356,16.005301 21.833601,16.419927 21.783846,16.671072 L 21.757784,16.808491 H 21.589564 C 21.497162,16.806121 21.279187,16.796644 21.10386,16.784798 20.928532,16.775321 20.772159,16.770582 20.755574,16.77769 20.715296,16.791906 20.630002,17.118868 20.653694,17.166253 20.672649,17.206531 20.722404,17.21127 21.381067,17.249179 21.584825,17.261025 21.646427,17.272872 21.646427,17.298934 21.646427,17.355797 21.373959,18.161355 21.298141,18.324836 L 21.229432,18.47884 20.933271,18.462255 C 20.772159,18.455147 20.53523,18.44567 20.407288,18.44567 L 20.177467,18.443301 20.089803,18.618628 C 20.042418,18.7134 20.011617,18.808171 20.018725,18.829495 20.032941,18.867404 20.115866,18.87925 20.70345,18.910051 L 21.025673,18.929005 20.859823,19.21332 C 20.76979,19.369693 20.613417,19.61373 20.513906,19.755887 L 20.331471,20.011771 19.786534,20.009401 19.241598,20.007032 19.104179,20.177621 C 19.028362,20.270023 18.969129,20.362426 18.973868,20.383749 18.980976,20.41455 19.040208,20.424027 19.348216,20.438243 19.549605,20.44772 19.758103,20.459567 19.807858,20.461936 L 19.90263,20.469044 19.70124,20.637263 C 19.592253,20.732035 19.419294,20.867084 19.315046,20.940532 L 19.130241,21.073213 H 17.829501 16.53113 L 16.941017,20.663326 C 17.376966,20.229745 17.639958,19.902783 17.917165,19.44551 18.53318,18.433824 18.969129,17.116498 19.144457,15.74231 19.196581,15.334792 19.236859,14.671391 19.227382,14.358645 L 19.217905,14.081438 19.277137,14.093284 C 19.310307,14.100392 19.909737,14.154886 20.608678,14.216487 Z M 4.9997955,16.074011 C 5.0211191,16.168782 5.0329655,16.254077 5.0282269,16.261185 5.0187498,16.2754 4.7249578,16.47679 3.6895781,17.1781 L 3.2654752,17.464784 3.1446414,17.303672 C 3.0783013,17.213639 2.9740525,17.102283 2.9100817,17.052528 2.8484801,17.002772 2.7963558,16.950648 2.7939865,16.938802 2.7916172,16.924586 3.2725831,16.5763 3.860167,16.164044 L 4.931086,15.412979 4.9476711,15.657016 C 4.9571482,15.792065 4.9808411,15.979239 4.9997955,16.074011 Z M 2.5309953,17.481369 C 2.6589369,17.642481 2.822418,17.983659 2.8840195,18.218218 2.985899,18.618628 2.9645753,19.237013 2.8318951,19.684809 2.6897377,20.161036 2.3296056,20.639633 1.9884279,20.803114 1.8770712,20.855238 1.8083618,20.871823 1.6377729,20.878931 1.5193084,20.883669 1.3795203,20.876562 1.3250267,20.862346 1.1710228,20.819699 0.95304814,20.660956 0.82984506,20.497475 0.59054677,20.17999 0.45075866,19.78195 0.42469647,19.343631 0.41285002,19.125656 0.44602008,18.637583 0.47919014,18.554657 0.4886673,18.526226 0.68294908,18.393546 0.69479553,18.405392 0.69953411,18.410131 0.68768766,18.481209 0.66636405,18.564135 0.62608612,18.737093 0.59291606,19.234644 0.61660896,19.329415 0.6308247,19.388648 0.63556328,19.391017 0.82747577,19.391017 H 1.0217575 L 1.0596662,19.533174 1.0975748,19.675332 H 0.88670802 C 0.6782105,19.675332 0.67584121,19.675332 0.69005695,19.727456 0.71611914,19.81275 0.73033488,19.817489 0.94594027,19.834074 L 1.1520685,19.84829 1.2255165,19.98097 C 1.3084416,20.125497 1.5358935,20.376641 1.6046029,20.397965 1.6306651,20.407442 1.6496194,20.421658 1.6496194,20.433504 1.6496194,20.466674 1.3937361,20.487998 1.2942259,20.461936 1.1615457,20.428766 1.1994543,20.469044 1.343981,20.51643 1.5027234,20.570923 1.7467603,20.554338 1.8960256,20.483259 2.0310751,20.416919 2.2419419,20.208422 2.3627757,20.025987 2.7252771,19.462095 2.7797707,18.58072 2.4859788,17.971812 2.4054229,17.803592 2.1874482,17.550078 2.0666144,17.483738 2.0287058,17.462415 2.038183,17.448199 2.161386,17.360535 L 2.3011741,17.263394 2.3675143,17.31078 C 2.4054229,17.336842 2.4788709,17.41266 2.5309953,17.481369 Z M 4.7154807,17.502693 4.8268373,17.640111 4.4785517,17.805962 C 4.2866392,17.895995 4.1255274,17.967074 4.1231582,17.962335 4.0994653,17.941011 4.0686645,17.696974 4.0852495,17.680389 4.1089424,17.656697 4.5970162,17.355797 4.6017547,17.360535 4.604124,17.362905 4.6538791,17.426875 4.7154807,17.502693 Z M 6.6867299,18.296405 C 6.8762731,18.393546 7.0563392,18.670753 7.136895,18.988237 7.1961273,19.21332 7.1890194,19.630315 7.1250486,19.900414 7.0113227,20.376641 6.7222693,20.798375 6.3976766,20.957117 6.262627,21.023458 6.2318263,21.030565 6.0612374,21.023458 5.8148312,21.011611 5.6868896,20.935794 5.5328857,20.710711 5.3575582,20.457197 5.3101725,20.277131 5.3101725,19.864875 5.3101725,19.44551 5.3528197,19.239382 5.4997157,18.94559 5.682151,18.585458 5.9143414,18.348529 6.172594,18.265604 6.3218593,18.218218 6.5611576,18.232434 6.6867299,18.296405 Z M 4.4003651,18.827126 C 4.6491405,18.966914 4.8007751,19.31283 4.8007751,19.739302 4.8007751,20.172882 4.6799413,20.533015 4.440643,20.798375 4.1658054,21.106383 3.7819804,21.139553 3.54979,20.871823 3.3744625,20.675172 3.2844295,20.395596 3.2844295,20.063895 3.2844295,19.616099 3.4171097,19.260706 3.6872088,18.983499 3.860167,18.808171 3.9241378,18.77974 4.1586975,18.777371 4.2724234,18.775001 4.3316557,18.786848 4.4003651,18.827126 Z"/> </symbol> + <symbol id="icon-nintendoMusic" viewBox="0 0 114 114"><path d="m 25.732087,113.50416 c -6.588515,-0.68012 -8.695543,-1.22214 -12.553652,-3.22936 -1.762772,-0.9171 -2.993406,-1.82849 -4.819732,-3.56942 -4.002744,-3.81559 -6.122812,-7.644635 -7.313171,-13.208305 -0.57951298,-2.7086 -0.57982798,-2.7284 -0.57982798,-36.44445 0,-33.62928 0.0018,-33.74274 0.57492598,-36.41917 1.686613,-7.87615 6.06013,-13.7685635 12.652915,-17.0471985 6.30684,-3.13643301 6.476747,-3.14880001 43.259379,-3.14880001 36.83125,0 36.91674,0.0063 43.334176,3.17739101 2.30211,1.13757 3.12687,1.734291 5.36135,3.879006 3.97494,3.8152415 5.99397,7.4652015 7.1956,13.0080215 0.53982,2.4901 0.5558,3.30337 0.66081,33.64033 0.0716,20.68964 0.0125,32.32756 -0.17666,34.79271 -0.60064,7.82649 -2.45786,12.343825 -6.99853,17.022595 -2.97211,3.0625 -5.98498,4.91141 -10.136116,6.22023 -4.18488,1.31945 -4.22496,1.32091 -37.91772,1.37306 -17.389737,0.0269 -32.034424,0.006 -32.543747,-0.0466 z M 50.073754,90.083395 c 5.28821,-1.00894 11.01156,-4.94764 13.30048,-9.15315 1.62746,-2.99019 1.61562,-2.84326 1.71693,-21.30579 l 0.0926,-16.87504 1.51367,0.80678 c 3.87465,2.06517 6.93228,5.07249 7.98053,7.84925 1.15152,3.0503 0.16775,6.87834 -2.63159,10.24005 -1.08169,1.29899 -1.22105,1.64952 -0.86804,2.18341 0.30452,0.46055 5.11665,4.07164 5.42586,4.07164 0.99334,0 4.56281,-5.3344 5.63772,-8.42531 2.66837,-7.67291 -1.02275,-15.80495 -10.79188,-23.77593 -4.79047,-3.90873 -5.90889,-5.43704 -6.2796,-8.58104 -0.10769,-0.91337 -0.34912,-1.69723 -0.56478,-1.83372 -0.20718,-0.13112 -1.37934,-0.24022 -2.60481,-0.24245 -3.55907,-0.006 -3.19583,-2.32457 -3.19583,20.39499 v 19.52328 l -1.19679,-0.52939 c -7.671865,-3.39363 -19.019411,0.11167 -23.831713,7.36171 -3.779121,5.69349 -2.626548,12.55586 2.702974,16.0934 3.63067,2.4099 8.421397,3.18425 13.594279,2.19731 z"/></symbol> <symbol id="icon-soundcloud" viewBox="0 0 40 40"><path d="M13.8,27.4l0.3-4.2L13.8,14c0-0.1-0.1-0.2-0.1-0.3c-0.1-0.1-0.2-0.1-0.3-0.1c-0.1,0-0.2,0-0.3,0.1C13.1,13.8,13,13.9,13,14 l-0.2,9.2l0.2,4.2c0,0.1,0.1,0.2,0.1,0.3c0.1,0.1,0.2,0.1,0.3,0.1C13.7,27.8,13.8,27.7,13.8,27.4z M18.8,26.9l0.2-3.7l-0.2-10.3 c0-0.2-0.1-0.3-0.2-0.4c-0.1-0.1-0.2-0.1-0.3-0.1s-0.2,0-0.3,0.1c-0.1,0.1-0.2,0.2-0.2,0.4l0,0.1l-0.2,10.1c0,0,0.1,1.4,0.2,4.1v0 c0,0.1,0,0.2,0.1,0.3c0.1,0.1,0.2,0.2,0.4,0.2c0.1,0,0.2-0.1,0.3-0.2c0.1-0.1,0.2-0.2,0.2-0.4L18.8,26.9z M1.2,20.9l0.3,2.2 l-0.3,2.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.1-0.1-0.2-0.2l-0.3-2.2l0.3-2.2c0-0.1,0.1-0.2,0.2-0.2S1.2,20.8,1.2,20.9z M2.7,19.5 l0.4,3.6l-0.4,3.6c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L2,23.2l0.4-3.6c0-0.1,0.1-0.2,0.2-0.2C2.6,19.4,2.7,19.4,2.7,19.5z M4.2,18.9l0.4,4.3l-0.4,4.2c0,0.1-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2l-0.4-4.2l0.4-4.3c0-0.1,0.1-0.2,0.2-0.2 C4.2,18.7,4.2,18.7,4.2,18.9z M5.8,18.8l0.4,4.4l-0.4,4.3c0,0.2-0.1,0.2-0.2,0.2c-0.1,0-0.2-0.1-0.2-0.2L5,23.2l0.4-4.4 c0-0.2,0.1-0.2,0.2-0.2C5.7,18.5,5.8,18.6,5.8,18.8z M7.4,19.1l0.4,4.1l-0.4,4.3c0,0.2-0.1,0.3-0.3,0.3c-0.1,0-0.1,0-0.2-0.1 c-0.1-0.1-0.1-0.1-0.1-0.2l-0.3-4.3l0.3-4.1c0-0.1,0-0.1,0.1-0.2C7,18.8,7,18.8,7.1,18.8C7.3,18.8,7.4,18.9,7.4,19.1L7.4,19.1z M9,16.5l0.4,6.7L9,27.5c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3l0.3-6.7 c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.1,0,0.2,0.1S9,16.4,9,16.5z M10.5,15l0.3,8.2l-0.3,4.3c0,0.1,0,0.2-0.1,0.2 c-0.1,0.1-0.1,0.1-0.2,0.1c-0.2,0-0.3-0.1-0.3-0.3l-0.3-4.3L9.9,15c0-0.2,0.1-0.3,0.3-0.3c0.1,0,0.2,0,0.2,0.1 C10.5,14.8,10.5,14.9,10.5,15z M12.2,14.3l0.3,8.9l-0.3,4.2c0,0.2-0.1,0.4-0.4,0.4c-0.2,0-0.3-0.1-0.4-0.4l-0.3-4.2l0.3-8.9 c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0.1C12.1,14.1,12.2,14.2,12.2,14.3z M18.8,27.3L18.8,27.3L18.8,27.3z M15.4,14.2l0.3,8.9l-0.3,4.2c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1s-0.1-0.2-0.1-0.3l-0.2-4.2 l0.2-8.9c0-0.1,0-0.2,0.1-0.3c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C15.4,14,15.4,14.1,15.4,14.2L15.4,14.2z M17.1,14.6 l0.2,8.6l-0.2,4.1c0,0.1,0,0.2-0.1,0.3c-0.1,0.1-0.2,0.1-0.3,0.1c-0.1,0-0.2,0-0.3-0.1c-0.1-0.1-0.1-0.2-0.2-0.3L16,23.2l0.2-8.6 c0-0.1,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1C17.1,14.3,17.1,14.4,17.1,14.6z M20.7,23.2l-0.2,4 c0,0.2-0.1,0.3-0.2,0.4c-0.1,0.1-0.2,0.2-0.4,0.2c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4l-0.1-2l-0.1-2L19.4,12V12 c0-0.2,0.1-0.3,0.2-0.4c0.1-0.1,0.2-0.1,0.3-0.1c0.1,0,0.2,0,0.3,0.1c0.2,0.1,0.2,0.2,0.3,0.5L20.7,23.2z M39.4,22.9 c0,1.4-0.5,2.5-1.4,3.5c-0.9,1-2,1.4-3.4,1.4H21.4c-0.1,0-0.3-0.1-0.4-0.2c-0.1-0.1-0.2-0.2-0.2-0.4V11.5c0-0.3,0.2-0.5,0.5-0.6 c1-0.4,2-0.6,3-0.6c2.2,0,4.1,0.8,5.7,2.3c1.6,1.5,2.5,3.4,2.7,5.7c0.6-0.3,1.2-0.4,1.8-0.4c1.3,0,2.4,0.5,3.4,1.5 C38.9,20.3,39.4,21.5,39.4,22.9L39.4,22.9z"/></symbol> + <symbol id="icon-steam" viewBox="-4 -4 96.32 96.47"><path d="M 44.084,0 C 20.846,0 1.809,17.918 0,40.689 l 23.71,9.803 c 2.009,-1.374 4.436,-2.179 7.047,-2.179 0.234,0 0.467,0.01 0.698,0.021 L 41.999,33.051 c 0,-0.073 0,-0.144 0,-0.216 0,-9.199 7.483,-16.683 16.683,-16.683 9.199,0 16.682,7.484 16.682,16.683 0,9.199 -7.483,16.684 -16.682,16.684 -0.127,0 -0.253,0 -0.379,-0.01 l -15.038,10.73 c 0.01,0.195 0.015,0.394 0.015,0.592 0,6.906 -5.617,12.522 -12.522,12.522 -6.061,0 -11.129,-4.326 -12.277,-10.055 L 1.524,56.292 c 5.25,18.568 22.309,32.181 42.56,32.181 24.432,0 44.237,-19.806 44.237,-44.235 C 88.321,19.805 68.515,0 44.084,0" /><path d="m 27.721,67.122 -5.434,-2.245 c 0.963,2.005 2.629,3.684 4.841,4.606 4.782,1.992 10.295,-0.277 12.288,-5.063 0.965,-2.314 0.971,-4.869 0.014,-7.189 C 38.475,54.91 36.673,53.1 34.356,52.134 32.057,51.177 29.594,51.212 27.43,52.029 l 5.613,2.321 c 3.527,1.47 5.195,5.52 3.725,9.047 -1.467,3.528 -5.52,5.196 -9.047,3.725" /><path d="m 69.796,32.835 c 0,-6.129 -4.986,-11.116 -11.116,-11.116 -6.129,0 -11.116,4.987 -11.116,11.116 0,6.13 4.987,11.115 11.116,11.115 6.13,0 11.116,-4.986 11.116,-11.115 M 50.348,32.816 c 0,-4.612 3.739,-8.35 8.351,-8.35 4.612,0 8.351,3.738 8.351,8.35 0,4.612 -3.739,8.35 -8.351,8.35 -4.612,0 -8.351,-3.739 -8.351,-8.35" /></symbol> <symbol id="icon-tiktok" viewBox="0 0 5 5.292"><path fill-rule="evenodd" clip-rule="evenodd" d="M 3.0056593,3.7402047 C 2.9917683,4.1032048 2.686077,4.394531 2.3113304,4.394531 c -0.08567,0 -0.1676888,-0.015266 -0.2434637,-0.043152 0.075775,0.027886 0.1578202,0.043152 0.2434902,0.043152 0.3747466,0 0.6804387,-0.2913262 0.6943554,-0.6542999 l 0.00132,-3.24141643 h 0.6058809 c 0.058393,0.30815327 0.2455538,0.57257183 0.5048391,0.73777393 7.93e-5,1.058e-4 1.852e-4,2.117e-4 2.645e-4,3.175e-4 0.1804944,0.1149589 0.3957012,0.1820292 0.6267297,0.1820292 v 0.1800449 c 0,0 0,0 2.65e-5,2.65e-5 V 2.227616 c -0.4291437,0 -0.8268027,-0.1341672 -1.1513855,-0.3618624 v 1.6436603 c 0,0.8208777 -0.6833226,1.4886974 -1.5232747,1.4886974 -0.3245565,0 -0.6255391,-0.1000367 -0.8729448,-0.269816 C 1.1970357,4.728163 1.1969034,4.7280043 1.1967446,4.727872 0.80416835,4.4583206 0.54689375,4.0127459 0.54689375,3.5092552 c 0,-0.8208513 0.68332265,-1.4886974 1.52327485,-1.4886974 0.069689,0 0.1380032,0.00561 0.2052587,0.014526 V 2.22669 C 1.5091597,2.2441785 0.88092205,2.817015 0.79752745,3.5485978 0.88100145,2.8170944 1.5091863,2.2443373 2.2754008,2.2268487 v 0.6340861 c -0.06498,-0.01987 -0.1336642,-0.031432 -0.2052851,-0.031432 -0.3835836,0 -0.6956521,0.3050312 -0.6956521,0.6799109 0,0.2610585 0.1515497,0.4878806 0.3729741,0.6017548 0,2.64e-5 0,2.64e-5 0,2.64e-5 0.096544,0.049661 0.2062111,0.078103 0.3226514,0.078103 0.3747467,0 0.6804387,-0.2913261 0.6943554,-0.6542997 l 0.00132,-3.24144299 H 3.593361 c 0,0.0701124 0.00691,0.13863846 0.019525,0.20525906 H 3.0070087 Z" /></symbol> <symbol id="icon-tumblr" viewBox="6 2 40 34"><path d="m 27.827893,29.747564 c 0,2.493786 1.258711,3.356328 3.263194,3.356328 h 2.843742 v 6.339769 h -5.384094 c -4.847873,0 -8.461022,-2.494139 -8.461022,-8.461022 v -9.555691 h -4.405136 v -5.174192 c 4.847872,-1.258711 6.875638,-5.43066 7.109177,-9.04381 h 5.034139 v 8.204552 h 5.873397 v 6.01345 h -5.873397 v 8.320616"/></symbol> <symbol id="icon-twitch" viewBox="0 0 2400 2800"><g><path d="M500,0L0,500v1800h600v500l500-500h400l900-900V0H500z M2200,1300l-400,400h-400l-350,350v-350H600V200h1600 V1300z"/><rect x="1700" y="550" width="200" height="600"/><rect x="1150" y="550" width="200" height="600"/></g></symbol> diff --git a/src/static/misc/image.svg b/src/static/misc/image.svg new file mode 100644 index 00000000..a251b373 --- /dev/null +++ b/src/static/misc/image.svg @@ -0,0 +1,11 @@ +<!-- Copyright © (c) 2019-2023 The Bootstrap authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software. --> + +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-image-fill" viewBox="0 0 16 16"> + <path d="M.002 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-12a2 2 0 0 1-2-2V3zm1 9v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12zm5-6.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/> +</svg> diff --git a/src/static/warning.svg b/src/static/misc/warning.svg index 92e55778..92e55778 100644 --- a/src/static/warning.svg +++ b/src/static/misc/warning.svg diff --git a/src/static/shared-util/README.md b/src/static/shared-util/README.md new file mode 100644 index 00000000..d21c0e6b --- /dev/null +++ b/src/static/shared-util/README.md @@ -0,0 +1,11 @@ +# `src/static/shared-util` + +Module imports under `src/static/js` may appear to be pointing to files that aren't at quite the right place. For example, the import: + + import {empty} from '../shared-util/sugar.js'; + +...is reading a file that doesn't exist here, under `shared-util`. This isn't an error! + +This folder (`src/shared-util`) does not actually exist in a build of the website; instead, the folder `src/util` is symlinked in its place. So, all files under `src/util` are actually available at (e.g.) `/static/shared-util/` online. + +The above import would actually import from the bindings in `src/util/sugar.js`. |