瀏覽代碼

ESM: Rewritten src/web/ in ESM format.

n1474335 7 年之前
父節點
當前提交
07715bd167

+ 1 - 0
src/core/operations/FromHex.mjs

@@ -53,6 +53,7 @@ class FromHex extends Operation {
      * @returns {Object[]} pos
      */
     highlight(pos, args) {
+        if (args[0] === "Auto") return false;
         const delim = Utils.charRep(args[0] || "Space"),
             len = delim === "\r\n" ? 1 : delim.length,
             width = len + 2;

+ 627 - 619
src/web/App.js

@@ -1,745 +1,753 @@
-import Utils from "../core/Utils";
-import {fromBase64} from "../core/lib/Base64";
-import Manager from "./Manager.js";
-import HTMLCategory from "./HTMLCategory.js";
-import HTMLOperation from "./HTMLOperation.js";
-import Split from "split.js";
-
-
 /**
- * HTML view for CyberChef responsible for building the web page and dealing with all user
- * interactions.
- *
  * @author n1474335 [n1474335@gmail.com]
  * @copyright Crown Copyright 2016
  * @license Apache-2.0
- *
- * @constructor
- * @param {CatConf[]} categories - The list of categories and operations to be populated.
- * @param {Object.<string, OpConf>} operations - The list of operation configuration objects.
- * @param {String[]} defaultFavourites - A list of default favourite operations.
- * @param {Object} options - Default setting for app options.
  */
-const App = function(categories, operations, defaultFavourites, defaultOptions) {
-    this.categories    = categories;
-    this.operations    = operations;
-    this.dfavourites   = defaultFavourites;
-    this.doptions      = defaultOptions;
-    this.options       = Object.assign({}, defaultOptions);
 
-    this.manager       = new Manager(this);
-
-    this.baking        = false;
-    this.autoBake_     = false;
-    this.autoBakePause = false;
-    this.progress      = 0;
-    this.ingId         = 0;
-};
+import Utils from "../core/Utils";
+import {fromBase64} from "../core/lib/Base64";
+import Manager from "./Manager";
+import HTMLCategory from "./HTMLCategory";
+import HTMLOperation from "./HTMLOperation";
+import Split from "split.js";
 
 
 /**
- * This function sets up the stage and creates listeners for all events.
- *
- * @fires Manager#appstart
+ * HTML view for CyberChef responsible for building the web page and dealing with all user
+ * interactions.
  */
-App.prototype.setup = function() {
-    document.dispatchEvent(this.manager.appstart);
-    this.initialiseSplitter();
-    this.loadLocalStorage();
-    this.populateOperationsList();
-    this.manager.setup();
-    this.resetLayout();
-    this.setCompileMessage();
-
-    log.debug("App loaded");
-    this.appLoaded = true;
+class App {
+
+    /**
+     * App constructor.
+     *
+     * @param {CatConf[]} categories - The list of categories and operations to be populated.
+     * @param {Object.<string, OpConf>} operations - The list of operation configuration objects.
+     * @param {String[]} defaultFavourites - A list of default favourite operations.
+     * @param {Object} options - Default setting for app options.
+     */
+    constructor(categories, operations, defaultFavourites, defaultOptions) {
+        this.categories    = categories;
+        this.operations    = operations;
+        this.dfavourites   = defaultFavourites;
+        this.doptions      = defaultOptions;
+        this.options       = Object.assign({}, defaultOptions);
+
+        this.manager       = new Manager(this);
+
+        this.baking        = false;
+        this.autoBake_     = false;
+        this.autoBakePause = false;
+        this.progress      = 0;
+        this.ingId         = 0;
+    }
 
-    this.loadURIParams();
-    this.loaded();
-};
 
+    /**
+     * This function sets up the stage and creates listeners for all events.
+     *
+     * @fires Manager#appstart
+     */
+    setup() {
+        document.dispatchEvent(this.manager.appstart);
+        this.initialiseSplitter();
+        this.loadLocalStorage();
+        this.populateOperationsList();
+        this.manager.setup();
+        this.resetLayout();
+        this.setCompileMessage();
+
+        log.debug("App loaded");
+        this.appLoaded = true;
+
+        this.loadURIParams();
+        this.loaded();
+    }
 
-/**
- * Fires once all setup activities have completed.
- *
- * @fires Manager#apploaded
- */
-App.prototype.loaded = function() {
-    // Check that both the app and the worker have loaded successfully, and that
-    // we haven't already loaded before attempting to remove the loading screen.
-    if (!this.workerLoaded || !this.appLoaded ||
-        !document.getElementById("loader-wrapper")) return;
 
-    // Trigger CSS animations to remove preloader
-    document.body.classList.add("loaded");
+    /**
+     * Fires once all setup activities have completed.
+     *
+     * @fires Manager#apploaded
+     */
+    loaded() {
+        // Check that both the app and the worker have loaded successfully, and that
+        // we haven't already loaded before attempting to remove the loading screen.
+        if (!this.workerLoaded || !this.appLoaded ||
+            !document.getElementById("loader-wrapper")) return;
 
-    // Wait for animations to complete then remove the preloader and loaded style
-    // so that the animations for existing elements don't play again.
-    setTimeout(function() {
-        document.getElementById("loader-wrapper").remove();
-        document.body.classList.remove("loaded");
-    }, 1000);
+        // Trigger CSS animations to remove preloader
+        document.body.classList.add("loaded");
 
-    // Clear the loading message interval
-    clearInterval(window.loadingMsgsInt);
+        // Wait for animations to complete then remove the preloader and loaded style
+        // so that the animations for existing elements don't play again.
+        setTimeout(function() {
+            document.getElementById("loader-wrapper").remove();
+            document.body.classList.remove("loaded");
+        }, 1000);
 
-    // Remove the loading error handler
-    window.removeEventListener("error", window.loadingErrorHandler);
+        // Clear the loading message interval
+        clearInterval(window.loadingMsgsInt);
 
-    document.dispatchEvent(this.manager.apploaded);
-};
+        // Remove the loading error handler
+        window.removeEventListener("error", window.loadingErrorHandler);
 
+        document.dispatchEvent(this.manager.apploaded);
+    }
 
-/**
- * An error handler for displaying the error to the user.
- *
- * @param {Error} err
- * @param {boolean} [logToConsole=false]
- */
-App.prototype.handleError = function(err, logToConsole) {
-    if (logToConsole) log.error(err);
-    const msg = err.displayStr || err.toString();
-    this.alert(msg, "danger", this.options.errorTimeout, !this.options.showErrors);
-};
 
+    /**
+     * An error handler for displaying the error to the user.
+     *
+     * @param {Error} err
+     * @param {boolean} [logToConsole=false]
+     */
+    handleError(err, logToConsole) {
+        if (logToConsole) log.error(err);
+        const msg = err.displayStr || err.toString();
+        this.alert(msg, "danger", this.options.errorTimeout, !this.options.showErrors);
+    }
 
-/**
- * Asks the ChefWorker to bake the current input using the current recipe.
- *
- * @param {boolean} [step] - Set to true if we should only execute one operation instead of the
- *   whole recipe.
- */
-App.prototype.bake = function(step) {
-    if (this.baking) return;
 
-    // Reset attemptHighlight flag
-    this.options.attemptHighlight = true;
+    /**
+     * Asks the ChefWorker to bake the current input using the current recipe.
+     *
+     * @param {boolean} [step] - Set to true if we should only execute one operation instead of the
+     *   whole recipe.
+     */
+    bake(step) {
+        if (this.baking) return;
+
+        // Reset attemptHighlight flag
+        this.options.attemptHighlight = true;
+
+        this.manager.worker.bake(
+            this.getInput(),        // The user's input
+            this.getRecipeConfig(), // The configuration of the recipe
+            this.options,           // Options set by the user
+            this.progress,          // The current position in the recipe
+            step                    // Whether or not to take one step or execute the whole recipe
+        );
+    }
 
-    this.manager.worker.bake(
-        this.getInput(),        // The user's input
-        this.getRecipeConfig(), // The configuration of the recipe
-        this.options,           // Options set by the user
-        this.progress,          // The current position in the recipe
-        step                    // Whether or not to take one step or execute the whole recipe
-    );
-};
 
+    /**
+     * Runs Auto Bake if it is set.
+     */
+    autoBake() {
+        // If autoBakePause is set, we are loading a full recipe (and potentially input), so there is no
+        // need to set the staleness indicator. Just exit and wait until auto bake is called after loading
+        // has completed.
+        if (this.autoBakePause) return false;
 
-/**
- * Runs Auto Bake if it is set.
- */
-App.prototype.autoBake = function() {
-    // If autoBakePause is set, we are loading a full recipe (and potentially input), so there is no
-    // need to set the staleness indicator. Just exit and wait until auto bake is called after loading
-    // has completed.
-    if (this.autoBakePause) return false;
-
-    if (this.autoBake_ && !this.baking) {
-        log.debug("Auto-baking");
-        this.bake();
-    } else {
-        this.manager.controls.showStaleIndicator();
+        if (this.autoBake_ && !this.baking) {
+            log.debug("Auto-baking");
+            this.bake();
+        } else {
+            this.manager.controls.showStaleIndicator();
+        }
     }
-};
 
 
-/**
- * Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed
- * to do a real bake.
- *
- * The output will not be modified (hence "silent" bake). This will only actually execute the recipe
- * if auto-bake is enabled, otherwise it will just wake up the ChefWorker with an empty recipe.
- */
-App.prototype.silentBake = function() {
-    let recipeConfig = [];
+    /**
+     * Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed
+     * to do a real bake.
+     *
+     * The output will not be modified (hence "silent" bake). This will only actually execute the recipe
+     * if auto-bake is enabled, otherwise it will just wake up the ChefWorker with an empty recipe.
+     */
+    silentBake() {
+        let recipeConfig = [];
+
+        if (this.autoBake_) {
+            // If auto-bake is not enabled we don't want to actually run the recipe as it may be disabled
+            // for a good reason.
+            recipeConfig = this.getRecipeConfig();
+        }
 
-    if (this.autoBake_) {
-        // If auto-bake is not enabled we don't want to actually run the recipe as it may be disabled
-        // for a good reason.
-        recipeConfig = this.getRecipeConfig();
+        this.manager.worker.silentBake(recipeConfig);
     }
 
-    this.manager.worker.silentBake(recipeConfig);
-};
-
 
-/**
- * Gets the user's input data.
- *
- * @returns {string}
- */
-App.prototype.getInput = function() {
-    return this.manager.input.get();
-};
+    /**
+     * Gets the user's input data.
+     *
+     * @returns {string}
+     */
+    getInput() {
+        return this.manager.input.get();
+    }
 
 
-/**
- * Sets the user's input data.
- *
- * @param {string} input - The string to set the input to
- */
-App.prototype.setInput = function(input) {
-    this.manager.input.set(input);
-};
+    /**
+     * Sets the user's input data.
+     *
+     * @param {string} input - The string to set the input to
+     */
+    setInput(input) {
+        this.manager.input.set(input);
+    }
 
 
-/**
- * Populates the operations accordion list with the categories and operations specified in the
- * view constructor.
- *
- * @fires Manager#oplistcreate
- */
-App.prototype.populateOperationsList = function() {
-    // Move edit button away before we overwrite it
-    document.body.appendChild(document.getElementById("edit-favourites"));
-
-    let html = "";
-    let i;
-
-    for (i = 0; i < this.categories.length; i++) {
-        const catConf = this.categories[i],
-            selected = i === 0,
-            cat = new HTMLCategory(catConf.name, selected);
-
-        for (let j = 0; j < catConf.ops.length; j++) {
-            const opName = catConf.ops[j];
-            if (!this.operations.hasOwnProperty(opName)) {
-                log.warn(`${opName} could not be found.`);
-                continue;
+    /**
+     * Populates the operations accordion list with the categories and operations specified in the
+     * view constructor.
+     *
+     * @fires Manager#oplistcreate
+     */
+    populateOperationsList() {
+        // Move edit button away before we overwrite it
+        document.body.appendChild(document.getElementById("edit-favourites"));
+
+        let html = "";
+        let i;
+
+        for (i = 0; i < this.categories.length; i++) {
+            const catConf = this.categories[i],
+                selected = i === 0,
+                cat = new HTMLCategory(catConf.name, selected);
+
+            for (let j = 0; j < catConf.ops.length; j++) {
+                const opName = catConf.ops[j];
+                if (!this.operations.hasOwnProperty(opName)) {
+                    log.warn(`${opName} could not be found.`);
+                    continue;
+                }
+
+                const op = new HTMLOperation(opName, this.operations[opName], this, this.manager);
+                cat.addOperation(op);
             }
 
-            const op = new HTMLOperation(opName, this.operations[opName], this, this.manager);
-            cat.addOperation(op);
+            html += cat.toHtml();
         }
 
-        html += cat.toHtml();
-    }
+        document.getElementById("categories").innerHTML = html;
 
-    document.getElementById("categories").innerHTML = html;
+        const opLists = document.querySelectorAll("#categories .op-list");
 
-    const opLists = document.querySelectorAll("#categories .op-list");
+        for (i = 0; i < opLists.length; i++) {
+            opLists[i].dispatchEvent(this.manager.oplistcreate);
+        }
 
-    for (i = 0; i < opLists.length; i++) {
-        opLists[i].dispatchEvent(this.manager.oplistcreate);
+        // Add edit button to first category (Favourites)
+        document.querySelector("#categories a").appendChild(document.getElementById("edit-favourites"));
     }
 
-    // Add edit button to first category (Favourites)
-    document.querySelector("#categories a").appendChild(document.getElementById("edit-favourites"));
-};
-
 
-/**
- * Sets up the adjustable splitter to allow the user to resize areas of the page.
- */
-App.prototype.initialiseSplitter = function() {
-    this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
-        sizes: [20, 30, 50],
-        minSize: [240, 325, 450],
-        gutterSize: 4,
-        onDrag: function() {
-            this.manager.controls.adjustWidth();
-            this.manager.output.adjustWidth();
-        }.bind(this)
-    });
-
-    this.ioSplitter = Split(["#input", "#output"], {
-        direction: "vertical",
-        gutterSize: 4,
-    });
-
-    this.resetLayout();
-};
+    /**
+     * Sets up the adjustable splitter to allow the user to resize areas of the page.
+     */
+    initialiseSplitter() {
+        this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
+            sizes: [20, 30, 50],
+            minSize: [240, 325, 450],
+            gutterSize: 4,
+            onDrag: function() {
+                this.manager.controls.adjustWidth();
+                this.manager.output.adjustWidth();
+            }.bind(this)
+        });
 
+        this.ioSplitter = Split(["#input", "#output"], {
+            direction: "vertical",
+            gutterSize: 4,
+        });
 
-/**
- * Loads the information previously saved to the HTML5 local storage object so that user options
- * and favourites can be restored.
- */
-App.prototype.loadLocalStorage = function() {
-    // Load options
-    let lOptions;
-    if (this.isLocalStorageAvailable() && localStorage.options !== undefined) {
-        lOptions = JSON.parse(localStorage.options);
+        this.resetLayout();
     }
-    this.manager.options.load(lOptions);
 
-    // Load favourites
-    this.loadFavourites();
-};
 
+    /**
+     * Loads the information previously saved to the HTML5 local storage object so that user options
+     * and favourites can be restored.
+     */
+    loadLocalStorage() {
+        // Load options
+        let lOptions;
+        if (this.isLocalStorageAvailable() && localStorage.options !== undefined) {
+            lOptions = JSON.parse(localStorage.options);
+        }
+        this.manager.options.load(lOptions);
 
-/**
- * Loads the user's favourite operations from the HTML5 local storage object and populates the
- * Favourites category with them.
- * If the user currently has no saved favourites, the defaults from the view constructor are used.
- */
-App.prototype.loadFavourites = function() {
-    let favourites;
-
-    if (this.isLocalStorageAvailable()) {
-        favourites = localStorage.favourites && localStorage.favourites.length > 2 ?
-            JSON.parse(localStorage.favourites) :
-            this.dfavourites;
-        favourites = this.validFavourites(favourites);
-        this.saveFavourites(favourites);
-    } else {
-        favourites = this.dfavourites;
+        // Load favourites
+        this.loadFavourites();
     }
 
-    const favCat = this.categories.filter(function(c) {
-        return c.name === "Favourites";
-    })[0];
 
-    if (favCat) {
-        favCat.ops = favourites;
-    } else {
-        this.categories.unshift({
-            name: "Favourites",
-            ops: favourites
-        });
+    /**
+     * Loads the user's favourite operations from the HTML5 local storage object and populates the
+     * Favourites category with them.
+     * If the user currently has no saved favourites, the defaults from the view constructor are used.
+     */
+    loadFavourites() {
+        let favourites;
+
+        if (this.isLocalStorageAvailable()) {
+            favourites = localStorage.favourites && localStorage.favourites.length > 2 ?
+                JSON.parse(localStorage.favourites) :
+                this.dfavourites;
+            favourites = this.validFavourites(favourites);
+            this.saveFavourites(favourites);
+        } else {
+            favourites = this.dfavourites;
+        }
+
+        const favCat = this.categories.filter(function(c) {
+            return c.name === "Favourites";
+        })[0];
+
+        if (favCat) {
+            favCat.ops = favourites;
+        } else {
+            this.categories.unshift({
+                name: "Favourites",
+                ops: favourites
+            });
+        }
     }
-};
 
 
-/**
- * Filters the list of favourite operations that the user had stored and removes any that are no
- * longer available. The user is notified if this is the case.
+    /**
+     * Filters the list of favourite operations that the user had stored and removes any that are no
+     * longer available. The user is notified if this is the case.
 
- * @param {string[]} favourites - A list of the user's favourite operations
- * @returns {string[]} A list of the valid favourites
- */
-App.prototype.validFavourites = function(favourites) {
-    const validFavs = [];
-    for (let i = 0; i < favourites.length; i++) {
-        if (this.operations.hasOwnProperty(favourites[i])) {
-            validFavs.push(favourites[i]);
-        } else {
-            this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) +
-                "\" is no longer available. It has been removed from your favourites.", "info");
+     * @param {string[]} favourites - A list of the user's favourite operations
+     * @returns {string[]} A list of the valid favourites
+     */
+    validFavourites(favourites) {
+        const validFavs = [];
+        for (let i = 0; i < favourites.length; i++) {
+            if (this.operations.hasOwnProperty(favourites[i])) {
+                validFavs.push(favourites[i]);
+            } else {
+                this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) +
+                    "\" is no longer available. It has been removed from your favourites.", "info");
+            }
         }
+        return validFavs;
     }
-    return validFavs;
-};
 
 
-/**
- * Saves a list of favourite operations to the HTML5 local storage object.
- *
- * @param {string[]} favourites - A list of the user's favourite operations
- */
-App.prototype.saveFavourites = function(favourites) {
-    if (!this.isLocalStorageAvailable()) {
-        this.alert(
-            "Your security settings do not allow access to local storage so your favourites cannot be saved.",
-            "danger",
-            5000
-        );
-        return false;
+    /**
+     * Saves a list of favourite operations to the HTML5 local storage object.
+     *
+     * @param {string[]} favourites - A list of the user's favourite operations
+     */
+    saveFavourites(favourites) {
+        if (!this.isLocalStorageAvailable()) {
+            this.alert(
+                "Your security settings do not allow access to local storage so your favourites cannot be saved.",
+                "danger",
+                5000
+            );
+            return false;
+        }
+
+        localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites)));
     }
 
-    localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites)));
-};
 
+    /**
+     * Resets favourite operations back to the default as specified in the view constructor and
+     * refreshes the operation list.
+     */
+    resetFavourites() {
+        this.saveFavourites(this.dfavourites);
+        this.loadFavourites();
+        this.populateOperationsList();
+        this.manager.recipe.initialiseOperationDragNDrop();
+    }
 
-/**
- * Resets favourite operations back to the default as specified in the view constructor and
- * refreshes the operation list.
- */
-App.prototype.resetFavourites = function() {
-    this.saveFavourites(this.dfavourites);
-    this.loadFavourites();
-    this.populateOperationsList();
-    this.manager.recipe.initialiseOperationDragNDrop();
-};
 
