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