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
@@ -93,6 +93,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
'data-fullwidth': fullWidth ? '' : undefined,
|
||||
id: idValue,
|
||||
className: cssClass || undefined,
|
||||
},
|
||||
|
||||
@@ -89,6 +89,9 @@ export const GRADIENTS = [
|
||||
{ label: 'None', value: 'none' },
|
||||
];
|
||||
|
||||
/* Canvas frame widths per device. Each sits inside the published breakpoint
|
||||
range (utils/breakpoints.ts): mobile <=767, tablet 768-991, desktop >=992,
|
||||
so the editor's emulation (#37) and the exported media queries agree. */
|
||||
export const DEVICE_WIDTHS: Record<string, string> = {
|
||||
desktop: '100%',
|
||||
tablet: '768px',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { exportBodyHtml, type ExportZone } from '../utils/html-export';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
import { useSaveGuard } from '../state/SaveGuardContext';
|
||||
@@ -80,17 +80,19 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
const usedStates = new Set<string>();
|
||||
/** Export a stored (not live) state, reusing the memo. Never throws for a bad state:
|
||||
* exportBodyHtml swallows its own errors and returns empty html (see below). */
|
||||
const exportStored = (state: string): ExportResult => {
|
||||
usedStates.add(state);
|
||||
const hit = exportCache.get(state);
|
||||
const exportStored = (state: string, zone: ExportZone = 'body'): ExportResult => {
|
||||
// Zone is part of the key: the responsive scope prefix differs per zone.
|
||||
const key = `${zone}\0${state}`;
|
||||
usedStates.add(key);
|
||||
const hit = exportCache.get(key);
|
||||
if (hit) return hit;
|
||||
const result = exportBodyHtml(state);
|
||||
const result = exportBodyHtml(state, zone);
|
||||
// exportBodyHtml swallows its own errors and returns empty html; a
|
||||
// non-empty state yielding '' is a failed export, so retry it next build.
|
||||
if (result.html === '' && state !== '') {
|
||||
console.error('HTML export produced no output; not caching, will retry next save.');
|
||||
} else {
|
||||
exportCache.set(state, result);
|
||||
exportCache.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -116,7 +118,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerCraftState) {
|
||||
headerHtml = (isEditingHeader ? exportBodyHtml(headerCraftState) : exportStored(headerCraftState)).html;
|
||||
headerHtml = (isEditingHeader ? exportBodyHtml(headerCraftState, 'header') : exportStored(headerCraftState, 'header')).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Header HTML export failed:', e);
|
||||
@@ -125,7 +127,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerCraftState) {
|
||||
footerHtml = (isEditingFooter ? exportBodyHtml(footerCraftState) : exportStored(footerCraftState)).html;
|
||||
footerHtml = (isEditingFooter ? exportBodyHtml(footerCraftState, 'footer') : exportStored(footerCraftState, 'footer')).html;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Footer HTML export failed:', e);
|
||||
|
||||
@@ -221,7 +221,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerPage.craftState) {
|
||||
headerHtml = exportBodyHtml(headerPage.craftState).html;
|
||||
headerHtml = exportBodyHtml(headerPage.craftState, 'header').html;
|
||||
}
|
||||
} catch (e) { console.warn('Header export failed:', e); }
|
||||
|
||||
@@ -233,7 +233,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerPage.craftState) {
|
||||
footerHtml = exportBodyHtml(footerPage.craftState).html;
|
||||
footerHtml = exportBodyHtml(footerPage.craftState, 'footer').html;
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
|
||||
@@ -2232,3 +2232,55 @@ body {
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
#37 device emulation
|
||||
The exported viewport media queries cannot fire inside a narrowed frame,
|
||||
so the frame carries data-device and these rules mirror them. Selectors
|
||||
for stacking match the exporter verbatim. !important: styles are inline.
|
||||
-------------------------------------------------------------------------- */
|
||||
.canvas-device-frame[data-device="desktop"] [data-hide-desktop]:not([data-craft-selected]),
|
||||
.canvas-device-frame[data-device="tablet"] [data-hide-tablet]:not([data-craft-selected]),
|
||||
.canvas-device-frame[data-device="mobile"] [data-hide-mobile]:not([data-craft-selected]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* A hidden-for-this-device node that is selected stays reachable, dimmed. */
|
||||
.canvas-device-frame[data-device="desktop"] [data-hide-desktop][data-craft-selected],
|
||||
.canvas-device-frame[data-device="tablet"] [data-hide-tablet][data-craft-selected],
|
||||
.canvas-device-frame[data-device="mobile"] [data-hide-mobile][data-craft-selected] {
|
||||
opacity: 0.35;
|
||||
outline: 2px dashed var(--color-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.canvas-device-frame[data-device="mobile"] [style*="display: flex"][style*="flex-direction: row"],
|
||||
.canvas-device-frame[data-device="mobile"] [style*="display:flex"][style*="flex-direction:row"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.canvas-device-frame:not([data-device="desktop"]) [data-fullwidth] {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.canvas-device-frame[data-device="tablet"],
|
||||
.canvas-device-frame[data-device="mobile"] {
|
||||
position: relative;
|
||||
}
|
||||
.canvas-device-frame[data-device="tablet"]::before,
|
||||
.canvas-device-frame[data-device="mobile"]::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
border-bottom-left-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.canvas-device-frame[data-device="tablet"]::before { content: "Tablet \00b7 768px"; }
|
||||
.canvas-device-frame[data-device="mobile"]::before { content: "Mobile \00b7 375px"; }
|
||||
@@ -631,19 +631,36 @@ describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => {
|
||||
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>',
|
||||
'<style data-wsb-responsive>@media (max-width:767px){[data-wsb-r="b-head1"]{font-size:24px!important}}</style>',
|
||||
)).toBe(true);
|
||||
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="head1">Hello</h2>');
|
||||
expect(html).toContain('<h2 style="font-size:40px" data-wsb-r="b-head1">Hello</h2>');
|
||||
expect(css).toBe('');
|
||||
});
|
||||
|
||||
test('ROOT overrides in header/body/footer get distinct scopes (no cross-zone collision)', () => {
|
||||
const rootOnly = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, displayName: 'Container', custom: {},
|
||||
hidden: false, nodes: [], linkedNodes: {}, props: { tag: 'div', style: {}, styleMobile: { padding: '4px' } } },
|
||||
});
|
||||
const h = exportBodyHtml(rootOnly, 'header').html;
|
||||
const b = exportBodyHtml(rootOnly).html;
|
||||
const f = exportBodyHtml(rootOnly, 'footer').html;
|
||||
expect(h).toContain('[data-wsb-r="h-ROOT"]');
|
||||
expect(h).toContain('<div data-wsb-r="h-ROOT"');
|
||||
expect(b).toContain('[data-wsb-r="b-ROOT"]');
|
||||
expect(f).toContain('[data-wsb-r="f-ROOT"]');
|
||||
const doc = h + b + f;
|
||||
expect(doc.match(/data-wsb-r="b-ROOT"/g)!.length).toBe(2); // rule + attribute
|
||||
expect(doc).not.toMatch(/data-wsb-r="ROOT"/);
|
||||
});
|
||||
|
||||
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>',
|
||||
'<style data-wsb-responsive>@media (max-width:991px){[data-wsb-r="b-head1"]{text-align:center!important}}</style>',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -716,7 +733,7 @@ describe('injectAttrs skips leading <style> blocks (W0-breakpoints)', () => {
|
||||
},
|
||||
});
|
||||
const { html } = exportBodyHtml(state);
|
||||
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="btn1">Go<\/a>/);
|
||||
expect(html).toMatch(/<a href="#"[^>]* data-wsb-r="b-btn1">Go<\/a>/);
|
||||
expect(html).not.toContain('<style data-wsb-r=');
|
||||
expect(html).toContain('</style><a href="#"');
|
||||
});
|
||||
|
||||
@@ -109,7 +109,12 @@ function leadingStyleLength(html: string): number {
|
||||
* 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, cssAcc: string[] = []): { html: string } {
|
||||
function renderNode(
|
||||
nodes: Record<string, any>,
|
||||
nodeId: string,
|
||||
cssAcc: string[] = [],
|
||||
scopePrefix = 'b-',
|
||||
): { html: string } {
|
||||
const node = nodes[nodeId];
|
||||
if (!node) return { html: '' };
|
||||
// Hidden nodes (and their whole subtree) are left out of the export.
|
||||
@@ -124,12 +129,12 @@ function renderNode(nodes: Record<string, any>, nodeId: string, cssAcc: string[]
|
||||
|
||||
// Render direct child nodes
|
||||
let childrenHtml = childNodeIds
|
||||
.map((childId: string) => renderNode(nodes, childId, cssAcc).html)
|
||||
.map((childId: string) => renderNode(nodes, childId, cssAcc, scopePrefix).html)
|
||||
.join('');
|
||||
|
||||
// Render linked nodes (e.g., Section's inner container)
|
||||
const linkedHtml = Object.values(linkedNodes)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId, cssAcc).html)
|
||||
.map((linkedId: string) => renderNode(nodes, linkedId, cssAcc, scopePrefix).html)
|
||||
.join('');
|
||||
|
||||
// For linked nodes, the component's toHtml should handle them via childrenHtml
|
||||
@@ -139,10 +144,13 @@ function renderNode(nodes: Record<string, any>, nodeId: string, cssAcc: string[]
|
||||
// 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);
|
||||
// The scope is zone-prefixed (h-/b-/f-): every zone's root is the Craft id
|
||||
// 'ROOT', and header + body + footer end up in ONE published document.
|
||||
const scope = responsiveScopeId(scopePrefix + nodeId);
|
||||
const responsiveCss = responsiveCssForNode(scope, props);
|
||||
if (responsiveCss) {
|
||||
cssAcc.push(responsiveCss);
|
||||
dataAttrs += ` data-wsb-r="${responsiveScopeId(nodeId)}"`;
|
||||
dataAttrs += ` data-wsb-r="${scope}"`;
|
||||
}
|
||||
|
||||
// Look up component in resolver and call toHtml. `nodeId` is the Craft.js
|
||||
@@ -440,6 +448,11 @@ ${bodyHtml}${animationScript}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Which part of the document a serialized state becomes; picks the
|
||||
* responsive scope prefix so zones composed together never collide. */
|
||||
export type ExportZone = 'header' | 'body' | 'footer';
|
||||
const ZONE_SCOPE_PREFIX: Record<ExportZone, string> = { header: 'h-', body: 'b-', footer: 'f-' };
|
||||
|
||||
/**
|
||||
* Render the page body: the node tree, preceded by one
|
||||
* `<style data-wsb-responsive>` block when any node carries tablet/mobile
|
||||
@@ -447,9 +460,9 @@ ${bodyHtml}${animationScript}
|
||||
* 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 {
|
||||
function renderBody(nodes: Record<string, any>, zone: ExportZone = 'body'): string {
|
||||
const cssAcc: string[] = [];
|
||||
const { html } = renderNode(nodes, 'ROOT', cssAcc);
|
||||
const { html } = renderNode(nodes, 'ROOT', cssAcc, ZONE_SCOPE_PREFIX[zone]);
|
||||
const css = cssAcc.join('');
|
||||
return css ? `<style data-wsb-responsive>${css}</style>${html}` : html;
|
||||
}
|
||||
@@ -482,10 +495,11 @@ export function exportToHtml(
|
||||
*/
|
||||
export function exportBodyHtml(
|
||||
serializedState: string,
|
||||
zone: ExportZone = 'body',
|
||||
): { html: string; css: string } {
|
||||
try {
|
||||
const nodes = JSON.parse(serializedState);
|
||||
return { html: renderBody(nodes), css: '' };
|
||||
return { html: renderBody(nodes, zone), css: '' };
|
||||
} catch (e) {
|
||||
console.error('Body export failed:', e);
|
||||
return { html: '', css: '' };
|
||||
|
||||
@@ -183,6 +183,15 @@ describe('responsiveCssForNode', () => {
|
||||
expect(css.match(/[{}]/g)!.join('')).toBe('{{}}');
|
||||
});
|
||||
|
||||
test('comment delimiters are stripped from values (cannot swallow the stylesheet)', () => {
|
||||
const css = responsiveCssForNode('n', {
|
||||
styleMobile: { fontSize: '1px/*' as any, color: 'red*/' as any, lineHeight: '2/*x*/' as any },
|
||||
});
|
||||
expect(css).not.toContain('/*');
|
||||
expect(css).not.toContain('*/');
|
||||
expect(css).toContain('font-size:1px!important');
|
||||
});
|
||||
|
||||
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}}');
|
||||
|
||||
@@ -122,6 +122,17 @@ export function withoutOverride<T extends ResponsiveProps>(
|
||||
return { ...props, [field]: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `device` has at least one SET override value applying to it
|
||||
* (mobile inherits tablet's). Same isSet filtering as resolution/export, so
|
||||
* `''`/`null` placeholders never count.
|
||||
*/
|
||||
export function hasActiveOverride(props: ResponsiveProps | undefined | null, device: DeviceMode): boolean {
|
||||
if (!props || device === 'desktop') return false;
|
||||
if (Object.keys(setEntries(props.styleTablet)).length > 0) return true;
|
||||
return device === 'mobile' && Object.keys(setEntries(props.styleMobile)).length > 0;
|
||||
}
|
||||
|
||||
/** True when either override partial carries at least one value. */
|
||||
export function hasAnyOverride(props: ResponsiveProps | undefined | null): boolean {
|
||||
if (!props) return false;
|
||||
@@ -139,10 +150,11 @@ export function responsiveScopeId(nodeId: string): string {
|
||||
/**
|
||||
* 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.
|
||||
* otherwise end the rule and open a new, unscoped one, and comment delimiters would
|
||||
* open or close a comment that swallows the rest of the stylesheet.
|
||||
*/
|
||||
function declaration(key: string, value: unknown): string {
|
||||
const decl = cssPropsToString({ [key]: value } as CSSProperties).replace(/[{}]/g, '');
|
||||
const decl = cssPropsToString({ [key]: value } as CSSProperties).replace(/[{}]/g, '').replace(/\/\*|\*\//g, '');
|
||||
return decl ? `${decl}!important` : '';
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user