2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
|
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
|
|
|
import { SettingsTabs } from '../../ui/SettingsTabs';
|
|
|
|
|
import { BorderControl } from '../../ui/BorderControl';
|
|
|
|
|
import { AdvancedTab } from '../../ui/AdvancedTab';
|
|
|
|
|
|
|
|
|
|
interface ContainerProps {
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
cssId?: string;
|
|
|
|
|
cssClass?: string;
|
|
|
|
|
hideOnDesktop?: boolean;
|
|
|
|
|
hideOnTablet?: boolean;
|
|
|
|
|
hideOnMobile?: boolean;
|
|
|
|
|
animation?: string;
|
|
|
|
|
animationDelay?: string;
|
|
|
|
|
fullWidth?: boolean;
|
|
|
|
|
contentWidth?: 'boxed' | 'full';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 20:24:28 -07:00
|
|
|
// Map textAlign to a flex alignItems value so block-level children (images,
|
|
|
|
|
// columns, sections) align horizontally — textAlign alone only affects inline
|
|
|
|
|
// content. Returns undefined when no alignment is set so we leave layout as
|
|
|
|
|
// normal block flow.
|
|
|
|
|
const flexAlignFromTextAlign = (textAlign: CSSProperties['textAlign']): CSSProperties => {
|
|
|
|
|
if (textAlign === 'center') return { display: 'flex', flexDirection: 'column', alignItems: 'center' };
|
|
|
|
|
if (textAlign === 'right') return { display: 'flex', flexDirection: 'column', alignItems: 'flex-end' };
|
|
|
|
|
if (textAlign === 'left') return { display: 'flex', flexDirection: 'column', alignItems: 'flex-start' };
|
|
|
|
|
return {};
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
export const Container: UserComponent<ContainerProps> = ({
|
|
|
|
|
style = {},
|
|
|
|
|
tag = 'div',
|
|
|
|
|
children,
|
|
|
|
|
fullWidth = false,
|
|
|
|
|
contentWidth = 'full',
|
|
|
|
|
}) => {
|
|
|
|
|
const { connectors: { connect, drag } } = useNode();
|
|
|
|
|
|
2026-04-26 20:24:28 -07:00
|
|
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
|
|
|
|
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const outerStyle: CSSProperties = {
|
|
|
|
|
minHeight: '40px',
|
|
|
|
|
...style,
|
|
|
|
|
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
2026-04-26 20:24:28 -07:00
|
|
|
...(needsBoxedWrapper ? {} : flexStyles),
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const el = React.createElement(
|
|
|
|
|
tag,
|
|
|
|
|
{
|
|
|
|
|
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
|
|
|
|
style: outerStyle,
|
|
|
|
|
'data-craft-container': 'true',
|
|
|
|
|
},
|
|
|
|
|
needsBoxedWrapper
|
2026-04-26 20:24:28 -07:00
|
|
|
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
2026-04-05 18:31:16 -07:00
|
|
|
: children,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return el;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
|
|
|
|
|
|
const cLabelStyle: React.CSSProperties = {
|
|
|
|
|
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
|
|
|
|
|
textTransform: 'uppercase', letterSpacing: '0.3px',
|
|
|
|
|
};
|
|
|
|
|
const cInputStyle: React.CSSProperties = {
|
|
|
|
|
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
|
|
|
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
|
|
|
|
};
|
|
|
|
|
const cPresetBtnStyle = (active: boolean): React.CSSProperties => ({
|
|
|
|
|
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#e4e4e7',
|
|
|
|
|
});
|
|
|
|
|
const cSwatchStyle = (color: string, active: boolean): React.CSSProperties => ({
|
|
|
|
|
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46', backgroundColor: color, cursor: 'pointer',
|
|
|
|
|
outline: active ? '2px solid #3b82f6' : 'none', outlineOffset: 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const cToggleBtnStyle = (active: boolean): React.CSSProperties => ({
|
|
|
|
|
flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
|
|
|
|
border: '1px solid #3f3f46',
|
|
|
|
|
background: active ? '#3b82f6' : '#27272a',
|
|
|
|
|
color: active ? '#fff' : '#e4e4e7',
|
|
|
|
|
fontWeight: active ? 600 : 400,
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ContainerSettings: React.FC = () => {
|
|
|
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
|
|
|
props: node.data.props as ContainerProps,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const bgColors = ['transparent', '#ffffff', '#f9fafb', '#f1f5f9', '#1f2937', '#111827', '#0f172a', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b'];
|
|
|
|
|
const gradients = [
|
|
|
|
|
{ label: 'None', value: 'none' },
|
|
|
|
|
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
|
|
|
|
{ label: 'Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
|
|
|
|
{ label: 'Sunset', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
|
|
|
|
|
{ label: 'Dark', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
|
|
|
|
|
{ label: 'Green', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
|
|
|
|
|
];
|
|
|
|
|
const alignPresets = [
|
|
|
|
|
{ label: 'Left', value: 'left', icon: 'fa-align-left' },
|
|
|
|
|
{ label: 'Center', value: 'center', icon: 'fa-align-center' },
|
|
|
|
|
{ label: 'Right', value: 'right', icon: 'fa-align-right' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const currentBg = props.style?.backgroundColor || '';
|
|
|
|
|
const currentBgImage = props.style?.backgroundImage || '';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<SettingsTabs
|
|
|
|
|
general={
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
|
|
|
{/* Tag */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>HTML Element</label>
|
|
|
|
|
<select
|
|
|
|
|
value={props.tag || 'div'}
|
|
|
|
|
onChange={(e) => setProp((p: ContainerProps) => { p.tag = e.target.value as ContainerProps['tag']; })}
|
|
|
|
|
style={cInputStyle}
|
|
|
|
|
>
|
|
|
|
|
{['div', 'section', 'article', 'header', 'footer', 'main'].map((t) => (
|
|
|
|
|
<option key={t} value={t}><{t}></option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Full Width */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500, cursor: 'pointer' }}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={props.fullWidth || false}
|
|
|
|
|
onChange={(e) => setProp((p: ContainerProps) => { p.fullWidth = e.target.checked; })}
|
|
|
|
|
/>
|
|
|
|
|
Full Width
|
|
|
|
|
</label>
|
|
|
|
|
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 2 }}>
|
|
|
|
|
Breaks out of parent constraints to fill the viewport width
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Content Width */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Content Width</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'full'; })}
|
|
|
|
|
style={cToggleBtnStyle((props.contentWidth || 'full') === 'full')}
|
|
|
|
|
>
|
|
|
|
|
Full
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'boxed'; })}
|
|
|
|
|
style={cToggleBtnStyle(props.contentWidth === 'boxed')}
|
|
|
|
|
>
|
|
|
|
|
Boxed (1200px)
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 4 }}>
|
|
|
|
|
{props.contentWidth === 'boxed'
|
|
|
|
|
? 'Content is centered with a max-width of 1200px'
|
|
|
|
|
: 'Content fills the full container width'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
style={
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
|
|
|
{/* Background Color */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Background Color</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
|
|
|
{bgColors.map((c) => (
|
|
|
|
|
<button key={c} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundColor: c, backgroundImage: 'none' }; })}
|
|
|
|
|
style={cSwatchStyle(c === 'transparent' ? '#fff' : c, currentBg === c)} title={c} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Background Gradient */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Gradient</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
|
|
|
{gradients.map((g) => (
|
|
|
|
|
<button key={g.value} onClick={() => setProp((p: ContainerProps) => {
|
|
|
|
|
p.style = { ...p.style, backgroundImage: g.value === 'none' ? 'none' : g.value, backgroundColor: 'transparent' };
|
|
|
|
|
})} style={{
|
|
|
|
|
width: 32, height: 24, borderRadius: 4, cursor: 'pointer',
|
|
|
|
|
border: currentBgImage === g.value ? '2px solid #3b82f6' : '1px solid #3f3f46',
|
|
|
|
|
background: g.value === 'none' ? '#27272a' : g.value,
|
|
|
|
|
}} title={g.label} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Background Image */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Background Image</label>
|
|
|
|
|
<input type="text" placeholder="Image URL..."
|
|
|
|
|
value={(props.style?.backgroundImage || '').replace(/^url\(['"]?|['"]?\)$/g, '')}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const val = e.target.value.trim();
|
|
|
|
|
setProp((p: ContainerProps) => {
|
|
|
|
|
p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' };
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
style={cInputStyle} />
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
|
|
|
|
|
{['cover', 'contain', 'auto'].map((s) => (
|
|
|
|
|
<button key={s} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundSize: s }; })}
|
|
|
|
|
style={cPresetBtnStyle(props.style?.backgroundSize === s)}>{s}</button>
|
|
|
|
|
))}
|
|
|
|
|
{['center', 'top', 'bottom'].map((pos) => (
|
|
|
|
|
<button key={pos} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundPosition: pos }; })}
|
|
|
|
|
style={cPresetBtnStyle(props.style?.backgroundPosition === pos)}>{pos}</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Overlay */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Overlay Color</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
|
|
|
<input type="color" value={props.style?.['--overlayColor' as keyof CSSProperties] || '#000000'}
|
|
|
|
|
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, ['--overlayColor' as keyof CSSProperties]: e.target.value }; })}
|
|
|
|
|
style={{ width: 32, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} />
|
|
|
|
|
<span style={{ fontSize: 11, color: '#71717a' }}>Overlay (via CSS custom property)</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Parallax */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500 }}>
|
|
|
|
|
<input type="checkbox"
|
|
|
|
|
checked={props.style?.backgroundAttachment === 'fixed'}
|
|
|
|
|
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundAttachment: e.target.checked ? 'fixed' : 'scroll' }; })} />
|
|
|
|
|
Parallax Effect
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Text Alignment */}
|
|
|
|
|
<div>
|
|
|
|
|
<label style={cLabelStyle}>Content Alignment</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
{alignPresets.map((a) => (
|
|
|
|
|
<button key={a.value} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, textAlign: a.value as any }; })}
|
|
|
|
|
style={{ ...cPresetBtnStyle(props.style?.textAlign === a.value), flex: 1 }}>
|
|
|
|
|
<i className={`fa ${a.icon}`} />
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Border */}
|
|
|
|
|
<BorderControl
|
|
|
|
|
style={props.style || {}}
|
|
|
|
|
onChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
advanced={
|
|
|
|
|
<AdvancedTab
|
|
|
|
|
style={props.style || {}}
|
|
|
|
|
onStyleChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
|
|
|
|
|
showTagSelector
|
|
|
|
|
tag={props.tag || 'div'}
|
|
|
|
|
onTagChange={(tag) => setProp((p: ContainerProps) => { p.tag = tag as ContainerProps['tag']; })}
|
|
|
|
|
cssId={props.cssId || ''}
|
|
|
|
|
onCssIdChange={(id) => setProp((p: ContainerProps) => { p.cssId = id; })}
|
|
|
|
|
cssClass={props.cssClass || ''}
|
|
|
|
|
onCssClassChange={(cls) => setProp((p: ContainerProps) => { p.cssClass = cls; })}
|
|
|
|
|
hideOnDesktop={props.hideOnDesktop}
|
|
|
|
|
onHideOnDesktopChange={(v) => setProp((p: ContainerProps) => { p.hideOnDesktop = v; })}
|
|
|
|
|
hideOnTablet={props.hideOnTablet}
|
|
|
|
|
onHideOnTabletChange={(v) => setProp((p: ContainerProps) => { p.hideOnTablet = v; })}
|
|
|
|
|
hideOnMobile={props.hideOnMobile}
|
|
|
|
|
onHideOnMobileChange={(v) => setProp((p: ContainerProps) => { p.hideOnMobile = v; })}
|
|
|
|
|
animation={props.animation}
|
|
|
|
|
onAnimationChange={(v) => setProp((p: ContainerProps) => { p.animation = v; })}
|
|
|
|
|
animationDelay={props.animationDelay}
|
|
|
|
|
onAnimationDelayChange={(v) => setProp((p: ContainerProps) => { p.animationDelay = v; })}
|
|
|
|
|
/>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
Container.craft = {
|
|
|
|
|
displayName: 'Container',
|
|
|
|
|
props: {
|
|
|
|
|
style: { padding: '20px', minHeight: '100px' },
|
|
|
|
|
tag: 'div',
|
|
|
|
|
fullWidth: false,
|
|
|
|
|
contentWidth: 'full',
|
|
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => true,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
related: {
|
|
|
|
|
settings: ContainerSettings,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
|
|
|
|
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
|
|
|
|
const tag = props.tag || 'div';
|
2026-04-26 20:24:28 -07:00
|
|
|
const isBoxed = props.contentWidth === 'boxed';
|
|
|
|
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
|
|
|
|
|
|
|
|
|
const outerCss: CSSProperties = {
|
|
|
|
|
...props.style,
|
|
|
|
|
...(isBoxed ? {} : flexStyles),
|
|
|
|
|
};
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
if (props.fullWidth) {
|
|
|
|
|
outerCss.width = '100vw';
|
|
|
|
|
outerCss.marginLeft = 'calc(-50vw + 50%)';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const styleStr = cssPropsToString(outerCss);
|
|
|
|
|
|
2026-04-26 20:24:28 -07:00
|
|
|
if (isBoxed) {
|
|
|
|
|
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
2026-04-05 18:31:16 -07:00
|
|
|
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
|
|
|
|
};
|