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:
1 parent
d8ff295093
commit
c2a90cd7a7
15 files changed
+806
-78
No files matched your search
@@ -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}</>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user