浏览代码

Merge branch 'j433866-carriage-returns'

n1474335 5 年之前
父节点
当前提交
9e1079027b

+ 13 - 4
src/web/App.mjs

@@ -670,18 +670,22 @@ class App {
      *
      * @param {string} title - The title of the box
      * @param {string} body - The question (HTML supported)
+     * @param {string} accept - The text of the accept button
+     * @param {string} reject - The text of the reject button
      * @param {function} callback - A function accepting one boolean argument which handles the
      *   response e.g. function(answer) {...}
      * @param {Object} [scope=this] - The object to bind to the callback function
      *
      * @example
      * // Pops up a box asking if the user would like a cookie. Prints the answer to the console.
-     * this.confirm("Question", "Would you like a cookie?", function(answer) {console.log(answer);});
+     * this.confirm("Question", "Would you like a cookie?", "Yes", "No", function(answer) {console.log(answer);});
      */
-    confirm(title, body, callback, scope) {
+    confirm(title, body, accept, reject, callback, scope) {
         scope = scope || this;
         document.getElementById("confirm-title").innerHTML = title;
         document.getElementById("confirm-body").innerHTML = body;
+        document.getElementById("confirm-yes").innerText = accept;
+        document.getElementById("confirm-no").innerText = reject;
         document.getElementById("confirm-modal").style.display = "block";
 
         this.confirmClosed = false;
@@ -694,9 +698,14 @@ class App {
                 callback.bind(scope)(true);
                 $("#confirm-modal").modal("hide");
             }.bind(this))
+            .one("click", "#confirm-no", function() {
+                this.confirmClosed = true;
+                callback.bind(scope)(false);
+            }.bind(this))
             .one("hide.bs.modal", function(e) {
-                if (!this.confirmClosed)
-                    callback.bind(scope)(false);
+                if (!this.confirmClosed) {
+                    callback.bind(scope)(undefined);
+                }
                 this.confirmClosed = true;
             }.bind(this));
     }

+ 1 - 1
src/web/Manager.mjs

@@ -224,7 +224,7 @@ class Manager {
         document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options));
         document.getElementById("reset-options").addEventListener("click", this.options.resetOptionsClick.bind(this.options));
         this.addDynamicListener(".option-item input[type=checkbox]", "change", this.options.switchChange, this.options);
-        this.addDynamicListener(".option-item input[type=checkbox]", "change", this.options.setWordWrap, this.options);
+        this.addDynamicListener(".option-item input[type=checkbox]#wordWrap", "change", this.options.setWordWrap, this.options);
         this.addDynamicListener(".option-item input[type=checkbox]#useMetaKey", "change", this.bindings.updateKeybList, this.bindings);
         this.addDynamicListener(".option-item input[type=number]", "keyup", this.options.numberChange, this.options);
         this.addDynamicListener(".option-item input[type=number]", "change", this.options.numberChange, this.options);

+ 13 - 6
src/web/html/index.html

@@ -563,16 +563,23 @@
                         <div class="checkbox option-item">
                             <label for="imagePreview">
                                 <input type="checkbox" option="imagePreview" id="imagePreview">
-                                Render a preview of the input if it's detected to be an image.
+                                Render a preview of the input if it's detected to be an image
                             </label>
                         </div>
 
                         <div class="checkbox option-item">
-                                <label for="syncTabs">
-                                    <input type="checkbox" option="syncTabs" id="syncTabs">
-                                    Keep the current tab in sync between the input and output.
-                                </label>
-                            </div>
+                            <label for="syncTabs">
+                                <input type="checkbox" option="syncTabs" id="syncTabs">
+                                Keep the current tab in sync between the input and output
+                            </label>
+                        </div>
+
+                        <div class="checkbox option-item">
+                            <label for="preserveCR" data-toggle="tooltip" data-placement="right" data-html="true" title="As HTML textareas don't support carriage returns, editing input must be turned off to preserve them.<br><br>When this option is enabled, editing is disabled for pasted text that contains carriage returns. Otherwise, editing will remain enabled but carriage returns will not be preserved.">
+                                <input type="checkbox" option="preserveCR" id="preserveCR">
+                                Preserve carriage returns when pasting an input
+                            </label>
+                        </div>
                     </div>
                     <div class="modal-footer">
                         <button type="button" class="btn btn-secondary" id="reset-options">Reset options to default</button>

