2026-04-05 18:31:16 -07:00
|
|
|
import React, { useCallback } from 'react';
|
|
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import {
|
|
|
|
|
SPACING_PRESETS,
|
2026-07-14 06:47:59 -07:00
|
|
|
SHADOW_PRESETS,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from '../../../constants/presets';
|
|
|
|
|
import {
|
|
|
|
|
StylePanelProps,
|
|
|
|
|
SectionLabel,
|
|
|
|
|
PresetButtonGrid,
|
|
|
|
|
CollapsibleSection,
|
|
|
|
|
ColorPickerField,
|
2026-07-10 13:10:46 -07:00
|
|
|
navColorFields,
|
|
|
|
|
btnActiveStyle,
|
2026-04-05 18:31:16 -07:00
|
|
|
labelStyle,
|
|
|
|
|
inputStyle,
|
|
|
|
|
smallInputStyle,
|
|
|
|
|
sectionGap,
|
2026-07-12 15:13:35 -07:00
|
|
|
useNodeProp,
|
2026-07-14 06:47:59 -07:00
|
|
|
SpacingControl,
|
|
|
|
|
BorderControl,
|
|
|
|
|
BorderValue,
|
|
|
|
|
buildBorderShorthand,
|
|
|
|
|
AnimationControl,
|
|
|
|
|
VisibilityControl,
|
2026-04-05 18:31:16 -07:00
|
|
|
} from './shared';
|
2026-07-12 13:00:10 -07:00
|
|
|
import { AssetPicker } from '../../../ui/AssetPicker';
|
2026-07-14 06:47:59 -07:00
|
|
|
import { usePages } from '../../../state/PageContext';
|
|
|
|
|
import { PageData } from '../../../types';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-07-14 06:47:59 -07:00
|
|
|
/* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
|
|
|
|
|
Mirrors the export convention used elsewhere: the first page is always the
|
|
|
|
|
landing page and publishes to '/', every other page publishes to '/{slug}'.
|
|
|
|
|
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
|
|
|
|
|
in renamePage() for why index 0 is special-cased this way. */
|
|
|
|
|
export function pageHref(page: PageData, index: number): string {
|
|
|
|
|
return index === 0 ? '/' : `/${page.slug}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
|
|
|
|
|
|
|
|
|
|
function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
|
|
|
|
|
const v = value || '';
|
|
|
|
|
if (pageHrefs.includes(v)) return 'page';
|
|
|
|
|
if (v.startsWith('#')) return 'anchor';
|
|
|
|
|
if (v.startsWith('tel:')) return 'tel';
|
|
|
|
|
if (v.startsWith('mailto:')) return 'mailto';
|
|
|
|
|
return 'url';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- LinkPicker ----------
|
|
|
|
|
Reused for every link-href field in this panel (standalone Logo's href,
|
|
|
|
|
Navbar's logoUrl, and each Navbar/Menu link item's href): a dropdown of
|
|
|
|
|
the site's PAGES (read-only via usePages() -- PageContext itself is
|
|
|
|
|
Wave-2's territory) plus manual URL / #anchor / tel: / mailto: entry.
|
|
|
|
|
safeUrl (in toHtml) already allows tel:/mailto: schemes, so no export-side
|
|
|
|
|
change is needed for those. */
|
|
|
|
|
interface LinkPickerProps {
|
|
|
|
|
value: string;
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
}
|
|
|
|
|
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
|
|
|
|
const { pages } = usePages();
|
|
|
|
|
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
|
|
|
|
|
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
|
|
|
|
|
|
|
|
|
|
const switchMode = (next: LinkMode) => {
|
|
|
|
|
if (next === mode) return;
|
|
|
|
|
if (next === 'page') onChange(pageOptions[0]?.href || '/');
|
|
|
|
|
else if (next === 'anchor') onChange('#');
|
|
|
|
|
else if (next === 'tel') onChange('tel:');
|
|
|
|
|
else if (next === 'mailto') onChange('mailto:');
|
|
|
|
|
else onChange('');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={sectionGap} data-testid="link-picker">
|
|
|
|
|
<label style={labelStyle}>Link</label>
|
|
|
|
|
<select
|
|
|
|
|
value={mode}
|
|
|
|
|
onChange={(e) => switchMode(e.target.value as LinkMode)}
|
|
|
|
|
style={{ ...inputStyle, marginBottom: 4, cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
<option value="page">Page</option>
|
|
|
|
|
<option value="url">Custom URL</option>
|
|
|
|
|
<option value="anchor">Anchor (#section)</option>
|
|
|
|
|
<option value="tel">Phone (tel:)</option>
|
|
|
|
|
<option value="mailto">Email (mailto:)</option>
|
|
|
|
|
</select>
|
|
|
|
|
{mode === 'page' && (
|
|
|
|
|
pageOptions.length > 0 ? (
|
|
|
|
|
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
|
|
|
|
|
{pageOptions.map((p) => (
|
|
|
|
|
<option key={p.id} value={p.href}>{p.name}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ fontSize: 11, color: '#71717a' }}>No pages yet</div>
|
|
|
|
|
)
|
|
|
|
|
)}
|
|
|
|
|
{mode === 'anchor' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={value || ''}
|
|
|
|
|
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
|
|
|
|
|
placeholder="#section-id"
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{mode === 'tel' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={(value || '').replace(/^tel:/, '')}
|
|
|
|
|
onChange={(e) => onChange(`tel:${e.target.value}`)}
|
|
|
|
|
placeholder="+15551234567"
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{mode === 'mailto' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={(value || '').replace(/^mailto:/, '')}
|
|
|
|
|
onChange={(e) => onChange(`mailto:${e.target.value}`)}
|
|
|
|
|
placeholder="name@example.com"
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{mode === 'url' && (
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={value || ''}
|
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
placeholder="https://example.com or /page"
|
|
|
|
|
style={inputStyle}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* Parses a `border` shorthand ("2px solid #hex" / "none") back into the
|
|
|
|
|
{ width, style, color } shape BorderControl edits. */
|
|
|
|
|
function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
|
|
|
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
|
|
|
const m = v.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/);
|
|
|
|
|
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
|
|
|
return { width: m[1], style: m[2], color: m[3] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function capitalize(s: string): string {
|
|
|
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
|
2026-04-05 18:31:16 -07:00
|
|
|
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
|
|
|
const { actions } = useEditor();
|
2026-07-12 15:13:35 -07:00
|
|
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
2026-07-14 06:47:59 -07:00
|
|
|
const { pages } = usePages();
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
const links: any[] = nodeProps.links || [];
|
|
|
|
|
|
|
|
|
|
const updateLink = useCallback((index: number, field: string, value: any) => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => {
|
|
|
|
|
const updated = [...(props.links || [])];
|
|
|
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
|
|
|
props.links = updated;
|
|
|
|
|
});
|
|
|
|
|
}, [actions, selectedId]);
|
|
|
|
|
|
|
|
|
|
const addLink = useCallback(() => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => {
|
|
|
|
|
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
|
|
|
|
|
});
|
|
|
|
|
}, [actions, selectedId]);
|
|
|
|
|
|
|
|
|
|
const removeLink = useCallback((index: number) => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => {
|
|
|
|
|
const updated = [...(props.links || [])];
|
|
|
|
|
updated.splice(index, 1);
|
|
|
|
|
props.links = updated;
|
|
|
|
|
});
|
|
|
|
|
}, [actions, selectedId]);
|
|
|
|
|
|
2026-07-14 06:47:59 -07:00
|
|
|
/* F2: (re)populate the links array from the current pages list --
|
|
|
|
|
label = page name, href = '/' for the landing page else '/{slug}'.
|
|
|
|
|
Any existing CTA link (isCta: true) is preserved (appended after the
|
|
|
|
|
freshly-generated page links) rather than being wiped, matching the
|
|
|
|
|
legacy GrapesJS builder's "Sync with Pages" behavior. */
|
|
|
|
|
const syncWithPages = useCallback(() => {
|
|
|
|
|
actions.setProp(selectedId, (props: any) => {
|
|
|
|
|
const existing: any[] = props.links || [];
|
|
|
|
|
const ctaLinks = existing.filter((l) => l.isCta);
|
|
|
|
|
const pageLinks = pages.map((p, i) => ({ text: p.name, href: pageHref(p, i) }));
|
|
|
|
|
props.links = [...pageLinks, ...ctaLinks];
|
|
|
|
|
});
|
|
|
|
|
}, [actions, selectedId, pages]);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
/* Detect standalone Logo vs Navbar/Menu */
|
|
|
|
|
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
|
|
|
|
|
2026-07-10 13:10:46 -07:00
|
|
|
/* Color controls, derived from the props the selected component actually has */
|
|
|
|
|
const colorFields = navColorFields(nodeProps);
|
|
|
|
|
|
|
|
|
|
/* Menu layout controls (alignment/orientation/gap/font size). These props are
|
|
|
|
|
unique to the Menu component — Navbar uses navAlignment and has no
|
|
|
|
|
orientation/gap/fontSize — so guarding on their presence scopes this section
|
|
|
|
|
to the Menu without leaking into Navbar or a standalone Logo. */
|
|
|
|
|
const hasMenuLayout = !isStandaloneLogo && (
|
|
|
|
|
nodeProps.alignment !== undefined ||
|
|
|
|
|
nodeProps.orientation !== undefined ||
|
|
|
|
|
nodeProps.gap !== undefined ||
|
|
|
|
|
nodeProps.fontSize !== undefined
|
|
|
|
|
);
|
|
|
|
|
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
|
|
|
|
|
|
2026-07-14 06:47:59 -07:00
|
|
|
/* Box-model / animation / visibility values, read off `style` (margin,
|
|
|
|
|
padding, border, boxShadow, opacity) or top-level props (animation,
|
|
|
|
|
hideOn*). This panel is only ever mounted for the 4 owned components
|
|
|
|
|
(Navbar/Menu/Logo/Footer), which all now carry these props (see each
|
|
|
|
|
component's .craft.props), so -- unlike the Links/Colors sections above,
|
|
|
|
|
which are shared across a genuinely disparate prop schema -- these
|
|
|
|
|
sections render unconditionally rather than gating on presence. */
|
|
|
|
|
const style = nodeProps.style || {};
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Standalone Logo component settings */}
|
|
|
|
|
{isStandaloneLogo && (
|
|
|
|
|
<CollapsibleSection title="Logo" defaultOpen={true}>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp('type', 'text')}
|
|
|
|
|
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'text' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'text' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'text' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-font" style={{ marginRight: 4 }} />Text
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp('type', 'image')}
|
|
|
|
|
style={{ padding: '4px 10px', fontSize: 11, background: nodeProps.type === 'image' ? '#3b82f6' : '#27272a', color: nodeProps.type === 'image' ? '#fff' : '#a1a1aa', border: `1px solid ${nodeProps.type === 'image' ? '#3b82f6' : '#3f3f46'}`, borderRadius: 4, cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-image" style={{ marginRight: 4 }} />Image
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{nodeProps.type === 'text' && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Logo Text</label>
|
|
|
|
|
<input type="text" value={nodeProps.text || ''} onChange={(e) => setProp('text', e.target.value)} style={inputStyle} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Font Size</label>
|
|
|
|
|
<input type="text" value={nodeProps.fontSize || '20px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Font Weight</label>
|
|
|
|
|
<select value={nodeProps.fontWeight || '700'} onChange={(e) => setProp('fontWeight', e.target.value)} style={{ ...inputStyle, cursor: 'pointer' }}>
|
|
|
|
|
<option value="300">Light</option>
|
|
|
|
|
<option value="400">Normal</option>
|
|
|
|
|
<option value="500">Medium</option>
|
|
|
|
|
<option value="600">Semi</option>
|
|
|
|
|
<option value="700">Bold</option>
|
|
|
|
|
<option value="800">Extra Bold</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<ColorPickerField label="Text Color" value={nodeProps.color || '#1f2937'} onChange={(v) => setProp('color', v)} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{nodeProps.type === 'image' && (
|
|
|
|
|
<>
|
|
|
|
|
<div style={sectionGap}>
|
2026-07-12 13:00:10 -07:00
|
|
|
<label style={labelStyle}>Logo Image</label>
|
|
|
|
|
<AssetPicker value={nodeProps.imageSrc || ''} onChange={(url) => setProp('imageSrc', url)} variant="full" />
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Image Width</label>
|
|
|
|
|
<input type="text" value={nodeProps.imageWidth || '120px'} onChange={(e) => setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-07-14 06:47:59 -07:00
|
|
|
<LinkPicker value={nodeProps.href || '/'} onChange={(v) => setProp('href', v)} />
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginTop: -8, marginBottom: 12 }}>
|
|
|
|
|
<input type="checkbox" checked={!!nodeProps.download} onChange={(e) => setProp('download', e.target.checked)} />
|
|
|
|
|
Download (link points at a file)
|
|
|
|
|
</label>
|
2026-04-05 18:31:16 -07:00
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Navbar Logo settings */}
|
|
|
|
|
{nodeProps.logoText !== undefined && (
|
|
|
|
|
<CollapsibleSection title="Logo">
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Logo Text</label>
|
|
|
|
|
<input type="text" value={nodeProps.logoText || ''} onChange={(e) => setProp('logoText', e.target.value)} style={inputStyle} />
|
|
|
|
|
</div>
|
|
|
|
|
{nodeProps.logoImage !== undefined && (
|
|
|
|
|
<div style={sectionGap}>
|
2026-07-12 13:00:10 -07:00
|
|
|
<label style={labelStyle}>Logo Image</label>
|
|
|
|
|
<AssetPicker value={nodeProps.logoImage || ''} onChange={(url) => setProp('logoImage', url)} variant="full" />
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{nodeProps.logoUrl !== undefined && (
|
2026-07-14 06:47:59 -07:00
|
|
|
<LinkPicker value={nodeProps.logoUrl || '/'} onChange={(v) => setProp('logoUrl', v)} />
|
2026-04-05 18:31:16 -07:00
|
|
|
)}
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-14 06:47:59 -07:00
|
|
|
{/* Links (not shown for standalone Logo, or for components -- like
|
|
|
|
|
Footer -- that don't carry a `links` array at all). */}
|
|
|
|
|
{!isStandaloneLogo && nodeProps.links !== undefined && (
|
2026-04-05 18:31:16 -07:00
|
|
|
<CollapsibleSection title="Links">
|
2026-07-14 06:47:59 -07:00
|
|
|
<button
|
|
|
|
|
onClick={syncWithPages}
|
|
|
|
|
title="Replace these links with one per page (preserves any CTA link)"
|
|
|
|
|
style={{ marginBottom: 8, width: '100%', padding: '6px', fontSize: 11, background: 'rgba(59,130,246,0.12)', color: '#93c5fd', border: '1px solid rgba(59,130,246,0.4)', borderRadius: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-refresh" /> Sync links with Pages
|
|
|
|
|
</button>
|
2026-04-05 18:31:16 -07:00
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
|
|
|
{links.map((link, i) => (
|
|
|
|
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
|
|
|
|
|
<button onClick={() => removeLink(i)} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
|
|
|
|
<i className="fa fa-times" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-07-14 06:47:59 -07:00
|
|
|
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
|
|
|
|
|
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
|
|
|
|
|
Download (points at a file)
|
|
|
|
|
</label>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<button onClick={addLink} style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}>
|
|
|
|
|
+ Add Link
|
|
|
|
|
</button>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-10 13:10:46 -07:00
|
|
|
{/* Colors (not shown for standalone Logo - it has its own color picker).
|
|
|
|
|
Fields are derived from whichever color props the selected component
|
|
|
|
|
actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…)
|
|
|
|
|
each get the right controls instead of an empty section. */}
|
|
|
|
|
{!isStandaloneLogo && colorFields.length > 0 && (
|
2026-04-05 18:31:16 -07:00
|
|
|
<CollapsibleSection title="Colors">
|
2026-07-10 13:10:46 -07:00
|
|
|
{colorFields.map((f) => (
|
|
|
|
|
<ColorPickerField
|
|
|
|
|
key={f.key}
|
|
|
|
|
label={f.label}
|
|
|
|
|
value={nodeProps[f.key] || f.fallback}
|
|
|
|
|
onChange={(v) => setProp(f.key, v)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Menu layout (alignment / orientation / gap / font size) */}
|
|
|
|
|
{hasMenuLayout && (
|
|
|
|
|
<CollapsibleSection title="Layout">
|
|
|
|
|
{nodeProps.alignment !== undefined && (
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Alignment</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
{(['left', 'center', 'right'] as const).map((a) => (
|
|
|
|
|
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle((nodeProps.alignment || 'right') === a)}>
|
|
|
|
|
{a.charAt(0).toUpperCase() + a.slice(1)}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{nodeProps.orientation !== undefined && (
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Orientation</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
{(['horizontal', 'vertical'] as const).map((o) => (
|
|
|
|
|
<button key={o} onClick={() => setProp('orientation', o)} style={btnActiveStyle((nodeProps.orientation || 'horizontal') === o)}>
|
|
|
|
|
{o.charAt(0).toUpperCase() + o.slice(1)}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
)}
|
2026-07-10 13:10:46 -07:00
|
|
|
{nodeProps.gap !== undefined && (
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Gap</label>
|
|
|
|
|
<PresetButtonGrid presets={GAP_PRESETS} activeValue={nodeProps.gap} onSelect={(v) => setProp('gap', v)} />
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
)}
|
2026-07-10 13:10:46 -07:00
|
|
|
{nodeProps.fontSize !== undefined && (
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Font Size</label>
|
|
|
|
|
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
|
|
|
|
|
</div>
|
2026-04-05 18:31:16 -07:00
|
|
|
)}
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-14 06:47:59 -07:00
|
|
|
{/* Box model: margin + padding (per-side, via style.*). The old
|
|
|
|
|
single "Padding" preset row is folded into the Padding SpacingControl
|
|
|
|
|
below (still writes to style.padding when linked, matching the
|
|
|
|
|
previous behavior exactly). */}
|
2026-04-05 18:31:16 -07:00
|
|
|
<CollapsibleSection title="Spacing" defaultOpen={false}>
|
2026-07-14 06:47:59 -07:00
|
|
|
<SpacingControl
|
|
|
|
|
label="Margin"
|
|
|
|
|
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
|
|
|
|
onChange={(side, v) => setPropStyle(`margin${capitalize(side)}`, v)}
|
|
|
|
|
/>
|
|
|
|
|
<SpacingControl
|
|
|
|
|
label="Padding"
|
|
|
|
|
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
|
|
|
|
onChange={(side, v) => setPropStyle(`padding${capitalize(side)}`, v)}
|
|
|
|
|
presets={SPACING_PRESETS}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
|
|
|
|
{/* Border & Effects: border, box-shadow, opacity */}
|
|
|
|
|
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
|
|
|
|
<BorderControl
|
|
|
|
|
value={parseBorderShorthand(style.border)}
|
|
|
|
|
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
|
|
|
/>
|
2026-04-05 18:31:16 -07:00
|
|
|
<div className="guided-section">
|
2026-07-14 06:47:59 -07:00
|
|
|
<SectionLabel>Box Shadow</SectionLabel>
|
|
|
|
|
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow || 'none'} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
|
|
|
</div>
|
|
|
|
|
<div style={sectionGap}>
|
|
|
|
|
<label style={labelStyle}>Opacity</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={0}
|
|
|
|
|
max={100}
|
|
|
|
|
value={style.opacity !== undefined ? Math.round(parseFloat(style.opacity) * 100) : 100}
|
|
|
|
|
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
/>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
</CollapsibleSection>
|
2026-07-14 06:47:59 -07:00
|
|
|
|
|
|
|
|
{/* Entrance animation */}
|
|
|
|
|
<CollapsibleSection title="Animation" defaultOpen={false}>
|
|
|
|
|
<AnimationControl
|
|
|
|
|
value={{ animation: nodeProps.animation, animationDelay: nodeProps.animationDelay }}
|
|
|
|
|
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.animation = v.animation; p.animationDelay = v.animationDelay; })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
|
|
|
|
|
|
|
|
|
{/* Responsive visibility */}
|
|
|
|
|
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
|
|
|
|
<VisibilityControl
|
|
|
|
|
value={{ hideOnDesktop: nodeProps.hideOnDesktop, hideOnTablet: nodeProps.hideOnTablet, hideOnMobile: nodeProps.hideOnMobile }}
|
|
|
|
|
onChange={(v) => actions.setProp(selectedId, (p: any) => { p.hideOnDesktop = v.hideOnDesktop; p.hideOnTablet = v.hideOnTablet; p.hideOnMobile = v.hideOnMobile; })}
|
|
|
|
|
/>
|
|
|
|
|
</CollapsibleSection>
|
2026-04-05 18:31:16 -07:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|