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>
56 lines
2.3 KiB
TypeScript
56 lines
2.3 KiB
TypeScript
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;
|
|
}
|