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: Difference between revisions

MediaWiki interface page
No edit summary
No edit summary
Line 347: Line 347:
  * SV Definitions v1 — tooltips for .sv-def
  * SV Definitions v1 — tooltips for .sv-def
  * - Reads: data-sv-def-tip, data-sv-def-link
  * - Reads: data-sv-def-tip, data-sv-def-link
  * - Works with spans output by Module:Definitions
  * - Only activates when data-sv-def-tip is non-empty
  * ========================================================================= */
  * ========================================================================== */
(function (mw) {
(function (mw) {
   "use strict";
   "use strict";
Line 358: Line 358:
   var activeEl = null;
   var activeEl = null;
   var pinned = false;
   var pinned = false;
   var lastPoint = null; // {x,y} for point positioning
   var lastPoint = null; // {x,y}
  var lastTouchTime = 0;


   function qTipText(el) {
   function qTipText(el) {
Line 390: Line 391:
     tipEl.style.lineHeight = "1.25";
     tipEl.style.lineHeight = "1.25";
     tipEl.style.boxShadow = "0 8px 24px rgba(0,0,0,0.35)";
     tipEl.style.boxShadow = "0 8px 24px rgba(0,0,0,0.35)";
     tipEl.style.pointerEvents = "none"; // toggled when pinned
     tipEl.style.pointerEvents = "none";


     tipInner = document.createElement("div");
     tipInner = document.createElement("div");
Line 421: Line 422:
   }
   }


  // Cursor/finger anchored positioning; prefers ABOVE the point.
   function positionTipPoint(clientX, clientY) {
   function positionTipPoint(clientX, clientY) {
     if (!tipEl) return;
     if (!tipEl) return;
Line 431: Line 433:
     var tr = tipEl.getBoundingClientRect();
     var tr = tipEl.getBoundingClientRect();
     var pad = 8;
     var pad = 8;
     var gap = 12;
     var gap = pinned ? 14 : 18; // larger gap on hover so cursor doesn't cover text


     var x = clientX - (tr.width / 2);
     var x = clientX - tr.width / 2;
     x = clamp(x, pad, window.innerWidth - tr.width - pad);
     x = clamp(x, pad, window.innerWidth - tr.width - pad);


     var y = clientY - tr.height - gap;
     var y = clientY - tr.height - gap; // ABOVE by default
     if (y < pad) y = clientY + gap;
     if (y < pad) y = clientY + gap;   // flip below if needed
     y = clamp(y, pad, window.innerHeight - tr.height - pad);
     y = clamp(y, pad, window.innerHeight - tr.height - pad);


Line 450: Line 452:


     var txt = qTipText(el);
     var txt = qTipText(el);
     if (!txt) return;
     if (!txt) return false;


     activeEl = el;
     activeEl = el;
Line 458: Line 460:


     suppressTitle(el);
     suppressTitle(el);
    return true;
   }
   }


Line 482: Line 485:
   }
   }


   // Hover in/out (desktop) — disable rollover if tip is blank
   // Desktop hover
   document.addEventListener(
   document.addEventListener("mouseover", function (e) {
    "mouseover",
    if (pinned) return;
    function (e) {
      if (pinned) return;


      var el = closestDef(e.target);
    var el = closestDef(e.target);


      // If we moved onto non-definition content, hide any visible hover tooltip
    if (!el) {
      if (!el) {
      if (activeEl) hide();
        if (activeEl) hide();
      return;
        return;
    }
      }
 
    if (isEntering(el, e.relatedTarget)) return;
    if (activeEl === el) return;


      if (isEntering(el, e.relatedTarget)) return;
    // Blank definition disables rollover AND clears any previous tooltip
       if (activeEl === el) return;
    if (!qTipText(el)) {
       if (activeEl) hide();
      return;
    }


      var txt = qTipText(el);
    if (show(el)) positionTipPoint(e.clientX, e.clientY);
      if (!txt) {
  }, true);
        // IMPORTANT: no tooltip data -> ensure the old tooltip is not left up
        if (activeEl) hide();
        return;
      }


      show(el);
  document.addEventListener("mousemove", function (e) {
      positionTipPoint(e.clientX, e.clientY);
     if (pinned) return;
     },
    true
  );


  document.addEventListener(
    var el = closestDef(e.target);
     "mousemove",
     if (!el) {
    function (e) {
       if (activeEl) hide();
       if (pinned) return;
      return;
    }


      var el = closestDef(e.target);
    if (activeEl && el !== activeEl) return;
      if (!el) {
        if (activeEl) hide();
        return;
      }


       if (activeEl && el !== activeEl) return;
    if (!qTipText(el)) {
       if (activeEl) hide();
      return;
    }


      var txt = qTipText(el);
    if (activeEl) positionTipPoint(e.clientX, e.clientY);
      if (!txt) {
  }, true);
        if (activeEl) hide();
        return;
      }


      // Track mouse so it never sits under the cursor
  document.addEventListener("mouseout", function (e) {
      if (activeEl) positionTipPoint(e.clientX, e.clientY);
    if (pinned) return;
     },
    var el = closestDef(e.target);
    true
    if (!el) return;
  );
    if (isEntering(el, e.relatedTarget)) return;
     if (activeEl === el) hide();
  }, true);


   document.addEventListener(
  // Touch pin (positions above finger)
    "mouseout",
   document.addEventListener("pointerdown", function (e) {
    function (e) {
    var el = closestDef(e.target);
      if (pinned) return;
    if (!el) return;
      var el = closestDef(e.target);
      if (!el) return;
      if (isEntering(el, e.relatedTarget)) return;
      if (activeEl === el) hide();
    },
    true
  );


  // Touch: pin tooltip and position ABOVE the finger press point.
     if (e.pointerType !== "touch") return;
  // If tip is blank, tap does nothing (and closes any pinned tooltip).
  document.addEventListener(
     "pointerdown",
    function (e) {
      var el = closestDef(e.target);
      if (!el) return;


      if (e.pointerType !== "touch") return;
    // Link = allow navigation
    if (qLink(el)) return;


      // If we have a link, let navigation happen.
    // Blank tip = do nothing (and close any pinned tooltip)
      if (qLink(el)) return;
    if (!qTipText(el)) {
      if (pinned) hide();
      return;
    }


      var txt = qTipText(el);
    lastTouchTime = Date.now();
      if (!txt) {
    pinned = true;
        if (pinned) hide();
        return;
      }


      pinned = true;
    if (show(el)) positionTipPoint(e.clientX, e.clientY);
      show(el);
      positionTipPoint(e.clientX, e.clientY);


      e.preventDefault();
    e.preventDefault();
      e.stopPropagation();
    e.stopPropagation();
    },
  }, true);
    true
  );


   // Click behavior:
   // Click behavior:
   // - If link exists, navigate.
   // - link -> navigate
   // - If tip exists, toggle pinned tooltip.
   // - tip -> toggle pinned
   // - If tip is blank, do nothing (and close pinned if clicking another def).
   // - blank tip -> close pinned
   document.addEventListener(
   document.addEventListener("click", function (e) {
    "click",
    // Ignore the synthetic click right after a touch pin
    function (e) {
    if (Date.now() - lastTouchTime < 500) return;
      var el = closestDef(e.target);


      if (!el) {
    var el = closestDef(e.target);
        if (pinned) hide();
        return;
      }


      var link = qLink(el);
    if (!el) {
       if (link) {
       if (pinned) hide();
        window.location.href = mw.util.getUrl(link);
      return;
        return;
    }
      }


      var txt = qTipText(el);
    var link = qLink(el);
      if (!txt) {
    if (link) {
        if (pinned) hide();
      window.location.href = mw.util.getUrl(link);
        return;
      return;
      }
    }


       e.preventDefault();
    if (!qTipText(el)) {
       e.stopPropagation();
       if (pinned) hide();
       return;
    }


      if (pinned && activeEl === el) {
    e.preventDefault();
        hide();
    e.stopPropagation();
        return;
      }


      pinned = true;
    if (pinned && activeEl === el) {
       show(el);
       hide();
       positionTipPoint(e.clientX, e.clientY);
       return;
     },
     }
    true
  );


  document.addEventListener(
     pinned = true;
     "keydown",
     if (show(el)) positionTipPoint(e.clientX, e.clientY);
     function (e) {
  }, true);
      if (e.key === "Escape" && (pinned || activeEl)) hide();
    },
    true
  );


   window.addEventListener(
   document.addEventListener("keydown", function (e) {
    "resize",
    if (e.key === "Escape" && (pinned || activeEl)) hide();
    function () {
  }, true);
      if (tipEl && tipEl.style.display === "block" && lastPoint) {
        positionTipPoint(lastPoint.x, lastPoint.y);
      }
    },
    true
  );


   window.addEventListener(
   window.addEventListener("resize", function () {
     "scroll",
     if (tipEl && tipEl.style.display === "block" && lastPoint) {
     function () {
      positionTipPoint(lastPoint.x, lastPoint.y);
      if (tipEl && tipEl.style.display === "block" && lastPoint) {
     }
        positionTipPoint(lastPoint.x, lastPoint.y);
  }, true);
      }
 
    },
  window.addEventListener("scroll", function () {
    true
    if (tipEl && tipEl.style.display === "block" && lastPoint) {
  );
      positionTipPoint(lastPoint.x, lastPoint.y);
    }
  }, true);


  mw.hook("wikipage.content").add(function () {
    // No-op: event delegation already covers new content.
  });
})(window.mw);
})(window.mw);