+    /**
+     * Adds an operation to the user's favourites.
+     *
+     * @param {string} name - The name of the operation
+     */
+    addFavourite(name) {
+        const favourites = JSON.parse(localStorage.favourites);
 
-/**
- * Adds an operation to the user's favourites.
- *
- * @param {string} name - The name of the operation
- */
-App.prototype.addFavourite = function(name) {
-    const favourites = JSON.parse(localStorage.favourites);
+        if (favourites.indexOf(name) >= 0) {
+            this.alert("'" + name + "' is already in your favourites", "info", 2000);
+            return;
+        }
 
-    if (favourites.indexOf(name) >= 0) {
-        this.alert("'" + name + "' is already in your favourites", "info", 2000);
-        return;
+        favourites.push(name);
+        this.saveFavourites(favourites);
+        this.loadFavourites();
+        this.populateOperationsList();
+        this.manager.recipe.initialiseOperationDragNDrop();
     }
 
-    favourites.push(name);
-    this.saveFavourites(favourites);
-    this.loadFavourites();
-    this.populateOperationsList();
-    this.manager.recipe.initialiseOperationDragNDrop();
-};
 
+    /**
+     * Checks for input and recipe in the URI parameters and loads them if present.
+     */
+    loadURIParams() {
+        // Load query string or hash from URI (depending on which is populated)
+        // We prefer getting the hash by splitting the href rather than referencing
+        // location.hash as some browsers (Firefox) automatically URL decode it,
+        // which cause issues.
+        const params = window.location.search ||
+            window.location.href.split("#")[1] ||
+            window.location.hash;
+        this.uriParams = Utils.parseURIParams(params);
+        this.autoBakePause = true;
+
+        // Read in recipe from URI params
+        if (this.uriParams.recipe) {
+            try {
+                const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
+                this.setRecipeConfig(recipeConfig);
+            } catch (err) {}
+        } else if (this.uriParams.op) {
+            // If there's no recipe, look for single operations
+            this.manager.recipe.clearRecipe();
+
+            // Search for nearest match and add it
+            const matchedOps = this.manager.ops.filterOperations(this.uriParams.op, false);
+            if (matchedOps.length) {
+                this.manager.recipe.addOperation(matchedOps[0].name);
+            }
 
-/**
- * Checks for input and recipe in the URI parameters and loads them if present.
- */
-App.prototype.loadURIParams = function() {
-    // Load query string or hash from URI (depending on which is populated)
-    // We prefer getting the hash by splitting the href rather than referencing
-    // location.hash as some browsers (Firefox) automatically URL decode it,
-    // which cause issues.
-    const params = window.location.search ||
-        window.location.href.split("#")[1] ||
-        window.location.hash;
-    this.uriParams = Utils.parseURIParams(params);
-    this.autoBakePause = true;
-
-    // Read in recipe from URI params
-    if (this.uriParams.recipe) {
-        try {
-            const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
-            this.setRecipeConfig(recipeConfig);
-        } catch (err) {}
-    } else if (this.uriParams.op) {
-        // If there's no recipe, look for single operations
-        this.manager.recipe.clearRecipe();
-
-        // Search for nearest match and add it
-        const matchedOps = this.manager.ops.filterOperations(this.uriParams.op, false);
-        if (matchedOps.length) {
-            this.manager.recipe.addOperation(matchedOps[0].name);
-        }
+            // Populate search with the string
+            const search = document.getElementById("search");
 
-        // Populate search with the string
-        const search = document.getElementById("search");
+            search.value = this.uriParams.op;
+            search.dispatchEvent(new Event("search"));
+        }
 
-        search.value = this.uriParams.op;
-        search.dispatchEvent(new Event("search"));
-    }
+        // Read in input data from URI params
+        if (this.uriParams.input) {
+            try {
+                const inputData = fromBase64(this.uriParams.input);
+                this.setInput(inputData);
+            } catch (err) {}
+        }
 
-    // Read in input data from URI params
-    if (this.uriParams.input) {
-        try {
-            const inputData = fromBase64(this.uriParams.input);
-            this.setInput(inputData);
-        } catch (err) {}
+        this.autoBakePause = false;
+        this.autoBake();
     }
 
-    this.autoBakePause = false;
-    this.autoBake();
-};
 
+    /**
+     * Returns the next ingredient ID and increments it for next time.
+     *
+     * @returns {number}
+     */
+    nextIngId() {
+        return this.ingId++;
+    }
 
-/**
- * Returns the next ingredient ID and increments it for next time.
- *
- * @returns {number}
- */
-App.prototype.nextIngId = function() {
-    return this.ingId++;
-};
 
+    /**
+     * Gets the current recipe configuration.
+     *
+     * @returns {Object[]}
+     */
+    getRecipeConfig() {
+        return this.manager.recipe.getConfig();
+    }
 
-/**
- * Gets the current recipe configuration.
- *
- * @returns {Object[]}
- */
-App.prototype.getRecipeConfig = function() {
-    return this.manager.recipe.getConfig();
-};
 
+    /**
+     * Given a recipe configuration, sets the recipe to that configuration.
+     *
+     * @fires Manager#statechange
+     * @param {Object[]} recipeConfig - The recipe configuration
+     */
+    setRecipeConfig(recipeConfig) {
+        document.getElementById("rec-list").innerHTML = null;
+
+        // Pause auto-bake while loading but don't modify `this.autoBake_`
+        // otherwise `manualBake` cannot trigger.
+        this.autoBakePause = true;
+
+        for (let i = 0; i < recipeConfig.length; i++) {
+            const item = this.manager.recipe.addOperation(recipeConfig[i].op);
+
+            // Populate arguments
+            const args = item.querySelectorAll(".arg");
+            for (let j = 0; j < args.length; j++) {
+                if (recipeConfig[i].args[j] === undefined) continue;
+                if (args[j].getAttribute("type") === "checkbox") {
+                    // checkbox
+                    args[j].checked = recipeConfig[i].args[j];
+                } else if (args[j].classList.contains("toggle-string")) {
+                    // toggleString
+                    args[j].value = recipeConfig[i].args[j].string;
+                    args[j].previousSibling.children[0].innerHTML =
+                        Utils.escapeHtml(recipeConfig[i].args[j].option) +
+                        " <span class='caret'></span>";
+                } else {
+                    // all others
+                    args[j].value = recipeConfig[i].args[j];
+                }
+            }
 
-/**
- * Given a recipe configuration, sets the recipe to that configuration.
- *
- * @fires Manager#statechange
- * @param {Object[]} recipeConfig - The recipe configuration
- */
-App.prototype.setRecipeConfig = function(recipeConfig) {
-    document.getElementById("rec-list").innerHTML = null;
-
-    // Pause auto-bake while loading but don't modify `this.autoBake_`
-    // otherwise `manualBake` cannot trigger.
-    this.autoBakePause = true;
-
-    for (let i = 0; i < recipeConfig.length; i++) {
-        const item = this.manager.recipe.addOperation(recipeConfig[i].op);
-
-        // Populate arguments
-        const args = item.querySelectorAll(".arg");
-        for (let j = 0; j < args.length; j++) {
-            if (recipeConfig[i].args[j] === undefined) continue;
-            if (args[j].getAttribute("type") === "checkbox") {
-                // checkbox
-                args[j].checked = recipeConfig[i].args[j];
-            } else if (args[j].classList.contains("toggle-string")) {
-                // toggleString
-                args[j].value = recipeConfig[i].args[j].string;
-                args[j].previousSibling.children[0].innerHTML =
-                    Utils.escapeHtml(recipeConfig[i].args[j].option) +
-                    " <span class='caret'></span>";
-            } else {
-                // all others
-                args[j].value = recipeConfig[i].args[j];
+            // Set disabled and breakpoint
+            if (recipeConfig[i].disabled) {
+                item.querySelector(".disable-icon").click();
+            }
+            if (recipeConfig[i].breakpoint) {
+                item.querySelector(".breakpoint").click();
             }
-        }
 
-        // Set disabled and breakpoint
-        if (recipeConfig[i].disabled) {
-            item.querySelector(".disable-icon").click();
-        }
-        if (recipeConfig[i].breakpoint) {
-            item.querySelector(".breakpoint").click();
+            this.progress = 0;
         }
 
-        this.progress = 0;
+        // Unpause auto bake
+        this.autoBakePause = false;
     }
 
-    // Unpause auto bake
-    this.autoBakePause = false;
-};
 
+    /**
+     * Resets the splitter positions to default.
+     */
+    resetLayout() {
+        this.columnSplitter.setSizes([20, 30, 50]);
+        this.ioSplitter.setSizes([50, 50]);
 
-/**
- * Resets the splitter positions to default.
- */
-App.prototype.resetLayout = function() {
-    this.columnSplitter.setSizes([20, 30, 50]);
-    this.ioSplitter.setSizes([50, 50]);
+        this.manager.controls.adjustWidth();
+        this.manager.output.adjustWidth();
+    }
 
-    this.manager.controls.adjustWidth();
-    this.manager.output.adjustWidth();
-};
 
+    /**
+     * Sets the compile message.
+     */
+    setCompileMessage() {
+        // Display time since last build and compile message
+        const now = new Date(),
+            timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime);
 
-/**
- * Sets the compile message.
- */
-App.prototype.setCompileMessage = function() {
-    // Display time since last build and compile message
-    const now = new Date(),
-        timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime);
+        // Calculate previous version to compare to
+        const prev = PKG_VERSION.split(".").map(n => {
+            return parseInt(n, 10);
+        });
+        if (prev[2] > 0) prev[2]--;
+        else if (prev[1] > 0) prev[1]--;
+        else prev[0]--;
 
-    // Calculate previous version to compare to
-    const prev = PKG_VERSION.split(".").map(n => {
-        return parseInt(n, 10);
-    });
-    if (prev[2] > 0) prev[2]--;
-    else if (prev[1] > 0) prev[1]--;
-    else prev[0]--;
+        const compareURL = `https://github.com/gchq/CyberChef/compare/v${prev.join(".")}...v${PKG_VERSION}`;
 
-    const compareURL = `https://github.com/gchq/CyberChef/compare/v${prev.join(".")}...v${PKG_VERSION}`;
+        let compileInfo = `<a href='${compareURL}'>Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago</a>`;
 
-    let compileInfo = `<a href='${compareURL}'>Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago</a>`;
+        if (window.compileMessage !== "") {
+            compileInfo += " - " + window.compileMessage;
+        }
 
-    if (window.compileMessage !== "") {
-        compileInfo += " - " + window.compileMessage;
+        document.getElementById("notice").innerHTML = compileInfo;
     }
 
-    document.getElementById("notice").innerHTML = compileInfo;
-};
 
-
-/**
- * Determines whether the browser supports Local Storage and if it is accessible.
- *
- * @returns {boolean}
- */
-App.prototype.isLocalStorageAvailable = function() {
-    try {
-        if (!localStorage) return false;
-        return true;
-    } catch (err) {
-        // Access to LocalStorage is denied
-        return false;
+    /**
+     * Determines whether the browser supports Local Storage and if it is accessible.
+     *
+     * @returns {boolean}
+     */
+    isLocalStorageAvailable() {
+        try {
+            if (!localStorage) return false;
+            return true;
+        } catch (err) {
+            // Access to LocalStorage is denied
+            return false;
+        }
     }
-};
 
 
-/**
- * Pops up a message to the user and writes it to the console log.
- *
- * @param {string} str - The message to display (HTML supported)
- * @param {string} style - The colour of the popup
- *     "danger"  = red
- *     "warning" = amber
- *     "info"    = blue
- *     "success" = green
- * @param {number} timeout - The number of milliseconds before the popup closes automatically
- *     0 for never (until the user closes it)
- * @param {boolean} [silent=false] - Don't show the message in the popup, only print it to the
- *     console
- *
- * @example
- * // Pops up a red box with the message "[current time] Error: Something has gone wrong!"
- * // that will need to be dismissed by the user.
- * this.alert("Error: Something has gone wrong!", "danger", 0);
- *
- * // Pops up a blue information box with the message "[current time] Happy Christmas!"
- * // that will disappear after 5 seconds.
- * this.alert("Happy Christmas!", "info", 5000);
- */
-App.prototype.alert = function(str, style, timeout, silent) {
-    const time = new Date();
-
-    log.info("[" + time.toLocaleString() + "] " + str);
-    if (silent) return;
-
-    style = style || "danger";
-    timeout = timeout || 0;
-
-    const alertEl = document.getElementById("alert"),
-        alertContent = document.getElementById("alert-content");
-
-    alertEl.classList.remove("alert-danger");
-    alertEl.classList.remove("alert-warning");
-    alertEl.classList.remove("alert-info");
-    alertEl.classList.remove("alert-success");
-    alertEl.classList.add("alert-" + style);
-
-    // If the box hasn't been closed, append to it rather than replacing
-    if (alertEl.style.display === "block") {
-        alertContent.innerHTML +=
-            "<br><br>[" + time.toLocaleTimeString() + "] " + str;
-    } else {
-        alertContent.innerHTML =
-            "[" + time.toLocaleTimeString() + "] " + str;
-    }
+    /**
+     * Pops up a message to the user and writes it to the console log.
+     *
+     * @param {string} str - The message to display (HTML supported)
+     * @param {string} style - The colour of the popup
+     *     "danger"  = red
+     *     "warning" = amber
+     *     "info"    = blue
+     *     "success" = green
+     * @param {number} timeout - The number of milliseconds before the popup closes automatically
+     *     0 for never (until the user closes it)
+     * @param {boolean} [silent=false] - Don't show the message in the popup, only print it to the
+     *     console
+     *
+     * @example
+     * // Pops up a red box with the message "[current time] Error: Something has gone wrong!"
+     * // that will need to be dismissed by the user.
+     * this.alert("Error: Something has gone wrong!", "danger", 0);
+     *
+     * // Pops up a blue information box with the message "[current time] Happy Christmas!"
+     * // that will disappear after 5 seconds.
+     * this.alert("Happy Christmas!", "info", 5000);
+     */
+    alert(str, style, timeout, silent) {
+        const time = new Date();
+
+        log.info("[" + time.toLocaleString() + "] " + str);
+        if (silent) return;
+
+        style = style || "danger";
+        timeout = timeout || 0;
+
+        const alertEl = document.getElementById("alert"),
+            alertContent = document.getElementById("alert-content");
+
+        alertEl.classList.remove("alert-danger");
+        alertEl.classList.remove("alert-warning");
+        alertEl.classList.remove("alert-info");
+        alertEl.classList.remove("alert-success");
+        alertEl.classList.add("alert-" + style);
+
+        // If the box hasn't been closed, append to it rather than replacing
+        if (alertEl.style.display === "block") {
+            alertContent.innerHTML +=
+                "<br><br>[" + time.toLocaleTimeString() + "] " + str;
+        } else {
+            alertContent.innerHTML =
+                "[" + time.toLocaleTimeString() + "] " + str;
+        }
 
-    // Stop the animation if it is in progress
-    $("#alert").stop();
-    alertEl.style.display = "block";
-    alertEl.style.opacity = 1;
+        // Stop the animation if it is in progress
+        $("#alert").stop();
+        alertEl.style.display = "block";
+        alertEl.style.opacity = 1;
 
-    if (timeout > 0) {
-        clearTimeout(this.alertTimeout);
-        this.alertTimeout = setTimeout(function(){
-            $("#alert").slideUp(100);
-        }, timeout);
+        if (timeout > 0) {
+            clearTimeout(this.alertTimeout);
+            this.alertTimeout = setTimeout(function(){
+                $("#alert").slideUp(100);
+            }, timeout);
+        }
     }
-};
 
 
-/**
- * Pops up a box asking the user a question and sending the answer to a specified callback function.
- *
- * @param {string} title - The title of the box
- * @param {string} body - The question (HTML supported)
- * @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);});
- */
-App.prototype.confirm = function(title, body, callback, scope) {
-    scope = scope || this;
-    document.getElementById("confirm-title").innerHTML = title;
-    document.getElementById("confirm-body").innerHTML = body;
-    document.getElementById("confirm-modal").style.display = "block";
-
-    this.confirmClosed = false;
-    $("#confirm-modal").modal()
-        .one("show.bs.modal", function(e) {
-            this.confirmClosed = false;
-        }.bind(this))
-        .one("click", "#confirm-yes", function() {
-            this.confirmClosed = true;
-            callback.bind(scope)(true);
-            $("#confirm-modal").modal("hide");
-        }.bind(this))
-        .one("hide.bs.modal", function(e) {
-            if (!this.confirmClosed)
-                callback.bind(scope)(false);
-            this.confirmClosed = true;
-        }.bind(this));
-};
+    /**
+     * Pops up a box asking the user a question and sending the answer to a specified callback function.
+     *
+     * @param {string} title - The title of the box
+     * @param {string} body - The question (HTML supported)
+     * @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);});
+     */
+    confirm(title, body, callback, scope) {
+        scope = scope || this;
+        document.getElementById("confirm-title").innerHTML = title;
+        document.getElementById("confirm-body").innerHTML = body;
+        document.getElementById("confirm-modal").style.display = "block";
+
+        this.confirmClosed = false;
+        $("#confirm-modal").modal()
+            .one("show.bs.modal", function(e) {
+                this.confirmClosed = false;
+            }.bind(this))
+            .one("click", "#confirm-yes", function() {
+                this.confirmClosed = true;
+                callback.bind(scope)(true);
+                $("#confirm-modal").modal("hide");
+            }.bind(this))
+            .one("hide.bs.modal", function(e) {
+                if (!this.confirmClosed)
+                    callback.bind(scope)(false);
+                this.confirmClosed = true;
+            }.bind(this));
+    }
 
 
-/**
- * Handler for the alert close button click event.
- * Closes the alert box.
- */
-App.prototype.alertCloseClick = function() {
-    document.getElementById("alert").style.display = "none";
-};
+    /**
+     * Handler for the alert close button click event.
+     * Closes the alert box.
+     */
+    alertCloseClick() {
+        document.getElementById("alert").style.display = "none";
+    }
 
 
-/**
- * Handler for CyerChef statechange events.
- * Fires whenever the input or recipe changes in any way.
- *
- * @listens Manager#statechange
- * @param {event} e
- */
-App.prototype.stateChange = function(e) {
-    this.autoBake();
-
-    // Set title
-    const recipeConfig = this.getRecipeConfig();
-    let title = "CyberChef";
-    if (recipeConfig.length === 1) {
-        title = `${recipeConfig[0].op} - ${title}`;
-    } else if (recipeConfig.length > 1) {
-        // See how long the full recipe is
-        const ops = recipeConfig.map(op => op.op).join(", ");
-        if (ops.length < 45) {
-            title = `${ops} - ${title}`;
-        } else {
-            // If it's too long, just use the first one and say how many more there are
-            title = `${recipeConfig[0].op}, ${recipeConfig.length - 1} more - ${title}`;
+    /**
+     * Handler for CyerChef statechange events.
+     * Fires whenever the input or recipe changes in any way.
+     *
+     * @listens Manager#statechange
+     * @param {event} e
+     */
+    stateChange(e) {
+        this.autoBake();
+
+        // Set title
+        const recipeConfig = this.getRecipeConfig();
+        let title = "CyberChef";
+        if (recipeConfig.length === 1) {
+            title = `${recipeConfig[0].op} - ${title}`;
+        } else if (recipeConfig.length > 1) {
+            // See how long the full recipe is
+            const ops = recipeConfig.map(op => op.op).join(", ");
+            if (ops.length < 45) {
+                title = `${ops} - ${title}`;
+            } else {
+                // If it's too long, just use the first one and say how many more there are
+                title = `${recipeConfig[0].op}, ${recipeConfig.length - 1} more - ${title}`;
+            }
         }
-    }
-    document.title = title;
+        document.title = title;
 
-    // Update the current history state (not creating a new one)
-    if (this.options.updateUrl) {
-        this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig);
-        window.history.replaceState({}, title, this.lastStateUrl);
+        // Update the current history state (not creating a new one)
+        if (this.options.updateUrl) {
+            this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig);
+            window.history.replaceState({}, title, this.lastStateUrl);
+        }
     }
-};
 
 
-/**
- * Handler for the history popstate event.
- * Reloads parameters from the URL.
- *
- * @param {event} e
- */
-App.prototype.popState = function(e) {
-    this.loadURIParams();
-};
+    /**
+     * Handler for the history popstate event.
+     * Reloads parameters from the URL.
+     *
+     * @param {event} e
+     */
+    popState(e) {
+        this.loadURIParams();
+    }
 
 
-/**
- * Function to call an external API from this view.
- */
-App.prototype.callApi = function(url, type, data, dataType, contentType) {
-    type = type || "POST";
-    data = data || {};
-    dataType = dataType || undefined;
-    contentType = contentType || "application/json";
-
-    let response = null,
-        success = false;
-
-    $.ajax({
-        url: url,
-        async: false,
-        type: type,
-        data: data,
-        dataType: dataType,
-        contentType: contentType,
-        success: function(data) {
-            success = true;
-            response = data;
-        },
-        error: function(data) {
+    /**
+     * Function to call an external API from this view.
+     */
+    callApi(url, type, data, dataType, contentType) {
+        type = type || "POST";
+        data = data || {};
+        dataType = dataType || undefined;
+        contentType = contentType || "application/json";
+
+        let response = null,
             success = false;
-            response = data;
-        },
-    });
-
-    return {
-        success: success,
-        response: response
-    };
-};
+
+        $.ajax({
+            url: url,
+            async: false,
+            type: type,
+            data: data,
+            dataType: dataType,
+            contentType: contentType,
+            success: function(data) {
+                success = true;
+                response = data;
+            },
+            error: function(data) {
+                success = false;
+                response = data;
+            },
+        });
+
+        return {
+            success: success,
+            response: response
+        };
+    }
+
+}
 
 export default App;

+ 753 - 0
src/web/App.mjs

@@ -0,0 +1,753 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import Utils from "../core/Utils";
+import {fromBase64} from "../core/lib/Base64";
+import Manager from "./Manager";
+import HTMLCategory from "./HTMLCategory";
+import HTMLOperation from "./HTMLOperation";
+import Split from "split.js";
+
+
+/**
+ * HTML view for CyberChef responsible for building the web page and dealing with all user
+ * interactions.
+ */
+class App {
+
+    /**
+     * App constructor.
+     *
+     * @param {CatConf[]} categories - The list of categories and operations to be populated.
+     * @param {Object.<string, OpConf>} operations - The list of operation configuration objects.
+     * @param {String[]} defaultFavourites - A list of default favourite operations.
+     * @param {Object} options - Default setting for app options.
+     */
+    constructor(categories, operations, defaultFavourites, defaultOptions) {
+        this.categories    = categories;
+        this.operations    = operations;
+        this.dfavourites   = defaultFavourites;
+        this.doptions      = defaultOptions;
+        this.options       = Object.assign({}, defaultOptions);
+
+        this.manager       = new Manager(this);
+
+        this.baking        = false;
+        this.autoBake_     = false;
+        this.autoBakePause = false;
+        this.progress      = 0;
+        this.ingId         = 0;
+    }
+
+
+    /**
+     * This function sets up the stage and creates listeners for all events.
+     *
+     * @fires Manager#appstart
+     */
+    setup() {
+        document.dispatchEvent(this.manager.appstart);
+        this.initialiseSplitter();
+        this.loadLocalStorage();
+        this.populateOperationsList();
+        this.manager.setup();
+        this.resetLayout();
+        this.setCompileMessage();
+
+        log.debug("App loaded");
+        this.appLoaded = true;
+
+        this.loadURIParams();
+        this.loaded();
+    }
+
+
+    /**
+     * Fires once all setup activities have completed.
+     *
+     * @fires Manager#apploaded
+     */
+    loaded() {
+        // Check that both the app and the worker have loaded successfully, and that
+        // we haven't already loaded before attempting to remove the loading screen.
+        if (!this.workerLoaded || !this.appLoaded ||
+            !document.getElementById("loader-wrapper")) return;
+
+        // Trigger CSS animations to remove preloader
+        document.body.classList.add("loaded");
+
+        // Wait for animations to complete then remove the preloader and loaded style
+        // so that the animations for existing elements don't play again.
+        setTimeout(function() {
+            document.getElementById("loader-wrapper").remove();
+            document.body.classList.remove("loaded");
+        }, 1000);
+
+        // Clear the loading message interval
+        clearInterval(window.loadingMsgsInt);
+
+        // Remove the loading error handler
+        window.removeEventListener("error", window.loadingErrorHandler);
+
+        document.dispatchEvent(this.manager.apploaded);
+    }
+
+
+    /**
+     * An error handler for displaying the error to the user.
+     *
+     * @param {Error} err
+     * @param {boolean} [logToConsole=false]
+     */
+    handleError(err, logToConsole) {
+        if (logToConsole) log.error(err);
+        const msg = err.displayStr || err.toString();
+        this.alert(msg, "danger", this.options.errorTimeout, !this.options.showErrors);
+    }
+
+
+    /**
+     * Asks the ChefWorker to bake the current input using the current recipe.
+     *
+     * @param {boolean} [step] - Set to true if we should only execute one operation instead of the
+     *   whole recipe.
+     */
+    bake(step) {
+        if (this.baking) return;
+
+        // Reset attemptHighlight flag
+        this.options.attemptHighlight = true;
+
+        this.manager.worker.bake(
+            this.getInput(),        // The user's input
+            this.getRecipeConfig(), // The configuration of the recipe
+            this.options,           // Options set by the user
+            this.progress,          // The current position in the recipe
+            step                    // Whether or not to take one step or execute the whole recipe
+        );
+    }
+
+
+    /**
+     * Runs Auto Bake if it is set.
+     */
+    autoBake() {
+        // If autoBakePause is set, we are loading a full recipe (and potentially input), so there is no
+        // need to set the staleness indicator. Just exit and wait until auto bake is called after loading
+        // has completed.
+        if (this.autoBakePause) return false;
+
+        if (this.autoBake_ && !this.baking) {
+            log.debug("Auto-baking");
+            this.bake();
+        } else {
+            this.manager.controls.showStaleIndicator();
+        }
+    }
+
+
+    /**
+     * Runs a silent bake, forcing the browser to load and cache all the relevant JavaScript code needed
+     * to do a real bake.
+     *
+     * The output will not be modified (hence "silent" bake). This will only actually execute the recipe
+     * if auto-bake is enabled, otherwise it will just wake up the ChefWorker with an empty recipe.
+     */
+    silentBake() {
+        let recipeConfig = [];
+
+        if (this.autoBake_) {
+            // If auto-bake is not enabled we don't want to actually run the recipe as it may be disabled
+            // for a good reason.
+            recipeConfig = this.getRecipeConfig();
+        }
+
+        this.manager.worker.silentBake(recipeConfig);
+    }
+
+
+    /**
+     * Gets the user's input data.
+     *
+     * @returns {string}
+     */
+    getInput() {
+        return this.manager.input.get();
+    }
+
+
+    /**
+     * Sets the user's input data.
+     *
+     * @param {string} input - The string to set the input to
+     */
+    setInput(input) {
+        this.manager.input.set(input);
+    }
+
+
+    /**
+     * Populates the operations accordion list with the categories and operations specified in the
+     * view constructor.
+     *
+     * @fires Manager#oplistcreate
+     */
+    populateOperationsList() {
+        // Move edit button away before we overwrite it
+        document.body.appendChild(document.getElementById("edit-favourites"));
+
+        let html = "";
+        let i;
+
+        for (i = 0; i < this.categories.length; i++) {
+            const catConf = this.categories[i],
+                selected = i === 0,
+                cat = new HTMLCategory(catConf.name, selected);
+
+            for (let j = 0; j < catConf.ops.length; j++) {
+                const opName = catConf.ops[j];
+                if (!this.operations.hasOwnProperty(opName)) {
+                    log.warn(`${opName} could not be found.`);
+                    continue;
+                }
+
+                const op = new HTMLOperation(opName, this.operations[opName], this, this.manager);
+                cat.addOperation(op);
+            }
+
+            html += cat.toHtml();
+        }
+
+        document.getElementById("categories").innerHTML = html;
+
+        const opLists = document.querySelectorAll("#categories .op-list");
+
+        for (i = 0; i < opLists.length; i++) {
+            opLists[i].dispatchEvent(this.manager.oplistcreate);
+        }
+
+        // Add edit button to first category (Favourites)
+        document.querySelector("#categories a").appendChild(document.getElementById("edit-favourites"));
+    }
+
+
+    /**
+     * Sets up the adjustable splitter to allow the user to resize areas of the page.
+     */
+    initialiseSplitter() {
+        this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
+            sizes: [20, 30, 50],
+            minSize: [240, 325, 450],
+            gutterSize: 4,
+            onDrag: function() {
+                this.manager.controls.adjustWidth();
+                this.manager.output.adjustWidth();
+            }.bind(this)
+        });
+
+        this.ioSplitter = Split(["#input", "#output"], {
+            direction: "vertical",
+            gutterSize: 4,
+        });
+
+        this.resetLayout();
+    }
+
+
+    /**
+     * Loads the information previously saved to the HTML5 local storage object so that user options
+     * and favourites can be restored.
+     */
+    loadLocalStorage() {
+        // Load options
+        let lOptions;
+        if (this.isLocalStorageAvailable() && localStorage.options !== undefined) {
+            lOptions = JSON.parse(localStorage.options);
+        }
+        this.manager.options.load(lOptions);
+
+        // Load favourites
+        this.loadFavourites();
+    }
+
+
+    /**
+     * Loads the user's favourite operations from the HTML5 local storage object and populates the
+     * Favourites category with them.
+     * If the user currently has no saved favourites, the defaults from the view constructor are used.
+     */
+    loadFavourites() {
+        let favourites;
+
+        if (this.isLocalStorageAvailable()) {
+            favourites = localStorage.favourites && localStorage.favourites.length > 2 ?
+                JSON.parse(localStorage.favourites) :
+                this.dfavourites;
+            favourites = this.validFavourites(favourites);
+            this.saveFavourites(favourites);
+        } else {
+            favourites = this.dfavourites;
+        }
+
+        const favCat = this.categories.filter(function(c) {
+            return c.name === "Favourites";
+        })[0];
+
+        if (favCat) {
+            favCat.ops = favourites;
+        } else {
+            this.categories.unshift({
+                name: "Favourites",
+                ops: favourites
+            });
+        }
+    }
+
+
+    /**
+     * Filters the list of favourite operations that the user had stored and removes any that are no
+     * longer available. The user is notified if this is the case.
+
+     * @param {string[]} favourites - A list of the user's favourite operations
+     * @returns {string[]} A list of the valid favourites
+     */
+    validFavourites(favourites) {
+        const validFavs = [];
+        for (let i = 0; i < favourites.length; i++) {
+            if (this.operations.hasOwnProperty(favourites[i])) {
+                validFavs.push(favourites[i]);
+            } else {
+                this.alert("The operation \"" + Utils.escapeHtml(favourites[i]) +
+                    "\" is no longer available. It has been removed from your favourites.", "info");
+            }
+        }
+        return validFavs;
+    }
+
+
+    /**
+     * Saves a list of favourite operations to the HTML5 local storage object.
+     *
+     * @param {string[]} favourites - A list of the user's favourite operations
+     */
+    saveFavourites(favourites) {
+        if (!this.isLocalStorageAvailable()) {
+            this.alert(
+                "Your security settings do not allow access to local storage so your favourites cannot be saved.",
+                "danger",
+                5000
+            );
+            return false;
+        }
+
+        localStorage.setItem("favourites", JSON.stringify(this.validFavourites(favourites)));
+    }
+
+
+    /**
+     * Resets favourite operations back to the default as specified in the view constructor and
+     * refreshes the operation list.
+     */
+    resetFavourites() {
+        this.saveFavourites(this.dfavourites);
+        this.loadFavourites();
+        this.populateOperationsList();
+        this.manager.recipe.initialiseOperationDragNDrop();
+    }
+
+
+    /**
+     * Adds an operation to the user's favourites.
+     *
+     * @param {string} name - The name of the operation
+     */
+    addFavourite(name) {
+        const favourites = JSON.parse(localStorage.favourites);
+
+        if (favourites.indexOf(name) >= 0) {
+            this.alert("'" + name + "' is already in your favourites", "info", 2000);
+            return;
+        }
+
+        favourites.push(name);
+        this.saveFavourites(favourites);
+        this.loadFavourites();
+        this.populateOperationsList();
+        this.manager.recipe.initialiseOperationDragNDrop();
+    }
+
+
+    /**
+     * Checks for input and recipe in the URI parameters and loads them if present.
+     */
+    loadURIParams() {
+        // Load query string or hash from URI (depending on which is populated)
+        // We prefer getting the hash by splitting the href rather than referencing
+        // location.hash as some browsers (Firefox) automatically URL decode it,
+        // which cause issues.
+        const params = window.location.search ||
+            window.location.href.split("#")[1] ||
+            window.location.hash;
+        this.uriParams = Utils.parseURIParams(params);
+        this.autoBakePause = true;
+
+        // Read in recipe from URI params
+        if (this.uriParams.recipe) {
+            try {
+                const recipeConfig = Utils.parseRecipeConfig(this.uriParams.recipe);
+                this.setRecipeConfig(recipeConfig);
+            } catch (err) {}
+        } else if (this.uriParams.op) {
+            // If there's no recipe, look for single operations
+            this.manager.recipe.clearRecipe();
+
+            // Search for nearest match and add it
+            const matchedOps = this.manager.ops.filterOperations(this.uriParams.op, false);
+            if (matchedOps.length) {
+                this.manager.recipe.addOperation(matchedOps[0].name);
+            }
+
+            // Populate search with the string
+            const search = document.getElementById("search");
+
+            search.value = this.uriParams.op;
+            search.dispatchEvent(new Event("search"));
+        }
+
+        // Read in input data from URI params
+        if (this.uriParams.input) {
+            try {
+                const inputData = fromBase64(this.uriParams.input);
+                this.setInput(inputData);
+            } catch (err) {}
+        }
+
+        this.autoBakePause = false;
+        this.autoBake();
+    }
+
+
+    /**
+     * Returns the next ingredient ID and increments it for next time.
+     *
+     * @returns {number}
+     */
+    nextIngId() {
+        return this.ingId++;
+    }
+
+
+    /**
+     * Gets the current recipe configuration.
+     *
+     * @returns {Object[]}
+     */
+    getRecipeConfig() {
+        return this.manager.recipe.getConfig();
+    }
+
+
+    /**
+     * Given a recipe configuration, sets the recipe to that configuration.
+     *
+     * @fires Manager#statechange
+     * @param {Object[]} recipeConfig - The recipe configuration
+     */
+    setRecipeConfig(recipeConfig) {
+        document.getElementById("rec-list").innerHTML = null;
+
+        // Pause auto-bake while loading but don't modify `this.autoBake_`
+        // otherwise `manualBake` cannot trigger.
+        this.autoBakePause = true;
+
+        for (let i = 0; i < recipeConfig.length; i++) {
+            const item = this.manager.recipe.addOperation(recipeConfig[i].op);
+
+            // Populate arguments
+            const args = item.querySelectorAll(".arg");
+            for (let j = 0; j < args.length; j++) {
+                if (recipeConfig[i].args[j] === undefined) continue;
+                if (args[j].getAttribute("type") === "checkbox") {
+                    // checkbox
+                    args[j].checked = recipeConfig[i].args[j];
+                } else if (args[j].classList.contains("toggle-string")) {
+                    // toggleString
+                    args[j].value = recipeConfig[i].args[j].string;
+                    args[j].previousSibling.children[0].innerHTML =
+                        Utils.escapeHtml(recipeConfig[i].args[j].option) +
+                        " <span class='caret'></span>";
+                } else {
+                    // all others
+                    args[j].value = recipeConfig[i].args[j];
+                }
+            }
+
+            // Set disabled and breakpoint
+            if (recipeConfig[i].disabled) {
+                item.querySelector(".disable-icon").click();
+            }
+            if (recipeConfig[i].breakpoint) {
+                item.querySelector(".breakpoint").click();
+            }
+
+            this.progress = 0;
+        }
+
+        // Unpause auto bake
+        this.autoBakePause = false;
+    }
+
+
+    /**
+     * Resets the splitter positions to default.
+     */
+    resetLayout() {
+        this.columnSplitter.setSizes([20, 30, 50]);
+        this.ioSplitter.setSizes([50, 50]);
+
+        this.manager.controls.adjustWidth();
+        this.manager.output.adjustWidth();
+    }
+
+
+    /**
+     * Sets the compile message.
+     */
+    setCompileMessage() {
+        // Display time since last build and compile message
+        const now = new Date(),
+            timeSinceCompile = Utils.fuzzyTime(now.getTime() - window.compileTime);
+
+        // Calculate previous version to compare to
+        const prev = PKG_VERSION.split(".").map(n => {
+            return parseInt(n, 10);
+        });
+        if (prev[2] > 0) prev[2]--;
+        else if (prev[1] > 0) prev[1]--;
+        else prev[0]--;
+
+        const compareURL = `https://github.com/gchq/CyberChef/compare/v${prev.join(".")}...v${PKG_VERSION}`;
+
+        let compileInfo = `<a href='${compareURL}'>Last build: ${timeSinceCompile.substr(0, 1).toUpperCase() + timeSinceCompile.substr(1)} ago</a>`;
+
+        if (window.compileMessage !== "") {
+            compileInfo += " - " + window.compileMessage;
+        }
+
+        document.getElementById("notice").innerHTML = compileInfo;
+    }
+
+
+    /**
+     * Determines whether the browser supports Local Storage and if it is accessible.
+     *
+     * @returns {boolean}
+     */
+    isLocalStorageAvailable() {
+        try {
+            if (!localStorage) return false;
+            return true;
+        } catch (err) {
+            // Access to LocalStorage is denied
+            return false;
+        }
+    }
+
+
+    /**
+     * Pops up a message to the user and writes it to the console log.
+     *
+     * @param {string} str - The message to display (HTML supported)
+     * @param {string} style - The colour of the popup
+     *     "danger"  = red
+     *     "warning" = amber
+     *     "info"    = blue
+     *     "success" = green
+     * @param {number} timeout - The number of milliseconds before the popup closes automatically
+     *     0 for never (until the user closes it)
+     * @param {boolean} [silent=false] - Don't show the message in the popup, only print it to the
+     *     console
+     *
+     * @example
+     * // Pops up a red box with the message "[current time] Error: Something has gone wrong!"
+     * // that will need to be dismissed by the user.
+     * this.alert("Error: Something has gone wrong!", "danger", 0);
+     *
+     * // Pops up a blue information box with the message "[current time] Happy Christmas!"
+     * // that will disappear after 5 seconds.
+     * this.alert("Happy Christmas!", "info", 5000);
+     */
+    alert(str, style, timeout, silent) {
+        const time = new Date();
+
+        log.info("[" + time.toLocaleString() + "] " + str);
+        if (silent) return;
+
+        style = style || "danger";
+        timeout = timeout || 0;
+
+        const alertEl = document.getElementById("alert"),
+            alertContent = document.getElementById("alert-content");
+
+        alertEl.classList.remove("alert-danger");
+        alertEl.classList.remove("alert-warning");
+        alertEl.classList.remove("alert-info");
+        alertEl.classList.remove("alert-success");
+        alertEl.classList.add("alert-" + style);
+
+        // If the box hasn't been closed, append to it rather than replacing
+        if (alertEl.style.display === "block") {
+            alertContent.innerHTML +=
+                "<br><br>[" + time.toLocaleTimeString() + "] " + str;
+        } else {
+            alertContent.innerHTML =
+                "[" + time.toLocaleTimeString() + "] " + str;
+        }
+
+        // Stop the animation if it is in progress
+        $("#alert").stop();
+        alertEl.style.display = "block";
+        alertEl.style.opacity = 1;
+
+        if (timeout > 0) {
+            clearTimeout(this.alertTimeout);
+            this.alertTimeout = setTimeout(function(){
+                $("#alert").slideUp(100);
+            }, timeout);
+        }
+    }
+
+
+    /**
+     * Pops up a box asking the user a question and sending the answer to a specified callback function.
+     *
+     * @param {string} title - The title of the box
+     * @param {string} body - The question (HTML supported)
+     * @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);});
+     */
+    confirm(title, body, callback, scope) {
+        scope = scope || this;
+        document.getElementById("confirm-title").innerHTML = title;
+        document.getElementById("confirm-body").innerHTML = body;
+        document.getElementById("confirm-modal").style.display = "block";
+
+        this.confirmClosed = false;
+        $("#confirm-modal").modal()
+            .one("show.bs.modal", function(e) {
+                this.confirmClosed = false;
+            }.bind(this))
+            .one("click", "#confirm-yes", function() {
+                this.confirmClosed = true;
+                callback.bind(scope)(true);
+                $("#confirm-modal").modal("hide");
+            }.bind(this))
+            .one("hide.bs.modal", function(e) {
+                if (!this.confirmClosed)
+                    callback.bind(scope)(false);
+                this.confirmClosed = true;
+            }.bind(this));
+    }
+
+
+    /**
+     * Handler for the alert close button click event.
+     * Closes the alert box.
+     */
+    alertCloseClick() {
+        document.getElementById("alert").style.display = "none";
+    }
+
+
+    /**
+     * Handler for CyerChef statechange events.
+     * Fires whenever the input or recipe changes in any way.
+     *
+     * @listens Manager#statechange
+     * @param {event} e
+     */
+    stateChange(e) {
+        this.autoBake();
+
+        // Set title
+        const recipeConfig = this.getRecipeConfig();
+        let title = "CyberChef";
+        if (recipeConfig.length === 1) {
+            title = `${recipeConfig[0].op} - ${title}`;
+        } else if (recipeConfig.length > 1) {
+            // See how long the full recipe is
+            const ops = recipeConfig.map(op => op.op).join(", ");
+            if (ops.length < 45) {
+                title = `${ops} - ${title}`;
+            } else {
+                // If it's too long, just use the first one and say how many more there are
+                title = `${recipeConfig[0].op}, ${recipeConfig.length - 1} more - ${title}`;
+            }
+        }
+        document.title = title;
+
+        // Update the current history state (not creating a new one)
+        if (this.options.updateUrl) {
+            this.lastStateUrl = this.manager.controls.generateStateUrl(true, true, recipeConfig);
+            window.history.replaceState({}, title, this.lastStateUrl);
+        }
+    }
+
+
+    /**
+     * Handler for the history popstate event.
+     * Reloads parameters from the URL.
+     *
+     * @param {event} e
+     */
+    popState(e) {
+        this.loadURIParams();
+    }
+
+
+    /**
+     * Function to call an external API from this view.
+     */
+    callApi(url, type, data, dataType, contentType) {
+        type = type || "POST";
+        data = data || {};
+        dataType = dataType || undefined;
+        contentType = contentType || "application/json";
+
+        let response = null,
+            success = false;
+
+        $.ajax({
+            url: url,
+            async: false,
+            type: type,
+            data: data,
+            dataType: dataType,
+            contentType: contentType,
+            success: function(data) {
+                success = true;
+                response = data;
+            },
+            error: function(data) {
+                success = false;
+                response = data;
+            },
+        });
+
+        return {
+            success: success,
+            response: response
+        };
+    }
+
+}
+
+export default App;

+ 0 - 217
src/web/BindingsWaiter.js

@@ -1,217 +0,0 @@
-/**
- * Waiter to handle keybindings to CyberChef functions (i.e. Bake, Step, Save, Load etc.)
- *
- * @author Matt C [matt@artemisbot.uk]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const BindingsWaiter = function (app, manager) {
-    this.app = app;
-    this.manager = manager;
-};
-
-
-/**
- * Handler for all keydown events
- * Checks whether valid keyboard shortcut has been instated
- *
- * @fires Manager#statechange
- * @param {event} e
- */
-BindingsWaiter.prototype.parseInput = function(e) {
-    const modKey = this.app.options.useMetaKey ? e.metaKey : e.altKey;
-
-    if (e.ctrlKey && modKey) {
-        let elem;
-        switch (e.code) {
-            case "KeyF": // Focus search
-                e.preventDefault();
-                document.getElementById("search").focus();
-                break;
-            case "KeyI": // Focus input
-                e.preventDefault();
-                document.getElementById("input-text").focus();
-                break;
-            case "KeyO": // Focus output
-                e.preventDefault();
-                document.getElementById("output-text").focus();
-                break;
-            case "Period": // Focus next operation
-                e.preventDefault();
-                try {
-                    elem = document.activeElement.closest(".operation") || document.querySelector("#rec-list .operation");
-                    if (elem.parentNode.lastChild === elem) {
-                        // If operation is last in recipe, loop around to the top operation's first argument
-                        elem.parentNode.firstChild.querySelectorAll(".arg")[0].focus();
-                    } else {
-                        // Focus first argument of next operation
-                        elem.nextSibling.querySelectorAll(".arg")[0].focus();
-                    }
-                } catch (e) {
-                    // do nothing, just don't throw an error
-                }
-                break;
-            case "KeyB": // Set breakpoint
-                e.preventDefault();
-                try {
-                    elem = document.activeElement.closest(".operation").querySelectorAll(".breakpoint")[0];
-                    if (elem.getAttribute("break") === "false") {
-                        elem.setAttribute("break", "true"); // add break point if not already enabled
-                        elem.classList.add("breakpoint-selected");
-                    } else {
-                        elem.setAttribute("break", "false"); // remove break point if already enabled
-                        elem.classList.remove("breakpoint-selected");
-                    }
-                    window.dispatchEvent(this.manager.statechange);
-                } catch (e) {
-                    // do nothing, just don't throw an error
-                }
-                break;
-            case "KeyD": // Disable operation
-                e.preventDefault();
-                try {
-                    elem = document.activeElement.closest(".operation").querySelectorAll(".disable-icon")[0];
-                    if (elem.getAttribute("disabled") === "false") {
-                        elem.setAttribute("disabled", "true"); // disable operation if enabled
-                        elem.classList.add("disable-elem-selected");
-                        elem.parentNode.parentNode.classList.add("disabled");
-                    } else {
-                        elem.setAttribute("disabled", "false"); // enable operation if disabled
-                        elem.classList.remove("disable-elem-selected");
-                        elem.parentNode.parentNode.classList.remove("disabled");
-                    }
-                    this.app.progress = 0;
-                    window.dispatchEvent(this.manager.statechange);
-                } catch (e) {
-                    // do nothing, just don't throw an error
-                }
-                break;
-            case "Space": // Bake
-                e.preventDefault();
-                this.app.bake();
-                break;
-            case "Quote": // Step through
-                e.preventDefault();
-                this.app.bake(true);
-                break;
-            case "KeyC": // Clear recipe
-                e.preventDefault();
-                this.manager.recipe.clearRecipe();
-                break;
-            case "KeyS": // Save output to file
-                e.preventDefault();
-                this.manager.output.saveClick();
-                break;
-            case "KeyL": // Load recipe
-                e.preventDefault();
-                this.manager.controls.loadClick();
-                break;
-            case "KeyM": // Switch input and output
-                e.preventDefault();
-                this.manager.output.switchClick();
-                break;
-            default:
-                if (e.code.match(/Digit[0-9]/g)) { // Select nth operation
-                    e.preventDefault();
-                    try {
-                        // Select the first argument of the operation corresponding to the number pressed
-                        document.querySelector(`li:nth-child(${e.code.substr(-1)}) .arg`).focus();
-                    } catch (e) {
-                        // do nothing, just don't throw an error
-                    }
-                }
-                break;
-        }
-    }
-};
-
-
-/**
- * Updates keybinding list when metaKey option is toggled
- *
- */
-BindingsWaiter.prototype.updateKeybList = function() {
-    let modWinLin = "Alt";
-    let modMac = "Opt";
-    if (this.app.options.useMetaKey) {
-        modWinLin = "Win";
-        modMac = "Cmd";
-    }
-    document.getElementById("keybList").innerHTML = `
-    <tr>
-        <td><b>Command</b></td>
-        <td><b>Shortcut (Win/Linux)</b></td>
-        <td><b>Shortcut (Mac)</b></td>
-    </tr>
-    <tr>
-        <td>Place cursor in search field</td>
-        <td>Ctrl+${modWinLin}+f</td>
-        <td>Ctrl+${modMac}+f</td>
-    <tr>
-        <td>Place cursor in input box</td>
-        <td>Ctrl+${modWinLin}+i</td>
-        <td>Ctrl+${modMac}+i</td>
-    </tr>
-    <tr>
-        <td>Place cursor in output box</td>
-        <td>Ctrl+${modWinLin}+o</td>
-        <td>Ctrl+${modMac}+o</td>
-    </tr>
-    <tr>
-        <td>Place cursor in first argument field of the next operation in the recipe</td>
-        <td>Ctrl+${modWinLin}+.</td>
-        <td>Ctrl+${modMac}+.</td>
-    </tr>
-    <tr>
-        <td>Place cursor in first argument field of the nth operation in the recipe</td>
-        <td>Ctrl+${modWinLin}+[1-9]</td>
-        <td>Ctrl+${modMac}+[1-9]</td>
-    </tr>
-    <tr>
-        <td>Disable current operation</td>
-        <td>Ctrl+${modWinLin}+d</td>
-        <td>Ctrl+${modMac}+d</td>
-    </tr>
-    <tr>
-        <td>Set/clear breakpoint</td>
-        <td>Ctrl+${modWinLin}+b</td>
-        <td>Ctrl+${modMac}+b</td>
-    </tr>
-    <tr>
-        <td>Bake</td>
-        <td>Ctrl+${modWinLin}+Space</td>
-        <td>Ctrl+${modMac}+Space</td>
-    </tr>
-    <tr>
-        <td>Step</td>
-        <td>Ctrl+${modWinLin}+'</td>
-        <td>Ctrl+${modMac}+'</td>
-    </tr>
-    <tr>
-        <td>Clear recipe</td>
-        <td>Ctrl+${modWinLin}+c</td>
-        <td>Ctrl+${modMac}+c</td>
-    </tr>
-    <tr>
-        <td>Save to file</td>
-        <td>Ctrl+${modWinLin}+s</td>
-        <td>Ctrl+${modMac}+s</td>
-    </tr>
-    <tr>
-        <td>Load recipe</td>
-        <td>Ctrl+${modWinLin}+l</td>
-        <td>Ctrl+${modMac}+l</td>
-    </tr>
-    <tr>
-        <td>Move output to input</td>
-        <td>Ctrl+${modWinLin}+m</td>
-        <td>Ctrl+${modMac}+m</td>
-    </tr>
-    `;
-};
-
-export default BindingsWaiter;

+ 224 - 0
src/web/BindingsWaiter.mjs

@@ -0,0 +1,224 @@
+/**
+ * @author Matt C [matt@artemisbot.uk]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * Waiter to handle keybindings to CyberChef functions (i.e. Bake, Step, Save, Load etc.)
+ */
+class BindingsWaiter {
+
+    /**
+     * BindingsWaiter 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;
+    }
+
+
+    /**
+     * Handler for all keydown events
+     * Checks whether valid keyboard shortcut has been instated
+     *
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    parseInput(e) {
+        const modKey = this.app.options.useMetaKey ? e.metaKey : e.altKey;
+
+        if (e.ctrlKey && modKey) {
+            let elem;
+            switch (e.code) {
+                case "KeyF": // Focus search
+                    e.preventDefault();
+                    document.getElementById("search").focus();
+                    break;
+                case "KeyI": // Focus input
+                    e.preventDefault();
+                    document.getElementById("input-text").focus();
+                    break;
+                case "KeyO": // Focus output
+                    e.preventDefault();
+                    document.getElementById("output-text").focus();
+                    break;
+                case "Period": // Focus next operation
+                    e.preventDefault();
+                    try {
+                        elem = document.activeElement.closest(".operation") || document.querySelector("#rec-list .operation");
+                        if (elem.parentNode.lastChild === elem) {
+                            // If operation is last in recipe, loop around to the top operation's first argument
+                            elem.parentNode.firstChild.querySelectorAll(".arg")[0].focus();
+                        } else {
+                            // Focus first argument of next operation
+                            elem.nextSibling.querySelectorAll(".arg")[0].focus();
+                        }
+                    } catch (e) {
+                        // do nothing, just don't throw an error
+                    }
+                    break;
+                case "KeyB": // Set breakpoint
+                    e.preventDefault();
+                    try {
+                        elem = document.activeElement.closest(".operation").querySelectorAll(".breakpoint")[0];
+                        if (elem.getAttribute("break") === "false") {
+                            elem.setAttribute("break", "true"); // add break point if not already enabled
+                            elem.classList.add("breakpoint-selected");
+                        } else {
+                            elem.setAttribute("break", "false"); // remove break point if already enabled
+                            elem.classList.remove("breakpoint-selected");
+                        }
+                        window.dispatchEvent(this.manager.statechange);
+                    } catch (e) {
+                        // do nothing, just don't throw an error
+                    }
+                    break;
+                case "KeyD": // Disable operation
+                    e.preventDefault();
+                    try {
+                        elem = document.activeElement.closest(".operation").querySelectorAll(".disable-icon")[0];
+                        if (elem.getAttribute("disabled") === "false") {
+                            elem.setAttribute("disabled", "true"); // disable operation if enabled
+                            elem.classList.add("disable-elem-selected");
+                            elem.parentNode.parentNode.classList.add("disabled");
+                        } else {
+                            elem.setAttribute("disabled", "false"); // enable operation if disabled
+                            elem.classList.remove("disable-elem-selected");
+                            elem.parentNode.parentNode.classList.remove("disabled");
+                        }
+                        this.app.progress = 0;
+                        window.dispatchEvent(this.manager.statechange);
+                    } catch (e) {
+                        // do nothing, just don't throw an error
+                    }
+                    break;
+                case "Space": // Bake
+                    e.preventDefault();
+                    this.app.bake();
+                    break;
+                case "Quote": // Step through
+                    e.preventDefault();
+                    this.app.bake(true);
+                    break;
+                case "KeyC": // Clear recipe
+                    e.preventDefault();
+                    this.manager.recipe.clearRecipe();
+                    break;
+                case "KeyS": // Save output to file
+                    e.preventDefault();
+                    this.manager.output.saveClick();
+                    break;
+                case "KeyL": // Load recipe
+                    e.preventDefault();
+                    this.manager.controls.loadClick();
+                    break;
+                case "KeyM": // Switch input and output
+                    e.preventDefault();
+                    this.manager.output.switchClick();
+                    break;
+                default:
+                    if (e.code.match(/Digit[0-9]/g)) { // Select nth operation
+                        e.preventDefault();
+                        try {
+                            // Select the first argument of the operation corresponding to the number pressed
+                            document.querySelector(`li:nth-child(${e.code.substr(-1)}) .arg`).focus();
+                        } catch (e) {
+                            // do nothing, just don't throw an error
+                        }
+                    }
+                    break;
+            }
+        }
+    }
+
+
+    /**
+     * Updates keybinding list when metaKey option is toggled
+     */
+    updateKeybList() {
+        let modWinLin = "Alt";
+        let modMac = "Opt";
+        if (this.app.options.useMetaKey) {
+            modWinLin = "Win";
+            modMac = "Cmd";
+        }
+        document.getElementById("keybList").innerHTML = `
+        <tr>
+            <td><b>Command</b></td>
+            <td><b>Shortcut (Win/Linux)</b></td>
+            <td><b>Shortcut (Mac)</b></td>
+        </tr>
+        <tr>
+            <td>Place cursor in search field</td>
+            <td>Ctrl+${modWinLin}+f</td>
+            <td>Ctrl+${modMac}+f</td>
+        <tr>
+            <td>Place cursor in input box</td>
+            <td>Ctrl+${modWinLin}+i</td>
+            <td>Ctrl+${modMac}+i</td>
+        </tr>
+        <tr>
+            <td>Place cursor in output box</td>
+            <td>Ctrl+${modWinLin}+o</td>
+            <td>Ctrl+${modMac}+o</td>
+        </tr>
+        <tr>
+            <td>Place cursor in first argument field of the next operation in the recipe</td>
+            <td>Ctrl+${modWinLin}+.</td>
+            <td>Ctrl+${modMac}+.</td>
+        </tr>
+        <tr>
+            <td>Place cursor in first argument field of the nth operation in the recipe</td>
+            <td>Ctrl+${modWinLin}+[1-9]</td>
+            <td>Ctrl+${modMac}+[1-9]</td>
+        </tr>
+        <tr>
+            <td>Disable current operation</td>
+            <td>Ctrl+${modWinLin}+d</td>
+            <td>Ctrl+${modMac}+d</td>
+        </tr>
+        <tr>
+            <td>Set/clear breakpoint</td>
+            <td>Ctrl+${modWinLin}+b</td>
+            <td>Ctrl+${modMac}+b</td>
+        </tr>
+        <tr>
+            <td>Bake</td>
+            <td>Ctrl+${modWinLin}+Space</td>
+            <td>Ctrl+${modMac}+Space</td>
+        </tr>
+        <tr>
+            <td>Step</td>
+            <td>Ctrl+${modWinLin}+'</td>
+            <td>Ctrl+${modMac}+'</td>
+        </tr>
+        <tr>
+            <td>Clear recipe</td>
+            <td>Ctrl+${modWinLin}+c</td>
+            <td>Ctrl+${modMac}+c</td>
+        </tr>
+        <tr>
+            <td>Save to file</td>
+            <td>Ctrl+${modWinLin}+s</td>
+            <td>Ctrl+${modMac}+s</td>
+        </tr>
+        <tr>
+            <td>Load recipe</td>
+            <td>Ctrl+${modWinLin}+l</td>
+            <td>Ctrl+${modMac}+l</td>
+        </tr>
+        <tr>
+            <td>Move output to input</td>
+            <td>Ctrl+${modWinLin}+m</td>
+            <td>Ctrl+${modMac}+m</td>
+        </tr>
+        `;
+    }
+
+}
+
+export default BindingsWaiter;

+ 0 - 441
src/web/ControlsWaiter.js

@@ -1,441 +0,0 @@
-import Utils from "../core/Utils";
-import {toBase64} from "../core/lib/Base64";
-
-
-/**
- * Waiter to handle events related to the CyberChef controls (i.e. Bake, Step, Save, Load etc.)
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const ControlsWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-};
-
-
-/**
- * Adjusts the display properties of the control buttons so that they fit within the current width
- * without wrapping or overflowing.
- */
-ControlsWaiter.prototype.adjustWidth = function() {
-    const controls     = document.getElementById("controls");
-    const step         = document.getElementById("step");
-    const clrBreaks    = document.getElementById("clr-breaks");
-    const saveImg      = document.querySelector("#save img");
-    const loadImg      = document.querySelector("#load img");
-    const stepImg      = document.querySelector("#step img");
-    const clrRecipImg  = document.querySelector("#clr-recipe img");
-    const clrBreaksImg = document.querySelector("#clr-breaks img");
-
-    if (controls.clientWidth < 470) {
-        step.childNodes[1].nodeValue = " Step";
-    } else {
-        step.childNodes[1].nodeValue = " Step through";
-    }
-
-    if (controls.clientWidth < 400) {
-        saveImg.style.display = "none";
-        loadImg.style.display = "none";
-        stepImg.style.display = "none";
-        clrRecipImg.style.display = "none";
-        clrBreaksImg.style.display = "none";
-    } else {
-        saveImg.style.display = "inline";
-        loadImg.style.display = "inline";
-        stepImg.style.display = "inline";
-        clrRecipImg.style.display = "inline";
-        clrBreaksImg.style.display = "inline";
-    }
-
-    if (controls.clientWidth < 330) {
-        clrBreaks.childNodes[1].nodeValue = " Clear breaks";
-    } else {
-        clrBreaks.childNodes[1].nodeValue = " Clear breakpoints";
-    }
-};
-
-
-/**
- * Checks or unchecks the Auto Bake checkbox based on the given value.
- *
- * @param {boolean} value - The new value for Auto Bake.
- */
-ControlsWaiter.prototype.setAutoBake = function(value) {
-    const autoBakeCheckbox = document.getElementById("auto-bake");
-
-    if (autoBakeCheckbox.checked !== value) {
-        autoBakeCheckbox.click();
-    }
-};
-
-
-/**
- * Handler to trigger baking.
- */
-ControlsWaiter.prototype.bakeClick = function() {
-    if (document.getElementById("bake").textContent.indexOf("Bake") > 0) {
-        this.app.bake();
-    } else {
-        this.manager.worker.cancelBake();
-    }
-};
-
-
-/**
- * Handler for the 'Step through' command. Executes the next step of the recipe.
- */
-ControlsWaiter.prototype.stepClick = function() {
-    this.app.bake(true);
-};
-
-
-/**
- * Handler for changes made to the Auto Bake checkbox.
- */
-ControlsWaiter.prototype.autoBakeChange = function() {
-    const autoBakeLabel    = document.getElementById("auto-bake-label");
-    const autoBakeCheckbox = document.getElementById("auto-bake");
-
-    this.app.autoBake_ = autoBakeCheckbox.checked;
-
-    if (autoBakeCheckbox.checked) {
-        autoBakeLabel.classList.add("btn-success");
-        autoBakeLabel.classList.remove("btn-default");
-    } else {
-        autoBakeLabel.classList.add("btn-default");
-        autoBakeLabel.classList.remove("btn-success");
-    }
-};
-
-
-/**
- * Handler for the 'Clear recipe' command. Removes all operations from the recipe.
- */
-ControlsWaiter.prototype.clearRecipeClick = function() {
-    this.manager.recipe.clearRecipe();
-};
-
-
-/**
- * Handler for the 'Clear breakpoints' command. Removes all breakpoints from operations in the
- * recipe.
- */
-ControlsWaiter.prototype.clearBreaksClick = function() {
-    const bps = document.querySelectorAll("#rec-list li.operation .breakpoint");
-
-    for (let i = 0; i < bps.length; i++) {
-        bps[i].setAttribute("break", "false");
-        bps[i].classList.remove("breakpoint-selected");
-    }
-};
-
-
-/**
- * Populates the save disalog box with a URL incorporating the recipe and input.
- *
- * @param {Object[]} [recipeConfig] - The recipe configuration object array.
- */
-ControlsWaiter.prototype.initialiseSaveLink = function(recipeConfig) {
-    recipeConfig = recipeConfig || this.app.getRecipeConfig();
-
-    const includeRecipe = document.getElementById("save-link-recipe-checkbox").checked;
-    const includeInput = document.getElementById("save-link-input-checkbox").checked;
-    const saveLinkEl = document.getElementById("save-link");
-    const saveLink = this.generateStateUrl(includeRecipe, includeInput, recipeConfig);
-
-    saveLinkEl.innerHTML = Utils.truncate(saveLink, 120);
-    saveLinkEl.setAttribute("href", saveLink);
-};
-
-
-/**
- * Generates a URL containing the current recipe and input state.
- *
- * @param {boolean} includeRecipe - Whether to include the recipe in the URL.
- * @param {boolean} includeInput - Whether to include the input in the URL.
- * @param {Object[]} [recipeConfig] - The recipe configuration object array.
- * @param {string} [baseURL] - The CyberChef URL, set to the current URL if not included
- * @returns {string}
- */
-ControlsWaiter.prototype.generateStateUrl = function(includeRecipe, includeInput, recipeConfig, baseURL) {
-    recipeConfig = recipeConfig || this.app.getRecipeConfig();
-
-    const link = baseURL || window.location.protocol + "//" +
-        window.location.host +
-        window.location.pathname;
-    const recipeStr = Utils.generatePrettyRecipe(recipeConfig);
-    const inputStr = toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding
-
-    includeRecipe = includeRecipe && (recipeConfig.length > 0);
-    // Only inlcude input if it is less than 50KB (51200 * 4/3 as it is Base64 encoded)
-    includeInput = includeInput && (inputStr.length > 0) && (inputStr.length <= 68267);
-
-    const params = [
-        includeRecipe ? ["recipe", recipeStr] : undefined,
-        includeInput ? ["input", inputStr] : undefined,
-    ];
-
-    const hash = params
-        .filter(v => v)
-        .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`)
-        .join("&");
-
-    if (hash) {
-        return `${link}#${hash}`;
-    }
-
-    return link;
-};
-
-
-/**
- * Handler for changes made to the save dialog text area. Re-initialises the save link.
- */
-ControlsWaiter.prototype.saveTextChange = function(e) {
-    try {
-        const recipeConfig = Utils.parseRecipeConfig(e.target.value);
-        this.initialiseSaveLink(recipeConfig);
-    } catch (err) {}
-};
-
-
-/**
- * Handler for the 'Save' command. Pops up the save dialog box.
- */
-ControlsWaiter.prototype.saveClick = function() {
-    const recipeConfig = this.app.getRecipeConfig();
-    const recipeStr = JSON.stringify(recipeConfig);
-
-    document.getElementById("save-text-chef").value = Utils.generatePrettyRecipe(recipeConfig, true);
-    document.getElementById("save-text-clean").value = JSON.stringify(recipeConfig, null, 2)
-        .replace(/{\n\s+"/g, "{ \"")
-        .replace(/\[\n\s{3,}/g, "[")
-        .replace(/\n\s{3,}]/g, "]")
-        .replace(/\s*\n\s*}/g, " }")
-        .replace(/\n\s{6,}/g, " ");
-    document.getElementById("save-text-compact").value = recipeStr;
-
-    this.initialiseSaveLink(recipeConfig);
-    $("#save-modal").modal();
-};
-
-
-/**
- * Handler for the save link recipe checkbox change event.
- */
-ControlsWaiter.prototype.slrCheckChange = function() {
-    this.initialiseSaveLink();
-};
-
-
-/**
- * Handler for the save link input checkbox change event.
- */
-ControlsWaiter.prototype.sliCheckChange = function() {
-    this.initialiseSaveLink();
-};
-
-
-/**
- * Handler for the 'Load' command. Pops up the load dialog box.
- */
-ControlsWaiter.prototype.loadClick = function() {
-    this.populateLoadRecipesList();
-    $("#load-modal").modal();
-};
-
-
-/**
- * Saves the recipe specified in the save textarea to local storage.
- */
-ControlsWaiter.prototype.saveButtonClick = function() {
-    if (!this.app.isLocalStorageAvailable()) {
-        this.app.alert(
-            "Your security settings do not allow access to local storage so your recipe cannot be saved.",
-            "danger",
-            5000
-        );
-        return false;
-    }
-
-    const recipeName = Utils.escapeHtml(document.getElementById("save-name").value);
-    const recipeStr  = document.querySelector("#save-texts .tab-pane.active textarea").value;
-
-    if (!recipeName) {
-        this.app.alert("Please enter a recipe name", "danger", 2000);
-        return;
-    }
-
-    const savedRecipes = localStorage.savedRecipes ?
-        JSON.parse(localStorage.savedRecipes) : [];
-    let recipeId = localStorage.recipeId || 0;
-
-    savedRecipes.push({
-        id: ++recipeId,
-        name: recipeName,
-        recipe: recipeStr
-    });
-
-    localStorage.savedRecipes = JSON.stringify(savedRecipes);
-    localStorage.recipeId = recipeId;
-
-    this.app.alert("Recipe saved as \"" + recipeName + "\".", "success", 2000);
-};
-
-
-/**
- * Populates the list of saved recipes in the load dialog box from local storage.
- */
-ControlsWaiter.prototype.populateLoadRecipesList = function() {
-    if (!this.app.isLocalStorageAvailable()) return false;
-
-    const loadNameEl = document.getElementById("load-name");
-
-    // Remove current recipes from select
-    let i = loadNameEl.options.length;
-    while (i--) {
-        loadNameEl.remove(i);
-    }
-
-    // Add recipes to select
-    const savedRecipes = localStorage.savedRecipes ?
-        JSON.parse(localStorage.savedRecipes) : [];
-
-    for (i = 0; i < savedRecipes.length; i++) {
-        const opt = document.createElement("option");
-        opt.value = savedRecipes[i].id;
-        // Unescape then re-escape in case localStorage has been corrupted
-        opt.innerHTML = Utils.escapeHtml(Utils.unescapeHtml(savedRecipes[i].name));
-
-        loadNameEl.appendChild(opt);
-    }
-
-    // Populate textarea with first recipe
-    document.getElementById("load-text").value = savedRecipes.length ? savedRecipes[0].recipe : "";
-};
-
-
-/**
- * Removes the currently selected recipe from local storage.
- */
-ControlsWaiter.prototype.loadDeleteClick = function() {
-    if (!this.app.isLocalStorageAvailable()) return false;
-
-    const id = parseInt(document.getElementById("load-name").value, 10);
-    const rawSavedRecipes = localStorage.savedRecipes ?
-        JSON.parse(localStorage.savedRecipes) : [];
-
-    const savedRecipes = rawSavedRecipes.filter(r => r.id !== id);
-
-    localStorage.savedRecipes = JSON.stringify(savedRecipes);
-    this.populateLoadRecipesList();
-};
-
-
-/**
- * Displays the selected recipe in the load text box.
- */
-ControlsWaiter.prototype.loadNameChange = function(e) {
-    if (!this.app.isLocalStorageAvailable()) return false;
-
-    const el = e.target;
-    const savedRecipes = localStorage.savedRecipes ?
-        JSON.parse(localStorage.savedRecipes) : [];
-    const id = parseInt(el.value, 10);
-
-    const recipe = savedRecipes.find(r => r.id === id);
-
-    document.getElementById("load-text").value = recipe.recipe;
-};
-
-
-/**
- * Loads the selected recipe and populates the Recipe with its operations.
- */
-ControlsWaiter.prototype.loadButtonClick = function() {
-    try {
-        const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value);
-        this.app.setRecipeConfig(recipeConfig);
-        this.app.autoBake();
-
-        $("#rec-list [data-toggle=popover]").popover();
-    } catch (e) {
-        this.app.alert("Invalid recipe", "danger", 2000);
-    }
-};
-
-
-/**
- * Populates the bug report information box with useful technical info.
- *
- * @param {event} e
- */
-ControlsWaiter.prototype.supportButtonClick = function(e) {
-    e.preventDefault();
-
-    const reportBugInfo = document.getElementById("report-bug-info");
-    const saveLink = this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/");
-
-    if (reportBugInfo) {
-        reportBugInfo.innerHTML = `* Version: ${PKG_VERSION + (typeof INLINE === "undefined" ? "" : "s")}
-* Compile time: ${COMPILE_TIME}
-* User-Agent: 
-${navigator.userAgent}
-* [Link to reproduce](${saveLink})
-
-`;
-    }
-};
-
-
-/**
- * Shows the stale indicator to show that the input or recipe has changed
- * since the last bake.
- */
-ControlsWaiter.prototype.showStaleIndicator = function() {
-    const staleIndicator = document.getElementById("stale-indicator");
-
-    staleIndicator.style.visibility = "visible";
-    staleIndicator.style.opacity = 1;
-};
-
-
-/**
- * Hides the stale indicator to show that the input or recipe has not changed
- * since the last bake.
- */
-ControlsWaiter.prototype.hideStaleIndicator = function() {
-    const staleIndicator = document.getElementById("stale-indicator");
-
-    staleIndicator.style.opacity = 0;
-    staleIndicator.style.visibility = "hidden";
-};
-
-
-/**
- * Switches the Bake button between 'Bake' and 'Cancel' functions.
- *
- * @param {boolean} cancel - Whether to change to cancel or not
- */
-ControlsWaiter.prototype.toggleBakeButtonFunction = function(cancel) {
-    const bakeButton = document.getElementById("bake"),
-        btnText = bakeButton.querySelector("span");
-
-    if (cancel) {
-        btnText.innerText = "Cancel";
-        bakeButton.classList.remove("btn-success");
-        bakeButton.classList.add("btn-danger");
-    } else {
-        btnText.innerText = "Bake!";
-        bakeButton.classList.remove("btn-danger");
-        bakeButton.classList.add("btn-success");
-    }
-};
-
-export default ControlsWaiter;

+ 449 - 0
src/web/ControlsWaiter.mjs

@@ -0,0 +1,449 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import Utils from "../core/Utils";
+import {toBase64} from "../core/lib/Base64";
+
+
+/**
+ * Waiter to handle events related to the CyberChef controls (i.e. Bake, Step, Save, Load etc.)
+ */
+class ControlsWaiter {
+
+    /**
+     * ControlsWaiter 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;
+    }
+
+
+    /**
+     * Adjusts the display properties of the control buttons so that they fit within the current width
+     * without wrapping or overflowing.
+     */
+    adjustWidth() {
+        const controls     = document.getElementById("controls");
+        const step         = document.getElementById("step");
+        const clrBreaks    = document.getElementById("clr-breaks");
+        const saveImg      = document.querySelector("#save img");
+        const loadImg      = document.querySelector("#load img");
+        const stepImg      = document.querySelector("#step img");
+        const clrRecipImg  = document.querySelector("#clr-recipe img");
+        const clrBreaksImg = document.querySelector("#clr-breaks img");
+
+        if (controls.clientWidth < 470) {
+            step.childNodes[1].nodeValue = " Step";
+        } else {
+            step.childNodes[1].nodeValue = " Step through";
+        }
+
+        if (controls.clientWidth < 400) {
+            saveImg.style.display = "none";
+            loadImg.style.display = "none";
+            stepImg.style.display = "none";
+            clrRecipImg.style.display = "none";
+            clrBreaksImg.style.display = "none";
+        } else {
+            saveImg.style.display = "inline";
+            loadImg.style.display = "inline";
+            stepImg.style.display = "inline";
+            clrRecipImg.style.display = "inline";
+            clrBreaksImg.style.display = "inline";
+        }
+
+        if (controls.clientWidth < 330) {
+            clrBreaks.childNodes[1].nodeValue = " Clear breaks";
+        } else {
+            clrBreaks.childNodes[1].nodeValue = " Clear breakpoints";
+        }
+    }
+
+
+    /**
+     * Checks or unchecks the Auto Bake checkbox based on the given value.
+     *
+     * @param {boolean} value - The new value for Auto Bake.
+     */
+    setAutoBake(value) {
+        const autoBakeCheckbox = document.getElementById("auto-bake");
+
+        if (autoBakeCheckbox.checked !== value) {
+            autoBakeCheckbox.click();
+        }
+    }
+
+
+    /**
+     * Handler to trigger baking.
+     */
+    bakeClick() {
+        if (document.getElementById("bake").textContent.indexOf("Bake") > 0) {
+            this.app.bake();
+        } else {
+            this.manager.worker.cancelBake();
+        }
+    }
+
+
+    /**
+     * Handler for the 'Step through' command. Executes the next step of the recipe.
+     */
+    stepClick() {
+        this.app.bake(true);
+    }
+
+
+    /**
+     * Handler for changes made to the Auto Bake checkbox.
+     */
+    autoBakeChange() {
+        const autoBakeLabel    = document.getElementById("auto-bake-label");
+        const autoBakeCheckbox = document.getElementById("auto-bake");
+
+        this.app.autoBake_ = autoBakeCheckbox.checked;
+
+        if (autoBakeCheckbox.checked) {
+            autoBakeLabel.classList.add("btn-success");
+            autoBakeLabel.classList.remove("btn-default");
+        } else {
+            autoBakeLabel.classList.add("btn-default");
+            autoBakeLabel.classList.remove("btn-success");
+        }
+    }
+
+
+    /**
+     * Handler for the 'Clear recipe' command. Removes all operations from the recipe.
+     */
+    clearRecipeClick() {
+        this.manager.recipe.clearRecipe();
+    }
+
+
+    /**
+     * Handler for the 'Clear breakpoints' command. Removes all breakpoints from operations in the
+     * recipe.
+     */
+    clearBreaksClick() {
+        const bps = document.querySelectorAll("#rec-list li.operation .breakpoint");
+
+        for (let i = 0; i < bps.length; i++) {
+            bps[i].setAttribute("break", "false");
+            bps[i].classList.remove("breakpoint-selected");
+        }
+    }
+
+
+    /**
+     * Populates the save disalog box with a URL incorporating the recipe and input.
+     *
+     * @param {Object[]} [recipeConfig] - The recipe configuration object array.
+     */
+    initialiseSaveLink(recipeConfig) {
+        recipeConfig = recipeConfig || this.app.getRecipeConfig();
+
+        const includeRecipe = document.getElementById("save-link-recipe-checkbox").checked;
+        const includeInput = document.getElementById("save-link-input-checkbox").checked;
+        const saveLinkEl = document.getElementById("save-link");
+        const saveLink = this.generateStateUrl(includeRecipe, includeInput, recipeConfig);
+
+        saveLinkEl.innerHTML = Utils.truncate(saveLink, 120);
+        saveLinkEl.setAttribute("href", saveLink);
+    }
+
+
+    /**
+     * Generates a URL containing the current recipe and input state.
+     *
+     * @param {boolean} includeRecipe - Whether to include the recipe in the URL.
+     * @param {boolean} includeInput - Whether to include the input in the URL.
+     * @param {Object[]} [recipeConfig] - The recipe configuration object array.
+     * @param {string} [baseURL] - The CyberChef URL, set to the current URL if not included
+     * @returns {string}
+     */
+    generateStateUrl(includeRecipe, includeInput, recipeConfig, baseURL) {
+        recipeConfig = recipeConfig || this.app.getRecipeConfig();
+
+        const link = baseURL || window.location.protocol + "//" +
+            window.location.host +
+            window.location.pathname;
+        const recipeStr = Utils.generatePrettyRecipe(recipeConfig);
+        const inputStr = toBase64(this.app.getInput(), "A-Za-z0-9+/"); // B64 alphabet with no padding
+
+        includeRecipe = includeRecipe && (recipeConfig.length > 0);
+        // Only inlcude input if it is less than 50KB (51200 * 4/3 as it is Base64 encoded)
+        includeInput = includeInput && (inputStr.length > 0) && (inputStr.length <= 68267);
+
+        const params = [
+            includeRecipe ? ["recipe", recipeStr] : undefined,
+            includeInput ? ["input", inputStr] : undefined,
+        ];
+
+        const hash = params
+            .filter(v => v)
+            .map(([key, value]) => `${key}=${Utils.encodeURIFragment(value)}`)
+            .join("&");
+
+        if (hash) {
+            return `${link}#${hash}`;
+        }
+
+        return link;
+    }
+
+
+    /**
+     * Handler for changes made to the save dialog text area. Re-initialises the save link.
+     */
+    saveTextChange(e) {
+        try {
+            const recipeConfig = Utils.parseRecipeConfig(e.target.value);
+            this.initialiseSaveLink(recipeConfig);
+        } catch (err) {}
+    }
+
+
+    /**
+     * Handler for the 'Save' command. Pops up the save dialog box.
+     */
+    saveClick() {
+        const recipeConfig = this.app.getRecipeConfig();
+        const recipeStr = JSON.stringify(recipeConfig);
+
+        document.getElementById("save-text-chef").value = Utils.generatePrettyRecipe(recipeConfig, true);
+        document.getElementById("save-text-clean").value = JSON.stringify(recipeConfig, null, 2)
+            .replace(/{\n\s+"/g, "{ \"")
+            .replace(/\[\n\s{3,}/g, "[")
+            .replace(/\n\s{3,}]/g, "]")
+            .replace(/\s*\n\s*}/g, " }")
+            .replace(/\n\s{6,}/g, " ");
+        document.getElementById("save-text-compact").value = recipeStr;
+
+        this.initialiseSaveLink(recipeConfig);
+        $("#save-modal").modal();
+    }
+
+
+    /**
+     * Handler for the save link recipe checkbox change event.
+     */
+    slrCheckChange() {
+        this.initialiseSaveLink();
+    }
+
+
+    /**
+     * Handler for the save link input checkbox change event.
+     */
+    sliCheckChange() {
+        this.initialiseSaveLink();
+    }
+
+
+    /**
+     * Handler for the 'Load' command. Pops up the load dialog box.
+     */
+    loadClick() {
+        this.populateLoadRecipesList();
+        $("#load-modal").modal();
+    }
+
+
+    /**
+     * Saves the recipe specified in the save textarea to local storage.
+     */
+    saveButtonClick() {
+        if (!this.app.isLocalStorageAvailable()) {
+            this.app.alert(
+                "Your security settings do not allow access to local storage so your recipe cannot be saved.",
+                "danger",
+                5000
+            );
+            return false;
+        }
+
+        const recipeName = Utils.escapeHtml(document.getElementById("save-name").value);
+        const recipeStr  = document.querySelector("#save-texts .tab-pane.active textarea").value;
+
+        if (!recipeName) {
+            this.app.alert("Please enter a recipe name", "danger", 2000);
+            return;
+        }
+
+        const savedRecipes = localStorage.savedRecipes ?
+            JSON.parse(localStorage.savedRecipes) : [];
+        let recipeId = localStorage.recipeId || 0;
+
+        savedRecipes.push({
+            id: ++recipeId,
+            name: recipeName,
+            recipe: recipeStr
+        });
+
+        localStorage.savedRecipes = JSON.stringify(savedRecipes);
+        localStorage.recipeId = recipeId;
+
+        this.app.alert("Recipe saved as \"" + recipeName + "\".", "success", 2000);
+    }
+
+
+    /**
+     * Populates the list of saved recipes in the load dialog box from local storage.
+     */
+    populateLoadRecipesList() {
+        if (!this.app.isLocalStorageAvailable()) return false;
+
+        const loadNameEl = document.getElementById("load-name");
+
+        // Remove current recipes from select
+        let i = loadNameEl.options.length;
+        while (i--) {
+            loadNameEl.remove(i);
+        }
+
+        // Add recipes to select
+        const savedRecipes = localStorage.savedRecipes ?
+            JSON.parse(localStorage.savedRecipes) : [];
+
+        for (i = 0; i < savedRecipes.length; i++) {
+            const opt = document.createElement("option");
+            opt.value = savedRecipes[i].id;
+            // Unescape then re-escape in case localStorage has been corrupted
+            opt.innerHTML = Utils.escapeHtml(Utils.unescapeHtml(savedRecipes[i].name));
+
+            loadNameEl.appendChild(opt);
+        }
+
+        // Populate textarea with first recipe
+        document.getElementById("load-text").value = savedRecipes.length ? savedRecipes[0].recipe : "";
+    }
+
+
+    /**
+     * Removes the currently selected recipe from local storage.
+     */
+    loadDeleteClick() {
+        if (!this.app.isLocalStorageAvailable()) return false;
+
+        const id = parseInt(document.getElementById("load-name").value, 10);
+        const rawSavedRecipes = localStorage.savedRecipes ?
+            JSON.parse(localStorage.savedRecipes) : [];
+
+        const savedRecipes = rawSavedRecipes.filter(r => r.id !== id);
+
+        localStorage.savedRecipes = JSON.stringify(savedRecipes);
+        this.populateLoadRecipesList();
+    }
+
+
+    /**
+     * Displays the selected recipe in the load text box.
+     */
+    loadNameChange(e) {
+        if (!this.app.isLocalStorageAvailable()) return false;
+
+        const el = e.target;
+        const savedRecipes = localStorage.savedRecipes ?
+            JSON.parse(localStorage.savedRecipes) : [];
+        const id = parseInt(el.value, 10);
+
+        const recipe = savedRecipes.find(r => r.id === id);
+
+        document.getElementById("load-text").value = recipe.recipe;
+    }
+
+
+    /**
+     * Loads the selected recipe and populates the Recipe with its operations.
+     */
+    loadButtonClick() {
+        try {
+            const recipeConfig = Utils.parseRecipeConfig(document.getElementById("load-text").value);
+            this.app.setRecipeConfig(recipeConfig);
+            this.app.autoBake();
+
+            $("#rec-list [data-toggle=popover]").popover();
+        } catch (e) {
+            this.app.alert("Invalid recipe", "danger", 2000);
+        }
+    }
+
+
+    /**
+     * Populates the bug report information box with useful technical info.
+     *
+     * @param {event} e
+     */
+    supportButtonClick(e) {
+        e.preventDefault();
+
+        const reportBugInfo = document.getElementById("report-bug-info");
+        const saveLink = this.generateStateUrl(true, true, null, "https://gchq.github.io/CyberChef/");
+
+        if (reportBugInfo) {
+            reportBugInfo.innerHTML = `* Version: ${PKG_VERSION + (typeof INLINE === "undefined" ? "" : "s")}
+* Compile time: ${COMPILE_TIME}
+* User-Agent: 
+${navigator.userAgent}
+* [Link to reproduce](${saveLink})
+
+`;
+        }
+    }
+
+
+    /**
+     * Shows the stale indicator to show that the input or recipe has changed
+     * since the last bake.
+     */
+    showStaleIndicator() {
+        const staleIndicator = document.getElementById("stale-indicator");
+
+        staleIndicator.style.visibility = "visible";
+        staleIndicator.style.opacity = 1;
+    }
+
+
+    /**
+     * Hides the stale indicator to show that the input or recipe has not changed
+     * since the last bake.
+     */
+    hideStaleIndicator() {
+        const staleIndicator = document.getElementById("stale-indicator");
+
+        staleIndicator.style.opacity = 0;
+        staleIndicator.style.visibility = "hidden";
+    }
+
+
+    /**
+     * Switches the Bake button between 'Bake' and 'Cancel' functions.
+     *
+     * @param {boolean} cancel - Whether to change to cancel or not
+     */
+    toggleBakeButtonFunction(cancel) {
+        const bakeButton = document.getElementById("bake"),
+            btnText = bakeButton.querySelector("span");
+
+        if (cancel) {
+            btnText.innerText = "Cancel";
+            bakeButton.classList.remove("btn-success");
+            bakeButton.classList.add("btn-danger");
+        } else {
+            btnText.innerText = "Bake!";
+            bakeButton.classList.remove("btn-danger");
+            bakeButton.classList.add("btn-success");
+        }
+    }
+
+}
+
+export default ControlsWaiter;

+ 0 - 52
src/web/HTMLCategory.js

@@ -1,52 +0,0 @@
-/**
- * Object to handle the creation of operation categories.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {string} name - The name of the category.
- * @param {boolean} selected - Whether this category is pre-selected or not.
- */
-const HTMLCategory = function(name, selected) {
-    this.name = name;
-    this.selected = selected;
-    this.opList = [];
-};
-
-
-/**
- * Adds an operation to this category.
- *
- * @param {HTMLOperation} operation - The operation to add.
- */
-HTMLCategory.prototype.addOperation = function(operation) {
-    this.opList.push(operation);
-};
-
-
-/**
- * Renders the category and all operations within it in HTML.
- *
- * @returns {string}
- */
-HTMLCategory.prototype.toHtml = function() {
-    const catName = "cat" + this.name.replace(/[\s/-:_]/g, "");
-    let html = "<div class='panel category'>\
-        <a class='category-title' data-toggle='collapse'\
-            data-parent='#categories' href='#" + catName + "'>\
-            " + this.name + "\
-        </a>\
-        <div id='" + catName + "' class='panel-collapse collapse\
-        " + (this.selected ? " in" : "") + "'><ul class='op-list'>";
-
-    for (let i = 0; i < this.opList.length; i++) {
-        html += this.opList[i].toStubHtml();
-    }
-
-    html += "</ul></div></div>";
-    return html;
-};
-
-export default HTMLCategory;

+ 60 - 0
src/web/HTMLCategory.mjs

@@ -0,0 +1,60 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * Object to handle the creation of operation categories.
+ */
+class HTMLCategory {
+
+    /**
+     * HTMLCategory constructor.
+     *
+     * @param {string} name - The name of the category.
+     * @param {boolean} selected - Whether this category is pre-selected or not.
+     */
+    constructor(name, selected) {
+        this.name = name;
+        this.selected = selected;
+        this.opList = [];
+    }
+
+
+    /**
+     * Adds an operation to this category.
+     *
+     * @param {HTMLOperation} operation - The operation to add.
+     */
+    addOperation(operation) {
+        this.opList.push(operation);
+    }
+
+
+    /**
+     * Renders the category and all operations within it in HTML.
+     *
+     * @returns {string}
+     */
+    toHtml() {
+        const catName = "cat" + this.name.replace(/[\s/-:_]/g, "");
+        let html = "<div class='panel category'>\
+            <a class='category-title' data-toggle='collapse'\
+                data-parent='#categories' href='#" + catName + "'>\
+                " + this.name + "\
+            </a>\
+            <div id='" + catName + "' class='panel-collapse collapse\
+            " + (this.selected ? " in" : "") + "'><ul class='op-list'>";
+
+        for (let i = 0; i < this.opList.length; i++) {
+            html += this.opList[i].toStubHtml();
+        }
+
+        html += "</ul></div></div>";
+        return html;
+    }
+
+}
+
+export default HTMLCategory;

+ 0 - 215
src/web/HTMLIngredient.js

@@ -1,215 +0,0 @@
-/**
- * Object to handle the creation of operation ingredients.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {Object} config - The configuration object for this ingredient.
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const HTMLIngredient = function(config, app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    this.name = config.name;
-    this.type = config.type;
-    this.value = config.value;
-    this.disabled = config.disabled || false;
-    this.disableArgs = config.disableArgs || false;
-    this.placeholder = config.placeholder || false;
-    this.target = config.target;
-    this.toggleValues = config.toggleValues;
-    this.id = "ing-" + this.app.nextIngId();
-};
-
-
-/**
- * Renders the ingredient in HTML.
- *
- * @returns {string}
- */
-HTMLIngredient.prototype.toHtml = function() {
-    const inline = (
-        this.type === "boolean" ||
-        this.type === "number" ||
-        this.type === "option" ||
-        this.type === "shortString" ||
-        this.type === "binaryShortString"
-    );
-    let html = inline ? "" : "<div class='clearfix'>&nbsp;</div>",
-        i, m;
-
-    html += "<div class='arg-group" + (inline ? " inline-args" : "") +
-        (this.type === "text" ? " arg-group-text" : "") + "'><label class='arg-label' for='" +
-        this.id + "'>" + this.name + "</label>";
-
-    switch (this.type) {
-        case "string":
-        case "binaryString":
-        case "byteArray":
-            html += "<input type='text' id='" + this.id + "' class='arg arg-input' arg-name='" +
-                this.name + "' value='" + this.value + "'" +
-                (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
-            break;
-        case "shortString":
-        case "binaryShortString":
-            html += "<input type='text' id='" + this.id +
-                "'class='arg arg-input short-string' arg-name='" + this.name + "'value='" +
-                this.value + "'" + (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
-            break;
-        case "toggleString":
-            html += "<div class='input-group'><div class='input-group-btn'>\
-                <button type='button' class='btn btn-default dropdown-toggle' data-toggle='dropdown'\
-                aria-haspopup='true' aria-expanded='false'" +
-                (this.disabled ? " disabled='disabled'" : "") + ">" + this.toggleValues[0] +
-                " <span class='caret'></span></button><ul class='dropdown-menu'>";
-            for (i = 0; i < this.toggleValues.length; i++) {
-                html += "<li><a href='#'>" + this.toggleValues[i] + "</a></li>";
-            }
-            html += "</ul></div><input type='text' class='arg arg-input toggle-string'" +
-                (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + "></div>";
-            break;
-        case "number":
-            html += "<input type='number' id='" + this.id + "'class='arg arg-input' arg-name='" +
-                this.name + "'value='" + this.value + "'" +
-                (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
-            break;
-        case "boolean":
-            html += "<input type='checkbox' id='" + this.id + "'class='arg' arg-name='" +
-                this.name + "'" + (this.value ? " checked='checked' " : "") +
-                (this.disabled ? " disabled='disabled'" : "") + ">";
-
-            if (this.disableArgs) {
-                this.manager.addDynamicListener("#" + this.id, "click", this.toggleDisableArgs, this);
-            }
-            break;
-        case "option":
-            html += "<select class='arg' id='" + this.id + "'arg-name='" + this.name + "'" +
-                (this.disabled ? " disabled='disabled'" : "") + ">";
-            for (i = 0; i < this.value.length; i++) {
-                if ((m = this.value[i].match(/\[([a-z0-9 -()^]+)\]/i))) {
-                    html += "<optgroup label='" + m[1] + "'>";
-                } else if ((m = this.value[i].match(/\[\/([a-z0-9 -()^]+)\]/i))) {
-                    html += "</optgroup>";
-                } else {
-                    html += "<option>" + this.value[i] + "</option>";
-                }
-            }
-            html += "</select>";
-            break;
-        case "populateOption":
-            html += "<select class='arg' id='" + this.id + "'arg-name='" + this.name + "'" +
-                (this.disabled ? " disabled='disabled'" : "") + ">";
-            for (i = 0; i < this.value.length; i++) {
-                if ((m = this.value[i].name.match(/\[([a-z0-9 -()^]+)\]/i))) {
-                    html += "<optgroup label='" + m[1] + "'>";
-                } else if ((m = this.value[i].name.match(/\[\/([a-z0-9 -()^]+)\]/i))) {
-                    html += "</optgroup>";
-                } else {
-                    html += "<option populate-value='" + this.value[i].value + "'>" +
-                        this.value[i].name + "</option>";
-                }
-            }
-            html += "</select>";
-
-            this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this);
-            break;
-        case "editableOption":
-            html += "<div class='editable-option'>";
-            html += "<select class='editable-option-select' id='sel-" + this.id + "'" +
-                (this.disabled ? " disabled='disabled'" : "") + ">";
-            for (i = 0; i < this.value.length; i++) {
-                html += "<option value='" + this.value[i].value + "'>" + this.value[i].name + "</option>";
-            }
-            html += "</select>";
-            html += "<input class='arg arg-input editable-option-input' id='" + this.id +
-                "'arg-name='" + this.name + "'" + " value='" + this.value[0].value + "'" +
-                (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
-            html += "</div>";
-
-
-            this.manager.addDynamicListener("#sel-" + this.id, "change", this.editableOptionChange, this);
-            break;
-        case "text":
-            html += "<textarea id='" + this.id + "' class='arg' arg-name='" +
-                this.name + "'" + (this.disabled ? " disabled='disabled'" : "") +
-                (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">" +
-                this.value + "</textarea>";
-            break;
-        default:
-            break;
-    }
-    html += "</div>";
-
-    return html;
-};
-
-
-/**
- * Handler for argument disable toggle.
- * Toggles disabled state for all arguments in the disableArgs list for this ingredient.
- *
- * @param {event} e
- */
-HTMLIngredient.prototype.toggleDisableArgs = function(e) {
-    const el = e.target;
-    const op = el.parentNode.parentNode;
-    const args = op.querySelectorAll(".arg-group");
-
-    for (let i = 0; i < this.disableArgs.length; i++) {
-        const els = args[this.disableArgs[i]].querySelectorAll("input, select, button");
-
-        for (let j = 0; j < els.length; j++) {
-            if (els[j].getAttribute("disabled")) {
-                els[j].removeAttribute("disabled");
-            } else {
-                els[j].setAttribute("disabled", "disabled");
-            }
-        }
-    }
-
-    this.manager.recipe.ingChange();
-};
-
-
-/**
- * Handler for populate option changes.
- * Populates the relevant argument with the specified value.
- *
- * @param {event} e
- */
-HTMLIngredient.prototype.populateOptionChange = function(e) {
-    const el = e.target;
-    const op = el.parentNode.parentNode;
-    const target = op.querySelectorAll(".arg-group")[this.target].querySelector("input, select, textarea");
-
-    target.value = el.childNodes[el.selectedIndex].getAttribute("populate-value");
-
-    this.manager.recipe.ingChange();
-};
-
-
-/**
- * Handler for editable option changes.
- * Populates the input box with the selected value.
- *
- * @param {event} e
- */
-HTMLIngredient.prototype.editableOptionChange = function(e) {
-    const select = e.target,
-        input = select.nextSibling;
-
-    input.value = select.childNodes[select.selectedIndex].value;
-
-    this.manager.recipe.ingChange();
-};
-
-export default HTMLIngredient;

+ 223 - 0
src/web/HTMLIngredient.mjs

@@ -0,0 +1,223 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * Object to handle the creation of operation ingredients.
+ */
+class HTMLIngredient {
+
+    /**
+     * HTMLIngredient constructor.
+     *
+     * @param {Object} config - The configuration object for this ingredient.
+     * @param {App} app - The main view object for CyberChef.
+     * @param {Manager} manager - The CyberChef event manager.
+     */
+    constructor(config, app, manager) {
+        this.app = app;
+        this.manager = manager;
+
+        this.name = config.name;
+        this.type = config.type;
+        this.value = config.value;
+        this.disabled = config.disabled || false;
+        this.disableArgs = config.disableArgs || false;
+        this.placeholder = config.placeholder || false;
+        this.target = config.target;
+        this.toggleValues = config.toggleValues;
+        this.id = "ing-" + this.app.nextIngId();
+    }
+
+
+    /**
+     * Renders the ingredient in HTML.
+     *
+     * @returns {string}
+     */
+    toHtml() {
+        const inline = (
+            this.type === "boolean" ||
+            this.type === "number" ||
+            this.type === "option" ||
+            this.type === "shortString" ||
+            this.type === "binaryShortString"
+        );
+        let html = inline ? "" : "<div class='clearfix'>&nbsp;</div>",
+            i, m;
+
+        html += "<div class='arg-group" + (inline ? " inline-args" : "") +
+            (this.type === "text" ? " arg-group-text" : "") + "'><label class='arg-label' for='" +
+            this.id + "'>" + this.name + "</label>";
+
+        switch (this.type) {
+            case "string":
+            case "binaryString":
+            case "byteArray":
+                html += "<input type='text' id='" + this.id + "' class='arg arg-input' arg-name='" +
+                    this.name + "' value='" + this.value + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
+                break;
+            case "shortString":
+            case "binaryShortString":
+                html += "<input type='text' id='" + this.id +
+                    "'class='arg arg-input short-string' arg-name='" + this.name + "'value='" +
+                    this.value + "'" + (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
+                break;
+            case "toggleString":
+                html += "<div class='input-group'><div class='input-group-btn'>\
+                    <button type='button' class='btn btn-default dropdown-toggle' data-toggle='dropdown'\
+                    aria-haspopup='true' aria-expanded='false'" +
+                    (this.disabled ? " disabled='disabled'" : "") + ">" + this.toggleValues[0] +
+                    " <span class='caret'></span></button><ul class='dropdown-menu'>";
+                for (i = 0; i < this.toggleValues.length; i++) {
+                    html += "<li><a href='#'>" + this.toggleValues[i] + "</a></li>";
+                }
+                html += "</ul></div><input type='text' class='arg arg-input toggle-string'" +
+                    (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + "></div>";
+                break;
+            case "number":
+                html += "<input type='number' id='" + this.id + "'class='arg arg-input' arg-name='" +
+                    this.name + "'value='" + this.value + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
+                break;
+            case "boolean":
+                html += "<input type='checkbox' id='" + this.id + "'class='arg' arg-name='" +
+                    this.name + "'" + (this.value ? " checked='checked' " : "") +
+                    (this.disabled ? " disabled='disabled'" : "") + ">";
+
+                if (this.disableArgs) {
+                    this.manager.addDynamicListener("#" + this.id, "click", this.toggleDisableArgs, this);
+                }
+                break;
+            case "option":
+                html += "<select class='arg' id='" + this.id + "'arg-name='" + this.name + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") + ">";
+                for (i = 0; i < this.value.length; i++) {
+                    if ((m = this.value[i].match(/\[([a-z0-9 -()^]+)\]/i))) {
+                        html += "<optgroup label='" + m[1] + "'>";
+                    } else if ((m = this.value[i].match(/\[\/([a-z0-9 -()^]+)\]/i))) {
+                        html += "</optgroup>";
+                    } else {
+                        html += "<option>" + this.value[i] + "</option>";
+                    }
+                }
+                html += "</select>";
+                break;
+            case "populateOption":
+                html += "<select class='arg' id='" + this.id + "'arg-name='" + this.name + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") + ">";
+                for (i = 0; i < this.value.length; i++) {
+                    if ((m = this.value[i].name.match(/\[([a-z0-9 -()^]+)\]/i))) {
+                        html += "<optgroup label='" + m[1] + "'>";
+                    } else if ((m = this.value[i].name.match(/\[\/([a-z0-9 -()^]+)\]/i))) {
+                        html += "</optgroup>";
+                    } else {
+                        html += "<option populate-value='" + this.value[i].value + "'>" +
+                            this.value[i].name + "</option>";
+                    }
+                }
+                html += "</select>";
+
+                this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this);
+                break;
+            case "editableOption":
+                html += "<div class='editable-option'>";
+                html += "<select class='editable-option-select' id='sel-" + this.id + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") + ">";
+                for (i = 0; i < this.value.length; i++) {
+                    html += "<option value='" + this.value[i].value + "'>" + this.value[i].name + "</option>";
+                }
+                html += "</select>";
+                html += "<input class='arg arg-input editable-option-input' id='" + this.id +
+                    "'arg-name='" + this.name + "'" + " value='" + this.value[0].value + "'" +
+                    (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">";
+                html += "</div>";
+
+
+                this.manager.addDynamicListener("#sel-" + this.id, "change", this.editableOptionChange, this);
+                break;
+            case "text":
+                html += "<textarea id='" + this.id + "' class='arg' arg-name='" +
+                    this.name + "'" + (this.disabled ? " disabled='disabled'" : "") +
+                    (this.placeholder ? " placeholder='" + this.placeholder + "'" : "") + ">" +
+                    this.value + "</textarea>";
+                break;
+            default:
+                break;
+        }
+        html += "</div>";
+
+        return html;
+    }
+
+
+    /**
+     * Handler for argument disable toggle.
+     * Toggles disabled state for all arguments in the disableArgs list for this ingredient.
+     *
+     * @param {event} e
+     */
+    toggleDisableArgs(e) {
+        const el = e.target;
+        const op = el.parentNode.parentNode;
+        const args = op.querySelectorAll(".arg-group");
+
+        for (let i = 0; i < this.disableArgs.length; i++) {
+            const els = args[this.disableArgs[i]].querySelectorAll("input, select, button");
+
+            for (let j = 0; j < els.length; j++) {
+                if (els[j].getAttribute("disabled")) {
+                    els[j].removeAttribute("disabled");
+                } else {
+                    els[j].setAttribute("disabled", "disabled");
+                }
+            }
+        }
+
+        this.manager.recipe.ingChange();
+    }
+
+
+    /**
+     * Handler for populate option changes.
+     * Populates the relevant argument with the specified value.
+     *
+     * @param {event} e
+     */
+    populateOptionChange(e) {
+        const el = e.target;
+        const op = el.parentNode.parentNode;
+        const target = op.querySelectorAll(".arg-group")[this.target].querySelector("input, select, textarea");
+
+        target.value = el.childNodes[el.selectedIndex].getAttribute("populate-value");
+
+        this.manager.recipe.ingChange();
+    }
+
+
+    /**
+     * Handler for editable option changes.
+     * Populates the input box with the selected value.
+     *
+     * @param {event} e
+     */
+    editableOptionChange(e) {
+        const select = e.target,
+            input = select.nextSibling;
+
+        input.value = select.childNodes[select.selectedIndex].value;
+
+        this.manager.recipe.ingChange();
+    }
+
+}
+
+export default HTMLIngredient;

+ 0 - 128
src/web/HTMLOperation.js

@@ -1,128 +0,0 @@
-import HTMLIngredient from "./HTMLIngredient.js";
-
-
-/**
- * Object to handle the creation of operations.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {string} name - The name of the operation.
- * @param {Object} config - The configuration object for this operation.
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const HTMLOperation = function(name, config, app, manager) {
-    this.app         = app;
-    this.manager     = manager;
-
-    this.name        = name;
-    this.description = config.description;
-    this.manualBake  = config.manualBake || false;
-    this.config      = config;
-    this.ingList     = [];
-
-    for (let i = 0; i < config.args.length; i++) {
-        const ing = new HTMLIngredient(config.args[i], this.app, this.manager);
-        this.ingList.push(ing);
-    }
-};
-
-
-/**
- * @constant
- */
-HTMLOperation.INFO_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAByElEQVR4XqVTzWoaYRQ9KZJmoVaS1J1QiYTIuOgqi9lEugguQhYhdGs3hTyAi0CWJTvJIks30ZBNsimUtlqkVLoQCuJsphRriyFjabWtEyf/Rv3iWcwwymTlgQuH851z5hu43wRGkEwmXwCIA4hiGAUAmUQikQbhEHwyGCWVSglVVUW73RYmyKnxjB56ncJ6NpsVxHGrI/ZLuniVb3DIqQmCHnrNkgcggNeSJPlisRgyJR2b737j/TcDsQUPwv6H5NR4BnroZcb6Z16N2PvyX6yna9Z8qp6JQ0Uf0ughmGHWBSAuyzJqrQ7eqKewY/dzE363C71e39LoWQq5wUwul4uzIBoIBHD01RgyrkZ8eDbvwUWnj623v2DHx4qB51IAzLIAXq8XP/7W0bUVVJtXWIk8wvlN364TA+/1IDMLwmWK/Hq3axmhaBdoGLeklm73ElaBYRgIzkyifHIOO4QQJKM3oJcZq6CgaVp0OTyHw9K/kQI4FiyHfdC0n2CWe5ApFosIPZ7C2tNpXpcDOehGyD/FIbd0euhlhllzFxRzC3fydbG4XRYbB9/tQ41n9m1U7l3lyp9LkfygiZeZCoecmtMqj/+Yxn7Od3v0j50qCO3zAAAAAElFTkSuQmCC";
-/**
- * @constant
- */
-HTMLOperation.REMOVE_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVR42qRTPU8CQRB9K2CCMRJ6NTQajOUaqfxIbLCRghhjQixosLAgFNBQ3l8wsabxLxBJbCyVUBiMCVQEQkOEKBbCnefM3p4eohWXzM3uvHlv52b2hG3bmOWZw4yPn1/XQkCQ9wFxcgZZ0QLKpifpN8Z1n1L13griBBjHhYK0nMT4b+wom53ClAAFQacZJ/m8rNfrSOZy0vxJjPP6IJ2WzWYTO6mUwiwtILiJJSHUKVSWkchkZK1WQzQaxU2pVGUglkjIbreLUCiEx0qlStlFCpfPiPstYDtVKJH9ZFI2Gw1FGA6H6LTbCAaDeGu1FJl6UuYjpwTGzucokZW1NfnS66kyfT4fXns9RaZmlgNcuhZQU+jowLzuOK/HgwEW3E5ZlhLXVWKk11P3wNYNWw+HZdA0sUgx1zjGmD05nckx0ilGjBJdUq3fr7K5e8bGf43RdL7fOPSQb4lI8SLbrUfkUIuY32VTI1bJn5BqDnh4Dodt9ryPUDzyD7aquWoKQohl2i9sAbubwPkTcHkP3FHsg+yT+7sN7G0AF3Xg6sHB3onbdgWWKBDQg/BcTuVt51dQA/JrnIcyIu6rmPV3/hJgACPc0BMEYTg+AAAAAElFTkSuQmCC";
-
-
-/**
- * Renders the operation in HTML as a stub operation with no ingredients.
- *
- * @returns {string}
- */
-HTMLOperation.prototype.toStubHtml = function(removeIcon) {
-    let html = "<li class='operation'";
-
-    if (this.description) {
-        html += " data-container='body' data-toggle='popover' data-placement='auto right'\
-            data-content=\"" + this.description + "\" data-html='true' data-trigger='hover'";
-    }
-
-    html += ">" + this.name;
-
-    if (removeIcon) {
-        html += "<img src='data:image/png;base64," + HTMLOperation.REMOVE_ICON +
-            "' class='op-icon remove-icon'>";
-    }
-
-    if (this.description) {
-        html += "<img src='data:image/png;base64," + HTMLOperation.INFO_ICON + "' class='op-icon'>";
-    }
-
-    html += "</li>";
-
-    return html;
-};
-
-
-/**
- * Renders the operation in HTML as a full operation with ingredients.
- *
- * @returns {string}
- */
-HTMLOperation.prototype.toFullHtml = function() {
-    let html = "<div class='arg-title'>" + this.name + "</div>";
-
-    for (let i = 0; i < this.ingList.length; i++) {
-        html += this.ingList[i].toHtml();
-    }
-
-    html += "<div class='recip-icons'>\
-        <div class='breakpoint' title='Set breakpoint' break='false'></div>\
-        <div class='disable-icon recip-icon' title='Disable operation'\
-            disabled='false'></div>";
-
-    html += "</div>\
-        <div class='clearfix'>&nbsp;</div>";
-
-    return html;
-};
-
-
-/**
- * Highlights the searched string in the name and description of the operation.
- *
- * @param {string} searchStr
- * @param {number} namePos - The position of the search string in the operation name
- * @param {number} descPos - The position of the search string in the operation description
- */
-HTMLOperation.prototype.highlightSearchString = function(searchStr, namePos, descPos) {
-    if (namePos >= 0) {
-        this.name = this.name.slice(0, namePos) + "<b><u>" +
-            this.name.slice(namePos, namePos + searchStr.length) + "</u></b>" +
-            this.name.slice(namePos + searchStr.length);
-    }
-
-    if (this.description && descPos >= 0) {
-        // Find HTML tag offsets
-        const re = /<[^>]+>/g;
-        let match;
-        while ((match = re.exec(this.description))) {
-            // If the search string occurs within an HTML tag, return without highlighting it.
-            if (descPos >= match.index && descPos <= (match.index + match[0].length))
-                return;
-        }
-
-        this.description = this.description.slice(0, descPos) + "<b><u>" +
-            this.description.slice(descPos, descPos + searchStr.length) + "</u></b>" +
-            this.description.slice(descPos + searchStr.length);
-    }
-};
-
-export default HTMLOperation;

+ 129 - 0
src/web/HTMLOperation.mjs

@@ -0,0 +1,129 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import HTMLIngredient from "./HTMLIngredient";
+
+const INFO_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAByElEQVR4XqVTzWoaYRQ9KZJmoVaS1J1QiYTIuOgqi9lEugguQhYhdGs3hTyAi0CWJTvJIks30ZBNsimUtlqkVLoQCuJsphRriyFjabWtEyf/Rv3iWcwwymTlgQuH851z5hu43wRGkEwmXwCIA4hiGAUAmUQikQbhEHwyGCWVSglVVUW73RYmyKnxjB56ncJ6NpsVxHGrI/ZLuniVb3DIqQmCHnrNkgcggNeSJPlisRgyJR2b737j/TcDsQUPwv6H5NR4BnroZcb6Z16N2PvyX6yna9Z8qp6JQ0Uf0ughmGHWBSAuyzJqrQ7eqKewY/dzE363C71e39LoWQq5wUwul4uzIBoIBHD01RgyrkZ8eDbvwUWnj623v2DHx4qB51IAzLIAXq8XP/7W0bUVVJtXWIk8wvlN364TA+/1IDMLwmWK/Hq3axmhaBdoGLeklm73ElaBYRgIzkyifHIOO4QQJKM3oJcZq6CgaVp0OTyHw9K/kQI4FiyHfdC0n2CWe5ApFosIPZ7C2tNpXpcDOehGyD/FIbd0euhlhllzFxRzC3fydbG4XRYbB9/tQ41n9m1U7l3lyp9LkfygiZeZCoecmtMqj/+Yxn7Od3v0j50qCO3zAAAAAElFTkSuQmCC";
+const REMOVE_ICON = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABwklEQVR42qRTPU8CQRB9K2CCMRJ6NTQajOUaqfxIbLCRghhjQixosLAgFNBQ3l8wsabxLxBJbCyVUBiMCVQEQkOEKBbCnefM3p4eohWXzM3uvHlv52b2hG3bmOWZw4yPn1/XQkCQ9wFxcgZZ0QLKpifpN8Z1n1L13griBBjHhYK0nMT4b+wom53ClAAFQacZJ/m8rNfrSOZy0vxJjPP6IJ2WzWYTO6mUwiwtILiJJSHUKVSWkchkZK1WQzQaxU2pVGUglkjIbreLUCiEx0qlStlFCpfPiPstYDtVKJH9ZFI2Gw1FGA6H6LTbCAaDeGu1FJl6UuYjpwTGzucokZW1NfnS66kyfT4fXns9RaZmlgNcuhZQU+jowLzuOK/HgwEW3E5ZlhLXVWKk11P3wNYNWw+HZdA0sUgx1zjGmD05nckx0ilGjBJdUq3fr7K5e8bGf43RdL7fOPSQb4lI8SLbrUfkUIuY32VTI1bJn5BqDnh4Dodt9ryPUDzyD7aquWoKQohl2i9sAbubwPkTcHkP3FHsg+yT+7sN7G0AF3Xg6sHB3onbdgWWKBDQg/BcTuVt51dQA/JrnIcyIu6rmPV3/hJgACPc0BMEYTg+AAAAAElFTkSuQmCC";
+
+
+/**
+ * Object to handle the creation of operations.
+ */
+class HTMLOperation {
+
+    /**
+     * HTMLOperation constructor.
+     *
+     * @param {string} name - The name of the operation.
+     * @param {Object} config - The configuration object for this operation.
+     * @param {App} app - The main view object for CyberChef.
+     * @param {Manager} manager - The CyberChef event manager.
+     */
+    constructor(name, config, app, manager) {
+        this.app         = app;
+        this.manager     = manager;
+
+        this.name        = name;
+        this.description = config.description;
+        this.manualBake  = config.manualBake || false;
+        this.config      = config;
+        this.ingList     = [];
+
+        for (let i = 0; i < config.args.length; i++) {
+            const ing = new HTMLIngredient(config.args[i], this.app, this.manager);
+            this.ingList.push(ing);
+        }
+    }
+
+
+    /**
+     * Renders the operation in HTML as a stub operation with no ingredients.
+     *
+     * @returns {string}
+     */
+    toStubHtml(removeIcon) {
+        let html = "<li class='operation'";
+
+        if (this.description) {
+            html += " data-container='body' data-toggle='popover' data-placement='auto right'\
+                data-content=\"" + this.description + "\" data-html='true' data-trigger='hover'";
+        }
+
+        html += ">" + this.name;
+
+        if (removeIcon) {
+            html += "<img src='data:image/png;base64," + REMOVE_ICON +
+                "' class='op-icon remove-icon'>";
+        }
+
+        if (this.description) {
+            html += "<img src='data:image/png;base64," + INFO_ICON + "' class='op-icon'>";
+        }
+
+        html += "</li>";
+
+        return html;
+    }
+
+
+    /**
+     * Renders the operation in HTML as a full operation with ingredients.
+     *
+     * @returns {string}
+     */
+    toFullHtml() {
+        let html = "<div class='arg-title'>" + this.name + "</div>";
+
+        for (let i = 0; i < this.ingList.length; i++) {
+            html += this.ingList[i].toHtml();
+        }
+
+        html += "<div class='recip-icons'>\
+            <div class='breakpoint' title='Set breakpoint' break='false'></div>\
+            <div class='disable-icon recip-icon' title='Disable operation'\
+                disabled='false'></div>";
+
+        html += "</div>\
+            <div class='clearfix'>&nbsp;</div>";
+
+        return html;
+    }
+
+
+    /**
+     * Highlights the searched string in the name and description of the operation.
+     *
+     * @param {string} searchStr
+     * @param {number} namePos - The position of the search string in the operation name
+     * @param {number} descPos - The position of the search string in the operation description
+     */
+    highlightSearchString(searchStr, namePos, descPos) {
+        if (namePos >= 0) {
+            this.name = this.name.slice(0, namePos) + "<b><u>" +
+                this.name.slice(namePos, namePos + searchStr.length) + "</u></b>" +
+                this.name.slice(namePos + searchStr.length);
+        }
+
+        if (this.description && descPos >= 0) {
+            // Find HTML tag offsets
+            const re = /<[^>]+>/g;
+            let match;
+            while ((match = re.exec(this.description))) {
+                // If the search string occurs within an HTML tag, return without highlighting it.
+                if (descPos >= match.index && descPos <= (match.index + match[0].length))
+                    return;
+            }
+
+            this.description = this.description.slice(0, descPos) + "<b><u>" +
+                this.description.slice(descPos, descPos + searchStr.length) + "</u></b>" +
+                this.description.slice(descPos + searchStr.length);
+        }
+    }
+
+}
+
+export default HTMLOperation;

+ 0 - 461
src/web/HighlighterWaiter.js

@@ -1,461 +0,0 @@
-/**
- * Waiter to handle events related to highlighting in CyberChef.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const HighlighterWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    this.mouseButtonDown = false;
-    this.mouseTarget = null;
-};
-
-
-/**
- * HighlighterWaiter data type enum for the input.
- * @readonly
- * @enum
- */
-HighlighterWaiter.INPUT  = 0;
-/**
- * HighlighterWaiter data type enum for the output.
- * @readonly
- * @enum
- */
-HighlighterWaiter.OUTPUT = 1;
-
-
-/**
- * Determines if the current text selection is running backwards or forwards.
- * StackOverflow answer id: 12652116
- *
- * @private
- * @returns {boolean}
- */
-HighlighterWaiter.prototype._isSelectionBackwards = function() {
-    let backwards = false;
-    const sel = window.getSelection();
-
-    if (!sel.isCollapsed) {
-        const range = document.createRange();
-        range.setStart(sel.anchorNode, sel.anchorOffset);
-        range.setEnd(sel.focusNode, sel.focusOffset);
-        backwards = range.collapsed;
-        range.detach();
-    }
-    return backwards;
-};
-
-
-/**
- * Calculates the text offset of a position in an HTML element, ignoring HTML tags.
- *
- * @private
- * @param {element} node - The parent HTML node.
- * @param {number} offset - The offset since the last HTML element.
- * @returns {number}
- */
-HighlighterWaiter.prototype._getOutputHtmlOffset = function(node, offset) {
-    const sel = window.getSelection();
-    const range = document.createRange();
-
-    range.selectNodeContents(document.getElementById("output-html"));
-    range.setEnd(node, offset);
-    sel.removeAllRanges();
-    sel.addRange(range);
-
-    return sel.toString().length;
-};
-
-
-/**
- * Gets the current selection offsets in the output HTML, ignoring HTML tags.
- *
- * @private
- * @returns {Object} pos
- * @returns {number} pos.start
- * @returns {number} pos.end
- */
-HighlighterWaiter.prototype._getOutputHtmlSelectionOffsets = function() {
-    const sel = window.getSelection();
-    let range,
-        start = 0,
-        end = 0,
-        backwards = false;
-
-    if (sel.rangeCount) {
-        range = sel.getRangeAt(sel.rangeCount - 1);
-        backwards = this._isSelectionBackwards();
-        start = this._getOutputHtmlOffset(range.startContainer, range.startOffset);
-        end = this._getOutputHtmlOffset(range.endContainer, range.endOffset);
-        sel.removeAllRanges();
-        sel.addRange(range);
-
-        if (backwards) {
-            // If selecting backwards, reverse the start and end offsets for the selection to
-            // prevent deselecting as the drag continues.
-            sel.collapseToEnd();
-            sel.extend(sel.anchorNode, range.startOffset);
-        }
-    }
-
-    return {
-        start: start,
-        end: end
-    };
-};
-
-
-/**
- * Handler for input scroll events.
- * Scrolls the highlighter pane to match the input textarea position.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.inputScroll = function(e) {
-    const el = e.target;
-    document.getElementById("input-highlighter").scrollTop = el.scrollTop;
-    document.getElementById("input-highlighter").scrollLeft = el.scrollLeft;
-};
-
-
-/**
- * Handler for output scroll events.
- * Scrolls the highlighter pane to match the output textarea position.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.outputScroll = function(e) {
-    const el = e.target;
-    document.getElementById("output-highlighter").scrollTop = el.scrollTop;
-    document.getElementById("output-highlighter").scrollLeft = el.scrollLeft;
-};
-
-
-/**
- * Handler for input mousedown events.
- * Calculates the current selection info, and highlights the corresponding data in the output.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.inputMousedown = function(e) {
-    this.mouseButtonDown = true;
-    this.mouseTarget = HighlighterWaiter.INPUT;
-    this.removeHighlights();
-
-    const el = e.target;
-    const start = el.selectionStart;
-    const end = el.selectionEnd;
-
-    if (start !== 0 || end !== 0) {
-        document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end);
-        this.highlightOutput([{start: start, end: end}]);
-    }
-};
-
-
-/**
- * Handler for output mousedown events.
- * Calculates the current selection info, and highlights the corresponding data in the input.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.outputMousedown = function(e) {
-    this.mouseButtonDown = true;
-    this.mouseTarget = HighlighterWaiter.OUTPUT;
-    this.removeHighlights();
-
-    const el = e.target;
-    const start = el.selectionStart;
-    const end = el.selectionEnd;
-
-    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
- */
-HighlighterWaiter.prototype.outputHtmlMousedown = function(e) {
-    this.mouseButtonDown = true;
-    this.mouseTarget = HighlighterWaiter.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.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.inputMouseup = function(e) {
-    this.mouseButtonDown = false;
-};
-
-
-/**
- * Handler for output mouseup events.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.outputMouseup = function(e) {
-    this.mouseButtonDown = false;
-};
-
-
-/**
- * Handler for output HTML mouseup events.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.outputHtmlMouseup = function(e) {
-    this.mouseButtonDown = false;
-};
-
-
-/**
- * Handler for input mousemove events.
- * Calculates the current selection info, and highlights the corresponding data in the output.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.inputMousemove = function(e) {
-    // Check that the left mouse button is pressed
-    if (!this.mouseButtonDown ||
-        e.which !== 1 ||
-        this.mouseTarget !== HighlighterWaiter.INPUT)
-        return;
-
-    const el = e.target;
-    const start = el.selectionStart;
-    const end = el.selectionEnd;
-
-    if (start !== 0 || end !== 0) {
-        document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end);
-        this.highlightOutput([{start: start, end: end}]);
-    }
-};
-
-
-/**
- * Handler for output mousemove events.
- * Calculates the current selection info, and highlights the corresponding data in the input.
- *
- * @param {event} e
- */
-HighlighterWaiter.prototype.outputMousemove = function(e) {
-    // Check that the left mouse button is pressed
-    if (!this.mouseButtonDown ||
-        e.which !== 1 ||
-        this.mouseTarget !== HighlighterWaiter.OUTPUT)
-        return;
-
-    const el = e.target;
-    const start = el.selectionStart;
-    const end = el.selectionEnd;
-
-    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
- */
-HighlighterWaiter.prototype.outputHtmlMousemove = function(e) {
-    // Check that the left mouse button is pressed
-    if (!this.mouseButtonDown ||
-        e.which !== 1 ||
-        this.mouseTarget !== HighlighterWaiter.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.
- *
- * @param {number} start - The start offset.
- * @param {number} end - The end offset.
- * @returns {string}
- */
-HighlighterWaiter.prototype.selectionInfo = function(start, end) {
-    const len = end.toString().length;
-    const width = len < 2 ? 2 : len;
-    const startStr = start.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-    const endStr = end.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-    const lenStr = (end-start).toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-
-    return "start: " + startStr + "<br>end: " + endStr + "<br>length: " + lenStr;
-};
-
-
-/**
- * Removes highlighting and selection information.
- */
-HighlighterWaiter.prototype.removeHighlights = function() {
-    document.getElementById("input-highlighter").innerHTML = "";
-    document.getElementById("output-highlighter").innerHTML = "";
-    document.getElementById("input-selection-info").innerHTML = "";
-    document.getElementById("output-selection-info").innerHTML = "";
-};
-
-
-/**
- * Highlights the given offsets in the output.
- * We will only highlight if:
- *     - input hasn't changed since last bake
- *     - last bake was a full bake
- *     - all operations in the recipe support highlighting
- *
- * @param {Object} pos - The position object for the highlight.
- * @param {number} pos.start - The start offset.
- * @param {number} pos.end - The end offset.
- */
-HighlighterWaiter.prototype.highlightOutput = function(pos) {
-    if (!this.app.autoBake_ || this.app.baking) return false;
-    this.manager.worker.highlight(this.app.getRecipeConfig(), "forward", pos);
-};
-
-
-/**
- * Highlights the given offsets in the input.
- * We will only highlight if:
- *     - input hasn't changed since last bake
- *     - last bake was a full bake
- *     - all operations in the recipe support highlighting
- *
- * @param {Object} pos - The position object for the highlight.
- * @param {number} pos.start - The start offset.
- * @param {number} pos.end - The end offset.
- */
-HighlighterWaiter.prototype.highlightInput = function(pos) {
-    if (!this.app.autoBake_ || this.app.baking) return false;
-    this.manager.worker.highlight(this.app.getRecipeConfig(), "reverse", pos);
-};
-
-
-/**
- * Displays highlight offsets sent back from the Chef.
- *
- * @param {Object} pos - The position object for the highlight.
- * @param {number} pos.start - The start offset.
- * @param {number} pos.end - The end offset.
- * @param {string} direction
- */
-HighlighterWaiter.prototype.displayHighlights = function(pos, direction) {
-    if (!pos) return;
-
-    const io = direction === "forward" ? "output" : "input";
-
-    document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end);
-    this.highlight(
-        document.getElementById(io + "-text"),
-        document.getElementById(io + "-highlighter"),
-        pos);
-};
-
-
-/**
- * Adds the relevant HTML to the specified highlight element such that highlighting appears
- * underneath the correct offset.
- *
- * @param {element} textarea - The input or output textarea.
- * @param {element} highlighter - The input or output highlighter element.
- * @param {Object} pos - The position object for the highlight.
- * @param {number} pos.start - The start offset.
- * @param {number} pos.end - The end offset.
- */
-HighlighterWaiter.prototype.highlight = async function(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";
-    //if (colour) cssClass += "-"+colour;
-
-    // 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;
-};
-
-export default HighlighterWaiter;

+ 468 - 0
src/web/HighlighterWaiter.mjs

@@ -0,0 +1,468 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * HighlighterWaiter data type enum for the input.
+ * @enum
+ */
+const INPUT = 0;
+
+/**
+ * HighlighterWaiter data type enum for the output.
+ * @enum
+ */
+const OUTPUT = 1;
+
+
+/**
+ * Waiter to handle events related to highlighting in CyberChef.
+ */
+class HighlighterWaiter {
+
+    /**
+     * HighlighterWaiter 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;
+
+        this.mouseButtonDown = false;
+        this.mouseTarget = null;
+    }
+
+
+    /**
+     * Determines if the current text selection is running backwards or forwards.
+     * StackOverflow answer id: 12652116
+     *
+     * @private
+     * @returns {boolean}
+     */
+    _isSelectionBackwards() {
+        let backwards = false;
+        const sel = window.getSelection();
+
+        if (!sel.isCollapsed) {
+            const range = document.createRange();
+            range.setStart(sel.anchorNode, sel.anchorOffset);
+            range.setEnd(sel.focusNode, sel.focusOffset);
+            backwards = range.collapsed;
+            range.detach();
+        }
+        return backwards;
+    }
+
+
+    /**
+     * Calculates the text offset of a position in an HTML element, ignoring HTML tags.
+     *
+     * @private
+     * @param {element} node - The parent HTML node.
+     * @param {number} offset - The offset since the last HTML element.
+     * @returns {number}
+     */
+    _getOutputHtmlOffset(node, offset) {
+        const sel = window.getSelection();
+        const range = document.createRange();
+
+        range.selectNodeContents(document.getElementById("output-html"));
+        range.setEnd(node, offset);
+        sel.removeAllRanges();
+        sel.addRange(range);
+
+        return sel.toString().length;
+    }
+
+
+    /**
+     * Gets the current selection offsets in the output HTML, ignoring HTML tags.
+     *
+     * @private
+     * @returns {Object} pos
+     * @returns {number} pos.start
+     * @returns {number} pos.end
+     */
+    _getOutputHtmlSelectionOffsets() {
+        const sel = window.getSelection();
+        let range,
+            start = 0,
+            end = 0,
+            backwards = false;
+
+        if (sel.rangeCount) {
+            range = sel.getRangeAt(sel.rangeCount - 1);
+            backwards = this._isSelectionBackwards();
+            start = this._getOutputHtmlOffset(range.startContainer, range.startOffset);
+            end = this._getOutputHtmlOffset(range.endContainer, range.endOffset);
+            sel.removeAllRanges();
+            sel.addRange(range);
+
+            if (backwards) {
+                // If selecting backwards, reverse the start and end offsets for the selection to
+                // prevent deselecting as the drag continues.
+                sel.collapseToEnd();
+                sel.extend(sel.anchorNode, range.startOffset);
+            }
+        }
+
+        return {
+            start: start,
+            end: end
+        };
+    }
+
+
+    /**
+     * Handler for input scroll events.
+     * Scrolls the highlighter pane to match the input textarea position.
+     *
+     * @param {event} e
+     */
+    inputScroll(e) {
+        const el = e.target;
+        document.getElementById("input-highlighter").scrollTop = el.scrollTop;
+        document.getElementById("input-highlighter").scrollLeft = el.scrollLeft;
+    }
+
+
+    /**
+     * Handler for output scroll events.
+     * Scrolls the highlighter pane to match the output textarea position.
+     *
+     * @param {event} e
+     */
+    outputScroll(e) {
+        const el = e.target;
+        document.getElementById("output-highlighter").scrollTop = el.scrollTop;
+        document.getElementById("output-highlighter").scrollLeft = el.scrollLeft;
+    }
+
+
+    /**
+     * Handler for input mousedown events.
+     * Calculates the current selection info, and highlights the corresponding data in the output.
+     *
+     * @param {event} e
+     */
+    inputMousedown(e) {
+        this.mouseButtonDown = true;
+        this.mouseTarget = INPUT;
+        this.removeHighlights();
+
+        const el = e.target;
+        const start = el.selectionStart;
+        const end = el.selectionEnd;
+
+        if (start !== 0 || end !== 0) {
+            document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end);
+            this.highlightOutput([{start: start, end: end}]);
+        }
+    }
+
+
+    /**
+     * Handler for output mousedown events.
+     * Calculates the current selection info, and highlights the corresponding data in the input.
+     *
+     * @param {event} e
+     */
+    outputMousedown(e) {
+        this.mouseButtonDown = true;
+        this.mouseTarget = OUTPUT;
+        this.removeHighlights();
+
+        const el = e.target;
+        const start = el.selectionStart;
+        const end = el.selectionEnd;
+
+        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.
+     *
+     * @param {event} e
+     */
+    inputMouseup(e) {
+        this.mouseButtonDown = false;
+    }
+
+
+    /**
+     * Handler for output mouseup events.
+     *
+     * @param {event} e
+     */
+    outputMouseup(e) {
+        this.mouseButtonDown = false;
+    }
+
+
+    /**
+     * 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.
+     *
+     * @param {event} e
+     */
+    inputMousemove(e) {
+        // Check that the left mouse button is pressed
+        if (!this.mouseButtonDown ||
+            e.which !== 1 ||
+            this.mouseTarget !== INPUT)
+            return;
+
+        const el = e.target;
+        const start = el.selectionStart;
+        const end = el.selectionEnd;
+
+        if (start !== 0 || end !== 0) {
+            document.getElementById("input-selection-info").innerHTML = this.selectionInfo(start, end);
+            this.highlightOutput([{start: start, end: end}]);
+        }
+    }
+
+
+    /**
+     * Handler for output mousemove events.
+     * Calculates the current selection info, and highlights the corresponding data in the input.
+     *
+     * @param {event} e
+     */
+    outputMousemove(e) {
+        // Check that the left mouse button is pressed
+        if (!this.mouseButtonDown ||
+            e.which !== 1 ||
+            this.mouseTarget !== OUTPUT)
+            return;
+
+        const el = e.target;
+        const start = el.selectionStart;
+        const end = el.selectionEnd;
+
+        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.
+     *
+     * @param {number} start - The start offset.
+     * @param {number} end - The end offset.
+     * @returns {string}
+     */
+    selectionInfo(start, end) {
+        const len = end.toString().length;
+        const width = len < 2 ? 2 : len;
+        const startStr = start.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
+        const endStr = end.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
+        const lenStr = (end-start).toString().padStart(width, " ").replace(/ /g, "&nbsp;");
+
+        return "start: " + startStr + "<br>end: " + endStr + "<br>length: " + lenStr;
+    }
+
+
+    /**
+     * Removes highlighting and selection information.
+     */
+    removeHighlights() {
+        document.getElementById("input-highlighter").innerHTML = "";
+        document.getElementById("output-highlighter").innerHTML = "";
+        document.getElementById("input-selection-info").innerHTML = "";
+        document.getElementById("output-selection-info").innerHTML = "";
+    }
+
+
+    /**
+     * Highlights the given offsets in the output.
+     * We will only highlight if:
+     *     - input hasn't changed since last bake
+     *     - last bake was a full bake
+     *     - all operations in the recipe support highlighting
+     *
+     * @param {Object} pos - The position object for the highlight.
+     * @param {number} pos.start - The start offset.
+     * @param {number} pos.end - The end offset.
+     */
+    highlightOutput(pos) {
+        if (!this.app.autoBake_ || this.app.baking) return false;
+        this.manager.worker.highlight(this.app.getRecipeConfig(), "forward", pos);
+    }
+
+
+    /**
+     * Highlights the given offsets in the input.
+     * We will only highlight if:
+     *     - input hasn't changed since last bake
+     *     - last bake was a full bake
+     *     - all operations in the recipe support highlighting
+     *
+     * @param {Object} pos - The position object for the highlight.
+     * @param {number} pos.start - The start offset.
+     * @param {number} pos.end - The end offset.
+     */
+    highlightInput(pos) {
+        if (!this.app.autoBake_ || this.app.baking) return false;
+        this.manager.worker.highlight(this.app.getRecipeConfig(), "reverse", pos);
+    }
+
+
+    /**
+     * Displays highlight offsets sent back from the Chef.
+     *
+     * @param {Object} pos - The position object for the highlight.
+     * @param {number} pos.start - The start offset.
+     * @param {number} pos.end - The end offset.
+     * @param {string} direction
+     */
+    displayHighlights(pos, direction) {
+        if (!pos) return;
+
+        const io = direction === "forward" ? "output" : "input";
+
+        document.getElementById(io + "-selection-info").innerHTML = this.selectionInfo(pos[0].start, pos[0].end);
+        this.highlight(
+            document.getElementById(io + "-text"),
+            document.getElementById(io + "-highlighter"),
+            pos);
+    }
+
+
+    /**
+     * Adds the relevant HTML to the specified highlight element such that highlighting appears
+     * underneath the correct offset.
+     *
+     * @param {element} textarea - The input or output textarea.
+     * @param {element} highlighter - The input or output highlighter element.
+     * @param {Object} pos - The position object for the highlight.
+     * @param {number} pos.start - The start offset.
+     * @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";
+        //if (colour) cssClass += "-"+colour;
+
+        // 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;
+    }
+
+}
+
+export default HighlighterWaiter;

+ 0 - 321
src/web/InputWaiter.js

@@ -1,321 +0,0 @@
-import LoaderWorker from "worker-loader?inline&fallback=false!./LoaderWorker.js";
-import Utils from "../core/Utils";
-
-
-/**
- * Waiter to handle events related to the input.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const InputWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    // Define keys that don't change the input so we don't have to autobake when they are pressed
-    this.badKeys = [
-        16, //Shift
-        17, //Ctrl
-        18, //Alt
-        19, //Pause
-        20, //Caps
-        27, //Esc
-        33, 34, 35, 36, //PgUp, PgDn, End, Home
-        37, 38, 39, 40, //Directional
-        44, //PrntScrn
-        91, 92, //Win
-        93, //Context
-        112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12
-        144, //Num
-        145, //Scroll
-    ];
-
-    this.loaderWorker = null;
-    this.fileBuffer = null;
-};
-
-
-/**
- * Gets the user's input from the input textarea.
- *
- * @returns {string}
- */
-InputWaiter.prototype.get = function() {
-    return this.fileBuffer || document.getElementById("input-text").value;
-};
-
-
-/**
- * Sets the input in the input area.
- *
- * @param {string|File} input
- *
- * @fires Manager#statechange
- */
-InputWaiter.prototype.set = function(input) {
-    const inputText = document.getElementById("input-text");
-    if (input instanceof File) {
-        this.setFile(input);
-        inputText.value = "";
-        this.setInputInfo(input.size, null);
-    } else {
-        inputText.value = input;
-        this.closeFile();
-        window.dispatchEvent(this.manager.statechange);
-        const lines = input.length < (this.app.options.ioDisplayThreshold * 1024) ?
-            input.count("\n") + 1 : null;
-        this.setInputInfo(input.length, lines);
-    }
-};
-
-
-/**
- * Shows file details.
- *
- * @param {File} file
- */
-InputWaiter.prototype.setFile = function(file) {
-    // Display file overlay in input area with details
-    const fileOverlay = document.getElementById("input-file"),
-        fileName = document.getElementById("input-file-name"),
-        fileSize = document.getElementById("input-file-size"),
-        fileType = document.getElementById("input-file-type"),
-        fileLoaded = document.getElementById("input-file-loaded");
-
-    this.fileBuffer = new ArrayBuffer();
-    fileOverlay.style.display = "block";
-    fileName.textContent = file.name;
-    fileSize.textContent = file.size.toLocaleString() + " bytes";
-    fileType.textContent = file.type || "unknown";
-    fileLoaded.textContent = "0%";
-};
-
-
-/**
- * Displays information about the input.
- *
- * @param {number} length - The length of the current input string
- * @param {number} lines - The number of the lines in the current input string
- */
-InputWaiter.prototype.setInputInfo = function(length, lines) {
-    let width = length.toString().length;
-    width = width < 2 ? 2 : width;
-
-    const lengthStr = length.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-    let msg = "length: " + lengthStr;
-
-    if (typeof lines === "number") {
-        const linesStr = lines.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
-        msg += "<br>lines: " + linesStr;
-    }
-
-    document.getElementById("input-info").innerHTML = msg;
-};
-
-
-/**
- * Handler for input change events.
- *
- * @param {event} e
- *
- * @fires Manager#statechange
- */
-InputWaiter.prototype.inputChange = function(e) {
-    // Ignore this function if the input is a File
-    if (this.fileBuffer) return;
-
-    // Remove highlighting from input and output panes as the offsets might be different now
-    this.manager.highlighter.removeHighlights();
-
-    // Reset recipe progress as any previous processing will be redundant now
-    this.app.progress = 0;
-
-    // Update the input metadata info
-    const inputText = this.get();
-    const lines = inputText.length < (this.app.options.ioDisplayThreshold * 1024) ?
-        inputText.count("\n") + 1 : null;
-
-    this.setInputInfo(inputText.length, lines);
-
-    if (e && this.badKeys.indexOf(e.keyCode) < 0) {
-        // Fire the statechange event as the input has been modified
-        window.dispatchEvent(this.manager.statechange);
-    }
-};
-
-
-/**
- * Handler for input paste events.
- * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob.
- *
- * @param {event} e
- */
-InputWaiter.prototype.inputPaste = function(e) {
-    const pastedData = e.clipboardData.getData("Text");
-
-    if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) {
-        this.inputChange(e);
-    } else {
-        e.preventDefault();
-        e.stopPropagation();
-
-        const file = new File([pastedData], "PastedData", {
-            type: "text/plain",
-            lastModified: Date.now()
-        });
-
-        this.loaderWorker = new LoaderWorker();
-        this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this));
-        this.loaderWorker.postMessage({"file": file});
-        this.set(file);
-        return false;
-    }
-};
-
-
-/**
- * Handler for input dragover events.
- * Gives the user a visual cue to show that items can be dropped here.
- *
- * @param {event} e
- */
-InputWaiter.prototype.inputDragover = function(e) {
-    // This will be set if we're dragging an operation
-    if (e.dataTransfer.effectAllowed === "move")
-        return false;
-
-    e.stopPropagation();
-    e.preventDefault();
-    e.target.closest("#input-text,#input-file").classList.add("dropping-file");
-};
-
-
-/**
- * Handler for input dragleave events.
- * Removes the visual cue.
- *
- * @param {event} e
- */
-InputWaiter.prototype.inputDragleave = function(e) {
-    e.stopPropagation();
-    e.preventDefault();
-    document.getElementById("input-text").classList.remove("dropping-file");
-    document.getElementById("input-file").classList.remove("dropping-file");
-};
-
-
-/**
- * Handler for input drop events.
- * Loads the dragged data into the input textarea.
- *
- * @param {event} e
- */
-InputWaiter.prototype.inputDrop = function(e) {
-    // This will be set if we're dragging an operation
-    if (e.dataTransfer.effectAllowed === "move")
-        return false;
-
-    e.stopPropagation();
-    e.preventDefault();
-
-    const file = e.dataTransfer.files[0];
-    const text = e.dataTransfer.getData("Text");
-
-    document.getElementById("input-text").classList.remove("dropping-file");
-    document.getElementById("input-file").classList.remove("dropping-file");
-
-    if (text) {
-        this.closeFile();
-        this.set(text);
-        return;
-    }
-
-    if (file) {
-        this.closeFile();
-        this.loaderWorker = new LoaderWorker();
-        this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this));
-        this.loaderWorker.postMessage({"file": file});
-        this.set(file);
-    }
-};
-
-
-/**
- * Handler for messages sent back by the LoaderWorker.
- *
- * @param {MessageEvent} e
- */
-InputWaiter.prototype.handleLoaderMessage = function(e) {
-    const r = e.data;
-    if (r.hasOwnProperty("progress")) {
-        const fileLoaded = document.getElementById("input-file-loaded");
-        fileLoaded.textContent = r.progress + "%";
-    }
-
-    if (r.hasOwnProperty("error")) {
-        this.app.alert(r.error, "danger", 10000);
-    }
-
-    if (r.hasOwnProperty("fileBuffer")) {
-        log.debug("Input file loaded");
-        this.fileBuffer = r.fileBuffer;
-        this.displayFilePreview();
-        window.dispatchEvent(this.manager.statechange);
-    }
-};
-
-
-/**
- * Shows a chunk of the file in the input behind the file overlay.
- */
-InputWaiter.prototype.displayFilePreview = function() {
-    const inputText = document.getElementById("input-text"),
-        fileSlice = this.fileBuffer.slice(0, 4096);
-
-    inputText.style.overflow = "hidden";
-    inputText.classList.add("blur");
-    inputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice));
-    if (this.fileBuffer.byteLength > 4096) {
-        inputText.value += "[truncated]...";
-    }
-};
-
-
-/**
- * Handler for file close events.
- */
-InputWaiter.prototype.closeFile = function() {
-    if (this.loaderWorker) this.loaderWorker.terminate();
-    this.fileBuffer = null;
-    document.getElementById("input-file").style.display = "none";
-    const inputText = document.getElementById("input-text");
-    inputText.style.overflow = "auto";
-    inputText.classList.remove("blur");
-};
-
-
-/**
- * Handler for clear IO events.
- * Resets the input, output and info areas.
- *
- * @fires Manager#statechange
- */
-InputWaiter.prototype.clearIoClick = function() {
-    this.closeFile();
-    this.manager.output.closeFile();
-    this.manager.highlighter.removeHighlights();
-    document.getElementById("input-text").value = "";
-    document.getElementById("output-text").value = "";
-    document.getElementById("input-info").innerHTML = "";
-    document.getElementById("output-info").innerHTML = "";
-    document.getElementById("input-selection-info").innerHTML = "";
-    document.getElementById("output-selection-info").innerHTML = "";
-    window.dispatchEvent(this.manager.statechange);
-};
-
-export default InputWaiter;

+ 329 - 0
src/web/InputWaiter.mjs

@@ -0,0 +1,329 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import LoaderWorker from "worker-loader?inline&fallback=false!./LoaderWorker";
+import Utils from "../core/Utils";
+
+
+/**
+ * Waiter to handle events related to the input.
+ */
+class InputWaiter {
+
+    /**
+     * InputWaiter 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;
+
+        // Define keys that don't change the input so we don't have to autobake when they are pressed
+        this.badKeys = [
+            16, //Shift
+            17, //Ctrl
+            18, //Alt
+            19, //Pause
+            20, //Caps
+            27, //Esc
+            33, 34, 35, 36, //PgUp, PgDn, End, Home
+            37, 38, 39, 40, //Directional
+            44, //PrntScrn
+            91, 92, //Win
+            93, //Context
+            112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, //F1-12
+            144, //Num
+            145, //Scroll
+        ];
+
+        this.loaderWorker = null;
+        this.fileBuffer = null;
+    }
+
+
+    /**
+     * Gets the user's input from the input textarea.
+     *
+     * @returns {string}
+     */
+    get() {
+        return this.fileBuffer || document.getElementById("input-text").value;
+    }
+
+
+    /**
+     * Sets the input in the input area.
+     *
+     * @param {string|File} input
+     *
+     * @fires Manager#statechange
+     */
+    set(input) {
+        const inputText = document.getElementById("input-text");
+        if (input instanceof File) {
+            this.setFile(input);
+            inputText.value = "";
+            this.setInputInfo(input.size, null);
+        } else {
+            inputText.value = input;
+            this.closeFile();
+            window.dispatchEvent(this.manager.statechange);
+            const lines = input.length < (this.app.options.ioDisplayThreshold * 1024) ?
+                input.count("\n") + 1 : null;
+            this.setInputInfo(input.length, lines);
+        }
+    }
+
+
+    /**
+     * Shows file details.
+     *
+     * @param {File} file
+     */
+    setFile(file) {
+        // Display file overlay in input area with details
+        const fileOverlay = document.getElementById("input-file"),
+            fileName = document.getElementById("input-file-name"),
+            fileSize = document.getElementById("input-file-size"),
+            fileType = document.getElementById("input-file-type"),
+            fileLoaded = document.getElementById("input-file-loaded");
+
+        this.fileBuffer = new ArrayBuffer();
+        fileOverlay.style.display = "block";
+        fileName.textContent = file.name;
+        fileSize.textContent = file.size.toLocaleString() + " bytes";
+        fileType.textContent = file.type || "unknown";
+        fileLoaded.textContent = "0%";
+    }
+
+
+    /**
+     * Displays information about the input.
+     *
+     * @param {number} length - The length of the current input string
+     * @param {number} lines - The number of the lines in the current input string
+     */
+    setInputInfo(length, lines) {
+        let width = length.toString().length;
+        width = width < 2 ? 2 : width;
+
+        const lengthStr = length.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
+        let msg = "length: " + lengthStr;
+
+        if (typeof lines === "number") {
+            const linesStr = lines.toString().padStart(width, " ").replace(/ /g, "&nbsp;");
+            msg += "<br>lines: " + linesStr;
+        }
+
+        document.getElementById("input-info").innerHTML = msg;
+    }
+
+
+    /**
+     * Handler for input change events.
+     *
+     * @param {event} e
+     *
+     * @fires Manager#statechange
+     */
+    inputChange(e) {
+        // Ignore this function if the input is a File
+        if (this.fileBuffer) return;
+
+        // Remove highlighting from input and output panes as the offsets might be different now
+        this.manager.highlighter.removeHighlights();
+
+        // Reset recipe progress as any previous processing will be redundant now
+        this.app.progress = 0;
+
+        // Update the input metadata info
+        const inputText = this.get();
+        const lines = inputText.length < (this.app.options.ioDisplayThreshold * 1024) ?
+            inputText.count("\n") + 1 : null;
+
+        this.setInputInfo(inputText.length, lines);
+
+        if (e && this.badKeys.indexOf(e.keyCode) < 0) {
+            // Fire the statechange event as the input has been modified
+            window.dispatchEvent(this.manager.statechange);
+        }
+    }
+
+
+    /**
+     * Handler for input paste events.
+     * Checks that the size of the input is below the display limit, otherwise treats it as a file/blob.
+     *
+     * @param {event} e
+     */
+    inputPaste(e) {
+        const pastedData = e.clipboardData.getData("Text");
+
+        if (pastedData.length < (this.app.options.ioDisplayThreshold * 1024)) {
+            this.inputChange(e);
+        } else {
+            e.preventDefault();
+            e.stopPropagation();
+
+            const file = new File([pastedData], "PastedData", {
+                type: "text/plain",
+                lastModified: Date.now()
+            });
+
+            this.loaderWorker = new LoaderWorker();
+            this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this));
+            this.loaderWorker.postMessage({"file": file});
+            this.set(file);
+            return false;
+        }
+    }
+
+
+    /**
+     * Handler for input dragover events.
+     * Gives the user a visual cue to show that items can be dropped here.
+     *
+     * @param {event} e
+     */
+    inputDragover(e) {
+        // This will be set if we're dragging an operation
+        if (e.dataTransfer.effectAllowed === "move")
+            return false;
+
+        e.stopPropagation();
+        e.preventDefault();
+        e.target.closest("#input-text,#input-file").classList.add("dropping-file");
+    }
+
+
+    /**
+     * Handler for input dragleave events.
+     * Removes the visual cue.
+     *
+     * @param {event} e
+     */
+    inputDragleave(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        document.getElementById("input-text").classList.remove("dropping-file");
+        document.getElementById("input-file").classList.remove("dropping-file");
+    }
+
+
+    /**
+     * Handler for input drop events.
+     * Loads the dragged data into the input textarea.
+     *
+     * @param {event} e
+     */
+    inputDrop(e) {
+        // This will be set if we're dragging an operation
+        if (e.dataTransfer.effectAllowed === "move")
+            return false;
+
+        e.stopPropagation();
+        e.preventDefault();
+
+        const file = e.dataTransfer.files[0];
+        const text = e.dataTransfer.getData("Text");
+
+        document.getElementById("input-text").classList.remove("dropping-file");
+        document.getElementById("input-file").classList.remove("dropping-file");
+
+        if (text) {
+            this.closeFile();
+            this.set(text);
+            return;
+        }
+
+        if (file) {
+            this.closeFile();
+            this.loaderWorker = new LoaderWorker();
+            this.loaderWorker.addEventListener("message", this.handleLoaderMessage.bind(this));
+            this.loaderWorker.postMessage({"file": file});
+            this.set(file);
+        }
+    }
+
+
+    /**
+     * Handler for messages sent back by the LoaderWorker.
+     *
+     * @param {MessageEvent} e
+     */
+    handleLoaderMessage(e) {
+        const r = e.data;
+        if (r.hasOwnProperty("progress")) {
+            const fileLoaded = document.getElementById("input-file-loaded");
+            fileLoaded.textContent = r.progress + "%";
+        }
+
+        if (r.hasOwnProperty("error")) {
+            this.app.alert(r.error, "danger", 10000);
+        }
+
+        if (r.hasOwnProperty("fileBuffer")) {
+            log.debug("Input file loaded");
+            this.fileBuffer = r.fileBuffer;
+            this.displayFilePreview();
+            window.dispatchEvent(this.manager.statechange);
+        }
+    }
+
+
+    /**
+     * Shows a chunk of the file in the input behind the file overlay.
+     */
+    displayFilePreview() {
+        const inputText = document.getElementById("input-text"),
+            fileSlice = this.fileBuffer.slice(0, 4096);
+
+        inputText.style.overflow = "hidden";
+        inputText.classList.add("blur");
+        inputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice));
+        if (this.fileBuffer.byteLength > 4096) {
+            inputText.value += "[truncated]...";
+        }
+    }
+
+
+    /**
+     * Handler for file close events.
+     */
+    closeFile() {
+        if (this.loaderWorker) this.loaderWorker.terminate();
+        this.fileBuffer = null;
+        document.getElementById("input-file").style.display = "none";
+        const inputText = document.getElementById("input-text");
+        inputText.style.overflow = "auto";
+        inputText.classList.remove("blur");
+    }
+
+
+    /**
+     * Handler for clear IO events.
+     * Resets the input, output and info areas.
+     *
+     * @fires Manager#statechange
+     */
+    clearIoClick() {
+        this.closeFile();
+        this.manager.output.closeFile();
+        this.manager.highlighter.removeHighlights();
+        document.getElementById("input-text").value = "";
+        document.getElementById("output-text").value = "";
+        document.getElementById("input-info").innerHTML = "";
+        document.getElementById("output-info").innerHTML = "";
+        document.getElementById("input-selection-info").innerHTML = "";
+        document.getElementById("output-selection-info").innerHTML = "";
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+}
+
+export default InputWaiter;

+ 0 - 299
src/web/Manager.js

@@ -1,299 +0,0 @@
-import WorkerWaiter from "./WorkerWaiter.js";
-import WindowWaiter from "./WindowWaiter.js";
-import ControlsWaiter from "./ControlsWaiter.js";
-import RecipeWaiter from "./RecipeWaiter.js";
-import OperationsWaiter from "./OperationsWaiter.js";
-import InputWaiter from "./InputWaiter.js";
-import OutputWaiter from "./OutputWaiter.js";
-import OptionsWaiter from "./OptionsWaiter.js";
-import HighlighterWaiter from "./HighlighterWaiter.js";
-import SeasonalWaiter from "./SeasonalWaiter.js";
-import BindingsWaiter from "./BindingsWaiter.js";
-
-
-/**
- * This object controls the Waiters responsible for handling events from all areas of the app.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- */
-const Manager = function(app) {
-    this.app = app;
-
-    // Define custom events
-    /**
-     * @event Manager#appstart
-     */
-    this.appstart = new CustomEvent("appstart", {bubbles: true});
-    /**
-     * @event Manager#apploaded
-     */
-    this.apploaded = new CustomEvent("apploaded", {bubbles: true});
-    /**
-     * @event Manager#operationadd
-     */
-    this.operationadd = new CustomEvent("operationadd", {bubbles: true});
-    /**
-     * @event Manager#operationremove
-     */
-    this.operationremove = new CustomEvent("operationremove", {bubbles: true});
-    /**
-     * @event Manager#oplistcreate
-     */
-    this.oplistcreate = new CustomEvent("oplistcreate", {bubbles: true});
-    /**
-     * @event Manager#statechange
-     */
-    this.statechange = new CustomEvent("statechange", {bubbles: true});
-
-    // Define Waiter objects to handle various areas
-    this.worker      = new WorkerWaiter(this.app, this);
-    this.window      = new WindowWaiter(this.app);
-    this.controls    = new ControlsWaiter(this.app, this);
-    this.recipe      = new RecipeWaiter(this.app, this);
-    this.ops         = new OperationsWaiter(this.app, this);
-    this.input       = new InputWaiter(this.app, this);
-    this.output      = new OutputWaiter(this.app, this);
-    this.options     = new OptionsWaiter(this.app, this);
-    this.highlighter = new HighlighterWaiter(this.app, this);
-    this.seasonal    = new SeasonalWaiter(this.app, this);
-    this.bindings    = new BindingsWaiter(this.app, this);
-
-    // Object to store dynamic handlers to fire on elements that may not exist yet
-    this.dynamicHandlers = {};
-
-    this.initialiseEventListeners();
-};
-
-
-/**
- * Sets up the various components and listeners.
- */
-Manager.prototype.setup = function() {
-    this.worker.registerChefWorker();
-    this.recipe.initialiseOperationDragNDrop();
-    this.controls.autoBakeChange();
-    this.bindings.updateKeybList();
-    this.seasonal.load();
-};
-
-
-/**
- * Main function to handle the creation of the event listeners.
- */
-Manager.prototype.initialiseEventListeners = function() {
-    // Global
-    window.addEventListener("resize", this.window.windowResize.bind(this.window));
-    window.addEventListener("blur", this.window.windowBlur.bind(this.window));
-    window.addEventListener("focus", this.window.windowFocus.bind(this.window));
-    window.addEventListener("statechange", this.app.stateChange.bind(this.app));
-    window.addEventListener("popstate", this.app.popState.bind(this.app));
-
-    // Controls
-    document.getElementById("bake").addEventListener("click", this.controls.bakeClick.bind(this.controls));
-    document.getElementById("auto-bake").addEventListener("change", this.controls.autoBakeChange.bind(this.controls));
-    document.getElementById("step").addEventListener("click", this.controls.stepClick.bind(this.controls));
-    document.getElementById("clr-recipe").addEventListener("click", this.controls.clearRecipeClick.bind(this.controls));
-    document.getElementById("clr-breaks").addEventListener("click", this.controls.clearBreaksClick.bind(this.controls));
-    document.getElementById("save").addEventListener("click", this.controls.saveClick.bind(this.controls));
-    document.getElementById("save-button").addEventListener("click", this.controls.saveButtonClick.bind(this.controls));
-    document.getElementById("save-link-recipe-checkbox").addEventListener("change", this.controls.slrCheckChange.bind(this.controls));
-    document.getElementById("save-link-input-checkbox").addEventListener("change", this.controls.sliCheckChange.bind(this.controls));
-    document.getElementById("load").addEventListener("click", this.controls.loadClick.bind(this.controls));
-    document.getElementById("load-delete-button").addEventListener("click", this.controls.loadDeleteClick.bind(this.controls));
-    document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls));
-    document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls));
-    document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls));
-    this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls);
-
-    // Operations
-    this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops);
-    this.addDynamicListener(".op-list li.operation", "dblclick", this.ops.operationDblclick, this.ops);
-    document.getElementById("edit-favourites").addEventListener("click", this.ops.editFavouritesClick.bind(this.ops));
-    document.getElementById("save-favourites").addEventListener("click", this.ops.saveFavouritesClick.bind(this.ops));
-    document.getElementById("reset-favourites").addEventListener("click", this.ops.resetFavouritesClick.bind(this.ops));
-    this.addDynamicListener(".op-list .op-icon", "mouseover", this.ops.opIconMouseover, this.ops);
-    this.addDynamicListener(".op-list .op-icon", "mouseleave", this.ops.opIconMouseleave, this.ops);
-    this.addDynamicListener(".op-list", "oplistcreate", this.ops.opListCreate, this.ops);
-    this.addDynamicListener("li.operation", "operationadd", this.recipe.opAdd, this.recipe);
-
-    // Recipe
-    this.addDynamicListener(".arg:not(select)", "input", this.recipe.ingChange, this.recipe);
-    this.addDynamicListener(".arg[type=checkbox], .arg[type=radio], select.arg", "change", this.recipe.ingChange, this.recipe);
-    this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
-    this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe);
-    this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe);
-    this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe);
-    this.addDynamicListener("#rec-list .input-group .dropdown-menu a", "click", this.recipe.dropdownToggleClick, this.recipe);
-    this.addDynamicListener("#rec-list", "operationremove", this.recipe.opRemove.bind(this.recipe));
-
-    // Input
-    this.addMultiEventListener("#input-text", "keyup", this.input.inputChange, this.input);
-    this.addMultiEventListener("#input-text", "paste", this.input.inputPaste, this.input);
-    document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app));
-    document.getElementById("clr-io").addEventListener("click", this.input.clearIoClick.bind(this.input));
-    this.addListeners("#input-text,#input-file", "dragover", this.input.inputDragover, this.input);
-    this.addListeners("#input-text,#input-file", "dragleave", this.input.inputDragleave, this.input);
-    this.addListeners("#input-text,#input-file", "drop", this.input.inputDrop, this.input);
-    document.getElementById("input-text").addEventListener("scroll", this.highlighter.inputScroll.bind(this.highlighter));
-    document.getElementById("input-text").addEventListener("mouseup", this.highlighter.inputMouseup.bind(this.highlighter));
-    document.getElementById("input-text").addEventListener("mousemove", this.highlighter.inputMousemove.bind(this.highlighter));
-    this.addMultiEventListener("#input-text", "mousedown dblclick select",  this.highlighter.inputMousedown, this.highlighter);
-    document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input));
-
-    // Output
-    document.getElementById("save-to-file").addEventListener("click", this.output.saveClick.bind(this.output));
-    document.getElementById("copy-output").addEventListener("click", this.output.copyClick.bind(this.output));
-    document.getElementById("switch").addEventListener("click", this.output.switchClick.bind(this.output));
-    document.getElementById("undo-switch").addEventListener("click", this.output.undoSwitchClick.bind(this.output));
-    document.getElementById("maximise-output").addEventListener("click", this.output.maximiseOutputClick.bind(this.output));
-    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-slice", "click", this.output.displayFileSlice, this.output);
-    document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output));
-
-    // Options
-    document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options));
-    document.getElementById("reset-options").addEventListener("click", this.options.resetOptionsClick.bind(this.options));
-    $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.switchChange.bind(this.options));
-    $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.setWordWrap.bind(this.options));
-    $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox#useMetaKey", this.bindings.updateKeybList.bind(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);
-    this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options);
-    document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options));
-    document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options));
-
-    // Misc
-    window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings));
-    document.getElementById("alert-close").addEventListener("click", this.app.alertCloseClick.bind(this.app));
-};
-
-
-/**
- * Adds an event listener to each element in the specified group.
- *
- * @param {string} selector - A selector string for the element group to add the event to, see
- *   this.getAll()
- * @param {string} eventType - The event to listen for
- * @param {function} callback - The function to execute when the event is triggered
- * @param {Object} [scope=this] - The object to bind to the callback function
- *
- * @example
- * // Calls the clickable function whenever any element with the .clickable class is clicked
- * this.addListeners(".clickable", "click", this.clickable, this);
- */
-Manager.prototype.addListeners = function(selector, eventType, callback, scope) {
-    scope = scope || this;
-    [].forEach.call(document.querySelectorAll(selector), function(el) {
-        el.addEventListener(eventType, callback.bind(scope));
-    });
-};
-
-
-/**
- * Adds multiple event listeners to the specified element.
- *
- * @param {string} selector - A selector string for the element to add the events to
- * @param {string} eventTypes - A space-separated string of all the event types to listen for
- * @param {function} callback - The function to execute when the events are triggered
- * @param {Object} [scope=this] - The object to bind to the callback function
- *
- * @example
- * // Calls the search function whenever the the keyup, paste or search events are triggered on the
- * // search element
- * this.addMultiEventListener("search", "keyup paste search", this.search, this);
- */
-Manager.prototype.addMultiEventListener = function(selector, eventTypes, callback, scope) {
-    const evs = eventTypes.split(" ");
-    for (let i = 0; i < evs.length; i++) {
-        document.querySelector(selector).addEventListener(evs[i], callback.bind(scope));
-    }
-};
-
-
-/**
- * Adds multiple event listeners to each element in the specified group.
- *
- * @param {string} selector - A selector string for the element group to add the events to
- * @param {string} eventTypes - A space-separated string of all the event types to listen for
- * @param {function} callback - The function to execute when the events are triggered
- * @param {Object} [scope=this] - The object to bind to the callback function
- *
- * @example
- * // Calls the save function whenever the the keyup or paste events are triggered on any element
- * // with the .saveable class
- * this.addMultiEventListener(".saveable", "keyup paste", this.save, this);
- */
-Manager.prototype.addMultiEventListeners = function(selector, eventTypes, callback, scope) {
-    const evs = eventTypes.split(" ");
-    for (let i = 0; i < evs.length; i++) {
-        this.addListeners(selector, evs[i], callback, scope);
-    }
-};
-
-
-/**
- * Adds an event listener to the global document object which will listen on dynamic elements which
- * may not exist in the DOM yet.
- *
- * @param {string} selector - A selector string for the element(s) to add the event to
- * @param {string} eventType - The event(s) to listen for
- * @param {function} callback - The function to execute when the event(s) is/are triggered
- * @param {Object} [scope=this] - The object to bind to the callback function
- *
- * @example
- * // Pops up an alert whenever any button is clicked, even if it is added to the DOM after this
- * // listener is created
- * this.addDynamicListener("button", "click", alert, this);
- */
-Manager.prototype.addDynamicListener = function(selector, eventType, callback, scope) {
-    const eventConfig = {
-        selector: selector,
-        callback: callback.bind(scope || this)
-    };
-
-    if (this.dynamicHandlers.hasOwnProperty(eventType)) {
-        // Listener already exists, add new handler to the appropriate list
-        this.dynamicHandlers[eventType].push(eventConfig);
-    } else {
-        this.dynamicHandlers[eventType] = [eventConfig];
-        // Set up listener for this new type
-        document.addEventListener(eventType, this.dynamicListenerHandler.bind(this));
-    }
-};
-
-
-/**
- * Handler for dynamic events. This function is called for any dynamic event and decides which
- * callback(s) to execute based on the type and selector.
- *
- * @param {Event} e - The event to be handled
- */
-Manager.prototype.dynamicListenerHandler = function(e) {
-    const { type, target } = e;
-    const handlers = this.dynamicHandlers[type];
-    const matches = target.matches ||
-            target.webkitMatchesSelector ||
-            target.mozMatchesSelector ||
-            target.msMatchesSelector ||
-            target.oMatchesSelector;
-
-    for (let i = 0; i < handlers.length; i++) {
-        if (matches && matches.call(target, handlers[i].selector)) {
-            handlers[i].callback(e);
-        }
-    }
-};
-
-export default Manager;

