Selaa lähdekoodia

Output now uses CodeMirror editor

n1474335 3 vuotta sitten
vanhempi
commit
68733c74cc

+ 1 - 1
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;

+ 0 - 4
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);

+ 0 - 190
src/web/extensions/statusBar.mjs

@@ -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 `<span data-toggle="tooltip" title="Input length">
-            <i class="material-icons">abc</i>
-            <span id="stats-length-value"></span>
-        </span>
-        <span data-toggle="tooltip" title="Number of lines">
-            <i class="material-icons">sort</i>
-            <span id="stats-lines-value"></span>
-        </span>
-
-        <span id="sel-info" data-toggle="tooltip" title="Selection">
-            <i class="material-icons">highlight_alt</i>
-            <span id="sel-start-value"></span>\u279E<span id="sel-end-value"></span>
-            (<span id="sel-length-value"></span> selected)
-        </span>
-        <span id="cur-offset-info" data-toggle="tooltip" title="Cursor offset">
-            <i class="material-icons">location_on</i>
-            <span id="cur-offset-value"></span>
-        </span>`;
-}
-
-/**
- * Builds the Right-hand-side widgets
- * Event listener set up in Manager
- * @returns {string}
- */
-function constructRHS() {
-    return `<span data-toggle="tooltip" title="Input character encoding">
-        <i class="material-icons">language</i>
-        <span id="char-enc-value">UTF-16</span>
-    </span>
-
-    <div class="cm-status-bar-select eol-select">
-        <span class="cm-status-bar-select-btn" data-toggle="tooltip" data-placement="bottom" title="End of line sequence">
-            <i class="material-icons">keyboard_return</i> <span id="eol-value"></span>
-        </span>
-        <div class="cm-status-bar-select-content">
-            <a href="#" data-val="LF">Line Feed, U+000A</a>
-            <a href="#" data-val="VT">Vertical Tab, U+000B</a>
-            <a href="#" data-val="FF">Form Feed, U+000C</a>
-            <a href="#" data-val="CR">Carriage Return, U+000D</a>
-            <a href="#" data-val="CRLF">CR+LF, U+000D U+000A</a>
-            <!-- <a href="#" data-val="NL">Next Line, U+0085</a> This causes problems. -->
-            <a href="#" data-val="LS">Line Separator, U+2028</a>
-            <a href="#" data-val="PS">Paragraph Separator, U+2029</a>
-        </div>
-    </div>`;
-}
-
-/**
- * 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);
-}

+ 2 - 5
src/web/html/index.html

@@ -191,7 +191,7 @@
                     <ul id="rec-list" class="list-area no-select"></ul>
 
                     <div id="controls" class="no-select hide-on-maximised-output">
-                        <div id="controls-content" class="d-flex align-items-center">
+                        <div id="controls-content">
                             <button type="button" class="mx-2 btn btn-lg btn-secondary" id="step" data-toggle="tooltip" title="Step through the recipe">
                                 Step
                             </button>
@@ -289,8 +289,6 @@
                             <label for="output-text">Output</label>
                             <span class="pane-controls">
                                 <div class="io-info" id="bake-info"></div>
-                                <div class="io-info" id="output-selection-info"></div>
-                                <div class="io-info" id="output-info"></div>
                                 <button type="button" class="btn btn-primary bmd-btn-icon" id="save-all-to-file" data-toggle="tooltip" title="Save all outputs to a zip file" style="display: none">
                                         <i class="material-icons">archive</i>
                                     </button>
@@ -344,8 +342,7 @@
                             </div>
                             <div class="textarea-wrapper">
                                 <div id="output-highlighter" class="no-select"></div>
-                                <div id="output-html"></div>
-                                <textarea id="output-text" readonly="readonly" spellcheck="false"></textarea>
+                                <div id="output-text"></div>
                                 <img id="show-file-overlay" aria-hidden="true" src="<%- require('../static/images/file-32x32.png') %>" alt="Show file overlay" title="Show file overlay"/>
                                 <div id="output-file">
                                     <div class="file-overlay"></div>

+ 10 - 38
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;

+ 28 - 0
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;
+}

+ 87 - 0
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;
+}

+ 271 - 0
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 `
+            <span data-toggle="tooltip" title="${this.label} length">
+                <i class="material-icons">abc</i>
+                <span class="stats-length-value"></span>
+            </span>
+            <span data-toggle="tooltip" title="Number of lines">
+                <i class="material-icons">sort</i>
+                <span class="stats-lines-value"></span>
+            </span>
+
+            <span class="sel-info" data-toggle="tooltip" title="Main selection">
+                <i class="material-icons">highlight_alt</i>
+                <span class="sel-start-value"></span>\u279E<span class="sel-end-value"></span>
+                (<span class="sel-length-value"></span> selected)
+            </span>
+            <span class="cur-offset-info" data-toggle="tooltip" title="Cursor offset">
+                <i class="material-icons">location_on</i>
+                <span class="cur-offset-value"></span>
+            </span>`;
+    }
+
+    /**
+     * Builds the Right-hand-side widgets
+     * Event listener set up in Manager
+     * @returns {string}
+     */
+    constructRHS() {
+        return `
+            <span class="baking-time-info" style="display: none" data-toggle="tooltip" title="Baking time">
+                <i class="material-icons">schedule</i>
+                <span class="baking-time-value"></span>ms
+            </span>
+
+            <span data-toggle="tooltip" title="${this.label} character encoding">
+                <i class="material-icons">language</i>
+                <span class="char-enc-value">UTF-16</span>
+            </span>
+
+            <div class="cm-status-bar-select eol-select">
+                <span class="cm-status-bar-select-btn" data-toggle="tooltip" data-placement="left" title="End of line sequence">
+                    <i class="material-icons">keyboard_return</i> <span class="eol-value"></span>
+                </span>
+                <div class="cm-status-bar-select-content">
+                    <a href="#" data-val="LF">Line Feed, U+000A</a>
+                    <a href="#" data-val="VT">Vertical Tab, U+000B</a>
+                    <a href="#" data-val="FF">Form Feed, U+000C</a>
+                    <a href="#" data-val="CR">Carriage Return, U+000D</a>
+                    <a href="#" data-val="CRLF">CR+LF, U+000D U+000A</a>
+                    <!-- <a href="#" data-val="NL">Next Line, U+0085</a> This causes problems. -->
+                    <a href="#" data-val="LS">Line Separator, U+2028</a>
+                    <a href="#" data-val="PS">Paragraph Separator, U+2029</a>
+                </div>
+            </div>`;
+    }
+
+}
+
+/**
+ * A panel constructor factory building a panel that re-counts the stats every time the document changes.
+ * @param {Object} opts
+ * @returns {Function<PanelConstructor>}
+ */
+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);
+}

