Overview

The floating control panel is the editor's Quick Edit Panel — a 20rem wide property panel that mounts next to the currently selected element instead of being docked in the right sidebar. It exposes the full set of element controls across 21 lazy-loaded sections (layout, text, background, media, spacing, position, attributes, events, dialog block settings, and more), plus quick-action buttons for Duplicate, Delete, Copy Style, Paste Style, and Create Block.

The panel is opt-in. By default the editor uses the docked right sidebar; switching the panelMode preference to quick-edit activates the floating panel and hides the Configuration toggle in the top toolbar. Once active, the panel auto-positions to the right of your selection, but the moment you grab its header it becomes a free floating window — its position is persisted per session, survives selection changes, and clamps back into the viewport on resize.

How it works

The mode toggle lives in useEditorPreferencesStore (persisted to localStorage under the editor-preferences-store key). It exposes two settings: panelMode ('right-sidebar' or 'quick-edit') and quickEditTrigger ('right-click' or 'double-click'). When panelMode === 'quick-edit', the canvas gesture chosen by quickEditTrigger writes the focused element's id into useCommmerceEditorSetupStore.quickEditElementId, which is the single gate that decides whether the panel is allowed to render.

Single-element selections render QuickEditPanel via React Portal from inside each ElementRenderer instance (rendered at three layout branches in drag-drop/ElementRenderer.tsx). Multi-selections render QuickEditMultiPanel instead, which positions itself against the bounding box of the selected set. Both panels share useDraggablePanel — the hook that owns auto-positioning, pointer-down drag, viewport clamping, and sessionStorage persistence under commmerce-editor:quick-edit-panel:position.

The body of the panel — header, quick-action rows, and 21 lazy-loaded section components — lives in QuickEditSections.tsx so the same UI can be reused by the docked right sidebar. Each section is dynamically imported via React.lazy and rendered only when relevant to the selected element's tag and parent layout mode; the panel shows a "Loading…" fallback while the section bundle fetches.

Drag math is handled by useDraggablePanel: on pointer-down it records the cursor's offset from the panel's top-left so the panel moves relative to the grab point (not jumping to the cursor). Pointer-move clamps the new position so at least HANDLE_VISIBLE = 40 px of the header remains in the viewport — even if the rest of the panel falls off-screen. Pointer-up writes the final position to sessionStorage so the next mount lands at the same spot. A resize listener re-clamps the persisted position so a spot saved on a wider window doesn't strand the panel after the user shrinks the editor.

Key features

  • Auto-anchors to selection — on first appear, the panel computes autoPosition based on the selected element's bounding box. USE CASE: clicking a hero button puts the panel right next to it so you don't have to move your eye across the screen.
  • Flips left if it would overflow right — if rect.right + 20rem + 12px exceeds the viewport, the panel re-anchors to the left of the element. USE CASE: selecting an element near the right edge still leaves the panel fully visible.
  • Drag with relative offset — pointer-down records the cursor's offset inside the header so the panel doesn't snap to the cursor when dragging starts. USE CASE: grabbing the bottom-right of the header keeps the panel in the same spot until you actually move the mouse.
  • Position persists across selections — sessionStorage keeps the user-chosen spot for the lifetime of the tab. USE CASE: parking the panel in a fixed corner while authoring a long page; clicking different elements doesn't snap it back.
  • Viewport reclamp on resize — a resize listener clamps the persisted position to the new window size if it now falls out of bounds. USE CASE: closing a docked DevTools panel doesn't leave the Quick Edit panel stranded off-screen.
  • Ctrl/Cmd to hideuseIsModifierKeyPressed unmounts the panel while the modifier is held. USE CASE: peeking at the layer underneath without dismissing the selection.
  • Auto-hide during gestures — when interactionMode becomes 'drag' | 'resize' | 'rotate' | 'grid-cell-resize', the panel unmounts. USE CASE: dragging an element doesn't leave the panel floating over the drop target.
  • Mounted-but-hidden during media upload — when isUploadMediaOpen is true the panel keeps its React tree alive but hides via inline style. USE CASE: BannerSlider's active-slide index survives an image upload round-trip.
  • Multi-selection modeQuickEditMultiPanel snaps to the bounding box of every selected element and edits fan out across the set via the batch slice. USE CASE: aligning six product cards in one go.
  • Portal escape — the panel is rendered via createPortal to document.body, escaping any ancestor's z-index or overflow context. USE CASE: a clipped canvas container can't crop the panel.
  • Shared body with docked sidebar — when panelMode === 'right-sidebar' the same QuickEditSections component renders inside the docked sidebar with chromeless set and drag handle props omitted. USE CASE: switching panel modes doesn't change which controls are available.
  • Header badge shows parent layout — a blue pill in the header names the parent's layout mode (free-flow / stack / grid). USE CASE: instantly knowing why the Reorder section shows up/down vs left/right arrows.

