diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index 42603cc..2950208 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -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 * `` 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, 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, 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) => ( + + ))} + > + ) : ( + 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 = ({ 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 = ({ 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(null); const active = isSelected && id !== 'ROOT' && !!dom; @@ -149,28 +180,10 @@ export const RenderNode: React.FC = ({ 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) => ( - - ))} - > - ) : ( - 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 ? : render; if (!active) return <>{output}>; diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx index b0087b9..d307fa3 100644 --- a/craft/src/panels/right/GuidedStyles.tsx +++ b/craft/src/panels/right/GuidedStyles.tsx @@ -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<{ Ask Sitesmith + {/* TEXT */} {isText && } diff --git a/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx b/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx index 390c9f7..7da56b8 100644 --- a/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx +++ b/craft/src/panels/right/styles/BackgroundSectionStylePanel.tsx @@ -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 = ({ selectedId, nodeProps }) => { const { setProp, setPropStyle, setPropContinuous } = useNodeProp(selectedId); - const style = nodeProps.style || {}; + const style = useResolvedStyle(nodeProps); + const dot = (key: string | readonly string[]) => ; return ( <> @@ -54,7 +57,7 @@ export const BackgroundSectionStylePanel: React.FC = ({ selecte onChange={(e) => setPropContinuous('innerMaxWidth', e.target.value)} style={inputStyle} /> - Padding + Padding{dot(PADDING_KEYS)} setPropStyle('padding', v)} /> diff --git a/craft/src/panels/right/styles/ButtonStylePanel.tsx b/craft/src/panels/right/styles/ButtonStylePanel.tsx index 312a43f..04fce94 100644 --- a/craft/src/panels/right/styles/ButtonStylePanel.tsx +++ b/craft/src/panels/right/styles/ButtonStylePanel.tsx @@ -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 = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); - const style: CSSProperties = nodeProps.style || {}; + const style: CSSProperties = useResolvedStyle(nodeProps); + const dot = (key: string | readonly string[]) => ; const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId); @@ -121,7 +124,7 @@ export const ButtonStylePanel: React.FC = ({ selectedId, nodePr /> - Padding + Padding{dot(PADDING_KEYS)} = ({ selectedId, nodePr {/* Box model + border/effects + animation/visibility rollout */} 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)} /> diff --git a/craft/src/panels/right/styles/ContainerStylePanel.tsx b/craft/src/panels/right/styles/ContainerStylePanel.tsx index c55fa22..a82987f 100644 --- a/craft/src/panels/right/styles/ContainerStylePanel.tsx +++ b/craft/src/panels/right/styles/ContainerStylePanel.tsx @@ -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 = ({ selectedId, nodeProps }) => { - const style: CSSProperties = nodeProps.style || {}; + const style: CSSProperties = useResolvedStyle(nodeProps); + const dot = (key: string | readonly string[]) => ; + 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 = ({ 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, 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 = ({ selectedId, nod /> - Alignment + Alignment{dot('textAlign')} {(['left', 'center', 'right', 'justify'] as const).map((a) => ( = ({ selectedId, nod - Vertical Alignment + Vertical Alignment{dot(vAlignKey)} setPropStyle(vAlignKey, v)} /> + {isColumns && device !== 'desktop' && ( + + Columns Layout{dot('flexDirection')} + + + Stack on this device + + + )} {!isColumns && ( - Min Height + Min Height{dot('minHeight')} setPropStyleContinuous('minHeight', v)} @@ -134,7 +153,7 @@ export const ContainerStylePanel: React.FC = ({ selectedId, nod )} {/* Box model + border/effects + animation/visibility rollout */} - + > diff --git a/craft/src/panels/right/styles/ImageStylePanel.tsx b/craft/src/panels/right/styles/ImageStylePanel.tsx index b144412..c1ef480 100644 --- a/craft/src/panels/right/styles/ImageStylePanel.tsx +++ b/craft/src/panels/right/styles/ImageStylePanel.tsx @@ -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 = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); - const style: CSSProperties = nodeProps.style || {}; + const style: CSSProperties = useResolvedStyle(nodeProps); + const dot = (key: string | readonly string[]) => ; const { setProp, setPropStyle, setProps, setPropStyleContinuous, setPropStyles } = useNodeProp(selectedId); @@ -64,7 +67,7 @@ export const ImageStylePanel: React.FC = ({ 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. */} Width{dot('maxWidth')}>} value={(style.maxWidth as string) || ''} onChange={(v) => setPropStyleContinuous('maxWidth', v)} /> @@ -74,7 +77,7 @@ export const ImageStylePanel: React.FC = ({ selectedId, nodePro applyAspectRatio, which clears any stale height at that moment). */} {!style.aspectRatio && ( Height{dot('height')}>} value={(style.height as string) || ''} onChange={(v) => setPropStyleContinuous('height', v)} /> @@ -111,7 +114,7 @@ export const ImageStylePanel: React.FC = ({ selectedId, nodePro {/* Box model + animation/visibility rollout */} - + > diff --git a/craft/src/panels/right/styles/MediaStylePanel.tsx b/craft/src/panels/right/styles/MediaStylePanel.tsx index 372d1b8..edc98ff 100644 --- a/craft/src/panels/right/styles/MediaStylePanel.tsx +++ b/craft/src/panels/right/styles/MediaStylePanel.tsx @@ -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 = ({ 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[]) => ; // 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 = ({ selectedId, nodePro {nodeProps.videoUrl !== undefined && ( <> Width{dot('width')}>} value={(style.width as string) || ''} onChange={(v) => setPropStyleContinuous('width', v)} /> @@ -276,7 +279,7 @@ export const MediaStylePanel: React.FC = ({ selectedId, nodePro {/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider all carry these props now, so it's safe to render unconditionally). */} - + > diff --git a/craft/src/panels/right/styles/OverrideDot.tsx b/craft/src/panels/right/styles/OverrideDot.tsx new file mode 100644 index 0000000..1cadf29 --- /dev/null +++ b/craft/src/panels/right/styles/OverrideDot.tsx @@ -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; + 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 = ({ 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 ( + { e.preventDefault(); e.stopPropagation(); reset(); }} + /> + ); + } + if (!inherited) return null; + const from = device === 'mobile' ? 'larger screens' : 'desktop'; + return ( + + ); +}; diff --git a/craft/src/panels/right/styles/TextStylePanel.tsx b/craft/src/panels/right/styles/TextStylePanel.tsx index 7a980c2..2151847 100644 --- a/craft/src/panels/right/styles/TextStylePanel.tsx +++ b/craft/src/panels/right/styles/TextStylePanel.tsx @@ -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 = ({ selectedId, nodeProps }) => { - const style: CSSProperties = nodeProps.style || {}; + const style: CSSProperties = useResolvedStyle(nodeProps); + const dot = (key: string | readonly string[]) => ; const { setProp, setPropStyle, setProps, setPropStyleContinuous } = useNodeProp(selectedId); @@ -87,7 +90,7 @@ export const TextStylePanel: React.FC = ({ selectedId, nodeProp /> - Text Size + Text Size{dot('fontSize')} = ({ selectedId, nodeProp /> - Line Height + Line Height{dot('lineHeight')} = ({ selectedId, nodeProp /> - Letter Spacing + Letter Spacing{dot('letterSpacing')} = ({ selectedId, nodeProp /> - Alignment + Alignment{dot('textAlign')} {(['left', 'center', 'right', 'justify'] as const).map((a) => ( = ({ selectedId, nodeProp {/* Box model + border/effects + animation/visibility rollout */} 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)} /> 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)} /> diff --git a/craft/src/panels/right/styles/containerBoxModel.tsx b/craft/src/panels/right/styles/containerBoxModel.tsx index dee8240..743d6c3 100644 --- a/craft/src/panels/right/styles/containerBoxModel.tsx +++ b/craft/src/panels/right/styles/containerBoxModel.tsx @@ -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, 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 = ({ style, setPropStyle, setPropStyles }) => { +export const BoxModelSection: React.FC = ({ 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 = ({ style, setProp return ( Margin{labelAddon?.(MARGIN_KEYS)}>} value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }} onChange={marginSetter} /> 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*')} diff --git a/craft/src/panels/right/styles/mediaBoxModel.tsx b/craft/src/panels/right/styles/mediaBoxModel.tsx index b69b951..8c19a6f 100644 --- a/craft/src/panels/right/styles/mediaBoxModel.tsx +++ b/craft/src/panels/right/styles/mediaBoxModel.tsx @@ -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; 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 = ({ style, setPropStyle }) => { +export const BoxModelSection: React.FC = ({ style, setPropStyle, labelAddon }) => { const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) => setPropStyle(`${kind}${capitalize(side)}`, value); return ( Margin{labelAddon?.(MARGIN_KEYS)}>} value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }} onChange={sideSetter('margin')} /> Padding{labelAddon?.(PADDING_KEYS)}>} value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }} onChange={sideSetter('padding')} /> diff --git a/craft/src/panels/right/styles/responsive-panel.test.tsx b/craft/src/panels/right/styles/responsive-panel.test.tsx new file mode 100644 index 0000000..659d9c4 --- /dev/null +++ b/craft/src/panels/right/styles/responsive-panel.test.tsx @@ -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, nodes: string[] = [], extra: Record = {}) => ({ + type: { resolvedName: type }, + isCanvas: type === 'Container', + props, + displayName: type === 'ColumnLayout' ? 'Columns' : type, + custom: {}, + hidden: false, + nodes, + linkedNodes: {}, + ...extra, +}); + +const state = (headingExtra: Record = {}) => + 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 }> }> = ({ id, Panel }) => { + const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} })); + return ; +}; + +function mount(device: DeviceMode, opts: { initialState?: string; renderNode?: boolean } = {}) { + h = renderEditorHarness({ + initialState: opts.initialState ?? state(), + ...(opts.renderNode ? { onRender: RenderNode } : {}), + wrap: (tree) => ( + + + {tree} + + ), + }); + return h; +} + +function mountPanel(device: DeviceMode, Panel: React.FC = TextStylePanel, id = 'h-1', initialState?: string) { + mount(device, { initialState }); + h!.mountChild(); + 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 ( + + setPropStyles({ fontSize: '20px', color: '#ff0000', paddingTop: '4px', fontFamily: 'Georgia' })} /> + setPropStyleContinuous('fontSize', '21px')} /> + setPropStyleContinuous('fontSize', '22px')} /> + + ); + }; + 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(); + 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(); + 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(); + 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(/]*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 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(); + const el = startInlineEdit(); + switchDevice('mobile'); + expectStillEditing(el); + expect(el.style.fontSize).toBe('24px'); + switchDevice('desktop'); + expectStillEditing(el); + expect(el.style.fontSize).toBe('36px'); + }); +}); diff --git a/craft/src/panels/right/styles/responsive-panel.tsx b/craft/src/panels/right/styles/responsive-panel.tsx new file mode 100644 index 0000000..f64aa47 --- /dev/null +++ b/craft/src/panels/right/styles/responsive-panel.tsx @@ -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 = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobile' }; + +/** The node's style as rendered on the current device. */ +export function useResolvedStyle(nodeProps: Record): 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, + 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 ( + + + + Editing {name} - spacing, size and alignment changes apply to {scope}. + {' '}Colours and text apply everywhere. + + setDevice('desktop')}> + Back to desktop + + + ); +}; diff --git a/craft/src/panels/right/styles/shared.tsx b/craft/src/panels/right/styles/shared.tsx index 8f07cdc..b103f84 100644 --- a/craft/src/panels/right/styles/shared.tsx +++ b/craft/src/panels/right/styles/shared.tsx @@ -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) { + let base: Record | null = null; + let over: Record | 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 = (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.`). */ +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) => discreteSetProp(actions, selectedId, (p: any) => { Object.assign(p, patch); }), [actions, selectedId]); - const setPropStyles = useCallback((patch: Record) => discreteSetProp(actions, selectedId, (p: any) => { p.style = { ...p.style, ...patch }; }), [actions, selectedId]); + const setPropStyles = useCallback((patch: Record) => 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, 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 = ({ (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. */ diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index f437ff9..677df12 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; +}