feat(builder): mobile-B touch editing -- selection toolbar, tap-to-add, swipe-dismiss

Phase B makes the Craft.js editor genuinely usable by touch on top of Phase
A's responsive shell, gated entirely behind useIsMobile()/<=768px:

- Extract useNodeActions(nodeId) out of ContextMenu.tsx (move/duplicate/
  delete/select-parent), shared by the desktop right-click menu (behavior
  unchanged) and the new mobile MobileSelectionToolbar.
- MobileSelectionToolbar: bottom-fixed selection toolbar (Move Up/Down,
  Duplicate, Select Parent, Edit Styles, two-tap Delete confirm), hidden
  while a sheet is open.
- BlocksPanel: tap-to-add on mobile (insert after selection, close sheet,
  select + scroll the new node into view); desktop drag/double-click
  unchanged.
- LayersPanel rows >=44px on mobile; HeadCodeModal portaled to document.body
  (same fix TemplateModal already had); BottomSheet gets swipe-to-dismiss
  and on-screen-keyboard clearance via a new useVisualViewportInsets hook.

Also fixes two pre-existing bugs surfaced only by driving a real Craft.js
document with Playwright touch input (masked by tests that mock
@craftjs/core): regenerateTreeIds structuredClone'd a live node's whole
data object, including the component function reference in data.type,
throwing DataCloneError and silently breaking Duplicate/Paste for every
node type; and an earlier useNodeActions draft cached canMoveUp/canMoveDown
inside a useEditor collector closed over nodeId, which goes stale for one
render whenever the selection changes without an unrelated store event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 08:07:51 -07:00
parent 2a071bc7ab
commit 2c8425ffb0
13 changed files with 1023 additions and 93 deletions
+17 -57
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps {
visible: boolean;
@@ -65,19 +65,16 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}
}, [nodeId, query]);
const duplicate = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
// Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
// extracted into `useNodeActions` so the mobile selection toolbar drives
// the exact same behavior. Wrapped here purely to also `onClose()` the
// menu after each action, same as before the extraction.
const nodeActions = useNodeActions(nodeId);
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
actions.addNodeTree(tree, parentId);
} catch (e) {
console.error('Duplicate failed:', e);
}
const duplicate = useCallback(() => {
nodeActions.duplicate();
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
}, [nodeActions, onClose]);
const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
@@ -118,47 +115,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx > 0) {
actions.move(nodeId, parentId, idx - 1);
}
} catch (e) {
console.error('Move up failed:', e);
}
nodeActions.moveUp();
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
}, [nodeActions, onClose]);
const moveDown = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const parentId = getParentId();
if (!parentId) return;
const parent = query.node(parentId).get();
const children = parent.data.nodes || [];
const idx = children.indexOf(nodeId);
if (idx < children.length - 1) {
actions.move(nodeId, parentId, idx + 2);
}
} catch (e) {
console.error('Move down failed:', e);
}
nodeActions.moveDown();
onClose();
}, [nodeId, actions, query, getParentId, onClose]);
}, [nodeActions, onClose]);
const selectParent = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
nodeActions.selectParent();
onClose();
}, [nodeId, actions, getParentId, onClose]);
}, [nodeActions, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
@@ -172,18 +141,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId);
if (!target) {
onClose();
return;
}
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
nodeActions.deleteNode();
onClose();
}, [nodeId, actions, query, onClose]);
}, [nodeActions, onClose]);
if (!visible) return null;
+103 -19
View File
@@ -1,5 +1,7 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => {
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
/** Default insertion target when there's no usable selection to anchor
* to: the first real Craft.js canvas in the document (falls back to
* ROOT). Extracted from the pre-existing onDoubleClick handler so
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
* the exact same fallback. */
const findDefaultCanvasId = useCallback((): string => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
for (const id of nodeIds) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}, [query]);
/**
* Builds a fresh node tree for `block` and inserts it into the canvas,
* returning the new node's id (or null on failure). Shared by desktop's
* double-click (unchanged behavior/position: always appended to
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
* prefers inserting as a sibling right after the current selection --
* `insertAfterSelection: true` only from the mobile tap handler below.
*/
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
try {
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
let inserted = false;
if (insertAfterSelection) {
try {
const selectedIds = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const selectedNode = query.node(selectedId).get();
const parentId = selectedNode?.data?.parent;
if (parentId) {
const parent = query.node(parentId).get();
const siblings: string[] = parent?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. lives in a
// linkedNodes slot) -- fall through to the default canvas below.
}
}
if (!inserted) {
actions.addNodeTree(tree, findDefaultCanvasId());
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}, [query, actions, findDefaultCanvasId]);
/**
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
* closes the Blocks sheet, then selects the new node and scrolls it into
* view so the user immediately sees it (and gets the selection toolbar).
* The select/scroll step is deferred two animation frames past the
* `addNodeTree` call -- Craft.js's own state update (and thus the new
* node's real DOM element) lands asynchronously after this handler
* returns, so `query.node(id).get().dom` isn't populated yet if read
* synchronously here.
*/
const handleTapToAdd = useCallback((block: BlockDef) => {
const newId = addBlockNode(block, true);
closeSheet();
if (!newId) return;
requestAnimationFrame(() => {
try {
actions.selectNode(newId);
} catch {
// Node may have failed to mount -- nothing to select.
}
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [addBlockNode, closeSheet, actions, query]);
return (
<div>
{categories.map((category) => {
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
key={block.id}
className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
let canvasId = 'ROOT';
for (const nodeId of nodeIds) {
if (serialized[nodeId].isCanvas && nodeId !== 'ROOT') {
canvasId = nodeId;
break;
}
}
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
actions.addNodeTree(tree, canvasId);
} catch (e) {
console.error('Failed to add block:', e);
}
}}
title={`Drag or double-click to add ${block.label}`}
onDoubleClick={() => addBlockNode(block, false)}
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
+1
View File
@@ -146,6 +146,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
{...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className="layer-node-row"
style={{
position: 'relative',
display: 'flex',
+119 -5
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useVisualViewportInsets } from '../../hooks/useVisualViewport';
export interface BottomSheetProps {
open: boolean;
@@ -7,6 +8,10 @@ export interface BottomSheetProps {
children: React.ReactNode;
}
/** Vertical drag distance (px) past which releasing the handle/header
* commits to closing the sheet, rather than snapping back open. */
const SWIPE_DISMISS_THRESHOLD_PX = 80;
/**
* Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components
@@ -18,6 +23,22 @@ export interface BottomSheetProps {
* dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases.
*
* Phase B adds two more deferred behaviors on top of Phase A's static sheet:
* - **Swipe-to-dismiss**: a touchstart/move/end drag tracked on the handle
* row + header (not the scrollable body, so it never fights a panel's own
* vertical scroll) that follows the finger while dragging down and either
* commits to `onClose()` past `SWIPE_DISMISS_THRESHOLD_PX` or snaps back
* with a short transition otherwise.
* - **On-screen-keyboard clearance**: `useVisualViewportInsets` reports how
* much the visual viewport has shrunk from the bottom (i.e. the
* keyboard's height); that's surfaced as a `--keyboard-inset` CSS custom
* property (see editor.css) which both lifts the whole backdrop/sheet
* clear of the keyboard AND caps the sheet's own max-height so it never
* extends into the space the keyboard occupies. A `focusin` listener also
* scrolls the newly-focused input into view once the keyboard has had a
* moment to animate in, as a second line of defense for a field that's
* still off-screen after the resize alone (e.g. deep in a long panel).
*/
export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => {
@@ -35,6 +56,78 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
};
}, [open, onClose]);
const { keyboardInset } = useVisualViewportInsets();
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const bodyEl = bodyRef.current;
if (!bodyEl) return;
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) {
// Give the on-screen keyboard a moment to finish animating in
// before scrolling -- doing it immediately measures the pre-keyboard
// layout and can undershoot.
window.setTimeout(() => {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 300);
}
};
bodyEl.addEventListener('focusin', handleFocusIn);
return () => bodyEl.removeEventListener('focusin', handleFocusIn);
}, [open]);
// Swipe-to-dismiss: tracked only on the handle row + header (never the
// scrollable `.mobile-sheet-body`, which needs its own vertical touch
// scrolling to keep working unimpeded).
//
// The authoritative drag distance lives in a REF (`dragOffsetRef`),
// updated synchronously and imperatively on every touchmove -- NOT solely
// in the `dragOffset` state used for rendering the drag transform. Two
// touchmoves can fire back-to-back inside the same synchronous event
// dispatch (e.g. a fast real swipe, or synthetic touch events fired
// without a yield between them), and React batches their `setDragOffset`
// calls into a single pending update that hasn't committed yet by the
// time `touchend` runs in that same tick -- reading the `dragOffset`
// STATE value directly in `handleTouchEnd` would then see a stale
// (pre-drag) value and wrongly decide the swipe didn't clear the
// threshold. The ref sidesteps that entirely (plain synchronous mutation,
// no batching). Relatedly, `onClose()` mutates an ANCESTOR component's
// state (MobileChromeContext's `closeSheet`) -- it must be called as a
// plain statement in the event handler, never from inside a `setState`
// functional updater (that runs during this component's own render phase
// and trips React's "Cannot update a component while rendering a
// different component" warning).
const dragStartYRef = useRef<number | null>(null);
const dragOffsetRef = useRef(0);
const [dragOffset, setDragOffset] = useState(0);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
dragStartYRef.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (dragStartYRef.current === null) return;
const delta = e.touches[0].clientY - dragStartYRef.current;
// Only follow downward drags -- dragging up shouldn't do anything (the
// sheet is already fully open; there's no "expand further" state).
const next = Math.max(0, delta);
dragOffsetRef.current = next;
setDragOffset(next);
}, []);
const handleTouchEnd = useCallback(() => {
dragStartYRef.current = null;
const finalOffset = dragOffsetRef.current;
dragOffsetRef.current = 0;
setDragOffset(0);
if (finalOffset > SWIPE_DISMISS_THRESHOLD_PX) onClose();
}, [onClose]);
if (!open) return null;
return (
@@ -43,16 +136,37 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
>
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */}
<div className="mobile-sheet" role="dialog" aria-label={title}>
<div className="mobile-sheet-handle-row">
<div
className="mobile-sheet"
role="dialog"
aria-label={title}
style={{
transform: dragOffset ? `translateY(${dragOffset}px)` : undefined,
transition: dragOffset ? 'none' : 'transform 0.2s ease',
}}
>
<div
className="mobile-sheet-handle-row"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-handle" aria-hidden="true" />
</div>
<div className="mobile-sheet-header">
<div
className="mobile-sheet-header"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
<span className="mobile-sheet-title">{title}</span>
<button
type="button"
@@ -63,7 +177,7 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="mobile-sheet-body">{children}</div>
<div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
</div>
</div>
);
@@ -0,0 +1,174 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useMobileChrome } from '../../state/MobileChromeContext';
/**
* Phase B mobile selection toolbar -- the on-canvas action bar that makes a
* tap-selected node actually EDITABLE by touch (reorder/duplicate/delete/
* select-parent/edit-styles), mirroring the desktop right-click ContextMenu.
* Both share the exact same behavior via `useNodeActions` (item 1).
*
* Mounted ONCE (in `EditorShell`'s mobile branch), reading the current
* selection from `useEditor` directly -- NOT per-node -- and positioned
* bottom-fixed just above the tab bar (simplest, robust; a node-anchored
* floating toolbar would have to constantly reposition as the canvas
* scrolls, and risks being clipped at the viewport edge for a node near the
* top or bottom of a tall page). This is a deliberate design call: see the
* report for the alternative (floating over the node) that was passed over.
*
* Hidden whenever a bottom sheet is open (`activeSheet !== null`) -- the
* sheet supersedes it, and the two must never visually stack.
*
* Delete is a two-tap in-app confirm (tap once -> the button becomes
* "Confirm?" for a few seconds; tap again within that window commits the
* delete; selecting something else cancels it) rather than a native
* `confirm()`, matching the project's no-native-dialogs rule.
*/
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
export const MobileSelectionToolbar: React.FC = () => {
const { activeSheet, openSheet } = useMobileChrome();
const { selectedId } = useEditor((state) => {
const selected = state.events.selected;
const id = selected && selected.size > 0 ? (Array.from(selected)[0] as string) : null;
return { selectedId: id && id !== 'ROOT' ? id : null };
});
const nodeActions = useNodeActions(selectedId);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const confirmTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// `useNodeActions` computes canMoveUp/canMoveDown/canDelete synchronously
// from a live (never-cached) Craft.js query -- see that hook's doc comment
// for why it deliberately does NOT hold its own store subscription to force
// a refresh. Moving/duplicating/deleting via THIS toolbar changes the
// selected node's position/siblings WITHOUT changing `selectedId` itself
// (the node stays selected), so nothing else would otherwise trigger a
// re-render of this component to pick up the new boundary flags after,
// say, tapping "Move Up" until the node reaches the top. Bumping a plain
// local tick from these buttons' own click handlers -- an ordinary,
// locally-owned re-render -- covers exactly that, without the broader
// store-wide subscription that was found to trip React's "setState while
// rendering a different component" warning whenever some unrelated
// container mounts (see useNodeActions.ts).
const [, forceRefresh] = useState(0);
const bumpTick = useCallback(() => forceRefresh((n) => n + 1), []);
const clearConfirmTimeout = useCallback(() => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
}, []);
// Reset the two-tap delete confirmation whenever the selection changes
// (including being cleared) so a stale "Confirm?" state never lingers onto
// a different node.
const prevSelectedRef = useRef(selectedId);
useEffect(() => {
if (prevSelectedRef.current !== selectedId) {
prevSelectedRef.current = selectedId;
clearConfirmTimeout();
setConfirmingDelete(false);
}
}, [selectedId, clearConfirmTimeout]);
useEffect(() => clearConfirmTimeout, [clearConfirmTimeout]);
const handleDeleteTap = useCallback(() => {
if (confirmingDelete) {
clearConfirmTimeout();
setConfirmingDelete(false);
nodeActions.deleteNode();
bumpTick();
return;
}
setConfirmingDelete(true);
clearConfirmTimeout();
confirmTimeoutRef.current = setTimeout(() => setConfirmingDelete(false), DELETE_CONFIRM_TIMEOUT_MS);
}, [confirmingDelete, clearConfirmTimeout, nodeActions, bumpTick]);
const handleEditStyles = useCallback(() => {
openSheet('styles');
}, [openSheet]);
const handleMoveUp = useCallback(() => {
nodeActions.moveUp();
bumpTick();
}, [nodeActions, bumpTick]);
const handleMoveDown = useCallback(() => {
nodeActions.moveDown();
bumpTick();
}, [nodeActions, bumpTick]);
const handleDuplicate = useCallback(() => {
nodeActions.duplicate();
bumpTick();
}, [nodeActions, bumpTick]);
if (!selectedId || activeSheet !== null) return null;
return (
<div className="mobile-selection-toolbar" role="toolbar" aria-label="Selected element actions">
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveUp}
disabled={!nodeActions.canMoveUp}
aria-label="Move up"
>
<i className="fa fa-arrow-up" aria-hidden="true" />
<span>Up</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleMoveDown}
disabled={!nodeActions.canMoveDown}
aria-label="Move down"
>
<i className="fa fa-arrow-down" aria-hidden="true" />
<span>Down</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleDuplicate}
aria-label="Duplicate"
>
<i className="fa fa-clone" aria-hidden="true" />
<span>Copy</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={nodeActions.selectParent}
aria-label="Select parent"
>
<i className="fa fa-level-up" aria-hidden="true" />
<span>Parent</span>
</button>
<button
type="button"
className="mobile-selection-toolbar-btn"
onClick={handleEditStyles}
aria-label="Edit styles"
>
<i className="fa fa-paint-brush" aria-hidden="true" />
<span>Style</span>
</button>
<button
type="button"
className={`mobile-selection-toolbar-btn danger${confirmingDelete ? ' confirming' : ''}`}
onClick={handleDeleteTap}
disabled={!nodeActions.canDelete}
aria-label={confirmingDelete ? 'Tap again to confirm delete' : 'Delete'}
>
<i className="fa fa-trash" aria-hidden="true" />
<span>{confirmingDelete ? 'Confirm?' : 'Delete'}</span>
</button>
</div>
);
};
+12 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal';
@@ -10,7 +11,15 @@ interface HeadCodeModalProps {
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
const { design, updateDesign } = useSiteDesign();
return (
// Mobile-A2/Phase B: portaled to `document.body` -- same fix TemplateModal
// already got (see its comment). TopBar.tsx mounts this modal as a child
// of `.topbar`, which (as a flex item with its own z-index) forms its own
// stacking context; that trapped the modal's fixed-position backdrop
// underneath the mobile tab bar (z-index: var(--z-tabbar)) no matter how
// high the modal's own z-index was set. Portaling escapes that stacking
// context entirely so `--z-modal` (editor.css) is evaluated at the
// document root, same as TemplateModal/SitesmithModal.
return createPortal(
<Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */}
@@ -82,7 +91,8 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
</button>
</div>
</div>
</Modal>
</Modal>,
document.body,
);
};