섹션 삭제 후 텍스트 블록 추가 버그 수정 및 버튼/구분선 스타일-속성 패널 동기화
CI / test (push) Successful in 11m2s
CI / pr_and_merge (push) Successful in 1m19s
CI / test (pull_request) Successful in 51m33s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-22 15:18:32 +09:00
parent d423aedcbe
commit 4e4c9cd37a
17 changed files with 2658 additions and 152 deletions
@@ -64,6 +64,17 @@ interface PublicPageRendererProps {
blocks: Block[];
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
const convertPxStringToEm = (value?: string | null) => {
if (!value) return null;
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const px = parseFloat(match[0]);
if (!Number.isFinite(px)) return null;
return pxToEm(px);
};
export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
@@ -131,8 +142,14 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const labelLayout = props.labelLayout ?? "stacked";
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const wrapperLayoutClass =
labelLayout === "inline" ? "flex flex-row items-center gap-2" : "flex flex-col gap-1";
const isInlineLayout = labelLayout === "inline";
const wrapperLayoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const wrapperStyle: CSSProperties = {};
if (isInlineLayout) {
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
wrapperStyle.columnGap = pxToEm(gapPx);
}
const baseInputClass =
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
@@ -145,18 +162,42 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
// 너비: fixed 모드일 때 widthPx 를 직접 사용한다.
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyle.width = `${props.widthPx}px`;
inputStyle.width = pxToEm(props.widthPx);
}
// 타이포 관련 커스텀 값
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
inputStyle.fontSize = props.fontSizeCustom;
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
inputStyle.fontSize = fontSizeEm;
}
} else {
inputStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
inputStyle.lineHeight = props.lineHeightCustom;
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
inputStyle.lineHeight = lineHeightEm;
}
} else {
inputStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
inputStyle.letterSpacing = props.letterSpacingCustom;
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
inputStyle.letterSpacing = letterSpacingEm;
}
} else {
inputStyle.letterSpacing = letterSpacingValue;
}
}
// 색상 관련 커스텀 값
@@ -178,6 +219,14 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
inputStyle.borderColor = "#334155";
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
inputStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
inputStyle.paddingBlock = pxToEm(props.paddingY);
}
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
const radiusToken = props.borderRadius ?? "md";
const radiusPx =
@@ -185,16 +234,23 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
inputStyle.borderRadius = `${radiusPx}px`;
return (
<label key={block.id} className={`${wrapperLayoutClass} text-xs text-slate-200`}>
<label
key={block.id}
data-testid="preview-form-input-wrapper"
className={`${wrapperLayoutClass} text-xs text-slate-200`}
style={wrapperStyle}
>
<span>{props.label}</span>
{props.inputType === "textarea" ? (
<textarea
data-testid="preview-form-input"
className={`${baseInputClass} ${widthClass}`}
style={inputStyle}
placeholder={props.placeholder || props.label}
/>
) : (
<input
data-testid="preview-form-input"
className={`${baseInputClass} ${widthClass}`}
style={inputStyle}
type={props.inputType === "email" ? "email" : "text"}
@@ -207,38 +263,179 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "formSelect") {
const props = block.props as FormSelectBlockProps;
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const widthClass = widthMode === "full" ? "w-full" : "";
const wrapperStyle: CSSProperties = {};
const selectStyle: CSSProperties = {};
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
wrapperStyle.width = pxToEm(props.widthPx);
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
wrapperStyle.fontSize = fontSizeEm;
selectStyle.fontSize = fontSizeEm;
}
} else {
wrapperStyle.fontSize = fontSizeValue;
selectStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
wrapperStyle.lineHeight = lineHeightEm;
selectStyle.lineHeight = lineHeightEm;
}
} else {
wrapperStyle.lineHeight = lineHeightValue;
selectStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
wrapperStyle.letterSpacing = letterSpacingEm;
selectStyle.letterSpacing = letterSpacingEm;
}
} else {
wrapperStyle.letterSpacing = letterSpacingValue;
selectStyle.letterSpacing = letterSpacingValue;
}
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const paddingEm = pxToEm(props.paddingX);
selectStyle.paddingInline = paddingEm;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const paddingEm = pxToEm(props.paddingY);
selectStyle.paddingBlock = paddingEm;
}
return (
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.label}</span>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
defaultValue={props.options[0]?.value ?? ""}
<div
data-testid="preview-form-select"
className={`${widthClass}`}
style={wrapperStyle}
>
{props.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<select
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
style={selectStyle}
defaultValue={props.options[0]?.value ?? ""}
>
{props.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</label>
);
}
if (block.type === "formCheckbox") {
const props = block.props as FormCheckboxBlockProps;
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const widthClass = widthMode === "full" ? "w-full" : "";
const groupStyle: CSSProperties = {};
const optionTextStyle: CSSProperties = {};
const groupTextStyle: CSSProperties = {};
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
groupStyle.width = pxToEm(props.widthPx);
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
groupTextStyle.fontSize = fontSizeEm;
optionTextStyle.fontSize = fontSizeEm;
}
} else {
groupTextStyle.fontSize = fontSizeValue;
optionTextStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
groupTextStyle.lineHeight = lineHeightEm;
optionTextStyle.lineHeight = lineHeightEm;
}
} else {
groupTextStyle.lineHeight = lineHeightValue;
optionTextStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
groupTextStyle.letterSpacing = letterSpacingEm;
optionTextStyle.letterSpacing = letterSpacingEm;
}
} else {
groupTextStyle.letterSpacing = letterSpacingValue;
optionTextStyle.letterSpacing = letterSpacingValue;
}
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
optionTextStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
}
const optionsStyle: CSSProperties = {};
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
}
return (
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.groupLabel}</span>
<div className="flex flex-col gap-1">
<div
key={block.id}
data-testid="preview-form-checkbox-group"
className={["flex flex-col gap-1", textSizeClass, "text-slate-200", widthClass]
.filter(Boolean)
.join(" ")}
style={groupStyle}
>
<span style={groupTextStyle}>{props.groupLabel}</span>
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
/>
<span>{opt.label}</span>
<span data-testid="preview-form-checkbox-option" style={optionTextStyle}>
{opt.label}
</span>
</label>
))}
</div>
@@ -248,11 +445,83 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "formRadio") {
const props = block.props as FormRadioBlockProps;
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const widthClass = widthMode === "full" ? "w-full" : "";
const groupStyle: CSSProperties = {};
const optionTextStyle: CSSProperties = {};
const groupTextStyle: CSSProperties = {};
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
groupStyle.width = pxToEm(props.widthPx);
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
groupTextStyle.fontSize = fontSizeEm;
optionTextStyle.fontSize = fontSizeEm;
}
} else {
groupTextStyle.fontSize = fontSizeValue;
optionTextStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
groupTextStyle.lineHeight = lineHeightEm;
optionTextStyle.lineHeight = lineHeightEm;
}
} else {
groupTextStyle.lineHeight = lineHeightValue;
optionTextStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
groupTextStyle.letterSpacing = letterSpacingEm;
optionTextStyle.letterSpacing = letterSpacingEm;
}
} else {
groupTextStyle.letterSpacing = letterSpacingValue;
optionTextStyle.letterSpacing = letterSpacingValue;
}
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
optionTextStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
}
const optionsStyle: CSSProperties = {};
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
}
return (
<div key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
<span>{props.groupLabel}</span>
<div className="flex flex-col gap-1">
<div
key={block.id}
data-testid="preview-form-radio-group"
className={["flex flex-col gap-1", textSizeClass, "text-slate-200", widthClass]
.filter(Boolean)
.join(" ")}
style={groupStyle}
>
<span style={groupTextStyle}>{props.groupLabel}</span>
<div className="flex flex-col" data-testid="preview-form-radio-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<input
@@ -260,7 +529,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
name={props.formFieldName}
/>
<span>{opt.label}</span>
<span data-testid="preview-form-radio-option" style={optionTextStyle}>
{opt.label}
</span>
</label>
))}
</div>
@@ -270,15 +541,76 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "button") {
const props = block.props as ButtonBlockProps;
const alignClass =
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
const buttonStyle: CSSProperties = {};
const fontSizeEm = convertPxStringToEm(props.fontSizeCustom);
if (fontSizeEm) {
buttonStyle.fontSize = fontSizeEm;
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
buttonStyle.lineHeight = props.lineHeightCustom;
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacing = props.letterSpacingCustom.trim();
if (letterSpacing.endsWith("px")) {
const emValue = convertPxStringToEm(letterSpacing);
if (emValue) {
buttonStyle.letterSpacing = emValue;
}
} else {
buttonStyle.letterSpacing = letterSpacing;
}
}
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
buttonStyle.width = pxToEm(props.widthPx);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
buttonStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
buttonStyle.paddingBlock = pxToEm(props.paddingY);
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
buttonStyle.backgroundColor = props.fillColorCustom;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
buttonStyle.borderColor = props.strokeColorCustom;
buttonStyle.borderWidth = "1px";
buttonStyle.borderStyle = "solid";
}
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
buttonStyle.color = props.textColorCustom;
}
const radiusToken = props.borderRadius ?? "md";
const radiusPx =
radiusToken === "none" ? 0 : radiusToken === "sm" ? 4 : radiusToken === "lg" ? 12 : radiusToken === "full" ? 9999 : 8;
buttonStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
const widthClass = widthMode === "full" ? "w-full" : "";
return (
<a
key={block.id}
href={props.href}
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
>
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
<div key={block.id} className={alignClass}>
<a
href={props.href}
className={`inline-flex items-center justify-center text-sm font-medium hover:bg-sky-500 transition-colors ${widthClass}`}
style={buttonStyle}
>
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
</div>
);
}
@@ -290,7 +622,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
const margin =
const marginPx =
typeof props.marginYPx === "number"
? props.marginYPx
: props.marginY === "sm"
@@ -298,6 +630,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
: props.marginY === "lg"
? 24
: 16;
const marginEm = pxToEm(marginPx);
const dividerStyle: CSSProperties = {
backgroundColor:
@@ -316,20 +649,27 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
} else if (widthMode === "fixed") {
innerWidthClass = "";
if (typeof props.widthPx === "number" && props.widthPx > 0) {
innerStyle.width = `${props.widthPx}px`;
innerStyle.width = "auto";
dividerStyle.width = pxToEm(props.widthPx);
} else {
innerStyle.width = "320px";
innerStyle.width = "auto";
dividerStyle.width = pxToEm(320);
}
}
return (
<div
key={block.id}
data-testid="preview-divider"
className={`flex ${alignClass}`}
style={{ marginTop: margin, marginBottom: margin }}
style={{ marginTop: marginEm, marginBottom: marginEm }}
>
<div className="max-w-xs" style={innerStyle}>
<div className={`${thicknessClass} ${innerWidthClass}`} style={dividerStyle} />
<div style={innerStyle}>
<div
data-testid="preview-divider-line"
className={`${thicknessClass} ${innerWidthClass}`}
style={dividerStyle}
/>
</div>
</div>
);
@@ -354,7 +694,15 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const listStyle: CSSProperties = {};
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
listStyle.fontSize = props.fontSizeCustom;
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
listStyle.fontSize = fontSizeEm;
}
} else {
listStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
listStyle.lineHeight = props.lineHeightCustom;
@@ -401,7 +749,15 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
{nodes.map((node, index) => (
<li
key={node.id}
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
style={
index < nodes.length - 1
? {
// gapPx 는 에디터 UI 에서 px 단위로 입력받지만,
// 실제 렌더링에서는 1em = 16px 기준으로 em 단위로 변환해 사용한다.
marginBottom: `${gapPx / 16}em`,
}
: undefined
}
>
{node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
@@ -507,6 +863,23 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
);
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label;
const widthMode = props.formWidthMode ?? "auto";
const formStyle: CSSProperties = {};
const formClassNames = ["space-y-3"];
if (widthMode === "full") {
formClassNames.push("w-full");
}
if (widthMode === "fixed" && typeof props.formWidthPx === "number" && props.formWidthPx > 0) {
formStyle.width = pxToEm(props.formWidthPx);
}
if (typeof props.marginYPx === "number") {
const marginEm = pxToEm(props.marginYPx);
formStyle.marginTop = marginEm;
formStyle.marginBottom = marginEm;
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -538,7 +911,13 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
};
return (
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
<form
key={block.id}
data-testid="preview-form-controller"
className={formClassNames.join(" ")}
style={formStyle}
onSubmit={handleSubmit}
>
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
<input type="hidden" name="__config" value={JSON.stringify(props)} />
<div className="flex flex-col gap-2 text-xs text-slate-200">
@@ -672,11 +1051,28 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
if (block.type === "image") {
const props = block.props as ImageBlockProps;
const alignClass =
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
const widthMode = props.widthMode ?? "auto";
const imageStyle: CSSProperties = {
display: "block",
maxWidth: "100%",
height: "auto",
};
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
imageStyle.width = pxToEm(props.widthPx);
}
const radiusToken = props.borderRadius ?? "md";
const radiusPx = radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
return (
<div key={block.id} className="w-full flex justify-center">
<div key={block.id} className={`w-full flex ${alignClass}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={props.src || ""} alt={props.alt} className="max-w-full h-auto object-contain" />
<img src={props.src || ""} alt={props.alt} className="object-contain" style={imageStyle} />
</div>
);
}
@@ -697,19 +1093,38 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
sectionStyle.backgroundColor = props.backgroundColorCustom;
}
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
sectionStyle.paddingTop = `${props.paddingYPx}px`;
sectionStyle.paddingBottom = `${props.paddingYPx}px`;
const paddingEm = pxToEm(props.paddingYPx);
sectionStyle.paddingTop = paddingEm;
sectionStyle.paddingBottom = paddingEm;
}
const innerWrapperStyle: CSSProperties = {};
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
innerWrapperStyle.maxWidth = pxToEm(props.maxWidthPx);
}
const columnsContainerStyle: CSSProperties = {};
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
columnsContainerStyle.columnGap = pxToEm(props.gapXPx);
}
return (
<section key={section.id} className={`${backgroundClass} ${paddingYClass}`} style={sectionStyle}>
<section
key={section.id}
data-testid="preview-section"
data-section-id={section.id}
className={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
style={sectionStyle}
>
<div
className={`mx-auto ${maxWidthClass} px-4`}
style={{ maxWidth: typeof props.maxWidthPx === "number" && props.maxWidthPx > 0 ? `${props.maxWidthPx}px` : undefined }}
data-testid="preview-section-inner"
className={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
style={innerWrapperStyle}
>
<div
className={`flex ${gapXClass} ${alignItemsClass}`}
style={{ columnGap: typeof props.gapXPx === "number" && props.gapXPx > 0 ? `${props.gapXPx}px` : undefined }}
data-testid="preview-section-columns"
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
style={columnsContainerStyle}
>
{columns.map((col) => {
const basis = `${(col.span / 12) * 100}%`;
+47 -10
View File
@@ -95,7 +95,11 @@ export interface ButtonBlockProps {
variant?: "solid" | "outline" | "ghost";
colorPalette?: "primary" | "muted" | "danger" | "success" | "neutral";
fullWidth?: boolean;
widthMode?: "auto" | "full" | "fixed";
widthPx?: number;
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
paddingX?: number;
paddingY?: number;
// 버튼 텍스트 크기 (예: "14px")
fontSizeCustom?: string;
// 버튼 텍스트 줄 간격 (예: "1.4")
@@ -487,6 +491,10 @@ export interface FormFieldStyleProps {
// 필드 너비 및 레이아웃
widthMode?: "auto" | "full" | "fixed";
widthPx?: number;
paddingX?: number;
paddingY?: number;
optionGapPx?: number;
labelGapPx?: number;
labelLayout?: "stacked" | "inline";
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 필드 텍스트 색상/타이포그라피
@@ -597,6 +605,10 @@ export interface FormBlockProps {
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
fieldIds?: string[];
submitButtonId?: string | null;
// 폼 레이아웃
formWidthMode?: "auto" | "full" | "fixed";
formWidthPx?: number;
marginYPx?: number;
}
// 공통 블록 모델
@@ -982,7 +994,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
paddingX: 12,
paddingY: 10,
optionGapPx: 4,
labelLayout: "stacked",
labelGapPx: 8,
},
sectionId: null,
columnId: null,
@@ -1020,7 +1036,10 @@ const createEditorState = (set: any, get: any): EditorState => ({
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
paddingX: 12,
paddingY: 10,
labelLayout: "stacked",
labelGapPx: 8,
},
sectionId: null,
columnId: null,
@@ -1058,7 +1077,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
paddingX: 12,
paddingY: 10,
optionGapPx: 4,
labelLayout: "stacked",
labelGapPx: 8,
},
sectionId: null,
columnId: null,
@@ -1096,7 +1119,11 @@ const createEditorState = (set: any, get: any): EditorState => ({
strokeColorCustom: "",
textColorCustom: "",
widthMode: "full",
paddingX: 12,
paddingY: 10,
optionGapPx: 4,
labelLayout: "stacked",
labelGapPx: 8,
},
sectionId: null,
columnId: null,
@@ -1136,6 +1163,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
props: {
align: "center",
thickness: "thin",
colorHex: "#475569",
},
sectionId,
columnId,
@@ -1302,10 +1330,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
variant: "solid",
colorPalette: "primary",
fullWidth: false,
widthMode: "auto",
widthPx: 240,
borderRadius: "md",
paddingX: 16,
paddingY: 10,
fontSizeCustom: "14px",
fillColorCustom: "",
strokeColorCustom: "",
// 기본 primary/solid 버튼 색상과 동일한 커스텀 색상 값을 사용해
// 에디터/프리뷰/속성 패널이 모두 같은 색상을 공유하도록 한다.
textColorCustom: "#0b1120",
fillColorCustom: "#0ea5e9",
strokeColorCustom: "#0284c7",
},
sectionId,
columnId,
@@ -1510,19 +1545,21 @@ const createEditorState = (set: any, get: any): EditorState => ({
if (index === -1) {
return state;
}
const target = current[index];
const nextBlocks = current.filter((b) => b.id !== id);
let nextBlocks: Block[];
if (target.type === "section") {
// 섹션 블록이 삭제될 때는 해당 섹션 안에 속한 자식 블록들도 함께 제거한다.
nextBlocks = current.filter((b) => b.id !== id && b.sectionId !== id);
} else {
nextBlocks = current.filter((b) => b.id !== id);
}
// 선택 상태는 삭제된 블록의 이전 블록 또는 다음 블록으로 이동한다.
let nextSelected: string | null = null;
if (nextBlocks.length > 0) {
const prevIndex = index - 1;
if (prevIndex >= 0) {
nextSelected = nextBlocks[prevIndex].id;
} else {
// 첫 블록이 삭제된 경우, 새 첫 블록을 선택한다.
nextSelected = nextBlocks[0].id;
}
const candidateIndex = Math.min(Math.max(index - 1, 0), nextBlocks.length - 1);
nextSelected = nextBlocks[candidateIndex].id;
}
return {