« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/static/client3.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/static/client3.js')
-rw-r--r--src/static/client3.js346
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', {