+ 307 - 0
src/web/Manager.mjs

@@ -0,0 +1,307 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import WorkerWaiter from "./WorkerWaiter";
+import WindowWaiter from "./WindowWaiter";
+import ControlsWaiter from "./ControlsWaiter";
+import RecipeWaiter from "./RecipeWaiter";
+import OperationsWaiter from "./OperationsWaiter";
+import InputWaiter from "./InputWaiter";
+import OutputWaiter from "./OutputWaiter";
+import OptionsWaiter from "./OptionsWaiter";
+import HighlighterWaiter from "./HighlighterWaiter";
+import SeasonalWaiter from "./SeasonalWaiter";
+import BindingsWaiter from "./BindingsWaiter";
+
+
+/**
+ * This object controls the Waiters responsible for handling events from all areas of the app.
+ */
+class Manager {
+
+    /**
+     * Manager constructor.
+     *
+     * @param {App} app - The main view object for CyberChef.
+     */
+    constructor(app) {
+        this.app = app;
+
+        // Define custom events
+        /**
+         * @event Manager#appstart
+         */
+        this.appstart = new CustomEvent("appstart", {bubbles: true});
+        /**
+         * @event Manager#apploaded
+         */
+        this.apploaded = new CustomEvent("apploaded", {bubbles: true});
+        /**
+         * @event Manager#operationadd
+         */
+        this.operationadd = new CustomEvent("operationadd", {bubbles: true});
+        /**
+         * @event Manager#operationremove
+         */
+        this.operationremove = new CustomEvent("operationremove", {bubbles: true});
+        /**
+         * @event Manager#oplistcreate
+         */
+        this.oplistcreate = new CustomEvent("oplistcreate", {bubbles: true});
+        /**
+         * @event Manager#statechange
+         */
+        this.statechange = new CustomEvent("statechange", {bubbles: true});
+
+        // Define Waiter objects to handle various areas
+        this.worker      = new WorkerWaiter(this.app, this);
+        this.window      = new WindowWaiter(this.app);
+        this.controls    = new ControlsWaiter(this.app, this);
+        this.recipe      = new RecipeWaiter(this.app, this);
+        this.ops         = new OperationsWaiter(this.app, this);
+        this.input       = new InputWaiter(this.app, this);
+        this.output      = new OutputWaiter(this.app, this);
+        this.options     = new OptionsWaiter(this.app, this);
+        this.highlighter = new HighlighterWaiter(this.app, this);
+        this.seasonal    = new SeasonalWaiter(this.app, this);
+        this.bindings    = new BindingsWaiter(this.app, this);
+
+        // Object to store dynamic handlers to fire on elements that may not exist yet
+        this.dynamicHandlers = {};
+
+        this.initialiseEventListeners();
+    }
+
+
+    /**
+     * Sets up the various components and listeners.
+     */
+    setup() {
+        this.worker.registerChefWorker();
+        this.recipe.initialiseOperationDragNDrop();
+        this.controls.autoBakeChange();
+        this.bindings.updateKeybList();
+        this.seasonal.load();
+    }
+
+
+    /**
+     * Main function to handle the creation of the event listeners.
+     */
+    initialiseEventListeners() {
+        // Global
+        window.addEventListener("resize", this.window.windowResize.bind(this.window));
+        window.addEventListener("blur", this.window.windowBlur.bind(this.window));
+        window.addEventListener("focus", this.window.windowFocus.bind(this.window));
+        window.addEventListener("statechange", this.app.stateChange.bind(this.app));
+        window.addEventListener("popstate", this.app.popState.bind(this.app));
+
+        // Controls
+        document.getElementById("bake").addEventListener("click", this.controls.bakeClick.bind(this.controls));
+        document.getElementById("auto-bake").addEventListener("change", this.controls.autoBakeChange.bind(this.controls));
+        document.getElementById("step").addEventListener("click", this.controls.stepClick.bind(this.controls));
+        document.getElementById("clr-recipe").addEventListener("click", this.controls.clearRecipeClick.bind(this.controls));
+        document.getElementById("clr-breaks").addEventListener("click", this.controls.clearBreaksClick.bind(this.controls));
+        document.getElementById("save").addEventListener("click", this.controls.saveClick.bind(this.controls));
+        document.getElementById("save-button").addEventListener("click", this.controls.saveButtonClick.bind(this.controls));
+        document.getElementById("save-link-recipe-checkbox").addEventListener("change", this.controls.slrCheckChange.bind(this.controls));
+        document.getElementById("save-link-input-checkbox").addEventListener("change", this.controls.sliCheckChange.bind(this.controls));
+        document.getElementById("load").addEventListener("click", this.controls.loadClick.bind(this.controls));
+        document.getElementById("load-delete-button").addEventListener("click", this.controls.loadDeleteClick.bind(this.controls));
+        document.getElementById("load-name").addEventListener("change", this.controls.loadNameChange.bind(this.controls));
+        document.getElementById("load-button").addEventListener("click", this.controls.loadButtonClick.bind(this.controls));
+        document.getElementById("support").addEventListener("click", this.controls.supportButtonClick.bind(this.controls));
+        this.addMultiEventListeners("#save-texts textarea", "keyup paste", this.controls.saveTextChange, this.controls);
+
+        // Operations
+        this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops);
+        this.addDynamicListener(".op-list li.operation", "dblclick", this.ops.operationDblclick, this.ops);
+        document.getElementById("edit-favourites").addEventListener("click", this.ops.editFavouritesClick.bind(this.ops));
+        document.getElementById("save-favourites").addEventListener("click", this.ops.saveFavouritesClick.bind(this.ops));
+        document.getElementById("reset-favourites").addEventListener("click", this.ops.resetFavouritesClick.bind(this.ops));
+        this.addDynamicListener(".op-list .op-icon", "mouseover", this.ops.opIconMouseover, this.ops);
+        this.addDynamicListener(".op-list .op-icon", "mouseleave", this.ops.opIconMouseleave, this.ops);
+        this.addDynamicListener(".op-list", "oplistcreate", this.ops.opListCreate, this.ops);
+        this.addDynamicListener("li.operation", "operationadd", this.recipe.opAdd, this.recipe);
+
+        // Recipe
+        this.addDynamicListener(".arg:not(select)", "input", this.recipe.ingChange, this.recipe);
+        this.addDynamicListener(".arg[type=checkbox], .arg[type=radio], select.arg", "change", this.recipe.ingChange, this.recipe);
+        this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
+        this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe);
+        this.addDynamicListener("#rec-list li.operation", "dblclick", this.recipe.operationDblclick, this.recipe);
+        this.addDynamicListener("#rec-list li.operation > div", "dblclick", this.recipe.operationChildDblclick, this.recipe);
+        this.addDynamicListener("#rec-list .input-group .dropdown-menu a", "click", this.recipe.dropdownToggleClick, this.recipe);
+        this.addDynamicListener("#rec-list", "operationremove", this.recipe.opRemove.bind(this.recipe));
+
+        // Input
+        this.addMultiEventListener("#input-text", "keyup", this.input.inputChange, this.input);
+        this.addMultiEventListener("#input-text", "paste", this.input.inputPaste, this.input);
+        document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app));
+        document.getElementById("clr-io").addEventListener("click", this.input.clearIoClick.bind(this.input));
+        this.addListeners("#input-text,#input-file", "dragover", this.input.inputDragover, this.input);
+        this.addListeners("#input-text,#input-file", "dragleave", this.input.inputDragleave, this.input);
+        this.addListeners("#input-text,#input-file", "drop", this.input.inputDrop, this.input);
+        document.getElementById("input-text").addEventListener("scroll", this.highlighter.inputScroll.bind(this.highlighter));
+        document.getElementById("input-text").addEventListener("mouseup", this.highlighter.inputMouseup.bind(this.highlighter));
+        document.getElementById("input-text").addEventListener("mousemove", this.highlighter.inputMousemove.bind(this.highlighter));
+        this.addMultiEventListener("#input-text", "mousedown dblclick select",  this.highlighter.inputMousedown, this.highlighter);
+        document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input));
+
+        // Output
+        document.getElementById("save-to-file").addEventListener("click", this.output.saveClick.bind(this.output));
+        document.getElementById("copy-output").addEventListener("click", this.output.copyClick.bind(this.output));
+        document.getElementById("switch").addEventListener("click", this.output.switchClick.bind(this.output));
+        document.getElementById("undo-switch").addEventListener("click", this.output.undoSwitchClick.bind(this.output));
+        document.getElementById("maximise-output").addEventListener("click", this.output.maximiseOutputClick.bind(this.output));
+        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-slice", "click", this.output.displayFileSlice, this.output);
+        document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output));
+
+        // Options
+        document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options));
+        document.getElementById("reset-options").addEventListener("click", this.options.resetOptionsClick.bind(this.options));
+        $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.switchChange.bind(this.options));
+        $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox", this.options.setWordWrap.bind(this.options));
+        $(document).on("switchChange.bootstrapSwitch", ".option-item input:checkbox#useMetaKey", this.bindings.updateKeybList.bind(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);
+        this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options);
+        document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options));
+        document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options));
+
+        // Misc
+        window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings));
+        document.getElementById("alert-close").addEventListener("click", this.app.alertCloseClick.bind(this.app));
+    }
+
+
+    /**
+     * Adds an event listener to each element in the specified group.
+     *
+     * @param {string} selector - A selector string for the element group to add the event to, see
+     *   this.getAll()
+     * @param {string} eventType - The event to listen for
+     * @param {function} callback - The function to execute when the event is triggered
+     * @param {Object} [scope=this] - The object to bind to the callback function
+     *
+     * @example
+     * // Calls the clickable function whenever any element with the .clickable class is clicked
+     * this.addListeners(".clickable", "click", this.clickable, this);
+     */
+    addListeners(selector, eventType, callback, scope) {
+        scope = scope || this;
+        [].forEach.call(document.querySelectorAll(selector), function(el) {
+            el.addEventListener(eventType, callback.bind(scope));
+        });
+    }
+
+
+    /**
+     * Adds multiple event listeners to the specified element.
+     *
+     * @param {string} selector - A selector string for the element to add the events to
+     * @param {string} eventTypes - A space-separated string of all the event types to listen for
+     * @param {function} callback - The function to execute when the events are triggered
+     * @param {Object} [scope=this] - The object to bind to the callback function
+     *
+     * @example
+     * // Calls the search function whenever the the keyup, paste or search events are triggered on the
+     * // search element
+     * this.addMultiEventListener("search", "keyup paste search", this.search, this);
+     */
+    addMultiEventListener(selector, eventTypes, callback, scope) {
+        const evs = eventTypes.split(" ");
+        for (let i = 0; i < evs.length; i++) {
+            document.querySelector(selector).addEventListener(evs[i], callback.bind(scope));
+        }
+    }
+
+
+    /**
+     * Adds multiple event listeners to each element in the specified group.
+     *
+     * @param {string} selector - A selector string for the element group to add the events to
+     * @param {string} eventTypes - A space-separated string of all the event types to listen for
+     * @param {function} callback - The function to execute when the events are triggered
+     * @param {Object} [scope=this] - The object to bind to the callback function
+     *
+     * @example
+     * // Calls the save function whenever the the keyup or paste events are triggered on any element
+     * // with the .saveable class
+     * this.addMultiEventListener(".saveable", "keyup paste", this.save, this);
+     */
+    addMultiEventListeners(selector, eventTypes, callback, scope) {
+        const evs = eventTypes.split(" ");
+        for (let i = 0; i < evs.length; i++) {
+            this.addListeners(selector, evs[i], callback, scope);
+        }
+    }
+
+
+    /**
+     * Adds an event listener to the global document object which will listen on dynamic elements which
+     * may not exist in the DOM yet.
+     *
+     * @param {string} selector - A selector string for the element(s) to add the event to
+     * @param {string} eventType - The event(s) to listen for
+     * @param {function} callback - The function to execute when the event(s) is/are triggered
+     * @param {Object} [scope=this] - The object to bind to the callback function
+     *
+     * @example
+     * // Pops up an alert whenever any button is clicked, even if it is added to the DOM after this
+     * // listener is created
+     * this.addDynamicListener("button", "click", alert, this);
+     */
+    addDynamicListener(selector, eventType, callback, scope) {
+        const eventConfig = {
+            selector: selector,
+            callback: callback.bind(scope || this)
+        };
+
+        if (this.dynamicHandlers.hasOwnProperty(eventType)) {
+            // Listener already exists, add new handler to the appropriate list
+            this.dynamicHandlers[eventType].push(eventConfig);
+        } else {
+            this.dynamicHandlers[eventType] = [eventConfig];
+            // Set up listener for this new type
+            document.addEventListener(eventType, this.dynamicListenerHandler.bind(this));
+        }
+    }
+
+
+    /**
+     * Handler for dynamic events. This function is called for any dynamic event and decides which
+     * callback(s) to execute based on the type and selector.
+     *
+     * @param {Event} e - The event to be handled
+     */
+    dynamicListenerHandler(e) {
+        const { type, target } = e;
+        const handlers = this.dynamicHandlers[type];
+        const matches = target.matches ||
+                target.webkitMatchesSelector ||
+                target.mozMatchesSelector ||
+                target.msMatchesSelector ||
+                target.oMatchesSelector;
+
+        for (let i = 0; i < handlers.length; i++) {
+            if (matches && matches.call(target, handlers[i].selector)) {
+                handlers[i].callback(e);
+            }
+        }
+    }
+
+}
+
+export default Manager;

