Tablet/mobile Alignment and Vertical-alignment overrides published only the raw text-align/justify-content declarations, while the editor (which renders from the device-resolved style) also applied the derived flex layout (display:flex;flex-direction:column;align-items). Desktop-centred containers stayed centred on mobile, mobile-only centring/vertical-align did nothing, and in boxed mode the flex sits on the inner div which had no scope at all. - Container/Section: single containerLayout()/sectionLayout() helper used by render, toHtml and a new static responsiveLayout hook. - responsiveCssForNode takes an optional DerivedLayoutFn: per device it diffs the layout derived from the resolved style against what already applies (desktop for tablet, tablet for mobile), emits changes (and resets for dropped keys), and scopes boxed-mode inner rules as [data-wsb-r=x]>div. - html-export passes the component's responsiveLayout hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
7.8 KiB
TypeScript
204 lines
7.8 KiB
TypeScript
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><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';
|
|
|
|
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.
|
|
|
|
/**
|
|
* The layout declarations Container DERIVES from its style (not the style
|
|
* itself): `outer` goes on the root element, `inner` on the boxed wrapper div
|
|
* (empty when not boxed -- the alignment flex then sits on the root). One
|
|
* source for the editor render, `toHtml` and the per-device `@media` rules
|
|
* (`responsiveLayout` below), so a tablet/mobile Alignment or Vertical
|
|
* alignment override publishes exactly as the editor shows it (finding 7).
|
|
*/
|
|
export const containerLayout = (
|
|
style: CSSProperties | undefined,
|
|
isBoxed: boolean,
|
|
): { outer: CSSProperties; inner: CSSProperties } => {
|
|
const flexStyles = flexAlignFromTextAlign(style?.textAlign);
|
|
const outer: CSSProperties = {
|
|
...(style?.justifyContent ? { display: 'flex', flexDirection: 'column' } : {}),
|
|
...(isBoxed ? {} : flexStyles),
|
|
};
|
|
return { outer, inner: isBoxed ? flexStyles : {} };
|
|
};
|
|
|
|
export const Container: UserComponent<ContainerProps> = ({
|
|
style = {},
|
|
tag = 'div',
|
|
children,
|
|
fullWidth = false,
|
|
contentWidth = 'full',
|
|
anchorId,
|
|
cssId,
|
|
cssClass,
|
|
}) => {
|
|
const { connectors: { connect, drag } } = useNode();
|
|
|
|
const safeTag = sanitizeContainerTag(tag);
|
|
const needsBoxedWrapper = contentWidth === 'boxed';
|
|
// `style` is the device-resolved style (RenderNode), so the derived flex
|
|
// follows the current device's Alignment / Vertical alignment.
|
|
const layout = containerLayout(style, needsBoxedWrapper);
|
|
|
|
const outerStyle: CSSProperties = {
|
|
minHeight: '40px',
|
|
...style,
|
|
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
|
|
...layout.outer,
|
|
};
|
|
|
|
// 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',
|
|
'data-fullwidth': fullWidth ? '' : undefined,
|
|
id: idValue,
|
|
className: cssClass || undefined,
|
|
},
|
|
needsBoxedWrapper
|
|
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...layout.inner } }, 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 layout = containerLayout(props.style, isBoxed);
|
|
|
|
const outerCss: CSSProperties = {
|
|
...props.style,
|
|
...layout.outer,
|
|
};
|
|
|
|
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', ...layout.inner });
|
|
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
|
}
|
|
|
|
return { html: `<${tag}${idAttr}${classAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
|
};
|
|
|
|
/**
|
|
* Responsive-export hook (see `DerivedLayoutFn` in utils/responsive-style.ts):
|
|
* the derived layout for a device-resolved style, keyed by selector suffix
|
|
* relative to the node's `[data-wsb-r]` scope. In boxed mode the alignment
|
|
* flex lives on the only child, the wrapper div.
|
|
*/
|
|
(Container as any).responsiveLayout = (style: CSSProperties, props: ContainerProps): Record<string, CSSProperties> => {
|
|
const isBoxed = props.contentWidth === 'boxed';
|
|
const layout = containerLayout(style, isBoxed);
|
|
return isBoxed ? { '': layout.outer, '>div': layout.inner } : { '': layout.outer };
|
|
};
|