MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
No edit summary |
||
| Line 348: | Line 348: | ||
* - 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 | * - Works with spans output by Module:Definitions | ||
* | * ========================================================================= */ | ||
(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 touch positioning | |||
function qTipText(el) { | function qTipText(el) { | ||
| Line 391: | Line 392: | ||
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"; | tipEl.style.pointerEvents = "none"; // toggled in position/show | ||
tipInner = document.createElement("div"); | tipInner = document.createElement("div"); | ||
| Line 432: | Line 433: | ||
tipEl.style.top = "0px"; | tipEl.style.top = "0px"; | ||
tipEl.style.display = "block"; | tipEl.style.display = "block"; | ||
tipEl.style.pointerEvents = pinned ? "auto" : "none"; | |||
var tr = tipEl.getBoundingClientRect(); | var tr = tipEl.getBoundingClientRect(); | ||
| Line 448: | Line 450: | ||
tipEl.style.left = Math.round(x) + "px"; | tipEl.style.left = Math.round(x) + "px"; | ||
tipEl.style.top = Math.round(y) + "px"; | tipEl.style.top = Math.round(y) + "px"; | ||
} | |||
// Position relative to a screen point (used for touch so the finger doesn't cover it) | |||
function positionTipPoint(clientX, clientY) { | |||
if (!tipEl) return; | |||
// Ensure we can measure tip size | |||
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 = 12; | |||
var x = clientX - (tr.width / 2); | |||
x = clamp(x, pad, window.innerWidth - tr.width - pad); | |||
// Prefer ABOVE the finger/cursor | |||
var y = clientY - tr.height - gap; | |||
// If not enough room above, drop below | |||
if (y < pad) { | |||
y = clientY + gap; | |||
} | |||
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 }; | |||
} | } | ||
| Line 462: | Line 495: | ||
suppressTitle(el); | suppressTitle(el); | ||
// Default show behavior anchors to the element | |||
positionTip(el); | positionTip(el); | ||
} | } | ||
| Line 475: | Line 510: | ||
activeEl = null; | activeEl = null; | ||
pinned = false; | pinned = false; | ||
lastPoint = null; | |||
} | } | ||
| Line 487: | Line 523: | ||
// Hover in/out (desktop) | // Hover in/out (desktop) | ||
document.addEventListener("mouseover", function (e) { | document.addEventListener( | ||
"mouseover", | |||
function (e) { | |||
if (pinned) return; | |||
var el = closestDef(e.target); | |||
if (!el) return; | |||
if (isEntering(el, e.relatedTarget)) return; | |||
}, true); | 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 | |||
); | |||
// Touch: pin tooltip and position ABOVE the finger press point. | |||
// We do this on pointerdown to avoid the finger hiding the tooltip. | |||
document.addEventListener( | |||
"pointerdown", | |||
function (e) { | |||
var el = closestDef(e.target); | |||
if (!el) return; | |||
if (e.pointerType !== "touch") return; | |||
// If we have a future link, let normal navigation happen. | |||
if (qLink(el)) return; | |||
var txt = qTipText(el); | |||
if (!txt) return; | |||
pinned = true; | |||
show(el); | |||
positionTipPoint(e.clientX, e.clientY); | |||
// Prevent the follow-up click from immediately toggling/closing. | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
}, | |||
true | |||
); | |||
// Tap/click behavior: | // Tap/click behavior: | ||
// - If data-sv-def-link is set, navigate (future-ready). | // - If data-sv-def-link is set, navigate (future-ready). | ||
// - Otherwise toggle a pinned tooltip (mobile-friendly). | // - Otherwise toggle a pinned tooltip (mobile-friendly). | ||
document.addEventListener("click", function (e) { | document.addEventListener( | ||
"click", | |||
function (e) { | |||
var el = closestDef(e.target); | |||
// Click outside closes a pinned tooltip | |||
if (!el) { | |||
if (pinned) hide(); | |||
return; | return; | ||
} | |||
var link = qLink(el); | |||
if (link) { | |||
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); | |||
// For non-touch click, keep element-anchored positioning. | |||
// (Touch uses pointerdown positioning already.) | |||
lastPoint = null; | |||
}, | |||
true | |||
); | |||
// Close on Escape | // Close on Escape | ||
document.addEventListener("keydown", function (e) { | document.addEventListener( | ||
"keydown", | |||
function (e) { | |||
if (e.key === "Escape" && (pinned || activeEl)) { | |||
hide(); | |||
} | |||
}, | |||
true | |||
); | |||
// Reposition if visible | // Reposition if visible | ||
window.addEventListener("resize", function () { | window.addEventListener( | ||
"resize", | |||
function () { | |||
if (tipEl && tipEl.style.display === "block") { | |||
if (pinned && lastPoint) positionTipPoint(lastPoint.x, lastPoint.y); | |||
else if (activeEl) positionTip(activeEl); | |||
} | |||
}, | |||
true | |||
); | |||
// Capture scroll events from containers too | // Capture scroll events from containers too | ||
window.addEventListener("scroll", function () { | window.addEventListener( | ||
"scroll", | |||
function () { | |||
if (tipEl && tipEl.style.display === "block") { | |||
if (pinned && lastPoint) positionTipPoint(lastPoint.x, lastPoint.y); | |||
else if (activeEl) positionTip(activeEl); | |||
} | |||
}, | |||
true | |||
); | |||
// Support dynamic page content (VisualEditor, etc.) | // Support dynamic page content (VisualEditor, etc.) | ||
Revision as of 04:39, 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
* - 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;
var lastPoint = null; // {x,y} for touch positioning
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"; // toggled in position/show
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";
tipEl.style.pointerEvents = pinned ? "auto" : "none";
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";
}
// Position relative to a screen point (used for touch so the finger doesn't cover it)
function positionTipPoint(clientX, clientY) {
if (!tipEl) return;
// Ensure we can measure tip size
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 = 12;
var x = clientX - (tr.width / 2);
x = clamp(x, pad, window.innerWidth - tr.width - pad);
// Prefer ABOVE the finger/cursor
var y = clientY - tr.height - gap;
// If not enough room above, drop below
if (y < pad) {
y = clientY + gap;
}
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;
activeEl = el;
tipInner.textContent = txt;
tipEl.setAttribute("aria-hidden", "false");
tipEl.style.display = "block";
suppressTitle(el);
// Default show behavior anchors to the element
positionTip(el);
}
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");
}
// 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
);
// Touch: pin tooltip and position ABOVE the finger press point.
// We do this on pointerdown to avoid the finger hiding the tooltip.
document.addEventListener(
"pointerdown",
function (e) {
var el = closestDef(e.target);
if (!el) return;
if (e.pointerType !== "touch") return;
// If we have a future link, let normal navigation happen.
if (qLink(el)) return;
var txt = qTipText(el);
if (!txt) return;
pinned = true;
show(el);
positionTipPoint(e.clientX, e.clientY);
// Prevent the follow-up click from immediately toggling/closing.
e.preventDefault();
e.stopPropagation();
},
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) {
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);
// For non-touch click, keep element-anchored positioning.
// (Touch uses pointerdown positioning already.)
lastPoint = null;
},
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 (tipEl && tipEl.style.display === "block") {
if (pinned && lastPoint) positionTipPoint(lastPoint.x, lastPoint.y);
else if (activeEl) positionTip(activeEl);
}
},
true
);
// Capture scroll events from containers too
window.addEventListener(
"scroll",
function () {
if (tipEl && tipEl.style.display === "block") {
if (pinned && lastPoint) positionTipPoint(lastPoint.x, lastPoint.y);
else if (activeEl) 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);