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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user