feat(builder): mobile-responsive editor chrome (Phase A)

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 06:54:15 -07:00
parent 329a782052
commit 979331b12d
10 changed files with 852 additions and 64 deletions
+155 -54
View File
@@ -4,9 +4,11 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
import { useWhpApi } from '../../hooks/useWhpApi';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { useIsMobile } from '../../hooks/useIsMobile';
import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
@@ -33,6 +35,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const [overflowOpen, setOverflowOpen] = useState(false);
const isMobile = useIsMobile();
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -122,6 +126,156 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
};
}, []);
// Extracted so both the desktop inline Preview button and the mobile
// overflow-menu "Preview" item can trigger the exact same flow.
const handlePreview = useCallback(() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}, [query, headerPage, footerPage, whpConfig, design]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
// Preview, Head Code, the guides toggle, and the device switcher moved
// into the "⋯" overflow menu. This keeps desktop's JSX/behavior below
// completely untouched.
if (isMobile) {
return (
<nav className="topbar topbar-mobile">
<div className="topbar-left">
{isWHP && (
<a
href={whpConfig!.backUrl}
className="topbar-btn icon-only back-btn"
aria-label="Back to Panel"
data-tooltip="Back"
>
<i className="fa fa-arrow-left" />
</a>
)}
</div>
<div className="topbar-right">
<button
className="topbar-btn icon-only"
onClick={() => actions.history.undo()}
disabled={!canUndo}
aria-label="Undo"
data-tooltip="Undo"
>
<i className="fa fa-undo" />
</button>
<button
className="topbar-btn icon-only"
onClick={() => actions.history.redo()}
disabled={!canRedo}
aria-label="Redo"
data-tooltip="Redo"
>
<i className="fa fa-repeat" />
</button>
<span className="topbar-divider" />
<button
className="topbar-btn icon-only primary"
onClick={handleSave}
disabled={saveStatus === 'saving'}
aria-label="Save Draft"
data-tooltip="Save"
>
<i className={`fa ${saveStatus === 'saving' ? 'fa-spinner fa-spin' : 'fa-save'}`} />
</button>
{isWHP && (
<button
className="topbar-btn icon-only publish"
onClick={handlePublish}
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
aria-label="Publish to live site"
data-tooltip="Publish"
>
<i className={`fa ${publishStatus === 'publishing' ? 'fa-spinner fa-spin' : 'fa-globe'}`} />
</button>
)}
<button
type="button"
className={`topbar-btn icon-only overflow-toggle${overflowOpen ? ' active' : ''}`}
onClick={() => setOverflowOpen((v) => !v)}
aria-label="More options"
aria-expanded={overflowOpen}
aria-haspopup="menu"
data-tooltip="More"
>
<i className="fa fa-ellipsis-h" />
</button>
</div>
<TopBarOverflowMenu
open={overflowOpen}
onClose={() => setOverflowOpen(false)}
device={device}
onDeviceChange={onDeviceChange}
showGuides={showGuides}
onToggleGuides={onToggleGuides}
onOpenTemplates={() => setTemplateModalOpen(true)}
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
onPreview={handlePreview}
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
/>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
</nav>
);
}
return (
<nav className="topbar">
<div className="topbar-left">
@@ -182,60 +336,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
>
<i className="fa fa-code" />
</button>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={() => {
try {
const serialized = query.serialize();
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
// Get header HTML
let headerHtml = '';
try {
if (headerPage.craftState) {
headerHtml = exportBodyHtml(headerPage.craftState).html;
}
} catch (e) { console.warn('Header export failed:', e); }
// Get page body HTML
const bodyResult = exportBodyHtml(serialized);
const bodyHtml = bodyResult.html;
// Get footer HTML
let footerHtml = '';
try {
if (footerPage.craftState) {
footerHtml = exportBodyHtml(footerPage.craftState).html;
}
} catch (e) { console.warn('Footer export failed:', e); }
// Compose full page: header + body + footer
const composedBody = headerHtml + bodyHtml + footerHtml;
const result = exportToHtml(serialized, {
title: whpConfig?.siteName || 'Preview',
includeFonts: true,
headCode: design.headCode,
});
// Replace the body in the full document with our composed version.
// Use a function replacer -- a plain string replacer treats
// `$&`/`$1`/`$$` sequences in user content as replacement
// patterns, silently corrupting the output.
let html = result.html;
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
if (bodyMatch) {
html = html.replace(bodyMatch[1], () => composedBody);
}
// Make proxy URLs absolute so they work from the blob: context
const origin = window.location.origin;
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
});
} catch (e) {
console.error('Preview failed:', e);
}
}}>
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
<i className="fa fa-eye" />
</button>