MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary |
No edit summary Tags: Mobile edit Mobile web edit |
||
| Line 1,571: | Line 1,571: | ||
} else { | } else { | ||
initAllTabs(document); | initAllTabs(document); | ||
} | |||
})(); | |||
(function () { | |||
/* ================================================================== */ | |||
/* MODULE: Universal Popups (v2) */ | |||
/* - Popup B aesthetic everywhere */ | |||
/* - Small = hover/focus (non-click open on hover-capable devices) */ | |||
/* - Large = click/tap open, header click closes, outside closes */ | |||
/* - Supersedes legacy injected black tips by intercepting on window */ | |||
/* ================================================================== */ | |||
var SV = (window.SV = window.SV || {}); | |||
var COMMON = (SV.common = SV.common || {}); | |||
var POPV2_VERSION = 1; | |||
if (typeof COMMON.popupsV2Init === "number" && COMMON.popupsV2Init >= POPV2_VERSION) return; | |||
COMMON.popupsV2Init = POPV2_VERSION; | |||
try { | |||
document.documentElement.classList.add("sv-uipop-v2"); | |||
} catch (e) {} | |||
var DEF_SEL = ".sv-def"; | |||
var DEF_TIP_ATTR = "data-sv-def-tip"; | |||
var DEF_LINK_ATTR = "data-sv-def-link"; | |||
var TIP_BTN_SEL = ".sv-tip-btn[data-sv-toggle='1'], details.sv-tip > summary"; | |||
var DISCLOSE_BTN_SEL = ".sv-disclose-btn[data-sv-toggle='1'], details.sv-disclose > summary"; | |||
var POPUP_DETAILS_SEL = "details.sv-tip, details.sv-disclose"; | |||
var POPUP_WRAP_SEL = ".sv-tip, .sv-disclose"; | |||
var POPUP_POP_SEL = ".sv-tip-pop, .sv-disclose-pop"; | |||
var HOVER_CAPABLE = false; | |||
try { | |||
HOVER_CAPABLE = | |||
!!window.matchMedia && | |||
window.matchMedia("(hover:hover) and (pointer:fine)").matches; | |||
} catch (e) { | |||
HOVER_CAPABLE = false; | |||
} | |||
var popEl = null; | |||
var state = { | |||
open: false, | |||
pinned: false, | |||
mode: "hover", // hover | click | |||
size: "sm", // sm | lg | |||
trigger: null, | |||
lastPointerType: "mouse", | |||
hideTimer: 0 | |||
}; | |||
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 clamp(n, min, max) { | |||
if (n < min) return min; | |||
if (n > max) return max; | |||
return n; | |||
} | |||
function safeIdSelector(id) { | |||
return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1"); | |||
} | |||
function ensurePopEl() { | |||
if (popEl) return popEl; | |||
var el = document.createElement("div"); | |||
el.className = "sv-uipop sv-uipop--sm"; | |||
el.setAttribute("role", "dialog"); | |||
el.setAttribute("aria-hidden", "true"); | |||
document.body.appendChild(el); | |||
popEl = el; | |||
return popEl; | |||
} | |||
function setHidden(el, hidden) { | |||
if (!el) return; | |||
el.setAttribute("aria-hidden", hidden ? "true" : "false"); | |||
} | |||
function stripIds(root) { | |||
if (!root || root.nodeType !== 1) return; | |||
if (root.hasAttribute && root.hasAttribute("id")) root.removeAttribute("id"); | |||
var nodes = root.querySelectorAll ? root.querySelectorAll("[id]") : []; | |||
for (var i = 0; i < nodes.length; i++) nodes[i].removeAttribute("id"); | |||
} | |||
function unhideTree(root) { | |||
if (!root || root.nodeType !== 1) return; | |||
if (root.hasAttribute && root.hasAttribute("hidden")) root.removeAttribute("hidden"); | |||
if (root.classList) root.classList.remove("sv-hidden"); | |||
if (root.style && root.style.display === "none") root.style.display = ""; | |||
var kids = root.children || []; | |||
for (var i = 0; i < kids.length; i++) unhideTree(kids[i]); | |||
} | |||
function isBlankContent(node) { | |||
if (!node) return true; | |||
var t = node.textContent; | |||
if (t == null) return true; | |||
var compact = String(t) | |||
.replace(/\s+/g, "") | |||
.replace(/[—–-]+/g, "") | |||
.trim(); | |||
return !compact; | |||
} | |||
function getDefTipText(defEl) { | |||
if (!defEl || !defEl.getAttribute) return ""; | |||
var t = defEl.getAttribute(DEF_TIP_ATTR); | |||
if (t == null) return ""; | |||
t = String(t); | |||
if (!t.replace(/\s+/g, "")) return ""; | |||
return t; | |||
} | |||
function getDefLinkTitle(defEl) { | |||
if (!defEl || !defEl.getAttribute) return ""; | |||
var t = defEl.getAttribute(DEF_LINK_ATTR); | |||
if (t == null) return ""; | |||
return String(t).trim(); | |||
} | |||
function getContentNodeFromTrigger(trigger) { | |||
if (!trigger) return null; | |||
if (trigger.tagName === "SUMMARY") { | |||
var det = closest(trigger, POPUP_DETAILS_SEL); | |||
if (!det) return null; | |||
var pop = det.querySelector(POPUP_POP_SEL); | |||
if (!pop) return null; | |||
// Prefer body node if present | |||
var body = | |||
pop.querySelector(".sv-tip-pop-body") || | |||
pop.querySelector(".sv-disclose-pop-body") || | |||
pop.querySelector(".sv-disclose-list"); | |||
return body || pop; | |||
} | |||
var id = trigger.getAttribute ? trigger.getAttribute("aria-controls") : ""; | |||
if (id) { | |||
var wrap = closest(trigger, POPUP_WRAP_SEL); | |||
if (wrap) { | |||
try { | |||
var local = wrap.querySelector(safeIdSelector(id)); | |||
if (local) { | |||
var b0 = | |||
local.querySelector(".sv-tip-pop-body") || | |||
local.querySelector(".sv-disclose-pop-body") || | |||
local.querySelector(".sv-disclose-list"); | |||
return b0 || local; | |||
} | |||
} catch (e) {} | |||
} | |||
var global = document.getElementById(id); | |||
if (global) { | |||
var b1 = | |||
global.querySelector(".sv-tip-pop-body") || | |||
global.querySelector(".sv-disclose-pop-body") || | |||
global.querySelector(".sv-disclose-list"); | |||
return b1 || global; | |||
} | |||
} | |||
var w = closest(trigger, POPUP_WRAP_SEL); | |||
if (w) { | |||
var p = w.querySelector(POPUP_POP_SEL); | |||
if (p) { | |||
var b2 = | |||
p.querySelector(".sv-tip-pop-body") || | |||
p.querySelector(".sv-disclose-pop-body") || | |||
p.querySelector(".sv-disclose-list"); | |||
return b2 || p; | |||
} | |||
} | |||
return null; | |||
} | |||
function guessTitle(trigger, sourceNode) { | |||
// Prefer existing pop title nodes when the source is a pop container | |||
var pop = null; | |||
if (sourceNode && sourceNode.nodeType === 1) { | |||
pop = closest(sourceNode, POPUP_POP_SEL); | |||
} | |||
if (pop) { | |||
var t = | |||
pop.querySelector(".sv-tip-pop-title") || | |||
pop.querySelector(".sv-disclose-pop-title"); | |||
if (t && t.textContent) return String(t.textContent).trim(); | |||
} | |||
var aria = trigger && trigger.getAttribute ? trigger.getAttribute("aria-label") : ""; | |||
if (aria) return String(aria).trim(); | |||
var txt = trigger && trigger.textContent ? String(trigger.textContent).trim() : ""; | |||
if (txt) return txt.replace(/\s+/g, " ").trim(); | |||
return "Info"; | |||
} | |||
function buildActionsForDefinition(linkTitle) { | |||
if (!linkTitle) return null; | |||
var wrap = document.createElement("div"); | |||
wrap.className = "sv-uipop-actions"; | |||
var a = document.createElement("a"); | |||
a.className = "sv-uipop-action"; | |||
a.textContent = "Open page"; | |||
if (window.mw && mw.util && typeof mw.util.getUrl === "function") { | |||
a.href = mw.util.getUrl(linkTitle); | |||
} else { | |||
a.href = String(linkTitle); | |||
} | |||
wrap.appendChild(a); | |||
return wrap; | |||
} | |||
function renderPopup(opts) { | |||
var el = ensurePopEl(); | |||
if (!el) return; | |||
el.classList.remove("sv-uipop--sm", "sv-uipop--lg"); | |||
el.classList.add(opts.size === "lg" ? "sv-uipop--lg" : "sv-uipop--sm"); | |||
// Clear | |||
while (el.firstChild) el.removeChild(el.firstChild); | |||
// Head | |||
var head = document.createElement("div"); | |||
head.className = "sv-uipop-head" + (opts.mode === "click" ? " sv-uipop-head--clickable" : ""); | |||
var title = document.createElement("div"); | |||
title.className = "sv-uipop-title"; | |||
title.textContent = opts.title || "Info"; | |||
var hint = document.createElement("div"); | |||
hint.className = "sv-uipop-hint"; | |||
hint.textContent = opts.hint || ""; | |||
head.appendChild(title); | |||
head.appendChild(hint); | |||
el.appendChild(head); | |||
// Body | |||
var body = document.createElement("div"); | |||
body.className = "sv-uipop-body"; | |||
if (opts.bodyPre) body.classList.add("sv-uipop-body--pre"); | |||
if (opts.bodyNode) { | |||
var clone = opts.bodyNode.cloneNode(true); | |||
stripIds(clone); | |||
unhideTree(clone); | |||
// If the clone is a list, keep it; CSS normalizes list styles inside popup | |||
body.appendChild(clone); | |||
} else if (opts.bodyText != null) { | |||
body.textContent = String(opts.bodyText); | |||
} | |||
if (opts.actionsNode) { | |||
body.appendChild(opts.actionsNode); | |||
} | |||
el.appendChild(body); | |||
// Header close (large/click) | |||
if (opts.mode === "click") { | |||
head.addEventListener( | |||
"click", | |||
function (e) { | |||
e.preventDefault(); | |||
hidePopup(true); | |||
}, | |||
{ once: true } | |||
); | |||
} | |||
} | |||
function positionPopupNearTrigger(trigger) { | |||
var el = ensurePopEl(); | |||
if (!el || !trigger || !trigger.getBoundingClientRect) return; | |||
// Reset so we can measure | |||
el.style.left = "0px"; | |||
el.style.top = "0px"; | |||
// Temporarily show for measurement | |||
setHidden(el, false); | |||
requestAnimationFrame(function () { | |||
if (!state.open) return; | |||
var vw = window.innerWidth || document.documentElement.clientWidth || 0; | |||
var vh = window.innerHeight || document.documentElement.clientHeight || 0; | |||
var margin = 10; | |||
var gap = 8; | |||
var tr = trigger.getBoundingClientRect(); | |||
var pr = el.getBoundingClientRect(); | |||
var w = pr.width || 320; | |||
var h = pr.height || 180; | |||
// Prefer below, left-aligned; icon triggers align right | |||
var isIconish = (tr.width || 0) <= 40; | |||
var left = isIconish ? (tr.right - w) : tr.left; | |||
var top = tr.bottom + gap; | |||
// Clamp horizontally | |||
left = clamp(left, margin, Math.max(margin, vw - w - margin)); | |||
// If bottom overflow, flip above | |||
if (top + h + margin > vh) { | |||
top = tr.top - h - gap; | |||
} | |||
top = clamp(top, margin, Math.max(margin, vh - h - margin)); | |||
el.style.left = Math.round(left) + "px"; | |||
el.style.top = Math.round(top) + "px"; | |||
}); | |||
} | |||
function setExpanded(trigger, expanded) { | |||
if (!trigger || !trigger.setAttribute) return; | |||
trigger.setAttribute("aria-expanded", expanded ? "true" : "false"); | |||
} | |||
function hidePopup(fromUser) { | |||
if (!state.open) return; | |||
clearTimeout(state.hideTimer); | |||
state.hideTimer = 0; | |||
setExpanded(state.trigger, false); | |||
state.open = false; | |||
state.pinned = false; | |||
state.mode = "hover"; | |||
state.size = "sm"; | |||
state.trigger = null; | |||
var el = ensurePopEl(); | |||
setHidden(el, true); | |||
} | |||
function openPopup(trigger, opts) { | |||
if (!trigger) return; | |||
if (state.open && state.trigger && state.trigger !== trigger) { | |||
hidePopup(false); | |||
} | |||
state.open = true; | |||
state.pinned = !!opts.pinned; | |||
state.mode = opts.mode; | |||
state.size = opts.size; | |||
state.trigger = trigger; | |||
setExpanded(trigger, true); | |||
renderPopup(opts); | |||
setHidden(popEl, false); | |||
positionPopupNearTrigger(trigger); | |||
} | |||
function findTrigger(t) { | |||
if (!t) return null; | |||
var defEl = closest(t, DEF_SEL); | |||
if (defEl) return defEl; | |||
var tip = closest(t, TIP_BTN_SEL); | |||
if (tip) return tip; | |||
var dis = closest(t, DISCLOSE_BTN_SEL); | |||
if (dis) return dis; | |||
return null; | |||
} | |||
function resolveOptsForTrigger(trigger) { | |||
// Defaults: | |||
// - .sv-tip-btn => small hover | |||
// - .sv-disclose-btn => large click | |||
// - .sv-def => hover if no link, click if link | |||
var isDef = trigger && trigger.matches && trigger.matches(DEF_SEL); | |||
var isTip = trigger && trigger.matches && trigger.matches(TIP_BTN_SEL); | |||
var isDisclose = trigger && trigger.matches && trigger.matches(DISCLOSE_BTN_SEL); | |||
var mode = "hover"; | |||
var size = "sm"; | |||
// explicit overrides | |||
var attrMode = trigger.getAttribute && trigger.getAttribute("data-sv-pop"); | |||
if (attrMode === "hover" || attrMode === "click") mode = attrMode; | |||
var attrSize = trigger.getAttribute && trigger.getAttribute("data-sv-pop-size"); | |||
if (attrSize === "sm" || attrSize === "lg") size = attrSize; | |||
if (!attrMode) { | |||
if (isDisclose) mode = "click"; | |||
else if (isTip) mode = "hover"; | |||
else if (isDef) { | |||
var link = getDefLinkTitle(trigger); | |||
mode = link ? "click" : "hover"; | |||
} | |||
} | |||
if (!attrSize) { | |||
size = mode === "click" ? "lg" : "sm"; | |||
} | |||
var sourceNode = null; | |||
var title = "Info"; | |||
var hint = ""; | |||
if (isDef) { | |||
var tip = getDefTipText(trigger); | |||
var linkTitle = getDefLinkTitle(trigger); | |||
if (!tip) return null; | |||
title = String(trigger.textContent || "").trim() || "Definition"; | |||
hint = | |||
mode === "click" | |||
? "Click to close" | |||
: (HOVER_CAPABLE ? "Hover to view" : "Tap to view"); | |||
return { | |||
mode: mode, | |||
size: size, | |||
pinned: mode === "click" ? true : (!HOVER_CAPABLE && state.lastPointerType !== "mouse"), | |||
title: title, | |||
hint: hint, | |||
bodyText: tip, | |||
bodyPre: true, | |||
actionsNode: mode === "click" ? buildActionsForDefinition(linkTitle) : null | |||
}; | |||
} | |||
sourceNode = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(sourceNode)) return null; | |||
title = guessTitle(trigger, sourceNode); | |||
hint = | |||
mode === "click" | |||
? "Click to close" | |||
: (HOVER_CAPABLE ? "Hover to view" : "Tap to view"); | |||
// For hover-small on hover-capable devices: never pinned | |||
var pinned = mode === "click" ? true : (!HOVER_CAPABLE && state.lastPointerType !== "mouse"); | |||
return { | |||
mode: mode, | |||
size: size, | |||
pinned: pinned, | |||
title: title, | |||
hint: hint, | |||
bodyNode: sourceNode, | |||
bodyPre: false, | |||
actionsNode: null | |||
}; | |||
} | |||
function isInsidePopup(target) { | |||
return !!(popEl && (target === popEl || (popEl.contains && popEl.contains(target)))); | |||
} | |||
/* ------------------------------------------------------------------ */ | |||
/* Event Intercepts (window capture) — prevents legacy handlers running */ | |||
/* ------------------------------------------------------------------ */ | |||
window.addEventListener( | |||
"pointerdown", | |||
function (e) { | |||
if (e && e.pointerType) state.lastPointerType = e.pointerType; | |||
var trigger = findTrigger(e.target); | |||
if (!trigger) return; | |||
var opts = resolveOptsForTrigger(trigger); | |||
if (!opts) return; | |||
// For hover-mode on hover-capable devices, do not open on click | |||
if (opts.mode === "hover" && HOVER_CAPABLE && (e.pointerType === "mouse" || !e.pointerType)) { | |||
// Let hover handlers manage it; suppress click-open | |||
e.stopPropagation(); | |||
return; | |||
} | |||
e.preventDefault(); | |||
e.stopPropagation(); | |||
// Toggle if already open + pinned + same trigger | |||
if (state.open && state.pinned && state.trigger === trigger) { | |||
hidePopup(true); | |||
return; | |||
} | |||
openPopup(trigger, opts); | |||
}, | |||
true | |||
); | |||
// Hover open/close (small) | |||
window.addEventListener( | |||
"mouseover", | |||
function (e) { | |||
if (!HOVER_CAPABLE) return; | |||
var trigger = findTrigger(e.target); | |||
if (!trigger) { | |||
if (state.open && !state.pinned) hidePopup(false); | |||
return; | |||
} | |||
var opts = resolveOptsForTrigger(trigger); | |||
if (!opts || opts.mode !== "hover") return; | |||
e.stopPropagation(); | |||
clearTimeout(state.hideTimer); | |||
state.hideTimer = 0; | |||
openPopup(trigger, opts); | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"mouseout", | |||
function (e) { | |||
if (!HOVER_CAPABLE) return; | |||
if (!state.open || state.pinned) return; | |||
var trigger = findTrigger(e.target); | |||
if (!trigger || trigger !== state.trigger) return; | |||
e.stopPropagation(); | |||
clearTimeout(state.hideTimer); | |||
state.hideTimer = setTimeout(function () { | |||
if (state.open && !state.pinned) hidePopup(false); | |||
}, 60); | |||
}, | |||
true | |||
); | |||
// Click outside closes pinned | |||
window.addEventListener( | |||
"click", | |||
function (e) { | |||
if (!state.open) return; | |||
var trigger = findTrigger(e.target); | |||
// Clicking the trigger is handled by pointerdown toggle | |||
if (trigger) { | |||
e.stopPropagation(); | |||
return; | |||
} | |||
// Let links inside popup work | |||
if (isInsidePopup(e.target)) return; | |||
if (state.pinned) { | |||
hidePopup(true); | |||
e.stopPropagation(); | |||
} | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"keydown", | |||
function (e) { | |||
if (e.key !== "Escape") return; | |||
if (state.open) { | |||
hidePopup(true); | |||
e.stopPropagation(); | |||
} | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"scroll", | |||
function () { | |||
if (!state.open || !state.trigger) return; | |||
positionPopupNearTrigger(state.trigger); | |||
}, | |||
true | |||
); | |||
window.addEventListener( | |||
"resize", | |||
function () { | |||
if (!state.open || !state.trigger) return; | |||
positionPopupNearTrigger(state.trigger); | |||
}, | |||
true | |||
); | |||
// Optional: remove native browser tooltips on defs if any remain | |||
function stripDefTitles(root) { | |||
var container = root || document; | |||
var defs = container.querySelectorAll(DEF_SEL + "[title]"); | |||
for (var i = 0; i < defs.length; i++) defs[i].removeAttribute("title"); | |||
} | |||
if (window.mw && mw.hook) { | |||
mw.hook("wikipage.content").add(function ($content) { | |||
stripDefTitles($content && $content[0]); | |||
}); | |||
} | |||
if (document.readyState === "loading") { | |||
document.addEventListener("DOMContentLoaded", function () { | |||
stripDefTitles(document); | |||
}); | |||
} else { | |||
stripDefTitles(document); | |||
} | } | ||
})(); | })(); | ||