텍스트 스타일 및 프리뷰 렌더링 정리
CI / test (push) Successful in 11m5s
CI / pr_and_merge (push) Successful in 1m40s
CI / test (pull_request) Failing after 35m42s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-20 23:36:47 +09:00
parent 9e40ee405c
commit 2f59e3781e
19 changed files with 3831 additions and 556 deletions
+353 -402
View File
@@ -32,6 +32,11 @@ import type {
DividerBlockProps,
ListBlockProps,
FormBlockProps,
FormFieldConfig,
FormInputBlockProps,
FormSelectBlockProps,
FormCheckboxBlockProps,
FormRadioBlockProps,
} from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
@@ -39,6 +44,9 @@ import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
import { DividerPropertiesPanel } from "./panels/DividerPropertiesPanel";
import { ImagePropertiesPanel } from "./panels/ImagePropertiesPanel";
import { SectionPropertiesPanel } from "./panels/SectionPropertiesPanel";
import { BlocksSidebar } from "./panels/BlocksSidebar";
import { PropertiesSidebar } from "./panels/PropertiesSidebar";
import { EditorCanvas } from "./EditorCanvas";
export default function EditorPage() {
const blocks = useEditorStore((state) => state.blocks);
@@ -50,6 +58,10 @@ export default function EditorPage() {
const addListBlock = useEditorStore((state) => state.addListBlock);
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
const addFormBlock = useEditorStore((state) => state.addFormBlock);
const addFormInputBlock = useEditorStore((state) => (state as any).addFormInputBlock);
const addFormSelectBlock = useEditorStore((state) => (state as any).addFormSelectBlock);
const addFormCheckboxBlock = useEditorStore((state) => (state as any).addFormCheckboxBlock);
const addFormRadioBlock = useEditorStore((state) => (state as any).addFormRadioBlock);
const addHeroTemplateSection = useEditorStore((state) => state.addHeroTemplateSection);
const addFeaturesTemplateSection = useEditorStore((state) => state.addFeaturesTemplateSection);
const addCtaTemplateSection = useEditorStore((state) => state.addCtaTemplateSection);
@@ -566,315 +578,33 @@ export default function EditorPage() {
</div>
</header>
<section className="flex flex-1 overflow-hidden">
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
<h2 className="font-medium"></h2>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTextBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addButtonBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addImageBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addDividerBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addListBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addSectionBlock}
>
</button>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={addFormBlock}
>
</button>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<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"
onClick={addHeroTemplateSection}
>
Hero 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFeaturesTemplateSection}
>
Features 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addCtaTemplateSection}
>
CTA 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFaqTemplateSection}
>
FAQ 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addPricingTemplateSection}
>
Pricing 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTestimonialsTemplateSection}
>
Testimonials 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addBlogTemplateSection}
>
Blog 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addTeamTemplateSection}
>
Team 릿
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={addFooterTemplateSection}
>
Footer 릿
</button>
</div>
<p className="text-xs text-slate-500">
Text / Image / Button / Divider / List / Section .
</p>
</aside>
<div
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
data-testid="editor-canvas"
onClick={(event) => {
// 캔버스의 빈 공간을 클릭했을 때만 선택을 해제한다.
if (event.target === event.currentTarget) {
selectBlock(null);
}
}}
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
</div>
) : (
<DndContext
sensors={sensors}
// 컬럼/드롭존 하이라이트가 마우스 위치와 더욱 직관적으로 일치하도록
// 가장 가까운 중심점 대신 실제 마우스가 겹치는 영역 기준(rectIntersection)으로 계산한다.
collisionDetection={rectIntersection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{/* 루트 레벨 블록들만 정렬 대상으로 둔다. 섹션 내부 컬럼 블록은 각 컬럼 별 SortableContext에서 관리한다. */}
<SortableContext
items={rootBlocks.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
{renderBlocks(rootBlocks)}
{/* 루트 레벨 맨 아래 드롭존: 블록을 캔버스 최하단으로 이동 */}
{rootBlocks.length > 0 && (
<BlockDropzone id="dropzone-after-root" testId="root-bottom-dropzone" />
)}
</SortableContext>
{/* 드래그 중에는 실제 블록 대신 일관된 크기의 고스트 카드를 오버레이로 보여준다. */}
<DragOverlay>
{activeDragId && (
<DragPreview block={blocks.find((b) => b.id === activeDragId) ?? null} />
)}
</DragOverlay>
</DndContext>
)}
</div>
<aside className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto">
<h2 className="font-medium mb-2"> </h2>
{selectedBlockId ? (
<div className="space-y-3">
<div className="flex gap-2 text-[11px]">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
onClick={() => removeBlock(selectedBlockId)}
>
</button>
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => duplicateBlock(selectedBlockId)}
>
</button>
</div>
{(() => {
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
if (selectedBlock.type === "text") {
const textProps = selectedBlock.props as TextBlockProps;
return (
<TextPropertiesPanel
textProps={textProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
/>
);
}
if (selectedBlock.type === "divider") {
const dividerProps = selectedBlock.props as DividerBlockProps;
return (
<DividerPropertiesPanel
dividerProps={dividerProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "image") {
const imageProps = selectedBlock.props as ImageBlockProps;
return (
<ImagePropertiesPanel
imageProps={imageProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "section") {
const sectionProps = selectedBlock.props as SectionBlockProps;
return (
<SectionPropertiesPanel
sectionProps={sectionProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "list") {
const listProps = selectedBlock.props as ListBlockProps;
return (
<ListPropertiesPanel
listProps={listProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "button") {
const buttonProps = selectedBlock.props as ButtonBlockProps;
return (
<ButtonPropertiesPanel
buttonProps={buttonProps}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
/>
);
}
if (selectedBlock.type === "form") {
const formProps = selectedBlock.props as FormBlockProps;
return (
<div className="space-y-3 text-xs">
<p className="text-slate-400">
/api/forms/submit .
</p>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={formProps.successMessage ?? "성공적으로 전송되었습니다."}
onChange={(e) =>
updateBlock(selectedBlockId, {
successMessage: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
value={formProps.errorMessage ?? "전송 중 오류가 발생했습니다."}
onChange={(e) =>
updateBlock(selectedBlockId, {
errorMessage: e.target.value,
} as any)
}
/>
</label>
</div>
);
}
return null;
})()}
</div>
) : (
<p className="text-xs text-slate-500">
.
</p>
)}
</aside>
<BlocksSidebar />
<EditorCanvas
blocks={blocks}
rootBlocks={rootBlocks}
selectedBlockId={selectedBlockId}
editingBlockId={editingBlockId}
editingText={editingText}
activeDragId={activeDragId}
sensors={sensors}
onCanvasEmptyClick={() => selectBlock(null)}
renderBlocks={renderBlocks}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragCancel={handleDragCancel}
/>
<PropertiesSidebar
blocks={blocks}
selectedBlockId={selectedBlockId}
updateBlock={(id, partial) => updateBlock(id, partial as any)}
removeBlock={removeBlock}
duplicateBlock={duplicateBlock}
editingBlockId={editingBlockId}
setEditingText={setEditingText}
/>
</section>
</main>
);
{activeModal === "project" && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
@@ -997,8 +727,6 @@ export default function EditorPage() {
</div>
</div>
)}
</main>
);
}
interface DragPreviewProps {
@@ -1048,97 +776,6 @@ interface TextInlineToolbarProps {
onChangeSize: (value: TextBlockProps["size"]) => void;
}
function TextInlineToolbar({ align, size, onChangeAlign, onChangeSize }: TextInlineToolbarProps) {
return (
<div className="flex items-center justify-between gap-2 text-[10px] text-slate-300">
<div className="inline-flex items-center gap-1">
<span className="text-slate-500"></span>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "left"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="왼쪽 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("left")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "center"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="가운데 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("center")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
align === "right"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="오른쪽 정렬"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeAlign("right")}
>
</button>
</div>
<div className="inline-flex items-center gap-1">
<span className="text-slate-500"></span>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "sm"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 작게"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("sm")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "base"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 보통"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("base")}
>
</button>
<button
type="button"
className={`px-1 py-0.5 rounded border text-[10px] ${
size === "lg"
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
aria-label="글자 크게"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChangeSize("lg")}
>
</button>
</div>
</div>
);
}
interface SortableEditorBlockProps {
block: Block;
isSelected: boolean;
@@ -1341,9 +978,323 @@ function SortableEditorBlock({
autoFocus
/>
) : (
<div className="whitespace-pre-wrap">{(block.props as TextBlockProps).text}</div>
<div
className={`whitespace-pre-wrap ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass}`}
style={textStyleOverrides}
>
{(block.props as TextBlockProps).text}
</div>
)
)}
{block.type === "formInput" && (() => {
const inputProps = block.props as FormInputBlockProps;
const inputType = inputProps.inputType ?? "text";
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
const fieldStyle: CSSProperties = {};
if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") {
fieldStyle.color = inputProps.textColorCustom;
}
if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = inputProps.fillColorCustom;
}
if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = inputProps.strokeColorCustom;
}
const widthMode = inputProps.widthMode ?? "full";
if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) {
fieldStyle.width = `${inputProps.widthPx}px`;
}
const radius = inputProps.borderRadius ?? "md";
if (radius === "none") fieldStyle.borderRadius = 0;
else if (radius === "sm") fieldStyle.borderRadius = 4;
else if (radius === "lg") fieldStyle.borderRadius = 9999;
else if (radius === "full") fieldStyle.borderRadius = 9999;
// md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다).
const labelLayout = inputProps.labelLayout ?? "stacked";
const isInline = labelLayout === "inline";
const align = inputProps.align ?? "left";
const inputAlignClass =
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
return (
<div className={`text-xs ${isInline ? "flex items-center gap-2" : "flex flex-col gap-1"}`}>
{inputProps.labelMode === "image" && inputProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inputProps.labelImageUrl}
alt={inputProps.labelImageAlt || inputProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
)}
{(() => {
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
let widthClass = "w-full";
if (isInline) {
if (widthMode === "fixed") {
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
widthClass = "flex-none";
} else if (widthMode === "full") {
widthClass = "flex-1";
} else {
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
widthClass = "";
}
} else {
if (widthMode === "fixed") {
widthClass = ""; // 고정 너비는 style.width 로만 제어
} else {
widthClass = "w-full";
}
}
return (
<div
data-testid="form-input-field"
style={fieldStyle}
className={`${widthClass} rounded border border-slate-700 bg-slate-950`}
>
{inputType === "textarea" ? (
<textarea
className={`w-full min-h-[60px] bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
) : (
<input
className={`w-full bg-transparent px-2 py-1 text-xs outline-none ${inputAlignClass}`}
type={inputType === "email" ? "email" : "text"}
placeholder={inputProps.placeholder || inputProps.label}
aria-label={inputProps.label}
readOnly
/>
)}
</div>
);
})()}
</div>
);
})()}
{block.type === "formSelect" && (() => {
const selectProps = block.props as FormSelectBlockProps;
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
];
const fieldStyle: CSSProperties = {};
if (selectProps.textColorCustom && selectProps.textColorCustom.trim() !== "") {
fieldStyle.color = selectProps.textColorCustom;
}
if (selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = selectProps.fillColorCustom;
}
if (selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = selectProps.strokeColorCustom;
}
const selectWidthMode = selectProps.widthMode ?? "full";
if (
selectWidthMode === "fixed" &&
typeof selectProps.widthPx === "number" &&
selectProps.widthPx > 0
) {
fieldStyle.width = `${selectProps.widthPx}px`;
}
const selectRadius = selectProps.borderRadius ?? "md";
if (selectRadius === "none") fieldStyle.borderRadius = 0;
else if (selectRadius === "sm") fieldStyle.borderRadius = 4;
else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999;
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
return (
<div className="flex flex-col gap-1 text-xs">
{selectProps.labelMode === "image" && selectProps.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={selectProps.labelImageUrl}
alt={selectProps.labelImageAlt || selectProps.label || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span className="text-slate-200">{selectProps.label}</span>
)}
<div
style={fieldStyle}
className="w-full rounded border border-slate-700 bg-slate-950"
>
<select
className="w-full bg-transparent px-2 py-1 text-xs outline-none"
aria-label={selectProps.label}
defaultValue={options[0]?.value}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
})()}
{block.type === "formRadio" && (() => {
const radioProps = block.props as FormRadioBlockProps;
const options = Array.isArray(radioProps.options) && radioProps.options.length > 0
? radioProps.options
: [
{ label: "옵션 A", value: "option_a" },
{ label: "옵션 B", value: "option_b" },
];
const groupLabelMode = radioProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") {
fieldStyle.color = radioProps.textColorCustom;
}
if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = radioProps.fillColorCustom;
}
if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = radioProps.strokeColorCustom;
}
const radioWidthMode = radioProps.widthMode ?? "full";
if (
radioWidthMode === "fixed" &&
typeof radioProps.widthPx === "number" &&
radioProps.widthPx > 0
) {
fieldStyle.width = `${radioProps.widthPx}px`;
}
const radioRadius = radioProps.borderRadius ?? "md";
if (radioRadius === "none") fieldStyle.borderRadius = 0;
else if (radioRadius === "sm") fieldStyle.borderRadius = 4;
else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs">
{/* 그룹 타이틀은 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && radioProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={radioProps.groupLabelImageUrl}
alt={radioProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
/>
) : (
<span className="text-slate-200">{radioProps.groupLabel}</span>
)}
<div
style={fieldStyle}
className="flex flex-col gap-1 rounded border border-slate-700 bg-slate-950"
>
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2 text-slate-200">
<input
type="radio"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={radioProps.formFieldName}
value={opt.value}
readOnly
/>
{/* 옵션 옆 라벨은 옵션에 개별 이미지 URL 이 있는 경우 이미지로 렌더링한다. */}
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || radioProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "formCheckbox" && (() => {
const checkboxProps = block.props as FormCheckboxBlockProps;
const options = Array.isArray(checkboxProps.options) && checkboxProps.options.length > 0
? checkboxProps.options
: [
{ label: "체크 1", value: "check_1" },
{ label: "체크 2", value: "check_2" },
];
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
const fieldStyle: CSSProperties = {};
if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") {
fieldStyle.color = checkboxProps.textColorCustom;
}
if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = checkboxProps.fillColorCustom;
}
if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = checkboxProps.strokeColorCustom;
}
const checkboxWidthMode = checkboxProps.widthMode ?? "full";
if (
checkboxWidthMode === "fixed" &&
typeof checkboxProps.widthPx === "number" &&
checkboxProps.widthPx > 0
) {
fieldStyle.width = `${checkboxProps.widthPx}px`;
}
const checkboxRadius = checkboxProps.borderRadius ?? "md";
if (checkboxRadius === "none") fieldStyle.borderRadius = 0;
else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4;
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs text-slate-200">
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={checkboxProps.groupLabelImageUrl}
alt={checkboxProps.groupLabel || "폼 그룹 타이틀"}
className="h-4 w-auto text-slate-200"
/>
) : (
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
)}
<div className="flex flex-col gap-1">
{options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-2">
<input
type="checkbox"
className="h-3 w-3 rounded border border-slate-700 bg-slate-950"
name={checkboxProps.formFieldName}
value={opt.value}
readOnly
/>
{opt.labelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={opt.labelImageUrl}
alt={opt.label || checkboxProps.groupLabel || "폼 라벨"}
className="h-4 w-auto"
/>
) : (
<span>{opt.label}</span>
)}
</label>
))}
</div>
</div>
);
})()}
{block.type === "button" && (() => {
const buttonProps = block.props as ButtonBlockProps;
const size = buttonProps.size ?? "md";