video 블록 및 리펙터링
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,12 @@ import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
|
||||
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
|
||||
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
|
||||
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
export type BlockType =
|
||||
| "text"
|
||||
| "button"
|
||||
| "image"
|
||||
| "video"
|
||||
| "section"
|
||||
| "divider"
|
||||
| "list"
|
||||
@@ -138,6 +139,34 @@ export interface ImageBlockProps {
|
||||
assetId?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoBlockProps {
|
||||
sourceUrl: string;
|
||||
sourceType?: "asset" | "externalUrl";
|
||||
assetId?: string | null;
|
||||
posterImageSrc?: string;
|
||||
posterSourceType?: "asset" | "externalUrl";
|
||||
posterAssetId?: string | null;
|
||||
platform?: "auto" | "youtube" | "vimeo" | "html5";
|
||||
align?: "left" | "center" | "right";
|
||||
widthMode?: "auto" | "full" | "fixed";
|
||||
widthPx?: number;
|
||||
aspectRatio?: "16:9" | "4:3" | "1:1";
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
controls?: boolean;
|
||||
titleText?: string;
|
||||
ariaLabel?: string;
|
||||
captionText?: string;
|
||||
// HTML5 비디오 재생 범위(초 단위)
|
||||
startTimeSec?: number;
|
||||
endTimeSec?: number;
|
||||
// 카드 스타일: 배경색/패딩/모서리 둥글기
|
||||
backgroundColorCustom?: string;
|
||||
cardPaddingPx?: number;
|
||||
borderRadiusPx?: number;
|
||||
}
|
||||
|
||||
// 구분선 블록 속성
|
||||
export interface DividerBlockProps {
|
||||
align: "left" | "center" | "right";
|
||||
@@ -494,6 +523,20 @@ export interface SectionBlockProps {
|
||||
gapXPx?: number;
|
||||
alignItems?: "top" | "center" | "bottom";
|
||||
backgroundColorCustom?: string;
|
||||
// 섹션 배경 이미지
|
||||
backgroundImageSrc?: string;
|
||||
backgroundImageSourceType?: "asset" | "externalUrl";
|
||||
backgroundImageAssetId?: string | null;
|
||||
backgroundImageSize?: "auto" | "cover" | "contain";
|
||||
backgroundImagePosition?: "center" | "top" | "bottom" | "left" | "right";
|
||||
backgroundImageRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y";
|
||||
backgroundImagePositionMode?: "preset" | "custom";
|
||||
backgroundImagePositionXPercent?: number;
|
||||
backgroundImagePositionYPercent?: number;
|
||||
// 섹션 배경 비디오
|
||||
backgroundVideoSrc?: string;
|
||||
backgroundVideoSourceType?: "asset" | "externalUrl";
|
||||
backgroundVideoAssetId?: string | null;
|
||||
}
|
||||
|
||||
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
||||
@@ -651,6 +694,7 @@ export interface Block {
|
||||
| TextBlockProps
|
||||
| ButtonBlockProps
|
||||
| ImageBlockProps
|
||||
| VideoBlockProps
|
||||
| SectionBlockProps
|
||||
| DividerBlockProps
|
||||
| ListBlockProps
|
||||
@@ -676,6 +720,7 @@ export interface EditorState {
|
||||
addTextBlock: () => void;
|
||||
addButtonBlock: () => void;
|
||||
addImageBlock: () => void;
|
||||
addVideoBlock: () => void;
|
||||
addDividerBlock: () => void;
|
||||
addListBlock: () => void;
|
||||
addSectionBlock: () => void;
|
||||
@@ -1182,6 +1227,52 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
}));
|
||||
},
|
||||
|
||||
// 비디오 블록 추가: 기본 소스/정렬/비율 설정과 함께 생성 후 선택 상태로 만든다
|
||||
addVideoBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId: string | null = null;
|
||||
let columnId: string | null = null;
|
||||
|
||||
if (selectedBlockId) {
|
||||
const target = blocks.find((b: Block) => b.id === selectedBlockId);
|
||||
if (target) {
|
||||
if (target.type === "section") {
|
||||
const sProps = target.props as SectionBlockProps;
|
||||
sectionId = target.id;
|
||||
columnId = sProps.columns?.[0]?.id ?? null;
|
||||
} else {
|
||||
sectionId = (target as any).sectionId ?? null;
|
||||
columnId = (target as any).columnId ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "video",
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
platform: "auto",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
muted: false,
|
||||
},
|
||||
sectionId,
|
||||
columnId,
|
||||
};
|
||||
|
||||
set((state: EditorState) => ({
|
||||
blocks: [...state.blocks, newBlock],
|
||||
selectedBlockId: id,
|
||||
}));
|
||||
},
|
||||
|
||||
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
|
||||
addFormInputBlock: () => {
|
||||
const id = createId();
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type ButtonAlign = "left" | "center" | "right" | null | undefined;
|
||||
export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl" | null | undefined;
|
||||
export type ButtonVariant = "solid" | "outline" | "ghost" | null | undefined;
|
||||
export type ButtonColorPalette = "primary" | "muted" | "danger" | "success" | "neutral" | null | undefined;
|
||||
export type ButtonRadius = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
||||
|
||||
export interface ButtonStyleInput {
|
||||
align?: ButtonAlign;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
colorPalette?: ButtonColorPalette;
|
||||
borderRadius?: ButtonRadius;
|
||||
fullWidth?: boolean | null | undefined;
|
||||
widthMode?: "auto" | "full" | "fixed" | null | undefined;
|
||||
widthPx?: number | null | undefined;
|
||||
paddingX?: number | null | undefined;
|
||||
paddingY?: number | null | undefined;
|
||||
fillColorCustom?: string | null | undefined;
|
||||
strokeColorCustom?: string | null | undefined;
|
||||
textColorCustom?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface ButtonPbTokens {
|
||||
alignClass: string;
|
||||
sizeClass: string;
|
||||
variantClass: string;
|
||||
radiusClass: string;
|
||||
inlineStyles: string[];
|
||||
}
|
||||
|
||||
export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
||||
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 sizeToken = (input.size as ButtonSize | null) ?? "md";
|
||||
const sizeMap: Record<Exclude<ButtonSize, null | undefined>, string> = {
|
||||
xs: "pb-btn-size-xs",
|
||||
sm: "pb-btn-size-sm",
|
||||
md: "pb-btn-size-md",
|
||||
lg: "pb-btn-size-lg",
|
||||
xl: "pb-btn-size-xl",
|
||||
};
|
||||
const sizeClass = sizeMap[sizeToken as Exclude<ButtonSize, null | undefined>] ?? "pb-btn-size-md";
|
||||
|
||||
const variant = (input.variant as ButtonVariant | null) ?? "solid";
|
||||
const palette = (input.colorPalette as ButtonColorPalette | null) ?? "primary";
|
||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
||||
|
||||
const radiusToken = (input.borderRadius as ButtonRadius | null) ?? "md";
|
||||
const radiusMap: Record<Exclude<ButtonRadius, null | undefined>, string> = {
|
||||
none: "pb-btn-radius-none",
|
||||
sm: "pb-btn-radius-sm",
|
||||
md: "pb-btn-radius-md",
|
||||
lg: "pb-btn-radius-lg",
|
||||
full: "pb-btn-radius-full",
|
||||
};
|
||||
const radiusClass = radiusMap[radiusToken as Exclude<ButtonRadius, null | undefined>] ?? "";
|
||||
|
||||
const inlineStyles: string[] = [];
|
||||
|
||||
const widthMode = input.widthMode ?? (input.fullWidth ? "full" : "auto");
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
inlineStyles.push(`width:${input.widthPx}px`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingX === "number") {
|
||||
inlineStyles.push(`padding-left:${input.paddingX}px`);
|
||||
inlineStyles.push(`padding-right:${input.paddingX}px`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingY === "number") {
|
||||
inlineStyles.push(`padding-top:${input.paddingY}px`);
|
||||
inlineStyles.push(`padding-bottom:${input.paddingY}px`);
|
||||
}
|
||||
|
||||
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`background-color:${input.fillColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.strokeColorCustom && input.strokeColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`border-color:${input.strokeColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.textColorCustom && input.textColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
variantClass,
|
||||
radiusClass,
|
||||
inlineStyles,
|
||||
};
|
||||
}
|
||||
|
||||
// 퍼블릭 렌더러용 버튼 스타일 토큰
|
||||
export interface ButtonPublicTokens {
|
||||
alignClass: string;
|
||||
widthClass: 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 computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublicTokens {
|
||||
const align = props.align ?? "left";
|
||||
const alignClass =
|
||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
||||
|
||||
const styleOverrides: CSSProperties = {};
|
||||
|
||||
// 폰트 크기: fontSizeCustom(px)는 em 으로 변환, 그 외는 그대로 사용
|
||||
const fontSizeEm = convertPxStringToEm(props.fontSizeCustom);
|
||||
if (fontSizeEm) {
|
||||
styleOverrides.fontSize = fontSizeEm;
|
||||
}
|
||||
|
||||
// lineHeightCustom 은 px 단위든 아니든 그대로 사용 (기존 PublicPageRenderer 로직 유지)
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
styleOverrides.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
|
||||
// letterSpacingCustom 은 px 인 경우만 em 으로 변환, 그 외는 그대로 사용
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacing = props.letterSpacingCustom.trim();
|
||||
if (letterSpacing.endsWith("px")) {
|
||||
const emValue = convertPxStringToEm(letterSpacing);
|
||||
if (emValue) {
|
||||
styleOverrides.letterSpacing = emValue;
|
||||
}
|
||||
} else {
|
||||
styleOverrides.letterSpacing = letterSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleOverrides.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
styleOverrides.paddingInline = pxToEm(props.paddingX);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
styleOverrides.paddingBlock = pxToEm(props.paddingY);
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
styleOverrides.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
styleOverrides.borderColor = props.strokeColorCustom;
|
||||
styleOverrides.borderWidth = "1px";
|
||||
styleOverrides.borderStyle = "solid";
|
||||
}
|
||||
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
styleOverrides.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 12
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
styleOverrides.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
||||
|
||||
const widthClass = widthMode === "full" ? "w-full" : "";
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
widthClass,
|
||||
styleOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ButtonEditorTokens {
|
||||
wrapperWidthClass: string;
|
||||
buttonStyle: CSSProperties;
|
||||
}
|
||||
|
||||
export function computeButtonEditorTokens(props: ButtonBlockProps): ButtonEditorTokens {
|
||||
const baseFullWidth = props.fullWidth ?? false;
|
||||
const widthMode = props.widthMode ?? (baseFullWidth ? "full" : "auto");
|
||||
const isFullWidth = widthMode === "full" || baseFullWidth;
|
||||
|
||||
const wrapperWidthClass = isFullWidth ? "w-full" : "inline-block";
|
||||
|
||||
const buttonStyle: CSSProperties = {};
|
||||
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
buttonStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
buttonStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
|
||||
buttonStyle.letterSpacing = props.letterSpacingCustom;
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
buttonStyle.backgroundColor = props.fillColorCustom;
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
buttonStyle.borderColor = props.strokeColorCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
buttonStyle.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
buttonStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
buttonStyle.paddingInline = `${props.paddingX}px`;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
buttonStyle.paddingBlock = `${props.paddingY}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
wrapperWidthClass,
|
||||
buttonStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface DividerEscapers {
|
||||
escapeAttr: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface DividerExportTokens {
|
||||
style: string;
|
||||
}
|
||||
|
||||
export const computeDividerExportTokens = (
|
||||
props: DividerBlockProps,
|
||||
escapers: DividerEscapers,
|
||||
): DividerExportTokens => {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const thickness = props.thickness === "medium" ? "2px" : "1px";
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const color = colorRaw !== "" ? colorRaw : "#475569";
|
||||
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
|
||||
|
||||
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginY}px 0;`;
|
||||
|
||||
return { style };
|
||||
};
|
||||
|
||||
export interface DividerEditorTokens {
|
||||
thicknessClass: string;
|
||||
innerWidthClass: string;
|
||||
widthPx?: number;
|
||||
borderColor: string;
|
||||
marginPx: number;
|
||||
}
|
||||
|
||||
export const computeDividerEditorTokens = (props: DividerBlockProps): DividerEditorTokens => {
|
||||
const thicknessClass = props.thickness === "medium" ? "border-t-2" : "border-t";
|
||||
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const borderColor = colorRaw !== "" ? colorRaw : "#475569";
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
let innerWidthClass = "w-full";
|
||||
let widthPx: number | undefined;
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
widthPx = props.widthPx;
|
||||
} else {
|
||||
widthPx = 320;
|
||||
}
|
||||
}
|
||||
|
||||
const marginPx =
|
||||
typeof props.marginYPx === "number"
|
||||
? props.marginYPx
|
||||
: props.marginY === "sm"
|
||||
? 8
|
||||
: props.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
|
||||
return {
|
||||
thicknessClass,
|
||||
innerWidthClass,
|
||||
widthPx,
|
||||
borderColor,
|
||||
marginPx,
|
||||
};
|
||||
};
|
||||
|
||||
export interface DividerPublicTokens {
|
||||
alignClass: string;
|
||||
thicknessClass: string;
|
||||
innerWidthClass: string;
|
||||
wrapperMarginEm: string;
|
||||
innerStyle: CSSProperties;
|
||||
lineStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export const computeDividerPublicTokens = (props: DividerBlockProps): DividerPublicTokens => {
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "right"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
const thicknessClass = props.thickness === "medium" ? "h-[2px]" : "h-px";
|
||||
|
||||
const marginPx =
|
||||
typeof props.marginYPx === "number"
|
||||
? props.marginYPx
|
||||
: props.marginY === "sm"
|
||||
? 8
|
||||
: props.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
const wrapperMarginEm = pxToEm(marginPx);
|
||||
|
||||
const colorRaw = typeof props.colorHex === "string" ? props.colorHex.trim() : "";
|
||||
const backgroundColor = colorRaw !== "" ? colorRaw : "#475569";
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
const innerStyle: CSSProperties = {};
|
||||
let innerWidthClass = "w-full";
|
||||
const lineStyle: CSSProperties = {
|
||||
backgroundColor,
|
||||
};
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
innerStyle.width = "auto";
|
||||
|
||||
if (typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
lineStyle.width = pxToEm(props.widthPx);
|
||||
} else {
|
||||
lineStyle.width = pxToEm(320);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
thicknessClass,
|
||||
innerWidthClass,
|
||||
wrapperMarginEm,
|
||||
innerStyle,
|
||||
lineStyle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,934 @@
|
||||
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 px = props.paddingX;
|
||||
inputStyleParts.push(`padding-left:${px}px`);
|
||||
inputStyleParts.push(`padding-right:${px}px`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
inputStyleParts.push(`padding-top:${py}px`);
|
||||
inputStyleParts.push(`padding-bottom:${py}px`);
|
||||
}
|
||||
|
||||
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
inputStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
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) {
|
||||
selectStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
const px = props.paddingX;
|
||||
selectStyleParts.push(`padding-left:${px}px`);
|
||||
selectStyleParts.push(`padding-right:${px}px`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
selectStyleParts.push(`padding-top:${py}px`);
|
||||
selectStyleParts.push(`padding-bottom:${py}px`);
|
||||
}
|
||||
|
||||
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 px = props.paddingX;
|
||||
optionStyleParts.push(`padding-left:${px}px`);
|
||||
optionStyleParts.push(`padding-right:${px}px`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const py = props.paddingY;
|
||||
optionStyleParts.push(`padding-top:${py}px`);
|
||||
optionStyleParts.push(`padding-bottom:${py}px`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const widthMode = props.formWidthMode ?? "auto";
|
||||
const formStyle: CSSProperties = {};
|
||||
const formClassNames = ["space-y-3"];
|
||||
|
||||
if (widthMode === "full") {
|
||||
formClassNames.push("w-full");
|
||||
}
|
||||
if (widthMode === "fixed" && typeof props.formWidthPx === "number" && props.formWidthPx > 0) {
|
||||
formStyle.width = pxToEm(props.formWidthPx);
|
||||
}
|
||||
|
||||
if (typeof props.marginYPx === "number") {
|
||||
const marginEm = pxToEm(props.marginYPx);
|
||||
formStyle.marginTop = marginEm;
|
||||
formStyle.marginBottom = marginEm;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const formStyleParts: string[] = [];
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasControllerFields: controllerFields.length > 0,
|
||||
controllerFields,
|
||||
fallbackFields,
|
||||
formStyleParts,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type ImageWidthMode = "auto" | "full" | "fixed" | null | undefined;
|
||||
export type ImageBorderRadiusToken = "none" | "sm" | "md" | "lg" | "full" | null | undefined;
|
||||
|
||||
export interface ImageExportStyleInput {
|
||||
backgroundColorCustom?: string | null | undefined;
|
||||
widthMode?: ImageWidthMode;
|
||||
widthPx?: number | null | undefined;
|
||||
borderRadius?: ImageBorderRadiusToken;
|
||||
borderRadiusPx?: number | null | undefined;
|
||||
}
|
||||
|
||||
export interface ImageExportStyles {
|
||||
wrapperStyleParts: string[];
|
||||
imgStyleParts: string[];
|
||||
}
|
||||
|
||||
export function computeImageExportStyles(input: ImageExportStyleInput): ImageExportStyles {
|
||||
const wrapperStyleParts: string[] = [];
|
||||
const imgStyleParts: string[] = [];
|
||||
|
||||
if (input.backgroundColorCustom && input.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyleParts.push(`background-color:${input.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
imgStyleParts.push(`width:${input.widthPx}px`);
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
||||
? input.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
if (typeof radiusPx === "number" && radiusPx >= 0) {
|
||||
imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`);
|
||||
}
|
||||
|
||||
return {
|
||||
wrapperStyleParts,
|
||||
imgStyleParts,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageEditorTokens {
|
||||
widthPx?: number;
|
||||
radiusPx: number;
|
||||
}
|
||||
|
||||
export function computeImageEditorTokens(input: ImageExportStyleInput): ImageEditorTokens {
|
||||
const widthMode: ImageWidthMode = input.widthMode ?? "auto";
|
||||
let widthPx: number | undefined;
|
||||
|
||||
if (widthMode === "fixed" && typeof input.widthPx === "number" && input.widthPx > 0) {
|
||||
widthPx = input.widthPx;
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = input.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof input.borderRadiusPx === "number" && input.borderRadiusPx >= 0
|
||||
? input.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
return {
|
||||
widthPx,
|
||||
radiusPx,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImagePublicTokens {
|
||||
alignClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
imageStyle: CSSProperties;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeImagePublicTokens(props: ImageBlockProps): ImagePublicTokens {
|
||||
const alignClass =
|
||||
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
const widthMode: ImageWidthMode = props.widthMode ?? "auto";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const imageStyle: CSSProperties = {
|
||||
display: "block",
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
imageStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
const radiusToken: ImageBorderRadiusToken = props.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
|
||||
const radiusPx =
|
||||
typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0
|
||||
? props.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
|
||||
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
wrapperStyle,
|
||||
imageStyle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface ListExportTokens {
|
||||
Tag: "ul" | "ol";
|
||||
items: string[];
|
||||
listStyleParts: string[];
|
||||
}
|
||||
|
||||
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
|
||||
const ordered = Boolean(props.ordered);
|
||||
const Tag: "ul" | "ol" = ordered ? "ol" : "ul";
|
||||
|
||||
const items: string[] = [];
|
||||
|
||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
||||
const walk = (nodes: { text?: string; children?: any[] }[]) => {
|
||||
for (const node of nodes) {
|
||||
if (typeof node.text === "string" && node.text.trim() !== "") {
|
||||
items.push(node.text);
|
||||
}
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(props.itemsTree as any);
|
||||
} else if (Array.isArray(props.items)) {
|
||||
for (const raw of props.items) {
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
items.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alignToken = props.align;
|
||||
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
|
||||
|
||||
const listStyleParts: string[] = [`text-align:${align}`];
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
Tag,
|
||||
items,
|
||||
listStyleParts,
|
||||
};
|
||||
};
|
||||
|
||||
export interface ListEditorTokens {
|
||||
gapPx: number;
|
||||
listStyle: CSSProperties;
|
||||
bulletStyle: string;
|
||||
}
|
||||
|
||||
export const computeListEditorTokens = (props: ListBlockProps): ListEditorTokens => {
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
|
||||
const listStyle: CSSProperties = {};
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
listStyle.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
}
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
return {
|
||||
gapPx,
|
||||
listStyle,
|
||||
bulletStyle,
|
||||
};
|
||||
};
|
||||
|
||||
export interface ListPublicTokens {
|
||||
alignClass: string;
|
||||
gapEm: number;
|
||||
listStyle: CSSProperties;
|
||||
bulletStyle: string;
|
||||
}
|
||||
|
||||
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 computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
|
||||
const gapEm = gapPx / 16;
|
||||
|
||||
const listStyle: CSSProperties = {};
|
||||
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = props.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
listStyle.fontSize = fontSizeEm;
|
||||
}
|
||||
} else {
|
||||
listStyle.fontSize = fontSizeValue;
|
||||
}
|
||||
}
|
||||
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = props.lineHeightCustom;
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
listStyle.color = props.textColorCustom;
|
||||
}
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
gapEm,
|
||||
listStyle,
|
||||
bulletStyle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export interface SectionExportTokens {
|
||||
sectionClasses: string;
|
||||
sectionStyleParts: string[];
|
||||
backgroundVideoHtml: string;
|
||||
}
|
||||
|
||||
export function computeSectionExportTokens(props: SectionBlockProps): SectionExportTokens {
|
||||
const bgToken = props.background ?? "default";
|
||||
const bgClassMap: Record<SectionBlockProps["background"], string> = {
|
||||
default: "pb-section-bg-default",
|
||||
muted: "pb-section-bg-muted",
|
||||
primary: "pb-section-bg-primary",
|
||||
};
|
||||
const bgClass = bgClassMap[bgToken] ?? "pb-section-bg-default";
|
||||
|
||||
const pyToken = props.paddingY ?? "md";
|
||||
const pyClassMap: Record<SectionBlockProps["paddingY"], string> = {
|
||||
sm: "pb-section-py-sm",
|
||||
md: "pb-section-py-md",
|
||||
lg: "pb-section-py-lg",
|
||||
};
|
||||
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
const sectionStyleParts: string[] = [];
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
const bgImgSrc = props.backgroundImageSrc;
|
||||
if (typeof bgImgSrc === "string" && bgImgSrc.trim() !== "") {
|
||||
const url = bgImgSrc.trim();
|
||||
sectionStyleParts.push(`background-image:url(${url})`);
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
sectionStyleParts.push(`background-size:${size}`);
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (posMode === "custom") {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
const xSafe = typeof x === "number" ? x : 50;
|
||||
const ySafe = typeof y === "number" ? y : 50;
|
||||
sectionStyleParts.push(`background-position:${xSafe}% ${ySafe}%`);
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
sectionStyleParts.push(`background-position:${position}`);
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
sectionStyleParts.push(`background-repeat:${repeat}`);
|
||||
}
|
||||
|
||||
const bgVideoSrc = props.backgroundVideoSrc;
|
||||
let backgroundVideoHtml = "";
|
||||
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
|
||||
const url = bgVideoSrc.trim();
|
||||
backgroundVideoHtml = `<video class="pb-section-bg-video" src="${url}" autoplay loop muted playsinline></video>`;
|
||||
sectionStyleParts.push("position:relative");
|
||||
sectionStyleParts.push("overflow:hidden");
|
||||
}
|
||||
|
||||
return {
|
||||
sectionClasses,
|
||||
sectionStyleParts,
|
||||
backgroundVideoHtml,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SectionPublicTokens {
|
||||
sectionStyle: CSSProperties;
|
||||
innerWrapperStyle: CSSProperties;
|
||||
columnsContainerStyle: CSSProperties;
|
||||
hasBackgroundVideo: boolean;
|
||||
backgroundVideoSrc: string | null;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
|
||||
const sectionStyle: CSSProperties = {};
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
const paddingEm = pxToEm(props.paddingYPx);
|
||||
sectionStyle.paddingTop = paddingEm;
|
||||
sectionStyle.paddingBottom = paddingEm;
|
||||
}
|
||||
|
||||
const rawBgVideoSrc = (props as any).backgroundVideoSrc;
|
||||
let hasBackgroundVideo = false;
|
||||
let backgroundVideoSrc: string | null = null;
|
||||
if (typeof rawBgVideoSrc === "string" && rawBgVideoSrc.trim() !== "") {
|
||||
hasBackgroundVideo = true;
|
||||
backgroundVideoSrc = rawBgVideoSrc.trim();
|
||||
sectionStyle.position = "relative";
|
||||
sectionStyle.overflow = "hidden";
|
||||
}
|
||||
|
||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
||||
const url = props.backgroundImageSrc.trim();
|
||||
sectionStyle.backgroundImage = `url(${url})`;
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
sectionStyle.backgroundSize = size;
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (
|
||||
posMode === "custom" &&
|
||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
||||
typeof props.backgroundImagePositionYPercent === "number"
|
||||
) {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
sectionStyle.backgroundPosition = `${x}% ${y}%`;
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
sectionStyle.backgroundPosition = position;
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
sectionStyle.backgroundRepeat = repeat;
|
||||
}
|
||||
|
||||
const innerWrapperStyle: CSSProperties = {};
|
||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
||||
innerWrapperStyle.maxWidth = pxToEm(props.maxWidthPx);
|
||||
}
|
||||
|
||||
const columnsContainerStyle: CSSProperties = {};
|
||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
||||
columnsContainerStyle.columnGap = pxToEm(props.gapXPx);
|
||||
}
|
||||
|
||||
return {
|
||||
sectionStyle,
|
||||
innerWrapperStyle,
|
||||
columnsContainerStyle,
|
||||
hasBackgroundVideo,
|
||||
backgroundVideoSrc,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SectionEditorTokens {
|
||||
bgClass: string;
|
||||
pyClass: string;
|
||||
alignItemsClass: string;
|
||||
wrapperStyle: CSSProperties;
|
||||
innerWrapperStyle: CSSProperties;
|
||||
columnsContainerStyle: CSSProperties;
|
||||
hasBackgroundVideo: boolean;
|
||||
backgroundVideoSrc: string | null;
|
||||
}
|
||||
|
||||
export function computeSectionEditorTokens(props: SectionBlockProps): SectionEditorTokens {
|
||||
const bgClass =
|
||||
props.background === "muted"
|
||||
? "bg-slate-950/40"
|
||||
: props.background === "primary"
|
||||
? "bg-sky-950/40 border-sky-900/60"
|
||||
: "bg-slate-900/60";
|
||||
|
||||
const pyClass =
|
||||
props.paddingY === "sm"
|
||||
? "py-4"
|
||||
: props.paddingY === "lg"
|
||||
? "py-10"
|
||||
: "py-6";
|
||||
|
||||
const alignItemsClass =
|
||||
props.alignItems === "center"
|
||||
? "items-center"
|
||||
: props.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom;
|
||||
}
|
||||
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
wrapperStyle.paddingTop = `${props.paddingYPx}px`;
|
||||
wrapperStyle.paddingBottom = `${props.paddingYPx}px`;
|
||||
}
|
||||
|
||||
let hasBackgroundVideo = false;
|
||||
let backgroundVideoSrc: string | null = null;
|
||||
if (typeof props.backgroundVideoSrc === "string" && props.backgroundVideoSrc.trim() !== "") {
|
||||
hasBackgroundVideo = true;
|
||||
backgroundVideoSrc = props.backgroundVideoSrc.trim();
|
||||
wrapperStyle.position = "relative";
|
||||
wrapperStyle.overflow = "hidden";
|
||||
}
|
||||
|
||||
if (props.backgroundImageSrc && props.backgroundImageSrc.trim() !== "") {
|
||||
const url = props.backgroundImageSrc.trim();
|
||||
wrapperStyle.backgroundImage = `url(${url})`;
|
||||
|
||||
const size = props.backgroundImageSize ?? "cover";
|
||||
wrapperStyle.backgroundSize = size;
|
||||
|
||||
const posMode = props.backgroundImagePositionMode ?? "preset";
|
||||
if (
|
||||
posMode === "custom" &&
|
||||
typeof props.backgroundImagePositionXPercent === "number" &&
|
||||
typeof props.backgroundImagePositionYPercent === "number"
|
||||
) {
|
||||
const x = props.backgroundImagePositionXPercent;
|
||||
const y = props.backgroundImagePositionYPercent;
|
||||
wrapperStyle.backgroundPosition = `${x}% ${y}%`;
|
||||
} else {
|
||||
const posToken = props.backgroundImagePosition ?? "center";
|
||||
const position =
|
||||
posToken === "top"
|
||||
? "center top"
|
||||
: posToken === "bottom"
|
||||
? "center bottom"
|
||||
: posToken === "left"
|
||||
? "left center"
|
||||
: posToken === "right"
|
||||
? "right center"
|
||||
: "center center";
|
||||
wrapperStyle.backgroundPosition = position;
|
||||
}
|
||||
|
||||
const repeat = props.backgroundImageRepeat ?? "no-repeat";
|
||||
wrapperStyle.backgroundRepeat = repeat;
|
||||
}
|
||||
|
||||
const innerWrapperStyle: CSSProperties = {};
|
||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
||||
innerWrapperStyle.maxWidth = `${props.maxWidthPx}px`;
|
||||
}
|
||||
|
||||
const columnsContainerStyle: CSSProperties = {};
|
||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
||||
columnsContainerStyle.columnGap = `${props.gapXPx}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
bgClass,
|
||||
pyClass,
|
||||
alignItemsClass,
|
||||
wrapperStyle,
|
||||
innerWrapperStyle,
|
||||
columnsContainerStyle,
|
||||
hasBackgroundVideo,
|
||||
backgroundVideoSrc,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
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 =
|
||||
fontSizeMode === "scale" && 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;
|
||||
}
|
||||
|
||||
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() !== ""
|
||||
) {
|
||||
styleOverrides.fontSize = props.fontSizeCustom;
|
||||
}
|
||||
|
||||
if (props.colorCustom && props.colorCustom.trim() !== "") {
|
||||
styleOverrides.color = props.colorCustom.trim();
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
styleOverrides.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
styleOverrides,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export type VideoPlatform = "youtube" | "vimeo" | "html5";
|
||||
|
||||
export type VideoPlatformInput = VideoPlatform | "auto" | null | undefined;
|
||||
|
||||
// 비디오 sourceUrl 을 정규화한다: null/undefined 를 빈 문자열로 만들고 양 끝 공백을 제거한다.
|
||||
export function normalizeVideoSourceUrl(source: string | null | undefined): string {
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return source.trim();
|
||||
}
|
||||
|
||||
// platform 값과 URL 을 기반으로 최종 비디오 플랫폼을 결정한다.
|
||||
// - platform 이 auto 가 아니면 해당 값을 우선 사용한다.
|
||||
// - auto 인 경우 URL 을 검사해 youtube/vimeo/html5 를 판별한다.
|
||||
export function resolveVideoPlatform(rawUrl: string, platform: VideoPlatformInput): VideoPlatform {
|
||||
if (platform && platform !== "auto") {
|
||||
return platform;
|
||||
}
|
||||
|
||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
||||
const lower = normalized.toLowerCase();
|
||||
|
||||
if (lower.includes("youtube.com") || lower.includes("youtu.be")) {
|
||||
return "youtube";
|
||||
}
|
||||
|
||||
if (lower.includes("vimeo.com")) {
|
||||
return "vimeo";
|
||||
}
|
||||
|
||||
return "html5";
|
||||
}
|
||||
|
||||
export type BuildEmbedUrlOptions = {
|
||||
enableVimeoEmbed?: boolean;
|
||||
};
|
||||
|
||||
// 플랫폼과 URL 을 기반으로 최종 embed URL 을 생성한다.
|
||||
// - YouTube: 항상 watch/short/youtu.be 형태에서 embed URL 로 변환.
|
||||
// - Vimeo: enableVimeoEmbed 가 true 인 경우에만 player.vimeo.com 기반 embed URL 로 변환.
|
||||
// - HTML5: 항상 정규화된 원본 URL 을 그대로 사용.
|
||||
export function buildVideoEmbedUrl(
|
||||
rawUrl: string,
|
||||
platform: VideoPlatform,
|
||||
options?: BuildEmbedUrlOptions,
|
||||
): string {
|
||||
const normalized = normalizeVideoSourceUrl(rawUrl);
|
||||
|
||||
if (platform === "youtube") {
|
||||
const idMatch =
|
||||
normalized.match(/[?&]v=([^&]+)/) ||
|
||||
normalized.match(/youtu\.be\/([^?&]+)/) ||
|
||||
normalized.match(/youtube\.com\/shorts\/([^?&]+)/);
|
||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (platform === "vimeo" && options?.enableVimeoEmbed) {
|
||||
const idMatch = normalized.match(/vimeo\.com\/(\d+)/);
|
||||
const videoId = idMatch && idMatch[1] ? idMatch[1] : "";
|
||||
if (videoId) {
|
||||
return `https://player.vimeo.com/video/${videoId}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface VideoExportEscapers {
|
||||
escapeAttr: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface VideoExportTokens {
|
||||
wrapperStyleParts: string[];
|
||||
videoStyleParts: string[];
|
||||
outerStyleParts: string[];
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
export function computeVideoExportTokens(
|
||||
props: VideoBlockProps,
|
||||
escapers: VideoExportEscapers,
|
||||
): VideoExportTokens {
|
||||
const { escapeAttr } = escapers;
|
||||
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyleParts: string[] = [];
|
||||
const videoStyleParts: string[] = [];
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
wrapperStyleParts.push(`width:${props.widthPx}px`);
|
||||
videoStyleParts.push(`width:${props.widthPx}px`);
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyleParts.push("width:100%");
|
||||
videoStyleParts.push("width:100%");
|
||||
}
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
||||
wrapperStyleParts.push(`padding:${props.cardPaddingPx}px`);
|
||||
}
|
||||
|
||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
||||
wrapperStyleParts.push(`border-radius:${props.borderRadiusPx}px`);
|
||||
}
|
||||
|
||||
const align =
|
||||
props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center";
|
||||
const outerStyleParts = ["display:flex", `justify-content:${align}`];
|
||||
|
||||
return {
|
||||
wrapperStyleParts,
|
||||
videoStyleParts,
|
||||
outerStyleParts,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoPublicTokens {
|
||||
wrapperStyle: CSSProperties;
|
||||
videoStyle: CSSProperties;
|
||||
justifyClass: string;
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeVideoPublicTokens(props: VideoBlockProps): VideoPublicTokens {
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const videoStyle: CSSProperties = {};
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
const widthEm = pxToEm(props.widthPx);
|
||||
wrapperStyle.width = widthEm;
|
||||
videoStyle.width = widthEm;
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyle.width = "100%";
|
||||
videoStyle.width = "100%";
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
if (typeof (props as any).cardPaddingPx === "number" && (props as any).cardPaddingPx >= 0) {
|
||||
wrapperStyle.padding = pxToEm((props as any).cardPaddingPx as number);
|
||||
}
|
||||
|
||||
if (typeof (props as any).borderRadiusPx === "number" && (props as any).borderRadiusPx >= 0) {
|
||||
wrapperStyle.borderRadius = pxToEm((props as any).borderRadiusPx as number);
|
||||
}
|
||||
|
||||
const align = props.align ?? "center";
|
||||
const justifyClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
return {
|
||||
wrapperStyle,
|
||||
videoStyle,
|
||||
justifyClass,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
|
||||
export interface VideoEditorTokens {
|
||||
wrapperStyle: CSSProperties;
|
||||
videoStyle: CSSProperties;
|
||||
justifyClass: string;
|
||||
aspectClass: string;
|
||||
}
|
||||
|
||||
export function computeVideoEditorTokens(props: VideoBlockProps): VideoEditorTokens {
|
||||
const aspect = props.aspectRatio ?? "16:9";
|
||||
const aspectClass =
|
||||
aspect === "4:3" ? " pb-video-wrapper--4by3" : aspect === "1:1" ? " pb-video-wrapper--1by1" : "";
|
||||
|
||||
const wrapperStyle: CSSProperties = {};
|
||||
const videoStyle: CSSProperties = {};
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
wrapperStyle.width = `${props.widthPx}px`;
|
||||
videoStyle.width = `${props.widthPx}px`;
|
||||
} else if (widthMode === "full") {
|
||||
wrapperStyle.width = "100%";
|
||||
videoStyle.width = "100%";
|
||||
}
|
||||
|
||||
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
if (typeof props.cardPaddingPx === "number" && props.cardPaddingPx >= 0) {
|
||||
wrapperStyle.padding = `${props.cardPaddingPx}px`;
|
||||
}
|
||||
if (typeof props.borderRadiusPx === "number" && props.borderRadiusPx >= 0) {
|
||||
wrapperStyle.borderRadius = `${props.borderRadiusPx}px`;
|
||||
}
|
||||
|
||||
const align = props.align ?? "center";
|
||||
const justifyClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
|
||||
return {
|
||||
wrapperStyle,
|
||||
videoStyle,
|
||||
justifyClass,
|
||||
aspectClass,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user