MediaWiki:Common.js: Difference between revisions
No edit summary Tag: Manual revert |
No edit summary |
||
| Line 951: | Line 951: | ||
wireUllekhaLinks(); | wireUllekhaLinks(); | ||
} | } | ||
}() ); | |||
/* ── Search result highlight — highlight query term on arrival ── | |||
* When user clicks a search result, we pass the query in the URL | |||
* hash as #gr-search:QUERY or read it from sessionStorage. | |||
* On page load we find all matching text nodes and wrap them. | |||
* ─────────────────────────────────────────────────────────────── */ | |||
( function () { | |||
/* ── Step 1: When leaving via a search result link, | |||
store the query in sessionStorage ── */ | |||
function storeQueryForLink( url, query ) { | |||
try { | |||
sessionStorage.setItem( 'gr_search_hl', JSON.stringify({ | |||
query: query, | |||
url: url.split( '#' )[0] // store without anchor | |||
}) ); | |||
} catch(e) {} | |||
} | |||
/* ── Step 2: On page load, check if we arrived from a search result ── */ | |||
function applyHighlight() { | |||
var stored; | |||
try { | |||
stored = JSON.parse( sessionStorage.getItem( 'gr_search_hl' ) || 'null' ); | |||
} catch(e) { return; } | |||
if ( !stored || !stored.query ) return; | |||
/* Check URL matches */ | |||
var currentBase = window.location.href.split( '#' )[0]; | |||
if ( stored.url && stored.url !== currentBase ) { | |||
/* Different page — clear and bail */ | |||
try { sessionStorage.removeItem( 'gr_search_hl' ); } catch(e) {} | |||
return; | |||
} | |||
var query = stored.query.trim(); | |||
if ( !query ) return; | |||
/* Clear so refreshing doesn't re-highlight */ | |||
try { sessionStorage.removeItem( 'gr_search_hl' ); } catch(e) {} | |||
/* Wait for content to render */ | |||
setTimeout( function () { | |||
highlightText( query ); | |||
}, 400 ); | |||
} | |||
/* ── Core: walk text nodes and wrap matches ── */ | |||
function highlightText( query ) { | |||
var content = document.querySelector( '#mw-content-text .mw-parser-output' ); | |||
if ( !content ) return; | |||
/* Normalise query — strip quotes, split on spaces */ | |||
var raw = query.replace( /^"|"$/g, '' ).trim(); | |||
if ( !raw ) return; | |||
/* Build regex — escape special chars, match whole query first, | |||
fall back to individual words */ | |||
var patterns = []; | |||
/* Full phrase */ | |||
patterns.push( escapeRegex( raw ) ); | |||
/* Individual words (min 2 chars) */ | |||
raw.split( /\s+/ ).forEach( function(w) { | |||
if ( w.length >= 2 ) patterns.push( escapeRegex( w ) ); | |||
} ); | |||
var matched = false; | |||
for ( var pi = 0; pi < patterns.length; pi++ ) { | |||
var re; | |||
try { re = new RegExp( '(' + patterns[pi] + ')', 'gi' ); } | |||
catch(e) { continue; } | |||
var count = wrapMatches( content, re ); | |||
if ( count > 0 ) { matched = true; break; } | |||
} | |||
if ( !matched ) return; | |||
/* Scroll to first highlight */ | |||
var first = document.querySelector( '.gr-search-hl' ); | |||
if ( first ) { | |||
first.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |||
/* Pulse animation */ | |||
first.classList.add( 'gr-search-hl-pulse' ); | |||
setTimeout( function() { | |||
first.classList.remove( 'gr-search-hl-pulse' ); | |||
}, 2000 ); | |||
} | |||
/* Show dismiss button */ | |||
showDismissBar( query ); | |||
} | |||
function escapeRegex( s ) { | |||
return s.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); | |||
} | |||
function wrapMatches( root, re ) { | |||
var count = 0; | |||
var walker = document.createTreeWalker( | |||
root, NodeFilter.SHOW_TEXT, { | |||
acceptNode: function( node ) { | |||
/* Skip inside script, style, our own highlights */ | |||
var p = node.parentElement; | |||
if ( !p ) return NodeFilter.FILTER_REJECT; | |||
var tag = p.tagName.toUpperCase(); | |||
if ( tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' ) return NodeFilter.FILTER_REJECT; | |||
if ( p.classList.contains( 'gr-search-hl' ) ) return NodeFilter.FILTER_REJECT; | |||
return NodeFilter.FILTER_ACCEPT; | |||
} | |||
}, false | |||
); | |||
var nodes = []; | |||
var node; | |||
while ( ( node = walker.nextNode() ) ) nodes.push( node ); | |||
nodes.forEach( function( textNode ) { | |||
var val = textNode.nodeValue; | |||
if ( !re.test( val ) ) return; | |||
re.lastIndex = 0; | |||
var frag = document.createDocumentFragment(); | |||
var last = 0; | |||
var m; | |||
while ( ( m = re.exec( val ) ) !== null ) { | |||
if ( m.index > last ) { | |||
frag.appendChild( document.createTextNode( val.slice( last, m.index ) ) ); | |||
} | |||
var span = document.createElement( 'span' ); | |||
span.className = 'gr-search-hl'; | |||
span.textContent = m[0]; | |||
frag.appendChild( span ); | |||
last = m.index + m[0].length; | |||
count++; | |||
} | |||
if ( last < val.length ) { | |||
frag.appendChild( document.createTextNode( val.slice( last ) ) ); | |||
} | |||
textNode.parentNode.replaceChild( frag, textNode ); | |||
} ); | |||
return count; | |||
} | |||
/* ── Dismiss bar ── */ | |||
function showDismissBar( query ) { | |||
var isMob = window.innerWidth < 768; | |||
var bar = document.createElement( 'div' ); | |||
bar.id = 'gr-hl-bar'; | |||
/* On mobile: float as a pill near top to avoid bottom bar conflicts. | |||
On desktop: sit at bottom. */ | |||
if ( isMob ) { | |||
bar.style.cssText = [ | |||
'position:fixed', | |||
'top:calc(var(--gr-header-height,56px) + var(--gr-toc-top,52px) + 8px)', | |||
'left:50%', 'transform:translateX(-50%)', | |||
'z-index:10200', | |||
'background:#b5451b', 'color:#fff', | |||
'padding:8px 14px', | |||
'border-radius:24px', | |||
'display:flex', 'align-items:center', 'gap:8px', | |||
'font-family:system-ui,sans-serif', 'font-size:13px', | |||
'box-shadow:0 3px 12px rgba(0,0,0,0.25)', | |||
'white-space:nowrap', | |||
'max-width:calc(100vw - 32px)' | |||
].join(';'); | |||
} else { | |||
bar.style.cssText = [ | |||
'position:fixed', 'bottom:0', 'left:0', 'right:0', 'z-index:10200', | |||
'background:#b5451b', 'color:#fff', 'padding:10px 16px', | |||
'display:flex', 'align-items:center', 'justify-content:space-between', | |||
'font-family:system-ui,sans-serif', 'font-size:14px', | |||
'box-shadow:0 -2px 8px rgba(0,0,0,0.2)' | |||
].join(';'); | |||
} | |||
var count = document.querySelectorAll( '.gr-search-hl' ).length; | |||
if ( isMob ) { | |||
/* Mobile: compact pill — just count + prev/next + dismiss */ | |||
bar.innerHTML = | |||
'<span style="flex-shrink:0">🔍 ' + count + '</span>' + | |||
'<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">↑</button>' + | |||
'<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">↓</button>' + | |||
'<button id="gr-hl-dismiss" style="background:rgba(255,255,255,0.15);border:none;color:#fff;min-width:36px;height:36px;border-radius:50%;cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;">✕</button>'; | |||
} else { | |||
/* Desktop: full bar */ | |||
var nav = document.createElement( 'div' ); | |||
nav.style.cssText = 'display:flex;align-items:center;gap:12px;'; | |||
nav.innerHTML = | |||
'<span>🔍 <strong>' + escHtml(query) + '</strong> — ' + count + ' match' + (count===1?'':'es') + '</span>' + | |||
'<button id="gr-hl-prev" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↑ Prev</button>' + | |||
'<button id="gr-hl-next" style="background:rgba(255,255,255,0.2);border:none;color:#fff;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;">↓ Next</button>'; | |||
bar.appendChild( nav ); | |||
} | |||
var dismiss = isMob | |||
? bar.querySelector( '#gr-hl-dismiss' ) | |||
: ( function() { | |||
var b = document.createElement( 'button' ); | |||
b.textContent = '✕ Clear'; | |||
b.id = 'gr-hl-dismiss'; | |||
b.style.cssText = 'background:rgba(255,255,255,0.15);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:13px;min-height:32px;'; | |||
bar.appendChild( b ); | |||
return b; | |||
}() ); | |||
dismiss.onclick = function () { | |||
clearHighlights(); | |||
bar.remove(); | |||
}; | |||
document.body.appendChild( bar ); | |||
/* Prev / Next navigation */ | |||
var hlEls = Array.from( document.querySelectorAll( '.gr-search-hl' ) ); | |||
var currentIdx = 0; | |||
function goTo( idx ) { | |||
hlEls.forEach( function(el) { el.classList.remove( 'gr-search-hl-current' ); } ); | |||
currentIdx = ( idx + hlEls.length ) % hlEls.length; | |||
var el = hlEls[ currentIdx ]; | |||
el.classList.add( 'gr-search-hl-current' ); | |||
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |||
} | |||
var nextBtn = document.getElementById( 'gr-hl-next' ); | |||
var prevBtn = document.getElementById( 'gr-hl-prev' ); | |||
if ( nextBtn ) nextBtn.onclick = function() { goTo( currentIdx + 1 ); }; | |||
if ( prevBtn ) prevBtn.onclick = function() { goTo( currentIdx - 1 ); }; | |||
} | |||
function clearHighlights() { | |||
document.querySelectorAll( '.gr-search-hl' ).forEach( function( span ) { | |||
var parent = span.parentNode; | |||
while ( span.firstChild ) parent.insertBefore( span.firstChild, span ); | |||
parent.removeChild( span ); | |||
} ); | |||
} | |||
function escHtml( s ) { | |||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); | |||
} | |||
/* ── Inject CSS for highlights ── */ | |||
function injectHighlightCSS() { | |||
if ( document.getElementById( 'gr-hl-css' ) ) return; | |||
var s = document.createElement( 'style' ); | |||
s.id = 'gr-hl-css'; | |||
s.textContent = [ | |||
'.gr-search-hl{', | |||
' background:#fff176;color:#1a1a1a;', | |||
' border-radius:2px;padding:0 1px;', | |||
' box-shadow:0 0 0 1px rgba(181,69,27,0.25);', | |||
'}', | |||
'.gr-search-hl-current{', | |||
' background:#ffb300!important;', | |||
' box-shadow:0 0 0 2px #b5451b!important;', | |||
'}', | |||
'@keyframes gr-hl-pulse{', | |||
' 0%{background:#ffb300;}', | |||
' 50%{background:#fff176;}', | |||
' 100%{background:#fff176;}', | |||
'}', | |||
'.gr-search-hl-pulse{animation:gr-hl-pulse 1.2s ease 2;}', | |||
].join(''); | |||
document.head.appendChild( s ); | |||
} | |||
/* ── Boot ── */ | |||
injectHighlightCSS(); | |||
if ( document.readyState === 'loading' ) { | |||
document.addEventListener( 'DOMContentLoaded', applyHighlight ); | |||
} else { | |||
applyHighlight(); | |||
} | |||
/* ── Expose storeQueryForLink for readerToolbar to call ── */ | |||
window.grStoreSearchHL = storeQueryForLink; | |||
}() ); | }() ); | ||