« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/content/dependencies/linkContribution.js18
-rw-r--r--src/static/client3.js207
-rw-r--r--src/static/site6.css42
3 files changed, 262 insertions, 5 deletions
diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js
index cb57aa47..c69a5b48 100644
--- a/src/content/dependencies/linkContribution.js
+++ b/src/content/dependencies/linkContribution.js
@@ -1,4 +1,4 @@
-import {empty} from '#sugar';
+import {empty, stitchArrays} from '#sugar';
 
 export default {
   contentDependencies: [
@@ -34,6 +34,7 @@ export default {
   data(contribution) {
     return {
       what: contribution.what,
+      urls: contribution.who.urls,
     };
   },
 
@@ -74,12 +75,21 @@ export default {
                   {[html.joinChildren]: ''},
 
                 content:
-                  relations.artistIcons
-                    .map(icon =>
+                  stitchArrays({
+                    icon: relations.artistIcons,
+                    url: data.urls,
+                  }).map(({icon, url}) => [
                       icon.slots({
                         context: 'artist',
                         withText: true,
-                      })),
+                      }),
+
+                      html.tag('span', {class: 'icon-platform'},
+                        language.formatExternalLink(url, {
+                          context: 'artist',
+                          style: 'platform',
+                        })),
+                    ]),
               }),
           })
         : relations.artistLink);
diff --git a/src/static/client3.js b/src/static/client3.js
index 5738b46f..f86fd840 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);
 
@@ -3036,6 +3037,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', {
diff --git a/src/static/site6.css b/src/static/site6.css
index 36662e75..a29158c5 100644
--- a/src/static/site6.css
+++ b/src/static/site6.css
@@ -594,6 +594,48 @@ li:not(:first-child:last-child) .tooltip,
           user-select: none;
 
   cursor: default;
+
+  display: grid;
+
+  grid-template-columns:
+    [icon-start] auto [icon-end domain-start] auto [domain-end];
+}
+
+.icons-tooltip .icon {
+  grid-column-start: icon-start;
+  grid-column-end: icon-end;
+}
+
+.icons-tooltip .icon-platform {
+  display: none;
+
+  grid-column-start: domain-start;
+  grid-column-end: domain-end;
+
+  --icon-platform-opacity: 0.8;
+  padding-right: 4px;
+  opacity: 0.8;
+}
+
+.icons-tooltip.show-info .icon-platform {
+  display: inline;
+  animation: icon-platform 0.2s forwards linear;
+}
+
+@keyframes icon-platform {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: var(--icon-platform-opacity);
+  }
+}
+
+.icons-tooltip .icon:hover + .icon-platform {
+  --icon-platform-opacity: 1;
+  text-decoration: underline;
+  text-decoration-color: #ffffffaa;
 }
 
 .datetimestamp-tooltip .tooltip-content,