Revision as of 20:14, 18 February 2026

/* Any JavaScript here will be loaded for all users on every page load. */

/*
  SpiritVale Skill Slider

  What this does:
  - Finds every skill card:        <div class="sv-skill-card" data-max-level="10" data-level="10">
  - Inserts a slider into:         <div class="sv-level-slider"></div>
  - Updates any element inside the card that has:
        data-series='["Lv1 text","Lv2 text", ...]'
    so that it shows the correct value for the chosen level.
*/

(function () {

    // Ensure a number stays between a minimum and maximum.
    function clampNumber(value, min, max) {
        var n = parseInt(value, 10);

        // If value is not a valid number, fall back to min.
        if (isNaN(n)) {
            return min;
        }

        if (n < min) return min;
        if (n > max) return max;
        return n;
    }

    // Initialize skill cards found under `root`.
    // `root` is usually the page, but MediaWiki sometimes gives us just a section of HTML.
    function initSkillCards(root) {
        var container = root || document;

        // Only pick cards we haven't initialized yet (data-sv-init is our marker).
        var cards = container.querySelectorAll(
            ".sv-skill-card:not([data-sv-init]), .sv-passive-card:not([data-sv-init])"
        );

        cards.forEach(function (card) {
            // Read max level from HTML attribute (default 1 if missing).
            var maxLevel = clampNumber(card.getAttribute("data-max-level"), 1, 999);

            // If max level is 1, there's no reason to create a slider.
            if (maxLevel <= 1) {
                card.setAttribute("data-sv-init", "1");
                return;
            }

            // Read the starting level; if missing, default to max level.
            var startLevel = card.getAttribute("data-level");
            if (startLevel == null || startLevel === "") {
                startLevel = maxLevel;
            }
            startLevel = clampNumber(startLevel, 1, maxLevel);

            // Find where we should place the slider.
            var sliderHost = card.querySelector(".sv-level-slider");
            if (!sliderHost) {
                // No placeholder = nothing to do.
                card.setAttribute("data-sv-init", "1");
                return;
            }

            // Optional: element that shows the current level number in text.
            var levelNumberSpan = card.querySelector(".sv-level-num");

            // Find all dynamic elements that contain a data-series list.
            var dynamicElements = card.querySelectorAll("[data-series]");

            // Parse the JSON data-series once and store it on the element for reuse.
            dynamicElements.forEach(function (el) {
                var raw = el.getAttribute("data-series");
                try {
                    el._svSeries = JSON.parse(raw);
                } catch (e) {
                    el._svSeries = null;
                }
            });

            // Create the slider element.
            var slider = document.createElement("input");
            slider.type = "range";
            slider.min = "1";
            slider.max = String(maxLevel);
            slider.value = String(startLevel);
            slider.className = "sv-level-range";

            // Clear any existing content in the host, then add the slider.
            sliderHost.textContent = "";
            sliderHost.appendChild(slider);

            // Apply a given level to this card:
            // - update the level number text
            // - update all dynamic elements to show the correct series value
            function applyLevel(level) {
                var lv = clampNumber(level, 1, maxLevel);

                // Store the chosen level on the card (optional, but useful for debugging).
                card.setAttribute("data-level", String(lv));

                // Update the visible "current level" number if present.
                if (levelNumberSpan) {
                    levelNumberSpan.textContent = String(lv);
                }

                // Update each dynamic element:
                // Level 1 => index 0, Level 2 => index 1, etc.
                var index = lv - 1;

                dynamicElements.forEach(function (el) {
                    var series = el._svSeries;

                    // If series is missing or not a proper list, skip it.
                    if (!series || !Array.isArray(series) || series.length === 0) {
                        return;
                    }

                    // If the series is shorter than maxLevel, clamp index to last element.
                    var safeIndex = index;
                    if (safeIndex >= series.length) {
                        safeIndex = series.length - 1;
                    }

                    var value = series[safeIndex];

                    // Put the chosen value into the element.
                    // Use an empty string if it's null/undefined.
                    if (value == null) {
                        el.textContent = "";
                    } else {
                        el.textContent = String(value);
                    }
                });
            }

            // When the slider moves, update the card live.
            slider.addEventListener("input", function () {
                applyLevel(slider.value);
            });

            // Apply the starting level right away.
            applyLevel(startLevel);

            // Mark this card as initialized so we don't do it twice.
            card.setAttribute("data-sv-init", "1");
        });
    }

    // MediaWiki hook:
    // Runs when page content is ready (and also after AJAX updates).
    if (window.mw && mw.hook) {
        mw.hook("wikipage.content").add(function ($content) {
            // $content is usually a jQuery object; $content[0] is the real DOM node.
            initSkillCards($content && $content[0]);
        });
    }

    // Also run on normal page load as a fallback.
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", function () {
            initSkillCards(document);
        });
    } else {
        initSkillCards(document);
    }

})();