+ 0 - 313
src/web/OperationsWaiter.js

@@ -1,313 +0,0 @@
-import HTMLOperation from "./HTMLOperation.js";
-import Sortable from "sortablejs";
-
-
-/**
- * Waiter to handle events related to the operations.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const OperationsWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    this.options = {};
-    this.removeIntent = false;
-};
-
-
-/**
- * Handler for search events.
- * Finds operations which match the given search term and displays them under the search box.
- *
- * @param {event} e
- */
-OperationsWaiter.prototype.searchOperations = function(e) {
-    let ops, selected;
-
-    if (e.type === "search") { // Search
-        e.preventDefault();
-        ops = document.querySelectorAll("#search-results li");
-        if (ops.length) {
-            selected = this.getSelectedOp(ops);
-            if (selected > -1) {
-                this.manager.recipe.addOperation(ops[selected].innerHTML);
-            }
-        }
-    }
-
-    if (e.keyCode === 13) { // Return
-        e.preventDefault();
-    } else if (e.keyCode === 40) { // Down
-        e.preventDefault();
-        ops = document.querySelectorAll("#search-results li");
-        if (ops.length) {
-            selected = this.getSelectedOp(ops);
-            if (selected > -1) {
-                ops[selected].classList.remove("selected-op");
-            }
-            if (selected === ops.length-1) selected = -1;
-            ops[selected+1].classList.add("selected-op");
-        }
-    } else if (e.keyCode === 38) { // Up
-        e.preventDefault();
-        ops = document.querySelectorAll("#search-results li");
-        if (ops.length) {
-            selected = this.getSelectedOp(ops);
-            if (selected > -1) {
-                ops[selected].classList.remove("selected-op");
-            }
-            if (selected === 0) selected = ops.length;
-            ops[selected-1].classList.add("selected-op");
-        }
-    } else {
-        const searchResultsEl = document.getElementById("search-results");
-        const el = e.target;
-        const str = el.value;
-
-        while (searchResultsEl.firstChild) {
-            try {
-                $(searchResultsEl.firstChild).popover("destroy");
-            } catch (err) {}
-            searchResultsEl.removeChild(searchResultsEl.firstChild);
-        }
-
-        $("#categories .in").collapse("hide");
-        if (str) {
-            const matchedOps = this.filterOperations(str, true);
-            const matchedOpsHtml = matchedOps
-                .map(v => v.toStubHtml())
-                .join("");
-
-            searchResultsEl.innerHTML = matchedOpsHtml;
-            searchResultsEl.dispatchEvent(this.manager.oplistcreate);
-        }
-    }
-};
-
-
-/**
- * Filters operations based on the search string and returns the matching ones.
- *
- * @param {string} searchStr
- * @param {boolean} highlight - Whether or not to highlight the matching string in the operation
- *   name and description
- * @returns {string[]}
- */
-OperationsWaiter.prototype.filterOperations = function(inStr, highlight) {
-    const matchedOps = [];
-    const matchedDescs = [];
-
-    const searchStr = inStr.toLowerCase();
-
-    for (const opName in this.app.operations) {
-        const op = this.app.operations[opName];
-        const namePos = opName.toLowerCase().indexOf(searchStr);
-        const descPos = op.description.toLowerCase().indexOf(searchStr);
-
-        if (namePos >= 0 || descPos >= 0) {
-            const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
-            if (highlight) {
-                operation.highlightSearchString(searchStr, namePos, descPos);
-            }
-
-            if (namePos < 0) {
-                matchedOps.push(operation);
-            } else {
-                matchedDescs.push(operation);
-            }
-        }
-    }
-
-    return matchedDescs.concat(matchedOps);
-};
-
-
-/**
- * Finds the operation which has been selected using keyboard shortcuts. This will have the class
- * 'selected-op' set. Returns the index of the operation within the given list.
- *
- * @param {element[]} ops
- * @returns {number}
- */
-OperationsWaiter.prototype.getSelectedOp = function(ops) {
-    for (let i = 0; i < ops.length; i++) {
-        if (ops[i].classList.contains("selected-op")) {
-            return i;
-        }
-    }
-    return -1;
-};
-
-
-/**
- * Handler for oplistcreate events.
- *
- * @listens Manager#oplistcreate
- * @param {event} e
- */
-OperationsWaiter.prototype.opListCreate = function(e) {
-    this.manager.recipe.createSortableSeedList(e.target);
-    this.enableOpsListPopovers(e.target);
-};
-
-
-/**
- * Sets up popovers, allowing the popover itself to gain focus which enables scrolling
- * and other interactions.
- *
- * @param {Element} el - The element to start selecting from
- */
-OperationsWaiter.prototype.enableOpsListPopovers = function(el) {
-    $(el).find("[data-toggle=popover]").addBack("[data-toggle=popover]")
-        .popover({trigger: "manual"})
-        .on("mouseenter", function(e) {
-            if (e.buttons > 0) return; // Mouse button held down - likely dragging an opertion
-            const _this = this;
-            $(this).popover("show");
-            $(".popover").on("mouseleave", function () {
-                $(_this).popover("hide");
-            });
-        }).on("mouseleave", function () {
-            const _this = this;
-            setTimeout(function() {
-                // Determine if the popover associated with this element is being hovered over
-                if ($(_this).data("bs.popover") &&
-                    ($(_this).data("bs.popover").$tip && !$(_this).data("bs.popover").$tip.is(":hover"))) {
-                    $(_this).popover("hide");
-                }
-            }, 50);
-        });
-};
-
-
-/**
- * Handler for operation doubleclick events.
- * Adds the operation to the recipe and auto bakes.
- *
- * @param {event} e
- */
-OperationsWaiter.prototype.operationDblclick = function(e) {
-    const li = e.target;
-
-    this.manager.recipe.addOperation(li.textContent);
-};
-
-
-/**
- * Handler for edit favourites click events.
- * Sets up the 'Edit favourites' pane and displays it.
- *
- * @param {event} e
- */
-OperationsWaiter.prototype.editFavouritesClick = function(e) {
-    e.preventDefault();
-    e.stopPropagation();
-
-    // Add favourites to modal
-    const favCat = this.app.categories.filter(function(c) {
-        return c.name === "Favourites";
-    })[0];
-
-    let html = "";
-    for (let i = 0; i < favCat.ops.length; i++) {
-        const opName = favCat.ops[i];
-        const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
-        html += operation.toStubHtml(true);
-    }
-
-    const editFavouritesList = document.getElementById("edit-favourites-list");
-    editFavouritesList.innerHTML = html;
-    this.removeIntent = false;
-
-    const editableList = Sortable.create(editFavouritesList, {
-        filter: ".remove-icon",
-        onFilter: function (evt) {
-            const el = editableList.closest(evt.item);
-            if (el && el.parentNode) {
-                $(el).popover("destroy");
-                el.parentNode.removeChild(el);
-            }
-        },
-        onEnd: function(evt) {
-            if (this.removeIntent) {
-                $(evt.item).popover("destroy");
-                evt.item.remove();
-            }
-        }.bind(this),
-    });
-
-    Sortable.utils.on(editFavouritesList, "dragleave", function() {
-        this.removeIntent = true;
-    }.bind(this));
-
-    Sortable.utils.on(editFavouritesList, "dragover", function() {
-        this.removeIntent = false;
-    }.bind(this));
-
-    $("#edit-favourites-list [data-toggle=popover]").popover();
-    $("#favourites-modal").modal();
-};
-
-
-/**
- * Handler for save favourites click events.
- * Saves the selected favourites and reloads them.
- */
-OperationsWaiter.prototype.saveFavouritesClick = function() {
-    const favs = document.querySelectorAll("#edit-favourites-list li");
-    const favouritesList = Array.from(favs, e => e.textContent);
-
-    this.app.saveFavourites(favouritesList);
-    this.app.loadFavourites();
-    this.app.populateOperationsList();
-    this.manager.recipe.initialiseOperationDragNDrop();
-};
-
-
-/**
- * Handler for reset favourites click events.
- * Resets favourites to their defaults.
- */
-OperationsWaiter.prototype.resetFavouritesClick = function() {
-    this.app.resetFavourites();
-};
-
-
-/**
- * Handler for opIcon mouseover events.
- * Hides any popovers already showing on the operation so that there aren't two at once.
- *
- * @param {event} e
- */
-OperationsWaiter.prototype.opIconMouseover = function(e) {
-    const opEl = e.target.parentNode;
-    if (e.target.getAttribute("data-toggle") === "popover") {
-        $(opEl).popover("hide");
-    }
-};
-
-
-/**
- * Handler for opIcon mouseleave events.
- * If this icon created a popover and we're moving back to the operation element, display the
- *   operation popover again.
- *
- * @param {event} e
- */
-OperationsWaiter.prototype.opIconMouseleave = function(e) {
-    const opEl = e.target.parentNode;
-    const toEl = e.toElement || e.relatedElement;
-
-    if (e.target.getAttribute("data-toggle") === "popover" && toEl === opEl) {
-        $(opEl).popover("show");
-    }
-};
-
-export default OperationsWaiter;

