diff --git a/craft/src/components/layout/Container.tsx b/craft/src/components/layout/Container.tsx index 0f6cedc..a57a98b 100644 --- a/craft/src/components/layout/Container.tsx +++ b/craft/src/components/layout/Container.tsx @@ -93,6 +93,7 @@ export const Container: UserComponent = ({ 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, }, diff --git a/craft/src/constants/presets.ts b/craft/src/constants/presets.ts index 8d4c05d..8cf7546 100644 --- a/craft/src/constants/presets.ts +++ b/craft/src/constants/presets.ts @@ -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 = { desktop: '100%', tablet: '768px', diff --git a/craft/src/editor/Canvas.device.test.tsx b/craft/src/editor/Canvas.device.test.tsx new file mode 100644 index 0000000..9c67677 --- /dev/null +++ b/craft/src/editor/Canvas.device.test.tsx @@ -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( + + + + , + ); + 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'); + }); +}); diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 1188cfd..2a8f059 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -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 = ({ showGuides }) => { >
{ // 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); + }); }); diff --git a/craft/src/editor/RenderNode.test.tsx b/craft/src/editor/RenderNode.test.tsx index 9c29d74..8eb6796 100644 --- a/craft/src/editor/RenderNode.test.tsx +++ b/craft/src/editor/RenderNode.test.tsx @@ -15,6 +15,7 @@ let mockNode: { displayName: string; parent: string | null; isCanvas: boolean; + props?: Record; }; 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(); + 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(); + }); + 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(); + expect(nodeDom.hasAttribute('data-craft-selected')).toBe(true); + mockNode.selected = false; + act(() => { + root.render(); + }); + expect(nodeDom.hasAttribute('data-craft-selected')).toBe(false); + container.remove(); + nodeDom.remove(); + document.querySelector('.component-indicator')?.remove(); + }); }); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index 5c665ac..42603cc 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -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 = ({ 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 = ({ 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(null); const active = isSelected && id !== 'ROOT' && !!dom; @@ -119,6 +122,24 @@ export const RenderNode: React.FC = ({ render }) => { } }, [dom, isHovered]); + // #37: mirror the export's visibility flags and the selection onto the dom; + // editor.css hides `[data-hide-]` 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) { diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index ed6e54c..0e77a8b 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -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(); /** 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); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index a932050..b257484 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -221,7 +221,7 @@ export const TopBar: React.FC = ({ 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 = ({ 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); } diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 9b67aa5..6d81c7c 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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"; } diff --git a/craft/src/utils/html-export.test.ts b/craft/src/utils/html-export.test.ts index 9038c60..68d64bd 100644 --- a/craft/src/utils/html-export.test.ts +++ b/craft/src/utils/html-export.test.ts @@ -631,19 +631,36 @@ describe('per-breakpoint overrides + hidden nodes (W0-breakpoints)', () => { test('a mobile override exports one scoped body ', + '', )).toBe(true); - expect(html).toContain('

Hello

'); + expect(html).toContain('

Hello

'); 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('
{ const { html } = exportToHtml(makeState({ styleTablet: { textAlign: 'center' } })); const head = html.slice(0, html.indexOf('')); const body = html.slice(html.indexOf('')); expect(head).not.toContain('data-wsb'); expect(body).toContain( - '', + '', ); }); @@ -716,7 +733,7 @@ describe('injectAttrs skips leading ` block. */ -function renderNode(nodes: Record, nodeId: string, cssAcc: string[] = []): { html: string } { +function renderNode( + nodes: Record, + 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, 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, 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} `; } +/** 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 = { header: 'h-', body: 'b-', footer: 'f-' }; + /** * Render the page body: the node tree, preceded by one * `${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: '' }; diff --git a/craft/src/utils/responsive-style.test.ts b/craft/src/utils/responsive-style.test.ts index 7200079..8b1b3be 100644 --- a/craft/src/utils/responsive-style.test.ts +++ b/craft/src/utils/responsive-style.test.ts @@ -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}}'); diff --git a/craft/src/utils/responsive-style.ts b/craft/src/utils/responsive-style.ts index 2681f34..448503d 100644 --- a/craft/src/utils/responsive-style.ts +++ b/craft/src/utils/responsive-style.ts @@ -122,6 +122,17 @@ export function withoutOverride( 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 `