feat(site-builder): tablet/mobile style overrides that never touch desktop

On tablet/mobile the useNodeProp style setters now route responsive keys
(spacing, font size, line height, letter spacing, alignment, size, flex
layout) into styleTablet/styleMobile; colours and other keys still write
style. An empty value removes the override. Continuous-burst keys include
the device so a desktop drag and a mobile drag never merge.

- shared.tsx: device-aware setters, new setResponsiveStyleProp /
  clearResponsiveStyleProp exports, setPropStylesContinuous
- responsive-panel.tsx: useResolvedStyle, useOverrideState, DeviceBanner
- OverrideDot: filled dot resets the override (one undo step), hollow dot
  marks an inherited value; wired into Text/Button/Container/Image/Media/
  BackgroundSection panels and both box-model sections
- Columns: "Stack on this device" toggle (flexDirection override)
- GuidedStyles: device banner under the type header
- RenderNode: every component node renders through one stable
  ResolvedRender so gaining/losing an override or switching device no
  longer remounts the node (the remount dropped inline-edit focus/caret
  and blanked a selected Heading)

Refs #45
This commit is contained in:
shadowdao committed 2026-10-07 11:51:53 -07:00
1 parent d8ff295093
commit c2a90cd7a7
15 files changed
+806 -78

No files matched your search

