fix(editor): mobile column stacking follows the desktop style, as published
Published mobile stacking is the global RESPONSIVE_CSS rule matching the DESKTOP inline style, so un-stacking Columns on mobile under a stacked tablet (styleMobile.flexDirection 'row') publishes un-stacked. The editor's device-emulation copy matched the resolved inline style and showed it stacked. RenderNode now marks nodes with a flexDirection/display override with data-wsb-base-stack (baseStacksOnMobile) and editor.css keys off it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
579ea5370c
commit
e20c604413
4 files changed
+134
-4
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { exportBodyHtml, RESPONSIVE_CSS_MINIFIED } from '../../utils/html-export';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { DeviceProvider } from '../../state/DeviceContext';
|
||||
import { RenderNode } from '../../editor/RenderNode';
|
||||
import type { DeviceMode } from '../../types';
|
||||
// Vitest stubs CSS imports (`?raw` reads back empty); read the real file (same as DropIndicator.test).
|
||||
const nodeFs = (globalThis as any).process.getBuiltinModule('node:fs');
|
||||
const EDITOR_CSS: string = nodeFs.readFileSync(`${(globalThis as any).process.cwd()}/src/styles/editor.css`, 'utf8');
|
||||
|
||||
/**
|
||||
* Minor finding: un-stacking Columns on mobile (styleMobile.flexDirection 'row'
|
||||
* under a stacked tablet) rendered STACKED in the editor but UN-stacked when
|
||||
* published. Published mobile stacking is the global RESPONSIVE_CSS rule, which
|
||||
* matches the DESKTOP inline style; the editor's device-emulation copy of that
|
||||
* rule matched the device-RESOLVED inline style instead.
|
||||
*/
|
||||
|
||||
|
||||
/** Selector list of every editor.css rule that stacks (flex-direction:column) on the mobile frame. */
|
||||
function editorMobileStackSelectors(): string {
|
||||
const out: string[] = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(EDITOR_CSS))) {
|
||||
const sel = m[1].replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
||||
if (sel.includes('[data-device="mobile"]') && /flex-direction:\s*column\s*!important/.test(m[2])) out.push(sel);
|
||||
}
|
||||
return out.join(',');
|
||||
}
|
||||
|
||||
/** The published global mobile stacking selector (from RESPONSIVE_CSS). */
|
||||
const PUBLISHED_STACK_SELECTOR = /\{([^{}]+)\{flex-direction:column!important\}/.exec(RESPONSIVE_CSS_MINIFIED)![1];
|
||||
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type,
|
||||
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
|
||||
});
|
||||
const colsState = (props: Record<string, unknown>) =>
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['cl']),
|
||||
cl: node('ColumnLayout', { columns: 2, split: '50-50', gap: '16px', anchorId: 'cl', ...props }, [], {
|
||||
parent: 'ROOT', isCanvas: false, linkedNodes: { 'col-0': 'c0', 'col-1': 'c1' },
|
||||
}),
|
||||
c0: node('Container', { tag: 'div', style: {} }, [], { parent: 'cl' }),
|
||||
c1: node('Container', { tag: 'div', style: {} }, [], { parent: 'cl' }),
|
||||
});
|
||||
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => { h?.unmount(); h = null; });
|
||||
|
||||
function editorStacksOnMobile(state: string, device: DeviceMode = 'mobile'): boolean {
|
||||
h = renderEditorHarness({
|
||||
initialState: state,
|
||||
onRender: RenderNode,
|
||||
wrap: (tree) => <DeviceProvider initialDevice={device}><div className="canvas-device-frame" data-device={device}>{tree}</div></DeviceProvider>,
|
||||
});
|
||||
const el = h.container.querySelector('#cl') as HTMLElement;
|
||||
return el.matches(editorMobileStackSelectors());
|
||||
}
|
||||
|
||||
function publishedBaseStacks(state: string): boolean {
|
||||
const doc = document.createElement('div');
|
||||
doc.innerHTML = exportBodyHtml(state).html;
|
||||
return (doc.querySelector('#cl') as HTMLElement).matches(PUBLISHED_STACK_SELECTOR);
|
||||
}
|
||||
|
||||
describe('Columns mobile stacking: editor matches published', () => {
|
||||
test('un-stack on mobile under a stacked tablet -> NOT stacked in the editor (published is not)', () => {
|
||||
const state = colsState({ style: {}, styleTablet: { flexDirection: 'column' }, styleMobile: { flexDirection: 'row' } });
|
||||
expect(publishedBaseStacks(state)).toBe(false);
|
||||
expect(exportBodyHtml(state).html).toMatch(/@media \(max-width:767px\)\{\[data-wsb-r="b-cl"\]\{flex-direction:row!important/);
|
||||
expect(editorStacksOnMobile(state)).toBe(false);
|
||||
});
|
||||
|
||||
test('desktop row, no override -> stacked in both', () => {
|
||||
const state = colsState({ style: { flexDirection: 'row' } });
|
||||
expect(publishedBaseStacks(state)).toBe(true);
|
||||
expect(editorStacksOnMobile(state)).toBe(true);
|
||||
});
|
||||
|
||||
test('desktop row + mobile row override -> stacked in both (global rule wins published)', () => {
|
||||
const state = colsState({ style: { flexDirection: 'row' }, styleMobile: { flexDirection: 'row' } });
|
||||
expect(publishedBaseStacks(state)).toBe(true);
|
||||
expect(editorStacksOnMobile(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'reac
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodeElement, useEditor, useNode } from '@craftjs/core';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
|
||||
import { baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { GlobalInstanceContext } from '../state/GlobalInstanceContext';
|
||||
|
||||
@@ -97,7 +97,7 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
const { actions } = useEditor();
|
||||
const { device } = useDevice();
|
||||
const parentInstance = useContext(GlobalInstanceContext);
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot } = useNode((node) => {
|
||||
const { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile, isInstanceRoot, baseStack } = useNode((node) => {
|
||||
// Keep the collector cheap (booleans only); ResolvedRender collects the
|
||||
// node's type/props/children itself, as Craft's DefaultRender does.
|
||||
const props = node.data.props as ResponsiveProps | undefined;
|
||||
@@ -116,6 +116,7 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
hideTablet: !!node.data.props?.hideOnTablet,
|
||||
hideMobile: !!node.data.props?.hideOnMobile,
|
||||
isInstanceRoot: !!(node.data.custom?.global as { libraryId?: string } | undefined)?.libraryId,
|
||||
baseStack: baseStacksOnMobile(props, node.dom?.style),
|
||||
};
|
||||
});
|
||||
const instanceRoot = isInstanceRoot ? id : parentInstance;
|
||||
@@ -182,6 +183,14 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
|
||||
}
|
||||
}, [dom, hideDesktop, hideTablet, hideMobile, isSelected]);
|
||||
|
||||
// Mobile stacking follows the DESKTOP style when published (see
|
||||
// baseStacksOnMobile); editor.css keys off this instead of the resolved inline.
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (baseStack === null) dom.removeAttribute('data-wsb-base-stack');
|
||||
else dom.setAttribute('data-wsb-base-stack', baseStack ? '1' : '0');
|
||||
}, [dom, baseStack]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dom) return;
|
||||
if (activeOverride) {
|
||||
|
||||
@@ -2314,8 +2314,11 @@ body {
|
||||
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"] {
|
||||
/* Published stacking matches the DESKTOP inline style; a node with a
|
||||
flexDirection/display override carries data-wsb-base-stack (RenderNode). */
|
||||
.canvas-device-frame[data-device="mobile"] [style*="display: flex"][style*="flex-direction: row"]:not([data-wsb-base-stack]),
|
||||
.canvas-device-frame[data-device="mobile"] [style*="display:flex"][style*="flex-direction:row"]:not([data-wsb-base-stack]),
|
||||
.canvas-device-frame[data-device="mobile"] [data-wsb-base-stack="1"] {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,36 @@ export function hasActiveOverride(props: ResponsiveProps | undefined | null, dev
|
||||
return device === 'mobile' && Object.keys(setEntries(props.styleMobile)).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Published mobile stacking is the global RESPONSIVE_CSS rule, which matches the
|
||||
* DESKTOP inline style (`display: flex` + `flex-direction: row*`); a per-device
|
||||
* flexDirection/display override cannot change whether it fires. For a node whose
|
||||
* overrides touch those keys, returns whether that rule fires on the published
|
||||
* element (so the editor can mirror it instead of matching the resolved inline
|
||||
* style); `null` when no such override exists or the base is unknowable.
|
||||
* `renderedStyle` is the element's current inline style (its un-overridden keys
|
||||
* equal the desktop values, including component defaults like Columns' flex).
|
||||
*/
|
||||
export function baseStacksOnMobile(
|
||||
props: ResponsiveProps | undefined | null,
|
||||
renderedStyle: { display?: string; flexDirection?: string } | undefined,
|
||||
): boolean | null {
|
||||
if (!props) return null;
|
||||
const t = asRecord(props.styleTablet);
|
||||
const m = asRecord(props.styleMobile);
|
||||
const overridden = (k: string) => isSet(t[k]) || isSet(m[k]);
|
||||
if (!overridden('flexDirection') && !overridden('display')) return null;
|
||||
const base = asRecord(props.style);
|
||||
const baseValue = (k: 'display' | 'flexDirection'): string | null => {
|
||||
if (isSet(base[k])) return String(base[k]);
|
||||
if (!overridden(k)) return renderedStyle?.[k] ?? '';
|
||||
return k === 'flexDirection' ? '' : null; // no component default sets a row direction
|
||||
};
|
||||
const display = baseValue('display');
|
||||
if (display === null) return null;
|
||||
return display === 'flex' && /^row/.test(baseValue('flexDirection') ?? '');
|
||||
}
|
||||
|
||||
/** True when either override partial carries at least one value. */
|
||||
export function hasAnyOverride(props: ResponsiveProps | undefined | null): boolean {
|
||||
if (!props) return false;
|
||||
|
||||
Reference in new issue
Block a user