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) => (
- Vertical Alignment + Vertical Alignment{dot(vAlignKey)} setPropStyle(vAlignKey, v)} />
+ {isColumns && device !== 'desktop' && ( +
+ Columns Layout{dot('flexDirection')} + +
+ )} {!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 ( + + + ); +}; 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; +}