(function () {
    let pop = null;
    let activeBtn = null;
    let openMode = null; // "hover" | "click"

    function ensurePop() {
        if (pop) return pop;
        pop = document.createElement("div");
        pop.className = "sv-tip-pop";
        pop.setAttribute("role", "dialog");
        pop.setAttribute("id", "sv-tip-pop");
        pop.style.display = "none";
        document.body.appendChild(pop);
        return pop;
    }

    function findContent(btn) {
        const kind = btn.getAttribute("data-sv-tip");
        if (!kind) return null;

        const scope = btn.closest(".sv-tip-scope")
            || btn.closest(".sv-slot-card")
            || btn.closest(".sv-skill-card")
            || btn.closest(".sv-passive-card")
            || btn.parentElement;
        if (!scope) return null;

        return scope.querySelector(`.sv-tip-content[data-sv-tip-content="${CSS.escape(kind)}"]`);
    }

    function setExpanded(btn, expanded) {
        if (!btn) return;
        btn.setAttribute("aria-expanded", expanded ? "true" : "false");
    }

    function positionPop(btn) {
        if (!pop || !btn) return;

        const r = btn.getBoundingClientRect();
        const margin = 8;

        const pw = pop.offsetWidth;
        const ph = pop.offsetHeight;

        let left = r.left + (r.width / 2) - (pw / 2);
        left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin));

        let top = r.bottom + margin;
        if (top + ph > window.innerHeight - margin) {
            top = r.top - ph - margin;
        }

        pop.style.left = `${Math.round(left)}px`;
        pop.style.top  = `${Math.round(top)}px`;
    }

    function closeTip() {
        if (!pop) return;
        setExpanded(activeBtn, false);
        activeBtn = null;
        openMode = null;
        pop.style.display = "none";
        pop.innerHTML = "";
    }

    function openTip(btn, mode) {
        const content = findContent(btn);
        if (!content) {
            closeTip();
            return;
        }

        ensurePop();

        if (activeBtn === btn && pop.style.display !== "none") {
            openMode = mode || openMode;
            positionPop(btn);
            return;
        }

        document.querySelectorAll(".sv-tip-btn[aria-expanded='true']").forEach(function (b) { setExpanded(b, false); });

        activeBtn = btn;
        openMode = mode || null;
        btn.setAttribute("aria-controls", "sv-tip-pop");
        setExpanded(btn, true);

        pop.innerHTML = content.innerHTML;
        pop.style.display = "block";
        positionPop(btn);
    }

    document.addEventListener("click", function (e) {
        const btn = e.target.closest(".sv-tip-btn");
        if (btn) {
            e.preventDefault();
            if (activeBtn === btn && pop && pop.style.display !== "none") {
                if (openMode === "click") {
                    closeTip();
                } else {
                    openTip(btn, "click");
                }
                return;
            }

            openTip(btn, "click");
            return;
        }

        if (pop && pop.style.display !== "none") {
            if (!e.target.closest(".sv-tip-pop")) closeTip();
        }
    });

    const hoverCapable = window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
    if (hoverCapable) {
        document.addEventListener("mouseover", function (e) {
            const btn = e.target.closest(".sv-tip-btn");
            if (!btn) return;
            if (openMode === "click") return;
            openTip(btn, "hover");
        });

        document.addEventListener("mouseout", function (e) {
            if (openMode !== "hover" || !activeBtn || !pop || pop.style.display === "none") return;

            const from = e.target;
            if (!(activeBtn.contains(from) || pop.contains(from))) return;

            const to = e.relatedTarget;
            if (to && (activeBtn.contains(to) || pop.contains(to))) return;

            const toBtn = to && to.closest ? to.closest(".sv-tip-btn") : null;
            if (toBtn && toBtn !== activeBtn) return;

            closeTip();
        });
    }

    document.addEventListener("keydown", function (e) {
        if (e.key === "Escape") {
            closeTip();
            return;
        }

        const btn = document.activeElement && document.activeElement.closest
            ? document.activeElement.closest(".sv-tip-btn")
            : null;

        if (!btn) return;

        if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            if (activeBtn === btn && pop && pop.style.display !== "none") {
                if (openMode === "click") {
                    closeTip();
                } else {
                    openTip(btn, "click");
                }
                return;
            }

            openTip(btn, "click");
        }
    });

    if (window.mw && mw.hook) {
        mw.hook("wikipage.content").add(function () {
            closeTip();
        });
    }

    window.addEventListener("resize", function () { if (activeBtn) positionPop(activeBtn); });
    window.addEventListener("scroll",  function () { if (activeBtn) positionPop(activeBtn); }, true);
})();