+ 321 - 0
src/web/OperationsWaiter.mjs

@@ -0,0 +1,321 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import HTMLOperation from "./HTMLOperation";
+import Sortable from "sortablejs";
+
+
+/**
+ * Waiter to handle events related to the operations.
+ */
+class OperationsWaiter {
+
+    /**
+     * OperationsWaiter 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;
+
+        this.options = {};
+        this.removeIntent = false;
+    }
+
+
+    /**
+     * Handler for search events.
+     * Finds operations which match the given search term and displays them under the search box.
+     *
+     * @param {event} e
+     */
+    searchOperations(e) {
+        let ops, selected;
+
+        if (e.type === "search") { // Search
+            e.preventDefault();
+            ops = document.querySelectorAll("#search-results li");
+            if (ops.length) {
+                selected = this.getSelectedOp(ops);
+                if (selected > -1) {
+                    this.manager.recipe.addOperation(ops[selected].innerHTML);
+                }
+            }
+        }
+
+        if (e.keyCode === 13) { // Return
+            e.preventDefault();
+        } else if (e.keyCode === 40) { // Down
+            e.preventDefault();
+            ops = document.querySelectorAll("#search-results li");
+            if (ops.length) {
+                selected = this.getSelectedOp(ops);
+                if (selected > -1) {
+                    ops[selected].classList.remove("selected-op");
+                }
+                if (selected === ops.length-1) selected = -1;
+                ops[selected+1].classList.add("selected-op");
+            }
+        } else if (e.keyCode === 38) { // Up
+            e.preventDefault();
+            ops = document.querySelectorAll("#search-results li");
+            if (ops.length) {
+                selected = this.getSelectedOp(ops);
+                if (selected > -1) {
+                    ops[selected].classList.remove("selected-op");
+                }
+                if (selected === 0) selected = ops.length;
+                ops[selected-1].classList.add("selected-op");
+            }
+        } else {
+            const searchResultsEl = document.getElementById("search-results");
+            const el = e.target;
+            const str = el.value;
+
+            while (searchResultsEl.firstChild) {
+                try {
+                    $(searchResultsEl.firstChild).popover("destroy");
+                } catch (err) {}
+                searchResultsEl.removeChild(searchResultsEl.firstChild);
+            }
+
+            $("#categories .in").collapse("hide");
+            if (str) {
+                const matchedOps = this.filterOperations(str, true);
+                const matchedOpsHtml = matchedOps
+                    .map(v => v.toStubHtml())
+                    .join("");
+
+                searchResultsEl.innerHTML = matchedOpsHtml;
+                searchResultsEl.dispatchEvent(this.manager.oplistcreate);
+            }
+        }
+    }
+
+
+    /**
+     * Filters operations based on the search string and returns the matching ones.
+     *
+     * @param {string} searchStr
+     * @param {boolean} highlight - Whether or not to highlight the matching string in the operation
+     *   name and description
+     * @returns {string[]}
+     */
+    filterOperations(inStr, highlight) {
+        const matchedOps = [];
+        const matchedDescs = [];
+
+        const searchStr = inStr.toLowerCase();
+
+        for (const opName in this.app.operations) {
+            const op = this.app.operations[opName];
+            const namePos = opName.toLowerCase().indexOf(searchStr);
+            const descPos = op.description.toLowerCase().indexOf(searchStr);
+
+            if (namePos >= 0 || descPos >= 0) {
+                const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
+                if (highlight) {
+                    operation.highlightSearchString(searchStr, namePos, descPos);
+                }
+
+                if (namePos < 0) {
+                    matchedOps.push(operation);
+                } else {
+                    matchedDescs.push(operation);
+                }
+            }
+        }
+
+        return matchedDescs.concat(matchedOps);
+    }
+
+
+    /**
+     * Finds the operation which has been selected using keyboard shortcuts. This will have the class
+     * 'selected-op' set. Returns the index of the operation within the given list.
+     *
+     * @param {element[]} ops
+     * @returns {number}
+     */
+    getSelectedOp(ops) {
+        for (let i = 0; i < ops.length; i++) {
+            if (ops[i].classList.contains("selected-op")) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+
+    /**
+     * Handler for oplistcreate events.
+     *
+     * @listens Manager#oplistcreate
+     * @param {event} e
+     */
+    opListCreate(e) {
+        this.manager.recipe.createSortableSeedList(e.target);
+        this.enableOpsListPopovers(e.target);
+    }
+
+
+    /**
+     * Sets up popovers, allowing the popover itself to gain focus which enables scrolling
+     * and other interactions.
+     *
+     * @param {Element} el - The element to start selecting from
+     */
+    enableOpsListPopovers(el) {
+        $(el).find("[data-toggle=popover]").addBack("[data-toggle=popover]")
+            .popover({trigger: "manual"})
+            .on("mouseenter", function(e) {
+                if (e.buttons > 0) return; // Mouse button held down - likely dragging an opertion
+                const _this = this;
+                $(this).popover("show");
+                $(".popover").on("mouseleave", function () {
+                    $(_this).popover("hide");
+                });
+            }).on("mouseleave", function () {
+                const _this = this;
+                setTimeout(function() {
+                    // Determine if the popover associated with this element is being hovered over
+                    if ($(_this).data("bs.popover") &&
+                        ($(_this).data("bs.popover").$tip && !$(_this).data("bs.popover").$tip.is(":hover"))) {
+                        $(_this).popover("hide");
+                    }
+                }, 50);
+            });
+    }
+
+
+    /**
+     * Handler for operation doubleclick events.
+     * Adds the operation to the recipe and auto bakes.
+     *
+     * @param {event} e
+     */
+    operationDblclick(e) {
+        const li = e.target;
+
+        this.manager.recipe.addOperation(li.textContent);
+    }
+
+
+    /**
+     * Handler for edit favourites click events.
+     * Sets up the 'Edit favourites' pane and displays it.
+     *
+     * @param {event} e
+     */
+    editFavouritesClick(e) {
+        e.preventDefault();
+        e.stopPropagation();
+
+        // Add favourites to modal
+        const favCat = this.app.categories.filter(function(c) {
+            return c.name === "Favourites";
+        })[0];
+
+        let html = "";
+        for (let i = 0; i < favCat.ops.length; i++) {
+            const opName = favCat.ops[i];
+            const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
+            html += operation.toStubHtml(true);
+        }
+
+        const editFavouritesList = document.getElementById("edit-favourites-list");
+        editFavouritesList.innerHTML = html;
+        this.removeIntent = false;
+
+        const editableList = Sortable.create(editFavouritesList, {
+            filter: ".remove-icon",
+            onFilter: function (evt) {
+                const el = editableList.closest(evt.item);
+                if (el && el.parentNode) {
+                    $(el).popover("destroy");
+                    el.parentNode.removeChild(el);
+                }
+            },
+            onEnd: function(evt) {
+                if (this.removeIntent) {
+                    $(evt.item).popover("destroy");
+                    evt.item.remove();
+                }
+            }.bind(this),
+        });
+
+        Sortable.utils.on(editFavouritesList, "dragleave", function() {
+            this.removeIntent = true;
+        }.bind(this));
+
+        Sortable.utils.on(editFavouritesList, "dragover", function() {
+            this.removeIntent = false;
+        }.bind(this));
+
+        $("#edit-favourites-list [data-toggle=popover]").popover();
+        $("#favourites-modal").modal();
+    }
+
+
+    /**
+     * Handler for save favourites click events.
+     * Saves the selected favourites and reloads them.
+     */
+    saveFavouritesClick() {
+        const favs = document.querySelectorAll("#edit-favourites-list li");
+        const favouritesList = Array.from(favs, e => e.textContent);
+
+        this.app.saveFavourites(favouritesList);
+        this.app.loadFavourites();
+        this.app.populateOperationsList();
+        this.manager.recipe.initialiseOperationDragNDrop();
+    }
+
+
+    /**
+     * Handler for reset favourites click events.
+     * Resets favourites to their defaults.
+     */
+    resetFavouritesClick() {
+        this.app.resetFavourites();
+    }
+
+
+    /**
+     * Handler for opIcon mouseover events.
+     * Hides any popovers already showing on the operation so that there aren't two at once.
+     *
+     * @param {event} e
+     */
+    opIconMouseover(e) {
+        const opEl = e.target.parentNode;
+        if (e.target.getAttribute("data-toggle") === "popover") {
+            $(opEl).popover("hide");
+        }
+    }
+
+
+    /**
+     * Handler for opIcon mouseleave events.
+     * If this icon created a popover and we're moving back to the operation element, display the
+     *   operation popover again.
+     *
+     * @param {event} e
+     */
+    opIconMouseleave(e) {
+        const opEl = e.target.parentNode;
+        const toEl = e.toElement || e.relatedElement;
+
+        if (e.target.getAttribute("data-toggle") === "popover" && toEl === opEl) {
+            $(opEl).popover("show");
+        }
+    }
+
+}
+
+export default OperationsWaiter;

+ 0 - 0
src/web/OptionsWaiter.js → src/web/OptionsWaiter.mjs


+ 0 - 441
src/web/OutputWaiter.js

@@ -1,441 +0,0 @@
-import Utils from "../core/Utils";
-import FileSaver from "file-saver";
-
-
-/**
- * Waiter to handle events related to the output.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const OutputWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    this.dishBuffer = null;
-    this.dishStr = null;
-};
-
-
-/**
- * Gets the output string from the output textarea.
- *
- * @returns {string}
- */
-OutputWaiter.prototype.get = function() {
-    return document.getElementById("output-text").value;
-};
-
-
-/**
- * Sets the output in the output textarea.
- *
- * @param {string|ArrayBuffer} data - The output string/HTML/ArrayBuffer
- * @param {string} type - The data type of the output
- * @param {number} duration - The length of time (ms) it took to generate the output
- * @param {boolean} [preserveBuffer=false] - Whether to preserve the dishBuffer
- */
-OutputWaiter.prototype.set = async function(data, type, duration, preserveBuffer) {
-    log.debug("Output type: " + type);
-    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");
-    let scriptElements, lines, length;
-
-    if (!preserveBuffer) {
-        this.closeFile();
-        this.dishStr = null;
-        document.getElementById("show-file-overlay").style.display = "none";
-    }
-
-    switch (type) {
-        case "html":
-            outputText.style.display = "none";
-            outputHtml.style.display = "block";
-            outputFile.style.display = "none";
-            outputHighlighter.display = "none";
-            inputHighlighter.display = "none";
-
-            outputText.value = "";
-            outputHtml.innerHTML = data;
-
-            // 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);
-                }
-            }
-
-            await this.getDishStr();
-            length = this.dishStr.length;
-            lines = this.dishStr.count("\n") + 1;
-            break;
-        case "ArrayBuffer":
-            outputText.style.display = "block";
-            outputHtml.style.display = "none";
-            outputHighlighter.display = "none";
-            inputHighlighter.display = "none";
-
-            outputText.value = "";
-            outputHtml.innerHTML = "";
-            length = data.byteLength;
-
-            this.setFile(data);
-            break;
-        case "string":
-        default:
-            outputText.style.display = "block";
-            outputHtml.style.display = "none";
-            outputFile.style.display = "none";
-            outputHighlighter.display = "block";
-            inputHighlighter.display = "block";
-
-            outputText.value = Utils.printable(data, true);
-            outputHtml.innerHTML = "";
-
-            lines = data.count("\n") + 1;
-            length = data.length;
-            this.dishStr = data;
-            break;
-    }
-
-    this.manager.highlighter.removeHighlights();
-    this.setOutputInfo(length, lines, duration);
-};
-
-
-/**
- * Shows file details.
- *
- * @param {ArrayBuffer} buf
- */
-OutputWaiter.prototype.setFile = function(buf) {
-    this.dishBuffer = buf;
-    const file = new File([buf], "output.dat");
-
-    // Display file overlay in output area with details
-    const fileOverlay = document.getElementById("output-file"),
-        fileSize = document.getElementById("output-file-size");
-
-    fileOverlay.style.display = "block";
-    fileSize.textContent = file.size.toLocaleString() + " bytes";
-
-    // Display preview slice in the background
-    const outputText = document.getElementById("output-text"),
-        fileSlice = this.dishBuffer.slice(0, 4096);
-
-    outputText.classList.add("blur");
-    outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice));
-};
-
-
-/**
- * Removes the output file and nulls its memory.
- */
-OutputWaiter.prototype.closeFile = function() {
-    this.dishBuffer = null;
-    document.getElementById("output-file").style.display = "none";
-    document.getElementById("output-text").classList.remove("blur");
-};
-
-
-/**
- * Handler for file download events.
- */
-OutputWaiter.prototype.downloadFile = async function() {
-    this.filename = window.prompt("Please enter a filename:", this.filename || "download.dat");
-    await this.getDishBuffer();
-    const file = new File([this.dishBuffer], this.filename);
-    if (this.filename) FileSaver.saveAs(file, this.filename, false);
-};
-
-
-/**
- * Handler for file slice display events.
- */
-OutputWaiter.prototype.displayFileSlice = function() {
-    const startTime = new Date().getTime(),
-        showFileOverlay = document.getElementById("show-file-overlay"),
-        sliceFromEl = document.getElementById("output-file-slice-from"),
-        sliceToEl = document.getElementById("output-file-slice-to"),
-        sliceFrom = parseInt(sliceFromEl.value, 10),
-        sliceTo = parseInt(sliceToEl.value, 10),
-        str = Utils.arrayBufferToStr(this.dishBuffer.slice(sliceFrom, sliceTo));
-
-    document.getElementById("output-text").classList.remove("blur");
-    showFileOverlay.style.display = "block";
-    this.set(str, "string", new Date().getTime() - startTime, true);
-};
-
-
-/**
- * Handler for show file overlay events.
- *
- * @param {Event} e
- */
-OutputWaiter.prototype.showFileOverlayClick = function(e) {
-    const outputFile = document.getElementById("output-file"),
-        showFileOverlay = e.target;
-
-    document.getElementById("output-text").classList.add("blur");
-    outputFile.style.display = "block";
-    showFileOverlay.style.display = "none";
-    this.setOutputInfo(this.dishBuffer.byteLength, null, 0);
-};
-
-
-/**
- * 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
- */
-OutputWaiter.prototype.setOutputInfo = function(length, lines, duration) {
-    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("input-selection-info").innerHTML = "";
-    document.getElementById("output-selection-info").innerHTML = "";
-};
-
-
-/**
- * Adjusts the display properties of the output buttons so that they fit within the current width
- * without wrapping or overflowing.
- */
-OutputWaiter.prototype.adjustWidth = function() {
-    const output         = document.getElementById("output");
-    const saveToFile     = document.getElementById("save-to-file");
-    const copyOutput     = document.getElementById("copy-output");
-    const switchIO       = document.getElementById("switch");
-    const undoSwitch     = document.getElementById("undo-switch");
-    const maximiseOutput = document.getElementById("maximise-output");
-
-    if (output.clientWidth < 680) {
-        saveToFile.childNodes[1].nodeValue = "";
-        copyOutput.childNodes[1].nodeValue = "";
-        switchIO.childNodes[1].nodeValue = "";
-        undoSwitch.childNodes[1].nodeValue = "";
-        maximiseOutput.childNodes[1].nodeValue = "";
-    } else {
-        saveToFile.childNodes[1].nodeValue = " Save to file";
-        copyOutput.childNodes[1].nodeValue = " Copy output";
-        switchIO.childNodes[1].nodeValue = " Move output to input";
-        undoSwitch.childNodes[1].nodeValue = " Undo";
-        maximiseOutput.childNodes[1].nodeValue =
-            maximiseOutput.getAttribute("title") === "Maximise" ? " Max" : " Restore";
-    }
-};
-
-
-/**
- * Handler for save click events.
- * Saves the current output to a file.
- */
-OutputWaiter.prototype.saveClick = function() {
-    this.downloadFile();
-};
-
-
-/**
- * Handler for copy click events.
- * Copies the output to the clipboard.
- */
-OutputWaiter.prototype.copyClick = async function() {
-    await this.getDishStr();
-
-    // Create invisible textarea to populate with the raw dish string (not the printable version that
-    // contains dots instead of the actual bytes)
-    const textarea = document.createElement("textarea");
-    textarea.style.position = "fixed";
-    textarea.style.top = 0;
-    textarea.style.left = 0;
-    textarea.style.width = 0;
-    textarea.style.height = 0;
-    textarea.style.border = "none";
-
-    textarea.value = this.dishStr;
-    document.body.appendChild(textarea);
-
-    // Select and copy the contents of this textarea
-    let success = false;
-    try {
-        textarea.select();
-        success = this.dishStr && document.execCommand("copy");
-    } catch (err) {
-        success = false;
-    }
-
-    if (success) {
-        this.app.alert("Copied raw output successfully.", "success", 2000);
-    } else {
-        this.app.alert("Sorry, the output could not be copied.", "danger", 2000);
-    }
-
-    // Clean up
-    document.body.removeChild(textarea);
-};
-
-
-/**
- * Handler for switch click events.
- * Moves the current output into the input textarea.
- */
-OutputWaiter.prototype.switchClick = async function() {
-    this.switchOrigData = this.manager.input.get();
-    document.getElementById("undo-switch").disabled = false;
-    if (this.dishBuffer) {
-        this.manager.input.setFile(new File([this.dishBuffer], "output.dat"));
-        this.manager.input.handleLoaderMessage({
-            data: {
-                progress: 100,
-                fileBuffer: this.dishBuffer
-            }
-        });
-    } else {
-        await this.getDishStr();
-        this.app.setInput(this.dishStr);
-    }
-};
-
-
-/**
- * Handler for undo switch click events.
- * Removes the output from the input and replaces the input that was removed.
- */
-OutputWaiter.prototype.undoSwitchClick = function() {
-    this.app.setInput(this.switchOrigData);
-    document.getElementById("undo-switch").disabled = true;
-};
-
-
-/**
- * Handler for maximise output click events.
- * Resizes the output frame to be as large as possible, or restores it to its original size.
- */
-OutputWaiter.prototype.maximiseOutputClick = function(e) {
-    const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode;
-
-    if (el.getAttribute("title") === "Maximise") {
-        this.app.columnSplitter.collapse(0);
-        this.app.columnSplitter.collapse(1);
-        this.app.ioSplitter.collapse(0);
-
-        el.setAttribute("title", "Restore");
-        el.innerHTML = "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlUlEQVQ4y93RwQpBQRQG4C9ba1fxBteGPIj38BTejFJKLFnwCJIiCsW1mcV0k9yx82/OzGK+OXMGOpiiLTFjFNiilQI0sQ7IJiAjLKsgGVYB2YdaVO0kwy46/BVQi9ZDNPyQWen2ub/KufS8y7shfkq9tF9U7SC+/YluKvAI9YZeFeCECXJcA3JHP2WgMXJM/ZUcBwxeM+YuSWTgMtUAAAAASUVORK5CYII='> Restore";
-        this.adjustWidth();
-    } else {
-        el.setAttribute("title", "Maximise");
-        el.innerHTML = "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAi0lEQVQ4y83TMQrCQBCF4S+5g4rJEdJ7KE+RQ1lrIQQCllroEULuoM0Ww3a7aXwwLAzMPzDvLcz4hnooUItT1rsoVNy+4lgLWNL7RlcCmDBij2eCfNCrUITc0dRCrhj8m5otw0O6SV8LuAV3uhrAAa8sJ2Np7KPFawhgscVLjH9bCDhjt8WNKft88w/HjCvuVqu53QAAAABJRU5ErkJggg=='> Max";
-        this.app.resetLayout();
-    }
-};
-
-
-/**
- * Shows or hides the loading icon.
- *
- * @param {boolean} value
- */
-OutputWaiter.prototype.toggleLoader = function(value) {
-    const outputLoader = document.getElementById("output-loader"),
-        outputElement = document.getElementById("output-text");
-
-    if (value) {
-        this.manager.controls.hideStaleIndicator();
-        this.bakingStatusTimeout = setTimeout(function() {
-            outputElement.disabled = true;
-            outputLoader.style.visibility = "visible";
-            outputLoader.style.opacity = 1;
-            this.manager.controls.toggleBakeButtonFunction(true);
-        }.bind(this), 200);
-    } else {
-        clearTimeout(this.bakingStatusTimeout);
-        outputElement.disabled = false;
-        outputLoader.style.opacity = 0;
-        outputLoader.style.visibility = "hidden";
-        this.manager.controls.toggleBakeButtonFunction(false);
-        this.setStatusMsg("");
-    }
-};
-
-
-/**
- * Sets the baking status message value.
- *
- * @param {string} msg
- */
-OutputWaiter.prototype.setStatusMsg = function(msg) {
-    const el = document.querySelector("#output-loader .loading-msg");
-
-    el.textContent = msg;
-};
-
-
-/**
- * Returns true if the output contains carriage returns
- *
- * @returns {boolean}
- */
-OutputWaiter.prototype.containsCR = async function() {
-    await this.getDishStr();
-    return this.dishStr.indexOf("\r") >= 0;
-};
-
-
-/**
- * Retrieves the current dish as a string, returning the cached version if possible.
- *
- * @returns {string}
- */
-OutputWaiter.prototype.getDishStr = async function() {
-    if (this.dishStr) return this.dishStr;
-
-    this.dishStr = await new Promise(resolve => {
-        this.manager.worker.getDishAs(this.app.dish, "string", r => {
-            resolve(r.value);
-        });
-    });
-    return this.dishStr;
-};
-
-
-/**
- * Retrieves the current dish as an ArrayBuffer, returning the cached version if possible.
- *
- * @returns {ArrayBuffer}
- */
-OutputWaiter.prototype.getDishBuffer = async function() {
-    if (this.dishBuffer) return this.dishBuffer;
-
-    this.dishBuffer = await new Promise(resolve => {
-        this.manager.worker.getDishAs(this.app.dish, "ArrayBuffer", r => {
-            resolve(r.value);
-        });
-    });
-    return this.dishBuffer;
-};
-
-export default OutputWaiter;

