feat(site-builder): tablet/mobile canvas emulates published hide, stacking and full-width

Frame gets data-device; RenderNode mirrors hide flags and selection onto the
dom; editor.css emulates the exported breakpoints inside the narrowed frame.
Also: data-wsb-overridden counts only set values, responsive scope ids are
zone-prefixed (h-/b-/f-) so ROOT no longer collides, and comment delimiters
are stripped from responsive declarations.

Refs #37
This commit is contained in:
shadowdao committed 2026-10-07 11:39:25 -07:00
1 parent ce3db23506
commit 22c59eb970
14 files changed
+269 -32

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import { describe, test, expect, afterEach } from 'vitest';
// vitest strips CSS from `?raw`/`?inline` imports, so read the file directly
// (no @types/node in this project -- hence the ignores).
// @ts-ignore
import { readFileSync } from 'node:fs';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { DeviceProvider, useDevice } from '../state/DeviceContext';
import { Canvas } from './Canvas';
import type { DeviceMode } from '../types';
let h: EditorHarness | null = null;
let setDevice: (d: DeviceMode) => void = () => {};
const Grab: React.FC = () => {
setDevice = useDevice().setDevice;
return null;
};
afterEach(() => {
h?.unmount();
h = null;
});
const frame = () => h!.container.querySelector('.canvas-device-frame') as HTMLElement;
describe('Canvas device frame (#37)', () => {
test('frame carries data-device and follows setDevice()', () => {
h = renderEditorHarness();
h.mountChild(
<DeviceProvider>
<Grab />
<Canvas showGuides />
</DeviceProvider>,
);
expect(frame().getAttribute('data-device')).toBe('desktop');
h.act(() => setDevice('mobile'));
expect(frame().getAttribute('data-device')).toBe('mobile');
expect(frame().style.width).toBe('375px');
h.act(() => setDevice('tablet'));
expect(frame().getAttribute('data-device')).toBe('tablet');
expect(frame().style.width).toBe('768px');
});
});
describe('editor.css device emulation selectors', () => {
const css: string = readFileSync(`${(globalThis as any).process.cwd()}/src/styles/editor.css`, 'utf8');
const region = css.slice(css.indexOf('#37 device emulation'));
test('hide rules per device, selected-node exemption, stacking, fullwidth, badge', () => {
expect(region).toContain('.canvas-device-frame[data-device="desktop"] [data-hide-desktop]:not([data-craft-selected])');
expect(region).toContain('.canvas-device-frame[data-device="tablet"] [data-hide-tablet]:not([data-craft-selected])');
expect(region).toContain('.canvas-device-frame[data-device="mobile"] [data-hide-mobile]:not([data-craft-selected])');
expect(region).toContain('[data-hide-mobile][data-craft-selected]');
expect(region).toContain('[style*="display: flex"][style*="flex-direction: row"]');
expect(region).toContain('flex-direction: column !important');
expect(region).toContain('.canvas-device-frame:not([data-device="desktop"]) [data-fullwidth]');
expect(region).toContain('[data-device="mobile"]::before');
});
});
+3 -2
View File
@@ -26,12 +26,12 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
const renderedHtml = useMemo(() => {
if (!craftState) return null;
try {
const result = exportBodyHtml(craftState);
const result = exportBodyHtml(craftState, zone);
return result.html || null;
} catch {
return null;
}
}, [craftState]);
}, [craftState, zone]);
// Set the rendered HTML into the container via ref (user-authored content)
useEffect(() => {
@@ -188,6 +188,7 @@ export const Canvas: React.FC<CanvasProps> = ({ showGuides }) => {
>
<div
className={`canvas-device-frame${showGuides ? '' : ' guides-off'}`}
data-device={device}
style={{
width,
maxWidth: '100%',
@@ -115,4 +115,16 @@ describe('RenderNode per-device style resolution (real editor)', () => {
// desktop style untouched in the serialized state
expect(JSON.parse(h!.getSerialized()).head1.props.style).toEqual({ fontSize: '40px' });
});
test("data-wsb-overridden ignores ''/null placeholders and counts only set values", () => {
mount(state({ styleMobile: { textAlign: '', fontSize: null } }), 'mobile');
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
h!.unmount();
mount(state({ styleTablet: { textAlign: '' }, styleMobile: { fontSize: '20px' } }), 'mobile');
expect(h2().hasAttribute('data-wsb-overridden')).toBe(true);
h!.unmount();
// tablet device does not see a mobile-only override
mount(state({ styleMobile: { fontSize: '20px' } }), 'tablet');
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
});
});
+34
View File
@@ -15,6 +15,7 @@ let mockNode: {
displayName: string;
parent: string | null;
isCanvas: boolean;
props?: Record<string, unknown>;
};
const selectNodeSpy = vi.fn();
@@ -29,6 +30,7 @@ vi.mock('@craftjs/core', () => ({
displayName: mockNode.displayName,
parent: mockNode.parent,
isCanvas: mockNode.isCanvas,
props: mockNode.props ?? {},
},
};
return { id: mockNode.id, ...(collect ? collect(node) : {}) };
@@ -61,6 +63,7 @@ beforeEach(() => {
displayName: 'Heading',
parent: 'ROOT',
isCanvas: false,
props: {},
};
selectNodeSpy.mockClear();
});
@@ -166,4 +169,35 @@ describe('RenderNode (Editor onRender override)', () => {
container.remove();
nodeDom.remove();
});
test('mirrors hideOnMobile/Tablet/Desktop onto the dom and removes them when toggled off', () => {
mockNode.props = { hideOnMobile: true, hideOnDesktop: true };
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-hide-mobile')).toBe(true);
expect(nodeDom.hasAttribute('data-hide-desktop')).toBe(true);
expect(nodeDom.hasAttribute('data-hide-tablet')).toBe(false);
mockNode.props = { hideOnMobile: false, hideOnTablet: true };
act(() => {
root.render(<RenderNode render={rendered} />);
});
expect(nodeDom.hasAttribute('data-hide-mobile')).toBe(false);
expect(nodeDom.hasAttribute('data-hide-desktop')).toBe(false);
expect(nodeDom.hasAttribute('data-hide-tablet')).toBe(true);
container.remove();
nodeDom.remove();
});
test('selected node dom carries data-craft-selected; cleared on deselect', () => {
mockNode.selected = true;
render(<RenderNode render={rendered} />);
expect(nodeDom.hasAttribute('data-craft-selected')).toBe(true);
mockNode.selected = false;
act(() => {
root.render(<RenderNode render={rendered} />);
});
expect(nodeDom.hasAttribute('data-craft-selected')).toBe(false);
container.remove();
nodeDom.remove();
document.querySelector('.component-indicator')?.remove();
});
});
+27 -6
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { NodeElement, useEditor, useNode } from '@craftjs/core';
import { useDevice } from '../state/DeviceContext';
import { hasAnyOverride, resolveStyleForDevice, overrideFor, type ResponsiveProps } from '../utils/responsive-style';
import { hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
interface RenderNodeProps {
render: React.ReactElement;
@@ -39,6 +39,9 @@ interface RenderNodeProps {
* - `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.
* Counts only SET values (not ''/null placeholders), like export.
* - `data-hide-desktop|tablet|mobile` / `data-craft-selected` (#37): mirror
* the node's hide flags and selection for the device-emulation CSS.
*
* Per-breakpoint styles (#45/#37): when the device is not desktop and the
* node carries `styleTablet`/`styleMobile`, this is the ONE place the
@@ -54,7 +57,7 @@ interface RenderNodeProps {
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { device } = useDevice();
const { id, isSelected, dom, name, parent, isCanvas, isHovered, overridden } = useNode((node) => {
const { id, isSelected, dom, name, parent, isCanvas, isHovered, overridden, hideDesktop, hideTablet, hideMobile } = 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;
@@ -69,15 +72,15 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
parent: node.data.parent,
isCanvas: node.data.isCanvas,
overridden,
hideDesktop: !!node.data.props?.hideOnDesktop,
hideTablet: !!node.data.props?.hideOnTablet,
hideMobile: !!node.data.props?.hideOnMobile,
};
});
// 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));
!!overridden && hasActiveOverride(overridden.props as ResponsiveProps, device);
const badgeRef = useRef<HTMLDivElement>(null);
const active = isSelected && id !== 'ROOT' && !!dom;
@@ -119,6 +122,24 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
}
}, [dom, isHovered]);
// #37: mirror the export's visibility flags and the selection onto the dom;
// editor.css hides `[data-hide-<device>]` for the active device (the
// viewport media queries cannot fire inside a narrowed frame) but keeps the
// selected node visible (dimmed) so it stays editable.
useEffect(() => {
if (!dom) return;
const flags: Array<[string, boolean]> = [
['data-hide-desktop', hideDesktop],
['data-hide-tablet', hideTablet],
['data-hide-mobile', hideMobile],
['data-craft-selected', isSelected],
];
for (const [attr, on] of flags) {
if (on) dom.setAttribute(attr, '');
else dom.removeAttribute(attr);
}
}, [dom, hideDesktop, hideTablet, hideMobile, isSelected]);
useEffect(() => {
if (!dom) return;
if (activeOverride) {