/* ============================================================================
 * SV Definitions v1 — tooltips for .sv-def
 * - Reads: data-sv-def-tip, data-sv-def-link
 * - Only activates when data-sv-def-tip is non-empty
 * ========================================================================== */
(function (mw) {
  "use strict";
  if (!mw || window.SV_DEF_INIT) return;
  window.SV_DEF_INIT = true;

  var tipEl = null;
  var tipInner = null;
  var activeEl = null;
  var pinned = false;
  var lastPoint = null; // {x,y}
  var lastTouchTime = 0;

  function qTipText(el) {
    var s = (el && el.getAttribute("data-sv-def-tip")) || "";
    return (s || "").trim();
  }

  function qLink(el) {
    var s = (el && el.getAttribute("data-sv-def-link")) || "";
    return (s || "").trim();
  }

  function ensureTip() {
    if (tipEl) return;

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

    tipEl.style.position = "fixed";
    tipEl.style.zIndex = "99999";
    tipEl.style.display = "none";
    tipEl.style.maxWidth = "340px";
    tipEl.style.padding = "8px 10px";
    tipEl.style.borderRadius = "8px";
    tipEl.style.background = "rgba(15, 18, 24, 0.96)";
    tipEl.style.color = "#fff";
    tipEl.style.fontSize = "13px";
    tipEl.style.lineHeight = "1.25";
    tipEl.style.boxShadow = "0 8px 24px rgba(0,0,0,0.35)";
    tipEl.style.pointerEvents = "none";

    tipInner = document.createElement("div");
    tipInner.className = "sv-def-tip-inner";
    tipEl.appendChild(tipInner);

    document.body.appendChild(tipEl);
  }

  function suppressTitle(el) {
    if (!el) return;
    var t = el.getAttribute("title");
    if (t && !el.getAttribute("data-sv-def-title")) {
      el.setAttribute("data-sv-def-title", t);
      el.removeAttribute("title");
    }
  }

  function restoreTitle(el) {
    if (!el) return;
    var t = el.getAttribute("data-sv-def-title");
    if (t) {
      el.setAttribute("title", t);
      el.removeAttribute("data-sv-def-title");
    }
  }

  function clamp(n, lo, hi) {
    return Math.max(lo, Math.min(hi, n));
  }

  // Cursor/finger anchored positioning; prefers ABOVE the point.
  function positionTipPoint(clientX, clientY) {
    if (!tipEl) return;

    tipEl.style.left = "0px";
    tipEl.style.top = "0px";
    tipEl.style.display = "block";
    tipEl.style.pointerEvents = pinned ? "auto" : "none";

    var tr = tipEl.getBoundingClientRect();
    var pad = 8;
    var gap = pinned ? 14 : 18; // larger gap on hover so cursor doesn't cover text

    var x = clientX - tr.width / 2;
    x = clamp(x, pad, window.innerWidth - tr.width - pad);

    var y = clientY - tr.height - gap; // ABOVE by default
    if (y < pad) y = clientY + gap;    // flip below if needed
    y = clamp(y, pad, window.innerHeight - tr.height - pad);

    tipEl.style.left = Math.round(x) + "px";
    tipEl.style.top = Math.round(y) + "px";

    lastPoint = { x: clientX, y: clientY };
  }

  function show(el) {
    ensureTip();

    var txt = qTipText(el);
    if (!txt) return false;

    activeEl = el;
    tipInner.textContent = txt;
    tipEl.setAttribute("aria-hidden", "false");
    tipEl.style.display = "block";

    suppressTitle(el);
    return true;
  }

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

    tipEl.style.display = "none";
    tipEl.setAttribute("aria-hidden", "true");

    if (activeEl) restoreTitle(activeEl);

    activeEl = null;
    pinned = false;
    lastPoint = null;
  }

  function isEntering(container, relatedTarget) {
    return relatedTarget && container && container.contains(relatedTarget);
  }

  function closestDef(target) {
    if (!target || !target.closest) return null;
    return target.closest(".sv-def");
  }

  // Desktop hover
  document.addEventListener("mouseover", function (e) {
    if (pinned) return;

    var el = closestDef(e.target);

    if (!el) {
      if (activeEl) hide();
      return;
    }

    if (isEntering(el, e.relatedTarget)) return;
    if (activeEl === el) return;

    // Blank definition disables rollover AND clears any previous tooltip
    if (!qTipText(el)) {
      if (activeEl) hide();
      return;
    }

    if (show(el)) positionTipPoint(e.clientX, e.clientY);
  }, true);

  document.addEventListener("mousemove", function (e) {
    if (pinned) return;

    var el = closestDef(e.target);
    if (!el) {
      if (activeEl) hide();
      return;
    }

    if (activeEl && el !== activeEl) return;

    if (!qTipText(el)) {
      if (activeEl) hide();
      return;
    }

    if (activeEl) positionTipPoint(e.clientX, e.clientY);
  }, true);

  document.addEventListener("mouseout", function (e) {
    if (pinned) return;
    var el = closestDef(e.target);
    if (!el) return;
    if (isEntering(el, e.relatedTarget)) return;
    if (activeEl === el) hide();
  }, true);

  // Touch pin (positions above finger)
  document.addEventListener("pointerdown", function (e) {
    var el = closestDef(e.target);
    if (!el) return;

    if (e.pointerType !== "touch") return;

    // Link = allow navigation
    if (qLink(el)) return;

    // Blank tip = do nothing (and close any pinned tooltip)
    if (!qTipText(el)) {
      if (pinned) hide();
      return;
    }

    lastTouchTime = Date.now();
    pinned = true;

    if (show(el)) positionTipPoint(e.clientX, e.clientY);

    e.preventDefault();
    e.stopPropagation();
  }, true);

  // Click behavior:
  // - link -> navigate
  // - tip -> toggle pinned
  // - blank tip -> close pinned
  document.addEventListener("click", function (e) {
    // Ignore the synthetic click right after a touch pin
    if (Date.now() - lastTouchTime < 500) return;

    var el = closestDef(e.target);

    if (!el) {
      if (pinned) hide();
      return;
    }

    var link = qLink(el);
    if (link) {
      window.location.href = mw.util.getUrl(link);
      return;
    }

    if (!qTipText(el)) {
      if (pinned) hide();
      return;
    }

    e.preventDefault();
    e.stopPropagation();

    if (pinned && activeEl === el) {
      hide();
      return;
    }

    pinned = true;
    if (show(el)) positionTipPoint(e.clientX, e.clientY);
  }, true);

  document.addEventListener("keydown", function (e) {
    if (e.key === "Escape" && (pinned || activeEl)) hide();
  }, true);

  window.addEventListener("resize", function () {
    if (tipEl && tipEl.style.display === "block" && lastPoint) {
      positionTipPoint(lastPoint.x, lastPoint.y);
    }
  }, true);

  window.addEventListener("scroll", function () {
    if (tipEl && tipEl.style.display === "block" && lastPoint) {
      positionTipPoint(lastPoint.x, lastPoint.y);
    }
  }, true);

})(window.mw);