섹션 삭제 후 텍스트 블록 추가 버그 수정 및 버튼/구분선 스타일-속성 패널 동기화
This commit is contained in:
@@ -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}%`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user