diff options
Diffstat (limited to 'src/static/client3.js')
-rw-r--r-- | src/static/client3.js | 346 |
1 files changed, 337 insertions, 9 deletions
diff --git a/src/static/client3.js b/src/static/client3.js index 236e98a..64f5b37 100644 --- a/src/static/client3.js +++ b/src/static/client3.js @@ -5,7 +5,8 @@ // that cannot 8e done at static-site compile time, 8y its fundamentally // ephemeral nature. -import {empty, filterMultipleArrays, stitchArrays} from '../util/sugar.js'; +import {accumulateSum, empty, filterMultipleArrays, stitchArrays} + from '../util/sugar.js'; const clientInfo = window.hsmusicClientInfo = Object.create(null); @@ -17,7 +18,7 @@ const clientSteps = { addPageListeners: [], }; -function initInfo(key, description) { +function initInfo(infoKey, description) { const object = {...description}; for (const obj of [ @@ -30,7 +31,47 @@ function initInfo(key, description) { Object.preventExtensions(obj); } - clientInfo[key] = object; + if (object.session) { + const sessionDefaults = object.session; + + object.session = {}; + + for (const [key, defaultValue] of Object.entries(sessionDefaults)) { + const storageKey = `hsmusic.${infoKey}.${key}`; + + let fallbackValue = defaultValue; + + Object.defineProperty(object.session, key, { + get: () => { + try { + return sessionStorage.getItem(storageKey) ?? defaultValue; + } catch (error) { + if (error instanceof DOMException) { + return fallbackValue; + } else { + throw error; + } + } + }, + + set: (value) => { + try { + sessionStorage.setItem(storageKey, value); + } catch (error) { + if (error instanceof DOMException) { + fallbackValue = value; + } else { + throw error; + } + } + }, + }); + } + + Object.preventExtensions(object.session); + } + + clientInfo[infoKey] = object; return object; } @@ -193,6 +234,34 @@ class WikiRect extends DOMRect { return this.fromRect(element.getBoundingClientRect()); } + static fromMouse() { + const {clientX, clientY} = liveMousePositionInfo.state; + + return WikiRect.fromRect({ + x: clientX, + y: clientY, + width: 0, + height: 0, + }); + } + + static fromElementUnderMouse(element) { + const mouseRect = WikiRect.fromMouse(); + + const rects = + Array.from(element.getClientRects()) + .map(rect => WikiRect.fromRect(rect)); + + const rectUnderMouse = + rects.find(rect => rect.contains(mouseRect)); + + if (rectUnderMouse) { + return rectUnderMouse; + } else { + return rects[0]; + } + } + static leftOf(origin, offset = 0) { // Returns a rectangle representing everywhere to the left of the provided // point or rectangle (with no top or bottom bounds), towards negative x. @@ -689,6 +758,29 @@ function mutateCSSCompatibilityContent() { clientSteps.getPageReferences.push(getCSSCompatibilityAssistantInfoReferences); clientSteps.mutatePageContent.push(mutateCSSCompatibilityContent); +// Ever-updating mouse position helper -------------------- + +const liveMousePositionInfo = initInfo('liveMousePositionInfo', { + state: { + clientX: null, + clientY: null, + }, +}); + +function addLiveMousePositionPageListeners() { + const info = liveMousePositionInfo; + const {state} = info; + + document.body.addEventListener('mousemove', domEvent => { + Object.assign(state, { + clientX: domEvent.clientX, + clientY: domEvent.clientY, + }); + }); +} + +clientSteps.addPageListeners.push(addLiveMousePositionPageListeners); + // JS-based links ----------------------------------------- const scriptedLinkInfo = initInfo('scriptedLinkInfo', { @@ -1024,6 +1116,11 @@ const hoverableTooltipInfo = initInfo('hoverableTooltipInfo', { currentTouchIdentifiers: new Set(), touchIdentifiersBanishedByScrolling: new Set(), }, + + event: { + whenTooltipShows: [], + whenTooltipHides: [], + }, }); // Adds DOM event listeners, so must be called during addPageListeners step. @@ -1280,7 +1377,25 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { // 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; + if (state.currentlyShownTooltip === tooltip) { + // If the hoverable was *recently* touched - meaning that this is a second + // touchend in short succession - then just letting the click come through + // naturally would (depending on timing) not actually navigate anywhere, + // because we've deliberately banished the *first* touch from navigation. + // We do want the second touch to navigate, so clear that recently-touched + // state, allowing this touch's click to behave as normal. + if (state.hoverableWasRecentlyTouched) { + clearTimeout(state.touchTimeout); + state.touchTimeout = null; + state.hoverableWasRecentlyTouched = false; + } + + // Otherwise, this is just a second touch after enough time has passed + // that the one which showed the tooltip is no longer "recent", and we're + // not in any special state. The link will navigate to its page just like + // normal. + return; + } const touches = Array.from(domEvent.changedTouches); const identifiers = touches.map(touch => touch.identifier); @@ -1322,8 +1437,9 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { state.hoverableWasRecentlyTouched = true; state.touchTimeout = setTimeout(() => { + state.touchTimeout = null; state.hoverableWasRecentlyTouched = false; - }, 250); + }, 1200); } function handleTooltipHoverableClicked(hoverable) { @@ -1425,7 +1541,7 @@ function endTransitioningTooltipHidden() { } function hideCurrentlyShownTooltip(intendingToReplace = false) { - const {settings, state} = hoverableTooltipInfo; + const {settings, state, event} = hoverableTooltipInfo; const {currentlyShownTooltip: tooltip} = state; // If there was no tooltip to begin with, we're functionally in the desired @@ -1465,11 +1581,15 @@ function hideCurrentlyShownTooltip(intendingToReplace = false) { state.tooltipWasJustHidden = false; }); + dispatchInternalEvent(event, 'whenTooltipHides', { + tooltip, + }); + return true; } function showTooltipFromHoverable(hoverable) { - const {state} = hoverableTooltipInfo; + const {state, event} = hoverableTooltipInfo; const {tooltip} = state.registeredHoverables.get(hoverable); if (!hideCurrentlyShownTooltip(true)) return false; @@ -1490,6 +1610,10 @@ function showTooltipFromHoverable(hoverable) { state.tooltipWasJustHidden = false; + dispatchInternalEvent(event, 'whenTooltipShows', { + tooltip, + }); + return true; } @@ -1591,7 +1715,7 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { getTooltipBaselineOpportunityAreas(tooltip); const hoverableRect = - WikiRect.fromElement(hoverable).toExtended(5, 10); + WikiRect.fromElementUnderMouse(hoverable).toExtended(5, 10); const tooltipRect = peekTooltipClientRect(tooltip); @@ -1666,7 +1790,7 @@ function getTooltipFromHoverablePlacementOpportunityAreas(hoverable) { const regionRect = regionRects[i]; if (regionRect.width > 0) { - return regionRect; + return rect; } else { return WikiRect.fromRect({ x: regionRect.right - tooltipRect.width, @@ -2972,6 +3096,210 @@ function addDatestampTooltipPageListeners() { clientSteps.getPageReferences.push(getDatestampTooltipReferences); clientSteps.addPageListeners.push(addDatestampTooltipPageListeners); +// Artist external link tooltips -------------------------- + +// These don't need to have tooltip events specially added as +// they're implemented with "text with tooltip" components. + +const artistExternalLinkTooltipInfo = initInfo('artistExternalLinkTooltipInfo', { + tooltips: null, + tooltipRows: null, + + settings: { + // This is the maximum distance, in CSS pixels, that the mouse + // can appear to be moving per second while still considered + // "idle". A greater value means higher tolerance for small + // movements. + maximumIdleSpeed: 40, + + // Leaving the mouse idle for this amount of time, over a single + // row of the tooltip, will cause a column of supplemental info + // to display. + mouseIdleShowInfoDelay: 1000, + + // If none of these tooltips are visible for this amount of time, + // the supplemental info column is hidden. It'll never disappear + // while a tooltip is actually visible. + hideInfoAfterTooltipHiddenDelay: 2250, + }, + + state: { + // This is shared by all tooltips. + showingTooltipInfo: false, + + mouseIdleTimeout: null, + hideInfoTimeout: null, + + mouseMovementPositions: [], + mouseMovementTimestamps: [], + }, +}); + +function getArtistExternalLinkTooltipPageReferences() { + const info = artistExternalLinkTooltipInfo; + + info.tooltips = + Array.from(document.getElementsByClassName('icons-tooltip')); + + info.tooltipRows = + info.tooltips.map(tooltip => + Array.from(tooltip.getElementsByClassName('icon'))); +} + +function addArtistExternalLinkTooltipInternalListeners() { + const info = artistExternalLinkTooltipInfo; + + hoverableTooltipInfo.event.whenTooltipShows.push(({tooltip}) => { + const {state} = info; + + if (info.tooltips.includes(tooltip)) { + clearTimeout(state.hideInfoTimeout); + state.hideInfoTimeout = null; + } + }); + + hoverableTooltipInfo.event.whenTooltipHides.push(() => { + const {settings, state} = info; + + if (state.showingTooltipInfo) { + state.hideInfoTimeout = + setTimeout(() => { + state.hideInfoTimeout = null; + hideArtistExternalLinkTooltipInfo(); + }, settings.hideInfoAfterTooltipHiddenDelay); + } else { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + }); +} + +function addArtistExternalLinkTooltipPageListeners() { + const info = artistExternalLinkTooltipInfo; + + for (const tooltip of info.tooltips) { + tooltip.addEventListener('mousemove', domEvent => { + handleArtistExternalLinkTooltipMouseMoved(domEvent); + }); + + tooltip.addEventListener('mouseout', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } + + for (const tooltipRow of info.tooltipRows.flat()) { + tooltipRow.addEventListener('mouseover', () => { + const {state} = info; + + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + }); + } +} + +function handleArtistExternalLinkTooltipMouseMoved(domEvent) { + const info = artistExternalLinkTooltipInfo; + const {settings, state} = info; + + if (state.showingTooltipInfo) { + return; + } + + // Clean out expired mouse movements + + const expiryTime = 1000; + + if (!empty(state.mouseMovementTimestamps)) { + const firstRecentMovementIndex = + state.mouseMovementTimestamps + .findIndex(value => Date.now() - value <= expiryTime); + + if (firstRecentMovementIndex === -1) { + state.mouseMovementTimestamps.splice(0); + state.mouseMovementPositions.splice(0); + } else if (firstRecentMovementIndex > 0) { + state.mouseMovementTimestamps.splice(0, firstRecentMovementIndex - 1); + state.mouseMovementPositions.splice(0, firstRecentMovementIndex - 1); + } + } + + const currentMovementDistance = + Math.sqrt(domEvent.movementX ** 2 + domEvent.movementY ** 2); + + state.mouseMovementTimestamps.push(Date.now()); + state.mouseMovementPositions.push([domEvent.screenX, domEvent.screenY]); + + // We can't really compute speed without having + // at least two data points! + if (state.mouseMovementPositions.length < 2) { + return; + } + + const movementTravelDistances = + state.mouseMovementPositions.map((current, index, array) => { + if (index === 0) return 0; + + const previous = array[index - 1]; + const deltaX = current[0] - previous[0]; + const deltaY = current[1] - previous[1]; + return Math.sqrt(deltaX ** 2 + deltaY ** 2); + }); + + const totalTravelDistance = + accumulateSum(movementTravelDistances); + + // In seconds rather than milliseconds. + const timeSinceFirstMovement = + (Date.now() - state.mouseMovementTimestamps[0]) / 1000; + + const averageSpeed = + Math.floor(totalTravelDistance / timeSinceFirstMovement); + + if (averageSpeed > settings.maximumIdleSpeed) { + clearTimeout(state.mouseIdleTimeout); + state.mouseIdleTimeout = null; + } + + if (state.mouseIdleTimeout) { + return; + } + + state.mouseIdleTimeout = + setTimeout(() => { + state.mouseIdleTimeout = null; + showArtistExternalLinkTooltipInfo(); + }, settings.mouseIdleShowInfoDelay); +} + +function showArtistExternalLinkTooltipInfo() { + const info = artistExternalLinkTooltipInfo; + const {state} = info; + + state.showingTooltipInfo = true; + + for (const tooltip of info.tooltips) { + tooltip.classList.add('show-info'); + } +} + +function hideArtistExternalLinkTooltipInfo() { + const info = artistExternalLinkTooltipInfo; + const {state} = info; + + state.showingTooltipInfo = false; + + for (const tooltip of info.tooltips) { + tooltip.classList.remove('show-info'); + } +} + +clientSteps.getPageReferences.push(getArtistExternalLinkTooltipPageReferences); +clientSteps.addInternalListeners.push(addArtistExternalLinkTooltipInternalListeners); +clientSteps.addPageListeners.push(addArtistExternalLinkTooltipPageListeners); + // Sticky commentary sidebar ------------------------------ const albumCommentarySidebarInfo = initInfo('albumCommentarySidebarInfo', { |