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
+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}%`;