site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith
Three related features: 1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction. New shared ctas[] array (text + href + variant + target) replaces the primary/secondary pair. Settings panel gets add/remove/reorder controls. Legacy fields stay readable for backwards compat — first user edit migrates the section onto the new array. 2. Anchor IDs on all layout/section components (Container, Section, BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel subagent, plus Hero/CTA/CallToAction). Anchor input lives in the settings panel with an "auto from heading" button that walks the subtree for the first Heading.text. Renders as id="..." on the outermost element so #anchor URLs resolve. 3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith" and a button at the top of the right-side settings panel both open the modal pre-targeted at the selected node. The node's serialized subtree is sent to the server; system prompt is augmented to require a patch with replace_node. Editor lifts modal state into a new SitesmithContext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
overlayOpacity = 0.4,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -77,6 +81,7 @@ const BackgroundSectionSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
||||
<input
|
||||
@@ -162,6 +167,7 @@ BackgroundSection.craft = {
|
||||
overlayOpacity: 0.4,
|
||||
innerMaxWidth: '1200px',
|
||||
style: { padding: '0' },
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -176,6 +182,7 @@ BackgroundSection.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -200,7 +207,8 @@ BackgroundSection.craft = {
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
@@ -18,6 +19,7 @@ interface ColumnLayoutProps {
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
@@ -59,6 +61,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
split = '50-50',
|
||||
gap = '16px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
const widths = getWidths(split, columns);
|
||||
@@ -66,6 +69,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
@@ -124,6 +128,7 @@ const ColumnLayoutSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
{/* Preset layouts */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Layout</label>
|
||||
@@ -270,6 +275,7 @@ ColumnLayout.craft = {
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {},
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -284,6 +290,7 @@ ColumnLayout.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const gap = props.gap || '16px';
|
||||
const outerStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
@@ -292,7 +299,8 @@ ColumnLayout.craft = {
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
html: `<div${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { BorderControl } from '../../ui/BorderControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface ContainerProps {
|
||||
style?: CSSProperties;
|
||||
@@ -11,6 +12,7 @@ interface ContainerProps {
|
||||
children?: React.ReactNode;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
@@ -37,6 +39,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
children,
|
||||
fullWidth = false,
|
||||
contentWidth = 'full',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
@@ -56,6 +59,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
id: anchorId || undefined,
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||
@@ -120,6 +124,7 @@ const ContainerSettings: React.FC = () => {
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<AnchorIdField />
|
||||
{/* Tag */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>HTML Element</label>
|
||||
@@ -304,6 +309,7 @@ Container.craft = {
|
||||
tag: 'div',
|
||||
fullWidth: false,
|
||||
contentWidth: 'full',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -318,6 +324,7 @@ Container.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const tag = props.tag || 'div';
|
||||
const isBoxed = props.contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||
@@ -333,11 +340,12 @@ Container.craft = {
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
if (isBoxed) {
|
||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { Container } from './Container';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
@@ -27,6 +28,7 @@ interface SectionProps {
|
||||
bottomDivider?: DividerShape;
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
@@ -85,6 +87,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
bottomDivider = 'none',
|
||||
bottomDividerColor = '#ffffff',
|
||||
bottomDividerHeight = '50px',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
@@ -94,6 +97,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
@@ -229,6 +233,7 @@ const SectionSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -333,6 +338,7 @@ Section.craft = {
|
||||
bottomDivider: 'none',
|
||||
bottomDividerColor: '#ffffff',
|
||||
bottomDividerHeight: '50px',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -377,6 +383,7 @@ function buildDividerHtml(
|
||||
}
|
||||
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||
|
||||
@@ -394,8 +401,9 @@ function buildDividerHtml(
|
||||
|
||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user