Merge issue-35 (Refs #35)
This commit is contained in:
commit
bee052833c
17 files changed
+263
-44
No files matched your search
@@ -47,7 +47,7 @@ export const BackgroundSectionStylePanel: React.FC<StylePanelProps> = ({ selecte
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Layout */}
|
||||
<CollapsibleSection title="Layout" defaultOpen={false}>
|
||||
<CollapsibleSection title="Layout">
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Inner Max Width</label>
|
||||
<input type="text" value={nodeProps.innerMaxWidth || '1200px'}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import {
|
||||
BG_COLORS,
|
||||
SPACING_PRESETS,
|
||||
RADIUS_PRESETS,
|
||||
} from '../../../constants/presets';
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
inputStyle,
|
||||
sectionGap,
|
||||
useNodeProp,
|
||||
continuousSetProp,
|
||||
} from './shared';
|
||||
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
|
||||
|
||||
@@ -33,6 +34,7 @@ const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
|
||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const style: CSSProperties = nodeProps.style || {};
|
||||
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
|
||||
// ColumnLayout only ever carries `columns`/`split` props -- Container and
|
||||
@@ -41,6 +43,9 @@ 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';
|
||||
|
||||
@@ -85,14 +90,6 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
|
||||
onSelect={(v) => setPropStyle('background', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Padding</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
presets={SPACING_PRESETS}
|
||||
activeValue={style.padding as string}
|
||||
onSelect={(v) => setPropStyle('padding', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Border Radius</SectionLabel>
|
||||
<PresetButtonGrid
|
||||
@@ -137,7 +134,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
|
||||
)}
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} />
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
|
||||
</>
|
||||
|
||||
@@ -478,7 +478,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
)}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
@@ -496,7 +496,7 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
{/* Box model: margin/padding (per-side), border, shadow, opacity --
|
||||
common enh-batch rollout, applies to the whole form family since
|
||||
they all spread `style` onto their root element. */}
|
||||
<CollapsibleSection title="Spacing, Border & Effects" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing, Border & Effects">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
|
||||
@@ -263,7 +263,7 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
)}
|
||||
|
||||
{/* Background & radius */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
|
||||
@@ -55,6 +55,7 @@ function setValue(input: HTMLInputElement, value: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
try { window.localStorage.clear(); } catch { /* ignore */ }
|
||||
setPropSpy.mockClear();
|
||||
});
|
||||
|
||||
@@ -178,13 +179,14 @@ describe('MediaStylePanel Video AspectRatioControl applies crop-fills-by-default
|
||||
function openCollapsibleByTitle(title: string) {
|
||||
const btn = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes(title));
|
||||
expect(btn).toBeTruthy();
|
||||
if (btn!.getAttribute('aria-expanded') === 'true') return;
|
||||
act(() => {
|
||||
btn!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('MediaStylePanel box-model + animation/visibility rollout renders for every media type', () => {
|
||||
test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (collapsed by default, open on click)', () => {
|
||||
test('a Map-shaped selection still gets Spacing / Border & Effects / Animation & Visibility sections (Spacing open by default, others open on click)', () => {
|
||||
lastProps = { address: 'New York, NY', zoom: 14, height: '400px', style: {}, animation: '', hideOnDesktop: false };
|
||||
render(<MediaStylePanel selectedId="map-1" nodeProps={lastProps} />);
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ function click(el: Element | null) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
try { window.localStorage.clear(); } catch { /* ignore */ }
|
||||
setPropSpy.mockClear();
|
||||
mockPages = [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
|
||||
@@ -227,14 +228,12 @@ describe('NavStylePanel: "Sync links with Pages" (F2, regression vs legacy build
|
||||
});
|
||||
});
|
||||
|
||||
/** The Spacing/Border & Effects/Animation/Visibility CollapsibleSections all
|
||||
* default closed (defaultOpen={false}, matching the pre-existing "Spacing"
|
||||
* section's convention) -- open one by clicking its header button before
|
||||
* asserting on / interacting with its contents. */
|
||||
/** Border & Effects / Animation / Visibility default closed; Spacing defaults
|
||||
* open. Opens a section if it is not already, before asserting on it. */
|
||||
function openSection(title: string) {
|
||||
const header = Array.from(container.querySelectorAll('button'))
|
||||
.find((b) => b.textContent?.includes(title))!;
|
||||
click(header);
|
||||
if (header.getAttribute('aria-expanded') !== 'true') click(header);
|
||||
}
|
||||
|
||||
describe('NavStylePanel: box-model + animation + visibility controls always present', () => {
|
||||
|
||||
@@ -405,7 +405,7 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
single "Padding" preset row is folded into the Padding SpacingControl
|
||||
below (still writes to style.padding when linked, matching the
|
||||
previous behavior exactly). */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
|
||||
@@ -227,7 +227,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Colors */}
|
||||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||||
<CollapsibleSection title="Colors">
|
||||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => continuousSetProp(actions, selectedId, 'featuredBg', (p: any) => { p.featuredBg = v; })} />
|
||||
{/* Regular (non-featured) card colors -- built into PricingTable's
|
||||
render/toHtml but previously hard-coded literals with no control
|
||||
@@ -242,7 +242,7 @@ export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeP
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
|
||||
@@ -129,7 +129,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
})}
|
||||
|
||||
{/* Style */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
@@ -155,7 +155,7 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding, border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
|
||||
@@ -247,7 +247,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
)}
|
||||
|
||||
{/* Background & padding */}
|
||||
<CollapsibleSection title="Style" defaultOpen={false}>
|
||||
<CollapsibleSection title="Style">
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
||||
@@ -259,7 +259,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
|
||||
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing & Border">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{
|
||||
|
||||
@@ -175,7 +175,7 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
||||
</div>
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop as string, right: style.marginRight as string, bottom: style.marginBottom as string, left: style.marginLeft as string }}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
|
||||
import { CollapsibleSection, closeContinuousBurst } from './shared';
|
||||
import { ContainerStylePanel } from './ContainerStylePanel';
|
||||
import { TextStylePanel } from './TextStylePanel';
|
||||
import { ButtonStylePanel } from './ButtonStylePanel';
|
||||
import { expandPaddingShorthand } from './containerBoxModel';
|
||||
|
||||
const node = (type: string, props: any, extra: any = {}) => ({
|
||||
type: { resolvedName: type }, isCanvas: false, props, displayName: type, custom: {}, hidden: false, nodes: [], linkedNodes: {}, ...extra,
|
||||
});
|
||||
const STATE = JSON.stringify({
|
||||
ROOT: node('Container', { style: {}, tag: 'div' }, { isCanvas: true, nodes: ['sec', 'head', 'btn'] }),
|
||||
sec: node('Section', {
|
||||
style: { padding: '40px 0', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', marginTop: '' },
|
||||
innerMaxWidth: '1200px',
|
||||
}, { parent: 'ROOT' }),
|
||||
head: node('Heading', { text: 'Hi', level: 'h2', style: { color: '#000000' } }, { parent: 'ROOT' }),
|
||||
btn: node('ButtonLink', { text: 'Go', style: {} }, { parent: 'ROOT' }),
|
||||
});
|
||||
|
||||
let harness: EditorHarness | null = null;
|
||||
beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } });
|
||||
afterEach(() => { closeContinuousBurst(); vi.useRealTimers(); harness?.unmount(); harness = null; });
|
||||
|
||||
const Probe: React.FC<{ id: string; Panel: React.FC<any> }> = ({ id, Panel }) => {
|
||||
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id].data.props }));
|
||||
return <Panel selectedId={id} nodeProps={nodeProps} />;
|
||||
};
|
||||
|
||||
describe('CollapsibleSection persistence', () => {
|
||||
function mount() {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
const root = createRoot(el);
|
||||
act(() => { root.render(<CollapsibleSection title="Spacing"><span data-testid="body">x</span></CollapsibleSection>); });
|
||||
return { el, unmount: () => { act(() => root.unmount()); el.remove(); } };
|
||||
}
|
||||
const header = (el: HTMLElement) => el.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
test('defaults open, and a collapse survives unmount/remount', () => {
|
||||
let m = mount();
|
||||
expect(header(m.el).getAttribute('aria-expanded')).toBe('true');
|
||||
act(() => { header(m.el).click(); });
|
||||
expect(m.el.querySelector('[data-testid=body]')).toBeNull();
|
||||
m.unmount();
|
||||
m = mount();
|
||||
expect(header(m.el).getAttribute('aria-expanded')).toBe('false');
|
||||
expect(m.el.querySelector('[data-testid=body]')).toBeNull();
|
||||
m.unmount();
|
||||
});
|
||||
|
||||
test('falls back to defaultOpen when localStorage throws', () => {
|
||||
const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked'); });
|
||||
const set = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked'); });
|
||||
try {
|
||||
const m = mount();
|
||||
expect(header(m.el).getAttribute('aria-expanded')).toBe('true');
|
||||
expect(() => act(() => { header(m.el).click(); })).not.toThrow();
|
||||
expect(header(m.el).getAttribute('aria-expanded')).toBe('false');
|
||||
m.unmount();
|
||||
} finally { spy.mockRestore(); set.mockRestore(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandPaddingShorthand', () => {
|
||||
test.each([
|
||||
['20px', { top: '20px', right: '20px', bottom: '20px', left: '20px' }],
|
||||
['40px 0', { top: '40px', right: '0', bottom: '40px', left: '0' }],
|
||||
['1px 2px 3px', { top: '1px', right: '2px', bottom: '3px', left: '2px' }],
|
||||
['1px 2px 3px 4px', { top: '1px', right: '2px', bottom: '3px', left: '4px' }],
|
||||
['calc(1px + 2px) 0', { top: 'calc(1px + 2px)', right: '0', bottom: 'calc(1px + 2px)', left: '0' }],
|
||||
['', {}],
|
||||
['1 2 3 4 5', {}],
|
||||
])('%s', (input, expected) => { expect(expandPaddingShorthand(input)).toEqual(expected); });
|
||||
});
|
||||
|
||||
describe('Container padding is a single control', () => {
|
||||
const paddingGrid = () => harness!.container.querySelectorAll('[data-testid=spacing-control]');
|
||||
|
||||
test('Section: M preset sets four longhands, clears shorthand, one undo step', () => {
|
||||
harness = renderEditorHarness({ initialState: STATE });
|
||||
harness.mountChild(<Probe id="sec" Panel={ContainerStylePanel} />);
|
||||
// no separate shorthand "Padding" preset row: only Margin + Padding controls
|
||||
expect(Array.from(harness.container.querySelectorAll('.guided-section')).filter((e) => e.textContent?.startsWith('Padding') && !e.getAttribute('data-testid')).length).toBe(0);
|
||||
expect(paddingGrid().length).toBe(2);
|
||||
// legacy '40px 0' is shown expanded in the unlinked fields
|
||||
const m = Array.from(paddingGrid()[1].querySelectorAll('button')).find((b) => b.textContent === 'M')!;
|
||||
harness.act(() => { harness!.actions.history.clear(); });
|
||||
expect(harness.query.history.canUndo()).toBe(false);
|
||||
harness.act(() => { m.click(); });
|
||||
const style = JSON.parse(harness.getSerialized()).sec.props.style;
|
||||
expect([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]).toEqual(['16px', '16px', '16px', '16px']);
|
||||
expect(style.padding).toBe('');
|
||||
expect(harness.query.history.canUndo()).toBe(true);
|
||||
harness.act(() => { harness!.actions.history.undo(); });
|
||||
const back = JSON.parse(harness.getSerialized()).sec.props.style;
|
||||
expect(back.padding).toBe('40px 0');
|
||||
expect(back.paddingTop).toBe('');
|
||||
expect(harness.query.history.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
test('editing one side of a legacy shorthand seeds the others, one step', () => {
|
||||
harness = renderEditorHarness({ initialState: STATE });
|
||||
harness.mountChild(<Probe id="sec" Panel={ContainerStylePanel} />);
|
||||
harness.act(() => { harness!.actions.history.clear(); });
|
||||
const pad = paddingGrid()[1];
|
||||
harness.act(() => { (pad.querySelector('[data-testid=spacing-link-toggle]') as HTMLButtonElement).click(); });
|
||||
const top = pad.querySelector('[data-testid=spacing-top-number]') as HTMLInputElement;
|
||||
expect(top.value).toBe('40');
|
||||
harness.act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(top, '20');
|
||||
top.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const style = JSON.parse(harness.getSerialized()).sec.props.style;
|
||||
expect([style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft]).toEqual(['20px', '0', '40px', '0']);
|
||||
expect(style.padding).toBe('');
|
||||
harness.act(() => { harness!.actions.history.undo(); });
|
||||
expect(JSON.parse(harness.getSerialized()).sec.props.style.padding).toBe('40px 0');
|
||||
expect(harness.query.history.canUndo()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default open/closed sections', () => {
|
||||
function sections(id: string, Panel: React.FC<any>): Record<string, boolean> {
|
||||
harness = renderEditorHarness({ initialState: STATE });
|
||||
harness.mountChild(<Probe id={id} Panel={Panel} />);
|
||||
const out: Record<string, boolean> = {};
|
||||
harness.container.querySelectorAll('button[aria-expanded]').forEach((b) => {
|
||||
out[(b.textContent || '').trim()] = b.getAttribute('aria-expanded') === 'true';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Text panel', () => {
|
||||
expect(sections('head', TextStylePanel)).toEqual({ Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false });
|
||||
});
|
||||
test('Button panel', () => {
|
||||
expect(sections('btn', ButtonStylePanel)).toEqual({ 'Hover State': false, Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false });
|
||||
});
|
||||
test('Container panel', () => {
|
||||
expect(sections('sec', ContainerStylePanel)).toEqual({ Spacing: true, 'Border & Effects': false, 'Animation & Visibility': false });
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CollapsibleSection,
|
||||
SpacingControl,
|
||||
SpacingSide,
|
||||
SpacingSideValues,
|
||||
BorderControl,
|
||||
BorderValue,
|
||||
buildBorderShorthand,
|
||||
@@ -41,27 +42,78 @@ export function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||
return { width: m[1], style: m[2], color: m[3] };
|
||||
}
|
||||
|
||||
/** Splits a CSS value on top-level whitespace (so `calc(1px + 2px)` stays one token). */
|
||||
function splitCssTokens(v: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = '';
|
||||
for (const ch of v.trim()) {
|
||||
if (ch === '(') depth++;
|
||||
else if (ch === ')') depth = Math.max(0, depth - 1);
|
||||
if (/\s/.test(ch) && depth === 0) {
|
||||
if (cur) out.push(cur);
|
||||
cur = '';
|
||||
} else cur += ch;
|
||||
}
|
||||
if (cur) out.push(cur);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Expands a 1-4 value `padding` shorthand into per-side values (CSS order:
|
||||
* top, right, bottom, left). Anything unparseable yields no sides. */
|
||||
export function expandPaddingShorthand(value: string | undefined | null): SpacingSideValues {
|
||||
const t = splitCssTokens(String(value ?? ''));
|
||||
if (t.length < 1 || t.length > 4) return {};
|
||||
const [top, right = top, bottom = top, left = right] = t;
|
||||
return { top, right, bottom, left };
|
||||
}
|
||||
|
||||
const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
|
||||
const PADDING_KEYS = SIDES.map((s) => `padding${capitalize(s)}`);
|
||||
const hasValue = (v: unknown) => v !== undefined && v !== null && v !== '';
|
||||
|
||||
export interface BoxModelSectionProps {
|
||||
style: Record<string, any>;
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Margin + Padding, per-side, via the shared SpacingControl. */
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle }) => {
|
||||
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
|
||||
setPropStyle(`${kind}${capitalize(side)}`, value);
|
||||
/** 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 }) => {
|
||||
const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value);
|
||||
|
||||
const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k]));
|
||||
const seeded = legacyShorthand ? expandPaddingShorthand(style.padding) : {};
|
||||
const padding: SpacingSideValues = {
|
||||
top: hasValue(style.paddingTop) ? style.paddingTop : seeded.top,
|
||||
right: hasValue(style.paddingRight) ? style.paddingRight : seeded.right,
|
||||
bottom: hasValue(style.paddingBottom) ? style.paddingBottom : seeded.bottom,
|
||||
left: hasValue(style.paddingLeft) ? style.paddingLeft : seeded.left,
|
||||
};
|
||||
|
||||
const paddingPatch = (sides: SpacingSideValues): Record<string, string> => {
|
||||
const patch: Record<string, string> = { padding: '' };
|
||||
SIDES.forEach((s) => { patch[`padding${capitalize(s)}`] = sides[s] ?? ''; });
|
||||
return patch;
|
||||
};
|
||||
|
||||
return (
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={sideSetter('margin')}
|
||||
onChange={marginSetter}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={sideSetter('padding')}
|
||||
value={padding}
|
||||
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
|
||||
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
|
||||
setPropStyle(`${kind}${capitalize(side)}`, value);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
BORDER_STYLES,
|
||||
} from '../../../constants/presets';
|
||||
import { uploadAsset } from '../../../utils/assets';
|
||||
import { getPref, setPref } from '../../../utils/local-prefs';
|
||||
|
||||
/* ---------- Helper: auto text color for bg ---------- */
|
||||
export function autoTextColor(bg: string): string {
|
||||
@@ -387,12 +388,26 @@ export function navColorFields(nodeProps: Record<string, any>): NavColorField[]
|
||||
}
|
||||
|
||||
/* ---------- Collapsible section ---------- */
|
||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const slugifyTitle = (t: string) => t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
|
||||
/** Open/closed state is remembered per section type (never per node) under
|
||||
* `section:<storageKey>`; the key defaults to the slugified title. */
|
||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; storageKey?: string; children: React.ReactNode }> = ({ title, defaultOpen = true, storageKey, children }) => {
|
||||
const prefKey = `section:${storageKey ?? slugifyTitle(title)}`;
|
||||
const [open, setOpen] = useState<boolean>(() => {
|
||||
const v = getPref<unknown>(prefKey, defaultOpen);
|
||||
return typeof v === 'boolean' ? v : defaultOpen;
|
||||
});
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
setPref(prefKey, next);
|
||||
};
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid #2d2d3a', paddingTop: 8, marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', background: 'none', border: 'none', color: '#a1a1aa', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: '4px 0', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||
>
|
||||
<i className={`fa fa-chevron-${open ? 'down' : 'right'}`} style={{ fontSize: 8, width: 10 }} />
|
||||
@@ -750,13 +765,18 @@ export interface SpacingControlProps {
|
||||
/** 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. */
|
||||
onChange: (side: SpacingSide, value: string) => void;
|
||||
/** Optional: linked/preset edits call this once with all four sides instead
|
||||
* of calling `onChange` four times, so a consumer can make them one undo step. */
|
||||
onChangeAll?: (values: Required<SpacingSideValues>) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
const [linked, setLinked] = useState(true);
|
||||
|
||||
const applyToAllSides = (v: string) => SPACING_SIDES.forEach((s) => onChange(s, v));
|
||||
const applyToAllSides = (v: string) => (onChangeAll
|
||||
? onChangeAll({ top: v, right: v, bottom: v, left: v })
|
||||
: SPACING_SIDES.forEach((s) => onChange(s, v)));
|
||||
const linkedValue = value.top ?? '';
|
||||
const isAllEqual = SPACING_SIDES.every((s) => (value[s] ?? '') === linkedValue);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const INITIAL_STATE = JSON.stringify({
|
||||
|
||||
let harness: EditorHarness | null = null;
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } vi.useFakeTimers(); });
|
||||
afterEach(() => {
|
||||
closeContinuousBurst();
|
||||
vi.useRealTimers();
|
||||
|
||||
Reference in new issue
Block a user