985 lines
32 KiB
TypeScript
985 lines
32 KiB
TypeScript
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<FormSelectOption | FormCheckboxOption | FormRadioOption>;
|
|
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,
|
|
};
|
|
};
|