@@ -45,22 +45,7 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
4545 // highlight matches on the page
4646 if ( query && mainEl ) {
4747 // perform any highlighting
48- highlight ( escapeRegExp ( query ) , mainEl ) ;
49-
50- // Activate tabs that contain highlighted matches on pageshow rather than
51- // DOMContentLoaded. tabsets.js (loaded as a module) registers its pageshow
52- // handler during module execution, before DOMContentLoaded. By registering
53- // ours during DOMContentLoaded, listener ordering guarantees we run after
54- // tabsets.js restores tab state from localStorage — so search activation
55- // wins over stored tab preference.
56- window . addEventListener ( "pageshow" , function ( event ) {
57- if ( ! event . persisted ) {
58- activateTabsWithMatches ( mainEl ) ;
59- // Let the browser settle layout after Bootstrap tab transitions
60- // before calculating scroll position.
61- requestAnimationFrame ( ( ) => scrollToFirstMatch ( mainEl ) ) ;
62- }
63- } , { once : true } ) ;
48+ highlight ( query , mainEl ) ;
6449
6550 // fix up the URL to remove the q query param
6651 const replacementUrl = new URL ( window . location ) ;
@@ -359,22 +344,12 @@ window.document.addEventListener("DOMContentLoaded", function (_event) {
359344 } ,
360345
361346 item ( { item, createElement } ) {
362- // process items to include text fragments as they are rendered
363- if ( item . text && item . href && ! item . href . includes ( ':~:text=' ) ) {
364- // e.g. `item.text` for a search "def fiz": "bla bla bla<mark class='search-match'>def fiz</mark> bla bla"
365- const fullMatches = item . text . matchAll ( / < m a r k c l a s s = ' s e a r c h - m a t c h ' > ( .* ?) < \/ m a r k > / g)
366- // extract capture group with the search match
367- // result e.g. ["def fiz"]
368- const searchMatches = [ ...fullMatches ] . map ( match => match [ 1 ] )
369- if ( searchMatches [ 0 ] ) {
370- if ( item . href . includes ( '#' ) ) {
371- item . href += ':~:text=' + encodeURIComponent ( searchMatches [ 0 ] )
372- } else {
373- item . href += '#:~:text=' + encodeURIComponent ( searchMatches [ 0 ] )
374- }
375- }
347+ if ( item . text && item . href && ! item . href . includes ( '?q=' ) ) {
348+ const [ main , hash ] = item . href . split ( '#' )
349+ const hashAppend = hash ? '#' + hash : ''
350+ item . href = main + '?q=' + encodeURIComponent ( state . query ) + hashAppend
376351 }
377-
352+
378353 return renderItem (
379354 item ,
380355 createElement ,
@@ -1123,151 +1098,186 @@ function clearHighlight(searchterm, el) {
11231098 }
11241099}
11251100
1126- function escapeRegExp ( string ) {
1127- return string . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ; // $& means the whole matched string
1128- }
1129-
1130- // After search highlighting, activate any tabs whose panes contain <mark> matches.
1131- // This ensures that search results inside inactive Bootstrap tabs become visible.
1132- // Handles nested tabsets by walking up ancestor panes and activating outermost first.
1133- function activateTabsWithMatches ( mainEl ) {
1134- if ( typeof bootstrap === "undefined" ) return ;
1135-
1136- const marks = mainEl . querySelectorAll ( "mark" ) ;
1137- if ( marks . length === 0 ) return ;
1138-
1139- // Collect all tab panes that contain marks, including ancestor panes for nesting.
1140- // Group by their parent tabset (.tab-content container).
1141- const tabsetMatches = new Map ( ) ;
1142-
1143- const recordPane = ( pane ) => {
1144- const tabContent = pane . closest ( ".tab-content" ) ;
1145- if ( ! tabContent ) return ;
1146- if ( ! tabsetMatches . has ( tabContent ) ) {
1147- tabsetMatches . set ( tabContent , { activeHasMatch : false , firstInactivePane : null } ) ;
1148- }
1149- const info = tabsetMatches . get ( tabContent ) ;
1150- if ( pane . classList . contains ( "active" ) ) {
1151- info . activeHasMatch = true ;
1152- } else if ( ! info . firstInactivePane ) {
1153- info . firstInactivePane = pane ;
1154- }
1155- } ;
1101+ /** Get all html nodes under the given `root` that don't have children. */
1102+ function getLeafNodes ( root ) {
1103+ let leaves = [ ] ;
11561104
1157- for ( const mark of marks ) {
1158- // Walk up all ancestor tab panes (handles nested tabsets)
1159- let pane = mark . closest ( ".tab-pane" ) ;
1160- while ( pane ) {
1161- recordPane ( pane ) ;
1162- pane = pane . parentElement ?. closest ( ".tab-pane" ) ?? null ;
1105+ function traverse ( node ) {
1106+ if ( node . childNodes . length === 0 ) {
1107+ leaves . push ( node ) ;
1108+ } else {
1109+ node . childNodes . forEach ( traverse ) ;
11631110 }
11641111 }
11651112
1166- // Sort tabsets by DOM depth (outermost first) so outer tabs activate before inner
1167- const sorted = [ ...tabsetMatches . entries ( ) ] . sort ( ( a , b ) => {
1168- const depthA = ancestorCount ( a [ 0 ] , mainEl ) ;
1169- const depthB = ancestorCount ( b [ 0 ] , mainEl ) ;
1170- return depthA - depthB ;
1171- } ) ;
1113+ traverse ( root ) ;
1114+ return leaves ;
1115+ }
11721116
1173- for ( const [ , info ] of sorted ) {
1174- if ( info . activeHasMatch || ! info . firstInactivePane ) continue ;
1117+ const isWhitespace = s => s . trim ( ) . length === 0
1118+ const initMatch = ( ) => ( {
1119+ i : 0 ,
1120+ lohisByNode : new Map ( )
1121+ } )
1122+ /**
1123+ * keeps track of the start (lo) and end (hi) index of the match per node (leaf)
1124+ * note: mutates the contents of `matchContext`
1125+ */
1126+ const advanceMatch = ( leaf , leafi , matchContext ) => {
1127+ matchContext . i ++
1128+
1129+ const curLoHi = matchContext . lohisByNode . get ( leaf )
1130+
1131+ matchContext . lohisByNode . set ( leaf , { lo : curLoHi ?. lo ?? leafi , hi : leafi } )
1132+ }
1133+ /**
1134+ * Finds all non-overlapping matches for a search string in the document.
1135+ * The search string may be split between multiple consecutive leaf nodes.
1136+ *
1137+ * Whitespace in the search string must be present in the document to match, but
1138+ * there may be addititional whitespace in the document that is ignored.
1139+ *
1140+ * e.g. searching for `dogs rock` would match `dogs \n <span> rock</span>`,
1141+ * and would contribute the match
1142+ * `{ i:9, els: new Map([[textNode, {lo:0, hi:8}],[spanNode,{lo:0,hi:5}]]) }`
1143+ *
1144+ * @returns {Map<HTMLElement,{lo:number,hi:number}>[] }
1145+ */
1146+ function searchMatches ( inSearch , el ) {
1147+ // searchText has all sequences of whitespace replaced by a single space
1148+ const searchText = inSearch . toLowerCase ( ) . replace ( / \s + / g, ' ' )
1149+ const leafNodes = getLeafNodes ( el )
1150+
1151+ /** @type {Map<HTMLElement,{lo:number,hi:number}>[] } */
1152+ const matches = [ ]
1153+ /** @type {{i:number; els:Map<HTMLElement,{lo:number,hi:number}>}[] } */
1154+ let curMatchContext = initMatch ( )
1155+
1156+ for ( leaf of leafNodes ) {
1157+ const leafStr = leaf . textContent . toLowerCase ( )
1158+ // for each character in this leaf's text:
1159+ for ( let leafi = 0 ; leafi < leafStr . length ; leafi ++ ) {
1160+
1161+ if ( isWhitespace ( leafStr [ leafi ] ) ) {
1162+ // if there is at least one whitespace in the document
1163+ // we advance over a search text whitespace.
1164+ if ( isWhitespace ( searchText [ curMatchContext . i ] ) ) advanceMatch ( leaf , leafi , curMatchContext )
1165+ // all sequences of whitespace are otherwise ignored.
1166+ } else {
1167+ if ( searchText [ curMatchContext . i ] === leafStr [ leafi ] ) {
1168+ advanceMatch ( leaf , leafi , curMatchContext )
1169+ } else {
1170+ curMatchContext = initMatch ( )
1171+ // if current character in the document did not match at i in the search text,
1172+ // reset the search and see if that character matches at 0 in the search text.
1173+ if ( searchText [ curMatchContext . i ] === leafStr [ leafi ] ) advanceMatch ( leaf , leafi , curMatchContext )
1174+ }
1175+ }
11751176
1176- const escapedId = CSS . escape ( info . firstInactivePane . id ) ;
1177- const tabButton = mainEl . querySelector (
1178- `[data-bs-toggle="tab"][data-bs-target="#${ escapedId } "]`
1179- ) ;
1180- if ( tabButton ) {
1181- try {
1182- new bootstrap . Tab ( tabButton ) . show ( ) ;
1183- } catch ( e ) {
1184- console . debug ( "Failed to activate tab for search match:" , e ) ;
1177+ const isMatchComplete = curMatchContext . i === searchText . length
1178+ if ( isMatchComplete ) {
1179+ matches . push ( curMatchContext . lohisByNode )
1180+ curMatchContext = initMatch ( )
11851181 }
11861182 }
11871183 }
1184+
1185+ return matches
11881186}
11891187
1190- function ancestorCount ( el , stopAt ) {
1191- let count = 0 ;
1192- let node = el . parentElement ;
1193- while ( node && node !== stopAt ) {
1194- count ++ ;
1195- node = node . parentElement ;
1188+ /** create and return `<mark>${txt}</mark>` */
1189+ const markEl = txt => {
1190+ const el = document . createElement ( "mark" ) ;
1191+ el . appendChild ( document . createTextNode ( txt ) ) ;
1192+ return el
1193+ }
1194+ /**
1195+ * e.g. `markMatches(myTextNode, [[0,5],[12,15]])` would wrap the
1196+ * character sequences in myTextNode from 0-5 and 12-15 in marks.
1197+ * Its important to mark all sequences in a text node at once
1198+ * because this function replaces the entire text node; so any
1199+ * other references to that text node will no longer be in the DOM.
1200+ */
1201+ function markMatches ( node , lohis ) {
1202+ const text = node . nodeValue
1203+
1204+ const markFragment = document . createDocumentFragment ( ) ;
1205+
1206+ let prevHi = 0
1207+ for ( const [ lo , hi ] of lohis ) {
1208+ markFragment . append (
1209+ document . createTextNode ( text . slice ( prevHi , lo ) ) ,
1210+ markEl ( text . slice ( lo , hi + 1 ) )
1211+ )
1212+ prevHi = hi + 1
11961213 }
1197- return count ;
1214+ markFragment . append (
1215+ document . createTextNode ( text . slice ( prevHi , text . length ) )
1216+ )
1217+
1218+ const parent = node . parentElement
1219+ parent ?. replaceChild ( markFragment , node )
1220+ return parent
11981221}
11991222
1200- // After tab activation, scroll to the first visible search match so the user
1201- // sees the highlighted result without manually scrolling.
1202- // Only checks tab-pane visibility (not collapsed callouts, details/summary, etc.)
1203- // since this runs specifically after tab activation for search results.
1204- function scrollToFirstMatch ( mainEl ) {
1205- const marks = mainEl . querySelectorAll ( "mark" ) ;
1206- for ( const mark of marks ) {
1207- let hidden = false ;
1208- let el = mark . parentElement ;
1209- while ( el && el !== mainEl ) {
1210- if ( el . classList . contains ( "tab-pane" ) && ! el . classList . contains ( "active" ) ) {
1211- hidden = true ;
1212- break ;
1213- }
1214- el = el . parentElement ;
1215- }
1216- if ( ! hidden ) {
1217- mark . scrollIntoView ( { block : "center" } ) ;
1218- return ;
1219- }
1223+ /** get all ancestors of an element matching the given css selector */
1224+ const matchAncestors = ( el , selector ) => {
1225+ let ancestors = [ ] ;
1226+ while ( el ) {
1227+ if ( el . matches ?. ( selector ) ) ancestors . push ( el ) ;
1228+ el = el . parentNode ;
1229+ }
1230+ return ancestors ;
1231+ } ;
1232+ const openAllTabsetsContainingEl = el => {
1233+ for ( const tab of matchAncestors ( el , '.tab-pane' ) ) {
1234+ const tabButton = document . querySelector ( `[data-bs-target="#${ tab . id } "]` ) ;
1235+ if ( tabButton ) new bootstrap . Tab ( tabButton ) . show ( ) ;
12201236 }
12211237}
12221238
1223- // highlight matches
1224- function highlight ( term , el ) {
1225- const termRegex = new RegExp ( term , "ig" ) ;
1226- const childNodes = el . childNodes ;
1227-
1228- // walk back to front avoid mutating elements in front of us
1229- for ( let i = childNodes . length - 1 ; i >= 0 ; i -- ) {
1230- const node = childNodes [ i ] ;
1231-
1232- if ( node . nodeType === Node . TEXT_NODE ) {
1233- // Search text nodes for text to highlight
1234- const text = node . nodeValue ;
1235-
1236- let startIndex = 0 ;
1237- let matchIndex = text . search ( termRegex ) ;
1238- if ( matchIndex > - 1 ) {
1239- const markFragment = document . createDocumentFragment ( ) ;
1240- while ( matchIndex > - 1 ) {
1241- const prefix = text . slice ( startIndex , matchIndex ) ;
1242- markFragment . appendChild ( document . createTextNode ( prefix ) ) ;
1243-
1244- const mark = document . createElement ( "mark" ) ;
1245- mark . appendChild (
1246- document . createTextNode (
1247- text . slice ( matchIndex , matchIndex + term . length )
1248- )
1249- ) ;
1250- markFragment . appendChild ( mark ) ;
1251-
1252- startIndex = matchIndex + term . length ;
1253- matchIndex = text . slice ( startIndex ) . search ( new RegExp ( term , "ig" ) ) ;
1254- if ( matchIndex > - 1 ) {
1255- matchIndex = startIndex + matchIndex ;
1256- }
1257- }
1258- if ( startIndex < text . length ) {
1259- markFragment . appendChild (
1260- document . createTextNode ( text . slice ( startIndex , text . length ) )
1261- ) ;
1262- }
1239+ /**
1240+ * e.g.
1241+ * ```js
1242+ * const m = new Map()
1243+ *
1244+ * arrayMapPush(m, 'dog', 'Max')
1245+ * console.log(m) // Map { dog->['Max'] }
1246+ *
1247+ * arrayMapPush(m, 'dog', 'Samba')
1248+ * arrayMapPush(m, 'cat', 'Scruffle')
1249+ * console.log(m) // Map { dog->['Max', 'Samba'], cat->['Scruffle'] }
1250+ * ```
1251+ */
1252+ const arrayMapPush = ( map , key , item ) => {
1253+ if ( ! map . has ( key ) ) map . set ( key , [ ] )
1254+ map . set ( key , [ ...map . get ( key ) , item ] )
1255+ }
12631256
1264- el . replaceChild ( markFragment , node ) ;
1265- }
1266- } else if ( node . nodeType === Node . ELEMENT_NODE ) {
1267- // recurse through elements
1268- highlight ( term , node ) ;
1257+ // copy&paste any string from a quarto page and
1258+ // this should find that string in the page and highlight it.
1259+ // exception: text that starts outside/inside a tabset and ends
1260+ // inside/outside that tabset.
1261+ function highlight ( searchStr , el ) {
1262+ const matches = searchMatches ( searchStr , el ) ;
1263+
1264+ const matchesGroupedByNode = new Map ( )
1265+ for ( const match of matches ) {
1266+ for ( const [ mel , { lo, hi } ] of match ) {
1267+ arrayMapPush ( matchesGroupedByNode , mel , [ lo , hi ] )
12691268 }
12701269 }
1270+
1271+ const matchNodes = [ ...matchesGroupedByNode ] . map ( ( [ node , lohis ] ) => {
1272+ const matchNode = markMatches ( node , lohis )
1273+ openAllTabsetsContainingEl ( matchNode )
1274+ return matchNode
1275+ } )
1276+ // let things settle before scrolling
1277+ setTimeout ( ( ) =>
1278+ matchNodes [ 0 ] ?. scrollIntoView ( { behavior : 'smooth' , block : 'center' } ) ,
1279+ 400
1280+ )
12711281}
12721282
12731283/* Link Handling */
0 commit comments