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:
1 parent
ce3db23506
commit
22c59eb970
14 files changed
+269
-32
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user