PageSourceSearch

https://algarcia-ujaen.github.io/libs/exe_export.js

js algarcia-ujaen.github.io collected 2026-10-03 08:36:09 UTC 39,955 bytes, 949 lines download raw bytes

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.