+47 -34
View File
@@ -1,8 +1,9 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
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 type { DeviceMode } from '../types';
interface RenderNodeProps {
render: React.ReactElement;
@@ -49,29 +50,60 @@ interface RenderNodeProps {
* needs to know about devices. Craft hands `onRender` an opaque
* `<DefaultRender/>` element that reads the node's props itself, so the
* override cannot be applied with `cloneElement(render, {style})` -- instead
* the component element is rebuilt here exactly as Craft's DefaultRender
* does (`createElement(type, props, <NodeElement/> children)`) with the
* resolved `style`. Nodes without overrides, and every node on desktop,
* keep Craft's own `render` untouched.
* every component node renders through `ResolvedRender`, a copy of Craft's
* DefaultRender (`createElement(type, props, <NodeElement/> children)`) that
* swaps in the resolved `style`. It is used on EVERY device and whether or
* not the node has overrides, so the element type never changes: switching
* device or adding the first override only updates the style, it never
* remounts the component (#45 -- a remount dropped inline-edit focus/caret
* and blanked a selected Heading/Text, whose text is only written to the DOM
* while unselected). Raw-tag nodes keep Craft's own `render`.
*/
const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => {
const { type, props, childIds, hydrationTimestamp } = useNode((node) => ({
type: node.data.type,
props: node.data.props,
childIds: node.data.nodes,
hydrationTimestamp: (node as any)._hydrationTimestamp,
}));
// Only nodes with overrides depend on the device, so a device switch
// re-renders just those (plain nodes keep their memoised element).
const styleDevice: DeviceMode = hasAnyOverride(props as ResponsiveProps) ? device : 'desktop';
return useMemo(() => {
const children =
childIds && childIds.length > 0 ? (
<>
{childIds.map((childId: string) => (
<NodeElement id={childId} key={childId} />
))}
</>
) : (
props.children
);
const resolved =
styleDevice !== 'desktop' ? { ...props, style: resolveStyleForDevice(props as ResponsiveProps, styleDevice) } : props;
return React.createElement(type as React.ElementType, resolved, children);
}, [type, props, childIds, hydrationTimestamp, styleDevice]);
};
export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
const { actions } = useEditor();
const { device } = useDevice();
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 { id, isSelected, dom, name, parent, isCanvas, isHovered, isComponent, tabletOverride, mobileOverride, hideDesktop, hideTablet, hideMobile } = 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;
const overridden = hasAnyOverride(props)
? { type: node.data.type, props: node.data.props, childIds: node.data.nodes }
: null;
const type = node.data.type as unknown;
return {
isComponent: !!type && typeof type !== 'string',
tabletOverride: hasActiveOverride(props, 'tablet'),
mobileOverride: hasActiveOverride(props, 'mobile'),
isSelected: node.events.selected,
isHovered: node.events.hovered,
dom: node.dom,
name: (node.data.props?.aiName as string) || node.data.displayName,
parent: node.data.parent,
isCanvas: node.data.isCanvas,
overridden,
hideDesktop: !!node.data.props?.hideOnDesktop,
hideTablet: !!node.data.props?.hideOnTablet,
hideMobile: !!node.data.props?.hideOnMobile,
@@ -79,8 +111,7 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
});
// Overrides that apply on the current device (mobile inherits tablet's).
const activeOverride =
!!overridden && hasActiveOverride(overridden.props as ResponsiveProps, device);
const activeOverride = device === 'tablet' ? tabletOverride : device === 'mobile' ? mobileOverride : false;
const badgeRef = useRef<HTMLDivElement>(null);
const active = isSelected && id !== 'ROOT' && !!dom;
@@ -149,28 +180,10 @@ export const RenderNode: React.FC<RenderNodeProps> = ({ render }) => {
}
}, [dom, activeOverride]);
let output: React.ReactElement = render;
// String-typed (plain DOM) nodes go through Craft's own SimpleElement
// connector wrapper, which is not exported -- leave those untouched. Every
// resolver component is a React component, so this only skips raw tags.
if (device !== 'desktop' && overridden && typeof overridden.type !== 'string') {
const { type, props, childIds } = overridden;
const children =
childIds && childIds.length > 0 ? (
<>
{childIds.map((childId: string) => (
<NodeElement id={childId} key={childId} />
))}
</>
) : (
props.children
);
output = React.createElement(
type as React.ElementType,
{ ...props, style: resolveStyleForDevice(props as ResponsiveProps, device) },
children,
);
}
// resolver component is a React component (see ResolvedRender above).
const output: React.ReactElement = isComponent ? <ResolvedRender device={device} /> : render;
if (!active) return <>{output}</>;
+2
View File
@@ -19,6 +19,7 @@ import {
GenericPropsEditor,
HtmlStylePanel,
} from './styles';
import { DeviceBanner } from './styles/responsive-panel';
/* ================================================================
IMPORTANT: None of these panels use useNode().
@@ -140,6 +141,7 @@ export const GuidedStyles: React.FC<{
<i className="fa fa-magic" /> Ask Sitesmith
</button>
</div>
<DeviceBanner />
{/* TEXT */}
{isText && <TextStylePanel selectedId={selected} nodeProps={nodeProps} />}
@@ -16,13 +16,16 @@ import {
sectionGap,
useNodeProp,
} from './shared';
import { useResolvedStyle, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
/* ---------- BACKGROUND SECTION ---------- */
export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { setProp, setPropStyle, setPropContinuous } = useNodeProp(selectedId);
const style = nodeProps.style || {};
const style = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
return (
<>
@@ -54,7 +57,7 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
onChange={(e) => setPropContinuous('innerMaxWidth', e.target.value)} style={inputStyle} />
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
</div>
</CollapsibleSection>
@@ -28,6 +28,8 @@ import {
continuousSetProp,
discreteSetProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
@@ -54,7 +56,8 @@ function opacityPercent(v: unknown): number {
/* ---------- BUTTON ---------- */
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const style: CSSProperties = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
@@ -121,7 +124,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
/>
</div>
<div className="guided-section">
<SectionLabel>Padding</SectionLabel>
<SectionLabel>Padding{dot(PADDING_KEYS)}</SectionLabel>
<PresetButtonGrid
presets={SPACING_PRESETS}
activeValue={style.padding as string}
@@ -148,7 +151,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
@@ -1,5 +1,4 @@
import React, { CSSProperties } from 'react';
import { useEditor } from '@craftjs/core';
import {
BG_COLORS,
RADIUS_PRESETS,
@@ -15,8 +14,11 @@ import {
inputStyle,
sectionGap,
useNodeProp,
continuousSetProp,
} from './shared';
import { useResolvedStyle } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { useDevice } from '../../../state/DeviceContext';
import { resolveStyleForDevice, withoutOverride } from '../../../utils/responsive-style';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
// Vertical Alignment options shown to the user identically regardless of
@@ -32,10 +34,11 @@ const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
/* ---------- CONTAINER / SECTION / COLUMNS ---------- */
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {};
const style: CSSProperties = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { device } = useDevice();
const { actions } = useEditor();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
// ColumnLayout only ever carries `columns`/`split` props -- Container and
// Section never set them -- so checking either alone distinguishes the
@@ -43,12 +46,19 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
// column heights) from the flex-COLUMN case (Container/Section, which
// vertically center/position their OWN content via justify-content,
// paired with a Min Height control so centering is meaningful).
const setPropStylesContinuous = (patch: Record<string, string>, controlKey: string) =>
continuousSetProp(actions, selectedId, controlKey, (p: any) => { p.style = { ...p.style, ...patch }; });
const isColumns = nodeProps.columns !== undefined || nodeProps.split !== undefined;
const vAlignKey = isColumns ? 'alignItems' : 'justifyContent';
// "Stack on this device" (Columns, tablet/mobile only): a flexDirection
// override. Turning it off removes this device's override; if the columns
// would still inherit a stack (mobile under a stacked tablet), it writes
// 'row' instead so the toggle really un-stacks this device.
const stacked = style.flexDirection === 'column';
const inheritsStack =
device !== 'desktop' &&
resolveStyleForDevice(withoutOverride(nodeProps, device, 'flexDirection'), device).flexDirection === 'column';
const toggleStack = () => setPropStyle('flexDirection', stacked ? (inheritsStack ? 'row' : '') : 'column');
return (
<>
{nodeProps.cssId !== undefined && (
@@ -99,7 +109,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
/>
</div>
<div className="guided-section">
<SectionLabel>Alignment</SectionLabel>
<SectionLabel>Alignment{dot('textAlign')}</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
<button
@@ -114,16 +124,25 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
</div>
</div>
<div className="guided-section">
<SectionLabel>Vertical Alignment</SectionLabel>
<SectionLabel>Vertical Alignment{dot(vAlignKey)}</SectionLabel>
<PresetButtonGrid
presets={VERTICAL_ALIGN_OPTIONS}
activeValue={style[vAlignKey] as string}
onSelect={(v) => setPropStyle(vAlignKey, v)}
/>
</div>
{isColumns && device !== 'desktop' && (
<div className="guided-section">
<SectionLabel>Columns Layout{dot('flexDirection')}</SectionLabel>
<label className="wsb-stack-toggle">
<input type="checkbox" checked={stacked} onChange={toggleStack} data-testid="stack-columns" />
Stack on this device
</label>
</div>
)}
{!isColumns && (
<div className="guided-section">
<SectionLabel>Min Height</SectionLabel>
<SectionLabel>Min Height{dot('minHeight')}</SectionLabel>
<NumericUnitInput
value={(style.minHeight as string) || ''}
onChange={(v) => setPropStyleContinuous('minHeight', v)}
@@ -134,7 +153,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
)}
{/* Box model + border/effects + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
@@ -15,6 +15,8 @@ import {
useNodeProp,
continuousSetProp,
} from './shared';
import { useResolvedStyle } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
import { PLACEHOLDER_SRC } from '../../../components/media/ImageBlock';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
@@ -22,7 +24,8 @@ import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBo
/* ---------- IMAGE (with upload/browse/drop) ---------- */
export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const style: CSSProperties = nodeProps.style || {};
const style: CSSProperties = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { setProp, setPropStyle, setProps, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId);
@@ -64,7 +67,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Size -- Width absorbs the historical maxWidth presets (25/50/75/100%
are a subset of SizeControl's default preset row), Height is new. */}
<SizeControl
label="Width"
label={<>Width{dot('maxWidth')}</>}
value={(style.maxWidth as string) || ''}
onChange={(v) => setPropStyleContinuous('maxWidth', v)}
/>
@@ -74,7 +77,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
applyAspectRatio, which clears any stale height at that moment). */}
{!style.aspectRatio && (
<SizeControl
label="Height"
label={<>Height{dot('height')}</>}
value={(style.height as string) || ''}
onChange={(v) => setPropStyleContinuous('height', v)}
/>
@@ -111,7 +114,7 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
</div>
{/* Box model + animation/visibility rollout */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
@@ -21,6 +21,8 @@ import {
useNodeProp,
continuousSetProp,
} from './shared';
import { useResolvedStyle } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
import { AssetPicker } from '../../../ui/AssetPicker';
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './mediaBoxModel';
@@ -36,7 +38,8 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
const { actions } = useEditor();
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId);
const style = nodeProps.style || {};
const style = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
// Same crop-fills-by-default + no stale-height-conflict treatment as
// ImageStylePanel (see there for the full rationale): applying a ratio
@@ -72,7 +75,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{nodeProps.videoUrl !== undefined && (
<>
<SizeControl
label="Width"
label={<>Width{dot('width')}</>}
value={(style.width as string) || ''}
onChange={(v) => setPropStyleContinuous('width', v)}
/>
@@ -276,7 +279,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
all carry these props now, so it's safe to render unconditionally). */}
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
</>
@@ -0,0 +1,48 @@
import React from 'react';
import { useOverrideState } from './responsive-panel';
const DEVICE_NAME = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobile' } as const;
export interface OverrideDotProps {
nodeProps: Record<string, any>;
selectedId: string;
/** One style key, or a group shown as one dot (e.g. all four margin sides). */
styleKey: string | readonly string[];
}
/**
* Per-device marker next to a field label (#45). On tablet/mobile:
* - filled dot = this device has its own value; click resets it (one undo step);
* - hollow dot = the field follows the larger device, so an edit here
* creates a device-specific value.
* Nothing on desktop, nor for keys that are never per-device (colours...).
*/
export const OverrideDot: React.FC<OverrideDotProps> = ({ nodeProps, selectedId, styleKey }) => {
const { overridden, inherited, device, resetsTo, reset } = useOverrideState(nodeProps, styleKey, selectedId);
if (device === 'desktop') return null;
const name = DEVICE_NAME[device];
if (overridden) {
const title = `${name} override - click to reset to ${resetsTo}`;
return (
<button
type="button"
className="wsb-override-dot is-set"
title={title}
aria-label={title}
data-testid="override-dot"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); reset(); }}
/>
);
}
if (!inherited) return null;
const from = device === 'mobile' ? 'larger screens' : 'desktop';
return (
<span
className="wsb-override-dot is-inherited"
title={`Inherits ${from} - editing here sets a ${name.toLowerCase()} value`}
aria-label={`Inherits ${from}`}
role="img"
data-testid="override-dot-inherited"
/>
);
};
@@ -26,6 +26,8 @@ import {
labelStyle,
useNodeProp,
} from './shared';
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
import { OverrideDot } from './OverrideDot';
/* Text-transform is a small fixed enum with no natural home in the shared
foundation presets (constants/presets.ts is import-only for this
@@ -61,7 +63,8 @@ function opacityPercent(v: unknown): number {
/* ---------- TEXT ---------- */
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const style: CSSProperties = nodeProps.style || {};
const style: CSSProperties = useResolvedStyle(nodeProps);
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId);
@@ -87,7 +90,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
/>
</div>
<div className="guided-section">
<SectionLabel>Text Size</SectionLabel>
<SectionLabel>Text Size{dot('fontSize')}</SectionLabel>
<PresetButtonGrid
presets={TEXT_SIZES}
activeValue={style.fontSize as string}
@@ -143,7 +146,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
/>
</div>
<div className="guided-section">
<SectionLabel>Line Height</SectionLabel>
<SectionLabel>Line Height{dot('lineHeight')}</SectionLabel>
<PresetButtonGrid
presets={LINE_HEIGHTS}
activeValue={String(style.lineHeight || '')}
@@ -151,7 +154,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
/>
</div>
<div className="guided-section">
<SectionLabel>Letter Spacing</SectionLabel>
<SectionLabel>Letter Spacing{dot('letterSpacing')}</SectionLabel>
<PresetButtonGrid
presets={LETTER_SPACINGS}
activeValue={String(style.letterSpacing || '')}
@@ -159,7 +162,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
/>
</div>
<div className="guided-section">
<SectionLabel>Alignment</SectionLabel>
<SectionLabel>Alignment{dot('textAlign')}</SectionLabel>
<div className="preset-grid align-grid">
{(['left', 'center', 'right', 'justify'] as const).map((a) => (
<button
@@ -177,12 +180,12 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
{/* Box model + border/effects + animation/visibility rollout */}
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
label={<>Margin{dot(MARGIN_KEYS)}</>}
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
/>
<SpacingControl
label="Padding"
label={<>Padding{dot(PADDING_KEYS)}</>}
value={{ top: style.paddingTop as string, right: style.paddingRight as string, bottom: style.paddingBottom as string, left: style.paddingLeft as string }}
onChange={(side: SpacingSide, v: string) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
/>
@@ -15,6 +15,7 @@ import {
sectionGap,
labelStyle,
} from './shared';
import { MARGIN_KEYS, PADDING_KEYS as PADDING_GROUP } from './responsive-panel';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
@@ -78,13 +79,15 @@ export interface BoxModelSectionProps {
/** Writes several style keys in ONE undo step. `controlKey` names the
* control for undo-burst merging (e.g. "style.padding*"). */
setPropStyles: (patch: Record<string, string>, controlKey: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
}
/** Margin + Padding, per-side, via the shared SpacingControl. Padding is the
* only padding control: a legacy shorthand `style.padding` (with no
* longhands yet) is shown expanded and migrated to the four longhands on the
* first edit, in the same history step. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon }) => {
const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value);
const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k]));
@@ -105,12 +108,12 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
return (
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={marginSetter}
/>
<SpacingControl
label="Padding"
label={<>Padding{labelAddon?.(PADDING_GROUP)}</>}
value={padding}
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
@@ -14,6 +14,7 @@ import {
sectionGap,
labelStyle,
} from './shared';
import { MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
/* ==========================================================================
Shared box-model / border+effects / animation+visibility sections for the
@@ -42,22 +43,24 @@ export function parseBorderShorthand(v: string | undefined): BorderValue {
export interface BoxModelSectionProps {
style: Record<string, any>;
setPropStyle: (prop: string, value: string) => void;
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
labelAddon?: (keys: readonly string[]) => React.ReactNode;
}
/** Margin + Padding, per-side, via the shared SpacingControl. */
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon }) => {
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
setPropStyle(`${kind}${capitalize(side)}`, value);
return (
<CollapsibleSection title="Spacing">
<SpacingControl
label="Margin"
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
onChange={sideSetter('margin')}
/>
<SpacingControl
label="Padding"
label={<>Padding{labelAddon?.(PADDING_KEYS)}</>}
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
onChange={sideSetter('padding')}
/>
@@ -0,0 +1,384 @@
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
import React from 'react';
import { useEditor } from '@craftjs/core';
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
import { DeviceProvider, useDevice } from '../../../state/DeviceContext';
import { RenderNode } from '../../../editor/RenderNode';
import { exportBodyHtml } from '../../../utils/html-export';
import type { DeviceMode } from '../../../types';
import { GuidedStyles } from '../GuidedStyles';
import { TextStylePanel } from './TextStylePanel';
import { ContainerStylePanel } from './ContainerStylePanel';
import {
useNodeProp,
closeContinuousBurst,
clearResponsiveStyleProp,
setResponsiveStyleProp,
CONTINUOUS_EDIT_IDLE_MS,
} from './shared';
/**
* #45 per-device editing against the REAL editor: panels mounted through
* `mountChild` under a real DeviceProvider, writes going through the real
* device-aware `useNodeProp`, history/undo and export from Craft's real store.
*/
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
type: { resolvedName: type },
isCanvas: type === 'Container',
props,
displayName: type === 'ColumnLayout' ? 'Columns' : type,
custom: {},
hidden: false,
nodes,
linkedNodes: {},
...extra,
});
const state = (headingExtra: Record<string, unknown> = {}) =>
JSON.stringify({
ROOT: node('Container', { tag: 'div', style: {} }, ['h-1', 'cols']),
'h-1': node(
'Heading',
{ text: 'Hello', level: 'h2', style: { fontSize: '36px', color: '#000000', marginBottom: '16px' }, ...headingExtra },
[],
{ parent: 'ROOT' },
),
cols: node('ColumnLayout', { columns: 2, split: '50-50', gap: '16px', style: { alignItems: '' } }, [], { parent: 'ROOT' }),
});
let h: EditorHarness | null = null;
let setDevice: (d: DeviceMode) => void = () => {};
const DeviceGrab: React.FC = () => {
setDevice = useDevice().setDevice;
return null;
};
/** Re-renders the panel from the live node props, the way GuidedStyles does. */
const PanelHost: React.FC<{ id: string; Panel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }> }> = ({ id, Panel }) => {
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} }));
return <div data-testid="panel"><Panel selectedId={id} nodeProps={nodeProps} /></div>;
};
function mount(device: DeviceMode, opts: { initialState?: string; renderNode?: boolean } = {}) {
h = renderEditorHarness({
initialState: opts.initialState ?? state(),
...(opts.renderNode ? { onRender: RenderNode } : {}),
wrap: (tree) => (
<DeviceProvider initialDevice={device}>
<DeviceGrab />
{tree}
</DeviceProvider>
),
});
return h;
}
function mountPanel(device: DeviceMode, Panel: React.FC<any> = TextStylePanel, id = 'h-1', initialState?: string) {
mount(device, { initialState });
h!.mountChild(<PanelHost id={id} Panel={Panel} />);
h!.act(() => { h!.actions.history.clear(); });
}
const props = (id = 'h-1') => h!.query.node(id).get().data.props;
const act = (fn: () => void) => h!.act(fn);
const switchDevice = (d: DeviceMode) => act(() => setDevice(d));
/** The `.guided-section` whose label starts with `label`. */
function section(label: string): HTMLElement {
const labels = Array.from(h!.container.querySelectorAll('.guided-section-label'));
const el = labels.find((l) => (l.textContent || '').trim().startsWith(label));
if (!el) throw new Error(`no section "${label}"`);
return el.closest('.guided-section') as HTMLElement;
}
const presetBtn = (label: string, text: string) =>
Array.from(section(label).querySelectorAll('button.preset-btn')).find((b) => b.textContent === text) as HTMLButtonElement;
const activePreset = (label: string) => section(label).querySelector('button.preset-btn.active')?.textContent ?? null;
const filledDot = (label: string) => section(label).querySelector('[data-testid="override-dot"]') as HTMLButtonElement | null;
const hollowDot = (label: string) => section(label).querySelector('[data-testid="override-dot-inherited"]');
const click = (el: Element) => act(() => { (el as HTMLElement).click(); });
function setNative(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function undoDepth(): number {
let n = 0;
while (h!.query.history.canUndo() && n < 100) {
act(() => { h!.actions.history.undo(); });
n++;
}
return n;
}
beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } });
afterEach(() => {
closeContinuousBurst();
vi.useRealTimers();
h?.unmount();
h = null;
});
describe('device-aware style writes (TextStylePanel)', () => {
test('mobile: Text Size writes styleMobile only; desktop style.fontSize is untouched', () => {
mountPanel('mobile');
click(presetBtn('Text Size', 'XL'));
expect(props().styleMobile?.fontSize).toBe('24px');
expect(props().style.fontSize).toBe('36px');
// The field shows the effective (mobile) value.
expect(activePreset('Text Size')).toBe('XL');
});
test('desktop: the same control writes style.fontSize and creates no override', () => {
mountPanel('desktop');
click(presetBtn('Text Size', 'XL'));
expect(props().style.fontSize).toBe('24px');
expect(props().styleMobile).toBeUndefined();
expect(props().styleTablet).toBeUndefined();
});
test('mobile: Text Colour is not per-device and still writes style.color (allowlist gate)', () => {
mountPanel('mobile');
const swatch = section('Text Color').querySelector('[title="Blue"]') as HTMLElement;
click(swatch);
expect(props().style.color).toBe('#3b82f6');
expect(props().styleMobile?.color).toBeUndefined();
});
test('#40 debounced custom input on mobile commits into the mobile override', () => {
mountPanel('mobile');
const input = h!.container.querySelector('[data-testid="text-fontsize-custom-number"]') as HTMLInputElement;
act(() => setNative(input, '30'));
expect(props().styleMobile?.fontSize).toBeUndefined(); // still debouncing
act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
expect(props().styleMobile?.fontSize).toBe('30px');
expect(props().style.fontSize).toBe('36px');
});
test('clearing a field on mobile removes the override instead of storing an empty value', () => {
mountPanel('mobile', TextStylePanel, 'h-1', state({ styleMobile: { fontSize: '30px' } }));
const input = h!.container.querySelector('[data-testid="text-fontsize-custom-number"]') as HTMLInputElement;
expect(input.value).toBe('30');
act(() => setNative(input, ''));
act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
expect(props().styleMobile).toEqual({});
expect(props().style.fontSize).toBe('36px');
});
});
describe('OverrideDot', () => {
test('nothing on desktop', () => {
mountPanel('desktop', TextStylePanel, 'h-1', state({ styleMobile: { fontSize: '24px' } }));
expect(h!.container.querySelector('.wsb-override-dot')).toBeNull();
});
test('filled only when overridden; click resets to desktop in ONE undo step', () => {
mountPanel('mobile');
expect(filledDot('Text Size')).toBeNull();
expect(hollowDot('Text Size')).not.toBeNull();
// Not per-device → no dot of either kind.
expect(section('Text Color').querySelector('.wsb-override-dot')).toBeNull();
click(presetBtn('Text Size', 'XL'));
act(() => { h!.actions.history.clear(); });
const dot = filledDot('Text Size')!;
expect(dot).not.toBeNull();
expect(dot.getAttribute('title')).toBe('Mobile override - click to reset to desktop');
click(dot);
expect(props().styleMobile?.fontSize).toBeUndefined();
expect(props().style.fontSize).toBe('36px');
expect(activePreset('Text Size')).toBeNull(); // 36px is not a preset: shown as custom
expect((h!.container.querySelector('[data-testid="text-fontsize-custom-number"]') as HTMLInputElement).value).toBe('36');
expect(filledDot('Text Size')).toBeNull();
// One undo step restores the override.
expect(h!.query.history.canUndo()).toBe(true);
act(() => { h!.actions.history.undo(); });
expect(props().styleMobile?.fontSize).toBe('24px');
expect(h!.query.history.canUndo()).toBe(false);
});
test('mobile under a tablet override shows the TABLET value and a hollow dot (cascade)', () => {
mountPanel('mobile', TextStylePanel, 'h-1', state({ styleTablet: { fontSize: '32px' } }));
expect(activePreset('Text Size')).toBe('2XL');
expect(filledDot('Text Size')).toBeNull();
expect(hollowDot('Text Size')).not.toBeNull();
switchDevice('tablet');
expect(filledDot('Text Size')!.getAttribute('title')).toBe('Tablet override - click to reset to desktop');
});
test('spacing group dot: set when any side is overridden, reset clears the group', () => {
mountPanel('tablet', TextStylePanel, 'h-1', state({ styleTablet: { marginLeft: '4px', marginTop: '8px' } }));
const marginDot = h!.container.querySelector('[data-testid="spacing-control"] [data-testid="override-dot"]') as HTMLElement;
expect(marginDot).not.toBeNull();
click(marginDot);
expect(props().styleTablet).toEqual({});
expect(props().style.marginBottom).toBe('16px');
});
});
describe('setter routing + undo bursts', () => {
const SetterProbe: React.FC = () => {
const { setPropStyles, setPropStyleContinuous } = useNodeProp('h-1');
return (
<div>
<button data-testid="mixed" onClick={() => setPropStyles({ fontSize: '20px', color: '#ff0000', paddingTop: '4px', fontFamily: 'Georgia' })} />
<button data-testid="cont-a" onClick={() => setPropStyleContinuous('fontSize', '21px')} />
<button data-testid="cont-b" onClick={() => setPropStyleContinuous('fontSize', '22px')} />
</div>
);
};
const btn = (id: string) => h!.container.querySelector(`[data-testid="${id}"]`) as HTMLElement;
test('setPropStyles with mixed keys on tablet splits in ONE setProp (one undo step)', () => {
mount('tablet');
h!.mountChild(<SetterProbe />);
act(() => { h!.actions.history.clear(); });
click(btn('mixed'));
expect(props().styleTablet).toEqual({ fontSize: '20px', paddingTop: '4px' });
expect(props().style.color).toBe('#ff0000');
expect(props().style.fontFamily).toBe('Georgia');
expect(props().style.fontSize).toBe('36px');
expect(props().style.paddingTop).toBeUndefined();
expect(undoDepth()).toBe(1);
expect(props().styleTablet).toBeUndefined();
expect(props().style.color).toBe('#000000');
});
test('a desktop drag then a mobile drag inside the burst window are separate undo steps', () => {
vi.useFakeTimers();
mount('desktop');
h!.mountChild(<SetterProbe />);
act(() => { h!.actions.history.clear(); });
click(btn('cont-a'));
switchDevice('mobile');
act(() => { vi.advanceTimersByTime(CONTINUOUS_EDIT_IDLE_MS / 5); });
click(btn('cont-b'));
expect(props().style.fontSize).toBe('21px');
expect(props().styleMobile.fontSize).toBe('22px');
expect(undoDepth()).toBe(2);
});
test('exported helpers: set (continuous) + clear on tablet, desktop clear is a no-op', () => {
mount('desktop');
act(() => { h!.actions.history.clear(); });
act(() => setResponsiveStyleProp(h!.actions, 'h-1', 'tablet', 'width', '50%', { continuous: true, controlKey: 'drag' }));
act(() => setResponsiveStyleProp(h!.actions, 'h-1', 'tablet', 'width', '60%', { continuous: true, controlKey: 'drag' }));
expect(props().styleTablet).toEqual({ width: '60%' });
expect(props().style.width).toBeUndefined();
closeContinuousBurst();
act(() => clearResponsiveStyleProp(h!.actions, 'h-1', 'desktop', 'width'));
act(() => clearResponsiveStyleProp(h!.actions, 'h-1', 'tablet', 'width'));
expect(props().styleTablet).toEqual({});
expect(undoDepth()).toBe(2); // the drag burst + the clear
});
});
describe('Columns "Stack on this device"', () => {
test('hidden on desktop; on mobile it writes flexDirection:column to the mobile override only', () => {
mountPanel('desktop', ContainerStylePanel, 'cols');
expect(h!.container.querySelector('[data-testid="stack-columns"]')).toBeNull();
switchDevice('mobile');
const box = h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement;
click(box);
expect(props('cols').styleMobile).toEqual({ flexDirection: 'column' });
expect(props('cols').style.flexDirection).toBeUndefined();
click(h!.container.querySelector('[data-testid="stack-columns"]')!);
expect(props('cols').styleMobile).toEqual({});
});
test('un-stacking mobile under a stacked tablet writes row', () => {
const s = JSON.parse(state());
s.cols.props.styleTablet = { flexDirection: 'column' };
mountPanel('mobile', ContainerStylePanel, 'cols', JSON.stringify(s));
const box = h!.container.querySelector('[data-testid="stack-columns"]') as HTMLInputElement;
expect(box.checked).toBe(true);
click(box);
expect(props('cols').styleMobile).toEqual({ flexDirection: 'row' });
});
});
describe('DeviceBanner in GuidedStyles', () => {
test('shown on tablet/mobile only; "Back to desktop" switches the device', () => {
mount('desktop');
h!.mountChild(<GuidedStyles />);
act(() => { h!.actions.selectNode('h-1'); });
expect(h!.container.querySelector('[data-testid="device-banner"]')).toBeNull();
switchDevice('tablet');
const banner = h!.container.querySelector('[data-testid="device-banner"]')!;
expect(banner.textContent).toContain('Editing Tablet');
expect(banner.textContent).toContain('Colours and text apply everywhere');
click(banner.querySelector('button')!);
expect(h!.container.querySelector('[data-testid="device-banner"]')).toBeNull();
});
});
describe('end to end: export', () => {
test('a mobile font-size edit exports as a scoped max-width:767px rule', () => {
mountPanel('mobile');
click(presetBtn('Text Size', 'XL'));
const { html } = exportBodyHtml(h!.getSerialized());
expect(html).toContain('@media (max-width:767px)');
expect(html).toContain('font-size:24px!important');
expect(html).toMatch(/<h2[^>]*style="[^"]*font-size:\s*36px/); // desktop inline style unchanged
});
});
describe('canvas: overrides never remount the node (inline editing keeps focus/caret)', () => {
const ResetProbe: React.FC = () => {
const { setPropStyle } = useNodeProp('h-1');
return <button data-testid="apply" onClick={() => setPropStyle('fontSize', '24px')} />;
};
function startInlineEdit() {
act(() => { h!.actions.selectNode('h-1'); });
const el = h!.container.querySelector('h2') as HTMLElement;
expect(el.getAttribute('contenteditable')).toBe('true');
el.focus();
const range = document.createRange();
range.setStart(el.firstChild!, 3);
range.collapse(true);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
return el;
}
function expectStillEditing(el: HTMLElement) {
const now = h!.container.querySelector('h2') as HTMLElement;
expect(now).toBe(el);
expect(el.isConnected).toBe(true);
expect(el.textContent).toBe('Hello');
expect(document.activeElement).toBe(el);
const sel = window.getSelection()!;
expect(sel.anchorNode).toBe(el.firstChild);
expect(sel.anchorOffset).toBe(3);
}
test('the first override on mobile only restyles the selected heading', () => {
mount('mobile', { renderNode: true });
const el = startInlineEdit();
// Apply through the real setter while the heading keeps focus.
act(() => setResponsiveStyleProp(h!.actions, 'h-1', 'mobile', 'fontSize', '24px'));
expectStillEditing(el);
expect(el.style.fontSize).toBe('24px');
// ...and losing the last override does not remount either.
act(() => clearResponsiveStyleProp(h!.actions, 'h-1', 'mobile', 'fontSize'));
expectStillEditing(el);
expect(el.style.fontSize).toBe('36px');
});
test('switching device with an overridden, selected heading keeps the same element and its text', () => {
mount('desktop', { initialState: state({ styleMobile: { fontSize: '24px' } }), renderNode: true });
h!.mountChild(<ResetProbe />);
const el = startInlineEdit();
switchDevice('mobile');
expectStillEditing(el);
expect(el.style.fontSize).toBe('24px');
switchDevice('desktop');
expectStillEditing(el);
expect(el.style.fontSize).toBe('36px');
});
});
@@ -0,0 +1,96 @@
import React, { CSSProperties, useCallback, useMemo } from 'react';
import { useEditor } from '@craftjs/core';
import { useDevice } from '../../../state/DeviceContext';
import type { DeviceMode } from '../../../types';
import {
RESPONSIVE_STYLE_KEYS,
hasOverride,
resolveStyleForDevice,
type ResponsiveProps,
} from '../../../utils/responsive-style';
import { clearResponsiveStyleProp } from './shared';
/* ==========================================================================
Per-device panel helpers (#45). Panels show the EFFECTIVE value for the
current device (desktop → style, tablet → style+styleTablet, mobile →
style+styleTablet+styleMobile) and write through the device-aware
useNodeProp setters in shared.tsx, so editing tablet/mobile never touches
desktop.
========================================================================== */
/** Dot groups: one dot per spacing control, set when ANY side (or the
* legacy shorthand) is overridden; a reset clears the whole group. */
export const MARGIN_KEYS = ['marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'margin'] as const;
export const PADDING_KEYS = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'padding'] as const;
const DEVICE_LABEL: Record<DeviceMode, string> = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobile' };
/** The node's style as rendered on the current device. */
export function useResolvedStyle(nodeProps: Record<string, any>): CSSProperties {
const { device } = useDevice();
return useMemo(
() => resolveStyleForDevice(nodeProps as ResponsiveProps, device),
// The three style objects are replaced (never mutated) on every write.
// eslint-disable-next-line react-hooks/exhaustive-deps
[nodeProps.style, nodeProps.styleTablet, nodeProps.styleMobile, device],
);
}
export interface OverrideState {
/** The current (tablet/mobile) device stores its own value for the key(s). */
overridden: boolean;
/** Non-desktop, responsive key, no own value: it follows the larger device. */
inherited: boolean;
device: DeviceMode;
/** What a reset falls back to: 'tablet' when mobile inherits a tablet value. */
resetsTo: 'desktop' | 'tablet';
/** Removes this device's override of the key(s): one undo step. */
reset: () => void;
}
/**
* Override status of `key` (or a group, e.g. the four padding sides -- the
* group counts as overridden when ANY member is) on the current device.
* `selectedId` is needed for `reset`.
*/
export function useOverrideState(
nodeProps: Record<string, any>,
key: string | readonly string[],
selectedId: string,
): OverrideState {
const { device } = useDevice();
const { actions } = useEditor();
const keys = typeof key === 'string' ? [key] : key;
const props = nodeProps as ResponsiveProps;
const responsive = keys.some((k) => RESPONSIVE_STYLE_KEYS.has(k));
const overridden = device !== 'desktop' && keys.some((k) => hasOverride(props, device, k));
const inherited = device !== 'desktop' && responsive && !overridden;
const resetsTo = device === 'mobile' && keys.some((k) => hasOverride(props, 'tablet', k)) ? 'tablet' : 'desktop';
const keyId = keys.join('|');
const reset = useCallback(
() => clearResponsiveStyleProp(actions, selectedId, device, keyId.split('|')),
[actions, selectedId, device, keyId],
);
return { overridden, inherited, device, resetsTo, reset };
}
/** "You are editing tablet/mobile" strip under the panel's type header.
* Renders nothing on desktop. */
export const DeviceBanner: React.FC = () => {
const { device, setDevice } = useDevice();
if (device === 'desktop') return null;
const name = DEVICE_LABEL[device];
const scope = device === 'tablet' ? 'tablet and below' : 'mobile only';
return (
<div className="wsb-device-banner" role="status" data-testid="device-banner">
<i className={`fa fa-${device === 'tablet' ? 'tablet' : 'mobile'}`} aria-hidden />
<span>
<strong>Editing {name}</strong> - spacing, size and alignment changes apply to {scope}.
{' '}Colours and text apply everywhere.
</span>
<button type="button" className="wsb-device-banner-back" onClick={() => setDevice('desktop')}>
Back to desktop
</button>
</div>
);
};
+83 -7
View File
@@ -9,6 +9,9 @@ import {
} from '../../../constants/presets';
import { uploadAsset } from '../../../utils/assets';
import { getPref, setPref } from '../../../utils/local-prefs';
import { useDevice } from '../../../state/DeviceContext';
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
import type { DeviceMode } from '../../../types';
/* ---------- Helper: auto text color for bg ---------- */
export function autoTextColor(bg: string): string {
@@ -476,23 +479,96 @@ export function discreteSetProp(actions: any, nodeId: string, updater: (p: any)
actions.setProp(nodeId, updater);
}
/* ---------- Per-device style writes (#45) ----------
On tablet/mobile a RESPONSIVE_STYLE_KEYS key is written into that device's
partial (`styleTablet` / `styleMobile`) and never into `style`, so editing
a smaller device can't change desktop. Every other key (colours, fonts,
borders...) is not per-device and still writes `style`. An empty value on
tablet/mobile REMOVES the override (falls back to the inherited value)
rather than storing a `''` that overrides nothing -- same rule as
`withResponsiveValue` in utils/responsive-style.ts. */
const OVERRIDE_FIELD: Record<'tablet' | 'mobile', 'styleTablet' | 'styleMobile'> = {
tablet: 'styleTablet',
mobile: 'styleMobile',
};
const isEmptyStyleValue = (v: unknown) => v === undefined || v === null || v === '';
/** Mutates the Craft props draft `p`: routes each key of `patch` to `style`
* or to `device`'s override partial (see above). One call = one setProp. */
function applyStylePatch(p: any, device: DeviceMode, patch: Record<string, unknown>) {
let base: Record<string, unknown> | null = null;
let over: Record<string, unknown> | null = null;
const field = device === 'desktop' ? null : OVERRIDE_FIELD[device];
for (const [k, v] of Object.entries(patch)) {
if (field && RESPONSIVE_STYLE_KEYS.has(k)) {
const o: Record<string, unknown> = (over ??= { ...(p[field] || {}) });
if (isEmptyStyleValue(v)) delete o[k];
else o[k] = v;
} else {
(base ??= { ...p.style })[k] = v;
}
}
if (base) p.style = base;
if (field && over) p[field] = over;
}
/** Burst identity for a continuous style edit: includes the device, so a
* desktop drag followed by a mobile drag never merges into one undo step. */
const styleControlKey = (device: DeviceMode, controlKey: string) => `${device}:${controlKey}`;
/** Writes one style key for `device` (desktop → `style`, tablet/mobile →
* that device's override for responsive keys). Discrete by default (its own
* undo step); `continuous` joins the undo burst of `controlKey`
* (default `style.<key>`). */
export function setResponsiveStyleProp(
actions: any,
nodeId: string,
device: DeviceMode,
key: string,
value: unknown,
opts: { continuous?: boolean; controlKey?: string } = {},
) {
const update = (p: any) => applyStylePatch(p, device, { [key]: value });
if (opts.continuous) continuousSetProp(actions, nodeId, styleControlKey(device, opts.controlKey ?? `style.${key}`), update);
else discreteSetProp(actions, nodeId, update);
}
/** Removes `device`'s override of `key` (or of every key in a group, e.g. the
* four padding sides) in ONE discrete setProp = one undo step. No-op on
* desktop: the desktop value is the base, there is nothing to reset to. */
export function clearResponsiveStyleProp(actions: any, nodeId: string, device: DeviceMode, key: string | readonly string[]) {
if (device === 'desktop') return;
const field = OVERRIDE_FIELD[device];
const keys = typeof key === 'string' ? [key] : key;
discreteSetProp(actions, nodeId, (p: any) => {
const next = { ...(p[field] || {}) };
keys.forEach((k) => { delete next[k]; });
p[field] = next;
});
}
/* ---------- useNodeProp ----------
Shared setProp/setPropStyle boilerplate repeated across many *StylePanel.tsx
files. Only adopted where a panel's inline definitions were byte-equivalent
to this (some panels use different param names or extra logic and keep
their own local versions). */
their own local versions). The style setters are device-aware (#45): see
applyStylePatch above. */
export function useNodeProp(selectedId: string) {
const { actions } = useEditor();
const { device } = useDevice();
// Default setters are plain: one call = one undo step. Continuous inputs
// (colour pickers, sliders, typing) use the *Continuous variants.
const setProp = useCallback((key: string, value: any) => discreteSetProp(actions, selectedId, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyle = useCallback((prop: string, value: string) => discreteSetProp(actions, selectedId, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
const setPropStyle = useCallback((prop: string, value: string) => setResponsiveStyleProp(actions, selectedId, device, prop, value), [actions, selectedId, device]);
// Several props / style keys in ONE setProp call = exactly one undo step.
const setProps = useCallback((patch: Record<string, any>) => discreteSetProp(actions, selectedId, (p: any) => { Object.assign(p, patch); }), [actions, selectedId]);
const setPropStyles = useCallback((patch: Record<string, string>) => discreteSetProp(actions, selectedId, (p: any) => { p.style = { ...p.style, ...patch }; }), [actions, selectedId]);
const setPropStyles = useCallback((patch: Record<string, string>) => discreteSetProp(actions, selectedId, (p: any) => applyStylePatch(p, device, patch)), [actions, selectedId, device]);
const setPropContinuous = useCallback((key: string, value: any) => continuousSetProp(actions, selectedId, key, (p: any) => { p[key] = value; }), [actions, selectedId]);
const setPropStyleContinuous = useCallback((prop: string, value: string) => continuousSetProp(actions, selectedId, `style.${prop}`, (p: any) => { p.style = { ...p.style, [prop]: value }; }), [actions, selectedId]);
return { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous };
const setPropStyleContinuous = useCallback((prop: string, value: string) => setResponsiveStyleProp(actions, selectedId, device, prop, value, { continuous: true }), [actions, selectedId, device]);
// Several style keys as one continuous burst (`controlKey` names the control).
const setPropStylesContinuous = useCallback((patch: Record<string, string>, controlKey: string) => continuousSetProp(actions, selectedId, styleControlKey(device, controlKey), (p: any) => applyStylePatch(p, device, patch)), [actions, selectedId, device]);
return { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous };
}
/* ---------- Array Prop Editor (reusable for features, items, plans, etc.) ---------- */
@@ -641,7 +717,7 @@ export const NumericUnitInput: React.FC<NumericUnitInputProps> = ({
(label="Width" / label="Height") for components needing both, e.g.
ImageBlock / VideoBlock sizing. */
export interface SizeControlProps {
label: string;
label: React.ReactNode;
/** CSS value for this one dimension, e.g. "100%" / "400px" / "auto" / "". */
value: string;
onChange: (value: string) => void;
@@ -760,7 +836,7 @@ export type SpacingSide = 'top' | 'right' | 'bottom' | 'left';
export interface SpacingSideValues { top?: string; right?: string; bottom?: string; left?: string; }
const SPACING_SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
export interface SpacingControlProps {
label: string;
label: React.ReactNode;
value: SpacingSideValues;
/** Called per-side. Linked edits/presets call this once for each of the
* 4 sides (in top/right/bottom/left order) with the same value. */
+69
View File
@@ -2441,3 +2441,72 @@ body {
background: var(--color-accent-subtle);
cursor: default;
}
/* #45 overrides */
.wsb-override-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-left: 6px;
padding: 0;
vertical-align: middle;
border-radius: 50%;
box-sizing: border-box;
}
.wsb-override-dot.is-set {
background: var(--color-accent);
border: 1px solid var(--color-accent);
cursor: pointer;
}
.wsb-override-dot.is-set:hover,
.wsb-override-dot.is-set:focus-visible {
background: var(--color-danger);
border-color: var(--color-danger);
outline: none;
}
.wsb-override-dot.is-inherited {
background: transparent;
border: 1px solid var(--color-text-dim);
}
.wsb-device-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin: 0 0 8px;
padding: 8px 10px;
font-size: 11px;
line-height: 1.4;
color: var(--color-text);
background: var(--color-accent-subtle);
border: 1px solid var(--color-accent);
border-radius: var(--radius-md);
}
.wsb-device-banner > span {
flex: 1 1 180px;
}
.wsb-device-banner-back {
padding: 0;
font: inherit;
font-weight: 600;
color: var(--color-accent);
background: none;
border: 0;
cursor: pointer;
text-decoration: underline;
}
.wsb-stack-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--color-text);
cursor: pointer;
}