MediaWiki:Common.js
MediaWiki interface page
More actions
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);
}
})();