From 979331b12d0332d4f3a42ed9567603e008b60543 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 13 Jul 2026 06:54:15 -0700 Subject: [PATCH 1/2] feat(builder): mobile-responsive editor chrome (Phase A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the Craft.js editor usable on phones (≤768px) without touching desktop layout/behavior: a useIsMobile() hook gates a bottom tab bar + sheets (hosting the existing Blocks/Pages/Layers/Assets/Styles panels unchanged) in place of the side panels, a collapsed TopBar with a "..." overflow menu, 44px touch targets, 16px inputs, dvh/safe-area-aware sizing, and small copy/overflow fixes (empty-canvas hint, Templates modal tabs + close button). Co-Authored-By: Claude Opus 4.8 (1M context) --- craft/index.html | 2 +- craft/src/editor/Canvas.tsx | 8 +- craft/src/editor/EditorShell.tsx | 23 +- craft/src/hooks/useIsMobile.ts | 72 ++++ craft/src/panels/mobile/BottomSheet.tsx | 66 ++++ craft/src/panels/mobile/MobilePanelBar.tsx | 69 ++++ craft/src/panels/topbar/TemplateModal.tsx | 10 +- craft/src/panels/topbar/TopBar.tsx | 209 ++++++++--- .../src/panels/topbar/TopBarOverflowMenu.tsx | 110 ++++++ craft/src/styles/editor.css | 347 +++++++++++++++++- 10 files changed, 852 insertions(+), 64 deletions(-) create mode 100644 craft/src/hooks/useIsMobile.ts create mode 100644 craft/src/panels/mobile/BottomSheet.tsx create mode 100644 craft/src/panels/mobile/MobilePanelBar.tsx create mode 100644 craft/src/panels/topbar/TopBarOverflowMenu.tsx diff --git a/craft/index.html b/craft/index.html index a0bd55e..d57a881 100644 --- a/craft/index.html +++ b/craft/index.html @@ -2,7 +2,7 @@ - + Site Builder diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index ba3eab2..e50ec05 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -5,6 +5,7 @@ import { usePages } from '../state/PageContext'; import { DeviceMode } from '../types'; import { DEVICE_WIDTHS } from '../constants/presets'; import { exportBodyHtml } from '../utils/html-export'; +import { useIsMobile } from '../hooks/useIsMobile'; interface CanvasProps { device: DeviceMode; @@ -112,13 +113,18 @@ export const EmptyCanvasHint: React.FC = () => { isDragging: state.events.dragged.size > 0, }; }); + const isMobile = useIsMobile(); if (!isEmpty || isDragging) return null; return (
- Drag blocks from the left panel, or pick a Template to start. + + {isMobile + ? <>Tap Blocks below to add content, or pick a Template to start. + : 'Drag blocks from the left panel, or pick a Template to start.'} +
); }; diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index fb45fb6..848c75d 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -1,12 +1,14 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useEditor } from '@craftjs/core'; import { TopBar } from '../panels/topbar/TopBar'; import { LeftPanel } from '../panels/left/LeftPanel'; import { RightPanel } from '../panels/right/RightPanel'; +import { MobilePanelBar } from '../panels/mobile/MobilePanelBar'; import { Canvas } from './Canvas'; import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { useContextMenu } from '../hooks/useContextMenu'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; +import { useIsMobile } from '../hooks/useIsMobile'; import { DeviceMode } from '../types'; const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides'; @@ -21,7 +23,21 @@ function loadShowGuides(): boolean { } export const EditorShell: React.FC = () => { + const isMobile = useIsMobile(); const [device, setDevice] = useState('desktop'); + // Phase A: default the canvas to the "mobile" preview width the first + // time we detect a mobile viewport, so the frame fits without the user + // having to reach for the device switcher (now tucked in TopBar's + // overflow menu on mobile). Only fires once, and only if the device is + // still at its initial default -- it must not fight a device the user + // has already picked (e.g. from the overflow menu) on a later re-render. + const mobileDeviceAppliedRef = useRef(false); + useEffect(() => { + if (isMobile && !mobileDeviceAppliedRef.current) { + mobileDeviceAppliedRef.current = true; + setDevice((current) => (current === 'desktop' ? 'mobile' : current)); + } + }, [isMobile]); // Item 10: canvas dashed "guide" outlines toggle -- default ON, persisted // so the choice survives a reload. Lifted here (rather than owned by // TopBar or Canvas alone) because the toggle button lives in TopBar but @@ -67,12 +83,13 @@ export const EditorShell: React.FC = () => { onToggleGuides={() => setShowGuides(!showGuides)} />
- + {!isMobile && }
- + {!isMobile && }
+ {isMobile && } (computeIsMobile); + + useEffect(() => { + if (typeof window === 'undefined') return; + + if (typeof window.matchMedia === 'function') { + let mql: MediaQueryList | null = null; + try { + mql = window.matchMedia(MOBILE_QUERY); + } catch { + mql = null; + } + + if (mql) { + const handleChange = () => setIsMobile(mql!.matches); + handleChange(); + + if (typeof mql.addEventListener === 'function') { + mql.addEventListener('change', handleChange); + return () => mql!.removeEventListener('change', handleChange); + } + + // Safari < 14 only supports the deprecated addListener/removeListener pair. + const legacyMql = mql as MediaQueryList & { + addListener?: (listener: () => void) => void; + removeListener?: (listener: () => void) => void; + }; + legacyMql.addListener?.(handleChange); + return () => legacyMql.removeListener?.(handleChange); + } + } + + const handleResize = () => setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT_PX); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return isMobile; +} diff --git a/craft/src/panels/mobile/BottomSheet.tsx b/craft/src/panels/mobile/BottomSheet.tsx new file mode 100644 index 0000000..920920b --- /dev/null +++ b/craft/src/panels/mobile/BottomSheet.tsx @@ -0,0 +1,66 @@ +import React, { useEffect } from 'react'; + +export interface BottomSheetProps { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; +} + +/** + * Mobile-only bottom sheet (Phase A). Slides up from the bottom of the + * viewport to host one of the existing side-panel components + * (BlocksPanel/PagesPanel/LayersPanel/AssetsPanel/GuidedStyles) unchanged, + * as the mobile replacement for the desktop fixed left/right panel columns. + * See `MobilePanelBar`, which owns which sheet (if any) is open. + * + * Deliberately NOT a generic replacement for `src/ui/Modal.tsx` (centered + * 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. + */ +export const BottomSheet: React.FC = ({ open, onClose, title, children }) => { + useEffect(() => { + if (!open) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = prevOverflow; + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+
+ {title} +