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 04:23, 18 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.
/* 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
 * - Works with spans output by Module:Definitions
 * ========================================================================== */
(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;

  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");

    // Minimal inline styling so it works even without CSS.
    // You can override in CSS later using #sv-def-tip / .sv-def-tip.
    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) {
    // Prevent double-tooltips (browser title tooltip + our custom tooltip).
    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));
  }

  function positionTip(el) {
    if (!tipEl || !el) return;

    var r = el.getBoundingClientRect();

    // Ensure we can measure tip size
    tipEl.style.left = "0px";
    tipEl.style.top = "0px";
    tipEl.style.display = "block";

    var tr = tipEl.getBoundingClientRect();
    var pad = 8;
    var gap = 8;

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

    var y = r.bottom + gap;
    if (y + tr.height > window.innerHeight - pad) {
      y = r.top - tr.height - gap;
    }
    y = clamp(y, pad, window.innerHeight - tr.height - pad);

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

  function show(el) {
    ensureTip();

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

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

    suppressTitle(el);
    positionTip(el);
  }

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

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

    if (activeEl) restoreTitle(activeEl);

    activeEl = null;
    pinned = false;
  }

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

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

  // Hover in/out (desktop)
  document.addEventListener("mouseover", function (e) {
    if (pinned) return;
    var el = closestDef(e.target);
    if (!el) return;
    if (isEntering(el, e.relatedTarget)) return;
    if (activeEl === el) return;
    show(el);
  }, 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);

  // Tap/click behavior:
  // - If data-sv-def-link is set, navigate (future-ready).
  // - Otherwise toggle a pinned tooltip (mobile-friendly).
  document.addEventListener("click", function (e) {
    var el = closestDef(e.target);

    // Click outside closes a pinned tooltip
    if (!el) {
      if (pinned) hide();
      return;
    }

    var link = qLink(el);
    if (link) {
      // Let it act like a link to a page title
      window.location.href = mw.util.getUrl(link);
      return;
    }

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

    // Toggle pin
    e.preventDefault();
    e.stopPropagation();

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

    pinned = true;
    show(el);
  }, true);

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

  // Reposition if visible
  window.addEventListener("resize", function () {
    if (activeEl && tipEl && tipEl.style.display === "block") {
      positionTip(activeEl);
    }
  }, true);

  // Capture scroll events from containers too
  window.addEventListener("scroll", function () {
    if (activeEl && tipEl && tipEl.style.display === "block") {
      positionTip(activeEl);
    }
  }, true);

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