import type { CSSProperties } from "react"; import type { Block, ButtonBlockProps, FormBlockProps, FormCheckboxBlockProps, FormCheckboxOption, FormFieldConfig, FormInputBlockProps, FormRadioBlockProps, FormRadioOption, FormSelectBlockProps, FormSelectOption, } from "@/features/editor/state/editorStore"; export interface FormStyleEscapers { escapeAttr: (value: string) => string; } const defaultEscapers: FormStyleEscapers = { escapeAttr: (value) => value, }; export interface FormInputExportTokens { labelStyleAttr: string; inputStyleAttr: string; } export interface FormSelectExportTokens { labelStyleAttr: string; selectStyleAttr: string; } export interface FormOptionGroupExportTokens { labelStyleAttr: string; optionStyleAttr: string; } const normalizeTextColor = (raw: unknown): string => { if (typeof raw !== "string") return ""; const trimmed = raw.trim(); return trimmed !== "" ? trimmed : ""; }; const computeRadiusPx = (radius: unknown): number => { const token = typeof radius === "string" ? radius : "md"; if (token === "none") return 0; if (token === "sm") return 2; if (token === "lg") return 6; if (token === "full") return 9999; return 4; }; const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => { if (typeof props.widthMode === "string") { return props.widthMode as "auto" | "full" | "fixed"; } if (props.fullWidth) { return "full"; } return "auto"; }; export const computeFormInputExportTokens = ( props: FormInputBlockProps, escapers: FormStyleEscapers = defaultEscapers, ): FormInputExportTokens => { const { escapeAttr } = escapers; const textColorRaw = normalizeTextColor(props.textColorCustom); const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; const inputStyleParts: string[] = []; if (textColorRaw) { inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`); } if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); } if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); } if (typeof props.paddingX === "number" && props.paddingX >= 0) { const em = props.paddingX / 16; inputStyleParts.push(`padding-left:${em}em`); inputStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { const em = props.paddingY / 16; inputStyleParts.push(`padding-top:${em}em`); inputStyleParts.push(`padding-bottom:${em}em`); } const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { const em = props.widthPx / 16; inputStyleParts.push(`width:${em}em`); } const radiusPx = computeRadiusPx(props.borderRadius); inputStyleParts.push(`border-radius:${radiusPx}px`); const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : ""; return { labelStyleAttr, inputStyleAttr, }; }; export const computeFormSelectExportTokens = ( props: FormSelectBlockProps, escapers: FormStyleEscapers = defaultEscapers, ): FormSelectExportTokens => { const { escapeAttr } = escapers; const textColorRaw = normalizeTextColor(props.textColorCustom); const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; const selectStyleParts: string[] = []; if (textColorRaw) { selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`); } const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth }); if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { const em = props.widthPx / 16; selectStyleParts.push(`width:${em}em`); } if (typeof props.paddingX === "number" && props.paddingX >= 0) { const em = props.paddingX / 16; selectStyleParts.push(`padding-left:${em}em`); selectStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { const em = props.paddingY / 16; selectStyleParts.push(`padding-top:${em}em`); selectStyleParts.push(`padding-bottom:${em}em`); } if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); } if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); } const radiusPx = computeRadiusPx(props.borderRadius); selectStyleParts.push(`border-radius:${radiusPx}px`); const selectStyleAttr = selectStyleParts.length > 0 ? ` style="${selectStyleParts.join(";")}"` : ""; return { labelStyleAttr, selectStyleAttr, }; }; const computeOptionGroupExportTokensBase = ( props: { textColorCustom?: string; paddingX?: number; paddingY?: number; fillColorCustom?: string; strokeColorCustom?: string; borderRadius?: string }, escapers: FormStyleEscapers, ): FormOptionGroupExportTokens => { const { escapeAttr } = escapers; const textColorRaw = normalizeTextColor(props.textColorCustom); const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : ""; const optionStyleParts: string[] = []; if (textColorRaw) { optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`); } if (typeof props.paddingX === "number" && props.paddingX >= 0) { const em = props.paddingX / 16; optionStyleParts.push(`padding-left:${em}em`); optionStyleParts.push(`padding-right:${em}em`); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { const em = props.paddingY / 16; optionStyleParts.push(`padding-top:${em}em`); optionStyleParts.push(`padding-bottom:${em}em`); } if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") { optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`); } if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") { optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`); optionStyleParts.push("border-width:1px"); optionStyleParts.push("border-style:solid"); } const radiusPx = computeRadiusPx(props.borderRadius); optionStyleParts.push(`border-radius:${radiusPx}px`); const optionStyleAttr = optionStyleParts.length > 0 ? ` style="${optionStyleParts.join(";")}"` : ""; return { labelStyleAttr, optionStyleAttr, }; }; export const computeFormCheckboxExportTokens = ( props: FormCheckboxBlockProps, escapers: FormStyleEscapers = defaultEscapers, ): FormOptionGroupExportTokens => computeOptionGroupExportTokensBase( { textColorCustom: props.textColorCustom, paddingX: props.paddingX, paddingY: props.paddingY, fillColorCustom: props.fillColorCustom, strokeColorCustom: props.strokeColorCustom, borderRadius: props.borderRadius, }, escapers, ); export interface FormInputEditorTokens { fieldStyle: CSSProperties; widthClass: string; inputAlignClass: string; } export interface FormFieldEditorTokens { fieldStyle: CSSProperties; } export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormInputEditorTokens => { const fieldStyle: CSSProperties = {}; if (props.textColorCustom && props.textColorCustom.trim() !== "") { fieldStyle.color = props.textColorCustom; } if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { fieldStyle.backgroundColor = props.fillColorCustom; } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { fieldStyle.borderColor = props.strokeColorCustom; } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { fieldStyle.width = `${props.widthPx}px`; } const radius = props.borderRadius ?? "md"; if (radius === "none") { fieldStyle.borderRadius = 0; } else if (radius === "sm") { fieldStyle.borderRadius = 4; } else if (radius === "lg" || radius === "full") { fieldStyle.borderRadius = 9999; } // 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다. if (typeof props.paddingX === "number" && props.paddingX >= 0) { fieldStyle.paddingInline = `${props.paddingX}px`; } if (typeof props.paddingY === "number" && props.paddingY >= 0) { fieldStyle.paddingBlock = `${props.paddingY}px`; } if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") { fieldStyle.fontSize = props.fontSizeCustom.trim(); } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { fieldStyle.lineHeight = props.lineHeightCustom.trim(); } if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { fieldStyle.letterSpacing = props.letterSpacingCustom.trim(); } const labelLayout = props.labelLayout ?? "stacked"; const isInline = labelLayout === "inline"; const align = props.align ?? "left"; const inputAlignClass = align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left"; // 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 { fieldStyle, widthClass, inputAlignClass, }; }; export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): FormFieldEditorTokens => { const fieldStyle: CSSProperties = {}; if (props.textColorCustom && props.textColorCustom.trim() !== "") { fieldStyle.color = props.textColorCustom; } if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { fieldStyle.backgroundColor = props.fillColorCustom; } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { fieldStyle.borderColor = props.strokeColorCustom; } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { fieldStyle.width = `${props.widthPx}px`; } const radius = props.borderRadius ?? "md"; if (radius === "none") { fieldStyle.borderRadius = 0; } else if (radius === "sm") { fieldStyle.borderRadius = 4; } else if (radius === "lg" || radius === "full") { fieldStyle.borderRadius = 9999; } // 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다. if (typeof props.paddingX === "number" && props.paddingX >= 0) { fieldStyle.paddingInline = `${props.paddingX}px`; } if (typeof props.paddingY === "number" && props.paddingY >= 0) { fieldStyle.paddingBlock = `${props.paddingY}px`; } if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") { fieldStyle.fontSize = props.fontSizeCustom.trim(); } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { fieldStyle.lineHeight = props.lineHeightCustom.trim(); } if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { fieldStyle.letterSpacing = props.letterSpacingCustom.trim(); } return { fieldStyle }; }; export const computeFormOptionGroupEditorTokens = ( props: FormCheckboxBlockProps | FormRadioBlockProps, ): FormFieldEditorTokens => { const fieldStyle: CSSProperties = {}; if (props.textColorCustom && props.textColorCustom.trim() !== "") { fieldStyle.color = props.textColorCustom; } if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { fieldStyle.backgroundColor = props.fillColorCustom; } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { fieldStyle.borderColor = props.strokeColorCustom; } const widthMode = props.widthMode ?? "full"; if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { fieldStyle.width = `${props.widthPx}px`; } const radius = props.borderRadius ?? "md"; if (radius === "none") { fieldStyle.borderRadius = 0; } else if (radius === "sm") { fieldStyle.borderRadius = 4; } else if (radius === "lg" || radius === "full") { fieldStyle.borderRadius = 9999; } if (typeof props.paddingX === "number" && props.paddingX >= 0) { fieldStyle.paddingInline = `${props.paddingX}px`; } if (typeof props.paddingY === "number" && props.paddingY >= 0) { fieldStyle.paddingBlock = `${props.paddingY}px`; } if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") { fieldStyle.fontSize = props.fontSizeCustom.trim(); } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { fieldStyle.lineHeight = props.lineHeightCustom.trim(); } if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { fieldStyle.letterSpacing = props.letterSpacingCustom.trim(); } return { fieldStyle }; }; export const computeFormRadioExportTokens = ( props: FormRadioBlockProps, escapers: FormStyleEscapers = defaultEscapers, ): FormOptionGroupExportTokens => computeOptionGroupExportTokensBase( { textColorCustom: props.textColorCustom, paddingX: props.paddingX, paddingY: props.paddingY, fillColorCustom: props.fillColorCustom, strokeColorCustom: props.strokeColorCustom, borderRadius: props.borderRadius, }, escapers, ); // 퍼블릭 렌더러용 폼 필드 스타일 토큰 export interface FormInputPublicTokens { wrapperLayoutClass: string; wrapperStyle: CSSProperties; widthClass: string; inputStyle: CSSProperties; } export interface FormSelectPublicTokens { widthClass: string; wrapperStyle: CSSProperties; selectStyle: CSSProperties; } export interface FormOptionGroupPublicTokens { widthClass: string; textSizeClass: string; groupStyle: CSSProperties; groupTextStyle: CSSProperties; optionContainerStyle: CSSProperties; optionTextStyle: CSSProperties; optionsStyle: CSSProperties; } 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 const computeFormInputPublicTokens = (props: FormInputBlockProps): FormInputPublicTokens => { const align = props.align ?? "left"; const labelLayout = props.labelLayout ?? "stacked"; const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto"); 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 widthClass = widthMode === "full" ? "w-full" : ""; const inputStyle: CSSProperties = { textAlign: align, }; // 너비: fixed 모드일 때 widthPx 를 em 단위로 설정한다. if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) { inputStyle.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) { inputStyle.fontSize = fontSizeEm; } } else { inputStyle.fontSize = fontSizeValue; } } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { 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() !== "") { const letterSpacingValue = props.letterSpacingCustom.trim(); if (letterSpacingValue.endsWith("px")) { const letterSpacingEm = convertPxStringToEm(letterSpacingValue); if (letterSpacingEm) { inputStyle.letterSpacing = letterSpacingEm; } } else { inputStyle.letterSpacing = letterSpacingValue; } } // 색상 관련 커스텀 값 if (props.textColorCustom && props.textColorCustom.trim() !== "") { inputStyle.color = props.textColorCustom.trim(); } else { inputStyle.color = "#f9fafb"; } if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { inputStyle.backgroundColor = props.fillColorCustom.trim(); } else { inputStyle.backgroundColor = "#020617"; } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { inputStyle.borderColor = props.strokeColorCustom.trim(); } else { 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 = radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4; inputStyle.borderRadius = `${radiusPx}px`; return { wrapperLayoutClass, wrapperStyle, widthClass, inputStyle, }; }; export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): FormSelectPublicTokens => { 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; } } // 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다. if (props.textColorCustom && props.textColorCustom.trim() !== "") { const colorValue = props.textColorCustom.trim(); wrapperStyle.color = colorValue; selectStyle.color = colorValue; } else { wrapperStyle.color = "#f9fafb"; selectStyle.color = "#f9fafb"; } // 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다. if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { selectStyle.backgroundColor = props.fillColorCustom.trim(); } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { selectStyle.borderColor = props.strokeColorCustom.trim(); } // 모서리 둥글기: borderRadius 토큰을 px 로 변환한다. const selectRadiusToken = props.borderRadius ?? "md"; const selectRadiusPx = selectRadiusToken === "none" ? 0 : selectRadiusToken === "sm" ? 2 : selectRadiusToken === "lg" ? 6 : selectRadiusToken === "full" ? 9999 : 4; selectStyle.borderRadius = `${selectRadiusPx}px`; 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 { widthClass, wrapperStyle, selectStyle, }; }; const computeOptionGroupPublicTokensBase = ( props: FormCheckboxBlockProps | FormRadioBlockProps, ): FormOptionGroupPublicTokens => { const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto"); const widthClass = widthMode === "full" ? "w-full" : ""; const groupStyle: CSSProperties = {}; const optionContainerStyle: CSSProperties = {}; const optionTextStyle: CSSProperties = {}; const groupTextStyle: CSSProperties = {}; const optionsStyle: 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 (props.textColorCustom && props.textColorCustom.trim() !== "") { const colorValue = props.textColorCustom.trim(); groupTextStyle.color = colorValue; optionTextStyle.color = colorValue; } else { groupTextStyle.color = "#e5e7eb"; optionTextStyle.color = "#e5e7eb"; } if (typeof props.paddingX === "number" && props.paddingX >= 0) { optionContainerStyle.paddingInline = pxToEm(props.paddingX); } if (typeof props.paddingY === "number" && props.paddingY >= 0) { optionContainerStyle.paddingBlock = pxToEm(props.paddingY); } // 옵션 컨테이너 배경/테두리 색상 if (props.fillColorCustom && props.fillColorCustom.trim() !== "") { optionContainerStyle.backgroundColor = props.fillColorCustom.trim(); } if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") { const strokeValue = props.strokeColorCustom.trim(); optionContainerStyle.borderColor = strokeValue; optionContainerStyle.borderWidth = "1px"; optionContainerStyle.borderStyle = "solid"; } // 옵션 컨테이너 모서리 둥글기 const radiusToken = props.borderRadius ?? "md"; const radiusPx = radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4; optionContainerStyle.borderRadius = `${radiusPx}px`; if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) { optionsStyle.rowGap = pxToEm(props.optionGapPx); } return { widthClass, textSizeClass, groupStyle, groupTextStyle, optionContainerStyle, optionTextStyle, optionsStyle, }; }; export const computeFormCheckboxPublicTokens = ( props: FormCheckboxBlockProps, ): FormOptionGroupPublicTokens => computeOptionGroupPublicTokensBase(props); export const computeFormRadioPublicTokens = (props: FormRadioBlockProps): FormOptionGroupPublicTokens => computeOptionGroupPublicTokensBase(props); export interface FormControllerFieldPublicConfig { id: string; name: string; label: string; type: "text" | "email" | "textarea" | "select" | "checkbox" | "radio"; required?: boolean; options?: Array; groupLabelMode?: "text" | "image"; groupLabelImageUrl?: string; } export interface FormControllerPublicTokens { fields: FormControllerFieldPublicConfig[]; formClassName: string; formStyle: CSSProperties; submitLabel: string | null; } export const computeFormControllerPublicTokens = ( formBlock: Block, blocks: Block[], ): FormControllerPublicTokens => { const props = formBlock.props as FormBlockProps; const hasControllerFields = Array.isArray(props.fieldIds) && props.fieldIds.length > 0; const controllerFields: FormControllerFieldPublicConfig[] = hasControllerFields ? (props.fieldIds ?? []) .map((fieldId) => blocks.find((b) => b.id === fieldId)) .filter((b): b is Block => Boolean(b)) .filter( (b) => b.type === "formInput" || b.type === "formSelect" || b.type === "formCheckbox" || b.type === "formRadio", ) .map((fieldBlock) => { const anyProps: any = fieldBlock.props ?? {}; if (fieldBlock.type === "formInput") { const name = anyProps.formFieldName ?? fieldBlock.id; const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드"; return { id: fieldBlock.id, name, label, // 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다. type: "text", required: Boolean(anyProps.required), } satisfies FormControllerFieldPublicConfig; } if (fieldBlock.type === "formSelect") { const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; const name = anyProps.formFieldName ?? fieldBlock.id; const label = anyProps.label ?? anyProps.formFieldName ?? "선택 필드"; return { id: fieldBlock.id, name, label, type: "select", options, required: Boolean(anyProps.required), } satisfies FormControllerFieldPublicConfig; } if (fieldBlock.type === "formCheckbox") { const options: FormCheckboxOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; const name = anyProps.formFieldName ?? fieldBlock.id; const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스"; return { id: fieldBlock.id, name, label, type: "checkbox", options, groupLabelMode: anyProps.groupLabelMode, groupLabelImageUrl: anyProps.groupLabelImageUrl, required: Boolean(anyProps.required), } satisfies FormControllerFieldPublicConfig; } const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : []; const name = anyProps.formFieldName ?? fieldBlock.id; const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹"; return { id: fieldBlock.id, name, label, type: "radio", options, groupLabelMode: anyProps.groupLabelMode, groupLabelImageUrl: anyProps.groupLabelImageUrl, required: Boolean(anyProps.required), } satisfies FormControllerFieldPublicConfig; }) : []; const fields: FormControllerFieldPublicConfig[] = hasControllerFields && controllerFields.length > 0 ? controllerFields : Array.isArray(props.fields) && props.fields.length > 0 ? props.fields.map((field) => ({ id: field.id, name: field.name, label: field.label, type: field.type, required: field.required, })) : []; const mappedSubmitButton = blocks.find( (b) => b.type === "button" && b.id === props.submitButtonId, ); const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null; // FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이지만, // 배경색 커스텀 값을 허용해 form 요소 배경만 제어할 수 있도록 한다. const formClassNames = ["space-y-3"]; const formStyle: CSSProperties = {}; if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") { formStyle.backgroundColor = props.backgroundColorCustom.trim(); } return { fields, formClassName: formClassNames.join(" "), formStyle, submitLabel: mappedSubmitLabel, }; }; export interface FormBlockExportTokens { hasControllerFields: boolean; controllerFields: Block[]; fallbackFields: FormFieldConfig[]; formStyleParts: string[]; } export const computeFormBlockExportTokens = ( formBlock: Block, blocks: Block[], ): FormBlockExportTokens => { const props = formBlock.props as FormBlockProps; const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : []; const controllerFields = fieldIds .map((id) => blocks.find((b) => b.id === id)) .filter((b): b is Block => Boolean(b)) .filter( (b) => b.type === "formInput" || b.type === "formSelect" || b.type === "formCheckbox" || b.type === "formRadio", ); let fallbackFields: FormFieldConfig[] = []; if (Array.isArray(props.fields) && props.fields.length > 0) { fallbackFields = props.fields; } // FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다. const formStyleParts: string[] = []; return { hasControllerFields: controllerFields.length > 0, controllerFields, fallbackFields, formStyleParts, }; };