MediaWiki:Common.js: Difference between revisions
MediaWiki interface page
More actions
No edit summary Tags: Reverted Mobile edit Mobile web edit |
No edit summary Tags: Manual revert Mobile edit Mobile web edit |
||
| Line 1,576: | Line 1,576: | ||
(function () { | (function () { | ||
/* ================================================================== */ | /* ================================================================== */ | ||
/* MODULE: Universal Popups (v2 | /* 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 */ | |||
/* ================================================================== */ | /* ================================================================== */ | ||
| Line 1,584: | Line 1,586: | ||
var COMMON = (SV.common = SV.common || {}); | var COMMON = (SV.common = SV.common || {}); | ||
var POPV2_VERSION = | var POPV2_VERSION = 1; | ||
if (typeof COMMON.popupsV2Init === "number" && COMMON.popupsV2Init >= POPV2_VERSION) return; | if (typeof COMMON.popupsV2Init === "number" && COMMON.popupsV2Init >= POPV2_VERSION) return; | ||
COMMON.popupsV2Init = POPV2_VERSION; | COMMON.popupsV2Init = POPV2_VERSION; | ||
try { document.documentElement.classList.add("sv-uipop-v2"); } catch (e) {} | try { | ||
document.documentElement.classList.add("sv-uipop-v2"); | |||
} catch (e) {} | |||
var DEF_SEL = ".sv-def"; | var DEF_SEL = ".sv-def"; | ||
| Line 1,603: | Line 1,607: | ||
var HOVER_CAPABLE = false; | var HOVER_CAPABLE = false; | ||
try { | try { | ||
HOVER_CAPABLE = !!window.matchMedia && window.matchMedia("(hover:hover) and (pointer:fine)").matches; | HOVER_CAPABLE = | ||
!!window.matchMedia && | |||
window.matchMedia("(hover:hover) and (pointer:fine)").matches; | |||
} catch (e) { | } catch (e) { | ||
HOVER_CAPABLE = false; | HOVER_CAPABLE = false; | ||
| Line 1,638: | Line 1,644: | ||
function safeIdSelector(id) { | function safeIdSelector(id) { | ||
return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1"); | return "#" + String(id).replace(/([ !"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~])/g, "\\$1"); | ||
} | } | ||
| Line 1,652: | Line 1,653: | ||
el.setAttribute("role", "dialog"); | el.setAttribute("role", "dialog"); | ||
el.setAttribute("aria-hidden", "true"); | el.setAttribute("aria-hidden", "true"); | ||
document.body.appendChild(el); | document.body.appendChild(el); | ||
popEl = el; | popEl = el; | ||
return popEl; | return popEl; | ||
} | |||
function setHidden(el, hidden) { | |||
if (!el) return; | |||
el.setAttribute("aria-hidden", hidden ? "true" : "false"); | |||
} | } | ||
| Line 1,698: | Line 1,687: | ||
if (t == null) return true; | if (t == null) return true; | ||
var compact = String(t).replace(/\s+/g, "").replace(/[—–-]+/g, "").trim(); | var compact = String(t) | ||
.replace(/\s+/g, "") | |||
.replace(/[—–-]+/g, "") | |||
.trim(); | |||
return !compact; | return !compact; | ||
} | } | ||
| Line 1,716: | Line 1,709: | ||
if (t == null) return ""; | if (t == null) return ""; | ||
return String(t).trim(); | return String(t).trim(); | ||
} | } | ||
| Line 1,732: | Line 1,721: | ||
if (!pop) return null; | if (!pop) return null; | ||
// Prefer body node if present | |||
var body = | var body = | ||
pop.querySelector(".sv-tip-pop-body") || | pop.querySelector(".sv-tip-pop-body") || | ||
| Line 1,782: | Line 1,772: | ||
function guessTitle(trigger, sourceNode) { | function guessTitle(trigger, sourceNode) { | ||
// Prefer existing pop title nodes when the source is a pop container | |||
var pop = null; | var pop = null; | ||
if (sourceNode && sourceNode.nodeType === 1) pop = closest(sourceNode, POPUP_POP_SEL); | if (sourceNode && sourceNode.nodeType === 1) { | ||
pop = closest(sourceNode, POPUP_POP_SEL); | |||
} | |||
if (pop) { | if (pop) { | ||
var t = pop.querySelector(".sv-tip-pop-title") || pop.querySelector(".sv-disclose-pop-title"); | var t = | ||
pop.querySelector(".sv-tip-pop-title") || | |||
pop.querySelector(".sv-disclose-pop-title"); | |||
if (t && t.textContent) return String(t.textContent).trim(); | if (t && t.textContent) return String(t.textContent).trim(); | ||
} | } | ||
| Line 1,809: | Line 1,803: | ||
a.textContent = "Open page"; | a.textContent = "Open page"; | ||
if (window.mw && mw.util && typeof mw.util.getUrl === "function") a.href = mw.util.getUrl(linkTitle); | if (window.mw && mw.util && typeof mw.util.getUrl === "function") { | ||
else a.href = String(linkTitle); | a.href = mw.util.getUrl(linkTitle); | ||
} else { | |||
a.href = String(linkTitle); | |||
} | |||
wrap.appendChild(a); | wrap.appendChild(a); | ||
return wrap; | return wrap; | ||
| Line 1,824: | Line 1,820: | ||
el.classList.add(opts.size === "lg" ? "sv-uipop--lg" : "sv-uipop--sm"); | el.classList.add(opts.size === "lg" ? "sv-uipop--lg" : "sv-uipop--sm"); | ||
// Clear | |||
while (el.firstChild) el.removeChild(el.firstChild); | while (el.firstChild) el.removeChild(el.firstChild); | ||
// | // Head | ||
var head = document.createElement("div"); | var head = document.createElement("div"); | ||
head.className = "sv-uipop-head" | head.className = "sv-uipop-head" + (opts.mode === "click" ? " sv-uipop-head--clickable" : ""); | ||
var title = document.createElement("div"); | var title = document.createElement("div"); | ||
title.className = "sv-uipop-title"; | title.className = "sv-uipop-title"; | ||
title.textContent = opts.title || "Info"; | 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); | el.appendChild(head); | ||
| Line 1,851: | Line 1,842: | ||
var body = document.createElement("div"); | var body = document.createElement("div"); | ||
body.className = "sv-uipop-body"; | body.className = "sv-uipop-body"; | ||
if (opts.bodyPre) body.classList.add("sv-uipop-body--pre"); | if (opts.bodyPre) body.classList.add("sv-uipop-body--pre"); | ||
| Line 1,857: | Line 1,849: | ||
stripIds(clone); | stripIds(clone); | ||
unhideTree(clone); | unhideTree(clone); | ||
// If the clone is a list, keep it; CSS normalizes list styles inside popup | |||
body.appendChild(clone); | body.appendChild(clone); | ||
} else if (opts.bodyText != null) { | } else if (opts.bodyText != null) { | ||
| Line 1,862: | Line 1,856: | ||
} | } | ||
if (opts.actionsNode) body.appendChild(opts.actionsNode); | if (opts.actionsNode) { | ||
body.appendChild(opts.actionsNode); | |||
} | |||
el.appendChild(body); | el.appendChild(body); | ||
// Header click | // Header close (large/click) | ||
if (opts.mode === "click") { | if (opts.mode === "click") { | ||
head.addEventListener("click", function (e) { | head.addEventListener( | ||
"click", | |||
function (e) { | |||
e.preventDefault(); | |||
hidePopup(true); | |||
}, | |||
{ once: true } | |||
); | |||
} | } | ||
} | } | ||
| Line 1,878: | Line 1,879: | ||
if (!el || !trigger || !trigger.getBoundingClientRect) return; | if (!el || !trigger || !trigger.getBoundingClientRect) return; | ||
// Reset so we can measure | |||
el.style.left = "0px"; | el.style.left = "0px"; | ||
el.style.top = "0px"; | el.style.top = "0px"; | ||
// Temporarily show for measurement | |||
setHidden(el, false); | setHidden(el, false); | ||
| Line 1,896: | Line 1,900: | ||
var h = pr.height || 180; | var h = pr.height || 180; | ||
// Prefer below, left-aligned; icon triggers align right | |||
var isIconish = (tr.width || 0) <= 40; | var isIconish = (tr.width || 0) <= 40; | ||
var left = isIconish ? (tr.right - w) : tr.left; | var left = isIconish ? (tr.right - w) : tr.left; | ||
var top = tr.bottom + gap; | var top = tr.bottom + gap; | ||
// Clamp horizontally | |||
left = clamp(left, margin, Math.max(margin, vw - w - margin)); | left = clamp(left, margin, Math.max(margin, vw - w - margin)); | ||
if (top + h + margin > vh) top = tr.top - h - gap; | // 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)); | top = clamp(top, margin, Math.max(margin, vh - h - margin)); | ||
| Line 1,915: | Line 1,924: | ||
} | } | ||
function hidePopup() { | function hidePopup(fromUser) { | ||
if (!state.open) return; | if (!state.open) return; | ||
| Line 1,936: | Line 1,945: | ||
if (!trigger) return; | if (!trigger) return; | ||
if (state.open && state.trigger && state.trigger !== trigger) hidePopup(); | if (state.open && state.trigger && state.trigger !== trigger) { | ||
hidePopup(false); | |||
} | |||
state.open = true; | state.open = true; | ||
| Line 1,953: | Line 1,964: | ||
function findTrigger(t) { | function findTrigger(t) { | ||
if (!t) return null; | if (!t) return null; | ||
var defEl = closest(t, DEF_SEL); | var defEl = closest(t, DEF_SEL); | ||
| Line 1,970: | Line 1,978: | ||
function resolveOptsForTrigger(trigger) { | 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 isDef = trigger && trigger.matches && trigger.matches(DEF_SEL); | ||
var isTip = trigger && trigger.matches && trigger.matches(TIP_BTN_SEL); | var isTip = trigger && trigger.matches && trigger.matches(TIP_BTN_SEL); | ||
| Line 1,977: | Line 1,990: | ||
var size = "sm"; | var size = "sm"; | ||
// | // explicit overrides | ||
var attrMode = trigger.getAttribute && trigger.getAttribute("data-sv-pop"); | var attrMode = trigger.getAttribute && trigger.getAttribute("data-sv-pop"); | ||
if (attrMode === "hover" || attrMode === "click") mode = attrMode; | if (attrMode === "hover" || attrMode === "click") mode = attrMode; | ||
| Line 1,993: | Line 2,006: | ||
} | } | ||
if (!attrSize) size = | if (!attrSize) { | ||
size = mode === "click" ? "lg" : "sm"; | |||
} | |||
var sourceNode = null; | |||
var title = "Info"; | |||
var hint = ""; | |||
if (isDef) { | if (isDef) { | ||
var tip = getDefTipText(trigger); | var tip = getDefTipText(trigger); | ||
var linkTitle = getDefLinkTitle(trigger); | |||
if (!tip) return null; | 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 { | return { | ||
mode: mode, | mode: mode, | ||
size: size, | size: size, | ||
pinned: | pinned: mode === "click" ? true : (!HOVER_CAPABLE && state.lastPointerType !== "mouse"), | ||
title: title, | title: title, | ||
hint: | hint: hint, | ||
bodyText: tip, | bodyText: tip, | ||
bodyPre: true, | bodyPre: true, | ||
actionsNode: | actionsNode: mode === "click" ? buildActionsForDefinition(linkTitle) : null | ||
}; | }; | ||
} | } | ||
sourceNode = getContentNodeFromTrigger(trigger); | |||
if (isBlankContent(sourceNode)) return null; | 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 { | return { | ||
mode: mode, | mode: mode, | ||
size: size, | size: size, | ||
pinned: | pinned: pinned, | ||
title: | title: title, | ||
hint: | hint: hint, | ||
bodyNode: sourceNode, | bodyNode: sourceNode, | ||
bodyPre: false, | bodyPre: false, | ||
| Line 2,040: | Line 2,067: | ||
/* ------------------------------------------------------------------ */ | /* ------------------------------------------------------------------ */ | ||
/* Event Intercepts (window capture) — | /* Event Intercepts (window capture) — prevents legacy handlers running */ | ||
/* ------------------------------------------------------------------ */ | /* ------------------------------------------------------------------ */ | ||
window.addEventListener("pointerdown", function (e) { | 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(); | 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) | // Hover open/close (small) | ||
window.addEventListener("mouseover", function (e) { | 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 | |||
); | |||
e. | // 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 | |||
); | |||
if (state. | window.addEventListener( | ||
"keydown", | |||
function (e) { | |||
if (e.key !== "Escape") return; | |||
if (state.open) { | |||
hidePopup(true); | |||
e.stopPropagation(); | |||
} | |||
}, | |||
true | |||
); | |||
window.addEventListener("scroll", function () { | window.addEventListener( | ||
"scroll", | |||
function () { | |||
if (!state.open || !state.trigger) return; | |||
positionPopupNearTrigger(state.trigger); | |||
}, | |||
true | |||
); | |||
window.addEventListener("resize", function () { | 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) { | function stripDefTitles(root) { | ||
var container = root || document; | var container = root || document; | ||
| Line 2,213: | Line 2,215: | ||
if (document.readyState === "loading") { | if (document.readyState === "loading") { | ||
document.addEventListener("DOMContentLoaded", function () { stripDefTitles(document); }); | document.addEventListener("DOMContentLoaded", function () { | ||
stripDefTitles(document); | |||
}); | |||
} else { | } else { | ||
stripDefTitles(document); | stripDefTitles(document); | ||
} | } | ||
})(); | })(); | ||