+ 449 - 0
src/web/OutputWaiter.mjs

@@ -0,0 +1,449 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import Utils from "../core/Utils";
+import FileSaver from "file-saver";
+
+
+/**
+ * Waiter to handle events related to the output.
+ */
+class OutputWaiter {
+
+    /**
+     * OutputWaiter 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;
+
+        this.dishBuffer = null;
+        this.dishStr = null;
+    }
+
+
+    /**
+     * Gets the output string from the output textarea.
+     *
+     * @returns {string}
+     */
+    get() {
+        return document.getElementById("output-text").value;
+    }
+
+
+    /**
+     * Sets the output in the output textarea.
+     *
+     * @param {string|ArrayBuffer} data - The output string/HTML/ArrayBuffer
+     * @param {string} type - The data type of the output
+     * @param {number} duration - The length of time (ms) it took to generate the output
+     * @param {boolean} [preserveBuffer=false] - Whether to preserve the dishBuffer
+     */
+    async set(data, type, duration, preserveBuffer) {
+        log.debug("Output type: " + type);
+        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");
+        let scriptElements, lines, length;
+
+        if (!preserveBuffer) {
+            this.closeFile();
+            this.dishStr = null;
+            document.getElementById("show-file-overlay").style.display = "none";
+        }
+
+        switch (type) {
+            case "html":
+                outputText.style.display = "none";
+                outputHtml.style.display = "block";
+                outputFile.style.display = "none";
+                outputHighlighter.display = "none";
+                inputHighlighter.display = "none";
+
+                outputText.value = "";
+                outputHtml.innerHTML = data;
+
+                // 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);
+                    }
+                }
+
+                await this.getDishStr();
+                length = this.dishStr.length;
+                lines = this.dishStr.count("\n") + 1;
+                break;
+            case "ArrayBuffer":
+                outputText.style.display = "block";
+                outputHtml.style.display = "none";
+                outputHighlighter.display = "none";
+                inputHighlighter.display = "none";
+
+                outputText.value = "";
+                outputHtml.innerHTML = "";
+                length = data.byteLength;
+
+                this.setFile(data);
+                break;
+            case "string":
+            default:
+                outputText.style.display = "block";
+                outputHtml.style.display = "none";
+                outputFile.style.display = "none";
+                outputHighlighter.display = "block";
+                inputHighlighter.display = "block";
+
+                outputText.value = Utils.printable(data, true);
+                outputHtml.innerHTML = "";
+
+                lines = data.count("\n") + 1;
+                length = data.length;
+                this.dishStr = data;
+                break;
+        }
+
+        this.manager.highlighter.removeHighlights();
+        this.setOutputInfo(length, lines, duration);
+    }
+
+
+    /**
+     * Shows file details.
+     *
+     * @param {ArrayBuffer} buf
+     */
+    setFile(buf) {
+        this.dishBuffer = buf;
+        const file = new File([buf], "output.dat");
+
+        // Display file overlay in output area with details
+        const fileOverlay = document.getElementById("output-file"),
+            fileSize = document.getElementById("output-file-size");
+
+        fileOverlay.style.display = "block";
+        fileSize.textContent = file.size.toLocaleString() + " bytes";
+
+        // Display preview slice in the background
+        const outputText = document.getElementById("output-text"),
+            fileSlice = this.dishBuffer.slice(0, 4096);
+
+        outputText.classList.add("blur");
+        outputText.value = Utils.printable(Utils.arrayBufferToStr(fileSlice));
+    }
+
+
+    /**
+     * Removes the output file and nulls its memory.
+     */
+    closeFile() {
+        this.dishBuffer = null;
+        document.getElementById("output-file").style.display = "none";
+        document.getElementById("output-text").classList.remove("blur");
+    }
+
+
+    /**
+     * Handler for file download events.
+     */
+    async downloadFile() {
+        this.filename = window.prompt("Please enter a filename:", this.filename || "download.dat");
+        await this.getDishBuffer();
+        const file = new File([this.dishBuffer], this.filename);
+        if (this.filename) FileSaver.saveAs(file, this.filename, false);
+    }
+
+
+    /**
+     * Handler for file slice display events.
+     */
+    displayFileSlice() {
+        const startTime = new Date().getTime(),
+            showFileOverlay = document.getElementById("show-file-overlay"),
+            sliceFromEl = document.getElementById("output-file-slice-from"),
+            sliceToEl = document.getElementById("output-file-slice-to"),
+            sliceFrom = parseInt(sliceFromEl.value, 10),
+            sliceTo = parseInt(sliceToEl.value, 10),
+            str = Utils.arrayBufferToStr(this.dishBuffer.slice(sliceFrom, sliceTo));
+
+        document.getElementById("output-text").classList.remove("blur");
+        showFileOverlay.style.display = "block";
+        this.set(str, "string", new Date().getTime() - startTime, true);
+    }
+
+
+    /**
+     * Handler for show file overlay events.
+     *
+     * @param {Event} e
+     */
+    showFileOverlayClick(e) {
+        const outputFile = document.getElementById("output-file"),
+            showFileOverlay = e.target;
+
+        document.getElementById("output-text").classList.add("blur");
+        outputFile.style.display = "block";
+        showFileOverlay.style.display = "none";
+        this.setOutputInfo(this.dishBuffer.byteLength, null, 0);
+    }
+
+
+    /**
+     * 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) {
+        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("input-selection-info").innerHTML = "";
+        document.getElementById("output-selection-info").innerHTML = "";
+    }
+
+
+    /**
+     * Adjusts the display properties of the output buttons so that they fit within the current width
+     * without wrapping or overflowing.
+     */
+    adjustWidth() {
+        const output         = document.getElementById("output");
+        const saveToFile     = document.getElementById("save-to-file");
+        const copyOutput     = document.getElementById("copy-output");
+        const switchIO       = document.getElementById("switch");
+        const undoSwitch     = document.getElementById("undo-switch");
+        const maximiseOutput = document.getElementById("maximise-output");
+
+        if (output.clientWidth < 680) {
+            saveToFile.childNodes[1].nodeValue = "";
+            copyOutput.childNodes[1].nodeValue = "";
+            switchIO.childNodes[1].nodeValue = "";
+            undoSwitch.childNodes[1].nodeValue = "";
+            maximiseOutput.childNodes[1].nodeValue = "";
+        } else {
+            saveToFile.childNodes[1].nodeValue = " Save to file";
+            copyOutput.childNodes[1].nodeValue = " Copy output";
+            switchIO.childNodes[1].nodeValue = " Move output to input";
+            undoSwitch.childNodes[1].nodeValue = " Undo";
+            maximiseOutput.childNodes[1].nodeValue =
+                maximiseOutput.getAttribute("title") === "Maximise" ? " Max" : " Restore";
+        }
+    }
+
+
+    /**
+     * Handler for save click events.
+     * Saves the current output to a file.
+     */
+    saveClick() {
+        this.downloadFile();
+    }
+
+
+    /**
+     * Handler for copy click events.
+     * Copies the output to the clipboard.
+     */
+    async copyClick() {
+        await this.getDishStr();
+
+        // Create invisible textarea to populate with the raw dish string (not the printable version that
+        // contains dots instead of the actual bytes)
+        const textarea = document.createElement("textarea");
+        textarea.style.position = "fixed";
+        textarea.style.top = 0;
+        textarea.style.left = 0;
+        textarea.style.width = 0;
+        textarea.style.height = 0;
+        textarea.style.border = "none";
+
+        textarea.value = this.dishStr;
+        document.body.appendChild(textarea);
+
+        // Select and copy the contents of this textarea
+        let success = false;
+        try {
+            textarea.select();
+            success = this.dishStr && document.execCommand("copy");
+        } catch (err) {
+            success = false;
+        }
+
+        if (success) {
+            this.app.alert("Copied raw output successfully.", "success", 2000);
+        } else {
+            this.app.alert("Sorry, the output could not be copied.", "danger", 2000);
+        }
+
+        // Clean up
+        document.body.removeChild(textarea);
+    }
+
+
+    /**
+     * Handler for switch click events.
+     * Moves the current output into the input textarea.
+     */
+    async switchClick() {
+        this.switchOrigData = this.manager.input.get();
+        document.getElementById("undo-switch").disabled = false;
+        if (this.dishBuffer) {
+            this.manager.input.setFile(new File([this.dishBuffer], "output.dat"));
+            this.manager.input.handleLoaderMessage({
+                data: {
+                    progress: 100,
+                    fileBuffer: this.dishBuffer
+                }
+            });
+        } else {
+            await this.getDishStr();
+            this.app.setInput(this.dishStr);
+        }
+    }
+
+
+    /**
+     * Handler for undo switch click events.
+     * Removes the output from the input and replaces the input that was removed.
+     */
+    undoSwitchClick() {
+        this.app.setInput(this.switchOrigData);
+        document.getElementById("undo-switch").disabled = true;
+    }
+
+
+    /**
+     * Handler for maximise output click events.
+     * Resizes the output frame to be as large as possible, or restores it to its original size.
+     */
+    maximiseOutputClick(e) {
+        const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode;
+
+        if (el.getAttribute("title") === "Maximise") {
+            this.app.columnSplitter.collapse(0);
+            this.app.columnSplitter.collapse(1);
+            this.app.ioSplitter.collapse(0);
+
+            el.setAttribute("title", "Restore");
+            el.innerHTML = "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAlUlEQVQ4y93RwQpBQRQG4C9ba1fxBteGPIj38BTejFJKLFnwCJIiCsW1mcV0k9yx82/OzGK+OXMGOpiiLTFjFNiilQI0sQ7IJiAjLKsgGVYB2YdaVO0kwy46/BVQi9ZDNPyQWen2ub/KufS8y7shfkq9tF9U7SC+/YluKvAI9YZeFeCECXJcA3JHP2WgMXJM/ZUcBwxeM+YuSWTgMtUAAAAASUVORK5CYII='> Restore";
+            this.adjustWidth();
+        } else {
+            el.setAttribute("title", "Maximise");
+            el.innerHTML = "<img src='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAi0lEQVQ4y83TMQrCQBCF4S+5g4rJEdJ7KE+RQ1lrIQQCllroEULuoM0Ww3a7aXwwLAzMPzDvLcz4hnooUItT1rsoVNy+4lgLWNL7RlcCmDBij2eCfNCrUITc0dRCrhj8m5otw0O6SV8LuAV3uhrAAa8sJ2Np7KPFawhgscVLjH9bCDhjt8WNKft88w/HjCvuVqu53QAAAABJRU5ErkJggg=='> Max";
+            this.app.resetLayout();
+        }
+    }
+
+
+    /**
+     * Shows or hides the loading icon.
+     *
+     * @param {boolean} value
+     */
+    toggleLoader(value) {
+        const outputLoader = document.getElementById("output-loader"),
+            outputElement = document.getElementById("output-text");
+
+        if (value) {
+            this.manager.controls.hideStaleIndicator();
+            this.bakingStatusTimeout = setTimeout(function() {
+                outputElement.disabled = true;
+                outputLoader.style.visibility = "visible";
+                outputLoader.style.opacity = 1;
+                this.manager.controls.toggleBakeButtonFunction(true);
+            }.bind(this), 200);
+        } else {
+            clearTimeout(this.bakingStatusTimeout);
+            outputElement.disabled = false;
+            outputLoader.style.opacity = 0;
+            outputLoader.style.visibility = "hidden";
+            this.manager.controls.toggleBakeButtonFunction(false);
+            this.setStatusMsg("");
+        }
+    }
+
+
+    /**
+     * Sets the baking status message value.
+     *
+     * @param {string} msg
+     */
+    setStatusMsg(msg) {
+        const el = document.querySelector("#output-loader .loading-msg");
+
+        el.textContent = msg;
+    }
+
+
+    /**
+     * Returns true if the output contains carriage returns
+     *
+     * @returns {boolean}
+     */
+    async containsCR() {
+        await this.getDishStr();
+        return this.dishStr.indexOf("\r") >= 0;
+    }
+
+
+    /**
+     * Retrieves the current dish as a string, returning the cached version if possible.
+     *
+     * @returns {string}
+     */
+    async getDishStr() {
+        if (this.dishStr) return this.dishStr;
+
+        this.dishStr = await new Promise(resolve => {
+            this.manager.worker.getDishAs(this.app.dish, "string", r => {
+                resolve(r.value);
+            });
+        });
+        return this.dishStr;
+    }
+
+
+    /**
+     * Retrieves the current dish as an ArrayBuffer, returning the cached version if possible.
+     *
+     * @returns {ArrayBuffer}
+     */
+    async getDishBuffer() {
+        if (this.dishBuffer) return this.dishBuffer;
+
+        this.dishBuffer = await new Promise(resolve => {
+            this.manager.worker.getDishAs(this.app.dish, "ArrayBuffer", r => {
+                resolve(r.value);
+            });
+        });
+        return this.dishBuffer;
+    }
+
+}
+
+export default OutputWaiter;

