/** * @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"; import { eolCodeToName, eolSeqToCode } from "./editorUtils.mjs"; /** * A Status bar extension for CodeMirror */ class StatusBarPanel { /** * StatusBarPanel constructor * @param {Object} opts */ constructor(opts) { this.label = opts.label; this.timing = opts.timing; this.tabNumGetter = opts.tabNumGetter; this.eolHandler = opts.eolHandler; this.chrEncHandler = opts.chrEncHandler; this.chrEncGetter = opts.chrEncGetter; this.getEncodingState = opts.getEncodingState; this.getEOLState = opts.getEOLState; this.htmlOutput = opts.htmlOutput; 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"; dom.setAttribute("data-help-title", `${this.label} status bar`); dom.setAttribute("data-help", `This status bar provides information about data in the ${this.label}. Help topics are available for each of the components by activating help when hovering over them.`); 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"); const btn = e.target.closest(".cm-status-bar-select-btn"); if (btn.classList.contains("disabled")) return; 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 eolCode = e.target.getAttribute("data-val"); if (!eolCode) return; // Call relevant EOL change handler this.eolHandler(e.target.getAttribute("data-val"), true); 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(); const chrEncVal = parseInt(e.target.getAttribute("data-val"), 10); if (isNaN(chrEncVal)) return; this.chrEncHandler(chrEncVal, true); 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 {EditorState} state */ updateStats(state) { const length = this.dom.querySelector(".stats-length-value"), lines = this.dom.querySelector(".stats-lines-value"); let docLength = state.doc.length; // CodeMirror always counts line breaks as one character. // We want to show an accurate reading of how many bytes there are. if (state.lineBreak.length !== 1) { docLength += (state.lineBreak.length * state.doc.lines) - state.doc.lines - 1; } length.textContent = docLength; lines.textContent = state.doc.lines; } /** * Gets the current selection info * @param {EditorState} state * @param {boolean} selectionSet */ updateSelection(state, selectionSet) { const selLen = 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; } // CodeMirror always counts line breaks as one character. // We want to show an accurate reading of how many bytes there are. let from = state.selection.main.from, to = state.selection.main.to; if (state.lineBreak.length !== 1) { const fromLine = state.doc.lineAt(from).number; const toLine = state.doc.lineAt(to).number; from += (state.lineBreak.length * fromLine) - fromLine - 1; to += (state.lineBreak.length * toLine) - toLine - 1; } 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 = from; end.textContent = to; length.textContent = to - from; } else { // Position const offset = this.dom.querySelector(".cur-offset-value"); selInfo.style.display = "none"; curOffsetInfo.style.display = "inline-block"; offset.textContent = from; } } /** * Sets the current EOL separator in the status bar * @param {EditorState} state */ updateEOL(state) { if (this.getEOLState() < 2 && state.lineBreak === this.eolVal) return; const val = this.dom.querySelector(".eol-value"); const button = val.closest(".cm-status-bar-select-btn"); let eolCode = eolSeqToCode[state.lineBreak]; let eolName = eolCodeToName[eolCode]; switch (this.getEOLState()) { case 1: // Detected val.classList.add("font-italic"); eolCode += " (detected)"; eolName += " (detected)"; // Pulse val.classList.add("pulse"); setTimeout(() => { val.classList.remove("pulse"); }, 2000); break; case 0: // Unset case 2: // Manually set default: val.classList.remove("font-italic"); break; } val.textContent = eolCode; button.setAttribute("title", `End of line sequence:
${eolName}`); button.setAttribute("data-original-title", `End of line sequence:
${eolName}`); this.eolVal = state.lineBreak; } /** * Sets the current character encoding of the document */ updateCharEnc() { const chrEncVal = this.chrEncGetter(); if (this.getEncodingState() < 2 && chrEncVal === this.chrEncVal) return; let 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"); switch (this.getEncodingState()) { case 1: // Detected val.classList.add("font-italic"); name += " (detected)"; // Pulse val.classList.add("pulse"); setTimeout(() => { val.classList.remove("pulse"); }, 2000); break; case 0: // Unset case 2: // Manually set default: val.classList.remove("font-italic"); break; } 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 timing info */ updateTiming() { if (!this.timing) return; const bakingTime = this.dom.querySelector(".baking-time-value"); const bakingTimeInfo = this.dom.querySelector(".baking-time-info"); if (this.label === "Output" && this.timing) { bakingTimeInfo.style.display = "inline-block"; bakingTime.textContent = this.timing.duration(this.tabNumGetter()); const info = this.timing.printStages(this.tabNumGetter()).replace(/\n/g, "
"); bakingTimeInfo.setAttribute("data-original-title", info); } else { bakingTimeInfo.style.display = "none"; } } /** * Updates the sizing of elements that need to fit correctly * @param {EditorView} view */ updateSizing(view) { const viewHeight = view.contentDOM.parentNode.clientHeight; this.dom.querySelectorAll(".cm-status-bar-select-scroll").forEach( el => { el.style.maxHeight = (viewHeight - 50) + "px"; } ); } /** * Checks whether there is HTML output requiring some widgets to be disabled */ monitorHTMLOutput() { if (!this.htmlOutput?.changed) return; if (this.htmlOutput?.html === "") { // Enable all controls this.dom.querySelectorAll(".disabled").forEach(el => { el.classList.remove("disabled"); }); } else { // Disable chrenc, length, selection etc. this.dom.querySelectorAll(".cm-status-bar-select-btn").forEach(el => { el.classList.add("disabled"); }); this.dom.querySelector(".stats-length-value").parentNode.classList.add("disabled"); this.dom.querySelector(".stats-lines-value").parentNode.classList.add("disabled"); this.dom.querySelector(".sel-info").classList.add("disabled"); this.dom.querySelector(".cur-offset-info").classList.add("disabled"); } } /** * 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(""); let chrEncHelpText = "", eolHelpText = ""; if (this.label === "Input") { chrEncHelpText = "The input character encoding defines how the input text is encoded into bytes which are then processed by the Recipe.

The 'Raw bytes' option attempts to treat the input as individual bytes in the range 0-255. If it detects any characters with Unicode values above 255, it will treat the entire input as UTF-8. 'Raw bytes' is usually the best option if you are inputting binary data, such as a file."; eolHelpText = "The End of Line Sequence defines which bytes are considered EOL terminators. Pressing the return key will enter this value into the input and create a new line.

Changing the EOL sequence will not modify any existing data in the input but may change how previously entered line breaks are displayed. Lines added while a different EOL terminator was set may not now result in a new line, but may be displayed as control characters instead."; } else { chrEncHelpText = "The output character encoding defines how the output bytes are decoded into text which can be displayed to you.

The 'Raw bytes' option treats the output data as individual bytes in the range 0-255."; eolHelpText = "The End of Line Sequence defines which bytes are considered EOL terminators.

Changing this value will not modify the value of the output, but may change how certain bytes are displayed and whether they result in a new line being created."; } 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(); sbPanel.updateTiming(); sbPanel.updateStats(view.state); sbPanel.updateSelection(view.state, false); sbPanel.monitorHTMLOutput(); return { "dom": sbPanel.dom, update(update) { sbPanel.updateEOL(update.state); sbPanel.updateCharEnc(); sbPanel.updateSelection(update.state, update.selectionSet); sbPanel.updateTiming(); sbPanel.monitorHTMLOutput(); if (update.geometryChanged) { sbPanel.updateSizing(update.view); } if (update.docChanged) { sbPanel.updateStats(update.state); } } }; }; } /** * 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); }