Mobile Phase B: touch editing (selection toolbar, tap-to-add) + duplicate fix #10

Merged
jknapp merged 2 commits from mobile-phase-b into main 2026-07-13 15:43:29 +00:00
14 changed files with 1212 additions and 94 deletions
+42 -1
View File
@@ -6,6 +6,7 @@ import { DeviceMode } from '../types';
import { DEVICE_WIDTHS } from '../constants/presets'; import { DEVICE_WIDTHS } from '../constants/presets';
import { exportBodyHtml } from '../utils/html-export'; import { exportBodyHtml } from '../utils/html-export';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useMobileChrome } from '../state/MobileChromeContext';
interface CanvasProps { interface CanvasProps {
device: DeviceMode; device: DeviceMode;
@@ -135,6 +136,42 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const isEditingRegularPage = !isEditingHeader && !isEditingFooter; const isEditingRegularPage = !isEditingHeader && !isEditingFooter;
// Fast-follow item 4: while `MobileSelectionToolbar` is up (fixed, ~53px
// tall, just above the tab bar), it covers whatever content was at the
// very bottom of the canvas's own scroll area -- on a short page, the
// last section could sit permanently under the toolbar with no way to
// scroll past it. Mirrors that toolbar's own visibility condition
// (`selectedId && activeSheet === null`) exactly, computed independently
// here since Canvas has no other reason to depend on the toolbar
// component itself.
const isMobile = useIsMobile();
const { activeSheet } = 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 mobileToolbarVisible = isMobile && !!selectedId && activeSheet === null;
// Fast-follow item 3: `MobileSelectionToolbar`'s "Style" tap scrolls the
// selected node up towards the top of the canvas so it stays visible
// above the Styles sheet (~65dvh tall) -- but that scroll is still bound
// by the canvas's own natural scroll range. For a selected node near the
// END of the content (very plausibly the last section on the page --
// exactly the kind of node someone just added/duplicated and wants to
// style), there may not be enough scrollable distance below it to bring
// its top all the way up to the visible band above the sheet; the browser
// simply clamps at its existing max scrollTop, leaving the node's top
// stuck behind the sheet with nothing anyone can do about it (verified
// live: the last section of a page landed under the sheet even after the
// scroll-into-view ran). Pad the canvas with a full extra viewport's
// worth of scroll room while the sheet is open with a selection, exactly
// as `has-mobile-selection-toolbar` (item 4) already pads it for the
// fixed toolbar -- generous enough that ANY node, including the very
// last one, can always be scrolled with its top reaching the very top of
// the canvas (comfortably within the "top ~30%" target).
const mobileStylesSheetPad = isMobile && !!selectedId && activeSheet === 'styles';
const frameStyle = isEditingHeader const frameStyle = isEditingHeader
? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' } ? { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' }
: isEditingFooter : isEditingFooter
@@ -144,7 +181,11 @@ export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div'; const frameTag = isEditingHeader ? 'header' : isEditingFooter ? 'footer' : 'div';
return ( return (
<div className="editor-canvas"> <div
className={`editor-canvas${mobileToolbarVisible ? ' has-mobile-selection-toolbar' : ''}${
mobileStylesSheetPad ? ' has-mobile-styles-sheet' : ''
}`}
>
<div <div
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`} className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
style={{ style={{
+2
View File
@@ -4,6 +4,7 @@ import { TopBar } from '../panels/topbar/TopBar';
import { LeftPanel } from '../panels/left/LeftPanel'; import { LeftPanel } from '../panels/left/LeftPanel';
import { RightPanel } from '../panels/right/RightPanel'; import { RightPanel } from '../panels/right/RightPanel';
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar'; import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
import { MobileSelectionToolbar } from '../panels/mobile/MobileSelectionToolbar';
import { Canvas } from './Canvas'; import { Canvas } from './Canvas';
import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { ContextMenu } from '../panels/context-menu/ContextMenu';
import { useContextMenu } from '../hooks/useContextMenu'; import { useContextMenu } from '../hooks/useContextMenu';
@@ -97,6 +98,7 @@ export const EditorShell: React.FC = () => {
{!isMobile && <RightPanel />} {!isMobile && <RightPanel />}
</div> </div>
{isMobile && <MobilePanelBar />} {isMobile && <MobilePanelBar />}
{isMobile && <MobileSelectionToolbar />}
<ContextMenu <ContextMenu
visible={menuState.visible} visible={menuState.visible}
x={menuState.x} x={menuState.x}
+258
View File
@@ -0,0 +1,258 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import type { NodeTree, Node } from '@craftjs/core';
import { useNodeActions, type NodeActions } from './useNodeActions';
/**
* Phase B, item 1: `useNodeActions` was extracted out of `ContextMenu.tsx` so
* the desktop right-click menu and the mobile selection toolbar
* (`MobileSelectionToolbar`) share one implementation. This suite exercises
* the hook directly (mocking `@craftjs/core`'s `useEditor`, mirroring the
* pattern in `useKeyboardShortcuts.test.tsx`), covering:
* - moveUp/moveDown call `actions.move` with the right target index, and
* are no-ops at the respective boundary
* - canMoveUp/canMoveDown reflect the node's position among its siblings
* - duplicate regenerates ids, inserts the copy IMMEDIATELY AFTER the
* source node (`actions.addNodeTree(tree, parentId, sourceIndex + 1)`),
* and selects the new copy (`actions.selectNode(tree.rootNodeId)`) --
* Phase B fast-follow: the previous append-at-end behavior left the copy
* off-screen with the ORIGINAL still selected, so users couldn't see
* what "Duplicate" had just done.
* - selectParent calls `actions.selectNode` with the parent id
* - canSelectParent reflects whether the node's parent is itself real
* (not ROOT/missing) -- false at a top-level section, true one level in
* - deleteNode routes through `findDeletableTarget` (also covering the
* "no deletable target" no-op)
* - ROOT/null nodeId disables every action safely (no-ops, all
* canMoveUp/canMoveDown/canDelete/canSelectParent false)
*/
const moveMock = vi.fn();
const addNodeTreeMock = vi.fn();
const selectNodeMock = vi.fn();
const deleteMock = vi.fn();
interface FakeNodeData {
parent: string | null;
nodes: string[];
}
let fakeNodes: Record<string, { data: FakeNodeData }> = {};
const COPIED_TREE: NodeTree = {
rootNodeId: 'child-1',
nodes: {
'child-1': {
id: 'child-1',
data: { props: {}, type: { resolvedName: 'Container' }, name: 'Container', displayName: 'Container', isCanvas: false, parent: 'parent-1', linkedNodes: {}, nodes: [], hidden: false },
info: {},
events: { selected: false, dragged: false, hovered: false },
dom: null,
related: {},
rules: {},
_hydrationTimestamp: 0,
} as unknown as Node,
},
};
function makeQuery() {
return {
node: (id: string) => ({
get: () => fakeNodes[id] ?? null,
toNodeTree: () => {
if (id !== 'child-1') throw new Error(`unexpected toNodeTree() for "${id}"`);
return COPIED_TREE;
},
}),
};
}
vi.mock('@craftjs/core', () => ({
useEditor: (collector?: (state: any, query: any) => any) => {
const state = {
nodes: Object.fromEntries(Object.entries(fakeNodes).map(([id, n]) => [id, n])),
};
const query = makeQuery();
const collected = collector ? collector(state, query) : {};
return {
...collected,
actions: {
move: moveMock,
addNodeTree: addNodeTreeMock,
selectNode: selectNodeMock,
delete: deleteMock,
},
query,
};
},
}));
vi.mock('../utils/craft-tree', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/craft-tree')>();
return { ...actual, regenerateTreeIds: vi.fn(actual.regenerateTreeIds) };
});
import { regenerateTreeIds } from '../utils/craft-tree';
const regenerateTreeIdsMock = vi.mocked(regenerateTreeIds);
let container: HTMLDivElement;
let root: Root;
let captured: NodeActions | null = null;
const Probe: React.FC<{ nodeId: string | null }> = ({ nodeId }) => {
captured = useNodeActions(nodeId);
return null;
};
function render(nodeId: string | null) {
container = document.createElement('div');
document.body.appendChild(container);
act(() => {
root = createRoot(container);
root.render(<Probe nodeId={nodeId} />);
});
}
function rerender(nodeId: string | null) {
act(() => {
root.render(<Probe nodeId={nodeId} />);
});
}
function unmount() {
act(() => {
root.unmount();
});
container.remove();
}
beforeEach(() => {
moveMock.mockClear();
addNodeTreeMock.mockClear();
selectNodeMock.mockClear();
deleteMock.mockClear();
regenerateTreeIdsMock.mockClear();
captured = null;
fakeNodes = {
ROOT: { data: { parent: null, nodes: ['parent-1'] } },
'parent-1': { data: { parent: 'ROOT', nodes: ['child-0', 'child-1', 'child-2'] } },
'child-0': { data: { parent: 'parent-1', nodes: [] } },
'child-1': { data: { parent: 'parent-1', nodes: [] } },
'child-2': { data: { parent: 'parent-1', nodes: [] } },
};
});
afterEach(() => {
if (root) unmount();
});
describe('useNodeActions', () => {
test('a middle child can move both up and down', () => {
render('child-1');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(true);
});
test('the first child cannot move up, but can move down', () => {
render('child-0');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(true);
});
test('the last child can move up, but not down', () => {
render('child-2');
expect(captured!.canMoveUp).toBe(true);
expect(captured!.canMoveDown).toBe(false);
});
test('moveUp calls actions.move with idx - 1, moveDown with idx + 2 (Craft.js index semantics)', () => {
render('child-1');
act(() => captured!.moveUp());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 0);
moveMock.mockClear();
act(() => captured!.moveDown());
expect(moveMock).toHaveBeenCalledWith('child-1', 'parent-1', 3);
});
test('moveUp/moveDown at a boundary are no-ops', () => {
render('child-0');
act(() => captured!.moveUp());
expect(moveMock).not.toHaveBeenCalled();
rerender('child-2');
act(() => captured!.moveDown());
expect(moveMock).not.toHaveBeenCalled();
});
test('duplicate inserts the copy immediately after the source (parentId, sourceIndex + 1) and selects it', () => {
render('child-1');
let returned: string | null | undefined;
act(() => {
returned = captured!.duplicate();
});
expect(regenerateTreeIdsMock).toHaveBeenCalledTimes(1);
expect(addNodeTreeMock).toHaveBeenCalledTimes(1);
const [tree, parentId, index] = addNodeTreeMock.mock.calls[0];
// child-1 is at index 1 among ['child-0', 'child-1', 'child-2'] -> insert at 2.
expect(parentId).toBe('parent-1');
expect(index).toBe(2);
expect(tree.rootNodeId).not.toBe(COPIED_TREE.rootNodeId);
expect(selectNodeMock).toHaveBeenCalledTimes(1);
expect(selectNodeMock).toHaveBeenCalledWith(tree.rootNodeId);
expect(returned).toBe(tree.rootNodeId);
});
test('selectParent calls actions.selectNode with the parent id', () => {
render('child-1');
act(() => captured!.selectParent());
expect(selectNodeMock).toHaveBeenCalledWith('parent-1');
});
test('canSelectParent is false at a top-level node (parent is ROOT), true one level deeper', () => {
render('parent-1'); // parent-1's own parent is 'ROOT'
expect(captured!.canSelectParent).toBe(false);
rerender('child-1'); // child-1's parent is 'parent-1', a real node
expect(captured!.canSelectParent).toBe(true);
});
test('deleteNode calls actions.delete with the node id when deletable', () => {
render('child-1');
expect(captured!.canDelete).toBe(true);
act(() => captured!.deleteNode());
expect(deleteMock).toHaveBeenCalledWith('child-1');
});
test('ROOT nodeId disables every action and is a safe no-op', () => {
render('ROOT');
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
act(() => {
captured!.moveUp();
captured!.moveDown();
captured!.duplicate();
captured!.selectParent();
captured!.deleteNode();
});
expect(moveMock).not.toHaveBeenCalled();
expect(addNodeTreeMock).not.toHaveBeenCalled();
expect(selectNodeMock).not.toHaveBeenCalled();
expect(deleteMock).not.toHaveBeenCalled();
});
test('null nodeId disables every action and is a safe no-op', () => {
render(null);
expect(captured!.canMoveUp).toBe(false);
expect(captured!.canMoveDown).toBe(false);
expect(captured!.canDelete).toBe(false);
expect(captured!.canSelectParent).toBe(false);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { useEditor } from '@craftjs/core';
import { regenerateTreeIds } from '../utils/craft-tree';
import { findDeletableTarget } from '../utils/craft-helpers';
export interface NodeActions {
moveUp: () => void;
moveDown: () => void;
/** Duplicates `nodeId`, inserting the copy immediately after the source in
* the parent's children and selecting it. Returns the new node's id (so
* callers -- e.g. `MobileSelectionToolbar` -- can scroll it into view),
* or null if the duplicate could not be performed (no-op for ROOT/null,
* or a caught error). */
duplicate: () => string | null;
deleteNode: () => void;
selectParent: () => void;
/** True if `nodeId` has an earlier sibling under the same parent (so
* `moveUp` would actually move it). False for ROOT/null/no-parent. */
canMoveUp: boolean;
/** True if `nodeId` has a later sibling under the same parent (so
* `moveDown` would actually move it). False for ROOT/null/no-parent. */
canMoveDown: boolean;
/** True if `findDeletableTarget` resolves to a real, deletable node (either
* `nodeId` itself, or an ancestor when `nodeId` is an empty linked-node
* slot whose siblings are all also empty -- see `craft-helpers.ts`). */
canDelete: boolean;
/** True if `nodeId` has a real parent to select -- i.e. the parent is
* neither ROOT nor missing. `selectParent` on a top-level section (whose
* parent IS 'ROOT') would only select the page-wide ROOT node, which has
* no on-canvas outline and no toolbar of its own -- a dead end for a
* mobile user with no way back. False for ROOT/null/no-parent too. */
canSelectParent: boolean;
}
/**
* Shared node-action logic (move up/down, duplicate, delete, select parent)
* extracted from `ContextMenu.tsx` (Phase B) so both the desktop right-click
* menu AND the mobile on-canvas selection toolbar (`MobileSelectionToolbar`)
* drive the exact same behavior from one place instead of two independent
* copies drifting apart.
*
* IMPORTANT gotcha this hook works around: `@craftjs/core`'s `useEditor`
* collector (`@craftjs/utils`' `useCollector`) only synchronously computes
* the collector function ONCE, on this hook's very first mount. After that,
* it updates purely in reaction to the underlying store's OWN change
* notifications, invoking whatever collector closure is current AT THAT
* NOTIFICATION -- so a collector that closes over `nodeId` (an argument that
* changes across renders of the SAME mounted hook instance, e.g. every time
* `MobileSelectionToolbar` re-renders with a newly-selected node) goes stale
* for exactly one render: the cached value from the last store notification
* (computed against the PREVIOUS nodeId) is what gets returned, until some
* unrelated store event happens to trigger a fresh computation. An earlier
* version of this hook computed `canMoveUp`/`canMoveDown`/`canDelete` inside
* such a collector and was caught showing the PREVIOUS selection's move
* boundaries in the mobile toolbar for one render after tapping a new node
* (verified with real Playwright touch taps against a real Craft.js
* document -- a plain mocked `useEditor` in a unit test doesn't reproduce
* this, since a hand-rolled mock has no reason to replicate the real
* library's caching).
*
* The fix: get `actions`/`query` from a collector-FREE `useEditor()` call
* (an always-live reference, same pattern `ContextMenu.tsx` used before this
* extraction) and compute `canMoveUp`/`canMoveDown`/`canDelete` as plain
* synchronous code during render using that live `query` -- never cached.
*
* That still leaves one gap: `nodeId` staying the SAME across renders while
* its position changes (e.g. tapping "Move Up" repeatedly on the same
* still-selected node) needs SOMETHING to trigger a re-render so the boundary
* flags below get recomputed. An earlier version of this hook forced that
* via a second `useEditor((state) => ({ _: state.nodes }))` subscription --
* but subscribing to the WHOLE node map reacts to every `dom` ref
* assignment too (Craft's `connectors.connect(ref)` calls `actions.setDOM`
* synchronously from a React ref callback during COMMIT, i.e. while some
* OTHER component is still mounting), which trips React's "Cannot update a
* component while rendering a different component" warning the moment a
* freshly-added container with children mounts. Callers that need
* `canMoveUp`/`canMoveDown` to refresh after a move they themselves
* triggered (`MobileSelectionToolbar`) should instead bump their OWN local
* state right after calling `moveUp`/`moveDown` -- a plain, local,
* event-handler-triggered re-render, not a store-wide subscription.
*/
export function useNodeActions(nodeId: string | null | undefined): NodeActions {
const { actions, query } = useEditor();
const isRootOrNull = !nodeId || nodeId === 'ROOT';
let canMoveUp = false;
let canMoveDown = false;
let canSelectParent = false;
if (!isRootOrNull) {
try {
const node = query.node(nodeId).get();
const parentId: string | null | undefined = node?.data?.parent;
if (parentId) {
canSelectParent = parentId !== 'ROOT';
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const idx = siblings.indexOf(nodeId);
canMoveUp = idx > 0;
canMoveDown = idx !== -1 && idx < siblings.length - 1;
}
} catch {
// Node no longer exists (e.g. deleted out from under a stale
// reference) -- leave both false.
}
}
const canDelete = !isRootOrNull && !!findDeletableTarget(query, nodeId);
const getParentId = (): string | null => {
if (!nodeId) return null;
try {
const node = query.node(nodeId).get();
return node?.data?.parent || null;
} catch {
return null;
}
};
const duplicate = (): string | null => {
if (!nodeId || nodeId === 'ROOT') return null;
try {
const parentId = getParentId();
if (!parentId) return null;
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree());
// Insert the copy IMMEDIATELY AFTER the source node, not appended at
// the end of the parent -- on a top-level section, "append at end"
// landed the copy at the bottom of the page, off-screen, with the
// ORIGINAL still selected, so a duplicate looked like nothing had
// happened at all. Resolve the source's index among its siblings so
// the copy lands right next to what it was copied from; if that can't
// be resolved for any reason, fall back to the old append behavior
// rather than guessing at an index (or throwing).
let insertIndex: number | undefined;
try {
const siblings: string[] = query.node(parentId).get()?.data?.nodes || [];
const sourceIndex = siblings.indexOf(nodeId);
if (sourceIndex !== -1) insertIndex = sourceIndex + 1;
} catch {
// Leave insertIndex undefined -- addNodeTree appends when omitted.
}
if (insertIndex !== undefined) {
actions.addNodeTree(tree, parentId, insertIndex);
} else {
actions.addNodeTree(tree, parentId);
}
// Select the new copy (not the original) so the toolbar/context menu
// and any on-canvas outline immediately reflect what was just created.
actions.selectNode(tree.rootNodeId);
return tree.rootNodeId;
} catch (e) {
console.error('Duplicate failed:', e);
return null;
}
};
const moveUp = () => {
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);
}
};
const moveDown = () => {
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);
}
};
const selectParent = () => {
if (!nodeId || nodeId === 'ROOT') return;
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
};
const deleteNode = () => {
const target = findDeletableTarget(query, nodeId);
if (!target) return;
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
};
return {
moveUp,
moveDown,
duplicate,
deleteNode,
selectParent,
canMoveUp,
canMoveDown,
canDelete,
canSelectParent,
};
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
export interface VisualViewportInsets {
/** The visual viewport's current height (shrinks when the on-screen
* keyboard opens). Falls back to `window.innerHeight` when
* `visualViewport` isn't supported. */
height: number;
/** Extra inset a `position: fixed`, bottom-anchored element should add to
* its own `bottom` offset to stay clear of the on-screen keyboard --
* `window.innerHeight` minus the visual viewport's bottom edge (its
* height + offsetTop). Zero whenever no keyboard is open, or
* `visualViewport` isn't supported (a safe no-op fallback). */
keyboardInset: number;
}
const ZERO_INSETS: VisualViewportInsets = { height: 0, keyboardInset: 0 };
function computeInsets(): VisualViewportInsets {
if (typeof window === 'undefined') return ZERO_INSETS;
const vv = window.visualViewport;
if (!vv) return { height: window.innerHeight, keyboardInset: 0 };
const keyboardInset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
return { height: vv.height, keyboardInset };
}
/**
* Tracks `window.visualViewport`'s height/offset (item 5, Phase B) so
* `BottomSheet` can stay clear of the on-screen keyboard. `position: fixed`
* elements are positioned against the LAYOUT viewport, which does NOT shrink
* when a mobile keyboard opens -- only the visual viewport does -- so a
* bottom-anchored sheet's inputs can otherwise end up hidden underneath the
* keyboard with no visual indication anything is wrong.
*
* Guards for browsers without `visualViewport` (older WebViews): falls back
* to `{ height: window.innerHeight, keyboardInset: 0 }`, i.e. a no-op, so
* the sheet just keeps its existing (keyboard-unaware) sizing there.
*/
export function useVisualViewportInsets(): VisualViewportInsets {
const [insets, setInsets] = useState<VisualViewportInsets>(computeInsets);
useEffect(() => {
if (typeof window === 'undefined' || !window.visualViewport) return;
const vv = window.visualViewport;
const handleChange = () => setInsets(computeInsets());
handleChange();
vv.addEventListener('resize', handleChange);
vv.addEventListener('scroll', handleChange);
return () => {
vv.removeEventListener('resize', handleChange);
vv.removeEventListener('scroll', handleChange);
};
}, []);
return insets;
}
+17 -57
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useCallback, useRef } from 'react'; import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core'; import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext'; import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target'; import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree'; import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard'; import { getClipboardNodeId, setClipboardNodeId } from '../../hooks/clipboard';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps { interface ContextMenuProps {
visible: boolean; visible: boolean;
@@ -65,19 +65,16 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
} }
}, [nodeId, query]); }, [nodeId, query]);
const duplicate = useCallback(() => { // Shared move/duplicate/delete/select-parent logic (item 1, Phase B) --
if (!nodeId || nodeId === 'ROOT') return; // extracted into `useNodeActions` so the mobile selection toolbar drives
try { // the exact same behavior. Wrapped here purely to also `onClose()` the
const parentId = getParentId(); // menu after each action, same as before the extraction.
if (!parentId) return; const nodeActions = useNodeActions(nodeId);
const tree = regenerateTreeIds(query.node(nodeId).toNodeTree()); const duplicate = useCallback(() => {
actions.addNodeTree(tree, parentId); nodeActions.duplicate();
} catch (e) {
console.error('Duplicate failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const copyNode = useCallback(() => { const copyNode = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
@@ -118,47 +115,19 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, actions, query, onClose]); }, [nodeId, actions, query, onClose]);
const moveUp = useCallback(() => { const moveUp = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveUp();
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);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const moveDown = useCallback(() => { const moveDown = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.moveDown();
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);
}
onClose(); onClose();
}, [nodeId, actions, query, getParentId, onClose]); }, [nodeActions, onClose]);
const selectParent = useCallback(() => { const selectParent = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; nodeActions.selectParent();
const parentId = getParentId();
if (parentId) {
actions.selectNode(parentId);
}
onClose(); onClose();
}, [nodeId, actions, getParentId, onClose]); }, [nodeActions, onClose]);
const askSitesmith = useCallback(() => { const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return; if (!nodeId || nodeId === 'ROOT') return;
@@ -172,18 +141,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
}, [nodeId, query, openSitesmith, onClose]); }, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => { const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId); nodeActions.deleteNode();
if (!target) {
onClose();
return;
}
try {
actions.delete(target);
} catch (e) {
console.error('Delete failed:', e);
}
onClose(); onClose();
}, [nodeId, actions, query, onClose]); }, [nodeActions, onClose]);
if (!visible) return null; 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 { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container'; import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section'; import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout'; import { ColumnLayout } from '../../components/layout/ColumnLayout';
@@ -146,6 +148,8 @@ const categories: CategoryDef[] = [
export const BlocksPanel: React.FC = () => { export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor(); const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => { const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {}; const initial: Record<string, boolean> = {};
categories.forEach((cat, index) => { categories.forEach((cat, index) => {
@@ -158,6 +162,101 @@ export const BlocksPanel: React.FC = () => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] })); 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 ( return (
<div> <div>
{categories.map((category) => { {categories.map((category) => {
@@ -178,24 +277,9 @@ export const BlocksPanel: React.FC = () => {
key={block.id} key={block.id}
className="block-item" className="block-item"
ref={(ref) => { if (ref) connectors.create(ref, block.component); }} ref={(ref) => { if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => { onDoubleClick={() => addBlockNode(block, false)}
try { onClick={isMobile ? () => handleTapToAdd(block) : undefined}
const serialized = JSON.parse(query.serialize()); title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
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}`}
> >
<i className={`fa ${block.icon} block-item-icon`} /> <i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span> <span className="block-item-label">{block.label}</span>
+1
View File
@@ -146,6 +146,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
{...clickableProps(handleActivate)} {...clickableProps(handleActivate)}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className="layer-node-row"
style={{ style={{
position: 'relative', position: 'relative',
display: 'flex', 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 { export interface BottomSheetProps {
open: boolean; open: boolean;
@@ -7,6 +8,10 @@ export interface BottomSheetProps {
children: React.ReactNode; 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 * Mobile-only bottom sheet (Phase A). Slides up from the bottom of the
* viewport to host one of the existing side-panel components * 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 * dialog chrome) -- this is anchored to the bottom, sized to ~65dvh, and
* has its own drag-handle affordance + internally scrollable body, all of * has its own drag-handle affordance + internally scrollable body, all of
* which Modal doesn't need for its centered use cases. * 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 }) => { export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title, children }) => {
useEffect(() => { useEffect(() => {
@@ -35,6 +56,78 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
}; };
}, [open, onClose]); }, [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; if (!open) return null;
return ( return (
@@ -43,16 +136,37 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
style={{ ['--keyboard-inset' as any]: `${keyboardInset}px` }}
> >
{/* Deliberately NOT aria-modal: the tab bar stays reachable/operable {/* Deliberately NOT aria-modal: the tab bar stays reachable/operable
while a sheet is open (tapping another tab swaps sheets directly), while a sheet is open (tapping another tab swaps sheets directly),
so the rest of the screen must not be marked inert to assistive so the rest of the screen must not be marked inert to assistive
tech the way a true modal dialog would be. */} tech the way a true modal dialog would be. */}
<div className="mobile-sheet" role="dialog" aria-label={title}> <div
<div className="mobile-sheet-handle-row"> 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" /> <span className="mobile-sheet-handle" aria-hidden="true" />
</div> </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> <span className="mobile-sheet-title">{title}</span>
<button <button
type="button" type="button"
@@ -63,7 +177,7 @@ export const BottomSheet: React.FC<BottomSheetProps> = ({ open, onClose, title,
<i className="fa fa-times" aria-hidden="true" /> <i className="fa fa-times" aria-hidden="true" />
</button> </button>
</div> </div>
<div className="mobile-sheet-body">{children}</div> <div className="mobile-sheet-body" ref={bodyRef}>{children}</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,213 @@
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 };
});
// Collector-free -- an always-live reference (same pattern as
// GuidedStyles.tsx/BlocksPanel.tsx), used only for imperative DOM lookups
// below (scroll-into-view), never for anything that needs to react to
// store changes on its own.
const { query } = useEditor();
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]);
// Fast-follow item 3: the Styles bottom sheet covers the bottom ~65dvh of
// the canvas, so a selected node sitting anywhere in that band goes
// invisible the instant the sheet opens -- live style edits then have no
// visible effect until the user closes the sheet to check. Whenever the
// sheet becomes 'styles' (however it got opened -- currently only this
// toolbar's own "Style" button, but this is keyed on the sheet/selection
// state rather than the button tap so it stays correct if another entry
// point is added later) scroll the selected node to the top of the
// canvas's scroll area, into the sliver of screen the sheet leaves clear.
useEffect(() => {
if (activeSheet !== 'styles' || !selectedId) return;
try {
query.node(selectedId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch {
// Best-effort -- not fatal if the node/DOM isn't found.
}
}, [activeSheet, selectedId, query]);
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(() => {
const newId = nodeActions.duplicate();
bumpTick();
if (!newId) return;
// Mirror BlocksPanel's tap-to-add pattern: the new node's real DOM
// element isn't attached yet the instant `duplicate()` returns (Craft.js
// mounts it asynchronously after this handler's synchronous state
// update), so defer two animation frames before reading `.dom` off the
// query, same as `handleTapToAdd` does.
requestAnimationFrame(() => {
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.
}
});
});
}, [nodeActions, bumpTick, query]);
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}
disabled={!nodeActions.canSelectParent}
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 React from 'react';
import { createPortal } from 'react-dom';
import { useSiteDesign } from '../../state/SiteDesignContext'; import { useSiteDesign } from '../../state/SiteDesignContext';
import { Modal } from '../../ui/Modal'; import { Modal } from '../../ui/Modal';
@@ -10,7 +11,15 @@ interface HeadCodeModalProps {
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => { export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
const { design, updateDesign } = useSiteDesign(); 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}> <Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}> <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
@@ -82,7 +91,8 @@ export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) =
</button> </button>
</div> </div>
</div> </div>
</Modal> </Modal>,
document.body,
); );
}; };
+117 -4
View File
@@ -34,6 +34,11 @@
/* Phase A (mobile): fixed bottom tab bar height, used to size /* Phase A (mobile): fixed bottom tab bar height, used to size
.editor-container and the safe-area padding math below. */ .editor-container and the safe-area padding math below. */
--mobile-tabbar-height: 58px; --mobile-tabbar-height: 58px;
/* Phase B fast-follow item 4: rendered height of `.mobile-selection-toolbar`
(44px min-height content + 4px top/bottom padding + 1px border) -- used
to pad the canvas scroll area clear of it while it's visible, see
`.editor-canvas.has-mobile-selection-toolbar` below. */
--mobile-selection-toolbar-height: 53px;
/* Mobile-A2: explicit overlay layer scale (was ad-hoc numbers scattered /* Mobile-A2: explicit overlay layer scale (was ad-hoc numbers scattered
across the mobile block + Modal.tsx, causing accidental stacking -- across the mobile block + Modal.tsx, causing accidental stacking --
@@ -1626,6 +1631,90 @@ body {
color: var(--color-accent); color: var(--color-accent);
} }
/* --------------------------------------------------------------------
Mobile selection toolbar (MobileSelectionToolbar.tsx, Phase B item 2)
Bottom-fixed just above the tab bar -- simplest, robust positioning
that never has to track a scrolling/resizing node's DOM rect. Sits at
--z-selection-toolbar (600), above the tab bar (500) and sheet backdrop
(400) so it's never occluded by either, but well below --z-modal
(5000). Hidden entirely (component returns null) whenever a sheet is
open, so it never has to fight the sheet for the same screen space.
-------------------------------------------------------------------- */
.mobile-selection-toolbar {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px));
z-index: var(--z-selection-toolbar);
display: flex;
background: var(--color-bg-elevated);
border-top: 1px solid var(--color-border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
padding: 4px calc(2px + env(safe-area-inset-right, 0px)) 4px calc(2px + env(safe-area-inset-left, 0px));
}
.mobile-selection-toolbar-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 44px;
padding: 4px 2px;
font-size: 9px;
font-weight: 600;
line-height: 1;
color: var(--color-text);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
touch-action: manipulation;
}
.mobile-selection-toolbar-btn i {
font-size: 15px;
}
.mobile-selection-toolbar-btn:disabled {
color: var(--color-text-dim);
opacity: 0.4;
}
.mobile-selection-toolbar-btn.danger {
color: var(--color-danger);
}
.mobile-selection-toolbar-btn.danger.confirming {
background: rgba(239, 68, 68, 0.15);
}
/* Fast-follow item 4: while the toolbar above is visible, give the canvas
scroll area enough extra bottom padding to clear it (on top of the
regular 12px `.editor-canvas` padding, not replacing it), so the last
section of a short page is still reachable by scrolling instead of
sitting permanently underneath the fixed toolbar. */
.editor-canvas.has-mobile-selection-toolbar {
padding-bottom: calc(12px + var(--mobile-selection-toolbar-height));
}
/* Fast-follow item 3: while the Styles sheet is open with a node selected,
`MobileSelectionToolbar`'s scroll-into-view effect needs somewhere to
scroll THE NODE TO even when it's the last thing on the page -- without
extra room below it, the canvas is already at its max scrollTop and the
node's top can't be brought up past wherever it naturally landed
(verified live: the last section stayed stuck under the sheet). A full
extra viewport's worth of bottom padding guarantees any node's top can
always reach the very top of the canvas. Temporary/cosmetic cost (a
stretch of blank canvas below the real content while this is active) is
an acceptable trade for the node never being permanently unreachable.
-------------------------------------------------------------------- */
.editor-canvas.has-mobile-styles-sheet {
padding-bottom: 100vh;
padding-bottom: 100dvh;
}
/* -------------------------------------------------------------------- /* --------------------------------------------------------------------
Bottom sheet (BottomSheet.tsx) Bottom sheet (BottomSheet.tsx)
-------------------------------------------------------------------- */ -------------------------------------------------------------------- */
@@ -1637,8 +1726,13 @@ body {
/* Stop above the bottom tab bar (rather than a full-viewport `inset: 0`) /* Stop above the bottom tab bar (rather than a full-viewport `inset: 0`)
so the tab bar is never covered/blocked by the backdrop -- tapping a so the tab bar is never covered/blocked by the backdrop -- tapping a
different tab while a sheet is open swaps sheets directly instead of different tab while a sheet is open swaps sheets directly instead of
being swallowed by the overlay sitting on top of it. */ being swallowed by the overlay sitting on top of it.
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px)); `--keyboard-inset` (item 5, Phase B) is set inline by BottomSheet.tsx
from `useVisualViewportInsets` -- 0px when no on-screen keyboard is
open (the default below is the no-JS/unsupported-browser fallback),
otherwise the keyboard's height, so the whole sheet lifts clear of it
instead of the keyboard covering its lower portion. */
bottom: calc(var(--mobile-tabbar-height) + env(safe-area-inset-bottom, 0px) + var(--keyboard-inset, 0px));
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
z-index: var(--z-sheet-backdrop); z-index: var(--z-sheet-backdrop);
display: flex; display: flex;
@@ -1647,8 +1741,12 @@ body {
.mobile-sheet { .mobile-sheet {
width: 100%; width: 100%;
max-height: 65vh; /* Capped a second time by the keyboard inset so the sheet's own
max-height: 65dvh; max-height shrinks along with the lifted backdrop -- without this the
sheet could still try to be 65dvh tall from its new (higher) bottom
edge and overflow off the top of the screen. */
max-height: calc(65vh - var(--keyboard-inset, 0px));
max-height: calc(65dvh - var(--keyboard-inset, 0px));
background: var(--color-bg-surface); background: var(--color-bg-surface);
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
@@ -1664,6 +1762,10 @@ body {
justify-content: center; justify-content: center;
padding: 8px 0 4px; padding: 8px 0 4px;
flex-shrink: 0; flex-shrink: 0;
/* Swipe-to-dismiss (item 4, Phase B) tracks its own touchmove here --
`none` stops the browser's native scroll/rubber-band gesture from
competing with it. */
touch-action: none;
} }
.mobile-sheet-handle { .mobile-sheet-handle {
@@ -1680,6 +1782,8 @@ body {
padding: 4px 12px 10px 16px; padding: 4px 12px 10px 16px;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
/* Swipe-to-dismiss (item 4, Phase B) -- see .mobile-sheet-handle-row. */
touch-action: none;
} }
.mobile-sheet-title { .mobile-sheet-title {
@@ -1749,6 +1853,15 @@ body {
height: 40px; height: 40px;
} }
/* Layers panel rows (item 4) -- the reliable fallback selection path when
a node is hard to hit precisely on the canvas by touch. Bumped to the
44px touch-target minimum; tapping a row already selects (LayerNode's
`handleActivate`), unchanged. */
.mobile-sheet-body .layer-node-row {
min-height: 44px;
touch-action: manipulation;
}
/* -------------------------------------------------------------------- /* --------------------------------------------------------------------
Templates modal: category tabs stay horizontally scrollable and never Templates modal: category tabs stay horizontally scrollable and never
shrink below a comfortable touch target; close button enlarged to the shrink below a comfortable touch target; close button enlarged to the
+31
View File
@@ -147,6 +147,37 @@ describe('regenerateTreeIds', () => {
expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' }); expect(input.nodes['root-1'].data.props).toEqual({ alignment: 'left' });
}); });
// Regression test (found via real-browser Playwright touch verification,
// Phase B): a LIVE Craft.js node's `data.type` is the actual component
// function reference (not the `{resolvedName}` string wrapper used only
// in SERIALIZED state) -- `query.node(id).toNodeTree()` returns nodes
// shaped exactly like this. The previous implementation ran
// `structuredClone(oldNode.data)` on the WHOLE data object, which throws
// `DataCloneError` the instant `data.type` is a function, silently
// breaking Duplicate (ContextMenu.tsx / useNodeActions.ts) and Ctrl+V
// Paste (useKeyboardShortcuts.ts) for every real node in the app --
// masked by every other test in this suite using a plain string `type`
// ('div'), and by unit tests elsewhere that mock `@craftjs/core` entirely
// (so `toNodeTree()` never actually returns a function there).
test('does not throw when data.type is a live component function reference', () => {
const ButtonLikeComponent = (props: { text?: string }) => props.text;
const input = makeTree();
input.nodes['root-1'].data.type = ButtonLikeComponent as unknown as Node['data']['type'];
input.nodes['root-1'].data.props = { text: 'Click Me' };
expect(() => regenerateTreeIds(input)).not.toThrow();
const output = regenerateTreeIds(input);
const outputRoot = output.nodes[output.rootNodeId];
// The component reference itself is preserved (shared, not cloned --
// it's a stable reference, not mutable data).
expect(outputRoot.data.type).toBe(ButtonLikeComponent);
// Props are still genuinely deep-cloned (the actual bug fix's point).
expect(outputRoot.data.props).toEqual({ text: 'Click Me' });
(outputRoot.data.props as { text: string }).text = 'changed';
expect(input.nodes['root-1'].data.props).toEqual({ text: 'Click Me' });
});
}); });
describe('flattenTreeForCraft', () => { describe('flattenTreeForCraft', () => {
+21 -6
View File
@@ -366,12 +366,27 @@ export function regenerateTreeIds(tree: NodeTree): NodeTree {
newLinkedNodes[slot] = remapId(linkedId); newLinkedNodes[slot] = remapId(linkedId);
} }
// Deep-clone data so mutable sub-objects (props, custom, etc.) are never // Deep-clone only the mutable sub-objects (props, custom) so they're
// shared by reference between the original node and its regenerated // never shared by reference between the original node and its
// copy. Craft.js's setProp mutates data.props in place, so a shallow // regenerated copy -- Craft.js's setProp mutates data.props in place, so
// copy here would let edits to the duplicate silently corrupt the // a shallow copy here would let edits to the duplicate silently corrupt
// original. // the original.
const clonedData = structuredClone(oldNode.data); //
// Deliberately NOT `structuredClone(oldNode.data)` as a whole: for a
// LIVE Craft.js node (as opposed to a plain serialized one), `data.type`
// is the actual component function/class reference (see the long
// comment on `buildNodeTree` above) -- `structuredClone` cannot clone a
// function and throws `DataCloneError`, which silently broke EVERY
// duplicate/paste in the real app (masked in unit tests that mock
// `@craftjs/core` with plain-data fake nodes, so `toNodeTree()` never
// actually returns a function there). `type` is a stable reference that
// both the original and the duplicate should point at unchanged, so it
// never needed cloning in the first place.
const clonedData = {
...oldNode.data,
props: structuredClone(oldNode.data.props),
custom: structuredClone(oldNode.data.custom),
};
const newNode: Node = { const newNode: Node = {
...oldNode, ...oldNode,