+ 0 - 467
src/web/RecipeWaiter.js

@@ -1,467 +0,0 @@
-import HTMLOperation from "./HTMLOperation.js";
-import Sortable from "sortablejs";
-import Utils from "../core/Utils";
-
-
-/**
- * Waiter to handle events related to the recipe.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const RecipeWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-    this.removeIntent = false;
-};
-
-
-/**
- * Sets up the drag and drop capability for operations in the operations and recipe areas.
- */
-RecipeWaiter.prototype.initialiseOperationDragNDrop = function() {
-    const recList = document.getElementById("rec-list");
-
-    // Recipe list
-    Sortable.create(recList, {
-        group: "recipe",
-        sort: true,
-        animation: 0,
-        delay: 0,
-        filter: ".arg-input,.arg",
-        preventOnFilter: false,
-        setData: function(dataTransfer, dragEl) {
-            dataTransfer.setData("Text", dragEl.querySelector(".arg-title").textContent);
-        },
-        onEnd: function(evt) {
-            if (this.removeIntent) {
-                evt.item.remove();
-                evt.target.dispatchEvent(this.manager.operationremove);
-            }
-        }.bind(this),
-        onSort: function(evt) {
-            if (evt.from.id === "rec-list") {
-                document.dispatchEvent(this.manager.statechange);
-            }
-        }.bind(this)
-    });
-
-    Sortable.utils.on(recList, "dragover", function() {
-        this.removeIntent = false;
-    }.bind(this));
-
-    Sortable.utils.on(recList, "dragleave", function() {
-        this.removeIntent = true;
-        this.app.progress = 0;
-    }.bind(this));
-
-    Sortable.utils.on(recList, "touchend", function(e) {
-        const loc = e.changedTouches[0];
-        const target = document.elementFromPoint(loc.clientX, loc.clientY);
-
-        this.removeIntent = !recList.contains(target);
-    }.bind(this));
-
-    // Favourites category
-    document.querySelector("#categories a").addEventListener("dragover", this.favDragover.bind(this));
-    document.querySelector("#categories a").addEventListener("dragleave", this.favDragleave.bind(this));
-    document.querySelector("#categories a").addEventListener("drop", this.favDrop.bind(this));
-};
-
-
-/**
- * Creates a drag-n-droppable seed list of operations.
- *
- * @param {element} listEl - The list to initialise
- */
-RecipeWaiter.prototype.createSortableSeedList = function(listEl) {
-    Sortable.create(listEl, {
-        group: {
-            name: "recipe",
-            pull: "clone",
-            put: false,
-        },
-        sort: false,
-        setData: function(dataTransfer, dragEl) {
-            dataTransfer.setData("Text", dragEl.textContent);
-        },
-        onStart: function(evt) {
-            // Removes popover element and event bindings from the dragged operation but not the
-            // event bindings from the one left in the operations list. Without manually removing
-            // these bindings, we cannot re-initialise the popover on the stub operation.
-            $(evt.item).popover("destroy").removeData("bs.popover").off("mouseenter").off("mouseleave");
-            $(evt.clone).off(".popover").removeData("bs.popover");
-            evt.item.setAttribute("data-toggle", "popover-disabled");
-        },
-        onEnd: this.opSortEnd.bind(this)
-    });
-};
-
-
-/**
- * Handler for operation sort end events.
- * Removes the operation from the list if it has been dropped outside. If not, adds it to the list
- * at the appropriate place and initialises it.
- *
- * @fires Manager#operationadd
- * @param {event} evt
- */
-RecipeWaiter.prototype.opSortEnd = function(evt) {
-    if (this.removeIntent) {
-        if (evt.item.parentNode.id === "rec-list") {
-            evt.item.remove();
-        }
-        return;
-    }
-
-    // Reinitialise the popover on the original element in the ops list because for some reason it
-    // gets destroyed and recreated.
-    this.manager.ops.enableOpsListPopovers(evt.clone);
-
-    if (evt.item.parentNode.id !== "rec-list") {
-        return;
-    }
-
-    this.buildRecipeOperation(evt.item);
-    evt.item.dispatchEvent(this.manager.operationadd);
-};
-
-
-/**
- * Handler for favourite dragover events.
- * If the element being dragged is an operation, displays a visual cue so that the user knows it can
- * be dropped here.
- *
- * @param {event} e
- */
-RecipeWaiter.prototype.favDragover = function(e) {
-    if (e.dataTransfer.effectAllowed !== "move")
-        return false;
-
-    e.stopPropagation();
-    e.preventDefault();
-    if (e.target.className && e.target.className.indexOf("category-title") > -1) {
-        // Hovering over the a
-        e.target.classList.add("favourites-hover");
-    } else if (e.target.parentNode.className && e.target.parentNode.className.indexOf("category-title") > -1) {
-        // Hovering over the Edit button
-        e.target.parentNode.classList.add("favourites-hover");
-    } else if (e.target.parentNode.parentNode.className && e.target.parentNode.parentNode.className.indexOf("category-title") > -1) {
-        // Hovering over the image on the Edit button
-        e.target.parentNode.parentNode.classList.add("favourites-hover");
-    }
-};
-
-
-/**
- * Handler for favourite dragleave events.
- * Removes the visual cue.
- *
- * @param {event} e
- */
-RecipeWaiter.prototype.favDragleave = function(e) {
-    e.stopPropagation();
-    e.preventDefault();
-    document.querySelector("#categories a").classList.remove("favourites-hover");
-};
-
-
-/**
- * Handler for favourite drop events.
- * Adds the dragged operation to the favourites list.
- *
- * @param {event} e
- */
-RecipeWaiter.prototype.favDrop = function(e) {
-    e.stopPropagation();
-    e.preventDefault();
-    e.target.classList.remove("favourites-hover");
-
-    const opName = e.dataTransfer.getData("Text");
-    this.app.addFavourite(opName);
-};
-
-
-/**
- * Handler for ingredient change events.
- *
- * @fires Manager#statechange
- */
-RecipeWaiter.prototype.ingChange = function(e) {
-    window.dispatchEvent(this.manager.statechange);
-};
-
-
-/**
- * Handler for disable click events.
- * Updates the icon status.
- *
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.disableClick = function(e) {
-    const icon = e.target;
-
-    if (icon.getAttribute("disabled") === "false") {
-        icon.setAttribute("disabled", "true");
-        icon.classList.add("disable-icon-selected");
-        icon.parentNode.parentNode.classList.add("disabled");
-    } else {
-        icon.setAttribute("disabled", "false");
-        icon.classList.remove("disable-icon-selected");
-        icon.parentNode.parentNode.classList.remove("disabled");
-    }
-
-    this.app.progress = 0;
-    window.dispatchEvent(this.manager.statechange);
-};
-
-
-/**
- * Handler for breakpoint click events.
- * Updates the icon status.
- *
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.breakpointClick = function(e) {
-    const bp = e.target;
-
-    if (bp.getAttribute("break") === "false") {
-        bp.setAttribute("break", "true");
-        bp.classList.add("breakpoint-selected");
-    } else {
-        bp.setAttribute("break", "false");
-        bp.classList.remove("breakpoint-selected");
-    }
-
-    window.dispatchEvent(this.manager.statechange);
-};
-
-
-/**
- * Handler for operation doubleclick events.
- * Removes the operation from the recipe and auto bakes.
- *
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.operationDblclick = function(e) {
-    e.target.remove();
-    this.opRemove(e);
-};
-
-
-/**
- * Handler for operation child doubleclick events.
- * Removes the operation from the recipe.
- *
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.operationChildDblclick = function(e) {
-    e.target.parentNode.remove();
-    this.opRemove(e);
-};
-
-
-/**
- * Generates a configuration object to represent the current recipe.
- *
- * @returns {recipeConfig}
- */
-RecipeWaiter.prototype.getConfig = function() {
-    const config = [];
-    let ingredients, ingList, disabled, bp, item;
-    const operations = document.querySelectorAll("#rec-list li.operation");
-
-    for (let i = 0; i < operations.length; i++) {
-        ingredients = [];
-        disabled = operations[i].querySelector(".disable-icon");
-        bp = operations[i].querySelector(".breakpoint");
-        ingList = operations[i].querySelectorAll(".arg");
-
-        for (let j = 0; j < ingList.length; j++) {
-            if (ingList[j].getAttribute("type") === "checkbox") {
-                // checkbox
-                ingredients[j] = ingList[j].checked;
-            } else if (ingList[j].classList.contains("toggle-string")) {
-                // toggleString
-                ingredients[j] = {
-                    option: ingList[j].previousSibling.children[0].textContent.slice(0, -1),
-                    string: ingList[j].value
-                };
-            } else if (ingList[j].getAttribute("type") === "number") {
-                // number
-                ingredients[j] = parseFloat(ingList[j].value, 10);
-            } else {
-                // all others
-                ingredients[j] = ingList[j].value;
-            }
-        }
-
-        item = {
-            op: operations[i].querySelector(".arg-title").textContent,
-            args: ingredients
-        };
-
-        if (disabled && disabled.getAttribute("disabled") === "true") {
-            item.disabled = true;
-        }
-
-        if (bp && bp.getAttribute("break") === "true") {
-            item.breakpoint = true;
-        }
-
-        config.push(item);
-    }
-
-    return config;
-};
-
-
-/**
- * Moves or removes the breakpoint indicator in the recipe based on the position.
- *
- * @param {number} position
- */
-RecipeWaiter.prototype.updateBreakpointIndicator = function(position) {
-    const operations = document.querySelectorAll("#rec-list li.operation");
-    for (let i = 0; i < operations.length; i++) {
-        if (i === position) {
-            operations[i].classList.add("break");
-        } else {
-            operations[i].classList.remove("break");
-        }
-    }
-};
-
-
-/**
- * Given an operation stub element, this function converts it into a full recipe element with
- * arguments.
- *
- * @param {element} el - The operation stub element from the operations pane
- */
-RecipeWaiter.prototype.buildRecipeOperation = function(el) {
-    const opName = el.textContent;
-    const op = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
-    el.innerHTML = op.toFullHtml();
-
-    if (this.app.operations[opName].flowControl) {
-        el.classList.add("flow-control-op");
-    }
-
-    // Disable auto-bake if this is a manual op
-    if (op.manualBake && this.app.autoBake_) {
-        this.manager.controls.setAutoBake(false);
-        this.app.alert("Auto-Bake is disabled by default when using this operation.", "info", 5000);
-    }
-};
-
-/**
- * Adds the specified operation to the recipe.
- *
- * @fires Manager#operationadd
- * @param {string} name - The name of the operation to add
- * @returns {element}
- */
-RecipeWaiter.prototype.addOperation = function(name) {
-    const item = document.createElement("li");
-
-    item.classList.add("operation");
-    item.innerHTML = name;
-    this.buildRecipeOperation(item);
-    document.getElementById("rec-list").appendChild(item);
-
-    item.dispatchEvent(this.manager.operationadd);
-    return item;
-};
-
-
-/**
- * Removes all operations from the recipe.
- *
- * @fires Manager#operationremove
- */
-RecipeWaiter.prototype.clearRecipe = function() {
-    const recList = document.getElementById("rec-list");
-    while (recList.firstChild) {
-        recList.removeChild(recList.firstChild);
-    }
-    recList.dispatchEvent(this.manager.operationremove);
-};
-
-
-/**
- * Handler for operation dropdown events from toggleString arguments.
- * Sets the selected option as the name of the button.
- *
- * @param {event} e
- */
-RecipeWaiter.prototype.dropdownToggleClick = function(e) {
-    const el = e.target;
-    const button = el.parentNode.parentNode.previousSibling;
-
-    button.innerHTML = el.textContent + " <span class='caret'></span>";
-    this.ingChange();
-};
-
-
-/**
- * Handler for operationadd events.
- *
- * @listens Manager#operationadd
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.opAdd = function(e) {
-    log.debug(`'${e.target.querySelector(".arg-title").textContent}' added to recipe`);
-    window.dispatchEvent(this.manager.statechange);
-};
-
-
-/**
- * Handler for operationremove events.
- *
- * @listens Manager#operationremove
- * @fires Manager#statechange
- * @param {event} e
- */
-RecipeWaiter.prototype.opRemove = function(e) {
-    log.debug("Operation removed from recipe");
-    window.dispatchEvent(this.manager.statechange);
-};
-
-
-/**
- * Sets register values.
- *
- * @param {number} opIndex
- * @param {number} numPrevRegisters
- * @param {string[]} registers
- */
-RecipeWaiter.prototype.setRegisters = function(opIndex, numPrevRegisters, registers) {
-    const op = document.querySelector(`#rec-list .operation:nth-child(${opIndex + 1})`),
-        prevRegList = op.querySelector(".register-list");
-
-    // Remove previous div
-    if (prevRegList) prevRegList.remove();
-
-    const registerList = [];
-    for (let i = 0; i < registers.length; i++) {
-        registerList.push(`$R${numPrevRegisters + i} = ${Utils.escapeHtml(Utils.truncate(Utils.printable(registers[i]), 100))}`);
-    }
-    const registerListEl = `<div class="register-list">
-            ${registerList.join("<br>")}
-        </div>`;
-
-    op.insertAdjacentHTML("beforeend", registerListEl);
-};
-
-export default RecipeWaiter;

