import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeAttr } from '../../utils/escape'; // The only tag names Container actually supports (matches the TS union // below and the `tag` default in `.craft.props`). `tag` is settable via the // AI `update_props` path and from deserialized saved state -- neither is // type-checked at runtime -- so a malicious value like // `div> (ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div'; interface ContainerProps { style?: CSSProperties; tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'; children?: React.ReactNode; cssId?: string; cssClass?: string; anchorId?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; animation?: string; animationDelay?: string; fullWidth?: boolean; contentWidth?: 'boxed' | 'full'; } // 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 {}; }; // 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 = ({ style = {}, tag = 'div', children, fullWidth = false, contentWidth = 'full', anchorId, cssId, cssClass, }) => { const { connectors: { connect, drag } } = useNode(); const safeTag = sanitizeContainerTag(tag); const needsBoxedWrapper = contentWidth === 'boxed'; const flexStyles = flexAlignFromTextAlign(style.textAlign); const hasVerticalAlign = !!style.justifyContent; const outerStyle: CSSProperties = { minHeight: '40px', ...style, ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}), ...(needsBoxedWrapper ? {} : flexStyles), }; // cssId is the user-facing "CSS ID" advanced field; it takes precedence // over anchorId (the scroll-jump anchor) when both happen to be set, since // only one `id` attribute can be emitted on the element. const idValue = cssId || anchorId || undefined; const el = React.createElement( safeTag, { ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }, style: outerStyle, 'data-craft-container': 'true', id: idValue, className: cssClass || undefined, }, needsBoxedWrapper ? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children) : children, ); return el; }; /* ---------- Craft config ---------- */ Container.craft = { displayName: 'Container', props: { style: { padding: '20px', minHeight: '100px', justifyContent: '', marginTop: '', marginRight: '', marginBottom: '', marginLeft: '', paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '', border: 'none', boxShadow: 'none', opacity: '1', }, tag: 'div', fullWidth: false, contentWidth: 'full', anchorId: '', cssId: '', cssClass: '', animation: '', animationDelay: '0', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => { const tag = sanitizeContainerTag(props.tag); const isBoxed = props.contentWidth === 'boxed'; const flexStyles = flexAlignFromTextAlign(props.style?.textAlign); const hasVerticalAlign = !!props.style?.justifyContent; const outerCss: CSSProperties = { ...props.style, ...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}), ...(isBoxed ? {} : flexStyles), }; if (props.fullWidth) { outerCss.width = '100vw'; outerCss.marginLeft = 'calc(-50vw + 50%)'; } const styleStr = cssPropsToString(outerCss); // cssId wins over anchorId when both are set (see the render fn above for why). const idValue = props.cssId || props.anchorId; const idAttr = idValue ? ` id="${escapeAttr(idValue)}"` : ''; const classAttr = props.cssClass ? ` class="${escapeAttr(props.cssClass)}"` : ''; if (isBoxed) { const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles }); return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; } return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}` }; };