diff options
Diffstat (limited to 'src/static/js')
-rw-r--r-- | src/static/js/client-util.js | 46 | ||||
-rw-r--r-- | src/static/js/client/additional-names-box.js | 69 | ||||
-rw-r--r-- | src/static/js/client/art-tag-gallery-filter.js | 151 | ||||
-rw-r--r-- | src/static/js/client/art-tag-network.js | 147 | ||||
-rw-r--r-- | src/static/js/client/artist-rolling-window.js | 573 | ||||
-rw-r--r-- | src/static/js/client/css-compatibility-assistant.js | 26 | ||||
-rw-r--r-- | src/static/js/client/expandable-grid-section.js | 85 | ||||
-rw-r--r-- | src/static/js/client/gallery-style-selector.js | 123 | ||||
-rw-r--r-- | src/static/js/client/hoverable-tooltip.js | 53 | ||||
-rw-r--r-- | src/static/js/client/image-overlay.js | 52 | ||||
-rw-r--r-- | src/static/js/client/index.js | 12 | ||||
-rw-r--r-- | src/static/js/client/reveal-all-grid-control.js | 72 | ||||
-rw-r--r-- | src/static/js/client/sidebar-search.js | 389 | ||||
-rw-r--r-- | src/static/js/client/sticky-heading.js | 132 | ||||
-rw-r--r-- | src/static/js/rectangles.js | 42 | ||||
-rw-r--r-- | src/static/js/search-worker.js | 181 |
16 files changed, 1982 insertions, 171 deletions
diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js index f06b707a..396c4889 100644 --- a/src/static/js/client-util.js +++ b/src/static/js/client-util.js @@ -1,12 +1,19 @@ /* eslint-env browser */ export function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; + let result = document.documentElement.dataset[rebaseKey] || './'; + + if (!result.endsWith('/')) { + result += '/'; + } + + if (href.startsWith('/')) { + href = href.slice(1); } + + result += href; + + return result; } export function cssProp(el, ...args) { @@ -30,7 +37,7 @@ export function cssProp(el, ...args) { } } -export function templateContent(el) { +export function templateContent(el, slots = {}) { if (el === null) { return null; } @@ -39,7 +46,25 @@ export function templateContent(el) { throw new Error(`Expected a <template> element`); } - return el.content.cloneNode(true); + const content = el.content.cloneNode(true); + + for (const [key, value] of Object.entries(slots)) { + const slot = content.querySelector(`slot[name="${key}"]`); + + if (!slot) { + console.warn(`Slot ${key} missing in template:`, el); + continue; + } + + if (value === null || value === undefined) { + console.warn(`Valueless slot ${key} in template:`, el); + continue; + } + + slot.replaceWith(value); + } + + return content; } // Curry-style, so multiple points can more conveniently be tested at once. @@ -120,3 +145,10 @@ export function dispatchInternalEvent(event, eventName, ...args) { return results; } + +const languageCode = document.documentElement.getAttribute('lang'); + +export function formatDate(inputDate) { + const date = new Date(inputDate); + return date.toLocaleDateString(languageCode); +} diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js index 558ef06f..195ba25d 100644 --- a/src/static/js/client/additional-names-box.js +++ b/src/static/js/client/additional-names-box.js @@ -3,12 +3,17 @@ 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: { @@ -23,6 +28,16 @@ export function getPageReferences() { 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'); } @@ -33,6 +48,34 @@ export function addInternalListeners() { 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() { @@ -48,6 +91,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { domEvent.preventDefault(); + if (!domEvent.target.hasAttribute('href')) return; if (!info.box || !info.mainContentContainer) return; const margin = @@ -58,7 +102,30 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { ? info.box.getBoundingClientRect() : info.mainContentContainer.getBoundingClientRect()); - if (top + 20 < margin || top > 0.4 * window.innerHeight) { + 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(); } 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-rolling-window.js b/src/static/js/client/artist-rolling-window.js new file mode 100644 index 00000000..b201e7df --- /dev/null +++ b/src/static/js/client/artist-rolling-window.js @@ -0,0 +1,573 @@ +/* eslint-env browser */ + +import {cssProp, formatDate} from '../client-util.js'; + +import {sortByDate} from '../../shared-util/sort.js'; +import {chunkByConditions, chunkByProperties, empty, stitchArrays} + from '../../shared-util/sugar.js'; + +export const info = { + id: 'artistRollingWindowInfo', + + timeframeMonthsBefore: null, + timeframeMonthsAfter: null, + timeframeMonthsPeek: null, + + contributionKind: null, + contributionGroup: null, + + timeframeSelectionSomeLine: null, + timeframeSelectionNoneLine: null, + + timeframeSelectionContributionCount: null, + timeframeSelectionTimeframeCount: null, + timeframeSelectionFirstDate: null, + timeframeSelectionLastDate: null, + + timeframeSelectionControl: null, + timeframeSelectionMenu: null, + timeframeSelectionPrevious: null, + timeframeSelectionNext: null, + + timeframeEmptyLine: null, + + sourceArea: null, + sourceGrid: null, + sources: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') { + return; + } + + info.timeframeMonthsBefore = + document.getElementById('timeframe-months-before'); + + info.timeframeMonthsAfter = + document.getElementById('timeframe-months-after'); + + info.timeframeMonthsPeek = + document.getElementById('timeframe-months-peek'); + + info.contributionKind = + document.getElementById('contribution-kind'); + + info.contributionGroup = + document.getElementById('contribution-group'); + + info.timeframeSelectionSomeLine = + document.getElementById('timeframe-selection-some'); + + info.timeframeSelectionNoneLine = + document.getElementById('timeframe-selection-none'); + + info.timeframeSelectionContributionCount = + document.getElementById('timeframe-selection-contribution-count'); + + info.timeframeSelectionTimeframeCount = + document.getElementById('timeframe-selection-timeframe-count'); + + info.timeframeSelectionFirstDate = + document.getElementById('timeframe-selection-first-date'); + + info.timeframeSelectionLastDate = + document.getElementById('timeframe-selection-last-date'); + + info.timeframeSelectionControl = + document.getElementById('timeframe-selection-control'); + + info.timeframeSelectionMenu = + document.getElementById('timeframe-selection-menu'); + + info.timeframeSelectionPrevious = + document.getElementById('timeframe-selection-previous'); + + info.timeframeSelectionNext = + document.getElementById('timeframe-selection-next'); + + info.timeframeEmptyLine = + document.getElementById('timeframe-empty'); + + info.sourceArea = + document.getElementById('timeframe-source-area'); + + info.sourceGrid = + info.sourceArea.querySelector('.grid-listing'); + + info.sources = + info.sourceGrid.getElementsByClassName('grid-item'); +} + +export function addPageListeners() { + if (!info.sourceArea) { + return; + } + + for (const input of [ + info.timeframeMonthsBefore, + info.timeframeMonthsAfter, + info.timeframeMonthsPeek, + info.contributionKind, + info.contributionGroup, + ]) { + input.addEventListener('change', () => { + updateArtistRollingWindow() + }); + } + + info.timeframeSelectionMenu.addEventListener('change', () => { + updateRollingWindowTimeframeSelection(); + }); + + const eatClicks = (element, callback) => { + element.addEventListener('click', domEvent => { + domEvent.preventDefault(); + callback(); + }); + + element.addEventListener('mousedown', domEvent => { + if (domEvent.detail > 1) { + domEvent.preventDefault(); + } + }); + }; + + eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection); + eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection); +} + +export function mutatePageContent() { + if (!info.sourceArea) { + return; + } + + updateArtistRollingWindow(); +} + +function previousRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex > 0) { + menu.selectedIndex--; + } + + updateRollingWindowTimeframeSelection(); +} + +function nextRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex < menu.length - 1) { + menu.selectedIndex++; + } + + updateRollingWindowTimeframeSelection(); +} + +function getArtistRollingWindowSourceInfo() { + const sourceElements = + Array.from(info.sources); + + const sourceTimeElements = + sourceElements + .map(el => Array.from(el.getElementsByTagName('time'))); + + const sourceTimeClasses = + sourceTimeElements + .map(times => times + .map(time => Array.from(time.classList))); + + const sourceKinds = + sourceTimeClasses + .map(times => times + .map(classes => classes + .find(cl => cl.endsWith('-contribution-date')) + .slice(0, -'-contribution-date'.length))); + + const sourceGroups = + sourceElements + .map(el => + Array.from(el.querySelectorAll('.contribution-group')) + .map(data => data.value)); + + const sourceDates = + sourceTimeElements + .map(times => times + .map(time => new Date(time.getAttribute('datetime')))); + + return stitchArrays({ + element: sourceElements, + kinds: sourceKinds, + groups: sourceGroups, + dates: sourceDates, + }); +} + +function getArtistRollingWindowTimeframeInfo() { + const contributionKind = + info.contributionKind.value; + + const contributionGroup = + info.contributionGroup.value; + + const sourceInfo = + getArtistRollingWindowSourceInfo(); + + const principalSources = + sourceInfo.filter(source => { + if (!source.kinds.includes(contributionKind)) { + return false; + } + + if (contributionGroup !== '-') { + if (!source.groups.includes(contributionGroup)) { + return false; + } + } + + return true; + }); + + const principalSourceDates = + principalSources.map(source => + stitchArrays({ + kind: source.kinds, + date: source.dates, + }).find(({kind}) => kind === contributionKind) + .date); + + const getPeekDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value) + - parseInt(info.timeframeMonthsPeek.value))); + + return date; + }; + + const getEntranceDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value))); + + return date; + }; + + const getExitDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + + parseInt(info.timeframeMonthsAfter.value))); + + return date; + }; + + const principalSourceIndices = + Array.from({length: principalSources.length}, (_, i) => i); + + const timeframeSourceChunks = + chunkByConditions(principalSourceIndices, [ + (previous, next) => + +principalSourceDates[previous] !== + +principalSourceDates[next], + ]); + + const timeframeSourceChunkDates = + timeframeSourceChunks + .map(indices => indices[0]) + .map(index => principalSourceDates[index]); + + const timeframeSourceChunkPeekDates = + timeframeSourceChunkDates + .map(getPeekDate); + + const timeframeSourceChunkEntranceDates = + timeframeSourceChunkDates + .map(getEntranceDate); + + const timeframeSourceChunkExitDates = + timeframeSourceChunkDates + .map(getExitDate); + + const peekDateInfo = + stitchArrays({ + peek: timeframeSourceChunkPeekDates, + indices: timeframeSourceChunks, + }).map(({peek, indices}) => ({ + date: peek, + peek: indices, + })); + + const entranceDateInfo = + stitchArrays({ + entrance: timeframeSourceChunkEntranceDates, + indices: timeframeSourceChunks, + }).map(({entrance, indices}) => ({ + date: entrance, + entrance: indices, + })); + + const exitDateInfo = + stitchArrays({ + exit: timeframeSourceChunkExitDates, + indices: timeframeSourceChunks, + }).map(({exit, indices}) => ({ + date: exit, + exit: indices, + })); + + const dateInfoChunks = + chunkByProperties( + sortByDate([ + ...peekDateInfo, + ...entranceDateInfo, + ...exitDateInfo, + ]), + ['date']); + + const dateInfo = + dateInfoChunks + .map(({chunk}) => + Object.assign({ + peek: null, + entrance: null, + exit: null, + }, ...chunk)); + + const timeframeInfo = + dateInfo.reduce( + (accumulator, {date, peek, entrance, exit}) => { + const previous = accumulator.at(-1); + + // These mustn't be mutated! + let peeking = (previous ? previous.peeking : []); + let tracking = (previous ? previous.tracking : []); + + if (peek) { + peeking = + peeking.concat(peek); + } + + if (entrance) { + peeking = + peeking.filter(index => !entrance.includes(index)); + + tracking = + tracking.concat(entrance); + } + + if (exit) { + tracking = + tracking.filter(index => !exit.includes(index)); + } + + return [...accumulator, { + date, + peeking, + tracking, + peek, + entrance, + exit, + }]; + }, + []); + + const indicesToSources = indices => + (indices + ? indices.map(index => principalSources[index]) + : null); + + const finalizedTimeframeInfo = + timeframeInfo.map(({ + date, + peeking, + tracking, + peek, + entrance, + exit, + }) => ({ + date, + peeking: indicesToSources(peeking), + tracking: indicesToSources(tracking), + peek: indicesToSources(peek), + entrance: indicesToSources(entrance), + exit: indicesToSources(exit), + })); + + return finalizedTimeframeInfo; +} + +function updateArtistRollingWindow() { + const timeframeInfo = + getArtistRollingWindowTimeframeInfo(); + + if (empty(timeframeInfo)) { + cssProp(info.timeframeSelectionControl, 'display', 'none'); + cssProp(info.timeframeSelectionSomeLine, 'display', 'none'); + cssProp(info.timeframeSelectionNoneLine, 'display', null); + + updateRollingWindowTimeframeSelection(timeframeInfo); + + return; + } + + cssProp(info.timeframeSelectionControl, 'display', null); + cssProp(info.timeframeSelectionSomeLine, 'display', null); + cssProp(info.timeframeSelectionNoneLine, 'display', 'none'); + + // The last timeframe is just the exit of the final tracked sources, + // so we aren't going to display a menu option for it, and will just use + // it as the end of the final option's date range. + + const usedTimeframes = timeframeInfo.slice(0, -1); + const firstTimeframe = timeframeInfo.at(0); + const lastTimeframe = timeframeInfo.at(-1); + + const sourceCount = + timeframeInfo + .flatMap(({entrance}) => entrance ?? []) + .length; + + const timeframeCount = + usedTimeframes.length; + + info.timeframeSelectionContributionCount.innerText = sourceCount; + info.timeframeSelectionTimeframeCount.innerText = timeframeCount; + + const firstDate = firstTimeframe.date; + const lastDate = lastTimeframe.date; + + info.timeframeSelectionFirstDate.innerText = formatDate(firstDate); + info.timeframeSelectionLastDate.innerText = formatDate(lastDate); + + while (info.timeframeSelectionMenu.firstChild) { + info.timeframeSelectionMenu.firstChild.remove(); + } + + for (const [index, timeframe] of usedTimeframes.entries()) { + const nextTimeframe = timeframeInfo[index + 1]; + + const option = document.createElement('option'); + + option.appendChild(document.createTextNode( + `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`)); + + info.timeframeSelectionMenu.appendChild(option); + } + + updateRollingWindowTimeframeSelection(timeframeInfo); +} + +function updateRollingWindowTimeframeSelection(timeframeInfo) { + timeframeInfo ??= getArtistRollingWindowTimeframeInfo(); + + updateRollingWindowTimeframeSelectionControls(timeframeInfo); + updateRollingWindowTimeframeSelectionSources(timeframeInfo); +} + +function updateRollingWindowTimeframeSelectionControls(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const atFirstTimeframe = + currentIndex === 0; + + // The last actual timeframe is empty and not displayed as a menu option. + const atLastTimeframe = + currentIndex === timeframeInfo.length - 2; + + if (atFirstTimeframe) { + info.timeframeSelectionPrevious.removeAttribute('href'); + } else { + info.timeframeSelectionPrevious.setAttribute('href', '#'); + } + + if (atLastTimeframe) { + info.timeframeSelectionNext.removeAttribute('href'); + } else { + info.timeframeSelectionNext.setAttribute('href', '#'); + } +} + +function updateRollingWindowTimeframeSelectionSources(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const contributionGroup = + info.contributionGroup.value; + + cssProp(info.sourceGrid, 'display', null); + + const {peeking: peekingSources, tracking: trackingSources} = + (empty(timeframeInfo) + ? {peeking: [], tracking: []} + : timeframeInfo[currentIndex]); + + const peekingElements = + peekingSources.map(source => source.element); + + const trackingElements = + trackingSources.map(source => source.element); + + const showingElements = + [...trackingElements, ...peekingElements]; + + const hidingElements = + Array.from(info.sources) + .filter(element => + !peekingElements.includes(element) && + !trackingElements.includes(element)); + + for (const element of peekingElements) { + element.classList.add('peeking'); + element.classList.remove('tracking'); + } + + for (const element of trackingElements) { + element.classList.remove('peeking'); + element.classList.add('tracking'); + } + + for (const element of hidingElements) { + element.classList.remove('peeking'); + element.classList.remove('tracking'); + cssProp(element, 'display', 'none'); + } + + for (const element of showingElements) { + cssProp(element, 'display', null); + + for (const time of element.getElementsByTagName('time')) { + for (const className of time.classList) { + if (!className.endsWith('-contribution-date')) continue; + + const kind = className.slice(0, -'-contribution-date'.length); + if (kind === info.contributionKind.value) { + cssProp(time, 'display', null); + } else { + cssProp(time, 'display', 'none'); + } + } + } + + for (const data of element.getElementsByClassName('contribution-group')) { + if (contributionGroup === '-' || data.value !== contributionGroup) { + cssProp(data, 'display', null); + } else { + cssProp(data, 'display', 'none'); + } + } + } + + if (empty(peekingElements) && empty(trackingElements)) { + cssProp(info.timeframeEmptyLine, 'display', null); + } else { + cssProp(info.timeframeEmptyLine, 'display', 'none'); + } +} diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js index 6e7b15b5..aa637cc4 100644 --- a/src/static/js/client/css-compatibility-assistant.js +++ b/src/static/js/client/css-compatibility-assistant.js @@ -1,22 +1,30 @@ /* eslint-env browser */ +import {stitchArrays} from '../../shared-util/sugar.js'; + export const info = { id: 'cssCompatibilityAssistantInfo', - coverArtContainer: null, - coverArtImageDetails: null, + coverArtworks: null, + coverArtworkImageDetails: null, }; export function getPageReferences() { - info.coverArtContainer = - document.getElementById('cover-art-container'); + info.coverArtworks = + Array.from(document.querySelectorAll('.cover-artwork')); - info.coverArtImageDetails = - info.coverArtContainer?.querySelector('.image-details'); + info.coverArtworkImageDetails = + info.coverArtworks + .map(artwork => artwork.querySelector('.image-details')); } export function mutatePageContent() { - if (info.coverArtImageDetails) { - info.coverArtContainer.classList.add('has-image-details'); - } + 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/expandable-grid-section.js b/src/static/js/client/expandable-grid-section.js new file mode 100644 index 00000000..ce9a4c06 --- /dev/null +++ b/src/static/js/client/expandable-grid-section.js @@ -0,0 +1,85 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'expandableGallerySectionInfo', + + items: null, + toggles: null, + expandCues: null, + collapseCues: null, +}; + +export function getPageReferences() { + const expandos = + Array.from(document.querySelectorAll('.grid-expando')); + + const grids = + expandos + .map(expando => expando.closest('.grid-listing')); + + info.items = + grids + .map(grid => grid.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.toggles = + expandos + .map(expando => expando.querySelector('.grid-expando-toggle')); + + info.expandCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-expand-cue')); + + info.collapseCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-collapse-cue')); +} + +export function addPageListeners() { + stitchArrays({ + items: info.items, + toggle: info.toggles, + expandCue: info.expandCues, + collapseCue: info.collapseCues, + }).forEach(({ + items, + toggle, + expandCue, + collapseCue, + }) => { + toggle.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + const collapsed = + items.some(item => + item.classList.contains('hidden-by-expandable-cut')); + + for (const item of items) { + if ( + !item.classList.contains('hidden-by-expandable-cut') && + !item.classList.contains('shown-by-expandable-cut') + ) continue; + + if (collapsed) { + item.classList.remove('hidden-by-expandable-cut'); + item.classList.add('shown-by-expandable-cut'); + } else { + item.classList.add('hidden-by-expandable-cut'); + item.classList.remove('shown-by-expandable-cut'); + } + } + + if (collapsed) { + cssProp(expandCue, 'display', 'none'); + cssProp(collapseCue, 'display', null); + } else { + cssProp(expandCue, 'display', null); + cssProp(collapseCue, 'display', 'none'); + } + }); + }); +} diff --git a/src/static/js/client/gallery-style-selector.js b/src/static/js/client/gallery-style-selector.js new file mode 100644 index 00000000..c7086eae --- /dev/null +++ b/src/static/js/client/gallery-style-selector.js @@ -0,0 +1,123 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'galleryStyleSelectorInfo', + + selectors: null, + sections: null, + + selectorStyleInputs: null, + selectorStyleInputStyles: null, + + selectorReleaseItems: null, + selectorReleaseItemStyles: null, + + selectorCountAll: null, + selectorCountFiltered: null, + selectorCountFilteredCount: null, + selectorCountNone: null, +}; + +export function getPageReferences() { + info.selectors = + Array.from(document.querySelectorAll('.gallery-style-selector')); + + info.sections = + info.selectors + .map(selector => selector.closest('section')); + + info.selectorStyleInputs = + info.selectors + .map(selector => selector.querySelectorAll('.styles input')) + .map(inputs => Array.from(inputs)); + + info.selectorStyleInputStyles = + info.selectorStyleInputs + .map(inputs => inputs + .map(input => input.closest('label').dataset.style)); + + info.selectorReleaseItems = + info.sections + .map(section => section.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.selectorReleaseItemStyles = + info.selectorReleaseItems + .map(items => items + .map(item => item.dataset.style)); + + info.selectorCountAll = + info.selectors + .map(selector => selector.querySelector('.count.all')); + + info.selectorCountFiltered = + info.selectors + .map(selector => selector.querySelector('.count.filtered')); + + info.selectorCountFilteredCount = + info.selectorCountFiltered + .map(selector => selector.querySelector('span')); + + info.selectorCountNone = + info.selectors + .map(selector => selector.querySelector('.count.none')); +} + +export function addPageListeners() { + for (const index of info.selectors.keys()) { + for (const input of info.selectorStyleInputs[index]) { + input.addEventListener('input', () => updateVisibleReleases(index)); + } + } +} + +function updateVisibleReleases(index) { + const inputs = info.selectorStyleInputs[index]; + const inputStyles = info.selectorStyleInputStyles[index]; + + const selectedStyles = + stitchArrays({input: inputs, style: inputStyles}) + .filter(({input}) => input.checked) + .map(({style}) => style); + + const releases = info.selectorReleaseItems[index]; + const releaseStyles = info.selectorReleaseItemStyles[index]; + + let visible = 0; + + stitchArrays({ + release: releases, + style: releaseStyles, + }).forEach(({release, style}) => { + if (selectedStyles.includes(style)) { + release.classList.remove('hidden-by-style-mismatch'); + visible++; + } else { + release.classList.add('hidden-by-style-mismatch'); + } + }); + + const countAll = info.selectorCountAll[index]; + const countFiltered = info.selectorCountFiltered[index]; + const countFilteredCount = info.selectorCountFilteredCount[index]; + const countNone = info.selectorCountNone[index]; + + if (visible === releases.length) { + cssProp(countAll, 'display', null); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', 'none'); + } else if (visible === 0) { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', null); + } else { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', null); + cssProp(countNone, 'display', 'none'); + countFilteredCount.innerHTML = visible; + } +} diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js index 484f2ab0..89119a47 100644 --- a/src/static/js/client/hoverable-tooltip.js +++ b/src/static/js/client/hoverable-tooltip.js @@ -118,17 +118,17 @@ export function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', event => { - handleTooltipReceivedFocus(tooltip, event.relatedTarget); + tooltip.addEventListener('focusin', domEvent => { + handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget); }); - tooltip.addEventListener('focusout', event => { + tooltip.addEventListener('focusout', domEvent => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return; - handleTooltipLostFocus(tooltip, event.relatedTarget); + handleTooltipLostFocus(tooltip, domEvent.relatedTarget); }); } @@ -158,20 +158,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event); + hoverable.addEventListener('focusin', domEvent => { + handleTooltipHoverableReceivedFocus(hoverable, domEvent); }); - hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event); + hoverable.addEventListener('focusout', domEvent => { + handleTooltipHoverableLostFocus(hoverable, domEvent); }); - hoverable.addEventListener('touchend', event => { - handleTooltipHoverableTouchEnded(hoverable, event); + hoverable.addEventListener('touchend', domEvent => { + handleTooltipHoverableTouchEnded(hoverable, domEvent); }); - hoverable.addEventListener('click', event => { - handleTooltipHoverableClicked(hoverable, event); + hoverable.addEventListener('click', domEvent => { + handleTooltipHoverableClicked(hoverable, domEvent); }); } @@ -416,7 +416,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { }, 1200); } -function handleTooltipHoverableClicked(hoverable) { +function handleTooltipHoverableClicked(hoverable, domEvent) { const {state} = info; // Don't navigate away from the page if the this hoverable was recently @@ -426,7 +426,7 @@ function handleTooltipHoverableClicked(hoverable) { state.currentlyActiveHoverable === hoverable && state.hoverableWasRecentlyTouched ) { - event.preventDefault(); + domEvent.preventDefault(); } } @@ -576,6 +576,17 @@ export function showTooltipFromHoverable(hoverable) { 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'); @@ -667,12 +678,12 @@ export function positionTooltipFromHoverableWithBrains(hoverable) { for (let i = 0; i < numBaselineRects; i++) { for (const [dir1, dir2] of [ + ['down', 'right'], + ['down', 'left'], ['right', 'down'], ['left', 'down'], ['right', 'up'], ['left', 'up'], - ['down', 'right'], - ['down', 'left'], ['up', 'right'], ['up', 'left'], ]) { @@ -995,6 +1006,14 @@ export function getTooltipBaselineOpportunityAreas(tooltip) { 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; diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js index b51d57a4..e9e2708d 100644 --- a/src/static/js/client/image-overlay.js +++ b/src/static/js/client/image-overlay.js @@ -66,8 +66,13 @@ export function getPageReferences() { info.fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + const linkQuery = [ + '.image-link', + '.image-media-link', + ].join(', '); + info.links = - Array.from(document.querySelectorAll('.image-link')) + Array.from(document.querySelectorAll(linkQuery)) .filter(link => !link.closest('.no-image-preview')); } @@ -88,10 +93,13 @@ function handleContainerClicked(evt) { return; } - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. + // 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) { + if ( + evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 && + evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20 + ) { return; } @@ -141,13 +149,23 @@ function getImageLinkDetails(imageLink) { a.href, embeddedSrc: - img.src, + img?.src ?? + a.dataset.embedSrc, originalFileSize: - img.dataset.originalSize, + img?.dataset.originalSize ?? + a.dataset.originalSize ?? + null, availableThumbList: - img.dataset.thumbs, + img?.dataset.thumbs ?? + a.dataset.thumbs ?? + null, + + dimensions: + img?.dataset.dimensions?.split('x') ?? + a.dataset.dimensions?.split('x') ?? + null, color: cssProp(imageLink, '--primary-color'), @@ -170,7 +188,7 @@ function getImageSources(details) { }; } else { return { - mainSrc: originalSrc, + mainSrc: details.originalSrc, thumbSrc: null, mainThumb: '', thumbThumb: '', @@ -211,15 +229,31 @@ 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.thumbThubm; + 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); diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index 52d2afd6..86081b5d 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -4,16 +4,22 @@ 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 artistRollingWindowModule from './artist-rolling-window.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 expandableGridSectionModule from './expandable-grid-section.js'; +import * as galleryStyleSelectorModule from './gallery-style-selector.js'; import * as hashLinkModule from './hash-link.js'; import * as hoverableTooltipModule from './hoverable-tooltip.js'; import * as imageOverlayModule from './image-overlay.js'; import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js'; import * as liveMousePositionModule from './live-mouse-position.js'; import * as quickDescriptionModule from './quick-description.js'; +import * as revealAllGridControlModule from './reveal-all-grid-control.js'; import * as scriptedLinkModule from './scripted-link.js'; import * as sidebarSearchModule from './sidebar-search.js'; import * as stickyHeadingModule from './sticky-heading.js'; @@ -24,16 +30,22 @@ import * as wikiSearchModule from './wiki-search.js'; export const modules = [ additionalNamesBoxModule, albumCommentarySidebarModule, + artTagGalleryFilterModule, + artTagNetworkModule, artistExternalLinkTooltipModule, + artistRollingWindowModule, cssCompatibilityAssistantModule, datetimestampTooltipModule, draggedLinkModule, + expandableGridSectionModule, + galleryStyleSelectorModule, hashLinkModule, hoverableTooltipModule, imageOverlayModule, intrapageDotSwitcherModule, liveMousePositionModule, quickDescriptionModule, + revealAllGridControlModule, scriptedLinkModule, sidebarSearchModule, stickyHeadingModule, diff --git a/src/static/js/client/reveal-all-grid-control.js b/src/static/js/client/reveal-all-grid-control.js new file mode 100644 index 00000000..1b362bea --- /dev/null +++ b/src/static/js/client/reveal-all-grid-control.js @@ -0,0 +1,72 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'revealAllGridControlInfo', + + revealAllLinks: null, + revealables: null, + + revealLabels: null, + concealLabels: null, +}; + +export function getPageReferences() { + info.revealAllLinks = + Array.from(document.querySelectorAll('.reveal-all a')); + + info.revealables = + info.revealAllLinks + .map(link => link.closest('.grid-listing')) + .map(listing => listing.querySelectorAll('.reveal')); + + info.revealLabels = + info.revealAllLinks + .map(link => link.querySelector('.reveal-label')); + + info.concealLabels = + info.revealAllLinks + .map(link => link.querySelector('.conceal-label')); +} + +export function addPageListeners() { + for (const [index, link] of info.revealAllLinks.entries()) { + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + handleRevealAllLinkClicked(index); + }); + } +} + +export function addInternalListeners() { + // Don't even think about it. "Reveal all artworks" is a stable control, + // meaning it only changes because the user interacted with it directly. +} + +function handleRevealAllLinkClicked(index) { + const revealables = info.revealables[index]; + const revealLabel = info.revealLabels[index]; + const concealLabel = info.concealLabels[index]; + + const shouldReveal = + (cssProp(revealLabel, 'display') === 'none' + ? false + : true); + + for (const revealable of revealables) { + if (shouldReveal) { + revealable.classList.add('revealed'); + } else { + revealable.classList.remove('revealed'); + } + } + + if (shouldReveal) { + cssProp(revealLabel, 'display', 'none'); + cssProp(concealLabel, 'display', null); + } else { + cssProp(revealLabel, 'display', null); + cssProp(concealLabel, 'display', 'none'); + } +} diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js index c79fb837..4467766c 100644 --- a/src/static/js/client/sidebar-search.js +++ b/src/static/js/client/sidebar-search.js @@ -1,7 +1,7 @@ /* eslint-env browser */ import {getColors} from '../../shared-util/colors.js'; -import {accumulateSum, empty} from '../../shared-util/sugar.js'; +import {accumulateSum, empty, unique} from '../../shared-util/sugar.js'; import { cssProp, @@ -41,6 +41,14 @@ export const info = { failedRule: null, failedContainer: null, + filterContainer: null, + albumFilterLink: null, + artistFilterLink: null, + flashFilterLink: null, + groupFilterLink: null, + tagFilterLink: null, + trackFilterLink: null, + resultsRule: null, resultsContainer: null, results: null, @@ -49,6 +57,8 @@ export const info = { endSearchLine: null, endSearchLink: null, + standbyInputPlaceholder: null, + preparingString: null, loadingDataString: null, searchingString: null, @@ -63,12 +73,26 @@ export const info = { groupResultKindString: null, tagResultKindString: null, + groupResultDisambiguatorString: null, + flashResultDisambiguatorString: null, + trackResultDisambiguatorString: null, + + albumResultFilterString: null, + artistResultFilterString: null, + flashResultFilterString: null, + groupResultFilterString: null, + tagResultFilterString: null, + trackResultFilterString: null, + state: { sidebarColumnShownForSearch: null, tidiedSidebar: null, collapsedDetailsForTidiness: null, + recallingRecentSearch: null, + recallingRecentSearchFromMouse: null, + currentValue: null, workerStatus: null, @@ -92,6 +116,10 @@ export const info = { maxLength: settings => settings.maxActiveResultsStorage, }, + activeFilterType: { + type: 'string', + }, + repeatQueryOnReload: { type: 'boolean', default: false, @@ -133,6 +161,9 @@ export function getPageReferences() { info.searchSidebarColumn = info.searchBox.closest('.sidebar-column'); + info.standbyInputPlaceholder = + info.searchInput.placeholder; + const findString = classPart => info.searchBox.querySelector(`.wiki-search-${classPart}-string`); @@ -168,6 +199,33 @@ export function getPageReferences() { info.tagResultKindString = findString('tag-result-kind'); + + info.groupResultDisambiguatorString = + findString('group-result-disambiguator'); + + info.flashResultDisambiguatorString = + findString('flash-result-disambiguator'); + + info.trackResultDisambiguatorString = + findString('track-result-disambiguator'); + + info.albumResultFilterString = + findString('album-result-filter'); + + info.artistResultFilterString = + findString('artist-result-filter'); + + info.flashResultFilterString = + findString('flash-result-filter'); + + info.groupResultFilterString = + findString('group-result-filter'); + + info.tagResultFilterString = + findString('tag-result-filter'); + + info.trackResultFilterString = + findString('track-result-filter'); } export function addInternalListeners() { @@ -257,6 +315,38 @@ export function mutatePageContent() { info.searchBox.appendChild(info.failedRule); info.searchBox.appendChild(info.failedContainer); + // Filter section + + info.filterContainer = + document.createElement('div'); + + info.filterContainer.classList.add('wiki-search-filter-container'); + + cssProp(info.filterContainer, 'display', 'none'); + + forEachFilter((type, _filterLink) => { + // TODO: It's probably a sin to access `session` during this step LOL + const {session} = info; + + const filterLink = document.createElement('a'); + + filterLink.href = '#'; + filterLink.classList.add('wiki-search-filter-link'); + + if (session.activeFilterType === type) { + filterLink.classList.add('active'); + } + + const string = info[type + 'ResultFilterString']; + filterLink.appendChild(templateContent(string)); + + info[type + 'FilterLink'] = filterLink; + + info.filterContainer.appendChild(filterLink); + }); + + info.searchBox.appendChild(info.filterContainer); + // Results section info.resultsRule = @@ -310,6 +400,43 @@ export function mutatePageContent() { 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; @@ -326,7 +453,7 @@ export function addPageListeners() { const {settings, state} = info; if (!info.searchInput.value) { - clearSidebarSearch(); + clearSidebarSearch(); // ...but don't clear filter return; } @@ -388,10 +515,18 @@ export function addPageListeners() { info.endSearchLink.addEventListener('click', domEvent => { domEvent.preventDefault(); clearSidebarSearch(); + clearSidebarFilter(); possiblyHideSearchSidebarColumn(); restoreSidebarSearchColumn(); }); + forEachFilter((type, filterLink) => { + filterLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + toggleSidebarSearchFilter(type); + }); + }); + info.resultsContainer.addEventListener('scroll', () => { const {settings, state} = info; @@ -412,11 +547,11 @@ export function initializeState() { if (!info.searchInput) return; if (session.activeQuery) { - info.searchInput.value = session.activeQuery; if (session.repeatQueryOnReload) { + info.searchInput.value = session.activeQuery; activateSidebarSearch(session.activeQuery); } else if (session.activeQueryResults) { - showSidebarSearchResults(session.activeQueryResults); + considerRecallingRecentSidebarSearch(); } } } @@ -473,9 +608,28 @@ function trackSidebarSearchDownloadEnds(event) { } } +function forEachFilter(callback) { + const filterOrder = [ + 'track', + 'album', + 'artist', + 'group', + 'flash', + 'tag', + ]; + + for (const type of filterOrder) { + callback(type, info[type + 'FilterLink']); + } +} + async function activateSidebarSearch(query) { const {session, state} = info; + if (!query) { + return; + } + if (state.stoppedTypingTimeout) { clearTimeout(state.stoppedTypingTimeout); state.stoppedTypingTimeout = null; @@ -535,6 +689,16 @@ function clearSidebarSearch() { hideSidebarSearchResults(); } +function clearSidebarFilter() { + const {session} = info; + + toggleSidebarSearchFilter(session.activeFilterType); + + forEachFilter((_type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + }); +} + function updateSidebarSearchStatus() { const {state} = info; @@ -621,63 +785,131 @@ function showSidebarSearchFailed() { } function showSidebarSearchResults(results) { - console.debug(`Showing search results:`, results); + const {session} = info; - showSearchSidebarColumn(); + console.debug(`Showing search results:`, tidyResults(results)); - 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, - }))); + showSearchSidebarColumn(); info.searchBox.classList.add('showing-results'); info.searchSidebarColumn.classList.add('search-showing-results'); - while (info.results.firstChild) { - info.results.firstChild.remove(); + let filterType = session.activeFilterType; + let shownAnyResults = + fillResultElements(results, {filterType: session.activeFilterType}); + + showFilterElements(results); + + if (!shownAnyResults) { + shownAnyResults = toggleSidebarSearchFilter(filterType); + filterType = null; } - cssProp(info.resultsRule, 'display', 'block'); - cssProp(info.resultsContainer, 'display', 'block'); + if (shownAnyResults) { + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); - if (empty(flatResults)) { + tidySidebarSearchColumn(); + } else { 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); + restoreSidebarSearchResultsScrollOffset(); +} + +function tidyResults(results) { + const tidiedResults = + results.results.map(({doc, id}) => ({ + reference: id ?? null, + referenceType: (id ? id.split(':')[0] : null), + directory: (id ? id.split(':')[1] : null), + data: doc, + })); + + return tidiedResults; +} + +function fillResultElements(results, { + filterType = null, +} = {}) { + const tidiedResults = tidyResults(results); + + const filteredResults = + (filterType + ? tidiedResults.filter(result => result.referenceType === filterType) + : tidiedResults); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.resultsRule, 'display', 'block'); + cssProp(info.resultsContainer, 'display', 'block'); + + if (empty(filteredResults)) { + return false; + } + + for (const result of filteredResults) { + const el = generateSidebarSearchResult(result, filteredResults); if (!el) continue; info.results.appendChild(el); } - if (!empty(flatResults)) { - cssProp(info.endSearchRule, 'display', 'block'); - cssProp(info.endSearchLine, 'display', 'block'); + return true; +} - tidySidebarSearchColumn(); - } +function showFilterElements(results) { + const {queriedKind} = results; - restoreSidebarSearchResultsScrollOffset(); + const tidiedResults = tidyResults(results); + + const allReferenceTypes = + unique(tidiedResults.map(result => result.referenceType)); + + let shownAny = false; + + forEachFilter((type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + + if (allReferenceTypes.includes(type)) { + shownAny = true; + cssProp(filterLink, 'display', null); + + if (queriedKind) { + filterLink.setAttribute('inert', 'inert'); + } else { + filterLink.removeAttribute('inert'); + } + + if (type === queriedKind) { + filterLink.classList.add('active-from-query'); + } else { + filterLink.classList.remove('active-from-query'); + } + } else { + cssProp(filterLink, 'display', 'none'); + } + }); + + if (shownAny) { + cssProp(info.filterContainer, 'display', null); + } else { + cssProp(info.filterContainer, 'display', 'none'); + } } -function generateSidebarSearchResult(result) { +function generateSidebarSearchResult(result, results) { const preparedSlots = { color: result.data.color ?? null, name: - result.data.name ?? result.data.primaryName ?? null, + getSearchResultName(result), imageSource: getSearchResultImageSource(result), @@ -742,9 +974,37 @@ function generateSidebarSearchResult(result) { return null; } + const compareReferenceType = otherResult => + otherResult.referenceType === result.referenceType; + + const compareName = otherResult => + getSearchResultName(otherResult) === getSearchResultName(result); + + const ambiguous = + results.some(otherResult => + otherResult !== result && + compareReferenceType(otherResult) && + compareName(otherResult)); + + if (ambiguous) { + preparedSlots.disambiguate = + result.data.disambiguator; + + preparedSlots.disambiguatorString = + info[result.referenceType + 'ResultDisambiguatorString']; + } + return generateSidebarSearchResultTemplate(preparedSlots); } +function getSearchResultName(result) { + return ( + result.data.name ?? + result.data.primaryName ?? + null + ); +} + function getSearchResultImageSource(result) { const {artwork} = result.data; if (!artwork) return null; @@ -820,6 +1080,15 @@ function generateSidebarSearchResultTemplate(slots) { } } + if (!accentSpan && slots.disambiguate) { + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-result-disambiguator'); + accentSpan.appendChild( + templateContent(slots.disambiguatorString, { + disambiguator: slots.disambiguate, + })); + } + if (!accentSpan && slots.kindString) { accentSpan = document.createElement('span'); accentSpan.classList.add('wiki-search-result-kind'); @@ -859,6 +1128,8 @@ function generateSidebarSearchResultTemplate(slots) { } function hideSidebarSearchResults() { + cssProp(info.filterContainer, 'display', 'none'); + cssProp(info.resultsRule, 'display', 'none'); cssProp(info.resultsContainer, 'display', 'none'); @@ -991,6 +1262,36 @@ function tidySidebarSearchColumn() { } } +function toggleSidebarSearchFilter(toggleType) { + const {session} = info; + + if (!toggleType) return null; + + let shownAnyResults = null; + + forEachFilter((type, filterLink) => { + if (type === toggleType) { + const filterActive = filterLink.classList.toggle('active'); + const filterType = (filterActive ? type : null); + + if (cssProp(filterLink, 'display') !== 'none') { + filterLink.classList.add(filterActive ? 'shown' : 'hidden'); + } + + if (session.activeQueryResults) { + shownAnyResults = + fillResultElements(session.activeQueryResults, {filterType}); + } + + session.activeFilterType = filterType; + } else { + filterLink.classList.remove('active'); + } + }); + + return shownAnyResults; +} + function restoreSidebarSearchColumn() { const {state} = info; @@ -1004,6 +1305,28 @@ function restoreSidebarSearchColumn() { state.collapsedDetailsForTidiness = []; state.tidiedSidebar = null; + + info.searchInput.placeholder = info.standbyInputPlaceholder; +} + +function considerRecallingRecentSidebarSearch() { + const {session, state} = info; + + if (document.documentElement.dataset.urlKey === 'localized.home') { + return forgetRecentSidebarSearch(); + } + + info.searchInput.placeholder = session.activeQuery; + state.recallingRecentSearch = true; +} + +function forgetRecentSidebarSearch() { + const {session} = info; + + session.activeQuery = null; + session.activeQueryResults = null; + + clearSidebarFilter(); } async function handleDroppedIntoSearchInput(domEvent) { @@ -1032,7 +1355,7 @@ async function handleDroppedIntoSearchInput(domEvent) { let droppedURL; try { droppedURL = new URL(droppedText); - } catch (error) { + } catch { droppedURL = null; } diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js index ae63eab5..b65574d0 100644 --- a/src/static/js/client/sticky-heading.js +++ b/src/static/js/client/sticky-heading.js @@ -1,13 +1,19 @@ /* eslint-env browser */ import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; -import {dispatchInternalEvent, templateContent} from '../client-util.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, @@ -17,21 +23,33 @@ export const info = { 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 = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); + info.stickyRoots + .map(el => el.querySelector('.content-sticky-heading-container')); + + info.staticContainers = + info.stickyRoots + .map(el => el.nextElementSibling); info.stickyCoverContainers = info.stickyContainers @@ -45,6 +63,14 @@ export function getPageReferences() { 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')); @@ -55,11 +81,15 @@ export function getPageReferences() { info.contentContainers = info.stickyContainers - .map(el => el.parentElement); + .map(el => el.closest('.content-sticky-heading-root').parentElement); - info.contentCovers = + info.contentCoverColumns = info.contentContainers - .map(el => el.querySelector('#cover-art-container')); + .map(el => el.querySelector('#artwork-column')); + + info.contentCovers = + info.contentCoverColumns + .map(el => el ? el.querySelector('.cover-artwork') : null); info.contentCoversReveal = info.contentCovers @@ -68,6 +98,10 @@ export function getPageReferences() { 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() { @@ -137,15 +171,61 @@ function topOfViewInside(el, scroll = window.scrollY) { 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 contentCover = info.contentCovers[index]; + const stickyContainer = info.stickyContainers[index]; + const contentCoverColumn = info.contentCoverColumns[index]; - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 4) { + 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'); } } } @@ -157,26 +237,27 @@ function getContentHeadingClosestToStickySubheading(index) { return null; } - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); + const stickyHeadingRow = info.stickyHeadingRows[index]; + const stickyRect = stickyHeadingRow.getBoundingClientRect(); - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.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 + subheadingRect.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 + 20) { + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { return heading; } } @@ -187,7 +268,12 @@ function getContentHeadingClosestToStickySubheading(index) { function updateStickySubheadingContent(index) { const {event, state} = info; - const closestHeading = getContentHeadingClosestToStickySubheading(index); + const stickyContainer = info.stickyContainers[index]; + + const closestHeading = + (stickyContainer.classList.contains('collapse') + ? getContentHeadingClosestToStickySubheading(index) + : null); if (state.displayedHeading === closestHeading) return; @@ -233,6 +319,8 @@ function updateStickySubheadingContent(index) { } export function updateStickyHeadings(index) { + updateStuckStatus(index); + updateCollapseStatus(index); updateStickyCoverVisibility(index); updateStickySubheadingContent(index); } diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js index cdab2cb8..b00ed98e 100644 --- a/src/static/js/rectangles.js +++ b/src/static/js/rectangles.js @@ -510,4 +510,46 @@ export class WikiRect extends DOMRect { height: this.height, }); } + + // Other utilities + + #display = null; + + display() { + if (!this.#display) { + this.#display = document.createElement('div'); + document.body.appendChild(this.#display); + } + + Object.assign(this.#display.style, { + position: 'fixed', + background: '#000c', + border: '3px solid var(--primary-color)', + borderRadius: '4px', + top: this.top + 'px', + left: this.left + 'px', + width: this.width + 'px', + height: this.height + 'px', + pointerEvents: 'none', + }); + + let i = 0; + const int = setInterval(() => { + i++; + if (i >= 3) clearInterval(int); + if (!this.#display) return; + + this.#display.style.display = 'none'; + setTimeout(() => { + this.#display.style.display = ''; + }, 200); + }, 600); + } + + hide() { + if (this.#display) { + this.#display.remove(); + this.#display = null; + } + } } diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js index 1b4684ad..3e9fbfca 100644 --- a/src/static/js/search-worker.js +++ b/src/static/js/search-worker.js @@ -130,7 +130,7 @@ async function loadDatabase() { try { idb = await promisifyIDBRequest(request); - } catch (error) { + } catch { console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`); console.warn(request.error); idb = null; @@ -371,58 +371,76 @@ function postActionResult(id, status, value) { } function performSearchAction({query, options}) { - const {generic, ...otherIndexes} = indexes; + const {queriedKind} = processTerms(query); + const genericResults = queryGenericIndex(query, options); + const verbatimResults = queryVerbatimIndex(query, options); - const genericResults = - queryGenericIndex(generic, query, options); + const verbatimIDs = + new Set(verbatimResults?.map(result => result.id)); - const otherResults = - withEntries(otherIndexes, entries => entries - .map(([indexName, index]) => [ - indexName, - index.search(query, options), - ])); + const commonResults = + (verbatimResults && genericResults + ? genericResults + .filter(({id}) => verbatimIDs.has(id)) + : verbatimResults ?? genericResults); return { - generic: genericResults, - ...otherResults, + results: commonResults, + queriedKind, }; } -function queryGenericIndex(index, query, options) { - const interestingFieldCombinations = [ - ['primaryName', 'parentName', 'groups'], - ['primaryName', 'parentName'], - ['primaryName', 'groups', 'contributors'], - ['primaryName', 'groups', 'artTags'], - ['primaryName', 'groups'], - ['primaryName', 'contributors'], - ['primaryName', 'artTags'], - ['parentName', 'groups', 'artTags'], - ['parentName', 'artTags'], - ['groups', 'contributors'], - ['groups', 'artTags'], - - // This prevents just matching *everything* tagged "john" if you - // only search "john", but it actually supports matching more than - // *two* tags at once: "john rose lowas" works! This is thanks to - // flexsearch matching multiple field values in a single query. - ['artTags', 'artTags'], - - ['contributors', 'parentName'], - ['contributors', 'groups'], - ['primaryName', 'contributors'], - ['primaryName'], - ]; +const interestingFieldCombinations = [ + ['primaryName'], + + ['primaryName', 'parentName', 'groups'], + ['primaryName', 'parentName'], + ['primaryName', 'groups', 'contributors'], + ['primaryName', 'groups', 'artTags'], + ['primaryName', 'groups'], + ['primaryName', 'contributors'], + ['primaryName', 'artTags'], + ['parentName', 'groups', 'artTags'], + ['parentName', 'artTags'], + ['groups', 'contributors'], + ['groups', 'artTags'], + + // This prevents just matching *everything* tagged "john" if you + // only search "john", but it actually supports matching more than + // *two* tags at once: "john rose lowas" works! This is thanks to + // flexsearch matching multiple field values in a single query. + ['artTags', 'artTags'], + + ['contributors', 'parentName'], + ['contributors', 'groups'], + ['primaryName', 'contributors'], +]; + +function queryGenericIndex(query, options) { + return queryIndex({ + indexKey: 'generic', + termsKey: 'genericTerms', + }, query, options); +} + +function queryVerbatimIndex(query, options) { + return queryIndex({ + indexKey: 'verbatim', + termsKey: 'verbatimTerms', + }, query, options); +} +function queryIndex({termsKey, indexKey}, query, options) { const interestingFields = unique(interestingFieldCombinations.flat()); - const {genericTerms, queriedKind} = + const {[termsKey]: terms, queriedKind} = processTerms(query); + if (empty(terms)) return null; + const particles = - particulate(genericTerms); + particulate(terms); const groupedParticles = groupArray(particles, ({length}) => length); @@ -437,7 +455,7 @@ function queryGenericIndex(index, query, options) { query: values, })); - const boilerplate = queryBoilerplate(index); + const boilerplate = queryBoilerplate(indexes[indexKey]); const particleResults = Object.fromEntries( @@ -459,62 +477,73 @@ function queryGenericIndex(index, query, options) { ])), ])); - const results = new Set(); + let matchedResults = new Set(); for (const interestingFieldCombination of interestingFieldCombinations) { for (const query of queriesBy(interestingFieldCombination)) { - const idToMatchingFieldsMap = new Map(); - for (const {field, query: fieldQuery} of query) { - for (const id of particleResults[field][fieldQuery]) { - if (idToMatchingFieldsMap.has(id)) { - idToMatchingFieldsMap.get(id).push(field); - } else { - idToMatchingFieldsMap.set(id, [field]); - } - } - } + const [firstQueryFieldLine, ...restQueryFieldLines] = query; const commonAcrossFields = - Array.from(idToMatchingFieldsMap.entries()) - .filter(([id, matchingFields]) => - matchingFields.length === interestingFieldCombination.length) - .map(([id]) => id); + new Set( + particleResults + [firstQueryFieldLine.field] + [firstQueryFieldLine.query]); + + for (const currQueryFieldLine of restQueryFieldLines) { + const tossResults = new Set(commonAcrossFields); + + const keepResults = + particleResults + [currQueryFieldLine.field] + [currQueryFieldLine.query]; + + for (const result of keepResults) { + tossResults.delete(result); + } + + for (const result of tossResults) { + commonAcrossFields.delete(result); + } + } for (const result of commonAcrossFields) { - results.add(result); + matchedResults.add(result); } } } - const constituted = - boilerplate.constitute(results); + matchedResults = Array.from(matchedResults); - const constitutedAndFiltered = - constituted - .filter(({id}) => - (queriedKind - ? id.split(':')[0] === queriedKind - : true)); + const filteredResults = + (queriedKind + ? matchedResults.filter(id => id.split(':')[0] === queriedKind) + : matchedResults); - return constitutedAndFiltered; + const constitutedResults = + boilerplate.constitute(filteredResults); + + return constitutedResults; } function processTerms(query) { const kindTermSpec = [ - {kind: 'album', terms: ['album']}, - {kind: 'artist', terms: ['artist']}, - {kind: 'flash', terms: ['flash']}, - {kind: 'group', terms: ['group']}, - {kind: 'tag', terms: ['art tag', 'tag']}, - {kind: 'track', terms: ['track']}, + {kind: 'album', terms: ['album', 'albums']}, + {kind: 'artist', terms: ['artist', 'artists']}, + {kind: 'flash', terms: ['flash', 'flashes']}, + {kind: 'group', terms: ['group', 'groups']}, + {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']}, + {kind: 'track', terms: ['track', 'tracks']}, ]; const genericTerms = []; + const verbatimTerms = []; let queriedKind = null; const termRegexp = new RegExp( - String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` + + String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` + + String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` + + String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` + String.raw`|[^\s\-]+`, 'gi'); @@ -530,10 +559,16 @@ function processTerms(query) { continue; } + const verbatim = groups.regularVerbatim || groups.curlyVerbatim; + if (verbatim) { + verbatimTerms.push(verbatim); + continue; + } + genericTerms.push(match[0]); } - return {genericTerms, queriedKind}; + return {genericTerms, verbatimTerms, queriedKind}; } function particulate(terms) { |