+ 69 - 118
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;
-
-        // 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;
-
-        // 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;
-        }
-
-        const cssClass = "hl1";
-
-        // Remove HTML tags
-        text = text
-            .replace(/&/g, "&amp;")
-            .replace(/</g, "&lt;")
-            .replace(/>/g, "&gt;")
-            .replace(/\n/g, "&#10;")
-            // Convert placeholders to tags
-            .replace(startPlaceholderRegex, "<span class=\""+cssClass+"\">")
-            .replace(endPlaceholderRegex, "</span>") + "&nbsp;";
-
-        // Adjust width to allow for scrollbars
-        highlighter.style.width = textarea.clientWidth + "px";
-        highlighter.innerHTML = text;
-        highlighter.scrollTop = textarea.scrollTop;
-        highlighter.scrollLeft = textarea.scrollLeft;
+        // 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;
+
+        // 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;
+
+        //     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";
+
+        // // Remove HTML tags
+        // text = text
+        //     .replace(/&/g, "&amp;")
+        //     .replace(/</g, "&lt;")
+        //     .replace(/>/g, "&gt;")
+        //     .replace(/\n/g, "&#10;")
+        //     // Convert placeholders to tags
+        //     .replace(startPlaceholderRegex, "<span class=\""+cssClass+"\">")
+        //     .replace(endPlaceholderRegex, "</span>") + "&nbsp;";
+
+        // // Adjust width to allow for scrollbars
+        // highlighter.style.width = textarea.clientWidth + "px";
+        // highlighter.innerHTML = text;
+        // highlighter.scrollTop = textarea.scrollTop;
+        // highlighter.scrollLeft = textarea.scrollLeft;
     }
 
 }

+ 9 - 39
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

+ 1 - 4
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");
         }

+ 188 - 97
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, "&nbsp;");
-        const timeStr = (duration.toString() + "ms").padStart(width, " ").replace(/ /g, "&nbsp;");
-
-        let msg = "time: " + timeStr + "<br>length: " + lengthStr;
-
-        if (typeof lines === "number") {
-            const linesStr = lines.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-            msg += "<br>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);

+ 2 - 2
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");
     },

+ 4 - 4
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
     }
 }