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';
|
2026-07-12 11:49:10 -07:00
|
|
|
import { escapeAttr } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-07-12 17:44:59 -07:00
|
|
|
// 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><img src=x onerror=alert(1)` must never reach the `<${tag}` template
|
|
|
|
|
// position in `toHtml`/the live render. Anything not in this allowlist
|
|
|
|
|
// falls back to `'div'`.
|
|
|
|
|
const ALLOWED_CONTAINER_TAGS = ['div', 'section', 'article', 'header', 'footer', 'main'] as const;
|
|
|
|
|
export type ContainerTag = (typeof ALLOWED_CONTAINER_TAGS)[number];
|
|
|
|
|
|
|
|
|
|
export const sanitizeContainerTag = (tag: unknown): ContainerTag =>
|
|
|
|
|
(ALLOWED_CONTAINER_TAGS as readonly unknown[]).includes(tag) ? (tag as ContainerTag) : 'div';
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
interface ContainerProps {
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
cssId?: string;
|
|
|
|
|
cssClass?: string;
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId?: string;
|
2026-04-05 18:31:16 -07:00
|
|
|
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-07-14 06:55:20 -07:00
|
|
|
// 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.
|
2026-07-14 06:44:37 -07:00
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
export const Container: UserComponent<ContainerProps> = ({
|
|
|
|
|
style = {},
|
|
|
|
|
tag = 'div',
|
|
|
|
|
children,
|
|
|
|
|
fullWidth = false,
|
|
|
|
|
contentWidth = 'full',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId,
|
2026-07-12 13:46:23 -07:00
|
|
|
cssId,
|
|
|
|
|
cssClass,
|
2026-04-05 18:31:16 -07:00
|
|
|
}) => {
|
|
|
|
|
const { connectors: { connect, drag } } = useNode();
|
|
|
|
|
|
2026-07-12 17:44:59 -07:00
|
|
|
const safeTag = sanitizeContainerTag(tag);
|
2026-04-26 20:24:28 -07:00
|
|
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
|
|
|
|
const flexStyles = flexAlignFromTextAlign(style.textAlign);
|
2026-07-14 06:55:20 -07:00
|
|
|
const hasVerticalAlign = !!style.justifyContent;
|
2026-04-26 20:24:28 -07:00
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const outerStyle: CSSProperties = {
|
|
|
|
|
minHeight: '40px',
|
|
|
|
|
...style,
|
2026-07-14 06:55:20 -07:00
|
|
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
2026-04-05 18:31:16 -07:00
|
|
|
...(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
|
|
|
};
|
|
|
|
|
|
2026-07-12 13:46:23 -07:00
|
|
|
// 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;
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
const el = React.createElement(
|
2026-07-12 17:44:59 -07:00
|
|
|
safeTag,
|
2026-04-05 18:31:16 -07:00
|
|
|
{
|
|
|
|
|
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
|
|
|
|
style: outerStyle,
|
|
|
|
|
'data-craft-container': 'true',
|
2026-07-12 13:46:23 -07:00
|
|
|
id: idValue,
|
|
|
|
|
className: cssClass || undefined,
|
2026-04-05 18:31:16 -07:00
|
|
|
},
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
Container.craft = {
|
|
|
|
|
displayName: 'Container',
|
|
|
|
|
props: {
|
2026-07-14 06:44:37 -07:00
|
|
|
style: {
|
|
|
|
|
padding: '20px',
|
|
|
|
|
minHeight: '100px',
|
|
|
|
|
justifyContent: '',
|
|
|
|
|
marginTop: '', marginRight: '', marginBottom: '', marginLeft: '',
|
|
|
|
|
paddingTop: '', paddingRight: '', paddingBottom: '', paddingLeft: '',
|
|
|
|
|
border: 'none',
|
|
|
|
|
boxShadow: 'none',
|
|
|
|
|
opacity: '1',
|
|
|
|
|
},
|
2026-04-05 18:31:16 -07:00
|
|
|
tag: 'div',
|
|
|
|
|
fullWidth: false,
|
|
|
|
|
contentWidth: 'full',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId: '',
|
2026-07-12 13:46:23 -07:00
|
|
|
cssId: '',
|
|
|
|
|
cssClass: '',
|
2026-07-14 06:44:37 -07:00
|
|
|
animation: '',
|
|
|
|
|
animationDelay: '0',
|
|
|
|
|
hideOnDesktop: false,
|
|
|
|
|
hideOnTablet: false,
|
|
|
|
|
hideOnMobile: false,
|
2026-04-05 18:31:16 -07:00
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => true,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
|
|
|
|
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
2026-07-12 17:44:59 -07:00
|
|
|
const tag = sanitizeContainerTag(props.tag);
|
2026-04-26 20:24:28 -07:00
|
|
|
const isBoxed = props.contentWidth === 'boxed';
|
|
|
|
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
2026-07-14 06:55:20 -07:00
|
|
|
const hasVerticalAlign = !!props.style?.justifyContent;
|
2026-04-26 20:24:28 -07:00
|
|
|
|
|
|
|
|
const outerCss: CSSProperties = {
|
|
|
|
|
...props.style,
|
2026-07-14 06:55:20 -07:00
|
|
|
...(hasVerticalAlign ? { display: 'flex', flexDirection: 'column' } : {}),
|
2026-04-26 20:24:28 -07:00
|
|
|
...(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-07-12 13:46:23 -07:00
|
|
|
// 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)}"` : '';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
2026-04-26 20:24:28 -07:00
|
|
|
if (isBoxed) {
|
|
|
|
|
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
2026-07-12 13:46:23 -07:00
|
|
|
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:46:23 -07:00
|
|
|
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|