From 68733c74cc5dd5067d750c28e32708e9e7a280a0 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sat, 2 Jul 2022 19:23:03 +0100 Subject: [PATCH] Output now uses CodeMirror editor --- src/core/Utils.mjs | 2 +- src/web/Manager.mjs | 4 - src/web/extensions/statusBar.mjs | 190 ----------------- src/web/html/index.html | 7 +- src/web/stylesheets/layout/_io.css | 48 +---- src/web/utils/editorUtils.mjs | 28 +++ src/web/utils/htmlWidget.mjs | 87 ++++++++ src/web/utils/statusBar.mjs | 271 ++++++++++++++++++++++++ src/web/waiters/HighlighterWaiter.mjs | 173 ++++++---------- src/web/waiters/InputWaiter.mjs | 48 +---- src/web/waiters/OptionsWaiter.mjs | 5 +- src/web/waiters/OutputWaiter.mjs | 285 +++++++++++++++++--------- tests/browser/nightwatch.js | 4 +- tests/browser/ops.js | 8 +- 14 files changed, 665 insertions(+), 495 deletions(-) delete mode 100644 src/web/extensions/statusBar.mjs create mode 100644 src/web/utils/editorUtils.mjs create mode 100644 src/web/utils/htmlWidget.mjs create mode 100644 src/web/utils/statusBar.mjs diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index 66a98c36..5f36cae9 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -424,7 +424,7 @@ class Utils { const utf8Str = utf8.encode(str); if (str.length !== utf8Str.length) { - if (isWorkerEnvironment()) { + if (isWorkerEnvironment() && self && typeof self.setOption === "function") { self.setOption("attemptHighlight", false); } else if (isWebEnvironment()) { window.app.options.attemptHighlight = false; diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index 08a35d75..2477bb60 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -178,7 +178,6 @@ class Manager { this.addDynamicListener(".input-filter-result", "click", this.input.filterItemClick, this.input); document.getElementById("btn-open-file").addEventListener("click", this.input.inputOpenClick.bind(this.input)); document.getElementById("btn-open-folder").addEventListener("click", this.input.folderOpenClick.bind(this.input)); - this.addDynamicListener(".eol-select a", "click", this.input.eolSelectClick, this.input); // Output @@ -192,10 +191,7 @@ class Manager { document.getElementById("output-text").addEventListener("scroll", this.highlighter.outputScroll.bind(this.highlighter)); document.getElementById("output-text").addEventListener("mouseup", this.highlighter.outputMouseup.bind(this.highlighter)); document.getElementById("output-text").addEventListener("mousemove", this.highlighter.outputMousemove.bind(this.highlighter)); - document.getElementById("output-html").addEventListener("mouseup", this.highlighter.outputHtmlMouseup.bind(this.highlighter)); - document.getElementById("output-html").addEventListener("mousemove", this.highlighter.outputHtmlMousemove.bind(this.highlighter)); this.addMultiEventListener("#output-text", "mousedown dblclick select", this.highlighter.outputMousedown, this.highlighter); - this.addMultiEventListener("#output-html", "mousedown dblclick select", this.highlighter.outputHtmlMousedown, this.highlighter); this.addDynamicListener("#output-file-download", "click", this.output.downloadFile, this.output); this.addDynamicListener("#output-file-show-all", "click", this.output.showAllFile, this.output); this.addDynamicListener("#output-file-slice i", "click", this.output.displayFileSlice, this.output); diff --git a/src/web/extensions/statusBar.mjs b/src/web/extensions/statusBar.mjs deleted file mode 100644 index 8a837a51..00000000 --- a/src/web/extensions/statusBar.mjs +++ /dev/null @@ -1,190 +0,0 @@ -/** - * A Status bar extension for CodeMirror - * - * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2022 - * @license Apache-2.0 - */ - -import {showPanel} from "@codemirror/view"; - -/** - * Counts the stats of a document - * @param {element} el - * @param {Text} doc - */ -function updateStats(el, doc) { - const length = el.querySelector("#stats-length-value"), - lines = el.querySelector("#stats-lines-value"); - length.textContent = doc.length; - lines.textContent = doc.lines; -} - -/** - * Gets the current selection info - * @param {element} el - * @param {EditorState} state - * @param {boolean} selectionSet - */ -function updateSelection(el, state, selectionSet) { - const selLen = state.selection && state.selection.main ? - state.selection.main.to - state.selection.main.from : - 0; - - const selInfo = el.querySelector("#sel-info"), - curOffsetInfo = el.querySelector("#cur-offset-info"); - - if (!selectionSet) { - selInfo.style.display = "none"; - curOffsetInfo.style.display = "none"; - return; - } - - if (selLen > 0) { // Range - const start = el.querySelector("#sel-start-value"), - end = el.querySelector("#sel-end-value"), - length = el.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 = el.querySelector("#cur-offset-value"); - - selInfo.style.display = "none"; - curOffsetInfo.style.display = "inline-block"; - - offset.textContent = state.selection.main.from; - } -} - -/** - * Gets the current character encoding of the document - * @param {element} el - * @param {EditorState} state - */ -function updateCharEnc(el, state) { - // const charenc = el.querySelector("#char-enc-value"); - // TODO - // charenc.textContent = "TODO"; -} - -/** - * Returns what the current EOL separator is set to - * @param {element} el - * @param {EditorState} state - */ -function updateEOL(el, state) { - const eolLookup = { - "\u000a": "LF", - "\u000b": "VT", - "\u000c": "FF", - "\u000d": "CR", - "\u000d\u000a": "CRLF", - "\u0085": "NEL", - "\u2028": "LS", - "\u2029": "PS" - }; - - const val = el.querySelector("#eol-value"); - val.textContent = eolLookup[state.lineBreak]; -} - -/** - * Builds the Left-hand-side widgets - * @returns {string} - */ -function constructLHS() { - return ` - abc - - - - sort - - - - - highlight_alt - \u279E - ( selected) - - - location_on - - `; -} - -/** - * Builds the Right-hand-side widgets - * Event listener set up in Manager - * @returns {string} - */ -function constructRHS() { - return ` - language - UTF-16 - - -
- - keyboard_return - - -
`; -} - -/** - * A panel constructor building a panel that re-counts the stats every time the document changes. - * @param {EditorView} view - * @returns {Panel} - */ -function wordCountPanel(view) { - const dom = document.createElement("div"); - const lhs = document.createElement("div"); - const rhs = document.createElement("div"); - - dom.className = "cm-status-bar"; - lhs.innerHTML = constructLHS(); - rhs.innerHTML = constructRHS(); - - dom.appendChild(lhs); - dom.appendChild(rhs); - - updateEOL(rhs, view.state); - updateCharEnc(rhs, view.state); - updateStats(lhs, view.state.doc); - updateSelection(lhs, view.state, false); - - return { - dom, - update(update) { - updateEOL(rhs, update.state); - updateSelection(lhs, update.state, update.selectionSet); - updateCharEnc(rhs, update.state); - if (update.docChanged) { - updateStats(lhs, update.state.doc); - } - } - }; -} - -/** - * A function that build the extension that enables the panel in an editor. - * @returns {Extension} - */ -export function statusBar() { - return showPanel.of(wordCountPanel); -} diff --git a/src/web/html/index.html b/src/web/html/index.html index 3d237bdd..3eb150e5 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -191,7 +191,7 @@
-
+
@@ -289,8 +289,6 @@
-
-
@@ -344,8 +342,7 @@
-
- +
diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 625b81f7..ba670f3d 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -6,7 +6,8 @@ * @license Apache-2.0 */ -#input-text { +#input-text, +#output-text { position: relative; width: 100%; height: 100%; @@ -24,23 +25,6 @@ color: var(--fixed-width-font-colour); } -#output-text, -#output-html { - position: relative; - width: 100%; - height: 100%; - margin: 0; - padding: 3px; - -moz-padding-start: 3px; - -moz-padding-end: 3px; - border: none; - border-width: 0px; - resize: none; - background-color: transparent; - white-space: pre-wrap; - word-wrap: break-word; -} - #output-wrapper{ margin: 0; padding: 0; @@ -54,13 +38,6 @@ pointer-events: auto; } - -#output-html { - display: none; - overflow-y: auto; - -moz-padding-start: 1px; /* Fixes bug in Firefox */ -} - #input-tabs-wrapper #input-tabs, #output-tabs-wrapper #output-tabs { list-style: none; @@ -179,25 +156,15 @@ } #input-wrapper, -#output-wrapper, -#input-wrapper > :not(#input-text), -#output-wrapper > .textarea-wrapper > div, -#output-wrapper > .textarea-wrapper > textarea { +#output-wrapper { height: calc(100% - var(--title-height)); } #input-wrapper.show-tabs, -#input-wrapper.show-tabs > :not(#input-text), -#output-wrapper.show-tabs, -#output-wrapper.show-tabs > .textarea-wrapper > div, -#output-wrapper.show-tabs > .textarea-wrapper > textarea { +#output-wrapper.show-tabs { height: calc(100% - var(--tab-height) - var(--title-height)); } -#output-wrapper > .textarea-wrapper > #output-html { - height: 100%; -} - #show-file-overlay { height: 32px; } @@ -211,7 +178,6 @@ .textarea-wrapper textarea, .textarea-wrapper #output-text, -.textarea-wrapper #output-html, .textarea-wrapper #output-highlighter { font-family: var(--fixed-width-font-family); font-size: var(--fixed-width-font-size); @@ -477,6 +443,12 @@ /* Status bar */ +.ΝΌ2 .cm-panels { + background-color: var(--secondary-background-colour); + border-color: var(--secondary-border-colour); + color: var(--primary-font-colour); +} + .cm-status-bar { font-family: var(--fixed-width-font-family); font-weight: normal; diff --git a/src/web/utils/editorUtils.mjs b/src/web/utils/editorUtils.mjs new file mode 100644 index 00000000..fe6b83d4 --- /dev/null +++ b/src/web/utils/editorUtils.mjs @@ -0,0 +1,28 @@ +/** + * CodeMirror utilities that are relevant to both the input and output + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2022 + * @license Apache-2.0 + */ + + +/** + * Override for rendering special characters. + * Should mirror the toDOM function in + * https://github.com/codemirror/view/blob/main/src/special-chars.ts#L150 + * But reverts the replacement of line feeds with newline control pictures. + * @param {number} code + * @param {string} desc + * @param {string} placeholder + * @returns {element} + */ +export function renderSpecialChar(code, desc, placeholder) { + const s = document.createElement("span"); + // CodeMirror changes 0x0a to "NL" instead of "LF". We change it back. + s.textContent = code === 0x0a ? "\u240a" : placeholder; + s.title = desc; + s.setAttribute("aria-label", desc); + s.className = "cm-specialChar"; + return s; +} diff --git a/src/web/utils/htmlWidget.mjs b/src/web/utils/htmlWidget.mjs new file mode 100644 index 00000000..fbce9b49 --- /dev/null +++ b/src/web/utils/htmlWidget.mjs @@ -0,0 +1,87 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2022 + * @license Apache-2.0 + */ + +import {WidgetType, Decoration, ViewPlugin} from "@codemirror/view"; + +/** + * Adds an HTML widget to the Code Mirror editor + */ +class HTMLWidget extends WidgetType { + + /** + * HTMLWidget consructor + */ + constructor(html) { + super(); + this.html = html; + } + + /** + * Builds the DOM node + * @returns {DOMNode} + */ + toDOM() { + const wrap = document.createElement("span"); + wrap.setAttribute("id", "output-html"); + wrap.innerHTML = this.html; + return wrap; + } + +} + +/** + * Decorator function to provide a set of widgets for the editor DOM + * @param {EditorView} view + * @param {string} html + * @returns {DecorationSet} + */ +function decorateHTML(view, html) { + const widgets = []; + if (html.length) { + const deco = Decoration.widget({ + widget: new HTMLWidget(html), + side: 1 + }); + widgets.push(deco.range(0)); + } + return Decoration.set(widgets); +} + + +/** + * An HTML Plugin builder + * @param {Object} htmlOutput + * @returns {ViewPlugin} + */ +export function htmlPlugin(htmlOutput) { + const plugin = ViewPlugin.fromClass( + class { + /** + * Plugin constructor + * @param {EditorView} view + */ + constructor(view) { + this.htmlOutput = htmlOutput; + this.decorations = decorateHTML(view, this.htmlOutput.html); + } + + /** + * Editor update listener + * @param {ViewUpdate} update + */ + update(update) { + if (this.htmlOutput.changed) { + this.decorations = decorateHTML(update.view, this.htmlOutput.html); + this.htmlOutput.changed = false; + } + } + }, { + decorations: v => v.decorations + } + ); + + return plugin; +} diff --git a/src/web/utils/statusBar.mjs b/src/web/utils/statusBar.mjs new file mode 100644 index 00000000..431d8a3d --- /dev/null +++ b/src/web/utils/statusBar.mjs @@ -0,0 +1,271 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2022 + * @license Apache-2.0 + */ + +import {showPanel} from "@codemirror/view"; + +/** + * 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.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.addEventListener("click", this.eolSelectClick.bind(this), false); + + return dom; + } + + /** + * Handler for EOL Select clicks + * Sets the line separator + * @param {Event} e + */ + eolSelectClick(e) { + 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")]; + + // Call relevant EOL change handler + this.eolHandler(eolval); + } + + /** + * 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; + } + } + + /** + * Gets the current character encoding of the document + * @param {EditorState} state + */ + updateCharEnc(state) { + // const charenc = this.dom.querySelector("#char-enc-value"); + // TODO + // charenc.textContent = "TODO"; + } + + /** + * Returns what the current EOL separator is set to + * @param {EditorState} state + */ + updateEOL(state) { + const eolLookup = { + "\u000a": "LF", + "\u000b": "VT", + "\u000c": "FF", + "\u000d": "CR", + "\u000d\u000a": "CRLF", + "\u0085": "NEL", + "\u2028": "LS", + "\u2029": "PS" + }; + + const val = this.dom.querySelector(".eol-value"); + val.textContent = eolLookup[state.lineBreak]; + } + + /** + * 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"; + } + } + + /** + * 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() { + return ` + + + + language + UTF-16 + + + `; + } + +} + +/** + * 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(view.state); + 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.updateCharEnc(update.state); + sbPanel.updateBakeStats(); + 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); +} diff --git a/src/web/waiters/HighlighterWaiter.mjs b/src/web/waiters/HighlighterWaiter.mjs index 9f83b55c..d1340165 100755 --- a/src/web/waiters/HighlighterWaiter.mjs +++ b/src/web/waiters/HighlighterWaiter.mjs @@ -176,34 +176,16 @@ class HighlighterWaiter { this.mouseTarget = OUTPUT; this.removeHighlights(); - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; + const sel = document.getSelection(); + const start = sel.baseOffset; + const end = sel.extentOffset; if (start !== 0 || end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); this.highlightInput([{start: start, end: end}]); } } - /** - * Handler for output HTML mousedown events. - * Calculates the current selection info. - * - * @param {event} e - */ - outputHtmlMousedown(e) { - this.mouseButtonDown = true; - this.mouseTarget = OUTPUT; - - const sel = this._getOutputHtmlSelectionOffsets(); - if (sel.start !== 0 || sel.end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); - } - } - - /** * Handler for input mouseup events. * @@ -224,16 +206,6 @@ class HighlighterWaiter { } - /** - * Handler for output HTML mouseup events. - * - * @param {event} e - */ - outputHtmlMouseup(e) { - this.mouseButtonDown = false; - } - - /** * Handler for input mousemove events. * Calculates the current selection info, and highlights the corresponding data in the output. @@ -270,37 +242,16 @@ class HighlighterWaiter { this.mouseTarget !== OUTPUT) return; - const el = e.target; - const start = el.selectionStart; - const end = el.selectionEnd; + const sel = document.getSelection(); + const start = sel.baseOffset; + const end = sel.extentOffset; if (start !== 0 || end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(start, end); this.highlightInput([{start: start, end: end}]); } } - /** - * Handler for output HTML mousemove events. - * Calculates the current selection info. - * - * @param {event} e - */ - outputHtmlMousemove(e) { - // Check that the left mouse button is pressed - if (!this.mouseButtonDown || - e.which !== 1 || - this.mouseTarget !== OUTPUT) - return; - - const sel = this._getOutputHtmlSelectionOffsets(); - if (sel.start !== 0 || sel.end !== 0) { - document.getElementById("output-selection-info").innerHTML = this.selectionInfo(sel.start, sel.end); - } - } - - /** * Given start and end offsets, writes the HTML for the selection info element with the correct * padding. @@ -326,7 +277,6 @@ class HighlighterWaiter { removeHighlights() { document.getElementById("input-highlighter").innerHTML = ""; document.getElementById("output-highlighter").innerHTML = ""; - document.getElementById("output-selection-info").innerHTML = ""; } @@ -379,7 +329,8 @@ class HighlighterWaiter { const io = direction === "forward" ? "output" : "input"; - document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end); + // TODO + // document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end); this.highlight( document.getElementById(io + "-text"), document.getElementById(io + "-highlighter"), @@ -398,67 +349,67 @@ class HighlighterWaiter { * @param {number} pos.end - The end offset. */ async highlight(textarea, highlighter, pos) { - if (!this.app.options.showHighlighter) return false; - if (!this.app.options.attemptHighlight) return false; + // if (!this.app.options.showHighlighter) return false; + // if (!this.app.options.attemptHighlight) return false; - // Check if there is a carriage return in the output dish as this will not - // be displayed by the HTML textarea and will mess up highlighting offsets. - if (await this.manager.output.containsCR()) return false; + // // Check if there is a carriage return in the output dish as this will not + // // be displayed by the HTML textarea and will mess up highlighting offsets. + // if (await this.manager.output.containsCR()) return false; - const startPlaceholder = "[startHighlight]"; - const startPlaceholderRegex = /\[startHighlight\]/g; - const endPlaceholder = "[endHighlight]"; - const endPlaceholderRegex = /\[endHighlight\]/g; - let text = textarea.value; + // const startPlaceholder = "[startHighlight]"; + // const startPlaceholderRegex = /\[startHighlight\]/g; + // const endPlaceholder = "[endHighlight]"; + // const endPlaceholderRegex = /\[endHighlight\]/g; + // // let text = textarea.value; // TODO - // Put placeholders in position - // If there's only one value, select that - // If there are multiple, ignore the first one and select all others - if (pos.length === 1) { - if (pos[0].end < pos[0].start) return; - text = text.slice(0, pos[0].start) + - startPlaceholder + text.slice(pos[0].start, pos[0].end) + endPlaceholder + - text.slice(pos[0].end, text.length); - } else { - // O(n^2) - Can anyone improve this without overwriting placeholders? - let result = "", - endPlaced = true; + // // Put placeholders in position + // // If there's only one value, select that + // // If there are multiple, ignore the first one and select all others + // if (pos.length === 1) { + // if (pos[0].end < pos[0].start) return; + // text = text.slice(0, pos[0].start) + + // startPlaceholder + text.slice(pos[0].start, pos[0].end) + endPlaceholder + + // text.slice(pos[0].end, text.length); + // } else { + // // O(n^2) - Can anyone improve this without overwriting placeholders? + // let result = "", + // endPlaced = true; - for (let i = 0; i < text.length; i++) { - for (let j = 1; j < pos.length; j++) { - if (pos[j].end < pos[j].start) continue; - if (pos[j].start === i) { - result += startPlaceholder; - endPlaced = false; - } - if (pos[j].end === i) { - result += endPlaceholder; - endPlaced = true; - } - } - result += text[i]; - } - if (!endPlaced) result += endPlaceholder; - text = result; - } + // for (let i = 0; i < text.length; i++) { + // for (let j = 1; j < pos.length; j++) { + // if (pos[j].end < pos[j].start) continue; + // if (pos[j].start === i) { + // result += startPlaceholder; + // endPlaced = false; + // } + // if (pos[j].end === i) { + // result += endPlaceholder; + // endPlaced = true; + // } + // } + // result += text[i]; + // } + // if (!endPlaced) result += endPlaceholder; + // text = result; + // } - const cssClass = "hl1"; + // const cssClass = "hl1"; - // Remove HTML tags - text = text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\n/g, " ") - // Convert placeholders to tags - .replace(startPlaceholderRegex, "") - .replace(endPlaceholderRegex, "") + " "; + // // Remove HTML tags + // text = text + // .replace(/&/g, "&") + // .replace(//g, ">") + // .replace(/\n/g, " ") + // // Convert placeholders to tags + // .replace(startPlaceholderRegex, "") + // .replace(endPlaceholderRegex, "") + " "; - // Adjust width to allow for scrollbars - highlighter.style.width = textarea.clientWidth + "px"; - highlighter.innerHTML = text; - highlighter.scrollTop = textarea.scrollTop; - highlighter.scrollLeft = textarea.scrollLeft; + // // Adjust width to allow for scrollbars + // highlighter.style.width = textarea.clientWidth + "px"; + // highlighter.innerHTML = text; + // highlighter.scrollTop = textarea.scrollTop; + // highlighter.scrollLeft = textarea.scrollLeft; } } diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs index e8e71b12..0dc44dbe 100644 --- a/src/web/waiters/InputWaiter.mjs +++ b/src/web/waiters/InputWaiter.mjs @@ -19,7 +19,8 @@ import {defaultKeymap, insertTab, insertNewline, history, historyKeymap} from "@ import {bracketMatching} from "@codemirror/language"; import {search, searchKeymap, highlightSelectionMatches} from "@codemirror/search"; -import {statusBar} from "../extensions/statusBar.mjs"; +import {statusBar} from "../utils/statusBar.mjs"; +import {renderSpecialChar} from "../utils/editorUtils.mjs"; /** @@ -87,14 +88,17 @@ class InputWaiter { doc: null, extensions: [ history(), - highlightSpecialChars({render: this.renderSpecialChar}), + highlightSpecialChars({render: renderSpecialChar}), drawSelection(), rectangularSelection(), crosshairCursor(), bracketMatching(), highlightSelectionMatches(), search({top: true}), - statusBar(this.inputEditorConf), + statusBar({ + label: "Input", + eolHandler: this.eolChange.bind(this) + }), this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping), this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")), EditorState.allowMultipleSelections.of(true), @@ -118,44 +122,10 @@ class InputWaiter { } /** - * Override for rendering special characters. - * Should mirror the toDOM function in - * https://github.com/codemirror/view/blob/main/src/special-chars.ts#L150 - * But reverts the replacement of line feeds with newline control pictures. - * @param {number} code - * @param {string} desc - * @param {string} placeholder - * @returns {element} - */ - renderSpecialChar(code, desc, placeholder) { - const s = document.createElement("span"); - // CodeMirror changes 0x0a to "NL" instead of "LF". We change it back. - s.textContent = code === 0x0a ? "\u240a" : placeholder; - s.title = desc; - s.setAttribute("aria-label", desc); - s.className = "cm-specialChar"; - return s; - } - - /** - * Handler for EOL Select clicks + * Handler for EOL change events * Sets the line separator - * @param {Event} e */ - eolSelectClick(e) { - 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")]; + eolChange(eolval) { const oldInputVal = this.getInput(); // Update the EOL value diff --git a/src/web/waiters/OptionsWaiter.mjs b/src/web/waiters/OptionsWaiter.mjs index 52b81ab4..7d9a3e2d 100755 --- a/src/web/waiters/OptionsWaiter.mjs +++ b/src/web/waiters/OptionsWaiter.mjs @@ -140,14 +140,11 @@ class OptionsWaiter { */ setWordWrap() { this.manager.input.setWordWrap(this.app.options.wordWrap); - document.getElementById("output-text").classList.remove("word-wrap"); - document.getElementById("output-html").classList.remove("word-wrap"); + this.manager.output.setWordWrap(this.app.options.wordWrap); document.getElementById("input-highlighter").classList.remove("word-wrap"); document.getElementById("output-highlighter").classList.remove("word-wrap"); if (!this.app.options.wordWrap) { - document.getElementById("output-text").classList.add("word-wrap"); - document.getElementById("output-html").classList.add("word-wrap"); document.getElementById("input-highlighter").classList.add("word-wrap"); document.getElementById("output-highlighter").classList.add("word-wrap"); } diff --git a/src/web/waiters/OutputWaiter.mjs b/src/web/waiters/OutputWaiter.mjs index 8996edb0..496b0ac5 100755 --- a/src/web/waiters/OutputWaiter.mjs +++ b/src/web/waiters/OutputWaiter.mjs @@ -10,6 +10,18 @@ import Dish from "../../core/Dish.mjs"; import FileSaver from "file-saver"; import ZipWorker from "worker-loader?inline=no-fallback!../workers/ZipWorker.mjs"; +import { + EditorView, keymap, highlightSpecialChars, drawSelection, rectangularSelection, crosshairCursor +} from "@codemirror/view"; +import {EditorState, Compartment} from "@codemirror/state"; +import {defaultKeymap} from "@codemirror/commands"; +import {bracketMatching} from "@codemirror/language"; +import {search, searchKeymap, highlightSelectionMatches} from "@codemirror/search"; + +import {statusBar} from "../utils/statusBar.mjs"; +import {renderSpecialChar} from "../utils/editorUtils.mjs"; +import {htmlPlugin} from "../utils/htmlWidget.mjs"; + /** * Waiter to handle events related to the output */ @@ -25,12 +37,155 @@ class OutputWaiter { this.app = app; this.manager = manager; + this.outputTextEl = document.getElementById("output-text"); + // Object to contain bake statistics - used by statusBar extension + this.bakeStats = { + duration: 0 + }; + // Object to handle output HTML state - used by htmlWidget extension + this.htmlOutput = { + html: "", + changed: false + }; + this.initEditor(); + this.outputs = {}; this.zipWorker = null; this.maxTabs = this.manager.tabs.calcMaxTabs(); this.tabTimeout = null; } + /** + * Sets up the CodeMirror Editor and returns the view + */ + initEditor() { + this.outputEditorConf = { + eol: new Compartment, + lineWrapping: new Compartment + }; + + const initialState = EditorState.create({ + doc: null, + extensions: [ + EditorState.readOnly.of(true), + htmlPlugin(this.htmlOutput), + highlightSpecialChars({render: renderSpecialChar}), + drawSelection(), + rectangularSelection(), + crosshairCursor(), + bracketMatching(), + highlightSelectionMatches(), + search({top: true}), + statusBar({ + label: "Output", + bakeStats: this.bakeStats, + eolHandler: this.eolChange.bind(this) + }), + this.outputEditorConf.lineWrapping.of(EditorView.lineWrapping), + this.outputEditorConf.eol.of(EditorState.lineSeparator.of("\n")), + EditorState.allowMultipleSelections.of(true), + keymap.of([ + ...defaultKeymap, + ...searchKeymap + ]), + ] + }); + + this.outputEditorView = new EditorView({ + state: initialState, + parent: this.outputTextEl + }); + } + + /** + * Handler for EOL change events + * Sets the line separator + */ + eolChange(eolval) { + const oldOutputVal = this.getOutput(); + + // Update the EOL value + this.outputEditorView.dispatch({ + effects: this.outputEditorConf.eol.reconfigure(EditorState.lineSeparator.of(eolval)) + }); + + // Reset the output so that lines are recalculated, preserving the old EOL values + this.setOutput(oldOutputVal); + } + + /** + * Sets word wrap on the output editor + * @param {boolean} wrap + */ + setWordWrap(wrap) { + this.outputEditorView.dispatch({ + effects: this.outputEditorConf.lineWrapping.reconfigure( + wrap ? EditorView.lineWrapping : [] + ) + }); + } + + /** + * Gets the value of the current output + * @returns {string} + */ + getOutput() { + const doc = this.outputEditorView.state.doc; + const eol = this.outputEditorView.state.lineBreak; + return doc.sliceString(0, doc.length, eol); + } + + /** + * Sets the value of the current output + * @param {string} data + */ + setOutput(data) { + this.outputEditorView.dispatch({ + changes: { + from: 0, + to: this.outputEditorView.state.doc.length, + insert: data + } + }); + } + + /** + * Sets the value of the current output to a rendered HTML value + * @param {string} html + */ + setHTMLOutput(html) { + this.htmlOutput.html = html; + this.htmlOutput.changed = true; + // This clears the text output, but also fires a View update which + // triggers the htmlWidget to render the HTML. + this.setOutput(""); + + // Execute script sections + const scriptElements = document.getElementById("output-html").querySelectorAll("script"); + for (let i = 0; i < scriptElements.length; i++) { + try { + eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval + } catch (err) { + log.error(err); + } + } + } + + /** + * Clears the HTML output + */ + clearHTMLOutput() { + this.htmlOutput.html = ""; + this.htmlOutput.changed = true; + // Fire a blank change to force the htmlWidget to update and remove any HTML + this.outputEditorView.dispatch({ + changes: { + from: 0, + insert: "" + } + }); + } + /** * Calculates the maximum number of tabs to display */ @@ -245,8 +400,6 @@ class OutputWaiter { activeTab = this.manager.tabs.getActiveOutputTab(); if (typeof inputNum !== "number") inputNum = parseInt(inputNum, 10); - const outputText = document.getElementById("output-text"); - const outputHtml = document.getElementById("output-html"); const outputFile = document.getElementById("output-file"); const outputHighlighter = document.getElementById("output-highlighter"); const inputHighlighter = document.getElementById("input-highlighter"); @@ -278,95 +431,68 @@ class OutputWaiter { } else if (output.status === "error") { // style the tab if it's being shown this.toggleLoader(false); - outputText.style.display = "block"; - outputText.classList.remove("blur"); - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; + this.outputTextEl.classList.remove("blur"); outputFile.style.display = "none"; outputHighlighter.display = "none"; inputHighlighter.display = "none"; + this.clearHTMLOutput(); if (output.error) { - outputText.value = output.error; + this.setOutput(output.error); } else { - outputText.value = output.data.result; + this.setOutput(output.data.result); } - outputHtml.innerHTML = ""; } else if (output.status === "baked" || output.status === "inactive") { document.querySelector("#output-loader .loading-msg").textContent = `Loading output ${inputNum}`; this.closeFile(); - let scriptElements, lines, length; if (output.data === null) { - outputText.style.display = "block"; - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; outputFile.style.display = "none"; outputHighlighter.display = "block"; inputHighlighter.display = "block"; - outputText.value = ""; - outputHtml.innerHTML = ""; + this.clearHTMLOutput(); + this.setOutput(""); this.toggleLoader(false); return; } + this.bakeStats.duration = output.data.duration; + switch (output.data.type) { case "html": - outputText.style.display = "none"; - outputHtml.style.display = "block"; outputFile.style.display = "none"; outputHighlighter.style.display = "none"; inputHighlighter.style.display = "none"; - outputText.value = ""; - outputHtml.innerHTML = output.data.result; - - // Execute script sections - scriptElements = outputHtml.querySelectorAll("script"); - for (let i = 0; i < scriptElements.length; i++) { - try { - eval(scriptElements[i].innerHTML); // eslint-disable-line no-eval - } catch (err) { - log.error(err); - } - } + this.setHTMLOutput(output.data.result); break; case "ArrayBuffer": - outputText.style.display = "block"; - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; outputHighlighter.display = "none"; inputHighlighter.display = "none"; - outputText.value = ""; - outputHtml.innerHTML = ""; + this.clearHTMLOutput(); + this.setOutput(""); - length = output.data.result.byteLength; this.setFile(await this.getDishBuffer(output.data.dish), activeTab); break; case "string": default: - outputText.style.display = "block"; - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; outputFile.style.display = "none"; outputHighlighter.display = "block"; inputHighlighter.display = "block"; - outputText.value = Utils.printable(output.data.result, true); - outputHtml.innerHTML = ""; - - lines = output.data.result.count("\n") + 1; - length = output.data.result.length; + this.clearHTMLOutput(); + this.setOutput(output.data.result); break; } this.toggleLoader(false); - if (output.data.type === "html") { - const dishStr = await this.getDishStr(output.data.dish); - length = dishStr.length; - lines = dishStr.count("\n") + 1; - } - - this.setOutputInfo(length, lines, output.data.duration); debounce(this.backgroundMagic, 50, "backgroundMagic", this, [])(); } }.bind(this)); @@ -383,14 +509,13 @@ class OutputWaiter { // Display file overlay in output area with details const fileOverlay = document.getElementById("output-file"), fileSize = document.getElementById("output-file-size"), - outputText = document.getElementById("output-text"), fileSlice = buf.slice(0, 4096); fileOverlay.style.display = "block"; fileSize.textContent = buf.byteLength.toLocaleString() + " bytes"; - outputText.classList.add("blur"); - outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice)); + this.outputTextEl.classList.add("blur"); + this.setOutput(Utils.arrayBufferToStr(fileSlice)); } /** @@ -398,7 +523,7 @@ class OutputWaiter { */ closeFile() { document.getElementById("output-file").style.display = "none"; - document.getElementById("output-text").classList.remove("blur"); + this.outputTextEl.classList.remove("blur"); } /** @@ -466,7 +591,6 @@ class OutputWaiter { clearTimeout(this.outputLoaderTimeout); const outputLoader = document.getElementById("output-loader"), - outputElement = document.getElementById("output-text"), animation = document.getElementById("output-loader-animation"); if (value) { @@ -483,7 +607,6 @@ class OutputWaiter { // Show the loading screen this.outputLoaderTimeout = setTimeout(function() { - outputElement.disabled = true; outputLoader.style.visibility = "visible"; outputLoader.style.opacity = 1; }, 200); @@ -494,7 +617,6 @@ class OutputWaiter { animation.removeChild(this.bombeEl); } catch (err) {} }.bind(this), 500); - outputElement.disabled = false; outputLoader.style.opacity = 0; outputLoader.style.visibility = "hidden"; } @@ -717,8 +839,7 @@ class OutputWaiter { debounce(this.set, 50, "setOutput", this, [inputNum])(); - document.getElementById("output-html").scroll(0, 0); - document.getElementById("output-text").scroll(0, 0); + this.outputTextEl.scroll(0, 0); // TODO if (changeInput) { this.manager.input.changeTab(inputNum, false); @@ -996,32 +1117,6 @@ class OutputWaiter { } } - /** - * Displays information about the output. - * - * @param {number} length - The length of the current output string - * @param {number} lines - The number of the lines in the current output string - * @param {number} duration - The length of time (ms) it took to generate the output - */ - setOutputInfo(length, lines, duration) { - if (!length) return; - let width = length.toString().length; - width = width < 4 ? 4 : width; - - const lengthStr = length.toString().padStart(width, " ").replace(/ /g, " "); - const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, " "); - - let msg = "time: " + timeStr + "
length: " + lengthStr; - - if (typeof lines === "number") { - const linesStr = lines.toString().padStart(width, " ").replace(/ /g, " "); - msg += "
lines: " + linesStr; - } - - document.getElementById("output-info").innerHTML = msg; - document.getElementById("output-selection-info").innerHTML = ""; - } - /** * Triggers the BackgroundWorker to attempt Magic on the current output. */ @@ -1111,9 +1206,7 @@ class OutputWaiter { async displayFileSlice() { document.querySelector("#output-loader .loading-msg").textContent = "Loading file slice..."; this.toggleLoader(true); - const outputText = document.getElementById("output-text"), - outputHtml = document.getElementById("output-html"), - outputFile = document.getElementById("output-file"), + const outputFile = document.getElementById("output-file"), outputHighlighter = document.getElementById("output-highlighter"), inputHighlighter = document.getElementById("input-highlighter"), showFileOverlay = document.getElementById("show-file-overlay"), @@ -1130,12 +1223,12 @@ class OutputWaiter { str = Utils.arrayBufferToStr(await this.getDishBuffer(output.dish).slice(sliceFrom, sliceTo)); } - outputText.classList.remove("blur"); + this.outputTextEl.classList.remove("blur"); showFileOverlay.style.display = "block"; - outputText.value = Utils.printable(str, true); + this.clearHTMLOutput(); + this.setOutput(str); - outputText.style.display = "block"; - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; outputFile.style.display = "none"; outputHighlighter.display = "block"; inputHighlighter.display = "block"; @@ -1149,9 +1242,7 @@ class OutputWaiter { async showAllFile() { document.querySelector("#output-loader .loading-msg").textContent = "Loading entire file at user instruction. This may cause a crash..."; this.toggleLoader(true); - const outputText = document.getElementById("output-text"), - outputHtml = document.getElementById("output-html"), - outputFile = document.getElementById("output-file"), + const outputFile = document.getElementById("output-file"), outputHighlighter = document.getElementById("output-highlighter"), inputHighlighter = document.getElementById("input-highlighter"), showFileOverlay = document.getElementById("show-file-overlay"), @@ -1164,12 +1255,12 @@ class OutputWaiter { str = Utils.arrayBufferToStr(await this.getDishBuffer(output.dish)); } - outputText.classList.remove("blur"); + this.outputTextEl.classList.remove("blur"); showFileOverlay.style.display = "none"; - outputText.value = Utils.printable(str, true); + this.clearHTMLOutput(); + this.setOutput(str); - outputText.style.display = "block"; - outputHtml.style.display = "none"; + this.outputTextEl.style.display = "block"; outputFile.style.display = "none"; outputHighlighter.display = "block"; inputHighlighter.display = "block"; @@ -1185,7 +1276,7 @@ class OutputWaiter { showFileOverlayClick(e) { const showFileOverlay = e.target; - document.getElementById("output-text").classList.add("blur"); + this.outputTextEl.classList.add("blur"); showFileOverlay.style.display = "none"; this.set(this.manager.tabs.getActiveOutputTab()); } @@ -1212,7 +1303,7 @@ class OutputWaiter { * Handler for copy click events. * Copies the output to the clipboard */ - async copyClick() { + async copyClick() { // TODO - do we need this? const dish = this.getOutputDish(this.manager.tabs.getActiveOutputTab()); if (dish === null) { this.app.alert("Could not find data to copy. Has this output been baked yet?", 3000); diff --git a/tests/browser/nightwatch.js b/tests/browser/nightwatch.js index ba6f5204..e63a8036 100644 --- a/tests/browser/nightwatch.js +++ b/tests/browser/nightwatch.js @@ -90,7 +90,7 @@ module.exports = { browser .useCss() .waitForElementNotVisible("#stale-indicator", 1000) - .expect.element("#output-text").to.have.property("value").that.equals("44 6f 6e 27 74 20 50 61 6e 69 63 2e"); + .expect.element("#output-text").to.have.property("value").that.equals("44 6f 6e 27 74 20 50 61 6e 69 63 2e"); // TODO // Clear recipe browser @@ -206,7 +206,7 @@ module.exports = { .useCss() .waitForElementVisible(".operation .op-title", 1000) .waitForElementNotVisible("#stale-indicator", 1000) - .expect.element("#output-text").to.have.property("value").which.matches(/[\da-f-]{36}/); + .expect.element("#output-text").to.have.property("value").which.matches(/[\da-f-]{36}/); // TODO browser.click("#clr-recipe"); }, diff --git a/tests/browser/ops.js b/tests/browser/ops.js index d0933bb6..64f8e036 100644 --- a/tests/browser/ops.js +++ b/tests/browser/ops.js @@ -443,9 +443,9 @@ function testOp(browser, opName, input, output, args=[]) { bakeOp(browser, opName, input, args); if (typeof output === "string") { - browser.expect.element("#output-text").to.have.property("value").that.equals(output); + browser.expect.element("#output-text").to.have.property("value").that.equals(output); // TODO } else if (output instanceof RegExp) { - browser.expect.element("#output-text").to.have.property("value").that.matches(output); + browser.expect.element("#output-text").to.have.property("value").that.matches(output); // TODO } } @@ -463,8 +463,8 @@ function testOpHtml(browser, opName, input, cssSelector, output, args=[]) { bakeOp(browser, opName, input, args); if (typeof output === "string") { - browser.expect.element("#output-html " + cssSelector).text.that.equals(output); + browser.expect.element("#output-html " + cssSelector).text.that.equals(output); // TODO } else if (output instanceof RegExp) { - browser.expect.element("#output-html " + cssSelector).text.that.matches(output); + browser.expect.element("#output-html " + cssSelector).text.that.matches(output); // TODO } }