From c59545f5faafc826ff24ff779c20318ef14ae123 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 29 Oct 2023 09:27:23 -0300 Subject: content, client: generalize "random pages" listing to wiki dividing groups --- src/static/client2.js | 45 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 3 deletions(-) (limited to 'src/static') diff --git a/src/static/client2.js b/src/static/client2.js index 758d91a6..3a5f9c37 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -149,6 +149,47 @@ function addRandomLinkListeners() { a.href = openAlbum(pick(albumData).directory); break; + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + a.href = openAlbum(pick(albumDirectories)); + break; + } + + case 'track-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + const filteredAlbumData = + albumData.filter(album => + albumDirectories.includes(album.directory)); + + a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); + break; + } + + /* Legacy links, for old versions * + * of generateListRandomPageLinksGroupSection */ + case 'album-in-official': a.href = openAlbum(pick(officialAlbumData).directory); break; @@ -161,9 +202,7 @@ function addRandomLinkListeners() { a.href = openAlbum(pick(beyondAlbumData).directory); break; - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; + /* End legacy links */ case 'track-in-album': a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); -- cgit 1.3.0-6-gf8a5 From b8e612f9723ef1b890a1af8745e3f165220ce9d1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 20:31:57 -0400 Subject: content, client, css: accents in content headings --- src/static/client2.js | 4 ++++ src/static/site5.css | 6 ++++++ 2 files changed, 10 insertions(+) (limited to 'src/static') diff --git a/src/static/client2.js b/src/static/client2.js index 28882a88..0ec052bd 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -879,6 +879,10 @@ function updateStickySubheadingContent(index) { } for (const child of closestHeading.childNodes) { + if (child.classList?.contains('content-heading-accent')) { + continue; + } + if (child.tagName === 'A') { for (const grandchild of child.childNodes) { stickySubheading.appendChild(grandchild.cloneNode(true)); diff --git a/src/static/site5.css b/src/static/site5.css index 0eb7dcda..afce9b0f 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1275,6 +1275,12 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } +.content-heading .content-heading-accent { + font-weight: normal; + font-size: 1rem; + margin-left: 0.25em; +} + h3.content-heading { clear: both; } -- cgit 1.3.0-6-gf8a5 From 75ec07ac18cb91eb2e019aefce8f60488d794de1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 17:48:48 -0400 Subject: data: provide default wiki color in data, not css Fixes #169! --- src/static/site5.css | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index afce9b0f..014e6d25 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -3,13 +3,7 @@ * no need to re-run upd8.js when tweaking values here. Handy! */ -:root { - --primary-color: #0088ff; -} - -/* Layout - Common - * - */ +/* Layout - Common */ body { margin: 10px; -- cgit 1.3.0-6-gf8a5 From d019852fc5dcfa2a7686c17ec1bc9c4877ad5832 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 11:27:22 -0400 Subject: content, css: generateCommentarySection{Entry}? --- src/static/site5.css | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 014e6d25..1ffe5044 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -537,6 +537,17 @@ p .current { margin-top: 5px; } +.commentary-entry-heading { + margin-left: 15px; + padding-left: 5px; + padding-bottom: 0.2em; + border-bottom: 1px dotted var(--primary-color); +} + +.commentary-entry-accent { + font-style: oblique; +} + .commentary-art { float: right; width: 30%; -- cgit 1.3.0-6-gf8a5 From c399b00ccea8280032e0576a99eab2d34a04355c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:17:15 -0400 Subject: content, client: use 'track-in-sidebar' for random link in nav --- src/static/client2.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) (limited to 'src/static') diff --git a/src/static/client2.js b/src/static/client2.js index 0ec052bd..b72933d0 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -187,6 +187,19 @@ function addRandomLinkListeners() { break; } + case 'track-in-sidebar': { + // Note that the container for track links may be
    or
      , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + /* Legacy links, for old versions * * of generateListRandomPageLinksGroupSection */ @@ -255,9 +268,7 @@ function addNavigationKeyPressListeners() { } else if (event.charCode === 'P'.charCodeAt(0)) { scriptedLinkInfo.previousNavLink?.click(); } else if (event.charCode === 'R'.charCodeAt(0)) { - if (ready) { - scriptedLinkInfo.randomNavLink?.click(); - } + scriptedLinkInfo.randomNavLink?.click(); } } }); -- cgit 1.3.0-6-gf8a5 From 43141f1fc41768679b63e154ac21203e928b17c7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:19:00 -0400 Subject: client, content: client2.js -> client3.js --- src/static/client2.js | 1492 ------------------------------------------------- src/static/client3.js | 1453 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1453 insertions(+), 1492 deletions(-) delete mode 100644 src/static/client2.js create mode 100644 src/static/client3.js (limited to 'src/static') diff --git a/src/static/client2.js b/src/static/client2.js deleted file mode 100644 index b72933d0..00000000 --- a/src/static/client2.js +++ /dev/null @@ -1,1492 +0,0 @@ -/* eslint-env browser */ - -// This is the JS file that gets loaded on the client! It's only really used for -// the random track feature right now - the idea is we only use it for stuff -// that cannot 8e done at static-site compile time, 8y its fundamentally -// ephemeral nature. - -import {getColors} from '../util/colors.js'; -import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; - -let albumData, artistData; -let officialAlbumData, fandomAlbumData, beyondAlbumData; - -let ready = false; - -const clientInfo = window.hsmusicClientInfo = Object.create(null); - -const clientSteps = { - getPageReferences: [], - addInternalListeners: [], - mutatePageContent: [], - initializeState: [], - addPageListeners: [], -}; - -// Localiz8tion nonsense ---------------------------------- - -const language = document.documentElement.getAttribute('lang'); - -let list; -if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { - const getFormat = (type) => { - const formatter = new Intl.ListFormat(language, {type}); - return formatter.format.bind(formatter); - }; - - list = { - conjunction: getFormat('conjunction'), - disjunction: getFormat('disjunction'), - unit: getFormat('unit'), - }; -} else { - // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. - // We use the same mock for every list 'cuz we don't have any of the - // necessary CLDR info to appropri8tely distinguish 8etween them. - const arbitraryMock = (array) => array.join(', '); - - list = { - conjunction: arbitraryMock, - disjunction: arbitraryMock, - unit: arbitraryMock, - }; -} - -// Miscellaneous helpers ---------------------------------- - -function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; - } -} - -function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); -} - -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - -// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to -// separ8te the tooling around that into common-shared code too. -const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); -const openAlbum = (d) => rebase(`album/${d}`); -const openTrack = (d) => rebase(`track/${d}`); -const openArtist = (d) => rebase(`artist/${d}`); - -// TODO: This should also use urlSpec. -function fetchData(type, directory) { - return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( - (res) => res.json() - ); -} - -// JS-based links ----------------------------------------- - -const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { - randomLinks: null, - revealLinks: null, - - nextLink: null, - previousLink: null, - randomLink: null, -}; - -function getScriptedLinkReferences() { - scriptedLinkInfo.randomLinks = - document.querySelectorAll('[data-random]'); - - scriptedLinkInfo.revealLinks = - document.getElementsByClassName('reveal'); - - scriptedLinkInfo.nextNavLink = - document.getElementById('next-button'); - - scriptedLinkInfo.previousNavLink = - document.getElementById('previous-button'); - - scriptedLinkInfo.randomNavLink = - document.getElementById('random-button'); -} - -function addRandomLinkListeners() { - for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } - - const tracks = albumData => - albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); - - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } - - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); - - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } - - case 'track-in-sidebar': { - // Note that the container for track links may be
        or
          , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } - - /* Legacy links, for old versions * - * of generateListRandomPageLinksGroupSection */ - - case 'album-in-official': - a.href = openAlbum(pick(officialAlbumData).directory); - break; - - case 'album-in-fandom': - a.href = openAlbum(pick(fandomAlbumData).directory); - break; - - case 'album-in-beyond': - a.href = openAlbum(pick(beyondAlbumData).directory); - break; - - /* End legacy links */ - - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; - - case 'track-in-official': - a.href = openTrack(getRefDirectory(pick(tracks(officialAlbumData)))); - break; - - case 'track-in-fandom': - a.href = openTrack(getRefDirectory(pick(tracks(fandomAlbumData)))); - break; - - case 'track-in-beyond': - a.href = openTrack(getRefDirectory(pick(tracks(beyondAlbumData)))); - break; - - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; - - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); - } -} - -function mutateNavigationLinkContent() { - const prependTitle = (el, prepend) => - el?.setAttribute('title', - (el.hasAttribute('title') - ? prepend + ' ' + el.getAttribute('title') - : prepend)); - - prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); - prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); - prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); -} - -function addNavigationKeyPressListeners() { - document.addEventListener('keypress', (event) => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - scriptedLinkInfo.nextNavLink?.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - scriptedLinkInfo.previousNavLink?.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - scriptedLinkInfo.randomNavLink?.click(); - } - } - }); -} - -function addRevealLinkClickListeners() { - for (const reveal of scriptedLinkInfo.revealLinks ?? []) { - reveal.addEventListener('click', (event) => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); - } - }); - } -} - -clientSteps.getPageReferences.push(getScriptedLinkReferences); -clientSteps.addPageListeners.push(addRandomLinkListeners); -clientSteps.addPageListeners.push(addNavigationKeyPressListeners); -clientSteps.addPageListeners.push(addRevealLinkClickListeners); -clientSteps.mutatePageContent.push(mutateNavigationLinkContent); - -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); - -for (const element of elements1) element.style.display = 'block'; - -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; - - const albumsInGroup = directory => - albumData - .filter(album => - album.groups.includes(`group:${directory}`)); - - officialAlbumData = albumsInGroup('official'); - fandomAlbumData = albumsInGroup('fandom'); - beyondAlbumData = albumsInGroup('beyond'); - - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; - - ready = true; - }); - -// Data & info card --------------------------------------- - -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; - -let fastHover = false; -let endFastHoverTimeout = null; - -function colorLink(a, color) { - console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); - return; - - // eslint-disable-next-line no-unreachable - const chroma = {}; - - if (color) { - const {primary, dim} = getColors(color, {chroma}); - a.style.setProperty('--primary-color', primary); - a.style.setProperty('--dim-color', dim); - } -} - -function link(a, type, {name, directory, color}) { - colorLink(a, color); - a.innerText = name; - a.href = getLinkHref(type, directory); -} - -function joinElements(type, elements) { - // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on - // strings. So instead, we'll pass the element's outer HTML's (which means - // the entire HTML of that element). - // - // That does mean this function returns a string, so always 8e sure to - // set innerHTML when using it (not appendChild). - - return list[type](elements.map((el) => el.outerHTML)); -} - -const infoCard = (() => { - const container = document.getElementById('info-card-container'); - - let cancelShow = false; - let hideTimeout = null; - let showing = false; - - container.addEventListener('mouseenter', cancelHide); - container.addEventListener('mouseleave', readyHide); - - function show(type, target) { - cancelShow = false; - - fetchData(type, target.dataset[type]).then((data) => { - // Manual DOM 'cuz we're laaaazy. - - if (cancelShow) { - return; - } - - showing = true; - - const rect = target.getBoundingClientRect(); - - container.style.setProperty('--primary-color', data.color); - - container.style.top = window.scrollY + rect.bottom + 'px'; - container.style.left = window.scrollX + rect.left + 'px'; - - // Use a short timeout to let a currently hidden (or not yet shown) - // info card teleport to the position set a8ove. (If it's currently - // shown, it'll transition to that position.) - setTimeout(() => { - container.classList.remove('hide'); - container.classList.add('show'); - }, 50); - - // 8asic details. - - const nameLink = container.querySelector('.info-card-name a'); - link(nameLink, 'track', data); - - const albumLink = container.querySelector('.info-card-album a'); - link(albumLink, 'album', data.album); - - const artistSpan = container.querySelector('.info-card-artists span'); - artistSpan.innerHTML = joinElements( - 'conjunction', - data.artists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - - const coverArtistParagraph = container.querySelector( - '.info-card-cover-artists' - ); - const coverArtistSpan = coverArtistParagraph.querySelector('span'); - if (data.coverArtists.length) { - coverArtistParagraph.style.display = 'block'; - coverArtistSpan.innerHTML = joinElements( - 'conjunction', - data.coverArtists.map(({artist}) => { - const a = document.createElement('a'); - a.href = getLinkHref('artist', artist.directory); - a.innerText = artist.name; - return a; - }) - ); - } else { - coverArtistParagraph.style.display = 'none'; - } - - // Cover art. - - const [containerNoReveal, containerReveal] = [ - container.querySelector('.info-card-art-container.no-reveal'), - container.querySelector('.info-card-art-container.reveal'), - ]; - - const [containerShow, containerHide] = data.cover.warnings.length - ? [containerReveal, containerNoReveal] - : [containerNoReveal, containerReveal]; - - containerHide.style.display = 'none'; - containerShow.style.display = 'block'; - - const img = containerShow.querySelector('.info-card-art'); - img.src = rebase(data.cover.paths.small, 'rebaseMedia'); - - const imgLink = containerShow.querySelector('a'); - colorLink(imgLink, data.color); - imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); - - if (containerShow === containerReveal) { - const cw = containerShow.querySelector('.info-card-art-warnings'); - cw.innerText = list.unit(data.cover.warnings); - - const reveal = containerShow.querySelector('.reveal'); - reveal.classList.remove('revealed'); - } - }); - } - - function hide() { - container.classList.remove('show'); - container.classList.add('hide'); - cancelShow = true; - showing = false; - } - - function readyHide() { - if (!hideTimeout && showing) { - hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); - } - } - - function cancelHide() { - if (hideTimeout) { - clearTimeout(hideTimeout); - hideTimeout = null; - } - } - - return { - show, - hide, - readyHide, - cancelHide, - }; -})(); - -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - -// Info cards are disa8led for now since they aren't quite ready for release, -// 8ut you can try 'em out 8y setting this localStorage flag! -// -// localStorage.tryInfoCards = true; -// -if (localStorage.tryInfoCards) { - addInfoCardLinkHandlers('track'); -} -*/ - -// Custom hash links -------------------------------------- - -const hashLinkInfo = clientInfo.hashLinkInfo = { - links: null, - hrefs: null, - targets: null, - - state: { - highlightedTarget: null, - scrollingAfterClick: false, - concludeScrollingStateInterval: null, - }, - - event: { - whenHashLinkClicked: [], - }, -}; - -function getHashLinkReferences() { - const info = hashLinkInfo; - - info.links = - Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); - - info.hrefs = - info.links - .map(link => link.getAttribute('href')); - - info.targets = - info.hrefs - .map(href => document.getElementById(href.slice(1))); - - filterMultipleArrays( - info.links, - info.hrefs, - info.targets, - (_link, _href, target) => target); -} - -function processScrollingAfterHashLinkClicked() { - const {state} = hashLinkInfo; - - if (state.concludeScrollingStateInterval) return; - - let lastScroll = window.scrollY; - state.scrollingAfterClick = true; - state.concludeScrollingStateInterval = setInterval(() => { - if (Math.abs(window.scrollY - lastScroll) < 10) { - clearInterval(state.concludeScrollingStateInterval); - state.scrollingAfterClick = false; - state.concludeScrollingStateInterval = null; - } else { - lastScroll = window.scrollY; - } - }, 200); -} - -function addHashLinkListeners() { - // Instead of defining a scroll offset (to account for the sticky heading) - // in JavaScript, we interface with the CSS property 'scroll-margin-top'. - // This lets the scroll offset be consolidated where it makes sense, and - // sets an appropriate offset when (re)loading a page with hash for free! - - const info = hashLinkInfo; - const {state, event} = info; - - for (const {hashLink, href, target} of stitchArrays({ - hashLink: info.links, - href: info.hrefs, - target: info.targets, - })) { - hashLink.addEventListener('click', evt => { - if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { - return; - } - - // Hide skipper box right away, so the layout is updated on time for the - // math operations coming up next. - const skipper = document.getElementById('skippers'); - skipper.style.display = 'none'; - setTimeout(() => skipper.style.display = ''); - - const box = target.getBoundingClientRect(); - const style = window.getComputedStyle(target); - - const scrollY = - window.scrollY - + box.top - - style['scroll-margin-top'].replace('px', ''); - - evt.preventDefault(); - history.pushState({}, '', href); - window.scrollTo({top: scrollY, behavior: 'smooth'}); - target.focus({preventScroll: true}); - - const maxScroll = - document.body.scrollHeight - - window.innerHeight; - - if (scrollY > maxScroll && target.classList.contains('content-heading')) { - if (state.highlightedTarget) { - state.highlightedTarget.classList.remove('highlight-hash-link'); - } - - target.classList.add('highlight-hash-link'); - state.highlightedTarget = target; - } - - processScrollingAfterHashLinkClicked(); - - for (const handler of event.whenHashLinkClicked) { - handler({ - link: hashLink, - }); - } - }); - } - - for (const target of info.targets) { - target.addEventListener('animationend', evt => { - if (evt.animationName !== 'highlight-hash-link') return; - target.classList.remove('highlight-hash-link'); - if (target !== state.highlightedTarget) return; - state.highlightedTarget = null; - }); - } -} - -clientSteps.getPageReferences.push(getHashLinkReferences); -clientSteps.addPageListeners.push(addHashLinkListeners); - -// Sticky content heading --------------------------------- - -const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { - stickyContainers: null, - - stickySubheadingRows: null, - stickySubheadings: null, - - stickyCoverContainers: null, - stickyCoverTextAreas: null, - stickyCovers: null, - - contentContainers: null, - contentHeadings: null, - contentCovers: null, - contentCoversReveal: null, - - state: { - displayedHeading: null, - }, - - event: { - whenDisplayedHeadingChanges: [], - }, -}; - -function getStickyHeadingReferences() { - const info = stickyHeadingInfo; - - info.stickyContainers = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); - - info.stickyCoverContainers = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-heading-cover-container')); - - info.stickyCovers = - info.stickyCoverContainers - .map(el => el?.querySelector('.content-sticky-heading-cover')); - - info.stickyCoverTextAreas = - info.stickyCovers - .map(el => el?.querySelector('.image-text-area')); - - info.stickySubheadingRows = - info.stickyContainers - .map(el => el.querySelector('.content-sticky-subheading-row')); - - info.stickySubheadings = - info.stickySubheadingRows - .map(el => el.querySelector('h2')); - - info.contentContainers = - info.stickyContainers - .map(el => el.parentElement); - - info.contentCovers = - info.contentContainers - .map(el => el.querySelector('#cover-art-container')); - - info.contentCoversReveal = - info.contentCovers - .map(el => el ? !!el.querySelector('.reveal') : null); - - info.contentHeadings = - info.contentContainers - .map(el => Array.from(el.querySelectorAll('.content-heading'))); -} - -function removeTextPlaceholderStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const hasTextArea = - info.stickyCoverTextAreas.map(el => !!el); - - const coverContainersWithTextArea = - info.stickyCoverContainers - .filter((_el, index) => hasTextArea[index]); - - for (const el of coverContainersWithTextArea) { - el.remove(); - } - - info.stickyCoverContainers = - info.stickyCoverContainers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCovers = - info.stickyCovers - .map((el, index) => hasTextArea[index] ? null : el); - - info.stickyCoverTextAreas = - info.stickyCoverTextAreas - .slice() - .fill(null); -} - -function addRevealClassToStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCoversWhichReveal = - info.stickyCovers - .filter((_el, index) => info.contentCoversReveal[index]); - - for (const el of stickyCoversWhichReveal) { - el.classList.add('content-sticky-heading-cover-needs-reveal'); - } -} - -function addRevealListenersForStickyHeadingCovers() { - const info = stickyHeadingInfo; - - const stickyCovers = info.stickyCovers.slice(); - const contentCovers = info.contentCovers.slice(); - - filterMultipleArrays( - stickyCovers, - contentCovers, - (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); - - for (const {stickyCover, contentCover} of stitchArrays({ - stickyCover: stickyCovers, - contentCover: contentCovers, - })) { - // TODO: Janky - should use internal event instead of DOM event - contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { - stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); - }); - } -} - -function topOfViewInside(el, scroll = window.scrollY) { - return ( - scroll > el.offsetTop && - scroll < el.offsetTop + el.offsetHeight); -} - -function updateStickyCoverVisibility(index) { - const info = stickyHeadingInfo; - - const stickyCoverContainer = info.stickyCoverContainers[index]; - const contentCover = info.contentCovers[index]; - - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 0) { - stickyCoverContainer.classList.add('visible'); - } else { - stickyCoverContainer.classList.remove('visible'); - } - } -} - -function getContentHeadingClosestToStickySubheading(index) { - const info = stickyHeadingInfo; - - const contentContainer = info.contentContainers[index]; - - if (!topOfViewInside(contentContainer)) { - return null; - } - - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); - - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.getBoundingClientRect(); - - const stickyBottom = stickyRect.bottom + subheadingRect.height; - - // Iterate from bottom to top of the content area. - const contentHeadings = info.contentHeadings[index]; - for (const heading of contentHeadings.slice().reverse()) { - const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { - return heading; - } - } - - return null; -} - -function updateStickySubheadingContent(index) { - const info = stickyHeadingInfo; - const {event, state} = info; - - const closestHeading = getContentHeadingClosestToStickySubheading(index); - - if (state.displayedHeading === closestHeading) return; - - const stickySubheadingRow = info.stickySubheadingRows[index]; - - if (closestHeading) { - const stickySubheading = info.stickySubheadings[index]; - - // Array.from needed to iterate over a live array with for..of - for (const child of Array.from(stickySubheading.childNodes)) { - child.remove(); - } - - for (const child of closestHeading.childNodes) { - if (child.classList?.contains('content-heading-accent')) { - continue; - } - - if (child.tagName === 'A') { - for (const grandchild of child.childNodes) { - stickySubheading.appendChild(grandchild.cloneNode(true)); - } - } else { - stickySubheading.appendChild(child.cloneNode(true)); - } - } - - stickySubheadingRow.classList.add('visible'); - } else { - stickySubheadingRow.classList.remove('visible'); - } - - const oldDisplayedHeading = state.displayedHeading; - - state.displayedHeading = closestHeading; - - for (const handler of event.whenDisplayedHeadingChanges) { - handler(index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); - } -} - -function updateStickyHeadings(index) { - updateStickyCoverVisibility(index); - updateStickySubheadingContent(index); -} - -function initializeStateForStickyHeadings() { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } -} - -function addScrollListenerForStickyHeadings() { - document.addEventListener('scroll', () => { - for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { - updateStickyHeadings(i); - } - }); -} - -clientSteps.getPageReferences.push(getStickyHeadingReferences); -clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); -clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); -clientSteps.initializeState.push(initializeStateForStickyHeadings); -clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); -clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); - -// Image overlay ------------------------------------------ - -function addImageOverlayClickHandlers() { - const container = document.getElementById('image-overlay-container'); - - if (!container) { - console.warn(`#image-overlay-container missing, image overlay module disabled.`); - return; - } - - for (const link of document.querySelectorAll('.image-link')) { - if (link.querySelector('img').hasAttribute('data-no-image-preview')) { - continue; - } - - link.addEventListener('click', handleImageLinkClicked); - } - - const actionContainer = document.getElementById('image-overlay-action-container'); - - container.addEventListener('click', handleContainerClicked); - document.body.addEventListener('keydown', handleKeyDown); - - function handleContainerClicked(evt) { - // Only hide the image overlay if actually clicking the background. - if (evt.target !== container) { - return; - } - - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. - const rect = actionContainer.getBoundingClientRect(); - if (evt.clientY >= rect.top - 40) { - return; - } - - container.classList.remove('visible'); - } - - function handleKeyDown(evt) { - if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { - container.classList.remove('visible'); - } - } -} - -function handleImageLinkClicked(evt) { - if (evt.metaKey || evt.shiftKey || evt.altKey) { - return; - } - evt.preventDefault(); - - const container = document.getElementById('image-overlay-container'); - container.classList.add('visible'); - container.classList.remove('loaded'); - container.classList.remove('errored'); - - const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); - const mainImage = document.getElementById('image-overlay-image'); - const thumbImage = document.getElementById('image-overlay-image-thumb'); - - const {href: originalSrc} = evt.target.closest('a'); - - const { - src: embeddedSrc, - dataset: { - originalSize: originalFileSize, - thumbs: availableThumbList, - }, - } = evt.target.closest('a').querySelector('img'); - - updateFileSizeInformation(originalFileSize); - - let mainSrc = null; - let thumbSrc = null; - - if (availableThumbList) { - const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); - const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); - thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); - // Show the thumbnail size on each element's data attributes. - // Y'know, just for debugging convenience. - mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; - thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; - } else { - mainSrc = originalSrc; - thumbSrc = null; - mainImage.dataset.displayingThumb = ''; - thumbImage.dataset.displayingThumb = ''; - } - - if (thumbSrc) { - thumbImage.src = thumbSrc; - thumbImage.style.display = null; - } else { - thumbImage.src = ''; - thumbImage.style.display = 'none'; - } - - for (const viewOriginal of allViewOriginal) { - viewOriginal.href = originalSrc; - } - - mainImage.addEventListener('load', handleMainImageLoaded); - mainImage.addEventListener('error', handleMainImageErrored); - - container.style.setProperty('--download-progress', '0%'); - loadImage(mainSrc, progress => { - container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); - }).then( - blobUrl => { - mainImage.src = blobUrl; - container.style.setProperty('--download-progress', '100%'); - }, - handleMainImageErrored); - - function handleMainImageLoaded() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('loaded'); - } - - function handleMainImageErrored() { - mainImage.removeEventListener('load', handleMainImageLoaded); - mainImage.removeEventListener('error', handleMainImageErrored); - container.classList.add('errored'); - } -} - -function parseThumbList(availableThumbList) { - // Parse all the available thumbnail sizes! These are provided by the actual - // content generation on each image. - const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' - const availableSizes = - (availableThumbList || defaultThumbList) - .split(' ') - .map(part => part.split(':')) - .map(([thumb, length]) => ({thumb, length: parseInt(length)})) - .sort((a, b) => a.length - b.length); - - return availableSizes; -} - -function getPreferredThumbSize(availableThumbList) { - // Assuming a square, the image will be constrained to the lesser window - // dimension. Coefficient here matches CSS dimensions for image overlay. - const constrainedLength = Math.floor(Math.min( - 0.80 * window.innerWidth, - 0.80 * window.innerHeight)); - - // Match device pixel ratio, which is 2x for "retina" displays and certain - // device configurations. - const visualLength = window.devicePixelRatio * constrainedLength; - - const availableSizes = parseThumbList(availableThumbList); - - // Starting from the smallest dimensions, find (and return) the first - // available length which hits a "good enough" threshold - it's got to be - // at least that percent of the way to the actual displayed dimensions. - const goodEnoughThreshold = 0.90; - - // (The last item is skipped since we'd be falling back to it anyway.) - for (const {thumb, length} of availableSizes.slice(0, -1)) { - if (Math.floor(visualLength * goodEnoughThreshold) <= length) { - return {thumb, length}; - } - } - - // If none of the items in the list were big enough to hit the "good enough" - // threshold, just use the largest size available. - return availableSizes[availableSizes.length - 1]; -} - -function getSmallestThumbSize(availableThumbList) { - // Just snag the smallest size. This'll be used for displaying the "preview" - // as the bigger one is loading. - const availableSizes = parseThumbList(availableThumbList); - return availableSizes[0]; -} - -function updateFileSizeInformation(fileSize) { - const fileSizeWarningThreshold = 8 * 10 ** 6; - - const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); - const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); - - if (!fileSize) { - actionContentWithSize.classList.remove('visible'); - actionContentWithoutSize.classList.add('visible'); - return; - } - - actionContentWithoutSize.classList.remove('visible'); - actionContentWithSize.classList.add('visible'); - - const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); - const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); - const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); - const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); - const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); - - fileSize = parseInt(fileSize); - const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; - - if (fileSize > fileSizeWarningThreshold) { - fileSizeWarning.classList.add('visible'); - } else { - fileSizeWarning.classList.remove('visible'); - } - - if (fileSize > 10 ** 6) { - megabytesContainer.classList.add('visible'); - kilobytesContainer.classList.remove('visible'); - megabytesContent.innerText = round(6); - } else { - megabytesContainer.classList.remove('visible'); - kilobytesContainer.classList.add('visible'); - kilobytesContent.innerText = round(3); - } - - void fileSizeWarning; -} - -addImageOverlayClickHandlers(); - -/** - * Credits: Parziphal, Feb 13, 2017 - * https://stackoverflow.com/a/42196770 - * - * Loads an image with progress callback. - * - * The `onprogress` callback will be called by XMLHttpRequest's onprogress - * event, and will receive the loading progress ratio as an whole number. - * However, if it's not possible to compute the progress ratio, `onprogress` - * will be called only once passing -1 as progress value. This is useful to, - * for example, change the progress animation to an undefined animation. - * - * @param {string} imageUrl The image to load - * @param {Function} onprogress - * @return {Promise} - */ -function loadImage(imageUrl, onprogress) { - return new Promise((resolve, reject) => { - var xhr = new XMLHttpRequest(); - var notifiedNotComputable = false; - - xhr.open('GET', imageUrl, true); - xhr.responseType = 'arraybuffer'; - - xhr.onprogress = function(ev) { - if (ev.lengthComputable) { - onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); - } else { - if (!notifiedNotComputable) { - notifiedNotComputable = true; - onprogress(-1); - } - } - } - - xhr.onloadend = function() { - if (!xhr.status.toString().match(/^2/)) { - reject(xhr); - } else { - if (!notifiedNotComputable) { - onprogress(100); - } - - var options = {} - var headers = xhr.getAllResponseHeaders(); - var m = headers.match(/^Content-Type:\s*(.*?)$/mi); - - if (m && m[1]) { - options.type = m[1]; - } - - var blob = new Blob([this.response], options); - - resolve(window.URL.createObjectURL(blob)); - } - } - - xhr.send(); - }); -} - -// Group contributions table ------------------------------ - -const groupContributionsTableInfo = - Array.from(document.querySelectorAll('#content dl')) - .filter(dl => dl.querySelector('a.group-contributions-sort-button')) - .map(dl => ({ - sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), - sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), - sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), - sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), - })); - -function sortGroupContributionsTableBy(info, sort) { - const [showThese, hideThese] = - (sort === 'count' - ? [info.sortingByCountElements, info.sortingByDurationElements] - : [info.sortingByDurationElements, info.sortingByCountElements]); - - for (const element of showThese) element.classList.add('visible'); - for (const element of hideThese) element.classList.remove('visible'); -} - -for (const info of groupContributionsTableInfo) { - info.sortingByCountLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'duration'); - }); - - info.sortingByDurationLink.addEventListener('click', evt => { - evt.preventDefault(); - sortGroupContributionsTableBy(info, 'count'); - }); -} - -// Sticky commentary sidebar ------------------------------ - -const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { - sidebar: null, - - sidebarTrackLinks: null, - sidebarTrackDirectories: null, - - sidebarTrackSections: null, - sidebarTrackSectionStartIndices: null, - - state: { - currentTrackSection: null, - currentTrackLink: null, - justChangedTrackSection: false, - }, -}; - -function getAlbumCommentarySidebarReferences() { - const info = albumCommentarySidebarInfo; - - info.sidebar = - document.getElementById('sidebar-left'); - - info.sidebarHeading = - info.sidebar.querySelector('h1'); - - info.sidebarTrackLinks = - Array.from(info.sidebar.querySelectorAll('li a')); - - info.sidebarTrackDirectories = - info.sidebarTrackLinks - .map(el => el.getAttribute('href')?.slice(1) ?? null); - - info.sidebarTrackSections = - Array.from(info.sidebar.getElementsByTagName('details')); - - info.sidebarTrackSectionStartIndices = - info.sidebarTrackSections - .map(details => details.querySelector('ol, ul')) - .reduce( - (accumulator, _list, index, array) => - (empty(accumulator) - ? [0] - : [ - ...accumulator, - (accumulator[accumulator.length - 1] + - array[index - 1].querySelectorAll('li a').length), - ]), - []); -} - -function scrollAlbumCommentarySidebar() { - const info = albumCommentarySidebarInfo; - const {state} = info; - const {currentTrackLink, currentTrackSection} = state; - - if (!currentTrackLink) { - return; - } - - const {sidebar, sidebarHeading} = info; - - const scrollTop = sidebar.scrollTop; - - const headingRect = sidebarHeading.getBoundingClientRect(); - const sidebarRect = sidebar.getBoundingClientRect(); - - const stickyPadding = headingRect.height; - const sidebarViewportHeight = sidebarRect.height - stickyPadding; - - const linkRect = currentTrackLink.getBoundingClientRect(); - const sectionRect = currentTrackSection.getBoundingClientRect(); - - const sectionTopEdge = - sectionRect.top - (sidebarRect.top - scrollTop); - - const sectionHeight = - sectionRect.height; - - const sectionScrollTop = - sectionTopEdge - stickyPadding - 10; - - const linkTopEdge = - linkRect.top - (sidebarRect.top - scrollTop); - - const linkBottomEdge = - linkRect.bottom - (sidebarRect.top - scrollTop); - - const linkScrollTop = - linkTopEdge - stickyPadding - 5; - - const linkDistanceFromSection = - linkScrollTop - sectionTopEdge; - - const linkVisibleFromTopOfSection = - linkBottomEdge - sectionTopEdge > sidebarViewportHeight; - - const linkScrollBottom = - linkScrollTop - sidebarViewportHeight + linkRect.height + 20; - - const maxScrollInViewport = - scrollTop + stickyPadding + sidebarViewportHeight; - - const minScrollInViewport = - scrollTop + stickyPadding; - - if (linkBottomEdge > maxScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (linkTopEdge < minScrollInViewport) { - if (linkVisibleFromTopOfSection) { - sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); - } else { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } else if (state.justChangedTrackSection) { - if (sectionHeight < sidebarViewportHeight) { - sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); - } - } -} - -function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { - const info = albumCommentarySidebarInfo; - const {state} = info; - - const trackIndex = - (trackDirectory - ? info.sidebarTrackDirectories - .indexOf(trackDirectory) - : -1); - - const sectionIndex = - (trackIndex >= 0 - ? info.sidebarTrackSectionStartIndices - .findIndex((start, index, array) => - (index === array.length - 1 - ? true - : trackIndex < array[index + 1])) - : -1); - - const sidebarTrackLink = - (trackIndex >= 0 - ? info.sidebarTrackLinks[trackIndex] - : null); - - const sidebarTrackSection = - (sectionIndex >= 0 - ? info.sidebarTrackSections[sectionIndex] - : null); - - state.currentTrackLink?.classList?.remove('current'); - state.currentTrackLink = sidebarTrackLink; - state.currentTrackLink?.classList?.add('current'); - - if (sidebarTrackSection !== state.currentTrackSection) { - if (sidebarTrackSection && !sidebarTrackSection.open) { - if (state.currentTrackSection) { - state.currentTrackSection.open = false; - } - - sidebarTrackSection.open = true; - } - - state.currentTrackSection?.classList?.remove('current'); - state.currentTrackSection = sidebarTrackSection; - state.currentTrackSection?.classList?.add('current'); - state.justChangedTrackSection = true; - } else { - state.justChangedTrackSection = false; - } -} - -function addAlbumCommentaryInternalListeners() { - const info = albumCommentarySidebarInfo; - - const mainContentIndex = - (stickyHeadingInfo.contentContainers ?? []) - .findIndex(({id}) => id === 'content'); - - if (mainContentIndex === -1) return; - - stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { - if (index !== mainContentIndex) return; - if (hashLinkInfo.state.scrollingAfterClick) return; - - const trackDirectory = - (newHeading - ? newHeading.id - : null); - - markDirectoryAsCurrentForAlbumCommentary(trackDirectory); - scrollAlbumCommentarySidebar(); - }); - - hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { - const hash = link.getAttribute('href').slice(1); - if (!info.sidebarTrackDirectories.includes(hash)) return; - markDirectoryAsCurrentForAlbumCommentary(hash); - }); -} - -if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { - clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); - clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); -} - -// Run setup steps ---------------------------------------- - -for (const [key, steps] of Object.entries(clientSteps)) { - for (const step of steps) { - try { - step(); - } catch (error) { - console.warn(`During ${key}, failed to run ${step.name}`); - console.debug(error); - } - } -} diff --git a/src/static/client3.js b/src/static/client3.js new file mode 100644 index 00000000..94d4c4e2 --- /dev/null +++ b/src/static/client3.js @@ -0,0 +1,1453 @@ +/* eslint-env browser */ + +// This is the JS file that gets loaded on the client! It's only really used for +// the random track feature right now - the idea is we only use it for stuff +// that cannot 8e done at static-site compile time, 8y its fundamentally +// ephemeral nature. + +import {getColors} from '../util/colors.js'; +import {empty, stitchArrays} from '../util/sugar.js'; + +import { + filterMultipleArrays, + getArtistNumContributions, +} from '../util/wiki-data.js'; + +let albumData, artistData; + +let ready = false; + +const clientInfo = window.hsmusicClientInfo = Object.create(null); + +const clientSteps = { + getPageReferences: [], + addInternalListeners: [], + mutatePageContent: [], + initializeState: [], + addPageListeners: [], +}; + +// Localiz8tion nonsense ---------------------------------- + +const language = document.documentElement.getAttribute('lang'); + +let list; +if (typeof Intl === 'object' && typeof Intl.ListFormat === 'function') { + const getFormat = (type) => { + const formatter = new Intl.ListFormat(language, {type}); + return formatter.format.bind(formatter); + }; + + list = { + conjunction: getFormat('conjunction'), + disjunction: getFormat('disjunction'), + unit: getFormat('unit'), + }; +} else { + // Not a gr8 mock we've got going here, 8ut it's *mostly* language-free. + // We use the same mock for every list 'cuz we don't have any of the + // necessary CLDR info to appropri8tely distinguish 8etween them. + const arbitraryMock = (array) => array.join(', '); + + list = { + conjunction: arbitraryMock, + disjunction: arbitraryMock, + unit: arbitraryMock, + }; +} + +// Miscellaneous helpers ---------------------------------- + +function rebase(href, rebaseKey = 'rebaseLocalized') { + const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; + if (relative) { + return relative + href; + } else { + return href; + } +} + +function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +function cssProp(el, key) { + return getComputedStyle(el).getPropertyValue(key).trim(); +} + +function getRefDirectory(ref) { + return ref.split(':')[1]; +} + +function getAlbum(el) { + const directory = cssProp(el, '--album-directory'); + return albumData.find((album) => album.directory === directory); +} + +// TODO: These should pro8a8ly access some shared urlSpec path. We'd need to +// separ8te the tooling around that into common-shared code too. +const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); +const openAlbum = (d) => rebase(`album/${d}`); +const openTrack = (d) => rebase(`track/${d}`); +const openArtist = (d) => rebase(`artist/${d}`); + +// TODO: This should also use urlSpec. +function fetchData(type, directory) { + return fetch(rebase(`${type}/${directory}/data.json`, 'rebaseData')).then( + (res) => res.json() + ); +} + +// JS-based links ----------------------------------------- + +const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { + randomLinks: null, + revealLinks: null, + + nextLink: null, + previousLink: null, + randomLink: null, +}; + +function getScriptedLinkReferences() { + scriptedLinkInfo.randomLinks = + document.querySelectorAll('[data-random]'); + + scriptedLinkInfo.revealLinks = + document.getElementsByClassName('reveal'); + + scriptedLinkInfo.nextNavLink = + document.getElementById('next-button'); + + scriptedLinkInfo.previousNavLink = + document.getElementById('previous-button'); + + scriptedLinkInfo.randomNavLink = + document.getElementById('random-button'); +} + +function addRandomLinkListeners() { + for (const a of scriptedLinkInfo.randomLinks ?? []) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } + + const tracks = albumData => + albumData + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + + switch (a.dataset.random) { + case 'album': + a.href = openAlbum(pick(albumData).directory); + break; + + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + a.href = openAlbum(pick(albumDirectories)); + break; + } + + case 'track-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks.map(a => + getComputedStyle(a).getPropertyValue('--album-directory')); + + const filteredAlbumData = + albumData.filter(album => + albumDirectories.includes(album.directory)); + + a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); + break; + } + + case 'track-in-sidebar': { + // Note that the container for track links may be
            or
              , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); + + a.href = pick(trackLinks).href; + break; + } + + case 'track-in-album': + a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); + break; + + case 'artist': + a.href = openArtist(pick(artistData).directory); + break; + + case 'artist-more-than-one-contrib': + a.href = + openArtist( + pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) + .directory); + break; + } + }); + } +} + +function mutateNavigationLinkContent() { + const prependTitle = (el, prepend) => + el?.setAttribute('title', + (el.hasAttribute('title') + ? prepend + ' ' + el.getAttribute('title') + : prepend)); + + prependTitle(scriptedLinkInfo.nextNavLink, '(Shift+N)'); + prependTitle(scriptedLinkInfo.previousNavLink, '(Shift+P)'); + prependTitle(scriptedLinkInfo.randomNavLink, '(Shift+R)'); +} + +function addNavigationKeyPressListeners() { + document.addEventListener('keypress', (event) => { + if (event.shiftKey) { + if (event.charCode === 'N'.charCodeAt(0)) { + scriptedLinkInfo.nextNavLink?.click(); + } else if (event.charCode === 'P'.charCodeAt(0)) { + scriptedLinkInfo.previousNavLink?.click(); + } else if (event.charCode === 'R'.charCodeAt(0)) { + scriptedLinkInfo.randomNavLink?.click(); + } + } + }); +} + +function addRevealLinkClickListeners() { + for (const reveal of scriptedLinkInfo.revealLinks ?? []) { + reveal.addEventListener('click', (event) => { + if (!reveal.classList.contains('revealed')) { + reveal.classList.add('revealed'); + event.preventDefault(); + event.stopPropagation(); + reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); + } + }); + } +} + +clientSteps.getPageReferences.push(getScriptedLinkReferences); +clientSteps.addPageListeners.push(addRandomLinkListeners); +clientSteps.addPageListeners.push(addNavigationKeyPressListeners); +clientSteps.addPageListeners.push(addRevealLinkClickListeners); +clientSteps.mutatePageContent.push(mutateNavigationLinkContent); + +const elements1 = document.getElementsByClassName('js-hide-once-data'); +const elements2 = document.getElementsByClassName('js-show-once-data'); + +for (const element of elements1) element.style.display = 'block'; + +fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + albumData = data.albumData; + artistData = data.artistData; + + for (const element of elements1) element.style.display = 'none'; + for (const element of elements2) element.style.display = 'block'; + + ready = true; + }); + +// Data & info card --------------------------------------- + +/* +const NORMAL_HOVER_INFO_DELAY = 750; +const FAST_HOVER_INFO_DELAY = 250; +const END_FAST_HOVER_DELAY = 500; +const HIDE_HOVER_DELAY = 250; + +let fastHover = false; +let endFastHoverTimeout = null; + +function colorLink(a, color) { + console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); + return; + + // eslint-disable-next-line no-unreachable + const chroma = {}; + + if (color) { + const {primary, dim} = getColors(color, {chroma}); + a.style.setProperty('--primary-color', primary); + a.style.setProperty('--dim-color', dim); + } +} + +function link(a, type, {name, directory, color}) { + colorLink(a, color); + a.innerText = name; + a.href = getLinkHref(type, directory); +} + +function joinElements(type, elements) { + // We can't use the Intl APIs with elements, 8ecuase it only oper8tes on + // strings. So instead, we'll pass the element's outer HTML's (which means + // the entire HTML of that element). + // + // That does mean this function returns a string, so always 8e sure to + // set innerHTML when using it (not appendChild). + + return list[type](elements.map((el) => el.outerHTML)); +} + +const infoCard = (() => { + const container = document.getElementById('info-card-container'); + + let cancelShow = false; + let hideTimeout = null; + let showing = false; + + container.addEventListener('mouseenter', cancelHide); + container.addEventListener('mouseleave', readyHide); + + function show(type, target) { + cancelShow = false; + + fetchData(type, target.dataset[type]).then((data) => { + // Manual DOM 'cuz we're laaaazy. + + if (cancelShow) { + return; + } + + showing = true; + + const rect = target.getBoundingClientRect(); + + container.style.setProperty('--primary-color', data.color); + + container.style.top = window.scrollY + rect.bottom + 'px'; + container.style.left = window.scrollX + rect.left + 'px'; + + // Use a short timeout to let a currently hidden (or not yet shown) + // info card teleport to the position set a8ove. (If it's currently + // shown, it'll transition to that position.) + setTimeout(() => { + container.classList.remove('hide'); + container.classList.add('show'); + }, 50); + + // 8asic details. + + const nameLink = container.querySelector('.info-card-name a'); + link(nameLink, 'track', data); + + const albumLink = container.querySelector('.info-card-album a'); + link(albumLink, 'album', data.album); + + const artistSpan = container.querySelector('.info-card-artists span'); + artistSpan.innerHTML = joinElements( + 'conjunction', + data.artists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + + const coverArtistParagraph = container.querySelector( + '.info-card-cover-artists' + ); + const coverArtistSpan = coverArtistParagraph.querySelector('span'); + if (data.coverArtists.length) { + coverArtistParagraph.style.display = 'block'; + coverArtistSpan.innerHTML = joinElements( + 'conjunction', + data.coverArtists.map(({artist}) => { + const a = document.createElement('a'); + a.href = getLinkHref('artist', artist.directory); + a.innerText = artist.name; + return a; + }) + ); + } else { + coverArtistParagraph.style.display = 'none'; + } + + // Cover art. + + const [containerNoReveal, containerReveal] = [ + container.querySelector('.info-card-art-container.no-reveal'), + container.querySelector('.info-card-art-container.reveal'), + ]; + + const [containerShow, containerHide] = data.cover.warnings.length + ? [containerReveal, containerNoReveal] + : [containerNoReveal, containerReveal]; + + containerHide.style.display = 'none'; + containerShow.style.display = 'block'; + + const img = containerShow.querySelector('.info-card-art'); + img.src = rebase(data.cover.paths.small, 'rebaseMedia'); + + const imgLink = containerShow.querySelector('a'); + colorLink(imgLink, data.color); + imgLink.href = rebase(data.cover.paths.original, 'rebaseMedia'); + + if (containerShow === containerReveal) { + const cw = containerShow.querySelector('.info-card-art-warnings'); + cw.innerText = list.unit(data.cover.warnings); + + const reveal = containerShow.querySelector('.reveal'); + reveal.classList.remove('revealed'); + } + }); + } + + function hide() { + container.classList.remove('show'); + container.classList.add('hide'); + cancelShow = true; + showing = false; + } + + function readyHide() { + if (!hideTimeout && showing) { + hideTimeout = setTimeout(hide, HIDE_HOVER_DELAY); + } + } + + function cancelHide() { + if (hideTimeout) { + clearTimeout(hideTimeout); + hideTimeout = null; + } + } + + return { + show, + hide, + readyHide, + cancelHide, + }; +})(); + +function makeInfoCardLinkHandlers(type) { + let hoverTimeout = null; + + return { + mouseenter(evt) { + hoverTimeout = setTimeout( + () => { + fastHover = true; + infoCard.show(type, evt.target); + }, + fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); + + clearTimeout(endFastHoverTimeout); + endFastHoverTimeout = null; + + infoCard.cancelHide(); + }, + + mouseleave() { + clearTimeout(hoverTimeout); + + if (fastHover && !endFastHoverTimeout) { + endFastHoverTimeout = setTimeout(() => { + endFastHoverTimeout = null; + fastHover = false; + }, END_FAST_HOVER_DELAY); + } + + infoCard.readyHide(); + }, + }; +} + +const infoCardLinkHandlers = { + track: makeInfoCardLinkHandlers('track'), +}; + +function addInfoCardLinkHandlers(type) { + for (const a of document.querySelectorAll(`a[data-${type}]`)) { + for (const [eventName, handler] of Object.entries( + infoCardLinkHandlers[type] + )) { + a.addEventListener(eventName, handler); + } + } +} + +// Info cards are disa8led for now since they aren't quite ready for release, +// 8ut you can try 'em out 8y setting this localStorage flag! +// +// localStorage.tryInfoCards = true; +// +if (localStorage.tryInfoCards) { + addInfoCardLinkHandlers('track'); +} +*/ + +// Custom hash links -------------------------------------- + +const hashLinkInfo = clientInfo.hashLinkInfo = { + links: null, + hrefs: null, + targets: null, + + state: { + highlightedTarget: null, + scrollingAfterClick: false, + concludeScrollingStateInterval: null, + }, + + event: { + whenHashLinkClicked: [], + }, +}; + +function getHashLinkReferences() { + const info = hashLinkInfo; + + info.links = + Array.from(document.querySelectorAll('a[href^="#"]:not([href="#"])')); + + info.hrefs = + info.links + .map(link => link.getAttribute('href')); + + info.targets = + info.hrefs + .map(href => document.getElementById(href.slice(1))); + + filterMultipleArrays( + info.links, + info.hrefs, + info.targets, + (_link, _href, target) => target); +} + +function processScrollingAfterHashLinkClicked() { + const {state} = hashLinkInfo; + + if (state.concludeScrollingStateInterval) return; + + let lastScroll = window.scrollY; + state.scrollingAfterClick = true; + state.concludeScrollingStateInterval = setInterval(() => { + if (Math.abs(window.scrollY - lastScroll) < 10) { + clearInterval(state.concludeScrollingStateInterval); + state.scrollingAfterClick = false; + state.concludeScrollingStateInterval = null; + } else { + lastScroll = window.scrollY; + } + }, 200); +} + +function addHashLinkListeners() { + // Instead of defining a scroll offset (to account for the sticky heading) + // in JavaScript, we interface with the CSS property 'scroll-margin-top'. + // This lets the scroll offset be consolidated where it makes sense, and + // sets an appropriate offset when (re)loading a page with hash for free! + + const info = hashLinkInfo; + const {state, event} = info; + + for (const {hashLink, href, target} of stitchArrays({ + hashLink: info.links, + href: info.hrefs, + target: info.targets, + })) { + hashLink.addEventListener('click', evt => { + if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { + return; + } + + // Hide skipper box right away, so the layout is updated on time for the + // math operations coming up next. + const skipper = document.getElementById('skippers'); + skipper.style.display = 'none'; + setTimeout(() => skipper.style.display = ''); + + const box = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); + + const scrollY = + window.scrollY + + box.top + - style['scroll-margin-top'].replace('px', ''); + + evt.preventDefault(); + history.pushState({}, '', href); + window.scrollTo({top: scrollY, behavior: 'smooth'}); + target.focus({preventScroll: true}); + + const maxScroll = + document.body.scrollHeight + - window.innerHeight; + + if (scrollY > maxScroll && target.classList.contains('content-heading')) { + if (state.highlightedTarget) { + state.highlightedTarget.classList.remove('highlight-hash-link'); + } + + target.classList.add('highlight-hash-link'); + state.highlightedTarget = target; + } + + processScrollingAfterHashLinkClicked(); + + for (const handler of event.whenHashLinkClicked) { + handler({ + link: hashLink, + }); + } + }); + } + + for (const target of info.targets) { + target.addEventListener('animationend', evt => { + if (evt.animationName !== 'highlight-hash-link') return; + target.classList.remove('highlight-hash-link'); + if (target !== state.highlightedTarget) return; + state.highlightedTarget = null; + }); + } +} + +clientSteps.getPageReferences.push(getHashLinkReferences); +clientSteps.addPageListeners.push(addHashLinkListeners); + +// Sticky content heading --------------------------------- + +const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { + stickyContainers: null, + + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCovers: null, + contentCoversReveal: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + }, +}; + +function getStickyHeadingReferences() { + const info = stickyHeadingInfo; + + info.stickyContainers = + Array.from(document.getElementsByClassName('content-sticky-heading-container')); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.parentElement); + + info.contentCovers = + info.contentContainers + .map(el => el.querySelector('#cover-art-container')); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const info = stickyHeadingInfo; + + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStickyCoverVisibility(index) { + const info = stickyHeadingInfo; + + const stickyCoverContainer = info.stickyCoverContainers[index]; + const contentCover = info.contentCovers[index]; + + if (contentCover && stickyCoverContainer) { + if (contentCover.getBoundingClientRect().bottom < 0) { + stickyCoverContainer.classList.add('visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const info = stickyHeadingInfo; + + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickySubheading = info.stickySubheadings[index]; + + if (stickySubheading.childNodes.length === 0) { + // Supply a non-breaking space to ensure correct basic line height. + stickySubheading.appendChild(document.createTextNode('\xA0')); + } + + const stickyContainer = info.stickyContainers[index]; + const stickyRect = stickyContainer.getBoundingClientRect(); + + // TODO: Should this compute with the subheading row instead of h2? + const subheadingRect = stickySubheading.getBoundingClientRect(); + + const stickyBottom = stickyRect.bottom + subheadingRect.height; + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const info = stickyHeadingInfo; + const {event, state} = info; + + const closestHeading = getContentHeadingClosestToStickySubheading(index); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + for (const child of closestHeading.childNodes) { + if (child.classList?.contains('content-heading-accent')) { + continue; + } + + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + for (const handler of event.whenDisplayedHeadingChanges) { + handler(index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); + } +} + +function updateStickyHeadings(index) { + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +function initializeStateForStickyHeadings() { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < stickyHeadingInfo.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} + +clientSteps.getPageReferences.push(getStickyHeadingReferences); +clientSteps.mutatePageContent.push(removeTextPlaceholderStickyHeadingCovers); +clientSteps.mutatePageContent.push(addRevealClassToStickyHeadingCovers); +clientSteps.initializeState.push(initializeStateForStickyHeadings); +clientSteps.addPageListeners.push(addRevealListenersForStickyHeadingCovers); +clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); + +// Image overlay ------------------------------------------ + +function addImageOverlayClickHandlers() { + const container = document.getElementById('image-overlay-container'); + + if (!container) { + console.warn(`#image-overlay-container missing, image overlay module disabled.`); + return; + } + + for (const link of document.querySelectorAll('.image-link')) { + if (link.querySelector('img').hasAttribute('data-no-image-preview')) { + continue; + } + + link.addEventListener('click', handleImageLinkClicked); + } + + const actionContainer = document.getElementById('image-overlay-action-container'); + + container.addEventListener('click', handleContainerClicked); + document.body.addEventListener('keydown', handleKeyDown); + + function handleContainerClicked(evt) { + // Only hide the image overlay if actually clicking the background. + if (evt.target !== container) { + return; + } + + // If you clicked anything close to or beneath the action bar, don't hide + // the image overlay. + const rect = actionContainer.getBoundingClientRect(); + if (evt.clientY >= rect.top - 40) { + return; + } + + container.classList.remove('visible'); + } + + function handleKeyDown(evt) { + if (evt.key === 'Escape' || evt.key === 'Esc' || evt.keyCode === 27) { + container.classList.remove('visible'); + } + } +} + +function handleImageLinkClicked(evt) { + if (evt.metaKey || evt.shiftKey || evt.altKey) { + return; + } + evt.preventDefault(); + + const container = document.getElementById('image-overlay-container'); + container.classList.add('visible'); + container.classList.remove('loaded'); + container.classList.remove('errored'); + + const allViewOriginal = document.getElementsByClassName('image-overlay-view-original'); + const mainImage = document.getElementById('image-overlay-image'); + const thumbImage = document.getElementById('image-overlay-image-thumb'); + + const {href: originalSrc} = evt.target.closest('a'); + + const { + src: embeddedSrc, + dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }, + } = evt.target.closest('a').querySelector('img'); + + updateFileSizeInformation(originalFileSize); + + let mainSrc = null; + let thumbSrc = null; + + if (availableThumbList) { + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); + mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = originalSrc; + thumbSrc = null; + mainImage.dataset.displayingThumb = ''; + thumbImage.dataset.displayingThumb = ''; + } + + if (thumbSrc) { + thumbImage.src = thumbSrc; + thumbImage.style.display = null; + } else { + thumbImage.src = ''; + thumbImage.style.display = 'none'; + } + + for (const viewOriginal of allViewOriginal) { + viewOriginal.href = originalSrc; + } + + mainImage.addEventListener('load', handleMainImageLoaded); + mainImage.addEventListener('error', handleMainImageErrored); + + container.style.setProperty('--download-progress', '0%'); + loadImage(mainSrc, progress => { + container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); + }).then( + blobUrl => { + mainImage.src = blobUrl; + container.style.setProperty('--download-progress', '100%'); + }, + handleMainImageErrored); + + function handleMainImageLoaded() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('loaded'); + } + + function handleMainImageErrored() { + mainImage.removeEventListener('load', handleMainImageLoaded); + mainImage.removeEventListener('error', handleMainImageErrored); + container.classList.add('errored'); + } +} + +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { + // Assuming a square, the image will be constrained to the lesser window + // dimension. Coefficient here matches CSS dimensions for image overlay. + const constrainedLength = Math.floor(Math.min( + 0.80 * window.innerWidth, + 0.80 * window.innerHeight)); + + // Match device pixel ratio, which is 2x for "retina" displays and certain + // device configurations. + const visualLength = window.devicePixelRatio * constrainedLength; + + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. + const goodEnoughThreshold = 0.90; + + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } + } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; +} + +function updateFileSizeInformation(fileSize) { + const fileSizeWarningThreshold = 8 * 10 ** 6; + + const actionContentWithoutSize = document.getElementById('image-overlay-action-content-without-size'); + const actionContentWithSize = document.getElementById('image-overlay-action-content-with-size'); + + if (!fileSize) { + actionContentWithSize.classList.remove('visible'); + actionContentWithoutSize.classList.add('visible'); + return; + } + + actionContentWithoutSize.classList.remove('visible'); + actionContentWithSize.classList.add('visible'); + + const megabytesContainer = document.getElementById('image-overlay-file-size-megabytes'); + const kilobytesContainer = document.getElementById('image-overlay-file-size-kilobytes'); + const megabytesContent = megabytesContainer.querySelector('.image-overlay-file-size-count'); + const kilobytesContent = kilobytesContainer.querySelector('.image-overlay-file-size-count'); + const fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + + fileSize = parseInt(fileSize); + const round = (exp) => Math.round(fileSize / 10 ** (exp - 1)) / 10; + + if (fileSize > fileSizeWarningThreshold) { + fileSizeWarning.classList.add('visible'); + } else { + fileSizeWarning.classList.remove('visible'); + } + + if (fileSize > 10 ** 6) { + megabytesContainer.classList.add('visible'); + kilobytesContainer.classList.remove('visible'); + megabytesContent.innerText = round(6); + } else { + megabytesContainer.classList.remove('visible'); + kilobytesContainer.classList.add('visible'); + kilobytesContent.innerText = round(3); + } + + void fileSizeWarning; +} + +addImageOverlayClickHandlers(); + +/** + * Credits: Parziphal, Feb 13, 2017 + * https://stackoverflow.com/a/42196770 + * + * Loads an image with progress callback. + * + * The `onprogress` callback will be called by XMLHttpRequest's onprogress + * event, and will receive the loading progress ratio as an whole number. + * However, if it's not possible to compute the progress ratio, `onprogress` + * will be called only once passing -1 as progress value. This is useful to, + * for example, change the progress animation to an undefined animation. + * + * @param {string} imageUrl The image to load + * @param {Function} onprogress + * @return {Promise} + */ +function loadImage(imageUrl, onprogress) { + return new Promise((resolve, reject) => { + var xhr = new XMLHttpRequest(); + var notifiedNotComputable = false; + + xhr.open('GET', imageUrl, true); + xhr.responseType = 'arraybuffer'; + + xhr.onprogress = function(ev) { + if (ev.lengthComputable) { + onprogress(parseInt((ev.loaded / ev.total) * 1000) / 10); + } else { + if (!notifiedNotComputable) { + notifiedNotComputable = true; + onprogress(-1); + } + } + } + + xhr.onloadend = function() { + if (!xhr.status.toString().match(/^2/)) { + reject(xhr); + } else { + if (!notifiedNotComputable) { + onprogress(100); + } + + var options = {} + var headers = xhr.getAllResponseHeaders(); + var m = headers.match(/^Content-Type:\s*(.*?)$/mi); + + if (m && m[1]) { + options.type = m[1]; + } + + var blob = new Blob([this.response], options); + + resolve(window.URL.createObjectURL(blob)); + } + } + + xhr.send(); + }); +} + +// Group contributions table ------------------------------ + +const groupContributionsTableInfo = + Array.from(document.querySelectorAll('#content dl')) + .filter(dl => dl.querySelector('a.group-contributions-sort-button')) + .map(dl => ({ + sortingByCountLink: dl.querySelector('dt.group-contributions-sorted-by-count a.group-contributions-sort-button'), + sortingByDurationLink: dl.querySelector('dt.group-contributions-sorted-by-duration a.group-contributions-sort-button'), + sortingByCountElements: dl.querySelectorAll('.group-contributions-sorted-by-count'), + sortingByDurationElements: dl.querySelectorAll('.group-contributions-sorted-by-duration'), + })); + +function sortGroupContributionsTableBy(info, sort) { + const [showThese, hideThese] = + (sort === 'count' + ? [info.sortingByCountElements, info.sortingByDurationElements] + : [info.sortingByDurationElements, info.sortingByCountElements]); + + for (const element of showThese) element.classList.add('visible'); + for (const element of hideThese) element.classList.remove('visible'); +} + +for (const info of groupContributionsTableInfo) { + info.sortingByCountLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'duration'); + }); + + info.sortingByDurationLink.addEventListener('click', evt => { + evt.preventDefault(); + sortGroupContributionsTableBy(info, 'count'); + }); +} + +// Sticky commentary sidebar ------------------------------ + +const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { + sidebar: null, + + sidebarTrackLinks: null, + sidebarTrackDirectories: null, + + sidebarTrackSections: null, + sidebarTrackSectionStartIndices: null, + + state: { + currentTrackSection: null, + currentTrackLink: null, + justChangedTrackSection: false, + }, +}; + +function getAlbumCommentarySidebarReferences() { + const info = albumCommentarySidebarInfo; + + info.sidebar = + document.getElementById('sidebar-left'); + + info.sidebarHeading = + info.sidebar.querySelector('h1'); + + info.sidebarTrackLinks = + Array.from(info.sidebar.querySelectorAll('li a')); + + info.sidebarTrackDirectories = + info.sidebarTrackLinks + .map(el => el.getAttribute('href')?.slice(1) ?? null); + + info.sidebarTrackSections = + Array.from(info.sidebar.getElementsByTagName('details')); + + info.sidebarTrackSectionStartIndices = + info.sidebarTrackSections + .map(details => details.querySelector('ol, ul')) + .reduce( + (accumulator, _list, index, array) => + (empty(accumulator) + ? [0] + : [ + ...accumulator, + (accumulator[accumulator.length - 1] + + array[index - 1].querySelectorAll('li a').length), + ]), + []); +} + +function scrollAlbumCommentarySidebar() { + const info = albumCommentarySidebarInfo; + const {state} = info; + const {currentTrackLink, currentTrackSection} = state; + + if (!currentTrackLink) { + return; + } + + const {sidebar, sidebarHeading} = info; + + const scrollTop = sidebar.scrollTop; + + const headingRect = sidebarHeading.getBoundingClientRect(); + const sidebarRect = sidebar.getBoundingClientRect(); + + const stickyPadding = headingRect.height; + const sidebarViewportHeight = sidebarRect.height - stickyPadding; + + const linkRect = currentTrackLink.getBoundingClientRect(); + const sectionRect = currentTrackSection.getBoundingClientRect(); + + const sectionTopEdge = + sectionRect.top - (sidebarRect.top - scrollTop); + + const sectionHeight = + sectionRect.height; + + const sectionScrollTop = + sectionTopEdge - stickyPadding - 10; + + const linkTopEdge = + linkRect.top - (sidebarRect.top - scrollTop); + + const linkBottomEdge = + linkRect.bottom - (sidebarRect.top - scrollTop); + + const linkScrollTop = + linkTopEdge - stickyPadding - 5; + + const linkDistanceFromSection = + linkScrollTop - sectionTopEdge; + + const linkVisibleFromTopOfSection = + linkBottomEdge - sectionTopEdge > sidebarViewportHeight; + + const linkScrollBottom = + linkScrollTop - sidebarViewportHeight + linkRect.height + 20; + + const maxScrollInViewport = + scrollTop + stickyPadding + sidebarViewportHeight; + + const minScrollInViewport = + scrollTop + stickyPadding; + + if (linkBottomEdge > maxScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollBottom, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (linkTopEdge < minScrollInViewport) { + if (linkVisibleFromTopOfSection) { + sidebar.scrollTo({top: linkScrollTop, behavior: 'smooth'}); + } else { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } else if (state.justChangedTrackSection) { + if (sectionHeight < sidebarViewportHeight) { + sidebar.scrollTo({top: sectionScrollTop, behavior: 'smooth'}); + } + } +} + +function markDirectoryAsCurrentForAlbumCommentary(trackDirectory) { + const info = albumCommentarySidebarInfo; + const {state} = info; + + const trackIndex = + (trackDirectory + ? info.sidebarTrackDirectories + .indexOf(trackDirectory) + : -1); + + const sectionIndex = + (trackIndex >= 0 + ? info.sidebarTrackSectionStartIndices + .findIndex((start, index, array) => + (index === array.length - 1 + ? true + : trackIndex < array[index + 1])) + : -1); + + const sidebarTrackLink = + (trackIndex >= 0 + ? info.sidebarTrackLinks[trackIndex] + : null); + + const sidebarTrackSection = + (sectionIndex >= 0 + ? info.sidebarTrackSections[sectionIndex] + : null); + + state.currentTrackLink?.classList?.remove('current'); + state.currentTrackLink = sidebarTrackLink; + state.currentTrackLink?.classList?.add('current'); + + if (sidebarTrackSection !== state.currentTrackSection) { + if (sidebarTrackSection && !sidebarTrackSection.open) { + if (state.currentTrackSection) { + state.currentTrackSection.open = false; + } + + sidebarTrackSection.open = true; + } + + state.currentTrackSection?.classList?.remove('current'); + state.currentTrackSection = sidebarTrackSection; + state.currentTrackSection?.classList?.add('current'); + state.justChangedTrackSection = true; + } else { + state.justChangedTrackSection = false; + } +} + +function addAlbumCommentaryInternalListeners() { + const info = albumCommentarySidebarInfo; + + const mainContentIndex = + (stickyHeadingInfo.contentContainers ?? []) + .findIndex(({id}) => id === 'content'); + + if (mainContentIndex === -1) return; + + stickyHeadingInfo.event.whenDisplayedHeadingChanges.push((index, {newHeading}) => { + if (index !== mainContentIndex) return; + if (hashLinkInfo.state.scrollingAfterClick) return; + + const trackDirectory = + (newHeading + ? newHeading.id + : null); + + markDirectoryAsCurrentForAlbumCommentary(trackDirectory); + scrollAlbumCommentarySidebar(); + }); + + hashLinkInfo.event.whenHashLinkClicked.push(({link}) => { + const hash = link.getAttribute('href').slice(1); + if (!info.sidebarTrackDirectories.includes(hash)) return; + markDirectoryAsCurrentForAlbumCommentary(hash); + }); +} + +if (document.documentElement.dataset.urlKey === 'localized.albumCommentary') { + clientSteps.getPageReferences.push(getAlbumCommentarySidebarReferences); + clientSteps.addInternalListeners.push(addAlbumCommentaryInternalListeners); +} + +// Run setup steps ---------------------------------------- + +for (const [key, steps] of Object.entries(clientSteps)) { + for (const step of steps) { + try { + step(); + } catch (error) { + console.warn(`During ${key}, failed to run ${step.name}`); + console.debug(error); + } + } +} -- cgit 1.3.0-6-gf8a5 From 91f00a36b33d13630ea7a9ac6fcd03110b0f1a73 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:55:22 -0400 Subject: client: tidy & better isolate random links code in client info --- src/static/client3.js | 218 ++++++++++++++++++++++++++++---------------------- src/static/site5.css | 5 ++ 2 files changed, 126 insertions(+), 97 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 94d4c4e2..31eddfe9 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -13,10 +13,6 @@ import { getArtistNumContributions, } from '../util/wiki-data.js'; -let albumData, artistData; - -let ready = false; - const clientInfo = window.hsmusicClientInfo = Object.create(null); const clientSteps = { @@ -79,11 +75,6 @@ function getRefDirectory(ref) { return ref.split(':')[1]; } -function getAlbum(el) { - const directory = cssProp(el, '--album-directory'); - return albumData.find((album) => album.directory === directory); -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -107,6 +98,11 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { nextLink: null, previousLink: null, randomLink: null, + + state: { + albumData: null, + artistData: null, + }, }; function getScriptedLinkReferences() { @@ -128,93 +124,115 @@ function getScriptedLinkReferences() { function addRandomLinkListeners() { for (const a of scriptedLinkInfo.randomLinks ?? []) { - a.addEventListener('click', evt => { - if (!ready) { - evt.preventDefault(); - return; - } + a.addEventListener('click', domEvent => { + handleRandomLinkClicked(a, domEvent); + }); + } +} + +function handleRandomLinkClicked(a, domEvent) { + const href = determineRandomLinkHref(a); + + if (!href) { + domEvent.preventDefault(); + return; + } + + setTimeout(() => { + a.href = '#' + }); + + a.href = href; +} + +function determineRandomLinkHref(a) { + const {state} = scriptedLinkInfo; + const {albumData, artistData} = state; + + const tracksFromAlbums = albums => + albums + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); + + switch (a.dataset.random) { + case 'album': + if (!albumData) return null; + return openAlbum(pick(albumData).directory); + + case 'track': + if (!albumData) return null; + return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); - const tracks = albumData => + case 'album-in-group-dl': { + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + return openAlbum(pick(albumDirectories)); + } + + case 'track-in-group-dl': { + if (!albumData) return null; + + const albumLinks = + Array.from(a + .closest('dt') + .nextElementSibling + .querySelectorAll('li a')) + + const albumDirectories = + albumLinks + .map(a => cssProp(a, '--album-directory')); + + const filteredAlbumData = albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + .filter(album => + albumDirectories.includes(album.directory)); - setTimeout(() => { - a.href = rebase('js-disabled'); - }); - - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - case 'album-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) - - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); - - a.href = openAlbum(pick(albumDirectories)); - break; - } + return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + } - case 'track-in-group-dl': { - const albumLinks = - Array.from(a - .closest('dt') - .nextElementSibling - .querySelectorAll('li a')) + case 'track-in-sidebar': { + // Note that the container for track links may be
                or
                  , and + // they can't be identified by href, since links from one track to + // another don't include "track" in the href. + const trackLinks = + Array.from(document + .querySelector('.track-list-sidebar-box') + .querySelectorAll('li a')); - const albumDirectories = - albumLinks.map(a => - getComputedStyle(a).getPropertyValue('--album-directory')); + return pick(trackLinks).href; + } - const filteredAlbumData = - albumData.filter(album => - albumDirectories.includes(album.directory)); + case 'track-in-album': { + if (!albumData) return null; - a.href = openTrack(getRefDirectory(pick(tracks(filteredAlbumData)))); - break; - } + const directory = cssProp(a, '--album-directory'); + const {tracks} = albumData.find(album => album.directory === directory); - case 'track-in-sidebar': { - // Note that the container for track links may be
                    or
                      , and - // they can't be identified by href, since links from one track to - // another don't include "track" in the href. - const trackLinks = - Array.from(document - .querySelector('.track-list-sidebar-box') - .querySelectorAll('li a')); - - a.href = pick(trackLinks).href; - break; - } + return openTrack(getRefDirectory(pick(tracks))); + } + + case 'artist': { + if (!artistData) return null; + return openArtist(pick(artistData).directory); + } - case 'track-in-album': - a.href = openTrack(getRefDirectory(pick(getAlbum(a).tracks))); - break; + case 'artist-more-than-one-contrib': { + if (!artistData) return null; - case 'artist': - a.href = openArtist(pick(artistData).directory); - break; + const artists = + artistData + .filter(artist => getArtistNumContributions(artist) > 1); - case 'artist-more-than-one-contrib': - a.href = - openArtist( - pick(artistData.filter((artist) => getArtistNumContributions(artist) > 1)) - .directory); - break; - } - }); + return openArtist(pick(artists).directory); + } } } @@ -263,22 +281,28 @@ clientSteps.addPageListeners.push(addNavigationKeyPressListeners); clientSteps.addPageListeners.push(addRevealLinkClickListeners); clientSteps.mutatePageContent.push(mutateNavigationLinkContent); -const elements1 = document.getElementsByClassName('js-hide-once-data'); -const elements2 = document.getElementsByClassName('js-show-once-data'); +if ( + document.documentElement.dataset.urlKey === 'localized.listing' && + document.documentElement.dataset.urlValue0 === 'random' +) { + const dataLoadingLine = document.getElementById('data-loading-line'); + const dataLoadedLine = document.getElementById('data-loaded-line'); -for (const element of elements1) element.style.display = 'block'; + dataLoadingLine.style.display = 'block'; -fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { - albumData = data.albumData; - artistData = data.artistData; + fetch(rebase('data.json', 'rebaseShared')) + .then((data) => data.json()) + .then((data) => { + const {state} = scriptedLinkInfo; - for (const element of elements1) element.style.display = 'none'; - for (const element of elements2) element.style.display = 'block'; + state.albumData = data.albumData; + state.artistData = data.artistData; - ready = true; - }); + dataLoadingLine.style.display = 'none'; + dataLoadedLine.style.display = 'block'; + }); + +} // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 014e6d25..9111f3a6 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -792,6 +792,11 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { + display: none; +} + /* Images */ .image-container { -- cgit 1.3.0-6-gf8a5 From 28371f6e029fb86ba536bf4c20e787dba44d202b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 18:56:10 -0400 Subject: content, client: handle random data failing to load --- src/static/client3.js | 15 ++++++++++++++- src/static/site5.css | 7 ++++++- 2 files changed, 20 insertions(+), 2 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 31eddfe9..d2f2bd17 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -287,6 +287,7 @@ if ( ) { const dataLoadingLine = document.getElementById('data-loading-line'); const dataLoadedLine = document.getElementById('data-loaded-line'); + const dataErrorLine = document.getElementById('data-error-line'); dataLoadingLine.style.display = 'block'; @@ -300,8 +301,20 @@ if ( dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; - }); + }) + .catch(() => { + const info = scriptedLinkInfo; + + for (const a of info.randomLinks) { + const href = determineRandomLinkHref(a); + if (!href) { + a.removeAttribute('href'); + } + } + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; + }); } // Data & info card --------------------------------------- diff --git a/src/static/site5.css b/src/static/site5.css index 9111f3a6..bb83fe67 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -793,10 +793,15 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { } html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line { +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { display: none; } +html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { + opacity: 0.7; +} + /* Images */ .image-container { -- cgit 1.3.0-6-gf8a5 From 84c367c27ca82938c61b696ef24601d1cff9b2b3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 15 Nov 2023 19:16:52 -0400 Subject: client, write: data.json -> random-link-data.json --- src/static/client3.js | 120 +++++++++++++++++++++++++++----------------------- 1 file changed, 66 insertions(+), 54 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index d2f2bd17..8372a268 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -7,11 +7,7 @@ import {getColors} from '../util/colors.js'; import {empty, stitchArrays} from '../util/sugar.js'; - -import { - filterMultipleArrays, - getArtistNumContributions, -} from '../util/wiki-data.js'; +import {filterMultipleArrays} from '../util/wiki-data.js'; const clientInfo = window.hsmusicClientInfo = Object.create(null); @@ -71,10 +67,6 @@ function cssProp(el, key) { return getComputedStyle(el).getPropertyValue(key).trim(); } -function getRefDirectory(ref) { - return ref.split(':')[1]; -} - // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to // separ8te the tooling around that into common-shared code too. const getLinkHref = (type, directory) => rebase(`${type}/${directory}`); @@ -100,8 +92,10 @@ const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { randomLink: null, state: { - albumData: null, - artistData: null, + albumDirectories: null, + albumTrackDirectories: null, + artistDirectories: null, + artistNumContributions: null, }, }; @@ -147,21 +141,31 @@ function handleRandomLinkClicked(a, domEvent) { function determineRandomLinkHref(a) { const {state} = scriptedLinkInfo; - const {albumData, artistData} = state; - const tracksFromAlbums = albums => - albums - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + const trackDirectoriesFromAlbumDirectories = albumDirectories => + albumDirectories + .map(directory => state.albumDirectories.indexOf(directory)) + .map(index => state.albumTrackDirectories[index]) + .reduce((acc, trackDirectories) => acc.concat(trackDirectories, [])); switch (a.dataset.random) { - case 'album': - if (!albumData) return null; - return openAlbum(pick(albumData).directory); + case 'album': { + const {albumDirectories} = state; + if (!albumDirectories) return null; - case 'track': - if (!albumData) return null; - return openTrack(getRefDirectory(pick(tracksFromAlbums(albumData)))); + return openAlbum(pick(albumDirectories)); + } + + case 'track': { + const {albumDirectories} = state; + if (!albumDirectories) return null; + + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + albumDirectories); + + return openTrack(pick(trackDirectories)); + } case 'album-in-group-dl': { const albumLinks = @@ -170,15 +174,16 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - return openAlbum(pick(albumDirectories)); + return openAlbum(pick(listAlbumDirectories)); } case 'track-in-group-dl': { - if (!albumData) return null; + const {albumDirectories} = state; + if (!albumDirectories) return null; const albumLinks = Array.from(a @@ -186,16 +191,15 @@ function determineRandomLinkHref(a) { .nextElementSibling .querySelectorAll('li a')) - const albumDirectories = + const listAlbumDirectories = albumLinks .map(a => cssProp(a, '--album-directory')); - const filteredAlbumData = - albumData - .filter(album => - albumDirectories.includes(album.directory)); + const trackDirectories = + trackDirectoriesFromAlbumDirectories( + listAlbumDirectories); - return openTrack(getRefDirectory(pick(tracksFromAlbums(filteredAlbumData)))); + return openTrack(pick(trackDirectories)); } case 'track-in-sidebar': { @@ -211,27 +215,32 @@ function determineRandomLinkHref(a) { } case 'track-in-album': { - if (!albumData) return null; + const {albumDirectories, albumTrackDirectories} = state; + if (!albumDirectories || !albumTrackDirectories) return null; - const directory = cssProp(a, '--album-directory'); - const {tracks} = albumData.find(album => album.directory === directory); + const albumDirectory = cssProp(a, '--album-directory'); + const albumIndex = albumDirectories.indexOf(albumDirectory); + const trackDirectories = albumTrackDirectories[albumIndex]; - return openTrack(getRefDirectory(pick(tracks))); + return openTrack(pick(trackDirectories)); } case 'artist': { - if (!artistData) return null; - return openArtist(pick(artistData).directory); + const {artistDirectories} = state; + if (!artistDirectories) return null; + + return openArtist(pick(artistDirectories)); } case 'artist-more-than-one-contrib': { - if (!artistData) return null; + const {artistDirectories, artistNumContributions} = state; + if (!artistDirectories || !artistNumContributions) return null; - const artists = - artistData - .filter(artist => getArtistNumContributions(artist) > 1); + const filteredArtistDirectories = + artistDirectories + .filter((_artist, index) => artistNumContributions[index] > 1); - return openArtist(pick(artists).directory); + return openArtist(pick(filteredArtistDirectories)); } } } @@ -291,29 +300,32 @@ if ( dataLoadingLine.style.display = 'block'; - fetch(rebase('data.json', 'rebaseShared')) - .then((data) => data.json()) - .then((data) => { + fetch(rebase('random-link-data.json', 'rebaseShared')) + .then(data => data.json()) + .then(data => { const {state} = scriptedLinkInfo; - state.albumData = data.albumData; - state.artistData = data.artistData; + Object.assign(state, { + albumDirectories: data.albumDirectories, + albumTrackDirectories: data.albumTrackDirectories, + artistDirectories: data.artistDirectories, + artistNumContributions: data.artistNumContributions, + }); dataLoadingLine.style.display = 'none'; dataLoadedLine.style.display = 'block'; + }, () => { + dataLoadingLine.style.display = 'none'; + dataErrorLine.style.display = 'block'; }) - .catch(() => { - const info = scriptedLinkInfo; - - for (const a of info.randomLinks) { + .then(() => { + const {randomLinks} = scriptedLinkInfo; + for (const a of randomLinks) { const href = determineRandomLinkHref(a); if (!href) { a.removeAttribute('href'); } } - - dataLoadingLine.style.display = 'none'; - dataErrorLine.style.display = 'block'; }); } -- cgit 1.3.0-6-gf8a5 From 5c46ac058d7a09b6d129d612e6ea9d72df5d77cd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 7 Nov 2023 21:17:02 -0400 Subject: content, css: generateAlbumCommentaryPage: album listening links, etc --- src/static/site5.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index ba44ec37..3aa11f92 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1292,7 +1292,8 @@ html[data-url-key="localized.home"] .carousel-container { .content-heading .content-heading-accent { font-weight: normal; - font-size: 1rem; + font-style: oblique; + font-size: 0.9rem; margin-left: 0.25em; } -- cgit 1.3.0-6-gf8a5 From aec1daecb6f2ae60697b836ec80775edc171ebba Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:26:37 -0400 Subject: css: apply custom content-heading-accent style on specific page --- src/static/site5.css | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 3aa11f92..ccae29a0 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -803,6 +803,13 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } +html[data-url-key="localized.albumCommentary"] .content-heading .content-heading-accent { + font-weight: normal; + font-style: oblique; + font-size: 0.9rem; + margin-left: 0.25em; +} + html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { @@ -1290,13 +1297,6 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } -.content-heading .content-heading-accent { - font-weight: normal; - font-style: oblique; - font-size: 0.9rem; - margin-left: 0.25em; -} - h3.content-heading { clear: both; } -- cgit 1.3.0-6-gf8a5 From 69385cc2ca5d4a7691d6cb3726de5741de153a7c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:36:46 -0400 Subject: content, client: generateContentHeading: expose main title directly --- src/static/client3.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..6af548d9 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -899,11 +899,12 @@ function updateStickySubheadingContent(index) { child.remove(); } - for (const child of closestHeading.childNodes) { - if (child.classList?.contains('content-heading-accent')) { - continue; - } + const textContainer = + closestHeading.querySelector('.content-heading-main-title') + // Just for compatibility with older builds of the site. + ?? closestHeading; + for (const child of textContainer.childNodes) { if (child.tagName === 'A') { for (const grandchild of child.childNodes) { stickySubheading.appendChild(grandchild.cloneNode(true)); -- cgit 1.3.0-6-gf8a5 From d0716804ca0b547ca79f819c94f413a542b7e172 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 17:37:22 -0400 Subject: css: handle line wrapping in commentary heading accents better --- src/static/site5.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index ccae29a0..c47252f4 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -803,11 +803,15 @@ html[data-url-key="localized.albumCommentary"] li.no-commentary { opacity: 0.7; } -html[data-url-key="localized.albumCommentary"] .content-heading .content-heading-accent { +html[data-url-key="localized.albumCommentary"] .content-heading-main-title { + margin-right: 0.25em; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-accent { font-weight: normal; font-style: oblique; font-size: 0.9rem; - margin-left: 0.25em; + display: inline-block; } html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -- cgit 1.3.0-6-gf8a5 From 4eb923d78f3f735b8697697033543a27bad623d7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:36:30 -0400 Subject: css: add light shadow to commentary cover art --- src/static/site5.css | 1 + 1 file changed, 1 insertion(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index c47252f4..9ff3954e 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -553,6 +553,7 @@ p .current { width: 30%; max-width: 250px; margin: 15px 0 10px 20px; + box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); } .js-hide, -- cgit 1.3.0-6-gf8a5 From 1beb0d4712962ed943a18b9aeb81b231ad26691b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 14:45:14 -0400 Subject: css: tweak responsive long-content padding values & dynamics --- src/static/site5.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 9ff3954e..bfda0ba3 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -704,7 +704,7 @@ p code { } main.long-content { - --long-content-padding-ratio: 0.12; + --long-content-padding-ratio: 0.10; } main.long-content .main-content-container, @@ -1683,6 +1683,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content */ @media (min-width: 600px) and (max-width: 899.98px) { + /* Medim layout is mainly defined (to the user) by hiding the sidebar, so + * don't apply the similar layout change of widening the long-content area + * if this page doesn't have a sidebar to hide in the first place. + */ + #page-container:not(.has-zero-sidebars) main.long-content { + --long-content-padding-ratio: 0.06; + } } /* Layout - Wide or Medium */ @@ -1772,6 +1779,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content max-width: unset; } + main.long-content { + --long-content-padding-ratio: 0.02; + } + /* Show sticky heading above cover art */ .content-sticky-heading-container { @@ -1787,8 +1798,4 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content #header > div:not(:first-child) { margin-top: 0.5em; } - - main.long-content { - --long-content-padding-ratio: 0.04; - } } -- cgit 1.3.0-6-gf8a5 From fbcfb88ea407ab76d278c28f1f0711502ab2fbf9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 16 Nov 2023 17:38:17 -0400 Subject: content, css: quick visual fixes for commentary entries --- src/static/site5.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index ba44ec37..dd16cbf9 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -540,6 +540,7 @@ p .current { .commentary-entry-heading { margin-left: 15px; padding-left: 5px; + max-width: 625px; padding-bottom: 0.2em; border-bottom: 1px dotted var(--primary-color); } @@ -1700,7 +1701,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content float: right; width: 40%; max-width: 400px; - margin: -60px 0 10px 10px; + margin: -60px 0 10px 20px; position: relative; z-index: 2; -- cgit 1.3.0-6-gf8a5 From 45fb872e3c9db62da126c94c3219133b4945b532 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:23:58 -0400 Subject: css: allow custom scroll margin offset per-element --- src/static/site5.css | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index bb83fe67..6cdc0c35 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1310,6 +1310,10 @@ h3.content-heading { /* Sticky heading */ +[id] { + --custom-scroll-offset: 0px; +} + #content [id] { /* Adjust scroll margin. */ scroll-margin-top: calc( @@ -1317,6 +1321,7 @@ h3.content-heading { + 33px /* Sticky subheading */ - 1em /* One line of text (align bottom) */ - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ ); } -- cgit 1.3.0-6-gf8a5 From 7f56163ad123b3ba8da431630d3da67f5444adb6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:25:30 -0400 Subject: css: allow sticky heading text to occupy blank area in thin layout --- src/static/site5.css | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 6cdc0c35..ea27e35e 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1766,6 +1766,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + /* Disable grid features, just line header children up vertically */ #header { -- cgit 1.3.0-6-gf8a5 From 745eb45531afb233a49433b18e3095238eb41b84 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:22:15 -0400 Subject: client: internal beforeHashLinkScrolls listener --- src/static/client3.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..4a5dffc2 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -576,6 +576,7 @@ const hashLinkInfo = clientInfo.hashLinkInfo = { }, event: { + beforeHashLinkScrolls: [], whenHashLinkClicked: [], }, }; @@ -638,6 +639,21 @@ function addHashLinkListeners() { return; } + // Don't do anything if the target element isn't actually visible! + if (target.offsetParent === null) { + return; + } + + // Allow event handlers to prevent scrolling. + for (const handler of event.beforeHashLinkScrolls) { + if (handler({ + link: hashLink, + target, + }) === false) { + return; + } + } + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -675,6 +691,7 @@ function addHashLinkListeners() { for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, + target, }); } }); -- cgit 1.3.0-6-gf8a5 From bad238355e19c4fef5e5f3b41df88fa9b1b84aaa Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:31:45 -0400 Subject: content, client, css: generateAdditionalNamesBox --- src/static/client3.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/static/site5.css | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 4a5dffc2..94ba4a23 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1260,6 +1260,96 @@ function loadImage(imageUrl, onprogress) { }); } +// "Additional names" box --------------------------------- + +const additionalNamesBoxInfo = clientInfo.additionalNamesBox = { + box: null, + links: null, + mainContentContainer: null, + + state: { + visible: false, + }, +}; + +function getAdditionalNamesBoxReferences() { + const info = additionalNamesBoxInfo; + + info.box = + document.getElementById('additional-names-box'); + + info.links = + document.querySelectorAll('a[href="#additional-names-box"]'); + + info.mainContentContainer = + document.querySelector('#content .main-content-container'); +} + +function addAdditionalNamesBoxInternalListeners() { + const info = additionalNamesBoxInfo; + + hashLinkInfo.event.beforeHashLinkScrolls.push(({target}) => { + if (target === info.box) { + return false; + } + }); +} + +function addAdditionalNamesBoxListeners() { + const info = additionalNamesBoxInfo; + + for (const link of info.links) { + link.addEventListener('click', domEvent => { + handleAdditionalNamesBoxLinkClicked(domEvent); + }); + } +} + +function handleAdditionalNamesBoxLinkClicked(domEvent) { + const info = additionalNamesBoxInfo; + const {state} = info; + + domEvent.preventDefault(); + + if (!info.box || !info.mainContentContainer) return; + + const margin = + +(cssProp(info.box, 'scroll-margin-top').replace('px', '')); + + const {top} = + (state.visible + ? info.box.getBoundingClientRect() + : info.mainContentContainer.getBoundingClientRect()); + + if (top + 20 < margin || top > 0.4 * window.innerHeight) { + if (!state.visible) { + toggleAdditionalNamesBox(); + } + + window.scrollTo({ + top: window.scrollY + top - margin, + behavior: 'smooth', + }); + } else { + toggleAdditionalNamesBox(); + } +} + +function toggleAdditionalNamesBox() { + const info = additionalNamesBoxInfo; + const {state} = info; + + state.visible = !state.visible; + info.box.style.display = + (state.visible + ? 'block' + : 'none'); +} + +clientSteps.getPageReferences.push(getAdditionalNamesBoxReferences); +clientSteps.addInternalListeners.push(addAdditionalNamesBoxInternalListeners); +clientSteps.addPageListeners.push(addAdditionalNamesBoxListeners); + // Group contributions table ------------------------------ const groupContributionsTableInfo = diff --git a/src/static/site5.css b/src/static/site5.css index ea27e35e..31b2995b 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -802,6 +802,68 @@ html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not( opacity: 0.7; } +/* Additional names (heading and box) */ + +h1 a[href="#additional-names-box"] { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; +} + +h1 a[href="#additional-names-box"]:hover { + text-decoration-style: solid; +} + +#additional-names-box { + --custom-scroll-offset: calc(0.5em - 2px); + + margin: 1em 0 1em -10px; + padding: 15px 20px 10px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + + display: none; +} + +#additional-names-box > :first-child { margin-top: 0; } +#additional-names-box > :last-child { margin-bottom: 0; } + +#additional-names-box p { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 0; + font-style: oblique; +} + +#additional-names-box ul { + padding-left: 10px; + margin-top: 0.5em; +} + +#additional-names-box li .additional-name { + margin-right: 0.25em; +} + +#additional-names-box li .additional-name .content-image { + margin-bottom: 0.25em; + margin-top: 0.5em; +} + +#additional-names-box li .annotation { + opacity: 0.8; + display: inline-block; +} + /* Images */ .image-container { @@ -1760,6 +1822,10 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content max-width: unset; } + #additional-names-box { + max-width: unset; + } + /* Show sticky heading above cover art */ .content-sticky-heading-container { -- cgit 1.3.0-6-gf8a5 From c11edada828dc734cce6988e5819630a73326085 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 21 Jul 2023 20:06:32 -0300 Subject: content, test: linkContribution: tooltip icons --- src/static/client3.js | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/static/site5.css | 42 ++++++++++++++ 2 files changed, 192 insertions(+) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 8372a268..091d1fcf 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -958,6 +958,8 @@ clientSteps.addPageListeners.push(addScrollListenerForStickyHeadings); // Image overlay ------------------------------------------ +// TODO: Update to clientSteps style. + function addImageOverlayClickHandlers() { const container = document.getElementById('image-overlay-container'); @@ -1245,6 +1247,8 @@ function loadImage(imageUrl, onprogress) { // Group contributions table ------------------------------ +// TODO: Update to clientSteps style. + const groupContributionsTableInfo = Array.from(document.querySelectorAll('#content dl')) .filter(dl => dl.querySelector('a.group-contributions-sort-button')) @@ -1277,6 +1281,152 @@ for (const info of groupContributionsTableInfo) { }); } +// Artist link icon tooltips ------------------------------ + +// TODO: Update to clientSteps style. + +const linkIconTooltipInfo = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')) + .map(span => ({ + mainLink: span.querySelector('a'), + iconsContainer: span.querySelector('span.icons-tooltip'), + iconLinks: span.querySelectorAll('span.icons-tooltip a'), + })); + +for (const info of linkIconTooltipInfo) { + const focusElements = + [info.mainLink, ...info.iconLinks]; + + const hoverElements = + [info.mainLink, info.iconsContainer]; + + let hidden = true; + + const show = () => { + info.iconsContainer.classList.add('visible'); + info.iconsContainer.inert = false; + hidden = false; + }; + + const hide = () => { + info.iconsContainer.classList.remove('visible'); + info.iconsContainer.inert = true; + hidden = true; + }; + + const considerHiding = () => { + if (hoverElements.some(el => el.matches(':hover'))) { + return; + } + + if (focusElements.includes(document.activeElement)) { + return; + } + + if (justTouched) { + return; + } + + hide(); + }; + + // Hover (pointer) + + let hoverTimeout; + + info.mainLink.addEventListener('mouseenter', () => { + if (hidden) { + hoverTimeout = setTimeout(show, 250); + } + }); + + info.mainLink.addEventListener('mouseout', () => { + if (hidden) { + clearTimeout(hoverTimeout); + } else { + considerHiding(); + } + }); + + info.iconsContainer.addEventListener('mouseout', () => { + if (!hidden) { + considerHiding(); + } + }); + + // Focus (keyboard) + + let focusTimeout; + + info.mainLink.addEventListener('focus', () => { + focusTimeout = setTimeout(show, 750); + }); + + info.mainLink.addEventListener('blur', () => { + clearTimeout(focusTimeout); + }); + + info.iconsContainer.addEventListener('focusout', () => { + requestAnimationFrame(considerHiding); + }); + + info.mainLink.addEventListener('blur', () => { + requestAnimationFrame(considerHiding); + }); + + // Touch (finger) + + let justTouched = false; + let touchTimeout; + + info.mainLink.addEventListener('touchend', event => { + let wasTarget = false; + + for (const touch of event.changedTouches) { + if (touch.target === info.mainLink) { + wasTarget = true; + break; + } + } + + if (!wasTarget) { + return; + } + + justTouched = true; + + clearTimeout(touchTimeout); + touchTimeout = setTimeout(() => { + justTouched = false; + }, 250); + + show(); + }); + + info.mainLink.addEventListener('click', event => { + if (hidden && justTouched) { + event.preventDefault(); + event.target.focus(); + show(); + } + }); + + document.body.addEventListener('touchend', event => { + const touches = [...event.changedTouches, ...event.touches]; + for (const {clientX, clientY} of touches) { + const touchEl = document.elementFromPoint(clientX, clientY); + if (!touchEl) continue; + + for (const hoverEl of hoverElements) { + if (touchEl === hoverEl) return; + if (hoverEl.contains(touchEl)) return; + } + } + + hide(); + }); +} + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { diff --git a/src/static/site5.css b/src/static/site5.css index bb83fe67..06696799 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -427,6 +427,7 @@ a { a:hover { text-decoration: underline; + text-decoration-style: solid !important; } a.current { @@ -472,11 +473,52 @@ a:not([href]):hover { white-space: nowrap; } +.contribution { + position: relative; +} + +.contribution.has-tooltip a { + text-decoration: underline; + text-decoration-style: dotted; +} + .icons { font-style: normal; white-space: nowrap; } +.icons-tooltip { + position: absolute; + z-index: 999; + left: -12px; + top: calc(1em - 2px); + padding: 4px 12px 6px 8px; +} + +.icons-tooltip:not(.visible) { + display: none; +} + +.icons-tooltip-content { + display: block; + padding: 6px 2px 2px 2px; + background: var(--bg-black-color); + border: 1px dotted var(--primary-color); + border-radius: 4px; + + -webkit-user-select: none; + user-select: none; + cursor: default; +} + +.icons a:hover { + filter: brightness(1.4); +} + +.icons a { + padding: 0 3px; +} + .icon { display: inline-block; width: 24px; -- cgit 1.3.0-6-gf8a5 From 10140f5b90e0fa9b38cdacfa23b10d96fb6fd189 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:30:23 -0400 Subject: client: dispatchInternalEvent utility --- src/static/client3.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 091d1fcf..84a66e3b 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -81,6 +81,31 @@ function fetchData(type, directory) { ); } +function dispatchInternalEvent(event, eventName, ...args) { + const [infoName] = + Object.entries(clientInfo) + .find(pair => pair[1].event === event); + + if (!infoName) { + throw new Error(`Expected event to be stored on clientInfo`); + } + + const {[eventName]: listeners} = event; + + if (!listeners) { + throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); + } + + for (const listener of listeners) { + try { + listener(...args); + } catch (error) { + console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); + console.debug(error); + } + } +} + // JS-based links ----------------------------------------- const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { @@ -672,6 +697,8 @@ function addHashLinkListeners() { processScrollingAfterHashLinkClicked(); + dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink}); + for (const handler of event.whenHashLinkClicked) { handler({ link: hashLink, -- cgit 1.3.0-6-gf8a5 From c34da87fb949c7797a1f273264720798dc7341ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:32:08 -0400 Subject: client: add hoverable tooltip system, logic pulled from info cards While this system comprehensively covers everything that info cards did (which was generally smarter hovering logic than newer code for external icon tooltips), it isn't focus- and touch-capable yet, so isn't quite done within this commit. However, the interface this system provides to others is baked and fully implemented here. --- src/static/client3.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 8 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 84a66e3b..acd85880 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -354,17 +354,209 @@ if ( }); } -// Data & info card --------------------------------------- +// Tooltip-style hover (infrastructure) ------------------- -/* -const NORMAL_HOVER_INFO_DELAY = 750; -const FAST_HOVER_INFO_DELAY = 250; -const END_FAST_HOVER_DELAY = 500; -const HIDE_HOVER_DELAY = 250; +const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { + settings: { + normalHoverInfoDelay: 400, + fastHoveringInfoDelay: 150, + + endFastHoveringDelay: 500, + + hideTooltipDelay: 500, + }, + + state: { + // These maps store a record for each registered element and related state + // and registration info, if applicable. + registeredTooltips: new Map(), + registeredHoverables: new Map(), + + // These are common across all tooltips, rather than stored individually, + // based on the principles that 1) only a single tooltip can be displayed + // at once, and 2) only a single hoverable can be hovered at a once. + hoverTimeout: null, + hideTimeout: null, + currentlyShownTooltip: null, + + // Fast hovering is a global mode which is activated as soon as any tooltip + // is displayed and turns off after a delay of no hoverables being hovered. + // Note that fast hovering may be turned off while hovering a tooltip, but + // it will never be turned off while idling over a hoverable. + fastHovering: false, + endFastHoveringTimeout: false, + }, + + event: { + whenTooltipShouldBeShown: [], + whenTooltipShouldBeHidden: [], + }, +}; + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipElement(tooltip) { + const {state} = hoverableTooltipInfo; + + if (!tooltip) + throw new Error(`Expected tooltip`); + + if (state.registeredTooltips.has(tooltip)) + throw new Error(`This tooltip is already registered`); + + // No state or registration info here. + state.registeredTooltips.set(tooltip, {}); + + tooltip.addEventListener('mouseenter', () => { + handleTooltipMouseEntered(tooltip); + }); -let fastHover = false; -let endFastHoverTimeout = null; + tooltip.addEventListener('mouseleave', () => { + handleTooltipMouseLeft(tooltip); + }); +} + +// Adds DOM event listeners, so must be called during addPageListeners step. +function registerTooltipHoverableElement(hoverable, tooltip) { + const {state} = hoverableTooltipInfo; + + if (!hoverable || !tooltip) + if (hoverable) + throw new Error(`Expected hoverable and tooltip, got only hoverable`); + else + throw new Error(`Expected hoverable and tooltip, got neither`); + + if (!state.registeredTooltips.has(tooltip)) + throw new Error(`Register tooltip before registering hoverable`); + + if (state.registeredHoverables.has(hoverable)) + throw new Error(`This hoverable is already registered`); + + state.registeredHoverables.set(hoverable, {tooltip}); + + hoverable.addEventListener('mouseenter', () => { + handleTooltipHoverableMouseEntered(hoverable); + }); + + hoverable.addEventListener('mouseleave', () => { + handleTooltipHoverableMouseLeft(hoverable); + }); +} + +function handleTooltipMouseEntered(tooltip) { + const {state} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Don't time out the current tooltip while hovering it. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} +function handleTooltipMouseLeft(tooltip) { + const {state, settings} = hoverableTooltipInfo; + + if (state.currentlyShownTooltip !== tooltip) return; + + // Start timing out the current tooltip when it's left. This could be + // canceled by mousing over a hoverable, or back over the tooltip again. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function handleTooltipHoverableMouseEntered(hoverable) { + const {event, settings, state} = hoverableTooltipInfo; + + const hoverTimeoutDelay = + (state.fastHovering + ? settings.fastHoveringInfoDelay + : settings.normalHoverInfoDelay); + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. + state.hoverTimeout = + setTimeout(() => { + state.hoverTimeout = null; + state.fastHovering = true; + showTooltipFromHoverable(hoverable); + }, hoverTimeoutDelay); + + // Don't stop fast hovering while over any hoverable. + if (state.endFastHoveringTimeout) { + clearTimeout(state.endFastHoveringTimeout); + state.endFastHoveringTimeout = null; + } + + // Don't time out the current tooltip while over any hoverable. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipHoverableMouseLeft(hoverable) { + const {state, settings} = hoverableTooltipInfo; + + // Don't show a tooltip when not over a hoverable! + if (state.hoverTimeout) { + clearTimeout(state.hoverTimeout); + state.hoverTimeout = null; + } + + // Start timing out fast hovering (if active) when not over a hoverable. + // This will only be canceled by mousing over another hoverable. + if (state.fastHovering && !state.endFastHoveringTimeout) { + state.endFastHoveringTimeout = + setTimeout(() => { + state.endFastHoveringTimeout = null; + state.fastHovering = false; + }, settings.endFastHoveringDelay); + } + + // Start timing out the current tooltip when mousing not over a hoverable. + // This could be canceled by mousing over another hoverable, or over the + // currently shown tooltip. + if (state.currentlyShownTooltip && !state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function hideCurrentlyShownTooltip() { + const {event, state} = hoverableTooltipInfo; + const {currentlyShownTooltip: tooltip} = state; + + if (!tooltip) return; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + state.currentlyShownTooltip = null; +} + +function showTooltipFromHoverable(hoverable) { + const {event, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + hideCurrentlyShownTooltip(); + + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); + + state.currentlyShownTooltip = tooltip; +} + +// Data & info card --------------------------------------- + +/* function colorLink(a, color) { console.warn('Info card link colors temporarily disabled: chroma.js required, no dependency linking for client.js yet'); return; -- cgit 1.3.0-6-gf8a5 From 15f72dcf7bec602b979621d6c9e9c6d11617ffbb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 9 Nov 2023 22:35:18 -0400 Subject: client: integrate new tooltip system into external link icons Reference code currently retained, waiting for focus and touch support in the new tooltip system. But this commit should fully cover all the new integration needed! --- src/static/client3.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index acd85880..57922022 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1502,8 +1502,67 @@ for (const info of groupContributionsTableInfo) { // Artist link icon tooltips ------------------------------ -// TODO: Update to clientSteps style. +const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = { + hoverableLinks: null, + iconContainers: null, +}; + +function getExternalIconTooltipReferences() { + const info = externalIconTooltipInfo; + + const spans = + Array.from(document.querySelectorAll('span.contribution.has-tooltip')); + + info.hoverableLinks = + spans + .map(span => span.querySelector('a')); + + info.iconContainers = + spans + .map(span => span.querySelector('span.icons-tooltip')); +} + +function addExternalIconTooltipInternalListeners() { + const info = externalIconTooltipInfo; + + hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + showExternalIconTooltip(tooltip); + }); + + hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => { + if (!info.iconContainers.includes(tooltip)) return; + hideExternalIconTooltip(tooltip); + }); +} +function showExternalIconTooltip(iconContainer) { + iconContainer.classList.add('visible'); + iconContainer.inert = false; +} + +function hideExternalIconTooltip(iconContainer) { + iconContainer.classList.remove('visible'); + iconContainer.inert = true; +} + +function addExternalIconTooltipPageListeners() { + const info = externalIconTooltipInfo; + + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverableLinks, + tooltip: info.iconContainers, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} + +clientSteps.getPageReferences.push(getExternalIconTooltipReferences); +clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners); +clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); + +/* const linkIconTooltipInfo = Array.from(document.querySelectorAll('span.contribution.has-tooltip')) .map(span => ({ @@ -1538,7 +1597,7 @@ for (const info of linkIconTooltipInfo) { return; } - if (focusElements.includes(document.activeElement)) { + if () { return; } @@ -1645,6 +1704,7 @@ for (const info of linkIconTooltipInfo) { hide(); }); } +*/ // Sticky commentary sidebar ------------------------------ -- cgit 1.3.0-6-gf8a5 From 4c319007bdf151064ffed7d275001414b95f24d6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 10 Nov 2023 15:48:10 -0400 Subject: client: add basic tooltip focus behavior --- src/static/client3.js | 135 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 7 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 57922022..57bc21a8 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -361,6 +361,8 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + focusInfoDelay: 750, + endFastHoveringDelay: 500, hideTooltipDelay: 500, @@ -374,10 +376,13 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // These are common across all tooltips, rather than stored individually, // based on the principles that 1) only a single tooltip can be displayed - // at once, and 2) only a single hoverable can be hovered at a once. + // at once, and 2) likewise, only a single hoverable can be hovered, + // focused, or otherwise active at once. hoverTimeout: null, + focusTimeout: null, hideTimeout: null, currentlyShownTooltip: null, + currentlyActiveHoverable: null, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -413,6 +418,19 @@ function registerTooltipElement(tooltip) { tooltip.addEventListener('mouseleave', () => { handleTooltipMouseLeft(tooltip); }); + + tooltip.addEventListener('focusin', () => { + handleTooltipReceivedFocus(tooltip); + }); + + tooltip.addEventListener('focusout', event => { + // This event gets activated for tabbing *between* links inside the + // tooltip, which is no good and certainly doesn't represent the focus + // leaving the tooltip. + if (tooltip.contains(event.relatedTarget)) return; + + handleTooltipLostFocus(tooltip); + }); } // Adds DOM event listeners, so must be called during addPageListeners step. @@ -440,6 +458,14 @@ function registerTooltipHoverableElement(hoverable, tooltip) { hoverable.addEventListener('mouseleave', () => { handleTooltipHoverableMouseLeft(hoverable); }); + + hoverable.addEventListener('focusin', () => { + handleTooltipHoverableReceivedFocus(hoverable); + }); + + hoverable.addEventListener('focusout', () => { + handleTooltipHoverableLostFocus(hoverable); + }); } function handleTooltipMouseEntered(tooltip) { @@ -455,7 +481,7 @@ function handleTooltipMouseEntered(tooltip) { } function handleTooltipMouseLeft(tooltip) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; if (state.currentlyShownTooltip !== tooltip) return; @@ -470,6 +496,34 @@ function handleTooltipMouseLeft(tooltip) { } } +function handleTooltipReceivedFocus(tooltip) { + const {state} = hoverableTooltipInfo; + + // Cancel the tooltip-hiding timeout if it exists. The tooltip will never + // be hidden while it contains the focus anyway, but this ensures the timeout + // will be suitably reset when the tooltip loses focus. + if (state.hideTimeout) { + clearTimeout(state.hideTimeout); + state.hideTimeout = null; + } +} + +function handleTooltipLostFocus(tooltip) { + const {settings, state} = hoverableTooltipInfo; + + // Start timing out the current tooltip when it loses focus. This will be + // canceled if the tooltip receives focus again. Another tooltip might also + // display before this timeout runs, but since this is the same timeout name + // as all tooltip interactions, it'll get cleared appropriately. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + function handleTooltipHoverableMouseEntered(hoverable) { const {event, settings, state} = hoverableTooltipInfo; @@ -502,7 +556,7 @@ function handleTooltipHoverableMouseEntered(hoverable) { } function handleTooltipHoverableMouseLeft(hoverable) { - const {state, settings} = hoverableTooltipInfo; + const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip when not over a hoverable! if (state.hoverTimeout) { @@ -532,26 +586,93 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } +function handleTooltipHoverableReceivedFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Start a timer to show the corresponding tooltip. + state.focusTimeout = + setTimeout(() => { + state.focusTimeout = null; + showTooltipFromHoverable(hoverable); + }, settings.focusInfoDelay); +} + +function handleTooltipHoverableLostFocus(hoverable) { + const {settings, state} = hoverableTooltipInfo; + + // Don't show a tooltip from focusing a hoverable if it isn't focused + // anymore! If another hoverable is receiving focus, that will be evaluated + // and set its own focus timeout after we clear the previous one here. + if (state.focusTimeout) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + } + + // Start timing out the current tooltip when the hoverable loses focus. + // Yes, even if focus is going *into* that very tooltip! This timeout will + // be immediately canceled, in that case. + if (!state.hideTimeout) { + state.hideTimeout = + setTimeout(() => { + state.hideTimeout = null; + hideCurrentlyShownTooltip(); + }, settings.hideTooltipDelay); + } +} + +function currentlyShownTooltipHasFocus() { + const {state} = hoverableTooltipInfo; + + const { + currentlyShownTooltip: tooltip, + currentlyActiveHoverable: hoverable, + } = state; + + // If there's no tooltip, it can't possibly have focus. + if (!tooltip) return false; + + // If the tooltip literally contains (or is) the focused element, then that's + // the principle condition we're looking for. + if (tooltip.contains(document.activeElement)) return true; + + // If the hoverable *which opened the tooltip* is focused, then that also + // represents the tooltip being focused (in its currently shown state). + if (hoverable.contains(document.activeElement)) return true; + + return false; +} + function hideCurrentlyShownTooltip() { const {event, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; - if (!tooltip) return; + // If there was no tooltip to begin with, we're functionally in the desired + // state already, so return true. + if (!tooltip) return true; - dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + // Never hide the tooltip if it's focused. + if (currentlyShownTooltipHasFocus()) return false; state.currentlyShownTooltip = null; + state.currentlyActiveHoverable = null; + + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); + + return true; } function showTooltipFromHoverable(hoverable) { const {event, state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); - hideCurrentlyShownTooltip(); + if (!hideCurrentlyShownTooltip()) return false; + + state.currentlyShownTooltip = tooltip; + state.currentlyActiveHoverable = hoverable; dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); - state.currentlyShownTooltip = tooltip; + return true; } // Data & info card --------------------------------------- -- cgit 1.3.0-6-gf8a5 From 7a234a0b80f5db5d84388f661f473b561b2b0953 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 11 Nov 2023 17:42:40 -0400 Subject: client: more specialized tooltip focus behavior --- src/static/client3.js | 95 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 37 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 57bc21a8..d4e47f0a 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -358,13 +358,26 @@ if ( const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { settings: { + // Hovering has two speed settings. The normal setting is used by default, + // and once a tooltip is displayed as a result of hover, the entire tooltip + // system will enter a "fast hover mode" - hovering will activate tooltips + // sooner. "Fast hover mode" is disabled after a sustained duration of not + // hovering over any hoverables; it's meant only to accelerate switching + // tooltips while still deciding, or getting a quick overview across more + // than one tooltip. normalHoverInfoDelay: 400, fastHoveringInfoDelay: 150, + endFastHoveringDelay: 500, + // Focusing has a single speed setting, which is how long it will take to + // enter a functional "focus mode" (though it's not actually implemented + // in terms of this state). As soon as "focus mode" is entered, the tooltip + // for the current hoverable is displayed, and focusing another hoverable + // will cause the current tooltip to be swapped for that one immediately. + // "Focus mode" ends as soon as anything apart from a tooltip or hoverable + // is focused, and it will be necessary to wait on this delay again. focusInfoDelay: 750, - endFastHoveringDelay: 500, - hideTooltipDelay: 500, }, @@ -383,6 +396,7 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, + tooltipWasJustHidden: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -419,17 +433,17 @@ function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', () => { - handleTooltipReceivedFocus(tooltip); + tooltip.addEventListener('focusin', event => { + handleTooltipReceivedFocus(tooltip, event.relatedTarget); }); tooltip.addEventListener('focusout', event => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (tooltip.contains(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; - handleTooltipLostFocus(tooltip); + handleTooltipLostFocus(tooltip, event.relatedTarget); }); } @@ -459,12 +473,12 @@ function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', () => { - handleTooltipHoverableReceivedFocus(hoverable); + hoverable.addEventListener('focusin', event => { + handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); }); - hoverable.addEventListener('focusout', () => { - handleTooltipHoverableLostFocus(hoverable); + hoverable.addEventListener('focusout', event => { + handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); }); } @@ -508,20 +522,11 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip) { +function handleTooltipLostFocus(tooltip, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start timing out the current tooltip when it loses focus. This will be - // canceled if the tooltip receives focus again. Another tooltip might also - // display before this timeout runs, but since this is the same timeout name - // as all tooltip interactions, it'll get cleared appropriately. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); - } + // Hide the current tooltip right away when it loses focus. + hideCurrentlyShownTooltip(); } function handleTooltipHoverableMouseEntered(hoverable) { @@ -586,18 +591,30 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable) { +function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { const {settings, state} = hoverableTooltipInfo; - // Start a timer to show the corresponding tooltip. + // By default, display the corresponding tooltip after a delay. + state.focusTimeout = setTimeout(() => { state.focusTimeout = null; showTooltipFromHoverable(hoverable); }, settings.focusInfoDelay); + + // If a tooltip was just hidden - which is almost certainly a result of the + // focus changing - then display this tooltip immediately, canceling the + // above timeout. + + if (state.tooltipWasJustHidden) { + clearTimeout(state.focusTimeout); + state.focusTimeout = null; + + showTooltipFromHoverable(hoverable); + } } -function handleTooltipHoverableLostFocus(hoverable) { +function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -608,19 +625,15 @@ function handleTooltipHoverableLostFocus(hoverable) { state.focusTimeout = null; } - // Start timing out the current tooltip when the hoverable loses focus. - // Yes, even if focus is going *into* that very tooltip! This timeout will - // be immediately canceled, in that case. - if (!state.hideTimeout) { - state.hideTimeout = - setTimeout(() => { - state.hideTimeout = null; - hideCurrentlyShownTooltip(); - }, settings.hideTooltipDelay); + // Unless focus is entering the tooltip itself, hide the tooltip immediately. + // This will set the tooltipWasJustHidden flag, which is detected by a newly + // focused hoverable, if applicable. + if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + hideCurrentlyShownTooltip(); } } -function currentlyShownTooltipHasFocus() { +function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; const { @@ -633,11 +646,11 @@ function currentlyShownTooltipHasFocus() { // If the tooltip literally contains (or is) the focused element, then that's // the principle condition we're looking for. - if (tooltip.contains(document.activeElement)) return true; + if (tooltip.contains(focusElement)) return true; // If the hoverable *which opened the tooltip* is focused, then that also // represents the tooltip being focused (in its currently shown state). - if (hoverable.contains(document.activeElement)) return true; + if (hoverable.contains(focusElement)) return true; return false; } @@ -656,6 +669,12 @@ function hideCurrentlyShownTooltip() { state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; + // Set this for one tick of the event cycle. + state.tooltipWasJustHidden = true; + setTimeout(() => { + state.tooltipWasJustHidden = false; + }); + dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); return true; @@ -670,6 +689,8 @@ function showTooltipFromHoverable(hoverable) { state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; + state.tooltipWasJustHidden = false; + dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); return true; -- cgit 1.3.0-6-gf8a5 From db44a5ea5fd8cb3be1d491687acb64eba966abea Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 10:59:17 -0400 Subject: client: most of touch implementation for tooltips --- src/static/client3.js | 195 +++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 105 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index d4e47f0a..5cc34461 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -393,10 +393,12 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // focused, or otherwise active at once. hoverTimeout: null, focusTimeout: null, + touchTimeout: null, hideTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, tooltipWasJustHidden: false, + hoverableWasRecentlyTouched: false, // Fast hovering is a global mode which is activated as soon as any tooltip // is displayed and turns off after a delay of no hoverables being hovered. @@ -474,11 +476,19 @@ function registerTooltipHoverableElement(hoverable, tooltip) { }); hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event.relatedTarget); + handleTooltipHoverableReceivedFocus(hoverable, event); }); hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event.relatedTarget); + handleTooltipHoverableLostFocus(hoverable, event); + }); + + hoverable.addEventListener('touchend', event => { + handleTooltipHoverableTouchEnded(hoverable, event); + }); + + hoverable.addEventListener('click', event => { + handleTooltipHoverableClicked(hoverable, event); }); } @@ -522,7 +532,7 @@ function handleTooltipReceivedFocus(tooltip) { } } -function handleTooltipLostFocus(tooltip, newlyFocusedElement) { +function handleTooltipLostFocus(tooltip) { const {settings, state} = hoverableTooltipInfo; // Hide the current tooltip right away when it loses focus. @@ -591,7 +601,7 @@ function handleTooltipHoverableMouseLeft(hoverable) { } } -function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement) { +function handleTooltipHoverableReceivedFocus(hoverable) { const {settings, state} = hoverableTooltipInfo; // By default, display the corresponding tooltip after a delay. @@ -614,7 +624,7 @@ function handleTooltipHoverableReceivedFocus(hoverable, previouslyFocusedElement } } -function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { +function handleTooltipHoverableLostFocus(hoverable, domEvent) { const {settings, state} = hoverableTooltipInfo; // Don't show a tooltip from focusing a hoverable if it isn't focused @@ -628,11 +638,63 @@ function handleTooltipHoverableLostFocus(hoverable, newlyFocusedElement) { // Unless focus is entering the tooltip itself, hide the tooltip immediately. // This will set the tooltipWasJustHidden flag, which is detected by a newly // focused hoverable, if applicable. - if (!currentlyShownTooltipHasFocus(newlyFocusedElement)) { + if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { hideCurrentlyShownTooltip(); } } +function handleTooltipHoverableTouchEnded(hoverable, domEvent) { + const {settings, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't proceed if this hoverable's tooltip is already visible - in that + // case touching the hoverable again should behave just like a normal click. + if (state.currentlyShownTooltip === tooltip) return; + + const touchEndedOverHoverable = + Array.from(domEvent.changedTouches) + .some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!touchEndedOverHoverable) { + return; + } + + if (state.touchTimeout) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + } + + // Show the tooltip right away. + showTooltipFromHoverable(hoverable); + + // Set a state, for a brief but not instantaneous period, indicating that a + // hoverable was recently touched. The touchend event may precede the click + // event by some time, and we don't want to navigate away from the page as + // a result of the click event which this touch precipitated. + state.hoverableWasRecentlyTouched = true; + state.touchTimeout = + setTimeout(() => { + state.hoverableWasRecentlyTouched = false; + }, 250); +} + +function handleTooltipHoverableClicked(hoverable, domEvent) { + const {state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // Don't navigate away from the page if the this hoverable was recently + // touched (and had its tooltip activated). That flag won't be set if its + // tooltip was already open before the touch. + if ( + state.currentlyActiveHoverable === hoverable && + state.hoverableWasRecentlyTouched + ) { + event.preventDefault(); + } +} + function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { const {state} = hoverableTooltipInfo; @@ -696,6 +758,28 @@ function showTooltipFromHoverable(hoverable) { return true; } +function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const touches = [...domEvent.changedTouches, ...domEvent.touches]; + const hoverables = Array.from(state.registeredHoverables.keys()); + + // TODO: https://github.com/tc39/proposal-iterator-helpers + const anyTouchOverAnyHoverable = + touches.some(({clientX, clientY}) => { + const element = document.elementFromPoint(clientX, clientY); + return hoverables.some(hoverable => hoverable.contains(element)); + }); + + if (!anyTouchOverAnyHoverable) { + hideCurrentlyShownTooltip(); + } + }); +} + +clientSteps.addPageListeners.push(addHoverableTooltipPageListeners); + // Data & info card --------------------------------------- /* @@ -864,53 +948,6 @@ const infoCard = (() => { }; })(); -function makeInfoCardLinkHandlers(type) { - let hoverTimeout = null; - - return { - mouseenter(evt) { - hoverTimeout = setTimeout( - () => { - fastHover = true; - infoCard.show(type, evt.target); - }, - fastHover ? FAST_HOVER_INFO_DELAY : NORMAL_HOVER_INFO_DELAY); - - clearTimeout(endFastHoverTimeout); - endFastHoverTimeout = null; - - infoCard.cancelHide(); - }, - - mouseleave() { - clearTimeout(hoverTimeout); - - if (fastHover && !endFastHoverTimeout) { - endFastHoverTimeout = setTimeout(() => { - endFastHoverTimeout = null; - fastHover = false; - }, END_FAST_HOVER_DELAY); - } - - infoCard.readyHide(); - }, - }; -} - -const infoCardLinkHandlers = { - track: makeInfoCardLinkHandlers('track'), -}; - -function addInfoCardLinkHandlers(type) { - for (const a of document.querySelectorAll(`a[data-${type}]`)) { - for (const [eventName, handler] of Object.entries( - infoCardLinkHandlers[type] - )) { - a.addEventListener(eventName, handler); - } - } -} - // Info cards are disa8led for now since they aren't quite ready for release, // 8ut you can try 'em out 8y setting this localStorage flag! // @@ -1793,58 +1830,6 @@ for (const info of linkIconTooltipInfo) { info.mainLink.addEventListener('blur', () => { requestAnimationFrame(considerHiding); }); - - // Touch (finger) - - let justTouched = false; - let touchTimeout; - - info.mainLink.addEventListener('touchend', event => { - let wasTarget = false; - - for (const touch of event.changedTouches) { - if (touch.target === info.mainLink) { - wasTarget = true; - break; - } - } - - if (!wasTarget) { - return; - } - - justTouched = true; - - clearTimeout(touchTimeout); - touchTimeout = setTimeout(() => { - justTouched = false; - }, 250); - - show(); - }); - - info.mainLink.addEventListener('click', event => { - if (hidden && justTouched) { - event.preventDefault(); - event.target.focus(); - show(); - } - }); - - document.body.addEventListener('touchend', event => { - const touches = [...event.changedTouches, ...event.touches]; - for (const {clientX, clientY} of touches) { - const touchEl = document.elementFromPoint(clientX, clientY); - if (!touchEl) continue; - - for (const hoverEl of hoverElements) { - if (touchEl === hoverEl) return; - if (hoverEl.contains(touchEl)) return; - } - } - - hide(); - }); } */ -- cgit 1.3.0-6-gf8a5 From 2d31d6daa66d711a6dc22b84ec0d4b79d776c4ba Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:29:45 -0400 Subject: client: avoid processing touch events related to scrolling --- src/static/client3.js | 76 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 5cc34461..db9e5505 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -406,6 +406,15 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { // it will never be turned off while idling over a hoverable. fastHovering: false, endFastHoveringTimeout: false, + + // These track the identifiers of current touches and a record of current + // identifiers that are "banished" by scrolling - that is, touches which + // existed while the page scrolled and were probably responsible for that + // scrolling. This is a bit loose (we can't actually tell which touches + // caused the page to scroll) but it's intended to keep scrolling the page + // from causing the current tooltip to be hidden. + currentTouchIdentifiers: new Set(), + touchIdentifiersBanishedByScrolling: new Set(), }, event: { @@ -651,13 +660,25 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const touchEndedOverHoverable = - Array.from(domEvent.changedTouches) - .some(touch => - hoverable.contains( - document.elementFromPoint(touch.clientX, touch.clientY))); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; - if (!touchEndedOverHoverable) { + // Don't proceed if none of the (just-ended) touches ended over the + // hoverable. + const anyTouchEndedOverHoverable = + unbanishedTouches.some(touch => + hoverable.contains( + document.elementFromPoint(touch.clientX, touch.clientY))); + + if (!anyTouchEndedOverHoverable) { return; } @@ -759,15 +780,54 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + document.body.addEventListener('touchstart', domEvent => { + const {state} = hoverableTooltipInfo; + for (const {identifier} of domEvent.changedTouches) { + state.currentTouchIdentifiers.add(identifier); + } + }); + + window.addEventListener('scroll', domEvent => { + const {state} = hoverableTooltipInfo; + for (const identifier of state.currentTouchIdentifiers) { + state.touchIdentifiersBanishedByScrolling.add(identifier); + } + }); + + document.body.addEventListener('touchend', domEvent => { + const {state} = hoverableTooltipInfo; + + const identifiers = + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier); + + setTimeout(() => { + for (const identifier of identifiers) { + state.currentTouchIdentifiers.delete(identifier); + state.touchIdentifiersBanishedByScrolling.delete(identifier); + } + }); + }); + document.body.addEventListener('touchend', domEvent => { const {state} = hoverableTooltipInfo; - const touches = [...domEvent.changedTouches, ...domEvent.touches]; const hoverables = Array.from(state.registeredHoverables.keys()); + const endedTouches = Array.from(domEvent.changedTouches); + + // Don't process touch events that were "banished" because the page was + // scrolled while those touches were active, and most likely as a result of + // them. + const unbanishedTouches = + endedTouches.filter(touch => + !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + + if (empty(unbanishedTouches)) return; + // TODO: https://github.com/tc39/proposal-iterator-helpers const anyTouchOverAnyHoverable = - touches.some(({clientX, clientY}) => { + unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); return hoverables.some(hoverable => hoverable.contains(element)); }); -- cgit 1.3.0-6-gf8a5 From 5127fd36dcf5987f402cce0353768b1421d9b7b4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 11:34:43 -0400 Subject: client, css: minor tooltip fixes --- src/static/client3.js | 10 ++++++---- src/static/site5.css | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index db9e5505..88df58de 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -813,6 +813,7 @@ function addHoverableTooltipPageListeners() { const {state} = hoverableTooltipInfo; const hoverables = Array.from(state.registeredHoverables.keys()); + const tooltips = Array.from(state.registeredTooltips.keys()); const endedTouches = Array.from(domEvent.changedTouches); @@ -825,14 +826,15 @@ function addHoverableTooltipPageListeners() { if (empty(unbanishedTouches)) return; - // TODO: https://github.com/tc39/proposal-iterator-helpers - const anyTouchOverAnyHoverable = + const anyTouchOverAnyHoverableOrTooltip = unbanishedTouches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); - return hoverables.some(hoverable => hoverable.contains(element)); + if (hoverables.some(el => el.contains(element))) return true; + if (tooltips.some(el => el.contains(element))) return true; + return false; }); - if (!anyTouchOverAnyHoverable) { + if (!anyTouchOverAnyHoverableOrTooltip) { hideCurrentlyShownTooltip(); } }); diff --git a/src/static/site5.css b/src/static/site5.css index 06696799..582681bb 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -489,7 +489,7 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 999; + z-index: 1; left: -12px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; -- cgit 1.3.0-6-gf8a5 From 834a087643306090905a2c2f080324b1100c0710 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 12:28:04 -0400 Subject: client: tooltip touch syntax cleanup --- src/static/client3.js | 49 ++++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 25 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 88df58de..9db9fc6c 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -660,21 +660,22 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // case touching the hoverable again should behave just like a normal click. if (state.currentlyShownTooltip === tooltip) return; - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; // Don't proceed if none of the (just-ended) touches ended over the // hoverable. const anyTouchEndedOverHoverable = - unbanishedTouches.some(touch => + touches.some(touch => hoverable.contains( document.elementFromPoint(touch.clientX, touch.clientY))); @@ -780,29 +781,28 @@ function showTooltipFromHoverable(hoverable) { } function addHoverableTooltipPageListeners() { + const {state} = hoverableTooltipInfo; + + const getTouchIdentifiers = domEvent => + Array.from(domEvent.changedTouches) + .map(touch => touch.identifier) + .filter(identifier => typeof identifier !== 'undefined'); + document.body.addEventListener('touchstart', domEvent => { - const {state} = hoverableTooltipInfo; - for (const {identifier} of domEvent.changedTouches) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.add(identifier); } }); - window.addEventListener('scroll', domEvent => { - const {state} = hoverableTooltipInfo; + window.addEventListener('scroll', () => { for (const identifier of state.currentTouchIdentifiers) { state.touchIdentifiersBanishedByScrolling.add(identifier); } }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - - const identifiers = - Array.from(domEvent.changedTouches) - .map(touch => touch.identifier); - setTimeout(() => { - for (const identifier of identifiers) { + for (const identifier of getTouchIdentifiers(domEvent)) { state.currentTouchIdentifiers.delete(identifier); state.touchIdentifiersBanishedByScrolling.delete(identifier); } @@ -810,24 +810,23 @@ function addHoverableTooltipPageListeners() { }); document.body.addEventListener('touchend', domEvent => { - const {state} = hoverableTooltipInfo; - const hoverables = Array.from(state.registeredHoverables.keys()); const tooltips = Array.from(state.registeredTooltips.keys()); - const endedTouches = Array.from(domEvent.changedTouches); + const touches = Array.from(domEvent.changedTouches); + const identifiers = touches.map(touch => touch.identifier); // Don't process touch events that were "banished" because the page was // scrolled while those touches were active, and most likely as a result of // them. - const unbanishedTouches = - endedTouches.filter(touch => - !state.touchIdentifiersBanishedByScrolling.has(touch.identifier)); + filterMultipleArrays(touches, identifiers, + (_touch, identifier) => + !state.touchIdentifiersBanishedByScrolling.has(identifier)); - if (empty(unbanishedTouches)) return; + if (empty(touches)) return; const anyTouchOverAnyHoverableOrTooltip = - unbanishedTouches.some(({clientX, clientY}) => { + touches.some(({clientX, clientY}) => { const element = document.elementFromPoint(clientX, clientY); if (hoverables.some(el => el.contains(element))) return true; if (tooltips.some(el => el.contains(element))) return true; -- cgit 1.3.0-6-gf8a5 From d443e32d044dd74cd1923e3538af0a63ff6c6835 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 12 Nov 2023 15:01:22 -0400 Subject: css: quick tooltip tweaks --- src/static/site5.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 582681bb..8c2b07a1 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -506,8 +506,12 @@ a:not([href]):hover { border: 1px dotted var(--primary-color); border-radius: 4px; + -webkit-backdrop-filter: blur(2px); + backdrop-filter: blur(2px); + -webkit-user-select: none; - user-select: none; + user-select: none; + cursor: default; } -- cgit 1.3.0-6-gf8a5 From 0202375db8ccd03d98ed6c2ffbb800b67c026639 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 23 Nov 2023 09:16:23 -0400 Subject: content, css: vertical tooltips + basic external parsing --- src/static/site5.css | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index 8c2b07a1..c1dfff82 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -477,7 +477,7 @@ a:not([href]):hover { position: relative; } -.contribution.has-tooltip a { +.contribution.has-tooltip > a { text-decoration: underline; text-decoration-style: dotted; } @@ -489,8 +489,8 @@ a:not([href]):hover { .icons-tooltip { position: absolute; - z-index: 1; - left: -12px; + z-index: 3; + left: -36px; top: calc(1em - 2px); padding: 4px 12px 6px 8px; } @@ -504,14 +504,21 @@ a:not([href]):hover { padding: 6px 2px 2px 2px; background: var(--bg-black-color); border: 1px dotted var(--primary-color); - border-radius: 4px; + border-radius: 6px; - -webkit-backdrop-filter: blur(2px); - backdrop-filter: blur(2px); + -webkit-backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); -webkit-user-select: none; user-select: none; + box-shadow: + 0 3px 4px 4px #000000aa, + 0 -2px 4px -2px var(--primary-color) inset; + cursor: default; } @@ -538,6 +545,23 @@ a:not([href]):hover { fill: var(--primary-color); } +.icon.has-text { + display: block; + width: unset; + height: 1.4em; +} + +.icon.has-text > svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.icon.has-text > .icon-text { + margin-left: 24px; + padding-right: 8px; +} + .rerelease, .other-group-accent { opacity: 0.7; -- cgit 1.3.0-6-gf8a5 From 82bb115d8404b88fe8b1af1bf714b3c70969f99b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:23:58 -0400 Subject: css: allow custom scroll margin offset per-element --- src/static/site5.css | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index c1dfff82..fb3cf057 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1380,6 +1380,10 @@ h3.content-heading { /* Sticky heading */ +[id] { + --custom-scroll-offset: 0px; +} + #content [id] { /* Adjust scroll margin. */ scroll-margin-top: calc( @@ -1387,6 +1391,7 @@ h3.content-heading { + 33px /* Sticky subheading */ - 1em /* One line of text (align bottom) */ - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ ); } -- cgit 1.3.0-6-gf8a5 From 921f2d421d6ffb87fab1a2059a6c313b9c81f4f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 21 Nov 2023 07:25:30 -0400 Subject: css: allow sticky heading text to occupy blank area in thin layout --- src/static/site5.css | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css index fb3cf057..5a769545 100644 --- a/src/static/site5.css +++ b/src/static/site5.css @@ -1836,6 +1836,13 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content z-index: 2; } + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + /* Disable grid features, just line header children up vertically */ #header { -- cgit 1.3.0-6-gf8a5 From 4263dc13d48b385e78bbc5e1112dfe7a47054909 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:17:09 -0400 Subject: client: get results from dispatchInternalEvent + merge fixes --- src/static/client3.js | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index af0c381c..866b9ba2 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -96,14 +96,18 @@ function dispatchInternalEvent(event, eventName, ...args) { throw new Error(`Event name "${eventName}" isn't stored on ${infoName}.event`); } + let results = []; for (const listener of listeners) { try { - listener(...args); + results.push(listener(...args)); } catch (error) { console.warn(`Uncaught error in listener for ${infoName}.${eventName}`); console.debug(error); + results.push(undefined); } } + + return results; } // JS-based links ----------------------------------------- @@ -1102,13 +1106,14 @@ function addHashLinkListeners() { } // Allow event handlers to prevent scrolling. - for (const handler of event.beforeHashLinkScrolls) { - if (handler({ + const listenerResults = + dispatchInternalEvent(event, 'beforeHashLinkScrolls', { link: hashLink, target, - }) === false) { - return; - } + }); + + if (listenerResults.includes(false)) { + return; } // Hide skipper box right away, so the layout is updated on time for the @@ -1145,14 +1150,10 @@ function addHashLinkListeners() { processScrollingAfterHashLinkClicked(); - dispatchInternalEvent(event, 'whenHashLinkClicked', {link: hashLink}); - - for (const handler of event.whenHashLinkClicked) { - handler({ - link: hashLink, - target, - }); - } + dispatchInternalEvent(event, 'whenHashLinkClicked', { + link: hashLink, + target, + }); }); } @@ -1399,12 +1400,10 @@ function updateStickySubheadingContent(index) { state.displayedHeading = closestHeading; - for (const handler of event.whenDisplayedHeadingChanges) { - handler(index, { - oldHeading: oldDisplayedHeading, - newHeading: closestHeading, - }); - } + dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); } function updateStickyHeadings(index) { -- cgit 1.3.0-6-gf8a5 From daf6f5b39ebaf8cf62b0dab72edac41ceff72989 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 24 Nov 2023 15:26:05 -0400 Subject: css: site5.css -> site6.css, just to be safe --- src/static/site5.css | 1950 -------------------------------------------------- src/static/site6.css | 1950 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1950 insertions(+), 1950 deletions(-) delete mode 100644 src/static/site5.css create mode 100644 src/static/site6.css (limited to 'src/static') diff --git a/src/static/site5.css b/src/static/site5.css deleted file mode 100644 index 4c083527..00000000 --- a/src/static/site5.css +++ /dev/null @@ -1,1950 +0,0 @@ -/* A frontend file! Wow. - * This file is just loaded statically 8y s in the HTML files, so there's - * no need to re-run upd8.js when tweaking values here. Handy! - */ - -/* Layout - Common */ - -body { - margin: 10px; - overflow-y: scroll; -} - -body::before { - content: ""; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: -1; - - /* NB: these are 100 LVW, "largest view width", etc. - * Stabilizes background on viewports with modal dimensions, - * e.g. expanding/shrinking tab bar or collapsible find bar. - * 100% dimensions are kept above for browser compatibility. - */ - width: 100lvw; - height: 100lvh; -} - -#page-container { - max-width: 1100px; - margin: 10px auto 50px; - padding: 15px 0; -} - -#page-container > * { - margin-left: 15px; - margin-right: 15px; -} - -#skippers:focus-within { - position: static; - width: unset; - height: unset; -} - -#banner { - margin: 10px 0; - width: 100%; - position: relative; -} - -#banner::after { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -#banner img { - display: block; - width: 100%; - height: auto; -} - -#skippers { - position: absolute; - left: -10000px; - top: auto; - width: 1px; - height: 1px; -} - -.layout-columns { - display: flex; - align-items: stretch; -} - -#header, -#secondary-nav, -#skippers, -#footer { - padding: 5px; -} - -#header, -#secondary-nav, -#skippers { - margin-bottom: 10px; -} - -#footer { - margin-top: 10px; -} - -#header { - display: grid; -} - -#header.nav-has-main-links.nav-has-content { - grid-template-columns: 2.5fr 3fr; - grid-template-rows: min-content 1fr; - grid-template-areas: - "main-links content" - "bottom-row content"; -} - -#header.nav-has-main-links:not(.nav-has-content) { - grid-template-columns: 1fr; - grid-template-areas: - "main-links" - "bottom-row"; -} - -.nav-main-links { - grid-area: main-links; - margin-right: 20px; -} - -.nav-content { - grid-area: content; -} - -.nav-bottom-row { - grid-area: bottom-row; - align-self: start; -} - -.sidebar-column { - flex: 1 1 20%; - min-width: 150px; - max-width: 250px; - flex-basis: 250px; - align-self: flex-start; -} - -.sidebar-column.wide { - max-width: 350px; - flex-basis: 300px; - flex-shrink: 0; - flex-grow: 1; -} - -.sidebar-multiple { - display: flex; - flex-direction: column; -} - -.sidebar-multiple .sidebar:not(:first-child) { - margin-top: 15px; -} - -.sidebar { - --content-padding: 5px; - padding: var(--content-padding); -} - -#sidebar-left { - margin-right: 10px; -} - -#sidebar-right { - margin-left: 10px; -} - -#content { - position: relative; - --content-padding: 20px; - box-sizing: border-box; - padding: var(--content-padding); - flex-grow: 1; - flex-shrink: 3; -} - -.footer-content { - margin: 5px 12%; -} - -.footer-content > :first-child { - margin-top: 0; -} - -.footer-content > :last-child { - margin-bottom: 0; -} - -.footer-localization-links { - margin: 5px 12%; -} - -/* Design & Appearance - Layout elements */ - -body { - background: black; -} - -body::before { - background-image: url("../media/bg.jpg"); - background-position: center; - background-size: cover; - opacity: 0.5; -} - -#page-container { - background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); - color: #ffffff; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); -} - -#skippers > * { - display: inline-block; -} - -#skippers > .skipper-list:not(:last-child)::after { - display: inline-block; - content: "\00a0"; - margin-left: 2px; - margin-right: -2px; - border-left: 1px dotted; -} - -#skippers .skipper-list > .skipper:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -#banner { - background: black; - background-color: var(--dim-color); - border-bottom: 1px solid var(--primary-color); -} - -#banner::after { - box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); - pointer-events: none; -} - -#banner.dim img { - opacity: 0.8; -} - -#header, -#secondary-nav, -#skippers, -#footer, -.sidebar { - font-size: 0.85em; -} - -.sidebar, -#content, -#header, -#secondary-nav, -#skippers, -#footer { - background-color: rgba(0, 0, 0, 0.6); - border: 1px dotted var(--primary-color); - border-radius: 3px; - transition: background-color 0.2s; -} - -/* -.sidebar:focus-within, -#content:focus-within, -#header:focus-within, -#secondary-nav:focus-within, -#skippers:focus-within, -#footer:focus-within { - background-color: rgba(0, 0, 0, 0.85); - border-style: solid; -} -*/ - -.sidebar > h1, -.sidebar > h2, -.sidebar > h3, -.sidebar > p { - text-align: center; - padding-left: 4px; - padding-right: 4px; -} - -.sidebar h1 { - font-size: 1.25em; -} - -.sidebar h2 { - font-size: 1.1em; - margin: 0; -} - -.sidebar h3 { - font-size: 1.1em; - font-style: oblique; - font-variant: small-caps; - margin-top: 0.3em; - margin-bottom: 0em; -} - -.sidebar > p { - margin: 0.5em 0; - padding: 0 5px; -} - -.sidebar hr { - color: #555; - margin: 10px 5px; -} - -.sidebar > ol, -.sidebar > ul { - padding-left: 30px; - padding-right: 15px; -} - -.sidebar > dl { - padding-right: 15px; - padding-left: 0; -} - -.sidebar > dl dt { - padding-left: 10px; - margin-top: 0.5em; -} - -.sidebar > dl dt.current { - font-weight: 800; -} - -.sidebar > dl dd { - margin-left: 0; -} - -.sidebar > dl dd ul { - padding-left: 30px; - margin-left: 0; -} - -.sidebar > dl .side { - padding-left: 10px; -} - -.sidebar li.current { - font-weight: 800; -} - -.sidebar li { - overflow-wrap: break-word; -} - -.sidebar > details.current summary { - font-weight: 800; -} - -.sidebar > details summary { - margin-top: 0.5em; - padding-left: 5px; -} - -summary > span:hover { - cursor: pointer; - text-decoration: underline; - text-decoration-color: var(--primary-color); -} - -summary .group-name { - color: var(--primary-color); -} - -.sidebar > details ul, -.sidebar > details ol { - margin-top: 0; - margin-bottom: 0; -} - -.sidebar > details:last-child { - margin-bottom: 10px; -} - -.sidebar > details[open] { - margin-bottom: 1em; -} - -.sidebar article { - text-align: left; - margin: 5px 5px 15px 5px; -} - -.sidebar article:last-child { - margin-bottom: 5px; -} - -.sidebar article h2, -.news-index h2 { - border-bottom: 1px dotted; -} - -.sidebar article h2 time, -.news-index time { - float: right; - font-weight: normal; -} - -#content { - overflow-wrap: anywhere; -} - -footer { - text-align: center; - font-style: oblique; -} - -.footer-localization-links > span:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -/* Design & Appearance - Content elements */ - -a { - color: var(--primary-color); - text-decoration: none; -} - -a:hover { - text-decoration: underline; - text-decoration-style: solid !important; -} - -a.current { - font-weight: 800; -} - -a:not([href]) { - cursor: default; -} - -a:not([href]):hover { - text-decoration: none; -} - -.nav-main-links > span > span { - white-space: nowrap; -} - -.nav-main-links > span.current > span.nav-link-content > a { - font-weight: 800; -} - -.nav-links-index > span:not(:first-child):not(.no-divider)::before, -.nav-links-groups > span:not(:first-child):not(.no-divider)::before { - content: "\0020\00b7\0020"; - font-weight: 800; -} - -.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before { - content: "\0020/\0020"; -} - -#header .chronology .heading, -#header .chronology .buttons { - white-space: nowrap; -} - -#secondary-nav { - text-align: center; -} - -.nowrap { - white-space: nowrap; -} - -.contribution { - position: relative; -} - -.contribution.has-tooltip > a { - text-decoration: underline; - text-decoration-style: dotted; -} - -.icons { - font-style: normal; - white-space: nowrap; -} - -.icons-tooltip { - position: absolute; - z-index: 3; - left: -36px; - top: calc(1em - 2px); - padding: 4px 12px 6px 8px; -} - -.icons-tooltip:not(.visible) { - display: none; -} - -.icons-tooltip-content { - display: block; - padding: 6px 2px 2px 2px; - background: var(--bg-black-color); - border: 1px dotted var(--primary-color); - border-radius: 6px; - - -webkit-backdrop-filter: - brightness(1.5) saturate(1.4) blur(4px); - - backdrop-filter: - brightness(1.5) saturate(1.4) blur(4px); - - -webkit-user-select: none; - user-select: none; - - box-shadow: - 0 3px 4px 4px #000000aa, - 0 -2px 4px -2px var(--primary-color) inset; - - cursor: default; -} - -.icons a:hover { - filter: brightness(1.4); -} - -.icons a { - padding: 0 3px; -} - -.icon { - display: inline-block; - width: 24px; - height: 1em; - position: relative; -} - -.icon > svg { - width: 24px; - height: 24px; - top: -0.25em; - position: absolute; - fill: var(--primary-color); -} - -.icon.has-text { - display: block; - width: unset; - height: 1.4em; -} - -.icon.has-text > svg { - width: 18px; - height: 18px; - top: -0.1em; -} - -.icon.has-text > .icon-text { - margin-left: 24px; - padding-right: 8px; -} - -.rerelease, -.other-group-accent { - opacity: 0.7; - font-style: oblique; -} - -.other-group-accent { - white-space: nowrap; -} - -.content-columns { - columns: 2; -} - -.content-columns .column { - break-inside: avoid; -} - -.content-columns .column h2 { - margin-top: 0; - font-size: 1em; -} - -p .current { - font-weight: 800; -} - -#cover-art-container { - font-size: 0.8em; -} - -#cover-art .square { - box-shadow: 0 0 3px 6px rgba(0, 0, 0, 0.35); -} - -#cover-art img { - display: block; - width: 100%; - height: 100%; -} - -#cover-art-container p { - margin-top: 5px; -} - -.commentary-entry-heading { - margin-left: 15px; - padding-left: 5px; - max-width: 625px; - padding-bottom: 0.2em; - border-bottom: 1px dotted var(--primary-color); -} - -.commentary-entry-accent { - font-style: oblique; -} - -.commentary-art { - float: right; - width: 30%; - max-width: 250px; - margin: 15px 0 10px 20px; - box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); -} - -.js-hide, -.js-show-once-data, -.js-hide-once-data { - display: none; -} - -.content-image { - margin-top: 1em; - margin-bottom: 1em; -} - -a.box:focus { - outline: 3px double var(--primary-color); -} - -a.box:focus:not(:focus-visible) { - outline: none; -} - -a.box img { - display: block; - max-width: 100%; - height: auto; -} - -.square .image-container { - width: 100%; - height: 100%; -} - -h1 { - font-size: 1.5em; -} - -#content li { - margin-bottom: 4px; -} - -#content li i { - white-space: nowrap; -} - -#content.top-index h1, -#content.flash-index h1 { - text-align: center; - font-size: 2em; -} - -html[data-url-key="localized.home"] #content h1 { - text-align: center; - font-size: 2.5em; -} - -#content.flash-index h2 { - text-align: center; - font-size: 2.5em; - font-variant: small-caps; - font-style: oblique; - margin-bottom: 0; - text-align: center; - width: 100%; -} - -#content.top-index h2 { - text-align: center; - font-size: 2em; - font-weight: normal; - margin-bottom: 0.25em; -} - -.quick-info { - text-align: center; -} - -ul.quick-info { - list-style: none; - padding-left: 0; -} - -ul.quick-info li { - display: inline-block; -} - -ul.quick-info li:not(:last-child)::after { - content: " \00b7 "; - font-weight: 800; -} - -.carousel-container + .quick-info { - margin-top: 25px; -} - -#intro-menu { - margin: 24px 0; - padding: 10px; - background-color: #222222; - text-align: center; - border: 1px dotted var(--primary-color); - border-radius: 2px; -} - -#intro-menu p { - margin: 12px 0; -} - -#intro-menu a { - margin: 0 6px; -} - -li .by { - display: inline-block; - font-style: oblique; -} - -li .by a { - display: inline-block; -} - -p code { - font-size: 1em; - font-family: "courier new"; - font-weight: 800; -} - -#content blockquote { - margin-left: 40px; - max-width: 600px; - margin-right: 0; -} - -#content blockquote blockquote { - margin-left: 10px; - padding-left: 10px; - margin-right: 20px; - border-left: dotted 1px; - padding-top: 6px; - padding-bottom: 6px; -} - -#content blockquote blockquote > :first-child { - margin-top: 0; -} - -#content blockquote blockquote > :last-child { - margin-bottom: 0; -} - -main.long-content { - --long-content-padding-ratio: 0.10; -} - -main.long-content .main-content-container, -main.long-content > h1 { - padding-left: calc(var(--long-content-padding-ratio) * 100%); - padding-right: calc(var(--long-content-padding-ratio) * 100%); -} - -dl dt { - padding-left: 40px; - max-width: 600px; -} - -dl dt { - margin-bottom: 2px; -} - -dl dd { - margin-bottom: 1em; -} - -dl ul, -dl ol { - margin-top: 0; - margin-bottom: 0; -} - -ul > li.has-details { - list-style-type: none; - margin-left: -17px; -} - -.album-group-list dt { - font-style: oblique; - padding-left: 0; -} - -.album-group-list dd { - margin-left: 0; -} - -.group-chronology-link { - font-style: oblique; -} - -#content hr { - border: 1px inset #808080; - width: 100%; -} - -#content hr.split::before { - content: "(split)"; - color: #808080; -} - -#content hr.split { - position: relative; - overflow: hidden; - border: none; -} - -#content hr.split::after { - display: inline-block; - content: ""; - border: 1px inset #808080; - width: 100%; - position: absolute; - top: 50%; - margin-top: -2px; - margin-left: 10px; -} - -li > ul { - margin-top: 5px; -} - -.group-contributions-table { - display: inline-block; -} - -.group-contributions-table .group-contributions-row { - display: flex; - justify-content: space-between; -} - -.group-contributions-table .group-contributions-metrics { - margin-left: 1.5ch; - white-space: nowrap; -} - -.group-contributions-sorted-by-count:not(.visible), -.group-contributions-sorted-by-duration:not(.visible) { - display: none; -} - -html[data-url-key="localized.albumCommentary"] li.no-commentary { - opacity: 0.7; -} - -html[data-url-key="localized.albumCommentary"] .content-heading-main-title { - margin-right: 0.25em; -} - -html[data-url-key="localized.albumCommentary"] .content-heading-accent { - font-weight: normal; - font-style: oblique; - font-size: 0.9rem; - display: inline-block; -} - -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, -html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { - display: none; -} - -html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { - opacity: 0.7; -} - -/* Additional names (heading and box) */ - -h1 a[href="#additional-names-box"] { - color: inherit; - text-decoration: underline; - text-decoration-style: dotted; -} - -h1 a[href="#additional-names-box"]:hover { - text-decoration-style: solid; -} - -#additional-names-box { - --custom-scroll-offset: calc(0.5em - 2px); - - margin: 1em 0 1em -10px; - padding: 15px 20px 10px 20px; - width: max-content; - max-width: min(60vw, 600px); - - border: 1px dotted var(--primary-color); - border-radius: 6px; - - background: - linear-gradient(var(--bg-color), var(--bg-color)), - linear-gradient(#000000bb, #000000bb), - var(--primary-color); - - box-shadow: 0 -2px 6px -1px var(--dim-color) inset; - - display: none; -} - -#additional-names-box > :first-child { margin-top: 0; } -#additional-names-box > :last-child { margin-bottom: 0; } - -#additional-names-box p { - padding-left: 10px; - padding-right: 10px; - margin-bottom: 0; - font-style: oblique; -} - -#additional-names-box ul { - padding-left: 10px; - margin-top: 0.5em; -} - -#additional-names-box li .additional-name { - margin-right: 0.25em; -} - -#additional-names-box li .additional-name .content-image { - margin-bottom: 0.25em; - margin-top: 0.5em; -} - -#additional-names-box li .annotation { - opacity: 0.8; - display: inline-block; -} - -/* Images */ - -.image-container { - border: 2px solid var(--primary-color); - box-sizing: border-box; - position: relative; - padding: 5px; - text-align: left; - background-color: var(--dim-color); - color: white; - display: inline-block; - height: 100%; -} - -.image-text-area { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - padding: 5px 15px; - background: rgba(0, 0, 0, 0.65); - box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; - line-height: 1.35em; - color: var(--primary-color); - font-style: oblique; - text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); -} - -.image-inner-area { - width: 100%; - height: 100%; -} - -img { - object-fit: cover; -} - -.reveal { - position: relative; - width: 100%; - height: 100%; -} - -.reveal img { - filter: blur(20px); - opacity: 0.4; -} - -.reveal-text-container { - position: absolute; - top: 15px; - left: 10px; - right: 10px; - bottom: 10px; - display: flex; - flex-direction: column; - justify-content: flex-start; -} - -.reveal-text { - color: white; - text-align: center; - font-weight: bold; -} - -.reveal-interaction { - opacity: 0.8; -} - -.reveal.revealed img { - filter: none; - opacity: 1; -} - -.reveal.revealed .reveal-text { - display: none; -} - -.sidebar .image-container { - max-width: 350px; -} - -/* Grid listings */ - -.grid-listing { - display: flex; - flex-wrap: wrap; - justify-content: center; - align-items: flex-start; - padding: 5px 15px; -} - -.grid-item { - font-size: 0.9em; -} - -.grid-item { - display: inline-block; - text-align: center; - background-color: #111111; - border: 1px dotted var(--primary-color); - border-radius: 2px; - padding: 5px; - margin: 10px; -} - -.grid-item img { - width: 100%; - height: 100% !important; - margin-top: auto; - margin-bottom: auto; -} - -.grid-item:hover { - text-decoration: none; -} - -.grid-actions .grid-item:hover { - text-decoration: underline; -} - -.grid-item > span { - display: block; - overflow-wrap: break-word; - hyphens: auto; -} - -.grid-item > span:not(:first-child) { - margin-top: 2px; -} - -.grid-item > span:first-of-type { - margin-top: 6px; -} - -.grid-item > span:not(:first-of-type) { - font-size: 0.9em; - opacity: 0.8; -} - -.grid-item:hover > span:first-of-type { - text-decoration: underline; -} - -.grid-listing > .grid-item { - flex: 1 25%; - max-width: 200px; -} - -.grid-actions { - display: flex; - flex-direction: row; - margin: 15px; - align-self: center; - flex-wrap: wrap; - justify-content: center; -} - -.grid-actions > .grid-item { - flex-basis: unset !important; - margin: 5px; - width: 120px; - --primary-color: inherit !important; - --dim-color: inherit !important; -} - -/* Carousel */ - -.carousel-container { - --carousel-tile-min-width: 120px; - --carousel-row-count: 3; - --carousel-column-count: 6; - - position: relative; - overflow: hidden; - margin: 20px 0 5px 0; - padding: 8px 0; -} - -.carousel-container::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: -20; - background-color: var(--dim-color); - filter: brightness(0.6); -} - -.carousel-container::after { - content: ""; - pointer-events: none; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - border: 1px solid var(--primary-color); - border-radius: 4px; - z-index: 40; - box-shadow: - inset 20px 2px 40px var(--shadow-color), - inset -20px -2px 40px var(--shadow-color); -} - -.carousel-container:hover .carousel-grid { - animation-play-state: running; -} - -html[data-url-key="localized.home"] .carousel-container { - --carousel-tile-size: 140px; -} - -.carousel-container[data-carousel-rows="1"] { --carousel-row-count: 1; } -.carousel-container[data-carousel-rows="2"] { --carousel-row-count: 2; } -.carousel-container[data-carousel-rows="3"] { --carousel-row-count: 3; } -.carousel-container[data-carousel-columns="4"] { --carousel-column-count: 4; } -.carousel-container[data-carousel-columns="5"] { --carousel-column-count: 5; } -.carousel-container[data-carousel-columns="6"] { --carousel-column-count: 6; } - -.carousel-grid:nth-child(2), -.carousel-grid:nth-child(3) { - position: absolute; - top: 8px; - left: 0; - right: 0; -} - -.carousel-grid:nth-child(2) { - animation-name: carousel-marquee2; -} - -.carousel-grid:nth-child(3) { - animation-name: carousel-marquee3; -} - -@keyframes carousel-marquee1 { - 0% { - transform: translateX(-100%) translateX(70px); - } - - 100% { - transform: translateX(-200%) translateX(70px); - } -} - -@keyframes carousel-marquee2 { - 0% { - transform: translateX(0%) translateX(70px); - } - - 100% { - transform: translateX(-100%) translateX(70px); - } -} - -@keyframes carousel-marquee3 { - 0% { - transform: translateX(100%) translateX(70px); - } - - 100% { - transform: translateX(0%) translateX(70px); - } -} - -.carousel-grid { - /* Thanks to: https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */ - --carousel-gap-count: calc(var(--carousel-column-count) - 1); - --carousel-total-gap-width: calc(var(--carousel-gap-count) * 10px); - --carousel-calculated-tile-max-width: calc((100% - var(--carousel-total-gap-width)) / var(--carousel-column-count)); - - display: grid; - grid-template-columns: repeat(auto-fill, minmax(max(var(--carousel-tile-min-width), var(--carousel-calculated-tile-max-width)), 1fr)); - grid-template-rows: repeat(var(--carousel-row-count), auto); - grid-auto-flow: dense; - grid-auto-rows: 0; - overflow: hidden; - margin: auto; - - transform: translateX(0); - animation: carousel-marquee1 40s linear infinite; - animation-play-state: paused; - z-index: 5; -} - -.carousel-item { - display: inline-block; - margin: 0; - flex: 1 1 150px; - padding: 3px; - border-radius: 10px; - filter: brightness(0.8); -} - -.carousel-item .image-container { - border: none; - padding: 0; -} - -.carousel-item img { - width: 100%; - height: 100%; - margin-top: auto; - margin-bottom: auto; - border-radius: 6px; -} - -.carousel-item:hover { - filter: brightness(1); - background: var(--dim-color); -} - -/* Squares */ - -.square { - position: relative; - width: 100%; -} - -.square::after { - content: ""; - display: block; - padding-bottom: 100%; -} - -.square-content { - position: absolute; - width: 100%; - height: 100%; -} - -/* Info card */ - -#info-card-container { - position: absolute; - - left: 0; - right: 10px; - - pointer-events: none; /* Padding area shouldn't 8e interactive. */ - display: none; -} - -#info-card-container.show, -#info-card-container.hide { - display: flex; -} - -#info-card-container > * { - flex-basis: 400px; -} - -#info-card-container.show { - animation: 0.2s linear forwards info-card-show; - transition: top 0.1s, left 0.1s; -} - -#info-card-container.hide { - animation: 0.2s linear forwards info-card-hide; -} - -@keyframes info-card-show { - 0% { - opacity: 0; - margin-top: -5px; - } - - 100% { - opacity: 1; - margin-top: 0; - } -} - -@keyframes info-card-hide { - 0% { - opacity: 1; - margin-top: 0; - } - - 100% { - opacity: 0; - margin-top: 5px; - display: none !important; - } -} - -.info-card-decor { - padding-left: 3ch; - border-top: 1px solid white; -} - -.info-card { - background-color: black; - color: white; - - border: 1px dotted var(--primary-color); - border-radius: 3px; - box-shadow: 0 5px 5px black; - - padding: 5px; - font-size: 0.9em; - - pointer-events: none; -} - -.info-card::after { - content: ""; - display: block; - clear: both; -} - -#info-card-container.show .info-card { - animation: 0.01s linear 0.2s forwards info-card-become-interactive; -} - -@keyframes info-card-become-interactive { - to { - pointer-events: auto; - } -} - -.info-card-art-container { - float: right; - - width: 40%; - margin: 5px; - font-size: 0.8em; - - /* Dynamically shown. */ - display: none; -} - -.info-card-art-container .image-container { - padding: 2px; -} - -.info-card-art { - display: block; - width: 100%; - height: 100%; -} - -.info-card-name { - font-size: 1em; - border-bottom: 1px dotted; - margin: 0; -} - -.info-card p { - margin-top: 0.25em; - margin-bottom: 0.25em; -} - -.info-card p:last-child { - margin-bottom: 0; -} - -/* Custom hash links */ - -.content-heading { - border-bottom: 3px double transparent; - margin-bottom: -3px; -} - -.content-heading.highlight-hash-link { - animation: highlight-hash-link 4s; - animation-delay: 125ms; -} - -h3.content-heading { - clear: both; -} - -/* This animation's name is referenced in JavaScript */ -@keyframes highlight-hash-link { - 0% { - border-bottom-color: transparent; - } - - 10% { - border-bottom-color: white; - } - - 25% { - border-bottom-color: white; - } - - 100% { - border-bottom-color: transparent; - } -} - -/* Sticky heading */ - -[id] { - --custom-scroll-offset: 0px; -} - -#content [id] { - /* Adjust scroll margin. */ - scroll-margin-top: calc( - 74px /* Sticky heading */ - + 33px /* Sticky subheading */ - - 1em /* One line of text (align bottom) */ - - 12px /* Padding for hanging letters & focus ring */ - + var(--custom-scroll-offset) /* Customizable offset */ - ); -} - -.content-sticky-heading-container { - position: sticky; - top: 0; - - margin: calc(-1 * var(--content-padding)); - margin-bottom: calc(0.5 * var(--content-padding)); - - transform: translateY(-5px); -} - -main.long-content .content-sticky-heading-container { - padding-left: 0; - padding-right: 0; -} - -main.long-content .content-sticky-heading-container .content-sticky-heading-row, -main.long-content .content-sticky-heading-container .content-sticky-subheading-row { - padding-left: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); - padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); -} - -.content-sticky-heading-row { - box-sizing: border-box; - padding: - calc(1.25 * var(--content-padding) + 5px) - 20px - calc(0.75 * var(--content-padding)) - 20px; - - width: 100%; - margin: 0; - - background: var(--bg-black-color); - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - - -webkit-backdrop-filter: blur(6px); - backdrop-filter: blur(6px); -} - -.content-sticky-heading-container.has-cover .content-sticky-heading-row, -.content-sticky-heading-container.has-cover .content-sticky-subheading-row { - display: grid; - grid-template-areas: - "title cover"; - grid-template-columns: 1fr min(40%, 400px); -} - -.content-sticky-heading-row h1 { - margin: 0; - padding-right: 10px; -} - -.content-sticky-heading-cover-container { - position: relative; - height: 0; - margin: -15px 0px -5px -5px; -} - -.content-sticky-heading-cover-needs-reveal { - display: none; -} - -.content-sticky-heading-cover { - position: absolute; - top: 0; - width: 80px; - right: 10px; - box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.25); - transition: transform 0.35s, opacity 0.25s; -} - -.content-sticky-heading-cover-container:not(.visible) .content-sticky-heading-cover { - opacity: 0; - transform: translateY(15px); -} - -.content-sticky-heading-cover .image-container { - border-width: 1px; - padding: 2px; -} - -.content-sticky-heading-cover img { - display: block; - width: 100%; - height: 100%; -} - -.content-sticky-subheading-row { - position: absolute; - width: 100%; - box-sizing: border-box; - padding: 10px 40px 5px 20px; - margin-top: 0; - z-index: -1; - - background: var(--bg-black-color); - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); - - transition: margin-top 0.35s, opacity 0.25s; -} - -.content-sticky-subheading-row h2 { - margin: 0; - - font-size: 0.9em !important; - font-weight: normal; - font-style: oblique; - color: #eee; -} - -.content-sticky-subheading-row:not(.visible) { - margin-top: -20px; - opacity: 0; -} - -.content-sticky-heading-container h2.visible { - margin-top: 0; - opacity: 1; -} - -.content-sticky-heading-row { - box-shadow: - inset 0 10px 10px -5px var(--shadow-color), - 0 4px 4px rgba(0, 0, 0, 0.8); -} - -.content-sticky-heading-container h2.visible { - box-shadow: - inset 0 10px 10px -5px var(--shadow-color), - 0 4px 4px rgba(0, 0, 0, 0.8); -} - -#content, .sidebar { - contain: paint; -} - -/* Sticky sidebar */ - -.sidebar-column.sidebar.sticky-column, -.sidebar-column.sidebar.sticky-last, -.sidebar-multiple.sticky-last > .sidebar:last-child, -.sidebar-multiple.sticky-column { - position: sticky; - top: 10px; -} - -.sidebar-multiple.sticky-last { - align-self: stretch; -} - -.sidebar-multiple.sticky-column { - align-self: flex-start; -} - -.sidebar-column.sidebar.sticky-column { - max-height: calc(100vh - 20px); - align-self: start; - padding-bottom: 0; - box-sizing: border-box; - flex-basis: 275px; - padding-top: 0; - overflow-y: scroll; - scrollbar-width: thin; - scrollbar-color: var(--dark-color); -} - -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { - background: var(--dark-color); - width: 12px; -} - -.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { - transition: background 0.2s; - background: rgba(255, 255, 255, 0.2); - border: 3px solid transparent; - border-radius: 10px; - background-clip: content-box; -} - -.sidebar-column.sidebar.sticky-column > h1 { - position: sticky; - top: 0; - margin: 0 calc(-1 * var(--content-padding)); - margin-bottom: 10px; - - border-bottom: 1px dotted rgba(220, 220, 220, 0.4); - padding: 10px 5px; - - background: var(--bg-black-color); - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); -} - -/* Image overlay */ - -#image-overlay-container { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 20px 40px; - box-sizing: border-box; - - opacity: 0; - pointer-events: none; - transition: opacity 0.4s; - - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; -} - -#image-overlay-container.visible { - opacity: 1; - pointer-events: auto; -} - -#image-overlay-content-container { - border-radius: 0 0 8px 8px; - border: 2px solid var(--primary-color); - background: var(--dim-ghost-color); - padding: 3px; - overflow: hidden; - - -webkit-backdrop-filter: blur(3px); - backdrop-filter: blur(3px); -} - -#image-overlay-image-container { - display: block; - position: relative; - overflow: hidden; - width: 80vmin; - height: 80vmin; -} - -#image-overlay-image, -#image-overlay-image-thumb { - display: inline-block; - object-fit: contain; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.65); -} - -#image-overlay-image { - position: absolute; - top: 0; - left: 0; -} - -#image-overlay-image-thumb { - filter: blur(16px); - transform: scale(1.5); -} - -#image-overlay-container.loaded #image-overlay-image-thumb { - opacity: 0; - pointer-events: none; - transition: opacity 0.25s; -} - -#image-overlay-image-container::after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - height: 4px; - width: var(--download-progress); - background: var(--primary-color); - box-shadow: 0 -3px 12px 4px var(--primary-color); - transition: 0.25s; -} - -#image-overlay-container.loaded #image-overlay-image-container::after { - width: 100%; - background: white; - opacity: 0; -} - -#image-overlay-container.errored #image-overlay-image-container::after { - width: 100%; - background: red; -} - -#image-overlay-container:not(.visible) #image-overlay-image-container::after { - width: 0 !important; -} - -#image-overlay-action-container { - padding: 4px 4px 6px 4px; - border-radius: 0 0 5px 5px; - background: var(--bg-black-color); - color: white; - font-style: oblique; - text-align: center; -} - -#image-overlay-container #image-overlay-action-content-without-size:not(.visible), -#image-overlay-container #image-overlay-action-content-with-size:not(.visible), -#image-overlay-container #image-overlay-file-size-warning:not(.visible), -#image-overlay-container #image-overlay-file-size-kilobytes:not(.visible), -#image-overlay-container #image-overlay-file-size-megabytes:not(.visible) { - display: none; -} - -#image-overlay-file-size-warning { - opacity: 0.8; - font-size: 0.9em; -} - -/* important easter egg mode */ - -html[data-language-code="preview-en"][data-url-key="localized.home"] #content - h1::after { - font-family: cursive; - display: block; - content: "(Preview Build)"; - font-size: 0.8em; -} - -/* Layout - Wide (most computers) */ - -@media (min-width: 900px) { - #page-container:not(.has-zero-sidebars) #secondary-nav { - display: none; - } -} - -/* Layout - Medium (tablets, some landscape mobiles) - * - * Note: Rules defined here are exclusive to "medium" width, i.e. they don't - * additionally apply to "thin". Use the later section which applies to both - * if so desired. - */ - -@media (min-width: 600px) and (max-width: 899.98px) { - /* Medim layout is mainly defined (to the user) by hiding the sidebar, so - * don't apply the similar layout change of widening the long-content area - * if this page doesn't have a sidebar to hide in the first place. - */ - #page-container:not(.has-zero-sidebars) main.long-content { - --long-content-padding-ratio: 0.06; - } -} - -/* Layout - Wide or Medium */ - -@media (min-width: 600px) { - .content-sticky-heading-container { - /* Safari doesn't always play nicely with position: sticky, - * this seems to fix images sometimes displaying above the - * position: absolute subheading (h2) child - * - * See also: https://stackoverflow.com/questions/50224855/ - */ - transform: translate3d(0, 0, 0); - z-index: 1; - } - - /* Cover art floats to the right. It's positioned in HTML beneath the - * heading, so pull it up a little to "float" on top. - */ - #cover-art-container { - float: right; - width: 40%; - max-width: 400px; - margin: -60px 0 10px 20px; - - position: relative; - z-index: 2; - } - - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+10)) { - flex-basis: 23%; - margin: 15px; - } - - html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+10) { - flex-basis: 18%; - margin: 10px; - } -} - -/* Layout - Medium or Thin */ - -@media (max-width: 899.98px) { - .sidebar-column:not(.no-hide) { - display: none; - } - - #secondary-nav { - display: block; - } - - .layout-columns.vertical-when-thin { - flex-direction: column; - } - - .layout-columns.vertical-when-thin > *:not(:last-child) { - margin-bottom: 10px; - } - - .sidebar-column.no-hide { - max-width: unset !important; - flex-basis: unset !important; - margin-right: 0 !important; - margin-left: 0 !important; - width: 100%; - } - - .sidebar .news-entry:not(.first-news-entry) { - display: none; - } - - .grid-listing > .grid-item { - flex-basis: 40%; - } -} - -/* Layout - Thin (phones) */ - -@media (max-width: 600px) { - .content-columns { - columns: 1; - } - - main.long-content { - --long-content-padding-ratio: 0.02; - } - - #cover-art-container { - margin: 25px 0 5px 0; - width: 100%; - max-width: unset; - } - - #additional-names-box { - max-width: unset; - } - - /* Show sticky heading above cover art */ - - .content-sticky-heading-container { - z-index: 2; - } - - /* Let sticky heading text span past lower-index cover art */ - - .content-sticky-heading-container.has-cover .content-sticky-heading-row, - .content-sticky-heading-container.has-cover .content-sticky-subheading-row { - grid-template-columns: 1fr 90px; - } - - /* Disable grid features, just line header children up vertically */ - - #header { - display: block; - } - - #header > div:not(:first-child) { - margin-top: 0.5em; - } -} diff --git a/src/static/site6.css b/src/static/site6.css new file mode 100644 index 00000000..4c083527 --- /dev/null +++ b/src/static/site6.css @@ -0,0 +1,1950 @@ +/* A frontend file! Wow. + * This file is just loaded statically 8y s in the HTML files, so there's + * no need to re-run upd8.js when tweaking values here. Handy! + */ + +/* Layout - Common */ + +body { + margin: 10px; + overflow-y: scroll; +} + +body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: -1; + + /* NB: these are 100 LVW, "largest view width", etc. + * Stabilizes background on viewports with modal dimensions, + * e.g. expanding/shrinking tab bar or collapsible find bar. + * 100% dimensions are kept above for browser compatibility. + */ + width: 100lvw; + height: 100lvh; +} + +#page-container { + max-width: 1100px; + margin: 10px auto 50px; + padding: 15px 0; +} + +#page-container > * { + margin-left: 15px; + margin-right: 15px; +} + +#skippers:focus-within { + position: static; + width: unset; + height: unset; +} + +#banner { + margin: 10px 0; + width: 100%; + position: relative; +} + +#banner::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +#banner img { + display: block; + width: 100%; + height: auto; +} + +#skippers { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; +} + +.layout-columns { + display: flex; + align-items: stretch; +} + +#header, +#secondary-nav, +#skippers, +#footer { + padding: 5px; +} + +#header, +#secondary-nav, +#skippers { + margin-bottom: 10px; +} + +#footer { + margin-top: 10px; +} + +#header { + display: grid; +} + +#header.nav-has-main-links.nav-has-content { + grid-template-columns: 2.5fr 3fr; + grid-template-rows: min-content 1fr; + grid-template-areas: + "main-links content" + "bottom-row content"; +} + +#header.nav-has-main-links:not(.nav-has-content) { + grid-template-columns: 1fr; + grid-template-areas: + "main-links" + "bottom-row"; +} + +.nav-main-links { + grid-area: main-links; + margin-right: 20px; +} + +.nav-content { + grid-area: content; +} + +.nav-bottom-row { + grid-area: bottom-row; + align-self: start; +} + +.sidebar-column { + flex: 1 1 20%; + min-width: 150px; + max-width: 250px; + flex-basis: 250px; + align-self: flex-start; +} + +.sidebar-column.wide { + max-width: 350px; + flex-basis: 300px; + flex-shrink: 0; + flex-grow: 1; +} + +.sidebar-multiple { + display: flex; + flex-direction: column; +} + +.sidebar-multiple .sidebar:not(:first-child) { + margin-top: 15px; +} + +.sidebar { + --content-padding: 5px; + padding: var(--content-padding); +} + +#sidebar-left { + margin-right: 10px; +} + +#sidebar-right { + margin-left: 10px; +} + +#content { + position: relative; + --content-padding: 20px; + box-sizing: border-box; + padding: var(--content-padding); + flex-grow: 1; + flex-shrink: 3; +} + +.footer-content { + margin: 5px 12%; +} + +.footer-content > :first-child { + margin-top: 0; +} + +.footer-content > :last-child { + margin-bottom: 0; +} + +.footer-localization-links { + margin: 5px 12%; +} + +/* Design & Appearance - Layout elements */ + +body { + background: black; +} + +body::before { + background-image: url("../media/bg.jpg"); + background-position: center; + background-size: cover; + opacity: 0.5; +} + +#page-container { + background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); + color: #ffffff; + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); +} + +#skippers > * { + display: inline-block; +} + +#skippers > .skipper-list:not(:last-child)::after { + display: inline-block; + content: "\00a0"; + margin-left: 2px; + margin-right: -2px; + border-left: 1px dotted; +} + +#skippers .skipper-list > .skipper:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +#banner { + background: black; + background-color: var(--dim-color); + border-bottom: 1px solid var(--primary-color); +} + +#banner::after { + box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.35); + pointer-events: none; +} + +#banner.dim img { + opacity: 0.8; +} + +#header, +#secondary-nav, +#skippers, +#footer, +.sidebar { + font-size: 0.85em; +} + +.sidebar, +#content, +#header, +#secondary-nav, +#skippers, +#footer { + background-color: rgba(0, 0, 0, 0.6); + border: 1px dotted var(--primary-color); + border-radius: 3px; + transition: background-color 0.2s; +} + +/* +.sidebar:focus-within, +#content:focus-within, +#header:focus-within, +#secondary-nav:focus-within, +#skippers:focus-within, +#footer:focus-within { + background-color: rgba(0, 0, 0, 0.85); + border-style: solid; +} +*/ + +.sidebar > h1, +.sidebar > h2, +.sidebar > h3, +.sidebar > p { + text-align: center; + padding-left: 4px; + padding-right: 4px; +} + +.sidebar h1 { + font-size: 1.25em; +} + +.sidebar h2 { + font-size: 1.1em; + margin: 0; +} + +.sidebar h3 { + font-size: 1.1em; + font-style: oblique; + font-variant: small-caps; + margin-top: 0.3em; + margin-bottom: 0em; +} + +.sidebar > p { + margin: 0.5em 0; + padding: 0 5px; +} + +.sidebar hr { + color: #555; + margin: 10px 5px; +} + +.sidebar > ol, +.sidebar > ul { + padding-left: 30px; + padding-right: 15px; +} + +.sidebar > dl { + padding-right: 15px; + padding-left: 0; +} + +.sidebar > dl dt { + padding-left: 10px; + margin-top: 0.5em; +} + +.sidebar > dl dt.current { + font-weight: 800; +} + +.sidebar > dl dd { + margin-left: 0; +} + +.sidebar > dl dd ul { + padding-left: 30px; + margin-left: 0; +} + +.sidebar > dl .side { + padding-left: 10px; +} + +.sidebar li.current { + font-weight: 800; +} + +.sidebar li { + overflow-wrap: break-word; +} + +.sidebar > details.current summary { + font-weight: 800; +} + +.sidebar > details summary { + margin-top: 0.5em; + padding-left: 5px; +} + +summary > span:hover { + cursor: pointer; + text-decoration: underline; + text-decoration-color: var(--primary-color); +} + +summary .group-name { + color: var(--primary-color); +} + +.sidebar > details ul, +.sidebar > details ol { + margin-top: 0; + margin-bottom: 0; +} + +.sidebar > details:last-child { + margin-bottom: 10px; +} + +.sidebar > details[open] { + margin-bottom: 1em; +} + +.sidebar article { + text-align: left; + margin: 5px 5px 15px 5px; +} + +.sidebar article:last-child { + margin-bottom: 5px; +} + +.sidebar article h2, +.news-index h2 { + border-bottom: 1px dotted; +} + +.sidebar article h2 time, +.news-index time { + float: right; + font-weight: normal; +} + +#content { + overflow-wrap: anywhere; +} + +footer { + text-align: center; + font-style: oblique; +} + +.footer-localization-links > span:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +/* Design & Appearance - Content elements */ + +a { + color: var(--primary-color); + text-decoration: none; +} + +a:hover { + text-decoration: underline; + text-decoration-style: solid !important; +} + +a.current { + font-weight: 800; +} + +a:not([href]) { + cursor: default; +} + +a:not([href]):hover { + text-decoration: none; +} + +.nav-main-links > span > span { + white-space: nowrap; +} + +.nav-main-links > span.current > span.nav-link-content > a { + font-weight: 800; +} + +.nav-links-index > span:not(:first-child):not(.no-divider)::before, +.nav-links-groups > span:not(:first-child):not(.no-divider)::before { + content: "\0020\00b7\0020"; + font-weight: 800; +} + +.nav-links-hierarchical > span:not(:first-child):not(.no-divider)::before { + content: "\0020/\0020"; +} + +#header .chronology .heading, +#header .chronology .buttons { + white-space: nowrap; +} + +#secondary-nav { + text-align: center; +} + +.nowrap { + white-space: nowrap; +} + +.contribution { + position: relative; +} + +.contribution.has-tooltip > a { + text-decoration: underline; + text-decoration-style: dotted; +} + +.icons { + font-style: normal; + white-space: nowrap; +} + +.icons-tooltip { + position: absolute; + z-index: 3; + left: -36px; + top: calc(1em - 2px); + padding: 4px 12px 6px 8px; +} + +.icons-tooltip:not(.visible) { + display: none; +} + +.icons-tooltip-content { + display: block; + padding: 6px 2px 2px 2px; + background: var(--bg-black-color); + border: 1px dotted var(--primary-color); + border-radius: 6px; + + -webkit-backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + backdrop-filter: + brightness(1.5) saturate(1.4) blur(4px); + + -webkit-user-select: none; + user-select: none; + + box-shadow: + 0 3px 4px 4px #000000aa, + 0 -2px 4px -2px var(--primary-color) inset; + + cursor: default; +} + +.icons a:hover { + filter: brightness(1.4); +} + +.icons a { + padding: 0 3px; +} + +.icon { + display: inline-block; + width: 24px; + height: 1em; + position: relative; +} + +.icon > svg { + width: 24px; + height: 24px; + top: -0.25em; + position: absolute; + fill: var(--primary-color); +} + +.icon.has-text { + display: block; + width: unset; + height: 1.4em; +} + +.icon.has-text > svg { + width: 18px; + height: 18px; + top: -0.1em; +} + +.icon.has-text > .icon-text { + margin-left: 24px; + padding-right: 8px; +} + +.rerelease, +.other-group-accent { + opacity: 0.7; + font-style: oblique; +} + +.other-group-accent { + white-space: nowrap; +} + +.content-columns { + columns: 2; +} + +.content-columns .column { + break-inside: avoid; +} + +.content-columns .column h2 { + margin-top: 0; + font-size: 1em; +} + +p .current { + font-weight: 800; +} + +#cover-art-container { + font-size: 0.8em; +} + +#cover-art .square { + box-shadow: 0 0 3px 6px rgba(0, 0, 0, 0.35); +} + +#cover-art img { + display: block; + width: 100%; + height: 100%; +} + +#cover-art-container p { + margin-top: 5px; +} + +.commentary-entry-heading { + margin-left: 15px; + padding-left: 5px; + max-width: 625px; + padding-bottom: 0.2em; + border-bottom: 1px dotted var(--primary-color); +} + +.commentary-entry-accent { + font-style: oblique; +} + +.commentary-art { + float: right; + width: 30%; + max-width: 250px; + margin: 15px 0 10px 20px; + box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.35); +} + +.js-hide, +.js-show-once-data, +.js-hide-once-data { + display: none; +} + +.content-image { + margin-top: 1em; + margin-bottom: 1em; +} + +a.box:focus { + outline: 3px double var(--primary-color); +} + +a.box:focus:not(:focus-visible) { + outline: none; +} + +a.box img { + display: block; + max-width: 100%; + height: auto; +} + +.square .image-container { + width: 100%; + height: 100%; +} + +h1 { + font-size: 1.5em; +} + +#content li { + margin-bottom: 4px; +} + +#content li i { + white-space: nowrap; +} + +#content.top-index h1, +#content.flash-index h1 { + text-align: center; + font-size: 2em; +} + +html[data-url-key="localized.home"] #content h1 { + text-align: center; + font-size: 2.5em; +} + +#content.flash-index h2 { + text-align: center; + font-size: 2.5em; + font-variant: small-caps; + font-style: oblique; + margin-bottom: 0; + text-align: center; + width: 100%; +} + +#content.top-index h2 { + text-align: center; + font-size: 2em; + font-weight: normal; + margin-bottom: 0.25em; +} + +.quick-info { + text-align: center; +} + +ul.quick-info { + list-style: none; + padding-left: 0; +} + +ul.quick-info li { + display: inline-block; +} + +ul.quick-info li:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + +.carousel-container + .quick-info { + margin-top: 25px; +} + +#intro-menu { + margin: 24px 0; + padding: 10px; + background-color: #222222; + text-align: center; + border: 1px dotted var(--primary-color); + border-radius: 2px; +} + +#intro-menu p { + margin: 12px 0; +} + +#intro-menu a { + margin: 0 6px; +} + +li .by { + display: inline-block; + font-style: oblique; +} + +li .by a { + display: inline-block; +} + +p code { + font-size: 1em; + font-family: "courier new"; + font-weight: 800; +} + +#content blockquote { + margin-left: 40px; + max-width: 600px; + margin-right: 0; +} + +#content blockquote blockquote { + margin-left: 10px; + padding-left: 10px; + margin-right: 20px; + border-left: dotted 1px; + padding-top: 6px; + padding-bottom: 6px; +} + +#content blockquote blockquote > :first-child { + margin-top: 0; +} + +#content blockquote blockquote > :last-child { + margin-bottom: 0; +} + +main.long-content { + --long-content-padding-ratio: 0.10; +} + +main.long-content .main-content-container, +main.long-content > h1 { + padding-left: calc(var(--long-content-padding-ratio) * 100%); + padding-right: calc(var(--long-content-padding-ratio) * 100%); +} + +dl dt { + padding-left: 40px; + max-width: 600px; +} + +dl dt { + margin-bottom: 2px; +} + +dl dd { + margin-bottom: 1em; +} + +dl ul, +dl ol { + margin-top: 0; + margin-bottom: 0; +} + +ul > li.has-details { + list-style-type: none; + margin-left: -17px; +} + +.album-group-list dt { + font-style: oblique; + padding-left: 0; +} + +.album-group-list dd { + margin-left: 0; +} + +.group-chronology-link { + font-style: oblique; +} + +#content hr { + border: 1px inset #808080; + width: 100%; +} + +#content hr.split::before { + content: "(split)"; + color: #808080; +} + +#content hr.split { + position: relative; + overflow: hidden; + border: none; +} + +#content hr.split::after { + display: inline-block; + content: ""; + border: 1px inset #808080; + width: 100%; + position: absolute; + top: 50%; + margin-top: -2px; + margin-left: 10px; +} + +li > ul { + margin-top: 5px; +} + +.group-contributions-table { + display: inline-block; +} + +.group-contributions-table .group-contributions-row { + display: flex; + justify-content: space-between; +} + +.group-contributions-table .group-contributions-metrics { + margin-left: 1.5ch; + white-space: nowrap; +} + +.group-contributions-sorted-by-count:not(.visible), +.group-contributions-sorted-by-duration:not(.visible) { + display: none; +} + +html[data-url-key="localized.albumCommentary"] li.no-commentary { + opacity: 0.7; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-main-title { + margin-right: 0.25em; +} + +html[data-url-key="localized.albumCommentary"] .content-heading-accent { + font-weight: normal; + font-style: oblique; + font-size: 0.9rem; + display: inline-block; +} + +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loading-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-loaded-line, +html[data-url-key="localized.listing"][data-url-value0="random"] #data-error-line { + display: none; +} + +html[data-url-key="localized.listing"][data-url-value0="random"] #content a:not([href]) { + opacity: 0.7; +} + +/* Additional names (heading and box) */ + +h1 a[href="#additional-names-box"] { + color: inherit; + text-decoration: underline; + text-decoration-style: dotted; +} + +h1 a[href="#additional-names-box"]:hover { + text-decoration-style: solid; +} + +#additional-names-box { + --custom-scroll-offset: calc(0.5em - 2px); + + margin: 1em 0 1em -10px; + padding: 15px 20px 10px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + + display: none; +} + +#additional-names-box > :first-child { margin-top: 0; } +#additional-names-box > :last-child { margin-bottom: 0; } + +#additional-names-box p { + padding-left: 10px; + padding-right: 10px; + margin-bottom: 0; + font-style: oblique; +} + +#additional-names-box ul { + padding-left: 10px; + margin-top: 0.5em; +} + +#additional-names-box li .additional-name { + margin-right: 0.25em; +} + +#additional-names-box li .additional-name .content-image { + margin-bottom: 0.25em; + margin-top: 0.5em; +} + +#additional-names-box li .annotation { + opacity: 0.8; + display: inline-block; +} + +/* Images */ + +.image-container { + border: 2px solid var(--primary-color); + box-sizing: border-box; + position: relative; + padding: 5px; + text-align: left; + background-color: var(--dim-color); + color: white; + display: inline-block; + height: 100%; +} + +.image-text-area { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 5px 15px; + background: rgba(0, 0, 0, 0.65); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.5) inset; + line-height: 1.35em; + color: var(--primary-color); + font-style: oblique; + text-shadow: 0 2px 5px rgba(0, 0, 0, 0.75); +} + +.image-inner-area { + width: 100%; + height: 100%; +} + +img { + object-fit: cover; +} + +.reveal { + position: relative; + width: 100%; + height: 100%; +} + +.reveal img { + filter: blur(20px); + opacity: 0.4; +} + +.reveal-text-container { + position: absolute; + top: 15px; + left: 10px; + right: 10px; + bottom: 10px; + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.reveal-text { + color: white; + text-align: center; + font-weight: bold; +} + +.reveal-interaction { + opacity: 0.8; +} + +.reveal.revealed img { + filter: none; + opacity: 1; +} + +.reveal.revealed .reveal-text { + display: none; +} + +.sidebar .image-container { + max-width: 350px; +} + +/* Grid listings */ + +.grid-listing { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + padding: 5px 15px; +} + +.grid-item { + font-size: 0.9em; +} + +.grid-item { + display: inline-block; + text-align: center; + background-color: #111111; + border: 1px dotted var(--primary-color); + border-radius: 2px; + padding: 5px; + margin: 10px; +} + +.grid-item img { + width: 100%; + height: 100% !important; + margin-top: auto; + margin-bottom: auto; +} + +.grid-item:hover { + text-decoration: none; +} + +.grid-actions .grid-item:hover { + text-decoration: underline; +} + +.grid-item > span { + display: block; + overflow-wrap: break-word; + hyphens: auto; +} + +.grid-item > span:not(:first-child) { + margin-top: 2px; +} + +.grid-item > span:first-of-type { + margin-top: 6px; +} + +.grid-item > span:not(:first-of-type) { + font-size: 0.9em; + opacity: 0.8; +} + +.grid-item:hover > span:first-of-type { + text-decoration: underline; +} + +.grid-listing > .grid-item { + flex: 1 25%; + max-width: 200px; +} + +.grid-actions { + display: flex; + flex-direction: row; + margin: 15px; + align-self: center; + flex-wrap: wrap; + justify-content: center; +} + +.grid-actions > .grid-item { + flex-basis: unset !important; + margin: 5px; + width: 120px; + --primary-color: inherit !important; + --dim-color: inherit !important; +} + +/* Carousel */ + +.carousel-container { + --carousel-tile-min-width: 120px; + --carousel-row-count: 3; + --carousel-column-count: 6; + + position: relative; + overflow: hidden; + margin: 20px 0 5px 0; + padding: 8px 0; +} + +.carousel-container::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: -20; + background-color: var(--dim-color); + filter: brightness(0.6); +} + +.carousel-container::after { + content: ""; + pointer-events: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 1px solid var(--primary-color); + border-radius: 4px; + z-index: 40; + box-shadow: + inset 20px 2px 40px var(--shadow-color), + inset -20px -2px 40px var(--shadow-color); +} + +.carousel-container:hover .carousel-grid { + animation-play-state: running; +} + +html[data-url-key="localized.home"] .carousel-container { + --carousel-tile-size: 140px; +} + +.carousel-container[data-carousel-rows="1"] { --carousel-row-count: 1; } +.carousel-container[data-carousel-rows="2"] { --carousel-row-count: 2; } +.carousel-container[data-carousel-rows="3"] { --carousel-row-count: 3; } +.carousel-container[data-carousel-columns="4"] { --carousel-column-count: 4; } +.carousel-container[data-carousel-columns="5"] { --carousel-column-count: 5; } +.carousel-container[data-carousel-columns="6"] { --carousel-column-count: 6; } + +.carousel-grid:nth-child(2), +.carousel-grid:nth-child(3) { + position: absolute; + top: 8px; + left: 0; + right: 0; +} + +.carousel-grid:nth-child(2) { + animation-name: carousel-marquee2; +} + +.carousel-grid:nth-child(3) { + animation-name: carousel-marquee3; +} + +@keyframes carousel-marquee1 { + 0% { + transform: translateX(-100%) translateX(70px); + } + + 100% { + transform: translateX(-200%) translateX(70px); + } +} + +@keyframes carousel-marquee2 { + 0% { + transform: translateX(0%) translateX(70px); + } + + 100% { + transform: translateX(-100%) translateX(70px); + } +} + +@keyframes carousel-marquee3 { + 0% { + transform: translateX(100%) translateX(70px); + } + + 100% { + transform: translateX(0%) translateX(70px); + } +} + +.carousel-grid { + /* Thanks to: https://css-tricks.com/an-auto-filling-css-grid-with-max-columns/ */ + --carousel-gap-count: calc(var(--carousel-column-count) - 1); + --carousel-total-gap-width: calc(var(--carousel-gap-count) * 10px); + --carousel-calculated-tile-max-width: calc((100% - var(--carousel-total-gap-width)) / var(--carousel-column-count)); + + display: grid; + grid-template-columns: repeat(auto-fill, minmax(max(var(--carousel-tile-min-width), var(--carousel-calculated-tile-max-width)), 1fr)); + grid-template-rows: repeat(var(--carousel-row-count), auto); + grid-auto-flow: dense; + grid-auto-rows: 0; + overflow: hidden; + margin: auto; + + transform: translateX(0); + animation: carousel-marquee1 40s linear infinite; + animation-play-state: paused; + z-index: 5; +} + +.carousel-item { + display: inline-block; + margin: 0; + flex: 1 1 150px; + padding: 3px; + border-radius: 10px; + filter: brightness(0.8); +} + +.carousel-item .image-container { + border: none; + padding: 0; +} + +.carousel-item img { + width: 100%; + height: 100%; + margin-top: auto; + margin-bottom: auto; + border-radius: 6px; +} + +.carousel-item:hover { + filter: brightness(1); + background: var(--dim-color); +} + +/* Squares */ + +.square { + position: relative; + width: 100%; +} + +.square::after { + content: ""; + display: block; + padding-bottom: 100%; +} + +.square-content { + position: absolute; + width: 100%; + height: 100%; +} + +/* Info card */ + +#info-card-container { + position: absolute; + + left: 0; + right: 10px; + + pointer-events: none; /* Padding area shouldn't 8e interactive. */ + display: none; +} + +#info-card-container.show, +#info-card-container.hide { + display: flex; +} + +#info-card-container > * { + flex-basis: 400px; +} + +#info-card-container.show { + animation: 0.2s linear forwards info-card-show; + transition: top 0.1s, left 0.1s; +} + +#info-card-container.hide { + animation: 0.2s linear forwards info-card-hide; +} + +@keyframes info-card-show { + 0% { + opacity: 0; + margin-top: -5px; + } + + 100% { + opacity: 1; + margin-top: 0; + } +} + +@keyframes info-card-hide { + 0% { + opacity: 1; + margin-top: 0; + } + + 100% { + opacity: 0; + margin-top: 5px; + display: none !important; + } +} + +.info-card-decor { + padding-left: 3ch; + border-top: 1px solid white; +} + +.info-card { + background-color: black; + color: white; + + border: 1px dotted var(--primary-color); + border-radius: 3px; + box-shadow: 0 5px 5px black; + + padding: 5px; + font-size: 0.9em; + + pointer-events: none; +} + +.info-card::after { + content: ""; + display: block; + clear: both; +} + +#info-card-container.show .info-card { + animation: 0.01s linear 0.2s forwards info-card-become-interactive; +} + +@keyframes info-card-become-interactive { + to { + pointer-events: auto; + } +} + +.info-card-art-container { + float: right; + + width: 40%; + margin: 5px; + font-size: 0.8em; + + /* Dynamically shown. */ + display: none; +} + +.info-card-art-container .image-container { + padding: 2px; +} + +.info-card-art { + display: block; + width: 100%; + height: 100%; +} + +.info-card-name { + font-size: 1em; + border-bottom: 1px dotted; + margin: 0; +} + +.info-card p { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + +.info-card p:last-child { + margin-bottom: 0; +} + +/* Custom hash links */ + +.content-heading { + border-bottom: 3px double transparent; + margin-bottom: -3px; +} + +.content-heading.highlight-hash-link { + animation: highlight-hash-link 4s; + animation-delay: 125ms; +} + +h3.content-heading { + clear: both; +} + +/* This animation's name is referenced in JavaScript */ +@keyframes highlight-hash-link { + 0% { + border-bottom-color: transparent; + } + + 10% { + border-bottom-color: white; + } + + 25% { + border-bottom-color: white; + } + + 100% { + border-bottom-color: transparent; + } +} + +/* Sticky heading */ + +[id] { + --custom-scroll-offset: 0px; +} + +#content [id] { + /* Adjust scroll margin. */ + scroll-margin-top: calc( + 74px /* Sticky heading */ + + 33px /* Sticky subheading */ + - 1em /* One line of text (align bottom) */ + - 12px /* Padding for hanging letters & focus ring */ + + var(--custom-scroll-offset) /* Customizable offset */ + ); +} + +.content-sticky-heading-container { + position: sticky; + top: 0; + + margin: calc(-1 * var(--content-padding)); + margin-bottom: calc(0.5 * var(--content-padding)); + + transform: translateY(-5px); +} + +main.long-content .content-sticky-heading-container { + padding-left: 0; + padding-right: 0; +} + +main.long-content .content-sticky-heading-container .content-sticky-heading-row, +main.long-content .content-sticky-heading-container .content-sticky-subheading-row { + padding-left: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); + padding-right: calc(var(--long-content-padding-ratio) * (100% - 2 * var(--content-padding)) + var(--content-padding)); +} + +.content-sticky-heading-row { + box-sizing: border-box; + padding: + calc(1.25 * var(--content-padding) + 5px) + 20px + calc(0.75 * var(--content-padding)) + 20px; + + width: 100%; + margin: 0; + + background: var(--bg-black-color); + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} + +.content-sticky-heading-container.has-cover .content-sticky-heading-row, +.content-sticky-heading-container.has-cover .content-sticky-subheading-row { + display: grid; + grid-template-areas: + "title cover"; + grid-template-columns: 1fr min(40%, 400px); +} + +.content-sticky-heading-row h1 { + margin: 0; + padding-right: 10px; +} + +.content-sticky-heading-cover-container { + position: relative; + height: 0; + margin: -15px 0px -5px -5px; +} + +.content-sticky-heading-cover-needs-reveal { + display: none; +} + +.content-sticky-heading-cover { + position: absolute; + top: 0; + width: 80px; + right: 10px; + box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.25); + transition: transform 0.35s, opacity 0.25s; +} + +.content-sticky-heading-cover-container:not(.visible) .content-sticky-heading-cover { + opacity: 0; + transform: translateY(15px); +} + +.content-sticky-heading-cover .image-container { + border-width: 1px; + padding: 2px; +} + +.content-sticky-heading-cover img { + display: block; + width: 100%; + height: 100%; +} + +.content-sticky-subheading-row { + position: absolute; + width: 100%; + box-sizing: border-box; + padding: 10px 40px 5px 20px; + margin-top: 0; + z-index: -1; + + background: var(--bg-black-color); + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + + transition: margin-top 0.35s, opacity 0.25s; +} + +.content-sticky-subheading-row h2 { + margin: 0; + + font-size: 0.9em !important; + font-weight: normal; + font-style: oblique; + color: #eee; +} + +.content-sticky-subheading-row:not(.visible) { + margin-top: -20px; + opacity: 0; +} + +.content-sticky-heading-container h2.visible { + margin-top: 0; + opacity: 1; +} + +.content-sticky-heading-row { + box-shadow: + inset 0 10px 10px -5px var(--shadow-color), + 0 4px 4px rgba(0, 0, 0, 0.8); +} + +.content-sticky-heading-container h2.visible { + box-shadow: + inset 0 10px 10px -5px var(--shadow-color), + 0 4px 4px rgba(0, 0, 0, 0.8); +} + +#content, .sidebar { + contain: paint; +} + +/* Sticky sidebar */ + +.sidebar-column.sidebar.sticky-column, +.sidebar-column.sidebar.sticky-last, +.sidebar-multiple.sticky-last > .sidebar:last-child, +.sidebar-multiple.sticky-column { + position: sticky; + top: 10px; +} + +.sidebar-multiple.sticky-last { + align-self: stretch; +} + +.sidebar-multiple.sticky-column { + align-self: flex-start; +} + +.sidebar-column.sidebar.sticky-column { + max-height: calc(100vh - 20px); + align-self: start; + padding-bottom: 0; + box-sizing: border-box; + flex-basis: 275px; + padding-top: 0; + overflow-y: scroll; + scrollbar-width: thin; + scrollbar-color: var(--dark-color); +} + +.sidebar-column.sidebar.sticky-column::-webkit-scrollbar { + background: var(--dark-color); + width: 12px; +} + +.sidebar-column.sidebar.sticky-column::-webkit-scrollbar-thumb { + transition: background 0.2s; + background: rgba(255, 255, 255, 0.2); + border: 3px solid transparent; + border-radius: 10px; + background-clip: content-box; +} + +.sidebar-column.sidebar.sticky-column > h1 { + position: sticky; + top: 0; + margin: 0 calc(-1 * var(--content-padding)); + margin-bottom: 10px; + + border-bottom: 1px dotted rgba(220, 220, 220, 0.4); + padding: 10px 5px; + + background: var(--bg-black-color); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +/* Image overlay */ + +#image-overlay-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 20px 40px; + box-sizing: border-box; + + opacity: 0; + pointer-events: none; + transition: opacity 0.4s; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +#image-overlay-container.visible { + opacity: 1; + pointer-events: auto; +} + +#image-overlay-content-container { + border-radius: 0 0 8px 8px; + border: 2px solid var(--primary-color); + background: var(--dim-ghost-color); + padding: 3px; + overflow: hidden; + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +#image-overlay-image-container { + display: block; + position: relative; + overflow: hidden; + width: 80vmin; + height: 80vmin; +} + +#image-overlay-image, +#image-overlay-image-thumb { + display: inline-block; + object-fit: contain; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.65); +} + +#image-overlay-image { + position: absolute; + top: 0; + left: 0; +} + +#image-overlay-image-thumb { + filter: blur(16px); + transform: scale(1.5); +} + +#image-overlay-container.loaded #image-overlay-image-thumb { + opacity: 0; + pointer-events: none; + transition: opacity 0.25s; +} + +#image-overlay-image-container::after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + height: 4px; + width: var(--download-progress); + background: var(--primary-color); + box-shadow: 0 -3px 12px 4px var(--primary-color); + transition: 0.25s; +} + +#image-overlay-container.loaded #image-overlay-image-container::after { + width: 100%; + background: white; + opacity: 0; +} + +#image-overlay-container.errored #image-overlay-image-container::after { + width: 100%; + background: red; +} + +#image-overlay-container:not(.visible) #image-overlay-image-container::after { + width: 0 !important; +} + +#image-overlay-action-container { + padding: 4px 4px 6px 4px; + border-radius: 0 0 5px 5px; + background: var(--bg-black-color); + color: white; + font-style: oblique; + text-align: center; +} + +#image-overlay-container #image-overlay-action-content-without-size:not(.visible), +#image-overlay-container #image-overlay-action-content-with-size:not(.visible), +#image-overlay-container #image-overlay-file-size-warning:not(.visible), +#image-overlay-container #image-overlay-file-size-kilobytes:not(.visible), +#image-overlay-container #image-overlay-file-size-megabytes:not(.visible) { + display: none; +} + +#image-overlay-file-size-warning { + opacity: 0.8; + font-size: 0.9em; +} + +/* important easter egg mode */ + +html[data-language-code="preview-en"][data-url-key="localized.home"] #content + h1::after { + font-family: cursive; + display: block; + content: "(Preview Build)"; + font-size: 0.8em; +} + +/* Layout - Wide (most computers) */ + +@media (min-width: 900px) { + #page-container:not(.has-zero-sidebars) #secondary-nav { + display: none; + } +} + +/* Layout - Medium (tablets, some landscape mobiles) + * + * Note: Rules defined here are exclusive to "medium" width, i.e. they don't + * additionally apply to "thin". Use the later section which applies to both + * if so desired. + */ + +@media (min-width: 600px) and (max-width: 899.98px) { + /* Medim layout is mainly defined (to the user) by hiding the sidebar, so + * don't apply the similar layout change of widening the long-content area + * if this page doesn't have a sidebar to hide in the first place. + */ + #page-container:not(.has-zero-sidebars) main.long-content { + --long-content-padding-ratio: 0.06; + } +} + +/* Layout - Wide or Medium */ + +@media (min-width: 600px) { + .content-sticky-heading-container { + /* Safari doesn't always play nicely with position: sticky, + * this seems to fix images sometimes displaying above the + * position: absolute subheading (h2) child + * + * See also: https://stackoverflow.com/questions/50224855/ + */ + transform: translate3d(0, 0, 0); + z-index: 1; + } + + /* Cover art floats to the right. It's positioned in HTML beneath the + * heading, so pull it up a little to "float" on top. + */ + #cover-art-container { + float: right; + width: 40%; + max-width: 400px; + margin: -60px 0 10px 20px; + + position: relative; + z-index: 2; + } + + html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:not(:nth-child(n+10)) { + flex-basis: 23%; + margin: 15px; + } + + html[data-url-key="localized.home"] #page-container.has-one-sidebar .grid-listing > .grid-item:nth-child(n+10) { + flex-basis: 18%; + margin: 10px; + } +} + +/* Layout - Medium or Thin */ + +@media (max-width: 899.98px) { + .sidebar-column:not(.no-hide) { + display: none; + } + + #secondary-nav { + display: block; + } + + .layout-columns.vertical-when-thin { + flex-direction: column; + } + + .layout-columns.vertical-when-thin > *:not(:last-child) { + margin-bottom: 10px; + } + + .sidebar-column.no-hide { + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; + } + + .sidebar .news-entry:not(.first-news-entry) { + display: none; + } + + .grid-listing > .grid-item { + flex-basis: 40%; + } +} + +/* Layout - Thin (phones) */ + +@media (max-width: 600px) { + .content-columns { + columns: 1; + } + + main.long-content { + --long-content-padding-ratio: 0.02; + } + + #cover-art-container { + margin: 25px 0 5px 0; + width: 100%; + max-width: unset; + } + + #additional-names-box { + max-width: unset; + } + + /* Show sticky heading above cover art */ + + .content-sticky-heading-container { + z-index: 2; + } + + /* Let sticky heading text span past lower-index cover art */ + + .content-sticky-heading-container.has-cover .content-sticky-heading-row, + .content-sticky-heading-container.has-cover .content-sticky-subheading-row { + grid-template-columns: 1fr 90px; + } + + /* Disable grid features, just line header children up vertically */ + + #header { + display: block; + } + + #header > div:not(:first-child) { + margin-top: 0.5em; + } +} -- cgit 1.3.0-6-gf8a5 From f03ea65a10124d8962609f03d4df84be1531db17 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 18:16:09 -0400 Subject: css: adjust padding box around tooltip --- src/static/site6.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src/static') diff --git a/src/static/site6.css b/src/static/site6.css index 4c083527..884cfca6 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -490,9 +490,9 @@ a:not([href]):hover { .icons-tooltip { position: absolute; z-index: 3; - left: -36px; - top: calc(1em - 2px); - padding: 4px 12px 6px 8px; + left: -34px; + top: calc(1em + 1px); + padding: 3px 6px 6px 6px; } .icons-tooltip:not(.visible) { -- cgit 1.3.0-6-gf8a5 From 15bc6d580ec2b3a754ff3dc17e9eb24bc90e052a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 18:16:34 -0400 Subject: client, css: style hovered/active tooltip links wavy --- src/static/client3.js | 4 ++++ src/static/site6.css | 5 +++++ 2 files changed, 9 insertions(+) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 866b9ba2..390d020e 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -754,6 +754,8 @@ function hideCurrentlyShownTooltip() { // Never hide the tooltip if it's focused. if (currentlyShownTooltipHasFocus()) return false; + state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -774,6 +776,8 @@ function showTooltipFromHoverable(hoverable) { if (!hideCurrentlyShownTooltip()) return false; + hoverable.classList.add('has-visible-tooltip'); + state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; diff --git a/src/static/site6.css b/src/static/site6.css index 884cfca6..830e32f2 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -482,6 +482,11 @@ a:not([href]):hover { text-decoration-style: dotted; } +.contribution.has-tooltip > a:hover, +.contribution.has-tooltip > a.has-visible-tooltip { + text-decoration-style: wavy !important; +} + .icons { font-style: normal; white-space: nowrap; -- cgit 1.3.0-6-gf8a5 From ea3c4655c3023dee609865a0928ce52303a8e363 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:06:34 -0400 Subject: client, css: transition tooltips hidden --- src/static/client3.js | 95 ++++++++++++++++++++++++++++++++++++++++++++++----- src/static/site6.css | 8 +++++ 2 files changed, 94 insertions(+), 9 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 390d020e..285a5ef6 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -383,6 +383,10 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { focusInfoDelay: 750, hideTooltipDelay: 500, + + // If a tooltip that's transitioning to hidden is hovered, it'll cancel + // out of this animation immediately. + transitionHiddenDuration: 300, }, state: { @@ -399,8 +403,10 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { focusTimeout: null, touchTimeout: null, hideTimeout: null, + transitionHiddenTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, + currentlyTransitioningHiddenTooltip: null, tooltipWasJustHidden: false, hoverableWasRecentlyTouched: false, @@ -548,21 +554,36 @@ function handleTooltipReceivedFocus(tooltip) { function handleTooltipLostFocus(tooltip) { const {settings, state} = hoverableTooltipInfo; - // Hide the current tooltip right away when it loses focus. - hideCurrentlyShownTooltip(); + // Hide the current tooltip right away when it loses focus. Specify intent + // to replace - while we don't strictly know if another tooltip is going to + // immediately replace it, the mode of navigating with tab focus (once one + // tooltip has been activated) is a "switch focus immediately" kind of + // interaction in its nature. + hideCurrentlyShownTooltip(true); } function handleTooltipHoverableMouseEntered(hoverable) { const {event, settings, state} = hoverableTooltipInfo; + const {tooltip} = state.registeredHoverables.get(hoverable); + + // If this tooltip was transitioning to hidden, hovering should cancel that + // animation and show it immediately. + + if (tooltip === state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + showTooltipFromHoverable(hoverable); + return; + } + + // Start a timer to show the corresponding tooltip, with the delay depending + // on whether fast hovering or not. This could be canceled by mousing out of + // the hoverable. const hoverTimeoutDelay = (state.fastHovering ? settings.fastHoveringInfoDelay : settings.normalHoverInfoDelay); - // Start a timer to show the corresponding tooltip, with the delay depending - // on whether fast hovering or not. This could be canceled by mousing out of - // the hoverable. state.hoverTimeout = setTimeout(() => { state.hoverTimeout = null; @@ -650,9 +671,10 @@ function handleTooltipHoverableLostFocus(hoverable, domEvent) { // Unless focus is entering the tooltip itself, hide the tooltip immediately. // This will set the tooltipWasJustHidden flag, which is detected by a newly - // focused hoverable, if applicable. + // focused hoverable, if applicable. Always specify intent to replace when + // navigating via tab focus. (Check `handleTooltipLostFocus` for details.) if (!currentlyShownTooltipHasFocus(domEvent.relatedTarget)) { - hideCurrentlyShownTooltip(); + hideCurrentlyShownTooltip(true); } } @@ -743,7 +765,48 @@ function currentlyShownTooltipHasFocus(focusElement = document.activeElement) { return false; } -function hideCurrentlyShownTooltip() { +function beginTransitioningTooltipHidden(tooltip) { + const {settings, state} = hoverableTooltipInfo; + + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + } + + tooltip.classList.add('transition-tooltip-hidden'); + tooltip.style.transitionDuration = + `${settings.transitionHiddenDuration / 1000}s`; + + state.currentlyTransitioningHiddenTooltip = tooltip; + state.transitionHiddenTimeout = + setTimeout(() => { + endTransitioningTooltipHidden(); + }, settings.transitionHiddenDuration); +} + +function cancelTransitioningTooltipHidden() { + const {state} = hoverableTooltipInfo; + + endTransitioningTooltipHidden(); + + if (state.transitionHiddenTimeout) { + clearTimeout(state.transitionHiddenTimeout); + state.transitionHiddenTimeout = null; + } +} + +function endTransitioningTooltipHidden() { + const {state} = hoverableTooltipInfo; + const {currentlyTransitioningHiddenTooltip: tooltip} = state; + + if (!tooltip) return; + + tooltip.classList.remove('transition-tooltip-hidden'); + tooltip.style.removeProperty('transition-duration'); + + state.currentlyTransitioningHiddenTooltip = null; +} + +function hideCurrentlyShownTooltip(intendingToReplace = false) { const {event, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; @@ -756,6 +819,14 @@ function hideCurrentlyShownTooltip() { state.currentlyActiveHoverable.classList.remove('has-visible-tooltip'); + // If there's no intent to replace this tooltip, it's the last one currently + // apparent in the interaction, and should be hidden with a transition. + if (intendingToReplace) { + cancelTransitioningTooltipHidden(); + } else { + beginTransitioningTooltipHidden(state.currentlyShownTooltip); + } + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -774,7 +845,13 @@ function showTooltipFromHoverable(hoverable) { const {event, state} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); - if (!hideCurrentlyShownTooltip()) return false; + if (!hideCurrentlyShownTooltip(true)) return false; + + // Cancel out another tooltip that's transitioning hidden, if that's going + // on - it's a distraction that this tooltip is now replacing. + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(); + } hoverable.classList.add('has-visible-tooltip'); diff --git a/src/static/site6.css b/src/static/site6.css index 830e32f2..b06417db 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -504,6 +504,14 @@ a:not([href]):hover { display: none; } +.icons-tooltip:not(.visible).transition-tooltip-hidden { + display: block !important; + opacity: 0; + + transition-property: opacity; + transition-timing-function: linear; +} + .icons-tooltip-content { display: block; padding: 6px 2px 2px 2px; -- cgit 1.3.0-6-gf8a5 From db786d00e450396e680686e95db97ec353fe32f8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:07:26 -0400 Subject: client, css: define tooltip transitions 100% in JS --- src/static/client3.js | 40 +++++++++++++++++++++++++++++++++------- src/static/site6.css | 8 -------- 2 files changed, 33 insertions(+), 15 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 285a5ef6..59e889a8 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -63,8 +63,25 @@ function pick(array) { return array[Math.floor(Math.random() * array.length)]; } -function cssProp(el, key) { - return getComputedStyle(el).getPropertyValue(key).trim(); +function cssProp(el, ...args) { + if (typeof args[0] === 'string' && args.length === 1) { + return getComputedStyle(el).getPropertyValue(args[0]).trim(); + } + + if (typeof args[0] === 'string' && args.length === 2) { + if (args[1] === null) { + el.style.removeProperty(args[0]); + } else { + el.style.setProperty(args[0], args[1]); + } + return; + } + + if (typeof args[0] === 'object') { + for (const [property, value] of Object.entries(args[0])) { + cssProp(el, property, value); + } + } } // TODO: These should pro8a8ly access some shared urlSpec path. We'd need to @@ -772,9 +789,13 @@ function beginTransitioningTooltipHidden(tooltip) { cancelTransitioningTooltipHidden(); } - tooltip.classList.add('transition-tooltip-hidden'); - tooltip.style.transitionDuration = - `${settings.transitionHiddenDuration / 1000}s`; + cssProp(tooltip, { + 'display': 'block', + 'opacity': '0', + 'transition-property': 'opacity', + 'transition-timing-function': 'linear', + 'transition-duration': `${settings.transitionHiddenDuration / 1000}s`, + }); state.currentlyTransitioningHiddenTooltip = tooltip; state.transitionHiddenTimeout = @@ -800,8 +821,13 @@ function endTransitioningTooltipHidden() { if (!tooltip) return; - tooltip.classList.remove('transition-tooltip-hidden'); - tooltip.style.removeProperty('transition-duration'); + cssProp(tooltip, { + 'display': null, + 'opacity': null, + 'transition-property': null, + 'transition-timing-function': null, + 'transition-duration': null, + }); state.currentlyTransitioningHiddenTooltip = null; } diff --git a/src/static/site6.css b/src/static/site6.css index b06417db..830e32f2 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -504,14 +504,6 @@ a:not([href]):hover { display: none; } -.icons-tooltip:not(.visible).transition-tooltip-hidden { - display: block !important; - opacity: 0; - - transition-property: opacity; - transition-timing-function: linear; -} - .icons-tooltip-content { display: block; padding: 6px 2px 2px 2px; -- cgit 1.3.0-6-gf8a5 From 0581dd1fbb7cc36cf86d28c1bb0c53264f78b213 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:10:14 -0400 Subject: client: transition tooltip hidden in steps --- src/static/client3.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 59e889a8..a0b0ed2c 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -792,9 +792,12 @@ function beginTransitioningTooltipHidden(tooltip) { cssProp(tooltip, { 'display': 'block', 'opacity': '0', + 'transition-property': 'opacity', - 'transition-timing-function': 'linear', - 'transition-duration': `${settings.transitionHiddenDuration / 1000}s`, + 'transition-timing-function': + `steps(${Math.ceil(settings.transitionHiddenDuration / 60)}, end)`, + 'transition-duration': + `${settings.transitionHiddenDuration / 1000}s`, }); state.currentlyTransitioningHiddenTooltip = tooltip; -- cgit 1.3.0-6-gf8a5 From 511a26ddff981b866e7ccb6ecb0724c0a67d097e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:48:53 -0400 Subject: client: handle showing/hiding tooltips without internal listeners --- src/static/client3.js | 39 +++++---------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index a0b0ed2c..58687ecc 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -443,11 +443,6 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { currentTouchIdentifiers: new Set(), touchIdentifiersBanishedByScrolling: new Set(), }, - - event: { - whenTooltipShouldBeShown: [], - whenTooltipShouldBeHidden: [], - }, }; // Adds DOM event listeners, so must be called during addPageListeners step. @@ -856,6 +851,9 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } + tooltip.classList.remove('visible'); + tooltip.inert = true; + state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -865,8 +863,6 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { state.tooltipWasJustHidden = false; }); - dispatchInternalEvent(event, 'whenTooltipShouldBeHidden', {tooltip}); - return true; } @@ -883,14 +879,14 @@ function showTooltipFromHoverable(hoverable) { } hoverable.classList.add('has-visible-tooltip'); + tooltip.classList.add('visible'); + tooltip.inert = false; state.currentlyShownTooltip = tooltip; state.currentlyActiveHoverable = hoverable; state.tooltipWasJustHidden = false; - dispatchInternalEvent(event, 'whenTooltipShouldBeShown', {hoverable, tooltip}); - return true; } @@ -1979,30 +1975,6 @@ function getExternalIconTooltipReferences() { .map(span => span.querySelector('span.icons-tooltip')); } -function addExternalIconTooltipInternalListeners() { - const info = externalIconTooltipInfo; - - hoverableTooltipInfo.event.whenTooltipShouldBeShown.push(({tooltip}) => { - if (!info.iconContainers.includes(tooltip)) return; - showExternalIconTooltip(tooltip); - }); - - hoverableTooltipInfo.event.whenTooltipShouldBeHidden.push(({tooltip}) => { - if (!info.iconContainers.includes(tooltip)) return; - hideExternalIconTooltip(tooltip); - }); -} - -function showExternalIconTooltip(iconContainer) { - iconContainer.classList.add('visible'); - iconContainer.inert = false; -} - -function hideExternalIconTooltip(iconContainer) { - iconContainer.classList.remove('visible'); - iconContainer.inert = true; -} - function addExternalIconTooltipPageListeners() { const info = externalIconTooltipInfo; @@ -2016,7 +1988,6 @@ function addExternalIconTooltipPageListeners() { } clientSteps.getPageReferences.push(getExternalIconTooltipReferences); -clientSteps.addInternalListeners.push(addExternalIconTooltipInternalListeners); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); /* -- cgit 1.3.0-6-gf8a5 From 57c06ea665cf2c2ee4536cab70b2459457d05e15 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:53:45 -0400 Subject: static, css: define tooltip show/hide 100% in JS --- src/static/client3.js | 5 +++-- src/static/site6.css | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 58687ecc..a55361ce 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -847,11 +847,11 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { // apparent in the interaction, and should be hidden with a transition. if (intendingToReplace) { cancelTransitioningTooltipHidden(); + cssProp(tooltip, 'display', 'none'); } else { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } - tooltip.classList.remove('visible'); tooltip.inert = true; state.currentlyShownTooltip = null; @@ -879,7 +879,8 @@ function showTooltipFromHoverable(hoverable) { } hoverable.classList.add('has-visible-tooltip'); - tooltip.classList.add('visible'); + + cssProp(tooltip, 'display', 'block'); tooltip.inert = false; state.currentlyShownTooltip = tooltip; diff --git a/src/static/site6.css b/src/static/site6.css index 830e32f2..76b58f32 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -498,9 +498,6 @@ a:not([href]):hover { left: -34px; top: calc(1em + 1px); padding: 3px 6px 6px 6px; -} - -.icons-tooltip:not(.visible) { display: none; } -- cgit 1.3.0-6-gf8a5 From 930bb9e0f1fc7167dbf53636246e3cd2de773774 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Nov 2023 19:55:04 -0400 Subject: client: grace period during transition hidden --- src/static/client3.js | 52 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 14 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index a55361ce..86b5f985 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -401,9 +401,11 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { hideTooltipDelay: 500, - // If a tooltip that's transitioning to hidden is hovered, it'll cancel - // out of this animation immediately. + // If a tooltip that's transitioning to hidden is hovered during the grace + // period (or the corresponding hoverable is hovered at any point in the + // transition), it'll cancel out of this animation immediately. transitionHiddenDuration: 300, + inertGracePeriod: 100, }, state: { @@ -421,9 +423,11 @@ const hoverableTooltipInfo = clientInfo.hoverableTooltipInfo = { touchTimeout: null, hideTimeout: null, transitionHiddenTimeout: null, + inertGracePeriodTimeout: null, currentlyShownTooltip: null, currentlyActiveHoverable: null, currentlyTransitioningHiddenTooltip: null, + previouslyActiveHoverable: null, tooltipWasJustHidden: false, hoverableWasRecentlyTouched: false, @@ -526,9 +530,15 @@ function registerTooltipHoverableElement(hoverable, tooltip) { function handleTooltipMouseEntered(tooltip) { const {state} = hoverableTooltipInfo; + if (state.currentlyTransitioningHiddenTooltip) { + cancelTransitioningTooltipHidden(true); + return; + } + if (state.currentlyShownTooltip !== tooltip) return; // Don't time out the current tooltip while hovering it. + if (state.hideTimeout) { clearTimeout(state.hideTimeout); state.hideTimeout = null; @@ -582,8 +592,7 @@ function handleTooltipHoverableMouseEntered(hoverable) { // animation and show it immediately. if (tooltip === state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(); - showTooltipFromHoverable(hoverable); + cancelTransitioningTooltipHidden(true); return; } @@ -802,14 +811,13 @@ function beginTransitioningTooltipHidden(tooltip) { }, settings.transitionHiddenDuration); } -function cancelTransitioningTooltipHidden() { +function cancelTransitioningTooltipHidden(andShow = false) { const {state} = hoverableTooltipInfo; endTransitioningTooltipHidden(); - if (state.transitionHiddenTimeout) { - clearTimeout(state.transitionHiddenTimeout); - state.transitionHiddenTimeout = null; + if (andShow) { + showTooltipFromHoverable(state.previouslyActiveHoverable); } } @@ -828,10 +836,20 @@ function endTransitioningTooltipHidden() { }); state.currentlyTransitioningHiddenTooltip = null; + + if (state.inertGracePeriodTimeout) { + clearTimeout(state.inertGracePeriodTimeout); + state.inertGracePeriodTimeout = null; + } + + if (state.transitionHiddenTimeout) { + clearTimeout(state.transitionHiddenTimeout); + state.transitionHiddenTimeout = null; + } } function hideCurrentlyShownTooltip(intendingToReplace = false) { - const {event, state} = hoverableTooltipInfo; + const {event, settings, state} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; // If there was no tooltip to begin with, we're functionally in the desired @@ -846,13 +864,21 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { // If there's no intent to replace this tooltip, it's the last one currently // apparent in the interaction, and should be hidden with a transition. if (intendingToReplace) { - cancelTransitioningTooltipHidden(); cssProp(tooltip, 'display', 'none'); } else { beginTransitioningTooltipHidden(state.currentlyShownTooltip); } - tooltip.inert = true; + // Wait just a moment before making the tooltip inert. You might react + // (to the ghosting, or just to time passing) and realize you wanted + // to look at the tooltip after all - this delay gives a little buffer + // to second guess letting it disappear. + state.inertGracePeriodTimeout = + setTimeout(() => { + tooltip.inert = true; + }, settings.inertGracePeriod); + + state.previouslyActiveHoverable = state.currentlyActiveHoverable; state.currentlyShownTooltip = null; state.currentlyActiveHoverable = null; @@ -874,9 +900,7 @@ function showTooltipFromHoverable(hoverable) { // Cancel out another tooltip that's transitioning hidden, if that's going // on - it's a distraction that this tooltip is now replacing. - if (state.currentlyTransitioningHiddenTooltip) { - cancelTransitioningTooltipHidden(); - } + cancelTransitioningTooltipHidden(); hoverable.classList.add('has-visible-tooltip'); -- cgit 1.3.0-6-gf8a5 From 7ad62ef4a6908a550d5b48ae93877446088d4d82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 17:32:08 -0400 Subject: client: remove dead artist tooltip reference code --- src/static/client3.js | 92 --------------------------------------------------- 1 file changed, 92 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 86b5f985..1e64ebe1 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -2015,98 +2015,6 @@ function addExternalIconTooltipPageListeners() { clientSteps.getPageReferences.push(getExternalIconTooltipReferences); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); -/* -const linkIconTooltipInfo = - Array.from(document.querySelectorAll('span.contribution.has-tooltip')) - .map(span => ({ - mainLink: span.querySelector('a'), - iconsContainer: span.querySelector('span.icons-tooltip'), - iconLinks: span.querySelectorAll('span.icons-tooltip a'), - })); - -for (const info of linkIconTooltipInfo) { - const focusElements = - [info.mainLink, ...info.iconLinks]; - - const hoverElements = - [info.mainLink, info.iconsContainer]; - - let hidden = true; - - const show = () => { - info.iconsContainer.classList.add('visible'); - info.iconsContainer.inert = false; - hidden = false; - }; - - const hide = () => { - info.iconsContainer.classList.remove('visible'); - info.iconsContainer.inert = true; - hidden = true; - }; - - const considerHiding = () => { - if (hoverElements.some(el => el.matches(':hover'))) { - return; - } - - if () { - return; - } - - if (justTouched) { - return; - } - - hide(); - }; - - // Hover (pointer) - - let hoverTimeout; - - info.mainLink.addEventListener('mouseenter', () => { - if (hidden) { - hoverTimeout = setTimeout(show, 250); - } - }); - - info.mainLink.addEventListener('mouseout', () => { - if (hidden) { - clearTimeout(hoverTimeout); - } else { - considerHiding(); - } - }); - - info.iconsContainer.addEventListener('mouseout', () => { - if (!hidden) { - considerHiding(); - } - }); - - // Focus (keyboard) - - let focusTimeout; - - info.mainLink.addEventListener('focus', () => { - focusTimeout = setTimeout(show, 750); - }); - - info.mainLink.addEventListener('blur', () => { - clearTimeout(focusTimeout); - }); - - info.iconsContainer.addEventListener('focusout', () => { - requestAnimationFrame(considerHiding); - }); - - info.mainLink.addEventListener('blur', () => { - requestAnimationFrame(considerHiding); - }); -} -*/ - // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { -- cgit 1.3.0-6-gf8a5 From a0fa6520c77e46b7a2e55b87e9994df3af74f149 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 17:57:28 -0400 Subject: content, client, css: basic (absolute) datetimestamp tooltips --- src/static/client3.js | 53 ++++++++++++++++++++++++++++++++++++--------- src/static/site6.css | 59 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 87 insertions(+), 25 deletions(-) (limited to 'src/static') diff --git a/src/static/client3.js b/src/static/client3.js index 1e64ebe1..ce057712 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -1981,8 +1981,8 @@ for (const info of groupContributionsTableInfo) { // Artist link icon tooltips ------------------------------ const externalIconTooltipInfo = clientInfo.externalIconTooltipInfo = { - hoverableLinks: null, - iconContainers: null, + hoverables: null, + tooltips: null, }; function getExternalIconTooltipReferences() { @@ -1991,21 +1991,19 @@ function getExternalIconTooltipReferences() { const spans = Array.from(document.querySelectorAll('span.contribution.has-tooltip')); - info.hoverableLinks = - spans - .map(span => span.querySelector('a')); + info.hoverables = + spans.map(span => span.querySelector('a')); - info.iconContainers = - spans - .map(span => span.querySelector('span.icons-tooltip')); + info.tooltips = + spans.map(span => span.querySelector('span.icons-tooltip')); } function addExternalIconTooltipPageListeners() { const info = externalIconTooltipInfo; for (const {hoverable, tooltip} of stitchArrays({ - hoverable: info.hoverableLinks, - tooltip: info.iconContainers, + hoverable: info.hoverables, + tooltip: info.tooltips, })) { registerTooltipElement(tooltip); registerTooltipHoverableElement(hoverable, tooltip); @@ -2015,6 +2013,41 @@ function addExternalIconTooltipPageListeners() { clientSteps.getPageReferences.push(getExternalIconTooltipReferences); clientSteps.addPageListeners.push(addExternalIconTooltipPageListeners); +// Datetimestamp tooltips --------------------------------- + +const datetimestampTooltipInfo = clientInfo.datetimestampTooltipInfo = { + hoverables: null, + tooltips: null, +}; + +function getDatestampTooltipReferences() { + const info = datetimestampTooltipInfo; + + const spans = + Array.from(document.querySelectorAll('span.datetimestamp.has-tooltip')); + + info.hoverables = + spans.map(span => span.querySelector('time')); + + info.tooltips = + spans.map(span => span.querySelector('span.datetimestamp-tooltip')); +} + +function addDatestampTooltipPageListeners() { + const info = datetimestampTooltipInfo; + + for (const {hoverable, tooltip} of stitchArrays({ + hoverable: info.hoverables, + tooltip: info.tooltips, + })) { + registerTooltipElement(tooltip); + registerTooltipHoverableElement(hoverable, tooltip); + } +} + +clientSteps.getPageReferences.push(getDatestampTooltipReferences); +clientSteps.addPageListeners.push(addDatestampTooltipPageListeners); + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = clientInfo.albumCommentarySidebarInfo = { diff --git a/src/static/site6.css b/src/static/site6.css index 76b58f32..b7d5ce04 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -473,37 +473,51 @@ a:not([href]):hover { white-space: nowrap; } -.contribution { +.contribution.has-tooltip, +.datetimestamp.has-tooltip { position: relative; } -.contribution.has-tooltip > a { +.contribution.has-tooltip > a, +.datetimestamp.has-tooltip > time { text-decoration: underline; text-decoration-style: dotted; } -.contribution.has-tooltip > a:hover, -.contribution.has-tooltip > a.has-visible-tooltip { - text-decoration-style: wavy !important; +.datetimestamp.has-tooltip > time { + cursor: default; } -.icons { - font-style: normal; - white-space: nowrap; +.contribution.has-tooltip > a:hover, +.contribution.has-tooltip > a.has-visible-tooltip, +.datetimestamp.has-tooltip > time:hover, +.datetimestamp.has-tooltip > time.has-visible-tooltip { + text-decoration-style: wavy !important; } -.icons-tooltip { +.icons-tooltip, +.datetimestamp-tooltip { position: absolute; z-index: 3; left: -34px; top: calc(1em + 1px); - padding: 3px 6px 6px 6px; display: none; } -.icons-tooltip-content { +.icons-tooltip { + padding: 3px 6px 6px 6px; + left: -34px; +} + +.datetimestamp-tooltip { + padding: 3px 4px 2px 2px; + left: 14px; +} + +.icons-tooltip-content, +.datetimestamp-tooltip-content { display: block; - padding: 6px 2px 2px 2px; + background: var(--bg-black-color); border: 1px dotted var(--primary-color); border-radius: 6px; @@ -514,16 +528,31 @@ a:not([href]):hover { backdrop-filter: brightness(1.5) saturate(1.4) blur(4px); - -webkit-user-select: none; - user-select: none; - box-shadow: 0 3px 4px 4px #000000aa, 0 -2px 4px -2px var(--primary-color) inset; +} + +.icons-tooltip-content { + padding: 6px 2px 2px 2px; + + -webkit-user-select: none; + user-select: none; cursor: default; } +.datetimestamp-tooltip-content { + padding: 5px 6px; + white-space: nowrap; + font-size: 0.9em; +} + +.icons { + font-style: normal; + white-space: nowrap; +} + .icons a:hover { filter: brightness(1.4); } -- cgit 1.3.0-6-gf8a5 From ad823614a22807321d28ad25fa5440d439d84975 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 18:22:05 -0400 Subject: content: generateGroupInfoPage: colorize tooltips --- src/static/site6.css | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src/static') diff --git a/src/static/site6.css b/src/static/site6.css index b7d5ce04..113633ed 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -603,6 +603,10 @@ a:not([href]):hover { white-space: nowrap; } +.other-group-accent a { + color: var(--page-primary-color); +} + .content-columns { columns: 2; } -- cgit 1.3.0-6-gf8a5 From 03739d3af94702839bcb3691bf8edd4ff659b9ac Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 18:22:24 -0400 Subject: css: only add tooltip margin in context of list items --- src/static/site6.css | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src/static') diff --git a/src/static/site6.css b/src/static/site6.css index 113633ed..9d3da7c4 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -511,6 +511,10 @@ a:not([href]):hover { .datetimestamp-tooltip { padding: 3px 4px 2px 2px; + left: -10px; +} + +li .datetimestamp-tooltip { left: 14px; } -- cgit 1.3.0-6-gf8a5 From 84503bc64dca3e6bf6c135feb53338de5815ad3c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 29 Nov 2023 21:48:27 -0400 Subject: css: don't indent tooltip for single-item list --- src/static/site6.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/static') diff --git a/src/static/site6.css b/src/static/site6.css index 9d3da7c4..e30992f5 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -514,7 +514,7 @@ a:not([href]):hover { left: -10px; } -li .datetimestamp-tooltip { +li:not(:first-child:last-child) .datetimestamp-tooltip { left: 14px; } -- cgit 1.3.0-6-gf8a5 From 38e048838eae945a1b0ed8cffd747c0534e46af2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 3 Dec 2023 13:28:26 -0400 Subject: content: generateAdditionalNamesBoxItem + "from" support --- src/static/site6.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/static') diff --git a/src/static/site6.css b/src/static/site6.css index 76b58f32..892458d6 100644 --- a/src/static/site6.css +++ b/src/static/site6.css @@ -955,7 +955,7 @@ h1 a[href="#additional-names-box"]:hover { margin-top: 0.5em; } -#additional-names-box li .annotation { +#additional-names-box li .accent { opacity: 0.8; display: inline-block; } -- cgit 1.3.0-6-gf8a5