Commmerce Editor
Editor settings
Per-user, per-tab workspace state for the editor application itself — which device the canvas is rendering, whether you are in drag-drop or code mode, how smart-guide snapping behaves, sidebar collapse and active panel, zoom, interaction-mode mutex, dialog flags, the QuickEditPanel surface, and the user-wide panel-mode preference. None of these settings ever ship to the live site or get saved into the theme payload — they belong to you, the editor user, not the theme.
Overview
Editor settings cover the editor as an application: what mode it is in, which device frame is active, whether snap guides are on, whether the left or right sidebar is collapsed, the current zoom level, the panel-mode preference (docked vs floating QuickEditPanel), per-tab IndexedDB scope, and so on. They move with the user and the tab, never with the published theme.
This is distinct from Global settings (theme-wide branding, colour, typography, fonts/colors/buttons registries, product display defaults, global SEO, global code, breakpoint definitions — all server-persisted, all part of the theme), and from Configuration (breakpoint definitions and the responsive ladder). If you came here looking for "where do I configure the brand logo / fonts / colour tokens?", you want Global settings, not this page.
Three stores back this page:
useCommmerceEditorSetupStore— main editor UI state, persisted tosessionStoragewith transient fields stripped on rehydrate.useEditorPreferencesStore— user-wide preferences (panel mode, quick-edit trigger), persisted tolocalStorageso they survive across tabs and across browser restarts.useGlobalSettingsUIStore— transient UI state for the Global Settings panel itself (which inline section is drilled in). In memory only; never persisted.
Three storage layers also come into play: sessionStorage for the setup store, localStorage for editor preferences, and IndexedDB for the editor's content store (useCommmerceEditor) — keyed per (dev-app | store-theme | block) so two tabs editing different blocks each get their own slot.
How it works
Setup store — useCommmerceEditorSetupStore (common/components/commmerce-editor/store/useCommmerceEditorSetupStore.ts) is the largest of the three. It is wrapped in Zustand's persist middleware with createJSONStorage(() => sessionStorage) and the storage key commmerce-editor-setup-store. A partialize strips four fields from the slice before write so a reload never re-seeds them: isInitializing, initializingMessage, placementMode, and quickEditElementId.
The store is consumed everywhere — the control bar (control/index.tsx) calls toggleDeviceMode and toggleEditorMode; the drag-drop hooks (drag-drop/hooks/useSnapping.ts) read snapSettings; the canvas wrapper reads zoom and layoutSettings; the bottom toolbar (control/placement/usePlacementMode.ts) reads and writes placementMode.
Editor preferences store — useEditorPreferencesStore (common/store/useEditorPreferencesStore.ts) is a small user-wide store with a tighter scope: only the two fields that should outlive a tab (the chosen panel surface and the gesture that summons the floating panel). It persists to localStorage under the key editor-preferences-store so opening a second tab inherits the same preference.
Global Settings UI store — useGlobalSettingsUIStore (common/components/commmerce-editor/settings/useGlobalSettingsUIStore.ts) holds purely transient drilldown state for the Global Settings panel — which inline section is open, which sub-view inside Fonts / Colors / Buttons is active, which "wide" tab the floating SettingsExpandedPanel is hosting. It is in memory only; resetUIState() is called whenever the sidebar swaps back to the Element Inspector so we don't re-enter a stale drilled-in state.
The QuickEditPanel position is persisted separately by useDraggablePanel (element-editor/useDraggablePanel.ts) under the sessionStorage key commmerce-editor:quick-edit-panel:position, so once the user drags the panel its spot follows them through selection changes and panel remount cycles within the same tab.
Editor preferences reference
User-wide preferences from useEditorPreferencesStore. Persisted to localStorage so two tabs share the same value and the choice survives a browser restart.
panelMode 'right-sidebar' | 'quick-edit' 'right-sidebar' Where element-level controls surface. 'right-sidebar' shows the classic docked property panel on the right; 'quick-edit' hides the right sidebar by default and surfaces a floating QuickEditPanel anchored next to the focused element. Use case: power users on wide monitors prefer 'right-sidebar' for permanent visibility; designers working on laptops switch to 'quick-edit' to keep more canvas visible.quickEditTrigger 'right-click' | 'double-click' 'right-click' Which canvas gesture summons the floating QuickEditPanel. Only meaningful when panelMode === 'quick-edit'. Use case: keep 'right-click' if you prefer single-click for select + context-menu-style summon; switch to 'double-click' if you frequently need the native right-click menu and don't want it shadowed.Setup store reference
UI fields on useCommmerceEditorSetupStore that are part of the editor application's per-tab state. Persisted to sessionStorage under commmerce-editor-setup-store, with the transient fields stripped by partialize.
Canvas & modes
deviceMode BreakpointId 'xl' Active canvas breakpoint id. One of the four defaults (xl, desktop, tablet, mobile) or a custom custom_N id defined in useBreakpointStore. Changes the canvas frame width and determines which responsiveStyles slice the right sidebar reads from and writes to. Use case: switching to mobile lets every style edit target the mobile media query only, leaving desktop styles untouched.editorMode 'drag-drop' | 'code' 'drag-drop' Top-level mode toggle. 'drag-drop' shows the visual canvas; 'code' swaps in the Monaco-based code editor (dev/index.tsx) with the canvas DOM injected into an iframe for live inspection. Use case: drop into 'code' to paste a pre-built block, then flip back to 'drag-drop' to fine-tune visually.interactionMode 'select' | 'pan' | 'marquee' | 'resize' | 'drag' | 'idle' 'idle' Pointer-gesture mutex. Set by the interaction that currently owns the pointer; cleared on release. The drag-drop hooks check this to avoid event conflicts (e.g. don't start a marquee when a resize is in progress). Use case: rarely written by user code — it is owned by the drag-drop subsystem.zoom number 1 Canvas zoom multiplier (1 = 100%). Used by useCoordinateTransform to translate screen-space pointer coordinates into canvas-space during resize / drag. Use case: zoom out to lay out long pages; zoom in to fine-position a small element.Layout settings (per breakpoint)
layoutSettings is a Record<string, DeviceLayoutConfig> keyed by BreakpointId. Default shape:
{
xl: { mode: 'full-width', containedWidth: 240 },
desktop: { mode: 'full-width', containedWidth: 100 },
tablet: { mode: 'full-width', containedWidth: 50 },
mobile: { mode: 'full-width', containedWidth: 20 }
}These are the editor canvas frame values, distinct from the containedWidth on each breakpoint in settings.breakpoints (which drives the published theme's responsive output). Per leaf field:
layoutSettings[bp].mode 'full-width' | 'contained' 'full-width' Canvas framing for this breakpoint. 'full-width' renders sections edge-to-edge; 'contained' applies containedWidth as a per-side margin so insert handles and sticky elements have room to live outside the content column. Use case: switch desktop to 'contained' while wireframing a centered hero so guides snap cleanly.layoutSettings[bp].containedWidth number 240 / 100 / 50 / 20 (xl / desktop / tablet / mobile) Side padding in px when this breakpoint is in 'contained' mode. Not a max-width — it is the gap on each side of the canvas. Use case: bump mobile from 20 to 40 when previewing a layout that needs more thumb-reach margin.Snap settings (smart guides)
snapSettings is consumed by the drag-drop subsystem (drag-drop/hooks/useSnapping.ts). The bottom toolbar exposes toggleSnap() as a one-click on/off; fine-grained tweaks go through setSnapSettings(partial).
snapSettings.enabled boolean true Master toggle. When false, drag and resize stop drawing alignment guides and skip the snap math entirely. Use case: disable when free-form placing decorative shapes that shouldn't align to a grid.snapSettings.threshold number 5 Snap pull radius in px. How close a moving edge must be to a guide line before it locks. Use case: widen to 8–10 for a more forgiving snap when laying out cards; narrow to 2 for precise pixel-pushing.snapSettings.showDistances boolean true Live distance labels. When true, the drag overlay shows the px distance between the dragged element and its nearest neighbour. Use case: turn off to declutter the canvas when guides alone are enough.snapSettings.snapToElements boolean true Element-to-element guides. Computes guides against sibling edges, centers, and midpoints. Use case: disable when each element should be free-positioned independent of siblings (e.g. hotspots over a flat image).snapSettings.snapToContainer boolean true Container-edge guides. Computes guides against the parent container's edges and center. Use case: disable for nested layouts where every parent has its own padding and container snap is noisy.Sidebars
leftSidebarCollapse boolean false Collapse / expand the left sidebar (Layers / Elements / Blocks). Use case: collapse to maximise canvas width when doing big-picture layout work.leftSidebarStatus LeftSidebarKeys ('layers' | 'elements' | 'blocks') 'layers' Which left-sidebar tab is active. Use case: 'layers' when navigating the document tree; 'elements' when dragging in primitives; 'blocks' when assembling from saved blocks.rightSidebarCollapse boolean true Collapse / expand the right sidebar (Element Inspector / Global Settings). Starts collapsed so the canvas gets full width on first load. Use case: paired with panelMode: 'quick-edit' for a near-fullscreen canvas.Transient state (not persisted)
Four fields are explicitly stripped by partialize before sessionStorage serialises the slice. They are present in the type and rendered at runtime, but never rehydrate from storage on reload — they always start at their default. Treat them as session-scoped UI flags, not preferences.
placementMode { kind: 'text' | 'box' | 'image'; tag: string; label: string } | null null Bottom-toolbar click-to-place state. When set, the canvas shows a crosshair cursor and the next canvas click inserts a new element of the chosen tag. Cleared on Escape, deselect, or successful placement. Use case: insert a Text element exactly where you click instead of dragging from the Elements panel.quickEditElementId string | null null QuickEditPanel gate. Only the element whose id matches will render its QuickEditPanel. Set on the user's chosen trigger (right-click or double-click per quickEditTrigger), cleared on single-click of a different element, deselect, or Escape. Use case: keeps selection (single click) decoupled from the panel (chosen trigger).isInitializing boolean true Editor-shell loading gate. True while the DevEditor / Canvas is still fetching block + version data, auto-creating a new draft, or otherwise not ready to interact. Consumers render a full-canvas skeleton overlay until it flips to false. Use case: shown automatically — set via setInitializing(value, message?).initializingMessage string | null null Optional user-facing line shown on the initialisation skeleton, e.g. "Auto-creating draft version…". Cleared whenever isInitializing goes back to false.Other fields
activeHotspotIndex number | null null Hotspot focus. For <mmm-hotspot> elements, the index of the hotspot marker currently focused for editing. Shared so the QuickEditPanel and the canvas overlay stay in sync. Cleared on selection change.isReadOnly boolean false Locks the editor when the active version is published. All write actions go through a guard and silently no-op. Use case: previewing a published version without risking edits.appSlug string | null null Dev Portal preview only — the app_slug used to build the preview URL.appName string | null null Dev Portal display only — the app_name shown alongside the version label in the topbar.Modal / dialog flags
The setup store also tracks open / close flags for editor dialogs so consumers can read whether a dialog owns the screen without prop-drilling. Persisted (so a refresh reopens the dialog if it was open), but the payload pointers default to safe values.
isContainerOpen boolean false Container dialog (insert wrapper element). targetNodeInfo stores the target node id and side ('n' | 'e' | 's' | 'w') to insert against.isUploadMediaOpen boolean false Media Library upload dialog. selectedElementForMedia holds the target element id; backgroundImageCallback is set when the dialog was opened to set a background image rather than a foreground media.isCreateBlockOpen boolean false Create Block dialog. selectedNodesForBlock holds the node ids to package into the new reusable block.isGridCollisionModalOpen boolean false Grid collision resolution dialog. gridCollisionData holds the colliding cells; gridCollisionPreference stores the user's chosen strategy ('swap' | 'push-down' | 'push-right' | 'insert-row' | 'insert-column') so subsequent collisions in the same session can skip the dialog.Per-tab editor scope
The editor's content store (useCommmerceEditor) is persisted to IndexedDB, not sessionStorage, and the IndexedDB key is derived from the URL so two tabs editing different blocks each get their own slot. Three files in common/components/commmerce-editor/store/storage/ implement this:
deriveScope.ts pure helper — deriveEditorScope(params) builds the scope key from URL params. Two shapes: { kind: 'dev', app_id, app_version_code, block_id, block_version_code } or { kind: 'store', store_code, store_theme_id, store_theme_version_code, block_id, block_version_code }. Missing params fall back to '_'.editorScope.ts module-level singleton 'commmerce-editor:unscoped' getEditorScope() / setEditorScope(scope). The default value is intentionally distinctive — if you ever see commmerce-editor:unscoped as an IndexedDB key, an editor shell mounted without calling setEditorScope() first.indexedDBStorage.ts Zustand storage adapter — Implements getItem / setItem / removeItem against idb-keyval, intentionally ignoring Zustand's name argument and reading getEditorScope() as the actual key. That is what makes two tabs use different slots even though the Zustand store has a single name.Two example keys, one per project:
/* Dev Portal tab editing app version 12, block 47 v3 */
commmerce-editor:dev:123:12:47:3
/* Store Editor tab editing store_theme 88 v2, block 47 v3 */
commmerce-editor:store:my-store:88:2:47:3DevEditor, StoreEditor and ThemeAwareEditor all call setEditorScope() before persist.rehydrate() runs, so the very first read goes to the right IndexedDB key. The setup store (useCommmerceEditorSetupStore) does not participate in this scope — it lives in sessionStorage under a single fixed key, because device mode and snap preferences should be per tab, not per block.
The QuickEditPanel position has its own non-scoped key (commmerce-editor:quick-edit-panel:position) on sessionStorage, written by useDraggablePanel.persistPosition. It is intentionally shared across all panels in the tab so the panel always reappears where you last dragged it, regardless of which element is now selected.
Workflows
1. Switch from docked sidebar to floating QuickEditPanel
- Open the editor preferences UI (or, in code, grab the store directly).
- Call
setPanelMode('quick-edit'). The store writes tolocalStorageundereditor-preferences-store, so the next tab inherits it. - The right sidebar collapses; selecting an element no longer opens the docked inspector. Instead, the chosen
quickEditTriggergesture (right-click by default) summons the floating panel anchored next to the element. - If right-click conflicts with another workflow, also call
setQuickEditTrigger('double-click').
2. Tune snap for precision pixel-pushing
- Open the bottom toolbar's smart-guide controls.
- Click the magnet to leave snap on (
snapSettings.enabled === true). - Call
setSnapSettings({ threshold: 2, snapToContainer: false })— guides still draw for sibling elements but the snap radius is tight enough to do per-pixel adjustments. - When done, restore with
setSnapSettings({ threshold: 5, snapToContainer: true })— the change persists tosessionStoragewith the rest of the setup store.
3. Set a default device for a session
- Click any device icon in the top-of-canvas device switcher (
mobile, say). toggleDeviceMode('mobile')writes the newBreakpointIdinto the setup store.- The canvas resizes to the breakpoint's
canvasWidth; style writes from the right sidebar now target theresponsiveStyles.mobileslot. - Reload the tab —
sessionStoragerehydrates, the canvas comes back up onmobile. Open a new tab — that tab boots fresh at the default'xl'becausesessionStorageis per-tab.
4. Enter placement mode from the bottom toolbar
- Click Text, Box, or Image in the bottom toolbar —
setPlacementMode({ kind, tag, label })writes the transient placement state. - The canvas cursor switches to a crosshair and a floating "Placing X" pill follows the pointer.
- Click anywhere on the canvas to insert a new element of that tag. The setup store clears
placementModeback tonullon successful insert or Escape — and becauseplacementModeis in the transient set, reloading the tab also clears it (no crosshair ghosts on boot).
5. Open a near-fullscreen canvas
- Click the left-sidebar collapse handle — fires
setLeftSidebarCollapse(), flippingleftSidebarCollapsetotrue. - Click the right-sidebar collapse handle —
setRightSidebarCollapse()flipsrightSidebarCollapsetotrue(the right sidebar starts collapsed by default anyway). - For an even cleaner surface, set
panelMode: 'quick-edit'so element-level controls only appear when summoned. - Reload — both collapse states persist in
sessionStorage;panelModepersists inlocalStorage.
Examples
Read editor state from a component
import { useCommmerceEditorSetupStore } from
'common/components/commmerce-editor/store/useCommmerceEditorSetupStore';
import { useEditorPreferencesStore } from 'common/store/useEditorPreferencesStore';
const deviceMode = useCommmerceEditorSetupStore((s) => s.deviceMode);
const snapEnabled = useCommmerceEditorSetupStore((s) => s.snapSettings.enabled);
const zoom = useCommmerceEditorSetupStore((s) => s.zoom);
const panelMode = useEditorPreferencesStore((s) => s.panelMode);
const trigger = useEditorPreferencesStore((s) => s.quickEditTrigger);Persisted slice shape (sessionStorage)
/* Key: commmerce-editor-setup-store */
/* The four transient fields below are stripped by partialize */
/* so they always re-initialise on reload. */
{
"layoutSettings": {
"xl": { "mode": "full-width", "containedWidth": 240 },
"desktop": { "mode": "full-width", "containedWidth": 100 },
"tablet": { "mode": "full-width", "containedWidth": 50 },
"mobile": { "mode": "full-width", "containedWidth": 20 }
},
"leftSidebarCollapse": false,
"leftSidebarStatus": "layers",
"deviceMode": "xl",
"editorMode": "drag-drop",
"rightSidebarCollapse": true,
"zoom": 1,
"interactionMode": "idle",
"snapSettings": {
"enabled": true, "threshold": 5,
"showDistances": true, "snapToElements": true, "snapToContainer": true
}
/* stripped: isInitializing, initializingMessage, */
/* placementMode, quickEditElementId */
}Preferences slice shape (localStorage)
/* Key: editor-preferences-store */
{
"panelMode": "right-sidebar",
"quickEditTrigger": "right-click"
}Tips & gotchas
- Three storage layers, three lifetimes.
sessionStorage(setup store) is per-tab and dies with the tab.localStorage(preferences) is per-browser and survives restarts.IndexedDB(content store) is per-scope-key and survives indefinitely until the user clears site data. Don't try to "share" device mode across tabs by moving it tolocalStorage— that breaks the per-tab independence that lets two tabs edit different breakpoints side by side. - Transient fields never rehydrate.
isInitializing,initializingMessage,placementMode, andquickEditElementIdare stripped on every write bypartialize. If you add a new field that should also reset on reload, add it to the destructure insidepartializetoo. - Don't write max-width values into
layoutSettings.containedWidth. It is a per-side padding in px, not a maximum width. For the published theme's contained width, editsettings.breakpoints[bp].containedWidthinstead — that lives in Global settings. - Zoom is for coordinate math, not visual scaling. The drag-drop hooks divide pointer deltas by
zoomto stay in document space; the actual CSS scale is applied separately by the canvas wrapper. Settingzoom = 2in DevTools without updating the wrapper transform will desync them. - The IndexedDB key only changes when
setEditorScope()is called. If you mount a new editor surface and skip the call, you will read and write to whatever scope the previous mount set (or tocommmerce-editor:unscopedon the very first mount). Always callsetEditorScope(deriveEditorScope({...}))beforepersist.rehydrate(). - Switching to a custom breakpoint that no longer exists falls back to
desktopviagetBreakpointWithFallback. If you renamed or deleted a custom breakpoint, the next click on its device tab will resolve to the fallback rather than throw. panelModeis user-wide, not theme-wide. SettingpanelMode: 'quick-edit'in one tab immediately affects every other tab the same user has open on the same domain (becauselocalStorageevents propagate). Two designers on the same machine sharing a browser profile will see each other's preference.- The legacy
Settings.tsxmodal still exists and is wired intouseEditorSettingsStorevia the sameimportSettings/copyBreakpointStylesplumbing as the in-sidebar Global Settings panel. It edits theme settings, not editor settings — opening it does not let you changepanelModeorsnapSettings. - The QuickEditPanel position is one global slot per tab.
useDraggablePanelwrites to a singlesessionStoragekey shared across all quick-edit panels, so dragging one panel sets the spot for every subsequent panel in that tab — by design. Clearcommmerce-editor:quick-edit-panel:positionfromsessionStorageto reset.