diff --git a/.gitignore b/.gitignore
index 3b7449c4..616ca224 100755
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@ src/node/index.mjs
**/*.DS_Store
tests/browser/output/*
.node-version
+.idea
diff --git a/Gruntfile.js b/Gruntfile.js
index 32ba9007..1603a9ee 100755
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -215,6 +215,7 @@ module.exports = function (grunt) {
},
devServer: {
port: grunt.option("port") || 8080,
+ host: "0.0.0.0",
client: {
logging: "error",
overlay: true
diff --git a/README.md b/README.md
index 24f56e77..71084195 100755
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ There are four main areas in CyberChef:
1. The **input** box in the top right, where you can paste, type or drag the text or file you want to operate on.
2. The **output** box in the bottom right, where the outcome of your processing will be displayed.
- 3. The **operations** list on the far left, where you can find all the operations that CyberChef is capable of in categorised lists, or by searching.
+ 3. The **operations** list on the far left ( or in the dropdown at the top on mobile ), where you can find all the operations that CyberChef is capable of in categorised lists, or by searching.
4. The **recipe** area in the middle, where you can drag the operations that you want to use and specify arguments and options.
You can use as many operations as you like in simple or complex ways. Some examples are as follows:
@@ -49,6 +49,7 @@ You can use as many operations as you like in simple or complex ways. Some examp
- Drag and drop
- Operations can be dragged in and out of the recipe list, or reorganised.
- Files up to 2GB can be dragged over the input box to load them directly into the browser.
+ - On mobile devices, double-click the operations to add them to the recipe list.
- Auto Bake
- Whenever you modify the input or the recipe, CyberChef will automatically "bake" for you and produce the output immediately.
- This can be turned off and operated manually if it is affecting performance (if the input is very large, for instance).
@@ -89,8 +90,7 @@ CyberChef is built to support
## Node.js support
-CyberChef is built to fully support Node.js `v16`. For more information, see the ["Node API" wiki page](https://github.com/gchq/CyberChef/wiki/Node-API)
-
+CyberChef is built to fully support Node.js `v18`. For more information, see the Node API page in the project ["Node API" wiki page](https://github.com/gchq/CyberChef/wiki/Node-API)
## Contributing
diff --git a/nightwatch.json b/nightwatch.json
index 95359f44..bb97c31f 100644
--- a/nightwatch.json
+++ b/nightwatch.json
@@ -26,7 +26,6 @@
"prod": {
"launch_url": "http://localhost:8000/index.html"
}
-
}
}
diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs
index 3ce40aa4..d92968fb 100755
--- a/src/core/Recipe.mjs
+++ b/src/core/Recipe.mjs
@@ -18,7 +18,6 @@ let modules = null;
* The Recipe controls a list of Operations and the Dish they operate on.
*/
class Recipe {
-
/**
* Recipe constructor
*
diff --git a/src/web/App.mjs b/src/web/App.mjs
index cce91b1e..9ab66357 100755
--- a/src/web/App.mjs
+++ b/src/web/App.mjs
@@ -7,11 +7,11 @@
import Utils, { debounce } from "../core/Utils.mjs";
import {fromBase64} from "../core/lib/Base64.mjs";
import Manager from "./Manager.mjs";
-import HTMLCategory from "./HTMLCategory.mjs";
-import HTMLOperation from "./HTMLOperation.mjs";
import Split from "split.js";
import moment from "moment-timezone";
import cptable from "codepage";
+import {CCategoryLi} from "./components/c-category-li.mjs";
+import {CCategoryList} from "./components/c-category-list.mjs";
/**
@@ -46,8 +46,16 @@ class App {
this.appLoaded = false;
this.workerLoaded = false;
this.waitersLoaded = false;
+
+ this.breakpoint = 1024;
}
+ /**
+ * Is current view < breakpoint
+ */
+ isMobileView() {
+ return window.innerWidth < this.breakpoint;
+ }
/**
* This function sets up the stage and creates listeners for all events.
@@ -57,21 +65,17 @@ class App {
setup() {
document.dispatchEvent(this.manager.appstart);
- this.initialiseSplitter();
this.loadLocalStorage();
- this.populateOperationsList();
+ this.buildCategoryList();
+ this.buildUI();
this.manager.setup();
this.manager.output.saveBombe();
- this.adjustComponentSizes();
- this.setCompileMessage();
this.uriParams = this.getURIParams();
-
log.debug("App loaded");
this.appLoaded = true;
this.loaded();
}
-
/**
* Fires once all setup activities have completed.
*
@@ -241,84 +245,47 @@ class App {
/**
- * Populates the operations accordion list with the categories and operations specified in the
- * view constructor.
- *
- * @fires Manager#oplistcreate
+ * Sets up the adjustable splitter to allow the user to resize areas of the page.
*/
- 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 (!(opName in this.operations)) {
- 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();
+ buildUI() {
+ if (this.isMobileView()) {
+ this.setMobileUI();
+ } else {
+ this.setDesktopUI();
}
-
- 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)
- const favCat = document.querySelector("#categories a");
- favCat.appendChild(document.getElementById("edit-favourites"));
- favCat.setAttribute("data-help-title", "Favourite operations");
- favCat.setAttribute("data-help", `
This category displays your favourite operations.
-
-
To add: drag an operation over the Favourites category
-
To reorder: Click on the 'Edit favourites' button and drag operations up and down in the list provided
-
To remove: Click on the 'Edit favourites' button and hit the delete button next to the operation you want to remove
-
`);
}
-
/**
- * Sets up the adjustable splitter to allow the user to resize areas of the page.
+ * Set splitter
*
- * @param {boolean} [minimise=false] - Set this flag if attempting to minimise frames to 0 width
+ * We don't actually functionally use splitters on mobile, but we leverage the splitters
+ * to create our desired layout. This prevents some problems when resizing
+ * from mobile to desktop and vice versa and reduces code complexity
*/
- initialiseSplitter(minimise=false) {
+ setSplitter() {
if (this.columnSplitter) this.columnSplitter.destroy();
if (this.ioSplitter) this.ioSplitter.destroy();
+ const isMobileView = this.isMobileView();
+
this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
- sizes: [20, 30, 50],
- minSize: minimise ? [0, 0, 0] : [240, 310, 450],
- gutterSize: 4,
+ sizes: isMobileView ? [100, 100, 100] : [20, 40, 40],
+ minSize: [360, 330, 310],
+ gutterSize: isMobileView ? 0 : 4,
expandToMin: true,
- onDrag: debounce(function() {
- this.adjustComponentSizes();
+ onDrag: debounce(() => {
+ if (!isMobileView) {
+ this.manager.input.calcMaxTabs();
+ this.manager.output.calcMaxTabs();
+ }
}, 50, "dragSplitter", this, [])
});
this.ioSplitter = Split(["#input", "#output"], {
direction: "vertical",
- gutterSize: 4,
- minSize: minimise ? [0, 0] : [100, 100]
+ gutterSize: isMobileView ? 0 : 4,
+ minSize: [50, 50]
});
-
- this.adjustComponentSizes();
}
@@ -334,7 +301,6 @@ class App {
}
this.manager.options.load(lOptions);
- // Load favourites
this.loadFavourites();
}
@@ -412,14 +378,11 @@ class App {
/**
- * Resets favourite operations back to the default as specified in the view constructor and
+ * Resets favourite operations 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();
+ this.updateFavourites(this.dfavourites, true);
}
@@ -427,8 +390,9 @@ class App {
* Adds an operation to the user's favourites.
*
* @param {string} name - The name of the operation
+ * @param {Boolean} isExpanded - false by default
*/
- addFavourite(name) {
+ addFavourite(name, isExpanded = false) {
const favourites = JSON.parse(localStorage.favourites);
if (favourites.indexOf(name) >= 0) {
@@ -437,10 +401,51 @@ class App {
}
favourites.push(name);
+ this.updateFavourites(favourites, isExpanded);
+ }
+
+
+ /**
+ * Update favourites in localstorage, load the updated
+ * favourites and rebuild cat fav-list to reflect the updates
+ *
+ * @param {string[]} favourites
+ * @param {Boolean} isExpanded, false by default
+ */
+ updateFavourites(favourites, isExpanded = false) {
this.saveFavourites(favourites);
this.loadFavourites();
- this.populateOperationsList();
- this.manager.recipe.initialiseOperationDragNDrop();
+ this.buildFavouritesCategory(isExpanded);
+
+ window.dispatchEvent(this.manager.favouritesupdate);
+
+ this.manager.recipe.initDragAndDrop();
+ }
+
+ /**
+ * (Re)render only the favourites category after updating favourites
+ *
+ * @param {Boolean} isExpanded ( false by default )
+ */
+ buildFavouritesCategory(isExpanded = false) {
+ // double-check if the first category is indeed "catFavourites",
+ if (document.querySelector("c-category-list > ul > c-category-li > li > a[data-target='#catFavourites']")) {
+ // then destroy
+ document.querySelectorAll("c-category-list > ul > c-category-li")[0].remove();
+
+ // and rebuild it
+ const favCatConfig = this.categories.find(catConfig => catConfig.name === "Favourites");
+ const favouriteCategory = new CCategoryLi(
+ this,
+ favCatConfig,
+ this.operations,
+ isExpanded,
+ true
+ );
+
+ // finally prepend it to c-category-list
+ document.querySelector("c-category-list > ul").prepend(favouriteCategory);
+ }
}
/**
@@ -487,7 +492,7 @@ class App {
// 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);
+ this.manager.recipe.addOperation(matchedOps[0][0]);
}
// Populate search with the string
@@ -556,7 +561,7 @@ class App {
/**
* Gets the current recipe configuration.
*
- * @returns {Object[]}
+ * @returns {Object[]} recipeConfig - The recipe configuration
*/
getRecipeConfig() {
return this.manager.recipe.getConfig();
@@ -603,7 +608,7 @@ class App {
item.querySelector(".disable-icon").click();
}
if (recipeConfig[i].breakpoint) {
- item.querySelector(".breakpoint").click();
+ item.querySelector(".breakpoint-icon").click();
}
this.manager.recipe.triggerArgEvents(item);
@@ -617,27 +622,7 @@ class App {
/**
- * Resets the splitter positions to default.
- */
- resetLayout() {
- this.columnSplitter.setSizes([20, 30, 50]);
- this.ioSplitter.setSizes([50, 50]);
- this.adjustComponentSizes();
- }
-
- /**
- * Adjust components to fit their containers.
- */
- adjustComponentSizes() {
- this.manager.recipe.adjustWidth();
- this.manager.input.calcMaxTabs();
- this.manager.output.calcMaxTabs();
- this.manager.controls.calcControlsHeight();
- }
-
-
- /**
- * Sets the compile message.
+ * Sets the compile message ( "notice" in #banner ).
*/
setCompileMessage() {
// Display time since last build and compile message
@@ -766,17 +751,16 @@ class App {
/**
- * Handler for CyerChef statechange events.
+ * Handler for CyberChef statechange events.
* Fires whenever the input or recipe changes in any way.
*
* @listens Manager#statechange
- * @param {event} e
*/
- stateChange(e) {
- debounce(function() {
+ stateChange() {
+ debounce(() => {
this.progress = 0;
this.autoBake();
- this.updateURL(true, null, true);
+ this.updateURL(true);
}, 20, "stateChange", this, [])();
}
@@ -824,6 +808,81 @@ class App {
this.loadURIParams();
}
+ /**
+ * Set element visibility
+ *
+ * @param {HTMLElement} elm
+ * @param {boolean} isVisible
+ *
+ */
+ setElementVisibility(elm, isVisible) {
+ return isVisible ? elm.classList.remove("hidden") : elm.classList.add("hidden");
+ }
+
+ /**
+ * Set desktop UI ( on init and on window resize events )
+ */
+ setDesktopUI() {
+ this.setCompileMessage();
+ this.setSplitter();
+
+ this.manager.input.calcMaxTabs();
+ this.manager.output.calcMaxTabs();
+ }
+
+ /**
+ * Set mobile UI ( on init and on window resize events )
+ */
+ setMobileUI() {
+ this.setSplitter(false);
+ this.assignAvailableHeight();
+ $("[data-toggle=tooltip]").tooltip("disable");
+ }
+
+ /**
+ * Due to variable available heights on mobile devices ( due to the
+ * address bar etc. ), we need to calculate the available space and
+ * set some heights programmatically based on the full view height,
+ * minus fixed height elements.
+ *
+ * Be mindful to update these fixed numbers accordingly in the stylesheets
+ * ( themes/_structure ) if you make changes to those elements' height.
+ */
+ assignAvailableHeight() {
+ const bannerHeight = 40;
+ const controlsHeight = 50;
+ const operationsHeight = 80;
+
+ const remainingSpace = window.innerHeight - (bannerHeight+controlsHeight+operationsHeight - 1); // - 1 is accounting for a border
+
+ // equally divide among recipe, input and output
+ ["recipe", "input", "output"].forEach((div) => {
+ document.getElementById(div).style.height = `${remainingSpace/3}px`;
+ });
+
+ // set the ops-dropdown height
+ document.getElementById("operations-dropdown").style.maxHeight = `${window.innerHeight - (bannerHeight+operationsHeight)}px`;
+ }
+
+ /**
+ * Build a CCategoryList component and append it to #categories
+ */
+ buildCategoryList() {
+ // double-check if the c-category-list already exists,
+ if (document.querySelector("#categories > c-category-list")) {
+ // then destroy it
+ document.querySelector("#categories > c-category-list").remove();
+ }
+
+ const categoryList = new CCategoryList(
+ this,
+ this.categories,
+ this.operations,
+ true
+ );
+
+ document.querySelector("#categories").appendChild(categoryList);
+ }
}
export default App;
diff --git a/src/web/HTMLCategory.mjs b/src/web/HTMLCategory.mjs
deleted file mode 100755
index 0414fd71..00000000
--- a/src/web/HTMLCategory.mjs
+++ /dev/null
@@ -1,59 +0,0 @@
-/**
- * @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 = `
`;
-
- for (let i = 0; i < this.opList.length; i++) {
- html += this.opList[i].toStubHtml();
- }
-
- html += "
";
- return html;
- }
-
-}
-
-export default HTMLCategory;
diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs
index 7eddb32c..f97fb038 100755
--- a/src/web/HTMLIngredient.mjs
+++ b/src/web/HTMLIngredient.mjs
@@ -8,6 +8,9 @@ import Utils from "../core/Utils.mjs";
/**
* Object to handle the creation of operation ingredients.
+ *
+ * Note: Not to be confused with the native web component c-recipe-li, which is the component that is the (parent)
+ * list item in recipe-list.
*/
class HTMLIngredient {
@@ -128,20 +131,21 @@ class HTMLIngredient {
`;
break;
case "boolean":
- html += `
-
-
`;
+
`;
break;
case "option":
html += `
@@ -319,7 +323,7 @@ class HTMLIngredient {
* Handler for populate option changes.
* Populates the relevant argument with the specified value.
*
- * @param {event} e
+ * @param {Event} e
*/
populateOptionChange(e) {
e.preventDefault();
@@ -343,7 +347,7 @@ class HTMLIngredient {
* Handler for populate multi option changes.
* Populates the relevant arguments with the specified values.
*
- * @param {event} e
+ * @param {Event} e
*/
populateMultiOptionChange(e) {
e.preventDefault();
@@ -374,7 +378,7 @@ class HTMLIngredient {
* Handler for editable option clicks.
* Populates the input box with the selected value.
*
- * @param {event} e
+ * @param {Event} e
*/
editableOptionClick(e) {
e.preventDefault();
@@ -395,7 +399,7 @@ class HTMLIngredient {
* Handler for argument selector changes.
* Shows or hides the relevant arguments for this operation.
*
- * @param {event} e
+ * @param {Event} e
*/
argSelectorChange(e) {
e.preventDefault();
diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs
deleted file mode 100755
index d0523aa4..00000000
--- a/src/web/HTMLOperation.mjs
+++ /dev/null
@@ -1,176 +0,0 @@
-/**
- * @author n1474335 [n1474335@gmail.com]
- * @copyright Crown Copyright 2016
- * @license Apache-2.0
- */
-
-import HTMLIngredient from "./HTMLIngredient.mjs";
-import Utils from "../core/Utils.mjs";
-import url from "url";
-
-
-/**
- * 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.infoURL = config.infoURL;
- 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 = "
${titleFromWikiLink(this.infoURL)}` : "";
-
- html += ` data-container='body' data-toggle='popover' data-placement='right'
- data-content="${this.description}${infoLink}" data-html='true' data-trigger='hover'
- data-boundary='viewport'`;
- }
-
- html += ">" + this.name;
-
- if (removeIcon) {
- html += "delete";
- }
-
- html += "
";
-
- return html;
- }
-
-
- /**
- * Renders the operation in HTML as a full operation with ingredients.
- *
- * @returns {string}
- */
- toFullHtml() {
- let html = `
${Utils.escapeHtml(this.name)}
-
`;
-
- for (let i = 0; i < this.ingList.length; i++) {
- html += this.ingList[i].toHtml();
- }
-
- html += `
-
- pause
- not_interested
-
-
`;
-
- return html;
- }
-
-
- /**
- * Highlights searched strings in the name and description of the operation.
- *
- * @param {[[number]]} nameIdxs - Indexes of the search strings in the operation name [[start, length]]
- * @param {[[number]]} descIdxs - Indexes of the search strings in the operation description [[start, length]]
- */
- highlightSearchStrings(nameIdxs, descIdxs) {
- if (nameIdxs.length && typeof nameIdxs[0][0] === "number") {
- let opName = "",
- pos = 0;
-
- nameIdxs.forEach(idxs => {
- const [start, length] = idxs;
- if (typeof start !== "number") return;
- opName += this.name.slice(pos, start) + "" +
- this.name.slice(start, start + length) + "";
- pos = start + length;
- });
- opName += this.name.slice(pos, this.name.length);
- this.name = opName;
- }
-
- if (this.description && descIdxs.length && descIdxs[0][0] >= 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.
- const inHTMLTag = descIdxs.reduce((acc, idxs) => {
- const start = idxs[0];
- return start >= match.index && start <= (match.index + match[0].length);
- }, false);
-
- if (inHTMLTag) return;
- }
-
- let desc = "",
- pos = 0;
-
- descIdxs.forEach(idxs => {
- const [start, length] = idxs;
- desc += this.description.slice(pos, start) + "" +
- this.description.slice(start, start + length) + "";
- pos = start + length;
- });
- desc += this.description.slice(pos, this.description.length);
- this.description = desc;
- }
- }
-
-}
-
-
-/**
- * Given a URL for a Wikipedia (or other wiki) page, this function returns a link to that page.
- *
- * @param {string} urlStr
- * @returns {string}
- */
-function titleFromWikiLink(urlStr) {
- const urlObj = url.parse(urlStr);
- let wikiName = "",
- pageTitle = "";
-
- switch (urlObj.host) {
- case "forensicswiki.xyz":
- wikiName = "Forensics Wiki";
- pageTitle = urlObj.query.substr(6).replace(/_/g, " "); // Chop off 'title='
- break;
- case "wikipedia.org":
- wikiName = "Wikipedia";
- pageTitle = urlObj.pathname.substr(6).replace(/_/g, " "); // Chop off '/wiki/'
- break;
- default:
- // Not a wiki link, return full URL
- return `More Informationopen_in_new`;
- }
-
- return `${pageTitle}open_in_new on ${wikiName}`;
-}
-
-export default HTMLOperation;
diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs
index b02a7eee..15eb31fc 100755
--- a/src/web/Manager.mjs
+++ b/src/web/Manager.mjs
@@ -50,19 +50,19 @@ class Manager {
* @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});
+ /**
+ * @event Manager#favouritesupdate
+ */
+ this.favouritesupdate = new CustomEvent("favouritesupdate", {bubbles: true});
// Define Waiter objects to handle various areas
this.timing = new TimingWaiter(this.app, this);
this.worker = new WorkerWaiter(this.app, this);
- this.window = new WindowWaiter(this.app);
+ this.window = new WindowWaiter(this.app, this);
this.controls = new ControlsWaiter(this.app, this);
this.recipe = new RecipeWaiter(this.app, this);
this.ops = new OperationsWaiter(this.app, this);
@@ -89,7 +89,7 @@ class Manager {
this.input.setupInputWorker();
this.input.addInput(true);
this.worker.setupChefWorker();
- this.recipe.initialiseOperationDragNDrop();
+ this.recipe.initDragAndDrop();
this.controls.initComponents();
this.controls.autoBakeChange();
this.bindings.updateKeybList();
@@ -141,31 +141,29 @@ class Manager {
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);
+ document.getElementById("rec-list").addEventListener("click", this.controls.onMaximisedRecipeClick.bind(this.controls));
+ // A note for the Maximise Controls listeners below: click events via addDynamicListener don't properly bubble and the hit-box is unacceptably tiny, hence this solution
+ document.getElementById("maximise-recipe").addEventListener("click", this.controls.onMaximiseButtonClick.bind(this.controls));
+ document.getElementById("maximise-input").addEventListener("click", this.controls.onMaximiseButtonClick.bind(this.controls));
+ document.getElementById("maximise-output").addEventListener("click", this.controls.onMaximiseButtonClick.bind(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));
+ this.addMultiEventListener("#search", "keyup paste click", this.ops.searchOperations, this.ops);
+ document.getElementById("close-ops-dropdown-icon").addEventListener("click", this.ops.closeOpsDropdown.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", "oplistcreate", this.ops.opListCreate, this.ops);
- this.addDynamicListener("li.operation", "operationadd", this.recipe.opAdd, this.recipe);
+ this.addDynamicListener("c-operation-li", "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 .dropdown-menu.toggle-dropdown a", "click", this.recipe.dropdownToggleClick, this.recipe);
- this.addDynamicListener("#rec-list", "operationremove", this.recipe.opRemove.bind(this.recipe));
this.addDynamicListener("textarea.arg", "dragover", this.recipe.textArgDragover, this.recipe);
this.addDynamicListener("textarea.arg", "dragleave", this.recipe.textArgDragLeave, this.recipe);
this.addDynamicListener("textarea.arg", "drop", this.recipe.textArgDrop, this.recipe);
// Input
- document.getElementById("reset-layout").addEventListener("click", this.app.resetLayout.bind(this.app));
+ document.getElementById("reset-layout").addEventListener("click", this.app.setDesktopUI.bind(this.app));
this.addListeners("#clr-io,#btn-close-all-tabs", "click", this.input.clearAllIoClick, this.input);
this.addListeners("#open-file,#open-folder", "change", this.input.inputOpen, this.input);
document.getElementById("btn-open-file").addEventListener("click", this.input.inputOpenClick.bind(this.input));
@@ -198,7 +196,6 @@ class Manager {
document.getElementById("save-all-to-file").addEventListener("click", this.output.saveAllClick.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("maximise-output").addEventListener("click", this.output.maximiseOutputClick.bind(this.output));
document.getElementById("magic").addEventListener("click", this.output.magicClick.bind(this.output));
this.addDynamicListener(".extract-file,.extract-file i", "click", this.output.extractFileClick, this.output);
this.addDynamicListener("#output-tabs-wrapper #output-tabs li .output-tab-content", "click", this.output.changeTabClick, this.output);
@@ -289,7 +286,7 @@ class Manager {
* @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
+ * // Calls the save function whenever the keyup or paste events are triggered on any element
* // with the .saveable class
* this.addMultiEventListener(".saveable", "keyup paste", this.save, this);
*/
diff --git a/src/web/components/c-category-li.mjs b/src/web/components/c-category-li.mjs
new file mode 100644
index 00000000..232e1f38
--- /dev/null
+++ b/src/web/components/c-category-li.mjs
@@ -0,0 +1,175 @@
+import {COperationList} from "./c-operation-list.mjs";
+
+/**
+ * c(ustom element)-category-li ( list item )
+ */
+export class CCategoryLi extends HTMLElement {
+ /**
+ * @param {App} app - The main view object for CyberChef
+ * @param {CatConf} category - The category and operations to be populated.
+ * @param {OpConfig[]} operations - The list of operation configuration objects.
+ * @param {Boolean} isExpanded - expand the category by default on init or not
+ * @param {Boolean} includeOpLiStarIcon - Include the left side 'star' icon to each of the c-category-li >
+ * c-operation-list > c-operation-li list items in this category
+ */
+ constructor(
+ app,
+ category,
+ operations,
+ isExpanded,
+ includeOpLiStarIcon,
+ ) {
+ super();
+
+ this.app = app;
+ this.category = category; // contains a string[] of operation names under .ops
+ this.operations = operations; // opConfig[]
+ this.label = category.name;
+ this.isExpanded = isExpanded;
+ this.includeOpLiStarIcon = includeOpLiStarIcon;
+
+ this.build();
+
+ this.addEventListener("click", this.handleClick.bind(this));
+ }
+
+ /**
+ * Remove listeners on disconnectedCallback
+ */
+ disconnectedCallback() {
+ this.removeEventListener("click", this.handleClick.bind(this));
+ }
+
+ /**
+ * Handle click
+ *
+ * @param {Event} e
+ */
+ handleClick(e) {
+ if (e.target === this.querySelector("button") || e.target === this.querySelector("button > i")) {
+ e.stopPropagation(); // stop the event from propagating to the collapsable panel
+ this.app.manager.ops.editFavouritesClick(e);
+ }
+ }
+
+ /**
+ * Build c-category-li containing a nested c-operation-list ( op-list )
+ *
+ * @returns {HTMLElement}
+ */
+ build() {
+ const li = this.buildListItem();
+ const a = this.buildAnchor();
+ const div = this.buildCollapsablePanel();
+
+ li.appendChild(a);
+ li.appendChild(div);
+ this.appendChild(li);
+
+ const opList = new COperationList(
+ this.app,
+ this.category.ops.map(op => [op]),
+ this.includeOpLiStarIcon,
+ false,
+ true
+ );
+
+ div.appendChild(opList);
+ }
+
+ /**
+ * Build the li element
+ *
+ * @returns {HTMLElement}
+ */
+ buildListItem() {
+ const li = document.createElement("li");
+
+ li.classList.add("category");
+
+ return li;
+ }
+
+ /**
+ * Build the anchor element
+ *
+ * @returns {HTMLElement}
+ */
+ buildAnchor() {
+ const a = document.createElement("a");
+
+ a.classList.add("category-title");
+
+ a.setAttribute("data-toggle", "collapse");
+ a.setAttribute("data-target", `#${"cat" + this.label.replace(/[\s/\-:_]/g, "")}`);
+
+ a.innerText = this.label;
+
+ if (this.label === "Favourites") {
+ const editFavouritesButton = this.buildEditFavouritesButton(a);
+
+ // Note: I'm leaving this here as it was in the code originally, but it's not doing anything and it didn't
+ // do anything before my refactoring. I imagine we may want to fix that at some point though,
+ // hence I'm leaving this here.
+ a.setAttribute("data-help-title", "Favourite operations");
+ a.setAttribute("data-help", `
This category displays your favourite operations.
+
+
To add: Click on the star icon of an operation or drag an operation over the Favourites category on desktop devices
+
To reorder: Click on the 'Edit favourites' button and drag operations up and down in the list provided
+
To remove: Click on the 'Edit favourites' button and hit the delete button next to the operation you want to remove
+
`);
+
+ a.appendChild(editFavouritesButton);
+ }
+
+ return a;
+ }
+
+ /**
+ * Build the collapsable panel that contains the op-list for this category
+ *
+ * @returns {HTMLElement}
+ */
+ buildCollapsablePanel() {
+ const div = document.createElement("div");
+
+ div.setAttribute("id", `${"cat" + this.label.replace(/[\s/\-:_]/g, "")}`);
+ div.setAttribute("data-parent", "#categories");
+
+ div.classList.add("panel-collapse");
+ div.classList.add("collapse");
+
+ if (this.isExpanded) {
+ div.classList.add("show");
+ }
+
+ return div;
+ }
+
+ /**
+ * If this category is Favourites, build and return the star icon button
+ *
+ * @returns {HTMLElement}
+ */
+ buildEditFavouritesButton() {
+ const button = document.createElement("button");
+ const icon = document.createElement("i");
+
+ button.setAttribute("id", "edit-favourites");
+ button.setAttribute("type", "button");
+ button.setAttribute("data-toggle", "tooltip");
+ button.setAttribute("title", "Edit favourites");
+ button.classList.add("btn");
+ button.classList.add("btn-warning");
+ button.classList.add("bmd-btn-icon");
+
+ icon.classList.add("material-icons");
+ icon.innerText = "star";
+
+ button.appendChild(icon);
+
+ return button;
+ }
+}
+
+customElements.define("c-category-li", CCategoryLi);
diff --git a/src/web/components/c-category-list.mjs b/src/web/components/c-category-list.mjs
new file mode 100644
index 00000000..9db6e7f0
--- /dev/null
+++ b/src/web/components/c-category-list.mjs
@@ -0,0 +1,53 @@
+import {CCategoryLi} from "./c-category-li.mjs";
+
+/**
+ * c(ustom element)-category-list
+ */
+export class CCategoryList extends HTMLElement {
+ /**
+ * @param {App} app - The main view object for CyberChef
+ * @param {CatConf[]} categories - The list of categories and operations to be populated.
+ * @param {OpConfig[]} operations - A list of operation configuration objects.
+ * @param {Boolean} includeOpLiStarIcon - Include the left side 'star' icon to each of the c-category-li >
+ * c-operation-list > c-operation-li list items in this c-category-list
+ */
+ constructor(
+ app,
+ categories,
+ operations,
+ includeOpLiStarIcon
+ ) {
+ super();
+
+ this.app = app;
+ this.categories = categories;
+ this.operations = operations;
+ this.includeOpLiStarIcon = includeOpLiStarIcon;
+
+ this.build();
+ }
+
+ /**
+ * Build c-category-list
+ *
+ * @returns {HTMLElement}
+ */
+ build() {
+ const ul = document.createElement("ul");
+
+ this.categories.forEach((category, index) => {
+ const cat = new CCategoryLi(
+ this.app,
+ category,
+ this.operations,
+ index === 0,
+ this.includeOpLiStarIcon
+ );
+ ul.appendChild(cat);
+ });
+
+ this.append(ul);
+ }
+}
+
+customElements.define("c-category-list", CCategoryList);
diff --git a/src/web/components/c-operation-li.mjs b/src/web/components/c-operation-li.mjs
new file mode 100644
index 00000000..06ca912e
--- /dev/null
+++ b/src/web/components/c-operation-li.mjs
@@ -0,0 +1,311 @@
+import url from "url";
+
+/**
+ * c(ustom element)-operation-li ( list item )
+ */
+export class COperationLi extends HTMLElement {
+ /**
+ * @param {App} app - The main view object for CyberChef
+ * @param {string} name - The name of the operation
+ * @param {Object} icon - { class: string, innerText: string } - The optional and customizable icon displayed on the
+ * right side of the operation
+ * @param {Boolean} includeStarIcon - Include the left-side 'star' icon to favourite an operation
+ * @param {[number[]]} charIndicesToHighlight - optional array of indices that indicate characters to highlight (bold)
+ * in the operation name, for instance when the user searches for an operation by typing
+ */
+ constructor(
+ app,
+ name,
+ icon,
+ includeStarIcon,
+ charIndicesToHighlight = []
+ ) {
+ super();
+
+ this.app = app;
+ this.operationName = name;
+ this.icon = icon;
+ this.includeStarIcon = includeStarIcon;
+ this.charIndicesToHighlight = charIndicesToHighlight;
+
+ this.config = this.app.operations[name];
+
+ this.isFavourite = this.app.isLocalStorageAvailable() && JSON.parse(localStorage.favourites).indexOf(name) >= 0;
+
+ this.build();
+
+ // Use mousedown event instead of click to prevent accidentally firing the handler twice on mobile
+ this.addEventListener("mousedown", this.handleMousedown.bind(this));
+ this.addEventListener("dblclick", this.handleDoubleClick.bind(this));
+
+ if (this.includeStarIcon) {
+ this.observer = new MutationObserver(this.updateFavourite.bind(this));
+ this.observer.observe(this.querySelector("li"), { attributes: true });
+ }
+ }
+
+ /**
+ * Remove listeners on disconnectedCallback
+ */
+ disconnectedCallback() {
+ this.removeEventListener("mousedown", this.handleMousedown.bind(this));
+ this.removeEventListener("dblclick", this.handleDoubleClick.bind(this));
+
+ if (this.includeStarIcon) {
+ this.observer.disconnect();
+ }
+
+ $(this).find("[data-toggle=popover]").popover("dispose").popover("hide");
+ }
+
+ /**
+ * Handle double click
+ *
+ * @param {Event} e
+ */
+ handleDoubleClick(e) {
+ // this span is element holding the operation title
+ if (e.target === this.querySelector("li") || e.target === this.querySelector("span")) {
+ this.app.manager.recipe.addOperation(this.operationName);
+ }
+ }
+
+ /**
+ * Handle mousedown
+ *
+ * @param {Event} e
+ */
+ handleMousedown(e) {
+ if (e.target === this.querySelector("i.star-icon")) {
+ this.app.addFavourite(this.operationName);
+ }
+ // current use case: in the 'Edit favourites' modal, the c-operation-li components have a trashcan icon to the
+ // right
+ if (e.target === this.querySelector("i.remove-icon")) {
+ this.remove();
+ }
+ }
+
+ /**
+ * Disable or enable popover for an element
+ *
+ * @param {HTMLElement} el
+ */
+ handlePopover(el) {
+ // never display popovers on mobile on this component
+ if (this.app.isMobileView()) {
+ $(el).popover("disable");
+ } else {
+ $(el).popover("enable");
+ this.setPopover(el);
+ }
+ }
+
+
+ /**
+ * Build c-operation-li
+ *
+ * @returns {HTMLElement}
+ */
+ build() {
+ const li = this.buildListItem();
+ const icon = this.buildIcon();
+
+ if (this.includeStarIcon) {
+ const starIcon = this.buildStarIcon();
+ li.appendChild(starIcon);
+ }
+
+ li.appendChild(icon);
+ this.appendChild(li);
+ this.handlePopover(li);
+ }
+
+
+ /**
+ * Set the target operation popover itself to gain focus which
+ * enables scrolling and other interactions.
+ *
+ * @param {HTMLElement} el - The element to start selecting from
+ */
+ setPopover(el) {
+ $(this)
+ .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 operation
+ $(el).popover("show");
+ $(".popover").on("mouseleave", function () {
+ $(el).popover("hide");
+ });
+ })
+ .on("mouseleave", function () {
+ setTimeout(function() {
+ // Determine if the popover associated with this element is being hovered over
+ if ($(el).data("bs.popover") && ($(el).data("bs.popover").tip && !$($(el).data("bs.popover").tip).is(":hover"))) {
+ $(el).popover("hide");
+ }
+ }, 50);
+ });
+ }
+
+ /**
+ * Given a URL for a Wikipedia (or other wiki) page, this function returns a link to that page.
+ *
+ * @param {string} urlStr
+ * @returns {string}
+ */
+ titleFromWikiLink(urlStr) {
+ const urlObj = url.parse(urlStr);
+ let wikiName = "",
+ pageTitle = "";
+
+ switch (urlObj.host) {
+ case "forensicswiki.xyz":
+ wikiName = "Forensics Wiki";
+ pageTitle = urlObj.query.substr(6).replace(/_/g, " "); // Chop off 'title='
+ break;
+ case "wikipedia.org":
+ wikiName = "Wikipedia";
+ pageTitle = urlObj.pathname.substr(6).replace(/_/g, " "); // Chop off '/wiki/'
+ break;
+ default:
+ // Not a wiki link, return full URL
+ return `More Informationopen_in_new`;
+ }
+
+ return `${pageTitle}open_in_new on ${wikiName}`;
+ }
+
+ /**
+ * Build the li element
+ *
+ * @returns {HTMLElement}
+ */
+ buildListItem() {
+ const li = document.createElement("li");
+
+ li.appendChild(this.buildOperationName());
+
+ li.setAttribute("data-name", this.operationName);
+ li.classList.add("operation");
+
+ if (this.isFavourite) {
+ li.classList.add("favourite");
+ }
+
+ if (this.config.description) {
+ let dataContent = this.config.description;
+
+ if (this.config.infoURL) {
+ dataContent += `${this.titleFromWikiLink(this.config.infoURL)}`;
+ }
+
+ li.setAttribute("data-container", "body");
+ li.setAttribute("data-toggle", "popover");
+ li.setAttribute("data-html", "true");
+ li.setAttribute("data-boundary", "viewport");
+ li.setAttribute("data-content", dataContent);
+ }
+ return li;
+ }
+
+ /**
+ * Build the operation list item right side icon
+ *
+ * @returns {HTMLElement}
+ */
+ buildIcon() {
+ const icon = document.createElement("i");
+
+ icon.classList.add("material-icons");
+ icon.classList.add("op-icon");
+ icon.classList.add(this.icon.class);
+
+ icon.innerText = this.icon.innerText;
+
+ return icon;
+ }
+
+ /**
+ * Build the ( optional ) star icon
+ *
+ * @returns {HTMLElement}
+ */
+ buildStarIcon() {
+ const icon = document.createElement("i");
+ icon.setAttribute("title", this.operationName);
+
+ icon.classList.add("material-icons");
+ icon.classList.add("op-icon");
+ icon.classList.add("star-icon");
+
+ if (this.isFavourite) {
+ icon.innerText = "star";
+ } else {
+ icon.innerText = "star_outline";
+ }
+
+ return icon;
+ }
+
+ /**
+ * Update fav icon and 'fav' class on this li
+ */
+ updateFavourite() {
+ if (this.querySelector("li").classList.contains("favourite")) {
+ this.querySelector("i.star-icon").innerText = "star";
+ } else {
+ this.querySelector("i.star-icon").innerText = "star_outline";
+ }
+ }
+
+ /**
+ * Override native cloneNode method so we can clone c-operation-li properly
+ * with constructor arguments for sortable and cloneable lists. This function
+ * is needed for the drag and drop functionality of the Sortable lists
+ */
+ cloneNode() {
+ const { app, operationName, icon, includeStarIcon, charIndicesToHighlight } = this;
+ return new COperationLi(app, operationName, icon, includeStarIcon, charIndicesToHighlight);
+ }
+
+
+ /**
+ * Highlights searched strings ( if applicable ) in the name and description of the operation
+ * or simply sets the name in the span element
+ *
+ * @returns {HTMLElement}
+ */
+ buildOperationName() {
+ const span = document.createElement("span");
+
+ if (this.charIndicesToHighlight.length) {
+ let opName = "",
+ pos = 0;
+
+ this.charIndicesToHighlight.forEach(charIndices => {
+ const [start, length] = charIndices;
+ if (typeof start !== "number") return;
+ opName +=
+ "" + this.operationName.slice(pos, start) + "" + "" +
+ this.operationName.slice(start, start + length) + "";
+ pos = start + length;
+ });
+ opName += "" + this.operationName.slice(pos, this.operationName.length) + "";
+
+ const parser = new DOMParser();
+ opName = parser.parseFromString(opName, "text/html");
+
+ span.append(...opName.body.children);
+ } else {
+ span.innerText = this.operationName;
+ }
+
+ return span;
+ }
+}
+
+
+customElements.define("c-operation-li", COperationLi);
diff --git a/src/web/components/c-operation-list.mjs b/src/web/components/c-operation-list.mjs
new file mode 100644
index 00000000..7c00d62c
--- /dev/null
+++ b/src/web/components/c-operation-list.mjs
@@ -0,0 +1,203 @@
+import {COperationLi} from "./c-operation-li.mjs";
+import Sortable from "sortablejs";
+
+/**
+ * c(ustom element)-operation-list
+ */
+export class COperationList extends HTMLElement {
+ /**
+ * @param {App} app - The main view object for CyberChef
+ * @param {[string, number[]]} operations - A list of operation names and indexes of characters to highlight
+ * @param {Boolean} includeStarIcon - Include the left side 'star' icon to each of the c-category-li >
+ * c-operation-list > c-operation-li list items in this c-category-list
+ * @param {Boolean} isSortable - List items may be sorted ( reordered ). False by default
+ * @param {Boolean} isCloneable - List items are cloneable to a target list. True by default
+ * @param {Object} icon ( { class: string, innerText: string } ). 'check-icon' by default
+ */
+ constructor(
+ app,
+ operations,
+ includeStarIcon,
+ isSortable = false,
+ isCloneable = true,
+ icon
+ ) {
+ super();
+
+ this.app = app;
+ this.operations = operations;
+ this.includeStarIcon = includeStarIcon;
+ this.isSortable = isSortable;
+ this.isCloneable = isCloneable;
+ this.icon = icon;
+
+ this.build();
+
+ window.addEventListener("operationadd", this.handleChange.bind(this));
+ window.addEventListener("operationremove", this.handleChange.bind(this));
+ window.addEventListener("favouritesupdate", this.handleChange.bind(this));
+ }
+
+ /**
+ * Remove listeners on disconnectedCallback
+ */
+ disconnectedCallback() {
+ this.removeEventListener("operationadd", this.handleChange.bind(this));
+ this.removeEventListener("operationremove", this.handleChange.bind(this));
+ this.removeEventListener("favouritesupdate", this.handleChange.bind(this));
+ }
+
+ /**
+ * Handle change
+ * Fires on custom operationadd, operationremove, favouritesupdate events
+ */
+ handleChange() {
+ this.updateListItemsClasses("#catFavourites c-operation-list ul", "favourite");
+ this.updateListItemsClasses("#rec-list", "selected");
+ }
+
+ /**
+ * Build c-operation-list
+ *
+ * @returns {HTMLElement}
+ */
+ build() {
+ const ul = document.createElement("ul");
+ ul.classList.add("op-list");
+
+ this.operations.forEach((([opName, charIndicesToHighlight]) => {
+ const cOpLi = new COperationLi(
+ this.app,
+ opName,
+ {
+ class: this.icon ? this.icon.class : "check-icon",
+ innerText: this.icon ? this.icon.innerText : "check"
+ },
+ this.includeStarIcon,
+ charIndicesToHighlight
+ );
+
+ ul.appendChild(cOpLi);
+ }));
+
+ if (this.isSortable) {
+ this.createSortableList(ul);
+ } else if (!this.app.isMobileView() && this.isCloneable) {
+ this.createCloneableList(ul, "recipe", "rec-list");
+ }
+
+ this.append(ul);
+ }
+
+ /**
+ * Create a sortable ( but not cloneable ) list
+ *
+ * @param { HTMLElement } ul
+ * */
+ createSortableList(ul) {
+ const sortableList = Sortable.create(ul, {
+ group: "sorting",
+ sort: true,
+ draggable: "c-operation-li",
+ filter: "i.material-icons",
+ onFilter: function (e) {
+ const el = sortableList.closest(e.item);
+ if (el && el.parentNode) {
+ $(el).popover("dispose");
+ el.parentNode.removeChild(el);
+ }
+ },
+ onEnd: function(e) {
+ if (this.app.manager.recipe.removeIntent) {
+ $(e.item).popover("dispose");
+ e.item.remove();
+ }
+ }.bind(this),
+ });
+ }
+
+ /**
+ * Create a cloneable ( not sortable ) list
+ *
+ * @param { HTMLElement } ul
+ * @param { string } targetListName
+ * @param { string } targetListId
+ * */
+ createCloneableList(ul, targetListName, targetListId) {
+ let dragOverRecList = false;
+ const recList = document.querySelector(`#${targetListId}`);
+
+ Sortable.utils.on(recList, "dragover", function () {
+ dragOverRecList = true;
+ });
+
+ Sortable.utils.on(recList, "dragleave", function () {
+ dragOverRecList = false;
+ });
+
+ Sortable.create(ul, {
+ group: {
+ name: targetListName,
+ pull: "clone",
+ put: false,
+ },
+ draggable: "c-operation-li",
+ sort: false,
+ setData: function(dataTransfer, dragEl) {
+ dataTransfer.setData("Text", dragEl.querySelector("li").getAttribute("data-name"));
+ },
+ onStart: function(e) {
+ // 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.
+ $(e.item)
+ .find("[data-toggle=popover]")
+ .popover("dispose");
+ $(e.clone)
+ .find("[data-toggle=popover]")
+ .off(".popover")
+ .removeData("bs.popover");
+ },
+ onEnd: ({item, to, newIndex }) => {
+ if (item.parentNode.id === targetListId && dragOverRecList) {
+ this.app.manager.recipe.addOperation(item.name, newIndex);
+ item.remove();
+ } else if (!dragOverRecList && !to.classList.contains("op-list")) {
+ item.remove();
+ }
+ }
+ });
+ }
+
+ /**
+ * Update classes ( className ) on the li.operation elements in this list, based on the current state of a
+ * list of choice ( srcListSelector )
+ *
+ * @param {string} srcListSelector - the selector of the UL that we want to use as source of truth
+ * @param {string} className - the className to update
+ */
+ updateListItemsClasses(srcListSelector, className) {
+ const srcListItems= document.querySelectorAll(`${srcListSelector} li`);
+ const listItems = this.querySelectorAll("c-operation-li li.operation");
+
+ listItems.forEach((li => {
+ if (li.classList.contains(`${className}`)) {
+ li.classList.remove(`${className}`);
+ }
+ }));
+
+ if (srcListItems.length !== 0) {
+ srcListItems.forEach((item => {
+ const targetDataName = item.getAttribute("data-name");
+
+ listItems.forEach((listItem) => {
+ if (targetDataName === listItem.getAttribute("data-name")) {
+ listItem.classList.add(`${className}`);
+ }
+ });
+ }));
+ }
+ }
+}
+
+customElements.define("c-operation-list", COperationList);
diff --git a/src/web/components/c-recipe-li.mjs b/src/web/components/c-recipe-li.mjs
new file mode 100644
index 00000000..2e81f6c7
--- /dev/null
+++ b/src/web/components/c-recipe-li.mjs
@@ -0,0 +1,201 @@
+import HTMLIngredient from "../HTMLIngredient.mjs";
+
+/**
+ * c(ustom element)-recipe-li ( list item ).
+ *
+ * Note: This is the #recipe-list list item component, not to be confused with HTMLIngredient which make up the smaller
+ * components of this list item. It would be good to eventually fuse that code into this component or alternatively, to
+ * turn that into a separate native web component .
+ */
+export class CRecipeLi extends HTMLElement {
+ /**
+ * @param {App} app - The main view object for CyberChef
+ * @param {string} name - The operation name
+ * @param {object[]} args - The args properties of the operation ( see operation config file )
+ */
+ constructor(
+ app,
+ name,
+ args = []
+ ) {
+ super();
+
+ this.app = app;
+ this.name = name;
+ this.args = args;
+ this.ingredients = [];
+
+ this.flowControl = this.app.operations[this.name].flowControl;
+ this.manualBake = this.app.operations[this.name].manualBake;
+
+ for (let i = 0; i < args.length; i++) {
+ const ing = new HTMLIngredient(args[i], this.app, this.app.manager);
+ this.ingredients.push(ing);
+ }
+
+ this.build();
+
+ // Use mousedown event instead of click to prevent accidentally firing the handler twice on mobile
+ this.addEventListener("mousedown", this.handleMousedown.bind(this));
+ this.addEventListener("dblclick", this.handleDoubleClick.bind(this));
+ }
+
+ /**
+ * Remove listeners on disconnectedCallback
+ */
+ disconnectedCallback() {
+ this.removeEventListener("mousedown", this.handleMousedown.bind(this));
+ this.removeEventListener("dblclick", this.handleDoubleClick.bind(this));
+ }
+
+ /**
+ * Handle double click
+ *
+ * @param {Event} e
+ */
+ handleDoubleClick(e) {
+ // do not remove if icons or form elements are double-clicked
+ if (e.target === this.querySelector("li") || e.target === this.querySelector("div.op-title")) {
+ this.removeOperation();
+ }
+ }
+
+ /**
+ * Handle mousedown
+ * @fires Manager#statechange
+ * @param {Event} e
+ */
+ handleMousedown(e) {
+ const disableIcon = this.querySelector("i.disable-icon");
+ const breakpointIcon = this.querySelector("i.breakpoint-icon");
+
+ // handle click on 'disable-icon'
+ if (e.target === disableIcon) {
+ if (disableIcon.getAttribute("disabled") === "false") {
+ disableIcon.setAttribute("disabled", "true");
+ disableIcon.classList.add("disable-icon-selected");
+ this.querySelector("li.operation").classList.add("disabled");
+ } else {
+ disableIcon.setAttribute("disabled", "false");
+ disableIcon.classList.remove("disable-icon-selected");
+ this.querySelector("li.operation").classList.remove("disabled");
+ }
+
+ this.app.progress = 0;
+ window.dispatchEvent(this.app.manager.statechange);
+ }
+
+ // handle click on 'breakpoint-icon'
+ if (e.target === breakpointIcon) {
+ if (breakpointIcon.getAttribute("break") === "false") {
+ breakpointIcon.setAttribute("break", "true");
+ breakpointIcon.classList.add("breakpoint-selected");
+ } else {
+ breakpointIcon.setAttribute("break", "false");
+ breakpointIcon.classList.remove("breakpoint-selected");
+ }
+
+ window.dispatchEvent(this.app.manager.statechange);
+ }
+ }
+
+ /**
+ * Remove this operation from the recipe list
+ *
+ * @fires Manager#statechange
+ */
+ removeOperation() {
+ this.remove();
+ log.debug("Operation removed from recipe");
+ window.dispatchEvent(this.app.manager.statechange);
+ window.dispatchEvent(this.app.manager.operationremove);
+ }
+
+ /**
+ * Build the ingredient list item
+ *
+ * @returns {HTMLElement}
+ */
+ build() {
+ const li = document.createElement("li");
+ li.classList.add("operation");
+ li.setAttribute("data-name", this.name);
+
+ const titleDiv = document.createElement("div");
+ titleDiv.classList.add("op-title");
+ titleDiv.innerText = this.name;
+
+ const ingredientDiv = document.createElement("div");
+ ingredientDiv.classList.add("ingredients");
+
+ li.appendChild(titleDiv);
+ li.appendChild(ingredientDiv);
+
+ for (let i = 0; i < this.ingredients.length; i++) {
+ ingredientDiv.innerHTML += (this.ingredients[i].toHtml());
+ }
+
+ const icons = this.buildIcons();
+
+ const clearfixDiv = document.createElement("div");
+
+ if (this.flowControl) {
+ li.classList.add("flow-control-op");
+ }
+
+ if (this.manualBake && this.app.autoBake_) {
+ this.manager.controls.setAutoBake(false);
+ this.app.alert("Auto-Bake is disabled by default when using this operation.", 5000);
+ }
+
+ li.appendChild(icons);
+ li.appendChild(clearfixDiv);
+
+ this.appendChild(li);
+ }
+
+ /**
+ * Build the icons ( disable and breakpoint / pause )
+ *
+ * @returns {HTMLElement}
+ */
+ buildIcons() {
+ const div = document.createElement("div");
+ div.classList.add("recipe-icons");
+
+ const breakPointIcon = document.createElement("i");
+ breakPointIcon.classList.add("material-icons");
+ breakPointIcon.classList.add("breakpoint-icon");
+ breakPointIcon.setAttribute("title", "Set breakpoint");
+ breakPointIcon.setAttribute("break", "false");
+ breakPointIcon.setAttribute("data-help-title", "Setting breakpoints");
+ breakPointIcon.setAttribute("data-help", "Setting a breakpoint on an operation will cause execution of the Recipe to pause when it reaches that operation.");
+ breakPointIcon.innerText = "pause";
+
+ const disableIcon = document.createElement("i");
+ disableIcon.classList.add("material-icons");
+ disableIcon.classList.add("disable-icon");
+ disableIcon.setAttribute("title", "Disable operation");
+ disableIcon.setAttribute("disabled", "false");
+ disableIcon.setAttribute("data-help-title", "Disabling operations");
+ disableIcon.setAttribute("data-help", "Disabling an operation will prevent it from being executed when the Recipe is baked. Execution will skip over the disabled operation and continue with subsequent operations.");
+ disableIcon.innerText = "not_interested";
+
+ div.appendChild(breakPointIcon);
+ div.appendChild(disableIcon);
+
+ return div;
+ }
+
+ /**
+ * Override native cloneNode method so we can clone c-recipe-li properly
+ * with constructor arguments for sortable and cloneable lists. This function
+ * is needed for the drag and drop functionality of the Sortable lists
+ */
+ cloneNode() {
+ const { app, name, args } = this;
+ return new CRecipeLi(app, name, args);
+ }
+}
+
+customElements.define("c-recipe-li", CRecipeLi);
diff --git a/src/web/html/index.html b/src/web/html/index.html
index c602c275..38c82cc8 100755
--- a/src/web/html/index.html
+++ b/src/web/html/index.html
@@ -31,6 +31,7 @@
+