Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

MediaWiki:Common.js

MediaWiki interface page
Revision as of 19:05, 19 February 2026 by Eviand (talk | contribs)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* Common.js — Spirit Vale Wiki
   Standalone Module: Definitions (v1)
   - Event-delegated tooltips for .sv-def
   - Desktop: hover + follow cursor
   - Mobile/touch: tap to pin near tap point
   - Blank definitions do nothing (and will close any open tooltip)
   - Future-ready: data-sv-def-link navigation support

   Markup contract (produced by Module:Definitions / Template:def):
     <span class="sv-def"
           data-sv-def-domain="Stat"
           data-sv-def-key="Vit"
           data-sv-def-tip="Tooltip text (optional)"
           data-sv-def-link="Page Title (optional)">
       ...
     </span>
*/

(function () {
  // Namespace + per-module guard (no “41”).
  var SV = (window.SV = window.SV || {});
  var COMMON = (SV.common = SV.common || {});
  if (COMMON.definitionsInit) return;
  COMMON.definitionsInit = 1;

  /* ------------------------------------------------------------------ */
  /* Selectors / Attributes                                              */
  /* ------------------------------------------------------------------ */

  var DEF_SEL = ".sv-def";
  var TIP_ATTR = "data-sv-def-tip";
  var LINK_ATTR = "data-sv-def-link";

  /* ------------------------------------------------------------------ */
  /* Capability detection                                                 */
  /* ------------------------------------------------------------------ */

  var HOVER_CAPABLE = false;
  try {
    HOVER_CAPABLE =
      !!window.matchMedia &&
      window.matchMedia("(hover:hover) and (pointer:fine)").matches;
  } catch (e) {
    HOVER_CAPABLE = false;
  }

  /* ------------------------------------------------------------------ */
  /* State                                                               */
  /* ------------------------------------------------------------------ */

  var tipEl = null;

  var state = {
    visible: false,
    pinned: false,
    anchor: null, // current .sv-def element
    lastX: 0,
    lastY: 0,
    suppressClickUntil: 0, // prevents synthetic click after touch pin
  };

  /* ------------------------------------------------------------------ */
  /* Utilities                                                           */
  /* ------------------------------------------------------------------ */

  function closest(el, sel) {
    if (!el || el.nodeType !== 1) return null;
    if (el.closest) return el.closest(sel);
    while (el && el.nodeType === 1) {
      if (el.matches && el.matches(sel)) return el;
      el = el.parentElement;
    }
    return null;
  }

  function getTipText(defEl) {
    if (!defEl || !defEl.getAttribute) return "";
    var t = defEl.getAttribute(TIP_ATTR);
    if (t == null) return "";
    // Treat pure-whitespace as empty.
    t = String(t);
    if (!t.replace(/\s+/g, "")) return "";
    return t;
  }

  function getLinkTitle(defEl) {
    if (!defEl || !defEl.getAttribute) return "";
    var t = defEl.getAttribute(LINK_ATTR);
    if (t == null) return "";
    t = String(t).trim();
    return t;
  }

  function ensureTipEl() {
    if (tipEl) return tipEl;

    var el = document.createElement("div");
    el.className = "sv-def-tip sv-hidden";
    el.setAttribute("role", "tooltip");
    el.setAttribute("aria-hidden", "true");

    // Minimal inline safety styling (CSS can override via .sv-def-tip).
    // This is intentionally conservative so it still works even if site CSS
    // hasn’t loaded yet.
    el.style.position = "fixed";
    el.style.zIndex = "99999";
    el.style.maxWidth = "360px";
    el.style.boxSizing = "border-box";
    el.style.padding = "8px 10px";
    el.style.borderRadius = "10px";
    el.style.background = "rgba(10, 14, 22, 0.92)";
    el.style.color = "#fff";
    el.style.fontSize = "14px";
    el.style.lineHeight = "1.25";
    el.style.whiteSpace = "pre-line";
    el.style.pointerEvents = "none"; // enabled only when pinned
    el.style.display = "none";

    document.body.appendChild(el);
    tipEl = el;
    return tipEl;
  }

  function setHidden(el, hidden) {
    if (!el) return;
    if (hidden) {
      el.classList.add("sv-hidden");
      el.setAttribute("aria-hidden", "true");
      el.style.display = "none";
    } else {
      el.classList.remove("sv-hidden");
      el.setAttribute("aria-hidden", "false");
      el.style.display = "block";
    }
  }

  function clamp(n, min, max) {
    if (n < min) return min;
    if (n > max) return max;
    return n;
  }

  function positionTipAt(clientX, clientY) {
    var el = ensureTipEl();
    if (!el) return;

    // First, ensure it’s measurable.
    // (If we just turned it on, width/height might be 0 until next frame.)
    var vw = window.innerWidth || document.documentElement.clientWidth || 0;
    var vh = window.innerHeight || document.documentElement.clientHeight || 0;

    // Gap rules:
    // - hover: larger vertical gap so cursor doesn’t cover it
    // - pinned: tighter but still separated from the tap point
    var gapY = state.pinned ? 10 : 16;
    var gapX = 12;

    // Two-pass: set an initial position, then refine once we have rect.
    el.style.left = "0px";
    el.style.top = "0px";

    // Defer measuring to allow layout to settle.
    requestAnimationFrame(function () {
      if (!state.visible) return;

      var r = el.getBoundingClientRect();
      var w = r.width || 240;
      var h = r.height || 60;

      // Prefer above cursor/tap.
      var left = clientX - w / 2;
      var top = clientY - h - gapY;

      // Clamp horizontally.
      left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));

      // If not enough space above, flip below.
      if (top < gapY) top = clientY + gapY;

      // Clamp vertically.
      top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));

      el.style.left = Math.round(left) + "px";
      el.style.top = Math.round(top) + "px";
    });
  }

  function showTip(defEl, text, clientX, clientY, pinned) {
    var el = ensureTipEl();
    if (!el) return;

    state.visible = true;
    state.pinned = !!pinned;
    state.anchor = defEl || null;
    state.lastX = clientX || 0;
    state.lastY = clientY || 0;

    // Text-only (safe).
    el.textContent = text;

    // Pointer events only when pinned (so pinned tooltip can be interacted with later if needed).
    el.style.pointerEvents = state.pinned ? "auto" : "none";
    el.classList.toggle("sv-def-tip--pinned", state.pinned);

    setHidden(el, false);
    positionTipAt(state.lastX, state.lastY);
  }

  function hideTip() {
    if (!tipEl) return;

    state.visible = false;
    state.pinned = false;
    state.anchor = null;

    setHidden(tipEl, true);
  }

  function navigateToTitle(title) {
    if (!title) return;

    // MediaWiki-friendly navigation if available.
    if (window.mw && mw.util && typeof mw.util.getUrl === "function") {
      window.location.href = mw.util.getUrl(title);
      return;
    }

    // Fallback: treat as relative.
    window.location.href = String(title);
  }

  function sameAnchor(el) {
    return !!(el && state.anchor && el === state.anchor);
  }

  function shouldSuppressClick() {
    return Date.now() < state.suppressClickUntil;
  }

  /* ------------------------------------------------------------------ */
  /* Desktop hover behavior (only when hover-capable)                    */
  /* ------------------------------------------------------------------ */

  if (HOVER_CAPABLE) {
    document.addEventListener(
      "mouseover",
      function (e) {
        if (state.pinned) return; // pinned wins

        var defEl = closest(e.target, DEF_SEL);
        if (!defEl) {
          // If moving over non-def content, close hover tooltip (if any).
          if (state.visible && !state.pinned) hideTip();
          return;
        }

        // Avoid re-triggering within the same element.
        var rel = e.relatedTarget;
        if (rel && defEl.contains(rel)) return;

        var text = getTipText(defEl);
        if (!text) {
          // Blank tip closes any active hover tooltip.
          if (state.visible && !state.pinned) hideTip();
          return;
        }

        showTip(defEl, text, e.clientX, e.clientY, false);
      },
      true
    );

    document.addEventListener(
      "mousemove",
      function (e) {
        if (!state.visible || state.pinned) return;
        // Only track if we’re still “on” the same anchor.
        var defEl = closest(e.target, DEF_SEL);
        if (!defEl || !sameAnchor(defEl)) return;

        state.lastX = e.clientX;
        state.lastY = e.clientY;
        positionTipAt(state.lastX, state.lastY);
      },
      true
    );

    document.addEventListener(
      "mouseout",
      function (e) {
        if (!state.visible || state.pinned) return;

        var defEl = closest(e.target, DEF_SEL);
        if (!defEl || !sameAnchor(defEl)) return;

        var rel = e.relatedTarget;
        if (rel && defEl.contains(rel)) return; // still within the definition element

        hideTip();
      },
      true
    );
  }

  /* ------------------------------------------------------------------ */
  /* Touch / click behavior                                              */
  /* ------------------------------------------------------------------ */

  // Pointerdown is the best universal “tap” detector.
  document.addEventListener(
    "pointerdown",
    function (e) {
      var defEl = closest(e.target, DEF_SEL);
      if (!defEl) return;

      var text = getTipText(defEl);
      if (!text) {
        // Blank def: close if open.
        if (state.visible) hideTip();
        return;
      }

      // Touch/pen: pin tooltip near tap.
      // Mouse: we don’t pin on pointerdown (let hover handle it); clicking can toggle pin.
      var isTouchLike = e.pointerType && e.pointerType !== "mouse";
      if (!isTouchLike) return;

      // Prevent synthetic click immediately after a touch pin (avoids double-toggling).
      state.suppressClickUntil = Date.now() + 450;

      // If already pinned on same anchor and it has a link, a second tap navigates.
      var link = getLinkTitle(defEl);
      if (state.pinned && sameAnchor(defEl) && link) {
        e.preventDefault();
        navigateToTitle(link);
        return;
      }

      e.preventDefault();
      showTip(defEl, text, e.clientX, e.clientY, true);
    },
    true
  );

  // Click: desktop toggle pin (no link) OR navigate (has link).
  document.addEventListener(
    "click",
    function (e) {
      if (shouldSuppressClick()) return;

      var defEl = closest(e.target, DEF_SEL);
      if (!defEl) return;

      var text = getTipText(defEl);
      if (!text) {
        if (state.visible) hideTip();
        return;
      }

      var link = getLinkTitle(defEl);

      // If link exists: navigate.
      if (link) {
        // If tooltip is pinned on this item, allow click to navigate (expected “future” behavior).
        e.preventDefault();
        navigateToTitle(link);
        return;
      }

      // No link: toggle pinned state on desktop.
      e.preventDefault();

      if (state.pinned && sameAnchor(defEl)) {
        hideTip();
      } else {
        showTip(defEl, text, e.clientX, e.clientY, true);
      }
    },
    true
  );

  /* ------------------------------------------------------------------ */
  /* Dismissal behavior                                                  */
  /* ------------------------------------------------------------------ */

  // Click outside closes pinned tooltip.
  document.addEventListener(
    "click",
    function (e) {
      if (!state.visible || !state.pinned) return;

      // Click inside tooltip should not close it.
      if (tipEl && (e.target === tipEl || (tipEl.contains && tipEl.contains(e.target)))) return;

      // Click on a definition is handled by the click handler above.
      if (closest(e.target, DEF_SEL)) return;

      hideTip();
    },
    true
  );

  // Escape closes any tooltip (pinned or hover).
  document.addEventListener(
    "keydown",
    function (e) {
      if (e.key !== "Escape") return;
      if (state.visible) hideTip();
    },
    true
  );

  // If the page scrolls while pinned, keep it near the last anchor/tap point.
  window.addEventListener(
    "scroll",
    function () {
      if (!state.visible) return;
      positionTipAt(state.lastX, state.lastY);
    },
    true
  );

  window.addEventListener(
    "resize",
    function () {
      if (!state.visible) return;
      positionTipAt(state.lastX, state.lastY);
    },
    true
  );

  /* ------------------------------------------------------------------ */
  /* Init hook                                                           */
  /* ------------------------------------------------------------------ */

  // This module is fully event-delegated; no per-node init required.
  // Still, we keep the MediaWiki hook so this file matches your site pattern.
  function initAll(root) {
    // No-op on purpose (delegated listeners already installed once).
    // Placeholder for future expansions (e.g., prewarming tooltip element).
    void root;
  }

  if (window.mw && mw.hook) {
    mw.hook("wikipage.content").add(function ($content) {
      initAll($content && $content[0]);
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      initAll(document);
    });
  } else {
    initAll(document);
  }
})();