Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41e4238290 | |||
| e179035fbc | |||
| e1c91b1668 | |||
| 860eff514a | |||
| e16f8298ab | |||
| 48e13d2a75 |
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
// /api/export/preview 라우트는 Export 결과 HTML 을 ZIP 없이 바로 반환하는
|
||||
// 경량 프리뷰용 엔드포인트이다. 에디터에서 Export 미리보기 UX 를 제공하기 위해 사용한다.
|
||||
|
||||
type PreviewRequestBody = {
|
||||
blocks: Block[];
|
||||
projectConfig?: ProjectConfig;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: PreviewRequestBody;
|
||||
|
||||
try {
|
||||
// JSON 본문을 파싱해서 blocks + projectConfig 를 추출한다.
|
||||
body = (await request.json()) as PreviewRequestBody;
|
||||
} catch {
|
||||
// 잘못된 JSON 요청에 대해서는 400 에러를 반환한다.
|
||||
return NextResponse.json({ message: "잘못된 JSON 요청입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
const blocks = Array.isArray(body.blocks) ? body.blocks : [];
|
||||
const projectConfig = body.projectConfig;
|
||||
|
||||
// Export 본문 HTML 은 기존 buildStaticHtml 을 재사용해서 생성한다.
|
||||
// 이렇게 하면 /api/export ZIP 라우트와 동일한 정적 HTML 을 얻을 수 있다.
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// Export 미리보기는 ZIP 이 아니라 순수 HTML 문자열을 반환한다.
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
},
|
||||
});
|
||||
}
|
||||
+278
-491
@@ -10,7 +10,28 @@ import type {
|
||||
TextBlockProps,
|
||||
ButtonBlockProps,
|
||||
SectionBlockProps,
|
||||
VideoBlockProps,
|
||||
ListBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoExportTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeTextPbTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeImageExportStyles } from "@/features/editor/utils/imageHelpers";
|
||||
import { computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import { computeListExportTokens } from "@/features/editor/utils/listHelpers";
|
||||
import {
|
||||
computeFormInputExportTokens,
|
||||
computeFormSelectExportTokens,
|
||||
computeFormCheckboxExportTokens,
|
||||
computeFormRadioExportTokens,
|
||||
computeFormBlockExportTokens,
|
||||
} from "@/features/editor/utils/formHelpers";
|
||||
import { computeDividerExportTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
|
||||
type ExportRequestBody = {
|
||||
blocks: Block[];
|
||||
@@ -31,7 +52,9 @@ const escapeHtml = (value: string): string =>
|
||||
const escapeAttr = (value: string): string => escapeHtml(value);
|
||||
|
||||
export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig): string => {
|
||||
const pageTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
||||
const baseTitleRaw = (projectConfig?.title ?? "").trim() || "Page Builder Export";
|
||||
const seoTitleRaw = (projectConfig?.seoTitle ?? "").trim();
|
||||
const pageTitleRaw = seoTitleRaw || baseTitleRaw;
|
||||
const pageTitle = escapeHtml(pageTitleRaw);
|
||||
|
||||
const headExtraRaw = (projectConfig?.headHtml ?? "").trim();
|
||||
@@ -63,6 +86,77 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
|
||||
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
|
||||
|
||||
const seoDescriptionRaw = (projectConfig?.seoDescription ?? "").trim();
|
||||
const seoOgImageRaw = (projectConfig?.seoOgImageUrl ?? "").trim();
|
||||
const seoCanonicalRaw = (projectConfig?.seoCanonicalUrl ?? "").trim();
|
||||
const seoNoIndex = projectConfig?.seoNoIndex === true;
|
||||
|
||||
const seoHeadParts: string[] = [];
|
||||
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (pageTitleRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
seoHeadParts.push(` <meta property="og:type" content="website" />`);
|
||||
|
||||
if (seoOgImageRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoCanonicalRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta property="og:url" content="${escapeAttr(seoCanonicalRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
// Twitter 카드 메타 태그
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:card" content="summary_large_image" />`,
|
||||
);
|
||||
if (pageTitleRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:title" content="${escapeAttr(pageTitleRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoDescriptionRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:description" content="${escapeAttr(seoDescriptionRaw)}" />`,
|
||||
);
|
||||
}
|
||||
if (seoOgImageRaw) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="twitter:image" content="${escapeAttr(seoOgImageRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoCanonicalRaw) {
|
||||
seoHeadParts.push(
|
||||
` <link rel="canonical" href="${escapeAttr(seoCanonicalRaw)}" />`,
|
||||
);
|
||||
}
|
||||
|
||||
if (seoNoIndex) {
|
||||
seoHeadParts.push(
|
||||
` <meta name="robots" content="noindex, nofollow" />`,
|
||||
);
|
||||
}
|
||||
|
||||
const seoHeadHtml = seoHeadParts.length > 0 ? `\n${seoHeadParts.join("\n")}` : "";
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
@@ -71,114 +165,43 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const props = (block.props ?? {}) as TextBlockProps;
|
||||
const text = typeof props.text === "string" ? props.text : "";
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
|
||||
const fontSizeMode = props.fontSizeMode ?? "scale";
|
||||
const fallbackScale = props.size === "sm" ? "sm" : props.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = props.fontSizeScale ?? fallbackScale;
|
||||
const fontSizeMap: Record<NonNullable<TextBlockProps["fontSizeScale"]>, 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 = props.lineHeightMode ?? "scale";
|
||||
const lineHeightScale = props.lineHeightScale ?? "normal";
|
||||
const leadingMap: Record<NonNullable<TextBlockProps["lineHeightScale"]>, 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 = props.fontWeightMode ?? "scale";
|
||||
const fontWeightScale = props.fontWeightScale ?? "normal";
|
||||
const weightMap: Record<NonNullable<TextBlockProps["fontWeightScale"]>, 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 (props.colorMode === "palette") {
|
||||
const palette = props.colorPalette ?? "default";
|
||||
const paletteMap: Record<NonNullable<TextBlockProps["colorPalette"]>, 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 (
|
||||
props.colorMode === "custom" &&
|
||||
typeof props.colorCustom === "string" &&
|
||||
props.colorCustom.trim() !== ""
|
||||
) {
|
||||
inlineStyles.push(`color:${props.colorCustom.trim()}`);
|
||||
}
|
||||
|
||||
// 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
|
||||
if (typeof (props as any).backgroundColorCustom === "string" && (props as any).backgroundColorCustom.trim() !== "") {
|
||||
inlineStyles.push(`background-color:${(props as any).backgroundColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
let maxWidthClass = "";
|
||||
const maxWidthMode = props.maxWidthMode ?? "scale";
|
||||
const maxWidthScale = props.maxWidthScale ?? "none";
|
||||
if (maxWidthMode === "scale") {
|
||||
if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
}
|
||||
|
||||
const decoClasses: string[] = [];
|
||||
if (props.underline) decoClasses.push("pb-underline");
|
||||
if (props.strike) decoClasses.push("pb-line-through");
|
||||
if (props.italic) decoClasses.push("pb-italic");
|
||||
const tokens = computeTextPbTokens({
|
||||
text,
|
||||
align: props.align ?? "left",
|
||||
size: props.size ?? "base",
|
||||
fontSizeMode: props.fontSizeMode ?? "scale",
|
||||
fontSizeScale: props.fontSizeScale ?? undefined,
|
||||
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
||||
lineHeightMode: props.lineHeightMode ?? "scale",
|
||||
lineHeightScale: props.lineHeightScale ?? undefined,
|
||||
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
||||
fontWeightMode: props.fontWeightMode ?? "scale",
|
||||
fontWeightScale: props.fontWeightScale ?? undefined,
|
||||
fontWeightCustom: props.fontWeightCustom ?? undefined,
|
||||
colorMode: props.colorMode ?? undefined,
|
||||
colorPalette: props.colorPalette ?? undefined,
|
||||
colorCustom: props.colorCustom ?? undefined,
|
||||
backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
|
||||
maxWidthMode: props.maxWidthMode ?? "scale",
|
||||
maxWidthScale: props.maxWidthScale ?? undefined,
|
||||
underline: props.underline ?? false,
|
||||
strike: props.strike ?? false,
|
||||
italic: props.italic ?? false,
|
||||
});
|
||||
|
||||
const classes = [
|
||||
alignClass,
|
||||
sizeClass,
|
||||
leadingClass,
|
||||
weightClass,
|
||||
colorClass,
|
||||
maxWidthClass,
|
||||
"pb-whitespace-pre-wrap",
|
||||
...decoClasses,
|
||||
tokens.alignClass,
|
||||
tokens.sizeClass,
|
||||
tokens.leadingClass,
|
||||
tokens.weightClass,
|
||||
tokens.colorClass,
|
||||
tokens.maxWidthClass,
|
||||
...tokens.extraClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join(";")}"` : "";
|
||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
||||
|
||||
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
|
||||
}
|
||||
@@ -188,140 +211,128 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const href = typeof props.href === "string" ? props.href : "#";
|
||||
const label = typeof props.label === "string" ? props.label : "";
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
const tokens = computeButtonPbTokens({
|
||||
align: props.align ?? "left",
|
||||
size: props.size ?? "md",
|
||||
variant: props.variant ?? "solid",
|
||||
colorPalette: props.colorPalette ?? "primary",
|
||||
borderRadius: props.borderRadius ?? "md",
|
||||
fullWidth: props.fullWidth ?? false,
|
||||
widthMode: props.widthMode ?? undefined,
|
||||
widthPx: props.widthPx ?? undefined,
|
||||
paddingX: props.paddingX ?? undefined,
|
||||
paddingY: props.paddingY ?? undefined,
|
||||
fillColorCustom: props.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
||||
textColorCustom: props.textColorCustom ?? undefined,
|
||||
});
|
||||
|
||||
const sizeToken = props.size ?? "md";
|
||||
const sizeMap: Record<NonNullable<ButtonBlockProps["size"]>, 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] ?? "pb-btn-size-md";
|
||||
const styleAttr = tokens.inlineStyles.length > 0 ? ` style="${tokens.inlineStyles.join(";")}"` : "";
|
||||
|
||||
const variant = props.variant ?? "solid";
|
||||
const palette = props.colorPalette ?? "primary";
|
||||
const variantClass = `pb-btn-variant-${variant}-${palette}`;
|
||||
|
||||
const radiusToken = props.borderRadius ?? "md";
|
||||
const radiusMap: Record<NonNullable<ButtonBlockProps["borderRadius"]>, 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] ?? "";
|
||||
|
||||
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
|
||||
|
||||
const styleParts: string[] = [];
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
if (typeof props.paddingX === "number") {
|
||||
styleParts.push(`padding-left:${props.paddingX}px`);
|
||||
styleParts.push(`padding-right:${props.paddingX}px`);
|
||||
}
|
||||
if (typeof props.paddingY === "number") {
|
||||
styleParts.push(`padding-top:${props.paddingY}px`);
|
||||
styleParts.push(`padding-bottom:${props.paddingY}px`);
|
||||
}
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
styleParts.push(`background-color:${props.fillColorCustom}`);
|
||||
}
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
styleParts.push(`border-color:${props.strokeColorCustom}`);
|
||||
}
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
styleParts.push(`color:${props.textColorCustom}`);
|
||||
}
|
||||
const styleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
|
||||
|
||||
const btnClasses = ["pb-btn-base", sizeClass, variantClass, radiusClass]
|
||||
const btnClasses = ["pb-btn-base", tokens.sizeClass, tokens.variantClass, tokens.radiusClass]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return `<div class="${alignClass}"><a href="${escapeAttr(
|
||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||
href,
|
||||
)}" class="${btnClasses}"${styleAttr}>${escapeHtml(label)}</a></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "video") {
|
||||
const props: any = block.props ?? {};
|
||||
const rawUrl = normalizeVideoSourceUrl(props.sourceUrl ?? "");
|
||||
|
||||
const platform = resolveVideoPlatform(rawUrl, props.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(rawUrl, platform, { enableVimeoEmbed: true });
|
||||
|
||||
const tokens = computeVideoExportTokens(props, { escapeAttr });
|
||||
|
||||
const outerStyleAttr =
|
||||
tokens.outerStyleParts.length > 0 ? ` style="${tokens.outerStyleParts.join(";")}"` : "";
|
||||
const wrapperStyleAttr =
|
||||
tokens.wrapperStyleParts.length > 0 ? ` style="${tokens.wrapperStyleParts.join(";")}"` : "";
|
||||
const videoStyleAttr =
|
||||
tokens.videoStyleParts.length > 0 ? ` style="${tokens.videoStyleParts.join(";")}"` : "";
|
||||
|
||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
||||
|
||||
if (isEmbed) {
|
||||
const titleRaw = typeof (props as any).titleText === "string" ? (props as any).titleText.trim() : "";
|
||||
const iframeTitle = titleRaw !== "" ? titleRaw : "비디오";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><iframe title="${escapeAttr(
|
||||
iframeTitle,
|
||||
)}" src="${escapeAttr(
|
||||
embedUrl,
|
||||
)}" class="pb-video-frame" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe></div></div>`;
|
||||
}
|
||||
|
||||
const controlsAttr = props.controls === false ? "" : " controls";
|
||||
const autoplayAttr = props.autoplay ? " autoplay" : "";
|
||||
const loopAttr = props.loop ? " loop" : "";
|
||||
const mutedAttr = props.muted ? " muted" : "";
|
||||
const posterRaw = typeof (props as any).posterImageSrc === "string" ? (props as any).posterImageSrc.trim() : "";
|
||||
const posterAttr = posterRaw !== "" ? ` poster="${escapeAttr(posterRaw)}"` : "";
|
||||
const ariaRaw = typeof (props as any).ariaLabel === "string" ? (props as any).ariaLabel.trim() : "";
|
||||
const ariaAttr = ariaRaw !== "" ? ` aria-label="${escapeAttr(ariaRaw)}"` : "";
|
||||
const startDataAttr =
|
||||
typeof (props as any).startTimeSec === "number" && (props as any).startTimeSec >= 0
|
||||
? ` data-start-seconds="${(props as any).startTimeSec}"`
|
||||
: "";
|
||||
const endDataAttr =
|
||||
typeof (props as any).endTimeSec === "number" && (props as any).endTimeSec >= 0
|
||||
? ` data-end-seconds="${(props as any).endTimeSec}"`
|
||||
: "";
|
||||
const captionRaw = typeof (props as any).captionText === "string" ? (props as any).captionText.trim() : "";
|
||||
const captionHtml = captionRaw !== "" ? `<p class="pb-video-caption">${escapeHtml(captionRaw)}</p>` : "";
|
||||
|
||||
return `<div${outerStyleAttr}><div class="pb-video-wrapper${tokens.aspectClass}"${wrapperStyleAttr}><video class="pb-video-frame" src="${escapeAttr(
|
||||
rawUrl,
|
||||
)}"${posterAttr}${ariaAttr}${startDataAttr}${endDataAttr}${videoStyleAttr}${controlsAttr}${autoplayAttr}${loopAttr}${mutedAttr}></video></div>${captionHtml}</div>`;
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
const props: any = block.props ?? {};
|
||||
const src = typeof props.src === "string" ? props.src : "";
|
||||
const alt = typeof props.alt === "string" ? props.alt : "";
|
||||
|
||||
const wrapperStyleParts: string[] = [];
|
||||
const imgStyleParts: string[] = [];
|
||||
const styles = computeImageExportStyles({
|
||||
backgroundColorCustom:
|
||||
typeof props.backgroundColorCustom === "string" ? props.backgroundColorCustom : undefined,
|
||||
widthMode: props.widthMode ?? "auto",
|
||||
widthPx: typeof props.widthPx === "number" ? props.widthPx : undefined,
|
||||
borderRadius: props.borderRadius ?? "md",
|
||||
borderRadiusPx: typeof props.borderRadiusPx === "number" ? props.borderRadiusPx : undefined,
|
||||
});
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
wrapperStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
imgStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const radiusToken = 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;
|
||||
if (typeof radiusPx === "number" && radiusPx >= 0) {
|
||||
imgStyleParts.push(`border-radius:${radiusPx === 9999 ? "9999px" : `${radiusPx}px`}`);
|
||||
}
|
||||
|
||||
const wrapperStyleAttr = wrapperStyleParts.length > 0 ? ` style="${wrapperStyleParts.join(";")}"` : "";
|
||||
const imgStyleAttr = imgStyleParts.length > 0 ? ` style="${imgStyleParts.join(";")}"` : "";
|
||||
const wrapperStyleAttr =
|
||||
styles.wrapperStyleParts.length > 0 ? ` style="${styles.wrapperStyleParts.join(";")}"` : "";
|
||||
const imgStyleAttr = styles.imgStyleParts.length > 0 ? ` style="${styles.imgStyleParts.join(";")}"` : "";
|
||||
|
||||
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const tokens = computeFormBlockExportTokens(block, blocks);
|
||||
const controllerFields = tokens.hasControllerFields ? tokens.controllerFields : [];
|
||||
const fallbackFields = tokens.fallbackFields;
|
||||
|
||||
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",
|
||||
);
|
||||
const hasControllerFields = controllerFields.length > 0;
|
||||
const hasFallbackFields = fallbackFields.length > 0;
|
||||
|
||||
const fallbackFields = Array.isArray(props.fields) ? props.fields : [];
|
||||
|
||||
const fields = controllerFields.length > 0 ? controllerFields : null;
|
||||
// FormBlock 이 컨트롤러/필드 정보를 전혀 가지고 있지 않으면 정적 HTML 에서는 아무 것도 렌더하지 않는다.
|
||||
if (!hasControllerFields && !hasFallbackFields) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const formParts: string[] = [];
|
||||
const formStyleParts: string[] = [];
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
const formStyleAttr = formStyleParts.length > 0 ? ` style="${formStyleParts.join(";")}"` : "";
|
||||
const formStyleAttr = tokens.formStyleParts.length > 0 ? ` style="${tokens.formStyleParts.join(";")}"` : "";
|
||||
|
||||
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
|
||||
|
||||
if (fields) {
|
||||
for (const fieldBlock of fields) {
|
||||
if (hasControllerFields) {
|
||||
for (const fieldBlock of controllerFields) {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
const name = typeof anyProps.formFieldName === "string" ? anyProps.formFieldName : fieldBlock.id;
|
||||
const label =
|
||||
@@ -379,7 +390,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
|
||||
formParts.push(`</div>`);
|
||||
}
|
||||
} else if (fallbackFields.length > 0) {
|
||||
} else if (hasFallbackFields) {
|
||||
for (const field of fallbackFields) {
|
||||
const required = field.required ? " required" : "";
|
||||
const label = field.label ?? field.name;
|
||||
@@ -410,54 +421,23 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
|
||||
if (block.type === "divider") {
|
||||
const props: any = block.props ?? {};
|
||||
const thickness = props.thickness === "medium" ? "2px" : "1px";
|
||||
const color = typeof props.colorHex === "string" && props.colorHex.trim() !== "" ? props.colorHex : "#475569";
|
||||
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
|
||||
return `<hr class="pb-divider" style="border:0;border-bottom:${thickness} solid ${escapeAttr(
|
||||
color,
|
||||
)};margin:${marginY}px 0;" />`;
|
||||
const tokens = computeDividerExportTokens(props, { escapeAttr });
|
||||
return `<hr class="pb-divider" style="${tokens.style}" />`;
|
||||
}
|
||||
|
||||
if (block.type === "list") {
|
||||
const props: any = block.props ?? {};
|
||||
const ordered = Boolean(props.ordered);
|
||||
const Tag = ordered ? "ol" : "ul";
|
||||
const props = (block.props ?? {}) as ListBlockProps;
|
||||
|
||||
const items: string[] = [];
|
||||
if (Array.isArray(props.itemsTree) && props.itemsTree.length > 0) {
|
||||
const walk = (nodes: 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);
|
||||
} else if (Array.isArray(props.items)) {
|
||||
for (const raw of props.items) {
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
items.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
if (items.length === 0) {
|
||||
if (tokens.items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
|
||||
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
const listStyleAttr = tokens.listStyleParts.join(";");
|
||||
|
||||
const listStyleParts: string[] = [`text-align:${align}`];
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
const listStyleAttr = listStyleParts.join(";");
|
||||
|
||||
return `<${Tag} class="pb-list" style="${listStyleAttr}">${lis}</${Tag}>`;
|
||||
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
||||
}
|
||||
|
||||
if (block.type === "formInput") {
|
||||
@@ -468,69 +448,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const type = props.inputType === "email" ? "email" : "text";
|
||||
const required = props.required ? " required" : "";
|
||||
|
||||
const textColorRaw =
|
||||
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||
? props.textColorCustom.trim()
|
||||
: "";
|
||||
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 =
|
||||
typeof props.widthMode === "string"
|
||||
? props.widthMode
|
||||
: props.fullWidth
|
||||
? "full"
|
||||
: "auto";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
inputStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 2
|
||||
: radiusToken === "lg"
|
||||
? 6
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
inputStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
const inputStyleAttr =
|
||||
inputStyleParts.length > 0 ? ` style=\"${inputStyleParts.join(";")}\"` : "";
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
)}"${required}${inputStyleAttr} />`,
|
||||
)}"${required}${tokens.inputStyleAttr} />`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -543,67 +468,12 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const required = props.required ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const textColorRaw =
|
||||
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||
? props.textColorCustom.trim()
|
||||
: "";
|
||||
const labelStyleAttr = textColorRaw ? ` style=\"color:${escapeAttr(textColorRaw)}\"` : "";
|
||||
|
||||
const selectStyleParts: string[] = [];
|
||||
if (textColorRaw) {
|
||||
selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
|
||||
}
|
||||
|
||||
const widthMode =
|
||||
typeof props.widthMode === "string"
|
||||
? props.widthMode
|
||||
: props.fullWidth
|
||||
? "full"
|
||||
: "auto";
|
||||
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 radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||
const radiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 2
|
||||
: radiusToken === "lg"
|
||||
? 6
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
selectStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
const selectStyleAttr =
|
||||
selectStyleParts.length > 0 ? ` style=\"${selectStyleParts.join(";")}\"` : "";
|
||||
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${selectStyleAttr}>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
|
||||
@@ -624,61 +494,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
: name;
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const textColorRaw =
|
||||
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||
? props.textColorCustom.trim()
|
||||
: "";
|
||||
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 checkboxRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||
const checkboxRadiusPx =
|
||||
checkboxRadiusToken === "none"
|
||||
? 0
|
||||
: checkboxRadiusToken === "sm"
|
||||
? 2
|
||||
: checkboxRadiusToken === "lg"
|
||||
? 6
|
||||
: checkboxRadiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
optionStyleParts.push(`border-radius:${checkboxRadiusPx}px`);
|
||||
|
||||
const optionStyleAttr =
|
||||
optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : "";
|
||||
const tokens = computeFormCheckboxExportTokens(props, { escapeAttr });
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
@@ -697,61 +520,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
: name;
|
||||
const options = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const textColorRaw =
|
||||
typeof props.textColorCustom === "string" && props.textColorCustom.trim() !== ""
|
||||
? props.textColorCustom.trim()
|
||||
: "";
|
||||
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 radioRadiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||
const radioRadiusPx =
|
||||
radioRadiusToken === "none"
|
||||
? 0
|
||||
: radioRadiusToken === "sm"
|
||||
? 2
|
||||
: radioRadiusToken === "lg"
|
||||
? 6
|
||||
: radioRadiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
optionStyleParts.push(`border-radius:${radioRadiusPx}px`);
|
||||
|
||||
const optionStyleAttr =
|
||||
optionStyleParts.length > 0 ? ` style=\"${optionStyleParts.join(";")}\"` : "";
|
||||
const tokens = computeFormRadioExportTokens(props, { escapeAttr });
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
|
||||
);
|
||||
@@ -781,39 +557,26 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
? props.columns
|
||||
: [{ id: `${section.id}_col`, span: 12 }];
|
||||
|
||||
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 tokens = computeSectionExportTokens(props);
|
||||
|
||||
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 sectionStyleAttr =
|
||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
const sectionStyleParts: string[] = [];
|
||||
// 섹션 커스텀 배경색: backgroundColorCustom 이 설정된 경우에만 인라인 스타일로 적용하여 토큰 기반 배경 클래스를 오버라이드한다.
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyleParts.push(`background-color:${escapeAttr(props.backgroundColorCustom.trim())}`);
|
||||
}
|
||||
const sectionStyleAttr = sectionStyleParts.length > 0 ? ` style="${sectionStyleParts.join(";")}"` : "";
|
||||
bodyParts.push(
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
||||
);
|
||||
|
||||
bodyParts.push(`<section class="${sectionClasses}"${sectionStyleAttr}><div class="pb-section-inner"><div class="pb-section-columns">`);
|
||||
for (const col of columns) {
|
||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||
for (const column of columns) {
|
||||
const columnBlocks = blocks.filter(
|
||||
(b) => b.sectionId === section.id && b.columnId === column.id,
|
||||
);
|
||||
bodyParts.push('<div class="pb-section-column">');
|
||||
for (const block of columnBlocks) {
|
||||
bodyParts.push(renderBlock(block));
|
||||
}
|
||||
bodyParts.push("</div>");
|
||||
}
|
||||
|
||||
bodyParts.push("</div></div></section>");
|
||||
}
|
||||
|
||||
@@ -825,7 +588,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${pageTitle}</title>
|
||||
<link rel="stylesheet" href="./builder.css" />${headExtra}
|
||||
<link rel="stylesheet" href="./builder.css" />${seoHeadHtml}${headExtra}
|
||||
</head>
|
||||
<body style="${bodyStyle}">
|
||||
<main>
|
||||
@@ -854,7 +617,7 @@ export async function POST(request: Request) {
|
||||
const zip = new JSZip();
|
||||
|
||||
const imageIds = new Set<string>();
|
||||
const imageRegex = /\/api\/image\/([^"'>\s]+)/g;
|
||||
const imageRegex = /\/api\/image\/([^"'>\s)]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = imageRegex.exec(html)) !== null) {
|
||||
@@ -877,6 +640,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const videoIds = new Set<string>();
|
||||
const videoRegex = /\/api\/video\/([^"'>\s]+)/g;
|
||||
let videoMatch: RegExpExecArray | null;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((videoMatch = videoRegex.exec(html)) !== null) {
|
||||
if (videoMatch[1]) {
|
||||
videoIds.add(videoMatch[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of videoIds) {
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
zip.file(`videos/${id}`, bytes);
|
||||
|
||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const srcRegex = new RegExp(`/api/video/${escapedId}`, "g");
|
||||
html = html.replace(srcRegex, `./videos/${id}`);
|
||||
} catch {
|
||||
// 업로드 디렉터리에 파일이 없는 경우에는 조용히 건너뛴다.
|
||||
}
|
||||
}
|
||||
|
||||
zip.file("index.html", html);
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 환경에서는 mimeType 조회 없이 기본값만 사용한다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 MIME 타입은 기본값으로만 응답합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request, ctx: Params) {
|
||||
// Next.js 가 params 를 항상 보장하지 않는 상황을 대비해, URL 경로에서 id 를 보완적으로 파싱한다.
|
||||
let id: string | undefined = ctx?.params?.id;
|
||||
if (!id) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
// [..., "api", "video", ":id"] 형태를 기대한다.
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last !== "video") {
|
||||
id = last;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 실패 시에는 그대로 둔다.
|
||||
}
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ message: "id 파라미터가 필요합니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Referer 기반으로 간단한 핫링크 방지: 동일 호스트에서 온 요청만 허용한다.
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
const referer = request.headers.get("referer");
|
||||
|
||||
if (!referer) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const refererUrl = new URL(referer);
|
||||
if (refererUrl.host !== requestUrl.host) {
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 Referer 검사 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 접근이 허용되지 않습니다." }, { status: 403 });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, id);
|
||||
|
||||
try {
|
||||
const bytes = await fs.readFile(filePath);
|
||||
|
||||
// 기본 MIME 타입은 mp4 로 두되, 가능하면 Asset 메타데이터에서 실제 타입을 읽어온다.
|
||||
let mimeType = "video/mp4";
|
||||
if (prisma) {
|
||||
try {
|
||||
const asset = await prisma.asset.findUnique({ where: { id } });
|
||||
const meta: any = asset?.meta ?? null;
|
||||
const candidate = typeof meta?.mimeType === "string" ? meta.mimeType.trim() : "";
|
||||
if (candidate) {
|
||||
mimeType = candidate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("비디오 메타데이터 조회 실패 - 기본 MIME 타입으로 응답합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeType,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error && typeof error === "object" && (error as any).code === "ENOENT") {
|
||||
return NextResponse.json({ message: "비디오를 찾을 수 없습니다." }, { status: 404 });
|
||||
}
|
||||
|
||||
console.error("비디오 파일 읽기 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
// 업로드된 비디오 파일을 저장할 디렉터리 (프로젝트 루트 기준)
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "uploads");
|
||||
|
||||
let prisma: PrismaClient | null = null;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
} catch (error) {
|
||||
// DB 가 준비되지 않은 개발/테스트 환경에서도 업로드 자체는 동작할 수 있도록,
|
||||
// Prisma 초기화 실패는 치명적 오류로 취급하지 않고 로그만 남긴다.
|
||||
console.error("PrismaClient 초기화 실패 - 비디오 업로드는 파일 시스템 전용 모드로 동작합니다.", error);
|
||||
prisma = null;
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!file || typeof (file as any).arrayBuffer !== "function") {
|
||||
return NextResponse.json({ message: "file 필드는 필수입니다." }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 디렉터리 생성 실패", error);
|
||||
return NextResponse.json({ message: "업로드 디렉터리를 생성할 수 없습니다." }, { status: 500 });
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await (file as any).arrayBuffer();
|
||||
const bytes = new Uint8Array(arrayBuffer);
|
||||
const id = randomUUID();
|
||||
|
||||
// 파일 경로는 uploads/<id> 형태로 저장한다.
|
||||
const relativePath = path.join("uploads", id);
|
||||
const filePath = path.join(process.cwd(), relativePath);
|
||||
|
||||
await fs.writeFile(filePath, bytes);
|
||||
|
||||
// 가능하다면 Asset 테이블에도 메타데이터를 남긴다.
|
||||
if (prisma) {
|
||||
try {
|
||||
const project = await prisma.project.upsert({
|
||||
where: { slug: "assets-global" },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
title: "Assets Global",
|
||||
slug: "assets-global",
|
||||
contentJson: [],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.asset.create({
|
||||
data: {
|
||||
id,
|
||||
projectId: project.id,
|
||||
kind: "video",
|
||||
url: relativePath,
|
||||
meta: {
|
||||
sourceType: "uploaded",
|
||||
originalName: (file as any).name ?? "upload",
|
||||
mimeType: (file as any).type ?? "application/octet-stream",
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("비디오 Asset DB 저장 실패 - 파일 시스템만 사용합니다.", error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id,
|
||||
servedUrl: `/api/video/${id}`,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 처리 중 오류", error);
|
||||
return NextResponse.json({ message: "비디오 업로드 중 오류가 발생했습니다." }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
interface FormControllerPanelProps {
|
||||
block: Block; // type === "form"
|
||||
@@ -131,89 +129,6 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 레이아웃</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>폼 너비 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={formProps.formWidthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthMode: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">전체 폭</option>
|
||||
<option value="fixed">고정 폭 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(formProps.formWidthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="폼 고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.formWidthPx === "number" ? formProps.formWidthPx : 360}
|
||||
min={160}
|
||||
max={960}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "좁게", value: 280 },
|
||||
{ id: "md", label: "보통", value: 360 },
|
||||
{ id: "lg", label: "넓게", value: 480 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
formWidthPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="폼 위/아래 여백 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof formProps.marginYPx === "number" ? formProps.marginYPx : 16}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: 8 },
|
||||
{ id: "normal", label: "보통", value: 16 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
marginYPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 스타일</h3>
|
||||
<ColorPickerField
|
||||
label="폼 배경색"
|
||||
ariaLabelColorInput="폼 배경색 피커"
|
||||
ariaLabelHexInput="폼 배경색 HEX"
|
||||
value={
|
||||
formProps.backgroundColorCustom && formProps.backgroundColorCustom.trim() !== ""
|
||||
? formProps.backgroundColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#e5e7eb"
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: hex,
|
||||
} as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
onPaletteSelect={(item) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundColorCustom: item.color,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 메시지</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
|
||||
+287
-358
@@ -28,6 +28,7 @@ import type {
|
||||
TextBlockProps,
|
||||
ButtonBlockProps,
|
||||
ImageBlockProps,
|
||||
VideoBlockProps,
|
||||
SectionBlockProps,
|
||||
DividerBlockProps,
|
||||
ListBlockProps,
|
||||
@@ -40,6 +41,23 @@ import type {
|
||||
ListItemNode,
|
||||
ProjectConfig,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoEditorTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeTextEditorTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeButtonPbTokens, computeButtonEditorTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeDividerEditorTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
import { computeListEditorTokens } from "@/features/editor/utils/listHelpers";
|
||||
import { computeImageEditorTokens } from "@/features/editor/utils/imageHelpers";
|
||||
import { computeSectionEditorTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import {
|
||||
computeFormInputEditorTokens,
|
||||
computeFormSelectEditorTokens,
|
||||
computeFormOptionGroupEditorTokens,
|
||||
} from "@/features/editor/utils/formHelpers";
|
||||
import { ButtonPropertiesPanel } from "./panels/ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./panels/TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "./panels/ListPropertiesPanel";
|
||||
@@ -117,7 +135,9 @@ export default function EditorPage() {
|
||||
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
|
||||
const [activeModal, setActiveModal] = useState<"project" | "json" | "exportPreview" | null>(null);
|
||||
const [exportPreviewHtml, setExportPreviewHtml] = useState("");
|
||||
const [exportPreviewStatus, setExportPreviewStatus] = useState<"idle" | "loading" | "error">("idle");
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
@@ -143,7 +163,14 @@ export default function EditorPage() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("정적 파일 내보내기 실패", await response.text().catch(() => ""));
|
||||
const text = await response.text().catch(() => "");
|
||||
const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text;
|
||||
console.error(
|
||||
"정적 파일 내보내기 실패",
|
||||
response.status,
|
||||
response.statusText,
|
||||
truncated,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,6 +190,37 @@ export default function EditorPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshExportPreview = async () => {
|
||||
try {
|
||||
setExportPreviewStatus("loading");
|
||||
|
||||
const payload = {
|
||||
blocks,
|
||||
projectConfig,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/export/preview", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
setExportPreviewStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
setExportPreviewHtml(html);
|
||||
setExportPreviewStatus("idle");
|
||||
} catch (error) {
|
||||
console.error("Export 미리보기 요청 중 오류", error);
|
||||
setExportPreviewStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const commitEditing = () => {
|
||||
if (!editingBlockId) return;
|
||||
updateBlock(editingBlockId, { text: editingText });
|
||||
@@ -660,6 +718,16 @@ export default function EditorPage() {
|
||||
>
|
||||
JSON 내보내기/불러오기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setActiveModal("exportPreview");
|
||||
}}
|
||||
>
|
||||
Export 미리보기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left hover:bg-slate-800 text-slate-100 border-t border-slate-800"
|
||||
@@ -782,6 +850,55 @@ export default function EditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModal === "exportPreview" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-4xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">Export 미리보기</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-sm"
|
||||
onClick={() => setActiveModal(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-slate-400">
|
||||
현재 캔버스 상태를 기반으로 생성된 정적 Export HTML 을 미리 확인할 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-sky-700 bg-sky-950 px-3 py-1 text-[11px] text-sky-100 hover:bg-sky-900 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
onClick={handleRefreshExportPreview}
|
||||
disabled={exportPreviewStatus === "loading"}
|
||||
>
|
||||
{exportPreviewStatus === "loading" ? "로딩 중..." : "미리보기 새로고침"}
|
||||
</button>
|
||||
</div>
|
||||
{exportPreviewStatus === "error" && (
|
||||
<p className="text-[11px] text-red-300">
|
||||
Export 미리보기 HTML 을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.
|
||||
</p>
|
||||
)}
|
||||
{exportPreviewHtml ? (
|
||||
<iframe
|
||||
title="Export preview"
|
||||
data-testid="export-preview-frame"
|
||||
className="w-full h-[480px] rounded border border-slate-700 bg-slate-950"
|
||||
srcDoc={exportPreviewHtml}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-[200px] rounded border border-dashed border-slate-700 bg-slate-950 flex items-center justify-center text-[11px] text-slate-500">
|
||||
아직 Export 미리보기 HTML 이 없습니다. "미리보기 새로고침" 버튼을 눌러 생성해 보세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModal === "json" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
@@ -949,85 +1066,16 @@ function SortableEditorBlock({
|
||||
|
||||
if (block.type === "text") {
|
||||
const textProps = block.props as TextBlockProps;
|
||||
const textTokens = computeTextEditorTokens(textProps);
|
||||
|
||||
// 정렬: pb-text-*
|
||||
alignClass =
|
||||
textProps.align === "center"
|
||||
? "pb-text-center"
|
||||
: textProps.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
|
||||
// 폰트 크기: scale/custom + 기존 size 값 fallback
|
||||
const fontSizeMode = textProps.fontSizeMode ?? "scale";
|
||||
const fallbackScale =
|
||||
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
|
||||
|
||||
// 스케일/커스텀 모드와 무관하게 pb-text-* 스케일 클래스는 항상 유지한다.
|
||||
sizeClass = `pb-text-${fontSizeScale}`;
|
||||
|
||||
// custom 모드에서는 inline 스타일로 실제 폰트 크기를 덮어쓴다.
|
||||
if (fontSizeMode === "custom" && textProps.fontSizeCustom) {
|
||||
textStyleOverrides.fontSize = textProps.fontSizeCustom;
|
||||
}
|
||||
|
||||
// 줄 간격: scale/custom
|
||||
const lineHeightMode = textProps.lineHeightMode ?? "scale";
|
||||
const lineHeightScale = textProps.lineHeightScale ?? "normal";
|
||||
if (lineHeightMode === "scale") {
|
||||
leadingClass = `pb-leading-${lineHeightScale}`;
|
||||
} else if (lineHeightMode === "custom" && textProps.lineHeightCustom) {
|
||||
textStyleOverrides.lineHeight = textProps.lineHeightCustom;
|
||||
}
|
||||
|
||||
// 굵기: scale/custom
|
||||
const fontWeightMode = textProps.fontWeightMode ?? "scale";
|
||||
const fontWeightScale = textProps.fontWeightScale ?? "normal";
|
||||
if (fontWeightMode === "scale") {
|
||||
weightClass = `pb-font-${fontWeightScale}`;
|
||||
} else if (fontWeightMode === "custom" && textProps.fontWeightCustom) {
|
||||
textStyleOverrides.fontWeight = textProps.fontWeightCustom as CSSProperties["fontWeight"];
|
||||
}
|
||||
|
||||
// 색상: colorCustom이 있으면 항상 우선 사용, 없으면 팔레트
|
||||
const colorPalette = textProps.colorPalette ?? "default";
|
||||
if (textProps.colorCustom && textProps.colorCustom.trim() !== "") {
|
||||
textStyleOverrides.color = textProps.colorCustom;
|
||||
} else {
|
||||
colorClass = `pb-text-color-${colorPalette}`;
|
||||
}
|
||||
|
||||
// 블록 배경색: backgroundColorCustom 이 설정된 경우 편집기 텍스트 컨테이너 배경에도 반영한다.
|
||||
if (textProps.backgroundColorCustom && textProps.backgroundColorCustom.trim() !== "") {
|
||||
textStyleOverrides.backgroundColor = textProps.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
// 최대 너비: 커스텀 값이 있으면 우선 사용, 없으면 scale 프리셋
|
||||
const maxWidthScale = textProps.maxWidthScale ?? "none";
|
||||
if (textProps.maxWidthCustom && textProps.maxWidthCustom.trim() !== "") {
|
||||
textStyleOverrides.maxWidth = textProps.maxWidthCustom;
|
||||
} else if (maxWidthScale === "prose") {
|
||||
maxWidthClass = "pb-text-maxw-prose";
|
||||
} else if (maxWidthScale === "narrow") {
|
||||
maxWidthClass = "pb-text-maxw-narrow";
|
||||
}
|
||||
|
||||
// 글자 간격: 커스텀 값이 있으면 em 단위 그대로 사용
|
||||
if (textProps.letterSpacingCustom && textProps.letterSpacingCustom.trim() !== "") {
|
||||
textStyleOverrides.letterSpacing = textProps.letterSpacingCustom;
|
||||
}
|
||||
|
||||
// 텍스트 장식: 밑줄/가운데줄/이탤릭
|
||||
if (textProps.underline) {
|
||||
decorationClass += " pb-underline";
|
||||
}
|
||||
if (textProps.strike) {
|
||||
decorationClass += " pb-line-through";
|
||||
}
|
||||
if (textProps.italic) {
|
||||
decorationClass += " pb-italic";
|
||||
}
|
||||
alignClass = textTokens.alignClass;
|
||||
sizeClass = textTokens.sizeClass;
|
||||
leadingClass = textTokens.leadingClass;
|
||||
weightClass = textTokens.weightClass;
|
||||
colorClass = textTokens.colorClass;
|
||||
maxWidthClass = textTokens.maxWidthClass;
|
||||
decorationClass = textTokens.decorationClass;
|
||||
Object.assign(textStyleOverrides, textTokens.styleOverrides);
|
||||
} else if (block.type === "button") {
|
||||
const buttonProps = block.props as ButtonBlockProps;
|
||||
const align = buttonProps.align ?? "left";
|
||||
@@ -1051,6 +1099,11 @@ function SortableEditorBlock({
|
||||
: listProps.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
} else if (block.type === "video") {
|
||||
const videoProps = block.props as VideoBlockProps;
|
||||
const align = videoProps.align ?? "center";
|
||||
alignClass =
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
} else if (block.type === "section") {
|
||||
// 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만
|
||||
alignClass = "pb-text-left";
|
||||
@@ -1143,33 +1196,13 @@ function SortableEditorBlock({
|
||||
const inputType = inputProps.inputType ?? "text";
|
||||
|
||||
// 필드 스타일: 텍스트 색상/배경/테두리/모서리 등은 커스텀 props 를 통해 제어한다.
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (inputProps.textColorCustom && inputProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = inputProps.textColorCustom;
|
||||
}
|
||||
if (inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = inputProps.fillColorCustom;
|
||||
}
|
||||
if (inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = inputProps.strokeColorCustom;
|
||||
}
|
||||
const widthMode = inputProps.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof inputProps.widthPx === "number" && inputProps.widthPx > 0) {
|
||||
fieldStyle.width = `${inputProps.widthPx}px`;
|
||||
}
|
||||
const radius = inputProps.borderRadius ?? "md";
|
||||
if (radius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (radius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (radius === "lg") fieldStyle.borderRadius = 9999;
|
||||
else if (radius === "full") fieldStyle.borderRadius = 9999;
|
||||
// md 는 기본 tailwind rounded 와 비슷한 값으로 둔다 (별도 스타일 없음이면 클래스에 맡긴다).
|
||||
const inputTokens = computeFormInputEditorTokens(inputProps);
|
||||
const fieldStyle = inputTokens.fieldStyle;
|
||||
|
||||
const labelLayout = inputProps.labelLayout ?? "stacked";
|
||||
const isInline = labelLayout === "inline";
|
||||
|
||||
const align = inputProps.align ?? "left";
|
||||
const inputAlignClass =
|
||||
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
|
||||
const inputAlignClass = inputTokens.inputAlignClass;
|
||||
|
||||
// 에디터에서는 폼 입력 블록이 실제 입력 UI처럼 보이도록 라벨 + 인풋(또는 textarea)을 함께 렌더링한다.
|
||||
return (
|
||||
@@ -1186,24 +1219,7 @@ function SortableEditorBlock({
|
||||
)}
|
||||
{(() => {
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
const widthClass = inputTokens.widthClass;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1242,28 +1258,8 @@ function SortableEditorBlock({
|
||||
{ label: "옵션 2", value: "option_2" },
|
||||
];
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (selectProps.textColorCustom && selectProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = selectProps.textColorCustom;
|
||||
}
|
||||
if (selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = selectProps.fillColorCustom;
|
||||
}
|
||||
if (selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = selectProps.strokeColorCustom;
|
||||
}
|
||||
const selectWidthMode = selectProps.widthMode ?? "full";
|
||||
if (
|
||||
selectWidthMode === "fixed" &&
|
||||
typeof selectProps.widthPx === "number" &&
|
||||
selectProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${selectProps.widthPx}px`;
|
||||
}
|
||||
const selectRadius = selectProps.borderRadius ?? "md";
|
||||
if (selectRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (selectRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (selectRadius === "lg" || selectRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const selectTokens = computeFormSelectEditorTokens(selectProps);
|
||||
const fieldStyle = selectTokens.fieldStyle;
|
||||
|
||||
// 에디터에서는 폼 셀렉트 블록이 라벨 + 셀렉트 박스로 보이도록 렌더링한다.
|
||||
return (
|
||||
@@ -1308,28 +1304,8 @@ function SortableEditorBlock({
|
||||
|
||||
const groupLabelMode = radioProps.groupLabelMode ?? "text";
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (radioProps.textColorCustom && radioProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = radioProps.textColorCustom;
|
||||
}
|
||||
if (radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = radioProps.fillColorCustom;
|
||||
}
|
||||
if (radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = radioProps.strokeColorCustom;
|
||||
}
|
||||
const radioWidthMode = radioProps.widthMode ?? "full";
|
||||
if (
|
||||
radioWidthMode === "fixed" &&
|
||||
typeof radioProps.widthPx === "number" &&
|
||||
radioProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${radioProps.widthPx}px`;
|
||||
}
|
||||
const radioRadius = radioProps.borderRadius ?? "md";
|
||||
if (radioRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (radioRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (radioRadius === "lg" || radioRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const radioTokens = computeFormOptionGroupEditorTokens(radioProps);
|
||||
const fieldStyle = radioTokens.fieldStyle;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
@@ -1385,28 +1361,8 @@ function SortableEditorBlock({
|
||||
|
||||
const groupLabelMode = checkboxProps.groupLabelMode ?? "text";
|
||||
|
||||
const fieldStyle: CSSProperties = {};
|
||||
if (checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== "") {
|
||||
fieldStyle.color = checkboxProps.textColorCustom;
|
||||
}
|
||||
if (checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== "") {
|
||||
fieldStyle.backgroundColor = checkboxProps.fillColorCustom;
|
||||
}
|
||||
if (checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== "") {
|
||||
fieldStyle.borderColor = checkboxProps.strokeColorCustom;
|
||||
}
|
||||
const checkboxWidthMode = checkboxProps.widthMode ?? "full";
|
||||
if (
|
||||
checkboxWidthMode === "fixed" &&
|
||||
typeof checkboxProps.widthPx === "number" &&
|
||||
checkboxProps.widthPx > 0
|
||||
) {
|
||||
fieldStyle.width = `${checkboxProps.widthPx}px`;
|
||||
}
|
||||
const checkboxRadius = checkboxProps.borderRadius ?? "md";
|
||||
if (checkboxRadius === "none") fieldStyle.borderRadius = 0;
|
||||
else if (checkboxRadius === "sm") fieldStyle.borderRadius = 4;
|
||||
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
|
||||
const checkboxTokens = computeFormOptionGroupEditorTokens(checkboxProps);
|
||||
const fieldStyle = checkboxTokens.fieldStyle;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
|
||||
@@ -1449,71 +1405,31 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "button" && (() => {
|
||||
const buttonProps = block.props as ButtonBlockProps;
|
||||
const size = buttonProps.size ?? "md";
|
||||
const variant = buttonProps.variant ?? "solid";
|
||||
const colorPalette = buttonProps.colorPalette ?? "primary";
|
||||
const radius = buttonProps.borderRadius ?? "md";
|
||||
const baseFullWidth = buttonProps.fullWidth ?? false;
|
||||
const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto");
|
||||
const isFullWidth = widthMode === "full" || baseFullWidth;
|
||||
|
||||
const sizeClassBtn =
|
||||
size === "xs"
|
||||
? "pb-btn-size-xs"
|
||||
: size === "sm"
|
||||
? "pb-btn-size-sm"
|
||||
: size === "lg"
|
||||
? "pb-btn-size-lg"
|
||||
: size === "xl"
|
||||
? "pb-btn-size-xl"
|
||||
: "pb-btn-size-md";
|
||||
const radiusClassBtn =
|
||||
radius === "none"
|
||||
? "pb-btn-radius-none"
|
||||
: radius === "sm"
|
||||
? "pb-btn-radius-sm"
|
||||
: radius === "lg"
|
||||
? "pb-btn-radius-lg"
|
||||
: radius === "full"
|
||||
? "pb-btn-radius-full"
|
||||
: "pb-btn-radius-md";
|
||||
const variantClassBtn = `pb-btn-variant-${variant}-${colorPalette}`;
|
||||
const pbTokens = computeButtonPbTokens({
|
||||
align: buttonProps.align ?? "left",
|
||||
size: buttonProps.size ?? "md",
|
||||
variant: buttonProps.variant ?? "solid",
|
||||
colorPalette: buttonProps.colorPalette ?? "primary",
|
||||
borderRadius: buttonProps.borderRadius ?? "md",
|
||||
fullWidth: buttonProps.fullWidth ?? false,
|
||||
widthMode: buttonProps.widthMode ?? undefined,
|
||||
widthPx: typeof buttonProps.widthPx === "number" ? buttonProps.widthPx : undefined,
|
||||
paddingX: typeof buttonProps.paddingX === "number" ? buttonProps.paddingX : undefined,
|
||||
paddingY: typeof buttonProps.paddingY === "number" ? buttonProps.paddingY : undefined,
|
||||
fillColorCustom: buttonProps.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: buttonProps.strokeColorCustom ?? undefined,
|
||||
textColorCustom: buttonProps.textColorCustom ?? undefined,
|
||||
});
|
||||
|
||||
const buttonStyle: CSSProperties = {};
|
||||
if (buttonProps.fontSizeCustom && buttonProps.fontSizeCustom.trim() !== "") {
|
||||
buttonStyle.fontSize = buttonProps.fontSizeCustom;
|
||||
}
|
||||
if (buttonProps.lineHeightCustom && buttonProps.lineHeightCustom.trim() !== "") {
|
||||
buttonStyle.lineHeight = buttonProps.lineHeightCustom;
|
||||
}
|
||||
if (buttonProps.letterSpacingCustom && buttonProps.letterSpacingCustom.trim() !== "") {
|
||||
buttonStyle.letterSpacing = buttonProps.letterSpacingCustom;
|
||||
}
|
||||
if (buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== "") {
|
||||
buttonStyle.backgroundColor = buttonProps.fillColorCustom;
|
||||
}
|
||||
if (buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== "") {
|
||||
buttonStyle.borderColor = buttonProps.strokeColorCustom;
|
||||
}
|
||||
if (buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== "") {
|
||||
buttonStyle.color = buttonProps.textColorCustom;
|
||||
}
|
||||
if (widthMode === "fixed" && typeof buttonProps.widthPx === "number" && buttonProps.widthPx > 0) {
|
||||
buttonStyle.width = `${buttonProps.widthPx}px`;
|
||||
}
|
||||
if (typeof buttonProps.paddingX === "number" && buttonProps.paddingX >= 0) {
|
||||
buttonStyle.paddingInline = `${buttonProps.paddingX}px`;
|
||||
}
|
||||
if (typeof buttonProps.paddingY === "number" && buttonProps.paddingY >= 0) {
|
||||
buttonStyle.paddingBlock = `${buttonProps.paddingY}px`;
|
||||
}
|
||||
const editorTokens = computeButtonEditorTokens(buttonProps);
|
||||
|
||||
return (
|
||||
<div className={isFullWidth ? "w-full" : "inline-block"}>
|
||||
<div className={editorTokens.wrapperWidthClass}>
|
||||
<button
|
||||
type="button"
|
||||
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
|
||||
style={buttonStyle}
|
||||
className={`pb-btn-base ${pbTokens.sizeClass} ${pbTokens.radiusClass} ${pbTokens.variantClass} w-full`}
|
||||
style={editorTokens.buttonStyle}
|
||||
aria-label={buttonProps.label}
|
||||
onClick={(e) => {
|
||||
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
|
||||
@@ -1525,39 +1441,110 @@ function SortableEditorBlock({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "video" && (() => {
|
||||
const videoProps = block.props as VideoBlockProps;
|
||||
const normalizedUrl = normalizeVideoSourceUrl(videoProps.sourceUrl ?? "");
|
||||
const platform = resolveVideoPlatform(normalizedUrl, videoProps.platform ?? "auto");
|
||||
const embedUrl = buildVideoEmbedUrl(normalizedUrl, platform, { enableVimeoEmbed: false });
|
||||
const tokens = computeVideoEditorTokens(videoProps);
|
||||
|
||||
const startTimeSec =
|
||||
typeof videoProps.startTimeSec === "number" && videoProps.startTimeSec >= 0
|
||||
? videoProps.startTimeSec
|
||||
: null;
|
||||
const endTimeSec =
|
||||
typeof videoProps.endTimeSec === "number" && videoProps.endTimeSec >= 0
|
||||
? videoProps.endTimeSec
|
||||
: null;
|
||||
const isEmbed = platform === "youtube" || platform === "vimeo";
|
||||
|
||||
return (
|
||||
<div className={`w-full flex ${tokens.justifyClass}`}>
|
||||
<div>
|
||||
<div className={`pb-video-wrapper${tokens.aspectClass}`} style={tokens.wrapperStyle}>
|
||||
{isEmbed ? (
|
||||
<iframe
|
||||
title={videoProps.titleText && videoProps.titleText.trim() !== "" ? videoProps.titleText.trim() : "비디오"}
|
||||
src={embedUrl}
|
||||
className="pb-video-frame"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
data-testid="editor-video"
|
||||
className="pb-video-frame"
|
||||
src={normalizedUrl !== "" ? normalizedUrl : undefined}
|
||||
style={tokens.videoStyle}
|
||||
poster={videoProps.posterImageSrc && videoProps.posterImageSrc.trim() !== "" ? videoProps.posterImageSrc.trim() : undefined}
|
||||
aria-label={videoProps.ariaLabel && videoProps.ariaLabel.trim() !== "" ? videoProps.ariaLabel.trim() : undefined}
|
||||
onLoadedMetadata={(event) => {
|
||||
if (startTimeSec != null) {
|
||||
const el = event.currentTarget;
|
||||
try {
|
||||
el.currentTime = startTimeSec;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(event) => {
|
||||
if (endTimeSec != null) {
|
||||
const el = event.currentTarget;
|
||||
if (el.currentTime >= endTimeSec) {
|
||||
el.pause();
|
||||
try {
|
||||
if (!Number.isNaN(endTimeSec)) {
|
||||
el.currentTime = endTimeSec;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
controls={videoProps.controls !== false}
|
||||
autoPlay={!!videoProps.autoplay}
|
||||
loop={!!videoProps.loop}
|
||||
muted={!!videoProps.muted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{videoProps.captionText && videoProps.captionText.trim() !== "" && (
|
||||
<p
|
||||
data-testid="editor-video-caption"
|
||||
className="mt-2 text-xs text-slate-400 hidden"
|
||||
>
|
||||
{videoProps.captionText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "image" && (() => {
|
||||
const imageProps = block.props as ImageBlockProps;
|
||||
const hasSrc = imageProps.src.trim().length > 0;
|
||||
const align = imageProps.align ?? "center";
|
||||
const alignClass =
|
||||
align === "left" ? "justify-start" : align === "right" ? "justify-end" : "justify-center";
|
||||
const tokens = computeImageEditorTokens({
|
||||
widthMode: imageProps.widthMode,
|
||||
widthPx: imageProps.widthPx,
|
||||
borderRadius: imageProps.borderRadius,
|
||||
borderRadiusPx: imageProps.borderRadiusPx,
|
||||
});
|
||||
|
||||
const containerStyle: React.CSSProperties = {};
|
||||
const widthMode = imageProps.widthMode ?? "auto";
|
||||
if (widthMode === "fixed" && typeof imageProps.widthPx === "number" && imageProps.widthPx > 0) {
|
||||
containerStyle.width = `${imageProps.widthPx}px`;
|
||||
if (typeof tokens.widthPx === "number") {
|
||||
containerStyle.width = `${tokens.widthPx}px`;
|
||||
}
|
||||
|
||||
const imageStyle: React.CSSProperties = {
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: `${tokens.radiusPx}px`,
|
||||
};
|
||||
const radiusToken = imageProps.borderRadius ?? "md";
|
||||
const fallbackRadiusPx =
|
||||
radiusToken === "none"
|
||||
? 0
|
||||
: radiusToken === "sm"
|
||||
? 4
|
||||
: radiusToken === "lg"
|
||||
? 16
|
||||
: radiusToken === "full"
|
||||
? 9999
|
||||
: 8;
|
||||
const radiusPx =
|
||||
typeof imageProps.borderRadiusPx === "number" && imageProps.borderRadiusPx >= 0
|
||||
? imageProps.borderRadiusPx
|
||||
: fallbackRadiusPx;
|
||||
imageStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1581,51 +1568,35 @@ function SortableEditorBlock({
|
||||
})()}
|
||||
{block.type === "divider" && (() => {
|
||||
const dividerProps = block.props as DividerBlockProps;
|
||||
const thicknessClass = dividerProps.thickness === "medium" ? "border-t-2" : "border-t";
|
||||
const tokens = computeDividerEditorTokens(dividerProps);
|
||||
|
||||
// 색상: colorHex 가 있으면 사용, 없으면 기본 슬레이트 색상
|
||||
const borderColor =
|
||||
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
|
||||
? dividerProps.colorHex
|
||||
: "#475569";
|
||||
const borderColor = tokens.borderColor;
|
||||
|
||||
// 길이/너비: widthMode/widthPx 에 따라 가로 길이를 조정
|
||||
const widthMode = dividerProps.widthMode ?? "full";
|
||||
const widthPx = tokens.widthPx;
|
||||
|
||||
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
|
||||
const margin = tokens.marginPx;
|
||||
|
||||
const innerStyle: React.CSSProperties = {
|
||||
borderColor,
|
||||
};
|
||||
let innerWidthClass = "w-full";
|
||||
|
||||
if (widthMode === "auto") {
|
||||
innerWidthClass = "w-1/2";
|
||||
} else if (widthMode === "fixed") {
|
||||
innerWidthClass = "";
|
||||
if (typeof dividerProps.widthPx === "number" && dividerProps.widthPx > 0) {
|
||||
innerStyle.width = `${dividerProps.widthPx}px`;
|
||||
} else {
|
||||
innerStyle.width = "320px";
|
||||
if (typeof widthPx === "number") {
|
||||
innerStyle.width = `${widthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// 위/아래 여백: marginYPx 가 있으면 px 값 그대로 사용, 없으면 토큰 기반 기본값
|
||||
const margin =
|
||||
typeof dividerProps.marginYPx === "number"
|
||||
? dividerProps.marginYPx
|
||||
: dividerProps.marginY === "sm"
|
||||
? 8
|
||||
: dividerProps.marginY === "lg"
|
||||
? 24
|
||||
: 16;
|
||||
|
||||
return (
|
||||
<div className="w-full" style={{ marginTop: margin, marginBottom: margin }}>
|
||||
<div className={`${thicknessClass} ${innerWidthClass} border-t`} style={innerStyle} />
|
||||
<div
|
||||
className={`${tokens.thicknessClass} ${tokens.innerWidthClass} border-t`}
|
||||
style={innerStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{block.type === "list" && (() => {
|
||||
const listProps = block.props as ListBlockProps;
|
||||
const bulletStyleRaw = listProps.bulletStyle ?? (listProps.ordered ? "decimal" : "disc");
|
||||
const alignClass =
|
||||
listProps.align === "center"
|
||||
? "text-center"
|
||||
@@ -1633,30 +1604,16 @@ function SortableEditorBlock({
|
||||
? "text-right"
|
||||
: "text-left";
|
||||
|
||||
const tokens = computeListEditorTokens(listProps);
|
||||
|
||||
// 줄 간 간격: gapYPx 숫자 값을 우선 사용하고, 없으면 gapY 토큰을 space-y 클래스로 매핑
|
||||
const gapPx =
|
||||
typeof listProps.gapYPx === "number"
|
||||
? listProps.gapYPx
|
||||
: listProps.gapY === "sm"
|
||||
? 4
|
||||
: listProps.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
const gapPx = tokens.gapPx;
|
||||
|
||||
// 타이포/색상 스타일
|
||||
const listStyle: React.CSSProperties = {};
|
||||
if (listProps.fontSizeCustom && listProps.fontSizeCustom.trim() !== "") {
|
||||
listStyle.fontSize = listProps.fontSizeCustom;
|
||||
}
|
||||
if (listProps.lineHeightCustom && listProps.lineHeightCustom.trim() !== "") {
|
||||
listStyle.lineHeight = listProps.lineHeightCustom;
|
||||
}
|
||||
if (listProps.textColorCustom && listProps.textColorCustom.trim() !== "") {
|
||||
listStyle.color = listProps.textColorCustom;
|
||||
}
|
||||
const listStyle = tokens.listStyle;
|
||||
|
||||
// 불릿 스타일 (none 인 경우만 제거, decimal 은 숫자형으로 매핑)
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
const bulletStyle = tokens.bulletStyle;
|
||||
|
||||
const itemsTree: ListItemNode[] =
|
||||
(listProps.itemsTree as ListItemNode[] | undefined) && listProps.itemsTree!.length > 0
|
||||
@@ -1770,66 +1727,38 @@ function SortableEditorBlock({
|
||||
allBlocks.some(
|
||||
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
|
||||
));
|
||||
|
||||
const bgClass =
|
||||
sectionProps.background === "muted"
|
||||
? "bg-slate-950/40"
|
||||
: sectionProps.background === "primary"
|
||||
? "bg-sky-950/40 border-sky-900/60"
|
||||
: "bg-slate-900/60";
|
||||
|
||||
const pyClass =
|
||||
sectionProps.paddingY === "sm"
|
||||
? "py-4"
|
||||
: sectionProps.paddingY === "lg"
|
||||
? "py-10"
|
||||
: "py-6";
|
||||
|
||||
const alignItemsClass =
|
||||
sectionProps.alignItems === "center"
|
||||
? "items-center"
|
||||
: sectionProps.alignItems === "bottom"
|
||||
? "items-end"
|
||||
: "items-start";
|
||||
const sectionTokens = computeSectionEditorTokens(sectionProps);
|
||||
|
||||
const columns = sectionProps.columns && sectionProps.columns.length > 0
|
||||
? sectionProps.columns
|
||||
: [{ id: `${block.id}_col_fallback`, span: 12 }];
|
||||
|
||||
const sectionStyle: CSSProperties = {};
|
||||
if (sectionProps.backgroundColorCustom && sectionProps.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyle.backgroundColor = sectionProps.backgroundColorCustom;
|
||||
}
|
||||
if (typeof sectionProps.paddingYPx === "number" && sectionProps.paddingYPx > 0) {
|
||||
sectionStyle.paddingTop = `${sectionProps.paddingYPx}px`;
|
||||
sectionStyle.paddingBottom = `${sectionProps.paddingYPx}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="editor-section"
|
||||
className={`w-full ${bgClass} ${pyClass} rounded border ${
|
||||
className={`w-full ${sectionTokens.bgClass} ${sectionTokens.pyClass} rounded border ${
|
||||
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
|
||||
}`}
|
||||
style={sectionStyle}
|
||||
style={sectionTokens.wrapperStyle}
|
||||
>
|
||||
{sectionTokens.hasBackgroundVideo && (
|
||||
<video
|
||||
data-testid="editor-section-bg-video"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
src={sectionTokens.backgroundVideoSrc!}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="mx-auto px-4"
|
||||
style={{
|
||||
maxWidth:
|
||||
typeof sectionProps.maxWidthPx === "number" && sectionProps.maxWidthPx > 0
|
||||
? `${sectionProps.maxWidthPx}px`
|
||||
: undefined,
|
||||
}}
|
||||
style={sectionTokens.innerWrapperStyle}
|
||||
>
|
||||
<div
|
||||
className={`flex ${alignItemsClass}`}
|
||||
style={{
|
||||
columnGap:
|
||||
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
|
||||
? `${sectionProps.gapXPx}px`
|
||||
: undefined,
|
||||
}}
|
||||
className={`flex ${sectionTokens.alignItemsClass}`}
|
||||
style={sectionTokens.columnsContainerStyle}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const basis = `${(col.span / 12) * 100}%`;
|
||||
|
||||
@@ -8,6 +8,7 @@ export function BlocksSidebar() {
|
||||
const addTextBlock = useEditorStore((state) => state.addTextBlock);
|
||||
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
|
||||
const addImageBlock = useEditorStore((state) => state.addImageBlock);
|
||||
const addVideoBlock = useEditorStore((state) => (state as any).addVideoBlock);
|
||||
const addDividerBlock = useEditorStore((state) => state.addDividerBlock);
|
||||
const addListBlock = useEditorStore((state) => state.addListBlock);
|
||||
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
|
||||
@@ -32,6 +33,7 @@ export function BlocksSidebar() {
|
||||
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
|
||||
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
|
||||
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
|
||||
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
|
||||
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
|
||||
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
|
||||
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
|
||||
@@ -91,6 +93,13 @@ export function BlocksSidebar() {
|
||||
>
|
||||
이미지 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
onClick={handleAddVideo}
|
||||
>
|
||||
비디오 블록 추가
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
@@ -169,9 +178,13 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-hero"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
<div className="h-[2px] w-1/2 bg-slate-700/50" />
|
||||
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">
|
||||
@@ -190,9 +203,15 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-cta"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-2 w-6 rounded bg-slate-700/70" />
|
||||
<div className="flex h-full w-full items-center gap-[2px]">
|
||||
<div className="flex-1 flex flex-col justify-center gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">콜투액션(CTA) 섹션</p>
|
||||
@@ -214,11 +233,20 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-features"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">3컬럼 기능 소개 섹션</p>
|
||||
@@ -235,11 +263,16 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-faq"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="w-2 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[2px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">자주 묻는 질문(FAQ) 섹션</p>
|
||||
@@ -256,11 +289,21 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-pricing"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-2 flex-1 bg-slate-700/40" />
|
||||
<div className="h-4 flex-1 bg-slate-700/70" />
|
||||
<div className="h-3 flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-sky-500/70" />
|
||||
<div className="h-[1px] w-3/4 bg-sky-500/50" />
|
||||
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
<div className="h-[1px] w-3/4 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">요금제/플랜 소개 섹션</p>
|
||||
@@ -277,11 +320,23 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-blog"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">블로그 포스트 목록 섹션</p>
|
||||
@@ -303,10 +358,20 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-testimonials"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-center gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">고객 후기(Testimonials) 섹션</p>
|
||||
@@ -323,11 +388,23 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-team"
|
||||
className="shrink-0 flex h-6 w-10 items-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-3 flex-1 bg-slate-700/60" />
|
||||
<div className="h-4 flex-1 bg-slate-700/80" />
|
||||
<div className="h-2 flex-1 bg-slate-700/50" />
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
<div className="h-[1px] w-full bg-slate-700/70" />
|
||||
<div className="h-[1px] w-2/3 bg-slate-700/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">팀 소개 섹션</p>
|
||||
@@ -349,10 +426,14 @@ export function BlocksSidebar() {
|
||||
</button>
|
||||
<div
|
||||
data-testid="template-preview-footer"
|
||||
className="shrink-0 flex h-6 w-10 flex-col justify-end gap-[2px] rounded border border-slate-700 bg-slate-900"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
>
|
||||
<div className="h-[2px] w-full bg-slate-700/40" />
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
<div className="flex-1 h-[2px] bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
</div>
|
||||
<div className="flex-1 h-[1px] bg-slate-700/40" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 leading-snug">페이지 푸터 섹션</p>
|
||||
|
||||
@@ -104,6 +104,65 @@ export function ProjectPropertiesPanel() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">SEO / 메타</h4>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">SEO 타이틀</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="SEO 타이틀"
|
||||
placeholder={projectConfig.title || "페이지 제목"}
|
||||
value={projectConfig.seoTitle ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoTitle: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">메타 디스크립션</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500 min-h-[56px]"
|
||||
aria-label="메타 디스크립션"
|
||||
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
|
||||
value={projectConfig.seoDescription ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoDescription: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">OG/Twitter 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="OG/Twitter 이미지 URL"
|
||||
placeholder="예: https://example.com/og-image.png"
|
||||
value={projectConfig.seoOgImageUrl ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoOgImageUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">Canonical URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="Canonical URL"
|
||||
placeholder="예: https://example.com/landing"
|
||||
value={projectConfig.seoCanonicalUrl ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-600 bg-slate-900"
|
||||
aria-label="검색 엔진에 노출하지 않기 (noindex)"
|
||||
checked={Boolean(projectConfig.seoNoIndex)}
|
||||
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
|
||||
/>
|
||||
<span>검색 엔진에 노출하지 않기 (noindex)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">페이지 head HTML</span>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
|
||||
import { TextPropertiesPanel } from "./TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "./ListPropertiesPanel";
|
||||
import { DividerPropertiesPanel } from "./DividerPropertiesPanel";
|
||||
import { ImagePropertiesPanel } from "./ImagePropertiesPanel";
|
||||
import { SectionPropertiesPanel } from "./SectionPropertiesPanel";
|
||||
import { VideoPropertiesPanel } from "./VideoPropertiesPanel";
|
||||
import { FormInputPropertiesPanel } from "../forms/FormInputPropertiesPanel";
|
||||
import { FormSelectPropertiesPanel } from "../forms/FormSelectPropertiesPanel";
|
||||
import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPanel";
|
||||
@@ -142,6 +143,18 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "video") {
|
||||
const videoProps = selectedBlock.props as VideoBlockProps;
|
||||
|
||||
return (
|
||||
<VideoPropertiesPanel
|
||||
videoProps={videoProps}
|
||||
selectedBlockId={selectedBlockId}
|
||||
updateBlock={(id, partial) => updateBlock(id, partial as any)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedBlock.type === "section") {
|
||||
const sectionProps = selectedBlock.props as SectionBlockProps;
|
||||
|
||||
|
||||
@@ -11,6 +11,34 @@ export type SectionPropertiesPanelProps = {
|
||||
};
|
||||
|
||||
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
|
||||
const backgroundSource: "none" | "url" | "upload" = (() => {
|
||||
const src = sectionProps.backgroundImageSrc?.trim();
|
||||
const type = sectionProps.backgroundImageSourceType;
|
||||
|
||||
if (type === "asset") return "upload";
|
||||
if (type === "externalUrl") return "url";
|
||||
|
||||
if (src && src.startsWith("/api/image/")) return "upload";
|
||||
if (src) return "url";
|
||||
|
||||
return "none";
|
||||
})();
|
||||
|
||||
const positionMode: "preset" | "custom" = sectionProps.backgroundImagePositionMode ?? "preset";
|
||||
|
||||
const backgroundVideoSource: "none" | "url" | "upload" = (() => {
|
||||
const src = sectionProps.backgroundVideoSrc?.trim();
|
||||
const type = sectionProps.backgroundVideoSourceType;
|
||||
|
||||
if (type === "asset") return "upload";
|
||||
if (type === "externalUrl") return "url";
|
||||
|
||||
if (src && src.startsWith("/api/video/")) return "upload";
|
||||
if (src) return "url";
|
||||
|
||||
return "none";
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 섹션 배경: 커스텀 색상 피커만 사용 */}
|
||||
@@ -28,6 +56,333 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 섹션 배경 이미지 */}
|
||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 소스"
|
||||
value={backgroundSource}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "none" | "url" | "upload";
|
||||
if (next === "none") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: undefined,
|
||||
backgroundImageSourceType: undefined,
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
} else if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="none">없음</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{backgroundSource === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 URL"
|
||||
value={sectionProps.backgroundImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: value,
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{backgroundSource === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="배경 이미지 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("배경 이미지 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/image/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSrc: servedUrl,
|
||||
backgroundImageSourceType: "asset",
|
||||
backgroundImageAssetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("배경 이미지 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{backgroundSource !== "none" && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치 모드"
|
||||
value={positionMode}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: e.target.value as SectionBlockProps["backgroundImagePositionMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="preset">프리셋</option>
|
||||
<option value="custom">커스텀 (X/Y)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 크기</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 크기"
|
||||
value={sectionProps.backgroundImageSize ?? "cover"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageSize: e.target.value as SectionBlockProps["backgroundImageSize"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="cover">cover</option>
|
||||
<option value="contain">contain</option>
|
||||
<option value="auto">auto</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{positionMode === "preset" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치"
|
||||
value={sectionProps.backgroundImagePosition ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePosition: e.target.value as SectionBlockProps["backgroundImagePosition"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="center">가운데</option>
|
||||
<option value="top">위</option>
|
||||
<option value="bottom">아래</option>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{positionMode === "custom" && (
|
||||
<>
|
||||
<NumericPropertyControl
|
||||
label="배경 이미지 가로 위치"
|
||||
unitLabel="(%)"
|
||||
value={
|
||||
typeof sectionProps.backgroundImagePositionXPercent === "number"
|
||||
? sectionProps.backgroundImagePositionXPercent
|
||||
: 50
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "left", label: "왼쪽", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "right", label: "오른쪽", value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: next,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
|
||||
<NumericPropertyControl
|
||||
label="배경 이미지 세로 위치"
|
||||
unitLabel="(%)"
|
||||
value={
|
||||
typeof sectionProps.backgroundImagePositionYPercent === "number"
|
||||
? sectionProps.backgroundImagePositionYPercent
|
||||
: 50
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "top", label: "위", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "bottom", label: "아래", value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionYPercent: next,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 반복</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 반복"
|
||||
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundImageRepeat: e.target.value as SectionBlockProps["backgroundImageRepeat"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="no-repeat">반복 없음</option>
|
||||
<option value="repeat">가로/세로 반복</option>
|
||||
<option value="repeat-x">가로 반복</option>
|
||||
<option value="repeat-y">세로 반복</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 섹션 배경 비디오 */}
|
||||
<div className="mt-3 space-y-2 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 비디오 소스"
|
||||
value={backgroundVideoSource}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "none" | "url" | "upload";
|
||||
if (next === "none") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: undefined,
|
||||
backgroundVideoSourceType: undefined,
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
} else if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="none">없음</option>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{backgroundVideoSource === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="배경 비디오 URL"
|
||||
value={sectionProps.backgroundVideoSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: value,
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{backgroundVideoSource === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="배경 비디오 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/video", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("배경 비디오 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
backgroundVideoSrc: servedUrl,
|
||||
backgroundVideoSourceType: "asset",
|
||||
backgroundVideoAssetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("배경 비디오 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
|
||||
<div className="mt-3 space-y-1">
|
||||
<NumericPropertyControl
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
|
||||
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
|
||||
|
||||
export type VideoPropertiesPanelProps = {
|
||||
videoProps: VideoBlockProps;
|
||||
selectedBlockId: string;
|
||||
updateBlock: (id: string, partial: Partial<VideoBlockProps>) => void;
|
||||
};
|
||||
|
||||
export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock }: VideoPropertiesPanelProps) {
|
||||
// 비디오 소스 유형: 업로드(/api/video/:id) 또는 외부 URL 구분
|
||||
const source: "url" | "upload" =
|
||||
videoProps.sourceType === "asset" || (videoProps.sourceUrl && videoProps.sourceUrl.startsWith("/api/video/"))
|
||||
? "upload"
|
||||
: "url";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 비디오 소스 선택 (URL / 업로드) */}
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 소스</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 소스"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "url" | "upload";
|
||||
if (next === "url") {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
} else {
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceType: "asset",
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="url">URL</option>
|
||||
<option value="upload">파일 업로드</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* URL 입력 */}
|
||||
{source === "url" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 URL"
|
||||
value={videoProps.sourceUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceUrl: value,
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 접근성: 제목 / aria-label / 캡션 텍스트 (현재는 UI에서 숨김 처리) */}
|
||||
<div className="hidden">
|
||||
<label className="flex flex-col gap-1 mt-2">
|
||||
<span>비디오 제목</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 제목"
|
||||
value={(videoProps as any).titleText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { titleText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<span>비디오 aria-label</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 aria-label"
|
||||
value={(videoProps as any).ariaLabel ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { ariaLabel: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-2 flex flex-col gap-1">
|
||||
<span>비디오 캡션 텍스트</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 캡션 텍스트"
|
||||
value={(videoProps as any).captionText ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, { captionText: e.target.value } as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-1 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>포스터 이미지 URL</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="포스터 이미지 URL"
|
||||
value={videoProps.posterImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateBlock(selectedBlockId, {
|
||||
posterImageSrc: value,
|
||||
posterSourceType: "externalUrl",
|
||||
posterAssetId: null,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 */}
|
||||
{source === "upload" && (
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
|
||||
aria-label="비디오 파일 업로드"
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/video", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("비디오 업로드 실패", await response.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string; servedUrl?: string | null };
|
||||
const servedUrl = data.servedUrl ?? `/api/video/${data.id}`;
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
sourceUrl: servedUrl,
|
||||
sourceType: "asset",
|
||||
assetId: data.id,
|
||||
} as any);
|
||||
} catch (error) {
|
||||
console.error("비디오 업로드 중 오류", error);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3 text-xs text-slate-400">
|
||||
<h4 className="text-[11px] font-semibold text-slate-200">비디오 스타일</h4>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<ColorPickerField
|
||||
label="카드 배경색"
|
||||
ariaLabelColorInput="비디오 카드 배경색 피커"
|
||||
ariaLabelHexInput="비디오 카드 배경색 HEX"
|
||||
value={videoProps.backgroundColorCustom ?? ""}
|
||||
onChange={(hex) => {
|
||||
const next = hex && hex.trim().length > 0 ? hex : undefined;
|
||||
updateBlock(selectedBlockId, { backgroundColorCustom: next } as any);
|
||||
}}
|
||||
palette={TEXT_COLOR_PALETTE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 정렬</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 정렬"
|
||||
value={videoProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
align: e.target.value as VideoBlockProps["align"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="left">왼쪽</option>
|
||||
<option value="center">가운데</option>
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 너비 모드 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 너비 모드</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 너비 모드"
|
||||
value={videoProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
widthMode: e.target.value as VideoBlockProps["widthMode"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="auto">내용에 맞춤</option>
|
||||
<option value="full">가로 전체</option>
|
||||
<option value="fixed">고정 너비 (px)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(videoProps.widthMode ?? "auto") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="고정 너비 (px)"
|
||||
unitLabel="(px)"
|
||||
value={videoProps.widthPx ?? 640}
|
||||
min={160}
|
||||
max={1920}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "작게", value: 480 },
|
||||
{ id: "md", label: "보통", value: 640 },
|
||||
{ id: "lg", label: "넓게", value: 960 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
widthPx: v,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 카드 패딩 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 패딩"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.cardPaddingPx === "number" ? videoProps.cardPaddingPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={2}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { cardPaddingPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 카드 모서리 둥글기 */}
|
||||
<NumericPropertyControl
|
||||
label="카드 모서리 둥글기"
|
||||
unitLabel="(px)"
|
||||
value={typeof videoProps.borderRadiusPx === "number" ? videoProps.borderRadiusPx : 0}
|
||||
min={0}
|
||||
max={64}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { borderRadiusPx: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 시작 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="시작 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.startTimeSec === "number" ? videoProps.startTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { startTimeSec: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 종료 시점 (초) */}
|
||||
<NumericPropertyControl
|
||||
label="종료 시점 (초)"
|
||||
unitLabel="(초)"
|
||||
value={typeof videoProps.endTimeSec === "number" ? videoProps.endTimeSec : 0}
|
||||
min={0}
|
||||
max={600}
|
||||
step={1}
|
||||
onChangeValue={(v) => {
|
||||
const safe = Number.isFinite(v) && v >= 0 ? v : 0;
|
||||
updateBlock(selectedBlockId, { endTimeSec: safe } as any);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 화면 비율 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>화면 비율</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="화면 비율"
|
||||
value={videoProps.aspectRatio ?? "16:9"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
aspectRatio: e.target.value as VideoBlockProps["aspectRatio"],
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="16:9">16:9</option>
|
||||
<option value="4:3">4:3</option>
|
||||
<option value="1:1">1:1</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* 재생 옵션 */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.autoplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
autoplay: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">자동 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.loop}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
loop: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">반복 재생</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={!!videoProps.muted}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
muted: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">음소거</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-700 bg-slate-900"
|
||||
checked={videoProps.controls !== false}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
controls: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-[11px]">재생 컨트롤 표시</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createBlogTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,21 +42,40 @@ export function createBlogTemplateBlocks(opts: {
|
||||
const blogBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const post = postDefinitions[index] ?? postDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const titleId = createId();
|
||||
const summaryId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/400x250/1e293b/94a3b8?text=Blog+Image",
|
||||
alt: post.title,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
};
|
||||
|
||||
const titleProps: TextBlockProps = {
|
||||
text: post.title,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const summaryProps: TextBlockProps = {
|
||||
text: post.summary,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
@@ -73,7 +92,7 @@ export function createBlogTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [titleBlock, summaryBlock];
|
||||
return [imageBlock, titleBlock, summaryBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...blogBlocks];
|
||||
|
||||
@@ -9,7 +9,8 @@ export function createCtaTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 8 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
@@ -31,20 +32,21 @@ export function createCtaTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
const textId = createId();
|
||||
const textProps: TextBlockProps = {
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
|
||||
align: "center",
|
||||
size: "base",
|
||||
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.\n망설이지 말고 시작하세요!",
|
||||
align: "left",
|
||||
size: "lg",
|
||||
} as any;
|
||||
const textBlock: Block = {
|
||||
id: textId,
|
||||
type: "text",
|
||||
props: textProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const buttonId = createId();
|
||||
@@ -52,10 +54,10 @@ export function createCtaTemplateBlocks(opts: {
|
||||
label: "CTA 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
size: "lg",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
fullWidth: false,
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: "#0b1120",
|
||||
fillColorCustom: "#0ea5e9",
|
||||
@@ -66,7 +68,7 @@ export function createCtaTemplateBlocks(opts: {
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFaqTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 3 },
|
||||
{ id: `${sectionId}_col_2`, span: 9 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
// FAQ 는 비교적 좁은 폭과 보통 여백을 사용한다.
|
||||
paddingYPx: 56,
|
||||
maxWidthPx: 720,
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 1024,
|
||||
gapXPx: 48,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,20 +32,54 @@ export function createFaqTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const leftColumnId = `${sectionId}_col_1`;
|
||||
const rightColumnId = `${sectionId}_col_2`;
|
||||
|
||||
// Left Column: Title
|
||||
const titleId = createId();
|
||||
const titleProps: TextBlockProps = {
|
||||
text: "FAQ",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const titleBlock: Block = {
|
||||
id: titleId,
|
||||
type: "text",
|
||||
props: titleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
const subTitleId = createId();
|
||||
const subTitleProps: TextBlockProps = {
|
||||
text: "자주 묻는 질문",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const subTitleBlock: Block = {
|
||||
id: subTitleId,
|
||||
type: "text",
|
||||
props: subTitleProps,
|
||||
sectionId,
|
||||
columnId: leftColumnId,
|
||||
};
|
||||
|
||||
|
||||
// Right Column: Q&A List
|
||||
const faqPairs = [
|
||||
{
|
||||
question: "자주 묻는 질문 1",
|
||||
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "서비스 이용료는 얼마인가요?",
|
||||
answer: "기본 기능은 무료로 제공되며, 프리미엄 기능은 월 구독료가 발생합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 2",
|
||||
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "환불 정책은 어떻게 되나요?",
|
||||
answer: "결제 후 7일 이내에 사용 이력이 없는 경우 전액 환불 가능합니다.",
|
||||
},
|
||||
{
|
||||
question: "자주 묻는 질문 3",
|
||||
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
|
||||
question: "팀원 초대 기능이 있나요?",
|
||||
answer: "네, 프로 요금제 이상부터 팀원 초대가 가능합니다.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -55,12 +91,14 @@ export function createFaqTemplateBlocks(opts: {
|
||||
text: pair.question,
|
||||
align: "left",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const answerProps: TextBlockProps = {
|
||||
text: pair.answer,
|
||||
align: "left",
|
||||
size: "sm",
|
||||
size: "base",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const questionBlock: Block = {
|
||||
@@ -68,7 +106,7 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: questionProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
const answerBlock: Block = {
|
||||
@@ -76,13 +114,13 @@ export function createFaqTemplateBlocks(opts: {
|
||||
type: "text",
|
||||
props: answerProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: rightColumnId,
|
||||
};
|
||||
|
||||
return [questionBlock, answerBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...faqBlocks];
|
||||
const blocks: Block[] = [sectionBlock, titleBlock, subTitleBlock, ...faqBlocks];
|
||||
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -9,15 +9,17 @@ export function createFooterTemplateBlocks(opts: {
|
||||
const { sectionId, createId } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 12 },
|
||||
{ id: `${sectionId}_col_1`, span: 4 },
|
||||
{ id: `${sectionId}_col_2`, span: 4 },
|
||||
{ id: `${sectionId}_col_3`, span: 4 },
|
||||
];
|
||||
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "muted",
|
||||
paddingY: "md",
|
||||
// 푸터는 상대적으로 낮은 높이와 좁은 최대 폭을 사용한다.
|
||||
paddingYPx: 40,
|
||||
maxWidthPx: 800,
|
||||
paddingYPx: 64,
|
||||
maxWidthPx: 1120,
|
||||
backgroundColorCustom: "#020617",
|
||||
columns,
|
||||
} as any;
|
||||
@@ -30,37 +32,74 @@ export function createFooterTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const firstColumnId = `${sectionId}_col_1`;
|
||||
const col1Id = `${sectionId}_col_1`;
|
||||
const col2Id = `${sectionId}_col_2`;
|
||||
const col3Id = `${sectionId}_col_3`;
|
||||
|
||||
// Col 1: Brand
|
||||
const brandId = createId();
|
||||
const brandProps: TextBlockProps = {
|
||||
text: "MyLanding",
|
||||
align: "left",
|
||||
size: "xl",
|
||||
bold: true,
|
||||
} as any;
|
||||
const brandBlock: Block = {
|
||||
id: brandId,
|
||||
type: "text",
|
||||
props: brandProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
const descId = createId();
|
||||
const descProps: TextBlockProps = {
|
||||
text: "더 나은 웹사이트를 위한 최고의 선택.",
|
||||
align: "left",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const descBlock: Block = {
|
||||
id: descId,
|
||||
type: "text",
|
||||
props: descProps,
|
||||
sectionId,
|
||||
columnId: col1Id,
|
||||
};
|
||||
|
||||
// Col 2: Links
|
||||
const linksId = createId();
|
||||
const linksProps: TextBlockProps = {
|
||||
text: "이용약관 · 개인정보처리방침",
|
||||
text: "서비스 소개\n요금제\n고객지원\n문의하기",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const linksBlock: Block = {
|
||||
id: linksId,
|
||||
type: "text",
|
||||
props: linksProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col2Id,
|
||||
};
|
||||
|
||||
// Col 3: Copyright
|
||||
const copyrightId = createId();
|
||||
const copyrightProps: TextBlockProps = {
|
||||
text: "© 2025 MyLanding. All rights reserved.",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
text: "© 2025 MyLanding.\nAll rights reserved.",
|
||||
align: "right",
|
||||
size: "xs",
|
||||
color: "muted",
|
||||
} as any;
|
||||
const copyrightBlock: Block = {
|
||||
id: copyrightId,
|
||||
type: "text",
|
||||
props: copyrightProps,
|
||||
sectionId,
|
||||
columnId: firstColumnId,
|
||||
columnId: col3Id,
|
||||
};
|
||||
|
||||
const blocks: Block[] = [sectionBlock, linksBlock, copyrightBlock];
|
||||
const blocks: Block[] = [sectionBlock, brandBlock, descBlock, linksBlock, copyrightBlock];
|
||||
const lastSelectedId = copyrightId;
|
||||
|
||||
return { blocks, lastSelectedId };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createPricingTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -33,47 +33,106 @@ export function createPricingTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const planDefinitions: Array<{ name: string; price: string }> = [
|
||||
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
|
||||
{ name: "Basic", price: "₩9,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월", popular: true },
|
||||
{ name: "Enterprise", price: "문의" },
|
||||
];
|
||||
|
||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
||||
const isPopular = !!plan.popular;
|
||||
|
||||
const blocksInCol: Block[] = [];
|
||||
|
||||
// Popular Badge (only for middle)
|
||||
if (isPopular) {
|
||||
const badgeId = createId();
|
||||
const badgeProps: TextBlockProps = {
|
||||
text: "MOST POPULAR",
|
||||
align: "center",
|
||||
size: "xs",
|
||||
color: "primary",
|
||||
bold: true,
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: badgeId,
|
||||
type: "text",
|
||||
props: badgeProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
}
|
||||
|
||||
const nameId = createId();
|
||||
const priceId = createId();
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: plan.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "base",
|
||||
} as any;
|
||||
|
||||
const nameBlock: Block = {
|
||||
blocksInCol.push({
|
||||
id: nameId,
|
||||
type: "text",
|
||||
props: nameProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
const priceBlock: Block = {
|
||||
const priceId = createId();
|
||||
const priceProps: TextBlockProps = {
|
||||
text: plan.price,
|
||||
align: "center",
|
||||
size: "xl", // Price larger
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: priceId,
|
||||
type: "text",
|
||||
props: priceProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
});
|
||||
|
||||
return [nameBlock, priceBlock];
|
||||
// Features list (simple text for now)
|
||||
const featuresId = createId();
|
||||
const featuresProps: TextBlockProps = {
|
||||
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
blocksInCol.push({
|
||||
id: featuresId,
|
||||
type: "text",
|
||||
props: featuresProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
// Button
|
||||
const buttonId = createId();
|
||||
const buttonProps: ButtonBlockProps = {
|
||||
label: isPopular ? "시작하기" : "선택하기",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
variant: isPopular ? "solid" : "outline",
|
||||
colorPalette: "primary",
|
||||
fullWidth: true,
|
||||
borderRadius: "md",
|
||||
textColorCustom: isPopular ? "#0b1120" : "#e2e8f0",
|
||||
fillColorCustom: isPopular ? "#0ea5e9" : "transparent",
|
||||
strokeColorCustom: "#0ea5e9",
|
||||
};
|
||||
blocksInCol.push({
|
||||
id: buttonId,
|
||||
type: "button",
|
||||
props: buttonProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
});
|
||||
|
||||
return blocksInCol;
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
export function createTeamTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
@@ -42,28 +42,49 @@ export function createTeamTemplateBlocks(opts: {
|
||||
const teamBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const m = memberDefinitions[index] ?? memberDefinitions[0];
|
||||
|
||||
const imageId = createId();
|
||||
const nameId = createId();
|
||||
const roleId = createId();
|
||||
const bioId = createId();
|
||||
|
||||
const imageProps: ImageBlockProps = {
|
||||
src: "https://via.placeholder.com/150x150/334155/94a3b8?text=User",
|
||||
alt: m.name,
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 120,
|
||||
borderRadius: "full",
|
||||
};
|
||||
|
||||
const nameProps: TextBlockProps = {
|
||||
text: m.name,
|
||||
align: "center",
|
||||
size: "lg",
|
||||
bold: true,
|
||||
} as any;
|
||||
|
||||
const roleProps: TextBlockProps = {
|
||||
text: m.role,
|
||||
align: "center",
|
||||
size: "base",
|
||||
color: "primary",
|
||||
} as any;
|
||||
|
||||
const bioProps: TextBlockProps = {
|
||||
text: m.bio,
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
} as any;
|
||||
|
||||
const imageBlock: Block = {
|
||||
id: imageId,
|
||||
type: "image",
|
||||
props: imageProps,
|
||||
sectionId,
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
const nameBlock: Block = {
|
||||
id: nameId,
|
||||
type: "text",
|
||||
@@ -88,7 +109,7 @@ export function createTeamTemplateBlocks(opts: {
|
||||
columnId: col.id,
|
||||
};
|
||||
|
||||
return [nameBlock, roleBlock, bioBlock];
|
||||
return [imageBlock, nameBlock, roleBlock, bioBlock];
|
||||
});
|
||||
|
||||
const blocks: Block[] = [sectionBlock, ...teamBlocks];
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export const metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="ko">
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-slate-950 text-slate-50">
|
||||
{children}
|
||||
</body>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
|
||||
@@ -641,6 +684,12 @@ export interface ProjectConfig {
|
||||
bodyBgColorHex?: string;
|
||||
headHtml?: string;
|
||||
trackingScript?: string;
|
||||
// SEO / 메타 설정 (15.1)
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
seoOgImageUrl?: string;
|
||||
seoCanonicalUrl?: string;
|
||||
seoNoIndex?: boolean;
|
||||
}
|
||||
|
||||
// 공통 블록 모델
|
||||
@@ -651,6 +700,7 @@ export interface Block {
|
||||
| TextBlockProps
|
||||
| ButtonBlockProps
|
||||
| ImageBlockProps
|
||||
| VideoBlockProps
|
||||
| SectionBlockProps
|
||||
| DividerBlockProps
|
||||
| ListBlockProps
|
||||
@@ -676,6 +726,7 @@ export interface EditorState {
|
||||
addTextBlock: () => void;
|
||||
addButtonBlock: () => void;
|
||||
addImageBlock: () => void;
|
||||
addVideoBlock: () => void;
|
||||
addDividerBlock: () => void;
|
||||
addListBlock: () => void;
|
||||
addSectionBlock: () => void;
|
||||
@@ -758,6 +809,12 @@ const createEditorState = (set: any, get: any): EditorState => ({
|
||||
bodyBgColorHex: "#020617",
|
||||
headHtml: "",
|
||||
trackingScript: "",
|
||||
// SEO 기본값: 필요할 때만 값을 채우고, 없으면 title 등을 사용한다.
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
seoOgImageUrl: "",
|
||||
seoCanonicalUrl: "",
|
||||
seoNoIndex: false,
|
||||
},
|
||||
|
||||
updateProjectConfig: (partial: Partial<ProjectConfig>) => {
|
||||
@@ -1182,6 +1239,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,916 @@
|
||||
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;
|
||||
|
||||
// FormBlock 은 레이아웃/스타일을 가지지 않는 순수 컨트롤러이므로
|
||||
// formClassName 은 고정 기본값만 사용하고, formStyle 은 항상 빈 객체로 유지한다.
|
||||
const formClassNames = ["space-y-3"];
|
||||
const formStyle: CSSProperties = {};
|
||||
|
||||
return {
|
||||
fields,
|
||||
formClassName: formClassNames.join(" "),
|
||||
formStyle,
|
||||
submitLabel: mappedSubmitLabel,
|
||||
};
|
||||
};
|
||||
|
||||
export interface FormBlockExportTokens {
|
||||
hasControllerFields: boolean;
|
||||
controllerFields: Block[];
|
||||
fallbackFields: FormFieldConfig[];
|
||||
formStyleParts: string[];
|
||||
}
|
||||
|
||||
export const computeFormBlockExportTokens = (
|
||||
formBlock: Block,
|
||||
blocks: Block[],
|
||||
): FormBlockExportTokens => {
|
||||
const props = formBlock.props as FormBlockProps;
|
||||
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
const controllerFields = fieldIds
|
||||
.map((id) => blocks.find((b) => b.id === id))
|
||||
.filter((b): b is Block => Boolean(b))
|
||||
.filter(
|
||||
(b) =>
|
||||
b.type === "formInput" ||
|
||||
b.type === "formSelect" ||
|
||||
b.type === "formCheckbox" ||
|
||||
b.type === "formRadio",
|
||||
);
|
||||
|
||||
let fallbackFields: FormFieldConfig[] = [];
|
||||
if (Array.isArray(props.fields) && props.fields.length > 0) {
|
||||
fallbackFields = props.fields;
|
||||
}
|
||||
|
||||
// FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다.
|
||||
const formStyleParts: string[] = [];
|
||||
|
||||
return {
|
||||
hasControllerFields: controllerFields.length > 0,
|
||||
controllerFields,
|
||||
fallbackFields,
|
||||
formStyleParts,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +71,17 @@ body {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
/* Section background video */
|
||||
.pb-section-bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text alignment */
|
||||
.pb-text-left {
|
||||
text-align: left;
|
||||
@@ -213,6 +224,38 @@ body {
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
.pb-video-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pb-video-wrapper--4by3 {
|
||||
padding-bottom: 75%;
|
||||
}
|
||||
|
||||
.pb-video-wrapper--1by1 {
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.pb-video-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.pb-video-caption {
|
||||
display: none;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.pb-btn-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/export/preview TDD:
|
||||
// - blocks + projectConfig 를 받아 HTML 문자열을 바로 반환하는 경량 엔드포인트.
|
||||
// - 기존 buildStaticHtml 과 동일한 HTML 을 반환해야 한다.
|
||||
|
||||
describe("/api/export/preview", () => {
|
||||
it("POST /api/export/preview 는 blocks + projectConfig 로부터 HTML 문자열을 반환해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 테스트",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 페이지",
|
||||
slug: "export-preview",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
|
||||
|
||||
const html = await res.text();
|
||||
|
||||
expect(html).toContain("<title>Export 미리보기 페이지</title>");
|
||||
expect(html).toContain("Export 미리보기 테스트");
|
||||
});
|
||||
|
||||
it("/api/export/preview 의 HTML 은 동일 입력에 대해 buildStaticHtml 결과와 동일해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_preview_compare",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Preview vs Export 동일성 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "동일성 테스트 페이지",
|
||||
slug: "preview-equality-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handlePreview } = await import("@/app/api/export/preview/route");
|
||||
|
||||
const res = await handlePreview(
|
||||
new Request(`${BASE_URL}/api/export/preview`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
const previewHtml = await res.text();
|
||||
const staticHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(previewHtml).toBe(staticHtml);
|
||||
});
|
||||
});
|
||||
+858
-4
@@ -7,6 +7,7 @@ import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
|
||||
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
|
||||
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
|
||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
||||
import { buildStaticHtml } from "@/app/api/export/route";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
@@ -73,6 +74,144 @@ describe("/api/export", () => {
|
||||
expect(html).toContain('style="background-color:#111111;margin:0;padding:0;"');
|
||||
});
|
||||
|
||||
it("업로드 디렉터리에 이미지 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
||||
const missingImageId = `missing-image-${Date.now()}`;
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_image_missing",
|
||||
type: "image",
|
||||
props: {
|
||||
src: `/api/image/${missingImageId}`,
|
||||
alt: "누락된 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "이미지 파일 누락 내보내기 테스트",
|
||||
slug: "image-missing-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
// 업로드 파일이 없으므로 images/ 디렉터리에 엔트리는 없어야 한다.
|
||||
const imageEntry = zip.file(`images/${missingImageId}`);
|
||||
expect(imageEntry).toBeNull();
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
// HTML 은 원본 /api/image 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다.
|
||||
expect(html).toContain(`/api/image/${missingImageId}`);
|
||||
});
|
||||
|
||||
it("업로드 디렉터리에 비디오 파일이 없어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
||||
const missingVideoId = `missing-video-${Date.now()}`;
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_missing",
|
||||
type: "video",
|
||||
props: {
|
||||
sourceUrl: `/api/video/${missingVideoId}`,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 파일 누락 내보내기 테스트",
|
||||
slug: "video-missing-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const videoEntry = zip.file(`videos/${missingVideoId}`);
|
||||
expect(videoEntry).toBeNull();
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
// HTML 은 원본 /api/video 경로를 그대로 유지하거나, 최소한 오류 없이 렌더되어야 한다.
|
||||
expect(html).toContain(`/api/video/${missingVideoId}`);
|
||||
});
|
||||
|
||||
it("video 블록의 sourceUrl 이 비어 있어도 ZIP 생성은 실패하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_empty_source",
|
||||
type: "video",
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 sourceUrl 빈 값 내보내기 테스트",
|
||||
slug: "video-empty-source-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
// sourceUrl 이 비어 있어도 index.html 은 정상 생성되어야 한다.
|
||||
expect(html).toContain("<video");
|
||||
});
|
||||
|
||||
it("canvasPreset / canvasWidthPx / 배경색은 정적 HTML 캔버스 wrapper 및 body 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
@@ -183,6 +322,88 @@ describe("/api/export", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seoTitle/seoDescription/seoCanonicalUrl/seoOgImageUrl/seoNoIndex 가 설정된 경우 head 에 SEO 메타 태그들이 생성되어야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "기본 타이틀",
|
||||
slug: "seo-meta-test",
|
||||
canvasPreset: "full",
|
||||
seoTitle: "SEO 타이틀",
|
||||
seoDescription: "SEO 설명",
|
||||
seoOgImageUrl: "https://example.com/og.png",
|
||||
seoCanonicalUrl: "https://example.com/landing",
|
||||
seoNoIndex: true,
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toContain("<title>SEO 타이틀</title>");
|
||||
expect(html).toContain('meta name="description" content="SEO 설명"');
|
||||
expect(html).toContain('meta property="og:title" content="SEO 타이틀"');
|
||||
expect(html).toContain('meta property="og:description" content="SEO 설명"');
|
||||
expect(html).toContain('meta property="og:type" content="website"');
|
||||
expect(html).toContain('meta property="og:image" content="https://example.com/og.png"');
|
||||
expect(html).toContain('meta property="og:url" content="https://example.com/landing"');
|
||||
|
||||
expect(html).toContain('meta name="twitter:card" content="summary_large_image"');
|
||||
expect(html).toContain('meta name="twitter:title" content="SEO 타이틀"');
|
||||
expect(html).toContain('meta name="twitter:description" content="SEO 설명"');
|
||||
expect(html).toContain('meta name="twitter:image" content="https://example.com/og.png"');
|
||||
|
||||
expect(html).toContain('<link rel="canonical" href="https://example.com/landing" />');
|
||||
expect(html).toContain('<meta name="robots" content="noindex, nofollow" />');
|
||||
});
|
||||
|
||||
it("기본 SEO 메타 태그들 이후에 projectConfig.headHtml 이 head 에 추가되어야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "헤드 SEO 순서 테스트",
|
||||
slug: "head-seo-order-test",
|
||||
canvasPreset: "full",
|
||||
seoTitle: "SEO 타이틀",
|
||||
seoDescription: "SEO 설명",
|
||||
headHtml: '<meta name="custom" content="custom-meta" />',
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
const headStart = html.indexOf("<head>");
|
||||
const headEnd = html.indexOf("</head>");
|
||||
const head = html.slice(headStart, headEnd);
|
||||
|
||||
const descriptionIndex = head.indexOf('meta name="description"');
|
||||
const customIndex = head.indexOf('meta name="custom" content="custom-meta"');
|
||||
|
||||
expect(descriptionIndex).toBeGreaterThan(-1);
|
||||
expect(customIndex).toBeGreaterThan(-1);
|
||||
expect(customIndex).toBeGreaterThan(descriptionIndex);
|
||||
});
|
||||
|
||||
it("SEO 필드 값에 특수 문자가 포함되어도 HTML 이 깨지지 않고 이스케이프되어야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: '기본 & "타이틀" <테스트>',
|
||||
slug: "seo-escape-test",
|
||||
canvasPreset: "full",
|
||||
seoTitle: 'SEO & "타이틀" <테스트>',
|
||||
seoDescription: '설명 & "디스크립션" <테스트>',
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toContain(
|
||||
"<title>SEO & "타이틀" <테스트></title>",
|
||||
);
|
||||
expect(html).toContain(
|
||||
'meta name="description" content="설명 & "디스크립션" <테스트>"',
|
||||
);
|
||||
expect(html).toContain(
|
||||
'meta property="og:title" content="SEO & "타이틀" <테스트>"',
|
||||
);
|
||||
});
|
||||
|
||||
it("폼 블록과 컨트롤러 필드 블록들은 정적 HTML에서도 폼 요소로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
@@ -259,6 +480,55 @@ describe("/api/export", () => {
|
||||
expect(html).not.toMatch(/name=\"plan\"[^>]*required/);
|
||||
});
|
||||
|
||||
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 기본 폼/필드를 내보내지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_fallback_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
// fieldIds, fields 를 모두 생략해 fallback 경로를 타게 한다.
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 fallback 내보내기 테스트",
|
||||
slug: "form-fallback-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
|
||||
// 빈 FormBlock 은 컨트롤러 역할만 하고, 정적 HTML 에서는 기본 폼/필드를 생성하지 않아야 한다.
|
||||
expect(html).not.toContain("<form");
|
||||
expect(html).not.toMatch(/name=\"name\"/);
|
||||
expect(html).not.toMatch(/name=\"email\"/);
|
||||
expect(html).not.toMatch(/name=\"message\"/);
|
||||
});
|
||||
|
||||
it("/api/image/:id 기반 이미지 URL은 ZIP 안 images/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
||||
const imageId = `test-image-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
@@ -312,6 +582,460 @@ describe("/api/export", () => {
|
||||
expect(html).not.toContain(`/api/image/${imageId}`);
|
||||
});
|
||||
|
||||
it("비디오 포스터 이미지 /api/image/:id 는 ZIP 안 images/ 로 복사되고 poster 경로가 상대 경로로 치환되어야 한다", async () => {
|
||||
const posterId = `test-video-poster-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uploadDir, posterId), Buffer.from("dummy-poster"));
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_poster",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://example.com/video.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
posterImageSrc: `/api/image/${posterId}`,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 포스터 내보내기 테스트",
|
||||
slug: "video-poster-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const posterEntry = zip.file(`images/${posterId}`);
|
||||
expect(posterEntry).toBeTruthy();
|
||||
|
||||
const posterBytes = await posterEntry!.async("nodebuffer");
|
||||
expect(posterBytes.length).toBeGreaterThan(0);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain(`poster=\"./images/${posterId}\"`);
|
||||
expect(html).not.toContain(`/api/image/${posterId}`);
|
||||
});
|
||||
|
||||
it("HTML5 비디오 startTimeSec/endTimeSec 는 정적 HTML video 태그 data-start-seconds/data-end-seconds 속성으로 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_range",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://example.com/video-range.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
startTimeSec: 5,
|
||||
endTimeSec: 12,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 시작/종료 시점 내보내기 테스트",
|
||||
slug: "video-range-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain("data-start-seconds=\"5\"");
|
||||
expect(html).toContain("data-end-seconds=\"12\"");
|
||||
});
|
||||
|
||||
it("섹션 backgroundImageSrc 에 /api/image/:id 가 포함된 경우에도 ZIP images/ 및 상대 경로로 치환되어야 한다", async () => {
|
||||
const imageId = `test-section-bg-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uploadDir, imageId), Buffer.from("dummy-image"));
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_col_1", span: 12 }],
|
||||
backgroundImageSrc: `/api/image/${imageId}`,
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePosition: "center",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 배경 이미지 내보내기 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg",
|
||||
columnId: "sec_bg_col_1",
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 이미지 내보내기 테스트",
|
||||
slug: "section-bg-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const imageEntry = zip.file(`images/${imageId}`);
|
||||
expect(imageEntry).toBeTruthy();
|
||||
|
||||
const imageBytes = await imageEntry!.async("nodebuffer");
|
||||
expect(imageBytes.length).toBeGreaterThan(0);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain(`background-image:url(./images/${imageId})`);
|
||||
expect(html).not.toContain(`/api/image/${imageId}`);
|
||||
});
|
||||
|
||||
it("섹션 backgroundImageRepeat 가 설정되면 정적 HTML style 에 background-repeat 이 포함되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_repeat",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_repeat_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/pattern.png",
|
||||
backgroundImageSize: "auto",
|
||||
backgroundImageRepeat: "repeat-x",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 반복 테스트",
|
||||
slug: "section-bg-repeat-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain("background-repeat:repeat-x");
|
||||
});
|
||||
|
||||
it("섹션 backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 정적 HTML background-position 에 'X% Y%' 가 포함되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_xy",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_xy_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/section-bg-xy.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 10,
|
||||
backgroundImagePositionYPercent: 90,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 XY 위치 테스트",
|
||||
slug: "section-bg-xy-position-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain("background-position:10% 90%");
|
||||
});
|
||||
|
||||
it("HTML5 비디오 ariaLabel/captionText 는 정적 HTML video 태그 aria-label 및 pb-video-caption 으로 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_a11y_html5",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://example.com/video-a11y.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
ariaLabel: "정적 내보내기 접근성 비디오 설명",
|
||||
captionText: "정적 내보내기 비디오 캡션입니다.",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 접근성 HTML5 내보내기 테스트",
|
||||
slug: "video-a11y-html5-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain('aria-label="정적 내보내기 접근성 비디오 설명"');
|
||||
expect(html).toContain('<p class="pb-video-caption">정적 내보내기 비디오 캡션입니다.</p>');
|
||||
});
|
||||
|
||||
it("titleText 가 설정된 YouTube 비디오는 정적 HTML iframe title 로 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video_a11y_youtube",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "정적 내보내기 YouTube 비디오 제목",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 접근성 YouTube 내보내기 테스트",
|
||||
slug: "video-a11y-youtube-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain('iframe title="정적 내보내기 YouTube 비디오 제목"');
|
||||
});
|
||||
|
||||
it("/api/video/:id 기반 비디오 URL은 ZIP 안 videos/ 디렉터리로 복사되고 HTML에서 상대 경로로 치환되어야 한다", async () => {
|
||||
const videoId = `test-video-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-video"));
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "blk_video",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: `/api/video/${videoId}`,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "비디오 내보내기 테스트",
|
||||
slug: "video-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const videoEntry = zip.file(`videos/${videoId}`);
|
||||
expect(videoEntry).toBeTruthy();
|
||||
|
||||
const videoBytes = await videoEntry!.async("nodebuffer");
|
||||
expect(videoBytes.length).toBeGreaterThan(0);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain(`src="./videos/${videoId}`);
|
||||
expect(html).not.toContain(`/api/video/${videoId}`);
|
||||
// 비디오 카드 스타일이 wrapper style 에 반영되어야 한다.
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("padding:24px");
|
||||
expect(html).toContain("border-radius:16px");
|
||||
});
|
||||
|
||||
it("섹션 backgroundVideoSrc 가 /api/video/:id 인 경우 ZIP videos/ 및 상대 경로로 치환되고 섹션 안에 배경 비디오 video 태그로 렌더되어야 한다", async () => {
|
||||
const videoId = `test-section-bg-video-${Date.now()}`;
|
||||
const uploadDir = path.join(process.cwd(), "uploads");
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
await fs.writeFile(path.join(uploadDir, videoId), Buffer.from("dummy-section-video"));
|
||||
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_video",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
||||
backgroundVideoSrc: `/api/video/${videoId}`,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_video_text",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_video",
|
||||
columnId: "sec_bg_video_col_1",
|
||||
props: {
|
||||
text: "섹션 배경 비디오 내보내기 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 비디오 내보내기 테스트",
|
||||
slug: "section-bg-video-export-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(buffer);
|
||||
|
||||
const videoEntry = zip.file(`videos/${videoId}`);
|
||||
expect(videoEntry).toBeTruthy();
|
||||
|
||||
const videoBytes = await videoEntry!.async("nodebuffer");
|
||||
expect(videoBytes.length).toBeGreaterThan(0);
|
||||
|
||||
const html = await zip.file("index.html")!.async("string");
|
||||
expect(html).toContain(`src="./videos/${videoId}`);
|
||||
expect(html).not.toContain(`/api/video/${videoId}`);
|
||||
expect(html).toContain("class=\"pb-section-bg-video\"");
|
||||
});
|
||||
|
||||
it("divider 블록은 정적 HTML에서 구분선 요소로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
@@ -723,6 +1447,49 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
|
||||
expect(html).toContain("CTA 버튼");
|
||||
});
|
||||
|
||||
it("Footer 템플릿 섹션은 export HTML 의 마지막 섹션으로 렌더되고 푸터 텍스트를 포함해야 한다", async () => {
|
||||
const sectionId = "footer_section_export";
|
||||
const { blocks } = createFooterTemplateBlocks({ sectionId, createId: createIdFactory() });
|
||||
|
||||
const html = await exportTemplateHtml(blocks as Block[]);
|
||||
|
||||
expect(html).toContain("MyLanding");
|
||||
expect(html).toContain("더 나은 웹사이트를 위한 최고의 선택.");
|
||||
expect(html).toContain("서비스 소개");
|
||||
expect(html).toContain("문의하기");
|
||||
expect(html).toContain(" 2025 MyLanding.");
|
||||
|
||||
const lastSectionIndex = html.lastIndexOf("<section");
|
||||
expect(lastSectionIndex).toBeGreaterThan(-1);
|
||||
const lastSectionHtml = html.slice(lastSectionIndex);
|
||||
expect(lastSectionHtml).toContain(" 2025 MyLanding.");
|
||||
});
|
||||
|
||||
it("루트 버튼 내비게이션 링크는 Export HTML 에서 동일한 라벨과 href 로 렌더되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "nav_btn_1",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "요금제",
|
||||
href: "/pricing",
|
||||
align: "left",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "내비게이션 링크 테스트",
|
||||
slug: "nav-link-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toContain('<a href="/pricing"');
|
||||
expect(html).toContain(">요금제</a>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("스타일 테스트", () => {
|
||||
@@ -968,7 +1735,7 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("background-color:#00ff88");
|
||||
});
|
||||
|
||||
it("backgroundColorCustom 이 지정된 폼 블록은 정적 HTML에서 form 요소 배경색을 반영해야 한다", async () => {
|
||||
it("backgroundColorCustom 이 지정된 폼 블록은 정적 HTML에서 form 요소 배경색을 반영하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_bg_custom",
|
||||
@@ -976,7 +1743,16 @@ describe("/api/export", () => {
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
// v1 fields 설정이 있는 FormBlock 은 여전히 정적 HTML 폼으로 렌더되어야 한다.
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
fieldIds: [],
|
||||
formWidthMode: "auto",
|
||||
backgroundColorCustom: "#111111",
|
||||
@@ -1012,7 +1788,7 @@ describe("/api/export", () => {
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("<form");
|
||||
expect(html).toContain("background-color:#111111");
|
||||
expect(html).not.toContain("background-color:#111111");
|
||||
});
|
||||
|
||||
it("backgroundColorCustom 이 지정된 섹션 블록은 정적 HTML에서 섹션 요소 배경색을 인라인 스타일로 반영해야 한다", async () => {
|
||||
@@ -1072,6 +1848,77 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("background-color:#123456");
|
||||
});
|
||||
|
||||
it("루트/섹션 레이아웃은 pb-root / pb-section-inner / pb-section-columns 구조를 사용해야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "root_layout_text",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "루트 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_layout_1",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_layout_1_col_1", span: 12 }],
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_layout_1_text",
|
||||
type: "text",
|
||||
sectionId: "sec_layout_1",
|
||||
columnId: "sec_layout_1_col_1",
|
||||
props: {
|
||||
text: "섹션 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "레이아웃 구조 테스트",
|
||||
slug: "layout-structure-test",
|
||||
canvasPreset: "full",
|
||||
};
|
||||
|
||||
const payload = { blocks, projectConfig };
|
||||
|
||||
const { POST: handleExport } = await import("@/app/api/export/route");
|
||||
|
||||
const res = await handleExport(
|
||||
new Request(`${BASE_URL}/api/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
const indexEntry = zip.file("index.html");
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
// 루트 텍스트는 pb-root / pb-root-inner 안에 포함되어야 한다.
|
||||
expect(html).toContain('<section class="pb-root"><div class="pb-root-inner">');
|
||||
expect(html).toContain("루트 레이아웃 텍스트");
|
||||
// 섹션 레이아웃 구조: pb-section + pb-section-inner + pb-section-columns + pb-section-column
|
||||
expect(html).toContain('class="pb-section');
|
||||
expect(html).toContain('class="pb-section-inner"');
|
||||
expect(html).toContain('class="pb-section-columns"');
|
||||
expect(html).toContain('class="pb-section-column"');
|
||||
expect(html).toContain("섹션 레이아웃 텍스트");
|
||||
});
|
||||
|
||||
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
@@ -1113,7 +1960,7 @@ describe("/api/export", () => {
|
||||
expect(indexEntry).toBeTruthy();
|
||||
|
||||
const html = await indexEntry!.async("string");
|
||||
expect(html).toContain("name=\"email\"");
|
||||
expect(html).toContain('name="email"');
|
||||
expect(html).toContain("color:#123456");
|
||||
});
|
||||
|
||||
@@ -1695,6 +2542,13 @@ describe("/api/export", () => {
|
||||
expect(css).toContain(".pb-list");
|
||||
expect(css).toContain(".pb-whitespace-pre-wrap");
|
||||
expect(css).toContain("color: var(--pb-color-text-strong)");
|
||||
// 레이아웃 관련 유틸리티 클래스도 고정해 둔다.
|
||||
expect(css).toContain(".pb-root-inner");
|
||||
expect(css).toContain("max-width: 72rem");
|
||||
expect(css).toContain(".pb-section-inner");
|
||||
expect(css).toContain(".pb-section-columns");
|
||||
expect(css).toContain("gap: 2rem");
|
||||
expect(css).toContain(".pb-section-column");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
// /api/video 업로드 TDD
|
||||
// - POST /api/video 로 FormData(file) 를 전송하면 업로드 파일이 uploads 디렉터리에 저장되고
|
||||
// id / servedUrl(`/api/video/:id`) 을 반환해야 한다.
|
||||
|
||||
// Prisma 엔진이 실제로 로드되지 않도록 PrismaClient 를 목 처리한다.
|
||||
vi.mock("@/generated/prisma/client", () => {
|
||||
class PrismaClient {
|
||||
project = {
|
||||
upsert: async () => ({ id: "project-mock" }),
|
||||
};
|
||||
|
||||
asset = {
|
||||
create: async () => ({ id: "asset-mock" }),
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient };
|
||||
});
|
||||
|
||||
describe("/api/video 업로드", () => {
|
||||
it("POST /api/video 는 업로드된 비디오 파일을 저장하고 id/servedUrl 을 반환해야 한다", async () => {
|
||||
const { POST: handleUpload } = await import("@/app/api/video/route");
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(["dummy-video"], { type: "video/mp4" });
|
||||
// FormData 에서는 File/Blob 모두 허용되므로, Blob 을 그대로 사용한다.
|
||||
formData.append("file", blob as any);
|
||||
|
||||
const res = await handleUpload(
|
||||
new Request(`${BASE_URL}/api/video`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const json = (await res.json()) as { id: string; servedUrl?: string | null };
|
||||
expect(json.id).toBeTruthy();
|
||||
expect(json.servedUrl).toBe(`/api/video/${json.id}`);
|
||||
|
||||
const filePath = path.join(process.cwd(), "uploads", json.id);
|
||||
const stat = await fs.stat(filePath);
|
||||
expect(stat.isFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const otherActions = {
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addVideoBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
@@ -110,6 +111,15 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
|
||||
expect(screen.getByText("페이지 푸터 섹션"));
|
||||
});
|
||||
|
||||
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("템플릿들은 카테고리별로 그룹 헤더를 가져야 한다", () => {
|
||||
render(<BlocksSidebar />);
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
// EditorPage Export 미리보기 UX TDD
|
||||
// - 상단 메뉴에 "Export 미리보기" 항목이 노출되는지 검증한다.
|
||||
// - Export 미리보기 모달에서 "미리보기 새로고침" 클릭 시
|
||||
// - /api/export/preview 엔드포인트를 호출하고,
|
||||
// - 반환된 HTML 을 iframe srcdoc 으로 렌더하는지 확인한다.
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "Export 미리보기 테스트",
|
||||
slug: "export-preview-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [
|
||||
{
|
||||
id: "blk_text_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "Export 미리보기 본문",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
},
|
||||
] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
describe("EditorPage - Export 미리보기", () => {
|
||||
it("메뉴에서 Export 미리보기 항목을 클릭하면 Export 미리보기 모달이 열려야 한다", () => {
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const previewMenuItem = screen.getByText("Export 미리보기");
|
||||
fireEvent.click(previewMenuItem);
|
||||
|
||||
const modalTitle = screen.getByText("Export 미리보기");
|
||||
expect(modalTitle).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Export 미리보기 새로고침 시 /api/export/preview 를 호출하고 iframe 에 HTML 을 렌더해야 한다", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
"<!DOCTYPE html><html><head><title>Export 미리보기 테스트</title></head><body>Export 미리보기 본문</body></html>",
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const menuButton = screen.getByText("메뉴");
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const previewMenuItem = screen.getByText("Export 미리보기");
|
||||
fireEvent.click(previewMenuItem);
|
||||
|
||||
const refreshButton = screen.getByText("미리보기 새로고침");
|
||||
fireEvent.click(refreshButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const [url, options] = fetchMock.mock.calls[0] as any;
|
||||
expect(url).toBe("/api/export/preview");
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const parsed = JSON.parse(options.body);
|
||||
expect(Array.isArray(parsed.blocks)).toBe(true);
|
||||
expect(parsed.blocks[0].id).toBe("blk_text_1");
|
||||
expect(parsed.projectConfig.slug).toBe("export-preview-test");
|
||||
|
||||
const iframe = await screen.findByTestId("export-preview-frame");
|
||||
const srcDoc = iframe.getAttribute("srcdoc") ?? "";
|
||||
expect(srcDoc).toContain("Export 미리보기 테스트");
|
||||
expect(srcDoc).toContain("Export 미리보기 본문");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "섹션 배경 이미지 테스트",
|
||||
slug: "section-bg-image-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
// 섹션 배경 이미지 TDD
|
||||
// - backgroundImage* 속성이 EditorPage 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
describe("EditorPage - 섹션 배경 이미지", () => {
|
||||
it("섹션에 backgroundImageSrc/size/position 이 설정되면 editor-section 스타일에 backgroundImage/Size/Position 이 반영되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_image",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundImageSrc: "/api/image/section-bg-1",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePosition: "center",
|
||||
backgroundImageRepeat: "repeat",
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_1",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 안 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_image",
|
||||
columnId: "sec_bg_image_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_image";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-1");
|
||||
expect(sectionEl.style.backgroundSize).toBe("cover");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("center center");
|
||||
expect(sectionEl.style.backgroundRepeat).toBe("repeat");
|
||||
});
|
||||
|
||||
it("backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 background-position 은 'X% Y%' 로 설정되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_image_xy",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundImageSrc: "/api/image/section-bg-xy",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 30,
|
||||
backgroundImagePositionYPercent: 70,
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_xy",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "XY 커스텀 위치 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_image_xy",
|
||||
columnId: "sec_bg_image_xy_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_image_xy";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/api/image/section-bg-xy");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("30% 70%");
|
||||
});
|
||||
|
||||
it("backgroundVideoSrc 가 설정된 섹션은 배경 비디오 video 태그가 렌더되어야 한다", () => {
|
||||
const section: Block = {
|
||||
id: "sec_bg_video",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
backgroundVideoSrc: "/videos/section-bg.mp4",
|
||||
} as any,
|
||||
} as any;
|
||||
|
||||
const child: Block = {
|
||||
id: "txt_video",
|
||||
type: "text",
|
||||
props: {
|
||||
text: "섹션 배경 비디오 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
sectionId: "sec_bg_video",
|
||||
columnId: "sec_bg_video_col_1",
|
||||
} as any;
|
||||
|
||||
mockState.blocks = [section, child];
|
||||
mockState.selectedBlockId = "sec_bg_video";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const sectionEl = screen.getByTestId("editor-section") as HTMLElement;
|
||||
expect(sectionEl).toBeTruthy();
|
||||
|
||||
const videoEl = screen.getByTestId("editor-section-bg-video") as HTMLVideoElement;
|
||||
expect(videoEl).toBeTruthy();
|
||||
expect(videoEl.src).toContain("/videos/section-bg.mp4");
|
||||
expect(videoEl.autoplay).toBe(true);
|
||||
expect(videoEl.loop).toBe(true);
|
||||
expect(videoEl.muted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import EditorPage from "@/app/editor/page";
|
||||
|
||||
let mockState: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const baseProjectConfig: ProjectConfig = {
|
||||
title: "비디오 블록 스타일 테스트",
|
||||
slug: "editor-video-style-test",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
blocks: [] as Block[],
|
||||
projectConfig: baseProjectConfig,
|
||||
selectedBlockId: null as string | null,
|
||||
selectedListItemId: null as string | null,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
removeBlock: vi.fn(),
|
||||
duplicateBlock: vi.fn(),
|
||||
selectBlock: vi.fn(),
|
||||
selectListItem: vi.fn(),
|
||||
addTextBlock: vi.fn(),
|
||||
addButtonBlock: vi.fn(),
|
||||
addImageBlock: vi.fn(),
|
||||
addDividerBlock: vi.fn(),
|
||||
addListBlock: vi.fn(),
|
||||
addSectionBlock: vi.fn(),
|
||||
addFormBlock: vi.fn(),
|
||||
addFormInputBlock: vi.fn(),
|
||||
addFormSelectBlock: vi.fn(),
|
||||
addFormCheckboxBlock: vi.fn(),
|
||||
addFormRadioBlock: vi.fn(),
|
||||
addHeroTemplateSection: vi.fn(),
|
||||
addFeaturesTemplateSection: vi.fn(),
|
||||
addCtaTemplateSection: vi.fn(),
|
||||
addFaqTemplateSection: vi.fn(),
|
||||
addPricingTemplateSection: vi.fn(),
|
||||
addTestimonialsTemplateSection: vi.fn(),
|
||||
addBlogTemplateSection: vi.fn(),
|
||||
addTeamTemplateSection: vi.fn(),
|
||||
addFooterTemplateSection: vi.fn(),
|
||||
updateBlock: vi.fn(),
|
||||
replaceBlocks: vi.fn(),
|
||||
reorderBlocks: vi.fn(),
|
||||
moveBlock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
(useEditorStore as any).getState = () => mockState;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/link", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
};
|
||||
});
|
||||
|
||||
// 단순 유튜브 URL, 업로드 비디오 URL 케이스에 대한 에디터 렌더링을 검증한다.
|
||||
describe("EditorPage - 비디오 블록 스타일", () => {
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 비율 wrapper 를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_youtube_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const iframe = screen.getByTitle("비디오") as HTMLIFrameElement;
|
||||
const wrapper = iframe.parentElement as HTMLElement;
|
||||
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe.src).toContain("youtube.com/embed");
|
||||
expect(wrapper.className).toContain("pb-video-wrapper");
|
||||
});
|
||||
|
||||
it("업로드 비디오 URL 은 video 태그로 렌더되고 controls 속성을 포함해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_upload_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/abc123",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_upload_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("controls")).not.toBeNull();
|
||||
expect(video.style.width).toBe("640px");
|
||||
});
|
||||
|
||||
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_poster_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/sample.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
posterImageSrc: "/images/sample-poster.png",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_poster_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("poster")).toBe("/images/sample-poster.png");
|
||||
});
|
||||
|
||||
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 loadedmetadata/timeupdate 이벤트에서 시작/종료 범위가 적용되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_range_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/range.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
startTimeSec: 5,
|
||||
endTimeSec: 10,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_range_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
|
||||
fireEvent(video, new Event("loadedmetadata"));
|
||||
expect(video.currentTime).toBe(5);
|
||||
|
||||
(video as any).pause = vi.fn();
|
||||
video.currentTime = 10.1;
|
||||
fireEvent(video, new Event("timeupdate"));
|
||||
expect((video as any).pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 에디터에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_a11y_html5",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/a11y.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "접근성 비디오 타이틀",
|
||||
ariaLabel: "접근성 비디오 설명",
|
||||
captionText: "비디오 캡션입니다.",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_a11y_html5";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("aria-label")).toBe("접근성 비디오 설명");
|
||||
|
||||
const caption = screen.getByTestId("editor-video-caption");
|
||||
expect(caption).toBeTruthy();
|
||||
expect(caption.textContent).toBe("비디오 캡션입니다.");
|
||||
});
|
||||
|
||||
it("sourceUrl 이 비어 있는 HTML5 비디오는 src 속성을 렌더링하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_empty_src",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_empty_src";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.getAttribute("src")).toBeNull();
|
||||
});
|
||||
|
||||
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_card_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/card-test",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
aspectRatio: "16:9",
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
mockState.blocks = blocks;
|
||||
mockState.selectedBlockId = "video_card_1";
|
||||
|
||||
render(<EditorPage />);
|
||||
|
||||
const video = screen.getByTestId("editor-video") as HTMLVideoElement;
|
||||
const wrapper = video.parentElement as HTMLElement;
|
||||
|
||||
expect(wrapper).toBeTruthy();
|
||||
// #123456 -> rgb(18, 52, 86)
|
||||
expect(wrapper.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
expect(wrapper.style.padding).toBe("24px");
|
||||
expect(wrapper.style.borderRadius).toBe("16px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ProjectPropertiesPanel } from "@/app/editor/panels/ProjectPropertiesPanel";
|
||||
import type { ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
|
||||
let mockState: { projectConfig: ProjectConfig; updateProjectConfig: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
const baseConfig: ProjectConfig = {
|
||||
title: "프로젝트",
|
||||
slug: "project",
|
||||
canvasPreset: "full",
|
||||
canvasWidthPx: 1024,
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "#020617",
|
||||
headHtml: "",
|
||||
trackingScript: "",
|
||||
// SEO 필드는 아직 구현 전이지만, TDD 를 위해 기본값 형태만 지정해 둔다.
|
||||
// 실제 타입 정의는 추후 15.1 구현에서 확장한다.
|
||||
} as ProjectConfig;
|
||||
|
||||
mockState = {
|
||||
projectConfig: baseConfig,
|
||||
updateProjectConfig: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
vi.mock("@/features/editor/state/editorStore", () => {
|
||||
const useEditorStore = (selector: (state: any) => any) => selector(mockState);
|
||||
return {
|
||||
__esModule: true,
|
||||
useEditorStore,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ProjectPropertiesPanel SEO 메타", () => {
|
||||
it("SEO 타이틀 입력을 변경하면 seoTitle 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("SEO 타이틀") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "새 SEO 타이틀" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ seoTitle: "새 SEO 타이틀" });
|
||||
});
|
||||
|
||||
it("메타 디스크립션 입력을 변경하면 seoDescription 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const textarea = screen.getByLabelText("메타 디스크립션") as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "SEO 설명" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoDescription: "SEO 설명",
|
||||
});
|
||||
});
|
||||
|
||||
it("OG/Twitter 이미지 URL 입력을 변경하면 seoOgImageUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("OG/Twitter 이미지 URL") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com/og.png" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoOgImageUrl: "https://example.com/og.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("Canonical URL 입력을 변경하면 seoCanonicalUrl 이 updateProjectConfig 로 전달되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const input = screen.getByLabelText("Canonical URL") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "https://example.com/landing" } });
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoCanonicalUrl: "https://example.com/landing",
|
||||
});
|
||||
});
|
||||
|
||||
it("검색 노출 제어 체크박스를 토글하면 seoNoIndex 가 true 로 업데이트되어야 한다", () => {
|
||||
render(<ProjectPropertiesPanel />);
|
||||
|
||||
const checkbox = screen.getByLabelText(
|
||||
"검색 엔진에 노출하지 않기 (noindex)",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({
|
||||
seoNoIndex: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 이미지 블록 TDD
|
||||
// - src 가 비어 있을 때는 <img> 를 렌더하지 않아 Next.js 경고를 피한다.
|
||||
// - src 가 비어 있지 않을 때는 정상적으로 <img> 를 렌더한다.
|
||||
|
||||
describe("PublicPageRenderer - image block", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("src 가 비어 있으면 img 요소를 렌더하지 않아야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_empty",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "",
|
||||
alt: "빈 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = screen.queryByRole("img", { name: "빈 이미지" });
|
||||
expect(img).toBeNull();
|
||||
});
|
||||
|
||||
it("src 가 비어 있지 않으면 img 요소를 렌더해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "img_non_empty",
|
||||
type: "image",
|
||||
props: {
|
||||
src: "https://example.com/test.png",
|
||||
alt: "정상 이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const img = screen.getByRole("img", { name: "정상 이미지" }) as HTMLImageElement;
|
||||
expect(img).toBeTruthy();
|
||||
expect(img.src).toContain("https://example.com/test.png");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 푸터 템플릿 TDD
|
||||
// - footerTemplate 로 생성한 섹션이 프리뷰에서 섹션/텍스트 구조로 올바르게 렌더되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 푸터 템플릿", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("푸터 템플릿 섹션은 프리뷰에서 섹션과 푸터 텍스트들을 렌더해야 한다", () => {
|
||||
const sectionId = "footer_preview_section";
|
||||
let i = 0;
|
||||
const createId = () => `footer_${++i}`;
|
||||
|
||||
const { blocks } = createFooterTemplateBlocks({ sectionId, createId });
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
const section = screen.getByTestId("preview-section");
|
||||
expect(section.getAttribute("data-section-id")).toBe(sectionId);
|
||||
|
||||
expect(screen.getByText("MyLanding")).toBeTruthy();
|
||||
expect(screen.getByText("더 나은 웹사이트를 위한 최고의 선택.")).toBeTruthy();
|
||||
expect(screen.getByText(/서비스 소개/)).toBeTruthy();
|
||||
expect(screen.getByText(/문의하기/)).toBeTruthy();
|
||||
expect(screen.getByText(/© 2025 MyLanding\./)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 섹션 배경 이미지 TDD
|
||||
// - SectionBlockProps 의 backgroundImage* 속성이 섹션 wrapper 스타일에 반영되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 섹션 배경 이미지", () => {
|
||||
it("backgroundImageSrc/size/position 이 설정된 섹션은 backgroundImage/backgroundSize/backgroundPosition 스타일을 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_image",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/section-bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-y",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_image",
|
||||
columnId: "sec_bg_image_col_1",
|
||||
props: {
|
||||
text: "섹션 배경 이미지 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getByTestId("preview-section") as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg.png");
|
||||
expect(sectionEl.style.backgroundSize).toBe("cover");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("center top");
|
||||
expect(sectionEl.style.backgroundRepeat).toBe("repeat-y");
|
||||
});
|
||||
|
||||
it("backgroundImagePositionMode 가 custom 인 섹션은 background-position 에 'X% Y%' 값이 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_image_xy",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_image_xy_col_1", span: 12 }],
|
||||
backgroundImageSrc: "/images/section-bg-xy.png",
|
||||
backgroundImageSize: "contain",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 25,
|
||||
backgroundImagePositionYPercent: 80,
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text_xy",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_image_xy",
|
||||
columnId: "sec_bg_image_xy_col_1",
|
||||
props: {
|
||||
text: "XY 커스텀 위치 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getAllByTestId("preview-section").find(
|
||||
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_image_xy",
|
||||
) as HTMLElement;
|
||||
expect(sectionEl.style.backgroundImage).toContain("/images/section-bg-xy.png");
|
||||
expect(sectionEl.style.backgroundSize).toBe("contain");
|
||||
expect(sectionEl.style.backgroundPosition).toBe("25% 80%");
|
||||
});
|
||||
|
||||
it("backgroundVideoSrc 가 설정된 섹션은 프리뷰에서도 배경 비디오 video 태그가 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "sec_bg_video",
|
||||
type: "section",
|
||||
props: {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_bg_video_col_1", span: 12 }],
|
||||
backgroundVideoSrc: "/videos/section-bg-preview.mp4",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "sec_bg_text_video",
|
||||
type: "text",
|
||||
sectionId: "sec_bg_video",
|
||||
columnId: "sec_bg_video_col_1",
|
||||
props: {
|
||||
text: "섹션 배경 비디오 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const { getAllByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const sectionEl = getAllByTestId("preview-section").find(
|
||||
(el) => (el as HTMLElement).dataset.sectionId === "sec_bg_video",
|
||||
) as HTMLElement;
|
||||
expect(sectionEl).toBeTruthy();
|
||||
|
||||
const videoEl = getAllByTestId("preview-section-bg-video")[0] as HTMLVideoElement;
|
||||
expect(videoEl).toBeTruthy();
|
||||
expect(videoEl.src).toContain("/videos/section-bg-preview.mp4");
|
||||
expect(videoEl.autoplay).toBe(true);
|
||||
expect(videoEl.loop).toBe(true);
|
||||
expect(videoEl.muted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 섹션/루트 레이아웃 TDD
|
||||
// - Export 레이어의 pb-root / pb-section-* 구조와 개념적으로 대응되는 프리뷰 레이아웃을 고정한다.
|
||||
// - 정확한 px 값 비교 대신, 주요 Tailwind 레이아웃 클래스 존재 여부를 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeSectionWithText = (sectionOverride: Partial<SectionBlockProps> = {}, textOverride: Partial<TextBlockProps> = {}): Block[] => {
|
||||
const sectionProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_layout_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
alignItems: "top",
|
||||
...sectionOverride,
|
||||
} as SectionBlockProps;
|
||||
|
||||
const textProps: TextBlockProps = {
|
||||
text: "섹션 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
...textOverride,
|
||||
} as TextBlockProps;
|
||||
|
||||
const section: Block = {
|
||||
id: "sec_layout_1",
|
||||
type: "section",
|
||||
props: sectionProps,
|
||||
} as any;
|
||||
|
||||
const text: Block = {
|
||||
id: "sec_layout_1_text",
|
||||
type: "text",
|
||||
sectionId: "sec_layout_1",
|
||||
columnId: "sec_layout_col_1",
|
||||
props: textProps,
|
||||
} as any;
|
||||
|
||||
return [section, text];
|
||||
};
|
||||
|
||||
it("섹션 레이아웃은 mx-auto / max-w / px-* 및 flex 컬럼/갭 구조로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = makeSectionWithText();
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const inner = screen.getByTestId("preview-section-inner") as HTMLDivElement;
|
||||
const columns = screen.getByTestId("preview-section-columns") as HTMLDivElement;
|
||||
|
||||
// Export 의 pb-section-inner / pb-section-columns 에 대응되는 레이아웃 구조를 최소한으로 보장한다.
|
||||
expect(inner.className).toContain("mx-auto");
|
||||
expect(inner.className).toContain("px-4");
|
||||
expect(inner.className).toMatch(/max-w-/);
|
||||
|
||||
expect(columns.className).toContain("flex");
|
||||
// gapX 기본값(md)에 대해 gap-8 이 사용되는 현재 구현을 고정한다.
|
||||
expect(columns.className).toContain("gap-8");
|
||||
|
||||
// 컬럼 래퍼는 flex-col 과 일정한 세로 간격을 가진다.
|
||||
const columnWrapper = columns.querySelector("div.flex.flex-col") as HTMLDivElement | null;
|
||||
expect(columnWrapper).not.toBeNull();
|
||||
if (columnWrapper) {
|
||||
expect(columnWrapper.className).toContain("gap-4");
|
||||
}
|
||||
|
||||
// 섹션 텍스트가 실제로 섹션 안에 렌더되는지 확인한다.
|
||||
const textEl = screen.getByText("섹션 레이아웃 텍스트");
|
||||
const section = textEl.closest("section");
|
||||
expect(section).not.toBeNull();
|
||||
});
|
||||
|
||||
it("루트 블록은 상단 섹션 내 max-width 컨테이너 안에서 렌더되어야 한다", () => {
|
||||
const rootTextProps: TextBlockProps = {
|
||||
text: "루트 레이아웃 텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
const rootTextBlock: Block = {
|
||||
id: "root_layout_text",
|
||||
type: "text",
|
||||
props: rootTextProps,
|
||||
} as any;
|
||||
|
||||
const sectionBlocks = makeSectionWithText();
|
||||
|
||||
const blocks: Block[] = [rootTextBlock, ...sectionBlocks];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const rootTextEl = screen.getByText("루트 레이아웃 텍스트") as HTMLParagraphElement;
|
||||
|
||||
// 루트 텍스트는 상단 섹션(py-12) 안에 존재해야 한다.
|
||||
const rootSection = rootTextEl.closest("section");
|
||||
expect(rootSection).not.toBeNull();
|
||||
if (!rootSection) return;
|
||||
|
||||
expect(rootSection.className).toContain("py-12");
|
||||
|
||||
// 섹션 바로 아래의 max-width 컨테이너 구조를 확인한다.
|
||||
const innerContainer = rootSection.querySelector("div");
|
||||
expect(innerContainer).not.toBeNull();
|
||||
if (!innerContainer) return;
|
||||
|
||||
const className = innerContainer.className;
|
||||
expect(className).toContain("mx-auto");
|
||||
expect(className).toContain("max-w-3xl");
|
||||
expect(className).toContain("px-4");
|
||||
expect(className).toContain("flex");
|
||||
expect(className).toContain("gap-4");
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,9 @@ describe("PublicPageRenderer - 섹션 템플릿", () => {
|
||||
render(<PublicPageRenderer blocks={blocks as Block[]} />);
|
||||
|
||||
// CTA 본문 텍스트와 버튼 라벨이 존재해야 한다.
|
||||
screen.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.");
|
||||
screen.getByText((content) =>
|
||||
content.includes("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요."),
|
||||
);
|
||||
screen.getByText("CTA 버튼");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 텍스트 블록 스타일 TDD
|
||||
// - computeTextPublicTokens 결과가 실제 프리뷰 렌더링에 올바르게 반영되는지 검증한다.
|
||||
|
||||
describe("PublicPageRenderer - 텍스트 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeTextBlock = (override: Partial<TextBlockProps> = {}): Block => {
|
||||
const base: TextBlockProps = {
|
||||
text: "퍼블릭 텍스트 스타일 테스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
return {
|
||||
id: "text_1",
|
||||
type: "text",
|
||||
props: { ...base, ...override },
|
||||
} as any;
|
||||
};
|
||||
|
||||
it("기본 텍스트 블록은 text-left / text-base 및 기본 색상으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [makeTextBlock()];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
|
||||
|
||||
expect(el.className).toContain("text-left");
|
||||
expect(el.className).toContain("text-base");
|
||||
// 기본 색상은 Tailwind text-slate-50 클래스로 고정되어 있다.
|
||||
expect(el.className).toContain("text-slate-50");
|
||||
});
|
||||
|
||||
it("fontSizeMode=custom, fontSizeCustom, colorCustom, backgroundColorCustom 이 설정되면 스타일이 인라인으로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
makeTextBlock({
|
||||
align: "center",
|
||||
fontSizeMode: "custom",
|
||||
fontSizeCustom: "24px",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
}),
|
||||
];
|
||||
|
||||
render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const el = screen.getByText("퍼블릭 텍스트 스타일 테스트") as HTMLParagraphElement;
|
||||
|
||||
// 정렬 클래스
|
||||
expect(el.className).toContain("text-center");
|
||||
// 커스텀 폰트 크기: sizeClass 는 비워지고, 인라인 스타일로 설정된다.
|
||||
expect(el.style.fontSize).toBe("24px");
|
||||
// 커스텀 색상/배경색
|
||||
expect(el.style.color).toBe("rgb(255, 0, 0)");
|
||||
expect(el.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { PublicPageRenderer } from "@/features/editor/components/PublicPageRenderer";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// PublicPageRenderer 비디오 블록 스타일 TDD
|
||||
// - YouTube URL 은 iframe + pb-video-wrapper 로 렌더링되어야 한다.
|
||||
// - 업로드(/api/video/:id) URL 은 <video> 태그로 렌더되고 widthPx 가 em 단위로 반영되어야 한다.
|
||||
|
||||
describe("PublicPageRenderer - 비디오 블록 스타일", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("YouTube URL 비디오 블록은 iframe 으로 렌더되고 기본 16:9 wrapper 를 가져야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const iframe = container.querySelector('iframe[title="비디오"]') as HTMLIFrameElement | null;
|
||||
expect(iframe).not.toBeNull();
|
||||
expect(iframe!.src).toContain("youtube.com/embed");
|
||||
|
||||
const wrapper = iframe!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper!.className).toContain("pb-video-wrapper");
|
||||
});
|
||||
|
||||
it("업로드 비디오 URL 은 video 태그로 렌더되고 widthPx 가 em 단위 너비로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_upload_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/abc123",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("controls")).not.toBeNull();
|
||||
// 640px / 16 = 40em
|
||||
expect(video!.style.width).toBe("40em");
|
||||
});
|
||||
|
||||
it("posterImageSrc 가 설정된 HTML5 비디오는 video 태그 poster 속성으로 렌더되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_poster_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/sample-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
posterImageSrc: "/images/sample-poster-preview.png",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("poster")).toBe("/images/sample-poster-preview.png");
|
||||
});
|
||||
|
||||
it("startTimeSec/endTimeSec 가 설정된 HTML5 비디오는 프리뷰에서도 loadedmetadata/timeupdate 로 시작/종료 범위가 적용되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_range_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/range-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
startTimeSec: 3,
|
||||
endTimeSec: 8,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
|
||||
fireEvent(video as HTMLVideoElement, new Event("loadedmetadata"));
|
||||
expect((video as HTMLVideoElement).currentTime).toBe(3);
|
||||
|
||||
(video as any).pause = vi.fn();
|
||||
(video as HTMLVideoElement).currentTime = 8.2;
|
||||
fireEvent(video as HTMLVideoElement, new Event("timeupdate"));
|
||||
expect((video as any).pause).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("HTML5 비디오의 ariaLabel/titleText/captionText 는 프리뷰에서 aria-label 및 캡션 요소로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_a11y_preview",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/videos/a11y-preview.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "프리뷰 접근성 타이틀",
|
||||
ariaLabel: "프리뷰 접근성 설명",
|
||||
captionText: "프리뷰 비디오 캡션입니다.",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container, getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
expect(video!.getAttribute("aria-label")).toBe("프리뷰 접근성 설명");
|
||||
|
||||
const caption = getByTestId("preview-video-caption");
|
||||
expect(caption.textContent).toBe("프리뷰 비디오 캡션입니다.");
|
||||
});
|
||||
|
||||
it("titleText 가 설정된 YouTube 비디오는 iframe title 로 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_youtube_a11y_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
titleText: "사용자 정의 비디오 제목",
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const iframe = container.querySelector('iframe[title="사용자 정의 비디오 제목"]') as HTMLIFrameElement | null;
|
||||
expect(iframe).not.toBeNull();
|
||||
});
|
||||
|
||||
it("비디오 카드 스타일 props(backgroundColorCustom/cardPaddingPx/borderRadiusPx)는 wrapper 의 스타일에 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "video_card_preview_1",
|
||||
type: "video" as any,
|
||||
props: {
|
||||
sourceUrl: "/api/video/preview-card",
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
aspectRatio: "16:9",
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 32,
|
||||
borderRadiusPx: 16,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const { container } = render(<PublicPageRenderer blocks={blocks} />);
|
||||
|
||||
const video = container.querySelector("video") as HTMLVideoElement | null;
|
||||
expect(video).not.toBeNull();
|
||||
const wrapper = video!.parentElement as HTMLElement | null;
|
||||
expect(wrapper).not.toBeNull();
|
||||
|
||||
// #123456 -> rgb(18, 52, 86)
|
||||
expect(wrapper!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
||||
// 32px / 16 = 2em, 16px / 16 = 1em
|
||||
expect(wrapper!.style.padding).toBe("2em");
|
||||
expect(wrapper!.style.borderRadius).toBe("1em");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
import { SectionPropertiesPanel } from "@/app/editor/panels/SectionPropertiesPanel";
|
||||
|
||||
@@ -13,6 +13,103 @@ describe("SectionPropertiesPanel", () => {
|
||||
gapX: "md",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("가로 위치 프리셋 드롭다운 변경 시 backgroundImagePositionXPercent 가 0/50/100 등으로 설정된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 50,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-xy-x"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const presetSelect = screen.getByLabelText("배경 이미지 가로 위치 프리셋");
|
||||
fireEvent.change(presetSelect, { target: { value: "left" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-xy-x",
|
||||
expect.objectContaining({ backgroundImagePositionXPercent: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("세로 위치 프리셋 드롭다운 변경 시 backgroundImagePositionYPercent 가 0/50/100 등으로 설정된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionYPercent: 50,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-xy-y"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const presetSelect = screen.getByLabelText("배경 이미지 세로 위치 프리셋");
|
||||
fireEvent.change(presetSelect, { target: { value: "top" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-xy-y",
|
||||
expect.objectContaining({ backgroundImagePositionYPercent: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("위치 모드를 custom 으로 두면 X/Y 퍼센트 슬라이더 변경 시 backgroundImagePositionXPercent/YPercent 가 업데이트된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 50,
|
||||
backgroundImagePositionYPercent: 50,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-xy"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const xSlider = screen.getByLabelText("배경 이미지 가로 위치 슬라이더");
|
||||
fireEvent.change(xSlider, { target: { value: "30" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-xy",
|
||||
expect.objectContaining({
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 30,
|
||||
}),
|
||||
);
|
||||
|
||||
const ySlider = screen.getByLabelText("배경 이미지 세로 위치 슬라이더");
|
||||
fireEvent.change(ySlider, { target: { value: "70" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-xy",
|
||||
expect.objectContaining({
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionYPercent: 70,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("세로 패딩 프리셋/슬라이더 변경 시 updateBlock 이 paddingYPx 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
render(
|
||||
@@ -28,4 +125,219 @@ describe("SectionPropertiesPanel", () => {
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith("section-1", expect.objectContaining({ paddingYPx: 40 }));
|
||||
});
|
||||
|
||||
it("배경 이미지 URL 인풋 변경 시 updateBlock 이 backgroundImageSrc/SourceType/AssetId 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{ ...baseProps, backgroundImageSrc: "", backgroundImageSourceType: "externalUrl" }}
|
||||
selectedBlockId="section-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText("배경 이미지 URL");
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/bg.png" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-2",
|
||||
expect.objectContaining({
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("배경 이미지 크기 셀렉트 변경 시 updateBlock 이 backgroundImageSize 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("배경 이미지 크기");
|
||||
fireEvent.change(select, { target: { value: "contain" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-3",
|
||||
expect.objectContaining({ backgroundImageSize: "contain" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("배경 이미지 위치 셀렉트 변경 시 updateBlock 이 backgroundImagePosition 으로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImagePosition: "center",
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("배경 이미지 위치");
|
||||
fireEvent.change(select, { target: { value: "top" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-4",
|
||||
expect.objectContaining({ backgroundImagePosition: "top" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("기본 상태에서는 배경 이미지 소스가 '없음' 이고, '없음' 선택 시 관련 필드가 초기화된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{ ...baseProps }}
|
||||
selectedBlockId="section-0"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
|
||||
expect(sourceSelect.value).toBe("none");
|
||||
|
||||
fireEvent.change(sourceSelect, { target: { value: "none" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith("section-0", {
|
||||
backgroundImageSrc: undefined,
|
||||
backgroundImageSourceType: undefined,
|
||||
backgroundImageAssetId: null,
|
||||
} as any);
|
||||
|
||||
// "없음" 상태에서는 크기/위치/반복 컨트롤이 렌더되지 않아야 한다.
|
||||
expect(screen.queryByLabelText("배경 이미지 크기")).toBeNull();
|
||||
expect(screen.queryByLabelText("배경 이미지 위치")).toBeNull();
|
||||
expect(screen.queryByLabelText("배경 이미지 반복")).toBeNull();
|
||||
});
|
||||
|
||||
it("배경 이미지 반복 셀렉트 변경 시 updateBlock 이 backgroundImageRepeat 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImageRepeat: "no-repeat",
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-6"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const repeatSelect = screen.getByLabelText("배경 이미지 반복");
|
||||
fireEvent.change(repeatSelect, { target: { value: "repeat-x" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-6",
|
||||
expect.objectContaining({ backgroundImageRepeat: "repeat-x" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("배경 이미지 소스 셀렉트 변경 시 updateBlock 이 backgroundImageSourceType/backgroundImageAssetId 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundImageSrc: "/api/image/asset-1",
|
||||
backgroundImageSourceType: "asset",
|
||||
backgroundImageAssetId: "asset-1",
|
||||
}}
|
||||
selectedBlockId="section-5"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
|
||||
expect(sourceSelect.value).toBe("upload");
|
||||
|
||||
fireEvent.change(sourceSelect, { target: { value: "url" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-5",
|
||||
expect.objectContaining({
|
||||
backgroundImageSourceType: "externalUrl",
|
||||
backgroundImageAssetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("배경 비디오 URL 인풋 변경 시 updateBlock 이 backgroundVideoSrc/backgroundVideoSourceType/backgroundVideoAssetId 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundVideoSrc: "",
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
}}
|
||||
selectedBlockId="section-video-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText("배경 비디오 URL");
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/bg-video.mp4" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-video-1",
|
||||
expect.objectContaining({
|
||||
backgroundVideoSrc: "https://example.com/bg-video.mp4",
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("배경 비디오 소스 셀렉트 변경 시 updateBlock 이 backgroundVideoSourceType/backgroundVideoAssetId 로 호출된다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<SectionPropertiesPanel
|
||||
sectionProps={{
|
||||
...baseProps,
|
||||
backgroundVideoSrc: "/api/video/asset-video-1",
|
||||
backgroundVideoSourceType: "asset",
|
||||
backgroundVideoAssetId: "asset-video-1",
|
||||
}}
|
||||
selectedBlockId="section-video-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sourceSelect = screen.getByLabelText("배경 비디오 소스") as HTMLSelectElement;
|
||||
expect(sourceSelect.value).toBe("upload");
|
||||
|
||||
fireEvent.change(sourceSelect, { target: { value: "url" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"section-video-2",
|
||||
expect.objectContaining({
|
||||
backgroundVideoSourceType: "externalUrl",
|
||||
backgroundVideoAssetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { TextPropertiesPanel } from "@/app/editor/panels/TextPropertiesPanel";
|
||||
import { ListPropertiesPanel } from "@/app/editor/panels/ListPropertiesPanel";
|
||||
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
// Text/List/Form 패널에 추가되는 블록 배경색(backgroundColorCustom) 컨트롤에 대한 최소 TDD
|
||||
@@ -58,34 +57,4 @@ describe("Text/List/Form 패널 - 블록 배경색 컨트롤", () => {
|
||||
expect.objectContaining({ backgroundColorCustom: "#445566" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("FormControllerPanel 에서 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const formBlock: Block = {
|
||||
id: "form-1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
} as any,
|
||||
};
|
||||
|
||||
render(
|
||||
<FormControllerPanel
|
||||
block={formBlock}
|
||||
blocks={[]}
|
||||
selectedBlockId="form-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("폼 배경색 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#778899" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"form-1",
|
||||
expect.objectContaining({ backgroundColorCustom: "#778899" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { VideoPropertiesPanel } from "@/app/editor/panels/VideoPropertiesPanel";
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
// VideoPropertiesPanel 컨트롤 TDD
|
||||
// - 비디오 URL 인풋/정렬/너비 모드/화면 비율 컨트롤이 updateBlock 을 올바르게 호출하는지 검증한다.
|
||||
|
||||
describe("VideoPropertiesPanel", () => {
|
||||
const baseProps: VideoBlockProps = {
|
||||
sourceUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("비디오 URL 인풋 변경 시 updateBlock 이 sourceUrl / sourceType / assetId 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, sourceType: "externalUrl" }}
|
||||
selectedBlockId="video-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const urlInput = screen.getByLabelText("비디오 URL");
|
||||
fireEvent.change(urlInput, { target: { value: "https://example.com/video.mp4" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-1",
|
||||
expect.objectContaining({
|
||||
sourceUrl: "https://example.com/video.mp4",
|
||||
sourceType: "externalUrl",
|
||||
assetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("정렬 셀렉트 변경 시 updateBlock 이 align 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, align: "center" }}
|
||||
selectedBlockId="video-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("비디오 정렬");
|
||||
fireEvent.change(select, { target: { value: "left" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-2",
|
||||
expect.objectContaining({ align: "left" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, widthMode: "auto" }}
|
||||
selectedBlockId="video-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("비디오 너비 모드");
|
||||
fireEvent.change(select, { target: { value: "fixed" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-3",
|
||||
expect.objectContaining({ widthMode: "fixed" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("화면 비율 셀렉트 변경 시 updateBlock 이 aspectRatio 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, aspectRatio: "16:9" }}
|
||||
selectedBlockId="video-4"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByLabelText("화면 비율");
|
||||
fireEvent.change(select, { target: { value: "4:3" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-4",
|
||||
expect.objectContaining({ aspectRatio: "4:3" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("카드 배경색 HEX 인풋 변경 시 updateBlock 이 backgroundColorCustom 으로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, sourceType: "externalUrl" }}
|
||||
selectedBlockId="video-card-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hexInput = screen.getByLabelText("비디오 카드 배경색 HEX");
|
||||
fireEvent.change(hexInput, { target: { value: "#123456" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-card-1",
|
||||
expect.objectContaining({ backgroundColorCustom: "#123456" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("카드 패딩 슬라이더 변경 시 updateBlock 이 cardPaddingPx 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, cardPaddingPx: 16 }}
|
||||
selectedBlockId="video-card-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const slider = screen.getByLabelText("카드 패딩 슬라이더");
|
||||
fireEvent.change(slider, { target: { value: "24" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-card-2",
|
||||
expect.objectContaining({ cardPaddingPx: 24 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("카드 모서리 둥글기 슬라이더 변경 시 updateBlock 이 borderRadiusPx 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, borderRadiusPx: 8 }}
|
||||
selectedBlockId="video-card-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const slider = screen.getByLabelText("카드 모서리 둥글기 슬라이더");
|
||||
fireEvent.change(slider, { target: { value: "20" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-card-3",
|
||||
expect.objectContaining({ borderRadiusPx: 20 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("포스터 이미지 URL 인풋 변경 시 updateBlock 이 posterImageSrc / posterSourceType / posterAssetId 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
const posterProps: VideoBlockProps = {
|
||||
...baseProps,
|
||||
sourceType: "externalUrl",
|
||||
posterImageSrc: "https://example.com/poster-before.png",
|
||||
posterSourceType: "externalUrl",
|
||||
posterAssetId: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={posterProps}
|
||||
selectedBlockId="video-poster-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("포스터 이미지 URL");
|
||||
fireEvent.change(input, { target: { value: "https://example.com/poster-after.png" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-poster-1",
|
||||
expect.objectContaining({
|
||||
posterImageSrc: "https://example.com/poster-after.png",
|
||||
posterSourceType: "externalUrl",
|
||||
posterAssetId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("시작 시점 슬라이더 변경 시 updateBlock 이 startTimeSec 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, startTimeSec: 0 }}
|
||||
selectedBlockId="video-range-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const slider = screen.getByLabelText("시작 시점 (초) 슬라이더");
|
||||
fireEvent.change(slider, { target: { value: "5" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-range-1",
|
||||
expect.objectContaining({ startTimeSec: 5 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("종료 시점 슬라이더 변경 시 updateBlock 이 endTimeSec 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, endTimeSec: 10 }}
|
||||
selectedBlockId="video-range-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const slider = screen.getByLabelText("종료 시점 (초) 슬라이더");
|
||||
fireEvent.change(slider, { target: { value: "15" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-range-2",
|
||||
expect.objectContaining({ endTimeSec: 15 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("비디오 제목 인풋 변경 시 updateBlock 이 titleText 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, titleText: "기존 제목" } as any}
|
||||
selectedBlockId="video-a11y-1"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("비디오 제목");
|
||||
fireEvent.change(input, { target: { value: "새 비디오 제목" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-a11y-1",
|
||||
expect.objectContaining({ titleText: "새 비디오 제목" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("비디오 aria-label 인풋 변경 시 updateBlock 이 ariaLabel 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, ariaLabel: "기존 aria" } as any}
|
||||
selectedBlockId="video-a11y-2"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("비디오 aria-label");
|
||||
fireEvent.change(input, { target: { value: "새 aria-label" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-a11y-2",
|
||||
expect.objectContaining({ ariaLabel: "새 aria-label" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("비디오 캡션 텍스트 인풋 변경 시 updateBlock 이 captionText 로 호출되어야 한다", () => {
|
||||
const updateBlock = vi.fn();
|
||||
|
||||
render(
|
||||
<VideoPropertiesPanel
|
||||
videoProps={{ ...baseProps, captionText: "기존 캡션" } as any}
|
||||
selectedBlockId="video-a11y-3"
|
||||
updateBlock={updateBlock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("비디오 캡션 텍스트");
|
||||
fireEvent.change(input, { target: { value: "새 캡션 텍스트" } });
|
||||
|
||||
expect(updateBlock).toHaveBeenCalledWith(
|
||||
"video-a11y-3",
|
||||
expect.objectContaining({ captionText: "새 캡션 텍스트" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeButtonPbTokens,
|
||||
computeButtonPublicTokens,
|
||||
computeButtonEditorTokens,
|
||||
} from "@/features/editor/utils/buttonHelpers";
|
||||
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseProps = {
|
||||
align: "left" as const,
|
||||
size: "md" as const,
|
||||
variant: "solid" as const,
|
||||
colorPalette: "primary" as const,
|
||||
};
|
||||
|
||||
describe("buttonHelpers.computeButtonPbTokens", () => {
|
||||
it("기본 버튼은 pb-text-left / pb-btn-size-md / pb-btn-variant-solid-primary / pb-btn-radius-md 를 포함해야 한다", () => {
|
||||
const tokens = computeButtonPbTokens(baseProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-left");
|
||||
expect(tokens.sizeClass).toBe("pb-btn-size-md");
|
||||
expect(tokens.variantClass).toBe("pb-btn-variant-solid-primary");
|
||||
expect(tokens.radiusClass).toBe("pb-btn-radius-md");
|
||||
expect(tokens.inlineStyles.length).toBe(0);
|
||||
});
|
||||
|
||||
it("정렬/사이즈/팔레트/둥글기 스케일은 pb 토큰으로 올바르게 매핑되어야 한다", () => {
|
||||
const tokens = computeButtonPbTokens({
|
||||
align: "center",
|
||||
size: "lg",
|
||||
variant: "outline",
|
||||
colorPalette: "danger",
|
||||
borderRadius: "full",
|
||||
});
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-center");
|
||||
expect(tokens.sizeClass).toBe("pb-btn-size-lg");
|
||||
expect(tokens.variantClass).toBe("pb-btn-variant-outline-danger");
|
||||
expect(tokens.radiusClass).toBe("pb-btn-radius-full");
|
||||
});
|
||||
|
||||
it("widthMode=fixed, widthPx, paddingX/paddingY, fill/stroke/text 색상은 inlineStyles 에 포함되어야 한다", () => {
|
||||
const tokens = computeButtonPbTokens({
|
||||
align: "right",
|
||||
size: "sm",
|
||||
widthMode: "fixed",
|
||||
widthPx: 200,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
fillColorCustom: "#111111",
|
||||
strokeColorCustom: "#222222",
|
||||
textColorCustom: "#ffffff",
|
||||
});
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-right");
|
||||
expect(tokens.inlineStyles).toContain("width:200px");
|
||||
expect(tokens.inlineStyles).toContain("padding-left:16px");
|
||||
expect(tokens.inlineStyles).toContain("padding-right:16px");
|
||||
expect(tokens.inlineStyles).toContain("padding-top:8px");
|
||||
expect(tokens.inlineStyles).toContain("padding-bottom:8px");
|
||||
expect(tokens.inlineStyles).toContain("background-color:#111111");
|
||||
expect(tokens.inlineStyles).toContain("border-color:#222222");
|
||||
expect(tokens.inlineStyles).toContain("color:#ffffff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttonHelpers.computeButtonPublicTokens", () => {
|
||||
it("색상 커스텀 값은 backgroundColor/borderColor/borderWidth/borderStyle/color 에 반영되어야 한다", () => {
|
||||
const tokens = computeButtonPublicTokens({
|
||||
label: "컬러 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
textColorCustom: "#00ff00",
|
||||
} as ButtonBlockProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("text-center");
|
||||
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
|
||||
expect(tokens.styleOverrides.borderColor).toBe("#ff0000");
|
||||
expect(tokens.styleOverrides.borderWidth).toBe("1px");
|
||||
expect(tokens.styleOverrides.borderStyle).toBe("solid");
|
||||
expect(tokens.styleOverrides.color).toBe("#00ff00");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 인 경우 widthPx/paddingX/paddingY 는 em 단위로 변환되어야 한다", () => {
|
||||
const tokens = computeButtonPublicTokens({
|
||||
label: "레이아웃 버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
} as ButtonBlockProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("text-left");
|
||||
expect(tokens.styleOverrides.width).toBe("20em");
|
||||
expect(tokens.styleOverrides.paddingInline).toBe("1em");
|
||||
expect(tokens.styleOverrides.paddingBlock).toBe("0.5em");
|
||||
});
|
||||
|
||||
it("borderRadius 토큰은 em 단위 둥글기 또는 9999px 로 변환되어야 한다", () => {
|
||||
const lgTokens = computeButtonPublicTokens({
|
||||
label: "둥근 버튼",
|
||||
href: "#",
|
||||
borderRadius: "lg",
|
||||
} as ButtonBlockProps);
|
||||
|
||||
expect(lgTokens.styleOverrides.borderRadius).toBe("0.75em");
|
||||
|
||||
const fullTokens = computeButtonPublicTokens({
|
||||
label: "풀 둥근 버튼",
|
||||
href: "#",
|
||||
borderRadius: "full",
|
||||
} as ButtonBlockProps);
|
||||
|
||||
expect(fullTokens.styleOverrides.borderRadius).toBe("9999px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buttonHelpers.computeButtonEditorTokens", () => {
|
||||
it("fullWidth 나 widthMode 에 따라 wrapperWidthClass 와 버튼 스타일이 계산되어야 한다", () => {
|
||||
const tokens = computeButtonEditorTokens({
|
||||
label: "테스트 버튼",
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "lg",
|
||||
variant: "outline",
|
||||
colorPalette: "success",
|
||||
borderRadius: "full",
|
||||
fullWidth: true,
|
||||
fontSizeCustom: "18px",
|
||||
lineHeightCustom: "1.8",
|
||||
letterSpacingCustom: "0.1em",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
textColorCustom: "#ff0000",
|
||||
paddingX: 16,
|
||||
paddingY: 10,
|
||||
} as ButtonBlockProps);
|
||||
|
||||
// fullWidth=true 이므로 wrapperWidthClass 는 w-full 이어야 한다.
|
||||
expect(tokens.wrapperWidthClass).toBe("w-full");
|
||||
|
||||
// 버튼 스타일에 색상/패딩/타이포 값이 그대로 반영되어야 한다.
|
||||
expect(tokens.buttonStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.buttonStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.buttonStyle.color).toBe("#ff0000");
|
||||
expect(tokens.buttonStyle.paddingInline).toBe("16px");
|
||||
expect(tokens.buttonStyle.paddingBlock).toBe("10px");
|
||||
expect(tokens.buttonStyle.fontSize).toBe("18px");
|
||||
expect(tokens.buttonStyle.lineHeight).toBe("1.8");
|
||||
expect(tokens.buttonStyle.letterSpacing).toBe("0.1em");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 인 경우 widthPx 는 버튼 width 스타일에 반영되고 wrapperWidthClass 는 inline-block 이어야 한다", () => {
|
||||
const tokens = computeButtonEditorTokens({
|
||||
label: "고정 너비 버튼",
|
||||
href: "#",
|
||||
align: "left",
|
||||
size: "md",
|
||||
variant: "solid",
|
||||
colorPalette: "primary",
|
||||
borderRadius: "md",
|
||||
fullWidth: false,
|
||||
widthMode: "fixed",
|
||||
widthPx: 200,
|
||||
} as ButtonBlockProps);
|
||||
|
||||
expect(tokens.wrapperWidthClass).toBe("inline-block");
|
||||
expect(tokens.buttonStyle.width).toBe("200px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeDividerExportTokens,
|
||||
computeDividerEditorTokens,
|
||||
computeDividerPublicTokens,
|
||||
} from "@/features/editor/utils/dividerHelpers";
|
||||
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseProps: DividerBlockProps = {
|
||||
align: "left",
|
||||
thickness: "thin",
|
||||
colorHex: undefined,
|
||||
marginYPx: undefined,
|
||||
};
|
||||
|
||||
describe("dividerHelpers.computeDividerExportTokens", () => {
|
||||
const escapers = { escapeAttr: (v: string) => v };
|
||||
|
||||
it("기본 값은 1px 두께, 기본 색상(#475569), marginY=16px 이어야 한다", () => {
|
||||
const tokens = computeDividerExportTokens(baseProps, escapers);
|
||||
expect(tokens.style).toBe("border:0;border-bottom:1px solid #475569;margin:16px 0;");
|
||||
});
|
||||
|
||||
it("thickness=\"medium\" 인 경우 2px 두께로 렌더링되어야 한다", () => {
|
||||
const tokens = computeDividerExportTokens({ ...baseProps, thickness: "medium" }, escapers);
|
||||
expect(tokens.style).toContain("border-bottom:2px solid #475569;");
|
||||
});
|
||||
|
||||
it("colorHex 가 설정되면 공백을 제거한 뒤 해당 색상으로 border-color 를 사용해야 한다", () => {
|
||||
const tokens = computeDividerExportTokens({ ...baseProps, colorHex: " #ff0000 " }, escapers);
|
||||
expect(tokens.style).toContain("border-bottom:1px solid #ff0000;");
|
||||
});
|
||||
|
||||
it("marginYPx > 0 이면 해당 값을 margin Y 로 사용해야 한다", () => {
|
||||
const tokens = computeDividerExportTokens({ ...baseProps, marginYPx: 24 }, escapers);
|
||||
expect(tokens.style).toContain("margin:24px 0;");
|
||||
});
|
||||
|
||||
it("marginYPx 가 0 이하이거나 숫자가 아니면 기본 16px 을 사용해야 한다", () => {
|
||||
const zeroTokens = computeDividerExportTokens({ ...baseProps, marginYPx: 0 }, escapers);
|
||||
expect(zeroTokens.style).toContain("margin:16px 0;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dividerHelpers.computeDividerEditorTokens", () => {
|
||||
it("기본 값은 얇은 두께, 기본 색상, 전체 폭, margin 16px 이어야 한다", () => {
|
||||
const tokens = computeDividerEditorTokens(baseProps);
|
||||
expect(tokens.thicknessClass).toBe("border-t");
|
||||
expect(tokens.borderColor).toBe("#475569");
|
||||
expect(tokens.innerWidthClass).toBe("w-full");
|
||||
expect(tokens.widthPx).toBeUndefined();
|
||||
expect(tokens.marginPx).toBe(16);
|
||||
});
|
||||
|
||||
it("thickness=\"medium\", colorHex, widthMode=\"auto\", marginYPx 가 있으면 그대로 반영되어야 한다", () => {
|
||||
const tokens = computeDividerEditorTokens({
|
||||
...baseProps,
|
||||
thickness: "medium",
|
||||
colorHex: " #ff0000 ",
|
||||
widthMode: "auto",
|
||||
marginYPx: 20,
|
||||
});
|
||||
|
||||
expect(tokens.thicknessClass).toBe("border-t-2");
|
||||
expect(tokens.borderColor).toBe("#ff0000");
|
||||
expect(tokens.innerWidthClass).toBe("w-1/2");
|
||||
expect(tokens.widthPx).toBeUndefined();
|
||||
expect(tokens.marginPx).toBe(20);
|
||||
});
|
||||
|
||||
it("widthMode=\"fixed\" 이고 widthPx 가 양수이면 해당 px 로 width 를 사용해야 한다", () => {
|
||||
const tokens = computeDividerEditorTokens({
|
||||
...baseProps,
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
marginY: "lg",
|
||||
});
|
||||
|
||||
expect(tokens.innerWidthClass).toBe("");
|
||||
expect(tokens.widthPx).toBe(480);
|
||||
expect(tokens.marginPx).toBe(24);
|
||||
});
|
||||
|
||||
it("widthMode=\"fixed\" 이지만 widthPx 가 없거나 0 이하이면 기본 320px 을 사용해야 한다", () => {
|
||||
const tokens = computeDividerEditorTokens({
|
||||
...baseProps,
|
||||
widthMode: "fixed",
|
||||
widthPx: undefined,
|
||||
});
|
||||
|
||||
expect(tokens.innerWidthClass).toBe("");
|
||||
expect(tokens.widthPx).toBe(320);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dividerHelpers.computeDividerPublicTokens", () => {
|
||||
it("colorHex 가 있으면 해당 색상을, 없으면 기본 색상(#475569)을 사용해야 한다", () => {
|
||||
const withColor = computeDividerPublicTokens({
|
||||
thickness: "thin",
|
||||
colorHex: "#123456",
|
||||
} as DividerBlockProps);
|
||||
|
||||
expect(withColor.lineStyle.backgroundColor).toBe("#123456");
|
||||
|
||||
const withoutColor = computeDividerPublicTokens({
|
||||
thickness: "thin",
|
||||
} as DividerBlockProps);
|
||||
|
||||
expect(withoutColor.lineStyle.backgroundColor).toBe("#475569");
|
||||
});
|
||||
|
||||
it("marginYPx 와 marginY 토큰은 em 스케일 wrapperMarginEm 으로 변환되어야 한다", () => {
|
||||
const withMarginPx = computeDividerPublicTokens({
|
||||
thickness: "thin",
|
||||
marginYPx: 32,
|
||||
} as DividerBlockProps);
|
||||
|
||||
expect(withMarginPx.wrapperMarginEm).toBe("2em");
|
||||
|
||||
const withMarginToken = computeDividerPublicTokens({
|
||||
thickness: "thin",
|
||||
marginY: "lg",
|
||||
} as DividerBlockProps);
|
||||
|
||||
expect(withMarginToken.wrapperMarginEm).toBe("1.5em");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 인 경우 widthPx 는 라인 width 에 em 으로 반영되어야 한다", () => {
|
||||
const tokens = computeDividerPublicTokens({
|
||||
thickness: "thin",
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
} as DividerBlockProps);
|
||||
|
||||
expect(tokens.innerWidthClass).toBe("");
|
||||
expect(tokens.innerStyle.width).toBe("auto");
|
||||
expect(tokens.lineStyle.width).toBe("30em");
|
||||
});
|
||||
});
|
||||
@@ -686,288 +686,10 @@ describe("editorStore", () => {
|
||||
expect(buttonBlocks.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
|
||||
// 템플릿에서 생성된 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치된다고 가정한다.
|
||||
const columns = (section.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstColumnId = columns[0].id;
|
||||
|
||||
textBlocks.forEach((tb: any) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(tb.columnId).toBe(firstColumnId);
|
||||
});
|
||||
|
||||
buttonBlocks.forEach((bb: any) => {
|
||||
expect(bb.sectionId).toBe(section.id);
|
||||
expect(bb.columnId).toBe(firstColumnId);
|
||||
});
|
||||
|
||||
// 마지막으로 생성된 블록(예: CTA 버튼)이 선택되어 있다고 가정한다.
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
});
|
||||
|
||||
it("선택된 섹션이 있을 때 Team 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Team 템플릿으로 교체되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addTeamTemplateSection();
|
||||
|
||||
let { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const sectionId = section.id;
|
||||
|
||||
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||
|
||||
const originalLastSelectedId = selectedBlockId;
|
||||
|
||||
store.getState().selectBlock(sectionId);
|
||||
store.getState().addTeamTemplateSection();
|
||||
|
||||
({ blocks, selectedBlockId } = store.getState());
|
||||
|
||||
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocksAfter).toHaveLength(1);
|
||||
|
||||
const replacedSection = sectionBlocksAfter[0] as any;
|
||||
expect(replacedSection.id).toBe(sectionId);
|
||||
|
||||
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
textBlocksAfter.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(sectionId);
|
||||
});
|
||||
|
||||
const allIdsAfter = blocks.map((b) => b.id);
|
||||
oldTextIds.forEach((id) => {
|
||||
expect(allIdsAfter).not.toContain(id);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||
});
|
||||
|
||||
it("선택된 섹션이 있을 때 Blog 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Blog 템플릿으로 교체되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addBlogTemplateSection();
|
||||
|
||||
let { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const sectionId = section.id;
|
||||
|
||||
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||
|
||||
const originalLastSelectedId = selectedBlockId;
|
||||
|
||||
store.getState().selectBlock(sectionId);
|
||||
store.getState().addBlogTemplateSection();
|
||||
|
||||
({ blocks, selectedBlockId } = store.getState());
|
||||
|
||||
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocksAfter).toHaveLength(1);
|
||||
|
||||
const replacedSection = sectionBlocksAfter[0] as any;
|
||||
expect(replacedSection.id).toBe(sectionId);
|
||||
|
||||
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
textBlocksAfter.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(sectionId);
|
||||
});
|
||||
|
||||
const allIdsAfter = blocks.map((b) => b.id);
|
||||
oldTextIds.forEach((id) => {
|
||||
expect(allIdsAfter).not.toContain(id);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||
});
|
||||
|
||||
it("선택된 섹션이 있을 때 Hero 템플릿 섹션 액션을 호출하면 해당 섹션이 새 Hero 템플릿으로 교체되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
// 1) 먼저 Hero 템플릿 섹션을 하나 추가한다.
|
||||
store.getState().addHeroTemplateSection();
|
||||
|
||||
let { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const sectionId = section.id;
|
||||
|
||||
// 기존 텍스트/버튼 블록들의 id 를 기록해 둔다.
|
||||
const oldTextIds = blocks.filter((b) => b.type === "text").map((b) => b.id);
|
||||
const oldButtonIds = blocks.filter((b) => b.type === "button").map((b) => b.id);
|
||||
|
||||
const originalLastSelectedId = selectedBlockId;
|
||||
|
||||
// 2) 해당 섹션을 선택한 상태에서 다시 Hero 템플릿 섹션 액션을 호출한다.
|
||||
store.getState().selectBlock(sectionId);
|
||||
store.getState().addHeroTemplateSection();
|
||||
|
||||
({ blocks, selectedBlockId } = store.getState());
|
||||
|
||||
const sectionBlocksAfter = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocksAfter).toHaveLength(1);
|
||||
|
||||
const replacedSection = sectionBlocksAfter[0] as any;
|
||||
// 교체 후에도 섹션 id 는 동일하게 유지되어야 한다 (섹션 컨테이너 재사용).
|
||||
expect(replacedSection.id).toBe(sectionId);
|
||||
|
||||
const columns = (replacedSection.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||
const firstColumnId = columns[0].id;
|
||||
|
||||
const textBlocksAfter = blocks.filter((b) => b.type === "text") as any[];
|
||||
const buttonBlocksAfter = blocks.filter((b) => b.type === "button") as any[];
|
||||
|
||||
// 새 텍스트/버튼 블록이 최소 1개 이상 존재해야 한다.
|
||||
expect(textBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||
expect(buttonBlocksAfter.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 새 텍스트/버튼 블록은 모두 동일 섹션의 첫 컬럼에 배치되어야 한다.
|
||||
textBlocksAfter.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(sectionId);
|
||||
expect(tb.columnId).toBe(firstColumnId);
|
||||
});
|
||||
|
||||
buttonBlocksAfter.forEach((bb) => {
|
||||
expect(bb.sectionId).toBe(sectionId);
|
||||
expect(bb.columnId).toBe(firstColumnId);
|
||||
});
|
||||
|
||||
// 기존 텍스트/버튼 블록 id 들은 모두 제거되어야 한다.
|
||||
const allIdsAfter = blocks.map((b) => b.id);
|
||||
[...oldTextIds, ...oldButtonIds].forEach((id) => {
|
||||
expect(allIdsAfter).not.toContain(id);
|
||||
});
|
||||
|
||||
// 선택 상태는 새로 생성된 템플릿의 마지막 블록을 가리켜야 한다.
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
expect(selectedBlockId).not.toBe(originalLastSelectedId);
|
||||
});
|
||||
|
||||
it("Blog 템플릿 섹션을 추가하면 3개의 포스트 카드(제목/요약)가 생성되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addBlogTemplateSection();
|
||||
|
||||
const { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const columns = (section.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBe(3);
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
|
||||
// 3개의 포스트 카드에 대해 각 컬럼에 최소 2개 텍스트(제목 + 요약)를 가정한다.
|
||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
});
|
||||
|
||||
it("Team 템플릿 섹션을 추가하면 3명의 팀 카드(이름/역할/소개)가 생성되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addTeamTemplateSection();
|
||||
|
||||
const { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const columns = (section.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBe(3);
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
|
||||
// 3명의 팀원 카드에 대해 각 컬럼에 최소 3개 텍스트(이름/역할/소개)를 가정한다.
|
||||
expect(textBlocks.length).toBeGreaterThanOrEqual(9);
|
||||
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
});
|
||||
|
||||
it("Footer 템플릿 섹션을 추가하면 링크/카피라이트 텍스트가 포함된 섹션이 생성되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
store.getState().addFooterTemplateSection();
|
||||
|
||||
const { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const columns = (section.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstColumnId = columns[0].id;
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
expect(textBlocks.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(tb.columnId).toBe(firstColumnId);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
});
|
||||
|
||||
it("Features 템플릿 섹션을 추가하면 3컬럼 섹션과 각 컬럼의 제목/설명 텍스트가 생성되어야 한다", () => {
|
||||
const store = createEditorStore();
|
||||
|
||||
// Features 템플릿 액션을 호출한다고 가정한다.
|
||||
store.getState().addFeaturesTemplateSection();
|
||||
|
||||
const { blocks, selectedBlockId } = store.getState();
|
||||
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
expect(sectionBlocks).toHaveLength(1);
|
||||
|
||||
const section = sectionBlocks[0] as any;
|
||||
const columns = (section.props as any).columns;
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBe(3);
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
// 최소 3개의 feature 항목(제목/설명)을 가정하고, 각 컬럼에는 2개의 텍스트(제목+설명)가 있다고 본다.
|
||||
expect(textBlocks.length).toBeGreaterThanOrEqual(6);
|
||||
|
||||
// 모든 텍스트 블록이 동일 섹션에 속하는지 확인한다.
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
||||
@@ -992,8 +714,6 @@ describe("editorStore", () => {
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstColumnId = columns[0].id;
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
const buttonBlocks = blocks.filter((b) => b.type === "button") as any[];
|
||||
|
||||
@@ -1002,12 +722,12 @@ describe("editorStore", () => {
|
||||
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(tb.columnId).toBe(firstColumnId);
|
||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
||||
});
|
||||
|
||||
buttonBlocks.forEach((bb) => {
|
||||
expect(bb.sectionId).toBe(section.id);
|
||||
expect(bb.columnId).toBe(firstColumnId);
|
||||
expect(columns.some((col: any) => col.id === bb.columnId)).toBe(true);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
@@ -1028,8 +748,6 @@ describe("editorStore", () => {
|
||||
expect(Array.isArray(columns)).toBe(true);
|
||||
expect(columns.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const firstColumnId = columns[0].id;
|
||||
|
||||
const textBlocks = blocks.filter((b) => b.type === "text") as any[];
|
||||
|
||||
// 최소 3개의 FAQ 항목(질문+답변 쌍)을 가정한다.
|
||||
@@ -1037,7 +755,7 @@ describe("editorStore", () => {
|
||||
|
||||
textBlocks.forEach((tb) => {
|
||||
expect(tb.sectionId).toBe(section.id);
|
||||
expect(tb.columnId).toBe(firstColumnId);
|
||||
expect(columns.some((col: any) => col.id === tb.columnId)).toBe(true);
|
||||
});
|
||||
|
||||
expect(selectedBlockId).toBe(blocks[blocks.length - 1].id);
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeFormInputExportTokens,
|
||||
computeFormSelectExportTokens,
|
||||
computeFormCheckboxExportTokens,
|
||||
computeFormRadioExportTokens,
|
||||
computeFormInputEditorTokens,
|
||||
computeFormSelectEditorTokens,
|
||||
computeFormOptionGroupEditorTokens,
|
||||
computeFormInputPublicTokens,
|
||||
computeFormSelectPublicTokens,
|
||||
computeFormCheckboxPublicTokens,
|
||||
computeFormRadioPublicTokens,
|
||||
computeFormControllerPublicTokens,
|
||||
computeFormBlockExportTokens,
|
||||
} from "@/features/editor/utils/formHelpers";
|
||||
import type {
|
||||
Block,
|
||||
FormBlockProps,
|
||||
FormInputBlockProps,
|
||||
FormSelectBlockProps,
|
||||
FormCheckboxBlockProps,
|
||||
FormRadioBlockProps,
|
||||
} from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseInput: FormInputBlockProps = {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
};
|
||||
|
||||
const baseSelect: FormSelectBlockProps = {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [],
|
||||
};
|
||||
|
||||
const baseCheckbox: FormCheckboxBlockProps = {
|
||||
groupLabel: "옵션",
|
||||
formFieldName: "options",
|
||||
options: [],
|
||||
};
|
||||
|
||||
const baseRadio: FormRadioBlockProps = {
|
||||
groupLabel: "선택",
|
||||
formFieldName: "choice",
|
||||
options: [],
|
||||
};
|
||||
|
||||
describe("formHelpers.computeFormInputExportTokens", () => {
|
||||
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormInputExportTokens({
|
||||
...baseInput,
|
||||
textColorCustom: " #ff0000 ",
|
||||
fillColorCustom: " #111827 ",
|
||||
strokeColorCustom: " #4b5563 ",
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
widthMode: "fixed",
|
||||
widthPx: 300,
|
||||
borderRadius: "lg",
|
||||
});
|
||||
|
||||
expect(tokens.labelStyleAttr).toBe(' style="color:#ff0000"');
|
||||
expect(tokens.inputStyleAttr).toContain("color:#ff0000");
|
||||
expect(tokens.inputStyleAttr).toContain("background-color:#111827");
|
||||
expect(tokens.inputStyleAttr).toContain("border-color:#4b5563");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-left:8px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-right:8px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-top:4px");
|
||||
expect(tokens.inputStyleAttr).toContain("padding-bottom:4px");
|
||||
expect(tokens.inputStyleAttr).toContain("width:300px");
|
||||
expect(tokens.inputStyleAttr).toContain("border-radius:6px");
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
const tokens = computeFormInputExportTokens(baseInput);
|
||||
expect(tokens.labelStyleAttr).toBe("");
|
||||
expect(tokens.inputStyleAttr).toBe(' style="border-radius:4px"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers.computeFormSelectExportTokens", () => {
|
||||
it("텍스트 색상/배경/테두리/패딩/너비/둥글기 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormSelectExportTokens({
|
||||
...baseSelect,
|
||||
textColorCustom: " #00ff00 ",
|
||||
fillColorCustom: " #020617 ",
|
||||
strokeColorCustom: " #64748b ",
|
||||
paddingX: 12,
|
||||
paddingY: 6,
|
||||
widthMode: "fixed",
|
||||
widthPx: 240,
|
||||
borderRadius: "full",
|
||||
});
|
||||
|
||||
expect(tokens.labelStyleAttr).toBe(' style="color:#00ff00"');
|
||||
expect(tokens.selectStyleAttr).toContain("color:#00ff00");
|
||||
expect(tokens.selectStyleAttr).toContain("background-color:#020617");
|
||||
expect(tokens.selectStyleAttr).toContain("border-color:#64748b");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-left:12px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-right:12px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-top:6px");
|
||||
expect(tokens.selectStyleAttr).toContain("padding-bottom:6px");
|
||||
expect(tokens.selectStyleAttr).toContain("width:240px");
|
||||
expect(tokens.selectStyleAttr).toContain("border-radius:9999px");
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
const tokens = computeFormSelectExportTokens(baseSelect);
|
||||
expect(tokens.labelStyleAttr).toBe("");
|
||||
expect(tokens.selectStyleAttr).toBe(' style="border-radius:4px"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers.computeFormCheckboxExportTokens", () => {
|
||||
it("텍스트 색상/배경/테두리/패딩/둥글기 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormCheckboxExportTokens({
|
||||
...baseCheckbox,
|
||||
textColorCustom: " #38bdf8 ",
|
||||
fillColorCustom: " #0f172a ",
|
||||
strokeColorCustom: " #e5e7eb ",
|
||||
paddingX: 10,
|
||||
paddingY: 5,
|
||||
borderRadius: "sm",
|
||||
});
|
||||
|
||||
expect(tokens.labelStyleAttr).toBe(' style="color:#38bdf8"');
|
||||
expect(tokens.optionStyleAttr).toContain("color:#38bdf8");
|
||||
expect(tokens.optionStyleAttr).toContain("background-color:#0f172a");
|
||||
expect(tokens.optionStyleAttr).toContain("border-color:#e5e7eb");
|
||||
expect(tokens.optionStyleAttr).toContain("border-width:1px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-style:solid");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:10px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:10px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:5px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:5px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-radius:2px");
|
||||
});
|
||||
|
||||
it("스타일 값이 없으면 style 속성이 비어 있어야 한다", () => {
|
||||
const tokens = computeFormCheckboxExportTokens(baseCheckbox);
|
||||
expect(tokens.labelStyleAttr).toBe("");
|
||||
expect(tokens.optionStyleAttr).toBe(' style="border-radius:4px"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers.computeFormRadioExportTokens", () => {
|
||||
it("체크박스와 동일한 규칙으로 옵션 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormRadioExportTokens({
|
||||
...baseRadio,
|
||||
textColorCustom: " #fbbf24 ",
|
||||
fillColorCustom: " #111827 ",
|
||||
strokeColorCustom: " #facc15 ",
|
||||
paddingX: 4,
|
||||
paddingY: 2,
|
||||
borderRadius: "md",
|
||||
});
|
||||
|
||||
expect(tokens.labelStyleAttr).toBe(' style="color:#fbbf24"');
|
||||
expect(tokens.optionStyleAttr).toContain("color:#fbbf24");
|
||||
expect(tokens.optionStyleAttr).toContain("background-color:#111827");
|
||||
expect(tokens.optionStyleAttr).toContain("border-color:#facc15");
|
||||
expect(tokens.optionStyleAttr).toContain("border-width:1px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-style:solid");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-left:4px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-right:4px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-top:2px");
|
||||
expect(tokens.optionStyleAttr).toContain("padding-bottom:2px");
|
||||
expect(tokens.optionStyleAttr).toContain("border-radius:4px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers - editor tokens", () => {
|
||||
it("computeFormInputEditorTokens: 에디터 인풋 필드 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormInputEditorTokens({
|
||||
...baseInput,
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 240,
|
||||
borderRadius: "full",
|
||||
align: "center",
|
||||
labelLayout: "stacked",
|
||||
} as any);
|
||||
|
||||
expect(tokens.fieldStyle.color).toBe("#ff0000");
|
||||
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.fieldStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.fieldStyle.width).toBe("240px");
|
||||
expect(tokens.fieldStyle.borderRadius).toBe(9999);
|
||||
});
|
||||
|
||||
it("computeFormSelectEditorTokens: 에디터 셀렉트 필드 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormSelectEditorTokens({
|
||||
...baseSelect,
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 260,
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.fieldStyle.color).toBe("#ff0000");
|
||||
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.fieldStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.fieldStyle.width).toBe("260px");
|
||||
expect(tokens.fieldStyle.borderRadius).toBe(9999);
|
||||
});
|
||||
|
||||
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormOptionGroupEditorTokens({
|
||||
...baseRadio,
|
||||
textColorCustom: "#ff0000",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#654321",
|
||||
widthMode: "fixed",
|
||||
widthPx: 280,
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.fieldStyle.color).toBe("#ff0000");
|
||||
expect(tokens.fieldStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.fieldStyle.borderColor).toBe("#654321");
|
||||
expect(tokens.fieldStyle.width).toBe("280px");
|
||||
expect(tokens.fieldStyle.borderRadius).toBe(9999);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers - public tokens", () => {
|
||||
it("computeFormInputPublicTokens: 퍼블릭 인풋 필드 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormInputPublicTokens({
|
||||
...baseInput,
|
||||
align: "center",
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
textColorCustom: "#112233",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperLayoutClass).toBe("flex flex-col gap-1");
|
||||
expect(tokens.widthClass).toBe("");
|
||||
expect(tokens.inputStyle.textAlign).toBe("center");
|
||||
expect(tokens.inputStyle.width).toBe("20em");
|
||||
expect(tokens.inputStyle.paddingInline).toBe("1em");
|
||||
expect(tokens.inputStyle.paddingBlock).toBe("0.5em");
|
||||
expect(tokens.inputStyle.color).toBe("#112233");
|
||||
expect(tokens.inputStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.inputStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.inputStyle.borderRadius).toBe("6px");
|
||||
});
|
||||
|
||||
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormSelectPublicTokens({
|
||||
...baseSelect,
|
||||
widthMode: "fixed",
|
||||
widthPx: 240,
|
||||
paddingX: 16,
|
||||
paddingY: 8,
|
||||
textColorCustom: "#00ff00",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.widthClass).toBe("");
|
||||
expect(tokens.wrapperStyle.width).toBe("15em");
|
||||
expect(tokens.selectStyle.paddingInline).toBe("1em");
|
||||
expect(tokens.selectStyle.paddingBlock).toBe("0.5em");
|
||||
expect(tokens.selectStyle.color).toBe("#00ff00");
|
||||
expect(tokens.selectStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.selectStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.selectStyle.borderRadius).toBe("6px");
|
||||
});
|
||||
|
||||
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormCheckboxPublicTokens({
|
||||
...baseCheckbox,
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
optionGapPx: 16,
|
||||
textColorCustom: "#ffffff",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.widthClass).toBe("");
|
||||
expect(tokens.groupStyle.width).toBe("20em");
|
||||
expect(tokens.optionsStyle.rowGap).toBe("1em");
|
||||
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
|
||||
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
|
||||
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
|
||||
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
|
||||
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
|
||||
expect(tokens.groupTextStyle.color).toBe("#ffffff");
|
||||
expect(tokens.optionTextStyle.color).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
|
||||
const tokens = computeFormRadioPublicTokens({
|
||||
...baseRadio,
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
paddingX: 8,
|
||||
paddingY: 4,
|
||||
optionGapPx: 16,
|
||||
textColorCustom: "#ffffff",
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
} as any);
|
||||
|
||||
expect(tokens.widthClass).toBe("");
|
||||
expect(tokens.groupStyle.width).toBe("20em");
|
||||
expect(tokens.optionsStyle.rowGap).toBe("1em");
|
||||
expect(tokens.optionContainerStyle.paddingInline).toBe("0.5em");
|
||||
expect(tokens.optionContainerStyle.paddingBlock).toBe("0.25em");
|
||||
expect(tokens.optionContainerStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.optionContainerStyle.borderColor).toBe("#ff0000");
|
||||
expect(tokens.optionContainerStyle.borderWidth).toBe("1px");
|
||||
expect(tokens.optionContainerStyle.borderStyle).toBe("solid");
|
||||
expect(tokens.optionContainerStyle.borderRadius).toBe("6px");
|
||||
expect(tokens.groupTextStyle.color).toBe("#ffffff");
|
||||
expect(tokens.optionTextStyle.color).toBe("#ffffff");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers.computeFormControllerPublicTokens", () => {
|
||||
it("fieldIds 를 기반으로 컨트롤러 필드를 매핑하고 레이아웃/submitLabel 을 계산해야 한다", () => {
|
||||
const formBlock: Block = {
|
||||
id: "form-1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: ["input-1", "checkbox-1"],
|
||||
submitButtonId: "btn-1",
|
||||
formWidthMode: "fixed",
|
||||
formWidthPx: 320,
|
||||
marginYPx: 32,
|
||||
backgroundColorCustom: " #123456 ",
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const inputBlock: Block = {
|
||||
id: "input-1",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
inputType: "email",
|
||||
required: true,
|
||||
} as any,
|
||||
} as Block;
|
||||
|
||||
const checkboxBlock: Block = {
|
||||
id: "checkbox-1",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "옵션",
|
||||
formFieldName: "options",
|
||||
required: true,
|
||||
groupLabelMode: "image",
|
||||
groupLabelImageUrl: "https://example.com/group.png",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
} as any,
|
||||
} as Block;
|
||||
|
||||
const buttonBlock: Block = {
|
||||
id: "btn-1",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "컨트롤러 버튼",
|
||||
} as any,
|
||||
} as Block;
|
||||
|
||||
const blocks: Block[] = [formBlock, inputBlock, checkboxBlock, buttonBlock];
|
||||
|
||||
const tokens = computeFormControllerPublicTokens(formBlock, blocks);
|
||||
|
||||
expect(tokens.fields).toHaveLength(2);
|
||||
const inputField = tokens.fields[0];
|
||||
const checkboxField = tokens.fields[1];
|
||||
|
||||
expect(inputField.id).toBe("input-1");
|
||||
expect(inputField.name).toBe("name");
|
||||
expect(inputField.label).toBe("이름");
|
||||
expect(inputField.type).toBe("text");
|
||||
expect(inputField.required).toBe(true);
|
||||
|
||||
expect(checkboxField.id).toBe("checkbox-1");
|
||||
expect(checkboxField.name).toBe("options");
|
||||
expect(checkboxField.label).toBe("옵션");
|
||||
expect(checkboxField.type).toBe("checkbox");
|
||||
expect(checkboxField.required).toBe(true);
|
||||
expect(checkboxField.options).toHaveLength(2);
|
||||
expect(checkboxField.groupLabelMode).toBe("image");
|
||||
expect(checkboxField.groupLabelImageUrl).toBe("https://example.com/group.png");
|
||||
|
||||
expect(tokens.formClassName).toBe("space-y-3");
|
||||
// FormBlock 은 이제 레이아웃/배경 스타일을 가지지 않는 순수 컨트롤러이므로,
|
||||
// formStyle 은 항상 빈 객체여야 한다.
|
||||
expect(tokens.formStyle).toEqual({});
|
||||
|
||||
expect(tokens.submitLabel).toBe("컨트롤러 버튼");
|
||||
});
|
||||
|
||||
it("fields 가 있을 때는 fields 를 사용하고, 없으면 빈 배열을 반환해야 한다", () => {
|
||||
const formWithFields: Block = {
|
||||
id: "form-2",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const tokensWithFields = computeFormControllerPublicTokens(formWithFields, [formWithFields]);
|
||||
|
||||
expect(tokensWithFields.fields).toHaveLength(1);
|
||||
expect(tokensWithFields.fields[0].id).toBe("f1");
|
||||
expect(tokensWithFields.fields[0].name).toBe("email");
|
||||
expect(tokensWithFields.fields[0].label).toBe("이메일");
|
||||
expect(tokensWithFields.fields[0].type).toBe("email");
|
||||
expect(tokensWithFields.fields[0].required).toBe(true);
|
||||
|
||||
const formFallback: Block = {
|
||||
id: "form-3",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const tokensFallback = computeFormControllerPublicTokens(formFallback, [formFallback]);
|
||||
|
||||
expect(tokensFallback.fields).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formHelpers.computeFormBlockExportTokens", () => {
|
||||
it("fieldIds 가 있을 때 컨트롤러 필드와 폼 배경 스타일을 계산해야 한다", () => {
|
||||
const formBlock: Block = {
|
||||
id: "form-export-1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: ["input-1", "select-1"],
|
||||
fields: [],
|
||||
backgroundColorCustom: " #123456 ",
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const inputBlock: Block = {
|
||||
id: "input-1",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
inputType: "email",
|
||||
required: true,
|
||||
} as any,
|
||||
} as Block;
|
||||
|
||||
const selectBlock: Block = {
|
||||
id: "select-1",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
} as any,
|
||||
} as Block;
|
||||
|
||||
const blocks: Block[] = [formBlock, inputBlock, selectBlock];
|
||||
|
||||
const tokens = computeFormBlockExportTokens(formBlock, blocks);
|
||||
|
||||
expect(tokens.hasControllerFields).toBe(true);
|
||||
expect(tokens.controllerFields).toHaveLength(2);
|
||||
expect(tokens.controllerFields[0].id).toBe("input-1");
|
||||
expect(tokens.controllerFields[1].id).toBe("select-1");
|
||||
expect(tokens.fallbackFields).toHaveLength(0);
|
||||
// FormBlock 은 Export 레이어에서도 컨테이너 배경/레이아웃 스타일을 가지지 않는다.
|
||||
expect(tokens.formStyleParts).toEqual([]);
|
||||
});
|
||||
|
||||
it("fieldIds 가 없고 fields 가 있을 때 fallbackFields 를 사용해야 한다", () => {
|
||||
const formBlock: Block = {
|
||||
id: "form-export-2",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: [],
|
||||
fields: [
|
||||
{
|
||||
id: "f1",
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
backgroundColorCustom: undefined,
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
|
||||
|
||||
expect(tokens.hasControllerFields).toBe(false);
|
||||
expect(tokens.controllerFields).toHaveLength(0);
|
||||
expect(tokens.fallbackFields).toHaveLength(1);
|
||||
expect(tokens.fallbackFields[0].id).toBe("f1");
|
||||
expect(tokens.formStyleParts).toEqual([]);
|
||||
});
|
||||
|
||||
it("fieldIds 와 fields 가 모두 없으면 fallbackFields 도 빈 배열이어야 한다", () => {
|
||||
const formBlock: Block = {
|
||||
id: "form-export-3",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
} as FormBlockProps,
|
||||
} as Block;
|
||||
|
||||
const tokens = computeFormBlockExportTokens(formBlock, [formBlock]);
|
||||
|
||||
expect(tokens.hasControllerFields).toBe(false);
|
||||
expect(tokens.controllerFields).toHaveLength(0);
|
||||
expect(tokens.fallbackFields).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeImageExportStyles,
|
||||
computeImageEditorTokens,
|
||||
computeImagePublicTokens,
|
||||
} from "@/features/editor/utils/imageHelpers";
|
||||
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
describe("imageHelpers.computeImageExportStyles", () => {
|
||||
it("기본 값에서는 배경/너비 없이 border-radius 8px 를 사용해야 한다(md 토큰)", () => {
|
||||
const { wrapperStyleParts, imgStyleParts } = computeImageExportStyles({});
|
||||
|
||||
expect(wrapperStyleParts.length).toBe(0);
|
||||
expect(imgStyleParts).toContain("border-radius:8px");
|
||||
});
|
||||
|
||||
it("backgroundColorCustom 이 있으면 wrapperStyle 에 background-color 스타일을 추가해야 한다", () => {
|
||||
const { wrapperStyleParts } = computeImageExportStyles({
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expect(wrapperStyleParts).toContain("background-color:#123456");
|
||||
});
|
||||
|
||||
it("widthMode=fixed 이고 widthPx 가 양수이면 이미지 width 스타일을 추가해야 한다", () => {
|
||||
const { imgStyleParts } = computeImageExportStyles({
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
});
|
||||
|
||||
expect(imgStyleParts).toContain("width:320px");
|
||||
});
|
||||
|
||||
it("borderRadius 토큰이 full 이면 border-radius 9999px 를 사용해야 한다", () => {
|
||||
const { imgStyleParts } = computeImageExportStyles({
|
||||
borderRadius: "full",
|
||||
});
|
||||
|
||||
expect(imgStyleParts).toContain("border-radius:9999px");
|
||||
});
|
||||
|
||||
it("borderRadiusPx 가 지정되면 토큰 대신 해당 값을 px 로 사용해야 한다", () => {
|
||||
const { imgStyleParts } = computeImageExportStyles({
|
||||
borderRadius: "lg",
|
||||
borderRadiusPx: 20,
|
||||
});
|
||||
|
||||
expect(imgStyleParts).toContain("border-radius:20px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeImageEditorTokens", () => {
|
||||
it("기본 값에서는 widthPx 없이 borderRadius 8px 를 사용해야 한다(md 토큰)", () => {
|
||||
const tokens = computeImageEditorTokens({});
|
||||
|
||||
expect(tokens.widthPx).toBeUndefined();
|
||||
expect(tokens.radiusPx).toBe(8);
|
||||
});
|
||||
|
||||
it("widthMode=fixed 이고 widthPx 가 양수이면 widthPx 토큰을 그대로 사용해야 한다", () => {
|
||||
const tokens = computeImageEditorTokens({
|
||||
widthMode: "fixed",
|
||||
widthPx: 320,
|
||||
});
|
||||
|
||||
expect(tokens.widthPx).toBe(320);
|
||||
});
|
||||
|
||||
it("borderRadius 토큰이 full 이면 radiusPx 9999 를 사용해야 한다", () => {
|
||||
const tokens = computeImageEditorTokens({
|
||||
borderRadius: "full",
|
||||
});
|
||||
|
||||
expect(tokens.radiusPx).toBe(9999);
|
||||
});
|
||||
|
||||
it("borderRadiusPx 가 지정되면 토큰 대신 해당 radiusPx 를 사용해야 한다", () => {
|
||||
const tokens = computeImageEditorTokens({
|
||||
borderRadius: "lg",
|
||||
borderRadiusPx: 24,
|
||||
});
|
||||
|
||||
expect(tokens.radiusPx).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("imageHelpers.computeImagePublicTokens", () => {
|
||||
const baseProps: ImageBlockProps = {
|
||||
src: "/images/example.png",
|
||||
alt: "이미지",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
};
|
||||
|
||||
it("backgroundColorCustom 이 설정된 경우에만 wrapperStyle.backgroundColor 를 설정해야 한다", () => {
|
||||
const tokensWithBg = computeImagePublicTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expect(tokensWithBg.wrapperStyle.backgroundColor).toBe("#123456");
|
||||
|
||||
const tokensWithoutBg = computeImagePublicTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: undefined,
|
||||
});
|
||||
|
||||
expect(tokensWithoutBg.wrapperStyle.backgroundColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("widthMode 가 fixed 이고 widthPx 가 설정된 경우 imageStyle.width 는 em 단위로 설정되어야 한다", () => {
|
||||
const tokens = computeImagePublicTokens({
|
||||
...baseProps,
|
||||
widthMode: "fixed",
|
||||
widthPx: 480,
|
||||
});
|
||||
|
||||
expect(tokens.imageStyle.width).toBe("30em");
|
||||
});
|
||||
|
||||
it("borderRadiusPx 가 설정된 경우 imageStyle.borderRadius 는 em 단위로 설정되어야 한다", () => {
|
||||
const tokens = computeImagePublicTokens({
|
||||
...baseProps,
|
||||
borderRadiusPx: 32,
|
||||
});
|
||||
|
||||
expect(tokens.imageStyle.borderRadius).toBe("2em");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeListExportTokens,
|
||||
computeListEditorTokens,
|
||||
computeListPublicTokens,
|
||||
} from "@/features/editor/utils/listHelpers";
|
||||
import type { ListBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseProps: ListBlockProps = {
|
||||
items: [],
|
||||
ordered: false,
|
||||
align: "left",
|
||||
};
|
||||
|
||||
describe("listHelpers.computeListExportTokens", () => {
|
||||
it("기본 unordered 리스트는 Tag=ul, left 정렬, items 플랫닝을 수행해야 한다", () => {
|
||||
const props: ListBlockProps = {
|
||||
...baseProps,
|
||||
items: [" first ", "", " second"],
|
||||
};
|
||||
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
expect(tokens.Tag).toBe("ul");
|
||||
expect(tokens.items).toEqual([" first ", " second"]);
|
||||
expect(tokens.listStyleParts).toContain("text-align:left");
|
||||
});
|
||||
|
||||
it("ordered=true 인 경우 Tag=ol 이어야 한다", () => {
|
||||
const props: ListBlockProps = {
|
||||
...baseProps,
|
||||
ordered: true,
|
||||
items: ["one", "two"],
|
||||
};
|
||||
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
expect(tokens.Tag).toBe("ol");
|
||||
expect(tokens.items).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
it("itemsTree 가 있으면 중첩 트리를 DFS 순서로 평탄화해야 한다", () => {
|
||||
const props: ListBlockProps = {
|
||||
...baseProps,
|
||||
itemsTree: [
|
||||
{
|
||||
id: "n1",
|
||||
text: "parent",
|
||||
children: [
|
||||
{ id: "n1-1", text: "child-1", children: [] },
|
||||
{ id: "n1-2", text: "child-2", children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const tokens = computeListExportTokens(props);
|
||||
|
||||
expect(tokens.items).toEqual(["parent", "child-1", "child-2"]);
|
||||
});
|
||||
|
||||
it("align 값에 따라 text-align 스타일이 left/center/right 로 설정되어야 한다", () => {
|
||||
const centerTokens = computeListExportTokens({ ...baseProps, align: "center", items: ["a"] });
|
||||
const rightTokens = computeListExportTokens({ ...baseProps, align: "right", items: ["a"] });
|
||||
|
||||
expect(centerTokens.listStyleParts).toContain("text-align:center");
|
||||
expect(rightTokens.listStyleParts).toContain("text-align:right");
|
||||
});
|
||||
|
||||
it("backgroundColorCustom 이 있으면 background-color 스타일을 추가해야 한다", () => {
|
||||
const tokens = computeListExportTokens({
|
||||
...baseProps,
|
||||
items: ["a"],
|
||||
backgroundColorCustom: " #ff0000 ",
|
||||
});
|
||||
|
||||
expect(tokens.listStyleParts).toContain("background-color:#ff0000");
|
||||
});
|
||||
|
||||
it("items 와 itemsTree 가 모두 비어 있으면 items 배열은 빈 배열이어야 한다", () => {
|
||||
const tokens = computeListExportTokens({ ...baseProps });
|
||||
expect(tokens.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listHelpers.computeListEditorTokens", () => {
|
||||
it("gapY 토큰에 따라 gapPx 가 sm=4, md=8, lg=16 으로 계산되어야 한다", () => {
|
||||
const sm = computeListEditorTokens({ ...baseProps, gapY: "sm" });
|
||||
const md = computeListEditorTokens({ ...baseProps, gapY: "md" });
|
||||
const lg = computeListEditorTokens({ ...baseProps, gapY: "lg" });
|
||||
|
||||
expect(sm.gapPx).toBe(4);
|
||||
expect(md.gapPx).toBe(8);
|
||||
expect(lg.gapPx).toBe(16);
|
||||
});
|
||||
|
||||
it("gapYPx 가 있으면 gapY 토큰보다 우선해서 gapPx 를 설정해야 한다", () => {
|
||||
const tokens = computeListEditorTokens({ ...baseProps, gapY: "sm", gapYPx: 24 });
|
||||
|
||||
expect(tokens.gapPx).toBe(24);
|
||||
});
|
||||
|
||||
it("fontSizeCustom / lineHeightCustom / textColorCustom 이 listStyle 에 반영되어야 한다", () => {
|
||||
const tokens = computeListEditorTokens({
|
||||
...baseProps,
|
||||
fontSizeCustom: "18px",
|
||||
lineHeightCustom: "1.8",
|
||||
textColorCustom: "#ff0000",
|
||||
});
|
||||
|
||||
expect(tokens.listStyle.fontSize).toBe("18px");
|
||||
expect(tokens.listStyle.lineHeight).toBe("1.8");
|
||||
expect(tokens.listStyle.color).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
|
||||
const defaultDisc = computeListEditorTokens({
|
||||
...baseProps,
|
||||
ordered: false,
|
||||
bulletStyle: undefined,
|
||||
});
|
||||
|
||||
const defaultDecimal = computeListEditorTokens({
|
||||
...baseProps,
|
||||
ordered: true,
|
||||
bulletStyle: undefined,
|
||||
});
|
||||
|
||||
const none = computeListEditorTokens({
|
||||
...baseProps,
|
||||
bulletStyle: "none",
|
||||
});
|
||||
|
||||
expect(defaultDisc.bulletStyle).toBe("disc");
|
||||
expect(defaultDecimal.bulletStyle).toBe("decimal");
|
||||
expect(none.bulletStyle).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listHelpers.computeListPublicTokens", () => {
|
||||
it("align 값에 따라 alignClass 가 text-left/center/right 로 설정되어야 한다", () => {
|
||||
const leftTokens = computeListPublicTokens({ ...baseProps, align: "left" });
|
||||
const centerTokens = computeListPublicTokens({ ...baseProps, align: "center" });
|
||||
const rightTokens = computeListPublicTokens({ ...baseProps, align: "right" });
|
||||
|
||||
expect(leftTokens.alignClass).toBe("text-left");
|
||||
expect(centerTokens.alignClass).toBe("text-center");
|
||||
expect(rightTokens.alignClass).toBe("text-right");
|
||||
});
|
||||
|
||||
it("gapYPx 와 gapY 토큰에 따라 gapEm 이 px/16 값으로 계산되어야 한다", () => {
|
||||
const byPx = computeListPublicTokens({ ...baseProps, gapYPx: 24 });
|
||||
const byTokenSm = computeListPublicTokens({ ...baseProps, gapY: "sm" });
|
||||
const byTokenLg = computeListPublicTokens({ ...baseProps, gapY: "lg" });
|
||||
|
||||
expect(byPx.gapEm).toBe(24 / 16);
|
||||
expect(byTokenSm.gapEm).toBe(4 / 16);
|
||||
expect(byTokenLg.gapEm).toBe(16 / 16);
|
||||
});
|
||||
|
||||
it("fontSizeCustom 이 px 인 경우 em 단위로 변환되어야 하고, backgroundColorCustom 은 trim 되어야 한다", () => {
|
||||
const tokens = computeListPublicTokens({
|
||||
...baseProps,
|
||||
fontSizeCustom: "32px",
|
||||
backgroundColorCustom: " #123456 ",
|
||||
});
|
||||
|
||||
expect(tokens.listStyle.fontSize).toBe("2em");
|
||||
expect(tokens.listStyle.backgroundColor).toBe("#123456");
|
||||
});
|
||||
|
||||
it("bulletStyle 은 bulletStyle 지정값 또는 ordered 여부에 따라 결정되어야 한다", () => {
|
||||
const defaultDisc = computeListPublicTokens({ ...baseProps, ordered: false, bulletStyle: undefined });
|
||||
const defaultDecimal = computeListPublicTokens({ ...baseProps, ordered: true, bulletStyle: undefined });
|
||||
const none = computeListPublicTokens({ ...baseProps, bulletStyle: "none" });
|
||||
|
||||
expect(defaultDisc.bulletStyle).toBe("disc");
|
||||
expect(defaultDecimal.bulletStyle).toBe("decimal");
|
||||
expect(none.bulletStyle).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeSectionExportTokens,
|
||||
computeSectionPublicTokens,
|
||||
computeSectionEditorTokens,
|
||||
} from "@/features/editor/utils/sectionHelpers";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseProps: SectionBlockProps = {
|
||||
background: "default",
|
||||
paddingY: "md",
|
||||
columns: [{ id: "sec_col_1", span: 12 }],
|
||||
maxWidthMode: "normal",
|
||||
gapX: "md",
|
||||
};
|
||||
|
||||
describe("sectionHelpers.computeSectionExportTokens", () => {
|
||||
it("기본 섹션은 pb-section / pb-section-bg-default / pb-section-py-md 를 포함해야 한다", () => {
|
||||
const tokens = computeSectionExportTokens(baseProps);
|
||||
|
||||
expect(tokens.sectionClasses).toContain("pb-section");
|
||||
expect(tokens.sectionClasses).toContain("pb-section-bg-default");
|
||||
expect(tokens.sectionClasses).toContain("pb-section-py-md");
|
||||
expect(tokens.sectionStyleParts.length).toBe(0);
|
||||
expect(tokens.backgroundVideoHtml).toBe("");
|
||||
});
|
||||
|
||||
it("background=muted/primary 와 paddingY=sm/lg 는 각각 대응하는 pb-section-bg-*/pb-section-py-* 클래스로 매핑되어야 한다", () => {
|
||||
const muted = computeSectionExportTokens({ ...baseProps, background: "muted" });
|
||||
const primary = computeSectionExportTokens({ ...baseProps, background: "primary" });
|
||||
const sm = computeSectionExportTokens({ ...baseProps, paddingY: "sm" });
|
||||
const lg = computeSectionExportTokens({ ...baseProps, paddingY: "lg" });
|
||||
|
||||
expect(muted.sectionClasses).toContain("pb-section-bg-muted");
|
||||
expect(primary.sectionClasses).toContain("pb-section-bg-primary");
|
||||
expect(sm.sectionClasses).toContain("pb-section-py-sm");
|
||||
expect(lg.sectionClasses).toContain("pb-section-py-lg");
|
||||
});
|
||||
|
||||
it("backgroundColorCustom 이 있으면 background-color 스타일을 추가해야 한다", () => {
|
||||
const tokens = computeSectionExportTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expect(tokens.sectionStyleParts).toContain("background-color:#123456");
|
||||
});
|
||||
|
||||
it("backgroundImage 관련 옵션이 있으면 background-image/size/position/repeat 스타일을 추가해야 한다", () => {
|
||||
const tokens = computeSectionExportTokens({
|
||||
...baseProps,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "preset",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-x",
|
||||
});
|
||||
|
||||
expect(tokens.sectionStyleParts).toContain("background-image:url(https://example.com/bg.png)");
|
||||
expect(tokens.sectionStyleParts).toContain("background-size:cover");
|
||||
expect(tokens.sectionStyleParts).toContain("background-position:center top");
|
||||
expect(tokens.sectionStyleParts).toContain("background-repeat:repeat-x");
|
||||
});
|
||||
|
||||
it("배경 비디오가 있으면 backgroundVideoHtml 과 position/overflow 스타일을 추가해야 한다", () => {
|
||||
const tokens = computeSectionExportTokens({
|
||||
...baseProps,
|
||||
backgroundVideoSrc: "https://example.com/bg.mp4",
|
||||
});
|
||||
|
||||
expect(tokens.backgroundVideoHtml).toContain("pb-section-bg-video");
|
||||
expect(tokens.backgroundVideoHtml).toContain("https://example.com/bg.mp4");
|
||||
expect(tokens.sectionStyleParts).toContain("position:relative");
|
||||
expect(tokens.sectionStyleParts).toContain("overflow:hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionHelpers.computeSectionPublicTokens", () => {
|
||||
it("배경색 / paddingYPx / maxWidthPx / gapXPx 를 em 단위 스타일로 계산해야 한다", () => {
|
||||
const tokens = computeSectionPublicTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: " #123456 ",
|
||||
paddingYPx: 32,
|
||||
maxWidthPx: 800,
|
||||
gapXPx: 24,
|
||||
});
|
||||
|
||||
expect(tokens.sectionStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.sectionStyle.paddingTop).toBe("2em");
|
||||
expect(tokens.sectionStyle.paddingBottom).toBe("2em");
|
||||
expect(tokens.innerWrapperStyle.maxWidth).toBe("50em");
|
||||
expect(tokens.columnsContainerStyle.columnGap).toBe("1.5em");
|
||||
});
|
||||
|
||||
it("backgroundImage* 와 backgroundVideoSrc 를 올바르게 반영해야 한다", () => {
|
||||
const tokens = computeSectionPublicTokens({
|
||||
...baseProps,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "preset",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-x",
|
||||
backgroundVideoSrc: " https://example.com/bg.mp4 ",
|
||||
} as any);
|
||||
|
||||
expect(tokens.sectionStyle.backgroundImage).toBe("url(https://example.com/bg.png)");
|
||||
expect(tokens.sectionStyle.backgroundSize).toBe("cover");
|
||||
expect(tokens.sectionStyle.backgroundPosition).toBe("center top");
|
||||
expect(tokens.sectionStyle.backgroundRepeat).toBe("repeat-x");
|
||||
expect(tokens.hasBackgroundVideo).toBe(true);
|
||||
expect(tokens.backgroundVideoSrc).toBe("https://example.com/bg.mp4");
|
||||
expect(tokens.sectionStyle.position).toBe("relative");
|
||||
expect(tokens.sectionStyle.overflow).toBe("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sectionHelpers.computeSectionEditorTokens", () => {
|
||||
it("backgroundColorCustom / paddingYPx / maxWidthPx / gapXPx 가 px 단위 스타일로 계산되어야 한다", () => {
|
||||
const tokens = computeSectionEditorTokens({
|
||||
...baseProps,
|
||||
backgroundColorCustom: "#123456",
|
||||
paddingYPx: 80,
|
||||
maxWidthPx: 960,
|
||||
gapXPx: 32,
|
||||
alignItems: "center",
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.wrapperStyle.paddingTop).toBe("80px");
|
||||
expect(tokens.wrapperStyle.paddingBottom).toBe("80px");
|
||||
expect(tokens.innerWrapperStyle.maxWidth).toBe("960px");
|
||||
expect(tokens.columnsContainerStyle.columnGap).toBe("32px");
|
||||
expect(tokens.alignItemsClass).toBe("items-center");
|
||||
});
|
||||
|
||||
it("background 및 paddingY 값에 따라 bgClass/pyClass 를 계산해야 한다", () => {
|
||||
const muted = computeSectionEditorTokens({ ...baseProps, background: "muted" } as any);
|
||||
const primary = computeSectionEditorTokens({ ...baseProps, background: "primary" } as any);
|
||||
const sm = computeSectionEditorTokens({ ...baseProps, paddingY: "sm" } as any);
|
||||
const lg = computeSectionEditorTokens({ ...baseProps, paddingY: "lg" } as any);
|
||||
|
||||
expect(muted.bgClass).toContain("bg-slate-950/40");
|
||||
expect(primary.bgClass).toContain("bg-sky-950/40");
|
||||
expect(primary.bgClass).toContain("border-sky-900/60");
|
||||
expect(sm.pyClass).toBe("py-4");
|
||||
expect(lg.pyClass).toBe("py-10");
|
||||
});
|
||||
|
||||
it("backgroundImage* 옵션이 있으면 wrapperStyle 에 backgroundImage/Size/Position/Repeat 이 반영되어야 한다", () => {
|
||||
const tokens = computeSectionEditorTokens({
|
||||
...baseProps,
|
||||
backgroundImageSrc: "https://example.com/bg.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "preset",
|
||||
backgroundImagePosition: "top",
|
||||
backgroundImageRepeat: "repeat-x",
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperStyle.backgroundImage).toBe("url(https://example.com/bg.png)");
|
||||
expect(tokens.wrapperStyle.backgroundSize).toBe("cover");
|
||||
expect(tokens.wrapperStyle.backgroundPosition).toBe("center top");
|
||||
expect(tokens.wrapperStyle.backgroundRepeat).toBe("repeat-x");
|
||||
});
|
||||
|
||||
it("backgroundImagePositionMode 가 custom 이고 X/Y 퍼센트가 설정되면 background-position 은 'X% Y%' 로 설정되어야 한다", () => {
|
||||
const tokens = computeSectionEditorTokens({
|
||||
...baseProps,
|
||||
backgroundImageSrc: "https://example.com/bg-xy.png",
|
||||
backgroundImageSize: "cover",
|
||||
backgroundImagePositionMode: "custom",
|
||||
backgroundImagePositionXPercent: 30,
|
||||
backgroundImagePositionYPercent: 70,
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperStyle.backgroundImage).toBe("url(https://example.com/bg-xy.png)");
|
||||
expect(tokens.wrapperStyle.backgroundPosition).toBe("30% 70%");
|
||||
});
|
||||
|
||||
it("backgroundVideoSrc 가 있으면 hasBackgroundVideo=true, backgroundVideoSrc 와 position/overflow 스타일이 설정되어야 한다", () => {
|
||||
const tokens = computeSectionEditorTokens({
|
||||
...baseProps,
|
||||
backgroundVideoSrc: " https://example.com/bg.mp4 ",
|
||||
} as any);
|
||||
|
||||
expect(tokens.hasBackgroundVideo).toBe(true);
|
||||
expect(tokens.backgroundVideoSrc).toBe("https://example.com/bg.mp4");
|
||||
expect(tokens.wrapperStyle.position).toBe("relative");
|
||||
expect(tokens.wrapperStyle.overflow).toBe("hidden");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeTextPbTokens,
|
||||
computeTextEditorTokens,
|
||||
computeTextPublicTokens,
|
||||
} from "@/features/editor/utils/textHelpers";
|
||||
import type { TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const baseProps = {
|
||||
text: "텍스트",
|
||||
align: "left" as const,
|
||||
size: "base" as const,
|
||||
};
|
||||
|
||||
describe("textHelpers.computeTextPbTokens", () => {
|
||||
it("기본 텍스트는 pb-text-left / pb-text-base / pb-leading-normal / pb-font-normal / pb-text-color-strong / pb-whitespace-pre-wrap 를 포함해야 한다", () => {
|
||||
const tokens = computeTextPbTokens(baseProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-left");
|
||||
expect(tokens.sizeClass).toBe("pb-text-base");
|
||||
expect(tokens.leadingClass).toBe("pb-leading-normal");
|
||||
expect(tokens.weightClass).toBe("pb-font-normal");
|
||||
expect(tokens.colorClass).toBe("pb-text-color-strong");
|
||||
expect(tokens.extraClasses).toContain("pb-whitespace-pre-wrap");
|
||||
});
|
||||
|
||||
it("스케일 모드 텍스트는 폰트/라인하이트/굵기 스케일 토큰을 올바르게 매핑해야 한다", () => {
|
||||
const tokens = computeTextPbTokens({
|
||||
...baseProps,
|
||||
align: "center",
|
||||
fontSizeMode: "scale",
|
||||
fontSizeScale: "lg",
|
||||
lineHeightMode: "scale",
|
||||
lineHeightScale: "relaxed",
|
||||
fontWeightMode: "scale",
|
||||
fontWeightScale: "semibold",
|
||||
colorMode: "palette",
|
||||
colorPalette: "strong",
|
||||
underline: true,
|
||||
italic: true,
|
||||
});
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-center");
|
||||
expect(tokens.sizeClass).toBe("pb-text-lg");
|
||||
expect(tokens.leadingClass).toBe("pb-leading-relaxed");
|
||||
expect(tokens.weightClass).toBe("pb-font-semibold");
|
||||
expect(tokens.colorClass).toBe("pb-text-color-strong");
|
||||
expect(tokens.extraClasses).toContain("pb-underline");
|
||||
expect(tokens.extraClasses).toContain("pb-italic");
|
||||
});
|
||||
|
||||
it("커스텀 색상과 배경색은 inlineStyles 에 color/background-color 로 포함되어야 한다", () => {
|
||||
const tokens = computeTextPbTokens({
|
||||
...baseProps,
|
||||
colorMode: "custom",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
});
|
||||
|
||||
expect(tokens.inlineStyles).toContain("color:#ff0000");
|
||||
expect(tokens.inlineStyles).toContain("background-color:#123456");
|
||||
});
|
||||
|
||||
it("maxWidthScale 가 prose/narrow 인 경우 pb-text-maxw-* 클래스를 포함해야 한다", () => {
|
||||
const prose = computeTextPbTokens({ ...baseProps, maxWidthMode: "scale", maxWidthScale: "prose" });
|
||||
const narrow = computeTextPbTokens({ ...baseProps, maxWidthMode: "scale", maxWidthScale: "narrow" });
|
||||
|
||||
expect(prose.maxWidthClass).toBe("pb-text-maxw-prose");
|
||||
expect(narrow.maxWidthClass).toBe("pb-text-maxw-narrow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("textHelpers.computeTextEditorTokens", () => {
|
||||
const baseBlockProps: TextBlockProps = {
|
||||
text: "텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
it("기본 텍스트는 pb-text-left / pb-text-base / pb-leading-normal / pb-font-normal / pb-text-color-default 를 사용해야 한다", () => {
|
||||
const tokens = computeTextEditorTokens(baseBlockProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-left");
|
||||
expect(tokens.sizeClass).toBe("pb-text-base");
|
||||
expect(tokens.leadingClass).toBe("pb-leading-normal");
|
||||
expect(tokens.weightClass).toBe("pb-font-normal");
|
||||
expect(tokens.colorClass).toBe("pb-text-color-default");
|
||||
expect(tokens.styleOverrides.color).toBeUndefined();
|
||||
});
|
||||
|
||||
it("colorCustom / backgroundColorCustom / maxWidthCustom 이 styleOverrides 에 반영되어야 한다", () => {
|
||||
const tokens = computeTextEditorTokens({
|
||||
...baseBlockProps,
|
||||
align: "center",
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
maxWidthCustom: "320px",
|
||||
} as TextBlockProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("pb-text-center");
|
||||
expect(tokens.styleOverrides.color).toBe("#ff0000");
|
||||
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
|
||||
expect(tokens.styleOverrides.maxWidth).toBe("320px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("textHelpers.computeTextPublicTokens", () => {
|
||||
const baseBlockProps: TextBlockProps = {
|
||||
text: "텍스트",
|
||||
align: "left",
|
||||
size: "base",
|
||||
} as TextBlockProps;
|
||||
|
||||
it("기본 텍스트는 text-left / text-base 를 사용하고 색상/배경 styleOverrides 는 없어야 한다", () => {
|
||||
const tokens = computeTextPublicTokens(baseBlockProps);
|
||||
|
||||
expect(tokens.alignClass).toBe("text-left");
|
||||
expect(tokens.sizeClass).toBe("text-base");
|
||||
expect(tokens.styleOverrides.color).toBeUndefined();
|
||||
expect(tokens.styleOverrides.backgroundColor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fontSizeMode=custom 인 경우 sizeClass 는 비워지고 styleOverrides.fontSize 에 값이 설정되어야 한다", () => {
|
||||
const tokens = computeTextPublicTokens({
|
||||
...baseBlockProps,
|
||||
fontSizeMode: "custom",
|
||||
fontSizeCustom: "24px",
|
||||
} as TextBlockProps);
|
||||
|
||||
expect(tokens.sizeClass).toBe("");
|
||||
expect(tokens.styleOverrides.fontSize).toBe("24px");
|
||||
});
|
||||
|
||||
it("colorCustom / backgroundColorCustom 이 styleOverrides 에 반영되어야 한다", () => {
|
||||
const tokens = computeTextPublicTokens({
|
||||
...baseBlockProps,
|
||||
colorCustom: "#ff0000",
|
||||
backgroundColorCustom: "#123456",
|
||||
} as TextBlockProps);
|
||||
|
||||
expect(tokens.styleOverrides.color).toBe("#ff0000");
|
||||
expect(tokens.styleOverrides.backgroundColor).toBe("#123456");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
normalizeVideoSourceUrl,
|
||||
resolveVideoPlatform,
|
||||
buildVideoEmbedUrl,
|
||||
computeVideoPublicTokens,
|
||||
computeVideoEditorTokens,
|
||||
computeVideoExportTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import type { VideoBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
describe("videoHelpers", () => {
|
||||
it("normalizeVideoSourceUrl 은 null/undefined 를 빈 문자열로 만들고 공백을 제거해야 한다", () => {
|
||||
expect(normalizeVideoSourceUrl(undefined)).toBe("");
|
||||
expect(normalizeVideoSourceUrl(null)).toBe("");
|
||||
expect(normalizeVideoSourceUrl("")).toBe("");
|
||||
expect(normalizeVideoSourceUrl(" https://example.com/video.mp4 ")).toBe("https://example.com/video.mp4");
|
||||
});
|
||||
|
||||
it("resolveVideoPlatform 은 platform 이 auto 일 때 URL 로부터 youtube/vimeo/html5 를 판별해야 한다", () => {
|
||||
expect(resolveVideoPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "auto")).toBe("youtube");
|
||||
expect(resolveVideoPlatform("https://youtu.be/dQw4w9WgXcQ", "auto")).toBe("youtube");
|
||||
expect(resolveVideoPlatform("https://vimeo.com/123456", "auto")).toBe("vimeo");
|
||||
expect(resolveVideoPlatform("/videos/local.mp4", "auto")).toBe("html5");
|
||||
});
|
||||
|
||||
it("resolveVideoPlatform 은 platform 이 명시된 경우 해당 값을 우선 사용해야 한다", () => {
|
||||
expect(resolveVideoPlatform("https://example.com/video", "youtube")).toBe("youtube");
|
||||
expect(resolveVideoPlatform("https://example.com/video", "vimeo")).toBe("vimeo");
|
||||
expect(resolveVideoPlatform("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "html5")).toBe("html5");
|
||||
});
|
||||
|
||||
it("buildVideoEmbedUrl 은 YouTube URL 을 embed URL 로 변환해야 한다", () => {
|
||||
expect(buildVideoEmbedUrl("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "youtube")).toBe(
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
);
|
||||
expect(buildVideoEmbedUrl("https://youtu.be/dQw4w9WgXcQ", "youtube")).toBe(
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
);
|
||||
expect(buildVideoEmbedUrl("https://www.youtube.com/shorts/dQw4w9WgXcQ", "youtube")).toBe(
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
);
|
||||
});
|
||||
|
||||
it("buildVideoEmbedUrl 은 Vimeo URL 에 대해 옵션에 따라 embed URL 또는 원본 URL 을 반환해야 한다", () => {
|
||||
const url = "https://vimeo.com/123456";
|
||||
expect(buildVideoEmbedUrl(url, "vimeo", { enableVimeoEmbed: true })).toBe(
|
||||
"https://player.vimeo.com/video/123456",
|
||||
);
|
||||
expect(buildVideoEmbedUrl(url, "vimeo", { enableVimeoEmbed: false })).toBe("https://vimeo.com/123456");
|
||||
expect(buildVideoEmbedUrl(url, "vimeo")).toBe("https://vimeo.com/123456");
|
||||
});
|
||||
|
||||
it("buildVideoEmbedUrl 은 html5 플랫폼에서는 정규화된 원본 URL 을 그대로 반환해야 한다", () => {
|
||||
expect(buildVideoEmbedUrl(" /videos/local.mp4 ", "html5")).toBe("/videos/local.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoHelpers.computeVideoPublicTokens", () => {
|
||||
const baseProps: VideoBlockProps = {
|
||||
sourceUrl: "/videos/sample.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
it("fixed width / 카드 스타일을 em 단위로 계산해야 한다", () => {
|
||||
const tokens = computeVideoPublicTokens({
|
||||
...baseProps,
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 32,
|
||||
borderRadiusPx: 16,
|
||||
} as any);
|
||||
|
||||
expect(tokens.justifyClass).toBe("justify-start");
|
||||
expect(tokens.wrapperStyle.width).toBe("40em");
|
||||
expect(tokens.videoStyle.width).toBe("40em");
|
||||
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.wrapperStyle.padding).toBe("2em");
|
||||
expect(tokens.wrapperStyle.borderRadius).toBe("1em");
|
||||
});
|
||||
|
||||
it("widthMode 가 full 이면 wrapper/video width 를 100% 로 설정해야 한다", () => {
|
||||
const tokens = computeVideoPublicTokens({
|
||||
...baseProps,
|
||||
widthMode: "full",
|
||||
widthPx: 640,
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperStyle.width).toBe("100%");
|
||||
expect(tokens.videoStyle.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
|
||||
const defaultTokens = computeVideoPublicTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "16:9",
|
||||
} as any);
|
||||
const fourThree = computeVideoPublicTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "4:3",
|
||||
} as any);
|
||||
const oneOne = computeVideoPublicTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "1:1",
|
||||
} as any);
|
||||
|
||||
expect(defaultTokens.aspectClass).toBe("");
|
||||
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
|
||||
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoHelpers.computeVideoExportTokens", () => {
|
||||
const baseProps: VideoBlockProps = {
|
||||
sourceUrl: "/videos/sample-export.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
const escapers = { escapeAttr: (value: string) => value };
|
||||
|
||||
it("fixed width / 카드 스타일을 px 단위 스타일 파트로 계산해야 한다", () => {
|
||||
const tokens = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
|
||||
expect(tokens.aspectClass).toBe("");
|
||||
expect(tokens.wrapperStyleParts).toContain("width:640px");
|
||||
expect(tokens.videoStyleParts).toContain("width:640px");
|
||||
expect(tokens.wrapperStyleParts).toContain("background-color:#123456");
|
||||
expect(tokens.wrapperStyleParts).toContain("padding:24px");
|
||||
expect(tokens.wrapperStyleParts).toContain("border-radius:16px");
|
||||
expect(tokens.outerStyleParts).toContain("display:flex");
|
||||
expect(tokens.outerStyleParts).toContain("justify-content:flex-start");
|
||||
});
|
||||
|
||||
it("widthMode 가 full 이면 width:100% 로 설정하고 align 에 따라 justify-content 를 계산해야 한다", () => {
|
||||
const right = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
align: "right",
|
||||
widthMode: "full",
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
|
||||
expect(right.wrapperStyleParts).toContain("width:100%");
|
||||
expect(right.videoStyleParts).toContain("width:100%");
|
||||
expect(right.outerStyleParts).toContain("justify-content:flex-end");
|
||||
|
||||
const center = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
|
||||
expect(center.outerStyleParts).toContain("justify-content:center");
|
||||
});
|
||||
|
||||
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
|
||||
const def = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
aspectRatio: "16:9",
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
const fourThree = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
aspectRatio: "4:3",
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
const oneOne = computeVideoExportTokens(
|
||||
{
|
||||
...baseProps,
|
||||
aspectRatio: "1:1",
|
||||
} as any,
|
||||
escapers,
|
||||
);
|
||||
|
||||
expect(def.aspectClass).toBe("");
|
||||
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
|
||||
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoHelpers.computeVideoEditorTokens", () => {
|
||||
const baseProps: VideoBlockProps = {
|
||||
sourceUrl: "/videos/sample-editor.mp4",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
aspectRatio: "16:9",
|
||||
};
|
||||
|
||||
it("fixed width / 카드 스타일을 px 단위로 계산해야 한다", () => {
|
||||
const tokens = computeVideoEditorTokens({
|
||||
...baseProps,
|
||||
align: "left",
|
||||
widthMode: "fixed",
|
||||
widthPx: 640,
|
||||
backgroundColorCustom: "#123456",
|
||||
cardPaddingPx: 24,
|
||||
borderRadiusPx: 16,
|
||||
} as any);
|
||||
|
||||
expect(tokens.justifyClass).toBe("justify-start");
|
||||
expect(tokens.wrapperStyle.width).toBe("640px");
|
||||
expect(tokens.videoStyle.width).toBe("640px");
|
||||
expect(tokens.wrapperStyle.backgroundColor).toBe("#123456");
|
||||
expect(tokens.wrapperStyle.padding).toBe("24px");
|
||||
expect(tokens.wrapperStyle.borderRadius).toBe("16px");
|
||||
});
|
||||
|
||||
it("widthMode 가 full 이면 wrapper/video width 를 100% 로 설정해야 한다", () => {
|
||||
const tokens = computeVideoEditorTokens({
|
||||
...baseProps,
|
||||
widthMode: "full",
|
||||
widthPx: 640,
|
||||
} as any);
|
||||
|
||||
expect(tokens.wrapperStyle.width).toBe("100%");
|
||||
expect(tokens.videoStyle.width).toBe("100%");
|
||||
});
|
||||
|
||||
it("aspectRatio 에 따라 aspectClass 를 설정해야 한다", () => {
|
||||
const defaultTokens = computeVideoEditorTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "16:9",
|
||||
} as any);
|
||||
const fourThree = computeVideoEditorTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "4:3",
|
||||
} as any);
|
||||
const oneOne = computeVideoEditorTokens({
|
||||
...baseProps,
|
||||
aspectRatio: "1:1",
|
||||
} as any);
|
||||
|
||||
expect(defaultTokens.aspectClass).toBe("");
|
||||
expect(fourThree.aspectClass).toBe(" pb-video-wrapper--4by3");
|
||||
expect(oneOne.aspectClass).toBe(" pb-video-wrapper--1by1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-video
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
@@ -0,0 +1 @@
|
||||
dummy-image
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user