Add responsive phone and tablet layouts alongside desktop UI.

Use container-width layout modes so CyberChef works in embeds, with Recipe/Data navigation, operations sheet/drawer, tap-to-add, and touch reorder controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
blepman 2026-07-16 02:00:15 +02:00
parent 49b855c761
commit cddda865ae
10 changed files with 828 additions and 6 deletions

View File

@ -66,6 +66,8 @@ class App {
setup() {
document.dispatchEvent(this.manager.appstart);
// Splitters are created for desktop; ResponsiveLayoutWaiter destroys them
// immediately when the container is phone/tablet sized.
this.initialiseSplitter();
this.loadLocalStorage();
this.manager.options.applyPreferredColorScheme();
@ -309,14 +311,47 @@ class App {
}
/**
* Destroys Split.js instances and removes leftover gutters.
* Used when switching to mobile/tablet layouts.
*/
destroySplitters() {
if (this.columnSplitter) {
this.columnSplitter.destroy();
this.columnSplitter = null;
}
if (this.ioSplitter) {
this.ioSplitter.destroy();
this.ioSplitter = null;
}
document.querySelectorAll("#workspace-wrapper > .gutter, #IO > .gutter").forEach(g => g.remove());
// Clear inline sizes Split.js may have left behind
["operations", "recipe", "IO", "input", "output"].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.style.width = "";
el.style.height = "";
el.style.flex = "";
}
});
}
/**
* Sets up the adjustable splitter to allow the user to resize areas of the page.
*
* @param {boolean} [minimise=false] - Set this flag if attempting to minimise frames to 0 width
*/
initialiseSplitter(minimise=false) {
if (this.columnSplitter) this.columnSplitter.destroy();
if (this.ioSplitter) this.ioSplitter.destroy();
this.destroySplitters();
// Do not create splitters while in a touch layout — CSS owns the layout.
if (this.manager.responsive && this.manager.responsive.isTouchLayout()) {
this.adjustComponentSizes();
return;
}
this.columnSplitter = Split(["#operations", "#recipe", "#IO"], {
sizes: [20, 30, 50],
@ -629,6 +664,10 @@ class App {
* Resets the splitter positions to default.
*/
resetLayout() {
if (!this.columnSplitter || !this.ioSplitter) {
this.adjustComponentSizes();
return;
}
this.columnSplitter.setSizes([20, 30, 50]);
this.ioSplitter.setSizes([50, 50]);
this.adjustComponentSizes();

View File

@ -90,6 +90,8 @@ class HTMLOperation {
html += `</div>
<div class="recip-icons">
<i class="material-icons op-reorder-up responsive-only" title="Move up" aria-label="Move operation up">arrow_upward</i>
<i class="material-icons op-reorder-down responsive-only" title="Move down" aria-label="Move operation down">arrow_downward</i>
<i class="material-icons breakpoint" title="Set breakpoint" break="false" data-help-title="Setting breakpoints" data-help="Setting a breakpoint on an operation will cause execution of the Recipe to pause when it reaches that operation.">pause</i>
<i class="material-icons disable-icon" title="Disable operation" disabled="false" data-help-title="Disabling operations" 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.">not_interested</i>
<i class="material-icons hide-args-icon" title="Hide operation's arguments" hide-args="false" data-help-title="Hide operation's arguments" data-help="Hiding an operation's argument will save space in the Recipe window. Execution will still take place with the selected argument options.">keyboard_arrow_up</i>

View File

@ -18,6 +18,7 @@ import BindingsWaiter from "./waiters/BindingsWaiter.mjs";
import BackgroundWorkerWaiter from "./waiters/BackgroundWorkerWaiter.mjs";
import TabWaiter from "./waiters/TabWaiter.mjs";
import TimingWaiter from "./waiters/TimingWaiter.mjs";
import ResponsiveLayoutWaiter from "./waiters/ResponsiveLayoutWaiter.mjs";
/**
@ -74,6 +75,7 @@ class Manager {
this.seasonal = new SeasonalWaiter(this.app, this);
this.bindings = new BindingsWaiter(this.app, this);
this.background = new BackgroundWorkerWaiter(this.app, this);
this.responsive = new ResponsiveLayoutWaiter(this.app, this);
// Object to store dynamic handlers to fire on elements that may not exist yet
this.dynamicHandlers = {};
@ -95,6 +97,7 @@ class Manager {
this.bindings.updateKeybList();
this.background.registerChefWorker();
this.seasonal.load();
this.responsive.setup();
this.confirmWaitersLoaded();
}
@ -148,6 +151,7 @@ class Manager {
// Operations
this.addMultiEventListener("#search", "keyup paste search", this.ops.searchOperations, this.ops);
this.addDynamicListener(".op-list li.operation", "dblclick", this.ops.operationDblclick, this.ops);
this.addDynamicListener(".op-list li.operation", "click", this.ops.operationClick, 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));
@ -160,6 +164,8 @@ class Manager {
this.addDynamicListener(".hide-args-icon", "click", this.recipe.hideArgsClick, this.recipe);
this.addDynamicListener(".disable-icon", "click", this.recipe.disableClick, this.recipe);
this.addDynamicListener(".breakpoint", "click", this.recipe.breakpointClick, this.recipe);
this.addDynamicListener(".op-reorder-up", "click", this.recipe.moveOpUp, this.recipe);
this.addDynamicListener(".op-reorder-down", "click", this.recipe.moveOpDown, 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);

View File

@ -24,6 +24,7 @@
<head>
<meta charset="UTF-8">
<title>CyberChef</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="copyright" content="Crown Copyright 2016-<%= htmlWebpackPlugin.options.compileYear %>" />
<meta name="description" content="The Cyber Swiss Army Knife - a web app for encryption, encoding, compression and data analysis" />
@ -145,7 +146,10 @@
<button type="button" aria-label="Edit Favourites" class="btn btn-warning bmd-btn-icon" id="edit-favourites" data-toggle="tooltip" title="Edit favourites">
<i class="material-icons" aria-hidden="true">star</i>
</button>
<div tabindex="0" id="content-wrapper">
<div tabindex="0" id="content-wrapper" class="cyberchef-root" data-layout="desktop" data-mobile-pane="recipe" data-data-segment="input">
<div id="ops-overlay" hidden aria-hidden="true">
<div id="ops-scrim"></div>
</div>
<div id="banner" class="row">
<div class="col" style="text-align: left; padding-left: 10px;">
<a href="#" data-toggle="modal" data-target="#download-modal" data-help-title="Downloading CyberChef" data-help="<p>CyberChef can be downloaded to run locally or hosted within your own network. It has no server-side component so all that is required is that the ZIP file is uncompressed and the files are accessible.</p><p>As a user, it is worth noting that unofficial versions of CyberChef could have been modified to introduce Input and/or Recipe exfiltration. We recommend always using the official, open source, up-to-date version of CyberChef hosted at <a href='https://gchq.github.io/CyberChef'>https://gchq.github.io/CyberChef</a> if accessible.</p><p>The Network tab in your browser's Developer console (F12) can be used to inspect the network requests made by a website. This can confirm that no data is uploaded when a CyberChef recipe is baked.</p>">Download CyberChef <i class="material-icons">file_download</i></a>
@ -163,12 +167,26 @@
</span>
</div>
<div class="col" style="text-align: right; padding-right: 0;">
<button type="button" id="tablet-ops-toggle" class="btn btn-primary btn-sm tablet-only" aria-label="Open operations" aria-expanded="false" aria-controls="operations">
<i class="material-icons" aria-hidden="true">menu</i> Ops
</button>
<a href="#" id="options" data-help-title="Options and Settings" data-help="Configurable options to change how CyberChef behaves. These settings are stored in your browser's local storage, meaning they will persist between sessions that use the same browser profile.">Options <i class="material-icons">settings</i></a>
<a href="#" id="support" data-toggle="modal" data-target="#support-modal" data-help-title="About / Support" data-help="This pane provides information about the CyberChef web app, how to use some of the features, and how to raise bug reports.">About / Support <i class="material-icons">help</i></a>
</div>
</div>
<div id="workspace-wrapper">
<div id="operations" class="split split-horizontal no-select">
<div id="operations" class="split split-horizontal no-select" role="dialog" aria-label="Operations">
<div id="ops-sheet-header" class="mobile-only responsive-chrome">
<div style="width:100%;">
<div class="ops-handle" aria-hidden="true"></div>
<div style="display:flex;align-items:center;justify-content:space-between;">
<strong>Operations</strong>
<button type="button" id="ops-close" class="btn btn-primary bmd-btn-icon" aria-label="Close operations">
<i class="material-icons" aria-hidden="true">close</i>
</button>
</div>
</div>
</div>
<div class="title no-select" data-help-title="Operations list" data-help="<p>The Operations list contains all the operations in CyberChef arranged into categories. Some operations may be present in multiple categories. You can search for operations using the search box.</p><p>To use an operation, either double click it, or drag it into the Recipe pane. You will then be able to configure its arguments (or 'Ingredients' in CyberChef terminology).</p>">
Operations
<span class="op-count"></span>
@ -222,6 +240,10 @@
</div>
<div class="split split-horizontal" id="IO">
<div id="data-segment-bar" class="mobile-only" role="tablist" aria-label="Input and output">
<button type="button" id="seg-input" role="tab" aria-selected="true" class="active">Input</button>
<button type="button" id="seg-output" role="tab" aria-selected="false">Output</button>
</div>
<div id="input" class="split no-select" data-help-title="Input pane" data-help="<p>Input data can be entered by typing it in, pasting it in, dragging it in, or using the 'Load file' or 'Load folder' buttons.</p><p>CyberChef does its best to represent data as accurately as possible to ensure you know exactly what you are working with. Non-printable characters are represented using control character pictures, for example a null byte (0x00) is displayed like this: <span title='Control character null' aria-label='Control character null' class='cm-specialChar'>␀</span>.</p>">
<div class="title no-select">
<label for="input-text">Input</label>
@ -341,6 +363,19 @@
</div>
</div>
</div>
<nav id="mobile-nav" class="mobile-only" role="tablist" aria-label="Main navigation">
<button type="button" id="nav-recipe" role="tab" aria-selected="true" class="active" data-pane="recipe">
<i class="material-icons" aria-hidden="true">list_alt</i>
Recipe
</button>
<button type="button" id="nav-ops-fab" aria-label="Add operation" aria-expanded="false" aria-controls="operations">
<i class="material-icons" aria-hidden="true">add</i>
</button>
<button type="button" id="nav-data" role="tab" aria-selected="false" data-pane="data">
<i class="material-icons" aria-hidden="true">swap_horiz</i>
Data
</button>
</nav>
</div>
<div class="modal fade" id="save-modal" tabindex="-1" role="dialog">

View File

@ -34,6 +34,7 @@
@import "./layout/_operations.css";
@import "./layout/_recipe.css";
@import "./layout/_structure.css";
@import "./layout/_responsive.css";
/* Operations */
@import "./operations/diff.css";

View File

@ -0,0 +1,382 @@
/**
* Responsive phone / tablet layout for CyberChef.
* Driven by [data-layout] on #content-wrapper (set via ResizeObserver).
*
* @author blepman
* @license Apache-2.0
*/
/* ---- Chrome defaults (hidden on desktop) ---- */
.responsive-chrome,
.mobile-only,
.tablet-only,
.responsive-only {
display: none !important;
}
#ops-overlay {
display: none;
}
/* ---- Shared non-desktop ---- */
[data-layout="mobile"] .responsive-only,
[data-layout="tablet"] .responsive-only {
display: inline-flex !important;
}
[data-layout="mobile"] .gutter,
[data-layout="tablet"] .gutter {
display: none !important;
}
[data-layout="mobile"] #workspace-wrapper,
[data-layout="tablet"] #workspace-wrapper {
display: flex;
flex-direction: row;
overflow: hidden;
}
[data-layout="mobile"] #operations.split,
[data-layout="mobile"] #recipe.split,
[data-layout="mobile"] #IO.split,
[data-layout="tablet"] #operations.split,
[data-layout="tablet"] #recipe.split,
[data-layout="tablet"] #IO.split {
float: none;
width: auto;
height: auto;
}
[data-layout="mobile"] #banner .col:first-child,
[data-layout="tablet"] #banner .col:first-child {
display: none;
}
[data-layout="mobile"] #banner,
[data-layout="tablet"] #banner {
min-height: 40px;
align-items: center;
}
[data-layout="mobile"] #workspace-wrapper,
[data-layout="tablet"] #workspace-wrapper {
top: 40px;
}
/* Reorder / touch affordances */
.op-reorder-up,
.op-reorder-down {
cursor: pointer;
font-size: 20px !important;
margin-left: 2px;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.op-list li.operation {
position: relative;
}
/* ---- Tablet (7681023) ---- */
[data-layout="tablet"] .tablet-only {
display: inline-flex !important;
}
[data-layout="tablet"] #recipe {
flex: 1 1 45%;
min-width: 0;
height: 100%;
border-right: 1px solid var(--primary-border-colour);
}
[data-layout="tablet"] #IO {
flex: 1 1 55%;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
[data-layout="tablet"] #IO #input,
[data-layout="tablet"] #IO #output {
flex: 1 1 50%;
width: 100%;
height: 50%;
min-height: 0;
}
[data-layout="tablet"] #operations {
position: fixed;
top: 40px;
left: 0;
bottom: 0;
width: min(360px, 90%);
z-index: 1200;
background: var(--primary-background-colour);
border-right: 1px solid var(--primary-border-colour);
transform: translateX(-105%);
transition: transform 0.2s ease;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.2);
}
[data-layout="tablet"].ops-open #operations,
body[data-layout="tablet"] #content-wrapper.ops-open #operations {
transform: translateX(0);
}
[data-layout="tablet"] #ops-overlay:not([hidden]) {
display: block;
position: fixed;
inset: 40px 0 0 0;
z-index: 1100;
}
[data-layout="tablet"] #ops-scrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
}
[data-layout="tablet"] #tablet-ops-toggle {
display: inline-flex !important;
align-items: center;
gap: 4px;
margin-right: 8px;
min-height: 44px;
padding: 0 12px;
}
[data-layout="tablet"] #data-segment-bar,
[data-layout="tablet"] #mobile-nav {
display: none !important;
}
/* ---- Mobile (<768) ---- */
[data-layout="mobile"] .mobile-only {
display: flex !important;
}
[data-layout="mobile"] #workspace-wrapper {
flex-direction: column;
bottom: 56px;
}
[data-layout="mobile"] #recipe,
[data-layout="mobile"] #IO {
flex: 1 1 auto;
width: 100%;
min-height: 0;
height: 100%;
display: none;
flex-direction: column;
}
[data-layout="mobile"][data-mobile-pane="recipe"] #recipe,
#content-wrapper[data-layout="mobile"][data-mobile-pane="recipe"] #recipe {
display: flex;
}
[data-layout="mobile"][data-mobile-pane="data"] #IO,
#content-wrapper[data-layout="mobile"][data-mobile-pane="data"] #IO {
display: flex;
}
[data-layout="mobile"] #IO #input,
[data-layout="mobile"] #IO #output {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
display: none;
flex-direction: column;
}
[data-layout="mobile"][data-data-segment="input"] #IO #input,
#content-wrapper[data-layout="mobile"][data-data-segment="input"] #IO #input {
display: flex;
}
[data-layout="mobile"][data-data-segment="output"] #IO #output,
#content-wrapper[data-layout="mobile"][data-data-segment="output"] #IO #output {
display: flex;
}
[data-layout="mobile"] #data-segment-bar {
display: flex !important;
flex: 0 0 auto;
gap: 0;
border-bottom: 1px solid var(--primary-border-colour);
background: var(--secondary-background-colour);
}
[data-layout="mobile"] #data-segment-bar button {
flex: 1;
min-height: 44px;
border: 0;
background: transparent;
color: var(--primary-font-colour);
font-weight: 600;
cursor: pointer;
}
[data-layout="mobile"] #data-segment-bar button.active {
border-bottom: 3px solid var(--primary-font-colour);
background: var(--primary-background-colour);
}
[data-layout="mobile"][data-mobile-pane="recipe"] #data-segment-bar {
display: none !important;
}
/* Sticky Bake bar sits above bottom nav */
[data-layout="mobile"] #controls,
[data-layout="mobile"] #controls.mobile-controls-bar {
position: fixed;
left: 0;
right: 0;
bottom: 56px;
z-index: 900;
padding: 8px 4px;
width: 100%;
}
[data-layout="mobile"] #rec-list {
padding-bottom: 88px;
}
[data-layout="mobile"] #IO #input-wrapper,
[data-layout="mobile"] #IO #output-wrapper {
padding-bottom: 88px;
}
[data-layout="mobile"] #operations {
position: fixed;
left: 0;
right: 0;
bottom: 0;
top: auto;
height: 85dvh;
max-height: 85vh;
z-index: 1300;
background: var(--primary-background-colour);
border-radius: 12px 12px 0 0;
transform: translateY(105%);
transition: transform 0.25s ease;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.25);
width: 100%;
}
[data-layout="mobile"].ops-open #operations,
#content-wrapper[data-layout="mobile"].ops-open #operations {
transform: translateY(0);
}
[data-layout="mobile"] #ops-overlay:not([hidden]) {
display: block;
position: fixed;
inset: 0;
z-index: 1200;
}
[data-layout="mobile"] #ops-scrim {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
[data-layout="mobile"] #ops-sheet-header {
display: flex !important;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--primary-border-colour);
min-height: 48px;
}
[data-layout="mobile"] #ops-sheet-header .ops-handle {
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--secondary-border-colour);
margin: 4px auto 8px;
}
[data-layout="mobile"] #mobile-nav {
display: flex !important;
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 56px;
z-index: 1000;
background: var(--secondary-background-colour);
border-top: 1px solid var(--primary-border-colour);
align-items: stretch;
justify-content: space-around;
padding-bottom: env(safe-area-inset-bottom, 0);
}
[data-layout="mobile"] #mobile-nav button {
flex: 1;
border: 0;
background: transparent;
color: var(--primary-font-colour);
font-size: 12px;
font-weight: 600;
cursor: pointer;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
}
[data-layout="mobile"] #mobile-nav button.active {
color: var(--input-highlight-colour, #4caf50);
}
[data-layout="mobile"] #mobile-nav .material-icons {
font-size: 22px;
}
[data-layout="mobile"] #nav-ops-fab {
flex: 0 0 56px;
width: 56px;
height: 56px;
margin-top: -12px;
border-radius: 50%;
background: var(--primary-font-colour);
color: var(--primary-background-colour);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
[data-layout="mobile"] #nav-ops-fab .material-icons {
font-size: 28px;
}
[data-layout="mobile"] #tablet-ops-toggle {
display: none !important;
}
@media (prefers-reduced-motion: reduce) {
[data-layout="mobile"] #operations,
[data-layout="tablet"] #operations {
transition: none;
}
}
/* Fallback when container queries are unsupported: mirror via body attribute already set */
@supports (container-type: inline-size) {
#content-wrapper.cyberchef-root {
container-type: inline-size;
container-name: cyberchef;
}
}

View File

@ -229,9 +229,46 @@ class OperationsWaiter {
* @param {event} e
*/
operationDblclick(e) {
const li = e.target;
const li = e.target.closest("li.operation");
if (!li) return;
this.manager.recipe.addOperation(li.textContent);
this.manager.recipe.addOperation(this.operationNameFromListItem(li));
}
/**
* Tap-to-add on phone/tablet layouts (single click).
* Desktop keeps double-click / drag-and-drop.
*
* @param {event} e
*/
operationClick(e) {
if (!this.manager.responsive || !this.manager.responsive.isTouchLayout()) {
return;
}
// Ignore clicks on remove icons (favourites editor)
if (e.target.closest(".remove-icon")) return;
const li = e.target.closest("li.operation");
if (!li || !li.closest(".op-list")) return;
e.preventDefault();
this.manager.recipe.addOperation(this.operationNameFromListItem(li));
this.manager.responsive.afterOperationAdded();
}
/**
* Extract operation name from a list item without icon ligature text.
*
* @param {Element} li
* @returns {string}
*/
operationNameFromListItem(li) {
const clone = li.cloneNode(true);
clone.querySelectorAll("i").forEach(icon => icon.remove());
return clone.textContent.trim();
}

View File

@ -1531,6 +1531,13 @@ class OutputWaiter {
* Resizes the output frame to be as large as possible, or restores it to its original size.
*/
maximiseOutputClick(e) {
if (this.manager.responsive && this.manager.responsive.isTouchLayout()) {
// On phone/tablet, switch to the Data/Output view instead of Split.js maximise.
this.manager.responsive.showMobilePane("data");
this.manager.responsive.showDataSegment("output");
return;
}
const el = e.target.id === "maximise-output" ? e.target : e.target.parentNode;
if (el.getAttribute("data-original-title").indexOf("Maximise") === 0) {

View File

@ -463,6 +463,36 @@ class RecipeWaiter {
}
/**
* Move a recipe operation one step up (touch layout affordance).
*
* @param {event} e
*/
moveOpUp(e) {
e.preventDefault();
e.stopPropagation();
const li = e.target.closest("li.operation");
if (!li || !li.previousElementSibling) return;
li.parentNode.insertBefore(li, li.previousElementSibling);
window.dispatchEvent(this.manager.statechange);
}
/**
* Move a recipe operation one step down (touch layout affordance).
*
* @param {event} e
*/
moveOpDown(e) {
e.preventDefault();
e.stopPropagation();
const li = e.target.closest("li.operation");
if (!li || !li.nextElementSibling) return;
li.parentNode.insertBefore(li.nextElementSibling, li);
window.dispatchEvent(this.manager.statechange);
}
/**
* Handler for operation dropdown events from toggleString arguments.
* Sets the selected option as the name of the button.

View File

@ -0,0 +1,283 @@
/**
* Responsive phone / tablet layout for CyberChef.
* Uses container width (ResizeObserver) so iframe embeds behave correctly.
*
* @author blepman
* @license Apache-2.0
*/
const BREAKPOINT_MOBILE = 768;
const BREAKPOINT_TABLET = 1024;
/**
* Manages layout modes: mobile | tablet | desktop.
*/
class ResponsiveLayoutWaiter {
/**
* @param {App} app
* @param {Manager} manager
*/
constructor(app, manager) {
this.app = app;
this.manager = manager;
this.root = null;
this.observer = null;
this.currentMode = null;
this.mobilePane = "recipe"; // recipe | data
this.dataSegment = "input"; // input | output
this.opsOpen = false;
}
/**
* Initialise observer and chrome after DOM is ready.
*/
setup() {
this.root = document.getElementById("content-wrapper");
if (!this.root) return;
this.root.classList.add("cyberchef-root");
this.root.style.containerType = "inline-size";
this.root.style.containerName = "cyberchef";
this.bindChrome();
if (typeof ResizeObserver !== "undefined") {
this.observer = new ResizeObserver(entries => {
const width = entries[0].contentRect.width;
this.applyMode(this.modeFromWidth(width));
});
this.observer.observe(this.root);
this.applyMode(this.modeFromWidth(this.root.getBoundingClientRect().width));
} else {
const onResize = () => {
this.applyMode(this.modeFromWidth(window.innerWidth));
};
window.addEventListener("resize", onResize);
onResize();
}
}
/**
* @param {number} width
* @returns {"mobile"|"tablet"|"desktop"}
*/
modeFromWidth(width) {
if (width < BREAKPOINT_MOBILE) return "mobile";
if (width < BREAKPOINT_TABLET) return "tablet";
return "desktop";
}
/**
* Wire mobile/tablet chrome controls.
*/
bindChrome() {
const navRecipe = document.getElementById("nav-recipe");
const navData = document.getElementById("nav-data");
const navOps = document.getElementById("nav-ops-fab");
const opsClose = document.getElementById("ops-close");
const opsScrim = document.getElementById("ops-scrim");
const segInput = document.getElementById("seg-input");
const segOutput = document.getElementById("seg-output");
if (navRecipe) {
navRecipe.addEventListener("click", () => this.showMobilePane("recipe"));
}
if (navData) {
navData.addEventListener("click", () => this.showMobilePane("data"));
}
if (navOps) {
navOps.addEventListener("click", () => this.toggleOps(true));
}
if (opsClose) {
opsClose.addEventListener("click", () => this.toggleOps(false));
}
if (opsScrim) {
opsScrim.addEventListener("click", () => this.toggleOps(false));
}
if (segInput) {
segInput.addEventListener("click", () => this.showDataSegment("input"));
}
if (segOutput) {
segOutput.addEventListener("click", () => this.showDataSegment("output"));
}
document.addEventListener("keydown", e => {
if (e.key === "Escape" && this.opsOpen) {
this.toggleOps(false);
}
});
const tabletOps = document.getElementById("tablet-ops-toggle");
if (tabletOps) {
tabletOps.addEventListener("click", () => this.toggleOps(!this.opsOpen));
}
}
/**
* Switch layout mode and manage Split.js.
*
* @param {"mobile"|"tablet"|"desktop"} mode
*/
applyMode(mode) {
if (mode === this.currentMode || !this.root) return;
const prev = this.currentMode;
this.currentMode = mode;
this.root.dataset.layout = mode;
document.body.dataset.layout = mode;
this.placeControls(mode);
if (mode === "desktop") {
this.toggleOps(false);
if (prev && prev !== "desktop") {
this.app.initialiseSplitter();
}
} else {
this.app.destroySplitters();
if (mode === "mobile") {
this.showMobilePane(this.mobilePane);
this.showDataSegment(this.dataSegment);
} else {
this.showDataSegment("both");
}
}
this.app.adjustComponentSizes();
}
/**
* On mobile the Bake bar must stay visible across panes, so move #controls
* out of #recipe (which is display:none when Data is active).
*
* @param {"mobile"|"tablet"|"desktop"} mode
*/
placeControls(mode) {
const controls = document.getElementById("controls");
const recipe = document.getElementById("recipe");
const workspace = document.getElementById("workspace-wrapper");
if (!controls || !recipe || !workspace) return;
if (mode === "mobile") {
if (controls.parentElement !== workspace) {
workspace.appendChild(controls);
}
controls.classList.add("mobile-controls-bar");
} else if (controls.parentElement !== recipe) {
recipe.appendChild(controls);
controls.classList.remove("mobile-controls-bar");
}
}
/**
* @param {"recipe"|"data"} pane
*/
showMobilePane(pane) {
this.mobilePane = pane;
if (this.root) {
this.root.dataset.mobilePane = pane;
}
const navRecipe = document.getElementById("nav-recipe");
const navData = document.getElementById("nav-data");
if (navRecipe) {
navRecipe.classList.toggle("active", pane === "recipe");
navRecipe.setAttribute("aria-selected", pane === "recipe" ? "true" : "false");
}
if (navData) {
navData.classList.toggle("active", pane === "data");
navData.setAttribute("aria-selected", pane === "data" ? "true" : "false");
}
this.app.adjustComponentSizes();
}
/**
* @param {"input"|"output"|"both"} segment
*/
showDataSegment(segment) {
this.dataSegment = segment === "both" ? "input" : segment;
if (this.root) {
this.root.dataset.dataSegment = segment;
}
const segInput = document.getElementById("seg-input");
const segOutput = document.getElementById("seg-output");
if (segInput && segment !== "both") {
segInput.classList.toggle("active", segment === "input");
segInput.setAttribute("aria-selected", segment === "input" ? "true" : "false");
}
if (segOutput && segment !== "both") {
segOutput.classList.toggle("active", segment === "output");
segOutput.setAttribute("aria-selected", segment === "output" ? "true" : "false");
}
this.app.adjustComponentSizes();
}
/**
* Open / close operations sheet (mobile) or drawer (tablet).
*
* @param {boolean} open
*/
toggleOps(open) {
this.opsOpen = open;
if (this.root) {
this.root.classList.toggle("ops-open", open);
}
const overlay = document.getElementById("ops-overlay");
if (overlay) {
overlay.hidden = !open;
overlay.setAttribute("aria-hidden", open ? "false" : "true");
}
const ops = document.getElementById("operations");
if (ops) {
ops.setAttribute("aria-hidden", open || this.currentMode === "desktop" ? "false" : "true");
if (open) {
const search = document.getElementById("search");
if (search) search.focus();
}
}
const fab = document.getElementById("nav-ops-fab");
if (fab) {
fab.setAttribute("aria-expanded", open ? "true" : "false");
}
const tabletOps = document.getElementById("tablet-ops-toggle");
if (tabletOps) {
tabletOps.setAttribute("aria-expanded", open ? "true" : "false");
}
}
/**
* After adding an operation from the list, keep sheet open for multi-add.
*/
afterOperationAdded() {
if (this.currentMode === "mobile") {
this.showMobilePane("recipe");
}
}
/**
* @returns {boolean}
*/
isTouchLayout() {
return this.currentMode === "mobile" || this.currentMode === "tablet";
}
}
export default ResponsiveLayoutWaiter;