QuickEditSections (21 lazy-loaded)

Every section is imported via React.lazy and shown only when relevant to the selected element's tag, type, or parent layout. Sections render their own collapsed/expanded state locally so the floating panel and the docked sidebar remember independent expansion preferences.

SectionDefault stateShown whenPurpose / use case
LayoutModeSectionexpandedcontainer elementPick free-flow / stack / grid for the container. USE CASE: converting a free-flow card row into a grid.
DataBindingSectionexpandedalwaysBind the node to a data-source field or expression. USE CASE: pointing a product card's price node at {{product.price}}.
BannerSliderSectionexpandedbanner-slider elementManage slides, active index, autoplay. USE CASE: editing the hero carousel's three slides.
HotspotSectionexpandedhotspot elementAdd/edit interactive hotspots over an image. USE CASE: pinning a product callout to a lifestyle photo.
AlignmentSectionexpandedparent is free-flow + not rootQuick H/V center, edge align. USE CASE: centring a logo inside a free-flow hero.
ReorderSectionexpandedalwaysMove up/down/left/right depending on stack direction or grid. USE CASE: bumping a card to first position in a row.
GridSpanSectionexpandedparent layout mode = gridEdit column/row span and start position. USE CASE: making a feature card span two columns.
GridLayoutTemplatesPanelcollapsedcontainer with grid layoutOne-click apply a preset grid template (3-col, 2x2, hero+side, etc.). USE CASE: seeding a new container with a starting grid.
MediaSectionexpandedimg / video / background-imagePick / replace / crop / set object-fit on media. USE CASE: swapping a hero image without leaving the panel.
FormSectionexpandedform / input elementName, placeholder, validation, required toggle. USE CASE: configuring a newsletter signup input.
TextSectionexpandedtext-bearing elementFont family, size, weight, line-height, alignment, color. USE CASE: tightening the hero headline to 56/64 bold.
LinkSectionexpandedanchor / linkable elementHref, target, rel, link styling. USE CASE: pointing a "Shop Now" button at a collection page.
BackgroundSectionexpandedalwaysColor, gradient, image background with positioning. USE CASE: applying a brand-gradient background to a section.
AttributesSectionexpandedalwaysRaw HTML attributes (id, class, data-*, aria-*). USE CASE: adding a custom data-gtm attribute for analytics.
EventManagementSectionexpandedalwaysWire click/hover/submit events to dialogs, actions, or JS. USE CASE: opening a newsletter dialog on a button click.
DialogBlockSettingsSectionexpandeddialog block rootWidth, position, dismiss-behavior for the dialog itself. USE CASE: making a popup non-dismissible until form submit.
StylesSectioncollapsedalways (context-aware list)Free-form style key/value editor scoped to the element type and parent layout. USE CASE: hand-editing a filter: blur(2px) on an image.
PositionSectioncollapsedalwaysPosition type, top/right/bottom/left, z-index. USE CASE: floating a "New" badge over a product card.
SpacingSectioncollapsedalwaysMargin and padding for each side per breakpoint. USE CASE: bumping mobile padding without changing desktop.
BorderRadiusSectioncollapsedalwaysPer-corner border radius with link/unlink toggle. USE CASE: rounding only the top corners of a card.
GridActionsSectionslotcontainer with grid layoutRendered inside LayoutModeSection as a slot — add/remove rows/columns, set gap, autoexpand. USE CASE: adding a fourth column to an existing grid without rebuilding it.

Panel-action controls

Below the section header, two rows of quick-actions cover the most common element operations. The first row (Duplicate / Delete) is always enabled; the second row (Copy Style / Paste Style / Create Block) is conditionally gated.

