fix(builder): duplicate inserts+selects after source; select-parent/styles-sheet/canvas-pad mobile fixes
Ship-blocking fix (Fable consult): useNodeActions.duplicate() appended the regenerated tree at the end of the parent while leaving the ORIGINAL selected, so duplicating a top-level section landed the copy off-screen at the bottom of the page with no visible change -- shared by both the mobile selection toolbar and the desktop right-click ContextMenu. Now inserts the copy immediately after the source (actions.addNodeTree(tree, parentId, sourceIndex + 1)) and selects it (actions.selectNode(tree.rootNodeId)), falling back to append-at-end if the source's index can't be resolved. Mobile also scrolls the new node into view. Three cheap fast-follows: - canSelectParent on useNodeActions (false when the node's parent is ROOT or missing); MobileSelectionToolbar disables "Select Parent" instead of dead-ending on a page-wide ROOT outline with no toolbar of its own. - Opening the Styles sheet on mobile now scrolls the selected node above the 65dvh sheet; a temporary generous bottom-padding class handles the case where the node is the last thing on the page and there'd otherwise be no room left to scroll it into view. - Canvas gets bottom padding equal to the fixed selection toolbar's height while it's visible, so the last section of a short page isn't stuck permanently underneath it. Desktop duplicate behavior improves (inserts after + selects) via the shared hook; no toHtml changes. Verified live via Playwright at 375px and 1280px (screenshots in craft/scratchpad/mobileB2/). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,11 @@ export const MobileSelectionToolbar: React.FC = () => {
|
||||
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);
|
||||
@@ -76,6 +81,24 @@ export const MobileSelectionToolbar: React.FC = () => {
|
||||
|
||||
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();
|
||||
@@ -104,9 +127,24 @@ export const MobileSelectionToolbar: React.FC = () => {
|
||||
}, [nodeActions, bumpTick]);
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
nodeActions.duplicate();
|
||||
const newId = nodeActions.duplicate();
|
||||
bumpTick();
|
||||
}, [nodeActions, 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;
|
||||
|
||||
@@ -145,6 +183,7 @@ export const MobileSelectionToolbar: React.FC = () => {
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user