섹션 삭제 후 텍스트 블록 추가 버그 수정 및 버튼/구분선 스타일-속성 패널 동기화
This commit is contained in:
@@ -183,6 +183,175 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
|
||||
<NumericPropertyControl
|
||||
label="체크박스 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = checkboxProps.fontSizeCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 14;
|
||||
})()}
|
||||
min={8}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 12 },
|
||||
{ id: "sm", label: "S", value: 14 },
|
||||
{ id: "md", label: "M", value: 16 },
|
||||
{ id: "lg", label: "L", value: 18 },
|
||||
{ id: "xl", label: "XL", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
fontSizeCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 줄간격 px 입력값을 노출해 preview em 변환과 TDD 케이스를 연동한다 */}
|
||||
<NumericPropertyControl
|
||||
label="체크박스 줄간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = checkboxProps.lineHeightCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 20;
|
||||
})()}
|
||||
min={8}
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
lineHeightCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 자간 px 입력을 통한 폼 체크박스 미세 조정값을 노출 */}
|
||||
<NumericPropertyControl
|
||||
label="체크박스 자간 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = checkboxProps.letterSpacingCustom ?? "";
|
||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 0;
|
||||
})()}
|
||||
min={-10}
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
letterSpacingCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>필드 너비</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={checkboxProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(checkboxProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={checkboxProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={800}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 200 },
|
||||
{ id: "md", label: "보통", value: 280 },
|
||||
{ id: "lg", label: "넓게", value: 360 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="체크박스 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof checkboxProps.paddingX === "number" ? checkboxProps.paddingX : 12}
|
||||
min={0}
|
||||
max={60}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 10 },
|
||||
{ id: "md", label: "보통", value: 12 },
|
||||
{ id: "lg", label: "넓게", value: 16 },
|
||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingX: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="체크박스 세로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof checkboxProps.paddingY === "number" ? checkboxProps.paddingY : 10}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 8 },
|
||||
{ id: "md", label: "보통", value: 10 },
|
||||
{ id: "lg", label: "넓게", value: 12 },
|
||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingY: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="체크박스 옵션 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof checkboxProps.optionGapPx === "number" ? checkboxProps.optionGapPx : 4}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 2 },
|
||||
{ id: "normal", label: "보통", value: 4 },
|
||||
{ id: "relaxed", label: "느슨", value: 8 },
|
||||
{ id: "extra", label: "넓게", value: 12 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="텍스트 색상"
|
||||
ariaLabelColorInput="체크박스 텍스트 색상 피커"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
|
||||
interface FormControllerPanelProps {
|
||||
block: Block; // type === "form"
|
||||
@@ -129,6 +130,64 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 레이아웃</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>폼 너비 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={formProps.formWidthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthMode: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 폭 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(formProps.formWidthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="폼 고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 360}
|
||||
min={160}
|
||||
max={960}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "좁게", value: 280 },
|
||||
{ id: "md", label: "보통", value: 360 },
|
||||
{ id: "lg", label: "넓게", value: 480 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="폼 위/아래 여백 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 16}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: 8 },
|
||||
{ id: "normal", label: "보통", value: 16 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
marginYPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 컨트롤러</h3>
|
||||
|
||||
|
||||
@@ -130,6 +130,79 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</label>
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
<NumericPropertyControl
|
||||
label="필드 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = inputProps.fontSizeCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 14;
|
||||
})()}
|
||||
min={8}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 12 },
|
||||
{ id: "sm", label: "S", value: 14 },
|
||||
{ id: "md", label: "M", value: 16 },
|
||||
{ id: "lg", label: "L", value: 18 },
|
||||
{ id: "xl", label: "XL", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
fontSizeCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 줄간격 px 입력을 노출하여 preview em 변환의 근거를 제공 */}
|
||||
<NumericPropertyControl
|
||||
label="필드 줄간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = inputProps.lineHeightCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 20;
|
||||
})()}
|
||||
min={8}
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
lineHeightCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 자간 px 입력으로 폼 입력 타이포를 세밀하게 조정 */}
|
||||
<NumericPropertyControl
|
||||
label="필드 자간 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = inputProps.letterSpacingCustom ?? "";
|
||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 0;
|
||||
})()}
|
||||
min={-10}
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
letterSpacingCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>텍스트 정렬</span>
|
||||
<select
|
||||
@@ -161,8 +234,29 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(inputProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>필드 너비</span>
|
||||
<span>너비</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={inputProps.widthMode ?? "full"}
|
||||
@@ -197,6 +291,46 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="필드 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.paddingX === "number" ? inputProps.paddingX : 12}
|
||||
min={0}
|
||||
max={60}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 10 },
|
||||
{ id: "md", label: "보통", value: 12 },
|
||||
{ id: "lg", label: "넓게", value: 16 },
|
||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingX: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="필드 세로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.paddingY === "number" ? inputProps.paddingY : 10}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 8 },
|
||||
{ id: "md", label: "보통", value: 10 },
|
||||
{ id: "lg", label: "넓게", value: 12 },
|
||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingY: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="필드 텍스트 색상"
|
||||
ariaLabelColorInput="필드 텍스트 색상 피커"
|
||||
|
||||
@@ -183,6 +183,175 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
{/* 라디오 타이포 px 값을 직접 입력받아 em 스케일 변환 로직과 연동한다 */}
|
||||
<NumericPropertyControl
|
||||
label="라디오 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = radioProps.fontSizeCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 14;
|
||||
})()}
|
||||
min={8}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 12 },
|
||||
{ id: "sm", label: "S", value: 14 },
|
||||
{ id: "md", label: "M", value: 16 },
|
||||
{ id: "lg", label: "L", value: 18 },
|
||||
{ id: "xl", label: "XL", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
fontSizeCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 줄간격 px 입력을 노출하여 preview 에서 em 변환 근거로 사용 */}
|
||||
<NumericPropertyControl
|
||||
label="라디오 줄간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = radioProps.lineHeightCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 20;
|
||||
})()}
|
||||
min={8}
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
lineHeightCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 자간 px 입력 컨트롤로 프리뷰 적용 근거 제공 */}
|
||||
<NumericPropertyControl
|
||||
label="라디오 자간 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = radioProps.letterSpacingCustom ?? "";
|
||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 0;
|
||||
})()}
|
||||
min={-10}
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
letterSpacingCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>필드 너비</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={radioProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(radioProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={radioProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={800}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 200 },
|
||||
{ id: "md", label: "보통", value: 280 },
|
||||
{ id: "lg", label: "넓게", value: 360 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="라디오 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof radioProps.paddingX === "number" ? radioProps.paddingX : 12}
|
||||
min={0}
|
||||
max={60}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 10 },
|
||||
{ id: "md", label: "보통", value: 12 },
|
||||
{ id: "lg", label: "넓게", value: 16 },
|
||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingX: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="라디오 세로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof radioProps.paddingY === "number" ? radioProps.paddingY : 10}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 8 },
|
||||
{ id: "md", label: "보통", value: 10 },
|
||||
{ id: "lg", label: "넓게", value: 12 },
|
||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingY: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="라디오 옵션 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof radioProps.optionGapPx === "number" ? radioProps.optionGapPx : 4}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 2 },
|
||||
{ id: "normal", label: "보통", value: 4 },
|
||||
{ id: "relaxed", label: "느슨", value: 8 },
|
||||
{ id: "extra", label: "넓게", value: 12 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="텍스트 색상"
|
||||
ariaLabelColorInput="라디오 텍스트 색상 피커"
|
||||
|
||||
@@ -150,6 +150,155 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</label>
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">필드 스타일</h4>
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = selectProps.fontSizeCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 14;
|
||||
})()}
|
||||
min={8}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 12 },
|
||||
{ id: "sm", label: "S", value: 14 },
|
||||
{ id: "md", label: "M", value: 16 },
|
||||
{ id: "lg", label: "L", value: 18 },
|
||||
{ id: "xl", label: "XL", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
fontSizeCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 줄간격 px 입력을 받아 preview 렌더러의 em 변환 근거로 사용한다 */}
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 줄간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = selectProps.lineHeightCustom ?? "";
|
||||
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 20;
|
||||
})()}
|
||||
min={8}
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
lineHeightCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
{/* 자간 px 입력을 제공하여 TDD 시나리오와 동기화한다 */}
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 자간 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = selectProps.letterSpacingCustom ?? "";
|
||||
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
|
||||
if (match) return Number(match[1]);
|
||||
return 0;
|
||||
})()}
|
||||
min={-10}
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
letterSpacingCustom: `${v}px`,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>필드 너비</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={selectProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(selectProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={selectProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={800}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 200 },
|
||||
{ id: "md", label: "보통", value: 280 },
|
||||
{ id: "lg", label: "넓게", value: 360 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof selectProps.paddingX === "number" ? selectProps.paddingX : 12}
|
||||
min={0}
|
||||
max={60}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 10 },
|
||||
{ id: "md", label: "보통", value: 12 },
|
||||
{ id: "lg", label: "넓게", value: 16 },
|
||||
{ id: "xl", label: "아주 넓게", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingX: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 세로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof selectProps.paddingY === "number" ? selectProps.paddingY : 10}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 얇게", value: 6 },
|
||||
{ id: "sm", label: "얇게", value: 8 },
|
||||
{ id: "md", label: "보통", value: 10 },
|
||||
{ id: "lg", label: "넓게", value: 12 },
|
||||
{ id: "xl", label: "아주 넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingY: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<ColorPickerField
|
||||
label="필드 텍스트 색상"
|
||||
ariaLabelColorInput="셀렉트 텍스트 색상 피커"
|
||||
|
||||
+18
-3
@@ -876,9 +876,11 @@ function SortableEditorBlock({
|
||||
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
||||
|
||||
if (fontSizeMode === "scale") {
|
||||
sizeClass = `pb-text-${fontSizeScale}`;
|
||||
} else if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
|
||||
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
|
||||
sizeClass = `pb-text-${fontSizeScale}`;
|
||||
|
||||
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
|
||||
if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
|
||||
textStyleOverrides.fontSize = textProps.fontSizeCustom;
|
||||
}
|
||||
|
||||
@@ -980,6 +982,13 @@ function SortableEditorBlock({
|
||||
event.stopPropagation();
|
||||
selectBlock(block.id);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
if (block.type === "text") {
|
||||
// 더블클릭 시 텍스트 블록 인라인 편집 모드로 진입한다.
|
||||
event.stopPropagation();
|
||||
startEditing(block.id, (block.props as TextBlockProps).text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
@@ -1380,6 +1389,12 @@ function SortableEditorBlock({
|
||||
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
||||
buttonStyle.color = buttonProps.textColorCustom;
|
||||
}
|
||||
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
|
||||
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
|
||||
}
|
||||
if (typeof buttonProps.paddingY === "number" && buttonProps.paddingY >= 0) {
|
||||
buttonStyle.paddingBlock = `${buttonProps.paddingY}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={fullWidth ? "w-full" : "inline-block"}>
|
||||
|
||||
@@ -141,7 +141,7 @@ export function BlocksSidebar() {
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddFormRadio}
|
||||
>
|
||||
폼 라디오 그룹 추가
|
||||
폼 라디오 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -156,7 +156,7 @@ export function BlocksSidebar() {
|
||||
<h3 className="text-[11px] font-medium text-slate-300">템플릿</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-sky-700 bg-sky-950 px-3 py-2 text-left text-xs text-sky-100 hover:bg-sky-900"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddHeroTemplate}
|
||||
>
|
||||
Hero 템플릿 추가
|
||||
@@ -218,10 +218,6 @@ export function BlocksSidebar() {
|
||||
Footer 템플릿 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-500">
|
||||
Text / Image / Button / Divider / List / Section 블록을 이 영역에서 추가할 수 있습니다.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,50 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={buttonProps.paddingX ?? 16}
|
||||
min={0}
|
||||
max={120}
|
||||
step={4}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 8 },
|
||||
{ id: "sm", label: "S", value: 12 },
|
||||
{ id: "md", label: "M", value: 16 },
|
||||
{ id: "lg", label: "L", value: 24 },
|
||||
{ id: "xl", label: "XL", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingX: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="세로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
value={buttonProps.paddingY ?? 10}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "xs", label: "XS", value: 6 },
|
||||
{ id: "sm", label: "S", value: 8 },
|
||||
{ id: "md", label: "M", value: 10 },
|
||||
{ id: "lg", label: "L", value: 14 },
|
||||
{ id: "xl", label: "XL", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
paddingY: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>버튼 링크</span>
|
||||
@@ -58,27 +102,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="버튼 크기"
|
||||
value={(() => {
|
||||
const s = buttonProps.size ?? "md";
|
||||
return s === "xs" ? 1 : s === "sm" ? 2 : s === "lg" ? 4 : s === "xl" ? 5 : 3;
|
||||
})()}
|
||||
min={1}
|
||||
max={5}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "xs", label: "아주 작게", value: 1 },
|
||||
{ id: "sm", label: "작게", value: 2 },
|
||||
{ id: "md", label: "보통", value: 3 },
|
||||
{ id: "lg", label: "크게", value: 4 },
|
||||
{ id: "xl", label: "아주 크게", value: 5 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
const next = v <= 1 ? "xs" : v === 2 ? "sm" : v === 4 ? "lg" : v >= 5 ? "xl" : "md";
|
||||
updateBlock(selectedBlockId, { size: next } as any);
|
||||
}}
|
||||
/>
|
||||
{/* 버튼 크기 스케일 컨트롤은 paddingX/paddingY 및 폰트 크기(px) 컨트롤로 충분하므로 제거했다. */}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
@@ -191,9 +215,48 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>버튼 너비 모드</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 너비 모드"
|
||||
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value as NonNullable<ButtonBlockProps["widthMode"]>,
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
<option value="auto">자동</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 값</option>
|
||||
</select>
|
||||
</label>
|
||||
{(buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")) === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="버튼 고정 너비"
|
||||
unitLabel="(px)"
|
||||
value={buttonProps.widthPx ?? 240}
|
||||
min={80}
|
||||
max={600}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 180 },
|
||||
{ id: "md", label: "보통", value: 240 },
|
||||
{ id: "lg", label: "넓게", value: 320 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<NumericPropertyControl
|
||||
label="글자 크기"
|
||||
label="버튼 크기"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = buttonProps.fontSizeCustom ?? "";
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ListPropertiesPanel({
|
||||
|
||||
{/* 글자 크기 */}
|
||||
<NumericPropertyControl
|
||||
label="글자 크기"
|
||||
label="글자 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
const raw = listProps.fontSizeCustom ?? "";
|
||||
@@ -204,7 +204,7 @@ export function ListPropertiesPanel({
|
||||
|
||||
{/* 아이템 간 여백 (슬라이더) */}
|
||||
<NumericPropertyControl
|
||||
label="아이템 간 여백"
|
||||
label="아이템 간 여백 (px)"
|
||||
unitLabel="(px)"
|
||||
value={(() => {
|
||||
if (typeof listProps.gapYPx === "number") {
|
||||
|
||||
@@ -48,6 +48,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="properties-sidebar"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
|
||||
onKeyDownCapture={(e) => {
|
||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||
|
||||
@@ -325,7 +325,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
|
||||
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="최대 폭"
|
||||
label="최대 폭 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.maxWidthPx === "number" ? sectionProps.maxWidthPx : 960}
|
||||
min={640}
|
||||
@@ -346,7 +346,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
|
||||
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<NumericPropertyControl
|
||||
label="컬럼 간 간격"
|
||||
label="컬럼 간 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof sectionProps.gapXPx === "number" ? sectionProps.gapXPx : 24}
|
||||
min={0}
|
||||
|
||||
@@ -57,6 +57,9 @@ export function createCtaTemplateBlocks(opts: {
|
||||
colorPalette: "primary",
|
||||
fullWidth: false,
|
||||
borderRadius: "md",
|
||||
textColorCustom: "#0b1120",
|
||||
fillColorCustom: "#0ea5e9",
|
||||
strokeColorCustom: "#0284c7",
|
||||
};
|
||||
const buttonBlock: Block = {
|
||||
id: buttonId,
|
||||
|
||||
@@ -78,8 +78,9 @@ export function createHeroTemplateBlocks(opts: {
|
||||
fullWidth: false,
|
||||
borderRadius: "md",
|
||||
fontSizeCustom: "14px",
|
||||
fillColorCustom: "",
|
||||
strokeColorCustom: "",
|
||||
textColorCustom: "#0b1120",
|
||||
fillColorCustom: "#0ea5e9",
|
||||
strokeColorCustom: "#0284c7",
|
||||
};
|
||||
const heroButton: Block = {
|
||||
id: heroButtonId,
|
||||
|
||||
@@ -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