ActionTypeEnabled whenPurpose / use case
DuplicatebuttonalwaysCalls duplicateElement(node.id) on the editor store — inserts a deep clone next to the original. USE CASE: copying a product card to make a 6-card row from a 3-card one.
Deletebutton (red)alwaysCalls deleteElement(node.id). Red-tinted to flag a destructive action. USE CASE: removing a placeholder block before publish.
Copy Stylebuttoncallback suppliedSnapshots the node's base + responsive + pseudo styles into localStorage via styleClipboard. USE CASE: grabbing a polished button's styling to apply elsewhere.
Paste Stylebuttoncopy callback supplied AND hasCopiedStyles()Applies the copied style block to the current selection. Gated by hasCopiedStylesUtil() so it shows "No copied style" otherwise. USE CASE: unifying button styling across a long landing page.
Create Blockbuttoncallback supplied AND not rootOpens the Create Block dialog seeded with the current element subtree. Disabled when the selection is a section's root node (root would equal the page itself). USE CASE: turning a polished hero into a reusable global block.
Drag handlegrip icon headerfloating panel onlyPointer-down on the header detaches the panel from auto-position. Move-as-you-drag, clamped so at least 40 px of the header stays in the viewport. USE CASE: parking the panel above your code editor for the duration of the session.
Header badge (parent layout)labelparent is a containerShows the parent's layout mode (free-flow, stack, grid) in a blue pill. USE CASE: confirming you're editing a stack child before reaching for a Reorder arrow.

useEditorPreferencesStore

All persistent UI preferences for the panel live in this Zustand store, persisted to localStorage under editor-preferences-store. Changes survive across sessions and tabs.

FieldTypeDefaultPurpose / use case
panelMode'right-sidebar' | 'quick-edit''right-sidebar'Selects whether element properties surface in the docked sidebar or in the floating panel. USE CASE: a power user opts into Quick Edit for a faster, eye-level workflow.
setPanelMode(mode)(mode) => voidAction setter. Calling this with 'quick-edit' also hides the Configuration toggle in the top toolbar via isQuickEditPanel in control/index.tsx. USE CASE: a Settings dialog wires this to a radio group.
quickEditTrigger'right-click' | 'double-click''right-click'The canvas gesture that summons the panel for the focused element. Only meaningful when panelMode === 'quick-edit'. USE CASE: a designer who right-clicks to inspect in DevTools opts into double-click instead.
setQuickEditTrigger(trigger)(trigger) => voidAction setter. USE CASE: switching from right-click to double-click without restarting the editor.
quickEditElementIdstring | null (in setup store)nullSet by the trigger gesture; the only id whose ElementRenderer is allowed to portal a QuickEditPanel to document.body. USE CASE: gating which ElementRenderer instance owns the panel.
panelMode persistence keylocalStorage'editor-preferences-store'Set by persist(createJSONStorage(() => localStorage)). Survives across sessions. USE CASE: returning the next day still finds Quick Edit on.
panel position persistence keysessionStorage'commmerce-editor:quick-edit-panel:position'Set by useDraggablePanel. Cleared on tab close. USE CASE: a fresh tab starts with auto-positioning instead of inheriting yesterday's spot.

Workflows

1. Enable the floating panel

  1. Open Editor Settings (gear icon on the toolbar).
  2. Switch panelMode from Right Sidebar to Quick Edit.
  3. Choose a quickEditTriggerRight-click or Double-click.
  4. The Configuration toggle in the top toolbar disappears (the floating panel replaces the docked sidebar entirely).
  5. Click any canvas element to select it, then perform the trigger gesture — the panel appears anchored to the right of the element.

2. Reposition the panel

  1. Grab the panel header by the grip icon — cursor changes to grabbing.
  2. Drag anywhere in the viewport. The panel moves relative to the grab point so it doesn't jump to the cursor.
  3. Release. The position is written to sessionStorage under commmerce-editor:quick-edit-panel:position.
  4. Select a different element — the panel stays where you put it instead of snapping back to auto-position.
  5. On window resize, the panel is automatically reclamped so at least 40 px of the header stays visible.

3. Edit a multi-selection

  1. Shift-click or marquee-select multiple elements on the canvas.
  2. QuickEditMultiPanel mounts and positions itself against the bounding box of all selected rectangles.
  3. The multi-panel's resetKey uses the joined element ids, so changing which elements are selected recomputes the auto-position only when the set itself changes.
  4. Edits made in the panel fan out across every selected element via the batch slice.

4. Temporarily hide the panel

  1. Hold Ctrl (Windows) or Cmd (macOS). The panel unmounts immediately so you can interact with the underlying canvas, layers, or context menu.
  2. Release the modifier — the panel remounts at its persisted position.
  3. The panel also auto-hides during drag, resize, rotate, and grid-cell-resize interactions, and stays hidden until those gestures complete.
  4. During media upload it is kept mounted but hidden (so section state like the active slide index in BannerSlider survives the upload flow).

5. Copy and paste an element's style

  1. Select a polished button, then click Copy Style in the panel's secondary action row.
  2. The node's base + responsive + pseudo styles are snapshotted into localStorage via styleClipboard.
  3. Navigate to another element where you want the same styling.
  4. The Paste Style button becomes enabled (it was greyed out before hasCopiedStyles() returned true).
  5. Click Paste Style. The full style stack is applied — including responsive overrides — without affecting attributes or children.

