UNPKG

chappe

Version:

Developer Docs builder. Write guides in Markdown and references in API Blueprint. Comes with a built-in search engine.

1,788 lines (1,481 loc) 59.6 kB
/* * chappe * * Copyright 2021, Crisp IM SAS * Author: Valerian Saliou <valerian@valeriansaliou.name> */ /** * Common * @class * @classdesc Common class. */ class Common { /** * Constructor */ constructor() { let fn = "constructor"; try { this.ns = "Common"; this._$ = $; // Selectors this._document_sel = this._$(document); // Configuration this.__revision = "@:revision"; this.__url_status = "@:url_status"; this.__search_index_path = "@:search_index"; this.__search_index_options_base = "@:search_options_base"; this.__search_index_options_query = "@:search_options_query"; this.__second_in_milliseconds = 1000; // 1 second this.__copy_state_confirm_delay = 2000; // 2 seconds this.__content_anchor_viewed_delay = 100; // 1/10 second this.__status_poll_interval = 5000; // 5 seconds this.__status_poll_refresh = 90000; // 90 seconds this.__chatbox_z_index = 120; this.__search_results_limit = 12; this.__cookie_prefix = "chappe/"; this.__status_known_health = [ "healthy", "sick", "dead" ]; // Instances this.__escape_html_text_rules = { "&amp;" : /&/g, "&lt;" : /</g, "&gt;" : />/g, "&quot;" : /"/g }; // Storage this.__crisp_chat_feedback_shown = false; this.__search_opened = false; this.__appearance_mode = "light"; this.__content_anchor_viewed_timeout = null; this.__status_poll_scheduler = null; this.__search_index_responder = null; this.__search_index_load_pending = null; this.__search_field_value_last = ""; this.__status_poll_health = null; this.__status_poll_last_check = 0; } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Initializes the class * @public * @return {undefined} */ init() { let fn = "init"; try { this.__chatbox(); this.__options(); this.__events(); this.__schedules(); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Configures the chatbox * @private * @return {undefined} */ __chatbox() { let fn = "__chatbox"; try { // Adjust chatbox z-index? (if chatbox is included) if (typeof window.$crisp !== "undefined") { window.$crisp.push([ "config", "container:index", this.__chatbox_z_index ]); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Setups all user options * @private * @return {undefined} */ __options() { let fn = "__options"; try { // Restore appearance options this.__toggle_appearance( this.__detect_appearance_preference() //-[mode] ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds all events * @private * @return {undefined} */ __events() { let fn = "__events"; try { // Bind common events this.__bind_copy_click(); // Bind search events this.__bind_search_open_keydown(); this.__bind_search_open_click(); this.__bind_search_close_click(); this.__bind_search_field_keyup(); // Bind appearance events this.__bind_appearance_toggle_click(); // Bind sidebar events this.__bind_sidebar_toggler_click(); this.__bind_sidebar_nest_level_toggle_click(); // Bind content events this.__bind_content_anchor_viewed(); // Bind code events this.__bind_code_metas_picker_change(); this.__bind_code_block_viewed(); // Bind Markdown events this.__bind_markdown_embed_click(); // Bind chatbox events this.__bind_crisp_chat_open_click(); this.__bind_crisp_chat_feedback_click(); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds all schedules * @private * @return {undefined} */ __schedules() { let fn = "__schedules"; try { // Bind all schedules this.__bind_status_poll_schedule(); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Reads cookie * @private * @param {string} cookie_key * @return {string} Cookie value (if any) */ __read_cookie(cookie_key) { let fn = "__read_cookie"; let _cookie_value; try { _cookie_value = Cookies.get( (this.__cookie_prefix + cookie_key) ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return _cookie_value; } } /** * Writes cookie * @private * @param {string} cookie_key * @param {string} cookie_value * @return {undefined} */ __write_cookie(cookie_key, cookie_value) { let fn = "__write_cookie"; try { Cookies.set( (this.__cookie_prefix + cookie_key), cookie_value, { domain : location.hostname, expires : Infinity, sameSite : "strict" } ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Copies text (from element) * @private * @param {object} text_el * @return {undefined} */ __copy_text(text_el) { let fn = "__copy_text"; let _was_copied = false; try { // Select text (clear out existing selections first) let _range = document.createRange(); _range.selectNode(text_el); window.getSelection().removeAllRanges(); window.getSelection().addRange(_range); // Copy text document.execCommand("copy"); window.getSelection().removeAllRanges(); _was_copied = true; } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return _was_copied; } } /** * Escapes text (to be included in HTML) * @private * @param {string} text_raw * @return {string} Escaped text */ __escape_html_text(text_raw) { let fn = "__escape_html_text"; let _text_safe = ""; try { let _text_buffer = text_raw; // Apply all escape rules to text for (let _value in this.__escape_html_text_rules) { let _rule = this.__escape_html_text_rules[_value]; _text_buffer = _text_buffer.replace(_rule, _value); } _text_safe = _text_buffer; } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return _text_safe; } } /** * Opens up search * @private * @return {undefined} */ __open_search() { let fn = "__open_search"; try { if (this.__search_opened !== true) { this.__search_opened = true; // Open search let _search_sel = this._$("#search"), _input_sel = _search_sel.find(".spotlight-input"); _search_sel.css("display", "block"); // Focus on search input? if (_input_sel.length > 0) { _input_sel[0].focus(); } // Ensure that search index is loaded? (if not already loaded, or not \ // already loading) this.__ensure_load_search_index(_search_sel); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Closes search * @private * @param {boolean} [do_reset] * @return {undefined} */ __close_search(do_reset=false) { let fn = "__close_search"; try { if (this.__search_opened === true) { this.__search_opened = false; // Close search let _search_sel = this._$("#search"); _search_sel.css("display", "none"); // Reset search value and results? if (do_reset === true) { let _spotlight_sel = _search_sel.find(".spotlight"), _input_sel = _spotlight_sel.find(".spotlight-input"), _entries_sel = _spotlight_sel.find(".spotlight-entries"); // Reset input value this.__search_field_value_last = ""; _input_sel.val(""); // Reset search results _entries_sel.empty(); _spotlight_sel.attr("data-has-results", "false"); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Toggles selected search result * @private * @param {object} results_all_sel * @param {number} [increment] * @return {undefined} */ __toggle_search_result_selected(results_all_sel, increment=1) { let fn = "__toggle_search_result_selected"; try { // Any result? if (results_all_sel.length > 0) { // Find the index of the active result let _index = -1; for (let _i = 0; _i < results_all_sel.length; _i++) { if (results_all_sel[_i].getAttribute("data-selected") === "true") { _index = _i; break; } } // Process the index of the next element to activate _index += increment; if (_index < 0) { _index = (results_all_sel.length - 1); } else if (_index >= results_all_sel.length) { _index = 0; } // Select target element? if (_index > -1) { let _result_selected_el = results_all_sel[_index]; // Set selected state to selected element results_all_sel.removeAttr("data-selected"); this._$(_result_selected_el).attr("data-selected", "true"); // Scroll to selected element _result_selected_el.scrollIntoView({ behavior : "smooth", block : "nearest" }); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Proceeds search query * @private * @param {object} spotlight_sel * @param {object} results_sel * @param {object} entries_sel * @param {string} [search_query] * @return {undefined} */ __proceed_search_query( spotlight_sel, results_sel, entries_sel, search_query="" ) { let fn = "__proceed_search_query"; try { // Initialize 'has results' state let _has_results = false; // Acquire search results if (search_query) { // Responder is not available? Throw an error, as this should \ // never happen. if (this.__search_index_responder === null) { throw new Error( "Search index responder is not available (yet?)" ); } // Obtain search results from index responder let _search_results = ( this.__search_index_responder.search(search_query) ); if (_search_results.length > 0) { _has_results = true; // Limit results to N first results if (_search_results.length > this.__search_results_limit) { _search_results = _search_results.slice( 0, this.__search_results_limit ); } // Inject search results this.__inject_search_results( results_sel, entries_sel, _search_results ); } } // Mark as having results (or not) spotlight_sel.attr( "data-has-results", ((_has_results === true) ? "true" : "false") ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Ensures that search index is loaded * @private * @param {object} search_sel * @return {undefined} */ __ensure_load_search_index(search_sel) { let fn = "__ensure_load_search_index"; try { // Not already loaded, and load not already pending? if (this.__search_index_responder === null && this.__search_index_load_pending === null) { // Select spotlight element let _spotlight_sel = search_sel.find(".spotlight"); // Mark as pending (initialize pending operations stack) this.__search_index_load_pending = []; // Reset any previously-set error state _spotlight_sel.removeAttr("data-index-error"); // Load index data from network fetch( `/static/data/${this.__search_index_path}?${this.__revision}`, { mode : "same-origin" } ) .then((response) => { // Non-success response? if (response.status >= 400) { return Promise.reject(null); } // Examine the text in the response return response.text(); }) .then((index_data) => { // Build search index responder (this parses from text JSON) this.__search_index_responder = MiniSearch.loadJSON( index_data, Object.assign( {}, this.__search_index_options_base, this.__search_index_options_query ) ); // Handle all pending operations while (this.__search_index_load_pending.length > 0) { this.__search_index_load_pending.shift()(); } return Promise.resolve(); }) .catch(() => { // Show index loading error _spotlight_sel.attr("data-index-error", "true"); // Pass-through (ignore for this session) return Promise.resolve(); }) .then(() => { // Hide load spinner (as all deferred actions have been fired, \ // if index was successfully loaded) _spotlight_sel.removeAttr("data-index-loading"); // Mark as not pending anymore this.__search_index_load_pending = null; }); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Toggles sidebar nest level * @private * @param {object} target_sel * @param {boolean} [is_anchor] * @return {undefined} */ __toggle_sidebar_nest_level(target_sel, is_anchor=false) { let fn = "__toggle_sidebar_nest_level"; try { let _parent_sel = ( target_sel.parents(".nest-navigate-level") ); if (_parent_sel) { let _new_state = ( (_parent_sel.attr("data-expanded") === "true") ? "false" : "true" ); // Unexpand all other expanded levels first? (only if new state is \ // expanded) // Notice #1: we do not want to allow multiple levels to be expanded \ // at the same time, as this can consume quite a large amount of \ // browser height and clutter the UI. // Notice #2: only when click originates from a viewed anchor. if (_new_state === "true" && is_anchor === true) { let _expanded_all_sel = this._$( "#content .sidebar .nest .nest-navigate-level" + "[data-expanded=\"true\"]" ); _expanded_all_sel.attr("data-expanded", "false"); } // Toggle expanded state on selected level _parent_sel.attr("data-expanded", _new_state); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Toggles appearance mode * @private * @param {string} [new_mode] * @return {undefined} */ __toggle_appearance(new_mode=null) { let fn = "__toggle_appearance"; try { if (new_mode !== null && this.__appearance_mode !== new_mode) { // Store new appearance mode (now current mode) this.__appearance_mode = new_mode; // Update current appearance mode (in toggle) this._$("#header .coloring").attr("data-mode", new_mode); // Update dark mode in document document.body.setAttribute("data-appearance", new_mode); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Detects appearance preference * @private * @return {string} Detected appearance preference */ __detect_appearance_preference() { let fn = "__detect_appearance_preference"; let _mode = null; try { // Attempt to detect mode from cookies? (user override) let _mode_cookies = ( this.__read_cookie("appearance-mode") || null ); if (_mode_cookies !== null) { _mode = _mode_cookies; // Abort detection there. return; } // Attempt to detect mode from media query? (operating system) if (typeof window.matchMedia === "function" && (window.matchMedia("(prefers-color-scheme: dark)").matches === true)) { _mode = "dark"; // Abort detection there. return; } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return (_mode || "light"); } } /** * Refreshes code metas picker section * @private * @param {string} direction * @param {object} code_box_sel * @param {object} section_sel * @param {string} selected_value * @param {object} code_chunk * @return {undefined} */ __refresh_code_metas_picker_section( direction, code_box_sel, section_sel, selected_value, code_chunk ) { let fn = "__refresh_code_metas_picker_section"; try { if (section_sel.length > 0 && selected_value) { // Acquire selectors let _type_sel = section_sel.find(".code-meta-type"), _content_sel = section_sel.find(".code-content"); if (_type_sel.length > 0 && _content_sel.length > 0) { // Update code type _type_sel.text(code_chunk.type); // Inject code block? if (code_chunk.data) { // Generate the list of classes on code element let _class_list = []; if (code_chunk.type) { _class_list.push(`language-${code_chunk.type.toLowerCase()}`); } if (direction === "request") { _class_list.push("copy-value"); } let _code_class_spaced = ( (_class_list.length === 0) ? "" : ` class="${_class_list.join(" ")}"` ); _content_sel.html( `<code${_code_class_spaced}></code>` ); // Select newly-injected code block let _code_block_sel = _content_sel.find("code"); if (_code_block_sel.length > 0) { // Update code content _code_block_sel.text(code_chunk.data); // Re-apply code coloring _code_block_sel[0].setAttribute("data-viewed", ""); window.Prism.highlightElement(_code_block_sel[0]); } } else { // Replace w/ empty placeholder _content_sel.html("—"); } // Update 'has request' marker? (only if direction is 'request') if (direction === "request") { code_box_sel.attr( "data-has-request", (code_chunk.data ? "true" : "false") ); } } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Schedules the handling of content anchor viewed event * @private * @param {object} link_all_sel * @param {object} sidebar_left_sel * @param {string} anchor_id * @param {number} [header_height] * @return {undefined} */ __schedule_handle_content_anchor_viewed( link_all_sel, sidebar_left_sel, anchor_id, header_height=0 ) { let fn = "__schedule_handle_content_anchor_viewed"; try { // Cancel last scheduled update? if (this.__content_anchor_viewed_timeout !== null) { clearTimeout(this.__content_anchor_viewed_timeout); } // Schedule next update (defer) this.__content_anchor_viewed_timeout = setTimeout(() => { this.__content_anchor_viewed_timeout = null; // Trigger content anchor viewed event handler this.__handle_content_anchor_viewed( link_all_sel, sidebar_left_sel, anchor_id, header_height ); }, this.__content_anchor_viewed_delay); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Handles content anchor viewed event * @private * @param {object} link_all_sel * @param {object} sidebar_left_sel * @param {string} anchor_id * @param {number} [header_height] * @return {undefined} */ __handle_content_anchor_viewed( link_all_sel, sidebar_left_sel, anchor_id, header_height=0 ) { let fn = "__handle_content_anchor_viewed"; try { let _anchor_active_el = ( (link_all_sel.filter(`[data-anchor="#${anchor_id}"]`) || [])[0] || null ); if (_anchor_active_el !== null) { let _anchor_active_sel = this._$(_anchor_active_el); // Swap active attributes to active anchor link link_all_sel.removeAttr("data-active"); _anchor_active_sel.attr("data-active", "true"); // If active link belongs to a slice parent, then auto-expand this \ // slice let _level_active_sel = ( _anchor_active_sel.parents(".nest-navigate-level--first").first() ); if (_level_active_sel.length > 0 && _level_active_sel.attr("data-expanded") !== "true") { // Trigger a virtual click event to toggle visibility let _toggle_active_sel = _level_active_sel.find( ".nest-navigate-link--slice .nest-navigate-toggle" ); if (_toggle_active_sel.length > 0) { // Toggle sidebar nest level this.__toggle_sidebar_nest_level( _toggle_active_sel, true //-[is_anchor] ); } } // Ensure that active link is into view (otherwise, scroll to link) // Important: do not use 'smooth' scrolling, as we need the scroll \ // position change to be instant. _anchor_active_el.scrollIntoView({ behavior : "auto", block : "nearest" }); // Element is hidden below header? Scroll a bit up once more (note \ // that this only applies when scrolling upwards) let _bounding_box = _anchor_active_el.getBoundingClientRect(), _bounding_top = Math.floor(_bounding_box.top || 0.0); if (_bounding_top < header_height) { sidebar_left_sel[0].scrollTop -= (header_height - _bounding_top); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Handles code metas picker change event * @private * @param {object} code_box_sel * @return {undefined} */ __handle_code_metas_picker_change(code_box_sel) { let fn = "__handle_code_metas_picker_change"; try { // Acquire code data let _code_data = ( JSON.parse(code_box_sel.find(".code-data").val() || "{}") ); // Select request and response targets let _section_request_sel = ( code_box_sel.find(".code-section.code-section--request") ); let _section_response_sel = ( code_box_sel.find(".code-section.code-section--response") ); // Acquire selected values let _selected_request = ( _section_request_sel.find(".code-meta-picker select").val() ); let _selected_response = ( _section_response_sel.find(".code-meta-picker select").val() ); // Acquire code chunk associated to selected request let _code_chunk = (_code_data[_selected_request] || null); // Re-compute code section for request? if (_code_chunk !== null) { this.__refresh_code_metas_picker_section( "request", code_box_sel, _section_request_sel, _selected_request, _code_chunk.request ); // Acquire code chunk associated to selected response let _code_response = ( _code_chunk.responses[parseInt(_selected_response, 10)] || null ); // Re-compute code section for response? if (_code_response !== null) { this.__refresh_code_metas_picker_section( "response", code_box_sel, _section_response_sel, _selected_response, _code_response ); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Handles code block viewed event * @private * @param {object} target_sel * @return {undefined} */ __handle_code_block_viewed(target_sel) { let fn = "__handle_code_block_viewed"; try { // Load full code box parent element? (if any) // Notice: only if it was not already previously loaded, also note that \ // this only applies to 'pre' element wrapped in a '.code' box \ // wrapper. Not all 'pre' are being wrapped as such, therefore this \ // is not necessary in all cases. let _code_box_sel = ( target_sel.parents(".code[data-was-loaded=\"false\"]").first() ); if (_code_box_sel.length > 0) { _code_box_sel[0].setAttribute("data-was-loaded", "true"); // Trigger the first code box picker 'change' event to load default \ // code into view. this.__handle_code_metas_picker_change(_code_box_sel); } // Highlight code? Do not re-apply if already applied before. let _code_el = ( target_sel.find("code[class*=\"language-\"]")[0] || null ); if (_code_el !== null && _code_el.hasAttribute("data-viewed") !== true) { _code_el.setAttribute("data-viewed", ""); window.Prism.highlightElement(_code_el); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Handles Crisp chat open click event * @private * @return {boolean} False value (prevents default click event) */ __handle_crisp_chat_open_click() { let fn = "__handle_crisp_chat_open_click"; try { if (typeof window.$crisp !== "undefined") { window.$crisp.do("chat:open"); } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return false; } } /** * Handles Crisp chat feedback click event * @private * @return {boolean} False value (prevents default click event) */ __handle_crisp_chat_feedback_click() { let fn = "__handle_crisp_chat_feedback_click"; try { if (typeof window.$crisp !== "undefined") { // Open chatbox window.$crisp.do("chat:open"); // Show feedback message? (if not already shown in current session) if (this.__crisp_chat_feedback_shown !== true) { this.__crisp_chat_feedback_shown = true; window.$crisp.push([ "do", "message:show", [ "text", ( `Do you have feedback on this page? ` + `Please reply to this message with your comments! :)` + `\n\n` + `➡️ ${document.location.href}` ) ] ]); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } finally { return false; } } /** * Injects search results * @private * @param {object} results_sel * @param {object} entries_sel * @param {object} results * @return {undefined} */ __inject_search_results(results_sel, entries_sel, results) { let fn = "__inject_search_results"; try { // Generate results HTML let _results_html = ""; for (let _i = 0; _i < results.length; _i++) { let _result = results[_i]; // Generate result path HTML let _path_html = "", _path_chunks = _result.path.split(" > "); for (let _j = 0; _j < _path_chunks.length; _j++) { _path_html += ( this.__escape_html_text(_path_chunks[_j]) ); // Append separator? if (_path_chunks.length > 1 && _j < (_path_chunks.length - 1)) { _path_html += ( "<span class=\"spotlight-entry-separator\"></span>" ); } } // Acquire entry type let _type = ( (_result.id.includes("#") === true) ? "anchor" : "page" ); // Append whole result HTML to results HTML _results_html += ( `<li class="spotlight-entry spotlight-entry--${_type}">` + `<a class="spotlight-entry-link" href="${_result.id}">` + `<span class="spotlight-entry-path font-sans-semibold">` + _path_html + `</span>` + `<h6 class="spotlight-entry-title font-sans-bold">` + this.__escape_html_text(_result.title) + `</h6>` + ( _result.summary ? ( `<p class="spotlight-entry-preview">` + this.__escape_html_text(_result.summary) + `</p>` ) : "" ) + `</a>` + `</li>` ); } // Inject generated HTML entries_sel.html(_results_html); // Select results let _results_sel = entries_sel.find(".spotlight-entry"); // Select first result (if any) _results_sel.first().attr("data-selected", "true"); // Make sure that scroll position is restored to top of scroll area if (results_sel.length > 0) { results_sel[0].scrollTop = 0; } // Bind mouse enter + mouse leave events on new results _results_sel.on("mouseenter", (event) => { try { _results_sel.removeAttr("data-selected"); this._$(event.target).attr("data-selected", "true"); } catch (_error) { Console.error(`${this.ns}.${fn}:mouseenter`, _error); } }); // Bind click event on new results (just to auto-hide search in case an \ // anchor gets clicked) _results_sel.on("click", () => { try { // Close search immediately this.__close_search( true //-[do_reset] ); } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Fetches status health (based on provider) * @private * @param {string} provider * @param {string} target * @return {undefined} */ __fetch_status_health(provider, target) { switch (provider) { case "vigil": { // Vigil provider return fetch(`${target}/status/text/`, { mode : "cors" }) .then((response) => { // Non-success response? if (response.status !== 200) { return Promise.reject(null); } // Examine the text in the response return response.text(); }); } case "crisp": { // Crisp Status provider return fetch(`${target}/includes/report/`, { mode : "cors" }) .then((response) => { // Non-success response? if (response.status !== 200) { return Promise.reject(null); } // Examine the JSON in the response return response.json(); }) .then((report) => { // Extract health from report return Promise.resolve(report.health); }); } default: { // Provider unknown, hard-fail return Promise.reject(null); } } } /** * Refreshes status indicator * @private * @param {object} status_sel * @param {object} seconds_sel * @return {undefined} */ __refresh_status_indicator(status_sel, seconds_sel) { let fn = "__refresh_status_indicator"; try { // Refresh status status_sel.attr( "data-status", (this.__status_poll_health || "none") ); // Refresh 'last checked since' seconds seconds_sel.text( `${(this.__status_poll_last_check || 1)}` ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds search open keydown event * @private * @return {undefined} */ __bind_search_open_keydown() { let fn = "__bind_search_open_keydown"; try { const _KEY_CODE_K = 75, _KEY_CODE_ESCAPE = 27, _KEY_CODE_ENTER = 13, _KEY_CODE_ARROW_UP = 38, _KEY_CODE_ARROW_DOWN = 40; window.addEventListener("keydown", (event) => { switch (event.keyCode) { // 'K' pressed? case _KEY_CODE_K: { // Check if 'CTRL' is pressed (or 'COMMAND' on Mac computers) let _has_control = ( ((navigator.platform || "").toLowerCase().indexOf("mac") !== -1) ? event.metaKey : event.ctrlKey ); if (_has_control === true) { if (this.__search_opened !== true) { // Block 'CTRL + K' event default behavior (otherwise, browser \ // URL search prompt will be shown) event.preventDefault(); // Open search this.__open_search(); } else { // Close search // Notice: this will show the browser default URL search \ // prompt, letting the users press 'CTRL + K' twice quickly \ // if they want to perform a regular browser URL search. this.__close_search(); } } break; } // 'ESCAPE' pressed? case _KEY_CODE_ESCAPE: { if (this.__search_opened === true) { // Block event default behavior event.preventDefault(); // Close search this.__close_search(); } break; } // 'ENTER' pressed? case _KEY_CODE_ENTER: { if (this.__search_opened === true) { // Block event default behavior event.preventDefault(); // Select selected result let _result_selected_sel = this._$( "#search .spotlight-entry[data-selected=\"true\"] " + ".spotlight-entry-link" ); // Open selected search result? if (_result_selected_sel.length > 0) { let _target_url = _result_selected_sel.first().attr("href"); if (_target_url) { // Close search immediately this.__close_search( true //-[do_reset] ); // Split hash part from target URL? let _target_url_parts = _target_url.split("#"); // Navigate to URL? (if different than current one) if (_target_url_parts[0] !== document.location.pathname) { // Navigate to new full URL document.location.href = _target_url; } else if (_target_url_parts[1]) { // Navigate to new hash (or same) document.location.hash = `#${_target_url_parts[1]}`; } else if ((document.location.hash || "").length > 1) { // Reset current hash document.location.hash = ""; } } } } break; } // 'ARROW UP' or 'ARROW DOWN' pressed? case _KEY_CODE_ARROW_UP: case _KEY_CODE_ARROW_DOWN: { if (this.__search_opened === true) { // Block event default behavior event.preventDefault(); // Select search results let _results_all_sel = this._$("#search .spotlight-entry"); // Compute increment let _increment = ( (event.keyCode === _KEY_CODE_ARROW_DOWN) ? 1 : -1 ); // Select next or previous search result this.__toggle_search_result_selected( _results_all_sel, _increment ); } break; } } }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds search open click event * @private * @return {undefined} */ __bind_search_open_click() { let fn = "__bind_search_open_click"; try { this._$("#header .search").on( "click", this.__open_search.bind(this) ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds search close click event * @private * @return {undefined} */ __bind_search_close_click() { let fn = "__bind_search_close_click"; try { this._$("#search").on("click", (event) => { try { if (event.target) { let _target_sel = this._$(event.target); // Click away from spotlight box? Close search. if (_target_sel.is("#search .spotlight") === true) { this.__close_search(); } } } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds search field keyup event * @private * @return {undefined} */ __bind_search_field_keyup() { let fn = "__bind_search_field_keyup"; try { // Select all targets let _spotlight_sel = this._$("#search .spotlight"), _input_sel = _spotlight_sel.find(".spotlight-input"), _results_sel = _spotlight_sel.find(".spotlight-results"), _entries_sel = _results_sel.find(".spotlight-entries"); // Initialize last search value _input_sel.on("keyup", (event) => { try { // Acquire normalized search query value let _field_value = ( (event.target.value || "").trim().toLowerCase() ); if (_field_value !== this.__search_field_value_last) { // Retain last search value this.__search_field_value_last = _field_value; // Define proceed function let _fnProceed = () => { this.__proceed_search_query( _spotlight_sel, _results_sel, _entries_sel, _field_value ); }; // Proceed immediately? (pending stack is not defined) if (this.__search_index_load_pending === null) { // Proceed immediately _fnProceed(); } else { // Show load spinner (as search was deferred) _spotlight_sel.attr("data-index-loading", "true"); // Stack for later (note: reset stack, as we do not want any \ // previous operation from being processed, only the last one) this.__search_index_load_pending = [_fnProceed]; } } } catch (_error) { Console.error(`${this.ns}.${fn}:keyup`, _error); } }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds copy click event * @private * @return {undefined} */ __bind_copy_click() { let fn = "__bind_copy_click"; try { // Select all copy targets let _click_all_sel = this._$(".copy-button"); // Bind all click events _click_all_sel.each((click_el) => { let _state_confirm_timeout = null; // Notice: use 'addEventListener' instead of '.on' as this is ~500% \ // faster, which avoids blocking page rendering for ~10ms when there \ // are a lot of copy elements. click_el.addEventListener("click", () => { try { // Find click parent (from clicked basis) let _click_parent_sel = this._$(click_el); if (_click_parent_sel.is(".copy") === false) { _click_parent_sel = _click_parent_sel.parents(".copy").first(); } // Select clicked value let _value_sel = _click_parent_sel.find(".copy-value"); if (_click_parent_sel.length > 0 && _value_sel.length > 0) { // Select text and copy it let _was_copied = this.__copy_text(_value_sel[0]); // Change state to 'copied'? if (_was_copied === true) { // Clear out state clear timeout (if any) if (_state_confirm_timeout !== null) { clearTimeout(_state_confirm_timeout); } // Change state to 'copied' _click_parent_sel.attr("data-copy-state", "copied"); // Schedule state clear timeout _state_confirm_timeout = setTimeout(() => { _state_confirm_timeout = null; // Change state back to 'none' _click_parent_sel.attr("data-copy-state", "none"); }, this.__copy_state_confirm_delay); } } } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds appearance toggle click event * @private * @return {undefined} */ __bind_appearance_toggle_click() { let fn = "__bind_appearance_toggle_click"; try { this._$("#header .coloring").on("click", () => { try { // Acquire new appearance mode let _new_mode = ( (this.__appearance_mode === "light") ? "dark" : "light" ); // Toggle appearance mode this.__toggle_appearance(_new_mode); // Remember choice (with a cookie) this.__write_cookie( "appearance-mode", _new_mode ); } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds sidebar toggler click event * @private * @return {undefined} */ __bind_sidebar_toggler_click() { let fn = "__bind_sidebar_toggler_click"; try { let _sidebar_left_sel = ( this._$("#content .sidebar.sidebar--left") ); if (_sidebar_left_sel.length > 0) { // Bind sidebar toggler click events let _sidebar_togglers_sel = this._$( "#content .sidebar-toggler, #content .sidebar-toggler-retract" ); _sidebar_togglers_sel.on("click", () => { try { // Compute new sidebar visibility state let _new_state = ( (_sidebar_left_sel.attr("data-visible") === "true") ? "false" : "true" ); // Toggle visible state on sidebar _sidebar_left_sel.attr("data-visible", _new_state); } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); // Bind sidebar link click events (anchor links) let _sidebar_link_click = _sidebar_left_sel.find( ".nest-navigate-link[href^=\"#\"], .nest-navigate-slice[href^=\"#\"]" ); if (_sidebar_link_click.length > 0) { _sidebar_link_click.on("click", () => { try { // Forcibly hide sidebar _sidebar_left_sel.attr("data-visible", "false"); } catch (_error) { Console.error(`${this.ns}.${fn}:click`, _error); } }); } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds sidebar nest level toggle click event * @private * @return {undefined} */ __bind_sidebar_nest_level_toggle_click() { let fn = "__bind_sidebar_nest_level_toggle_click"; try { this._$("#content .sidebar .nest .nest-navigate-toggle").on( "click", (event) => { // Toggle sidebar nest level? if (event.target) { this.__toggle_sidebar_nest_level( this._$(event.target) ); } } ); } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds content anchor viewed event * @private * @return {undefined} */ __bind_content_anchor_viewed() { let fn = "__bind_content_anchor_viewed"; try { let _sidebar_left_sel = ( this._$("#content .sidebar.sidebar--left") || [] ); let _anchor_all_sel = ( this._$("#content .panes .content [id]") || [] ); let _link_all_sel = ( (_sidebar_left_sel.length > 0) ? _sidebar_left_sel.find(".nest-navigate-link[data-anchor^=\"#\"]") : [] ); // Anchors exist on page? Bind viewed listeners. if (_sidebar_left_sel.length > 0 && _anchor_all_sel.length > 0 && _link_all_sel.length > 0) { // Bind intersection observer? (if browser API is available, otherwise \ // ignore, as this is only a nice-to-have feature) // Notice: this will apply anchor viewed markers when the anchor \ // comes into view. if (typeof window.IntersectionObserver !== "undefined") { // Acquire header height (used to compute root margins, ie. \ // accounting for the fact that an element that went into view but \ // is still sliding below the header, is not really into view yet) let _header_sel = (this._$("#header") || []), _header_height = 0; if (_header_sel.length > 0) { _header_height = ( _header_sel[0].offsetHeight || 0 ); } // Allocate viewed context let _viewed_context = { active : null, visible : {} }; // Create intersection observer let _observer = new IntersectionObserver( (entries) => { for (let _i = 0; _i < entries.length; _i++) { let _entry = entries[_i]; if (_entry.target && _entry.target.id && (_link_all_sel.filter( `[data-anchor="#${_entry.target.id}"]` ).length > 0)) { // Entry intersecting? if (_entry.isIntersecting === true) { _viewed_context.visible[_entry.target.id] = true; } else { delete _viewed_context.visible[_entry.target.id]; } } } // Schedule content anchor viewed event? (only if active anchor \ // has changed; we need to find first active anchor from all \ // available anchors, ie. this is an ordered list) let _active_anchor = null; for (let _i = 0; _i < _anchor_all_sel.length; _i++) { let _cur_anchor_el = _anchor_all_sel[_i]; // This anchor is visible? Stop there, as we found the first \ // visible anchor. if (_viewed_context.visible[_cur_anchor_el.id] === true) { _active_anchor = _cur_anchor_el; break; } } if (_active_anchor !== null && _active_anchor !== _viewed_context.active) { this.__schedule_handle_content_anchor_viewed( _link_all_sel, _sidebar_left_sel, _active_anchor.id, _header_height ); } // Apply new active item? (or retain last active if stack is \ // empty) if (_active_anchor !== null) { _viewed_context.active = _active_anchor; } }, { threshold : 0.0, rootMargin : ( `${(-1 * _header_height)}px 0px 0px 0px` ) } ); // Bind intersection observer on all elements for (let _i = 0; _i < _anchor_all_sel.length; _i++) { _observer.observe(_anchor_all_sel[_i]); } } } } catch (error) { Console.error(`${this.ns}.${fn}`, error); } } /** * Binds code metas picker change event * @private * @return {undefined} */ __bind_code_metas_picker_change() { let fn = "__bind_code_metas_picker_change"; try { let _code_meta_picker_all_sel = ( this._$(".code .code-meta-picker select") || [] ); // Code boxes exist on page? Bind change listener on select. if (_code_meta_picker_all_sel.length > 0) { // Bind all change events _code_meta_picker_all_sel.each((picker_el) => { // Notice: use 'addEventListener' instead of '.on' as this is ~200% \ // faster, which avoids blocking page rendering for ~5ms when \ // there are a lot of select elements. picker_el.addEventListener("change", (