2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
|
|
|
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
|
|
|
import { Container } from './Container';
|
2026-07-12 15:56:25 -07:00
|
|
|
import { escapeAttr, cssValue } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- Shape Divider SVG Paths ---------- */
|
|
|
|
|
|
|
|
|
|
type DividerShape = 'none' | 'wave' | 'angle' | 'curve' | 'triangle' | 'zigzag';
|
|
|
|
|
|
|
|
|
|
const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
|
|
|
|
|
wave: 'M0,0 C150,120 350,0 600,60 C850,120 1050,0 1200,60 L1200,120 L0,120 Z',
|
|
|
|
|
angle: 'M0,0 L1200,120 L0,120 Z',
|
|
|
|
|
curve: 'M0,0 Q600,140 1200,0 L1200,120 L0,120 Z',
|
|
|
|
|
triangle: 'M0,120 L600,0 L1200,120 Z',
|
|
|
|
|
zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface SectionProps {
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
innerMaxWidth?: string;
|
|
|
|
|
children?: React.ReactNode;
|
|
|
|
|
topDivider?: DividerShape;
|
|
|
|
|
topDividerColor?: string;
|
|
|
|
|
topDividerHeight?: string;
|
|
|
|
|
bottomDivider?: DividerShape;
|
|
|
|
|
bottomDividerColor?: string;
|
|
|
|
|
bottomDividerHeight?: string;
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId?: string;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Divider renderer ---------- */
|
|
|
|
|
|
|
|
|
|
const ShapeDivider: React.FC<{
|
|
|
|
|
shape: DividerShape;
|
|
|
|
|
color: string;
|
|
|
|
|
height: string;
|
|
|
|
|
position: 'top' | 'bottom';
|
|
|
|
|
}> = ({ shape, color, height, position }) => {
|
|
|
|
|
if (!shape || shape === 'none') return null;
|
2026-07-12 18:03:44 -07:00
|
|
|
// `shape` is attacker-controlled (AI update_props / deserialized state) and
|
|
|
|
|
// not runtime-type-checked. A plain-object index lookup with a string key
|
|
|
|
|
// like '__proto__', 'toString', or 'constructor' returns an INHERITED
|
|
|
|
|
// Object.prototype value (not undefined), which would otherwise leak
|
|
|
|
|
// "[object Object]" / a function's source text into the SVG `d` attribute
|
|
|
|
|
// below. hasOwnProperty restricts the lookup to the real allowlisted keys.
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return null;
|
|
|
|
|
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
2026-04-05 18:31:16 -07:00
|
|
|
if (!path) return null;
|
|
|
|
|
|
|
|
|
|
const isTop = position === 'top';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
[position]: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
right: 0,
|
|
|
|
|
height: height || '50px',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
lineHeight: 0,
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
viewBox="0 0 1200 120"
|
|
|
|
|
preserveAspectRatio="none"
|
|
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
height: '100%',
|
|
|
|
|
fill: color || '#ffffff',
|
|
|
|
|
transform: isTop ? 'rotate(180deg)' : undefined,
|
|
|
|
|
display: 'block',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<path d={path} />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Component ---------- */
|
|
|
|
|
|
|
|
|
|
export const Section: UserComponent<SectionProps> = ({
|
|
|
|
|
style = {},
|
|
|
|
|
innerMaxWidth = '1200px',
|
|
|
|
|
children,
|
|
|
|
|
topDivider = 'none',
|
|
|
|
|
topDividerColor = '#ffffff',
|
|
|
|
|
topDividerHeight = '50px',
|
|
|
|
|
bottomDivider = 'none',
|
|
|
|
|
bottomDividerColor = '#ffffff',
|
|
|
|
|
bottomDividerHeight = '50px',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId,
|
2026-04-05 18:31:16 -07:00
|
|
|
}) => {
|
|
|
|
|
const { connectors: { connect, drag } } = useNode();
|
|
|
|
|
|
|
|
|
|
const hasTopDivider = topDivider && topDivider !== 'none';
|
|
|
|
|
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
2026-05-25 12:43:28 -07:00
|
|
|
id={anchorId || undefined}
|
2026-04-05 18:31:16 -07:00
|
|
|
style={{
|
|
|
|
|
width: '100%',
|
|
|
|
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
|
|
|
|
...style,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{hasTopDivider && (
|
|
|
|
|
<ShapeDivider
|
|
|
|
|
shape={topDivider}
|
|
|
|
|
color={topDividerColor}
|
|
|
|
|
height={topDividerHeight}
|
|
|
|
|
position="top"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<Element
|
|
|
|
|
id="section-inner"
|
|
|
|
|
is={Container}
|
|
|
|
|
canvas
|
|
|
|
|
style={{ maxWidth: innerMaxWidth, margin: '0 auto', position: 'relative', zIndex: 1 }}
|
|
|
|
|
tag="div"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</Element>
|
|
|
|
|
{hasBottomDivider && (
|
|
|
|
|
<ShapeDivider
|
|
|
|
|
shape={bottomDivider}
|
|
|
|
|
color={bottomDividerColor}
|
|
|
|
|
height={bottomDividerHeight}
|
|
|
|
|
position="bottom"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
Section.craft = {
|
|
|
|
|
displayName: 'Section',
|
|
|
|
|
props: {
|
|
|
|
|
style: { padding: '40px 0', backgroundColor: '#ffffff' },
|
|
|
|
|
innerMaxWidth: '1200px',
|
|
|
|
|
topDivider: 'none',
|
|
|
|
|
topDividerColor: '#ffffff',
|
|
|
|
|
topDividerHeight: '50px',
|
|
|
|
|
bottomDivider: 'none',
|
|
|
|
|
bottomDividerColor: '#ffffff',
|
|
|
|
|
bottomDividerHeight: '50px',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId: '',
|
2026-04-05 18:31:16 -07:00
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => true,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
|
|
|
|
function buildDividerHtml(
|
|
|
|
|
shape: DividerShape | undefined,
|
|
|
|
|
color: string | undefined,
|
|
|
|
|
height: string | undefined,
|
|
|
|
|
position: 'top' | 'bottom',
|
|
|
|
|
): string {
|
|
|
|
|
if (!shape || shape === 'none') return '';
|
2026-07-12 18:03:44 -07:00
|
|
|
// See the matching hasOwnProperty guard in <ShapeDivider> above -- same
|
|
|
|
|
// prototype-pollution-shaped lookup, same fix.
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(DIVIDER_PATHS, shape)) return '';
|
|
|
|
|
const path = DIVIDER_PATHS[shape as Exclude<DividerShape, 'none'>];
|
2026-04-05 18:31:16 -07:00
|
|
|
if (!path) return '';
|
|
|
|
|
|
|
|
|
|
const isTop = position === 'top';
|
|
|
|
|
const h = height || '50px';
|
2026-07-12 15:56:25 -07:00
|
|
|
// Sanitized -- raw string-interpolation sink in the SVG `fill:${c}` below.
|
|
|
|
|
const c = cssValue(color) || '#ffffff';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
const wrapperStyle = cssPropsToString({
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
[position]: '0',
|
|
|
|
|
left: '0',
|
|
|
|
|
right: '0',
|
|
|
|
|
height: h,
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
lineHeight: '0',
|
|
|
|
|
pointerEvents: 'none',
|
|
|
|
|
} as CSSProperties);
|
|
|
|
|
|
|
|
|
|
const svgTransform = isTop ? ' transform:rotate(180deg);' : '';
|
|
|
|
|
|
|
|
|
|
return `<div style="${wrapperStyle}"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:100%;height:100%;fill:${c};display:block;${svgTransform}"><path d="${path}"/></svg></div>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
|
|
|
|
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
|
|
|
|
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
|
|
|
|
|
|
|
|
|
const outerStyle = cssPropsToString({
|
|
|
|
|
width: '100%',
|
|
|
|
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
|
|
|
|
...props.style,
|
|
|
|
|
});
|
|
|
|
|
const innerStyle = cssPropsToString({
|
|
|
|
|
maxWidth: props.innerMaxWidth || '1200px',
|
|
|
|
|
margin: '0 auto',
|
|
|
|
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
|
|
|
|
zIndex: (hasTopDivider || hasBottomDivider) ? 1 : undefined,
|
|
|
|
|
} as CSSProperties);
|
|
|
|
|
|
|
|
|
|
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
|
|
|
|
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
2026-07-12 11:49:10 -07:00
|
|
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
return {
|
2026-05-25 12:43:28 -07:00
|
|
|
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
2026-04-05 18:31:16 -07:00
|
|
|
};
|
|
|
|
|
};
|