diff options
Diffstat (limited to 'src/static/js/client/sticky-heading.js')
-rw-r--r-- | src/static/js/client/sticky-heading.js | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js new file mode 100644 index 00000000..b65574d0 --- /dev/null +++ b/src/static/js/client/sticky-heading.js @@ -0,0 +1,345 @@ +/* eslint-env browser */ + +import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {cssProp, dispatchInternalEvent, templateContent} + from '../client-util.js'; + +export const info = { + id: 'stickyHeadingInfo', + + stickyRoots: null, + + stickyContainers: null, + staticContainers: null, + + stickyHeadingRows: null, + stickyHeadings: null, + stickySubheadingRows: null, + stickySubheadings: null, + + stickyCoverContainers: null, + stickyCoverTextAreas: null, + stickyCovers: null, + + contentContainers: null, + contentHeadings: null, + contentCoverColumns: null, + contentCovers: null, + contentCoversReveal: null, + + referenceCollapsedHeading: null, + + state: { + displayedHeading: null, + }, + + event: { + whenDisplayedHeadingChanges: [], + whenStuckStatusChanges: [], + }, +}; + +export function getPageReferences() { + info.stickyRoots = + Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])')); + + info.stickyContainers = + info.stickyRoots + .map(el => el.querySelector('.content-sticky-heading-container')); + + info.staticContainers = + info.stickyRoots + .map(el => el.nextElementSibling); + + info.stickyCoverContainers = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-cover-container')); + + info.stickyCovers = + info.stickyCoverContainers + .map(el => el?.querySelector('.content-sticky-heading-cover')); + + info.stickyCoverTextAreas = + info.stickyCovers + .map(el => el?.querySelector('.image-text-area')); + + info.stickyHeadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-row')); + + info.stickyHeadings = + info.stickyHeadingRows + .map(el => el.querySelector('h1')); + + info.stickySubheadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-subheading-row')); + + info.stickySubheadings = + info.stickySubheadingRows + .map(el => el.querySelector('h2')); + + info.contentContainers = + info.stickyContainers + .map(el => el.closest('.content-sticky-heading-root').parentElement); + + info.contentCoverColumns = + info.contentContainers + .map(el => el.querySelector('#artwork-column')); + + info.contentCovers = + info.contentCoverColumns + .map(el => el ? el.querySelector('.cover-artwork') : null); + + info.contentCoversReveal = + info.contentCovers + .map(el => el ? !!el.querySelector('.reveal') : null); + + info.contentHeadings = + info.contentContainers + .map(el => Array.from(el.querySelectorAll('.content-heading'))); + + info.referenceCollapsedHeading = + info.stickyHeadings + .map(el => el.querySelector('.reference-collapsed-heading')); +} + +export function mutatePageContent() { + removeTextPlaceholderStickyHeadingCovers(); + addRevealClassToStickyHeadingCovers(); +} + +function removeTextPlaceholderStickyHeadingCovers() { + const hasTextArea = + info.stickyCoverTextAreas.map(el => !!el); + + const coverContainersWithTextArea = + info.stickyCoverContainers + .filter((_el, index) => hasTextArea[index]); + + for (const el of coverContainersWithTextArea) { + el.remove(); + } + + info.stickyCoverContainers = + info.stickyCoverContainers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCovers = + info.stickyCovers + .map((el, index) => hasTextArea[index] ? null : el); + + info.stickyCoverTextAreas = + info.stickyCoverTextAreas + .slice() + .fill(null); +} + +function addRevealClassToStickyHeadingCovers() { + const stickyCoversWhichReveal = + info.stickyCovers + .filter((_el, index) => info.contentCoversReveal[index]); + + for (const el of stickyCoversWhichReveal) { + el.classList.add('content-sticky-heading-cover-needs-reveal'); + } +} + +function addRevealListenersForStickyHeadingCovers() { + const stickyCovers = info.stickyCovers.slice(); + const contentCovers = info.contentCovers.slice(); + + filterMultipleArrays( + stickyCovers, + contentCovers, + (_stickyCover, _contentCover, index) => info.contentCoversReveal[index]); + + for (const {stickyCover, contentCover} of stitchArrays({ + stickyCover: stickyCovers, + contentCover: contentCovers, + })) { + // TODO: Janky - should use internal event instead of DOM event + contentCover.querySelector('.reveal').addEventListener('hsmusic-reveal', () => { + stickyCover.classList.remove('content-sticky-heading-cover-needs-reveal'); + }); + } +} + +function topOfViewInside(el, scroll = window.scrollY) { + return ( + scroll > el.offsetTop && + scroll < el.offsetTop + el.offsetHeight); +} + +function updateStuckStatus(index) { + const {event} = info; + + const contentContainer = info.contentContainers[index]; + const stickyContainer = info.stickyContainers[index]; + + const wasStuck = stickyContainer.classList.contains('stuck'); + const stuck = topOfViewInside(contentContainer); + + if (stuck === wasStuck) return; + + if (stuck) { + stickyContainer.classList.add('stuck'); + } else { + stickyContainer.classList.remove('stuck'); + } + + dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck); +} + +function updateCollapseStatus(index) { + const stickyContainer = info.stickyContainers[index]; + const staticContainer = info.staticContainers[index]; + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + + const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect(); + const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect(); + + if ( + staticContainer.getBoundingClientRect().bottom < 4 || + staticContainer.getBoundingClientRect().top < -80 + ) { + if (!stickyContainer.classList.contains('collapse')) { + stickyContainer.classList.add('collapse'); + cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px'); + cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px'); + } + } else { + stickyContainer.classList.remove('collapse'); + } +} + +function updateStickyCoverVisibility(index) { + const stickyCoverContainer = info.stickyCoverContainers[index]; + const stickyContainer = info.stickyContainers[index]; + const contentCoverColumn = info.contentCoverColumns[index]; + + if (contentCoverColumn && stickyCoverContainer) { + if (contentCoverColumn.getBoundingClientRect().bottom < 4) { + stickyCoverContainer.classList.add('visible'); + stickyContainer.classList.add('cover-visible'); + } else { + stickyCoverContainer.classList.remove('visible'); + stickyContainer.classList.remove('cover-visible'); + } + } +} + +function getContentHeadingClosestToStickySubheading(index) { + const contentContainer = info.contentContainers[index]; + + if (!topOfViewInside(contentContainer)) { + return null; + } + + const stickyHeadingRow = info.stickyHeadingRows[index]; + const stickyRect = stickyHeadingRow.getBoundingClientRect(); + + // Subheadings only appear when the sticky heading is collapsed, + // so the used bottom edge should always be *as though* it's only + // displaying one line of text. Subtract the current discrepancy. + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + const correctBottomEdge = + stickyHeading.getBoundingClientRect().height - + referenceCollapsedHeading.getBoundingClientRect().height; + + const stickyBottom = + (stickyRect.bottom + - correctBottomEdge); + + // Iterate from bottom to top of the content area. + const contentHeadings = info.contentHeadings[index]; + for (const heading of contentHeadings.slice().reverse()) { + const headingRect = heading.getBoundingClientRect(); + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { + return heading; + } + } + + return null; +} + +function updateStickySubheadingContent(index) { + const {event, state} = info; + + const stickyContainer = info.stickyContainers[index]; + + const closestHeading = + (stickyContainer.classList.contains('collapse') + ? getContentHeadingClosestToStickySubheading(index) + : null); + + if (state.displayedHeading === closestHeading) return; + + const stickySubheadingRow = info.stickySubheadingRows[index]; + + if (closestHeading) { + const stickySubheading = info.stickySubheadings[index]; + + // Array.from needed to iterate over a live array with for..of + for (const child of Array.from(stickySubheading.childNodes)) { + child.remove(); + } + + const textContainer = + templateContent( + closestHeading.querySelector('.content-heading-sticky-title')) ?? + closestHeading.querySelector('.content-heading-main-title') ?? + closestHeading; + + for (const child of textContainer.childNodes) { + if (child.tagName === 'A') { + for (const grandchild of child.childNodes) { + stickySubheading.appendChild(grandchild.cloneNode(true)); + } + } else { + stickySubheading.appendChild(child.cloneNode(true)); + } + } + + stickySubheadingRow.classList.add('visible'); + } else { + stickySubheadingRow.classList.remove('visible'); + } + + const oldDisplayedHeading = state.displayedHeading; + + state.displayedHeading = closestHeading; + + dispatchInternalEvent(event, 'whenDisplayedHeadingChanges', index, { + oldHeading: oldDisplayedHeading, + newHeading: closestHeading, + }); +} + +export function updateStickyHeadings(index) { + updateStuckStatus(index); + updateCollapseStatus(index); + updateStickyCoverVisibility(index); + updateStickySubheadingContent(index); +} + +export function initializeState() { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } +} + +export function addPageListeners() { + addRevealListenersForStickyHeadingCovers(); + addScrollListenerForStickyHeadings(); +} + +function addScrollListenerForStickyHeadings() { + document.addEventListener('scroll', () => { + for (let i = 0; i < info.stickyContainers.length; i++) { + updateStickyHeadings(i); + } + }); +} |