/** * @author n1474335 [n1474335@gmail.com] * @copyright Crown Copyright 2022 * @license Apache-2.0 */ import {showPanel} from "@codemirror/view"; import {CHR_ENC_SIMPLE_LOOKUP, CHR_ENC_SIMPLE_REVERSE_LOOKUP} from "../../core/lib/ChrEnc.mjs"; /** * A Status bar extension for CodeMirror */ class StatusBarPanel { /** * StatusBarPanel constructor * @param {Object} opts */ constructor(opts) { this.label = opts.label; this.bakeStats = opts.bakeStats ? opts.bakeStats : null; this.eolHandler = opts.eolHandler; this.chrEncHandler = opts.chrEncHandler; this.eolVal = null; this.chrEncVal = null; this.dom = this.buildDOM(); } /** * Builds the status bar DOM tree * @returns {DOMNode} */ buildDOM() { const dom = document.createElement("div"); const lhs = document.createElement("div"); const rhs = document.createElement("div"); dom.className = "cm-status-bar"; lhs.innerHTML = this.constructLHS(); rhs.innerHTML = this.constructRHS(); dom.appendChild(lhs); dom.appendChild(rhs); // Event listeners dom.querySelectorAll(".cm-status-bar-select-btn").forEach( el => el.addEventListener("click", this.showDropUp.bind(this), false) ); dom.querySelector(".eol-select").addEventListener("click", this.eolSelectClick.bind(this), false); dom.querySelector(".chr-enc-select").addEventListener("click", this.chrEncSelectClick.bind(this), false); dom.querySelector(".cm-status-bar-filter-input").addEventListener("keyup", this.chrEncFilter.bind(this), false); return dom; } /** * Handler for dropup clicks * Shows/Hides the dropup * @param {Event} e */ showDropUp(e) { const el = e.target .closest(".cm-status-bar-select") .querySelector(".cm-status-bar-select-content"); el.classList.add("show"); // Focus the filter input if present const filter = el.querySelector(".cm-status-bar-filter-input"); if (filter) filter.focus(); // Set up a listener to close the menu if the user clicks outside of it hideOnClickOutside(el, e); } /** * Handler for EOL Select clicks * Sets the line separator * @param {Event} e */ eolSelectClick(e) { // preventDefault is required to stop the URL being modified and popState being triggered e.preventDefault(); const eolLookup = { "LF": "\u000a", "VT": "\u000b", "FF": "\u000c", "CR": "\u000d", "CRLF": "\u000d\u000a", "NEL": "\u0085", "LS": "\u2028", "PS": "\u2029" }; const eolval = eolLookup[e.target.getAttribute("data-val")]; if (eolval === undefined) return; // Call relevant EOL change handler this.eolHandler(eolval); hideElement(e.target.closest(".cm-status-bar-select-content")); } /** * Handler for Chr Enc Select clicks * Sets the character encoding * @param {Event} e */ chrEncSelectClick(e) { // preventDefault is required to stop the URL being modified and popState being triggered e.preventDefault(); // TODO - this breaks the menus when you click the button itself const chrEncVal = parseInt(e.target.getAttribute("data-val"), 10); if (isNaN(chrEncVal)) return; this.chrEncHandler(chrEncVal); this.updateCharEnc(chrEncVal); hideElement(e.target.closest(".cm-status-bar-select-content")); } /** * Handler for Chr Enc keyup events * Filters the list of selectable character encodings * @param {Event} e */ chrEncFilter(e) { const input = e.target; const filter = input.value.toLowerCase(); const div = input.closest(".cm-status-bar-select-content"); const a = div.getElementsByTagName("a"); for (let i = 0; i < a.length; i++) { const txtValue = a[i].textContent || a[i].innerText; if (txtValue.toLowerCase().includes(filter)) { a[i].style.display = "block"; } else { a[i].style.display = "none"; } } } /** * Counts the stats of a document * @param {Text} doc */ updateStats(doc) { const length = this.dom.querySelector(".stats-length-value"), lines = this.dom.querySelector(".stats-lines-value"); length.textContent = doc.length; lines.textContent = doc.lines; } /** * Gets the current selection info * @param {EditorState} state * @param {boolean} selectionSet */ updateSelection(state, selectionSet) { const selLen = state.selection && state.selection.main ? state.selection.main.to - state.selection.main.from : 0; const selInfo = this.dom.querySelector(".sel-info"), curOffsetInfo = this.dom.querySelector(".cur-offset-info"); if (!selectionSet) { selInfo.style.display = "none"; curOffsetInfo.style.display = "none"; return; } if (selLen > 0) { // Range const start = this.dom.querySelector(".sel-start-value"), end = this.dom.querySelector(".sel-end-value"), length = this.dom.querySelector(".sel-length-value"); selInfo.style.display = "inline-block"; curOffsetInfo.style.display = "none"; start.textContent = state.selection.main.from; end.textContent = state.selection.main.to; length.textContent = state.selection.main.to - state.selection.main.from; } else { // Position const offset = this.dom.querySelector(".cur-offset-value"); selInfo.style.display = "none"; curOffsetInfo.style.display = "inline-block"; offset.textContent = state.selection.main.from; } } /** * Sets the current EOL separator in the status bar * @param {EditorState} state */ updateEOL(state) { if (state.lineBreak === this.eolVal) return; const eolLookup = { "\u000a": ["LF", "Line Feed"], "\u000b": ["VT", "Vertical Tab"], "\u000c": ["FF", "Form Feed"], "\u000d": ["CR", "Carriage Return"], "\u000d\u000a": ["CRLF", "Carriage Return + Line Feed"], "\u0085": ["NEL", "Next Line"], "\u2028": ["LS", "Line Separator"], "\u2029": ["PS", "Paragraph Separator"] }; const val = this.dom.querySelector(".eol-value"); const button = val.closest(".cm-status-bar-select-btn"); const eolName = eolLookup[state.lineBreak]; val.textContent = eolName[0]; button.setAttribute("title", `End of line sequence: ${eolName[1]}`); button.setAttribute("data-original-title", `End of line sequence: ${eolName[1]}`); this.eolVal = state.lineBreak; } /** * Gets the current character encoding of the document * @param {number} chrEncVal */ updateCharEnc(chrEncVal) { if (chrEncVal === this.chrEncVal) return; const name = CHR_ENC_SIMPLE_REVERSE_LOOKUP[chrEncVal] ? CHR_ENC_SIMPLE_REVERSE_LOOKUP[chrEncVal] : "Raw Bytes"; const val = this.dom.querySelector(".chr-enc-value"); const button = val.closest(".cm-status-bar-select-btn"); val.textContent = name; button.setAttribute("title", `${this.label} character encoding: ${name}`); button.setAttribute("data-original-title", `${this.label} character encoding: ${name}`); this.chrEncVal = chrEncVal; } /** * Sets the latest bake duration */ updateBakeStats() { const bakingTime = this.dom.querySelector(".baking-time-value"); const bakingTimeInfo = this.dom.querySelector(".baking-time-info"); if (this.label === "Output" && this.bakeStats && typeof this.bakeStats.duration === "number" && this.bakeStats.duration >= 0) { bakingTimeInfo.style.display = "inline-block"; bakingTime.textContent = this.bakeStats.duration; } else { bakingTimeInfo.style.display = "none"; } } /** * Updates the sizing of elements that need to fit correctly * @param {EditorView} view */ updateSizing(view) { const viewHeight = view.contentDOM.clientHeight; this.dom.querySelectorAll(".cm-status-bar-select-scroll").forEach( el => { el.style.maxHeight = (viewHeight - 50) + "px"; } ); } /** * Builds the Left-hand-side widgets * @returns {string} */ constructLHS() { return ` abc sort highlight_alt \u279E ( selected) location_on `; } /** * Builds the Right-hand-side widgets * Event listener set up in Manager * * @returns {string} */ constructRHS() { const chrEncOptions = Object.keys(CHR_ENC_SIMPLE_LOOKUP).map(name => `${name}` ).join(""); return `
text_fields Raw Bytes
Raw Bytes ${chrEncOptions}
keyboard_return
Line Feed, U+000A Vertical Tab, U+000B Form Feed, U+000C Carriage Return, U+000D CR+LF, U+000D U+000A Line Separator, U+2028 Paragraph Separator, U+2029
`; } } const elementsWithListeners = {}; /** * Hides the provided element when a click is made outside of it * @param {Element} element * @param {Event} instantiatingEvent */ function hideOnClickOutside(element, instantiatingEvent) { /** * Handler for document click events * Closes element if click is outside it. * @param {Event} event */ const outsideClickListener = event => { // Don't trigger if we're clicking inside the element, or if the element // is not visible, or if this is the same click event that opened it. if (!element.contains(event.target) && event.timeStamp !== instantiatingEvent.timeStamp) { hideElement(element); } }; if (!Object.prototype.hasOwnProperty.call(elementsWithListeners, element)) { elementsWithListeners[element] = outsideClickListener; document.addEventListener("click", elementsWithListeners[element], false); } } /** * Hides the specified element and removes the click listener for it * @param {Element} element */ function hideElement(element) { element.classList.remove("show"); document.removeEventListener("click", elementsWithListeners[element], false); delete elementsWithListeners[element]; } /** * A panel constructor factory building a panel that re-counts the stats every time the document changes. * @param {Object} opts * @returns {Function} */ function makePanel(opts) { const sbPanel = new StatusBarPanel(opts); return (view) => { sbPanel.updateEOL(view.state); sbPanel.updateCharEnc(opts.initialChrEncVal); sbPanel.updateBakeStats(); sbPanel.updateStats(view.state.doc); sbPanel.updateSelection(view.state, false); return { "dom": sbPanel.dom, update(update) { sbPanel.updateEOL(update.state); sbPanel.updateSelection(update.state, update.selectionSet); sbPanel.updateBakeStats(); if (update.geometryChanged) { sbPanel.updateSizing(update.view); } if (update.docChanged) { sbPanel.updateStats(update.state.doc); } } }; }; } /** * A function that build the extension that enables the panel in an editor. * @param {Object} opts * @returns {Extension} */ export function statusBar(opts) { const panelMaker = makePanel(opts); return showPanel.of(panelMaker); }