import type { CSSProperties } from "react"; import type { TextBlockProps } from "@/features/editor/state/editorStore"; export type TextAlign = "left" | "center" | "right" | null | undefined; export type TextFontSizeScale = "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl"; export type TextLineHeightScale = "tight" | "snug" | "normal" | "relaxed" | "loose"; export type TextFontWeightScale = "normal" | "medium" | "semibold" | "bold"; export type TextColorPalette = | "default" | "muted" | "strong" | "accent" | "danger" | "success" | "warning" | "info" | "neutral"; export interface TextStyleInput { text?: string; align?: TextAlign; size?: "sm" | "base" | "lg" | null | undefined; fontSizeMode?: "scale" | "custom" | null | undefined; fontSizeScale?: TextFontSizeScale | null | undefined; fontSizeCustom?: string | null | undefined; lineHeightMode?: "scale" | "custom" | null | undefined; lineHeightScale?: TextLineHeightScale | null | undefined; lineHeightCustom?: string | null | undefined; fontWeightMode?: "scale" | "custom" | null | undefined; fontWeightScale?: TextFontWeightScale | null | undefined; fontWeightCustom?: string | number | null | undefined; colorMode?: "palette" | "custom" | null | undefined; colorPalette?: TextColorPalette | null | undefined; colorCustom?: string | null | undefined; backgroundColorCustom?: string | null | undefined; maxWidthMode?: "scale" | "custom" | null | undefined; maxWidthScale?: "none" | "prose" | "narrow" | null | undefined; underline?: boolean | null | undefined; strike?: boolean | null | undefined; italic?: boolean | null | undefined; } export interface TextPbTokens { alignClass: string; sizeClass: string; leadingClass: string; weightClass: string; colorClass: string; maxWidthClass: string; extraClasses: string[]; inlineStyles: string[]; } export function computeTextPbTokens(input: TextStyleInput): TextPbTokens { const align = input.align === "center" ? "center" : input.align === "right" ? "right" : "left"; const alignClass = align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left"; const fontSizeMode = input.fontSizeMode ?? "scale"; const fallbackScale: TextFontSizeScale = input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base"; const fontSizeScale: TextFontSizeScale = (input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale; const fontSizeMap: Record = { xs: "pb-text-xs", sm: "pb-text-sm", base: "pb-text-base", lg: "pb-text-lg", xl: "pb-text-xl", "2xl": "pb-text-2xl", "3xl": "pb-text-3xl", }; const sizeClass = fontSizeScale && fontSizeMap[fontSizeScale] ? fontSizeMap[fontSizeScale] : ""; const lineHeightMode = input.lineHeightMode ?? "scale"; const lineHeightScale: TextLineHeightScale = (input.lineHeightScale as TextLineHeightScale | null) ?? "normal"; const leadingMap: Record = { tight: "pb-leading-tight", snug: "pb-leading-snug", normal: "pb-leading-normal", relaxed: "pb-leading-relaxed", loose: "pb-leading-loose", }; const leadingClass = lineHeightMode === "scale" && lineHeightScale && leadingMap[lineHeightScale] ? leadingMap[lineHeightScale] : ""; const fontWeightMode = input.fontWeightMode ?? "scale"; const fontWeightScale: TextFontWeightScale = (input.fontWeightScale as TextFontWeightScale | null) ?? "normal"; const weightMap: Record = { normal: "pb-font-normal", medium: "pb-font-medium", semibold: "pb-font-semibold", bold: "pb-font-bold", }; const weightClass = fontWeightMode === "scale" && fontWeightScale && weightMap[fontWeightScale] ? weightMap[fontWeightScale] : ""; let colorClass = "pb-text-color-strong"; const inlineStyles: string[] = []; if (input.colorMode === "palette") { const palette: TextColorPalette = (input.colorPalette as TextColorPalette | null) ?? "default"; const paletteMap: Record = { default: "pb-text-color-default", muted: "pb-text-color-muted", strong: "pb-text-color-strong", accent: "pb-text-color-accent", danger: "pb-text-color-danger", success: "pb-text-color-success", warning: "pb-text-color-warning", info: "pb-text-color-info", neutral: "pb-text-color-neutral", }; colorClass = paletteMap[palette] ?? colorClass; } else if (input.colorMode === "custom" && input.colorCustom && input.colorCustom.trim() !== "") { inlineStyles.push(`color:${input.colorCustom.trim()}`); } if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") { inlineStyles.push(`background-color:${input.backgroundColorCustom.trim()}`); } let maxWidthClass = ""; const maxWidthMode = input.maxWidthMode ?? "scale"; const maxWidthScale = input.maxWidthScale ?? "none"; if (maxWidthMode === "scale") { if (maxWidthScale === "prose") { maxWidthClass = "pb-text-maxw-prose"; } else if (maxWidthScale === "narrow") { maxWidthClass = "pb-text-maxw-narrow"; } } const extraClasses: string[] = ["pb-whitespace-pre-wrap"]; if (input.underline) extraClasses.push("pb-underline"); if (input.strike) extraClasses.push("pb-line-through"); if (input.italic) extraClasses.push("pb-italic"); return { alignClass, sizeClass, leadingClass, weightClass, colorClass, maxWidthClass, extraClasses, inlineStyles, }; } export interface TextEditorTokens { alignClass: string; sizeClass: string; leadingClass: string; weightClass: string; colorClass: string; maxWidthClass: string; decorationClass: string; styleOverrides: CSSProperties; } export function computeTextEditorTokens(props: TextBlockProps): TextEditorTokens { let alignClass = ""; let sizeClass = ""; let leadingClass = ""; let weightClass = ""; let colorClass = ""; let maxWidthClass = ""; let decorationClass = ""; const styleOverrides: CSSProperties = {}; // 정렬: pb-text-* alignClass = props.align === "center" ? "pb-text-center" : props.align === "right" ? "pb-text-right" : "pb-text-left"; // 폰트 크기: scale/custom + 기존 size 값 fallback const fontSizeMode = props.fontSizeMode ?? "scale"; const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base"; const fontSizeScale = props.fontSizeScale ?? fallbackScale; // 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다. sizeClass = `pb-text-${fontSizeScale}`; // custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다. if (fontSizeMode === "custom" && props.fontSizeCustom && props.fontSizeCustom.trim() !== "") { styleOverrides.fontSize = props.fontSizeCustom; } // 줄 간격: scale/custom const lineHeightMode = props.lineHeightMode ?? "scale"; const lineHeightScale = props.lineHeightScale ?? "normal"; if (lineHeightMode === "scale") { leadingClass = `pb-leading-${lineHeightScale}`; } else if (lineHeightMode === "custom" && props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { styleOverrides.lineHeight = props.lineHeightCustom; } // 굵기: scale/custom const fontWeightMode = props.fontWeightMode ?? "scale"; const fontWeightScale = props.fontWeightScale ?? "normal"; if (fontWeightMode === "scale") { weightClass = `pb-font-${fontWeightScale}`; } else if (fontWeightMode === "custom" && props.fontWeightCustom && String(props.fontWeightCustom).trim() !== "") { styleOverrides.fontWeight = props.fontWeightCustom as CSSProperties["fontWeight"]; } // 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트 const colorPalette = props.colorPalette ?? "default"; if (props.colorCustom && props.colorCustom.trim() !== "") { styleOverrides.color = props.colorCustom.trim(); } else { colorClass = `pb-text-color-${colorPalette}`; } // 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다. if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") { styleOverrides.backgroundColor = props.backgroundColorCustom.trim(); } // 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋 const maxWidthScale = props.maxWidthScale ?? "none"; if (props.maxWidthCustom && props.maxWidthCustom.trim() !== "") { styleOverrides.maxWidth = props.maxWidthCustom.trim(); } else if (maxWidthScale === "prose") { maxWidthClass = "pb-text-maxw-prose"; } else if (maxWidthScale === "narrow") { maxWidthClass = "pb-text-maxw-narrow"; } // 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용 if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { styleOverrides.letterSpacing = props.letterSpacingCustom.trim(); } // 텍스트 장식: 밑줄/가운데줄/이탤릭 if (props.underline) { decorationClass += " pb-underline"; } if (props.strike) { decorationClass += " pb-line-through"; } if (props.italic) { decorationClass += " pb-italic"; } return { alignClass, sizeClass, leadingClass, weightClass, colorClass, maxWidthClass, decorationClass, styleOverrides, }; } export interface TextPublicTokens { alignClass: string; sizeClass: string; styleOverrides: 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 function computeTextPublicTokens(props: TextBlockProps): TextPublicTokens { const alignClass = props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left"; const fontSizeMode = props.fontSizeMode ?? "scale"; const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base"; const fontSizeScale = props.fontSizeScale ?? fallbackScale; const fontSizeScaleToClass: Record = { xs: "text-xs", sm: "text-sm", base: "text-base", lg: "text-lg", xl: "text-xl", "2xl": "text-2xl", "3xl": "text-3xl", }; let sizeClass = ""; const styleOverrides: CSSProperties = {}; if (fontSizeMode === "scale" && fontSizeScale && fontSizeScaleToClass[fontSizeScale]) { sizeClass = fontSizeScaleToClass[fontSizeScale]; } else if ( fontSizeMode === "custom" && props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ) { const fontSizeValue = props.fontSizeCustom.trim(); if (fontSizeValue.endsWith("px")) { const fontSizeEm = convertPxStringToEm(fontSizeValue); if (fontSizeEm) { styleOverrides.fontSize = fontSizeEm; } } else { styleOverrides.fontSize = fontSizeValue; } } if (props.colorCustom && props.colorCustom.trim() !== "") { styleOverrides.color = props.colorCustom.trim(); } if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") { styleOverrides.backgroundColor = props.backgroundColorCustom.trim(); } if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") { const lineHeightValue = props.lineHeightCustom.trim(); if (lineHeightValue.endsWith("px")) { const lineHeightEm = convertPxStringToEm(lineHeightValue); if (lineHeightEm) { styleOverrides.lineHeight = lineHeightEm; } } else { styleOverrides.lineHeight = lineHeightValue; } } if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") { const letterSpacingValue = props.letterSpacingCustom.trim(); if (letterSpacingValue.endsWith("px")) { const letterSpacingEm = convertPxStringToEm(letterSpacingValue); if (letterSpacingEm) { styleOverrides.letterSpacing = letterSpacingEm; } } else { styleOverrides.letterSpacing = letterSpacingValue; } } return { alignClass, sizeClass, styleOverrides, }; }