feat(site-builder): per-breakpoint style model, device context and responsive export
- utils/breakpoints.ts: single source for 767/768/991/992; head VISIBILITY/RESPONSIVE CSS built from it (flex-stacking rule moved from 768 to the 767 mobile boundary). - utils/responsive-style.ts: styleTablet/styleMobile partial overrides, cascade resolution, pure setters, scoped @media CSS per node. - state/DeviceContext.tsx: real DeviceProvider (owns the one-time mobile forcing from EditorShell); TopBar, overflow menu, ReportIssueModal and Canvas read useDevice() instead of props. - editor/RenderNode.tsx: resolves the effective style for the current device in one place and marks overridden nodes with data-wsb-overridden. Craft passes an opaque <DefaultRender/> to onRender, so the component element is rebuilt with the resolved style (cloneElement cannot work). - utils/html-export.ts: hidden nodes skipped; overrides emitted as one body-level <style data-wsb-responsive> block + data-wsb-r scope attr; injectAttrs skips leading <style> elements. - test-utils/editorHarness.tsx: optional onRender/wrap options. Refs #45 Refs #37 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2180f9a776
commit
5f99df23a7
24 files changed
+1006
-92
No files matched your search
@@ -2,14 +2,13 @@ import React, { useMemo, useRef, useEffect } from 'react';
|
||||
import { Frame, Element, useEditor } from '@craftjs/core';
|
||||
import { Container } from '../components/layout/Container';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { DeviceMode } from '../types';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { DEVICE_WIDTHS } from '../constants/presets';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../state/MobileChromeContext';
|
||||
|
||||
interface CanvasProps {
|
||||
device: DeviceMode;
|
||||
/** Item 10: when false, applies `.guides-off` to hide the dashed drop-target guides. */
|
||||
showGuides: boolean;
|
||||
}
|
||||
@@ -130,7 +129,8 @@ export const EmptyCanvasHint: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Canvas: React.FC<CanvasProps> = ({ device, showGuides }) => {
|
||||
export const Canvas: React.FC<CanvasProps> = ({ showGuides }) => {
|
||||
const { device } = useDevice();
|
||||
const width = DEVICE_WIDTHS[device];
|
||||
const { isEditingHeader, isEditingFooter, headerPage, footerPage } = usePages();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { TopBar } from '../panels/topbar/TopBar';
|
||||
import { LeftPanel } from '../panels/left/LeftPanel';
|
||||
@@ -14,7 +14,7 @@ import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
import { LoadGate } from './LoadGate';
|
||||
import { DirtyTracker } from './DirtyTracker';
|
||||
import { DeviceMode } from '../types';
|
||||
import { DeviceProvider } from '../state/DeviceContext';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
|
||||
@@ -27,28 +27,24 @@ function loadShowGuides(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorShell: React.FC = () => {
|
||||
/**
|
||||
* DeviceProvider sits OUTSIDE the shell body so every hook the body calls
|
||||
* (keyboard shortcuts, commands) sees the shared device, not the default.
|
||||
*/
|
||||
export const EditorShell: React.FC = () => (
|
||||
<DeviceProvider>
|
||||
<EditorShellBody />
|
||||
</DeviceProvider>
|
||||
);
|
||||
|
||||
const EditorShellBody: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [device, setDevice] = useState<DeviceMode>('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
|
||||
// the `.guides-off` class it drives is applied to Canvas's
|
||||
// `.canvas-device-frame`, mirroring how `device` is already lifted for
|
||||
// the same reason.
|
||||
// `.canvas-device-frame`. (The device mode itself is shared through
|
||||
// DeviceContext -- see the EditorShell wrapper above.)
|
||||
const [showGuides, setShowGuidesState] = useState<boolean>(loadShowGuides);
|
||||
const setShowGuides = useCallback((next: boolean) => {
|
||||
setShowGuidesState(next);
|
||||
@@ -91,15 +87,13 @@ export const EditorShell: React.FC = () => {
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<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} />
|
||||
<Canvas showGuides={showGuides} />
|
||||
</div>
|
||||
{!isMobile && <RightPanel />}
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ function mount() {
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
|
||||
@@ -71,7 +71,7 @@ function mount() {
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
<SiteDesignPanel />
|
||||
<GuardProbe />
|
||||
</PageProvider>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { DeviceProvider, useDevice } from '../state/DeviceContext';
|
||||
import { RenderNode } from './RenderNode';
|
||||
import type { DeviceMode } from '../types';
|
||||
|
||||
/**
|
||||
* Real-editor tests (no @craftjs/core mock -- RenderNode.test.tsx mocks it
|
||||
* for the badge tests, and vi.mock is file-wide): the actual RenderNode is
|
||||
* installed as `<Editor onRender>` under a real DeviceProvider, so the
|
||||
* per-device style resolution runs against Craft's real render pipeline.
|
||||
*/
|
||||
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type },
|
||||
isCanvas: type === 'Container',
|
||||
props,
|
||||
displayName: type,
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes,
|
||||
linkedNodes: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const state = (headingProps: Record<string, unknown>) =>
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
||||
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
|
||||
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
|
||||
parent: 'sec1',
|
||||
}),
|
||||
});
|
||||
|
||||
let h: EditorHarness | null = null;
|
||||
let setDevice: (d: DeviceMode) => void = () => {};
|
||||
const DeviceGrab: React.FC = () => {
|
||||
setDevice = useDevice().setDevice;
|
||||
return null;
|
||||
};
|
||||
|
||||
function mount(initialState: string, device: DeviceMode) {
|
||||
h = renderEditorHarness({
|
||||
initialState,
|
||||
onRender: RenderNode,
|
||||
wrap: (tree) => (
|
||||
<DeviceProvider initialDevice={device}>
|
||||
<DeviceGrab />
|
||||
{tree}
|
||||
</DeviceProvider>
|
||||
),
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
const h2 = () => h!.container.querySelector('h2') as HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
h?.unmount();
|
||||
h = null;
|
||||
});
|
||||
|
||||
describe('RenderNode per-device style resolution (real editor)', () => {
|
||||
test('mobile: a styleMobile override is applied to the rendered element', () => {
|
||||
mount(state({ styleMobile: { textAlign: 'center' } }), 'mobile');
|
||||
expect(h2().getAttribute('style')).toContain('text-align: center');
|
||||
expect(h2().style.fontSize).toBe('40px');
|
||||
});
|
||||
|
||||
test('desktop: the same node renders its desktop style only', () => {
|
||||
mount(state({ styleMobile: { textAlign: 'center' } }), 'desktop');
|
||||
expect(h2().getAttribute('style') || '').not.toContain('text-align');
|
||||
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
|
||||
});
|
||||
|
||||
test('tablet: tablet override applies; mobile inherits it and layers its own', () => {
|
||||
mount(state({ styleTablet: { fontSize: '32px' }, styleMobile: { textAlign: 'center' } }), 'tablet');
|
||||
expect(h2().style.fontSize).toBe('32px');
|
||||
expect(h2().style.textAlign).toBe('');
|
||||
h!.act(() => setDevice('mobile'));
|
||||
expect(h2().style.fontSize).toBe('32px');
|
||||
expect(h2().style.textAlign).toBe('center');
|
||||
h!.act(() => setDevice('desktop'));
|
||||
expect(h2().style.fontSize).toBe('40px');
|
||||
expect(h2().style.textAlign).toBe('');
|
||||
});
|
||||
|
||||
test('data-wsb-overridden marks nodes overridden on the current device only', () => {
|
||||
mount(state({ styleMobile: { textAlign: 'center' } }), 'mobile');
|
||||
expect(h2().hasAttribute('data-wsb-overridden')).toBe(true);
|
||||
h!.act(() => setDevice('tablet'));
|
||||
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
|
||||
});
|
||||
|
||||
test('canvas children of an overridden container still render', () => {
|
||||
const s = JSON.parse(state({}));
|
||||
s.sec1.props.styleMobile = { padding: '4px' };
|
||||
mount(JSON.stringify(s), 'mobile');
|
||||
const section = h!.container.querySelector('section') as HTMLElement;
|
||||
expect(section.style.padding).toBe('4px');
|
||||
expect(section.querySelector('h2')).not.toBeNull();
|
||||
expect(h2().textContent).toBe('Hello');
|
||||
});
|
||||
|
||||
test('a prop edit through real actions.setProp is reflected on the mobile canvas', () => {
|
||||
mount(state({}), 'mobile');
|
||||
expect(h2().style.textAlign).toBe('');
|
||||
h!.act(() => {
|
||||
h!.actions.setProp('head1', (p: any) => {
|
||||
p.styleMobile = { textAlign: 'right' };
|
||||
});
|
||||
});
|
||||
expect(h2().style.textAlign).toBe('right');
|
||||
// desktop style untouched in the serialized state
|
||||
expect(JSON.parse(h!.getSerialized()).head1.props.style).toEqual({ fontSize: '40px' });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor, useNode } from '@craftjs/core';
|
||||
import { NodeElement, useEditor, useNode } from '@craftjs/core';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { hasAnyOverride, resolveStyleForDevice, overrideFor, type ResponsiveProps } from '../utils/responsive-style';
|
||||
|
||||
interface RenderNodeProps {
|
||||
render: React.ReactElement;
|
||||
@@ -34,17 +36,48 @@ interface RenderNodeProps {
|
||||
* -- `actions.setNodeEvent` -- is stripped from the public `useEditor()`
|
||||
* API at runtime.) editor.css matches both attributes for the same
|
||||
* outline and suppresses both under `.guides-off`.
|
||||
* - `data-wsb-overridden`: set while the current device mode (tablet/
|
||||
* mobile, from DeviceContext) has per-breakpoint overrides for this node
|
||||
* -- i.e. the canvas is showing something other than the desktop style.
|
||||
*
|
||||
* Per-breakpoint styles (#45/#37): when the device is not desktop and the
|
||||
* node carries `styleTablet`/`styleMobile`, this is the ONE place the
|
||||
* effective style is resolved (`resolveStyleForDevice`), so no component
|
||||
* needs to know about devices. Craft hands `onRender` an opaque
|
||||
* `<DefaultRender/>` element that reads the node's props itself, so the
|
||||
* override cannot be applied with `cloneElement(render, {style})` -- instead
|
||||
* the component element is rebuilt here exactly as Craft's DefaultRender
|
||||
* does (`createElement(type, props, <NodeElement/> children)`) with the
|
||||
* resolved `style`. Nodes without overrides, and every node on desktop,
|
||||
* keep Craft's own `render` untouched.
|
||||
*/
|
||||
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered } = useNode((node) => ({
|
||||
isSelected: node.events.selected,
|
||||
isHovered: node.events.hovered,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
isCanvas: node.data.isCanvas,
|
||||
}));
|
||||
const { device } = useDevice();
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered, overridden } = useNode((node) => {
|
||||
// Keep the collector cheap: the node's type/props/children are only
|
||||
// collected for the (rare) nodes that actually carry overrides.
|
||||
const props = node.data.props as ResponsiveProps | undefined;
|
||||
const overridden = hasAnyOverride(props)
|
||||
? { type: node.data.type, props: node.data.props, childIds: node.data.nodes }
|
||||
: null;
|
||||
return {
|
||||
isSelected: node.events.selected,
|
||||
isHovered: node.events.hovered,
|
||||
dom: node.dom,
|
||||
name: (node.data.props?.aiName as string) || node.data.displayName,
|
||||
parent: node.data.parent,
|
||||
isCanvas: node.data.isCanvas,
|
||||
overridden,
|
||||
};
|
||||
});
|
||||
|
||||
// Overrides that apply on the current device (mobile inherits tablet's).
|
||||
const activeOverride =
|
||||
device !== 'desktop' &&
|
||||
!!overridden &&
|
||||
(Object.keys(overrideFor(overridden.props, device)).length > 0 ||
|
||||
(device === 'mobile' && Object.keys(overrideFor(overridden.props, 'tablet')).length > 0));
|
||||
|
||||
const badgeRef = useRef<HTMLDivElement>(null);
|
||||
const active = isSelected && id !== 'ROOT' && !!dom;
|
||||
@@ -86,11 +119,43 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
}
|
||||
}, [dom, isHovered]);
|
||||
|
||||
if (!active) return <>{render}</>;
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (activeOverride) {
|
||||
dom.setAttribute('data-wsb-overridden', '');
|
||||
} else {
|
||||
dom.removeAttribute('data-wsb-overridden');
|
||||
}
|
||||
}, [dom, activeOverride]);
|
||||
|
||||
let output: React.ReactElement = render;
|
||||
// String-typed (plain DOM) nodes go through Craft's own SimpleElement
|
||||
// connector wrapper, which is not exported -- leave those untouched. Every
|
||||
// resolver component is a React component, so this only skips raw tags.
|
||||
if (device !== 'desktop' && overridden && typeof overridden.type !== 'string') {
|
||||
const { type, props, childIds } = overridden;
|
||||
const children =
|
||||
childIds && childIds.length > 0 ? (
|
||||
<>
|
||||
{childIds.map((childId: string) => (
|
||||
<NodeElement id={childId} key={childId} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
props.children
|
||||
);
|
||||
output = React.createElement(
|
||||
type as React.ElementType,
|
||||
{ ...props, style: resolveStyleForDevice(props as ResponsiveProps, device) },
|
||||
children,
|
||||
);
|
||||
}
|
||||
|
||||
if (!active) return <>{output}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
{render}
|
||||
{output}
|
||||
{createPortal(
|
||||
<div ref={badgeRef} className="component-indicator" style={{ position: 'fixed' }}>
|
||||
<span>{name}</span>
|
||||
|
||||
@@ -112,7 +112,7 @@ async function mount() {
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<Probe />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
|
||||
@@ -96,7 +96,7 @@ async function mount() {
|
||||
<LoadGate />
|
||||
<DirtyTracker />
|
||||
<Probe />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
|
||||
@@ -46,7 +46,7 @@ function render(open = true) {
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
|
||||
root.render(<ReportIssueModal open={open} onClose={vi.fn()} />);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import { usePages } from '../../state/PageContext';
|
||||
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
|
||||
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||
import { editorBuild } from '../../utils/build-stamp';
|
||||
import { useDevice } from '../../state/DeviceContext';
|
||||
|
||||
export interface ReportIssueModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
device: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: { value: ReportCategory; label: string }[] = [
|
||||
@@ -25,7 +25,8 @@ const CATEGORIES: { value: ReportCategory; label: string }[] = [
|
||||
* losing text is a real possibility, not on every keystroke from zero. */
|
||||
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
|
||||
|
||||
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
|
||||
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose }) => {
|
||||
const { device } = useDevice();
|
||||
const { whpConfig } = useEditorConfig();
|
||||
const { activePageId, pages } = usePages();
|
||||
// Guarded with optional chaining: some hosts around this component (e.g.
|
||||
|
||||
@@ -69,7 +69,7 @@ function mount() {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ function render() {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides={false} onToggleGuides={() => {}} />
|
||||
<TopBar showGuides={false} onToggleGuides={() => {}} />
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
@@ -187,3 +187,30 @@ describe('TopBar publish-warnings wiring', () => {
|
||||
expect(container.querySelector('[data-testid="publish-warnings"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar device switcher reads/writes DeviceContext (W0-breakpoints)', () => {
|
||||
test('clicking Tablet updates the shared device for every consumer', async () => {
|
||||
const { DeviceProvider, useDevice } = await import('../../state/DeviceContext');
|
||||
const Probe: React.FC = () => <span data-testid="probe">{useDevice().device}</span>;
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<DeviceProvider>
|
||||
<TopBar showGuides={false} onToggleGuides={() => {}} />
|
||||
<Probe />
|
||||
</DeviceProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
const tablet = container.querySelector<HTMLButtonElement>('.device-btn[aria-label="Tablet"]')!;
|
||||
expect(tablet.getAttribute('aria-pressed')).toBe('false');
|
||||
act(() => {
|
||||
tablet.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(container.querySelector('[data-testid="probe"]')!.textContent).toBe('tablet');
|
||||
expect(tablet.getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { useDevice } from '../../state/DeviceContext';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
import { ReportIssueModal } from './ReportIssueModal';
|
||||
@@ -21,14 +22,13 @@ import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
|
||||
interface TopBarProps {
|
||||
device: DeviceMode;
|
||||
onDeviceChange: (device: DeviceMode) => void;
|
||||
/** Item 10: canvas dashed guide outlines, default ON. */
|
||||
showGuides: boolean;
|
||||
onToggleGuides: () => void;
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuides, onToggleGuides }) => {
|
||||
export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) => {
|
||||
const { device, setDevice: onDeviceChange } = useDevice();
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
|
||||
canUndo: query.history.canUndo(),
|
||||
@@ -377,8 +377,6 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<TopBarOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => setOverflowOpen(false)}
|
||||
device={device}
|
||||
onDeviceChange={onDeviceChange}
|
||||
showGuides={showGuides}
|
||||
onToggleGuides={onToggleGuides}
|
||||
onOpenTemplates={() => setTemplateModalOpen(true)}
|
||||
@@ -391,7 +389,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -516,7 +514,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { useDevice } from '../../state/DeviceContext';
|
||||
|
||||
export interface TopBarOverflowMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
device: DeviceMode;
|
||||
onDeviceChange: (device: DeviceMode) => void;
|
||||
showGuides: boolean;
|
||||
onToggleGuides: () => void;
|
||||
onOpenTemplates: () => void;
|
||||
@@ -30,8 +29,6 @@ export interface TopBarOverflowMenuProps {
|
||||
export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
device,
|
||||
onDeviceChange,
|
||||
showGuides,
|
||||
onToggleGuides,
|
||||
onOpenTemplates,
|
||||
@@ -41,6 +38,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
sitesmithNode,
|
||||
editingDisabled,
|
||||
}) => {
|
||||
const { device, setDevice: onDeviceChange } = useDevice();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, afterEach, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { DeviceProvider, useDevice } from './DeviceContext';
|
||||
import type { DeviceMode } from '../types';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let api: ReturnType<typeof useDevice>;
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
api = useDevice();
|
||||
return <span data-testid="device">{api.device}</span>;
|
||||
};
|
||||
|
||||
function mount(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
const shown = () => container.querySelector('[data-testid="device"]')!.textContent;
|
||||
|
||||
function stubMatchMedia(matches: boolean) {
|
||||
vi.stubGlobal('matchMedia', (q: string) => ({
|
||||
matches, media: q, onchange: null,
|
||||
addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false,
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('DeviceContext', () => {
|
||||
test('without a provider: desktop + a no-op setter', () => {
|
||||
mount(<Probe />);
|
||||
expect(shown()).toBe('desktop');
|
||||
act(() => api.setDevice('mobile'));
|
||||
expect(shown()).toBe('desktop');
|
||||
});
|
||||
|
||||
test('provider defaults to desktop on a desktop viewport', () => {
|
||||
stubMatchMedia(false);
|
||||
mount(<DeviceProvider><Probe /></DeviceProvider>);
|
||||
expect(shown()).toBe('desktop');
|
||||
});
|
||||
|
||||
test('setDevice propagates to every consumer', () => {
|
||||
stubMatchMedia(false);
|
||||
const Other: React.FC = () => <i data-testid="other">{useDevice().device}</i>;
|
||||
mount(<DeviceProvider><Probe /><Other /></DeviceProvider>);
|
||||
act(() => api.setDevice('tablet'));
|
||||
expect(shown()).toBe('tablet');
|
||||
expect(container.querySelector('[data-testid="other"]')!.textContent).toBe('tablet');
|
||||
});
|
||||
|
||||
test('a mobile viewport forces the mobile canvas once, then respects the user choice', () => {
|
||||
stubMatchMedia(true);
|
||||
mount(<DeviceProvider><Probe /></DeviceProvider>);
|
||||
expect(shown()).toBe('mobile');
|
||||
act(() => api.setDevice('desktop'));
|
||||
expect(shown()).toBe('desktop');
|
||||
});
|
||||
|
||||
test('initialDevice seeds the state', () => {
|
||||
stubMatchMedia(false);
|
||||
mount(<DeviceProvider initialDevice={'tablet' as DeviceMode}><Probe /></DeviceProvider>);
|
||||
expect(shown()).toBe('tablet');
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,51 @@
|
||||
import React, { createContext, useContext, useMemo, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
/**
|
||||
* MINIMAL STUB added by W0-commands so the command registry can code against
|
||||
* the `{device, setDevice}` interface. W0-breakpoints owns the real
|
||||
* implementation of this file; on merge, take theirs (the API is the same).
|
||||
* The editor's current device mode (desktop / tablet / mobile canvas).
|
||||
*
|
||||
* Shared state rather than props threaded from EditorShell: the TopBar
|
||||
* switcher, the Canvas frame width, RenderNode's per-device style resolution
|
||||
* (#45) and the command registry all read the same value.
|
||||
*
|
||||
* Without a provider (unit tests, standalone component mounts) the default
|
||||
* is `desktop` with a no-op setter, so consumers never need a guard.
|
||||
*/
|
||||
interface DeviceContextValue {
|
||||
export interface DeviceContextValue {
|
||||
device: DeviceMode;
|
||||
setDevice: (d: DeviceMode) => void;
|
||||
}
|
||||
|
||||
const DeviceCtx = createContext<DeviceContextValue>({ device: 'desktop', setDevice: () => {} });
|
||||
const DEFAULT_VALUE: DeviceContextValue = { device: 'desktop', setDevice: () => {} };
|
||||
|
||||
export const useDevice = () => useContext(DeviceCtx);
|
||||
const DeviceCtx = createContext<DeviceContextValue>(DEFAULT_VALUE);
|
||||
|
||||
export const useDevice = (): DeviceContextValue => useContext(DeviceCtx);
|
||||
|
||||
interface DeviceProviderProps {
|
||||
children: React.ReactNode;
|
||||
/** Starting device (tests). Defaults to `desktop`. */
|
||||
initialDevice?: DeviceMode;
|
||||
}
|
||||
|
||||
export const DeviceProvider: React.FC<DeviceProviderProps> = ({ children, initialDevice = 'desktop' }) => {
|
||||
const [device, setDevice] = useState<DeviceMode>(initialDevice);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Default the canvas to the "mobile" preview width the first time a mobile
|
||||
// viewport is detected, so the frame fits without the user reaching for the
|
||||
// device switcher (tucked in TopBar's overflow menu on mobile). Fires once,
|
||||
// and only while the device is still `desktop` -- it must not fight a device
|
||||
// the user has already picked on a later re-render.
|
||||
const mobileDeviceAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isMobile && !mobileDeviceAppliedRef.current) {
|
||||
mobileDeviceAppliedRef.current = true;
|
||||
setDevice((current) => (current === 'desktop' ? 'mobile' : current));
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
export const DeviceProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [device, setDevice] = useState<DeviceMode>('desktop');
|
||||
const value = useMemo(() => ({ device, setDevice }), [device]);
|
||||
return <DeviceCtx.Provider value={value}>{children}</DeviceCtx.Provider>;
|
||||
};
|
||||
@@ -123,6 +123,12 @@ export interface EditorHarnessOptions {
|
||||
* once `initialState` is given (deserialize replaces ROOT anyway); only
|
||||
* affects the very first (pre-deserialize) paint. */
|
||||
frameTag?: 'div' | 'header' | 'footer';
|
||||
/** Craft.js `<Editor onRender>` override (e.g. the real `RenderNode`).
|
||||
* Omitted → Craft's default (no wrapper), as before. */
|
||||
onRender?: React.ComponentType<{ render: React.ReactElement }>;
|
||||
/** Wrap the whole `<Editor>` tree, e.g. in a context provider
|
||||
* (`(tree) => <DeviceProvider>{tree}</DeviceProvider>`). */
|
||||
wrap?: (tree: React.ReactNode) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface EditorHarness {
|
||||
@@ -168,15 +174,22 @@ export function renderEditorHarness(opts: EditorHarnessOptions = {}): EditorHarn
|
||||
const frameTag = opts.frameTag ?? 'div';
|
||||
|
||||
function paint() {
|
||||
const tree = (
|
||||
<Editor
|
||||
resolver={componentResolver}
|
||||
enabled={true}
|
||||
{...(opts.onRender ? { onRender: opts.onRender } : {})}
|
||||
>
|
||||
<Frame>
|
||||
<Element is={Container} canvas tag={frameTag} style={{}} />
|
||||
</Frame>
|
||||
<Capture apiRef={apiRef} />
|
||||
{extraChild}
|
||||
</Editor>
|
||||
);
|
||||
root.render(
|
||||
<EditorConfigProvider config={null}>
|
||||
<Editor resolver={componentResolver} enabled={true}>
|
||||
<Frame>
|
||||
<Element is={Container} canvas tag={frameTag} style={{}} />
|
||||
</Frame>
|
||||
<Capture apiRef={apiRef} />
|
||||
{extraChild}
|
||||
</Editor>
|
||||
{opts.wrap ? opts.wrap(tree) : tree}
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('empty-save banner Undo', () => {
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<LoadGate />
|
||||
<TopBar device="desktop" onDeviceChange={() => {}} showGuides onToggleGuides={() => {}} />
|
||||
<TopBar showGuides onToggleGuides={() => {}} />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</SaveGuardProvider>
|
||||
|
||||
@@ -54,3 +54,11 @@ export interface StyleProps {
|
||||
}
|
||||
|
||||
export type DeviceMode = 'desktop' | 'tablet' | 'mobile';
|
||||
|
||||
/** Per-breakpoint PARTIAL style overrides layered over `style` (desktop):
|
||||
* tablet = style + styleTablet; mobile = style + styleTablet + styleMobile.
|
||||
* See utils/responsive-style.ts. */
|
||||
export interface ResponsiveStyleProps {
|
||||
styleTablet?: Partial<CSSProperties>;
|
||||
styleMobile?: Partial<CSSProperties>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DeviceMode } from '../types';
|
||||
|
||||
/**
|
||||
* Single source of truth for the builder's responsive breakpoints.
|
||||
*
|
||||
* mobile ≤ 767px
|
||||
* tablet 768px – 991px
|
||||
* desktop ≥ 992px
|
||||
*
|
||||
* Used by the exporter (visibility/flex-stacking CSS, per-node responsive
|
||||
* override blocks) and the editor (device-mode style resolution). The
|
||||
* backend `generateCompiledHTML` emits the same numbers in its head CSS --
|
||||
* keep the two in step (head parity).
|
||||
*/
|
||||
export const BREAKPOINTS = {
|
||||
mobileMax: 767,
|
||||
tabletMin: 768,
|
||||
tabletMax: 991,
|
||||
desktopMin: 992,
|
||||
} as const;
|
||||
|
||||
/** Cascade order: desktop is the base, tablet overrides it, mobile overrides tablet. */
|
||||
export const DEVICE_ORDER: readonly DeviceMode[] = ['desktop', 'tablet', 'mobile'];
|
||||
|
||||
/**
|
||||
* The media query an override for `device` is emitted under. Tablet uses
|
||||
* `max-width:991px` (not a closed 768–991 range) on purpose: mobile inherits
|
||||
* tablet overrides, mirroring `resolveStyleForDevice`'s cascade.
|
||||
*/
|
||||
export function mediaQueryFor(device: 'tablet' | 'mobile'): string {
|
||||
return device === 'tablet'
|
||||
? `(max-width:${BREAKPOINTS.tabletMax}px)`
|
||||
: `(max-width:${BREAKPOINTS.mobileMax}px)`;
|
||||
}
|
||||
|
||||
/** Which device bucket a viewport width falls in. */
|
||||
export function deviceForWidth(px: number): DeviceMode {
|
||||
if (px <= BREAKPOINTS.mobileMax) return 'mobile';
|
||||
if (px <= BREAKPOINTS.tabletMax) return 'tablet';
|
||||
return 'desktop';
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { exportBodyHtml, exportToHtml, buildAnimationScript, ExportOptions } from './html-export';
|
||||
import {
|
||||
exportBodyHtml,
|
||||
exportToHtml,
|
||||
buildAnimationScript,
|
||||
ExportOptions,
|
||||
injectAttrs,
|
||||
RESPONSIVE_CSS_MINIFIED,
|
||||
VISIBILITY_CSS_MINIFIED,
|
||||
} from './html-export';
|
||||
import { DEFAULT_SITE_DESIGN, SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
/**
|
||||
@@ -562,9 +570,17 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
||||
},
|
||||
});
|
||||
// W0-breakpoints deliberately moved the flex-stacking rule from 768 to the
|
||||
// 767 mobile boundary. Undo exactly that one change before hashing so these
|
||||
// baselines still prove nothing ELSE in the head moved.
|
||||
const undo767 = (html: string) => {
|
||||
const from = '@media(max-width:767px){[style*=';
|
||||
expect(html.split(from).length).toBe(2);
|
||||
return html.replace(from, '@media(max-width:768px){[style*=');
|
||||
};
|
||||
|
||||
test('default design document hash matches the pre-move baseline', async () => {
|
||||
const html = exportToHtml(emptyRoot, { title: 'T' }).html;
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
|
||||
expect(html.length).toBe(2164);
|
||||
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
|
||||
});
|
||||
@@ -576,7 +592,7 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
headingFont: '"Playfair Display", serif',
|
||||
accentColor: 'red}</style><script>',
|
||||
};
|
||||
const html = exportToHtml(emptyRoot, { title: 'T', design }).html;
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
|
||||
expect(html.length).toBe(2452);
|
||||
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
|
||||
});
|
||||
@@ -588,3 +604,120 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type },
|
||||
isCanvas: type === 'Container',
|
||||
props,
|
||||
displayName: type,
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes,
|
||||
linkedNodes: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const makeState = (headingProps: Record<string, unknown>, headingExtra: Record<string, unknown> = {}) =>
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
||||
sec1: node('Container', { tag: 'section', style: { padding: '20px' } }, ['head1'], { parent: 'ROOT' }),
|
||||
head1: node('Heading', { text: 'Hello', level: 'h2', style: { fontSize: '40px' }, ...headingProps }, [], {
|
||||
parent: 'sec1',
|
||||
...headingExtra,
|
||||
}),
|
||||
});
|
||||
|
||||
test('a mobile override exports one scoped body <style> block + data-wsb-r on the <h2>', () => {
|
||||
const { html, css } = exportBodyHtml(makeState({ styleMobile: { fontSize: '24px' } }));
|
||||
expect(html.startsWith(
|
||||
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="head1"]{font-size:24px!important}}</style>',
|
||||
)).toBe(true);
|
||||
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="head1">Hello</h2>');
|
||||
expect(css).toBe('');
|
||||
});
|
||||
|
||||
test('the full document carries the block in the BODY, not the head', () => {
|
||||
const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } }));
|
||||
const head = html.slice(0, html.indexOf('</head>'));
|
||||
const body = html.slice(html.indexOf('<body>'));
|
||||
expect(head).not.toContain('data-wsb');
|
||||
expect(body).toContain(
|
||||
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="head1"]{text-align:center!important}}</style>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a state without overrides exports byte-identically to the pre-change exporter', () => {
|
||||
// Captured from the exporter BEFORE this task.
|
||||
const { html } = exportBodyHtml(makeState({}));
|
||||
expect(html).toBe('<div><section style="padding:20px"><h2 style="font-size:40px">Hello</h2></section></div>');
|
||||
expect(exportBodyHtml(makeState({ styleTablet: {}, styleMobile: { fontSize: '' } })).html).toBe(html);
|
||||
});
|
||||
|
||||
test('a node with hidden:true is omitted together with its subtree', () => {
|
||||
const state = JSON.parse(makeState({ styleMobile: { fontSize: '24px' } }));
|
||||
state.sec1.hidden = true;
|
||||
const { html } = exportBodyHtml(JSON.stringify(state));
|
||||
expect(html).toBe('<div></div>');
|
||||
// ...and its overrides do not leak a style block either.
|
||||
expect(html).not.toContain('data-wsb');
|
||||
});
|
||||
|
||||
test('a hidden leaf is omitted, siblings survive', () => {
|
||||
const { html } = exportBodyHtml(makeState({}, { hidden: true }));
|
||||
expect(html).toBe('<div><section style="padding:20px"></section></div>');
|
||||
});
|
||||
|
||||
test('RESPONSIVE_CSS_MINIFIED stacks flex rows at the 767 mobile boundary', () => {
|
||||
expect(RESPONSIVE_CSS_MINIFIED).toContain('@media(max-width:767px){');
|
||||
expect(RESPONSIVE_CSS_MINIFIED).not.toContain('768');
|
||||
expect(VISIBILITY_CSS_MINIFIED).toBe(
|
||||
'@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}',
|
||||
);
|
||||
const pretty = exportToHtml(makeState({}), { minifyCss: false }).html;
|
||||
expect(pretty).toContain('@media (max-width: 767px) {\n [style*="display: flex"]');
|
||||
expect(pretty).not.toContain('max-width: 768px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
|
||||
test('attrs land on the element after a leading <style>', () => {
|
||||
expect(injectAttrs('<style>.b:hover{color:red}</style><a href="#">x</a>', ' data-x')).toBe(
|
||||
'<style>.b:hover{color:red}</style><a href="#" data-x>x</a>',
|
||||
);
|
||||
});
|
||||
|
||||
test('multiple leading style blocks are skipped', () => {
|
||||
expect(injectAttrs('<style>a{}</style><style media="x">b{}</style><div>y</div>', ' data-x')).toBe(
|
||||
'<style>a{}</style><style media="x">b{}</style><div data-x>y</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('void tag after a style block stays well-formed', () => {
|
||||
expect(injectAttrs('<style>i{}</style><img src="a" />', ' data-x')).toBe('<style>i{}</style><img src="a" data-x/>');
|
||||
});
|
||||
|
||||
test('plain html unchanged in behaviour', () => {
|
||||
expect(injectAttrs('<div>x</div>', ' data-x')).toBe('<div data-x>x</div>');
|
||||
expect(injectAttrs('<img src="a" />', ' data-x')).toBe('<img src="a" data-x/>');
|
||||
expect(injectAttrs('<div>x</div>', '')).toBe('<div>x</div>');
|
||||
});
|
||||
|
||||
test('ButtonLink with hover + override: data-wsb-r lands on the <a>, not the <style>', () => {
|
||||
const state = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} },
|
||||
displayName: 'Container', custom: {}, hidden: false, nodes: ['btn1'], linkedNodes: {},
|
||||
},
|
||||
btn1: {
|
||||
type: { resolvedName: 'ButtonLink' }, isCanvas: false, parent: 'ROOT',
|
||||
props: { text: 'Go', href: '#', hoverBg: '#000', style: {}, styleMobile: { width: '100%' } },
|
||||
displayName: 'ButtonLink', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
||||
},
|
||||
});
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="btn1">Go<\/a>/);
|
||||
expect(html).not.toContain('<style data-wsb-r=');
|
||||
expect(html).toContain('</style><a href="#"');
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { escapeHtml, escapeAttr, cssValue, safeImageUrl } from './escape';
|
||||
import { sanitizeContainerTag } from '../components/layout/Container';
|
||||
import { SiteDesign, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
import { TOKEN_VARS } from './tokens';
|
||||
import { BREAKPOINTS } from './breakpoints';
|
||||
import { responsiveCssForNode, responsiveScopeId } from './responsive-style';
|
||||
|
||||
export interface ExportOptions {
|
||||
title?: string;
|
||||
@@ -60,9 +62,15 @@ function buildDataAttrs(props: Record<string, any>): string {
|
||||
* self-close slash, outside the tag). Detect that trailing `/` and insert
|
||||
* the attrs before it instead, yielding well-formed `<img ... data-animation="..."/>`.
|
||||
* Non-void tags (no trailing `/`) are unaffected.
|
||||
*
|
||||
* Leading `<style>…</style>` elements (components such as ButtonLink and
|
||||
* ColumnLayout emit a scoped style block before their element) are skipped,
|
||||
* so the attrs land on the component's real element, not on the `<style>`.
|
||||
*/
|
||||
function injectAttrs(html: string, attrs: string): string {
|
||||
export function injectAttrs(html: string, attrs: string): string {
|
||||
if (!attrs) return html;
|
||||
const prefixLen = leadingStyleLength(html);
|
||||
if (prefixLen > 0) return html.slice(0, prefixLen) + injectAttrs(html.slice(prefixLen), attrs);
|
||||
// Find the first > of the opening tag and inject before it
|
||||
const idx = html.indexOf('>');
|
||||
if (idx === -1) return html;
|
||||
@@ -79,12 +87,33 @@ function injectAttrs(html: string, attrs: string): string {
|
||||
return html.slice(0, idx) + attrs + html.slice(idx);
|
||||
}
|
||||
|
||||
/** Length of any run of `<style …>…</style>` elements (plus whitespace) at the
|
||||
* very start of `html`; 0 when it does not start with one. Only skips when
|
||||
* something follows the style block(s) -- a bare style block is left alone. */
|
||||
function leadingStyleLength(html: string): number {
|
||||
const re = /^\s*<style\b[^>]*>[\s\S]*?<\/style>\s*/i;
|
||||
let len = 0;
|
||||
for (;;) {
|
||||
const m = re.exec(html.slice(len));
|
||||
if (!m) break;
|
||||
len += m[0].length;
|
||||
}
|
||||
return len < html.length ? len : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render a Craft.js node tree to HTML.
|
||||
*
|
||||
* `cssAcc` collects each node's scoped per-breakpoint override CSS
|
||||
* (`responsiveCssForNode`; children before parents -- each rule targets its own
|
||||
* element, so order is irrelevant); the exporters emit it as one
|
||||
* body-level `<style data-wsb-responsive>` block.
|
||||
*/
|
||||
function renderNode(nodes: Record<string, any>, nodeId: string): { html: string } {
|
||||
function renderNode(nodes: Record<string, any>, nodeId: string, cssAcc: string[] = []): { html: string } {
|
||||
const node = nodes[nodeId];
|
||||
if (!node) return { html: '' };
|
||||
// Hidden nodes (and their whole subtree) are left out of the export.
|
||||
if (node.hidden === true) return { html: '' };
|
||||
|
||||
const typeName = node.type?.resolvedName || node.type;
|
||||
const props = node.props || {};
|
||||
@@ -95,20 +124,26 @@ function renderNode(nodes: Record<string, any>, nodeId: string): { html: string
|
||||
|
||||
// Render direct child nodes
|
||||
let childrenHtml = childNodeIds
|
||||
.map((childId: string) => renderNode(nodes, childId).html)
|
||||
.map((childId: string) => renderNode(nodes, childId, cssAcc).html)
|
||||
.join('');
|
||||
|
||||
// Render linked nodes (e.g., Section's inner container)
|
||||
const linkedHtml = Object.values(linkedNodes)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId).html)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId, cssAcc).html)
|
||||
.join('');
|
||||
|
||||
// For linked nodes, the component's toHtml should handle them via childrenHtml
|
||||
// We prioritize linked nodes if direct children are empty
|
||||
const allChildrenHtml = childrenHtml + linkedHtml;
|
||||
|
||||
// Build data attributes for responsive visibility and animations
|
||||
const dataAttrs = buildDataAttrs(props);
|
||||
// Build data attributes for responsive visibility and animations, plus the
|
||||
// scope attribute that this node's per-breakpoint override CSS targets.
|
||||
let dataAttrs = buildDataAttrs(props);
|
||||
const responsiveCss = responsiveCssForNode(nodeId, props);
|
||||
if (responsiveCss) {
|
||||
cssAcc.push(responsiveCss);
|
||||
dataAttrs += ` data-wsb-r="${responsiveScopeId(nodeId)}"`;
|
||||
}
|
||||
|
||||
// Look up component in resolver and call toHtml. `nodeId` is the Craft.js
|
||||
// node id for this node -- unique per node in the tree and stable across
|
||||
@@ -298,22 +333,26 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
|
||||
return lines.join('\n ');
|
||||
}
|
||||
|
||||
// Built from `BREAKPOINTS` (utils/breakpoints.ts). Head CSS -- the backend
|
||||
// `generateCompiledHTML` must emit the minified forms byte-for-byte.
|
||||
const { mobileMax, tabletMin, tabletMax, desktopMin } = BREAKPOINTS;
|
||||
|
||||
const RESPONSIVE_CSS = `
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: ${mobileMax}px) {
|
||||
[style*="display: flex"][style*="flex-direction: row"],
|
||||
[style*="display:flex"][style*="flex-direction:row"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
}`;
|
||||
|
||||
const RESPONSIVE_CSS_MINIFIED = `@media(max-width:768px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
|
||||
export const RESPONSIVE_CSS_MINIFIED = `@media(max-width:${mobileMax}px){[style*="display: flex"][style*="flex-direction: row"],[style*="display:flex"][style*="flex-direction:row"]{flex-direction:column!important}}`;
|
||||
|
||||
const VISIBILITY_CSS = `
|
||||
@media (min-width: 992px) { [data-hide-desktop] { display: none !important; } }
|
||||
@media (min-width: 768px) and (max-width: 991px) { [data-hide-tablet] { display: none !important; } }
|
||||
@media (max-width: 767px) { [data-hide-mobile] { display: none !important; } }`;
|
||||
@media (min-width: ${desktopMin}px) { [data-hide-desktop] { display: none !important; } }
|
||||
@media (min-width: ${tabletMin}px) and (max-width: ${tabletMax}px) { [data-hide-tablet] { display: none !important; } }
|
||||
@media (max-width: ${mobileMax}px) { [data-hide-mobile] { display: none !important; } }`;
|
||||
|
||||
const VISIBILITY_CSS_MINIFIED = `@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}`;
|
||||
export const VISIBILITY_CSS_MINIFIED = `@media(min-width:${desktopMin}px){[data-hide-desktop]{display:none!important}}@media(min-width:${tabletMin}px) and (max-width:${tabletMax}px){[data-hide-tablet]{display:none!important}}@media(max-width:${mobileMax}px){[data-hide-mobile]{display:none!important}}`;
|
||||
|
||||
const ANIMATION_CSS = `
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@@ -401,6 +440,20 @@ ${bodyHtml}${animationScript}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page body: the node tree, preceded by one
|
||||
* `<style data-wsb-responsive>` block when any node carries tablet/mobile
|
||||
* overrides. It lives in the BODY (like ButtonLink/ColumnLayout's scoped
|
||||
* styles), so the stored page HTML carries it and the head stays identical
|
||||
* to the backend's; `css` in the save payload stays `''`.
|
||||
*/
|
||||
function renderBody(nodes: Record<string, any>): string {
|
||||
const cssAcc: string[] = [];
|
||||
const { html } = renderNode(nodes, 'ROOT', cssAcc);
|
||||
const css = cssAcc.join('');
|
||||
return css ? `<style data-wsb-responsive>${css}</style>${html}` : html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export serialized Craft.js state to standalone HTML.
|
||||
*/
|
||||
@@ -413,8 +466,7 @@ export function exportToHtml(
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html: bodyHtml } = renderNode(nodes, 'ROOT');
|
||||
const fullHtml = wrapInDocument(bodyHtml, options);
|
||||
const fullHtml = wrapInDocument(renderBody(nodes), options);
|
||||
return { html: fullHtml, css: '' };
|
||||
} catch (e) {
|
||||
console.error('Export to HTML failed:', e);
|
||||
@@ -433,8 +485,7 @@ export function exportBodyHtml(
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
const { html } = renderNode(nodes, 'ROOT');
|
||||
return { html, css: '' };
|
||||
return { html: renderBody(nodes), css: '' };
|
||||
} catch (e) {
|
||||
console.error('Body export failed:', e);
|
||||
return { html: '', css: '' };
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
RESPONSIVE_STYLE_KEYS,
|
||||
resolveStyleForDevice,
|
||||
overrideFor,
|
||||
hasOverride,
|
||||
withResponsiveValue,
|
||||
withoutOverride,
|
||||
hasAnyOverride,
|
||||
responsiveCssForNode,
|
||||
responsiveScopeId,
|
||||
} from './responsive-style';
|
||||
import { BREAKPOINTS, DEVICE_ORDER, mediaQueryFor, deviceForWidth } from './breakpoints';
|
||||
|
||||
describe('breakpoints', () => {
|
||||
test('values: ≤767 mobile / 768-991 tablet / ≥992 desktop', () => {
|
||||
expect(BREAKPOINTS).toEqual({ mobileMax: 767, tabletMin: 768, tabletMax: 991, desktopMin: 992 });
|
||||
expect(DEVICE_ORDER).toEqual(['desktop', 'tablet', 'mobile']);
|
||||
});
|
||||
|
||||
test('mediaQueryFor', () => {
|
||||
expect(mediaQueryFor('tablet')).toBe('(max-width:991px)');
|
||||
expect(mediaQueryFor('mobile')).toBe('(max-width:767px)');
|
||||
});
|
||||
|
||||
test('deviceForWidth boundaries', () => {
|
||||
expect(deviceForWidth(375)).toBe('mobile');
|
||||
expect(deviceForWidth(767)).toBe('mobile');
|
||||
expect(deviceForWidth(768)).toBe('tablet');
|
||||
expect(deviceForWidth(991)).toBe('tablet');
|
||||
expect(deviceForWidth(992)).toBe('desktop');
|
||||
expect(deviceForWidth(1440)).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStyleForDevice cascade', () => {
|
||||
const props = {
|
||||
style: { fontSize: '40px', color: 'red', textAlign: 'left' as const },
|
||||
styleTablet: { fontSize: '32px' },
|
||||
styleMobile: { fontSize: '24px', textAlign: 'center' as const },
|
||||
};
|
||||
|
||||
test('desktop → style only', () => {
|
||||
expect(resolveStyleForDevice(props, 'desktop')).toEqual(props.style);
|
||||
});
|
||||
|
||||
test('tablet → style + styleTablet', () => {
|
||||
expect(resolveStyleForDevice(props, 'tablet')).toEqual({ fontSize: '32px', color: 'red', textAlign: 'left' });
|
||||
});
|
||||
|
||||
test('mobile → style + styleTablet + styleMobile', () => {
|
||||
expect(resolveStyleForDevice(props, 'mobile')).toEqual({ fontSize: '24px', color: 'red', textAlign: 'center' });
|
||||
});
|
||||
|
||||
test('mobile inherits tablet when it has no own value', () => {
|
||||
expect(
|
||||
resolveStyleForDevice({ style: { padding: '40px' }, styleTablet: { padding: '20px' } }, 'mobile'),
|
||||
).toEqual({ padding: '20px' });
|
||||
});
|
||||
|
||||
test("'' (and null/undefined) in an override does NOT override", () => {
|
||||
const p = {
|
||||
style: { fontSize: '40px', padding: '10px' },
|
||||
styleTablet: { fontSize: '' },
|
||||
styleMobile: { padding: '' as const, margin: undefined },
|
||||
};
|
||||
expect(resolveStyleForDevice(p, 'tablet')).toEqual({ fontSize: '40px', padding: '10px' });
|
||||
expect(resolveStyleForDevice(p, 'mobile')).toEqual({ fontSize: '40px', padding: '10px' });
|
||||
});
|
||||
|
||||
test('missing style objects resolve to {}', () => {
|
||||
expect(resolveStyleForDevice({}, 'mobile')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('overrideFor / hasOverride / hasAnyOverride', () => {
|
||||
const props = { style: { fontSize: '40px' }, styleTablet: { fontSize: '32px', padding: '' } };
|
||||
|
||||
test('overrideFor returns the raw partial; {} for desktop / absent', () => {
|
||||
expect(overrideFor(props, 'tablet')).toEqual({ fontSize: '32px', padding: '' });
|
||||
expect(overrideFor(props, 'desktop')).toEqual({});
|
||||
expect(overrideFor(props, 'mobile')).toEqual({});
|
||||
});
|
||||
|
||||
test('hasOverride requires a non-empty value in that device', () => {
|
||||
expect(hasOverride(props, 'tablet', 'fontSize')).toBe(true);
|
||||
expect(hasOverride(props, 'tablet', 'padding')).toBe(false);
|
||||
expect(hasOverride(props, 'mobile', 'fontSize')).toBe(false);
|
||||
expect(hasOverride(props, 'desktop', 'fontSize')).toBe(false);
|
||||
});
|
||||
|
||||
test('hasAnyOverride', () => {
|
||||
expect(hasAnyOverride(props)).toBe(true);
|
||||
expect(hasAnyOverride({ style: { fontSize: '1px' } })).toBe(false);
|
||||
expect(hasAnyOverride({ styleTablet: {}, styleMobile: { padding: '' } })).toBe(false);
|
||||
expect(hasAnyOverride(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withResponsiveValue / withoutOverride', () => {
|
||||
const base = { text: 'Hi', style: { fontSize: '40px' } };
|
||||
|
||||
test('desktop writes style, leaves overrides alone', () => {
|
||||
const next = withResponsiveValue({ ...base, styleTablet: { fontSize: '30px' } }, 'desktop', 'fontSize', '48px');
|
||||
expect(next).toEqual({ text: 'Hi', style: { fontSize: '48px' }, styleTablet: { fontSize: '30px' } });
|
||||
});
|
||||
|
||||
test('tablet writes styleTablet, mobile writes styleMobile, desktop style untouched', () => {
|
||||
const t = withResponsiveValue(base, 'tablet', 'fontSize', '32px');
|
||||
expect(t).toEqual({ text: 'Hi', style: { fontSize: '40px' }, styleTablet: { fontSize: '32px' } });
|
||||
const m = withResponsiveValue(t, 'mobile', 'textAlign', 'center');
|
||||
expect(m.styleMobile).toEqual({ textAlign: 'center' });
|
||||
expect(m.styleTablet).toEqual({ fontSize: '32px' });
|
||||
expect(m.style).toEqual({ fontSize: '40px' });
|
||||
});
|
||||
|
||||
test('is pure (input not mutated)', () => {
|
||||
const input = { style: { fontSize: '40px' }, styleTablet: { padding: '1px' } };
|
||||
const snapshot = JSON.stringify(input);
|
||||
withResponsiveValue(input, 'tablet', 'fontSize', '2px');
|
||||
withoutOverride(input, 'tablet', 'padding');
|
||||
expect(JSON.stringify(input)).toBe(snapshot);
|
||||
});
|
||||
|
||||
test('throws for a non-responsive key on tablet/mobile, allows it on desktop', () => {
|
||||
expect(() => withResponsiveValue(base, 'tablet', 'color', 'red')).toThrow();
|
||||
expect(() => withResponsiveValue(base, 'mobile', 'backgroundColor', 'red')).toThrow();
|
||||
expect(withResponsiveValue(base, 'desktop', 'color', 'red').style).toEqual({ fontSize: '40px', color: 'red' });
|
||||
});
|
||||
|
||||
test("'' on tablet/mobile clears the override", () => {
|
||||
const p = { style: {}, styleMobile: { fontSize: '20px', padding: '4px' } };
|
||||
expect(withResponsiveValue(p, 'mobile', 'fontSize', '').styleMobile).toEqual({ padding: '4px' });
|
||||
});
|
||||
|
||||
test('withoutOverride removes only that key, keeps an (empty) object', () => {
|
||||
const p = { styleTablet: { fontSize: '20px' } };
|
||||
expect(withoutOverride(p, 'tablet', 'fontSize')).toEqual({ styleTablet: {} });
|
||||
expect(withoutOverride(p, 'desktop', 'fontSize')).toBe(p);
|
||||
});
|
||||
|
||||
test('RESPONSIVE_STYLE_KEYS covers the layout + type-scale set', () => {
|
||||
for (const k of ['fontSize', 'textAlign', 'padding', 'marginTop', 'gap', 'order', 'flexDirection', 'maxWidth']) {
|
||||
expect(RESPONSIVE_STYLE_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
expect(RESPONSIVE_STYLE_KEYS.has('color')).toBe(false);
|
||||
expect(RESPONSIVE_STYLE_KEYS.size).toBe(27);
|
||||
});
|
||||
});
|
||||
|
||||
describe('responsiveCssForNode', () => {
|
||||
test("'' when no overrides", () => {
|
||||
expect(responsiveCssForNode('abc', { style: { fontSize: '40px' } })).toBe('');
|
||||
expect(responsiveCssForNode('abc', { styleTablet: {}, styleMobile: { fontSize: '' } })).toBe('');
|
||||
});
|
||||
|
||||
test('exact string for a two-key example (kebab-case, !important on every declaration)', () => {
|
||||
expect(
|
||||
responsiveCssForNode('node_1', {
|
||||
style: { fontSize: '40px' },
|
||||
styleMobile: { fontSize: '24px', textAlign: 'center' },
|
||||
}),
|
||||
).toBe('@media (max-width:767px){[data-wsb-r="node_1"]{font-size:24px!important;text-align:center!important}}');
|
||||
});
|
||||
|
||||
test('tablet block precedes mobile block', () => {
|
||||
expect(
|
||||
responsiveCssForNode('n', { styleTablet: { paddingTop: '10px' }, styleMobile: { lineHeight: '1.2' } }),
|
||||
).toBe(
|
||||
'@media (max-width:991px){[data-wsb-r="n"]{padding-top:10px!important}}' +
|
||||
'@media (max-width:767px){[data-wsb-r="n"]{line-height:1.2!important}}',
|
||||
);
|
||||
});
|
||||
|
||||
test('breakout characters in values are sanitised', () => {
|
||||
const css = responsiveCssForNode('n', {
|
||||
styleMobile: { fontSize: '1px}body{display:none;color:red</style><script>x()</script>' as any },
|
||||
});
|
||||
expect(css).not.toContain('</style>');
|
||||
expect(css).not.toContain('<script>');
|
||||
expect(css).not.toContain(';color');
|
||||
// Only the scoped rule's own braces survive.
|
||||
expect(css.match(/[{}]/g)!.join('')).toBe('{{}}');
|
||||
});
|
||||
|
||||
test('a malicious KEY is dropped', () => {
|
||||
const css = responsiveCssForNode('n', { styleMobile: { '"]{x:y}': '1px', fontSize: '2px' } as any });
|
||||
expect(css).toBe('@media (max-width:767px){[data-wsb-r="n"]{font-size:2px!important}}');
|
||||
});
|
||||
|
||||
test('scope id is stripped to [A-Za-z0-9_-]', () => {
|
||||
expect(responsiveScopeId('a"b]c d_-9')).toBe('abcd_-9');
|
||||
expect(responsiveCssForNode('"]', { styleMobile: { fontSize: '2px' } })).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { cssPropsToString } from './style-helpers';
|
||||
import { mediaQueryFor } from './breakpoints';
|
||||
|
||||
/**
|
||||
* Per-breakpoint style overrides (site-builder #45 / #37).
|
||||
*
|
||||
* A node's `style` is the desktop (base) style. `styleTablet` / `styleMobile`
|
||||
* are optional PARTIAL objects layered on top of it -- they never touch
|
||||
* `style`, so editing tablet/mobile can never change desktop. Cascade:
|
||||
* desktop → style
|
||||
* tablet → style + styleTablet
|
||||
* mobile → style + styleTablet + styleMobile
|
||||
* The editor applies the cascade in ONE place (`editor/RenderNode.tsx`) and
|
||||
* the exporter emits the same cascade as scoped `@media` rules
|
||||
* (`responsiveCssForNode`), so Preview/Published match the canvas.
|
||||
*
|
||||
* v1 scope: only values under `props.style` participate. Device-relevant
|
||||
* props that live outside `style` (e.g. HeroSimple `minHeight`/`textAlign`,
|
||||
* ColumnLayout `gap`) are not overridable yet.
|
||||
*
|
||||
* Pure module -- no React runtime import.
|
||||
*/
|
||||
|
||||
/** Style keys that may carry a tablet/mobile override (layout + type scale). */
|
||||
export const RESPONSIVE_STYLE_KEYS: ReadonlySet<string> = new Set([
|
||||
'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'margin',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'padding',
|
||||
'fontSize', 'lineHeight', 'letterSpacing', 'textAlign',
|
||||
'width', 'maxWidth', 'minWidth', 'height', 'minHeight', 'maxHeight',
|
||||
'display', 'flexDirection', 'flexWrap', 'alignItems', 'justifyContent', 'gap', 'order',
|
||||
]);
|
||||
|
||||
export type ResponsiveProps = {
|
||||
style?: CSSProperties;
|
||||
styleTablet?: Partial<CSSProperties>;
|
||||
styleMobile?: Partial<CSSProperties>;
|
||||
};
|
||||
|
||||
type OverrideDevice = 'tablet' | 'mobile';
|
||||
type StyleRecord = Record<string, unknown>;
|
||||
|
||||
const OVERRIDE_KEY: Record<OverrideDevice, 'styleTablet' | 'styleMobile'> = {
|
||||
tablet: 'styleTablet',
|
||||
mobile: 'styleMobile',
|
||||
};
|
||||
|
||||
/** `''`, `null` and `undefined` mean "not set" -- they never override. */
|
||||
const isSet = (v: unknown): boolean => v !== undefined && v !== null && v !== '';
|
||||
|
||||
const asRecord = (o: unknown): StyleRecord =>
|
||||
o && typeof o === 'object' && !Array.isArray(o) ? (o as StyleRecord) : {};
|
||||
|
||||
/** Only the entries of a partial that actually carry a value. */
|
||||
function setEntries(partial: unknown): StyleRecord {
|
||||
const out: StyleRecord = {};
|
||||
for (const [k, v] of Object.entries(asRecord(partial))) {
|
||||
if (isSet(v)) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The effective style for `device` (see cascade above). */
|
||||
export function resolveStyleForDevice(props: ResponsiveProps, device: DeviceMode): CSSProperties {
|
||||
const base = asRecord(props.style);
|
||||
if (device === 'desktop') return base as CSSProperties;
|
||||
const tablet = setEntries(props.styleTablet);
|
||||
if (device === 'tablet') return { ...base, ...tablet } as CSSProperties;
|
||||
return { ...base, ...tablet, ...setEntries(props.styleMobile) } as CSSProperties;
|
||||
}
|
||||
|
||||
/** The raw partial stored for `device` (`{}` for desktop or when absent). */
|
||||
export function overrideFor(props: ResponsiveProps, device: DeviceMode): Partial<CSSProperties> {
|
||||
if (device === 'desktop') return {};
|
||||
return asRecord(props[OVERRIDE_KEY[device]]) as Partial<CSSProperties>;
|
||||
}
|
||||
|
||||
/** True when `device`'s own partial carries a non-empty value for `key`. */
|
||||
export function hasOverride(props: ResponsiveProps, device: DeviceMode, key: string): boolean {
|
||||
if (device === 'desktop') return false;
|
||||
return isSet(asRecord(props[OVERRIDE_KEY[device]])[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure setter: desktop writes `style[key]`; tablet/mobile write the key into
|
||||
* that device's partial. An empty value on tablet/mobile clears the override
|
||||
* (falls back to the inherited value). Throws for a non-responsive key on
|
||||
* tablet/mobile -- callers gate their controls on `RESPONSIVE_STYLE_KEYS`.
|
||||
*/
|
||||
export function withResponsiveValue<T extends ResponsiveProps>(
|
||||
props: T,
|
||||
device: DeviceMode,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): T & ResponsiveProps {
|
||||
if (device === 'desktop') {
|
||||
return { ...props, style: { ...asRecord(props.style), [key]: value } as CSSProperties };
|
||||
}
|
||||
if (!RESPONSIVE_STYLE_KEYS.has(key)) {
|
||||
throw new Error(`"${key}" cannot be overridden per device`);
|
||||
}
|
||||
if (!isSet(value)) return withoutOverride(props, device, key);
|
||||
const field = OVERRIDE_KEY[device];
|
||||
return { ...props, [field]: { ...asRecord(props[field]), [key]: value } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure: removes `key` from `device`'s partial. The partial is kept as an
|
||||
* (possibly empty) object rather than deleted so a caller that merges the
|
||||
* result into Craft props via `Object.assign` really does clear it.
|
||||
*/
|
||||
export function withoutOverride<T extends ResponsiveProps>(
|
||||
props: T,
|
||||
device: DeviceMode,
|
||||
key: string,
|
||||
): T & ResponsiveProps {
|
||||
if (device === 'desktop') return props;
|
||||
const field = OVERRIDE_KEY[device];
|
||||
const next = { ...asRecord(props[field]) };
|
||||
delete next[key];
|
||||
return { ...props, [field]: next };
|
||||
}
|
||||
|
||||
/** True when either override partial carries at least one value. */
|
||||
export function hasAnyOverride(props: ResponsiveProps | undefined | null): boolean {
|
||||
if (!props) return false;
|
||||
return (
|
||||
Object.keys(setEntries(props.styleTablet)).length > 0 ||
|
||||
Object.keys(setEntries(props.styleMobile)).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Craft ids are already `[A-Za-z0-9_-]`; strip anything else defensively. */
|
||||
export function responsiveScopeId(nodeId: string): string {
|
||||
return String(nodeId).replace(/[^A-Za-z0-9_-]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* One declaration through the same sanitiser as inline styles, plus the
|
||||
* extra characters that only matter inside a `<style>` block: `{`/`}` would
|
||||
* otherwise end the rule and open a new, unscoped one.
|
||||
*/
|
||||
function declaration(key: string, value: unknown): string {
|
||||
const decl = cssPropsToString({ [key]: value } as CSSProperties).replace(/[{}]/g, '');
|
||||
return decl ? `${decl}!important` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped CSS for a node's overrides, or `''` when it has none:
|
||||
* `@media (max-width:991px){[data-wsb-r="<id>"]{k:v!important;…}}@media (max-width:767px){…}`.
|
||||
* `!important` is required because the base value is an inline `style=""`.
|
||||
*/
|
||||
export function responsiveCssForNode(scopeAttr: string, props: ResponsiveProps): string {
|
||||
const scope = responsiveScopeId(scopeAttr);
|
||||
if (!scope) return '';
|
||||
let css = '';
|
||||
for (const device of ['tablet', 'mobile'] as const) {
|
||||
const decls = Object.entries(setEntries(props[OVERRIDE_KEY[device]]))
|
||||
.map(([k, v]) => declaration(k, v))
|
||||
.filter(Boolean);
|
||||
if (decls.length === 0) continue;
|
||||
css += `@media ${mediaQueryFor(device)}{[data-wsb-r="${scope}"]{${decls.join(';')}}}`;
|
||||
}
|
||||
return css;
|
||||
}
|
||||
Reference in new issue
Block a user