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
@@ -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