Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a426e3513 | ||
|
|
9750a6c2bf |
@@ -52,69 +52,3 @@ describe('ButtonLink.toHtml text escaping (attacker-controlled `text` prop)', ()
|
|||||||
expect(html).toContain('>Click Me</a>');
|
expect(html).toContain('>Click Me</a>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ButtonLink.toHtml hover state (scoped <style> block)', () => {
|
|
||||||
test('no hover props -- no <style> block, no class added', () => {
|
|
||||||
const { html } = toHtml({ href: '#', text: 'x' }, '', 'node-1');
|
|
||||||
expect(html).not.toContain('<style>');
|
|
||||||
expect(html).not.toContain('class=');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('hoverBg/hoverColor emit a scoped :hover rule scoped to the node id', () => {
|
|
||||||
const { html } = toHtml({ href: '#', text: 'x', hoverBg: '#111111', hoverColor: '#eeeeee' }, '', 'node-42');
|
|
||||||
expect(html).toMatch(/<style>\.btn_[a-z0-9]+:hover\{background-color:#111111;color:#eeeeee\}<\/style>/);
|
|
||||||
expect(html).toMatch(/class="btn_[a-z0-9]+"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two different node ids produce different scope classes (no collision)', () => {
|
|
||||||
const a = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-a').html;
|
|
||||||
const b = toHtml({ href: '#', text: 'x', hoverBg: '#111111' }, '', 'node-b').html;
|
|
||||||
const scopeOf = (html: string) => html.match(/btn_[a-z0-9]+/)?.[0];
|
|
||||||
expect(scopeOf(a)).toBeTruthy();
|
|
||||||
expect(scopeOf(a)).not.toBe(scopeOf(b));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an XSS breakout attempt in hoverBg cannot close the <style> element', () => {
|
|
||||||
const malicious = '</style><script>alert(1)</script>';
|
|
||||||
const { html } = toHtml({ href: '#', text: 'x', hoverBg: malicious }, '', 'node-1');
|
|
||||||
expect(html).not.toContain('</style><script>');
|
|
||||||
expect(html).not.toContain('<script>alert(1)</script>');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a rule-breakout attempt in hoverColor cannot inject a second selector/rule', () => {
|
|
||||||
const malicious = 'red;}body{background:red';
|
|
||||||
const { html } = toHtml({ href: '#', text: 'x', hoverColor: malicious }, '', 'node-1');
|
|
||||||
expect(html).not.toContain('}body{');
|
|
||||||
expect(html).not.toContain(';}');
|
|
||||||
// The whole export is still exactly one <style> element -- no new rule
|
|
||||||
// or element was opened by the malicious value.
|
|
||||||
expect((html.match(/<style>/g) || []).length).toBe(1);
|
|
||||||
expect((html.match(/<\/style>/g) || []).length).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ButtonLink.craft.props exposes target + hover + box-model + animation/visibility', () => {
|
|
||||||
test('target defaults to _self, hoverBg/hoverColor blank', () => {
|
|
||||||
const props = (ButtonLink as any).craft.props;
|
|
||||||
expect(props.target).toBe('_self');
|
|
||||||
expect(props.hoverBg).toBe('');
|
|
||||||
expect(props.hoverColor).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
|
||||||
const props = (ButtonLink as any).craft.props;
|
|
||||||
expect(props.animation).toBe('');
|
|
||||||
expect(props.animationDelay).toBe('0');
|
|
||||||
expect(props.hideOnDesktop).toBe(false);
|
|
||||||
expect(props.hideOnTablet).toBe(false);
|
|
||||||
expect(props.hideOnMobile).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('style carries blank/default box-model keys', () => {
|
|
||||||
const style = (ButtonLink as any).craft.props.style;
|
|
||||||
expect(style).toHaveProperty('marginTop');
|
|
||||||
expect(style.border).toBe('none');
|
|
||||||
expect(style.boxShadow).toBe('none');
|
|
||||||
expect(style.opacity).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { escapeHtml, escapeAttr, safeUrl, cssValue, scopeId } from '../../utils/escape';
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
||||||
|
|
||||||
interface ButtonLinkProps {
|
interface ButtonLinkProps {
|
||||||
text?: string;
|
text?: string;
|
||||||
href?: string;
|
href?: string;
|
||||||
target?: '_self' | '_blank';
|
target?: '_self' | '_blank';
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
/** Background color applied on `:hover` via a scoped `<style>` block
|
|
||||||
* (editor preview does not show hover state -- only the published
|
|
||||||
* export). Blank means "no hover background override". */
|
|
||||||
hoverBg?: string;
|
|
||||||
/** Text color applied on `:hover`, same scoped `<style>` block. */
|
|
||||||
hoverColor?: string;
|
|
||||||
animation?: string;
|
|
||||||
animationDelay?: string;
|
|
||||||
hideOnDesktop?: boolean;
|
|
||||||
hideOnTablet?: boolean;
|
|
||||||
hideOnMobile?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
||||||
@@ -26,8 +15,6 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
href = '#',
|
href = '#',
|
||||||
target = '_self',
|
target = '_self',
|
||||||
style = {},
|
style = {},
|
||||||
hoverBg = '',
|
|
||||||
hoverColor = '',
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -36,8 +23,6 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [hovered, setHovered] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
|
||||||
@@ -47,16 +32,12 @@ export const ButtonLink: UserComponent<ButtonLinkProps> = ({
|
|||||||
// Prevent navigation inside editor
|
// Prevent navigation inside editor
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
...style,
|
...style,
|
||||||
...(hovered && hoverBg ? { backgroundColor: hoverBg } : {}),
|
|
||||||
...(hovered && hoverColor ? { color: hoverColor } : {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
@@ -72,8 +53,6 @@ ButtonLink.craft = {
|
|||||||
text: 'Click Me',
|
text: 'Click Me',
|
||||||
href: '#',
|
href: '#',
|
||||||
target: '_self',
|
target: '_self',
|
||||||
hoverBg: '',
|
|
||||||
hoverColor: '',
|
|
||||||
style: {
|
style: {
|
||||||
backgroundColor: '#3b82f6',
|
backgroundColor: '#3b82f6',
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
@@ -82,15 +61,7 @@ ButtonLink.craft = {
|
|||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
|
||||||
boxShadow: 'none',
|
|
||||||
opacity: '1',
|
|
||||||
},
|
},
|
||||||
animation: '',
|
|
||||||
animationDelay: '0',
|
|
||||||
hideOnDesktop: false,
|
|
||||||
hideOnTablet: false,
|
|
||||||
hideOnMobile: false,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -101,7 +72,7 @@ ButtonLink.craft = {
|
|||||||
|
|
||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string, nodeId?: string) => {
|
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
|
||||||
const styleStr = cssPropsToString({
|
const styleStr = cssPropsToString({
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
@@ -109,28 +80,7 @@ ButtonLink.craft = {
|
|||||||
});
|
});
|
||||||
const escapedText = escapeHtml(props.text || '');
|
const escapedText = escapeHtml(props.text || '');
|
||||||
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||||
|
|
||||||
// Scoped hover style -- same pattern as Navbar/Menu: a deterministic,
|
|
||||||
// per-node class (via scopeId) avoids two ButtonLink instances on the
|
|
||||||
// same page colliding on a shared `.btn-link:hover` rule. hoverBg/
|
|
||||||
// hoverColor are sanitized through cssValue -- they land inside a
|
|
||||||
// `<style>` element, the worst-case XSS sink (an unescaped `<`/`>` or
|
|
||||||
// `{`/`}` could close the rule/element and open a `<script>`).
|
|
||||||
const hoverBg = cssValue(props.hoverBg);
|
|
||||||
const hoverColor = cssValue(props.hoverColor);
|
|
||||||
let hoverCss = '';
|
|
||||||
let cls = '';
|
|
||||||
if (hoverBg || hoverColor) {
|
|
||||||
const scope = scopeId(nodeId, (props.href || '') + (props.text || ''), 'btn');
|
|
||||||
cls = ` class="${scope}"`;
|
|
||||||
const decls = [
|
|
||||||
hoverBg ? `background-color:${hoverBg}` : '',
|
|
||||||
hoverColor ? `color:${hoverColor}` : '',
|
|
||||||
].filter(Boolean).join(';');
|
|
||||||
hoverCss = `<style>.${scope}:hover{${decls}}</style>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `${hoverCss}<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${cls}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
html: `<a href="${escapeAttr(safeUrl(props.href || '#'))}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,54 +56,3 @@ describe('Heading.toHtml text escaping (attacker-controlled `text` prop)', () =>
|
|||||||
expect(html).toBe('<h2>Hello world</h2>');
|
expect(html).toBe('<h2>Hello world</h2>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Heading.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
|
||||||
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
|
||||||
const { html } = toHtml({
|
|
||||||
text: 'x',
|
|
||||||
level: 'h2',
|
|
||||||
style: { lineHeight: '1.25', letterSpacing: '0.05em', textTransform: 'uppercase' },
|
|
||||||
}, '');
|
|
||||||
expect(html).toContain('line-height:1.25');
|
|
||||||
expect(html).toContain('letter-spacing:0.05em');
|
|
||||||
expect(html).toContain('text-transform:uppercase');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('italic + underline toggles emit font-style and text-decoration', () => {
|
|
||||||
const { html } = toHtml({
|
|
||||||
text: 'x',
|
|
||||||
level: 'h2',
|
|
||||||
style: { fontStyle: 'italic', textDecoration: 'underline' },
|
|
||||||
}, '');
|
|
||||||
expect(html).toContain('font-style:italic');
|
|
||||||
expect(html).toContain('text-decoration:underline');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a custom font-size (not one of the presets) still flows through', () => {
|
|
||||||
const { html } = toHtml({ text: 'x', level: 'h2', style: { fontSize: '42px' } }, '');
|
|
||||||
expect(html).toContain('font-size:42px');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Heading.craft.props exposes the box-model + animation/visibility rollout', () => {
|
|
||||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
|
||||||
const props = (Heading as any).craft.props;
|
|
||||||
expect(props.animation).toBe('');
|
|
||||||
expect(props.animationDelay).toBe('0');
|
|
||||||
expect(props.hideOnDesktop).toBe(false);
|
|
||||||
expect(props.hideOnTablet).toBe(false);
|
|
||||||
expect(props.hideOnMobile).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('style carries blank/default box-model + typography-depth keys', () => {
|
|
||||||
const style = (Heading as any).craft.props.style;
|
|
||||||
expect(style).toHaveProperty('marginTop');
|
|
||||||
expect(style).toHaveProperty('paddingTop');
|
|
||||||
expect(style).toHaveProperty('lineHeight');
|
|
||||||
expect(style).toHaveProperty('letterSpacing');
|
|
||||||
expect(style).toHaveProperty('textTransform');
|
|
||||||
expect(style.border).toBe('none');
|
|
||||||
expect(style.boxShadow).toBe('none');
|
|
||||||
expect(style.opacity).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -100,22 +100,7 @@ Heading.craft = {
|
|||||||
fontFamily: 'Inter, sans-serif',
|
fontFamily: 'Inter, sans-serif',
|
||||||
color: '#1f2937',
|
color: '#1f2937',
|
||||||
marginBottom: '16px',
|
marginBottom: '16px',
|
||||||
lineHeight: '',
|
|
||||||
letterSpacing: '',
|
|
||||||
textTransform: '' as CSSProperties['textTransform'],
|
|
||||||
fontStyle: '' as CSSProperties['fontStyle'],
|
|
||||||
textDecoration: '',
|
|
||||||
marginTop: '', marginRight: '', marginLeft: '',
|
|
||||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
opacity: '1',
|
|
||||||
},
|
},
|
||||||
animation: '',
|
|
||||||
animationDelay: '0',
|
|
||||||
hideOnDesktop: false,
|
|
||||||
hideOnTablet: false,
|
|
||||||
hideOnMobile: false,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -20,48 +20,3 @@ describe('TextBlock.toHtml text escaping (attacker-controlled `text` prop)', ()
|
|||||||
expect(html).toBe('<p>Hello world</p>');
|
expect(html).toBe('<p>Hello world</p>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TextBlock.toHtml typography depth (line-height/letter-spacing/transform/style/decoration)', () => {
|
|
||||||
test('line-height, letter-spacing, text-transform all flow into the style attribute', () => {
|
|
||||||
const { html } = toHtml({
|
|
||||||
text: 'x',
|
|
||||||
style: { lineHeight: '1.75', letterSpacing: '-0.02em', textTransform: 'capitalize' },
|
|
||||||
}, '');
|
|
||||||
expect(html).toContain('line-height:1.75');
|
|
||||||
expect(html).toContain('letter-spacing:-0.02em');
|
|
||||||
expect(html).toContain('text-transform:capitalize');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('italic + underline toggles emit font-style and text-decoration', () => {
|
|
||||||
const { html } = toHtml({ text: 'x', style: { fontStyle: 'italic', textDecoration: 'underline' } }, '');
|
|
||||||
expect(html).toContain('font-style:italic');
|
|
||||||
expect(html).toContain('text-decoration:underline');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a custom font-size (not one of the presets) still flows through', () => {
|
|
||||||
const { html } = toHtml({ text: 'x', style: { fontSize: '19px' } }, '');
|
|
||||||
expect(html).toContain('font-size:19px');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TextBlock.craft.props exposes the box-model + animation/visibility rollout', () => {
|
|
||||||
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
|
||||||
const props = (TextBlock as any).craft.props;
|
|
||||||
expect(props.animation).toBe('');
|
|
||||||
expect(props.animationDelay).toBe('0');
|
|
||||||
expect(props.hideOnDesktop).toBe(false);
|
|
||||||
expect(props.hideOnTablet).toBe(false);
|
|
||||||
expect(props.hideOnMobile).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('style carries blank/default box-model + typography-depth keys', () => {
|
|
||||||
const style = (TextBlock as any).craft.props.style;
|
|
||||||
expect(style).toHaveProperty('marginTop');
|
|
||||||
expect(style).toHaveProperty('paddingTop');
|
|
||||||
expect(style).toHaveProperty('letterSpacing');
|
|
||||||
expect(style).toHaveProperty('textTransform');
|
|
||||||
expect(style.border).toBe('none');
|
|
||||||
expect(style.boxShadow).toBe('none');
|
|
||||||
expect(style.opacity).toBe('1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -83,21 +83,7 @@ TextBlock.craft = {
|
|||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.6',
|
||||||
color: '#3f3f46',
|
color: '#3f3f46',
|
||||||
letterSpacing: '',
|
|
||||||
textTransform: '' as CSSProperties['textTransform'],
|
|
||||||
fontStyle: '' as CSSProperties['fontStyle'],
|
|
||||||
textDecoration: '',
|
|
||||||
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
|
||||||
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
|
||||||
border: 'none',
|
|
||||||
boxShadow: 'none',
|
|
||||||
opacity: '1',
|
|
||||||
},
|
},
|
||||||
animation: '',
|
|
||||||
animationDelay: '0',
|
|
||||||
hideOnDesktop: false,
|
|
||||||
hideOnTablet: false,
|
|
||||||
hideOnMobile: false,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -80,3 +80,56 @@ describe('ColumnLayout.toHtml XSS hardening (gap into <style>)', () => {
|
|||||||
expect(html).toMatch(/calc\(50% - 24px\)/);
|
expect(html).toMatch(/calc\(50% - 24px\)/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.toHtml vertical alignment (align-items on the flex row)', () => {
|
||||||
|
test('style.alignItems flows into the emitted style attribute (aligns uneven columns)', () => {
|
||||||
|
const { html } = toHtml({ columns: 2, split: '50-50', gap: '16px', style: { alignItems: 'center' } }, '<div>A</div><div>B</div>');
|
||||||
|
expect(html).toContain('align-items:center');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
columns: 2,
|
||||||
|
split: '50-50',
|
||||||
|
gap: '16px',
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'<div>A</div><div>B</div>',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ColumnLayout.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (ColumnLayout as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default alignItems and box-model keys', () => {
|
||||||
|
const style = (ColumnLayout as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('alignItems');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ interface ColumnLayoutProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitToWidths: Record<string, string[]> = {
|
const splitToWidths: Record<string, string[]> = {
|
||||||
@@ -102,8 +107,20 @@ ColumnLayout.craft = {
|
|||||||
columns: 2,
|
columns: 2,
|
||||||
split: '50-50',
|
split: '50-50',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
style: {},
|
style: {
|
||||||
|
alignItems: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
|
|||||||
@@ -63,3 +63,88 @@ describe('Container.toHtml tag allowlist (adversarial re-review, same class as C
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Container.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||||
|
// Regression lock: Container/Section must NOT unconditionally become a
|
||||||
|
// flex container. Flex-blockifies in-flow children, forcing components
|
||||||
|
// that deliberately render display:inline-block (ButtonLink, Icon) to
|
||||||
|
// stack vertically instead of sitting side-by-side -- a real visual
|
||||||
|
// regression for existing published pages that never touch vertical
|
||||||
|
// alignment.
|
||||||
|
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||||
|
const { html } = toHtml({}, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
expect(html).toContain('min-height:400px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||||
|
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:center');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style.minHeight flows into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '400px' } }, 'child');
|
||||||
|
expect(html).toContain('min-height:400px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('justify-content and min-height still flow through in boxed (contentWidth) mode', () => {
|
||||||
|
const { html } = toHtml({ contentWidth: 'boxed', style: { justifyContent: 'flex-end', minHeight: '500px' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:flex-end');
|
||||||
|
expect(html).toContain('min-height:500px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'child',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Container.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Container as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||||
|
const style = (Container as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('justifyContent');
|
||||||
|
expect(style).toHaveProperty('minHeight');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -43,6 +43,20 @@ const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSPrope
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container only becomes display:flex/flex-direction:column at its root
|
||||||
|
// (both in the editor render below and in toHtml) when the user has
|
||||||
|
// actually set `style.justifyContent` (the Vertical Alignment control,
|
||||||
|
// paired with `style.minHeight`) -- i.e. the flex conversion is gated on
|
||||||
|
// vertical-align actually being in use, not unconditional. In-flow children
|
||||||
|
// of a flex container get CSS-blockified, which would force components that
|
||||||
|
// deliberately render `display:inline-block` (ButtonLink, Icon) to stack
|
||||||
|
// vertically instead of sitting side-by-side -- a real visual regression for
|
||||||
|
// any container/section that never touches vertical alignment, not a no-op.
|
||||||
|
// So plain block flow (no `display`/`flex-direction` at all) is preserved
|
||||||
|
// unless vertical-align is set. `flexAlignFromTextAlign` above still
|
||||||
|
// supplies its own conditional flex conversion (cross-axis alignItems from
|
||||||
|
// `textAlign`) independently -- unrelated to this gate.
|
||||||
|
|
||||||
export const Container: UserComponent<ContainerProps> = ({
|
export const Container: UserComponent<ContainerProps> = ({
|
||||||
style = {},
|
style = {},
|
||||||
tag = 'div',
|
tag = 'div',
|
||||||
@@ -58,10 +72,12 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
const safeTag = sanitizeContainerTag(tag);
|
const safeTag = sanitizeContainerTag(tag);
|
||||||
const needsBoxedWrapper = contentWidth === 'boxed';
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
||||||
|
const hasVerticalAlign = !!style.justifyContent;
|
||||||
|
|
||||||
const outerStyle: CSSProperties = {
|
const outerStyle: CSSProperties = {
|
||||||
minHeight: '40px',
|
minHeight: '40px',
|
||||||
...style,
|
...style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
||||||
...(needsBoxedWrapper ? {} : flexStyles),
|
...(needsBoxedWrapper ? {} : flexStyles),
|
||||||
};
|
};
|
||||||
@@ -93,13 +109,27 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
Container.craft = {
|
Container.craft = {
|
||||||
displayName: 'Container',
|
displayName: 'Container',
|
||||||
props: {
|
props: {
|
||||||
style: { padding: '20px', minHeight: '100px' },
|
style: {
|
||||||
|
padding: '20px',
|
||||||
|
minHeight: '100px',
|
||||||
|
justifyContent: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
tag: 'div',
|
tag: 'div',
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
contentWidth: 'full',
|
contentWidth: 'full',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
cssId: '',
|
cssId: '',
|
||||||
cssClass: '',
|
cssClass: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -114,9 +144,11 @@ Container.craft = {
|
|||||||
const tag = sanitizeContainerTag(props.tag);
|
const tag = sanitizeContainerTag(props.tag);
|
||||||
const isBoxed = props.contentWidth === 'boxed';
|
const isBoxed = props.contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||||
|
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||||
|
|
||||||
const outerCss: CSSProperties = {
|
const outerCss: CSSProperties = {
|
||||||
...props.style,
|
...props.style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
...(isBoxed ? {} : flexStyles),
|
...(isBoxed ? {} : flexStyles),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,78 @@ describe('Section.toHtml shape divider color/height XSS hardening', () => {
|
|||||||
expect(html).not.toContain('<svg');
|
expect(html).not.toContain('<svg');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Section.toHtml vertical alignment (justify-content + min-height)', () => {
|
||||||
|
// Regression lock: same rationale as Container -- see Container.toHtml.test.ts.
|
||||||
|
// Section must not unconditionally become a flex container, or it
|
||||||
|
// blockifies inline-block children (ButtonLink, Icon) that are meant to
|
||||||
|
// sit side-by-side in existing published sections.
|
||||||
|
test('does NOT become a flex container when no vertical alignment is set (plain block flow preserved)', () => {
|
||||||
|
const { html } = toHtml({}, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT become a flex container from min-height alone (min-height must not itself trigger flex)', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||||
|
expect(html).not.toContain('display:flex');
|
||||||
|
expect(html).not.toContain('flex-direction');
|
||||||
|
expect(html).toContain('min-height:600px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('becomes a column flex container when style.justifyContent is set (feature still works)', () => {
|
||||||
|
const { html } = toHtml({ style: { justifyContent: 'center' } }, 'child');
|
||||||
|
expect(html).toContain('display:flex');
|
||||||
|
expect(html).toContain('flex-direction:column');
|
||||||
|
expect(html).toContain('justify-content:center');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style.minHeight flows into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml({ style: { minHeight: '600px' } }, 'child');
|
||||||
|
expect(html).toContain('min-height:600px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Section.toHtml box-model styles (margin/padding/border/shadow/opacity)', () => {
|
||||||
|
test('margin/padding/border/box-shadow/opacity all flow into the emitted style attribute', () => {
|
||||||
|
const { html } = toHtml(
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
marginTop: '10px', marginRight: '10px', marginBottom: '10px', marginLeft: '10px',
|
||||||
|
paddingTop: '5px',
|
||||||
|
border: '2px solid #ff0000',
|
||||||
|
boxShadow: '0 4px 8px rgba(0,0,0,0.12)',
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'child',
|
||||||
|
);
|
||||||
|
expect(html).toContain('margin-top:10px');
|
||||||
|
expect(html).toContain('padding-top:5px');
|
||||||
|
expect(html).toContain('border:2px solid #ff0000');
|
||||||
|
expect(html).toContain('box-shadow:0 4px 8px rgba(0,0,0,0.12)');
|
||||||
|
expect(html).toContain('opacity:0.8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Section.craft.props exposes the vertical-alignment/box-model/animation/visibility rollout', () => {
|
||||||
|
test('animation, animationDelay, hideOnDesktop/Tablet/Mobile are present with blank/false defaults', () => {
|
||||||
|
const props = (Section as any).craft.props;
|
||||||
|
expect(props.animation).toBe('');
|
||||||
|
expect(props.animationDelay).toBe('0');
|
||||||
|
expect(props.hideOnDesktop).toBe(false);
|
||||||
|
expect(props.hideOnTablet).toBe(false);
|
||||||
|
expect(props.hideOnMobile).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('style carries blank/default vertical-alignment and box-model keys', () => {
|
||||||
|
const style = (Section as any).craft.props.style;
|
||||||
|
expect(style).toHaveProperty('justifyContent');
|
||||||
|
expect(style).toHaveProperty('minHeight');
|
||||||
|
expect(style).toHaveProperty('marginTop');
|
||||||
|
expect(style).toHaveProperty('paddingTop');
|
||||||
|
expect(style.border).toBe('none');
|
||||||
|
expect(style.boxShadow).toBe('none');
|
||||||
|
expect(style.opacity).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ interface SectionProps {
|
|||||||
bottomDividerColor?: string;
|
bottomDividerColor?: string;
|
||||||
bottomDividerHeight?: string;
|
bottomDividerHeight?: string;
|
||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
|
hideOnDesktop?: boolean;
|
||||||
|
hideOnTablet?: boolean;
|
||||||
|
hideOnMobile?: boolean;
|
||||||
|
animation?: string;
|
||||||
|
animationDelay?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Divider renderer ---------- */
|
/* ---------- Divider renderer ---------- */
|
||||||
@@ -98,6 +103,13 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
|
|
||||||
const hasTopDivider = topDivider && topDivider !== 'none';
|
const hasTopDivider = topDivider && topDivider !== 'none';
|
||||||
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
||||||
|
// Section's root only becomes a column flex container when the user has
|
||||||
|
// actually set `style.justifyContent` (Vertical Alignment control, paired
|
||||||
|
// with `style.minHeight`) -- see the matching note in Container.tsx for
|
||||||
|
// why an unconditional conversion is a real regression (blockifies
|
||||||
|
// deliberately inline-block children like ButtonLink/Icon) rather than a
|
||||||
|
// no-op, so plain block flow is preserved unless vertical-align is set.
|
||||||
|
const hasVerticalAlign = !!style.justifyContent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -107,6 +119,7 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||||
...style,
|
...style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasTopDivider && (
|
{hasTopDivider && (
|
||||||
@@ -143,7 +156,17 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
Section.craft = {
|
Section.craft = {
|
||||||
displayName: 'Section',
|
displayName: 'Section',
|
||||||
props: {
|
props: {
|
||||||
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
style: {
|
||||||
|
padding: '40px 0',
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
minHeight: '',
|
||||||
|
justifyContent: '',
|
||||||
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
||||||
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
innerMaxWidth: '1200px',
|
innerMaxWidth: '1200px',
|
||||||
topDivider: 'none',
|
topDivider: 'none',
|
||||||
topDividerColor: '#ffffff',
|
topDividerColor: '#ffffff',
|
||||||
@@ -152,6 +175,11 @@ Section.craft = {
|
|||||||
bottomDividerColor: '#ffffff',
|
bottomDividerColor: '#ffffff',
|
||||||
bottomDividerHeight: '50px',
|
bottomDividerHeight: '50px',
|
||||||
anchorId: '',
|
anchorId: '',
|
||||||
|
animation: '',
|
||||||
|
animationDelay: '0',
|
||||||
|
hideOnDesktop: false,
|
||||||
|
hideOnTablet: false,
|
||||||
|
hideOnMobile: false,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -199,11 +227,13 @@ function buildDividerHtml(
|
|||||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||||
|
const hasVerticalAlign = !!props.style?.justifyContent;
|
||||||
|
|
||||||
const outerStyle = cssPropsToString({
|
const outerStyle = cssPropsToString({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||||
...props.style,
|
...props.style,
|
||||||
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
||||||
});
|
});
|
||||||
const innerStyle = cssPropsToString({
|
const innerStyle = cssPropsToString({
|
||||||
maxWidth: props.innerMaxWidth || '1200px',
|
maxWidth: props.innerMaxWidth || '1200px',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
BG_COLORS,
|
BG_COLORS,
|
||||||
RADIUS_PRESETS,
|
RADIUS_PRESETS,
|
||||||
SPACING_PRESETS,
|
SPACING_PRESETS,
|
||||||
SHADOW_PRESETS,
|
|
||||||
} from '../../../constants/presets';
|
} from '../../../constants/presets';
|
||||||
import {
|
import {
|
||||||
StylePanelProps,
|
StylePanelProps,
|
||||||
@@ -12,49 +11,16 @@ import {
|
|||||||
ColorSwatchGrid,
|
ColorSwatchGrid,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
TextInputField,
|
TextInputField,
|
||||||
ColorPickerField,
|
|
||||||
CollapsibleSection,
|
|
||||||
SpacingControl,
|
|
||||||
SpacingSide,
|
|
||||||
BorderControl,
|
|
||||||
BorderValue,
|
|
||||||
buildBorderShorthand,
|
|
||||||
AnimationControl,
|
|
||||||
VisibilityControl,
|
|
||||||
sectionGap,
|
|
||||||
labelStyle,
|
|
||||||
autoTextColor,
|
autoTextColor,
|
||||||
useNodeProp,
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
function capitalize(s: string): string {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
|
||||||
* the {width,style,color} shape BorderControl edits. Only needs to
|
|
||||||
* round-trip values this same panel produced via buildBorderShorthand. */
|
|
||||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
||||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
||||||
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
|
||||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
||||||
return { width: m[1], style: m[2], color: m[3] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
|
|
||||||
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
|
|
||||||
function opacityPercent(v: unknown): number {
|
|
||||||
if (v === undefined || v === null || v === '') return 100;
|
|
||||||
const n = parseFloat(String(v));
|
|
||||||
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- BUTTON ---------- */
|
/* ---------- BUTTON ---------- */
|
||||||
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const { actions } = useEditor();
|
const { actions } = useEditor();
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
const setButtonColor = useCallback(
|
const setButtonColor = useCallback(
|
||||||
(bgColor: string) => {
|
(bgColor: string) => {
|
||||||
@@ -95,16 +61,6 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
|||||||
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
actions.setProp(selectedId, (props: any) => { props.href = v; });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="guided-section">
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={nodeProps.target === '_blank'}
|
|
||||||
onChange={(e) => setProp('target', e.target.checked ? '_blank' : '_self')}
|
|
||||||
/>
|
|
||||||
Open in new tab
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Border Radius</SectionLabel>
|
<SectionLabel>Border Radius</SectionLabel>
|
||||||
<PresetButtonGrid
|
<PresetButtonGrid
|
||||||
@@ -121,70 +77,6 @@ export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
|||||||
onSelect={(v) => setPropStyle('padding', v)}
|
onSelect={(v) => setPropStyle('padding', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover state -- rendered into a scoped <style>...:hover{} block by
|
|
||||||
ButtonLink.toHtml (published export only; not shown live in the
|
|
||||||
editor canvas beyond the hover preview ButtonLink itself does). */}
|
|
||||||
<CollapsibleSection title="Hover State" defaultOpen={false}>
|
|
||||||
<ColorPickerField
|
|
||||||
label="Hover Background"
|
|
||||||
value={nodeProps.hoverBg || ''}
|
|
||||||
onChange={(v) => setProp('hoverBg', v)}
|
|
||||||
/>
|
|
||||||
<ColorPickerField
|
|
||||||
label="Hover Text Color"
|
|
||||||
value={nodeProps.hoverColor || ''}
|
|
||||||
onChange={(v) => setProp('hoverColor', v)}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
|
|
||||||
{/* Box model + border/effects + animation/visibility rollout */}
|
|
||||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
|
||||||
<SpacingControl
|
|
||||||
label="Margin"
|
|
||||||
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) => setPropStyle(`margin${capitalize(side)}`, v)}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
|
||||||
<BorderControl
|
|
||||||
value={parseBorderShorthand(style.border as string)}
|
|
||||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
||||||
/>
|
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Shadow</SectionLabel>
|
|
||||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
||||||
</div>
|
|
||||||
<div style={sectionGap}>
|
|
||||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={opacityPercent(style.opacity)}
|
|
||||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
|
||||||
<AnimationControl
|
|
||||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
|
||||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
|
||||||
/>
|
|
||||||
<VisibilityControl
|
|
||||||
value={{
|
|
||||||
hideOnDesktop: nodeProps.hideOnDesktop,
|
|
||||||
hideOnTablet: nodeProps.hideOnTablet,
|
|
||||||
hideOnMobile: nodeProps.hideOnMobile,
|
|
||||||
}}
|
|
||||||
onChange={(v) => {
|
|
||||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
|
||||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
|
||||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,18 +10,40 @@ import {
|
|||||||
ColorSwatchGrid,
|
ColorSwatchGrid,
|
||||||
GradientSwatchGrid,
|
GradientSwatchGrid,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
|
NumericUnitInput,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
useNodeProp,
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { BoxModelSection, BorderEffectsSection, AnimVisSection } from './containerBoxModel';
|
||||||
|
|
||||||
/* ---------- CONTAINER / SECTION ---------- */
|
// Vertical Alignment options shown to the user identically regardless of
|
||||||
|
// which CSS property they end up mapped to (align-items for the Columns
|
||||||
|
// flex ROW vs. justify-content for Container/Section's flex COLUMN root --
|
||||||
|
// see the per-type branch below).
|
||||||
|
const VERTICAL_ALIGN_OPTIONS: { label: string; value: string }[] = [
|
||||||
|
{ label: 'Top', value: 'flex-start' },
|
||||||
|
{ label: 'Center', value: 'center' },
|
||||||
|
{ label: 'Bottom', value: 'flex-end' },
|
||||||
|
{ label: 'Stretch', value: 'stretch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- CONTAINER / SECTION / COLUMNS ---------- */
|
||||||
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
|
// ColumnLayout only ever carries `columns`/`split` props -- Container and
|
||||||
|
// Section never set them -- so checking either alone distinguishes the
|
||||||
|
// flex-ROW case (align its columns via align-items, aligning uneven
|
||||||
|
// 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 isColumns = nodeProps.columns !== undefined || nodeProps.split !== undefined;
|
||||||
|
const vAlignKey = isColumns ? 'alignItems' : 'justifyContent';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{nodeProps.cssId !== undefined && (
|
{nodeProps.cssId !== undefined && (
|
||||||
@@ -94,6 +116,30 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="guided-section">
|
||||||
|
<SectionLabel>Vertical Alignment</SectionLabel>
|
||||||
|
<PresetButtonGrid
|
||||||
|
presets={VERTICAL_ALIGN_OPTIONS}
|
||||||
|
activeValue={style[vAlignKey] as string}
|
||||||
|
onSelect={(v) => setPropStyle(vAlignKey, v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isColumns && (
|
||||||
|
<div className="guided-section">
|
||||||
|
<SectionLabel>Min Height</SectionLabel>
|
||||||
|
<NumericUnitInput
|
||||||
|
value={(style.minHeight as string) || ''}
|
||||||
|
onChange={(v) => setPropStyle('minHeight', v)}
|
||||||
|
units={['px', 'vh', '%']}
|
||||||
|
placeholder="auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Box model + border/effects + animation/visibility rollout */}
|
||||||
|
<BoxModelSection style={style} setPropStyle={setPropStyle} />
|
||||||
|
<BorderEffectsSection style={style} setPropStyle={setPropStyle} />
|
||||||
|
<AnimVisSection nodeProps={nodeProps} setProp={setProp} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,69 +4,20 @@ import {
|
|||||||
FONT_FAMILIES,
|
FONT_FAMILIES,
|
||||||
TEXT_SIZES,
|
TEXT_SIZES,
|
||||||
FONT_WEIGHTS,
|
FONT_WEIGHTS,
|
||||||
LINE_HEIGHTS,
|
|
||||||
LETTER_SPACINGS,
|
|
||||||
SHADOW_PRESETS,
|
|
||||||
} from '../../../constants/presets';
|
} from '../../../constants/presets';
|
||||||
import {
|
import {
|
||||||
StylePanelProps,
|
StylePanelProps,
|
||||||
SectionLabel,
|
SectionLabel,
|
||||||
ColorSwatchGrid,
|
ColorSwatchGrid,
|
||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
NumericUnitInput,
|
|
||||||
CollapsibleSection,
|
|
||||||
SpacingControl,
|
|
||||||
SpacingSide,
|
|
||||||
BorderControl,
|
|
||||||
BorderValue,
|
|
||||||
buildBorderShorthand,
|
|
||||||
AnimationControl,
|
|
||||||
VisibilityControl,
|
|
||||||
sectionGap,
|
|
||||||
labelStyle,
|
|
||||||
useNodeProp,
|
useNodeProp,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
|
||||||
/* Text-transform is a small fixed enum with no natural home in the shared
|
|
||||||
foundation presets (constants/presets.ts is import-only for this
|
|
||||||
package), so it lives here as a package-local preset list. */
|
|
||||||
const TEXT_TRANSFORMS: { label: string; value: string }[] = [
|
|
||||||
{ label: 'None', value: 'none' },
|
|
||||||
{ label: 'UPPER', value: 'uppercase' },
|
|
||||||
{ label: 'lower', value: 'lowercase' },
|
|
||||||
{ label: 'Capitalize', value: 'capitalize' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function capitalize(s: string): string {
|
|
||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
|
||||||
* the {width,style,color} shape BorderControl edits. Only needs to
|
|
||||||
* round-trip values this same panel produced via buildBorderShorthand. */
|
|
||||||
function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
||||||
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
||||||
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
|
||||||
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
||||||
return { width: m[1], style: m[2], color: m[3] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** style.opacity is a CSS-length-free numeric string ("0.8") or blank
|
|
||||||
* (treated as fully opaque). Converts to a 0-100 integer for the UI. */
|
|
||||||
function opacityPercent(v: unknown): number {
|
|
||||||
if (v === undefined || v === null || v === '') return 100;
|
|
||||||
const n = parseFloat(String(v));
|
|
||||||
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- TEXT ---------- */
|
/* ---------- TEXT ---------- */
|
||||||
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||||
const style: CSSProperties = nodeProps.style || {};
|
const style: CSSProperties = nodeProps.style || {};
|
||||||
|
|
||||||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
const { setPropStyle } = useNodeProp(selectedId);
|
||||||
|
|
||||||
const isItalic = style.fontStyle === 'italic';
|
|
||||||
const isUnderline = style.textDecoration === 'underline';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -93,15 +44,6 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
activeValue={style.fontSize as string}
|
activeValue={style.fontSize as string}
|
||||||
onSelect={(v) => setPropStyle('fontSize', v)}
|
onSelect={(v) => setPropStyle('fontSize', v)}
|
||||||
/>
|
/>
|
||||||
<div style={{ marginTop: 6 }}>
|
|
||||||
<NumericUnitInput
|
|
||||||
value={TEXT_SIZES.some((p) => p.value === style.fontSize) ? '' : ((style.fontSize as string) || '')}
|
|
||||||
onChange={(v) => setPropStyle('fontSize', v)}
|
|
||||||
units={['px', 'em', 'rem', '%']}
|
|
||||||
placeholder="custom"
|
|
||||||
testId="text-fontsize-custom"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Font Weight</SectionLabel>
|
<SectionLabel>Font Weight</SectionLabel>
|
||||||
@@ -111,53 +53,6 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
onSelect={(v) => setPropStyle('fontWeight', v)}
|
onSelect={(v) => setPropStyle('fontWeight', v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Style</SectionLabel>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`preset-btn ${isItalic ? 'active' : ''}`}
|
|
||||||
style={{ flex: 1, fontStyle: 'italic' }}
|
|
||||||
onClick={() => setPropStyle('fontStyle', isItalic ? 'normal' : 'italic')}
|
|
||||||
title="Italic"
|
|
||||||
>
|
|
||||||
<i className="fa fa-italic" /> Italic
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`preset-btn ${isUnderline ? 'active' : ''}`}
|
|
||||||
style={{ flex: 1, textDecoration: 'underline' }}
|
|
||||||
onClick={() => setPropStyle('textDecoration', isUnderline ? 'none' : 'underline')}
|
|
||||||
title="Underline"
|
|
||||||
>
|
|
||||||
<i className="fa fa-underline" /> Underline
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Text Transform</SectionLabel>
|
|
||||||
<PresetButtonGrid
|
|
||||||
presets={TEXT_TRANSFORMS}
|
|
||||||
activeValue={(style.textTransform as string) || 'none'}
|
|
||||||
onSelect={(v) => setPropStyle('textTransform', v === 'none' ? '' : v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Line Height</SectionLabel>
|
|
||||||
<PresetButtonGrid
|
|
||||||
presets={LINE_HEIGHTS}
|
|
||||||
activeValue={String(style.lineHeight || '')}
|
|
||||||
onSelect={(v) => setPropStyle('lineHeight', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Letter Spacing</SectionLabel>
|
|
||||||
<PresetButtonGrid
|
|
||||||
presets={LETTER_SPACINGS}
|
|
||||||
activeValue={String(style.letterSpacing || '')}
|
|
||||||
onSelect={(v) => setPropStyle('letterSpacing', v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="guided-section">
|
<div className="guided-section">
|
||||||
<SectionLabel>Alignment</SectionLabel>
|
<SectionLabel>Alignment</SectionLabel>
|
||||||
<div className="preset-grid align-grid">
|
<div className="preset-grid align-grid">
|
||||||
@@ -173,59 +68,6 @@ export const TextStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Box model + border/effects + animation/visibility rollout */}
|
|
||||||
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
|
||||||
<SpacingControl
|
|
||||||
label="Margin"
|
|
||||||
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) => setPropStyle(`margin${capitalize(side)}`, v)}
|
|
||||||
/>
|
|
||||||
<SpacingControl
|
|
||||||
label="Padding"
|
|
||||||
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) => setPropStyle(`padding${capitalize(side)}`, v)}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
|
||||||
<BorderControl
|
|
||||||
value={parseBorderShorthand(style.border as string)}
|
|
||||||
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
||||||
/>
|
|
||||||
<div className="guided-section">
|
|
||||||
<SectionLabel>Shadow</SectionLabel>
|
|
||||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
||||||
</div>
|
|
||||||
<div style={sectionGap}>
|
|
||||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={opacityPercent(style.opacity)}
|
|
||||||
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CollapsibleSection>
|
|
||||||
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
|
||||||
<AnimationControl
|
|
||||||
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
|
||||||
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
|
||||||
/>
|
|
||||||
<VisibilityControl
|
|
||||||
value={{
|
|
||||||
hideOnDesktop: nodeProps.hideOnDesktop,
|
|
||||||
hideOnTablet: nodeProps.hideOnTablet,
|
|
||||||
hideOnMobile: nodeProps.hideOnMobile,
|
|
||||||
}}
|
|
||||||
onChange={(v) => {
|
|
||||||
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
|
||||||
setProp('hideOnTablet', !!v.hideOnTablet);
|
|
||||||
setProp('hideOnMobile', !!v.hideOnMobile);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CollapsibleSection>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SHADOW_PRESETS } from '../../../constants/presets';
|
||||||
|
import {
|
||||||
|
SectionLabel,
|
||||||
|
PresetButtonGrid,
|
||||||
|
CollapsibleSection,
|
||||||
|
SpacingControl,
|
||||||
|
SpacingSide,
|
||||||
|
BorderControl,
|
||||||
|
BorderValue,
|
||||||
|
buildBorderShorthand,
|
||||||
|
AnimationControl,
|
||||||
|
VisibilityControl,
|
||||||
|
sectionGap,
|
||||||
|
labelStyle,
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Shared box-model / border+effects / animation+visibility sections for the
|
||||||
|
CONTAINERS package's single shared panel (ContainerStylePanel, used for
|
||||||
|
Container / Section / Columns). Kept local to this package (not in
|
||||||
|
shared.tsx, which is foundation/import-only) since it's just DRY-ing the
|
||||||
|
identical JSX block across those 3 components rather than a genuinely
|
||||||
|
cross-package reusable control. Mirrors the equivalent helper in the
|
||||||
|
media package (mediaBoxModel.tsx) -- same shape, independently duplicated
|
||||||
|
per-package by design (packages are developed and merged in parallel).
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
function capitalize(s: string): string {
|
||||||
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a `border` shorthand string (e.g. "2px solid #ff0000") back into
|
||||||
|
* the {width,style,color} shape BorderControl edits. Only needs to
|
||||||
|
* round-trip values this same panel produced via buildBorderShorthand --
|
||||||
|
* not arbitrary author-supplied CSS. */
|
||||||
|
export function parseBorderShorthand(v: string | undefined): BorderValue {
|
||||||
|
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
||||||
|
const m = String(v).trim().match(/^(\d+(?:\.\d+)?(?:px|em|rem)?)\s+(\w+)\s+(.+)$/);
|
||||||
|
if (!m) return { width: '', style: 'none', color: '#000000' };
|
||||||
|
return { width: m[1], style: m[2], color: m[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxModelSectionProps {
|
||||||
|
style: Record<string, any>;
|
||||||
|
setPropStyle: (prop: string, value: 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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
||||||
|
<SpacingControl
|
||||||
|
label="Margin"
|
||||||
|
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||||
|
onChange={sideSetter('margin')}
|
||||||
|
/>
|
||||||
|
<SpacingControl
|
||||||
|
label="Padding"
|
||||||
|
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||||
|
onChange={sideSetter('padding')}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** style.opacity is stored as a CSS-length-free numeric string ("0.8") or
|
||||||
|
* may be blank/undefined (treated as fully opaque). Converts to a 0-100
|
||||||
|
* integer for the range input / label. */
|
||||||
|
function opacityPercent(v: unknown): number {
|
||||||
|
if (v === undefined || v === null || v === '') return 100;
|
||||||
|
const n = parseFloat(String(v));
|
||||||
|
return Number.isFinite(n) ? Math.round(n * 100) : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BorderEffectsSectionProps {
|
||||||
|
style: Record<string, any>;
|
||||||
|
setPropStyle: (prop: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Border (width/style/color) + box-shadow preset + opacity slider. */
|
||||||
|
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
|
||||||
|
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||||
|
<BorderControl
|
||||||
|
value={parseBorderShorthand(style.border)}
|
||||||
|
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
||||||
|
/>
|
||||||
|
<div className="guided-section">
|
||||||
|
<SectionLabel>Shadow</SectionLabel>
|
||||||
|
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||||
|
</div>
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={opacityPercent(style.opacity)}
|
||||||
|
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface AnimVisSectionProps {
|
||||||
|
nodeProps: Record<string, any>;
|
||||||
|
setProp: (key: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrance animation + responsive hide toggles -- top-level props consumed
|
||||||
|
* directly by html-export.ts's buildDataAttrs (no toHtml change needed). */
|
||||||
|
export const AnimVisSection: React.FC<AnimVisSectionProps> = ({ nodeProps, setProp }) => (
|
||||||
|
<CollapsibleSection title="Animation & Visibility" defaultOpen={false}>
|
||||||
|
<AnimationControl
|
||||||
|
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay }}
|
||||||
|
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
||||||
|
/>
|
||||||
|
<VisibilityControl
|
||||||
|
value={{
|
||||||
|
hideOnDesktop: nodeProps.hideOnDesktop,
|
||||||
|
hideOnTablet: nodeProps.hideOnTablet,
|
||||||
|
hideOnMobile: nodeProps.hideOnMobile,
|
||||||
|
}}
|
||||||
|
onChange={(v) => {
|
||||||
|
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
||||||
|
setProp('hideOnTablet', !!v.hideOnTablet);
|
||||||
|
setProp('hideOnMobile', !!v.hideOnMobile);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user