e1b4ab735c
- TextStylePanel (Heading/TextBlock): line-height/letter-spacing presets,
text-transform (none/uppercase/lowercase/capitalize), italic + underline
toggles (fontStyle/textDecoration), and a custom NumericUnitInput
font-size alongside the existing preset row. All write to component
`style`; no toHtml changes needed (style already flows through
cssPropsToString for both components).
- ButtonStylePanel/ButtonLink: "Open in new tab" checkbox writes the
existing `target` prop ('_self'/'_blank' -- toHtml already emitted
rel="noopener noreferrer" for _blank). New Hover State section
(hoverBg/hoverColor via ColorPickerField) renders a scoped
`<style>.btn_<hash>:hover{...}</style>` block before the `<a>` in
toHtml, scoped per-node via scopeId (same pattern as Navbar/Menu) so
two buttons on one page don't collide; both values sanitized through
cssValue against <style>-element breakout. Editor canvas gets a live
hover preview via onMouseEnter/onMouseLeave local state (mirrors Menu's
approach), since there's no way to preview a CSS :hover rule directly
on an inline-styled React element.
- Heading/TextBlock/ButtonLink: added margin(per-side)/padding(per-side,
Text only)/border/box-shadow/opacity style defaults + AnimationControl/
VisibilityControl-backed animation/animationDelay/hideOnDesktop/
hideOnTablet/hideOnMobile props, each panel gaining collapsible
Spacing / Border & Effects / Animation & Visibility sections. Button
keeps its existing padding-shorthand preset row rather than adding a
redundant per-side padding control; only margin got the new
per-side SpacingControl.
- Tests: extended all three *.toHtml.test.ts files -- typography style
emission (line-height/letter-spacing/text-transform/font-style/
text-decoration/custom font-size), button target+rel, scoped hover
style emission + two adversarial style-breakout cases (</style><script>
and rule-injection via `;}selector{`), and craft.props assertions for
every new prop on all three components. 674 tests green, tsc + vite
build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
import React, { useCallback, CSSProperties } from 'react';
|
|
import { useEditor } from '@craftjs/core';
|
|
import {
|
|
BG_COLORS,
|
|
RADIUS_PRESETS,
|
|
SPACING_PRESETS,
|
|
SHADOW_PRESETS,
|
|
} from '../../../constants/presets';
|
|
import {
|
|
StylePanelProps,
|
|
SectionLabel,
|
|
ColorSwatchGrid,
|
|
PresetButtonGrid,
|
|
TextInputField,
|
|
ColorPickerField,
|
|
CollapsibleSection,
|
|
SpacingControl,
|
|
SpacingSide,
|
|
BorderControl,
|
|
BorderValue,
|
|
buildBorderShorthand,
|
|
AnimationControl,
|
|
VisibilityControl,
|
|
sectionGap,
|
|
labelStyle,
|
|
autoTextColor,
|
|
useNodeProp,
|
|
} 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 ---------- */
|
|
export const ButtonStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
const { actions } = useEditor();
|
|
const style: CSSProperties = nodeProps.style || {};
|
|
|
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
|
|
|
const setButtonColor = useCallback(
|
|
(bgColor: string) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
props.style = {
|
|
...props.style,
|
|
backgroundColor: bgColor,
|
|
color: autoTextColor(bgColor),
|
|
};
|
|
});
|
|
},
|
|
[actions, selectedId],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="guided-section">
|
|
<SectionLabel>Button Color</SectionLabel>
|
|
<ColorSwatchGrid
|
|
colors={BG_COLORS}
|
|
activeValue={style.backgroundColor as string}
|
|
onSelect={setButtonColor}
|
|
/>
|
|
</div>
|
|
<TextInputField
|
|
label="Button Text"
|
|
value={nodeProps.text || ''}
|
|
placeholder="Click Me"
|
|
onChange={(v) => {
|
|
actions.setProp(selectedId, (props: any) => { props.text = v; });
|
|
}}
|
|
/>
|
|
<TextInputField
|
|
label="Link URL"
|
|
value={nodeProps.href || ''}
|
|
placeholder="https://..."
|
|
onChange={(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">
|
|
<SectionLabel>Border Radius</SectionLabel>
|
|
<PresetButtonGrid
|
|
presets={RADIUS_PRESETS}
|
|
activeValue={style.borderRadius as string}
|
|
onSelect={(v) => setPropStyle('borderRadius', v)}
|
|
/>
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Padding</SectionLabel>
|
|
<PresetButtonGrid
|
|
presets={SPACING_PRESETS}
|
|
activeValue={style.padding as string}
|
|
onSelect={(v) => setPropStyle('padding', v)}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
};
|