6. Create a global block from the selection

  1. Select a non-root element (e.g. a hero subtree, but not the section root).
  2. Click Create Block — the Create Block dialog opens seeded with the current element's tree.
  3. Name the new block, set its slug, and confirm. The dialog hands off to the block-create endpoint.
  4. The selection is replaced inline by a reference stub to the new global block.

Examples

Opt into Quick Edit programmatically

import { useEditorPreferencesStore } from '../../common/store/useEditorPreferencesStore';

/*   Switch from the docked Right Sidebar to the floating Quick Edit panel.  */
useEditorPreferencesStore.getState().setPanelMode('quick-edit');

/*   Change the trigger gesture — double-click is friendlier when           */
/*   right-click is reserved for the browser DevTools context menu.         */
useEditorPreferencesStore.getState().setQuickEditTrigger('double-click');

Render QuickEditSections inside a docked sidebar

/*   The same UI body that the floating panel uses can be embedded in any    */
/*   host container — drop the drag handle props and pass chromeless=true.   */
import QuickEditSections from '../../common/components/commmerce-editor/element-editor/QuickEditSections';

<QuickEditSections
    node={node}
    parentNode={parent}
    chromeless={true}
    onSaveAttribute={handleSaveAttribute}
    onSaveStyle={handleSaveStyle}
    onLayoutModeChange={handleLayoutModeChange}
    onCopyStyle={handleCopyStyle}
    onPasteStyle={handlePasteStyle}
    onCreateBlock={handleCreateBlock}
    /* no headerDragHandleProps -> static header, no drag affordance */
/>

Use a custom position storage key

/*   Two side-by-side panels can keep independent persisted positions by    */
/*   passing distinct storageKey strings to useDraggablePanel.               */
const { position, dragHandleProps, isDragging } = useDraggablePanel({
    autoPosition,
    panelRef,
    resetKey: node.id,
    storageKey: 'my-feature:secondary-panel:position'
});

Read the current persisted position

/*   The hook persists to sessionStorage on every pointer-up. Read it       */
/*   without invoking the hook (e.g. in a debug overlay).                    */
const raw = sessionStorage.getItem('commmerce-editor:quick-edit-panel:position');
const pos = raw ? JSON.parse(raw) : null;
/*   pos => { top: 142, left: 980 } or null when never dragged.            */

Keyboard shortcuts

ShortcutActionNotes
Ctrl / Cmd (hold) Temporarily hide the panel Tracked by useIsModifierKeyPressed; resets on keyup or window blur.
Esc Clear selection / dismiss panel Clearing selectedElementIds also unmounts the panel because there is no element to anchor against.
Right-click Summon panel (configurable) Default trigger. The native browser menu is suppressed inside the canvas.
Double-click Summon panel (configurable) Alternative trigger. On text-type elements, double-click also enters inline text editing.

Tips & gotchas

  • The panel is portaled to document.body via createPortal — it escapes any z-index or overflow context set by ancestors.
  • Auto-position prefers placing the panel to the right of the selection. If that would overflow, it flips to the left. Vertically, it clamps to a 10 px viewport margin top and bottom.
  • Position persistence uses sessionStorage, not localStorage — closing the tab resets the panel to auto-position on the next load. The panelMode preference itself, however, is in localStorage and survives across sessions.
  • Single-click never opens the panel; it only selects. Clicking a different element closes the panel; clicking the same element again leaves it open.
  • Sections are lazy-loaded via React.lazy. Opening the panel on a complex element shows the Loading… fallback while the section bundles fetch — first paint is intentionally fast.
  • Quick-action buttons render with inline styles (not Tailwind utility classes) so the panel looks identical in both the floating and docked sidebar host contexts.
  • When you've docked the right sidebar (the default right-sidebar mode), the same QuickEditSections body renders inside the sidebar with the drag handle props omitted — meaning a docked panel is the floating panel without its drag affordance.
  • Grid-context selections gain extra sections automatically: GridSpanSection, GridLayoutTemplatesPanel, and GridActionsSection only appear when the parent container's layout mode is grid.
  • The drag clamp uses HANDLE_VISIBLE = 40 px — at most the panel can be pushed so far off-screen that only 40 px of the header remains visible. Vertical clamp uses maxTop = window.innerHeight - 40; the rest of the panel may sit below the viewport because the panel can be up to 80vh tall.
  • The hook's resetKey is intentionally a no-op now — persistence is the product rule. Earlier versions snapped back to auto-position when the selection changed; the new behavior leaves the panel where the user parked it.