video 블록 및 리펙터링
CI / pr_and_merge (push) Has been skipped
CI / test (push) Failing after 46m46s
CI / test (pull_request) Failing after 43m8s
CI / pr_and_merge (pull_request) Has been skipped

This commit is contained in:
2025-11-27 10:07:59 +09:00
parent 672cca5271
commit 48e13d2a75
223 changed files with 8979 additions and 2125 deletions
+203 -489
View File
@@ -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[];
@@ -71,114 +92,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 +138,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 +317,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 +348,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 +375,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 +395,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 +421,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 +447,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 +484,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>");
}
@@ -854,7 +544,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 +567,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 {
+97
View File
@@ -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 });
}
}
+89
View File
@@ -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 });
}
}
+195 -358
View File
@@ -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";
@@ -143,7 +161,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;
}
@@ -949,85 +974,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,7 +1007,12 @@ function SortableEditorBlock({
: listProps.align === "right"
? "pb-text-right"
: "pb-text-left";
} else if (block.type === "section") {
} 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";
} else if (block.type === "form") {
@@ -1143,33 +1104,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 +1127,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 +1166,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 +1212,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 +1269,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 +1313,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 +1349,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 +1476,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 +1512,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 +1635,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}%`;
+111 -30
View File
@@ -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="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 className="w-2 flex flex-col gap-[1px]">
<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>
+14 -1
View File
@@ -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>
</>
);
}
+21 -2
View File
@@ -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];
+11 -9
View File
@@ -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];
+52 -14
View File
@@ -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 };
+50 -11
View File
@@ -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 };
+76 -17
View File
@@ -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];
+23 -2
View File
@@ -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
View File
@@ -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
+92 -1
View File
@@ -10,11 +10,12 @@ import { createFaqTemplateBlocks } from "@/app/editor/templates/faqTemplate";
import { createPricingTemplateBlocks } from "@/app/editor/templates/pricingTemplate";
import { createTestimonialsTemplateBlocks } from "@/app/editor/templates/testimonialsTemplate";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록/폼 요소 블록
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
export type BlockType =
| "text"
| "button"
| "image"
| "video"
| "section"
| "divider"
| "list"
@@ -138,6 +139,34 @@ export interface ImageBlockProps {
assetId?: string | null;
}
export interface VideoBlockProps {
sourceUrl: string;
sourceType?: "asset" | "externalUrl";
assetId?: string | null;
posterImageSrc?: string;
posterSourceType?: "asset" | "externalUrl";
posterAssetId?: string | null;
platform?: "auto" | "youtube" | "vimeo" | "html5";
align?: "left" | "center" | "right";
widthMode?: "auto" | "full" | "fixed";
widthPx?: number;
aspectRatio?: "16:9" | "4:3" | "1:1";
autoplay?: boolean;
loop?: boolean;
muted?: boolean;
controls?: boolean;
titleText?: string;
ariaLabel?: string;
captionText?: string;
// HTML5 비디오 재생 범위(초 단위)
startTimeSec?: number;
endTimeSec?: number;
// 카드 스타일: 배경색/패딩/모서리 둥글기
backgroundColorCustom?: string;
cardPaddingPx?: number;
borderRadiusPx?: number;
}
// 구분선 블록 속성
export interface DividerBlockProps {
align: "left" | "center" | "right";
@@ -494,6 +523,20 @@ export interface SectionBlockProps {
gapXPx?: number;
alignItems?: "top" | "center" | "bottom";
backgroundColorCustom?: string;
// 섹션 배경 이미지
backgroundImageSrc?: string;
backgroundImageSourceType?: "asset" | "externalUrl";
backgroundImageAssetId?: string | null;
backgroundImageSize?: "auto" | "cover" | "contain";
backgroundImagePosition?: "center" | "top" | "bottom" | "left" | "right";
backgroundImageRepeat?: "no-repeat" | "repeat" | "repeat-x" | "repeat-y";
backgroundImagePositionMode?: "preset" | "custom";
backgroundImagePositionXPercent?: number;
backgroundImagePositionYPercent?: number;
// 섹션 배경 비디오
backgroundVideoSrc?: string;
backgroundVideoSourceType?: "asset" | "externalUrl";
backgroundVideoAssetId?: string | null;
}
// 폼 입력/셀렉트/체크박스/라디오 공통 스타일 속성
@@ -651,6 +694,7 @@ export interface Block {
| TextBlockProps
| ButtonBlockProps
| ImageBlockProps
| VideoBlockProps
| SectionBlockProps
| DividerBlockProps
| ListBlockProps
@@ -676,6 +720,7 @@ export interface EditorState {
addTextBlock: () => void;
addButtonBlock: () => void;
addImageBlock: () => void;
addVideoBlock: () => void;
addDividerBlock: () => void;
addListBlock: () => void;
addSectionBlock: () => void;
@@ -1182,6 +1227,52 @@ const createEditorState = (set: any, get: any): EditorState => ({
}));
},
// 비디오 블록 추가: 기본 소스/정렬/비율 설정과 함께 생성 후 선택 상태로 만든다
addVideoBlock: () => {
const id = createId();
const { selectedBlockId, blocks } = get();
let sectionId: string | null = null;
let columnId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId);
if (target) {
if (target.type === "section") {
const sProps = target.props as SectionBlockProps;
sectionId = target.id;
columnId = sProps.columns?.[0]?.id ?? null;
} else {
sectionId = (target as any).sectionId ?? null;
columnId = (target as any).columnId ?? null;
}
}
}
const newBlock: Block = {
id,
type: "video",
props: {
sourceUrl: "",
platform: "auto",
align: "center",
widthMode: "auto",
aspectRatio: "16:9",
controls: true,
autoplay: false,
loop: false,
muted: false,
},
sectionId,
columnId,
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
addFormInputBlock: () => {
const id = createId();
+249
View File
@@ -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,
};
}
+137
View File
@@ -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,
};
};
+934
View File
@@ -0,0 +1,934 @@
import type { CSSProperties } from "react";
import type {
Block,
ButtonBlockProps,
FormBlockProps,
FormCheckboxBlockProps,
FormCheckboxOption,
FormFieldConfig,
FormInputBlockProps,
FormRadioBlockProps,
FormRadioOption,
FormSelectBlockProps,
FormSelectOption,
} from "@/features/editor/state/editorStore";
export interface FormStyleEscapers {
escapeAttr: (value: string) => string;
}
const defaultEscapers: FormStyleEscapers = {
escapeAttr: (value) => value,
};
export interface FormInputExportTokens {
labelStyleAttr: string;
inputStyleAttr: string;
}
export interface FormSelectExportTokens {
labelStyleAttr: string;
selectStyleAttr: string;
}
export interface FormOptionGroupExportTokens {
labelStyleAttr: string;
optionStyleAttr: string;
}
const normalizeTextColor = (raw: unknown): string => {
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
return trimmed !== "" ? trimmed : "";
};
const computeRadiusPx = (radius: unknown): number => {
const token = typeof radius === "string" ? radius : "md";
if (token === "none") return 0;
if (token === "sm") return 2;
if (token === "lg") return 6;
if (token === "full") return 9999;
return 4;
};
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
if (typeof props.widthMode === "string") {
return props.widthMode as "auto" | "full" | "fixed";
}
if (props.fullWidth) {
return "full";
}
return "auto";
};
export const computeFormInputExportTokens = (
props: FormInputBlockProps,
escapers: FormStyleEscapers = defaultEscapers,
): FormInputExportTokens => {
const { escapeAttr } = escapers;
const textColorRaw = normalizeTextColor(props.textColorCustom);
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const inputStyleParts: string[] = [];
if (textColorRaw) {
inputStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
inputStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
}
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
inputStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
inputStyleParts.push(`padding-left:${px}px`);
inputStyleParts.push(`padding-right:${px}px`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
inputStyleParts.push(`padding-top:${py}px`);
inputStyleParts.push(`padding-bottom:${py}px`);
}
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyleParts.push(`width:${props.widthPx}px`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
inputStyleParts.push(`border-radius:${radiusPx}px`);
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
return {
labelStyleAttr,
inputStyleAttr,
};
};
export const computeFormSelectExportTokens = (
props: FormSelectBlockProps,
escapers: FormStyleEscapers = defaultEscapers,
): FormSelectExportTokens => {
const { escapeAttr } = escapers;
const textColorRaw = normalizeTextColor(props.textColorCustom);
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const selectStyleParts: string[] = [];
if (textColorRaw) {
selectStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
}
const widthMode = computeWidthMode({ widthMode: props.widthMode, fullWidth: props.fullWidth });
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
selectStyleParts.push(`width:${props.widthPx}px`);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
selectStyleParts.push(`padding-left:${px}px`);
selectStyleParts.push(`padding-right:${px}px`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
selectStyleParts.push(`padding-top:${py}px`);
selectStyleParts.push(`padding-bottom:${py}px`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
selectStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
}
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
selectStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
}
const radiusPx = computeRadiusPx(props.borderRadius);
selectStyleParts.push(`border-radius:${radiusPx}px`);
const selectStyleAttr = selectStyleParts.length > 0 ? ` style="${selectStyleParts.join(";")}"` : "";
return {
labelStyleAttr,
selectStyleAttr,
};
};
const computeOptionGroupExportTokensBase = (
props: { textColorCustom?: string; paddingX?: number; paddingY?: number; fillColorCustom?: string; strokeColorCustom?: string; borderRadius?: string },
escapers: FormStyleEscapers,
): FormOptionGroupExportTokens => {
const { escapeAttr } = escapers;
const textColorRaw = normalizeTextColor(props.textColorCustom);
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const optionStyleParts: string[] = [];
if (textColorRaw) {
optionStyleParts.push(`color:${escapeAttr(textColorRaw)}`);
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const px = props.paddingX;
optionStyleParts.push(`padding-left:${px}px`);
optionStyleParts.push(`padding-right:${px}px`);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const py = props.paddingY;
optionStyleParts.push(`padding-top:${py}px`);
optionStyleParts.push(`padding-bottom:${py}px`);
}
if (typeof props.fillColorCustom === "string" && props.fillColorCustom.trim() !== "") {
optionStyleParts.push(`background-color:${escapeAttr(props.fillColorCustom.trim())}`);
}
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
optionStyleParts.push(`border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
optionStyleParts.push("border-width:1px");
optionStyleParts.push("border-style:solid");
}
const radiusPx = computeRadiusPx(props.borderRadius);
optionStyleParts.push(`border-radius:${radiusPx}px`);
const optionStyleAttr = optionStyleParts.length > 0 ? ` style="${optionStyleParts.join(";")}"` : "";
return {
labelStyleAttr,
optionStyleAttr,
};
};
export const computeFormCheckboxExportTokens = (
props: FormCheckboxBlockProps,
escapers: FormStyleEscapers = defaultEscapers,
): FormOptionGroupExportTokens =>
computeOptionGroupExportTokensBase(
{
textColorCustom: props.textColorCustom,
paddingX: props.paddingX,
paddingY: props.paddingY,
fillColorCustom: props.fillColorCustom,
strokeColorCustom: props.strokeColorCustom,
borderRadius: props.borderRadius,
},
escapers,
);
export interface FormInputEditorTokens {
fieldStyle: CSSProperties;
widthClass: string;
inputAlignClass: string;
}
export interface FormFieldEditorTokens {
fieldStyle: CSSProperties;
}
export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormInputEditorTokens => {
const fieldStyle: CSSProperties = {};
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
fieldStyle.color = props.textColorCustom;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = props.fillColorCustom;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = props.strokeColorCustom;
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
const labelLayout = props.labelLayout ?? "stacked";
const isInline = labelLayout === "inline";
const align = props.align ?? "left";
const inputAlignClass =
align === "center" ? "text-center" : align === "right" ? "text-right" : "text-left";
// widthMode 와 labelLayout 에 따라 wrapper 의 Tailwind width/flex 클래스를 결정한다.
let widthClass = "w-full";
if (isInline) {
if (widthMode === "fixed") {
// 고정 너비일 때는 flex 레이아웃의 너비 확장을 막기 위해 flex-none 으로 둔다.
widthClass = "flex-none";
} else if (widthMode === "full") {
widthClass = "flex-1";
} else {
// auto: 인라인 레이아웃에서 내용 기반 너비 사용
widthClass = "";
}
} else {
if (widthMode === "fixed") {
widthClass = ""; // 고정 너비는 style.width 로만 제어
} else {
widthClass = "w-full";
}
}
return {
fieldStyle,
widthClass,
inputAlignClass,
};
};
export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): FormFieldEditorTokens => {
const fieldStyle: CSSProperties = {};
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
fieldStyle.color = props.textColorCustom;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = props.fillColorCustom;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = props.strokeColorCustom;
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
return { fieldStyle };
};
export const computeFormOptionGroupEditorTokens = (
props: FormCheckboxBlockProps | FormRadioBlockProps,
): FormFieldEditorTokens => {
const fieldStyle: CSSProperties = {};
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
fieldStyle.color = props.textColorCustom;
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
fieldStyle.backgroundColor = props.fillColorCustom;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
fieldStyle.borderColor = props.strokeColorCustom;
}
const widthMode = props.widthMode ?? "full";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
fieldStyle.width = `${props.widthPx}px`;
}
const radius = props.borderRadius ?? "md";
if (radius === "none") {
fieldStyle.borderRadius = 0;
} else if (radius === "sm") {
fieldStyle.borderRadius = 4;
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
return { fieldStyle };
};
export const computeFormRadioExportTokens = (
props: FormRadioBlockProps,
escapers: FormStyleEscapers = defaultEscapers,
): FormOptionGroupExportTokens =>
computeOptionGroupExportTokensBase(
{
textColorCustom: props.textColorCustom,
paddingX: props.paddingX,
paddingY: props.paddingY,
fillColorCustom: props.fillColorCustom,
strokeColorCustom: props.strokeColorCustom,
borderRadius: props.borderRadius,
},
escapers,
);
// 퍼블릭 렌더러용 폼 필드 스타일 토큰
export interface FormInputPublicTokens {
wrapperLayoutClass: string;
wrapperStyle: CSSProperties;
widthClass: string;
inputStyle: CSSProperties;
}
export interface FormSelectPublicTokens {
widthClass: string;
wrapperStyle: CSSProperties;
selectStyle: CSSProperties;
}
export interface FormOptionGroupPublicTokens {
widthClass: string;
textSizeClass: string;
groupStyle: CSSProperties;
groupTextStyle: CSSProperties;
optionContainerStyle: CSSProperties;
optionTextStyle: CSSProperties;
optionsStyle: CSSProperties;
}
const pxToEm = (px: number, base = 16) => `${px / base}em`;
const convertPxStringToEm = (value?: string | null) => {
if (!value) return null;
const match = value.trim().match(/-?\d+(?:\.\d+)?/);
if (!match) return null;
const px = parseFloat(match[0]);
if (!Number.isFinite(px)) return null;
return pxToEm(px);
};
export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormInputPublicTokens => {
const align = props.align ?? "left";
const labelLayout = props.labelLayout ?? "stacked";
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const isInlineLayout = labelLayout === "inline";
const wrapperLayoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
const wrapperStyle: CSSProperties = {};
if (isInlineLayout) {
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
wrapperStyle.columnGap = pxToEm(gapPx);
}
const widthClass = widthMode === "full" ? "w-full" : "";
const inputStyle: CSSProperties = {
textAlign: align,
};
// 너비: fixed 모드일 때 widthPx 를 em 단위로 설정한다.
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
inputStyle.width = pxToEm(props.widthPx);
}
// 타이포 관련 커스텀 값
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
inputStyle.fontSize = fontSizeEm;
}
} else {
inputStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
inputStyle.lineHeight = lineHeightEm;
}
} else {
inputStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
inputStyle.letterSpacing = letterSpacingEm;
}
} else {
inputStyle.letterSpacing = letterSpacingValue;
}
}
// 색상 관련 커스텀 값
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
inputStyle.color = props.textColorCustom.trim();
} else {
inputStyle.color = "#f9fafb";
}
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
inputStyle.backgroundColor = props.fillColorCustom.trim();
} else {
inputStyle.backgroundColor = "#020617";
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
inputStyle.borderColor = props.strokeColorCustom.trim();
} else {
inputStyle.borderColor = "#334155";
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
inputStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
inputStyle.paddingBlock = pxToEm(props.paddingY);
}
// 모서리 둥글기: borderRadius 토큰을 px 값으로 변환한다.
const radiusToken = props.borderRadius ?? "md";
const radiusPx =
radiusToken === "none" ? 0 : radiusToken === "sm" ? 2 : radiusToken === "lg" ? 6 : radiusToken === "full" ? 9999 : 4;
inputStyle.borderRadius = `${radiusPx}px`;
return {
wrapperLayoutClass,
wrapperStyle,
widthClass,
inputStyle,
};
};
export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): FormSelectPublicTokens => {
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const widthClass = widthMode === "full" ? "w-full" : "";
const wrapperStyle: CSSProperties = {};
const selectStyle: CSSProperties = {};
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
wrapperStyle.width = pxToEm(props.widthPx);
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
wrapperStyle.fontSize = fontSizeEm;
selectStyle.fontSize = fontSizeEm;
}
} else {
wrapperStyle.fontSize = fontSizeValue;
selectStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
wrapperStyle.lineHeight = lineHeightEm;
selectStyle.lineHeight = lineHeightEm;
}
} else {
wrapperStyle.lineHeight = lineHeightValue;
selectStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
wrapperStyle.letterSpacing = letterSpacingEm;
selectStyle.letterSpacing = letterSpacingEm;
}
} else {
wrapperStyle.letterSpacing = letterSpacingValue;
selectStyle.letterSpacing = letterSpacingValue;
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
const colorValue = props.textColorCustom.trim();
wrapperStyle.color = colorValue;
selectStyle.color = colorValue;
} else {
wrapperStyle.color = "#f9fafb";
selectStyle.color = "#f9fafb";
}
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
selectStyle.backgroundColor = props.fillColorCustom.trim();
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
selectStyle.borderColor = props.strokeColorCustom.trim();
}
// 모서리 둥글기: borderRadius 토큰을 px 로 변환한다.
const selectRadiusToken = props.borderRadius ?? "md";
const selectRadiusPx =
selectRadiusToken === "none"
? 0
: selectRadiusToken === "sm"
? 2
: selectRadiusToken === "lg"
? 6
: selectRadiusToken === "full"
? 9999
: 4;
selectStyle.borderRadius = `${selectRadiusPx}px`;
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
const paddingEm = pxToEm(props.paddingX);
selectStyle.paddingInline = paddingEm;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
const paddingEm = pxToEm(props.paddingY);
selectStyle.paddingBlock = paddingEm;
}
return {
widthClass,
wrapperStyle,
selectStyle,
};
};
const computeOptionGroupPublicTokensBase = (
props: FormCheckboxBlockProps | FormRadioBlockProps,
): FormOptionGroupPublicTokens => {
const widthMode = props.widthMode ?? (props.fullWidth ? "full" : "auto");
const widthClass = widthMode === "full" ? "w-full" : "";
const groupStyle: CSSProperties = {};
const optionContainerStyle: CSSProperties = {};
const optionTextStyle: CSSProperties = {};
const groupTextStyle: CSSProperties = {};
const optionsStyle: CSSProperties = {};
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
groupStyle.width = pxToEm(props.widthPx);
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
const fontSizeValue = props.fontSizeCustom.trim();
if (fontSizeValue.endsWith("px")) {
const fontSizeEm = convertPxStringToEm(fontSizeValue);
if (fontSizeEm) {
groupTextStyle.fontSize = fontSizeEm;
optionTextStyle.fontSize = fontSizeEm;
}
} else {
groupTextStyle.fontSize = fontSizeValue;
optionTextStyle.fontSize = fontSizeValue;
}
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
const lineHeightValue = props.lineHeightCustom.trim();
if (lineHeightValue.endsWith("px")) {
const lineHeightEm = convertPxStringToEm(lineHeightValue);
if (lineHeightEm) {
groupTextStyle.lineHeight = lineHeightEm;
optionTextStyle.lineHeight = lineHeightEm;
}
} else {
groupTextStyle.lineHeight = lineHeightValue;
optionTextStyle.lineHeight = lineHeightValue;
}
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
const letterSpacingValue = props.letterSpacingCustom.trim();
if (letterSpacingValue.endsWith("px")) {
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
if (letterSpacingEm) {
groupTextStyle.letterSpacing = letterSpacingEm;
optionTextStyle.letterSpacing = letterSpacingEm;
}
} else {
groupTextStyle.letterSpacing = letterSpacingValue;
optionTextStyle.letterSpacing = letterSpacingValue;
}
}
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
const colorValue = props.textColorCustom.trim();
groupTextStyle.color = colorValue;
optionTextStyle.color = colorValue;
} else {
groupTextStyle.color = "#e5e7eb";
optionTextStyle.color = "#e5e7eb";
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
}
// 옵션 컨테이너 배경/테두리 색상
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
optionContainerStyle.backgroundColor = props.fillColorCustom.trim();
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
const strokeValue = props.strokeColorCustom.trim();
optionContainerStyle.borderColor = strokeValue;
optionContainerStyle.borderWidth = "1px";
optionContainerStyle.borderStyle = "solid";
}
// 옵션 컨테이너 모서리 둥글기
const radiusToken = props.borderRadius ?? "md";
const radiusPx =
radiusToken === "none"
? 0
: radiusToken === "sm"
? 2
: radiusToken === "lg"
? 6
: radiusToken === "full"
? 9999
: 4;
optionContainerStyle.borderRadius = `${radiusPx}px`;
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
}
return {
widthClass,
textSizeClass,
groupStyle,
groupTextStyle,
optionContainerStyle,
optionTextStyle,
optionsStyle,
};
};
export const computeFormCheckboxPublicTokens = (
props: FormCheckboxBlockProps,
): FormOptionGroupPublicTokens => computeOptionGroupPublicTokensBase(props);
export const computeFormRadioPublicTokens = (props: FormRadioBlockProps): FormOptionGroupPublicTokens =>
computeOptionGroupPublicTokensBase(props);
export interface FormControllerFieldPublicConfig {
id: string;
name: string;
label: string;
type: "text" | "email" | "textarea" | "select" | "checkbox" | "radio";
required?: boolean;
options?: Array<FormSelectOption | FormCheckboxOption | FormRadioOption>;
groupLabelMode?: "text" | "image";
groupLabelImageUrl?: string;
}
export interface FormControllerPublicTokens {
fields: FormControllerFieldPublicConfig[];
formClassName: string;
formStyle: CSSProperties;
submitLabel: string | null;
}
export const computeFormControllerPublicTokens = (
formBlock: Block,
blocks: Block[],
): FormControllerPublicTokens => {
const props = formBlock.props as FormBlockProps;
const hasControllerFields = Array.isArray(props.fieldIds) && props.fieldIds.length > 0;
const controllerFields: FormControllerFieldPublicConfig[] = hasControllerFields
? (props.fieldIds ?? [])
.map((fieldId) => blocks.find((b) => b.id === fieldId))
.filter((b): b is Block => Boolean(b))
.filter(
(b) =>
b.type === "formInput" ||
b.type === "formSelect" ||
b.type === "formCheckbox" ||
b.type === "formRadio",
)
.map((fieldBlock) => {
const anyProps: any = fieldBlock.props ?? {};
if (fieldBlock.type === "formInput") {
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
return {
id: fieldBlock.id,
name,
label,
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
type: "text",
required: Boolean(anyProps.required),
} satisfies FormControllerFieldPublicConfig;
}
if (fieldBlock.type === "formSelect") {
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.label ?? anyProps.formFieldName ?? "선택 필드";
return {
id: fieldBlock.id,
name,
label,
type: "select",
options,
required: Boolean(anyProps.required),
} satisfies FormControllerFieldPublicConfig;
}
if (fieldBlock.type === "formCheckbox") {
const options: FormCheckboxOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "체크박스";
return {
id: fieldBlock.id,
name,
label,
type: "checkbox",
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
} satisfies FormControllerFieldPublicConfig;
}
const options: FormRadioOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
const name = anyProps.formFieldName ?? fieldBlock.id;
const label = anyProps.groupLabel ?? anyProps.formFieldName ?? "라디오 그룹";
return {
id: fieldBlock.id,
name,
label,
type: "radio",
options,
groupLabelMode: anyProps.groupLabelMode,
groupLabelImageUrl: anyProps.groupLabelImageUrl,
required: Boolean(anyProps.required),
} satisfies FormControllerFieldPublicConfig;
})
: [];
const fields: FormControllerFieldPublicConfig[] =
hasControllerFields && controllerFields.length > 0
? controllerFields
: Array.isArray(props.fields) && props.fields.length > 0
? props.fields.map((field) => ({
id: field.id,
name: field.name,
label: field.label,
type: field.type,
required: field.required,
}))
: [];
const mappedSubmitButton = blocks.find(
(b) => b.type === "button" && b.id === props.submitButtonId,
);
const mappedSubmitLabel = (mappedSubmitButton?.props as ButtonBlockProps | undefined)?.label ?? null;
const widthMode = props.formWidthMode ?? "auto";
const formStyle: CSSProperties = {};
const formClassNames = ["space-y-3"];
if (widthMode === "full") {
formClassNames.push("w-full");
}
if (widthMode === "fixed" && typeof props.formWidthPx === "number" && props.formWidthPx > 0) {
formStyle.width = pxToEm(props.formWidthPx);
}
if (typeof props.marginYPx === "number") {
const marginEm = pxToEm(props.marginYPx);
formStyle.marginTop = marginEm;
formStyle.marginBottom = marginEm;
}
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
formStyle.backgroundColor = props.backgroundColorCustom.trim();
}
return {
fields,
formClassName: formClassNames.join(" "),
formStyle,
submitLabel: mappedSubmitLabel,
};
};
export interface FormBlockExportTokens {
hasControllerFields: boolean;
controllerFields: Block[];
fallbackFields: FormFieldConfig[];
formStyleParts: string[];
}
export const computeFormBlockExportTokens = (
formBlock: Block,
blocks: Block[],
): FormBlockExportTokens => {
const props = formBlock.props as FormBlockProps;
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
const controllerFields = fieldIds
.map((id) => blocks.find((b) => b.id === id))
.filter((b): b is Block => Boolean(b))
.filter(
(b) =>
b.type === "formInput" ||
b.type === "formSelect" ||
b.type === "formCheckbox" ||
b.type === "formRadio",
);
let fallbackFields: FormFieldConfig[] = [];
if (Array.isArray(props.fields) && props.fields.length > 0) {
fallbackFields = props.fields;
}
const formStyleParts: string[] = [];
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
formStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
}
return {
hasControllerFields: controllerFields.length > 0,
controllerFields,
fallbackFields,
formStyleParts,
};
};
+149
View File
@@ -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,
};
}
+154
View File
@@ -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,
};
};
+278
View File
@@ -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,
};
}
+327
View File
@@ -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,
};
}
+230
View File
@@ -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,
};
}
+43
View File
@@ -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;