2026-07-13 06:54:15 -07:00
|
|
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import { TopBar } from '../panels/topbar/TopBar';
|
|
|
|
|
import { LeftPanel } from '../panels/left/LeftPanel';
|
|
|
|
|
import { RightPanel } from '../panels/right/RightPanel';
|
2026-07-13 06:54:15 -07:00
|
|
|
import { MobilePanelBar } from '../panels/mobile/MobilePanelBar';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { Canvas } from './Canvas';
|
|
|
|
|
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
|
|
|
|
import { useContextMenu } from '../hooks/useContextMenu';
|
|
|
|
|
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
2026-07-13 06:54:15 -07:00
|
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
2026-07-13 07:30:12 -07:00
|
|
|
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { DeviceMode } from '../types';
|
|
|
|
|
|
2026-07-12 20:55:52 -07:00
|
|
|
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
|
|
|
|
|
|
|
|
|
function loadShowGuides(): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const stored = window.localStorage.getItem(SHOW_GUIDES_STORAGE_KEY);
|
|
|
|
|
return stored === null ? true : stored === '1';
|
|
|
|
|
} catch {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
export const EditorShell: React.FC = () => {
|
2026-07-13 06:54:15 -07:00
|
|
|
const isMobile = useIsMobile();
|
2026-04-05 18:31:16 -07:00
|
|
|
const [device, setDevice] = useState<DeviceMode>('desktop');
|
2026-07-13 06:54:15 -07:00
|
|
|
// 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]);
|
2026-07-12 20:55:52 -07:00
|
|
|
// 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
|
|
|
|
|
// the `.guides-off` class it drives is applied to Canvas's
|
|
|
|
|
// `.canvas-device-frame`, mirroring how `device` is already lifted for
|
|
|
|
|
// the same reason.
|
|
|
|
|
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
|
|
|
|
|
const setShowGuides = useCallback((next: boolean) => {
|
|
|
|
|
setShowGuidesState(next);
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(SHOW_GUIDES_STORAGE_KEY, next ? '1' : '0');
|
|
|
|
|
} catch {
|
|
|
|
|
// Storage unavailable (private browsing, etc.) -- in-memory state still works.
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2026-04-05 18:31:16 -07:00
|
|
|
const { menuState, show: showMenu, hide: hideMenu } = useContextMenu();
|
|
|
|
|
const { query } = useEditor();
|
|
|
|
|
|
|
|
|
|
// Register keyboard shortcuts
|
|
|
|
|
useKeyboardShortcuts();
|
|
|
|
|
|
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
// Find the selected node id
|
|
|
|
|
let nodeId: string | null = null;
|
|
|
|
|
try {
|
|
|
|
|
const selected = query.getEvent('selected').all();
|
|
|
|
|
if (selected.length > 0) {
|
|
|
|
|
nodeId = selected[0];
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// No selection
|
|
|
|
|
}
|
|
|
|
|
showMenu(e.clientX, e.clientY, nodeId);
|
|
|
|
|
}, [query, showMenu]);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-13 07:30:12 -07:00
|
|
|
// Mobile-A2: shared sheet/modal-open state (see MobileChromeContext) --
|
|
|
|
|
// provided around the whole shell so TopBar's Templates/Head Code modal
|
|
|
|
|
// state and MobilePanelBar's sheet state live in one place. Desktop
|
|
|
|
|
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
|
|
|
|
// identically to before (same booleans, just sourced from context).
|
|
|
|
|
<MobileChromeProvider>
|
|
|
|
|
<div className="editor-app">
|
|
|
|
|
<TopBar
|
|
|
|
|
device={device}
|
|
|
|
|
onDeviceChange={setDevice}
|
|
|
|
|
showGuides={showGuides}
|
|
|
|
|
onToggleGuides={() => setShowGuides(!showGuides)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="editor-container">
|
|
|
|
|
{!isMobile && <LeftPanel />}
|
|
|
|
|
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
|
|
|
|
<Canvas device={device} showGuides={showGuides} />
|
|
|
|
|
</div>
|
|
|
|
|
{!isMobile && <RightPanel />}
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
2026-07-13 07:30:12 -07:00
|
|
|
{isMobile && <MobilePanelBar />}
|
|
|
|
|
<ContextMenu
|
|
|
|
|
visible={menuState.visible}
|
|
|
|
|
x={menuState.x}
|
|
|
|
|
y={menuState.y}
|
|
|
|
|
nodeId={menuState.nodeId}
|
|
|
|
|
onClose={hideMenu}
|
|
|
|
|
/>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
2026-07-13 07:30:12 -07:00
|
|
|
</MobileChromeProvider>
|
2026-04-05 18:31:16 -07:00
|
|
|
);
|
|
|
|
|
};
|