From 84686df07e1029bcad91b9fda47b6f3bd280ee56 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 6 Jun 2023 20:07:03 -0300 Subject: content: generateCoverArtwork: mode commentary --- src/content/dependencies/generateCoverArtwork.js | 14 +++++++++++++- src/static/site4.css | 11 +++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 4060c6b0..385385e3 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -32,7 +32,7 @@ export default { }, mode: { - validate: v => v.is('primary', 'thumbnail'), + validate: v => v.is('primary', 'thumbnail', 'commentary'), default: 'primary', }, }, @@ -73,6 +73,18 @@ export default { square: true, }); + case 'commentary': + return relations.image + .slots({ + path: slots.path, + alt: slots.alt, + thumb: 'medium', + class: 'commentary-art', + reveal: true, + link: true, + square: true, + }); + default: return html.blank(); } diff --git a/src/static/site4.css b/src/static/site4.css index ab8976bc..eb8d5520 100644 --- a/src/static/site4.css +++ b/src/static/site4.css @@ -533,6 +533,13 @@ p .current { margin-top: 5px; } +.commentary-art { + float: right; + width: 30%; + max-width: 250px; + margin: 15px 0 10px 20px; +} + .js-hide, .js-show-once-data, .js-hide-once-data { @@ -1250,6 +1257,10 @@ html[data-url-key="localized.home"] .carousel-container { animation-delay: 125ms; } +h3.content-heading { + clear: both; +} + /* This animation's name is referenced in JavaScript */ @keyframes highlight-hash-link { 0% { -- cgit 1.3.0-6-gf8a5 From 1c3d50b059b3848f685ff6655242f5cb6815ddd6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 6 Jun 2023 20:17:22 -0300 Subject: content: generateAlbumSidebarTrackSection: be mildly more versatile --- .../generateAlbumSidebarTrackSection.js | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js index d71b0bdb..e8c0377d 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackSection.js +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -33,10 +33,19 @@ export default { } } + data.trackDirectories = + trackSection.tracks + .map(track => track.directory); + return data; }, - generate(data, relations, {getColors, html, language}) { + slots: { + anchor: {type: 'boolean'}, + open: {type: 'boolean'}, + }, + + generate(slots, data, relations, {getColors, html, language}) { const sectionName = html.tag('span', {class: 'group-name'}, (data.isDefaultTrackSection @@ -59,7 +68,13 @@ export default { 'current', }, language.$('albumSidebar.trackList.item', { - track: trackLink, + track: + (slots.anchor + ? trackLink.slots({ + anchor: true, + hash: data.trackDirectories[index], + }) + : trackLink), }))); return html.tag('details', @@ -67,6 +82,11 @@ export default { class: data.includesCurrentTrack && 'current', open: ( + // Allow forcing open via a template slot. + // This isn't exactly janky, but the rest of this function + // kind of is when you contextualize it in a template... + slots.open || + // Leave sidebar track sections collapsed on album info page, // since there's already a view of the full track listing // in the main content area. -- cgit 1.3.0-6-gf8a5 From 1b81e54834d5f447b5b6e52eb7dfb875673a44f1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 6 Jun 2023 20:17:35 -0300 Subject: content: generateAlbumCommentaryPage: add sidebar, artworks --- .../dependencies/generateAlbumCommentaryPage.js | 41 +++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 5979ed3f..85a2108c 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -2,10 +2,13 @@ import {stitchArrays} from '#sugar'; export default { contentDependencies: [ + 'generateAlbumCoverArtwork', 'generateAlbumNavAccent', + 'generateAlbumSidebarTrackSection', 'generateAlbumStyleRules', 'generateColorStyleVariables', 'generateContentHeading', + 'generateTrackCoverArtwork', 'generatePageLayout', 'linkAlbum', 'linkTrack', @@ -30,6 +33,9 @@ export default { relation('generateAlbumNavAccent', album, null); if (album.commentary) { + relations.albumCommentaryCover = + relation('generateAlbumCoverArtwork', album); + relations.albumCommentaryContent = relation('transformContent', album.commentary); } @@ -46,6 +52,13 @@ export default { tracksWithCommentary .map(track => relation('linkTrack', track)); + relations.trackCommentaryCovers = + tracksWithCommentary + .map(track => + (track.hasUniqueCoverArt + ? relation('generateTrackCoverArtwork', track) + : null)); + relations.trackCommentaryContent = tracksWithCommentary .map(track => relation('transformContent', track.commentary)); @@ -57,6 +70,13 @@ export default { ? null : relation('generateColorStyleVariables'))); + relations.sidebarAlbumLink = + relation('linkAlbum', album); + + relations.sidebarTrackSections = + album.trackSections.map(trackSection => + relation('generateAlbumSidebarTrackSection', album, null, trackSection)); + return relations; }, @@ -129,6 +149,11 @@ export default { {class: ['content-heading']}, language.$('albumCommentaryPage.entry.title.albumCommentary')), + relations.albumCommentaryCover + ?.slots({ + displayMode: 'commentary', + }), + html.tag('blockquote', relations.albumCommentaryContent), ], @@ -137,15 +162,19 @@ export default { heading: relations.trackCommentaryHeadings, link: relations.trackCommentaryLinks, directory: data.trackCommentaryDirectories, + cover: relations.trackCommentaryCovers, content: relations.trackCommentaryContent, colorVariables: relations.trackCommentaryColorVariables, color: data.trackCommentaryColors, - }).map(({heading, link, directory, content, colorVariables, color}) => [ + }).map(({heading, link, directory, cover, content, colorVariables, color}) => [ heading.slots({ tag: 'h3', id: directory, title: link, }), + + cover?.slots({mode: 'commentary'}), + html.tag('blockquote', (color ? {style: colorVariables.slot('color', color).content} @@ -170,6 +199,16 @@ export default { }), }, ], + + leftSidebarStickyMode: 'column', + leftSidebarContent: [ + html.tag('h1', relations.sidebarAlbumLink), + relations.sidebarTrackSections.map(section => + section.slots({ + anchor: true, + open: true, + })), + ], }); }, }; -- cgit 1.3.0-6-gf8a5 From 528d909f4eeec9d7f6b54031752a94a6c9dca5ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 24 Sep 2023 10:29:09 -0300 Subject: content: generateAlbum{CommentaryPage,SidebarTrackSection}: merge fixes --- src/content/dependencies/generateAlbumCommentaryPage.js | 4 +--- src/content/dependencies/generateAlbumSidebarTrackSection.js | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 85a2108c..5c057b80 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -150,9 +150,7 @@ export default { language.$('albumCommentaryPage.entry.title.albumCommentary')), relations.albumCommentaryCover - ?.slots({ - displayMode: 'commentary', - }), + ?.slots({mode: 'commentary'}), html.tag('blockquote', relations.albumCommentaryContent), diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js index e8c0377d..00e9b621 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackSection.js +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -45,7 +45,7 @@ export default { open: {type: 'boolean'}, }, - generate(slots, data, relations, {getColors, html, language}) { + generate(data, relations, slots, {getColors, html, language}) { const sectionName = html.tag('span', {class: 'group-name'}, (data.isDefaultTrackSection -- cgit 1.3.0-6-gf8a5 From b695bf0c8d0c904ef5b26c76361b804a7ebdc335 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 24 Sep 2023 10:29:25 -0300 Subject: content: generateCoverArtwork: lazy commentary covers --- src/content/dependencies/generateCoverArtwork.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 385385e3..aeba97de 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -83,6 +83,7 @@ export default { reveal: true, link: true, square: true, + lazy: true, }); default: -- cgit 1.3.0-6-gf8a5 From 8b1d5053f71959498c7327493db9f64b94f8de30 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 24 Sep 2023 11:00:57 -0300 Subject: css: misc. adjustments for sticky column sidebar --- src/static/site4.css | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/static/site4.css b/src/static/site4.css index eb8d5520..0e6166b4 100644 --- a/src/static/site4.css +++ b/src/static/site4.css @@ -1449,6 +1449,30 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r align-self: flex-start; } +.sidebar-column.sidebar.sticky-column { + max-height: calc(100vh - 20px); + overflow-y: scroll; + align-self: start; + padding-bottom: 0; + box-sizing: border-box; + flex-basis: 275px; + padding-top: 0; +} + +.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 { -- cgit 1.3.0-6-gf8a5 From fbabffe43bfaacab4fdb1ae508525b6c5c41dbbd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 24 Sep 2023 15:41:20 -0300 Subject: client: rework (most) steps to fail gracefully --- src/static/client2.js | 753 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 502 insertions(+), 251 deletions(-) diff --git a/src/static/client2.js b/src/static/client2.js index d9afcb03..96b422a8 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -6,13 +6,28 @@ // ephemeral nature. import {getColors} from '../util/colors.js'; -import {getArtistNumContributions} from '../util/wiki-data.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'); @@ -86,113 +101,148 @@ function fetchData(type, directory) { // JS-based links ----------------------------------------- -for (const a of document.body.querySelectorAll('[data-random]')) { - a.addEventListener('click', (evt) => { - if (!ready) { - evt.preventDefault(); - return; - } +const scriptedLinkInfo = clientInfo.scriptedLinkInfo = { + randomLinks: null, + revealLinks: null, - const tracks = albumData => - albumData - .map(album => album.tracks) - .reduce((acc, tracks) => acc.concat(tracks), []); + nextLink: null, + previousLink: null, + randomLink: null, +}; - setTimeout(() => { - a.href = rebase('js-disabled'); - }); +function getScriptedLinkReferences() { + scriptedLinkInfo.randomLinks = + document.querySelectorAll('[data-random]'); - switch (a.dataset.random) { - case 'album': - a.href = openAlbum(pick(albumData).directory); - break; - - 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; - - case 'track': - a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); - break; - - 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; - } - }); + scriptedLinkInfo.revealLinks = + document.getElementsByClassName('reveal'); + + scriptedLinkInfo.nextNavLink = + document.getElementById('next-button'); + + scriptedLinkInfo.previousNavLink = + document.getElementById('previous-button'); + + scriptedLinkInfo.randomNavLink = + document.getElementById('random-button'); } -const next = document.getElementById('next-button'); -const previous = document.getElementById('previous-button'); -const random = document.getElementById('random-button'); +function addRandomLinkListeners() { + for (const a of scriptedLinkInfo.randomLinks ?? []) { + a.addEventListener('click', evt => { + if (!ready) { + evt.preventDefault(); + return; + } -const prependTitle = (el, prepend) => { - const existing = el.getAttribute('title'); - if (existing) { - el.setAttribute('title', prepend + ' ' + existing); - } else { - el.setAttribute('title', prepend); - } -}; + const tracks = albumData => + albumData + .map(album => album.tracks) + .reduce((acc, tracks) => acc.concat(tracks), []); -if (next) prependTitle(next, '(Shift+N)'); -if (previous) prependTitle(previous, '(Shift+P)'); -if (random) prependTitle(random, '(Shift+R)'); - -document.addEventListener('keypress', (event) => { - if (event.shiftKey) { - if (event.charCode === 'N'.charCodeAt(0)) { - if (next) next.click(); - } else if (event.charCode === 'P'.charCodeAt(0)) { - if (previous) previous.click(); - } else if (event.charCode === 'R'.charCodeAt(0)) { - if (random && ready) random.click(); - } + setTimeout(() => { + a.href = rebase('js-disabled'); + }); + + switch (a.dataset.random) { + case 'album': + a.href = openAlbum(pick(albumData).directory); + break; + + 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; + + case 'track': + a.href = openTrack(getRefDirectory(pick(tracks(albumData)))); + break; + + 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)); -for (const reveal of document.querySelectorAll('.reveal')) { - reveal.addEventListener('click', (event) => { - if (!reveal.classList.contains('revealed')) { - reveal.classList.add('revealed'); - event.preventDefault(); - event.stopPropagation(); - reveal.dispatchEvent(new CustomEvent('hsmusic-reveal')); + 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)) { + if (ready) { + 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'); @@ -454,205 +504,393 @@ if (localStorage.tryInfoCards) { // Custom hash links -------------------------------------- -function addHashLinkHandlers() { +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! - let wasHighlighted; + const info = hashLinkInfo; + const {state, event} = info; - for (const a of document.links) { - const href = a.getAttribute('href'); - if (!href || !href.startsWith('#')) { - continue; - } + 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; + } - a.addEventListener('click', handleHashLinkClicked); - } + // 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 = ''); - function handleHashLinkClicked(evt) { - if (evt.metaKey || evt.shiftKey || evt.ctrlKey || evt.altKey) { - return; - } + const box = target.getBoundingClientRect(); + const style = window.getComputedStyle(target); - const href = evt.target.getAttribute('href'); - const id = href.slice(1); - const linked = document.getElementById(id); + const scrollY = + window.scrollY + + box.top + - style['scroll-margin-top'].replace('px', ''); - if (!linked) { - 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 = ''); + evt.preventDefault(); + history.pushState({}, '', href); + window.scrollTo({top: scrollY, behavior: 'smooth'}); + target.focus({preventScroll: true}); - const box = linked.getBoundingClientRect(); - const style = window.getComputedStyle(linked); + const maxScroll = + document.body.scrollHeight + - window.innerHeight; - const scrollY = - window.scrollY - + box.top - - style['scroll-margin-top'].replace('px', ''); + if (scrollY > maxScroll && target.classList.contains('content-heading')) { + if (state.highlightedTarget) { + state.highlightedTarget.classList.remove('highlight-hash-link'); + } - evt.preventDefault(); - history.pushState({}, '', href); - window.scrollTo({top: scrollY, behavior: 'smooth'}); - linked.focus({preventScroll: true}); + target.classList.add('highlight-hash-link'); + state.highlightedTarget = target; + } - const maxScroll = - document.body.scrollHeight - - window.innerHeight; + processScrollingAfterHashLinkClicked(); - if (scrollY > maxScroll && linked.classList.contains('content-heading')) { - if (wasHighlighted) { - wasHighlighted.classList.remove('highlight-hash-link'); + for (const handler of event.whenHashLinkClicked) { + handler({ + link: hashLink, + }); } + }); + } - wasHighlighted = linked; - linked.classList.add('highlight-hash-link'); - linked.addEventListener('animationend', function handle(evt) { - if (evt.animationName === 'highlight-hash-link') { - linked.removeEventListener('animationend', handle); - linked.classList.remove('highlight-hash-link'); - wasHighlighted = null; - } - }); - } + 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; + }); } } -addHashLinkHandlers(); +clientSteps.getPageReferences.push(getHashLinkReferences); +clientSteps.addPageListeners.push(addHashLinkListeners); // Sticky content heading --------------------------------- -const stickyHeadingInfo = Array.from(document.querySelectorAll('.content-sticky-heading-container')) - .map(stickyContainer => { - const {parentElement: contentContainer} = stickyContainer; - const stickySubheadingRow = stickyContainer.querySelector('.content-sticky-subheading-row'); - const stickySubheading = stickySubheadingRow.querySelector('h2'); - let stickyCoverContainer = stickyContainer.querySelector('.content-sticky-heading-cover-container'); - let stickyCover = stickyCoverContainer?.querySelector('.content-sticky-heading-cover'); - const contentHeadings = Array.from(contentContainer.querySelectorAll('.content-heading')); - const contentCover = contentContainer.querySelector('#cover-art-container'); - - if (stickyCover?.querySelector('.image-text-area')) { - stickyCoverContainer.remove(); - stickyCoverContainer = null; - stickyCover = null; - } +const stickyHeadingInfo = clientInfo.stickyHeadingInfo = { + stickyContainers: null, - return { - contentContainer, - contentCover, - contentHeadings, - stickyContainer, - stickyCover, - stickyCoverContainer, - stickySubheading, - stickySubheadingRow, - state: { - displayedHeading: null, - }, - }; - }); + stickySubheadingRows: null, + stickySubheadings: null, -const topOfViewInside = (el, scroll = window.scrollY) => ( - scroll > el.offsetTop && - scroll < el.offsetTop + el.offsetHeight -); - -function prepareStickyHeadings() { - for (const { - contentCover, - stickyCover, - } of stickyHeadingInfo) { - const coverRevealImage = contentCover?.querySelector('.reveal'); - if (coverRevealImage) { - stickyCover.classList.add('content-sticky-heading-cover-needs-reveal'); - coverRevealImage.addEventListener('hsmusic-reveal', () => { - stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); - }); + 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 updateStickyHeading() { - for (const { - contentContainer, - contentCover, - contentHeadings, - stickyContainer, - stickyCoverContainer, - stickySubheading, - stickySubheadingRow, - state, - } of stickyHeadingInfo) { - let closestHeading = null; +function getContentHeadingClosestToStickySubheading(index) { + const info = stickyHeadingInfo; - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 0) { - stickyCoverContainer.classList.add('visible'); - } else { - stickyCoverContainer.classList.remove('visible'); - } + 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; } + } - if (topOfViewInside(contentContainer)) { - if (stickySubheading.childNodes.length === 0) { - //   to ensure correct basic line height - stickySubheading.appendChild(document.createTextNode('\xA0')); - } + return null; +} - const stickyRect = stickyContainer.getBoundingClientRect(); - const subheadingRect = stickySubheading.getBoundingClientRect(); - const stickyBottom = stickyRect.bottom + subheadingRect.height; - - // This array is reversed so that we're starting from the bottom when - // iterating over it. - for (let i = contentHeadings.length - 1; i >= 0; i--) { - const heading = contentHeadings[i]; - const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { - closestHeading = heading; - break; +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.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); } + } else { + stickySubheading.appendChild(child.cloneNode(true)); } } - if (state.displayedHeading !== closestHeading) { - if (closestHeading) { - // Array.from needed to iterate over a live array with for..of - for (const child of Array.from(stickySubheading.childNodes)) { - child.remove(); - } + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } - for (const child of closestHeading.childNodes) { - if (child.tagName === 'A') { - for (const grandchild of child.childNodes) { - stickySubheading.appendChild(grandchild.cloneNode(true)); - } - } else { - stickySubheading.appendChild(child.cloneNode(true)); - } - } + const oldDisplayedHeading = state.displayedHeading; - stickySubheadingRow.classList.add('visible'); - } else { - stickySubheadingRow.classList.remove('visible'); - } + state.displayedHeading = closestHeading; - state.displayedHeading = closestHeading; - } + for (const handler of event.whenDisplayedHeadingChanges) { + handler(index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); } } -document.addEventListener('scroll', updateStickyHeading); -prepareStickyHeadings(); -updateStickyHeading(); +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 ------------------------------------------ @@ -970,3 +1208,16 @@ for (const info of groupContributionsTableInfo) { sortGroupContributionsTableBy(info, 'count'); }); } + +// 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 33f622ca94cdac2b7b6b1d3bbd57a96248e57035 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 24 Sep 2023 15:41:53 -0300 Subject: client: implement album commentary sidebar dynamics --- src/static/client2.js | 211 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/src/static/client2.js b/src/static/client2.js index 96b422a8..4f4a7153 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -1209,6 +1209,217 @@ for (const info of groupContributionsTableInfo) { }); } +// 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)); + + 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)) { -- cgit 1.3.0-6-gf8a5