+ 475 - 0
src/web/RecipeWaiter.mjs

@@ -0,0 +1,475 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+import HTMLOperation from "./HTMLOperation";
+import Sortable from "sortablejs";
+import Utils from "../core/Utils";
+
+
+/**
+ * Waiter to handle events related to the recipe.
+ */
+class RecipeWaiter {
+
+    /**
+     * RecipeWaiter 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;
+        this.removeIntent = false;
+    }
+
+
+    /**
+     * Sets up the drag and drop capability for operations in the operations and recipe areas.
+     */
+    initialiseOperationDragNDrop() {
+        const recList = document.getElementById("rec-list");
+
+        // Recipe list
+        Sortable.create(recList, {
+            group: "recipe",
+            sort: true,
+            animation: 0,
+            delay: 0,
+            filter: ".arg-input,.arg",
+            preventOnFilter: false,
+            setData: function(dataTransfer, dragEl) {
+                dataTransfer.setData("Text", dragEl.querySelector(".arg-title").textContent);
+            },
+            onEnd: function(evt) {
+                if (this.removeIntent) {
+                    evt.item.remove();
+                    evt.target.dispatchEvent(this.manager.operationremove);
+                }
+            }.bind(this),
+            onSort: function(evt) {
+                if (evt.from.id === "rec-list") {
+                    document.dispatchEvent(this.manager.statechange);
+                }
+            }.bind(this)
+        });
+
+        Sortable.utils.on(recList, "dragover", function() {
+            this.removeIntent = false;
+        }.bind(this));
+
+        Sortable.utils.on(recList, "dragleave", function() {
+            this.removeIntent = true;
+            this.app.progress = 0;
+        }.bind(this));
+
+        Sortable.utils.on(recList, "touchend", function(e) {
+            const loc = e.changedTouches[0];
+            const target = document.elementFromPoint(loc.clientX, loc.clientY);
+
+            this.removeIntent = !recList.contains(target);
+        }.bind(this));
+
+        // Favourites category
+        document.querySelector("#categories a").addEventListener("dragover", this.favDragover.bind(this));
+        document.querySelector("#categories a").addEventListener("dragleave", this.favDragleave.bind(this));
+        document.querySelector("#categories a").addEventListener("drop", this.favDrop.bind(this));
+    }
+
+
+    /**
+     * Creates a drag-n-droppable seed list of operations.
+     *
+     * @param {element} listEl - The list to initialise
+     */
+    createSortableSeedList(listEl) {
+        Sortable.create(listEl, {
+            group: {
+                name: "recipe",
+                pull: "clone",
+                put: false,
+            },
+            sort: false,
+            setData: function(dataTransfer, dragEl) {
+                dataTransfer.setData("Text", dragEl.textContent);
+            },
+            onStart: function(evt) {
+                // Removes popover element and event bindings from the dragged operation but not the
+                // event bindings from the one left in the operations list. Without manually removing
+                // these bindings, we cannot re-initialise the popover on the stub operation.
+                $(evt.item).popover("destroy").removeData("bs.popover").off("mouseenter").off("mouseleave");
+                $(evt.clone).off(".popover").removeData("bs.popover");
+                evt.item.setAttribute("data-toggle", "popover-disabled");
+            },
+            onEnd: this.opSortEnd.bind(this)
+        });
+    }
+
+
+    /**
+     * Handler for operation sort end events.
+     * Removes the operation from the list if it has been dropped outside. If not, adds it to the list
+     * at the appropriate place and initialises it.
+     *
+     * @fires Manager#operationadd
+     * @param {event} evt
+     */
+    opSortEnd(evt) {
+        if (this.removeIntent) {
+            if (evt.item.parentNode.id === "rec-list") {
+                evt.item.remove();
+            }
+            return;
+        }
+
+        // Reinitialise the popover on the original element in the ops list because for some reason it
+        // gets destroyed and recreated.
+        this.manager.ops.enableOpsListPopovers(evt.clone);
+
+        if (evt.item.parentNode.id !== "rec-list") {
+            return;
+        }
+
+        this.buildRecipeOperation(evt.item);
+        evt.item.dispatchEvent(this.manager.operationadd);
+    }
+
+
+    /**
+     * Handler for favourite dragover events.
+     * If the element being dragged is an operation, displays a visual cue so that the user knows it can
+     * be dropped here.
+     *
+     * @param {event} e
+     */
+    favDragover(e) {
+        if (e.dataTransfer.effectAllowed !== "move")
+            return false;
+
+        e.stopPropagation();
+        e.preventDefault();
+        if (e.target.className && e.target.className.indexOf("category-title") > -1) {
+            // Hovering over the a
+            e.target.classList.add("favourites-hover");
+        } else if (e.target.parentNode.className && e.target.parentNode.className.indexOf("category-title") > -1) {
+            // Hovering over the Edit button
+            e.target.parentNode.classList.add("favourites-hover");
+        } else if (e.target.parentNode.parentNode.className && e.target.parentNode.parentNode.className.indexOf("category-title") > -1) {
+            // Hovering over the image on the Edit button
+            e.target.parentNode.parentNode.classList.add("favourites-hover");
+        }
+    }
+
+
+    /**
+     * Handler for favourite dragleave events.
+     * Removes the visual cue.
+     *
+     * @param {event} e
+     */
+    favDragleave(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        document.querySelector("#categories a").classList.remove("favourites-hover");
+    }
+
+
+    /**
+     * Handler for favourite drop events.
+     * Adds the dragged operation to the favourites list.
+     *
+     * @param {event} e
+     */
+    favDrop(e) {
+        e.stopPropagation();
+        e.preventDefault();
+        e.target.classList.remove("favourites-hover");
+
+        const opName = e.dataTransfer.getData("Text");
+        this.app.addFavourite(opName);
+    }
+
+
+    /**
+     * Handler for ingredient change events.
+     *
+     * @fires Manager#statechange
+     */
+    ingChange(e) {
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+
+    /**
+     * Handler for disable click events.
+     * Updates the icon status.
+     *
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    disableClick(e) {
+        const icon = e.target;
+
+        if (icon.getAttribute("disabled") === "false") {
+            icon.setAttribute("disabled", "true");
+            icon.classList.add("disable-icon-selected");
+            icon.parentNode.parentNode.classList.add("disabled");
+        } else {
+            icon.setAttribute("disabled", "false");
+            icon.classList.remove("disable-icon-selected");
+            icon.parentNode.parentNode.classList.remove("disabled");
+        }
+
+        this.app.progress = 0;
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+
+    /**
+     * Handler for breakpoint click events.
+     * Updates the icon status.
+     *
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    breakpointClick(e) {
+        const bp = e.target;
+
+        if (bp.getAttribute("break") === "false") {
+            bp.setAttribute("break", "true");
+            bp.classList.add("breakpoint-selected");
+        } else {
+            bp.setAttribute("break", "false");
+            bp.classList.remove("breakpoint-selected");
+        }
+
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+
+    /**
+     * Handler for operation doubleclick events.
+     * Removes the operation from the recipe and auto bakes.
+     *
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    operationDblclick(e) {
+        e.target.remove();
+        this.opRemove(e);
+    }
+
+
+    /**
+     * Handler for operation child doubleclick events.
+     * Removes the operation from the recipe.
+     *
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    operationChildDblclick(e) {
+        e.target.parentNode.remove();
+        this.opRemove(e);
+    }
+
+
+    /**
+     * Generates a configuration object to represent the current recipe.
+     *
+     * @returns {recipeConfig}
+     */
+    getConfig() {
+        const config = [];
+        let ingredients, ingList, disabled, bp, item;
+        const operations = document.querySelectorAll("#rec-list li.operation");
+
+        for (let i = 0; i < operations.length; i++) {
+            ingredients = [];
+            disabled = operations[i].querySelector(".disable-icon");
+            bp = operations[i].querySelector(".breakpoint");
+            ingList = operations[i].querySelectorAll(".arg");
+
+            for (let j = 0; j < ingList.length; j++) {
+                if (ingList[j].getAttribute("type") === "checkbox") {
+                    // checkbox
+                    ingredients[j] = ingList[j].checked;
+                } else if (ingList[j].classList.contains("toggle-string")) {
+                    // toggleString
+                    ingredients[j] = {
+                        option: ingList[j].previousSibling.children[0].textContent.slice(0, -1),
+                        string: ingList[j].value
+                    };
+                } else if (ingList[j].getAttribute("type") === "number") {
+                    // number
+                    ingredients[j] = parseFloat(ingList[j].value, 10);
+                } else {
+                    // all others
+                    ingredients[j] = ingList[j].value;
+                }
+            }
+
+            item = {
+                op: operations[i].querySelector(".arg-title").textContent,
+                args: ingredients
+            };
+
+            if (disabled && disabled.getAttribute("disabled") === "true") {
+                item.disabled = true;
+            }
+
+            if (bp && bp.getAttribute("break") === "true") {
+                item.breakpoint = true;
+            }
+
+            config.push(item);
+        }
+
+        return config;
+    }
+
+
+    /**
+     * Moves or removes the breakpoint indicator in the recipe based on the position.
+     *
+     * @param {number} position
+     */
+    updateBreakpointIndicator(position) {
+        const operations = document.querySelectorAll("#rec-list li.operation");
+        for (let i = 0; i < operations.length; i++) {
+            if (i === position) {
+                operations[i].classList.add("break");
+            } else {
+                operations[i].classList.remove("break");
+            }
+        }
+    }
+
+
+    /**
+     * Given an operation stub element, this function converts it into a full recipe element with
+     * arguments.
+     *
+     * @param {element} el - The operation stub element from the operations pane
+     */
+    buildRecipeOperation(el) {
+        const opName = el.textContent;
+        const op = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
+        el.innerHTML = op.toFullHtml();
+
+        if (this.app.operations[opName].flowControl) {
+            el.classList.add("flow-control-op");
+        }
+
+        // Disable auto-bake if this is a manual op
+        if (op.manualBake && this.app.autoBake_) {
+            this.manager.controls.setAutoBake(false);
+            this.app.alert("Auto-Bake is disabled by default when using this operation.", "info", 5000);
+        }
+    }
+
+    /**
+     * Adds the specified operation to the recipe.
+     *
+     * @fires Manager#operationadd
+     * @param {string} name - The name of the operation to add
+     * @returns {element}
+     */
+    addOperation(name) {
+        const item = document.createElement("li");
+
+        item.classList.add("operation");
+        item.innerHTML = name;
+        this.buildRecipeOperation(item);
+        document.getElementById("rec-list").appendChild(item);
+
+        item.dispatchEvent(this.manager.operationadd);
+        return item;
+    }
+
+
+    /**
+     * Removes all operations from the recipe.
+     *
+     * @fires Manager#operationremove
+     */
+    clearRecipe() {
+        const recList = document.getElementById("rec-list");
+        while (recList.firstChild) {
+            recList.removeChild(recList.firstChild);
+        }
+        recList.dispatchEvent(this.manager.operationremove);
+    }
+
+
+    /**
+     * Handler for operation dropdown events from toggleString arguments.
+     * Sets the selected option as the name of the button.
+     *
+     * @param {event} e
+     */
+    dropdownToggleClick(e) {
+        const el = e.target;
+        const button = el.parentNode.parentNode.previousSibling;
+
+        button.innerHTML = el.textContent + " <span class='caret'></span>";
+        this.ingChange();
+    }
+
+
+    /**
+     * Handler for operationadd events.
+     *
+     * @listens Manager#operationadd
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    opAdd(e) {
+        log.debug(`'${e.target.querySelector(".arg-title").textContent}' added to recipe`);
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+
+    /**
+     * Handler for operationremove events.
+     *
+     * @listens Manager#operationremove
+     * @fires Manager#statechange
+     * @param {event} e
+     */
+    opRemove(e) {
+        log.debug("Operation removed from recipe");
+        window.dispatchEvent(this.manager.statechange);
+    }
+
+
+    /**
+     * Sets register values.
+     *
+     * @param {number} opIndex
+     * @param {number} numPrevRegisters
+     * @param {string[]} registers
+     */
+    setRegisters(opIndex, numPrevRegisters, registers) {
+        const op = document.querySelector(`#rec-list .operation:nth-child(${opIndex + 1})`),
+            prevRegList = op.querySelector(".register-list");
+
+        // Remove previous div
+        if (prevRegList) prevRegList.remove();
+
+        const registerList = [];
+        for (let i = 0; i < registers.length; i++) {
+            registerList.push(`$R${numPrevRegisters + i} = ${Utils.escapeHtml(Utils.truncate(Utils.printable(registers[i]), 100))}`);
+        }
+        const registerListEl = `<div class="register-list">
+                ${registerList.join("<br>")}
+            </div>`;
+
+        op.insertAdjacentHTML("beforeend", registerListEl);
+    }
+
+}
+
+export default RecipeWaiter;

+ 0 - 48
src/web/SeasonalWaiter.js

@@ -1,48 +0,0 @@
-/**
- * Waiter to handle seasonal events and easter eggs.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const SeasonalWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-};
-
-
-/**
- * Loads all relevant items depending on the current date.
- */
-SeasonalWaiter.prototype.load = function() {
-    // Konami code
-    this.kkeys = [];
-    window.addEventListener("keydown", this.konamiCodeListener.bind(this));
-};
-
-
-/**
- * Listen for the Konami code sequence of keys. Turn the page upside down if they are all heard in
- * sequence.
- * #konamicode
- */
-SeasonalWaiter.prototype.konamiCodeListener = function(e) {
-    this.kkeys.push(e.keyCode);
-    const konami = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
-    for (let i = 0; i < this.kkeys.length; i++) {
-        if (this.kkeys[i] !== konami[i]) {
-            this.kkeys = [];
-            break;
-        }
-        if (i === konami.length - 1) {
-            $("body").children().toggleClass("konami");
-            this.kkeys = [];
-        }
-    }
-};
-
-export default SeasonalWaiter;

+ 56 - 0
src/web/SeasonalWaiter.mjs

@@ -0,0 +1,56 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * Waiter to handle seasonal events and easter eggs.
+ */
+class SeasonalWaiter {
+
+    /**
+     * SeasonalWaiter contructor.
+     *
+     * @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;
+    }
+
+
+    /**
+     * Loads all relevant items depending on the current date.
+     */
+    load() {
+        // Konami code
+        this.kkeys = [];
+        window.addEventListener("keydown", this.konamiCodeListener.bind(this));
+    }
+
+
+    /**
+     * Listen for the Konami code sequence of keys. Turn the page upside down if they are all heard in
+     * sequence.
+     * #konamicode
+     */
+    konamiCodeListener(e) {
+        this.kkeys.push(e.keyCode);
+        const konami = [38, 38, 40, 40, 37, 39, 37, 39, 66, 65];
+        for (let i = 0; i < this.kkeys.length; i++) {
+            if (this.kkeys[i] !== konami[i]) {
+                this.kkeys = [];
+                break;
+            }
+            if (i === konami.length - 1) {
+                $("body").children().toggleClass("konami");
+                this.kkeys = [];
+            }
+        }
+    }
+
+}
+
+export default SeasonalWaiter;

+ 0 - 54
src/web/WindowWaiter.js

@@ -1,54 +0,0 @@
-/**
- * Waiter to handle events related to the window object.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- */
-const WindowWaiter = function(app) {
-    this.app = app;
-};
-
-
-/**
- * Handler for window resize events.
- * Resets the layout of CyberChef's panes after 200ms (so that continuous resizing doesn't cause
- * continuous resetting).
- */
-WindowWaiter.prototype.windowResize = function() {
-    clearTimeout(this.resetLayoutTimeout);
-    this.resetLayoutTimeout = setTimeout(this.app.resetLayout.bind(this.app), 200);
-};
-
-
-/**
- * Handler for window blur events.
- * Saves the current time so that we can calculate how long the window was unfocussed for when
- * focus is returned.
- */
-WindowWaiter.prototype.windowBlur = function() {
-    this.windowBlurTime = new Date().getTime();
-};
-
-
-/**
- * Handler for window focus events.
- *
- * When a browser tab is unfocused and the browser has to run lots of dynamic content in other
- * tabs, it swaps out the memory for that tab.
- * If the CyberChef tab has been unfocused for more than a minute, we run a silent bake which will
- * force the browser to load and cache all the relevant JavaScript code needed to do a real bake.
- * This will stop baking taking a long time when the CyberChef browser tab has been unfocused for
- * a long time and the browser has swapped out all its memory.
- */
-WindowWaiter.prototype.windowFocus = function() {
-    const unfocusedTime = new Date().getTime() - this.windowBlurTime;
-    if (unfocusedTime > 60000) {
-        this.app.silentBake();
-    }
-};
-
-export default WindowWaiter;

+ 62 - 0
src/web/WindowWaiter.mjs

@@ -0,0 +1,62 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2016
+ * @license Apache-2.0
+ */
+
+/**
+ * Waiter to handle events related to the window object.
+ */
+class WindowWaiter {
+
+    /**
+     * WindowWaiter constructor.
+     *
+     * @param {App} app - The main view object for CyberChef.
+     */
+    constructor(app) {
+        this.app = app;
+    }
+
+
+    /**
+     * Handler for window resize events.
+     * Resets the layout of CyberChef's panes after 200ms (so that continuous resizing doesn't cause
+     * continuous resetting).
+     */
+    windowResize() {
+        clearTimeout(this.resetLayoutTimeout);
+        this.resetLayoutTimeout = setTimeout(this.app.resetLayout.bind(this.app), 200);
+    }
+
+
+    /**
+     * Handler for window blur events.
+     * Saves the current time so that we can calculate how long the window was unfocussed for when
+     * focus is returned.
+     */
+    windowBlur() {
+        this.windowBlurTime = new Date().getTime();
+    }
+
+
+    /**
+     * Handler for window focus events.
+     *
+     * When a browser tab is unfocused and the browser has to run lots of dynamic content in other
+     * tabs, it swaps out the memory for that tab.
+     * If the CyberChef tab has been unfocused for more than a minute, we run a silent bake which will
+     * force the browser to load and cache all the relevant JavaScript code needed to do a real bake.
+     * This will stop baking taking a long time when the CyberChef browser tab has been unfocused for
+     * a long time and the browser has swapped out all its memory.
+     */
+    windowFocus() {
+        const unfocusedTime = new Date().getTime() - this.windowBlurTime;
+        if (unfocusedTime > 60000) {
+            this.app.silentBake();
+        }
+    }
+
+}
+
+export default WindowWaiter;

+ 0 - 231
src/web/WorkerWaiter.js

@@ -1,231 +0,0 @@
-import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker.js";
-
-/**
- * Waiter to handle conversations with the ChefWorker.
- *
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2017
- * @license Apache-2.0
- *
- * @constructor
- * @param {App} app - The main view object for CyberChef.
- * @param {Manager} manager - The CyberChef event manager.
- */
-const WorkerWaiter = function(app, manager) {
-    this.app = app;
-    this.manager = manager;
-
-    this.callbacks = {};
-    this.callbackID = 0;
-};
-
-
-/**
- * Sets up the ChefWorker and associated listeners.
- */
-WorkerWaiter.prototype.registerChefWorker = function() {
-    log.debug("Registering new ChefWorker");
-    this.chefWorker = new ChefWorker();
-    this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this));
-    this.setLogLevel();
-
-    let docURL = document.location.href.split(/[#?]/)[0];
-    const index = docURL.lastIndexOf("/");
-    if (index > 0) {
-        docURL = docURL.substring(0, index);
-    }
-    this.chefWorker.postMessage({"action": "docURL", "data": docURL});
-};
-
-
-/**
- * Handler for messages sent back by the ChefWorker.
- *
- * @param {MessageEvent} e
- */
-WorkerWaiter.prototype.handleChefMessage = function(e) {
-    const r = e.data;
-    log.debug("Receiving '" + r.action + "' from ChefWorker");
-
-    switch (r.action) {
-        case "bakeComplete":
-            this.bakingComplete(r.data);
-            break;
-        case "bakeError":
-            this.app.handleError(r.data);
-            this.setBakingStatus(false);
-            break;
-        case "dishReturned":
-            this.callbacks[r.data.id](r.data);
-            break;
-        case "silentBakeComplete":
-            break;
-        case "workerLoaded":
-            this.app.workerLoaded = true;
-            log.debug("ChefWorker loaded");
-            this.app.loaded();
-            break;
-        case "statusMessage":
-            this.manager.output.setStatusMsg(r.data);
-            break;
-        case "optionUpdate":
-            log.debug(`Setting ${r.data.option} to ${r.data.value}`);
-            this.app.options[r.data.option] = r.data.value;
-            break;
-        case "setRegisters":
-            this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers);
-            break;
-        case "highlightsCalculated":
-            this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction);
-            break;
-        default:
-            log.error("Unrecognised message from ChefWorker", e);
-            break;
-    }
-};
-
-
-/**
- * Updates the UI to show if baking is in process or not.
- *
- * @param {bakingStatus}
- */
-WorkerWaiter.prototype.setBakingStatus = function(bakingStatus) {
-    this.app.baking = bakingStatus;
-
-    this.manager.output.toggleLoader(bakingStatus);
-};
-
-
-/**
- * Cancels the current bake by terminating the ChefWorker and creating a new one.
- */
-WorkerWaiter.prototype.cancelBake = function() {
-    this.chefWorker.terminate();
-    this.registerChefWorker();
-    this.setBakingStatus(false);
-    this.manager.controls.showStaleIndicator();
-};
-
-
-/**
- * Handler for completed bakes.
- *
- * @param {Object} response
- */
-WorkerWaiter.prototype.bakingComplete = function(response) {
-    this.setBakingStatus(false);
-
-    if (!response) return;
-
-    if (response.error) {
-        this.app.handleError(response.error);
-    }
-
-    this.app.progress = response.progress;
-    this.app.dish = response.dish;
-    this.manager.recipe.updateBreakpointIndicator(response.progress);
-    this.manager.output.set(response.result, response.type, response.duration);
-    log.debug("--- Bake complete ---");
-};
-
-
-/**
- * Asks the ChefWorker to bake the current input using the current recipe.
- *
- * @param {string} input
- * @param {Object[]} recipeConfig
- * @param {Object} options
- * @param {number} progress
- * @param {boolean} step
- */
-WorkerWaiter.prototype.bake = function(input, recipeConfig, options, progress, step) {
-    this.setBakingStatus(true);
-
-    this.chefWorker.postMessage({
-        action: "bake",
-        data: {
-            input: input,
-            recipeConfig: recipeConfig,
-            options: options,
-            progress: progress,
-            step: step
-        }
-    });
-};
-
-
-/**
- * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant
- * JavaScript code needed to do a real bake.
- *
- * @param {Object[]} [recipeConfig]
- */
-WorkerWaiter.prototype.silentBake = function(recipeConfig) {
-    this.chefWorker.postMessage({
-        action: "silentBake",
-        data: {
-            recipeConfig: recipeConfig
-        }
-    });
-};
-
-
-/**
- * Asks the ChefWorker to calculate highlight offsets if possible.
- *
- * @param {Object[]} recipeConfig
- * @param {string} direction
- * @param {Object} pos - The position object for the highlight.
- * @param {number} pos.start - The start offset.
- * @param {number} pos.end - The end offset.
- */
-WorkerWaiter.prototype.highlight = function(recipeConfig, direction, pos) {
-    this.chefWorker.postMessage({
-        action: "highlight",
-        data: {
-            recipeConfig: recipeConfig,
-            direction: direction,
-            pos: pos
-        }
-    });
-};
-
-
-/**
- * Asks the ChefWorker to return the dish as the specified type
- *
- * @param {Dish} dish
- * @param {string} type
- * @param {Function} callback
- */
-WorkerWaiter.prototype.getDishAs = function(dish, type, callback) {
-    const id = this.callbackID++;
-    this.callbacks[id] = callback;
-    this.chefWorker.postMessage({
-        action: "getDishAs",
-        data: {
-            dish: dish,
-            type: type,
-            id: id
-        }
-    });
-};
-
-
-/**
- * Sets the console log level in the worker.
- *
- * @param {string} level
- */
-WorkerWaiter.prototype.setLogLevel = function(level) {
-    if (!this.chefWorker) return;
-
-    this.chefWorker.postMessage({
-        action: "setLogLevel",
-        data: log.getLevel()
-    });
-};
-
-
-export default WorkerWaiter;

+ 239 - 0
src/web/WorkerWaiter.mjs

@@ -0,0 +1,239 @@
+/**
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2017
+ * @license Apache-2.0
+ */
+
+import ChefWorker from "worker-loader?inline&fallback=false!../core/ChefWorker";
+
+/**
+ * Waiter to handle conversations with the ChefWorker.
+ */
+class WorkerWaiter {
+
+    /**
+     * WorkerWaiter 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;
+
+        this.callbacks = {};
+        this.callbackID = 0;
+    }
+
+
+    /**
+     * Sets up the ChefWorker and associated listeners.
+     */
+    registerChefWorker() {
+        log.debug("Registering new ChefWorker");
+        this.chefWorker = new ChefWorker();
+        this.chefWorker.addEventListener("message", this.handleChefMessage.bind(this));
+        this.setLogLevel();
+
+        let docURL = document.location.href.split(/[#?]/)[0];
+        const index = docURL.lastIndexOf("/");
+        if (index > 0) {
+            docURL = docURL.substring(0, index);
+        }
+        this.chefWorker.postMessage({"action": "docURL", "data": docURL});
+    }
+
+
+    /**
+     * Handler for messages sent back by the ChefWorker.
+     *
+     * @param {MessageEvent} e
+     */
+    handleChefMessage(e) {
+        const r = e.data;
+        log.debug("Receiving '" + r.action + "' from ChefWorker");
+
+        switch (r.action) {
+            case "bakeComplete":
+                this.bakingComplete(r.data);
+                break;
+            case "bakeError":
+                this.app.handleError(r.data);
+                this.setBakingStatus(false);
+                break;
+            case "dishReturned":
+                this.callbacks[r.data.id](r.data);
+                break;
+            case "silentBakeComplete":
+                break;
+            case "workerLoaded":
+                this.app.workerLoaded = true;
+                log.debug("ChefWorker loaded");
+                this.app.loaded();
+                break;
+            case "statusMessage":
+                this.manager.output.setStatusMsg(r.data);
+                break;
+            case "optionUpdate":
+                log.debug(`Setting ${r.data.option} to ${r.data.value}`);
+                this.app.options[r.data.option] = r.data.value;
+                break;
+            case "setRegisters":
+                this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers);
+                break;
+            case "highlightsCalculated":
+                this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction);
+                break;
+            default:
+                log.error("Unrecognised message from ChefWorker", e);
+                break;
+        }
+    }
+
+
+    /**
+     * Updates the UI to show if baking is in process or not.
+     *
+     * @param {bakingStatus}
+     */
+    setBakingStatus(bakingStatus) {
+        this.app.baking = bakingStatus;
+
+        this.manager.output.toggleLoader(bakingStatus);
+    }
+
+
+    /**
+     * Cancels the current bake by terminating the ChefWorker and creating a new one.
+     */
+    cancelBake() {
+        this.chefWorker.terminate();
+        this.registerChefWorker();
+        this.setBakingStatus(false);
+        this.manager.controls.showStaleIndicator();
+    }
+
+
+    /**
+     * Handler for completed bakes.
+     *
+     * @param {Object} response
+     */
+    bakingComplete(response) {
+        this.setBakingStatus(false);
+
+        if (!response) return;
+
+        if (response.error) {
+            this.app.handleError(response.error);
+        }
+
+        this.app.progress = response.progress;
+        this.app.dish = response.dish;
+        this.manager.recipe.updateBreakpointIndicator(response.progress);
+        this.manager.output.set(response.result, response.type, response.duration);
+        log.debug("--- Bake complete ---");
+    }
+
+
+    /**
+     * Asks the ChefWorker to bake the current input using the current recipe.
+     *
+     * @param {string} input
+     * @param {Object[]} recipeConfig
+     * @param {Object} options
+     * @param {number} progress
+     * @param {boolean} step
+     */
+    bake(input, recipeConfig, options, progress, step) {
+        this.setBakingStatus(true);
+
+        this.chefWorker.postMessage({
+            action: "bake",
+            data: {
+                input: input,
+                recipeConfig: recipeConfig,
+                options: options,
+                progress: progress,
+                step: step
+            }
+        });
+    }
+
+
+    /**
+     * Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant
+     * JavaScript code needed to do a real bake.
+     *
+     * @param {Object[]} [recipeConfig]
+     */
+    silentBake(recipeConfig) {
+        this.chefWorker.postMessage({
+            action: "silentBake",
+            data: {
+                recipeConfig: recipeConfig
+            }
+        });
+    }
+
+
+    /**
+     * Asks the ChefWorker to calculate highlight offsets if possible.
+     *
+     * @param {Object[]} recipeConfig
+     * @param {string} direction
+     * @param {Object} pos - The position object for the highlight.
+     * @param {number} pos.start - The start offset.
+     * @param {number} pos.end - The end offset.
+     */
+    highlight(recipeConfig, direction, pos) {
+        this.chefWorker.postMessage({
+            action: "highlight",
+            data: {
+                recipeConfig: recipeConfig,
+                direction: direction,
+                pos: pos
+            }
+        });
+    }
+
+
+    /**
+     * Asks the ChefWorker to return the dish as the specified type
+     *
+     * @param {Dish} dish
+     * @param {string} type
+     * @param {Function} callback
+     */
+    getDishAs(dish, type, callback) {
+        const id = this.callbackID++;
+        this.callbacks[id] = callback;
+        this.chefWorker.postMessage({
+            action: "getDishAs",
+            data: {
+                dish: dish,
+                type: type,
+                id: id
+            }
+        });
+    }
+
+
+    /**
+     * Sets the console log level in the worker.
+     *
+     * @param {string} level
+     */
+    setLogLevel(level) {
+        if (!this.chefWorker) return;
+
+        this.chefWorker.postMessage({
+            action: "setLogLevel",
+            data: log.getLevel()
+        });
+    }
+
+}
+
+
+export default WorkerWaiter;

+ 1 - 1
src/web/index.js

@@ -16,7 +16,7 @@ import moment from "moment-timezone";
 import CanvasComponents from "../core/vendor/canvascomponents.js";
 
 // CyberChef
-import App from "./App.js";
+import App from "./App";
 import Categories from "../core/config/Categories.json";
 import OperationConfig from "../core/config/OperationConfig.json";
 

+ 1 - 0
webpack.config.js

@@ -67,6 +67,7 @@ module.exports = {
             {
                 test: /\.m?js$/,
                 exclude: /node_modules\/(?!jsesc)/,
+                type: "javascript/auto",
                 loader: "babel-loader?compact=false"
             },
             {