372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
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<TextFontSizeScale, string> = {
|
|
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<TextLineHeightScale, string> = {
|
|
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<TextFontWeightScale, string> = {
|
|
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<TextColorPalette, string> = {
|
|
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<string, string> = {
|
|
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,
|
|
};
|
|
}
|