diff options
Diffstat (limited to 'src/static/js/client')
27 files changed, 2023 insertions, 232 deletions
diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js index 3535a0e5..e099904a 100644 --- a/src/static/js/client/additional-names-box.js +++ b/src/static/js/client/additional-names-box.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {cssProp} from '../client-util.js'; import {info as hashLinkInfo} from './hash-link.js'; @@ -19,8 +17,16 @@ export const info = { state: { visible: false, }, + + session: { + visibleWhileNavigatingAlbum: {type: 'string'}, + }, }; +export function* bindSessionStorage() { + yield 'visibleWhileNavigatingAlbum'; +} + export function getPageReferences() { info.box = document.getElementById('additional-names-box'); @@ -33,7 +39,7 @@ export function getPageReferences() { '.content-sticky-heading-container' + ' ' + 'a[href="#additional-names-box"]' + - ':not(:matches([inert] *))'); + ':not(:where([inert] *))'); info.contentContainer = document.querySelector('#content'); @@ -78,6 +84,33 @@ export function addInternalListeners() { }); } +export function mutatePageContent() { + const {session} = info; + + if (!info.box) return; + if (!session.visibleWhileNavigatingAlbum) return; + + const currentAlbum = + cssProp(document.body, '--album-directory'); + + if (session.visibleWhileNavigatingAlbum === currentAlbum) { + toggleAdditionalNamesBox(); + } +} + +export function initializeState() { + const {session} = info; + + if (!session.visibleWhileNavigatingAlbum) return; + + const currentAlbum = + cssProp(document.body, '--album-directory'); + + if (session.visibleWhileNavigatingAlbum !== currentAlbum) { + session.visibleWhileNavigatingAlbum = null; + } +} + export function addPageListeners() { for (const link of info.links) { link.addEventListener('click', domEvent => { @@ -121,7 +154,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { ? top > 0.4 * window.innerHeight : top > 0.5 * window.innerHeight) || - (bottom && bottomFitsInFrame + (bottom && boxFitsInFrame ? bottom > window.innerHeight - 20 : false); @@ -140,11 +173,17 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { } export function toggleAdditionalNamesBox() { - const {state} = info; + const {state, session} = info; state.visible = !state.visible; + info.box.style.display = (state.visible ? 'block' : 'none'); + + session.visibleWhileNavigatingAlbum = + (state.visible + ? cssProp(document.body, '--album-directory') + : null); } diff --git a/src/static/js/client/album-commentary-sidebar.js b/src/static/js/client/album-commentary-sidebar.js index c5eaf81b..144544ed 100644 --- a/src/static/js/client/album-commentary-sidebar.js +++ b/src/static/js/client/album-commentary-sidebar.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {empty} from '../../shared-util/sugar.js'; import {info as hashLinkInfo} from './hash-link.js'; @@ -25,7 +23,10 @@ export const info = { }; export function getPageReferences() { - if (document.documentElement.dataset.urlKey !== 'localized.albumCommentary') { + if ( + document.documentElement.dataset.urlKey !== 'localized.albumCommentary' && + document.documentElement.dataset.urlKey !== 'localized.vgmAlbumCommentary' + ) { return; } diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js index fd40d1a2..b7fff70d 100644 --- a/src/static/js/client/art-tag-gallery-filter.js +++ b/src/static/js/client/art-tag-gallery-filter.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: 'artTagGalleryFilterInfo', @@ -142,8 +140,8 @@ export function addPageListeners() { currentFeaturedLine.style.display = 'none'; currentShowingLine.style.display = 'none'; - nextFeaturedLine.style.display = 'block'; - nextShowingLine.style.display = 'block'; + nextFeaturedLine.style.display = 'inline'; + nextShowingLine.style.display = 'inline'; filterArtTagGallery(nextShowing); }); diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js index 44e10c11..d0576152 100644 --- a/src/static/js/client/art-tag-network.js +++ b/src/static/js/client/art-tag-network.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {cssProp} from '../client-util.js'; import {atOffset, stitchArrays} from '../../shared-util/sugar.js'; diff --git a/src/static/js/client/artist-external-link-tooltip.js b/src/static/js/client/artist-external-link-tooltip.js index 21ddfb91..2eadf916 100644 --- a/src/static/js/client/artist-external-link-tooltip.js +++ b/src/static/js/client/artist-external-link-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {accumulateSum, empty} from '../../shared-util/sugar.js'; import {info as hoverableTooltipInfo, repositionCurrentTooltip} 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..b8ff7354 --- /dev/null +++ b/src/static/js/client/artist-rolling-window.js @@ -0,0 +1,571 @@ +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 deleted file mode 100644 index 6e7b15b5..00000000 --- a/src/static/js/client/css-compatibility-assistant.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-env browser */ - -export const info = { - id: 'cssCompatibilityAssistantInfo', - - coverArtContainer: null, - coverArtImageDetails: null, -}; - -export function getPageReferences() { - info.coverArtContainer = - document.getElementById('cover-art-container'); - - info.coverArtImageDetails = - info.coverArtContainer?.querySelector('.image-details'); -} - -export function mutatePageContent() { - if (info.coverArtImageDetails) { - info.coverArtContainer.classList.add('has-image-details'); - } -} diff --git a/src/static/js/client/datetimestamp-tooltip.js b/src/static/js/client/datetimestamp-tooltip.js index 46d1cd5b..00530484 100644 --- a/src/static/js/client/datetimestamp-tooltip.js +++ b/src/static/js/client/datetimestamp-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - // TODO: Maybe datetimestamps can just be incorporated into text-with-tooltip? import {stitchArrays} from '../../shared-util/sugar.js'; diff --git a/src/static/js/client/dragged-link.js b/src/static/js/client/dragged-link.js index 56021e7f..3a4ee314 100644 --- a/src/static/js/client/dragged-link.js +++ b/src/static/js/client/dragged-link.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: `draggedLinkInfo`, 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..4d6e0058 --- /dev/null +++ b/src/static/js/client/expandable-grid-section.js @@ -0,0 +1,83 @@ +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..44f98ac3 --- /dev/null +++ b/src/static/js/client/gallery-style-selector.js @@ -0,0 +1,121 @@ +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/group-contributions-table.js b/src/static/js/client/group-contributions-table.js new file mode 100644 index 00000000..80ee38a1 --- /dev/null +++ b/src/static/js/client/group-contributions-table.js @@ -0,0 +1,163 @@ +import {cssProp} from '../client-util.js'; +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'groupContributionsInfo', + + tables: null, + lists: null, + + groupLinks: null, + groupLinkDirectories: null, + + chunkDTs: null, + chunkDDs: null, + chunkGroupDirectories: null, + + filterNotices: null, + filterNoticeClearLinks: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artist') { + return; + } + + info.tables = + Array.from(document.querySelectorAll('.group-contributions-table')); + + info.lists = + info.tables + .map(table => table.closest('dl')); + + info.groupLinks = + info.tables + .map(table => Array.from(table.querySelectorAll('td.group a'))); + + info.groupLinkDirectories = + info.groupLinks + .map(links => links + .map(link => link.dataset.directory)); + + info.chunkDTs = + info.lists + .map(list => Array.from(list.querySelectorAll('dt'))) + .map(dts => dts + .filter(dt => !dt.classList.contains('filter-notice'))); + + info.chunkDDs = + info.chunkDTs + .map(dts => dts + .map(dt => dt.nextElementSibling) + .map(el => el?.tagName === 'DD' ? el : null)); + + info.chunkGroupDirectories = + info.chunkDTs + .map(dts => dts + .map(dt => dt.dataset.groups) + .map(string => string ? string.split(' ') : [])); + + info.filterNotices = + info.lists + .map(list => list.querySelector('.filter-notice')); + + info.filterNoticeClearLinks = + info.filterNotices + .map(notice => notice.querySelector('a')); +} + +export function addPageListeners() { + if (!info.tables) return; + + stitchArrays({ + table: info.tables, + groupLinks: info.groupLinks, + }).forEach(({table, groupLinks}) => { + groupLinks.forEach(groupLink => { + groupLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + handleGroupLinkClicked(table, groupLink); + }); + }); + }); + + stitchArrays({ + table: info.tables, + clearLink: info.filterNoticeClearLinks, + }).forEach(({table, clearLink}) => { + clearLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + handleClearLinkClicked(table); + }); + }); +} + +function handleGroupLinkClicked(table, groupLink) { + const i = info.tables.indexOf(table); + + groupLink.classList.toggle('selected'); + + // For now, just disable having more than one link selected at a time. + for (const link of info.groupLinks[i]) { + if (link !== groupLink) { + link.classList.remove('selected'); + } + } + + updateVisibleChunks(table); +} + +function handleClearLinkClicked(table) { + const i = info.tables.indexOf(table); + + for (const link of info.groupLinks[i]) { + link.classList.remove('selected'); + } + + updateVisibleChunks(table); +} + +function updateVisibleChunks(table) { + const i = info.tables.indexOf(table); + + const selectedGroupDirectories = + stitchArrays({ + link: info.groupLinks[i], + directory: info.groupLinkDirectories[i], + }).filter(({link}) => link.classList.contains('selected')) + .map(({directory}) => directory); + + stitchArrays({ + chunkDT: info.chunkDTs[i], + chunkDD: info.chunkDDs[i], + chunkGroupDirectories: info.chunkGroupDirectories[i], + }).forEach(({ + chunkDT, + chunkDD, + chunkGroupDirectories, + }) => { + if (selectedGroupDirectories.length >= 1) { + const included = + chunkGroupDirectories + .some(d => selectedGroupDirectories.includes(d)); + + if (included) { + cssProp(chunkDT, 'display', null); + cssProp(chunkDD, 'display', null); + } else { + cssProp(chunkDT, 'display', 'none'); + cssProp(chunkDD, 'display', 'none'); + } + } else { + cssProp(chunkDT, 'display', null); + cssProp(chunkDD, 'display', null); + } + }); + + const filterNotice = info.filterNotices[i]; + if (selectedGroupDirectories.length >= 1) { + cssProp(filterNotice, 'display', null); + } else { + cssProp(filterNotice, 'display', 'none'); + } +} diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js index 27035e29..02ffdc23 100644 --- a/src/static/js/client/hash-link.js +++ b/src/static/js/client/hash-link.js @@ -1,6 +1,5 @@ -/* eslint-env browser */ - -import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {filterMultipleArrays, stitchArrays, unique} + from '../../shared-util/sugar.js'; import {dispatchInternalEvent} from '../client-util.js'; @@ -11,6 +10,9 @@ export const info = { hrefs: null, targets: null, + details: null, + detailsIDs: null, + state: { highlightedTarget: null, scrollingAfterClick: false, @@ -40,6 +42,19 @@ export function getPageReferences() { info.hrefs, info.targets, (_link, _href, target) => target); + + info.details = + unique([ + ...document.querySelectorAll('details[id]'), + ... + Array.from(document.querySelectorAll('summary[id]')) + .map(summary => summary.closest('details')), + ]); + + info.detailsIDs = + info.details.map(details => + details.id || + details.querySelector('summary').id); } function processScrollingAfterHashLinkClicked() { @@ -60,6 +75,15 @@ function processScrollingAfterHashLinkClicked() { }, 200); } +export function mutatePageContent() { + if (location.hash.length > 1) { + const target = document.getElementById(location.hash.slice(1)); + if (target) { + expandDetails(target); + } + } +} + 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'. @@ -94,6 +118,8 @@ export function addPageListeners() { return; } + expandDetails(target); + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -143,4 +169,32 @@ export function addPageListeners() { state.highlightedTarget = null; }); } + + stitchArrays({ + details: info.details, + id: info.detailsIDs, + }).forEach(({details, id}) => { + details.addEventListener('toggle', () => { + if (!details.open) { + detractHash(id); + } + }); + }); +} + +function expandDetails(target) { + if (target.nodeName === 'SUMMARY') { + const details = target.closest('details'); + if (details) { + details.open = true; + } + } else if (target.nodeName === 'DETAILS') { + target.open = true; + } +} + +function detractHash(id) { + if (location.hash === '#' + id) { + history.pushState({}, undefined, location.href.replace(/#.*$/, '')); + } } diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js index 484f2ab0..e0c74747 100644 --- a/src/static/js/client/hoverable-tooltip.js +++ b/src/static/js/client/hoverable-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {empty, filterMultipleArrays} from '../../shared-util/sugar.js'; import {WikiRect} from '../rectangles.js'; @@ -8,7 +6,6 @@ import { cssProp, dispatchInternalEvent, getVisuallyContainingElement, - pointIsOverAnyOf, } from '../client-util.js'; import {info as stickyHeadingInfo} from './sticky-heading.js'; @@ -118,17 +115,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,24 +155,31 @@ 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); }); } function handleTooltipMouseEntered(tooltip) { + // NOTE: This function is NOT NATURALLY CALLED on iOS Safari. + // Elements generally don't receive mouse events there at all - hoverables + // are the exception (we have not identified exactly why). We do however + // mock calling this function. However, because we mock the event and do so + // without any special awareness, this function may be called multiple times + // in sequence, without the tooltip ever receiving a mouseleave event. + const {state} = info; if (state.currentlyTransitioningHiddenTooltip) { @@ -194,6 +198,9 @@ function handleTooltipMouseEntered(tooltip) { } function handleTooltipMouseLeft(tooltip) { + // NOTE: This function is NOT NATURALLY CALLED on iOS Safari. + // We don't mock it there, either. + const {settings, state} = info; if (state.currentlyShownTooltip !== tooltip) return; @@ -340,7 +347,10 @@ function handleTooltipHoverableLostFocus(hoverable, domEvent) { // 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)) { + if ( + domEvent.relatedTarget && + !currentlyShownTooltipHasFocus(domEvent.relatedTarget) + ) { hideCurrentlyShownTooltip(true); } } @@ -386,11 +396,14 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // 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)); + let anyTouchEndedOverHoverable = false; + for (const touch of touches) { + const point = WikiRect.fromPoint(touch.clientX, touch.clientY); + if (WikiRect.fromElementContaining(hoverable, point)) { + anyTouchEndedOverHoverable = true; + break; + } + } if (!anyTouchEndedOverHoverable) { return; @@ -416,7 +429,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 +439,7 @@ function handleTooltipHoverableClicked(hoverable) { state.currentlyActiveHoverable === hoverable && state.hoverableWasRecentlyTouched ) { - event.preventDefault(); + domEvent.preventDefault(); } } @@ -576,6 +589,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'); @@ -647,7 +671,22 @@ export function positionTooltipFromHoverableWithBrains(hoverable) { const {numBaselineRects, idealBaseline: baselineRect} = opportunities; if (baselineRect.contains(tooltipRect)) { - return; + // ...unless hovering over a rectangle besides the hoverable's first. + // An element has multiple rectangles if it's an inline element that + // has wrapped across to the next line. + + const hoverableClientRects = + Array.from(hoverable.getClientRects()) + .map(rect => WikiRect.fromRect(rect)); + + const mouseRect = WikiRect.fromMouse(); + + const hoverableClientRectIndex = + hoverableClientRects.findIndex(rect => rect.contains(mouseRect)); + + if (hoverableClientRectIndex <= 0) { + return; + } } const tryDirection = (dir1, dir2, i) => { @@ -667,12 +706,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'], ]) { @@ -705,6 +744,8 @@ export function positionTooltip(tooltip, x, y) { cssProp(tooltip, { left: `${x - tooltipRect.x}px`, top: `${y - tooltipRect.y}px`, + right: 'unset', + bottom: 'unset', }); } @@ -712,6 +753,8 @@ export function resetDynamicTooltipPositioning(tooltip) { cssProp(tooltip, { left: null, top: null, + right: null, + bottom: null, }); } @@ -722,8 +765,12 @@ export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { const baselineRects = getTooltipBaselineOpportunityAreas(tooltip); + const basicHoverableRect = + WikiRect.fromElementUnderMouse(hoverable) ?? + WikiRect.fromRect(hoverable.getClientRects()[0]); + const hoverableRect = - WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10); + basicHoverableRect.toExtended(5, 10); const tooltipRect = peekTooltipClientRect(tooltip); @@ -850,7 +897,7 @@ export function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { const rightRightLeft = WikiRect.leftOf( - hoverableRect.left - neededHorizontalOverlap + tooltipRect.width); + hoverableRect.left + tooltipRect.width); const leftLeftRight = WikiRect.rightOf( @@ -995,6 +1042,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; @@ -1024,10 +1079,14 @@ export function addPageListeners() { }); }); - const getHoverablesAndTooltips = () => [ - ...Array.from(state.registeredHoverables.keys()), - ...Array.from(state.registeredTooltips.keys()), - ]; + const getHoverables = () => + Array.from(state.registeredHoverables.keys()); + + const getTooltips = () => + Array.from(state.registeredTooltips.keys()); + + const getHoverablesAndTooltips = () => + [...getHoverables(), ...getTooltips()]; document.body.addEventListener('touchend', domEvent => { const touches = Array.from(domEvent.changedTouches); @@ -1042,12 +1101,26 @@ export function addPageListeners() { if (empty(touches)) return; - const pointIsOverHoverableOrTooltip = - pointIsOverAnyOf(getHoverablesAndTooltips()); + let anyTouchOverAnyHoverableOrTooltip = false; + for (const touch of touches) { + const point = WikiRect.fromPoint(touch.clientX, touch.clientY); - const anyTouchOverAnyHoverableOrTooltip = - touches.some(({clientX, clientY}) => - pointIsOverHoverableOrTooltip(clientX, clientY)); + for (const hoverable of getHoverables()) { + if (WikiRect.fromElementContaining(hoverable, point)) { + anyTouchOverAnyHoverableOrTooltip = true; + } + } + + for (const tooltip of getTooltips()) { + if (WikiRect.fromElementContaining(tooltip, point)) { + anyTouchOverAnyHoverableOrTooltip = true; + + setTimeout(() => { + handleTooltipMouseEntered(tooltip); + }, 200); + } + } + } if (!anyTouchOverAnyHoverableOrTooltip) { hideCurrentlyShownTooltip(); @@ -1055,12 +1128,17 @@ export function addPageListeners() { }); document.body.addEventListener('click', domEvent => { - const {clientX, clientY} = domEvent; + const point = WikiRect.fromPoint(domEvent.clientX, domEvent.clientY); - const pointIsOverHoverableOrTooltip = - pointIsOverAnyOf(getHoverablesAndTooltips()); + let pointIsOverHoverableOrTooltip = false; + for (const element of getHoverablesAndTooltips()) { + if (WikiRect.fromElementContaining(element, point)) { + pointIsOverHoverableOrTooltip = true; + break; + } + } - if (!pointIsOverHoverableOrTooltip(clientX, clientY)) { + if (!pointIsOverHoverableOrTooltip) { // 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, diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js index da192178..0595bff7 100644 --- a/src/static/js/client/image-overlay.js +++ b/src/static/js/client/image-overlay.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {getColors} from '../../shared-util/colors.js'; import {cssProp} from '../client-util.js'; @@ -96,7 +94,10 @@ function handleContainerClicked(evt) { // 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) { + if ( + evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 && + evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20 + ) { return; } @@ -146,7 +147,8 @@ function getImageLinkDetails(imageLink) { a.href, embeddedSrc: - img?.src ?? + img?.src || + img?.currentSrc || a.dataset.embedSrc, originalFileSize: diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index 81ea3415..a438d6d8 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -1,25 +1,25 @@ -/* 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 artistRollingWindowModule from './artist-rolling-window.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 groupContributionsTableModule from './group-contributions-table.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 memorableDetailsModule from './memorable-details.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'; -import * as summaryNestedLinkModule from './summary-nested-link.js'; import * as textWithTooltipModule from './text-with-tooltip.js'; import * as wikiSearchModule from './wiki-search.js'; @@ -29,26 +29,34 @@ export const modules = [ artTagGalleryFilterModule, artTagNetworkModule, artistExternalLinkTooltipModule, - cssCompatibilityAssistantModule, + artistRollingWindowModule, datetimestampTooltipModule, draggedLinkModule, + expandableGridSectionModule, + galleryStyleSelectorModule, + groupContributionsTableModule, hashLinkModule, hoverableTooltipModule, imageOverlayModule, intrapageDotSwitcherModule, liveMousePositionModule, + memorableDetailsModule, quickDescriptionModule, + revealAllGridControlModule, scriptedLinkModule, sidebarSearchModule, stickyHeadingModule, - summaryNestedLinkModule, textWithTooltipModule, wikiSearchModule, ]; const clientInfo = window.hsmusicClientInfo = Object.create(null); -const clientSteps = { +// These steps are always run in the listed order, on page load. +// So for example, all modules' getPageReferences steps are evaluated, then +// all modules' addInternalListeners steps are evaluated, and so on. +const setupSteps = { + bindSessionStorage: [], getPageReferences: [], addInternalListeners: [], mutatePageContent: [], @@ -56,6 +64,18 @@ const clientSteps = { addPageListeners: [], }; +// These steps are run only on certain triggers. Those are global events, +// so all modules (which specify that step) respond in sequence. +const situationalSteps = { + /* There's none yet... sorry... */ +}; + +const stepInfoSymbol = Symbol(); + +const boundSessionStorage = + window.hsmusicBoundSessionStorage = + Object.create(null); + for (const module of modules) { const {info} = module; @@ -110,7 +130,7 @@ for (const module of modules) { break; case 'boolean': - formatRead = Boolean; + formatRead = value => value === 'true' ? true : false; formatWrite = String; break; @@ -140,12 +160,47 @@ for (const module of modules) { const storageKey = `hsmusic.${infoKey}.${key}`; + // There are two storage systems besides actual session storage in play. + // + // "Fallback" is for if session storage is not available, which may + // suddenly become the case, i.e. access is temporarily revoked or fails. + // The fallback value is controlled completely internally i.e. in this + // infrastructure, in this lexical scope. + // + // "Bound" is for if the value kept in session storage was saved to + // the page when the page was initially loaded, rather than a living + // window on session storage (which may be affected by pages later in + // the history stack). Whether or not bound storage is in effect is + // controlled at page load (of course), by each module's own logic. + // + // Asterisk: Bound storage can't work miracles and if the page is + // actually deloaded with its JavaScript state discarded, the bound + // values are lost, even if the browser recreates on-page form state. + let fallbackValue = defaultValue; + let boundValue = undefined; + + const updateBoundValue = (givenValue = undefined) => { + if (givenValue) { + if ( + infoKey in boundSessionStorage && + key in boundSessionStorage[infoKey] + ) { + boundSessionStorage[infoKey][key] = givenValue; + } + } else { + boundValue = boundSessionStorage[infoKey]?.[key]; + } + }; Object.defineProperty(info.session, key, { get: () => { + updateBoundValue(); + let value; - try { + if (boundValue !== undefined) { + value = boundValue ?? defaultValue; + } else try { value = sessionStorage.getItem(storageKey) ?? defaultValue; } catch (error) { if (error instanceof DOMException) { @@ -181,21 +236,23 @@ for (const module of modules) { return; } - let operation; + let sessionOperation; if (value === '') { fallbackValue = null; - operation = () => { + updateBoundValue(null); + sessionOperation = () => { sessionStorage.removeItem(storageKey); }; } else { fallbackValue = value; - operation = () => { + updateBoundValue(value); + sessionOperation = () => { sessionStorage.setItem(storageKey, value); }; } try { - operation(); + sessionOperation(); } catch (error) { if (!(error instanceof DOMException)) { throw error; @@ -208,28 +265,72 @@ for (const module of modules) { Object.preventExtensions(info.session); } - for (const key of Object.keys(clientSteps)) { - if (Object.hasOwn(module, key)) { - const fn = module[key]; + for (const stepsObject of [setupSteps, situationalSteps]) { + for (const key of Object.keys(stepsObject)) { + if (Object.hasOwn(module, key)) { + const fn = module[key]; - Object.defineProperty(fn, 'name', { - value: `${infoKey}/${fn.name}`, - }); + fn[stepInfoSymbol] = info; + + Object.defineProperty(fn, 'name', { + value: `${infoKey}/${fn.name}`, + }); + + stepsObject[key].push(fn); + } + } + } +} + +function evaluateBindSessionStorageStep(bindSessionStorage) { + const {id: infoKey, session: moduleExposedSessionObject} = + bindSessionStorage[stepInfoSymbol]; + + const generator = bindSessionStorage(); + + let lastBoundValue; + while (true) { + const {value: key, done} = generator.next(lastBoundValue); + const storageKey = `hsmusic.${infoKey}.${key}`; + + let value = undefined; + try { + value = sessionStorage.getItem(storageKey); + } catch (error) { + if (!(error instanceof DOMException)) { + throw error; + } + } - clientSteps[key].push(fn); + if (value === undefined) { + // This effectively gets the default value. + value = moduleExposedSessionObject[key]; } + + boundSessionStorage[infoKey] ??= Object.create(null); + boundSessionStorage[infoKey][key] = value; + + lastBoundValue = value; + + if (done) break; } } -for (const [key, steps] of Object.entries(clientSteps)) { - for (const step of steps) { +function evaluateStep(stepsObject, key) { + for (const step of stepsObject[key]) { try { - step(); + if (key === 'bindSessionStorage') { + evaluateBindSessionStorageStep(step); + } else { + 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); } } } + +for (const key of Object.keys(setupSteps)) { + evaluateStep(setupSteps, key); +} diff --git a/src/static/js/client/intrapage-dot-switcher.js b/src/static/js/client/intrapage-dot-switcher.js index d06bc5a6..b9a27a9b 100644 --- a/src/static/js/client/intrapage-dot-switcher.js +++ b/src/static/js/client/intrapage-dot-switcher.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; import {cssProp} from '../client-util.js'; diff --git a/src/static/js/client/live-mouse-position.js b/src/static/js/client/live-mouse-position.js index 36a28429..32fc5bf4 100644 --- a/src/static/js/client/live-mouse-position.js +++ b/src/static/js/client/live-mouse-position.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - export const info = { id: 'liveMousePositionInfo', diff --git a/src/static/js/client/memorable-details.js b/src/static/js/client/memorable-details.js new file mode 100644 index 00000000..57d9fde8 --- /dev/null +++ b/src/static/js/client/memorable-details.js @@ -0,0 +1,62 @@ +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'memorableDetailsInfo', + + details: null, + ids: null, + + session: { + openDetails: { + type: 'json', + maxLength: settings => settings.maxOpenDetailsStorage, + }, + }, + + settings: { + maxOpenDetailsStorage: 1000, + }, +}; + +export function getPageReferences() { + info.details = + Array.from(document.querySelectorAll('details.memorable')); + + info.ids = + info.details.map(details => details.getAttribute('data-memorable-id')); +} + +export function mutatePageContent() { + stitchArrays({ + details: info.details, + id: info.ids, + }).forEach(({details, id}) => { + if (info.session.openDetails?.includes(id)) { + details.open = true; + } + }); +} + +export function addPageListeners() { + for (const [index, details] of info.details.entries()) { + details.addEventListener('toggle', () => { + handleDetailsToggled(index); + }); + } +} + +function handleDetailsToggled(index) { + const details = info.details[index]; + const id = info.ids[index]; + + if (details.open) { + if (info.session.openDetails) { + info.session.openDetails = [...info.session.openDetails, id]; + } else { + info.session.openDetails = [id]; + } + } else if (info.session.openDetails?.includes(id)) { + info.session.openDetails = + info.session.openDetails.filter(item => item !== id); + } +} diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js index cff82252..9117d48c 100644 --- a/src/static/js/client/quick-description.js +++ b/src/static/js/client/quick-description.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; export const info = { 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..0572a190 --- /dev/null +++ b/src/static/js/client/reveal-all-grid-control.js @@ -0,0 +1,70 @@ +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/scripted-link.js b/src/static/js/client/scripted-link.js index 8b8d8a13..badc6ccb 100644 --- a/src/static/js/client/scripted-link.js +++ b/src/static/js/client/scripted-link.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {pick, stitchArrays} from '../../shared-util/sugar.js'; import { diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js index fb902636..61a33c0d 100644 --- a/src/static/js/client/sidebar-search.js +++ b/src/static/js/client/sidebar-search.js @@ -1,16 +1,22 @@ -/* eslint-env browser */ - import {getColors} from '../../shared-util/colors.js'; -import {accumulateSum, empty} from '../../shared-util/sugar.js'; + +import { + accumulateSum, + compareArrays, + empty, + unique, +} from '../../shared-util/sugar.js'; import { cssProp, + decodeEntities, openAlbum, openArtist, openArtTag, openFlash, openGroup, openTrack, + openVGMAlbum, rebase, templateContent, } from '../client-util.js'; @@ -33,6 +39,9 @@ export const info = { searchLabel: null, searchInput: null, + contextContainer: null, + contextBackLink: null, + progressRule: null, progressContainer: null, progressLabel: null, @@ -41,6 +50,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, @@ -60,10 +77,27 @@ export const info = { currentResultString: null, endSearchString: null, + backString: null, + albumResultKindString: null, artistResultKindString: null, + flashResultKindString: null, groupResultKindString: null, + singleResultKindString: null, tagResultKindString: null, + vgmAlbumResultKindString: null, + + groupResultDisambiguatorString: null, + flashResultDisambiguatorString: null, + trackResultDisambiguatorString1: null, + trackResultDisambiguatorString2: null, + + albumResultFilterString: null, + artistResultFilterString: null, + flashResultFilterString: null, + groupResultFilterString: null, + tagResultFilterString: null, + trackResultFilterString: null, state: { sidebarColumnShownForSearch: null, @@ -74,6 +108,8 @@ export const info = { recallingRecentSearch: null, recallingRecentSearchFromMouse: null, + justPerformedActiveQuery: false, + currentValue: null, workerStatus: null, @@ -92,11 +128,20 @@ export const info = { type: 'string', }, + activeQueryContextPageName: {type: 'string'}, + activeQueryContextPagePathname: {type: 'string'}, + activeQueryContextPageColor: {type: 'string'}, + zapActiveQueryContext: {type: 'boolean'}, + activeQueryResults: { type: 'json', maxLength: settings => settings.maxActiveResultsStorage, }, + activeFilterType: { + type: 'string', + }, + repeatQueryOnReload: { type: 'boolean', default: false, @@ -118,6 +163,19 @@ export const info = { }, }; +export function* bindSessionStorage() { + if (yield 'activeQuery') { + yield 'activeQueryContextPageName'; + yield 'activeQueryContextPagePathname'; + yield 'activeQueryContextPageColor'; + yield 'zapActiveQueryContext'; + + yield 'activeQueryResults'; + yield 'activeFilterType'; + yield 'resultsScrollOffset'; + } +} + export function getPageReferences() { info.pageContainer = document.getElementById('page-container'); @@ -159,6 +217,9 @@ export function getPageReferences() { info.noResultsString = findString('no-results'); + info.backString = + findString('back'); + info.currentResultString = findString('current-result'); @@ -171,11 +232,50 @@ export function getPageReferences() { info.artistResultKindString = findString('artist-result-kind'); + info.flashResultKindString = + findString('flash-result-kind'); + info.groupResultKindString = findString('group-result-kind'); + info.singleResultKindString = + findString('single-result-kind'); + info.tagResultKindString = findString('tag-result-kind'); + + info.vgmAlbumResultKindString = + findString('vgm-album-result-kind'); + + info.groupResultDisambiguatorString = + findString('group-result-disambiguator'); + + info.flashResultDisambiguatorString = + findString('flash-result-disambiguator'); + + info.trackResultDisambiguatorString1 = + findString('track-result-album-disambiguator'); + + info.trackResultDisambiguatorString2 = + findString('track-result-artist-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() { @@ -212,6 +312,25 @@ export function addInternalListeners() { export function mutatePageContent() { if (!info.searchBox) return; + // Context section + + info.contextContainer = + document.createElement('div'); + + info.contextContainer.classList.add('wiki-search-context-container'); + + info.contextBackLink = + document.createElement('a'); + + info.contextContainer.appendChild( + templateContent(info.backString, { + page: info.contextBackLink, + })); + + cssProp(info.contextContainer, 'display', 'none'); + + info.searchBox.appendChild(info.contextContainer); + // Progress section info.progressRule = @@ -265,6 +384,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 = @@ -313,6 +464,12 @@ export function mutatePageContent() { info.searchBox.appendChild(info.endSearchRule); info.searchBox.appendChild(info.endSearchLine); + + // Accommodate the web browser reconstructing the search input with a value + // that was previously entered (or restored after recall), i.e. because + // the user is traversing very far back in history and yet the browser is + // trying to rebuild the page as-was anyway, by telling it "no don't". + info.searchInput.value = ''; } export function addPageListeners() { @@ -371,7 +528,7 @@ export function addPageListeners() { const {settings, state} = info; if (!info.searchInput.value) { - clearSidebarSearch(); + clearSidebarSearch(); // ...but don't clear filter return; } @@ -433,8 +590,15 @@ 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', () => { @@ -449,6 +613,22 @@ export function addPageListeners() { saveSidebarSearchResultsScrollOffset(); }, settings.stoppedScrollingDelay); }); + + document.addEventListener('keypress', domEvent => { + const {tagName} = document.activeElement ?? {}; + if (tagName === 'INPUT' || tagName === 'TEXTAREA') { + return; + } + + if (domEvent.shiftKey && domEvent.code === 'Slash') { + if (domEvent.ctrlKey || domEvent.metaKey) { + return; + } + + domEvent.preventDefault(); + info.searchLabel.click(); + } + }); } export function initializeState() { @@ -518,6 +698,21 @@ 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; @@ -546,9 +741,12 @@ async function activateSidebarSearch(query) { return; } + state.justPerformedActiveQuery = true; state.searchStage = 'complete'; updateSidebarSearchStatus(); + recordActiveQueryContext(); + session.activeQuery = query; session.activeQueryResults = results; session.resultsScrollOffset = 0; @@ -562,8 +760,41 @@ async function activateSidebarSearch(query) { } } +function recordActiveQueryContext() { + const {session} = info; + + if (document.documentElement.dataset.urlKey === 'localized.home') { + session.activeQueryContextPageName = null; + session.activeQueryContextPagePathname = null; + session.activeQueryContextPageColor = null; + session.zapActiveQueryContext = true; + return; + } + + // Zapping means subsequent searches don't record context. + if (session.zapActiveQueryContext) { + return; + } + + // We also don't overwrite existing context. + if (session.activeQueryContextPagePathname) { + return; + } + + session.activeQueryContextPageName = + decodeEntities(document.querySelector('title').dataset.withoutWikiName) || + document.title; + + session.activeQueryContextPagePathname = + location.pathname; + + session.activeQueryContextPageColor = + document.querySelector('.color-style')?.dataset.color ?? + null; +} + function clearSidebarSearch() { - const {session, state} = info; + const {state} = info; if (state.stoppedTypingTimeout) { clearTimeout(state.stoppedTypingTimeout); @@ -576,12 +807,34 @@ function clearSidebarSearch() { info.searchInput.value = ''; state.searchStage = null; + state.justPerformedActiveQuery = false; + + clearActiveQuery(); + + hideSidebarSearchResults(); +} + +function clearActiveQuery() { + const {session} = info; session.activeQuery = null; session.activeQueryResults = null; session.resultsScrollOffset = null; - hideSidebarSearchResults(); + session.activeQueryContextPageName = null; + session.activeQueryContextPagePathname = null; + session.activeQueryContextPageColor = null; + session.zapActiveQueryContext = false; +} + +function clearSidebarFilter() { + const {session} = info; + + toggleSidebarSearchFilter(session.activeFilterType); + + forEachFilter((_type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + }); } function updateSidebarSearchStatus() { @@ -670,63 +923,189 @@ 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) { + info.results.classList.add('has-results'); + + showContextControls(); + + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); + + tidySidebarSearchColumn(); + } else { + info.results.classList.remove('has-results'); - 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); + 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); + + let filteredResults = tidiedResults; + + if (filterType) { + filteredResults = filteredResults + .filter(result => result.referenceType === filterType); + } + + if (!filterType) { + filteredResults = filteredResults + .filter(result => { + if (result.referenceType !== 'track') return true; + if (result.data.classification !== 'single') return true; + return !filteredResults.find(otherResult => { + if (otherResult.referenceType !== 'album') return false; + return otherResult.name === result.parentName; + }); + }); + } + + filteredResults = filteredResults + + 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) { + let el; + try { + el = generateSidebarSearchResult(result, filteredResults); + } catch (error) { + console.error(`Error showing result:`, result); + console.error(error); + } + 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; + + 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'); } +} - restoreSidebarSearchResultsScrollOffset(); +function showContextControls() { + const {session} = info; + + const shouldShow = + session.activeQueryContextPagePathname && + location.pathname !== session.activeQueryContextPagePathname; + + if (shouldShow) { + info.contextBackLink.href = + session.activeQueryContextPagePathname; + + cssProp(info.contextBackLink, + '--primary-color', + session.activeQueryContextPageColor); + + while (info.contextBackLink.firstChild) { + info.contextBackLink.firstChild.remove(); + } + + info.contextBackLink.appendChild( + document.createTextNode( + session.activeQueryContextPageName)); + + cssProp(info.contextContainer, 'display', 'block'); + } else { + cssProp(info.contextContainer, '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), @@ -735,10 +1114,16 @@ function generateSidebarSearchResult(result) { switch (result.referenceType) { case 'album': { preparedSlots.href = - openAlbum(result.directory); + (result.data.classification === 'in-game vgm' + ? openVGMAlbum(result.directory) + : openAlbum(result.directory)); preparedSlots.kindString = - info.albumResultKindString; + (result.data.classification === 'single' + ? info.singleResultKindString + : result.data.classification === 'in-game vgm' + ? info.vgmAlbumResultKindString + : info.albumResultKindString); break; } @@ -767,6 +1152,9 @@ function generateSidebarSearchResult(result) { preparedSlots.href = openFlash(result.directory); + preparedSlots.kindString = + info.flashResultKindString; + break; } @@ -791,9 +1179,103 @@ function generateSidebarSearchResult(result) { return null; } + const compareReferenceType = otherResult => + otherResult.referenceType === result.referenceType; + + const compareName = otherResult => + getSearchResultName(otherResult) === getSearchResultName(result); + + const ambiguousWith = + results.filter(otherResult => + otherResult !== result && + compareReferenceType(otherResult) && + compareName(otherResult)); + + if (!empty(ambiguousWith)) disambiguate: { + const allAmbiguous = [result, ...ambiguousWith]; + + // First search for an ideal disambiguation, which disambiguates + // all ambiguous results in the same way. + let disambiguation = null, i; + for (i = 0; i < result.data.disambiguators.length; i++) { + const disambiguations = + allAmbiguous.map(r => r.data.disambiguators[i]); + + if (unique(disambiguations).length === allAmbiguous.length) { + disambiguation = result.data.disambiguators[i]; + break; + } + } + + // Otherwise, search for a disambiguation which disambiguates + // *this result* with at least one other result which it is + // *otherwise* ambiguous with. + if (!disambiguation) { + for (i = 1; i < result.data.disambiguators.length; i++) { + const otherwiseAmbiguousWith = + ambiguousWith.filter(otherResult => + compareArrays( + otherResult.data.disambiguators.slice(0, i), + result.data.disambiguators.slice(0, i))); + + if ( + otherwiseAmbiguousWith.find(otherResult => + otherResult.data.disambiguators[i] !== + result.data.disambiguators[i]) + ) { + disambiguation = result.data.disambiguators[i]; + break; + } + } + } + + // Otherwise, search for a disambiguation which disambiguates + // this result at all. + if (!disambiguation) { + for (i = 0; i < result.data.disambiguators.length; i++) { + if ( + ambiguousWith.find(otherResult => + otherResult.data.disambiguators[i] !== + result.data.disambiguators[i]) + ) { + disambiguation = result.data.disambiguators[i]; + break; + } + } + } + + if (!disambiguation) { + break disambiguate; + } + + const string = + info[result.referenceType + 'ResultDisambiguatorString' + (i + 1)]; + + if (!string) break disambiguate; + + preparedSlots.disambiguate = disambiguation; + preparedSlots.disambiguatorString = string; + } + return generateSidebarSearchResultTemplate(preparedSlots); } +function getSearchResultName(result) { + const name = + result.data.name ?? + result.data.primaryName; + + if (!name) { + return null; + } + + if (result.data.nameDetail) { + return `${name} (${result.data.nameDetail})`; + } + + return name; +} + function getSearchResultImageSource(result) { const {artwork} = result.data; if (!artwork) return null; @@ -869,6 +1351,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'); @@ -908,6 +1399,9 @@ function generateSidebarSearchResultTemplate(slots) { } function hideSidebarSearchResults() { + cssProp(info.contextContainer, 'display', 'none'); + cssProp(info.filterContainer, 'display', 'none'); + cssProp(info.resultsRule, 'display', 'none'); cssProp(info.resultsContainer, 'display', 'none'); @@ -917,6 +1411,8 @@ function hideSidebarSearchResults() { cssProp(info.endSearchRule, 'display', 'none'); cssProp(info.endSearchLine, 'display', 'none'); + + restoreSidebarSearchColumn(); } function focusFirstSidebarSearchResult() { @@ -1000,7 +1496,7 @@ function possiblyHideSearchSidebarColumn() { // 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; + const {session, 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 @@ -1009,17 +1505,24 @@ function tidySidebarSearchColumn() { return; } - const here = location.href.replace(/\/$/, ''); + const hrefHere = location.href.replace(/\/$/, ''); const currentPageIsResult = Array.from(info.results.querySelectorAll('a')) .some(link => { - const there = link.href.replace(/\/$/, ''); - return here === there; + const hrefThere = link.href.replace(/\/$/, ''); + return hrefHere === hrefThere; }); + const currentPageIsContext = + location.pathname === session.activeQueryContextPagePathname; + // Don't tidy the sidebar if you've navigated to some other page than // what's in the current result list. - if (!currentPageIsResult) { + if ( + !state.justPerformedActiveQuery && + !currentPageIsResult && + !currentPageIsContext + ) { return; } @@ -1040,6 +1543,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; @@ -1069,10 +1602,8 @@ function considerRecallingRecentSidebarSearch() { } function forgetRecentSidebarSearch() { - const {session} = info; - - session.activeQuery = null; - session.activeQueryResults = null; + clearActiveQuery(); + clearSidebarFilter(); } async function handleDroppedIntoSearchInput(domEvent) { @@ -1101,7 +1632,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 fba05b84..c69e137f 100644 --- a/src/static/js/client/sticky-heading.js +++ b/src/static/js/client/sticky-heading.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; import {cssProp, dispatchInternalEvent, templateContent} from '../client-util.js'; @@ -23,6 +21,7 @@ export const info = { contentContainers: null, contentHeadings: null, + contentCoverColumns: null, contentCovers: null, contentCoversReveal: null, @@ -82,9 +81,13 @@ export function getPageReferences() { info.stickyContainers .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 @@ -212,10 +215,10 @@ function updateCollapseStatus(index) { function updateStickyCoverVisibility(index) { const stickyCoverContainer = info.stickyCoverContainers[index]; const stickyContainer = info.stickyContainers[index]; - const contentCover = info.contentCovers[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 { @@ -250,7 +253,11 @@ function getContentHeadingClosestToStickySubheading(index) { // Iterate from bottom to top of the content area. const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { + for (const heading of contentHeadings.toReversed()) { + if (heading.nodeName === 'SUMMARY' && !heading.closest('details').open) { + continue; + } + const headingRect = heading.getBoundingClientRect(); if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { return heading; diff --git a/src/static/js/client/summary-nested-link.js b/src/static/js/client/summary-nested-link.js deleted file mode 100644 index 23857fa5..00000000 --- a/src/static/js/client/summary-nested-link.js +++ /dev/null @@ -1,48 +0,0 @@ -/* 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 index dd207e04..2b855756 100644 --- a/src/static/js/client/text-with-tooltip.js +++ b/src/static/js/client/text-with-tooltip.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {stitchArrays} from '../../shared-util/sugar.js'; import {registerTooltipElement, registerTooltipHoverableElement} diff --git a/src/static/js/client/wiki-search.js b/src/static/js/client/wiki-search.js index 2446c172..9a6e29c1 100644 --- a/src/static/js/client/wiki-search.js +++ b/src/static/js/client/wiki-search.js @@ -1,5 +1,3 @@ -/* eslint-env browser */ - import {promiseWithResolvers} from '../../shared-util/sugar.js'; import {dispatchInternalEvent} from '../client-util.js'; |