+ 3 - 1
src/web/index.js

@@ -53,7 +53,9 @@ function main() {
         logLevel:            "info",
         autoMagic:           true,
         imagePreview:        true,
-        syncTabs:            true
+        syncTabs:            true,
+        preserveCR:          true,
+        userSetCR:           false
     };
 
     document.removeEventListener("DOMContentLoaded", main, false);

+ 108 - 38
src/web/waiters/InputWaiter.mjs

@@ -222,8 +222,6 @@ class InputWaiter {
         if (Object.prototype.hasOwnProperty.call(r, "progress") &&
             Object.prototype.hasOwnProperty.call(r, "inputNum")) {
             this.manager.tabs.updateInputTabProgress(r.inputNum, r.progress, 100);
-        } else if (Object.prototype.hasOwnProperty.call(r, "fileBuffer")) {
-            this.manager.tabs.updateInputTabProgress(r.inputNum, 100, 100);
         }
 
         const transferable = Object.prototype.hasOwnProperty.call(r, "fileBuffer") ? [r.fileBuffer] : undefined;
@@ -305,6 +303,9 @@ class InputWaiter {
             case "removeChefWorker":
                 this.removeChefWorker();
                 break;
+            case "fileLoaded":
+                this.fileLoaded(r.data.inputNum);
+                break;
             default:
                 log.error(`Unknown action ${r.action}.`);
         }
@@ -331,7 +332,7 @@ class InputWaiter {
      * @param {number} inputData.size - The size in bytes of the input file
      * @param {string} inputData.type - The MIME type of the input file
      * @param {number} inputData.progress - The load progress of the input file
-     * @param {boolean} [silent=false] - If true, fires the manager statechange event
+     * @param {boolean} [silent=false] - If false, fires the manager statechange event
      */
     async set(inputData, silent=false) {
         return new Promise(function(resolve, reject) {
@@ -373,7 +374,7 @@ class InputWaiter {
 
                 if (!silent) window.dispatchEvent(this.manager.statechange);
             } else {
-                this.setFile(inputData);
+                this.setFile(inputData, silent);
             }
 
         }.bind(this));
@@ -389,8 +390,9 @@ class InputWaiter {
      * @param {number} inputData.size - The size in bytes of the input file
      * @param {string} inputData.type - The MIME type of the input file
      * @param {number} inputData.progress - The load progress of the input file
+     * @param {boolean} [silent=true] - If false, fires the manager statechange event
      */
-    setFile(inputData) {
+    setFile(inputData, silent=true) {
         const activeTab = this.manager.tabs.getActiveInputTab();
         if (inputData.inputNum !== activeTab) return;
 
@@ -414,6 +416,30 @@ class InputWaiter {
 
         this.setInputInfo(inputData.size, null);
         this.displayFilePreview(inputData);
+
+        if (!silent) window.dispatchEvent(this.manager.statechange);
+    }
+
+    /**
+     * Update file details when a file completes loading
+     *
+     * @param {number} inputNum - The inputNum of the input which has finished loading
+     */
+    fileLoaded(inputNum) {
+        this.manager.tabs.updateInputTabProgress(inputNum, 100, 100);
+
+        const activeTab = this.manager.tabs.getActiveInputTab();
+        if (activeTab !== inputNum) return;
+
+        this.inputWorker.postMessage({
+            action: "setInput",
+            data: {
+                inputNum: inputNum,
+                silent: false
+            }
+        });
+
+        this.updateFileProgress(inputNum, 100);
     }
 
     /**
@@ -495,19 +521,6 @@ class InputWaiter {
             fileLoaded.textContent = progress + "%";
             fileLoaded.style.color = "";
         }
-
-        if (progress === 100 && progress !== oldProgress) {
-            // Don't set the input if the progress hasn't changed
-            this.inputWorker.postMessage({
-                action: "setInput",
-                data: {
-                    inputNum: inputNum,
-                    silent: false
-                }
-            });
-            window.dispatchEvent(this.manager.statechange);
-
-        }
     }
 
     /**
@@ -711,33 +724,50 @@ class InputWaiter {
      *
      * @param {event} e
      */
-    inputPaste(e) {
-        const pastedData = e.clipboardData.getData("Text");
-        if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) {
-            // Pasting normally fires the inputChange() event before
-            // changing the value, so instead change it here ourselves
-            // and manually fire inputChange()
-            e.preventDefault();
-            const inputText = document.getElementById("input-text");
-            const selStart = inputText.selectionStart;
-            const selEnd = inputText.selectionEnd;
-            const startVal = inputText.value.slice(0, selStart);
-            const endVal = inputText.value.slice(selEnd);
-
-            inputText.value = startVal + pastedData + endVal;
-            inputText.setSelectionRange(selStart + pastedData.length, selStart + pastedData.length);
-            this.debounceInputChange(e);
-        } else {
-            e.preventDefault();
-            e.stopPropagation();
+    async inputPaste(e) {
+        e.preventDefault();
+        e.stopPropagation();
 
+        const self = this;
+        /**
+         * Triggers the input file/binary data overlay
+         *
+         * @param {string} pastedData
+         */
+        function triggerOverlay(pastedData) {
             const file = new File([pastedData], "PastedData", {
                 type: "text/plain",
                 lastModified: Date.now()
             });
 
-            this.loadUIFiles([file]);
+            self.loadUIFiles([file]);
+        }
+
+        const pastedData = e.clipboardData.getData("Text");
+        const inputText = document.getElementById("input-text");
+        const selStart = inputText.selectionStart;
+        const selEnd = inputText.selectionEnd;
+        const startVal = inputText.value.slice(0, selStart);
+        const endVal = inputText.value.slice(selEnd);
+        const val = startVal + pastedData + endVal;
+
+        if (val.length >= (this.app.options.ioDisplayThreshold * 1024)) {
+            // Data too large to display, use overlay
+            triggerOverlay(val);
+            return false;
+        } else if (await this.preserveCarriageReturns(val)) {
+            // Data contains a carriage return and the user doesn't wish to edit it, use overlay
+            // We check this in a separate condition to make sure it is not run unless absolutely
+            // necessary.
+            triggerOverlay(val);
             return false;
+        } else {
+            // Pasting normally fires the inputChange() event before
+            // changing the value, so instead change it here ourselves
+            // and manually fire inputChange()
+            inputText.value = val;
+            inputText.setSelectionRange(selStart + pastedData.length, selStart + pastedData.length);
+            this.debounceInputChange(e);
         }
     }
 
@@ -815,6 +845,46 @@ class InputWaiter {
         }
     }
 
+    /**
+     * Checks if an input contains carriage returns.
+     * If a CR is detected, checks if the preserve CR option has been set,
+     * and if not, asks the user for their preference.
+     *
+     * @param {string} input - The input to be checked
+     * @returns {boolean} - If true, the input contains a CR which should be
+     *      preserved, so display an overlay so it can't be edited
+     */
+    async preserveCarriageReturns(input) {
+        if (input.indexOf("\r") < 0) return false;
+
+        const optionsStr = "This behaviour can be changed in the <a href='#' onclick='document.getElementById(\"options\").click()'>Options pane</a>";
+        if (!this.app.options.userSetCR) {
+            // User has not set a CR preference yet
+            let preserve = await new Promise(function(resolve, reject) {
+                this.app.confirm(
+                    "Carriage Return Detected",
+                    "A <a href='https://wikipedia.org/wiki/Carriage_return'>carriage return</a> (<code>\\r</code>, <code>0x0d</code>) was detected in your input. As HTML textareas <a href='https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element'>can't display carriage returns</a>, editing must be turned off to preserve them. <br>Alternatively, you can enable editing but your carriage returns will not be preserved.<br><br>This preference will be saved but can be toggled in the options pane.",
+                    "Preserve Carriage Returns",
+                    "Enable Editing", resolve, this);
+            }.bind(this));
+            if (preserve === undefined) {
+                // The confirm pane was closed without picking a specific choice
+                this.app.alert(`Not preserving carriage returns.\n${optionsStr}`, 5000);
+                preserve = false;
+            }
+            this.manager.options.updateOption("preserveCR", preserve);
+            this.manager.options.updateOption("userSetCR", true);
+        } else {
+            if (this.app.options.preserveCR) {
+                this.app.alert(`A carriage return (\\r, 0x0d) was detected in your input, so editing has been disabled to preserve it.<br>${optionsStr}`, 10000);
+            } else {
+                this.app.alert(`A carriage return (\\r, 0x0d) was detected in your input. Editing is remaining enabled, but carriage returns will not be preserved.<br>${optionsStr}`, 10000);
+            }
+        }
+
+        return this.app.options.preserveCR;
+    }
+
     /**
      * Load files from the UI into the inputWorker
      *

+ 142 - 136
src/web/waiters/OptionsWaiter.mjs

@@ -1,174 +1,180 @@
 /**
- * Waiter to handle events related to the CyberChef options.
- *
  * @author n1474335 [n1474335@gmail.com]
  * @copyright Crown Copyright 2016
  * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
  */
-const OptionsWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-};
-
 
 /**
- * Loads options and sets values of switches and inputs to match them.
- *
- * @param {Object} options
+ * Waiter to handle events related to the CyberChef options.
  */
-OptionsWaiter.prototype.load = function(options) {
-    for (const option in options) {
-        this.app.options[option] = options[option];
-    }
-
-    // Set options to match object
-    const cboxes = document.querySelectorAll("#options-body input[type=checkbox]");
-    let i;
-    for (i = 0; i < cboxes.length; i++) {
-        cboxes[i].checked = this.app.options[cboxes[i].getAttribute("option")];
+class OptionsWaiter {
+
+    /**
+     * OptionsWaiter constructor.
+     *
+     * @param {App} app - The main view object for CyberChef.
+     * @param {Manager} manager - The CyberChef event manager.
+     */
+    constructor(app, manager) {
+        this.app = app;
+        this.manager = manager;
     }
 
-    const nboxes = document.querySelectorAll("#options-body input[type=number]");
-    for (i = 0; i < nboxes.length; i++) {
-        nboxes[i].value = this.app.options[nboxes[i].getAttribute("option")];
-        nboxes[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
-    }
+    /**
+     * Loads options and sets values of switches and inputs to match them.
+     *
+     * @param {Object} options
+     */
+    load(options) {
+        for (const option in options) {
+            this.app.options[option] = options[option];
+        }
 
-    const selects = document.querySelectorAll("#options-body select");
-    for (i = 0; i < selects.length; i++) {
-        const val = this.app.options[selects[i].getAttribute("option")];
-        if (val) {
-            selects[i].value = val;
-            selects[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
-        } else {
-            selects[i].selectedIndex = 0;
+        // Set options to match object
+        const cboxes = document.querySelectorAll("#options-body input[type=checkbox]");
+        let i;
+        for (i = 0; i < cboxes.length; i++) {
+            cboxes[i].checked = this.app.options[cboxes[i].getAttribute("option")];
         }
-    }
-};
 
+        const nboxes = document.querySelectorAll("#options-body input[type=number]");
+        for (i = 0; i < nboxes.length; i++) {
+            nboxes[i].value = this.app.options[nboxes[i].getAttribute("option")];
+            nboxes[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
+        }
 
-/**
- * Handler for options click events.
- * Dispays the options pane.
- *
- * @param {event} e
- */
-OptionsWaiter.prototype.optionsClick = function(e) {
-    e.preventDefault();
-    $("#options-modal").modal();
-};
+        const selects = document.querySelectorAll("#options-body select");
+        for (i = 0; i < selects.length; i++) {
+            const val = this.app.options[selects[i].getAttribute("option")];
+            if (val) {
+                selects[i].value = val;
+                selects[i].dispatchEvent(new CustomEvent("change", {bubbles: true}));
+            } else {
+                selects[i].selectedIndex = 0;
+            }
+        }
+    }
 
 
-/**
- * Handler for reset options click events.
- * Resets options back to their default values.
- */
-OptionsWaiter.prototype.resetOptionsClick = function() {
-    this.load(this.app.doptions);
-};
+    /**
+     * Handler for options click events.
+     * Dispays the options pane.
+     *
+     * @param {event} e
+     */
+    optionsClick(e) {
+        e.preventDefault();
+        $("#options-modal").modal();
+    }
 
 
-/**
- * Handler for switch change events.
- * Modifies the option state and saves it to local storage.
- *
- * @param {event} e
- */
-OptionsWaiter.prototype.switchChange = function(e) {
-    const el = e.target;
-    const option = el.getAttribute("option");
-    const state = el.checked;
+    /**
+     * Handler for reset options click events.
+     * Resets options back to their default values.
+     */
+    resetOptionsClick() {
+        this.load(this.app.doptions);
+    }
 
-    log.debug(`Setting ${option} to ${state}`);
-    this.app.options[option] = state;
 
-    if (this.app.isLocalStorageAvailable())
-        localStorage.setItem("options", JSON.stringify(this.app.options));
-};
+    /**
+     * Handler for switch change events.
+     *
+     * @param {event} e
+     */
+    switchChange(e) {
+        const el = e.target;
+        const option = el.getAttribute("option");
+        const state = el.checked;
 
+        this.updateOption(option, state);
+    }
 
-/**
- * Handler for number change events.
- * Modifies the option value and saves it to local storage.
- *
- * @param {event} e
- */
-OptionsWaiter.prototype.numberChange = function(e) {
-    const el = e.target;
-    const option = el.getAttribute("option");
-    const val = parseInt(el.value, 10);
 
-    log.debug(`Setting ${option} to ${val}`);
-    this.app.options[option] = val;
+    /**
+     * Handler for number change events.
+     *
+     * @param {event} e
+     */
+    numberChange(e) {
+        const el = e.target;
+        const option = el.getAttribute("option");
+        const val = parseInt(el.value, 10);
 
-    if (this.app.isLocalStorageAvailable())
-        localStorage.setItem("options", JSON.stringify(this.app.options));
-};
+        this.updateOption(option, val);
+    }
 
 
-/**
- * Handler for select change events.
- * Modifies the option value and saves it to local storage.
- *
- * @param {event} e
- */
-OptionsWaiter.prototype.selectChange = function(e) {
-    const el = e.target;
-    const option = el.getAttribute("option");
+    /**
+     * Handler for select change events.
+     *
+     * @param {event} e
+     */
+    selectChange(e) {
+        const el = e.target;
+        const option = el.getAttribute("option");
 
-    log.debug(`Setting ${option} to ${el.value}`);
-    this.app.options[option] = el.value;
+        this.updateOption(option, el.value);
+    }
 
-    if (this.app.isLocalStorageAvailable())
-        localStorage.setItem("options", JSON.stringify(this.app.options));
-};
+    /**
+     * Modifies an option value and saves it to local storage.
+     *
+     * @param {string} option - The option to be updated
+     * @param {string|number|boolean} value - The new value of the option
+     */
+    updateOption(option, value) {
+        log.debug(`Setting ${option} to ${value}`);
+        this.app.options[option] = value;
+
+        if (this.app.isLocalStorageAvailable())
+            localStorage.setItem("options", JSON.stringify(this.app.options));
+    }
 
 
-/**
- * Sets or unsets word wrap on the input and output depending on the wordWrap option value.
- */
-OptionsWaiter.prototype.setWordWrap = function() {
-    document.getElementById("input-text").classList.remove("word-wrap");
-    document.getElementById("output-text").classList.remove("word-wrap");
-    document.getElementById("output-html").classList.remove("word-wrap");
-    document.getElementById("input-highlighter").classList.remove("word-wrap");
-    document.getElementById("output-highlighter").classList.remove("word-wrap");
-
-    if (!this.app.options.wordWrap) {
-        document.getElementById("input-text").classList.add("word-wrap");
-        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");
+    /**
+     * Sets or unsets word wrap on the input and output depending on the wordWrap option value.
+     */
+    setWordWrap() {
+        document.getElementById("input-text").classList.remove("word-wrap");
+        document.getElementById("output-text").classList.remove("word-wrap");
+        document.getElementById("output-html").classList.remove("word-wrap");
+        document.getElementById("input-highlighter").classList.remove("word-wrap");
+        document.getElementById("output-highlighter").classList.remove("word-wrap");
+
+        if (!this.app.options.wordWrap) {
+            document.getElementById("input-text").classList.add("word-wrap");
+            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");
+        }
     }
-};
 
 
-/**
- * Changes the theme by setting the class of the <html> element.
- *
- * @param {Event} e
- */
-OptionsWaiter.prototype.themeChange = function (e) {
-    const themeClass = e.target.value;
+    /**
+     * Changes the theme by setting the class of the <html> element.
+     *
+     * @param {Event} e
+     */
+    themeChange(e) {
+        const themeClass = e.target.value;
 
-    document.querySelector(":root").className = themeClass;
-};
+        document.querySelector(":root").className = themeClass;
+    }
 
 
-/**
- * Changes the console logging level.
- *
- * @param {Event} e
- */
-OptionsWaiter.prototype.logLevelChange = function (e) {
-    const level = e.target.value;
-    log.setLevel(level, false);
-    this.manager.worker.setLogLevel();
-    this.manager.input.setLogLevel();
-};
+    /**
+     * Changes the console logging level.
+     *
+     * @param {Event} e
+     */
+    logLevelChange(e) {
+        const level = e.target.value;
+        log.setLevel(level, false);
+        this.manager.worker.setLogLevel();
+        this.manager.input.setLogLevel();
+    }
+}
 
 export default OptionsWaiter;

+ 117 - 53
src/web/waiters/OutputWaiter.mjs

@@ -217,6 +217,9 @@ class OutputWaiter {
      */
     removeAllOutputs() {
         this.outputs = {};
+
+        this.resetSwitch();
+
         const tabsList = document.getElementById("output-tabs");
         const tabsListChildren = tabsList.children;
 
@@ -516,9 +519,10 @@ class OutputWaiter {
             this.app.alert("Could not find any output data to download. Has this output been baked?", 3000);
             return;
         }
-        let fileName = window.prompt("Please enter a filename: ", "download.dat");
+        const fileName = window.prompt("Please enter a filename: ", "download.dat");
 
-        if (fileName === null) fileName = "download.dat";
+        // Assume if the user clicks cancel they don't want to download
+        if (fileName === null) return;
 
         const data = await dish.get(Dish.ARRAY_BUFFER),
             file = new File([data], fileName);
@@ -529,12 +533,22 @@ class OutputWaiter {
      * Handler for save all click event
      * Saves all outputs to a single archvie file
      */
-    saveAllClick() {
+    async saveAllClick() {
         const downloadButton = document.getElementById("save-all-to-file");
         if (downloadButton.firstElementChild.innerHTML === "archive") {
             this.downloadAllFiles();
-        } else if (window.confirm("Cancel zipping of outputs?")) {
-            this.terminateZipWorker();
+        } else {
+            const cancel = await new Promise(function(resolve, reject) {
+                this.app.confirm(
+                    "Cancel zipping?",
+                    "The outputs are currently being zipped for download.<br>Cancel zipping?",
+                    "Continue zipping",
+                    "Cancel zipping",
+                    resolve, this);
+            }.bind(this));
+            if (!cancel) {
+                this.terminateZipWorker();
+            }
         }
     }
 
@@ -544,57 +558,61 @@ class OutputWaiter {
      * be zipped for download
      */
     async downloadAllFiles() {
-        return new Promise(resolve => {
-            const inputNums = Object.keys(this.outputs);
-            for (let i = 0; i < inputNums.length; i++) {
-                const iNum = inputNums[i];
-                if (this.outputs[iNum].status !== "baked" ||
-                this.outputs[iNum].bakeId !== this.manager.worker.bakeId) {
-                    if (window.confirm("Not all outputs have been baked yet. Continue downloading outputs?")) {
-                        break;
-                    } else {
-                        return;
-                    }
+        const inputNums = Object.keys(this.outputs);
+        for (let i = 0; i < inputNums.length; i++) {
+            const iNum = inputNums[i];
+            if (this.outputs[iNum].status !== "baked" ||
+            this.outputs[iNum].bakeId !== this.manager.worker.bakeId) {
+                const continueDownloading = await new Promise(function(resolve, reject) {
+                    this.app.confirm(
+                        "Incomplete outputs",
+                        "Not all outputs have been baked yet. Continue downloading outputs?",
+                        "Download", "Cancel", resolve, this);
+                }.bind(this));
+                if (continueDownloading) {
+                    break;
+                } else {
+                    return;
                 }
             }
+        }
 
-            let fileName = window.prompt("Please enter a filename: ", "download.zip");
+        let fileName = window.prompt("Please enter a filename: ", "download.zip");
 
-            if (fileName === null || fileName === "") {
-                // Don't zip the files if there isn't a filename
-                this.app.alert("No filename was specified.", 3000);
-                return;
-            }
+        if (fileName === null || fileName === "") {
+            // Don't zip the files if there isn't a filename
+            this.app.alert("No filename was specified.", 3000);
+            return;
+        }
 
-            if (!fileName.match(/.zip$/)) {
-                fileName += ".zip";
-            }
+        if (!fileName.match(/.zip$/)) {
+            fileName += ".zip";
+        }
 
-            let fileExt = window.prompt("Please enter a file extension for the files, or leave blank to detect automatically.", "");
+        let fileExt = window.prompt("Please enter a file extension for the files, or leave blank to detect automatically.", "");
 
-            if (fileExt === null) fileExt = "";
+        if (fileExt === null) fileExt = "";
 
-            if (this.zipWorker !== null) {
-                this.terminateZipWorker();
-            }
+        if (this.zipWorker !== null) {
+            this.terminateZipWorker();
+        }
 
-            const downloadButton = document.getElementById("save-all-to-file");
+        const downloadButton = document.getElementById("save-all-to-file");
 
-            downloadButton.classList.add("spin");
-            downloadButton.title = `Zipping ${inputNums.length} files...`;
-            downloadButton.setAttribute("data-original-title", `Zipping ${inputNums.length} files...`);
+        downloadButton.classList.add("spin");
+        downloadButton.title = `Zipping ${inputNums.length} files...`;
+        downloadButton.setAttribute("data-original-title", `Zipping ${inputNums.length} files...`);
 
-            downloadButton.firstElementChild.innerHTML = "autorenew";
+        downloadButton.firstElementChild.innerHTML = "autorenew";
 
-            log.debug("Creating ZipWorker");
-            this.zipWorker = new ZipWorker();
-            this.zipWorker.postMessage({
-                outputs: this.outputs,
-                filename: fileName,
-                fileExtension: fileExt
-            });
-            this.zipWorker.addEventListener("message", this.handleZipWorkerMessage.bind(this));
+        log.debug("Creating ZipWorker");
+        this.zipWorker = new ZipWorker();
+        this.zipWorker.postMessage({
+            outputs: this.outputs,
+            filename: fileName,
+            fileExtension: fileExt
         });
+        this.zipWorker.addEventListener("message", this.handleZipWorkerMessage.bind(this));
     }
 
     /**
@@ -1213,14 +1231,39 @@ class OutputWaiter {
      * Moves the current output into the input textarea.
      */
     async switchClick() {
-        const active = await this.getDishBuffer(this.getOutputDish(this.manager.tabs.getActiveOutputTab()));
+        const activeTab = this.manager.tabs.getActiveOutputTab();
+        const transferable = [];
+
+        const switchButton = document.getElementById("switch");
+        switchButton.classList.add("spin");
+        switchButton.disabled = true;
+        switchButton.firstElementChild.innerHTML = "autorenew";
+        $(switchButton).tooltip("hide");
+
+        let active = await this.getDishBuffer(this.getOutputDish(activeTab));
+
+        if (!this.outputExists(activeTab)) {
+            this.resetSwitchButton();
+            return;
+        }
+
+        if (this.outputs[activeTab].data.type === "string" &&
+            active.byteLength <= this.app.options.ioDisplayThreshold * 1024) {
+            const dishString = await this.getDishStr(this.getOutputDish(activeTab));
+            if (!await this.manager.input.preserveCarriageReturns(dishString)) {
+                active = dishString;
+            }
+        } else {
+            transferable.push(active);
+        }
+
         this.manager.input.inputWorker.postMessage({
             action: "inputSwitch",
             data: {
-                inputNum: this.manager.tabs.getActiveInputTab(),
+                inputNum: activeTab,
                 outputData: active
             }
-        }, [active]);
+        }, transferable);
     }
 
     /**
@@ -1238,6 +1281,9 @@ class OutputWaiter {
     inputSwitch(switchData) {
         this.switchOrigData = switchData;
         document.getElementById("undo-switch").disabled = false;
+
+        this.resetSwitchButton();
+
     }
 
     /**
@@ -1246,17 +1292,35 @@ class OutputWaiter {
      */
     undoSwitchClick() {
         this.manager.input.updateInputObj(this.switchOrigData.inputNum, this.switchOrigData.data);
+
+        this.manager.input.fileLoaded(this.switchOrigData.inputNum);
+
+        this.resetSwitch();
+    }
+
+    /**
+     * Removes the switch data and resets the switch buttons
+     */
+    resetSwitch() {
+        if (this.switchOrigData !== undefined) {
+            delete this.switchOrigData;
+        }
+
         const undoSwitch = document.getElementById("undo-switch");
         undoSwitch.disabled = true;
         $(undoSwitch).tooltip("hide");
 
-        this.manager.input.inputWorker.postMessage({
-            action: "setInput",
-            data: {
-                inputNum: this.switchOrigData.inputNum,
-                silent: false
-            }
-        });
+        this.resetSwitchButton();
+    }
+
+    /**
+     * Resets the switch button to its usual state
+     */
+    resetSwitchButton() {
+        const switchButton = document.getElementById("switch");
+        switchButton.classList.remove("spin");
+        switchButton.disabled = false;
+        switchButton.firstElementChild.innerHTML = "open_in_browser";
     }
 
     /**

+ 33 - 12
src/web/workers/InputWorker.mjs

@@ -202,6 +202,7 @@ self.bakeInput = function(inputNum, bakeId) {
     if (inputObj === null ||
         inputObj === undefined ||
         inputObj.status !== "loaded") {
+
         self.postMessage({
             action: "queueInputError",
             data: {
@@ -441,7 +442,7 @@ self.updateTabHeader = function(inputNum) {
  *
  * @param {object} inputData
  * @param {number} inputData.inputNum - The input to get the data for
- * @param {boolean} inputData.silent - If false, the manager statechange event won't be fired
+ * @param {boolean} inputData.silent - If false, the manager statechange event will be fired
  */
 self.setInput = function(inputData) {
     const inputNum = inputData.inputNum;
@@ -590,7 +591,7 @@ self.updateInputObj = function(inputData) {
     const inputNum = inputData.inputNum;
     const data = inputData.data;
 
-    if (self.getInputObj(inputNum) === -1) return;
+    if (self.getInputObj(inputNum) === undefined) return;
 
     self.inputs[inputNum].data = data;
 };
@@ -663,11 +664,19 @@ self.handleLoaderMessage = function(r) {
     if ("fileBuffer" in r) {
         log.debug(`Input file ${inputNum} loaded.`);
         self.loadingInputs--;
+
         self.updateInputValue({
             inputNum: inputNum,
             value: r.fileBuffer
         });
 
+        self.postMessage({
+            action: "fileLoaded",
+            data: {
+                inputNum: inputNum
+            }
+        });
+
         const idx = self.getLoaderWorkerIdx(r.id);
         self.loadNextFile(idx);
     } else if ("progress" in r) {
@@ -782,7 +791,7 @@ self.loadFiles = function(filesData) {
     }
 
     self.getLoadProgress();
-    self.setInput({inputNum: activeTab, silent: false});
+    self.setInput({inputNum: activeTab, silent: true});
 };
 
 /**
@@ -1025,7 +1034,7 @@ self.inputSwitch = function(switchData) {
     const currentData = currentInput.data;
     if (currentInput === undefined || currentInput === null) return;
 
-    if (typeof switchData.outputData === "object") {
+    if (typeof switchData.outputData !== "string") {
         const output = new Uint8Array(switchData.outputData),
             types = detectFileType(output);
         let type = "unknown",
@@ -1036,15 +1045,22 @@ self.inputSwitch = function(switchData) {
         }
 
         // ArrayBuffer
-        currentInput.data = {
-            fileBuffer: switchData.outputData,
-            name: `output.${ext}`,
-            size: switchData.outputData.byteLength.toLocaleString(),
-            type: type
-        };
+        self.updateInputObj({
+            inputNum: switchData.inputNum,
+            data: {
+                fileBuffer: switchData.outputData,
+                name: `output.${ext}`,
+                size: switchData.outputData.byteLength.toLocaleString(),
+                type: type
+            }
+        });
     } else {
         // String
-        currentInput.data = switchData.outputData;
+        self.updateInputValue({
+            inputNum: switchData.inputNum,
+            value: switchData.outputData,
+            force: true
+        });
     }
 
     self.postMessage({
@@ -1055,6 +1071,11 @@ self.inputSwitch = function(switchData) {
         }
     });
 
-    self.setInput({inputNum: switchData.inputNum, silent: false});
+    self.postMessage({
+        action: "fileLoaded",
+        data: {
+            inputNum: switchData.inputNum
+        }
+    });
 
 };