diff options
Diffstat (limited to 'src/static/js/client')
22 files changed, 5187 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..195ba25d --- /dev/null +++ b/src/static/js/client/additional-names-box.js @@ -0,0 +1,150 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {info as hashLinkInfo} from './hash-link.js'; +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'additionalNamesBoxInfo', + + box: null, + + links: null, + stickyHeadingLink: null, + + contentContainer: null, + mainContentContainer: null, + + state: { + visible: false, + }, +}; + +export function getPageReferences() { + info.box = + document.getElementById('additional-names-box'); + + info.links = + document.querySelectorAll('a[href="#additional-names-box"]'); + + info.stickyHeadingLink = + document.querySelector( + '.content-sticky-heading-container' + + ' ' + + 'a[href="#additional-names-box"]' + + ':not(:where([inert] *))'); + + info.contentContainer = + document.querySelector('#content'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +export function addInternalListeners() { + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); + + stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => { + const {state} = info; + + if (!info.stickyHeadingLink) return; + + const container = stickyHeadingInfo.contentContainers[index]; + if (container !== info.contentContainer) return; + + if (stuck) { + if (!state.visible) { + info.stickyHeadingLink.removeAttribute('href'); + + if (info.stickyHeadingLink.hasAttribute('title')) { + info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title'); + info.stickyHeadingLink.removeAttribute('title'); + } + } + } else { + info.stickyHeadingLink.setAttribute('href', '#additional-names-box'); + + const {restoreTitle} = info.stickyHeadingLink.dataset; + if (restoreTitle) { + info.stickyHeadingLink.setAttribute('title', restoreTitle); + delete info.stickyHeadingLink.dataset.restoreTitle; + } + } + }); +} + +export function addPageListeners() { + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const {state} = info; + + domEvent.preventDefault(); + + if (!domEvent.target.hasAttribute('href')) return; + if (!info.box || !info.mainContentContainer) return; + + const margin = + +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); + + const {top} = + (state.visible + ? info.box.getBoundingClientRect() + : info.mainContentContainer.getBoundingClientRect()); + + const {bottom, height} = + (state.visible + ? info.box.getBoundingClientRect() + : {bottom: null}); + + const boxFitsInFrame = + (height + ? height < window.innerHeight - margin - 60 + : null); + + const worthScrolling = + top + 20 < margin || + + (height && boxFitsInFrame + ? top > 0.7 * window.innerHeight + : height && !boxFitsInFrame + ? top > 0.4 * window.innerHeight + : top > 0.5 * window.innerHeight) || + + (bottom && boxFitsInFrame + ? bottom > window.innerHeight - 20 + : false); + + if (worthScrolling) { + if (!state.visible) { + toggleAdditionalNamesBox(); + } + + window.scrollTo({ + top: window.scrollY + top - margin, + behavior: 'smooth', + }); + } else { + toggleAdditionalNamesBox(); + } +} + +export function toggleAdditionalNamesBox() { + const {state} = info; + + state.visible = !state.visible; + info.box.style.display = + (state.visible + ? 'block' + : 'none'); +} diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js new file mode 100644 index 00000000..c5eaf81b --- /dev/null +++ b/src/static/js/client/album-commentary-sidebar.js @@ -0,0 +1,212 @@ +/* eslint-env browser */ + +import {empty} from '../../shared-util/sugar.js'; + +import {info as hashLinkInfo} from './hash-link.js'; +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'albumCommentarySidebarInfo', + + sidebar: null, + sidebarHeading: null, + + sidebarTrackLinks: null, + sidebarTrackDirectories: null, + + sidebarTrackSections: null, + sidebarTrackSectionStartIndices: null, + + state: { + currentTrackSection: null, + currentTrackLink: null, + justChangedTrackSection: false, + }, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') { + return; + } + + info.sidebar = + document.getElementById('sidebar-left'); + + info.sidebarHeading = + info.sidebar.querySelector('h1'); + + info.sidebarTrackLinks = + Array.from(info.sidebar.querySelectorAll('li a')); + + info.sidebarTrackDirectories = + info.sidebarTrackLinks + .map(el => el.getAttribute('href')?.slice(1) ?? null); + + info.sidebarTrackSections = + Array.from(info.sidebar.getElementsByTagName('details')); + + info.sidebarTrackSectionStartIndices = + info.sidebarTrackSections + .map(details => details.querySelector('ol, ul')) + .reduce( + (accumulator, _list, index, array) => + (empty(accumulator) + ? [0] + : [ + ...accumulator, + (accumulator[accumulator.length - 1] + + array[index - 1].querySelectorAll('li a').length), + ]), + []); +} + +function scrollAlbumCommentarySidebar() { + const {state} = info; + const {currentTrackLink, currentTrackSection} = state; + + if (!currentTrackLink) { + return; + } + + const {sidebar, sidebarHeading} = info; + + const scrollTop = sidebar.scrollTop; + + const headingRect = sidebarHeading.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + + const stickyPadding = headingRect.height; + const sidebarViewportHeight = sidebarRect.height - stickyPadding; + + const linkRect = currentTrackLink.getBoundingClientRect(); + const sectionRect = currentTrackSection.getBoundingClientRect(); + + const sectionTopEdge = + sectionRect.top - (sidebarRect.top - scrollTop); + + const sectionHeight = + sectionRect.height; + + const sectionScrollTop = + sectionTopEdge - stickyPadding - 10; + + const linkTopEdge = + linkRect.top - (sidebarRect.top - scrollTop); + + const linkBottomEdge = + linkRect.bottom - (sidebarRect.top - scrollTop); + + const linkScrollTop = + linkTopEdge - stickyPadding - 5; + + const linkVisibleFromTopOfSection = + linkBottomEdge - sectionTopEdge > sidebarViewportHeight; + + const linkScrollBottom = + linkScrollTop - sidebarViewportHeight + linkRect.height + 20; + + const maxScrollInViewport = + scrollTop + stickyPadding + sidebarViewportHeight; + + const minScrollInViewport = + scrollTop + stickyPadding; + + if (linkBottomEdge > maxScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (linkTopEdge < minScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (state.justChangedTrackSection) { + if (sectionHeight < sidebarViewportHeight) { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } +} + +function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { + const {state} = info; + + const trackIndex = + (trackDirectory + ? info.sidebarTrackDirectories + .indexOf(trackDirectory) + : -1); + + const sectionIndex = + (trackIndex >= 0 + ? info.sidebarTrackSectionStartIndices + .findIndex((start, index, array) => + (index === array.length - 1 + ? true + : trackIndex < array[index + 1])) + : -1); + + const sidebarTrackLink = + (trackIndex >= 0 + ? info.sidebarTrackLinks[trackIndex] + : null); + + const sidebarTrackSection = + (sectionIndex >= 0 + ? info.sidebarTrackSections[sectionIndex] + : null); + + state.currentTrackLink?.classList?.remove('current'); + state.currentTrackLink = sidebarTrackLink; + state.currentTrackLink?.classList?.add('current'); + + if (sidebarTrackSection !== state.currentTrackSection) { + if (sidebarTrackSection && !sidebarTrackSection.open) { + if (state.currentTrackSection) { + state.currentTrackSection.open = false; + } + + sidebarTrackSection.open = true; + } + + state.currentTrackSection?.classList?.remove('current'); + state.currentTrackSection = sidebarTrackSection; + state.currentTrackSection?.classList?.add('current'); + state.justChangedTrackSection = true; + } else { + state.justChangedTrackSection = false; + } +} + +export function addInternalListeners() { + if (!info.sidebar) { + return; + } + + const mainContentIndex = + (stickyHeadingInfo.contentContainers ?? []) + .findIndex(({id}) => id === 'content'); + + if (mainContentIndex === -1) return; + + stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { + if (index !== mainContentIndex) return; + if (hashLinkInfo.state.scrollingAfterClick) return; + + const trackDirectory = + (newHeading + ? newHeading.id + : null); + + markDirectoryAsCurrentForAlbumCommentary(trackDirectory); + scrollAlbumCommentarySidebar(); + }); + + hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { + const hash = link.getAttribute('href').slice(1); + if (!info.sidebarTrackDirectories.includes(hash)) return; + markDirectoryAsCurrentForAlbumCommentary(hash); + }); +} diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js new file mode 100644 index 00000000..fd40d1a2 --- /dev/null +++ b/src/static/js/client/art-tag-gallery-filter.js @@ -0,0 +1,151 @@ +/* eslint-env browser */ + +export const info = { + id: 'artTagGalleryFilterInfo', + + featuredAllLine: null, + showingAllLine: null, + showingAllLink: null, + + featuredDirectLine: null, + showingDirectLine: null, + showingDirectLink: null, + + featuredIndirectLine: null, + showingIndirectLine: null, + showingIndirectLink: null, + + gridItems: null, + gridItemsOnlyFeaturedIndirectly: null, + gridItemsFeaturedDirectly: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') { + return; + } + + info.featuredAllLine = + document.getElementById('featured-all-line'); + + info.featuredDirectLine = + document.getElementById('featured-direct-line'); + + info.featuredIndirectLine = + document.getElementById('featured-indirect-line'); + + info.showingAllLine = + document.getElementById('showing-all-line'); + + info.showingDirectLine = + document.getElementById('showing-direct-line'); + + info.showingIndirectLine = + document.getElementById('showing-indirect-line'); + + info.showingAllLink = + info.showingAllLine?.querySelector('a') ?? null; + + info.showingDirectLink = + info.showingDirectLine?.querySelector('a') ?? null; + + info.showingIndirectLink = + info.showingIndirectLine?.querySelector('a') ?? null; + + info.gridItems = + Array.from( + document.querySelectorAll('#content .grid-listing .grid-item')); + + info.gridItemsOnlyFeaturedIndirectly = + info.gridItems + .filter(gridItem => gridItem.classList.contains('featured-indirectly')); + + info.gridItemsFeaturedDirectly = + info.gridItems + .filter(gridItem => !gridItem.classList.contains('featured-indirectly')); +} + +function filterArtTagGallery(showing) { + let gridItemsToShow; + + switch (showing) { + case 'all': + gridItemsToShow = info.gridItems; + break; + + case 'direct': + gridItemsToShow = info.gridItemsFeaturedDirectly; + break; + + case 'indirect': + gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly; + break; + } + + for (const gridItem of info.gridItems) { + if (gridItemsToShow.includes(gridItem)) { + gridItem.style.removeProperty('display'); + } else { + gridItem.style.display = 'none'; + } + } +} + +export function addPageListeners() { + const orderShowing = [ + 'all', + 'direct', + 'indirect', + ]; + + const orderFeaturedLines = [ + info.featuredAllLine, + info.featuredDirectLine, + info.featuredIndirectLine, + ]; + + const orderShowingLines = [ + info.showingAllLine, + info.showingDirectLine, + info.showingIndirectLine, + ]; + + const orderShowingLinks = [ + info.showingAllLink, + info.showingDirectLink, + info.showingIndirectLink, + ]; + + for (let index = 0; index < orderShowing.length; index++) { + if (!orderShowingLines[index]) continue; + + let nextIndex = index; + do { + if (nextIndex === orderShowing.length) { + nextIndex = 0; + } else { + nextIndex++; + } + } while (!orderShowingLinks[nextIndex]); + + const currentFeaturedLine = orderFeaturedLines[index]; + const currentShowingLine = orderShowingLines[index]; + const currentShowingLink = orderShowingLinks[index]; + + const nextFeaturedLine = orderFeaturedLines[nextIndex]; + const nextShowingLine = orderShowingLines[nextIndex]; + const nextShowing = orderShowing[nextIndex]; + + currentShowingLink.addEventListener('click', event => { + event.preventDefault(); + + currentFeaturedLine.style.display = 'none'; + currentShowingLine.style.display = 'none'; + + nextFeaturedLine.style.display = 'block'; + nextShowingLine.style.display = 'block'; + + filterArtTagGallery(nextShowing); + }); + } +} diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js new file mode 100644 index 00000000..44e10c11 --- /dev/null +++ b/src/static/js/client/art-tag-network.js @@ -0,0 +1,147 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {atOffset, stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'artTagNetworkInfo', + + noneStatLink: null, + totalUsesStatLink: null, + directUsesStatLink: null, + descendantsStatLink: null, + leavesStatLink: null, + + tagsWithoutStats: null, + tagsWithStats: null, + + totalUsesStats: null, + directUsesStats: null, + descendantsStats: null, + leavesStats: null, +}; + +export function getPageReferences() { + if ( + document.documentElement.dataset.urlKey !== 'localized.listing' || + document.documentElement.dataset.urlValue0 !== 'tags/network' + ) { + return; + } + + info.noneStatLink = + document.getElementById('network-stat-none'); + + info.totalUsesStatLink = + document.getElementById('network-stat-total-uses'); + + info.directUsesStatLink = + document.getElementById('network-stat-direct-uses'); + + info.descendantsStatLink = + document.getElementById('network-stat-descendants'); + + info.leavesStatLink = + document.getElementById('network-stat-leaves'); + + info.tagsWithoutStats = + document.querySelectorAll('.network-tag:not(.with-stat)'); + + info.tagsWithStats = + document.querySelectorAll('.network-tag.with-stat'); + + info.totalUsesStats = + Array.from(document.getElementsByClassName('network-tag-total-uses-stat')); + + info.directUsesStats = + Array.from(document.getElementsByClassName('network-tag-direct-uses-stat')); + + info.descendantsStats = + Array.from(document.getElementsByClassName('network-tag-descendants-stat')); + + info.leavesStats = + Array.from(document.getElementsByClassName('network-tag-leaves-stat')); +} + +export function addPageListeners() { + if (!info.noneStatLink) return; + + const linkOrder = [ + info.noneStatLink, + info.totalUsesStatLink, + info.directUsesStatLink, + info.descendantsStatLink, + info.leavesStatLink, + ]; + + const statsOrder = [ + null, + info.totalUsesStats, + info.directUsesStats, + info.descendantsStats, + info.leavesStats, + ]; + + const stitched = + stitchArrays({ + link: linkOrder, + stats: statsOrder, + }); + + for (const [index, {link}] of stitched.entries()) { + const next = atOffset(stitched, index, +1, {wrap: true}); + + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + cssProp(link, 'display', 'none'); + cssProp(next.link, 'display', null); + + if (next.stats === null) { + hideArtTagNetworkStats(); + } else { + showArtTagNetworkStats(next.stats); + } + }); + } +} + +function showArtTagNetworkStats(stats) { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', 'none'); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', null); + } + + const allStats = [ + ...info.totalUsesStats, + ...info.directUsesStats, + ...info.descendantsStats, + ...info.leavesStats, + ]; + + const otherStats = + allStats + .filter(stat => !stats.includes(stat)); + + for (const statElement of otherStats) { + cssProp(statElement, 'display', 'none'); + } + + for (const statElement of stats) { + cssProp(statElement, 'display', null); + } +} + +function hideArtTagNetworkStats() { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', null); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', 'none'); + } +} diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js new file mode 100644 index 00000000..21ddfb91 --- /dev/null +++ b/src/static/js/client/artist-external-link-tooltip.js @@ -0,0 +1,196 @@ +/* eslint-env browser */ + +import {accumulateSum, empty} from '../../shared-util/sugar.js'; + +import {info as hoverableTooltipInfo, repositionCurrentTooltip} + from './hoverable-tooltip.js'; + +// These don't need to have tooltip events specially added as +// they're implemented with "text with tooltip" components. + +export const info = { + id: 'artistExternalLinkTooltipInfo', + + tooltips: null, + tooltipRows: null, + + settings: { + // This is the maximum distance, in CSS pixels, that the mouse + // can appear to be moving per second while still considered + // "idle". A greater value means higher tolerance for small + // movements. + maximumIdleSpeed: 40, + + // Leaving the mouse idle for this amount of time, over a single + // row of the tooltip, will cause a column of supplemental info + // to display. + mouseIdleShowInfoDelay: 1000, + + // If none of these tooltips are visible for this amount of time, + // the supplemental info column is hidden. It'll never disappear + // while a tooltip is actually visible. + hideInfoAfterTooltipHiddenDelay: 2250, + }, + + state: { + // This is shared by all tooltips. + showingTooltipInfo: false, + + mouseIdleTimeout: null, + hideInfoTimeout: null, + + mouseMovementPositions: [], + mouseMovementTimestamps: [], + }, +}; + +export function getPageReferences() { + info.tooltips = + Array.from(document.getElementsByClassName('contribution-tooltip')); + + info.tooltipRows = + info.tooltips.map(tooltip => + Array.from(tooltip.getElementsByClassName('icon'))); +} + +export function addInternalListeners() { + hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => { + const {state} = info; + + if (info.tooltips.includes(tooltip)) { + clearTimeout(state.hideInfoTimeout); + state.hideInfoTimeout = null; + } + }); + + hoverableTooltipInfo.event.whenTooltipHides.push(() => { + const {settings, state} = info; + + if (state.showingTooltipInfo) { + state.hideInfoTimeout = + setTimeout(() => { + state.hideInfoTimeout = null; + hideArtistExternalLinkTooltipInfo(); + }, settings.hideInfoAfterTooltipHiddenDelay); + } else { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + }); +} + +export function addPageListeners() { + for (const tooltip of info.tooltips) { + tooltip.addEventListener('mousemove', domEvent => { + handleArtistExternalLinkTooltipMouseMoved(domEvent); + }); + + tooltip.addEventListener('mouseout', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } + + for (const tooltipRow of info.tooltipRows.flat()) { + tooltipRow.addEventListener('mouseover', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } +} + +function handleArtistExternalLinkTooltipMouseMoved(domEvent) { + const {settings, state} = info; + + if (state.showingTooltipInfo) { + return; + } + + // Clean out expired mouse movements + + const expiryTime = 1000; + + if (!empty(state.mouseMovementTimestamps)) { + const firstRecentMovementIndex = + state.mouseMovementTimestamps + .findIndex(value => Date.now() - value <= expiryTime); + + if (firstRecentMovementIndex === -1) { + state.mouseMovementTimestamps.splice(0); + state.mouseMovementPositions.splice(0); + } else if (firstRecentMovementIndex > 0) { + state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1); + state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1); + } + } + + state.mouseMovementTimestamps.push(Date.now()); + state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]); + + // We can't really compute speed without having + // at least two data points! + if (state.mouseMovementPositions.length < 2) { + return; + } + + const movementTravelDistances = + state.mouseMovementPositions.map((current, index, array) => { + if (index === 0) return 0; + + const previous = array[index - 1]; + const deltaX = current[0] - previous[0]; + const deltaY = current[1] - previous[1]; + return Math.sqrt(deltaX ** 2 + deltaY ** 2); + }); + + const totalTravelDistance = + accumulateSum(movementTravelDistances); + + // In seconds rather than milliseconds. + const timeSinceFirstMovement = + (Date.now() - state.mouseMovementTimestamps[0]) / 1000; + + const averageSpeed = + Math.floor(totalTravelDistance / timeSinceFirstMovement); + + if (averageSpeed > settings.maximumIdleSpeed) { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + + if (state.mouseIdleTimeout) { + return; + } + + state.mouseIdleTimeout = + setTimeout(() => { + state.mouseIdleTimeout = null; + showArtistExternalLinkTooltipInfo(); + }, settings.mouseIdleShowInfoDelay); +} + +function showArtistExternalLinkTooltipInfo() { + const {state} = info; + + state.showingTooltipInfo = true; + + for (const tooltip of info.tooltips) { + tooltip.classList.add('show-info'); + } + + repositionCurrentTooltip(); +} + +function hideArtistExternalLinkTooltipInfo() { + const {state} = info; + + state.showingTooltipInfo = false; + + for (const tooltip of info.tooltips) { + tooltip.classList.remove('show-info'); + } +} diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js new file mode 100644 index 00000000..aa637cc4 --- /dev/null +++ b/src/static/js/client/css-compatibility-assistant.js @@ -0,0 +1,30 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'cssCompatibilityAssistantInfo', + + coverArtworks: null, + coverArtworkImageDetails: null, +}; + +export function getPageReferences() { + info.coverArtworks = + Array.from(document.querySelectorAll('.cover-artwork')); + + info.coverArtworkImageDetails = + info.coverArtworks + .map(artwork => artwork.querySelector('.image-details')); +} + +export function mutatePageContent() { + stitchArrays({ + coverArtwork: info.coverArtworks, + imageDetails: info.coverArtworkImageDetails, + }).forEach(({coverArtwork, imageDetails}) => { + if (imageDetails) { + coverArtwork.classList.add('has-image-details'); + } + }); +} diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js new file mode 100644 index 00000000..46d1cd5b --- /dev/null +++ b/src/static/js/client/datetimestamp-tooltip.js @@ -0,0 +1,36 @@ +/* eslint-env browser */ + +// TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip? + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {registerTooltipElement, registerTooltipHoverableElement} + from './hoverable-tooltip.js'; + +export const info = { + id: 'datetimestampTooltipInfo', + + hoverables: null, + tooltips: null, +}; + +export function getPageReferences() { + const spans = + Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip')); + + info.hoverables = + spans.map(span => span.querySelector('time')); + + info.tooltips = + spans.map(span => span.querySelector('span.datetimestamp-tooltip')); +} + +export function addPageListeners() { + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js new file mode 100644 index 00000000..56021e7f --- /dev/null +++ b/src/static/js/client/dragged-link.js @@ -0,0 +1,62 @@ +/* eslint-env browser */ + +export const info = { + id: `draggedLinkInfo`, + + state: { + latestDraggedLink: null, + observedLinks: new WeakSet(), + }, +}; + +export function getPageReferences() { + // First start handling all the links that currently exist. + + for (const a of document.getElementsByTagName('a')) { + observeLink(a); + addDragListener(a); + } + + // Then add a mutation observer to track new links. + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeName !== 'A') continue; + observeLink(node); + } + } + }); + + observer.observe(document.body, { + subtree: true, + childList: true, + }); +} + +export function getLatestDraggedLink() { + const {state} = info; + + if (state.latestDraggedLink) { + return state.latestDraggedLink.deref() ?? null; + } else { + return null; + } +} + +function observeLink(link) { + const {state} = info; + + if (state.observedLinks.has(link)) return; + + state.observedLinks.add(link); + addDragListener(link); +} + +function addDragListener(link) { + const {state} = info; + + link.addEventListener('dragstart', _domEvent => { + state.latestDraggedLink = new WeakRef(link); + }); +} diff --git a/src/static/js/client/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..9569de3e --- /dev/null +++ b/src/static/js/client/hoverable-tooltip.js @@ -0,0 +1,1102 @@ +/* eslint-env browser */ + +import {empty, filterMultipleArrays} from '../../shared-util/sugar.js'; + +import {WikiRect} from '../rectangles.js'; + +import { + cssProp, + dispatchInternalEvent, + getVisuallyContainingElement, + pointIsOverAnyOf, +} from '../client-util.js'; + +import {info as stickyHeadingInfo} from './sticky-heading.js'; + +export const info = { + id: 'hoverableTooltipInfo', + + settings: { + // Hovering has two speed settings. The normal setting is used by default, + // and once a tooltip is displayed as a result of hover, the entire tooltip + // system will enter a "fast hover mode" - hovering will activate tooltips + // sooner. "Fast hover mode" is disabled after a sustained duration of not + // hovering over any hoverables; it's meant only to accelerate switching + // tooltips while still deciding, or getting a quick overview across more + // than one tooltip. + normalHoverInfoDelay: 400, + fastHoveringInfoDelay: 150, + endFastHoveringDelay: 500, + + // Focusing has a single speed setting, which is how long it will take to + // enter a functional "focus mode" (though it's not actually implemented + // in terms of this state). As soon as "focus mode" is entered, the tooltip + // for the current hoverable is displayed, and focusing another hoverable + // will cause the current tooltip to be swapped for that one immediately. + // "Focus mode" ends as soon as anything apart from a tooltip or hoverable + // is focused, and it will be necessary to wait on this delay again. + focusInfoDelay: 750, + + hideTooltipDelay: 500, + + // If a tooltip that's transitioning to hidden is hovered during the grace + // period (or the corresponding hoverable is hovered at any point in the + // transition), it'll cancel out of this animation immediately. + transitionHiddenDuration: 300, + inertGracePeriod: 100, + }, + + state: { + // These maps store a record for each registered element and related state + // and registration info, if applicable. + registeredTooltips: new Map(), + registeredHoverables: new Map(), + + // These are common across all tooltips, rather than stored individually, + // based on the principles that 1) only a single tooltip can be displayed + // at once, and 2) likewise, only a single hoverable can be hovered, + // focused, or otherwise active at once. + hoverTimeout: null, + focusTimeout: null, + touchTimeout: null, + hideTimeout: null, + transitionHiddenTimeout: null, + inertGracePeriodTimeout: null, + currentlyShownTooltip: null, + currentlyActiveHoverable: null, + currentlyTransitioningHiddenTooltip: null, + previouslyActiveHoverable: null, + tooltipWasJustHidden: false, + hoverableWasRecentlyTouched: false, + + // Fast hovering is a global mode which is activated as soon as any tooltip + // is displayed and turns off after a delay of no hoverables being hovered. + // Note that fast hovering may be turned off while hovering a tooltip, but + // it will never be turned off while idling over a hoverable. + fastHovering: false, + endFastHoveringTimeout: false, + + // These track the identifiers of current touches and a record of current + // identifiers that are "banished" by scrolling - that is, touches which + // existed while the page scrolled and were probably responsible for that + // scrolling. This is a bit loose (we can't actually tell which touches + // caused the page to scroll) but it's intended to keep scrolling the page + // from causing the current tooltip to be hidden. + currentTouchIdentifiers: new Set(), + touchIdentifiersBanishedByScrolling: new Set(), + + // This is a two-item array that tracks the direction we've already + // dynamically placed the current tooltip. If we *reposition* the tooltip + // (because its dimensions changed), we'll try to follow this anchor first. + dynamicTooltipAnchorDirection: null, + }, + + event: { + whenTooltipShows: [], + whenTooltipHides: [], + }, +}; + +// Adds DOM event listeners, so must be called during addPageListeners step. +export function registerTooltipElement(tooltip) { + const {state} = info; + + if (!tooltip) + throw new Error(`Expected tooltip`); + + if (state.registeredTooltips.has(tooltip)) + throw new Error(`This tooltip is already registered`); + + // No state or registration info here. + state.registeredTooltips.set(tooltip, {}); + + tooltip.addEventListener('mouseenter', () => { + handleTooltipMouseEntered(tooltip); + }); + + tooltip.addEventListener('mouseleave', () => { + handleTooltipMouseLeft(tooltip); + }); + + tooltip.addEventListener('focusin', 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'); + + const isolator = + hoverable.closest('.isolate-tooltip-z-indexing > *'); + + if (isolator) { + for (const child of isolator.parentElement.children) { + cssProp(child, 'z-index', null); + } + + cssProp(isolator, 'z-index', '1'); + } + + positionTooltipFromHoverableWithBrains(hoverable); + + cssProp(tooltip, 'display', 'block'); + tooltip.inert = false; + + state.currentlyShownTooltip = tooltip; + state.currentlyActiveHoverable = hoverable; + + state.tooltipWasJustHidden = false; + + dispatchInternalEvent(event, 'whenTooltipShows', { + tooltip, + }); + + return true; +} + +export function peekTooltipClientRect(tooltip) { + const oldDisplayStyle = cssProp(tooltip, 'display'); + cssProp(tooltip, 'display', 'block'); + + // Tooltips have a bit of padding that makes the interactive + // area wider, so that you're less likely to accidentally let + // the tooltip disappear (by hovering outside it). But this + // isn't visual at all, so for placement we only care about + // the content element. + const content = + tooltip.querySelector('.tooltip-content'); + + try { + return WikiRect.fromElement(content); + } finally { + cssProp(tooltip, 'display', oldDisplayStyle); + } +} + +export function repositionCurrentTooltip() { + const {state} = info; + const {currentlyActiveHoverable} = state; + + if (!currentlyActiveHoverable) { + throw new Error(`No hoverable active to reposition tooltip from`); + } + + positionTooltipFromHoverableWithBrains(currentlyActiveHoverable); +} + +export function positionTooltipFromHoverableWithBrains(hoverable) { + const {state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + const anchorDirection = state.dynamicTooltipAnchorDirection; + + // Reset before doing anything else. We're going to adapt to + // its natural placement, adjusted by CSS, which otherwise + // could be obscured by a placement we've previously provided. + resetDynamicTooltipPositioning(tooltip); + + const opportunities = + getTooltipFromHoverablePlacementOpportunityAreas(hoverable); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // If the tooltip is already in the baseline containing area, + // prefer to keep it positioned naturally, adjusted by CSS + // instead of JavaScript. + + const {numBaselineRects, idealBaseline: baselineRect} = opportunities; + + if (baselineRect.contains(tooltipRect)) { + return; + } + + const tryDirection = (dir1, dir2, i) => { + selectedRect = opportunities[dir1][dir2][i]; + return !!selectedRect; + }; + + let selectedRect = null; + selectRect: { + if (anchorDirection) { + for (let i = 0; i < numBaselineRects; i++) { + if (tryDirection(...anchorDirection, i)) { + break selectRect; + } + } + } + + for (let i = 0; i < numBaselineRects; i++) { + for (const [dir1, dir2] of [ + ['down', 'right'], + ['down', 'left'], + ['right', 'down'], + ['left', 'down'], + ['right', 'up'], + ['left', 'up'], + ['up', 'right'], + ['up', 'left'], + ]) { + if (tryDirection(dir1, dir2, i)) { + state.dynamicTooltipAnchorDirection = [dir1, dir2]; + break selectRect; + } + } + } + + selectedRect = baselineRect; + } + + positionTooltip(tooltip, selectedRect.x, selectedRect.y); +} + +export function positionTooltip(tooltip, x, y) { + // Imagine what it'd be like if the tooltip were positioned + // with zero left/top offset, and calculate its actual offsets + // based on that. + + cssProp(tooltip, { + left: `0`, + top: `0`, + }); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + cssProp(tooltip, { + left: `${x - tooltipRect.x}px`, + top: `${y - tooltipRect.y}px`, + }); +} + +export function resetDynamicTooltipPositioning(tooltip) { + cssProp(tooltip, { + left: null, + top: null, + }); +} + +export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { + const {state} = info; + const {tooltip} = state.registeredHoverables.get(hoverable); + + const baselineRects = + getTooltipBaselineOpportunityAreas(tooltip); + + const hoverableRect = + WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // Get placements relative to the hoverable. Make these available by key, + // allowing the caller to choose by preferred orientation. Each value is + // an array which corresponds to the baseline areas - placement closer to + // front of the array indicates stronger preference. Since not all relative + // placements cooperate with all baseline areas, any of these arrays may + // include (or be entirely made of) null. + + const keepIfFits = (rect) => + (rect?.fits(tooltipRect) + ? rect + : null); + + const prepareRegionRects = (relationalRect, direct) => + baselineRects + .map(rect => rect.intersectionWith(relationalRect)) + .map(direct) + .map(keepIfFits); + + const regionRects = { + left: + prepareRegionRects( + WikiRect.leftOf(hoverableRect), + rect => WikiRect.fromRect({ + x: rect.right, + y: rect.y, + width: -rect.width, + height: rect.height, + })), + + right: + prepareRegionRects( + WikiRect.rightOf(hoverableRect), + rect => rect), + + top: + prepareRegionRects( + WikiRect.above(hoverableRect), + rect => WikiRect.fromRect({ + x: rect.x, + y: rect.bottom, + width: rect.width, + height: -rect.height, + })), + + bottom: + prepareRegionRects( + WikiRect.beneath(hoverableRect), + rect => rect), + }; + + const neededVerticalOverlap = 30; + const neededHorizontalOverlap = 30; + + const upTopDown = + WikiRect.beneath( + hoverableRect.top + neededVerticalOverlap - tooltipRect.height); + + const downBottomUp = + WikiRect.above( + hoverableRect.bottom - neededVerticalOverlap + tooltipRect.height); + + // Please don't ask us to make this but horizontal? + const prepareVerticalOrientationRects = (regionRects) => { + const orientations = {}; + + const orientHorizontally = (rect, i) => { + if (!rect) return null; + + const regionRect = regionRects[i]; + if (regionRect.width > 0) { + return rect; + } else { + return WikiRect.fromRect({ + x: regionRect.right - tooltipRect.width, + y: rect.y, + width: rect.width, + height: rect.height, + }); + } + }; + + orientations.up = + regionRects + .map(rect => rect?.intersectionWith(upTopDown)) + .map(orientHorizontally) + .map(keepIfFits); + + orientations.down = + regionRects + .map(rect => rect?.intersectionWith(downBottomUp)) + .map(rect => + (rect + ? rect.intersectionWith(WikiRect.fromRect({ + x: rect.x, + y: rect.bottom - tooltipRect.height, + width: rect.width, + height: tooltipRect.height, + })) + : null)) + .map(orientHorizontally) + .map(keepIfFits); + + const centerRect = + WikiRect.fromRect({ + x: -Infinity, width: Infinity, + y: hoverableRect.top + + hoverableRect.height / 2 + - tooltipRect.height / 2, + height: tooltipRect.height, + }); + + orientations.center = + regionRects + .map(rect => rect?.intersectionWith(centerRect)) + .map(orientHorizontally) + .map(keepIfFits); + + return orientations; + }; + + const rightRightLeft = + WikiRect.leftOf( + hoverableRect.left - neededHorizontalOverlap + tooltipRect.width); + + const leftLeftRight = + WikiRect.rightOf( + hoverableRect.left + neededHorizontalOverlap - tooltipRect.width); + + // Oops. + const prepareHorizontalOrientationRects = (regionRects) => { + const orientations = {}; + + const orientVertically = (rect, i) => { + if (!rect) return null; + + const regionRect = regionRects[i]; + + if (regionRect.height > 0) { + return rect; + } else { + return WikiRect.fromRect({ + x: rect.x, + y: regionRect.bottom - tooltipRect.height, + width: rect.width, + height: rect.height, + }); + } + }; + + orientations.left = + regionRects + .map(rect => rect?.intersectionWith(leftLeftRight)) + .map(orientVertically) + .map(keepIfFits); + + orientations.right = + regionRects + .map(rect => rect?.intersectionWith(rightRightLeft)) + .map(rect => + (rect + ? rect.intersectionWith(WikiRect.fromRect({ + x: rect.right - tooltipRect.width, + y: rect.y, + width: rect.width, + height: tooltipRect.height, + })) + : null)) + .map(orientVertically) + .map(keepIfFits); + + // No analogous center because we don't actually use + // center alignment... + + return orientations; + }; + + const orientationRects = { + left: prepareVerticalOrientationRects(regionRects.left), + right: prepareVerticalOrientationRects(regionRects.right), + down: prepareHorizontalOrientationRects(regionRects.bottom), + up: prepareHorizontalOrientationRects(regionRects.top), + }; + + return { + numBaselineRects: baselineRects.length, + idealBaseline: baselineRects[0], + ...orientationRects, + }; +} + +export function getTooltipBaselineOpportunityAreas(tooltip) { + // Returns multiple basic areas in order of preference, with front of the + // array representing greater preference. + + const {stickyContainers} = stickyHeadingInfo; + const results = []; + + const windowRect = + WikiRect.fromWindow().toInset(10); + + const workingRect = + WikiRect.fromRect(windowRect); + + const tooltipRect = + peekTooltipClientRect(tooltip); + + // As a baseline, always treat the window rect as fitting the tooltip. + results.unshift(WikiRect.fromRect(workingRect)); + + const containingParent = + getVisuallyContainingElement(tooltip); + + if (containingParent) { + const containingRect = + WikiRect.fromElement(containingParent); + + // Only respect a portion of the container's padding, giving + // the tooltip the impression of a "raised" element. + const padding = side => + 0.5 * + parseFloat(cssProp(containingParent, 'padding-' + side)); + + const insetContainingRect = + containingRect.toInset({ + left: padding('left'), + right: padding('right'), + top: padding('top'), + bottom: padding('bottom'), + }); + + workingRect.chopExtendingOutside(insetContainingRect); + + if (!workingRect.fits(tooltipRect)) { + return results; + } + + results.unshift(WikiRect.fromRect(workingRect)); + } + + // This currently assumes a maximum of one sticky container + // per visually containing element. + + const stickyContainer = + stickyContainers + .find(el => el.parentElement === containingParent); + + if (stickyContainer) { + const stickyRect = + stickyContainer.getBoundingClientRect() + + // Add some padding so the tooltip doesn't line up exactly + // with the edge of the sticky container. + const beneathStickyContainer = + WikiRect.beneath(stickyRect, 10); + + workingRect.chopExtendingOutside(beneathStickyContainer); + + if (!workingRect.fits(tooltipRect)) { + return results; + } + + results.unshift(WikiRect.fromRect(workingRect)); + } + + return results; +} + +export function mutatePageContent() { + for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) { + if (isolatorRoot.firstElementChild) { + cssProp(isolatorRoot.firstElementChild, 'z-index', '1'); + } + } +} + +export function addPageListeners() { + const {state} = info; + + const getTouchIdentifiers = domEvent => + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier) + .filter(identifier => typeof identifier !== 'undefined'); + + document.body.addEventListener('touchstart', domEvent => { + for (const identifier of getTouchIdentifiers(domEvent)) { + state.currentTouchIdentifiers.add(identifier); + } + }); + + window.addEventListener('scroll', () => { + for (const identifier of state.currentTouchIdentifiers) { + state.touchIdentifiersBanishedByScrolling.add(identifier); + } + }); + + document.body.addEventListener('touchend', domEvent => { + setTimeout(() => { + for (const identifier of getTouchIdentifiers(domEvent)) { + state.currentTouchIdentifiers.delete(identifier); + state.touchIdentifiersBanishedByScrolling.delete(identifier); + } + }); + }); + + const getHoverablesAndTooltips = () => [ + ...Array.from(state.registeredHoverables.keys()), + ...Array.from(state.registeredTooltips.keys()), + ]; + + document.body.addEventListener('touchend', domEvent => { + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); + + if (empty(touches)) return; + + const pointIsOverHoverableOrTooltip = + pointIsOverAnyOf(getHoverablesAndTooltips()); + + const anyTouchOverAnyHoverableOrTooltip = + touches.some(({clientX, clientY}) => + pointIsOverHoverableOrTooltip(clientX, clientY)); + + if (!anyTouchOverAnyHoverableOrTooltip) { + hideCurrentlyShownTooltip(); + } + }); + + document.body.addEventListener('click', domEvent => { + const {clientX, clientY} = domEvent; + + const pointIsOverHoverableOrTooltip = + pointIsOverAnyOf(getHoverablesAndTooltips()); + + if (!pointIsOverHoverableOrTooltip(clientX, clientY)) { + // Hide with "intent to replace" - we aren't actually going to replace + // the tooltip with a new one, but this intent indicates that it should + // be hidden right away, instead of showing. What we're really replacing, + // or rather removing, is the state of interacting with tooltips at all. + hideCurrentlyShownTooltip(true); + + // Part of that state is fast hovering, which should be canceled out. + state.fastHovering = false; + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Also cancel out of transitioning a tooltip hidden - this isn't caught + // by `hideCurrentlyShownTooltip` because a transitioning-hidden tooltip + // doesn't count as "shown" anymore. + cancelTransitioningTooltipHidden(); + } + }); +} diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js new file mode 100644 index 00000000..e9e2708d --- /dev/null +++ b/src/static/js/client/image-overlay.js @@ -0,0 +1,385 @@ +/* eslint-env browser */ + +import {getColors} from '../../shared-util/colors.js'; + +import {cssProp} from '../client-util.js'; +import {fetchWithProgress} from '../xhr-util.js'; + +export const info = { + id: 'imageOverlayInfo', + + container: null, + actionContainer: null, + + viewOriginalLinks: null, + mainImage: null, + thumbImage: null, + + actionContentWithoutSize: null, + actionContentWithSize: null, + + megabytesContainer: null, + kilobytesContainer: null, + megabytesContent: null, + kilobytesContent: null, + fileSizeWarning: null, + + links: null, +}; + +export function getPageReferences() { + info.container = + document.getElementById('image-overlay-container'); + + if (!info.container) return; + + info.actionContainer = + document.getElementById('image-overlay-action-container'); + + info.viewOriginalLinks = + document.getElementsByClassName('image-overlay-view-original'); + + info.mainImage = + document.getElementById('image-overlay-image'); + + info.thumbImage = + document.getElementById('image-overlay-image-thumb'); + + info.actionContentWithoutSize = + document.getElementById('image-overlay-action-content-without-size'); + + info.actionContentWithSize = + document.getElementById('image-overlay-action-content-with-size'); + + info.megabytesContainer = + document.getElementById('image-overlay-file-size-megabytes'); + + info.kilobytesContainer = + document.getElementById('image-overlay-file-size-kilobytes'); + + info.megabytesContent = + info.megabytesContainer.querySelector('.image-overlay-file-size-count'); + + info.kilobytesContent = + info.kilobytesContainer.querySelector('.image-overlay-file-size-count'); + + info.fileSizeWarning = + document.getElementById('image-overlay-file-size-warning'); + + const linkQuery = [ + '.image-link', + '.image-media-link', + ].join(', '); + + info.links = + Array.from(document.querySelectorAll(linkQuery)) + .filter(link => !link.closest('.no-image-preview')); +} + +export function addPageListeners() { + if (!info.container) return; + + for (const link of info.links) { + link.addEventListener('click', handleImageLinkClicked); + } + + info.container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); +} + +function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== info.container) { + return; + } + + // If you clicked anything near the action bar, don't hide the + // image overlay. + const rect = info.actionContainer.getBoundingClientRect(); + if ( + evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 && + evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20 + ) { + return; + } + + info.container.classList.remove('visible'); +} + +function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + info.container.classList.remove('visible'); + } +} + +async function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + + evt.preventDefault(); + + // Don't show the overlay if the image still needs to be revealed. + if (evt.target.closest('.reveal:not(.revealed)')) { + return; + } + + info.container.classList.add('visible'); + info.container.classList.remove('loaded'); + info.container.classList.remove('errored'); + + const details = getImageLinkDetails(evt.target); + + updateImageOverlayColors(details); + updateFileSizeInformation(details.originalFileSize); + + for (const link of info.viewOriginalLinks) { + link.href = details.originalSrc; + } + + await loadOverlayImage(details); +} + +function getImageLinkDetails(imageLink) { + const a = imageLink.closest('a'); + const img = a.querySelector('img'); + + const details = { + originalSrc: + a.href, + + embeddedSrc: + img?.src ?? + a.dataset.embedSrc, + + originalFileSize: + img?.dataset.originalSize ?? + a.dataset.originalSize ?? + null, + + availableThumbList: + img?.dataset.thumbs ?? + a.dataset.thumbs ?? + null, + + dimensions: + img?.dataset.dimensions?.split('x') ?? + a.dataset.dimensions?.split('x') ?? + null, + + color: + cssProp(imageLink, '--primary-color'), + }; + + Object.assign(details, getImageSources(details)); + + return details; +} + +function getImageSources(details) { + if (details.availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(details.availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(details.availableThumbList); + return { + mainSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`), + thumbSrc: details.embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`), + mainThumb: `${mainThumb}:${mainLength}`, + thumbThumb: `${smallThumb}:${smallLength}`, + }; + } else { + return { + mainSrc: details.originalSrc, + thumbSrc: null, + mainThumb: '', + thumbThumb: '', + }; + } +} + +function updateImageOverlayColors(details) { + if (details.color) { + let colors; + try { + colors = + getColors(details.color, { + chroma: window.chroma, + }); + } catch (error) { + console.warn(error); + return; + } + + cssProp(info.container, { + '--primary-color': colors.primary, + '--deep-color': colors.deep, + '--deep-ghost-color': colors.deepGhost, + '--bg-black-color': colors.bgBlack, + }); + } else { + cssProp(info.container, { + '--primary-color': null, + '--deep-color': null, + '--deep-ghost-color': null, + '--bg-black-color': null, + }); + } +} + +async function loadOverlayImage(details) { + if (details.thumbSrc) { + info.thumbImage.src = details.thumbSrc; + info.thumbImage.style.display = null; + info.container.classList.remove('no-thumb'); + } else { + info.thumbImage.src = ''; + info.thumbImage.style.display = 'none'; + info.container.classList.add('no-thumb'); + } + + // Show the thumbnail size on each <img> element's data attributes. + // Y'know, just for debugging convenience. + info.mainImage.dataset.displayingThumb = details.mainThumb; + info.thumbImage.dataset.displayingThumb = details.thumbThumb; + + if (details.dimensions) { + info.mainImage.width = details.dimensions[0]; + info.mainImage.height = details.dimensions[1]; + info.thumbImage.width = details.dimensions[0]; + info.thumbImage.height = details.dimensions[1]; + cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/')); + } else { + info.mainImage.removeAttribute('width'); + info.mainImage.removeAttribute('height'); + info.thumbImage.removeAttribute('width'); + info.thumbImage.removeAttribute('height'); + cssProp(info.thumbImage, 'aspect-ratio', null); + } + + info.mainImage.addEventListener('load', handleMainImageLoaded); + info.mainImage.addEventListener('error', handleMainImageErrored); + + const showProgress = amount => { + cssProp(info.container, '--download-progress', `${amount * 100}%`); + }; + + showProgress(0.00); + + const response = + await fetchWithProgress(details.mainSrc, progress => { + if (progress === -1) { + // TODO: Indeterminate response progress cue + showProgress(0.00); + } else { + showProgress(0.20 + 0.80 * progress); + } + }); + + if (!response.status.toString().startsWith('2')) { + handleMainImageErrored(); + return; + } + + const blob = await response.blob(); + const blobSrc = URL.createObjectURL(blob); + + info.mainImage.src = blobSrc; + showProgress(1.00); + + function handleMainImageLoaded() { + info.container.classList.add('loaded'); + removeEventListeners(); + } + + function handleMainImageErrored() { + info.container.classList.add('errored'); + removeEventListeners(); + } + + function removeEventListeners() { + info.mainImage.removeEventListener('load', handleMainImageLoaded); + info.mainImage.removeEventListener('error', handleMainImageErrored); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + if (!fileSize) { + info.actionContentWithSize.classList.remove('visible'); + info.actionContentWithoutSize.classList.add('visible'); + return; + } + + info.actionContentWithoutSize.classList.remove('visible'); + info.actionContentWithSize.classList.add('visible'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + info.fileSizeWarning.classList.add('visible'); + } else { + info.fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + info.megabytesContainer.classList.add('visible'); + info.kilobytesContainer.classList.remove('visible'); + info.megabytesContent.innerText = round(6); + } else { + info.megabytesContainer.classList.remove('visible'); + info.kilobytesContainer.classList.add('visible'); + info.kilobytesContent.innerText = round(3); + } +} diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js new file mode 100644 index 00000000..b2343f07 --- /dev/null +++ b/src/static/js/client/index.js @@ -0,0 +1,237 @@ +/* eslint-env browser */ + +import '../group-contributions-table.js'; + +import * as additionalNamesBoxModule from './additional-names-box.js'; +import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; +import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js'; +import * as artTagNetworkModule from './art-tag-network.js'; +import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js'; +import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js'; +import * as datetimestampTooltipModule from './datetimestamp-tooltip.js'; +import * as draggedLinkModule from './dragged-link.js'; +import * as hashLinkModule from './hash-link.js'; +import * as hoverableTooltipModule from './hoverable-tooltip.js'; +import * as imageOverlayModule from './image-overlay.js'; +import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js'; +import * as liveMousePositionModule from './live-mouse-position.js'; +import * as lyricsSwitcherModule from './lyrics-switcher.js'; +import * as quickDescriptionModule from './quick-description.js'; +import * as scriptedLinkModule from './scripted-link.js'; +import * as sidebarSearchModule from './sidebar-search.js'; +import * as stickyHeadingModule from './sticky-heading.js'; +import * as summaryNestedLinkModule from './summary-nested-link.js'; +import * as textWithTooltipModule from './text-with-tooltip.js'; +import * as wikiSearchModule from './wiki-search.js'; + +export const modules = [ + additionalNamesBoxModule, + albumCommentarySidebarModule, + artTagGalleryFilterModule, + artTagNetworkModule, + artistExternalLinkTooltipModule, + cssCompatibilityAssistantModule, + datetimestampTooltipModule, + draggedLinkModule, + hashLinkModule, + hoverableTooltipModule, + imageOverlayModule, + intrapageDotSwitcherModule, + liveMousePositionModule, + lyricsSwitcherModule, + quickDescriptionModule, + scriptedLinkModule, + sidebarSearchModule, + stickyHeadingModule, + summaryNestedLinkModule, + textWithTooltipModule, + wikiSearchModule, +]; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +for (const module of modules) { + const {info} = module; + + if (!info) { + throw new Error(`Module missing info`); + } + + const {id: infoKey} = info; + + if (!infoKey) { + throw new Error(`Module info missing id: ` + JSON.stringify(info)); + } + + clientInfo[infoKey] = info; + + for (const obj of [ + info, + info.state, + info.settings, + info.event, + ]) { + if (!obj) continue; + + if (obj !== info) { + obj[Symbol.for('hsmusic.clientInfo')] = info; + } + + Object.preventExtensions(obj); + } + + if (info.session) { + const sessionSpecs = info.session; + + info.session = {}; + + for (const [key, spec] of Object.entries(sessionSpecs)) { + const hasSpec = + typeof spec === 'object' && spec !== null; + + const defaultValue = + (hasSpec + ? spec.default ?? null + : spec); + + let formatRead = value => value; + let formatWrite = value => value; + if (hasSpec && spec.type) { + switch (spec.type) { + case 'number': + formatRead = parseFloat; + formatWrite = String; + break; + + case 'boolean': + formatRead = Boolean; + formatWrite = String; + break; + + case 'string': + formatRead = String; + formatWrite = String; + break; + + case 'json': + formatRead = JSON.parse; + formatWrite = JSON.stringify; + break; + + default: + throw new Error(`Unknown type for session storage spec "${spec.type}"`); + } + } + + let getMaxLength = + (!hasSpec + ? () => Infinity + : typeof spec.maxLength === 'function' + ? (info.settings + ? () => spec.maxLength(info.settings) + : () => spec.maxLength()) + : () => spec.maxLength); + + const storageKey = `hsmusic.${infoKey}.${key}`; + + let fallbackValue = defaultValue; + + Object.defineProperty(info.session, key, { + get: () => { + let value; + try { + value = sessionStorage.getItem(storageKey) ?? defaultValue; + } catch (error) { + if (error instanceof DOMException) { + value = fallbackValue; + } else { + throw error; + } + } + + if (value === null) { + return null; + } + + return formatRead(value); + }, + + set: (value) => { + if (value !== null && value !== '') { + value = formatWrite(value); + } + + if (value === null) { + value = ''; + } + + const maxLength = getMaxLength(); + if (value.length > maxLength) { + console.warn( + `Requested to set session storage ${storageKey} ` + + `beyond maximum length ${maxLength}, ` + + `ignoring this value.`); + console.trace(); + return; + } + + let operation; + if (value === '') { + fallbackValue = null; + operation = () => { + sessionStorage.removeItem(storageKey); + }; + } else { + fallbackValue = value; + operation = () => { + sessionStorage.setItem(storageKey, value); + }; + } + + try { + operation(); + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + } + }, + }); + } + + Object.preventExtensions(info.session); + } + + for (const key of Object.keys(clientSteps)) { + if (Object.hasOwn(module, key)) { + const fn = module[key]; + + Object.defineProperty(fn, 'name', { + value: `${infoKey}/${fn.name}`, + }); + + clientSteps[key].push(fn); + } + } +} + +for (const [key, steps] of Object.entries(clientSteps)) { + for (const step of steps) { + try { + step(); + } catch (error) { + // TODO: Be smarter about not running later steps for the same module! + // Or maybe not, since an error is liable to cause explosions anyway. + console.error(`During ${key}, failed to run ${step.name}`); + console.error(error); + } + } +} diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js new file mode 100644 index 00000000..d06bc5a6 --- /dev/null +++ b/src/static/js/client/intrapage-dot-switcher.js @@ -0,0 +1,82 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'intrapageDotSwitcherInfo', + + // Each is a two-level array, by switcher. + // This is an evil data structure. + switcherSpans: null, + switcherLinks: null, + switcherTargets: null, +}; + +export function getPageReferences() { + const switchers = + Array.from(document.querySelectorAll('.dot-switcher.intrapage')); + + info.switcherSpans = + switchers + .map(switcher => switcher.querySelectorAll(':scope > span')) + .map(spans => Array.from(spans)); + + info.switcherLinks = + info.switcherSpans + .map(spans => spans + .map(span => span.querySelector(':scope > a'))); + + info.switcherTargets = + info.switcherLinks + .map(links => links + .map(link => { + const targetID = link.getAttribute('data-target-id'); + const target = document.getElementById(targetID); + if (target) { + return target; + } else { + console.warn( + `An intrapage dot switcher option is targetting an ID that doesn't exist, #${targetID}`, + link); + link.setAttribute('inert', ''); + return null; + } + })); +} + +export function addPageListeners() { + for (const {links, spans, targets} of stitchArrays({ + spans: info.switcherSpans, + links: info.switcherLinks, + targets: info.switcherTargets, + })) { + for (const [index, {span, link, target}] of stitchArrays({ + span: spans, + link: links, + target: targets, + }).entries()) { + const otherSpans = + [...spans.slice(0, index), ...spans.slice(index + 1)]; + + const otherTargets = + [...targets.slice(0, index), ...targets.slice(index + 1)]; + + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + for (const otherSpan of otherSpans) { + otherSpan.classList.remove('current'); + } + + for (const otherTarget of otherTargets) { + cssProp(otherTarget, 'display', 'none'); + } + + span.classList.add('current'); + cssProp(target, 'display', 'block'); + }); + } + } +} diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js new file mode 100644 index 00000000..36a28429 --- /dev/null +++ b/src/static/js/client/live-mouse-position.js @@ -0,0 +1,21 @@ +/* eslint-env browser */ + +export const info = { + id: 'liveMousePositionInfo', + + state: { + clientX: null, + clientY: null, + }, +}; + +export function addPageListeners() { + const {state} = info; + + document.body.addEventListener('mousemove', domEvent => { + Object.assign(state, { + clientX: domEvent.clientX, + clientY: domEvent.clientY, + }); + }); +} diff --git a/src/static/js/client/lyrics-switcher.js b/src/static/js/client/lyrics-switcher.js new file mode 100644 index 00000000..b350ea50 --- /dev/null +++ b/src/static/js/client/lyrics-switcher.js @@ -0,0 +1,70 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'lyricsSwitcherInfo', + + entries: null, + switchLinks: null, + currentLinks: null, +}; + +export function getPageReferences() { + const content = document.getElementById('content'); + + if (!content) return; + + const switcher = content.querySelector('.lyrics-switcher'); + + if (!switcher) return; + + info.entries = + Array.from(content.querySelectorAll('.lyrics-entry')); + + info.currentLinks = + Array.from(switcher.querySelectorAll('a.current')); + + info.switchLinks = + Array.from(switcher.querySelectorAll('a:not(.current)')); +} + +export function addPageListeners() { + if (!info.switchLinks) return; + + for (const {switchLink, entry} of stitchArrays({ + switchLink: info.switchLinks, + entry: info.entries, + })) { + switchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + showLyricsEntry(entry); + }); + } +} + +function showLyricsEntry(entry) { + const entryToShow = entry; + + stitchArrays({ + entry: info.entries, + currentLink: info.currentLinks, + switchLink: info.switchLinks, + }).forEach(({ + entry, + currentLink, + switchLink, + }) => { + if (entry === entryToShow) { + cssProp(entry, 'display', null); + cssProp(currentLink, 'display', null); + cssProp(switchLink, 'display', 'none'); + } else { + cssProp(entry, 'display', 'none'); + cssProp(currentLink, 'display', 'none'); + cssProp(switchLink, 'display', null); + } + }); +} 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..8b8d8a13 --- /dev/null +++ b/src/static/js/client/scripted-link.js @@ -0,0 +1,285 @@ +/* eslint-env browser */ + +import {pick, stitchArrays} from '../../shared-util/sugar.js'; + +import { + cssProp, + rebase, + openAlbum, + openArtist, + openTrack, +} from '../client-util.js'; + +export const info = { + id: 'scriptedLinkInfo', + + randomLinks: null, + revealLinks: null, + revealContainers: null, + + nextNavLink: null, + previousNavLink: null, + randomNavLink: null, + + state: { + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, + }, +}; + +export function getPageReferences() { + info.randomLinks = + document.querySelectorAll('[data-random]'); + + info.revealLinks = + document.querySelectorAll('.reveal .image-outer-area > *'); + + info.revealContainers = + Array.from(info.revealLinks) + .map(link => link.closest('.reveal')); + + info.nextNavLink = + document.getElementById('next-button'); + + info.previousNavLink = + document.getElementById('previous-button'); + + info.randomNavLink = + document.getElementById('random-button'); +} + +export function addPageListeners() { + addRandomLinkListeners(); + addNavigationKeyPressListeners(); + addRevealLinkClickListeners(); +} + +function addRandomLinkListeners() { + for (const a of info.randomLinks ?? []) { + a.addEventListener('click', domEvent => { + handleRandomLinkClicked(a, domEvent); + }); + } +} + +function handleRandomLinkClicked(a, domEvent) { + const href = determineRandomLinkHref(a); + + if (!href) { + domEvent.preventDefault(); + return; + } + + setTimeout(() => { + a.href = '#' + }); + + a.href = href; +} + +function determineRandomLinkHref(a) { + const {state} = info; + + const trackDirectoriesFromAlbumDirectories = albumDirectories => + albumDirectories + .map(directory => state.albumDirectories.indexOf(directory)) + .map(index => state.albumTrackDirectories[index]) + .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); + + switch (a.dataset.random) { + case 'album': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + return openAlbum(pick(albumDirectories)); + } + + case 'track': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + albumDirectories); + + return openTrack(pick(trackDirectories)); + } + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const listAlbumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + return openAlbum(pick(listAlbumDirectories)); + } + + case 'track-in-group-dl': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const listAlbumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + listAlbumDirectories); + + return openTrack(pick(trackDirectories)); + } + + case 'track-in-sidebar': { + // Note that the container for track links may be <ol> or <ul>, and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + return pick(trackLinks).href; + } + + case 'track-in-album': { + const {albumDirectories, albumTrackDirectories} = state; + if (!albumDirectories || !albumTrackDirectories) return null; + + const albumDirectory = cssProp(a, '--album-directory'); + const albumIndex = albumDirectories.indexOf(albumDirectory); + const trackDirectories = albumTrackDirectories[albumIndex]; + + return openTrack(pick(trackDirectories)); + } + + case 'artist': { + const {artistDirectories} = state; + if (!artistDirectories) return null; + + return openArtist(pick(artistDirectories)); + } + + case 'artist-more-than-one-contrib': { + const {artistDirectories, artistNumContributions} = state; + if (!artistDirectories || !artistNumContributions) return null; + + const filteredArtistDirectories = + artistDirectories + .filter((_artist, index) => artistNumContributions[index] > 1); + + return openArtist(pick(filteredArtistDirectories)); + } + } +} + +export function mutatePageContent() { + mutateNavigationLinkContent(); +} + +function mutateNavigationLinkContent() { + const prependTitle = (el, prepend) => { + if (!el) return; + if (!el.hasAttribute('href')) return; + + el?.setAttribute( + 'title', + (el.hasAttribute('title') + ? prepend + ' ' + el.getAttribute('title') + : prepend)); + }; + + prependTitle(info.nextNavLink, '(Shift+N)'); + prependTitle(info.previousNavLink, '(Shift+P)'); + prependTitle(info.randomNavLink, '(Shift+R)'); +} + +function addNavigationKeyPressListeners() { + document.addEventListener('keypress', (event) => { + const {tagName} = document.activeElement ?? {}; + if (tagName === 'INPUT' || tagName === 'TEXTAREA') { + return; + } + + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + info.nextNavLink?.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + info.previousNavLink?.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + info.randomNavLink?.click(); + } + } + }); +} + +function addRevealLinkClickListeners() { + for (const {revealLink, revealContainer} of stitchArrays({ + revealLink: Array.from(info.revealLinks ?? []), + revealContainer: Array.from(info.revealContainers ?? []), + })) { + revealLink.addEventListener('click', (event) => { + handleRevealLinkClicked(event, revealLink, revealContainer); + }); + } +} + +function handleRevealLinkClicked(domEvent, _revealLink, revealContainer) { + if (revealContainer.classList.contains('revealed')) { + return; + } + + domEvent.preventDefault(); + revealContainer.classList.add('revealed'); + revealContainer.dispatchEvent(new CustomEvent('hsmusic-reveal')); +} + +if ( + document.documentElement.dataset.urlKey === 'localized.listing' && + document.documentElement.dataset.urlValue0 === 'random' +) { + const dataLoadingLine = document.getElementById('data-loading-line'); + const dataLoadedLine = document.getElementById('data-loaded-line'); + const dataErrorLine = document.getElementById('data-error-line'); + + dataLoadingLine.style.display = 'block'; + + fetch(rebase('random-link-data.json', 'rebaseShared')) + .then(data => data.json()) + .then(data => { + const {state} = info; + + Object.assign(state, { + albumDirectories: data.albumDirectories, + albumTrackDirectories: data.albumTrackDirectories, + artistDirectories: data.artistDirectories, + artistNumContributions: data.artistNumContributions, + }); + + dataLoadingLine.style.display = 'none'; + dataLoadedLine.style.display = 'block'; + }, () => { + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; + }) + .then(() => { + for (const a of info.randomLinks) { + const href = determineRandomLinkHref(a); + if (!href) { + a.removeAttribute('href'); + } + } + }); +} diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js new file mode 100644 index 00000000..fb902636 --- /dev/null +++ b/src/static/js/client/sidebar-search.js @@ -0,0 +1,1147 @@ +/* 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 {getLatestDraggedLink} from './dragged-link.js'; + +import { + info as wikiSearchInfo, + getSearchWorkerDownloadContext, + searchAll, +} from './wiki-search.js'; + +export const info = { + id: 'sidebarSearchInfo', + + pageContainer: null, + + searchSidebarColumn: null, + searchBox: null, + searchLabel: null, + searchInput: null, + + progressRule: null, + progressContainer: null, + progressLabel: null, + progressBar: null, + + failedRule: null, + failedContainer: null, + + resultsRule: null, + resultsContainer: null, + results: null, + + endSearchRule: null, + endSearchLine: null, + endSearchLink: null, + + standbyInputPlaceholder: null, + + preparingString: null, + loadingDataString: null, + searchingString: null, + failedString: null, + + noResultsString: null, + currentResultString: null, + endSearchString: null, + + albumResultKindString: null, + artistResultKindString: null, + groupResultKindString: null, + tagResultKindString: null, + + state: { + sidebarColumnShownForSearch: null, + + tidiedSidebar: null, + collapsedDetailsForTidiness: null, + + recallingRecentSearch: null, + recallingRecentSearchFromMouse: null, + + currentValue: null, + + workerStatus: null, + searchStage: null, + + stoppedTypingTimeout: null, + stoppedScrollingTimeout: null, + focusFirstResultTimeout: null, + dismissChangeEventTimeout: null, + + indexDownloadStatuses: Object.create(null), + }, + + session: { + activeQuery: { + type: 'string', + }, + + activeQueryResults: { + type: 'json', + maxLength: settings => settings.maxActiveResultsStorage, + }, + + repeatQueryOnReload: { + type: 'boolean', + default: false, + }, + + resultsScrollOffset: { + type: 'number', + }, + }, + + settings: { + stoppedTypingDelay: 800, + stoppedScrollingDelay: 200, + + pressDownToFocusFirstResultLatency: 500, + dismissChangeEventAfterFocusingFirstResultLatency: 50, + + maxActiveResultsStorage: 100000, + }, +}; + +export function getPageReferences() { + info.pageContainer = + document.getElementById('page-container'); + + info.searchBox = + document.querySelector('.wiki-search-sidebar-box'); + + if (!info.searchBox) { + return; + } + + info.searchLabel = + info.searchBox.querySelector('.wiki-search-label'); + + info.searchInput = + info.searchBox.querySelector('.wiki-search-input'); + + info.searchSidebarColumn = + info.searchBox.closest('.sidebar-column'); + + info.standbyInputPlaceholder = + info.searchInput.placeholder; + + const findString = classPart => + info.searchBox.querySelector(`.wiki-search-${classPart}-string`); + + info.preparingString = + findString('preparing'); + + info.loadingDataString = + findString('loading-data'); + + info.searchingString = + findString('searching'); + + info.failedString = + findString('failed'); + + info.noResultsString = + findString('no-results'); + + info.currentResultString = + findString('current-result'); + + info.endSearchString = + findString('end-search'); + + info.albumResultKindString = + findString('album-result-kind'); + + info.artistResultKindString = + findString('artist-result-kind'); + + info.groupResultKindString = + findString('group-result-kind'); + + info.tagResultKindString = + findString('tag-result-kind'); +} + +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('mousedown', _domEvent => { + const {state} = info; + + if (state.recallingRecentSearch) { + state.recallingRecentSearchFromMouse = true; + } + }); + + info.searchInput.addEventListener('focus', _domEvent => { + const {session, state} = info; + + if (state.recallingRecentSearch) { + info.searchInput.value = session.activeQuery; + info.searchInput.placeholder = info.standbyInputPlaceholder; + showSidebarSearchResults(session.activeQueryResults); + state.recallingRecentSearch = false; + } + }); + + info.searchLabel.addEventListener('click', domEvent => { + const {state} = info; + + if (state.recallingRecentSearchFromMouse) { + if (info.searchInput.selectionStart === info.searchInput.selectionEnd) { + info.searchInput.select(); + } + + state.recallingRecentSearchFromMouse = false; + return; + } + + const inputRect = info.searchInput.getBoundingClientRect(); + if (domEvent.clientX < inputRect.left - 3) { + info.searchInput.select(); + } + }); + + info.searchInput.addEventListener('change', _domEvent => { + const {state} = info; + + if (state.dismissChangeEventTimeout) { + state.dismissChangeEventTimeout = null; + clearTimeout(state.dismissChangeEventTimeout); + return; + } + + activateSidebarSearch(info.searchInput.value); + }); + + info.searchInput.addEventListener('input', _domEvent => { + const {settings, state} = info; + + if (!info.searchInput.value) { + clearSidebarSearch(); + return; + } + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + } + + state.stoppedTypingTimeout = + setTimeout(() => { + state.stoppedTypingTimeout = null; + activateSidebarSearch(info.searchInput.value); + }, settings.stoppedTypingDelay); + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + } + }); + + info.searchInput.addEventListener('drop', handleDroppedIntoSearchInput); + + info.searchInput.addEventListener('keydown', domEvent => { + const {settings, state} = info; + + if (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown') { + domEvent.preventDefault(); + } + + if (domEvent.key === 'ArrowDown') { + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + } + + state.focusFirstResultTimeout = + setTimeout(() => { + state.focusFirstResultTimeout = null; + }, settings.pressDownToFocusFirstResultLatency); + + activateSidebarSearch(info.searchInput.value); + } else { + focusFirstSidebarSearchResult(); + } + } + }); + + document.addEventListener('selectionchange', _domEvent => { + const {state} = info; + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + } + }); + + info.endSearchLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + clearSidebarSearch(); + 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) { + if (session.repeatQueryOnReload) { + info.searchInput.value = session.activeQuery; + activateSidebarSearch(session.activeQuery); + } else if (session.activeQueryResults) { + considerRecallingRecentSidebarSearch(); + } + } +} + +function trackSidebarSearchWorkerAlive() { + const {state} = info; + + state.workerStatus = 'alive'; +} + +function trackSidebarSearchWorkerReady() { + const {state} = info; + + state.workerStatus = 'ready'; + state.searchStage = 'searching'; +} + +function trackSidebarSearchWorkerFailsToInitialize() { + const {state} = info; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchWorkerHasRuntimeError() { + const {state} = info; + + state.workerStatus = 'failed'; + state.searchStage = 'failed'; +} + +function trackSidebarSearchDownloadsBegin(event) { + const {state} = info; + + if (event.context === 'search-indexes') { + for (const key of event.keys) { + state.indexDownloadStatuses[key] = 'active'; + } + } +} + +function trackSidebarSearchDownloadEnds(event) { + const {state} = info; + + if (event.context === 'search-indexes') { + state.indexDownloadStatuses[event.key] = 'complete'; + + const statuses = Object.values(state.indexDownloadStatuses); + if (statuses.every(status => status === 'complete')) { + for (const key of Object.keys(state.indexDownloadStatuses)) { + delete state.indexDownloadStatuses[key]; + } + } + } +} + +async function activateSidebarSearch(query) { + const {session, state} = info; + + if (!query) { + return; + } + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + state.searchStage = + (state.workerStatus === 'ready' + ? 'searching' + : 'preparing'); + updateSidebarSearchStatus(); + + let results; + try { + results = await searchAll(query, {enrich: true}); + } catch (error) { + console.error(`There was an error performing a sidebar search:`); + console.error(error); + showSidebarSearchFailed(); + return; + } + + state.searchStage = 'complete'; + updateSidebarSearchStatus(); + + session.activeQuery = query; + session.activeQueryResults = results; + session.resultsScrollOffset = 0; + + showSidebarSearchResults(results); + + if (state.focusFirstResultTimeout) { + clearTimeout(state.focusFirstResultTimeout); + state.focusFirstResultTimeout = null; + focusFirstSidebarSearchResult(); + } +} + +function clearSidebarSearch() { + const {session, state} = info; + + if (state.stoppedTypingTimeout) { + clearTimeout(state.stoppedTypingTimeout); + state.stoppedTypingTimeout = null; + } + + info.searchBox.classList.remove('showing-results'); + info.searchSidebarColumn.classList.remove('search-showing-results'); + + info.searchInput.value = ''; + + state.searchStage = null; + + session.activeQuery = null; + session.activeQueryResults = null; + session.resultsScrollOffset = null; + + hideSidebarSearchResults(); +} + +function 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(); + }); + + link.addEventListener('keydown', domEvent => { + if (domEvent.key === 'ArrowDown') { + const elem = link.nextElementSibling; + if (elem) { + domEvent.preventDefault(); + elem.focus({focusVisible: true}); + } + } else if (domEvent.key === 'ArrowUp') { + domEvent.preventDefault(); + const elem = link.previousElementSibling; + if (elem) { + elem.focus({focusVisible: true}); + } else { + info.searchInput.focus(); + } + } + }); + + return link; +} + +function hideSidebarSearchResults() { + cssProp(info.resultsRule, 'display', 'none'); + cssProp(info.resultsContainer, 'display', 'none'); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.endSearchRule, 'display', 'none'); + cssProp(info.endSearchLine, 'display', 'none'); +} + +function focusFirstSidebarSearchResult() { + const {settings, state} = info; + + const elem = info.results.firstChild; + if (!elem?.classList.contains('wiki-search-result')) { + return; + } + + if (state.dismissChangeEventTimeout) { + clearTimeout(state.dismissChangeEventTimeout); + } + + state.dismissChangeEventTimeout = + setTimeout(() => { + state.dismissChangeEventTimeout = null; + }, settings.dismissChangeEventAfterFocusingFirstResultLatency); + + elem.focus({focusVisible: true}); +} + +function saveSidebarSearchResultsScrollOffset() { + const {session} = info; + + session.resultsScrollOffset = info.resultsContainer.scrollTop; +} + +function restoreSidebarSearchResultsScrollOffset() { + const {session} = info; + + if (session.resultsScrollOffset) { + info.resultsContainer.scrollTop = session.resultsScrollOffset; + } +} + +function showSearchSidebarColumn() { + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!info.searchSidebarColumn.classList.contains('initially-hidden')) { + return; + } + + info.searchSidebarColumn.classList.remove('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.add('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.add('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = true; +} + +function possiblyHideSearchSidebarColumn() { + const {state} = info; + + if (!info.searchSidebarColumn) { + return; + } + + if (!state.sidebarColumnShownForSearch) { + return; + } + + info.searchSidebarColumn.classList.add('initially-hidden'); + + if (info.searchSidebarColumn.id === 'sidebar-left') { + info.pageContainer.classList.remove('showing-sidebar-left'); + } else if (info.searchSidebarColumn.id === 'sidebar-right') { + info.pageContainer.classList.remove('showing-sidebar-right'); + } + + state.sidebarColumnShownForSearch = null; +} + +// This should be called after results are shown, since it checks the +// elements added to understand the current search state. +function tidySidebarSearchColumn() { + const {state} = info; + + // Don't *re-tidy* the sidebar if we've already tidied it to display + // some results. This flag will get cleared if the search is dismissed + // altogether (and the pre-tidy state is restored). + if (state.tidiedSidebar) { + return; + } + + const here = location.href.replace(/\/$/, ''); + const currentPageIsResult = + Array.from(info.results.querySelectorAll('a')) + .some(link => { + const there = link.href.replace(/\/$/, ''); + return here === there; + }); + + // Don't tidy the sidebar if you've navigated to some other page than + // what's in the current result list. + if (!currentPageIsResult) { + return; + } + + state.tidiedSidebar = true; + state.collapsedDetailsForTidiness = []; + + for (const box of info.searchSidebarColumn.querySelectorAll('.sidebar')) { + if (box === info.searchBox) { + continue; + } + + for (const details of box.getElementsByTagName('details')) { + if (details.open) { + details.removeAttribute('open'); + state.collapsedDetailsForTidiness.push(details); + } + } + } +} + +function restoreSidebarSearchColumn() { + const {state} = info; + + if (!state.tidiedSidebar) { + return; + } + + for (const details of state.collapsedDetailsForTidiness) { + details.setAttribute('open', ''); + } + + state.collapsedDetailsForTidiness = []; + state.tidiedSidebar = null; + + info.searchInput.placeholder = info.standbyInputPlaceholder; +} + +function considerRecallingRecentSidebarSearch() { + const {session, state} = info; + + if (document.documentElement.dataset.urlKey === 'localized.home') { + return forgetRecentSidebarSearch(); + } + + info.searchInput.placeholder = session.activeQuery; + state.recallingRecentSearch = true; +} + +function forgetRecentSidebarSearch() { + const {session} = info; + + session.activeQuery = null; + session.activeQueryResults = null; +} + +async function handleDroppedIntoSearchInput(domEvent) { + const itemByType = type => + Array.from(domEvent.dataTransfer.items) + .find(item => item.type === type); + + const textItem = itemByType('text/plain'); + + if (!textItem) return; + + domEvent.preventDefault(); + + const getAssTring = item => + new Promise(res => item.getAsString(res)) + .then(string => string.trim()); + + const timer = Date.now(); + + let droppedText = + await getAssTring(textItem); + + if (Date.now() - timer > 500) return; + if (!droppedText) return; + + let droppedURL; + try { + droppedURL = new URL(droppedText); + } catch (error) { + droppedURL = null; + } + + if (droppedURL) matchLink: { + const isDroppedURL = a => + a.toString() === droppedURL.toString(); + + const matchingLinks = + Array.from(document.getElementsByTagName('a')) + .filter(a => + isDroppedURL(new URL(a.href, document.documentURI))); + + const latestDraggedLink = getLatestDraggedLink(); + + if (!matchingLinks.includes(latestDraggedLink)) { + break matchLink; + } + + let matchedLink = latestDraggedLink; + + if (matchedLink.querySelector('.normal-content')) { + matchedLink = matchedLink.cloneNode(true); + for (const node of matchedLink.querySelectorAll('.normal-content')) { + node.remove(); + } + } + + droppedText = matchedLink.innerText; + } + + if (droppedText.includes('-')) splitDashes: { + if (droppedURL) break splitDashes; + if (droppedText.includes(' ')) break splitDashes; + + const parts = droppedText.split('-'); + if (parts.length === 2) break splitDashes; + + droppedText = parts.join(' '); + } + + info.searchInput.value = droppedText; + activateSidebarSearch(info.searchInput.value); +} diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js new file mode 100644 index 00000000..b65574d0 --- /dev/null +++ b/src/static/js/client/sticky-heading.js @@ -0,0 +1,345 @@ +/* eslint-env browser */ + +import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {cssProp, dispatchInternalEvent, templateContent} + from '../client-util.js'; + +export const info = { + id: 'stickyHeadingInfo', + + stickyRoots: null, + + stickyContainers: null, + staticContainers: null, + + stickyHeadingRows: null, + stickyHeadings: null, + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCoverColumns: null, + contentCovers: null, + contentCoversReveal: null, + + referenceCollapsedHeading: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + whenStuckStatusChanges: [], + }, +}; + +export function getPageReferences() { + info.stickyRoots = + Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])')); + + info.stickyContainers = + info.stickyRoots + .map(el => el.querySelector('.content-sticky-heading-container')); + + info.staticContainers = + info.stickyRoots + .map(el => el.nextElementSibling); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickyHeadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-row')); + + info.stickyHeadings = + info.stickyHeadingRows + .map(el => el.querySelector('h1')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.closest('.content-sticky-heading-root').parentElement); + + info.contentCoverColumns = + info.contentContainers + .map(el => el.querySelector('#artwork-column')); + + info.contentCovers = + info.contentCoverColumns + .map(el => el ? el.querySelector('.cover-artwork') : null); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); + + info.referenceCollapsedHeading = + info.stickyHeadings + .map(el => el.querySelector('.reference-collapsed-heading')); +} + +export function mutatePageContent() { + removeTextPlaceholderStickyHeadingCovers(); + addRevealClassToStickyHeadingCovers(); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStuckStatus(index) { + const {event} = info; + + const contentContainer = info.contentContainers[index]; + const stickyContainer = info.stickyContainers[index]; + + const wasStuck = stickyContainer.classList.contains('stuck'); + const stuck = topOfViewInside(contentContainer); + + if (stuck === wasStuck) return; + + if (stuck) { + stickyContainer.classList.add('stuck'); + } else { + stickyContainer.classList.remove('stuck'); + } + + dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck); +} + +function updateCollapseStatus(index) { + const stickyContainer = info.stickyContainers[index]; + const staticContainer = info.staticContainers[index]; + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + + const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect(); + const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect(); + + if ( + staticContainer.getBoundingClientRect().bottom < 4 || + staticContainer.getBoundingClientRect().top < -80 + ) { + if (!stickyContainer.classList.contains('collapse')) { + stickyContainer.classList.add('collapse'); + cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px'); + cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px'); + } + } else { + stickyContainer.classList.remove('collapse'); + } +} + +function updateStickyCoverVisibility(index) { + const stickyCoverContainer = info.stickyCoverContainers[index]; + const stickyContainer = info.stickyContainers[index]; + const contentCoverColumn = info.contentCoverColumns[index]; + + if (contentCoverColumn && stickyCoverContainer) { + if (contentCoverColumn.getBoundingClientRect().bottom < 4) { + stickyCoverContainer.classList.add('visible'); + stickyContainer.classList.add('cover-visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + stickyContainer.classList.remove('cover-visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickyHeadingRow = info.stickyHeadingRows[index]; + const stickyRect = stickyHeadingRow.getBoundingClientRect(); + + // Subheadings only appear when the sticky heading is collapsed, + // so the used bottom edge should always be *as though* it's only + // displaying one line of text. Subtract the current discrepancy. + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + const correctBottomEdge = + stickyHeading.getBoundingClientRect().height - + referenceCollapsedHeading.getBoundingClientRect().height; + + const stickyBottom = + (stickyRect.bottom + - correctBottomEdge); + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const {event, state} = info; + + const stickyContainer = info.stickyContainers[index]; + + const closestHeading = + (stickyContainer.classList.contains('collapse') + ? getContentHeadingClosestToStickySubheading(index) + : null); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + const textContainer = + templateContent( + closestHeading.querySelector('.content-heading-sticky-title')) ?? + closestHeading.querySelector('.content-heading-main-title') ?? + closestHeading; + + for (const child of textContainer.childNodes) { + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); +} + +export function updateStickyHeadings(index) { + updateStuckStatus(index); + updateCollapseStatus(index); + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +export function initializeState() { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +export function addPageListeners() { + addRevealListenersForStickyHeadingCovers(); + addScrollListenerForStickyHeadings(); +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js new file mode 100644 index 00000000..23857fa5 --- /dev/null +++ b/src/static/js/client/summary-nested-link.js @@ -0,0 +1,48 @@ +/* eslint-env browser */ + +import { + empty, + filterMultipleArrays, + stitchArrays, +} from '../../shared-util/sugar.js'; + +export const info = { + id: 'summaryNestedLinkInfo', + + summaries: null, + links: null, +}; + +export function getPageReferences() { + info.summaries = + Array.from(document.getElementsByTagName('summary')); + + info.links = + info.summaries + .map(summary => + Array.from(summary.getElementsByTagName('a'))); + + filterMultipleArrays( + info.summaries, + info.links, + (_summary, links) => !empty(links)); +} + +export function addPageListeners() { + for (const {summary, links} of stitchArrays({ + summary: info.summaries, + links: info.links, + })) { + for (const link of links) { + link.addEventListener('mouseover', () => { + link.classList.add('nested-hover'); + summary.classList.add('has-nested-hover'); + }); + + link.addEventListener('mouseout', () => { + link.classList.remove('nested-hover'); + summary.classList.remove('has-nested-hover'); + }); + } + } +} diff --git a/src/static/js/client/text-with-tooltip.js b/src/static/js/client/text-with-tooltip.js new file mode 100644 index 00000000..dd207e04 --- /dev/null +++ b/src/static/js/client/text-with-tooltip.js @@ -0,0 +1,34 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +import {registerTooltipElement, registerTooltipHoverableElement} + from './hoverable-tooltip.js'; + +export const info = { + id: 'textWithTooltipInfo', + + hoverables: null, + tooltips: null, +}; + +export function getPageReferences() { + const spans = + Array.from(document.querySelectorAll('.text-with-tooltip')); + + info.hoverables = + spans.map(span => span.children[0]); + + info.tooltips = + spans.map(span => span.children[1]); +} + +export function addPageListeners() { + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js new file mode 100644 index 00000000..2446c172 --- /dev/null +++ b/src/static/js/client/wiki-search.js @@ -0,0 +1,239 @@ +/* eslint-env browser */ + +import {promiseWithResolvers} from '../../shared-util/sugar.js'; + +import {dispatchInternalEvent} from '../client-util.js'; + +export const info = { + id: 'wikiSearchInfo', + + state: { + worker: null, + + workerReadyPromise: null, + workerReadyPromiseResolvers: null, + + workerActionCounter: 0, + workerActionPromiseResolverMap: new Map(), + + downloads: Object.create(null), + }, + + event: { + whenWorkerAlive: [], + whenWorkerReady: [], + whenWorkerFailsToInitialize: [], + whenWorkerHasRuntimeError: [], + + whenDownloadBegins: [], + whenDownloadsBegin: [], + whenDownloadProgresses: [], + whenDownloadEnds: [], + }, +}; + +export async function initializeSearchWorker() { + const {state} = info; + + if (state.worker) { + return await state.workerReadyPromise; + } + + state.worker = + new Worker( + import.meta.resolve('../search-worker.js'), + {type: 'module'}); + + state.worker.onmessage = handleSearchWorkerMessage; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerReadyPromiseResolvers = {resolve, reject}; + + return await (state.workerReadyPromise = promise); +} + +function handleSearchWorkerMessage(message) { + switch (message.data.kind) { + case 'status': + handleSearchWorkerStatusMessage(message); + break; + + case 'result': + handleSearchWorkerResultMessage(message); + break; + + case 'download-begun': + handleSearchWorkerDownloadBegunMessage(message); + break; + + case 'download-progress': + handleSearchWorkerDownloadProgressMessage(message); + break; + + case 'download-complete': + handleSearchWorkerDownloadCompleteMessage(message); + break; + + default: + console.warn(`Unknown message kind "${message.data.kind}" <- from search worker`); + break; + } +} + +function handleSearchWorkerStatusMessage(message) { + const {state, event} = info; + + switch (message.data.status) { + case 'alive': + console.debug(`Search worker is alive, but not yet ready.`); + dispatchInternalEvent(event, 'whenWorkerAlive'); + break; + + case 'ready': + console.debug(`Search worker has loaded corpuses and is ready.`); + state.workerReadyPromiseResolvers.resolve(state.worker); + dispatchInternalEvent(event, 'whenWorkerReady'); + break; + + case 'setup-error': + console.debug(`Search worker failed to initialize.`); + state.workerReadyPromiseResolvers.reject(new Error('Received "setup-error" status from worker')); + dispatchInternalEvent(event, 'whenWorkerFailsToInitialize'); + break; + + case 'runtime-error': + console.debug(`Search worker had an uncaught runtime error.`); + dispatchInternalEvent(event, 'whenWorkerHasRuntimeError'); + break; + + default: + console.warn(`Unknown status "${message.data.status}" <- from search worker`); + break; + } +} + +function handleSearchWorkerResultMessage(message) { + const {state} = info; + const {id} = message.data; + + if (!id) { + console.warn(`Result without id <- from search worker:`, message.data); + return; + } + + if (!state.workerActionPromiseResolverMap.has(id)) { + console.warn(`Runaway result id <- from search worker:`, message.data); + return; + } + + const {resolve, reject} = + state.workerActionPromiseResolverMap.get(id); + + switch (message.data.status) { + case 'resolve': + resolve(message.data.value); + break; + + case 'reject': + reject(message.data.value); + break; + + default: + console.warn(`Unknown result status "${message.data.status}" <- from search worker`); + return; + } + + state.workerActionPromiseResolverMap.delete(id); +} + +function handleSearchWorkerDownloadBegunMessage(message) { + const {event} = info; + const {context: contextKey, keys} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey, true); + + for (const key of keys) { + context[key] = 0.00; + + dispatchInternalEvent(event, 'whenDownloadBegins', { + context: contextKey, + key, + }); + } + + dispatchInternalEvent(event, 'whenDownloadsBegin', { + context: contextKey, + keys, + }); +} + +function handleSearchWorkerDownloadProgressMessage(message) { + const {event} = info; + const {context: contextKey, key, progress} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = progress; + + dispatchInternalEvent(event, 'whenDownloadProgresses', { + context: contextKey, + key, + progress, + }); +} + +function handleSearchWorkerDownloadCompleteMessage(message) { + const {event} = info; + const {context: contextKey, key} = message.data; + + const context = getSearchWorkerDownloadContext(contextKey); + + context[key] = 1.00; + + dispatchInternalEvent(event, 'whenDownloadEnds', { + context: contextKey, + key, + }); +} + +export function getSearchWorkerDownloadContext(context, initialize = false) { + const {state} = info; + + if (context in state.downloads) { + return state.downloads[context]; + } + + if (!initialize) { + return null; + } + + return state.downloads[context] = Object.create(null); +} + +export async function postSearchWorkerAction(action, options) { + const {state} = info; + + const worker = await initializeSearchWorker(); + const id = ++state.workerActionCounter; + + const {promise, resolve, reject} = promiseWithResolvers(); + + state.workerActionPromiseResolverMap.set(id, {resolve, reject}); + + worker.postMessage({ + kind: 'action', + action: action, + id, + options, + }); + + return await promise; +} + +export async function searchAll(query, options = {}) { + return await postSearchWorkerAction('search', { + query, + options, + }); +} |