1/** 2 * Get i18n text with fallback to default 3 * @param {string} key - Translation key 4 * @param {string} defaultText - Fallback text if translation not found 5 * @returns {string} Translated or default text 6 */ 7function getI18nText(key, defaultText) { 8 return (typeof $exe_i18n !== 'undefined' && $exe_i18n[key]) 9 ? $exe_i18n[key] 10 : defaultText; 11} 12 13/** 14 * Translate elements with data-i18n attribute 15 * @param {string} key - The i18n key to look up 16 * @param {string} defaultText - Fallback text 17 */ 18function translateI18nElement(key, defaultText) { 19 var text = getI18nText(key, defaultText); 20 $('[data-i18n="' + key + '"]').each(function() { 21 $(this).attr('title', text); 22 $('span', this).text(text); 23 }); 24} 25 26// Guard against multiple loads (EPUB readers may reload scripts when navigating) 27if (typeof window.$exeExport === 'undefined') { 28window.$exeExport = { 29 30 isTogglingBox: false, 31 delayLoadingPageTime: 200, 32 delayLoadingIdevicesJson: 50, 33 delayLoadScorm: 50, 34 scormAPIwrapper: 'SCORM_API_wrapper.js', 35 scormFunctions: 'SCOFunctions.js', 36 37 init: function () { 38 try { 39 this.addBoxToggleEvent(); 40 this.translateNavButtons(); 41 } catch (err) { 42 console.error('Error: Failed to initialize box toggle events'); 43 } 44 try { 45 this.setExe(); 46 this.initExe(); 47 this.initJsonIdevices(); 48 } catch (err) { 49 console.error('Error: Failed to initialize content'); 50 } 51 try { 52 this.loadScorm(); 53 } catch (err) { 54 console.error('Error: Failed to initialize SCORM'); 55 } 56 // setTimeout to allow custom button in style 57 setTimeout(function(){ 58 try { 59 $exeExport.teacherMode.init(); 60 } catch (err) { 61 console.error('Error: Failed to initialize Teacher Mode'); 62 } 63 }, 100); 64 setTimeout(() => { this.addClassJsExecutedToExeContent() }, this.delayLoadingPageTime); 65 setTimeout(() => { 66 try { 67 this.triggerPrintIfRequested(); 68 } catch (err) { 69 console.error('Error: Failed to trigger print dialog'); 70 } 71 }, this.delayLoadingPageTime); 72 setTimeout(() => { 73 try { 74 this.searchBar.highlightFromUrl(); 75 } catch (err) { 76 // Failed to highlight search results 77 } 78 }, this.delayLoadingPageTime); 79 }, 80 81 /** 82 * Set eXe object 83 */ 84 setExe: function () { 85 window.eXe = {}; 86 window.eXe.app = $exe; 87 }, 88 89 /** 90 * Init legacy $exe object 91 */ 92 initExe: function () { 93 window.eXe.app.init(); 94 }, 95 96 /** 97 * Teacher Mode 98 * 99 * Teacher-only content (.teacher-only) is HIDDEN by default in exports (see the rule in 100 * base.css). It is revealed by the `mode-teacher` class on <html>, which the in-page 101 * self-serve toggle adds/removes. The toggle is opt-in via URL parameter, so host 102 * integrations (LMS/CMS) only need to change the iframe URL â no injected CSS/JS: 103 * 104 * ?exe-teacher=1|true|yes show the Teacher Mode toggle (alias: ?teacher-mode=1, or 105 * the legacy ?exe-teacher-toggler=1). The toggle is OFF by 106 * default and remembers the viewer's choice in localStorage; 107 * the viewer activates it to reveal teacher content. The 108 * parameter never reveals content on its own. 109 * (no parameter) no toggle; teacher content stays hidden (student view). 110 * 111 * NOTE: this is an opt-in PRESENTATION mode, not access control and not a security 112 * boundary. Truly sensitive answer keys need a separate authenticated/password-gated 113 * feature. 114 */ 115 teacherMode : { 116 STORAGE_KEY : 'exeTeacherMode', 117 _showToggler : false, 118 _navParams : '', 119 _truthy : function(v){ return v === '1' || v === 'true' || v === 'yes'; }, 120 /** 121 * Decide â as early as possible, since this file runs in <head> â whether the 122 * self-serve toggle should be available, and restore the viewer's stored choice 123 * flicker-free. The parameter only makes the toggle AVAILABLE; it never reveals 124 * content on its own. Vanilla JS only; never throws (safe default = student view). 125 */ 126 bootstrap : function(){ 127 var root = document.documentElement; 128 try { 129 var params = new URLSearchParams(window.location.search); 130 var teacher = params.get('exe-teacher'); 131 if (teacher === null) teacher = params.get('teacher-mode'); // documented alias 132 var toggler = params.get('exe-teacher-toggler'); // legacy alias, same effect 133 this._showToggler = this._truthy(teacher) || this._truthy(toggler); 134 this._navParams = this._showToggler ? 'exe-teacher=1' : ''; 135 if (this._showToggler) { 136 // Restore the viewer's stored choice (OFF by default), flicker-free. 137 try { 138 if (localStorage.getItem(this.STORAGE_KEY) === '1') root.classList.add('mode-teacher'); 139 } catch (e) {} 140 } 141 } catch (e) { 142 // Keep student mode (no toggle) as the safe default. 143 } 144 }, 145 /** 146 * Append the active teacher params to an in-package navigation href so the chosen 147 * view survives navigation between pages â works in same-origin AND opaque-origin 148 * iframes (where storage is unavailable). Leaves external links, non-relative 149 * schemes and pure fragments untouched. 150 */ 151 withTeacherParams : function(href){ 152 if (!href || !this._navParams) return href; 153 if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href)) return href; 154 if (href.indexOf('exe-teacher') !== -1) return href; 155 var hashIdx = href.indexOf('#'); 156 var hash = hashIdx >= 0 ? href.slice(hashIdx) : ''; 157 var base = hashIdx >= 0 ? href.slice(0, hashIdx) : href; 158 var sep = base.indexOf('?') !== -1 ? '&' : '?'; 159 return base + sep + this._navParams + hash; 160 }, 161 /** Rewrite the menu and prev/next links so navigation keeps the teacher view. */ 162 propagateNavParams : function(){ 163 if (!this._navParams) return; 164 var self = this; 165 var links = document.querySelectorAll('#siteNav a[href], .nav-buttons a[href]'); 166 links.forEach(function(a){ 167 a.setAttribute('href', self.withTeacherParams(a.getAttribute('href'))); 168 }); 169 }, 170 init : function(){ 171 // Reveal is already applied by bootstrap() (flicker-free); here we only carry 172 // the params across navigation and manage the optional self-serve toggle.
173 this.propagateNavParams(); 174 if (typeof(localStorage)!='object') return; 175 if ($(".box.teacher-only").length==0 && $(".idevice_node.teacher-only").length==0) return; 176 if (document.getElementById("teacher-mode-toggler")) return; 177 if ($("body").hasClass("exe-epub")) return; 178 // The self-serve toggle is opt-in: shown only when the teacher URL parameter 179 // made it available (?exe-teacher=1, alias ?teacher-mode=1, or the legacy 180 // ?exe-teacher-toggler=1) â see bootstrap(). _showToggler captures that decision. 181 if (this._showToggler !== true) return; 182 document.body.classList.add('exe-teacher-mode-toggler'); 183 var btn = '<div class="form-check form-switch" id="teacher-mode-toggler-wrapper"><input class="form-check-input" type="checkbox" role="switch" id="teacher-mode-toggler"><label class="form-check-label" for="teacher-mode-toggler">'+$exe_i18n.teacher_mode+'</label></div>'; 184 if ($("body").hasClass("exe-single-page")) $(".package-header").before(btn); 185 else $(".page-header").prepend(btn); 186 this.toggler = $("#teacher-mode-toggler"); 187 if (document.documentElement.classList.contains('mode-teacher')) { 188 this.toggler.prop("checked", true); 189 } 190 this.toggler.on("change", function(){ 191 var root = document.documentElement; 192 var key = $exeExport.teacherMode.STORAGE_KEY; 193 if (this.checked) { 194 try { localStorage.setItem(key, '1'); } catch (e) {} 195 root.classList.add('mode-teacher'); 196 } else { 197 try { localStorage.removeItem(key); } catch (e) {} 198 root.classList.remove('mode-teacher'); 199 } 200 }); 201 }, 202 isEnabled : function(){ 203 try { 204 return localStorage.getItem(this.STORAGE_KEY) === '1'; 205 } catch (e) { 206 return false; 207 } 208 } 209 }, 210 211 /** 212 * Load SCO functions 213 */ 214 loadScorm: function () { 215 if (document.querySelector('body').classList.contains('exe-scorm')) { 216 var loadScormScriptInterval = setInterval(() => { 217 if (typeof window.scorm != 'undefined' && typeof window.loadPage == 'function') { 218 this.initScorm(); 219 clearInterval(loadScormScriptInterval); 220 } 221 }, this.delayLoadScorm) 222 } 223 }, 224 225 /** 226 * Load scorm page item 227 */ 228 initScorm: function () { 229 if (typeof window.scorm != 'undefined' && typeof window.loadPage == 'function') { 230 var isSCORM = false; 231 // We go through the activities to see if any save scorm data 232 let idevicesNodes = document.querySelectorAll('.idevice_node'); 233 idevicesNodes.forEach(ideviceNode => { 234 let ideviceComponentType = ideviceNode.getAttribute('data-idevice-component-type'); 235 let ideviceType = ideviceNode.getAttribute('data-idevice-type'); 236 let ideviceObject = this.getIdeviceObject(ideviceType); 237 if (ideviceObject) { 238 // Check if idevice save scorm data 239 switch (ideviceComponentType) { 240 case 'js': 241 if (ideviceObject.options) { 242 ideviceObject.options.forEach(instanteOptions => { 243 if (instanteOptions.isScorm) isSCORM = true; 244 }) 245 } 246 break; 247 case 'json': 248 let jsonDataText = ideviceNode.getAttribute('data-idevice-json-data'); 249 let jsonData = null; 250 251 // Parse JSON data or create empty object if not valid 252 try { 253 if (jsonDataText) { 254 jsonData = JSON.parse(jsonDataText); 255 } 256 } catch (e) { 257 jsonData = null; 258 } 259 260 // Check for SCORM data if jsonData is valid 261 if ( 262 jsonData && 263 ( 264 (jsonData.exportScorm && jsonData.exportScorm.saveScore) || 265 Number(jsonData.isScorm) > 0 266 ) 267 ) { 268 isSCORM = true; 269 } 270 break; 271 } 272 } 273 }) 274 window.loadPage() 275 window.addEventListener('unload', () => window.unloadPage(isSCORM)); 276 } 277 }, 278 279 /** 280 * Init export json idevices 281 */ 282 initJsonIdevices: function () { 283 // Get idevices 284 let idevicesTypes = {}; 285 let idevicesNodes = document.querySelectorAll('.idevice_node');
286 idevicesNodes.forEach(ideviceNode => { 287 let ideviceComponentType = ideviceNode.getAttribute('data-idevice-component-type'); 288 if (ideviceComponentType == 'json') { 289 let ideviceType = ideviceNode.getAttribute('data-idevice-type'); 290 idevicesTypes[ideviceType] = true; 291 } 292 }) 293 // Init idevices 294 Object.keys(idevicesTypes).forEach(ideviceType => { 295 this.initJsonIdeviceInterval(ideviceType); 296 }) 297 }, 298 299 /** 300 * Init init export json idevice interval 301 * 302 * @param {*} ideviceType 303 */ 304 initJsonIdeviceInterval: function (ideviceType) { 305 let intervalName = 'eXe_idevice_init_interval_' + ideviceType; 306 window[intervalName] = setInterval( 307 () => this.initJsonIdevice(ideviceType, intervalName), 308 this.delayLoadingIdevicesJson); 309 }, 310 311 /** 312 * Init export json idevice 313 * 314 * @param {*} ideviceType 315 */ 316 initJsonIdevice: function (ideviceType, intervalName) { 317 // Idevice export object 318 let exportIdevice = this.getIdeviceObject(ideviceType); 319 if (exportIdevice === undefined) return false; 320 // Clear interval immediately - we only need to run once 321 clearInterval(window[intervalName]); 322 // Get json data and initializes each page component of the indicated type 323 let idevicesNodes = document.querySelectorAll(`.idevice_node.${ideviceType}`); 324 idevicesNodes.forEach(ideviceNode => { 325 // Skip if already loaded or loading (prevents duplicate fetches) 326 if (ideviceNode.classList.contains('loaded') || ideviceNode.classList.contains('loading')) { 327 return; 328 } 329 // Loading class 330 ideviceNode.classList.add('loading'); 331 // Get json data 332 let jsonDataText = ideviceNode.getAttribute('data-idevice-json-data'); 333 let jsonData = null; 334 335 // Text idevices don't need JSON data parsing - use empty object directly 336 const currentIdeviceType = ideviceNode.getAttribute('data-idevice-type'); 337 if (currentIdeviceType === 'text') { 338 jsonData = {}; 339 } else { 340 // Parse JSON data or create empty object if not valid 341 try { 342 if (jsonDataText) { 343 jsonData = JSON.parse(jsonDataText); 344 } 345 } catch (e) { 346 jsonData = null; 347 } 348 349 // If jsonData is not an object, create an empty one 350 if (!jsonData || typeof jsonData !== 'object' || Array.isArray(jsonData)) { 351 jsonData = {}; 352 } 353 } 354 355 jsonData.ideviceId = ideviceNode.id; 356 // Get accesibility 357 let accesibility = null; 358 // Get template filename and path 359 let templateFilename = ideviceNode.getAttribute('data-idevice-template'); 360 let idevicePath = ideviceNode.getAttribute('data-idevice-path'); 361 // Idevice export function 1: renderView 362 // JSON iDevices that store ALL content in jsonProperties (not in htmlView) need renderView 363 // to generate the complete interface. These iDevices have empty htmlView by design. 364 // Other JSON iDevices (like text) may have pre-rendered content in htmlView. 365 const isJsonIdevice = ideviceNode.getAttribute('data-idevice-component-type') === 'json'; 366 // JSON-only iDevices that store ALL content in jsonProperties (not in htmlView) 367 // and need renderView to generate the complete interface. 368 // 'trueorfalse' added for legacy imports that have empty htmlView. 369 const jsonOnlyIdevices = [ 370 'casestudy', 371 'file-attachment', 372 'form', 373 'image-gallery', 374 'magnifier', 375 'three-sixty-viewer', 376 'trueorfalse', 377 'adaptative-quiz', 378 ]; 379 const ideviceType = ideviceNode.getAttribute('data-idevice-type'); 380 const needsJsonRender = isJsonIdevice && jsonOnlyIdevices.includes(ideviceType); 381 if (needsJsonRender || ideviceNode.classList.contains('db-no-data')) { 382 // Load template content if we only have filename 383 this.loadTemplateAndRender(ideviceNode, exportIdevice, jsonData, accesibility, templateFilename, idevicePath); 384 } else { 385 // No renderView needed, just behaviour and init 386 exportIdevice.renderBehaviour(jsonData, accesibility); 387 exportIdevice.init(jsonData, accesibility); 388 this.afterIdeviceRendered(ideviceNode); 389 ideviceNode.classList.add('loaded'); 390 setTimeout(() => { ideviceNode.classList.remove('loading') }, 100); 391 } 392 }) 393 }, 394 395 /** 396 * Load template content and render idevice 397 * Templates are loaded from idevicePath + templateFilename 398 */ 399 loadTemplateAndRender: function (ideviceNode, exportIdevice, jsonData, accesibility, templateFilename, idevicePath) { 400 // If template is already full content (contains {content}), use it directly 401 if (templateFilename && templateFilename.includes('{content}')) { 402 this.renderWithTemplate(ideviceNode, exportIdevice, jsonData, accesibility, templateFilename); 403 return; 404 } 405 406 // If we have path and filename, fetch the template 407 if (idevicePath && templateFilename) { 408 const templateUrl = idevicePath + templateFilename; 409 fetch(templateUrl) 410 .then(response => { 411 if (!response.ok) { 412 throw new Error(`Template not found: ${templateUrl}`); 413 } 414 return response.text(); 415 }) 416 .then(templateContent => { 417 this.renderWithTemplate(ideviceNode, exportIdevice, jsonData, accesibility, templateContent); 418 }) 419 .catch(error => { 420 console.warn(`[exe_export] Could not load template: ${error.message}`); 421 // Fallback: render without template (just the generated HTML) 422 this.renderWithTemplate(ideviceNode, exportIdevice, jsonData, accesibility, '{content}'); 423 }); 424 } else { 425 // No template info, use simple wrapper 426 this.renderWithTemplate(ideviceNode, exportIdevice, jsonData, accesibility, '{content}'); 427 } 428 }, 429 430 /**
431 * Render idevice with loaded template content 432 */ 433 renderWithTemplate: function (ideviceNode, exportIdevice, jsonData, accesibility, templateContent) { 434 let htmlIdevice = exportIdevice.renderView(jsonData, accesibility, templateContent); 435 if (htmlIdevice) ideviceNode.innerHTML = htmlIdevice; 436 // Idevice export function 2: renderBehaviour 437 exportIdevice.renderBehaviour(jsonData, accesibility); 438 // Idevice export function 3: init 439 exportIdevice.init(jsonData, accesibility); 440 this.afterIdeviceRendered(ideviceNode); 441 // Loaded 442 ideviceNode.classList.add('loaded'); 443 setTimeout(() => { ideviceNode.classList.remove('loading') }, 100); 444 }, 445 446 /** 447 * Apply the enhancements shared by every iDevice to content an iDevice has just 448 * rendered. 449 * 450 * JSON iDevices build their markup from data long after the page-wide 451 * initialization has run, so rich text they generate (which authors write in 452 * TinyMCE, effects included) would never be processed. This is the export-side 453 * counterpart of the editor's `loadLegacyExeFunctionalitiesExport()`: iDevices 454 * stay unaware of the common libraries, and a new shared enhancement only has to 455 * be registered here. See #2170. 456 * 457 * @param {HTMLElement} ideviceNode 458 */ 459 afterIdeviceRendered: function (ideviceNode) { 460 if (!ideviceNode) return; 461 // Legacy $exe_effects object: initialize the effects inside this iDevice only, 462 // leaving the ones already initialized elsewhere on the page untouched. 463 if (typeof $exeFX !== 'undefined' && typeof $exeFX.init === 'function') { 464 $exeFX.init(ideviceNode); 465 } 466 }, 467 468 /** 469 * Get idevice export object 470 * 471 * @param {*} ideviceType 472 */ 473 getIdeviceObject: function (ideviceType) { 474 let exportIdeviceKey = this.getIdeviceObjectKey(ideviceType); 475 let exportIdevice = window[exportIdeviceKey]; 476 return exportIdevice; 477 }, 478 479 /** 480 * Get idevice export object key 481 * 482 * @param {*} ideviceType 483 */ 484 getIdeviceObjectKey: function (ideviceType) { 485 let exportIdeviceKey = `$${ideviceType.split("-").join("")}`; 486 return exportIdeviceKey; 487 }, 488 489 /** 490 * Add functionality to the boxes toggle button 491 */ 492 addBoxToggleEvent: function () { 493 // Apply i18n text to toggle buttons (translations from common_i18n.js) 494 var toggleText = (typeof $exe_i18n !== 'undefined' && $exe_i18n.toggleContent) 495 ? $exe_i18n.toggleContent 496 : 'Toggle content'; 497 $('article.box .box-head .box-toggle').each(function() { 498 $(this).attr('title', toggleText); 499 $('span', this).text(toggleText); 500 }); 501 502 $('article.box .box-head .box-toggle').on('click', function(){ 503 if ($exeExport.isTogglingBox) return; 504 $exeExport.isTogglingBox = true; 505 let box = $(this).parents('article.box'); 506 if (box.hasClass("minimized")) { 507 box.removeClass('minimized'); 508 $('.box-content', box).slideDown(function(){ 509 $exeExport.isTogglingBox = false; 510 }); 511 } else { 512 $('.box-content', box).slideUp(function(){ 513 box.addClass('minimized'); 514 $exeExport.isTogglingBox = false; 515 }); 516 } 517 }); 518 $('article.box .box-head').has('.box-toggle').css('cursor', 'pointer').on('click', function(e){ 519 let t = $(e.target); 520 if (t.hasClass('box-toggle')) return false; 521 $('.box-toggle', this).trigger('click'); 522 }); 523 524 }, 525 526 /** 527 * Translate navigation buttons using data-i18n attributes 528 * Applies translations from $exe_i18n (loaded from common_i18n.js) 529 */ 530 translateNavButtons: function () { 531 if (typeof $exe_i18n === 'undefined') return; 532 translateI18nElement('previous', 'Previous'); 533 translateI18nElement('next', 'Next'); 534 translateI18nElement('menu', 'Menu'); 535 }, 536 537 /** 538 * Add class to page 539 */ 540 addClassJsExecutedToExeContent: function () { 541 let eXeContent = document.querySelector('.exe-content'); 542 if (eXeContent) { 543 eXeContent.classList.add('post-js'); 544 eXeContent.classList.remove('pre-js'); 545 } 546 }, 547 548 /** 549 * Trigger browser print dialog when `print=1` query parameter is present. 550 */ 551 triggerPrintIfRequested: function () { 552 const params = new URLSearchParams(window.location.search); 553 if (params.get('print') === '1' && typeof window.print === 'function') { 554 window.print(); 555 } 556 } 557} 558} // End of if (typeof window.$exeExport === 'undefined') 559 560// Use local reference for cleaner code 561var $exeExport = window.$exeExport; 562 563// Apply the Teacher Mode reveal as early as possible. This script runs in <head>, so 564// doing it now (before DOMContentLoaded and the first paint) avoids any content flicker. 565try { $exeExport.teacherMode.bootstrap(); } catch (e) { /* student mode is the safe default */ } 566 567$(function () { 568 $exeExport.init(); 569}); 570 571/* To review: This should be in a different file (exe_search.js) */ 572$exeExport.searchBar = { 573 deepLinking : false, 574 markResults : true, // Mark results in list 575 removeAllMarksOnClick : true, // If true, clicking a mark removes all marks; if false, only that one 576 query : '', 577 // Normalize text for search comparison: lowercase and remove diacritical marks 578 normalizeText : function(text) { 579 if (!text) return ''; 580 return text 581 .toLowerCase() 582 .normalize('NFD') 583 .replace(/[\u0300-\u036f]/g, '');
583 // Remove combining diacritical marks 584 }, 585 // Mark search term in text (for search results display) 586 markText : function(text, term) { 587 if (!this.markResults || !text || !term) return text; 588 var normalizedText = this.normalizeText(text); 589 var result = ''; 590 var lastIndex = 0; 591 var index = normalizedText.indexOf(term); 592 while (index !== -1) { 593 result += text.substring(lastIndex, index); 594 result += '<mark class="exe-client-search-result">' + text.substring(index, index + term.length) + '</mark>'; 595 lastIndex = index + term.length; 596 index = normalizedText.indexOf(term, lastIndex); 597 } 598 result += text.substring(lastIndex); 599 return result; 600 }, 601 init : function(){ 602 var searchWrapper = $('#exe-client-search'); 603 if (searchWrapper.length != 1) return; 604 $("body").addClass('exe-search-on'); 605 this.isIndex = $("html").attr('id') == 'exe-index'; 606 // Service Worker preview serves content at /viewer/ path 607 this.isPreview = window.location.pathname.startsWith('/viewer/'); 608 this.createSearchForm(); 609 // Try window.exeSearchData first (from search_index.js), fallback to data-pages attribute 610 if (window.exeSearchData) { 611 this.data = window.exeSearchData; 612 } else { 613 var dataPagesAttr = searchWrapper.attr('data-pages'); 614 if (dataPagesAttr) { 615 this.data = JSON.parse(dataPagesAttr); 616 } 617 } 618 619 let page = document.querySelector('.exe-content > .page'); 620 let searchContainer = document.querySelector('#exe-client-search'); 621 if (searchContainer) { 622 let searchForm = document.querySelector('form#exe-client-search-form'); 623 if (searchForm) { 624 searchForm.addEventListener('submit', event => { 625 event.preventDefault(); 626 let searchText = event.target.querySelector('#exe-client-search-text'); 627 if (searchText) { 628 let valueSearch = searchText.value; 629 let valueSearchTempElement = document.createElement("div"); 630 valueSearchTempElement.innerHTML = valueSearch; 631 valueSearch = valueSearchTempElement.textContent; 632 if (valueSearch) { 633 $exeExport.searchBar.query = valueSearch; 634 this.doSearch(); 635 page.classList.add('exe-client-search-results'); 636 } 637 } 638 }); 639 } 640 let searchHide = document.querySelector('#exe-client-search #exe-client-search-reset'); 641 if (searchHide) { 642 searchHide.addEventListener('click', event => { 643 event.preventDefault(); 644 $("main > header, main div.page-content").show(); 645 $("#exe-client-search-reset").removeClass("visible"); 646 $('#exe-client-search-results-list').html(''); 647 }) 648 } 649 } 650 }, 651 createSearchForm : function(){ 652 if (document.getElementById("exe-client-search-form")) return; 653 let html = ` 654 <form id="exe-client-search-form" action="#" method="GET"> 655 <p> 656 <label for="exe-client-search-text" class="sr-av">${$exe_i18n.search}</label> 657 <input id="exe-client-search-text" type="text" placeholder="${$exe_i18n.search}"> 658 <input id="exe-client-search-submit" type="submit" value="${$exe_i18n.search || 'Search'}"> 659 <a id="exe-client-search-reset" href="#main" title="${$exe_i18n.hide}"><span>${$exe_i18n.hide}</span></a> 660 </p> 661 </form> 662 `; 663 $("#exe-client-search").prepend(html); 664 html = ` 665 <div id="exe-client-search-results"> 666 <div id="exe-client-search-results-list"> 667 </div> 668 </div> 669 `; 670 $("#exe-client-search").append(html); 671 }, 672 doSearch : function(){ 673 this.results = []; 674 var i; 675 let res = ''; 676 let str = $exeExport.searchBar.query; 677 str = this.normalizeText(str); 678 let data = this.data; 679 for (i in data) { 680 var node = data[i]; 681 var nodeTitle = node.name; 682 var nodetitle = this.normalizeText(nodeTitle); 683 if (nodetitle.indexOf(str) != -1) { 684 this.results.push(i); 685 let lnk = this.getLink(node.fileUrl); 686 lnk = this.addSearchParam(lnk); 687 let displayTitle = this.markText(nodeTitle, str); 688 res += '<li><a href="' + lnk +'">' + displayTitle + '</a><span> ' + this.searchInBlocks(i, str, false) + '</span></li>'; 689 } else { 690 res += this.searchInBlocks(i, str, true); 691 } 692 } 693 if (res != '') { 694 res = '<ul>' + res + '</ul>'; 695 } else { 696 res = '<p>'+$("#exe-client-search").attr("data-no-results-string")+'</p>'; 697 } 698 $("#exe-client-search-results-list").html(res); 699 $("main > header, main div.page-content").hide(); 700 $("#exe-client-search-reset").addClass("visible"); 701 this.checkBlockLinks(); 702 }, 703 getLink : function(lnk){ 704 if (this.isPreview) { 705 // Check if we're on a subpage (/viewer/html/*) 706 var currentPath = window.location.pathname; 707 var isOnSubpage = currentPath.indexOf('/html/') !== -1; 708 709 if (isOnSubpage) { 710 // From /viewer/html/current.html, need to go up one level 711 // html/page.html â ../html/page.html 712 // index.html â ../index.html 713 if (lnk.indexOf('../') !== 0 && lnk.indexOf('/') !== 0) { 714 return '../' + lnk; 715 } 716 } 717 return lnk; 718 } 719 if (!this.isIndex) { 720 lnk = lnk.replace('html/',''); 721 if (lnk == 'index.html') lnk = '../' + lnk; 722 } 723 return lnk; 724 }, 725 checkBlockLinks : function(){ 726 let spans = $("#exe-client-search-results-list span"); 727 if (this.deepLinking) { 728 spans.each(function(){ 729 var e = $(this); 730 var h = e.html(); 731 if (h != " ") { 732 h = h.replace(', ', '('); 733 if (!h.endsWith(')')) h = h + ')'; 734 e.html(h); 735 } else { 736 e.remove(); 737 } 738 }); 739 } else { 740 spans.remove(); 741 } 742 $("#exe-client-search-results-list a").on("click", function(){ 743 if (!$("#siteNav").is(":visible")) { 744 // Use & if URL already has parameters, otherwise use ? 745 var separator = this.href.indexOf('?') !== -1 ? '&' : '?'; 746 this.href += separator + 'nav=false'; 747 } 748 // Close search box and restore page content 749 $("main > header, main div.page-content").show(); 750 $("#exe-client-search-reset").removeClass("visible"); 751 $('#exe-client-search-results-list').html(''); 752 $('#exe-client-search').hide(); 753 $('#exe-client-search-text').val(''); 754 }); 755 }, 756 searchInBlocks : function(i, str, fullLink) { 757 if (this.deepLinking == false && this.results.indexOf(i) != -1) { 758 return ''; 759 } 760 var x, z; 761 let res = ''; 762 var node = this.data[i]; 763 var nodeTitle = node.name; 764 var boxes = node.blocks; 765 var boxCounter = 0; 766 for (x in boxes) { 767 boxCounter ++; 768 } 769 var localBoxOrder = 0; 770 var pageLinked = false;
771 for (x in boxes) { 772 localBoxOrder++; 773 var box = boxes[x]; 774 var boxOrder = localBoxOrder; 775 var boxTitle = box.name; 776 var boxtitle = this.normalizeText(boxTitle); 777 778 // Add the HTML of the iDevices to boxtitle so it searches there too 779 var iDevices = box.idevices; 780 for (z in iDevices) { 781 var iDevice = iDevices[z]; 782 if (typeof(iDevice.htmlView) == 'string') { 783 var iDeviceHTML = iDevice.htmlView; 784 iDeviceHTML = iDeviceHTML.replace(/<\/?[^>]+(>|$)/g, ""); 785 var tmp = $("<div></div>"); 786 tmp.html(iDeviceHTML); 787 var iDeviceText = tmp.text(); 788 iDeviceText = this.normalizeText(iDeviceText); 789 boxtitle += ' ' + iDeviceText; 790 } 791 } 792 793 if (boxtitle.indexOf(str) != -1) { 794 this.results.push(i); 795 let lnk = this.getLink(node.fileUrl); 796 var blockLabel = (typeof $exe_i18n !== 'undefined' && $exe_i18n.block) ? $exe_i18n.block : 'block'; 797 if (fullLink) { 798 if (this.deepLinking) { 799 lnk += '#' + x; 800 lnk = this.addSearchParam(lnk); 801 let displayTitle = this.markText(nodeTitle, str); 802 res += '<li><a href="' + lnk+ '">' + displayTitle + '</a>'; 803 if (boxCounter > 1) res += '<span> (' + blockLabel + ' ' + boxOrder + ')</span></li>'; 804 } else if (!pageLinked) { 805 lnk = this.addSearchParam(lnk); 806 let displayTitle = this.markText(nodeTitle, str); 807 res += '<li><a href="' + lnk+ '">' + displayTitle + '</a></li>'; 808 pageLinked = true; 809 } 810 } 811 else { 812 var blockLnk = lnk +'#' + x; 813 blockLnk = this.addSearchParam(blockLnk); 814 if (boxCounter > 1) res += ', <a href="' + blockLnk + '">' + blockLabel + ' ' + boxOrder + '</a>'; 815 } 816 } 817 } 818 return res; 819 }, 820 821 // Add search parameter to a link 822 addSearchParam : function(lnk) { 823 if (!this.query) return lnk; 824 var searchParam = encodeURIComponent(this.query); 825 // Handle hash 826 var hashIndex = lnk.indexOf('#'); 827 var hash = ''; 828 if (hashIndex !== -1) { 829 hash = lnk.substring(hashIndex); 830 lnk = lnk.substring(0, hashIndex); 831 } 832 // Add parameter 833 if (lnk.indexOf('?') !== -1) { 834 lnk += '&q=' + searchParam; 835 } else { 836 lnk += '?q=' + searchParam; 837 } 838 return lnk + hash; 839 }, 840 841 // Check URL for search parameter and highlight matches 842 highlightFromUrl : function() { 843 var params = new URLSearchParams(window.location.search); 844 var searchTerm = params.get('q'); 845 if (searchTerm) { 846 this.markSearchResults(searchTerm); 847 } 848 }, 849 850 // Mark search results in the page content 851 markSearchResults : function(term) { 852 var self = this; 853 var normalizedTerm = this.normalizeText(term); 854 if (!normalizedTerm) return; 855 856 // Tags where we should not search for text 857 var excludeTags = ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'NOSCRIPT', 'IFRAME', 'MARK', 'SVG', 'CODE', 'PRE']; 858 859 var container = document.querySelector('.exe-content') || document.body; 860 861 // Use TreeWalker to find text nodes 862 var walker = document.createTreeWalker( 863 container, 864 NodeFilter.SHOW_TEXT, 865 { 866 acceptNode: function(node) { 867 // Check if any ancestor is in the excludeTags 868 var parent = node.parentNode; 869 while (parent && parent !== container) { 870 if (excludeTags.indexOf(parent.tagName) !== -1) { 871 return NodeFilter.FILTER_REJECT; 872 } 873 parent = parent.parentNode; 874 } 875 // Skip empty or whitespace-only nodes 876 if (!node.textContent.trim()) { 877 return NodeFilter.FILTER_REJECT; 878 } 879 return NodeFilter.FILTER_ACCEPT; 880 } 881 } 882 ); 883 884 // Collect nodes to process 885 var nodesToProcess = []; 886 while (walker.nextNode()) { 887 var normalizedContent = self.normalizeText(walker.currentNode.textContent); 888 if (normalizedContent.indexOf(normalizedTerm) !== -1) { 889 nodesToProcess.push(walker.currentNode); 890 } 891 } 892 893 // Process each text node 894 nodesToProcess.forEach(function(textNode) { 895 var text = textNode.textContent; 896 var normalizedText = self.normalizeText(text); 897 var termLen = normalizedTerm.length; 898 var fragment = document.createDocumentFragment(); 899 var lastIndex = 0; 900 var index = normalizedText.indexOf(normalizedTerm); 901 902 while (index !== -1) { 903 // Text before the match 904 if (index > lastIndex) { 905 fragment.appendChild(document.createTextNode(text.substring(lastIndex, index))); 906 } 907 908 // The match (use original text to preserve accents) 909 var mark = document.createElement('mark'); 910 mark.className = 'exe-client-search-result'; 911 mark.textContent = text.substring(index, index + termLen); 912 fragment.appendChild(mark); 913 914 lastIndex = index + termLen; 915 index = normalizedText.indexOf(normalizedTerm, lastIndex); 916 } 917 918 // Remaining text after last match 919 if (lastIndex < text.length) { 920 fragment.appendChild(document.createTextNode(text.substring(lastIndex))); 921 } 922 923 textNode.parentNode.replaceChild(fragment, textNode); 924 }); 925 926 // Add click event to remove marks 927 document.addEventListener('click', function(e) { 928 if (e.target.matches && e.target.matches('mark.exe-client-search-result')) { 929 if ($exeExport.searchBar.removeAllMarksOnClick) { 930 // Remove all marks 931 var marks = document.querySelectorAll('mark.exe-client-search-result'); 932 marks.forEach(function(mark) { 933 var text = document.createTextNode(mark.textContent); 934 mark.parentNode.replaceChild(text, mark); 935 }); 936 } else { 937 // Remove only the clicked mark 938 var mark = e.target; 939 var text = document.createTextNode(mark.textContent); 940 mark.parentNode.replaceChild(text, mark); 941 } 942 } 943 }); 944 } 945}; 946 947$(function(){
948 $exeExport.searchBar.init(); 949});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.