1차 싱크 완료
CI / test (push) Failing after 41m13s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-24 21:32:37 +09:00
parent 7a8ad7c057
commit 672cca5271
83 changed files with 6681 additions and 325 deletions
+309 -20
View File
@@ -119,7 +119,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
? weightMap[fontWeightScale]
: "";
let colorClass = "";
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> = {
@@ -133,7 +135,18 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
info: "pb-text-color-info",
neutral: "pb-text-color-neutral",
};
colorClass = paletteMap[palette] ?? "";
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 = "";
@@ -159,12 +172,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
weightClass,
colorClass,
maxWidthClass,
"pb-whitespace-pre-wrap",
...decoClasses,
]
.filter(Boolean)
.join(" ");
return `<p class="${classes}">${escapeHtml(text)}</p>`;
const styleAttr = inlineStyles.length > 0 ? ` style="${inlineStyles.join(";")}"` : "";
return `<p class="${classes}"${styleAttr}>${escapeHtml(text)}</p>`;
}
if (block.type === "button") {
@@ -238,7 +254,42 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const props: any = block.props ?? {};
const src = typeof props.src === "string" ? props.src : "";
const alt = typeof props.alt === "string" ? props.alt : "";
return `<div><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}" /></div>`;
const wrapperStyleParts: string[] = [];
const imgStyleParts: string[] = [];
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(";")}"` : "";
return `<div${wrapperStyleAttr}><img src="${escapeAttr(src)}" alt="${escapeAttr(alt)}"${imgStyleAttr} /></div>`;
}
if (block.type === "form") {
@@ -261,7 +312,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const fields = controllerFields.length > 0 ? controllerFields : null;
const formParts: string[] = [];
formParts.push(`<form class="pb-form" method="post">`);
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(";")}"` : "";
formParts.push(`<form class="pb-form" method="post"${formStyleAttr}>`);
if (fields) {
for (const fieldBlock of fields) {
@@ -272,8 +329,15 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
? (anyProps.label ?? anyProps.groupLabel)
: name;
const textColorRaw =
typeof anyProps.textColorCustom === "string" && anyProps.textColorCustom.trim() !== ""
? anyProps.textColorCustom.trim()
: "";
const labelStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
const fieldTextStyleAttr = textColorRaw ? ` style="color:${escapeAttr(textColorRaw)}"` : "";
formParts.push(`<div class="pb-form-field">`);
formParts.push(`<label class="pb-form-label">${escapeHtml(label ?? "")}</label>`);
formParts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label ?? "")}</label>`);
if (fieldBlock.type === "formInput") {
const type = anyProps.inputType === "email" ? "email" : "text";
@@ -281,12 +345,12 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
formParts.push(
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label ?? "",
)}"${required} />`,
)}"${required}${fieldTextStyleAttr} />`,
);
} else if (fieldBlock.type === "formSelect") {
const required = anyProps.required ? " required" : "";
const options: FormSelectOption[] = Array.isArray(anyProps.options) ? anyProps.options : [];
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
formParts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${fieldTextStyleAttr}>`);
for (const opt of options) {
formParts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
@@ -297,7 +361,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
@@ -306,7 +370,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const options = Array.isArray(anyProps.options) ? anyProps.options : [];
for (const opt of options) {
formParts.push(
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
`<label class="pb-form-option"${fieldTextStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
@@ -386,7 +450,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const align = props.align === "center" ? "center" : props.align === "right" ? "right" : "left";
const lis = items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
return `<${Tag} class="pb-list" style="text-align:${align};">${lis}</${Tag}>`;
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}>`;
}
if (block.type === "formInput") {
@@ -397,12 +468,69 @@ 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(";")}\"` : "";
return [
'<div class="pb-form-field">',
`<label class="pb-form-label">${escapeHtml(label)}</label>`,
`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`,
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
label,
)}"${required} />`,
)}"${required}${inputStyleAttr} />`,
"</div>",
].join("");
}
@@ -415,10 +543,67 @@ 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 parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}>`);
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
parts.push(`<select class="pb-select" name="${escapeAttr(name)}"${required}${selectStyleAttr}>`);
for (const opt of options) {
parts.push(
`<option value="${escapeAttr(opt.value)}">${escapeHtml(opt.label)}</option>`,
@@ -439,12 +624,61 @@ 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 parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
for (const opt of options) {
parts.push(
`<label class="pb-form-option"><input type="checkbox" name="${escapeAttr(
`<label class="pb-form-option"${optionStyleAttr}><input type="checkbox" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
@@ -463,12 +697,61 @@ 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 parts: string[] = [];
parts.push('<div class="pb-form-field">');
parts.push(`<label class="pb-form-label">${escapeHtml(label)}</label>`);
parts.push(`<label class="pb-form-label"${labelStyleAttr}>${escapeHtml(label)}</label>`);
for (const opt of options) {
parts.push(
`<label class="pb-form-option"><input type="radio" name="${escapeAttr(
`<label class="pb-form-option"${optionStyleAttr}><input type="radio" name="${escapeAttr(
name,
)}" value="${escapeAttr(opt.value)}" /> ${escapeHtml(opt.label)}</label>`,
);
@@ -515,8 +798,14 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
const pyClass = pyClassMap[pyToken] ?? "pb-section-py-md";
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="${sectionClasses}"><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);
bodyParts.push('<div class="pb-section-column">');
+17 -6
View File
@@ -21,19 +21,24 @@ export async function POST(req: Request) {
}
const submitTarget = config?.submitTarget ?? "internal";
const successMessage = config?.successMessage;
const errorMessage = config?.errorMessage;
// 1) internal: 우리 서버에서 직접 처리하는 기본 모드
if (submitTarget === "internal") {
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, message: successMessage });
}
// 2) webhook: destinationUrl 로 서버에서 POST (Google Sheets 등 포함)
if (submitTarget === "webhook") {
const destinationUrl = config?.destinationUrl;
if (!destinationUrl) {
return NextResponse.json({ ok: false, error: "destinationUrl_missing" }, { status: 400 });
return NextResponse.json(
{ ok: false, error: "destinationUrl_missing", message: errorMessage },
{ status: 400 },
);
}
const payloadFormat = config?.payloadFormat ?? "form";
@@ -87,19 +92,25 @@ export async function POST(req: Request) {
if (!res.ok) {
console.error("[forms/submit][webhook] remote error", res.status, await res.text().catch(() => ""));
return NextResponse.json({ ok: false, error: "webhook_failed" }, { status: 502 });
return NextResponse.json(
{ ok: false, error: "webhook_failed", message: errorMessage },
{ status: 502 },
);
}
} catch (error) {
console.error("[forms/submit][webhook] fetch error", error);
return NextResponse.json({ ok: false, error: "webhook_exception" }, { status: 500 });
return NextResponse.json(
{ ok: false, error: "webhook_exception", message: errorMessage },
{ status: 500 },
);
}
console.log("[forms/submit][webhook] forwarded", { name, email, message, destinationUrl });
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, message: successMessage });
}
// 그 외 알 수 없는 모드는 internal 과 동일하게 처리
console.warn("[forms/submit] unknown submitTarget, fallback to internal", submitTarget);
console.log("[forms/submit][internal]", { name, email, message });
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, message: successMessage });
}
+7 -1
View File
@@ -42,6 +42,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
projectConfig,
} = props;
const canvasOuterStyle: CSSProperties = {};
const canvasInnerStyle: CSSProperties = {};
const preset = projectConfig.canvasPreset ?? "full";
const widthPx =
@@ -59,14 +60,19 @@ export function EditorCanvas(props: EditorCanvasProps) {
canvasInnerStyle.maxWidth = "1200px";
}
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
canvasOuterStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
if (projectConfig.canvasBgColorHex && projectConfig.canvasBgColorHex.trim()) {
canvasInnerStyle.backgroundColor = projectConfig.canvasBgColorHex;
}
return (
<div
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto"
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
data-testid="editor-canvas"
style={canvasOuterStyle}
onClick={(event) => {
if (event.target === event.currentTarget) {
onCanvasEmptyClick();
@@ -2,6 +2,7 @@
import type { Block, ButtonBlockProps, FormBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
interface FormControllerPanelProps {
block: Block; // type === "form"
@@ -188,6 +189,61 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
/>
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<ColorPickerField
label="폼 배경색"
ariaLabelColorInput="폼 배경색 피커"
ariaLabelHexInput="폼 배경색 HEX"
value={
formProps.backgroundColorCustom && formProps.backgroundColorCustom.trim() !== ""
? formProps.backgroundColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#e5e7eb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
backgroundColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
backgroundColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
placeholder="예: 성공적으로 전송되었습니다."
value={formProps.successMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
successMessage: e.target.value,
} as any)
}
/>
</label>
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
placeholder="예: 전송 중 오류가 발생했습니다."
value={formProps.errorMessage ?? ""}
onChange={(e) =>
updateBlock(selectedBlockId, {
errorMessage: e.target.value,
} as any)
}
/>
</label>
</div>
<div className="space-y-3 border-t border-slate-800 pt-3 mt-4">
<h3 className="text-[11px] font-semibold text-slate-200"> </h3>
+89 -17
View File
@@ -113,17 +113,14 @@ export default function EditorPage() {
const [projectSlug, setProjectSlug] = useState("");
const [loadSlug, setLoadSlug] = useState("");
const [projectMessage, setProjectMessage] = useState("");
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const editorMainStyle: CSSProperties = {};
if (projectConfig.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
editorMainStyle.backgroundColor = projectConfig.bodyBgColorHex;
}
const startEditing = (id: string, initialText: string) => {
selectBlock(id);
setEditingBlockId(id);
@@ -474,6 +471,20 @@ export default function EditorPage() {
// ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다.
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target) {
const tagName = target.tagName;
const isInputLike =
tagName === "INPUT" ||
tagName === "TEXTAREA" ||
(target as HTMLElement).isContentEditable;
if (isInputLike) {
// 입력 포커스 상태에서는 에디터 전역 단축키를 비활성화하고,
// 기본 브라우저 편집 동작(삭제/커서 이동 등)을 그대로 둔다.
return;
}
}
// 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동)
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } =
@@ -518,9 +529,20 @@ export default function EditorPage() {
const isMac = navigator.platform.toLowerCase().includes("mac");
const metaKey = isMac ? event.metaKey : event.ctrlKey;
// Delete / Backspace 로 선택된 블록 삭제
// Delete / Backspace 로 선택된 블록 삭제 (멀티 선택 우선)
if (event.key === "Delete" || event.key === "Backspace") {
const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState();
const { selectedBlockId: currentSelectedId, blocks: currentBlocks } = (useEditorStore as any).getState();
// 멀티 선택이 존재하면 멀티 삭제를 우선 처리한다.
if (selectedBlockIds.length > 1) {
event.preventDefault();
const selectedSet = new Set(selectedBlockIds);
const nextBlocks = (currentBlocks as Block[]).filter((b) => !selectedSet.has(b.id));
replaceBlocks(nextBlocks);
return;
}
// 단일 선택만 있는 경우 기존 removeBlock 로직 사용
if (currentSelectedId) {
event.preventDefault();
removeBlock(currentSelectedId);
@@ -559,11 +581,12 @@ export default function EditorPage() {
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [undo, redo]);
}, [undo, redo, selectedBlockIds, replaceBlocks, removeBlock]);
const renderBlocks = (targetBlocks: Block[]) =>
targetBlocks.map((block) => {
const isSelected = block.id === selectedBlockId;
const isSelected =
(selectedBlockId != null && block.id === selectedBlockId) || selectedBlockIds.includes(block.id);
const isEditing = block.id === editingBlockId;
return (
@@ -572,6 +595,9 @@ export default function EditorPage() {
<SortableEditorBlock
block={block}
isSelected={isSelected}
selectedBlockId={selectedBlockId ?? null}
selectedBlockIds={selectedBlockIds}
setSelectedBlockIds={setSelectedBlockIds}
isEditing={isEditing}
editingText={editingText}
startEditing={startEditing}
@@ -590,7 +616,7 @@ export default function EditorPage() {
});
return (
<main className="min-h-screen flex flex-col" style={editorMainStyle}>
<main className="h-screen flex flex-col overflow-hidden">
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20">
<div className="flex items-baseline gap-3">
<h1 className="text-xl font-semibold">Page Editor</h1>
@@ -867,6 +893,9 @@ interface TextInlineToolbarProps {
interface SortableEditorBlockProps {
block: Block;
isSelected: boolean;
selectedBlockId: string | null;
selectedBlockIds: string[];
setSelectedBlockIds: React.Dispatch<React.SetStateAction<string[]>>;
isEditing: boolean;
editingText: string;
startEditing: (id: string, initialText: string) => void;
@@ -884,6 +913,9 @@ interface SortableEditorBlockProps {
function SortableEditorBlock({
block,
isSelected,
selectedBlockId,
selectedBlockIds,
setSelectedBlockIds,
isEditing,
editingText,
startEditing,
@@ -966,6 +998,11 @@ function SortableEditorBlock({
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() !== "") {
@@ -1027,15 +1064,29 @@ function SortableEditorBlock({
ref={setNodeRef}
style={{ ...baseStyle, ...textStyleOverrides }}
data-testid="editor-block"
data-block-id={block.id}
data-selected={isSelected ? "true" : "false"}
aria-selected={isSelected ? "true" : "false"}
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
}`}
onClick={(event) => {
// 섹션 안 자식 블록을 클릭했을 때 섹션까지 선택되는 것을 막기 위해
// 클릭 이벤트를 여기서 중단하고 현재 블록만 선택한다.
// 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다.
const isMeta = event.metaKey || event.ctrlKey;
if (isMeta) {
event.stopPropagation();
setSelectedBlockIds((prev) => {
if (prev.includes(block.id)) {
return prev.filter((id) => id !== block.id);
}
return [...prev, block.id];
});
return;
}
// 일반 클릭: 현재 블록만 단일 선택으로 유지한다.
event.stopPropagation();
setSelectedBlockIds([block.id]);
selectBlock(block.id);
}}
onDoubleClick={(event) => {
@@ -1358,7 +1409,7 @@ function SortableEditorBlock({
else if (checkboxRadius === "lg" || checkboxRadius === "full") fieldStyle.borderRadius = 9999;
return (
<div className="flex flex-col gap-1 text-xs text-slate-200">
<div className="flex flex-col gap-1 text-xs text-slate-200" style={fieldStyle}>
{/* 체크박스 그룹 타이틀도 텍스트/이미지 모드를 지원한다. */}
{groupLabelMode === "image" && checkboxProps.groupLabelImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -1402,7 +1453,9 @@ function SortableEditorBlock({
const variant = buttonProps.variant ?? "solid";
const colorPalette = buttonProps.colorPalette ?? "primary";
const radius = buttonProps.borderRadius ?? "md";
const fullWidth = buttonProps.fullWidth ?? false;
const baseFullWidth = buttonProps.fullWidth ?? false;
const widthMode = buttonProps.widthMode ?? (baseFullWidth ? "full" : "auto");
const isFullWidth = widthMode === "full" || baseFullWidth;
const sizeClassBtn =
size === "xs"
@@ -1445,6 +1498,9 @@ function SortableEditorBlock({
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`;
}
@@ -1453,7 +1509,7 @@ function SortableEditorBlock({
}
return (
<div className={fullWidth ? "w-full" : "inline-block"}>
<div className={isFullWidth ? "w-full" : "inline-block"}>
<button
type="button"
className={`pb-btn-base ${sizeClassBtn} ${radiusClassBtn} ${variantClassBtn} w-full`}
@@ -1708,6 +1764,12 @@ function SortableEditorBlock({
})()}
{block.type === "section" && (() => {
const sectionProps = block.props as SectionBlockProps;
const isSectionSelected =
isSelected ||
(selectedBlockId != null &&
allBlocks.some(
(candidate) => candidate.id === selectedBlockId && candidate.sectionId === block.id,
));
const bgClass =
sectionProps.background === "muted"
@@ -1723,6 +1785,13 @@ function SortableEditorBlock({
? "py-10"
: "py-6";
const alignItemsClass =
sectionProps.alignItems === "center"
? "items-center"
: sectionProps.alignItems === "bottom"
? "items-end"
: "items-start";
const columns = sectionProps.columns && sectionProps.columns.length > 0
? sectionProps.columns
: [{ id: `${block.id}_col_fallback`, span: 12 }];
@@ -1738,7 +1807,10 @@ function SortableEditorBlock({
return (
<div
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700`}
data-testid="editor-section"
className={`w-full ${bgClass} ${pyClass} rounded border ${
isSectionSelected ? "border-sky-500" : "border-dashed border-slate-700"
}`}
style={sectionStyle}
>
<div
@@ -1751,7 +1823,7 @@ function SortableEditorBlock({
}}
>
<div
className="flex"
className={`flex ${alignItemsClass}`}
style={{
columnGap:
typeof sectionProps.gapXPx === "number" && sectionProps.gapXPx > 0
+207 -65
View File
@@ -68,7 +68,7 @@ export function BlocksSidebar() {
);
return (
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3">
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll">
<h2 className="font-medium"></h2>
<button
type="button"
@@ -152,71 +152,213 @@ export function BlocksSidebar() {
</button>
</div>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3>
<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={handleAddHeroTemplate}
>
Hero 릿
</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={handleAddFeaturesTemplate}
>
Features 릿
</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={handleAddCtaTemplate}
>
CTA 릿
</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={handleAddFaqTemplate}
>
FAQ 릿
</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={handleAddPricingTemplate}
>
Pricing 릿
</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={handleAddTestimonialsTemplate}
>
Testimonials 릿
</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={handleAddBlogTemplate}
>
Blog 릿
</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={handleAddTeamTemplate}
>
Team 릿
</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={handleAddFooterTemplate}
>
Footer 릿
</button>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> · CTA</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddHeroTemplate}
>
Hero 릿
</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"
>
<div className="flex-1 bg-slate-700/70" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
Hero ( + + )
</p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddCtaTemplate}
>
CTA 릿
</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"
>
<div className="h-2 w-6 rounded bg-slate-700/70" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400"> </p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFeaturesTemplate}
>
Features 릿
</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"
>
<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>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFaqTemplate}
>
FAQ 릿
</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"
>
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/40" />
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddPricingTemplate}
>
Pricing 릿
</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"
>
<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>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddBlogTemplate}
>
Blog 릿
</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"
>
<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>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTestimonialsTemplate}
>
Testimonials 릿
</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"
>
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-3/4 bg-slate-700/50" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</div>
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddTeamTemplate}
>
Team 릿
</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"
>
<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>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
<div className="space-y-2">
<p className="text-[10px] font-semibold text-slate-400">/</p>
<div className="space-y-2">
<div className="rounded border border-slate-800 bg-slate-950/50 p-2 space-y-1">
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddFooterTemplate}
>
Footer 릿
</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"
>
<div className="h-[2px] w-full bg-slate-700/40" />
<div className="h-[2px] w-3/4 bg-slate-700/70" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
</div>
</aside>
);
@@ -341,18 +341,6 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
}}
/>
</div>
<div className="space-y-1">
<label className="flex items-center justify-between text-xs text-slate-400">
<span> </span>
<input
type="checkbox"
checked={buttonProps.fullWidth ?? false}
onChange={(e) => {
updateBlock(selectedBlockId, { fullWidth: e.target.checked } as any);
}}
/>
</label>
</div>
</>
);
}
@@ -2,6 +2,7 @@
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export type ImagePropertiesPanelProps = {
imageProps: ImageBlockProps;
@@ -127,6 +128,21 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
<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={imageProps.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>
@@ -167,6 +167,17 @@ export function ListPropertiesPanel({
}}
/>
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="리스트 배경색 피커"
ariaLabelHexInput="리스트 배경색 HEX"
value={listProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, { backgroundColorCustom: hex } as any);
}}
palette={TEXT_COLOR_PALETTE}
/>
{/* 불릿 스타일 (● / ○ / ■ / 숫자형 / 없음) */}
<label className="flex flex-col gap-1">
<span>릿 </span>
+1 -1
View File
@@ -50,7 +50,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
return (
<aside
data-testid="properties-sidebar"
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto"
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll"
onKeyDownCapture={(e) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
e.stopPropagation();
@@ -181,7 +181,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
// 1컬럼인 경우: 12 고정 표시
if (colCount === 1) {
const only = columns[0];
const only = columns[0] ?? { id: `${selectedBlockId}_col_1`, span: 12 };
return (
<label key={only.id} className="flex flex-col gap-1">
<span>{`1열 폭 (고정 12/12)`}</span>
@@ -437,6 +437,21 @@ export function TextPropertiesPanel({
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="블록 배경색"
ariaLabelColorInput="텍스트 블록 배경색 피커"
ariaLabelHexInput="텍스트 블록 배경색 HEX"
value={textProps.backgroundColorCustom ?? ""}
onChange={(hex) => {
updateBlock(selectedBlockId, {
backgroundColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
@@ -123,6 +123,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
textStyle.color = props.colorCustom;
}
// 블록 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다 (기본은 투명/미지정).
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
textStyle.backgroundColor = props.backgroundColorCustom.trim();
}
return (
<p
key={block.id}
@@ -314,6 +319,41 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
}
}
// 텍스트 색상: 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() !== "") {
const bgValue = props.fillColorCustom.trim();
selectStyle.backgroundColor = bgValue;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
const strokeValue = props.strokeColorCustom.trim();
selectStyle.borderColor = strokeValue;
}
// 모서리 둥글기: 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;
@@ -353,6 +393,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
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 textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
@@ -404,14 +445,51 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 그룹/옵션 텍스트 모두에 동일하게 적용한다.
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) {
optionTextStyle.paddingInline = pxToEm(props.paddingX);
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
}
// 옵션 컨테이너 배경/테두리 색상
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
const bgValue = props.fillColorCustom.trim();
optionContainerStyle.backgroundColor = bgValue;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
const strokeValue = props.strokeColorCustom.trim();
optionContainerStyle.borderColor = strokeValue;
optionContainerStyle.borderWidth = "1px";
optionContainerStyle.borderStyle = "solid";
}
// 옵션 컨테이너 모서리 둥글기
const checkboxRadiusToken = props.borderRadius ?? "md";
const checkboxRadiusPx =
checkboxRadiusToken === "none"
? 0
: checkboxRadiusToken === "sm"
? 2
: checkboxRadiusToken === "lg"
? 6
: checkboxRadiusToken === "full"
? 9999
: 4;
optionContainerStyle.borderRadius = `${checkboxRadiusPx}px`;
const optionsStyle: CSSProperties = {};
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
@@ -439,7 +517,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
)}
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<label
key={opt.value}
className="inline-flex items-center gap-1"
style={optionContainerStyle}
>
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
@@ -469,6 +551,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
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 textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
@@ -520,14 +603,51 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
}
}
// 텍스트 색상: textColorCustom 이 설정되어 있으면 그룹/옵션 텍스트 모두에 동일하게 적용한다.
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) {
optionTextStyle.paddingInline = pxToEm(props.paddingX);
optionContainerStyle.paddingInline = pxToEm(props.paddingX);
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
optionTextStyle.paddingBlock = pxToEm(props.paddingY);
optionContainerStyle.paddingBlock = pxToEm(props.paddingY);
}
// 옵션 컨테이너 배경/테두리 색상
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
const bgValue = props.fillColorCustom.trim();
optionContainerStyle.backgroundColor = bgValue;
}
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
const strokeValue = props.strokeColorCustom.trim();
optionContainerStyle.borderColor = strokeValue;
optionContainerStyle.borderWidth = "1px";
optionContainerStyle.borderStyle = "solid";
}
// 옵션 컨테이너 모서리 둥글기
const radioRadiusToken = props.borderRadius ?? "md";
const radioRadiusPx =
radioRadiusToken === "none"
? 0
: radioRadiusToken === "sm"
? 2
: radioRadiusToken === "lg"
? 6
: radioRadiusToken === "full"
? 9999
: 4;
optionContainerStyle.borderRadius = `${radioRadiusPx}px`;
const optionsStyle: CSSProperties = {};
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
optionsStyle.rowGap = pxToEm(props.optionGapPx);
@@ -555,7 +675,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
)}
<div className="flex flex-col" data-testid="preview-form-radio-options" style={optionsStyle}>
{props.options.map((opt) => (
<label key={opt.value} className="inline-flex items-center gap-1">
<label
key={opt.value}
className="inline-flex items-center gap-1"
style={optionContainerStyle}
>
<input
type="radio"
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
@@ -753,6 +877,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
listStyle.color = props.textColorCustom;
}
// 리스트 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
listStyle.backgroundColor = props.backgroundColorCustom.trim();
}
const bulletStyle = bulletStyleRaw;
const itemsTree: ListItemNode[] =
@@ -922,6 +1051,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
formStyle.marginBottom = marginEm;
}
// 폼 배경색: backgroundColorCustom 이 설정된 경우에만 적용한다.
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
formStyle.backgroundColor = props.backgroundColorCustom.trim();
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -1097,6 +1231,7 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
props.align === "left" ? "justify-start" : props.align === "right" ? "justify-end" : "justify-center";
const widthMode = props.widthMode ?? "auto";
const wrapperStyle: CSSProperties = {};
const imageStyle: CSSProperties = {
display: "block",
maxWidth: "100%",
@@ -1124,8 +1259,12 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
: fallbackRadiusPx;
imageStyle.borderRadius = radiusPx === 9999 ? "9999px" : pxToEm(radiusPx);
if (props.backgroundColorCustom && props.backgroundColorCustom.trim() !== "") {
wrapperStyle.backgroundColor = props.backgroundColorCustom;
}
return (
<div key={block.id} className={`w-full flex ${alignClass}`}>
<div key={block.id} className={`w-full flex ${alignClass}`} style={wrapperStyle}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={props.src || ""} alt={props.alt} className="object-contain" style={imageStyle} />
</div>
@@ -1200,10 +1339,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
};
return (
<div className="flex-1 flex flex-col bg-slate-950 text-slate-50">
<div className="flex-1 flex flex-col text-slate-50">
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
{rootBlocks.length > 0 && (
<section className="bg-slate-950 py-12">
<section className="py-12">
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
{rootBlocks.map((b) => (
<div key={b.id}>{renderBlock(b)}</div>
+186 -18
View File
@@ -72,6 +72,9 @@ export interface TextBlockProps {
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
colorCustom?: string;
// 블록 배경색 커스텀 값 (예: "#123456")
backgroundColorCustom?: string;
// 최대 너비 모드: 프리셋 또는 커스텀 값
maxWidthMode?: "scale" | "custom";
// 최대 너비 스케일
@@ -127,6 +130,8 @@ export interface ImageBlockProps {
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 모서리 둥글기 px 값 (이미지에 한해서는 연속적인 px 단위 조정을 지원한다)
borderRadiusPx?: number;
// 이미지 카드를 감싸는 배경색 커스텀 값 (예: "#123456")
backgroundColorCustom?: string;
// 이미지 소스 타입: 업로드된 에셋 또는 외부 URL
sourceType?: "asset" | "externalUrl";
// 업로드된 에셋인 경우 Asset.id 를 저장해둔다.
@@ -450,6 +455,8 @@ export interface ListBlockProps {
fontSizeCustom?: string;
lineHeightCustom?: string;
textColorCustom?: string;
// 리스트 전체를 감싸는 배경색 커스텀 값
backgroundColorCustom?: string;
// 불릿/간격
bulletStyle?:
| "disc"
@@ -619,6 +626,8 @@ export interface FormBlockProps {
formWidthMode?: "auto" | "full" | "fixed";
formWidthPx?: number;
marginYPx?: number;
// 폼 전체를 감싸는 배경색 커스텀 값
backgroundColorCustom?: string;
}
export type CanvasPreset = "mobile" | "tablet" | "desktop" | "full" | "custom";
@@ -804,14 +813,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
addHeroTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -827,14 +854,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -845,14 +890,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
addBlogTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -863,14 +926,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
addTeamTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -881,14 +962,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
addFooterTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -899,14 +998,32 @@ const createEditorState = (set: any, get: any): EditorState => ({
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
addCtaTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
sectionId,
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -917,7 +1034,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
addFaqTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
sectionId,
@@ -925,7 +1052,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -936,7 +1070,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
addPricingTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
sectionId,
@@ -944,7 +1088,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
@@ -955,7 +1106,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
addTestimonialsTemplateSection: () => {
const sectionId = createId();
const { selectedBlockId, blocks } = get();
let sectionId = createId();
let replacingSectionId: string | null = null;
if (selectedBlockId) {
const target = blocks.find((b: Block) => b.id === selectedBlockId && b.type === "section");
if (target) {
sectionId = target.id;
replacingSectionId = target.id;
}
}
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
sectionId,
@@ -963,7 +1124,14 @@ const createEditorState = (set: any, get: any): EditorState => ({
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, ...templateBlocks];
let baseBlocks = state.blocks as Block[];
if (replacingSectionId) {
baseBlocks = baseBlocks.filter(
(b) => b.id !== replacingSectionId && (b as any).sectionId !== replacingSectionId,
);
}
const newBlocks = [...baseBlocks, ...templateBlocks];
return {
blocks: newBlocks,
+27
View File
@@ -186,6 +186,33 @@ body {
max-width: var(--pb-text-maxw-narrow);
}
.pb-whitespace-pre-wrap {
white-space: pre-wrap;
}
.pb-scroll {
scrollbar-width: thin;
scrollbar-color: #1f2937 #020617;
}
.pb-scroll::-webkit-scrollbar {
width: 8px;
}
.pb-scroll::-webkit-scrollbar-track {
background: transparent;
}
.pb-scroll::-webkit-scrollbar-thumb {
background-color: #1f2937;
border-radius: 9999px;
border: 2px solid #020617;
}
.pb-scroll::-webkit-scrollbar-thumb:hover {
background-color: #374151;
}
.pb-btn-base {
display: inline-flex;
align-items: center;