메인 플랜 및 에디터/폼 관련 변경 사항 main에 머지
CI / test (push) Failing after 6m19s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-20 01:05:14 +09:00
27 changed files with 3287 additions and 955 deletions
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const formData = await req.formData();
const name = formData.get("name");
const email = formData.get("email");
const message = formData.get("message");
// TODO: 이곳에서 DB 저장이나 이메일 전송 등 실제 처리를 수행한다.
console.log("[forms/submit]", { name, email, message });
return NextResponse.json({ ok: true });
}
+453 -353
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,295 @@
"use client";
import type { ButtonBlockProps } from "@/features/editor/state/editorStore";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
export type ButtonPropertiesPanelProps = {
buttonProps: ButtonBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<ButtonBlockProps>) => void;
};
export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBlock }: ButtonPropertiesPanelProps) {
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<textarea
className="w-full min-h-[60px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 텍스트"
value={buttonProps.label}
onChange={(e) => {
updateBlock(selectedBlockId, { label: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<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-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 링크"
value={buttonProps.href}
onChange={(e) => {
updateBlock(selectedBlockId, { href: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 정렬"
value={buttonProps.align ?? "left"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["align"]>;
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="버튼 크기"
value={(() => {
const s = buttonProps.size ?? "md";
return s === "xs" ? 1 : s === "sm" ? 2 : s === "lg" ? 4 : s === "xl" ? 5 : 3;
})()}
min={1}
max={5}
step={1}
presets={[
{ id: "xs", label: "아주 작게", value: 1 },
{ id: "sm", label: "작게", value: 2 },
{ id: "md", label: "보통", value: 3 },
{ id: "lg", label: "크게", value: 4 },
{ id: "xl", label: "아주 크게", value: 5 },
]}
onChangeValue={(v) => {
const next = v <= 1 ? "xs" : v === 2 ? "sm" : v === 4 ? "lg" : v >= 5 ? "xl" : "md";
updateBlock(selectedBlockId, { size: next } as any);
}}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="버튼 텍스트 색상 피커"
ariaLabelHexInput="버튼 텍스트 색상 HEX"
value={
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
? buttonProps.textColorCustom
: "#f9fafb"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
textColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
textColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 스타일"
value={buttonProps.variant ?? "solid"}
onChange={(e) => {
const value = e.target.value as NonNullable<ButtonBlockProps["variant"]>;
updateBlock(selectedBlockId, { variant: value } as any);
}}
>
<option value="solid"></option>
<option value="outline"></option>
<option value="ghost"></option>
</select>
</label>
</div>
<div className="space-y-1">
<ColorPickerField
label="채움 색상"
ariaLabelColorInput="버튼 채움 색상 피커"
ariaLabelHexInput="버튼 채움 색상 HEX"
value={
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
? buttonProps.fillColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
fillColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
fillColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="외곽선 색상"
ariaLabelColorInput="버튼 외곽선 색상 피커"
ariaLabelHexInput="버튼 외곽선 색상 HEX"
value={
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
? buttonProps.strokeColorCustom
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
strokeColorCustom: hex,
} as any);
}}
palette={TEXT_COLOR_PALETTE}
onPaletteSelect={(item) => {
updateBlock(selectedBlockId, {
strokeColorCustom: item.color,
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="모서리 둥글기"
value={(() => {
const r = buttonProps.borderRadius ?? "md";
return r === "none" ? 0 : r === "sm" ? 1 : r === "lg" ? 3 : r === "full" ? 4 : 2;
})()}
min={0}
max={4}
step={1}
presets={[
{ id: "none", label: "없음", value: 0 },
{ id: "sm", label: "작게", value: 1 },
{ id: "md", label: "보통", value: 2 },
{ id: "lg", label: "크게", value: 3 },
{ id: "full", label: "완전 둥글게", value: 4 },
]}
onChangeValue={(v) => {
const next =
v <= 0 ? "none" : v === 1 ? "sm" : v === 3 ? "lg" : v >= 4 ? "full" : "md";
updateBlock(selectedBlockId, { borderRadius: next } as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 크기"
unitLabel="(px)"
value={(() => {
const raw = buttonProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
// 기본 버튼 텍스트 크기: md 스케일(16px) 기준
return 16;
})()}
min={10}
max={32}
step={1}
presets={[
{ id: "xs", label: "XS", value: 12 },
{ id: "sm", label: "S", value: 14 },
{ id: "base", label: "M", value: 16 },
{ id: "lg", label: "L", value: 18 },
{ id: "xl", label: "XL", value: 20 },
{ id: "2xl", label: "2XL", value: 24 },
{ id: "3xl", label: "3XL", value: 30 },
]}
onChangeValue={(px) => {
// 버튼은 텍스트 스케일 상태 없이 px만 저장하지만,
// 프리셋 값은 TextPropertiesPanel과 동일한 테이블을 사용한다.
updateBlock(selectedBlockId, {
fontSizeCustom: `${px}px`,
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
value={(() => {
const raw = buttonProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 1.4;
})()}
min={0.8}
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.1 },
{ id: "normal", label: "보통", value: 1.4 },
{ id: "relaxed", label: "넓게", value: 1.8 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
lineHeightCustom: v.toString(),
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 간격"
unitLabel="(px)"
value={(() => {
const raw = buttonProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) {
const em = Number(match[1]);
if (Number.isFinite(em)) return em * 16;
}
return 0;
})()}
min={-2}
max={10}
step={0.1}
presets={[
{ id: "tighter", label: "아주 좁게", value: -1.5 },
{ id: "tight", label: "좁게", value: -0.5 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 1 },
{ id: "wider", label: "아주 넓게", value: 2 },
]}
onChangeValue={(px) => {
const em = px / 16;
updateBlock(selectedBlockId, {
letterSpacingCustom: `${em}em`,
} as any);
}}
/>
</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>
</>
);
}
@@ -0,0 +1,51 @@
"use client";
import type { DividerBlockProps } from "@/features/editor/state/editorStore";
export type DividerPropertiesPanelProps = {
dividerProps: DividerBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<DividerBlockProps>) => void;
};
export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBlock }: DividerPropertiesPanelProps) {
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="구분선 정렬"
value={dividerProps.align}
onChange={(e) => {
const value = e.target.value as DividerBlockProps["align"];
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="구분선 두께"
value={dividerProps.thickness}
onChange={(e) => {
const value = e.target.value as DividerBlockProps["thickness"];
updateBlock(selectedBlockId, { thickness: value } as any);
}}
>
<option value="thin"></option>
<option value="medium"></option>
</select>
</label>
</div>
</>
);
}
@@ -0,0 +1,42 @@
"use client";
import type { ImageBlockProps } from "@/features/editor/state/editorStore";
export type ImagePropertiesPanelProps = {
imageProps: ImageBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<ImageBlockProps>) => void;
};
export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock }: ImagePropertiesPanelProps) {
return (
<>
<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={imageProps.src}
onChange={(e) => {
updateBlock(selectedBlockId, { src: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<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-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="대체 텍스트"
value={imageProps.alt}
onChange={(e) => {
updateBlock(selectedBlockId, { alt: e.target.value } as any);
}}
/>
</label>
</div>
</>
);
}
@@ -0,0 +1,65 @@
"use client";
import type { ListBlockProps } from "@/features/editor/state/editorStore";
export type ListPropertiesPanelProps = {
listProps: ListBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<ListBlockProps>) => void;
};
export function ListPropertiesPanel({ listProps, selectedBlockId, updateBlock }: ListPropertiesPanelProps) {
const firstItem = listProps.items && listProps.items.length > 0 ? listProps.items[0] : "";
return (
<>
<div className="space-y-1">
<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-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="리스트 첫 번째 아이템"
value={firstItem}
onChange={(e) => {
const nextItems = [...(listProps.items ?? [])];
if (nextItems.length === 0) {
nextItems.push(e.target.value);
} else {
nextItems[0] = e.target.value;
}
updateBlock(selectedBlockId, { items: nextItems } as any);
}}
/>
</label>
</div>
<div className="flex items-center justify-between text-xs text-slate-400">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={listProps.ordered}
onChange={(e) => {
updateBlock(selectedBlockId, { ordered: e.target.checked } as any);
}}
/>
<span> </span>
</label>
<label className="flex items-center gap-2">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="리스트 정렬"
value={listProps.align}
onChange={(e) => {
const value = e.target.value as ListBlockProps["align"];
updateBlock(selectedBlockId, { align: value } as any);
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
</div>
</>
);
}
@@ -0,0 +1,55 @@
"use client";
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
export type SectionPropertiesPanelProps = {
sectionProps: SectionBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<SectionBlockProps>) => void;
};
export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBlock }: SectionPropertiesPanelProps) {
return (
<>
{/* 섹션 배경 */}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="섹션 배경"
value={sectionProps.background ?? "default"}
onChange={(e) => {
const value = e.target.value as NonNullable<SectionBlockProps["background"]>;
updateBlock(selectedBlockId, { background: value } as any);
}}
>
<option value="default"></option>
<option value="muted">Muted</option>
<option value="primary">Primary</option>
</select>
</label>
</div>
{/* 상하 패딩 */}
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="섹션 세로 패딩"
value={sectionProps.paddingY ?? "md"}
onChange={(e) => {
const value = e.target.value as NonNullable<SectionBlockProps["paddingY"]>;
updateBlock(selectedBlockId, { paddingY: value } as any);
}}
>
<option value="sm"></option>
<option value="md"></option>
<option value="lg"></option>
</select>
</label>
</div>
</>
);
}
@@ -0,0 +1,505 @@
"use client";
import type { TextBlockProps } from "@/features/editor/state/editorStore";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
export type TextPropertiesPanelProps = {
textProps: TextBlockProps;
selectedBlockId: string;
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void;
editingBlockId: string | null;
setEditingText: (value: string) => void;
};
export function TextPropertiesPanel({
textProps,
selectedBlockId,
updateBlock,
editingBlockId,
setEditingText,
}: TextPropertiesPanelProps) {
const fontSizeMode = textProps.fontSizeMode ?? "scale";
const fallbackScale =
textProps.size === "sm" ? "sm" : textProps.size === "lg" ? "lg" : "base";
const fontSizeScale = textProps.fontSizeScale ?? fallbackScale;
const lineHeightMode = textProps.lineHeightMode ?? "scale";
const lineHeightScale = textProps.lineHeightScale ?? "normal";
const fontWeightMode = textProps.fontWeightMode ?? "scale";
const fontWeightScale = textProps.fontWeightScale ?? "normal";
const colorPalette = textProps.colorPalette ?? "default";
const maxWidthMode = textProps.maxWidthMode ?? "scale";
const maxWidthScale = textProps.maxWidthScale ?? "none";
const fontSizeScaleToPx: Record<NonNullable<TextBlockProps["fontSizeScale"]>, number> = {
xs: 12,
sm: 14,
base: 16,
lg: 18,
xl: 20,
"2xl": 24,
"3xl": 30,
};
const parsedFontSize = (() => {
const raw = textProps.fontSizeCustom ?? "";
const match = raw.match(/([0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return fontSizeScaleToPx[fontSizeScale];
})();
const parsedLineHeight = (() => {
const raw = textProps.lineHeightCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) return Number(match[1]);
return 1.5;
})();
// 글자 간격: 내부 저장은 em 이지만, UI 에서는 px 단위로 보여준다.
const parsedLetterSpacingPx = (() => {
const raw = textProps.letterSpacingCustom ?? "";
const match = raw.match(/(-?[0-9]+(?:\.[0-9]+)?)/);
if (match) {
const em = Number(match[1]);
if (Number.isFinite(em)) {
return em * 16; // 1em = 16px 기준으로 환산
}
}
return 0;
})();
const letterSpacingPreset = (() => {
const px = parsedLetterSpacingPx;
if (px <= -1) return "tighter" as const;
if (px < 0) return "tight" as const;
if (px < 1) return "normal" as const;
if (px < 3) return "wide" as const;
return "wider" as const;
})();
const parsedFontWeight = (() => {
const raw = textProps.fontWeightCustom ?? "";
const match = raw.match(/([0-9]{3})/);
if (match) return Number(match[1]);
switch (fontWeightScale) {
case "medium":
return 500;
case "semibold":
return 600;
case "bold":
return 700;
default:
return 400;
}
})();
return (
<>
<div className="space-y-1">
<p className="text-xs text-slate-400"> </p>
<textarea
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="선택한 텍스트 블록 내용"
value={textProps.text}
onChange={(e) => {
const value = e.target.value;
updateBlock(selectedBlockId, { text: value });
if (editingBlockId === selectedBlockId) {
setEditingText(value);
}
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="정렬"
value={textProps.align}
onChange={(e) => {
const value = e.target.value as "left" | "center" | "right";
updateBlock(selectedBlockId, { align: value });
}}
>
<option value="left"></option>
<option value="center"></option>
<option value="right"></option>
</select>
</label>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px] text-slate-400">
<span> </span>
<div className="flex gap-1">
<button
type="button"
className={`px-2 py-0.5 rounded border text-[11px] ${
textProps.underline
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
underline: !textProps.underline,
} as any);
}}
>
</button>
<button
type="button"
className={`px-2 py-0.5 rounded border text-[11px] ${
textProps.strike
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
strike: !textProps.strike,
} as any);
}}
>
</button>
<button
type="button"
className={`px-2 py-0.5 rounded border text-[11px] ${
textProps.italic
? "border-sky-500 bg-sky-900/40 text-sky-100"
: "border-slate-700 bg-slate-900 text-slate-300"
}`}
onClick={() => {
updateBlock(selectedBlockId, {
italic: !textProps.italic,
} as any);
}}
>
</button>
</div>
</div>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="글자 크기"
unitLabel="(px)"
value={Number.isFinite(parsedFontSize) ? parsedFontSize : 16}
min={10}
max={72}
step={1}
presets={(
["xs", "sm", "base", "lg", "xl", "2xl", "3xl"] as NonNullable<
TextBlockProps["fontSizeScale"]
>[]
).map((scale) => ({
id: scale,
label:
scale === "xs"
? "XS"
: scale === "sm"
? "S"
: scale === "base"
? "M"
: scale === "lg"
? "L"
: scale === "xl"
? "XL"
: scale === "2xl"
? "2XL"
: "3XL",
value: fontSizeScaleToPx[scale],
}))}
onChangeValue={(px) => {
const raw = textProps.fontSizeCustom ?? "";
const suffixMatch = raw.match(/px|rem|em|%/);
const suffix = suffixMatch ? suffixMatch[0] : "px";
const entries = Object.entries(fontSizeScaleToPx) as [
NonNullable<TextBlockProps["fontSizeScale"]>,
number,
][];
const closest = entries.reduce(
(best, [scale, value]) => {
const dist = Math.abs(value - px);
if (dist < best.dist) return { scale, dist };
return best;
},
{ scale: fontSizeScale as NonNullable<TextBlockProps["fontSizeScale"]>, dist: Infinity },
).scale;
updateBlock(selectedBlockId, {
fontSizeScale: closest,
fontSizeCustom: `${px}${suffix}`,
fontSizeMode: "custom",
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<div className="flex items-center gap-2">
<select
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="글자 간격 프리셋"
value={letterSpacingPreset}
onChange={(e) => {
const preset = e.target.value as
| "tighter"
| "tight"
| "normal"
| "wide"
| "wider";
const presetPxMap: Record<
"tighter" | "tight" | "normal" | "wide" | "wider",
number
> = {
tighter: -1.5,
tight: -0.5,
normal: 0,
wide: 1,
wider: 2,
};
const px = presetPxMap[preset];
const em = px / 16;
updateBlock(selectedBlockId, {
letterSpacingCustom: `${em}em`,
} as any);
}}
>
<option value="tighter"> </option>
<option value="tight"></option>
<option value="normal"></option>
<option value="wide"></option>
<option value="wider"> </option>
</select>
<PropertySliderField
label=""
ariaLabelSlider="글자 간격 슬라이더"
ariaLabelInput="글자 간격 커스텀 (px)"
value={Number.isFinite(parsedLetterSpacingPx) ? parsedLetterSpacingPx : 0}
min={-2}
max={10}
step={0.1}
onChange={(px) => {
const em = px / 16;
updateBlock(selectedBlockId, {
letterSpacingCustom: `${em}em`,
} as any);
}}
/>
</div>
</label>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="줄 간격"
unitLabel=""
value={Number.isFinite(parsedLineHeight) ? parsedLineHeight : 1.5}
min={-1}
max={3}
step={0.05}
presets={[
{ id: "tight", label: "좁게", value: 1.25 },
{ id: "snug", label: "약간 좁게", value: 1.35 },
{ id: "normal", label: "보통", value: 1.5 },
{ id: "relaxed", label: "넓게", value: 1.7 },
{ id: "loose", label: "아주 넓게", value: 1.9 },
]}
onChangeValue={(v) => {
const preset: Record<NonNullable<TextBlockProps["lineHeightScale"]>, number> = {
tight: 1.25,
snug: 1.35,
normal: 1.5,
relaxed: 1.7,
loose: 1.9,
};
const entries = Object.entries(preset) as [
NonNullable<TextBlockProps["lineHeightScale"]>,
number,
][];
const closest = entries.reduce(
(best, [scale, value]) => {
const dist = Math.abs(value - v);
if (dist < best.dist) return { scale, dist };
return best;
},
{ scale: lineHeightScale as NonNullable<TextBlockProps["lineHeightScale"]>, dist: Infinity },
).scale;
updateBlock(selectedBlockId, {
lineHeightScale: closest,
lineHeightCustom: v.toString(),
lineHeightMode: "custom",
} as any);
}}
/>
</div>
<div className="space-y-1">
<NumericPropertyControl
label="굵기"
value={Number.isFinite(parsedFontWeight) ? parsedFontWeight : 400}
min={100}
max={900}
step={100}
presets={(
["normal", "medium", "semibold", "bold"] as NonNullable<
TextBlockProps["fontWeightScale"]
>[]
).map((scale) => ({
id: scale,
label:
scale === "normal"
? "보통"
: scale === "medium"
? "중간"
: scale === "semibold"
? "세미볼드"
: "볼드",
value:
scale === "normal"
? 400
: scale === "medium"
? 500
: scale === "semibold"
? 600
: 700,
}))}
onChangeValue={(v) => {
const preset: Record<NonNullable<TextBlockProps["fontWeightScale"]>, number> = {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
};
const entries = Object.entries(preset) as [
NonNullable<TextBlockProps["fontWeightScale"]>,
number,
][];
const closest = entries.reduce(
(best, [scale, value]) => {
const dist = Math.abs(value - v);
if (dist < best.dist) return { scale, dist };
return best;
},
{ scale: fontWeightScale as NonNullable<TextBlockProps["fontWeightScale"]>, dist: Infinity },
).scale;
updateBlock(selectedBlockId, {
fontWeightScale: closest,
fontWeightCustom: v.toString(),
fontWeightMode: "custom",
} as any);
}}
/>
</div>
<div className="space-y-1">
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="텍스트 색상 피커"
ariaLabelHexInput="텍스트 색상 HEX"
value={
(textProps.colorCustom && textProps.colorCustom.startsWith("#")
? textProps.colorCustom
: TEXT_COLOR_PALETTE.find((p) => p.id === colorPalette)?.color ?? "#ffffff")
}
onChange={(hex) => {
updateBlock(selectedBlockId, {
colorCustom: hex,
colorMode: "custom",
} as any);
}}
palette={TEXT_COLOR_PALETTE}
selectedPaletteId={colorPalette}
onPaletteSelect={(item) => {
const paletteId = item.id as NonNullable<TextBlockProps["colorPalette"]>;
updateBlock(selectedBlockId, {
colorPalette: paletteId,
colorCustom: item.color,
colorMode: "custom",
} as any);
}}
/>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<div className="flex items-center gap-2">
<select
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label="최대 너비 프리셋"
value={maxWidthScale}
onChange={(e) => {
const value = e.target.value as NonNullable<TextBlockProps["maxWidthScale"]>;
let custom = textProps.maxWidthCustom ?? "";
if (value === "prose") custom = "60ch";
else if (value === "narrow") custom = "40ch";
else custom = "";
updateBlock(selectedBlockId, {
maxWidthScale: value,
maxWidthMode: custom ? "custom" : "scale",
maxWidthCustom: custom,
} as any);
}}
>
<option value="none"> </option>
<option value="prose"> (60ch)</option>
<option value="narrow"> (40ch)</option>
</select>
<input
type="range"
min={20}
max={120}
step={5}
aria-label="최대 너비 슬라이더 (ch 단위)"
value={(() => {
const raw = textProps.maxWidthCustom ?? "";
const match = raw.match(/([0-9]+)ch/);
if (match) return Number(match[1]);
if (maxWidthScale === "prose") return 60;
if (maxWidthScale === "narrow") return 40;
return 120;
})()}
onChange={(e) => {
const ch = Number(e.target.value || 60);
updateBlock(selectedBlockId, {
maxWidthCustom: `${ch}ch`,
maxWidthMode: "custom",
} as any);
}}
className="flex-1"
/>
</div>
<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="최대 너비 커스텀"
placeholder="예: 600px, 40rem, 80%, 60ch"
value={textProps.maxWidthCustom ?? ""}
onChange={(e) => {
updateBlock(selectedBlockId, {
maxWidthCustom: e.target.value,
maxWidthMode: "custom",
} as any);
}}
/>
</label>
</div>
</>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createBlogTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionProps: SectionBlockProps = {
background: "default",
paddingY: "lg",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const postDefinitions = [
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
];
const blogBlocks: Block[] = columns.flatMap((col, index) => {
const post = postDefinitions[index] ?? postDefinitions[0];
const titleId = createId();
const summaryId = createId();
const titleProps: TextBlockProps = {
text: post.title,
align: "left",
size: "lg",
} as any;
const summaryProps: TextBlockProps = {
text: post.summary,
align: "left",
size: "sm",
} as any;
const titleBlock: Block = {
id: titleId,
type: "text",
props: titleProps,
sectionId,
columnId: col.id,
};
const summaryBlock: Block = {
id: summaryId,
type: "text",
props: summaryProps,
sectionId,
columnId: col.id,
};
return [titleBlock, summaryBlock];
});
const blocks: Block[] = [sectionBlock, ...blogBlocks];
const lastSelectedId = blogBlocks[blogBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
export function createCtaTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 12 },
];
const sectionProps: SectionBlockProps = {
background: "primary",
paddingY: "md",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const textId = createId();
const textProps: TextBlockProps = {
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
align: "center",
size: "base",
} as any;
const textBlock: Block = {
id: textId,
type: "text",
props: textProps,
sectionId,
columnId: firstColumnId,
};
const buttonId = createId();
const buttonProps: ButtonBlockProps = {
label: "CTA 버튼",
href: "#",
align: "center",
size: "md",
variant: "solid",
colorPalette: "primary",
fullWidth: false,
borderRadius: "md",
};
const buttonBlock: Block = {
id: buttonId,
type: "button",
props: buttonProps,
sectionId,
columnId: firstColumnId,
};
const blocks: Block[] = [sectionBlock, textBlock, buttonBlock];
const lastSelectedId = buttonId;
return { blocks, lastSelectedId };
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createFaqTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 12 },
];
const sectionProps: SectionBlockProps = {
background: "default",
paddingY: "md",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const faqPairs = [
{
question: "자주 묻는 질문 1",
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 2",
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 3",
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
},
];
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
const qId = createId();
const aId = createId();
const questionProps: TextBlockProps = {
text: pair.question,
align: "left",
size: "lg",
} as any;
const answerProps: TextBlockProps = {
text: pair.answer,
align: "left",
size: "sm",
} as any;
const questionBlock: Block = {
id: qId,
type: "text",
props: questionProps,
sectionId,
columnId: firstColumnId,
};
const answerBlock: Block = {
id: aId,
type: "text",
props: answerProps,
sectionId,
columnId: firstColumnId,
};
return [questionBlock, answerBlock];
});
const blocks: Block[] = [sectionBlock, ...faqBlocks];
const lastSelectedId = faqBlocks[faqBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
@@ -0,0 +1,70 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createFeaturesTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns = [
{ 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",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const featureBlocks: Block[] = columns.flatMap((col, index) => {
const titleId = createId();
const descId = createId();
const titleProps: TextBlockProps = {
text: `Feature ${index + 1} 제목`,
align: "left",
size: "lg",
} as any;
const descProps: TextBlockProps = {
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
align: "left",
size: "sm",
} as any;
const title: Block = {
id: titleId,
type: "text",
props: titleProps,
sectionId,
columnId: col.id,
};
const description: Block = {
id: descId,
type: "text",
props: descProps,
sectionId,
columnId: col.id,
};
return [title, description];
});
const blocks: Block[] = [sectionBlock, ...featureBlocks];
const lastSelectedId = featureBlocks[featureBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
@@ -0,0 +1,63 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createFooterTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 12 },
];
const sectionProps: SectionBlockProps = {
background: "muted",
paddingY: "md",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const linksId = createId();
const linksProps: TextBlockProps = {
text: "이용약관 · 개인정보처리방침",
align: "center",
size: "sm",
} as any;
const linksBlock: Block = {
id: linksId,
type: "text",
props: linksProps,
sectionId,
columnId: firstColumnId,
};
const copyrightId = createId();
const copyrightProps: TextBlockProps = {
text: "© 2025 MyLanding. All rights reserved.",
align: "center",
size: "sm",
} as any;
const copyrightBlock: Block = {
id: copyrightId,
type: "text",
props: copyrightProps,
sectionId,
columnId: firstColumnId,
};
const blocks: Block[] = [sectionBlock, linksBlock, copyrightBlock];
const lastSelectedId = copyrightId;
return { blocks, lastSelectedId };
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import type {
Block,
SectionBlockProps,
TextBlockProps,
ButtonBlockProps,
} from "@/features/editor/state/editorStore";
export function createHeroTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const sectionProps: SectionBlockProps = {
background: "default",
paddingY: "lg",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
};
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const heroHeadlineId = createId();
const heroHeadlineProps: TextBlockProps = {
text: "Hero 제목을 여기에 입력하세요",
align: "center",
size: "lg",
} as any;
const heroHeadline: Block = {
id: heroHeadlineId,
type: "text",
props: heroHeadlineProps,
sectionId,
columnId: firstColumnId,
};
const heroSubId = createId();
const heroSubProps: TextBlockProps = {
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
align: "center",
size: "base",
} as any;
const heroSub: Block = {
id: heroSubId,
type: "text",
props: heroSubProps,
sectionId,
columnId: firstColumnId,
};
const heroButtonId = createId();
const heroButtonProps: ButtonBlockProps = {
label: "지금 시작하기",
href: "#",
align: "center",
size: "md",
variant: "solid",
colorPalette: "primary",
fullWidth: false,
borderRadius: "md",
fontSizeCustom: "14px",
fillColorCustom: "",
strokeColorCustom: "",
};
const heroButton: Block = {
id: heroButtonId,
type: "button",
props: heroButtonProps,
sectionId,
columnId: firstColumnId,
};
const blocks: Block[] = [sectionBlock, heroHeadline, heroSub, heroButton];
return { blocks, lastSelectedId: heroButtonId };
}
@@ -0,0 +1,78 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createPricingTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionProps: SectionBlockProps = {
background: "muted",
paddingY: "lg",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const planDefinitions: Array<{ name: string; price: string }> = [
{ name: "Basic", price: "₩9,900/월" },
{ name: "Pro", price: "₩29,900/월" },
{ name: "Enterprise", price: "문의" },
];
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
const plan = planDefinitions[index] ?? planDefinitions[0];
const nameId = createId();
const priceId = createId();
const nameProps: TextBlockProps = {
text: plan.name,
align: "center",
size: "lg",
} as any;
const priceProps: TextBlockProps = {
text: plan.price,
align: "center",
size: "base",
} as any;
const nameBlock: Block = {
id: nameId,
type: "text",
props: nameProps,
sectionId,
columnId: col.id,
};
const priceBlock: Block = {
id: priceId,
type: "text",
props: priceProps,
sectionId,
columnId: col.id,
};
return [nameBlock, priceBlock];
});
const blocks: Block[] = [sectionBlock, ...pricingBlocks];
const lastSelectedId = pricingBlocks[pricingBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createTeamTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionProps: SectionBlockProps = {
background: "muted",
paddingY: "lg",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const memberDefinitions = [
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
];
const teamBlocks: Block[] = columns.flatMap((col, index) => {
const m = memberDefinitions[index] ?? memberDefinitions[0];
const nameId = createId();
const roleId = createId();
const bioId = createId();
const nameProps: TextBlockProps = {
text: m.name,
align: "center",
size: "lg",
} as any;
const roleProps: TextBlockProps = {
text: m.role,
align: "center",
size: "base",
} as any;
const bioProps: TextBlockProps = {
text: m.bio,
align: "center",
size: "sm",
} as any;
const nameBlock: Block = {
id: nameId,
type: "text",
props: nameProps,
sectionId,
columnId: col.id,
};
const roleBlock: Block = {
id: roleId,
type: "text",
props: roleProps,
sectionId,
columnId: col.id,
};
const bioBlock: Block = {
id: bioId,
type: "text",
props: bioProps,
sectionId,
columnId: col.id,
};
return [nameBlock, roleBlock, bioBlock];
});
const blocks: Block[] = [sectionBlock, ...teamBlocks];
const lastSelectedId = teamBlocks[teamBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
@@ -0,0 +1,78 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
export function createTestimonialsTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionProps: SectionBlockProps = {
background: "default",
paddingY: "lg",
columns,
} as any;
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: sectionProps,
sectionId: null,
columnId: null,
};
const testimonialDefinitions: Array<{ body: string; author: string }> = [
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
];
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
const bodyId = createId();
const authorId = createId();
const bodyProps: TextBlockProps = {
text: t.body,
align: "left",
size: "base",
} as any;
const authorProps: TextBlockProps = {
text: `- ${t.author}`,
align: "left",
size: "sm",
} as any;
const bodyBlock: Block = {
id: bodyId,
type: "text",
props: bodyProps,
sectionId,
columnId: col.id,
};
const authorBlock: Block = {
id: authorId,
type: "text",
props: authorProps,
sectionId,
columnId: col.id,
};
return [bodyBlock, authorBlock];
});
const blocks: Block[] = [sectionBlock, ...testimonialBlocks];
const lastSelectedId = testimonialBlocks[testimonialBlocks.length - 1]?.id ?? sectionId;
return { blocks, lastSelectedId };
}
+1
View File
@@ -1,4 +1,5 @@
import "../styles/globals.css";
import "../styles/builder.css";
import type { ReactNode } from "react";
export const metadata = {
@@ -0,0 +1,167 @@
import type { ChangeEvent } from "react";
import { useState } from "react";
export type ColorPaletteItem = {
// 팔레트 식별자
id: string;
// UI에 보여줄 이름
label: string;
// HEX 색상 값
color: string;
};
export type ColorPickerFieldProps = {
// 필드 라벨 텍스트
label: string;
// 컬러 인풋에 대한 접근성 라벨
ariaLabelColorInput: string;
// HEX 텍스트 인풋에 대한 접근성 라벨
ariaLabelHexInput: string;
// 현재 선택된 색상 값 (HEX)
value: string;
// 값 변경 콜백
onChange: (value: string) => void;
// 선택 가능한 팔레트 목록
palette?: ColorPaletteItem[];
// 현재 선택된 팔레트 ID (있다면 드롭다운 요약에 표시)
selectedPaletteId?: string;
// 팔레트 선택 시 호출되는 콜백 (팔레트 메타 정보까지 필요할 때 사용)
onPaletteSelect?: (item: ColorPaletteItem) => void;
};
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
// 기본/중립 계열
{ id: "default", label: "기본", color: "#e5e7eb" },
{ id: "muted", label: "연한", color: "#9ca3af" },
{ id: "strong", label: "강조", color: "#f9fafb" },
{ id: "neutral", label: "중립", color: "#94a3b8" },
// 브랜드/포인트 계열
{ id: "accent", label: "포인트", color: "#38bdf8" },
{ id: "accent-deep", label: "포인트 진하게", color: "#0ea5e9" },
{ id: "accent-soft", label: "포인트 연하게", color: "#bae6fd" },
// 상태 계열
{ id: "danger", label: "위험", color: "#f97373" },
{ id: "danger-deep", label: "위험 진하게", color: "#ef4444" },
{ id: "success", label: "성공", color: "#22c55e" },
{ id: "success-soft", label: "성공 연하게", color: "#bbf7d0" },
{ id: "warning", label: "경고", color: "#eab308" },
{ id: "info", label: "정보", color: "#0ea5e9" },
// 보라/핑크 계열
{ id: "purple", label: "보라", color: "#a855f7" },
{ id: "purple-soft", label: "연한 보라", color: "#e9d5ff" },
{ id: "pink", label: "핑크", color: "#ec4899" },
{ id: "pink-soft", label: "연한 핑크", color: "#f9a8d4" },
// 배경 대비용 어두운 텍스트
{ id: "dark", label: "어두운 텍스트", color: "#020617" },
{ id: "dark-muted", label: "어두운 연한", color: "#64748b" },
];
export function ColorPickerField({
label,
ariaLabelColorInput,
ariaLabelHexInput,
value,
onChange,
palette = [],
selectedPaletteId,
onPaletteSelect,
}: ColorPickerFieldProps) {
const [open, setOpen] = useState(false);
// 컬러 인풋 변경 시 HEX 문자열을 onChange로 전달한다.
const handleColorInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 텍스트 인풋 변경 시 그대로 onChange로 전달한다.
const handleHexInputChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value);
};
// 팔레트 버튼 클릭 시 해당 팔레트의 색상으로 onChange를 호출한다.
const handlePaletteClick = (item: ColorPaletteItem) => {
onChange(item.color);
if (onPaletteSelect) {
onPaletteSelect(item);
}
};
// 현재 선택된 팔레트 정보를 계산한다.
const selectedPalette =
selectedPaletteId && palette.length > 0
? palette.find((item) => item.id === selectedPaletteId) ?? null
: null;
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="color"
aria-label={ariaLabelColorInput}
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
value={value || "#ffffff"}
onChange={handleColorInputChange}
/>
<input
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label={ariaLabelHexInput}
placeholder="예: #ff0000"
value={value}
onChange={handleHexInputChange}
/>
{palette.length > 0 ? (
<div className="relative text-[11px] flex-none">
<button
type="button"
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
onClick={() => setOpen((prev) => !prev)}
>
<span className="flex items-center gap-1">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
/>
<span className="truncate text-slate-200">
{selectedPalette?.label ?? "색상 팔레트"}
</span>
</span>
<span className="text-slate-500 text-[10px]"></span>
</button>
{open ? (
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
{palette.map((item) => (
<button
key={item.id}
type="button"
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
selectedPaletteId === item.id
? "bg-sky-900/40 text-sky-100"
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
}`}
onClick={() => {
handlePaletteClick(item);
setOpen(false);
}}
>
<span className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span>{item.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</div>
</label>
);
}
@@ -0,0 +1,97 @@
import type { ChangeEvent } from "react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
export type NumericPresetOption = {
// 프리셋 식별자 (예: "tight", "normal")
id: string;
// UI 에 표시할 라벨
label: string;
// UI 기준 값 (px, 배율 등)
value: number;
};
export type NumericPropertyControlProps = {
// 필드 라벨 (예: 글자 크기, 줄 간격)
label: string;
// 단위 설명 (예: px, 배율 등)
unitLabel?: string;
// 현재 UI 값
value: number;
min: number;
max: number;
step: number;
// 프리셋 목록 (없으면 select 를 렌더링하지 않음)
presets?: NumericPresetOption[];
// 값 변경 시 호출되는 콜백 (UI 값 기준)
onChangeValue: (value: number) => void;
};
export function NumericPropertyControl({
label,
unitLabel,
value,
min,
max,
step,
presets,
onChangeValue,
}: NumericPropertyControlProps) {
// 현재 값에 가장 가까운 프리셋 ID 를 계산한다.
const currentPresetId = (() => {
if (!presets || presets.length === 0) return "";
let bestId = presets[0].id;
let bestDist = Math.abs(presets[0].value - value);
for (let i = 1; i < presets.length; i += 1) {
const dist = Math.abs(presets[i].value - value);
if (dist < bestDist) {
bestDist = dist;
bestId = presets[i].id;
}
}
return bestId;
})();
const handlePresetChange = (event: ChangeEvent<HTMLSelectElement>) => {
if (!presets || presets.length === 0) return;
const id = event.target.value;
const found = presets.find((p) => p.id === id);
if (!found) return;
onChangeValue(found.value);
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>
{label}
{unitLabel ? ` ${unitLabel}` : ""}
</span>
<div className="flex items-center gap-2">
{presets && presets.length > 0 ? (
<select
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
aria-label={`${label} 프리셋`}
value={currentPresetId}
onChange={handlePresetChange}
>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
) : null}
<PropertySliderField
label=""
ariaLabelSlider={`${label} 슬라이더`}
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
value={Number.isFinite(value) ? value : 0}
min={min}
max={max}
step={step}
onChange={onChangeValue}
/>
</div>
</label>
);
}
@@ -0,0 +1,70 @@
import type { ChangeEvent } from "react";
export type PropertySliderFieldProps = {
// 슬라이더 그룹 전체 라벨 텍스트
label: string;
// 슬라이더 요소에 대한 접근성 라벨
ariaLabelSlider: string;
// 텍스트 입력 요소에 대한 접근성 라벨
ariaLabelInput: string;
// 현재 값 (슬라이더와 입력이 공유)
value: number;
// 최소 값
min: number;
// 최대 값
max: number;
// 슬라이더 스텝
step: number;
// 값 변경 콜백
onChange: (value: number) => void;
};
export function PropertySliderField({
label,
ariaLabelSlider,
ariaLabelInput,
value,
min,
max,
step,
onChange,
}: PropertySliderFieldProps) {
// 슬라이더 변경 시 상위에서 전달받은 onChange에 숫자 값으로 전달한다.
const handleSliderChange = (event: ChangeEvent<HTMLInputElement>) => {
const numeric = Number(event.target.value || 0);
onChange(numeric);
};
// 텍스트 입력 변경 시에도 숫자 값으로 파싱하여 onChange를 호출한다.
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
const numeric = Number(raw);
if (!Number.isNaN(numeric)) {
onChange(numeric);
}
};
return (
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span>{label}</span>
<div className="flex items-center gap-2">
<input
type="range"
min={min}
max={max}
step={step}
aria-label={ariaLabelSlider}
value={value}
onChange={handleSliderChange}
className="flex-1"
/>
</div>
<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={ariaLabelInput}
value={Number.isFinite(value) ? value : ""}
onChange={handleInputChange}
/>
</label>
);
}
@@ -1,6 +1,14 @@
"use client";
import type { Block, TextBlockProps, ButtonBlockProps, ImageBlockProps, SectionBlockProps } from "@/features/editor/state/editorStore";
import { useState } from "react";
import type {
Block,
TextBlockProps,
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
FormBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 크롬 없이 실제 랜딩 페이지처럼 블록들을 렌더링하는 컴포넌트
interface PublicPageRendererProps {
@@ -11,6 +19,9 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sectionBlocks = blocks.filter((b) => b.type === "section");
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [formMessage, setFormMessage] = useState<string>("");
const renderBlock = (block: Block) => {
if (block.type === "text") {
const props = block.props as TextBlockProps;
@@ -20,7 +31,10 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
const sizeClass = props.size === "sm" ? "text-sm" : props.size === "lg" ? "text-2xl" : "text-base";
return (
<p key={block.id} className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed`}>
<p
key={block.id}
className={`${alignClass} ${sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
>
{props.text}
</p>
);
@@ -35,11 +49,98 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
href={props.href}
className="inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 transition-colors"
>
{props.label}
<span className="whitespace-pre-wrap">{props.label}</span>
</a>
);
}
if (block.type === "form") {
const props = block.props as FormBlockProps;
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
try {
setFormStatus("submitting");
setFormMessage("");
const res = await fetch("/api/forms/submit", {
method: "POST",
body: data,
});
if (res.ok) {
setFormStatus("success");
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
form.reset();
} else {
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
} catch (error) {
console.error("[PublicPageRenderer] form submit error", error);
setFormStatus("error");
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
}
};
return (
<form key={block.id} className="space-y-3" onSubmit={handleSubmit}>
<div className="flex flex-col gap-1 text-xs text-slate-200">
<label className="flex flex-col gap-1">
<span></span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="name"
placeholder="이름을 입력하세요"
required
/>
</label>
<label className="flex flex-col gap-1">
<span></span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="email"
type="email"
placeholder="you@example.com"
required
/>
</label>
<label className="flex flex-col gap-1">
<span></span>
<textarea
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
name="message"
placeholder="전달할 내용을 입력하세요"
required
/>
</label>
</div>
<button
type="submit"
disabled={formStatus === "submitting"}
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
>
{formStatus === "submitting" ? "전송 중..." : "폼 전송"}
</button>
{formStatus !== "idle" && formMessage ? (
<p
className={`text-xs mt-1 ${
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
}`}
>
{formMessage}
</p>
) : null}
</form>
);
}
if (block.type === "image") {
const props = block.props as ImageBlockProps;
+148 -525
View File
@@ -1,23 +1,102 @@
import { createStore } from "zustand";
import { create } from "zustand";
import { createHeroTemplateBlocks } from "@/app/editor/templates/heroTemplate";
import { createFeaturesTemplateBlocks } from "@/app/editor/templates/featuresTemplate";
import { createBlogTemplateBlocks } from "@/app/editor/templates/blogTemplate";
import { createTeamTemplateBlocks } from "@/app/editor/templates/teamTemplate";
import { createFooterTemplateBlocks } from "@/app/editor/templates/footerTemplate";
import { createCtaTemplateBlocks } from "@/app/editor/templates/ctaTemplate";
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" | "section" | "divider" | "list";
// 블록 타입 정의: 텍스트/버튼/이미지/섹션/구분선/리스트/폼 블록
export type BlockType = "text" | "button" | "image" | "section" | "divider" | "list" | "form";
// 텍스트 블록 속성
export interface TextBlockProps {
text: string;
// 텍스트 정렬: 기본은 left
align: "left" | "center" | "right";
// 텍스트 크기: 기본은 base
// 텍스트 크기: 기본은 base (기존 필드, 하위호환 유지)
size: "sm" | "base" | "lg";
// 폰트 크기 모드: 프리셋 스케일 또는 커스텀 값
fontSizeMode?: "scale" | "custom";
// 폰트 크기 스케일 (scale 모드일 때 사용)
fontSizeScale?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
// 폰트 크기 커스텀 값 (예: "18px", "1.125rem")
fontSizeCustom?: string;
// 줄 간격 모드: 프리셋 스케일 또는 커스텀 값
lineHeightMode?: "scale" | "custom";
// 줄 간격 스케일
lineHeightScale?: "tight" | "snug" | "normal" | "relaxed" | "loose";
// 줄 간격 커스텀 값 (예: "1.4", "24px")
lineHeightCustom?: string;
// 폰트 굵기 모드: 프리셋 스케일 또는 커스텀 값
fontWeightMode?: "scale" | "custom";
// 폰트 굵기 스케일
fontWeightScale?: "normal" | "medium" | "semibold" | "bold";
// 폰트 굵기 커스텀 값 (예: "500", "650")
fontWeightCustom?: string;
// 글자 간격 커스텀 값 (em 단위, 예: "0.05em", "-0.02em")
letterSpacingCustom?: string;
// 텍스트 색상 모드: 팔레트 또는 커스텀 웹색상
colorMode?: "palette" | "custom";
// 텍스트 색상 팔레트 (디자인 토큰)
colorPalette?:
| "default"
| "muted"
| "strong"
| "accent"
| "danger"
| "success"
| "warning"
| "info"
| "neutral";
// 커스텀 색상 값 (예: "#ff0000", "rgb(…)" 등)
colorCustom?: string;
// 최대 너비 모드: 프리셋 또는 커스텀 값
maxWidthMode?: "scale" | "custom";
// 최대 너비 스케일
maxWidthScale?: "none" | "prose" | "narrow";
// 최대 너비 커스텀 값 (예: "600px", "40rem")
maxWidthCustom?: string;
// 텍스트 장식
underline?: boolean;
strike?: boolean;
italic?: boolean;
}
// 버튼 블록 속성
export interface ButtonBlockProps {
label: string;
href: string;
// TODO: variant, size 등은 추후 확장
align?: "left" | "center" | "right";
// 버튼 크기: 더 세분화된 스케일(xs~xl)
size?: "xs" | "sm" | "md" | "lg" | "xl";
variant?: "solid" | "outline" | "ghost";
colorPalette?: "primary" | "muted" | "danger" | "success" | "neutral";
fullWidth?: boolean;
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
// 버튼 텍스트 크기 (예: "14px")
fontSizeCustom?: string;
// 버튼 텍스트 줄 간격 (예: "1.4")
lineHeightCustom?: string;
// 버튼 텍스트 글자 간격 (em 단위, 예: "0.05em")
letterSpacingCustom?: string;
// 채움(배경) 색상 커스텀 값
fillColorCustom?: string;
// 외곽선(보더) 색상 커스텀 값
strokeColorCustom?: string;
// 버튼 텍스트 색상 커스텀 값
textColorCustom?: string;
}
// 이미지 블록 속성
@@ -50,6 +129,15 @@ export interface SectionBlockProps {
}>;
}
// 폼 블록 속성 (1차 MVP: 고정 contact 폼)
export interface FormBlockProps {
kind: "contact";
// 전송 대상: 현재는 internal 만 지원, 이후 webhook 등 확장 가능
submitTarget: "internal";
successMessage?: string;
errorMessage?: string;
}
// 공통 블록 모델
export interface Block {
id: string;
@@ -60,7 +148,8 @@ export interface Block {
| ImageBlockProps
| SectionBlockProps
| DividerBlockProps
| ListBlockProps;
| ListBlockProps
| FormBlockProps;
// 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null)
sectionId?: string | null;
columnId?: string | null;
@@ -78,6 +167,7 @@ export interface EditorState {
addDividerBlock: () => void;
addListBlock: () => void;
addSectionBlock: () => void;
addFormBlock: () => void;
addHeroTemplateSection: () => void;
addFeaturesTemplateSection: () => void;
addCtaTemplateSection: () => void;
@@ -87,7 +177,10 @@ export interface EditorState {
addBlogTemplateSection: () => void;
addTeamTemplateSection: () => void;
addFooterTemplateSection: () => void;
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps>) => void;
updateBlock: (
id: string,
partial: Partial<TextBlockProps> | Partial<ButtonBlockProps>,
) => void;
selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void;
reorderBlocks: (activeId: string, overId: string) => void;
@@ -155,74 +248,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
addHeroTemplateSection: () => {
const sectionId = createId();
// Hero 템플릿용 섹션: 기본 배경/패딩 + 1컬럼(12)
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
// Hero 텍스트 블록 (예: 헤드라인)
const heroHeadlineId = createId();
const heroHeadline: Block = {
id: heroHeadlineId,
type: "text",
props: {
text: "Hero 제목을 여기에 입력하세요",
align: "center",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
// Hero 서브텍스트 블록
const heroSubId = createId();
const heroSub: Block = {
id: heroSubId,
type: "text",
props: {
text: "제품이나 서비스를 한 문장으로 설명하는 서브텍스트입니다.",
align: "center",
size: "base",
},
sectionId,
columnId: firstColumnId,
};
// CTA 버튼 블록
const heroButtonId = createId();
const heroButton: Block = {
id: heroButtonId,
type: "button",
props: {
label: "지금 시작하기",
href: "#",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, heroHeadline, heroSub, heroButton];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: heroButtonId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -230,64 +266,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
addFeaturesTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "md",
columns,
},
sectionId: null,
columnId: null,
};
const featureBlocks: Block[] = columns.flatMap((col, index) => {
const titleId = createId();
const descId = createId();
const title: Block = {
id: titleId,
type: "text",
props: {
text: `Feature ${index + 1} 제목`,
align: "left",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
sectionId,
columnId: col.id,
};
const description: Block = {
id: descId,
type: "text",
props: {
text: "해당 기능을 간단히 설명하는 텍스트입니다.",
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [title, description];
createId,
});
const lastBlockId = featureBlocks[featureBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...featureBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -295,72 +284,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
addBlogTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const postDefinitions = [
{ title: "블로그 포스트 1", summary: "첫 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 2", summary: "두 번째 포스트 요약을 여기에 입력하세요." },
{ title: "블로그 포스트 3", summary: "세 번째 포스트 요약을 여기에 입력하세요." },
];
const blogBlocks: Block[] = columns.flatMap((col, index) => {
const post = postDefinitions[index] ?? postDefinitions[0];
const titleId = createId();
const summaryId = createId();
const titleBlock: Block = {
id: titleId,
type: "text",
props: {
text: post.title,
align: "left",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
sectionId,
columnId: col.id,
};
const summaryBlock: Block = {
id: summaryId,
type: "text",
props: {
text: post.summary,
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [titleBlock, summaryBlock];
createId,
});
const lastBlockId = blogBlocks[blogBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...blogBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -368,85 +302,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
addTeamTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const memberDefinitions = [
{ name: "홍길동", role: "Product Designer", bio: "사용자 경험을 설계합니다." },
{ name: "김영희", role: "Frontend Engineer", bio: "깔끔한 UI를 구현합니다." },
{ name: "이철수", role: "Backend Engineer", bio: "안정적인 인프라를 책임집니다." },
];
const teamBlocks: Block[] = columns.flatMap((col, index) => {
const m = memberDefinitions[index] ?? memberDefinitions[0];
const nameId = createId();
const roleId = createId();
const bioId = createId();
const nameBlock: Block = {
id: nameId,
type: "text",
props: {
text: m.name,
align: "center",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
sectionId,
columnId: col.id,
};
const roleBlock: Block = {
id: roleId,
type: "text",
props: {
text: m.role,
align: "center",
size: "base",
},
sectionId,
columnId: col.id,
};
const bioBlock: Block = {
id: bioId,
type: "text",
props: {
text: m.bio,
align: "center",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [nameBlock, roleBlock, bioBlock];
createId,
});
const lastBlockId = teamBlocks[teamBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...teamBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -454,59 +320,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
addFooterTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const linksId = createId();
const copyrightId = createId();
const linksBlock: Block = {
id: linksId,
type: "text",
props: {
text: "이용약관 · 개인정보처리방침",
align: "center",
size: "sm",
},
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
const copyrightBlock: Block = {
id: copyrightId,
type: "text",
props: {
text: "© 2025 MyLanding. All rights reserved.",
align: "center",
size: "sm",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, linksBlock, copyrightBlock];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: copyrightId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -514,57 +338,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
addCtaTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "primary",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const textId = createId();
const textBlock: Block = {
id: textId,
type: "text",
props: {
text: "지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.",
align: "center",
size: "base",
},
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
const buttonId = createId();
const buttonBlock: Block = {
id: buttonId,
type: "button",
props: {
label: "CTA 버튼",
href: "#",
},
sectionId,
columnId: firstColumnId,
};
createId,
});
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, textBlock, buttonBlock];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: buttonId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -573,79 +357,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addFaqTemplateSection: () => {
const sectionId = createId();
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "md",
columns: [
{
id: `${sectionId}_col_1`,
span: 12,
},
],
},
sectionId: null,
columnId: null,
};
const firstColumnId = `${sectionId}_col_1`;
const faqPairs = [
{
question: "자주 묻는 질문 1",
answer: "첫 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 2",
answer: "두 번째 질문에 대한 답변을 여기에 입력하세요.",
},
{
question: "자주 묻는 질문 3",
answer: "세 번째 질문에 대한 답변을 여기에 입력하세요.",
},
];
const faqBlocks: Block[] = faqPairs.flatMap((pair) => {
const qId = createId();
const aId = createId();
const questionBlock: Block = {
id: qId,
type: "text",
props: {
text: pair.question,
align: "left",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
sectionId,
columnId: firstColumnId,
};
const answerBlock: Block = {
id: aId,
type: "text",
props: {
text: pair.answer,
align: "left",
size: "sm",
},
sectionId,
columnId: firstColumnId,
};
return [questionBlock, answerBlock];
createId,
});
const lastBlockId = faqBlocks[faqBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...faqBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -654,71 +376,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addPricingTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "muted",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const planDefinitions = [
{ name: "Basic", price: "₩9,900/월" },
{ name: "Pro", price: "₩29,900/월" },
{ name: "Enterprise", price: "문의" },
];
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
const plan = planDefinitions[index] ?? planDefinitions[0];
const nameId = createId();
const priceId = createId();
const nameBlock: Block = {
id: nameId,
type: "text",
props: {
text: plan.name,
align: "center",
size: "lg",
},
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
sectionId,
columnId: col.id,
};
const priceBlock: Block = {
id: priceId,
type: "text",
props: {
text: plan.price,
align: "center",
size: "base",
},
sectionId,
columnId: col.id,
};
return [nameBlock, priceBlock];
createId,
});
const lastBlockId = pricingBlocks[pricingBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...pricingBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -727,71 +395,17 @@ const createEditorState = (set: any, get: any): EditorState => ({
addTestimonialsTemplateSection: () => {
const sectionId = createId();
const columns = [
{ id: `${sectionId}_col_1`, span: 4 },
{ id: `${sectionId}_col_2`, span: 4 },
{ id: `${sectionId}_col_3`, span: 4 },
];
const sectionBlock: Block = {
id: sectionId,
type: "section",
props: {
background: "default",
paddingY: "lg",
columns,
},
sectionId: null,
columnId: null,
};
const testimonialDefinitions = [
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
];
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
const bodyId = createId();
const authorId = createId();
const bodyBlock: Block = {
id: bodyId,
type: "text",
props: {
text: t.body,
align: "left",
size: "base",
},
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
sectionId,
columnId: col.id,
};
const authorBlock: Block = {
id: authorId,
type: "text",
props: {
text: `- ${t.author}`,
align: "left",
size: "sm",
},
sectionId,
columnId: col.id,
};
return [bodyBlock, authorBlock];
createId,
});
const lastBlockId = testimonialBlocks[testimonialBlocks.length - 1].id;
set((state: EditorState) => {
const newBlocks = [...state.blocks, sectionBlock, ...testimonialBlocks];
const newBlocks = [...state.blocks, ...templateBlocks];
return {
blocks: newBlocks,
selectedBlockId: lastBlockId,
selectedBlockId: lastSelectedId,
};
});
},
@@ -967,6 +581,15 @@ const createEditorState = (set: any, get: any): EditorState => ({
props: {
label: "버튼",
href: "#",
align: "left",
size: "md",
variant: "solid",
colorPalette: "primary",
fullWidth: false,
borderRadius: "md",
fontSizeCustom: "14px",
fillColorCustom: "",
strokeColorCustom: "",
},
sectionId,
columnId,
@@ -985,7 +608,7 @@ const createEditorState = (set: any, get: any): EditorState => ({
block.id === id
? {
...block,
props: { ...block.props, ...partial },
props: { ...(block.props as any), ...(partial as any) },
}
: block,
),
+300
View File
@@ -0,0 +1,300 @@
/* Builder export/global design system classes */
/*
디자인 토큰: 폰트/라인하이트/색상/텍스트
- 에디터 프리뷰와 내보내기 HTML 모두 토큰과 pb-* 클래스를 공유한다.
*/
:root {
/* Font sizes (rem 단위, Tailwind 스케일과 유사) */
--pb-font-xs: 0.75rem; /* 12px */
--pb-font-sm: 0.875rem; /* 14px */
--pb-font-base: 1rem; /* 16px */
--pb-font-lg: 1.125rem; /* 18px */
--pb-font-xl: 1.25rem; /* 20px */
--pb-font-2xl: 1.5rem; /* 24px */
--pb-font-3xl: 1.875rem; /* 30px */
/* Line heights */
--pb-leading-tight: 1.25;
--pb-leading-snug: 1.35;
--pb-leading-normal: 1.5;
--pb-leading-relaxed: 1.7;
--pb-leading-loose: 1.9;
/* Text colors (팔레트) */
--pb-color-text-default: #e5e7eb; /* slate-200 정도 */
--pb-color-text-muted: #9ca3af; /* slate-400 */
--pb-color-text-strong: #f9fafb; /* slate-50 */
--pb-color-text-accent: #38bdf8; /* sky-400 */
--pb-color-text-danger: #f97373; /* red-400 근처 */
/* Text max width */
--pb-text-maxw-prose: 60ch;
--pb-text-maxw-narrow: 40ch;
}
/* 에디터/프리뷰 공통 기본 타이포 설정 (em/rem 기반) */
body {
font-size: 1rem; /* 기본 16px 기준 */
line-height: 1.5; /* 읽기 좋은 기본 라인하이트 */
letter-spacing: 0em; /* 기본 글자 간격은 0em */
}
/* Text alignment */
.pb-text-left {
text-align: left;
}
.pb-text-center {
text-align: center;
}
.pb-text-right {
text-align: right;
}
/* Text size scale (디자인 토큰 기반) */
.pb-text-xs {
font-size: var(--pb-font-xs);
}
.pb-text-sm {
font-size: var(--pb-font-sm);
}
.pb-text-base {
font-size: var(--pb-font-base);
}
.pb-text-lg {
font-size: var(--pb-font-lg);
}
.pb-text-xl {
font-size: var(--pb-font-xl);
}
.pb-text-2xl {
font-size: var(--pb-font-2xl);
}
.pb-text-3xl {
font-size: var(--pb-font-3xl);
}
/* Line-height scale */
.pb-leading-tight {
line-height: var(--pb-leading-tight);
}
.pb-leading-snug {
line-height: var(--pb-leading-snug);
}
.pb-leading-normal {
line-height: var(--pb-leading-normal);
}
.pb-leading-relaxed {
line-height: var(--pb-leading-relaxed);
}
.pb-leading-loose {
line-height: var(--pb-leading-loose);
}
/* Font weight scale */
.pb-font-normal {
font-weight: 400;
}
.pb-font-medium {
font-weight: 500;
}
.pb-font-semibold {
font-weight: 600;
}
.pb-font-bold {
font-weight: 700;
}
/* Text color palette */
.pb-text-color-default {
color: var(--pb-color-text-default);
}
.pb-text-color-muted {
color: var(--pb-color-text-muted);
}
.pb-text-color-strong {
color: var(--pb-color-text-strong);
}
.pb-text-color-accent {
color: var(--pb-color-text-accent);
}
.pb-text-color-danger {
color: var(--pb-color-text-danger);
}
/* Additional text colors for presets */
.pb-text-color-success {
color: #22c55e;
}
.pb-text-color-warning {
color: #eab308;
}
.pb-text-color-info {
color: #0ea5e9;
}
.pb-text-color-neutral {
color: #94a3b8;
}
/* Text decoration & style */
.pb-underline {
text-decoration-line: underline;
}
.pb-line-through {
text-decoration-line: line-through;
}
.pb-italic {
font-style: italic;
}
/* Text max width presets */
.pb-text-maxw-prose {
max-width: var(--pb-text-maxw-prose);
}
.pb-text-maxw-narrow {
max-width: var(--pb-text-maxw-narrow);
}
.pb-btn-base {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 500;
border-width: 1px;
}
.pb-btn-size-xs {
padding: 0.125rem 0.5rem;
}
.pb-btn-size-sm {
padding: 0.25rem 0.75rem;
}
.pb-btn-size-md {
padding: 0.375rem 1rem;
}
.pb-btn-size-lg {
padding: 0.5rem 1.25rem;
}
.pb-btn-size-xl {
padding: 0.75rem 1.5rem;
}
.pb-btn-radius-none {
border-radius: 0;
}
.pb-btn-radius-sm {
border-radius: 0.25rem;
}
.pb-btn-radius-md {
border-radius: 0.5rem;
}
.pb-btn-radius-lg {
border-radius: 0.75rem;
}
.pb-btn-radius-full {
border-radius: 9999px;
}
.pb-btn-variant-solid-primary {
background-color: #0ea5e9;
border-color: #0284c7;
color: #0b1120;
}
.pb-btn-variant-solid-muted {
background-color: #1f2937;
border-color: #4b5563;
color: #e5e7eb;
}
.pb-btn-variant-solid-danger {
background-color: #ef4444;
border-color: #b91c1c;
color: #f9fafb;
}
.pb-btn-variant-solid-success {
background-color: #22c55e;
border-color: #15803d;
color: #022c22;
}
.pb-btn-variant-solid-neutral {
background-color: #4b5563;
border-color: #374151;
color: #e5e7eb;
}
.pb-btn-variant-outline-primary {
background-color: transparent;
border-color: #38bdf8;
color: #e0f2fe;
}
.pb-btn-variant-outline-muted {
background-color: transparent;
border-color: #4b5563;
color: #e5e7eb;
}
.pb-btn-variant-outline-danger {
background-color: transparent;
border-color: #f97373;
color: #fecaca;
}
.pb-btn-variant-outline-success {
background-color: transparent;
border-color: #22c55e;
color: #bbf7d0;
}
.pb-btn-variant-outline-neutral {
background-color: transparent;
border-color: #64748b;
color: #e5e7eb;
}
.pb-btn-variant-ghost-primary {
background-color: transparent;
border-color: transparent;
color: #38bdf8;
}
.pb-btn-variant-ghost-muted {
background-color: transparent;
border-color: transparent;
color: #9ca3af;
}
.pb-btn-variant-ghost-danger {
background-color: transparent;
border-color: transparent;
color: #f97373;
}
.pb-btn-variant-ghost-success {
background-color: transparent;
border-color: transparent;
color: #4ade80;
}
.pb-btn-variant-ghost-neutral {
background-color: transparent;
border-color: transparent;
color: #cbd5f5;
}
/* Section background variants */
.pb-section-bg-default {
background-color: rgba(15, 23, 42, 0.6); /* slate-900/60 느낌 */
}
.pb-section-bg-muted {
background-color: rgba(15, 23, 42, 0.4); /* slate-950/40 느낌 */
}
.pb-section-bg-primary {
background-color: rgba(8, 47, 73, 0.4); /* sky-950/40 느낌 */
border-color: rgba(8, 47, 73, 0.6);
}
/* Section vertical padding scale */
.pb-section-py-sm {
padding-top: 1rem;
padding-bottom: 1rem;
}
.pb-section-py-md {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.pb-section-py-lg {
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}
+28
View File
@@ -137,6 +137,34 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
await expect(block).toHaveClass(/text-lg/);
});
test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경할 수 있어야 한다", async ({ page }) => {
test.skip(process.env.CI === "true");
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
// 더블클릭하여 인라인 편집 모드로 진입한다.
await block.dblclick({ force: true });
// 인라인 툴바 버튼을 이용해 가운데 정렬 및 큰 글자 크기로 변경한다.
const centerAlignButton = page.getByRole("button", { name: "가운데 정렬" });
const largeSizeButton = page.getByRole("button", { name: "글자 크게" });
await centerAlignButton.click();
await largeSizeButton.click();
// 편집을 종료하기 위해 블록 밖을 클릭한다.
await canvas.click({ position: { x: 5, y: 5 } });
// 캔버스 블록에 text-center 와 text-lg 클래스가 적용되어 있어야 한다.
await expect(block).toHaveClass(/text-center/);
await expect(block).toHaveClass(/text-lg/);
});
test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한 블록 상태가 복원되어야 한다", async ({ page }) => {
test.skip(process.env.CI === "true");
await page.goto("/editor");
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
import { ColorPickerField } from "@/features/editor/components/ColorPickerField";
// 슬라이더 필드가 라벨과 슬라이더, 입력 박스를 렌더링하고 onChange를 호출하는지 테스트
describe("PropertySliderField", () => {
it("라벨과 슬라이더, 텍스트 입력을 렌더링한다", () => {
const handleChange = vi.fn();
render(
<PropertySliderField
label="글자 크기"
ariaLabelSlider="글자 크기 슬라이더"
ariaLabelInput="글자 크기 커스텀"
value={16}
min={10}
max={72}
step={1}
onChange={handleChange}
/>,
);
expect(screen.getByText("글자 크기")).toBeInTheDocument();
expect(screen.getByLabelText("글자 크기 슬라이더")).toBeInTheDocument();
expect(screen.getByLabelText("글자 크기 커스텀")).toBeInTheDocument();
});
it("슬라이더 변경 시 onChange가 호출된다", () => {
const handleChange = vi.fn();
render(
<PropertySliderField
label="줄 간격"
ariaLabelSlider="줄 간격 슬라이더"
ariaLabelInput="줄 간격 커스텀"
value={1.5}
min={0.5}
max={3}
step={0.1}
onChange={handleChange}
/>,
);
const slider = screen.getByLabelText("줄 간격 슬라이더") as HTMLInputElement;
fireEvent.change(slider, { target: { value: "2" } });
expect(handleChange).toHaveBeenCalledWith(2);
});
});
// 컬러 피커 필드가 컬러 인풋과 텍스트 입력, 팔레트 버튼을 렌더링하고 상호작용 되는지 테스트
describe("ColorPickerField", () => {
it("HEX 인풋과 컬러 인풋을 렌더링한다", () => {
const handleChange = vi.fn();
render(
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="텍스트 색상 피커"
ariaLabelHexInput="텍스트 색상 HEX"
value="#ffffff"
onChange={handleChange}
palette={[{ id: "default", label: "기본", color: "#e5e7eb" }]}
/>,
);
expect(screen.getByText("텍스트 색상")).toBeInTheDocument();
expect(screen.getByLabelText("텍스트 색상 피커")).toBeInTheDocument();
expect(screen.getByLabelText("텍스트 색상 HEX")).toBeInTheDocument();
});
it("컬러 인풋 변경 시 onChange가 호출된다", () => {
const handleChange = vi.fn();
render(
<ColorPickerField
label="텍스트 색상"
ariaLabelColorInput="텍스트 색상 피커"
ariaLabelHexInput="텍스트 색상 HEX"
value="#ffffff"
onChange={handleChange}
palette={[{ id: "default", label: "기본", color: "#e5e7eb" }]}
/>,
);
const colorInput = screen.getByLabelText("텍스트 색상 피커") as HTMLInputElement;
fireEvent.change(colorInput, { target: { value: "#000000" } });
expect(handleChange).toHaveBeenCalledWith("#000000");
});
});
+21
View File
@@ -25,6 +25,27 @@ describe("editorStore", () => {
expect(selectedBlockId).toBe(blocks[0].id);
});
it("텍스트 블록에 글자 간격(letterSpacingCustom)을 em 단위로 설정할 수 있어야 한다", () => {
const store = createEditorStore();
store.getState().addTextBlock();
const { blocks } = store.getState();
const textBlock = blocks[0];
const textProps = textBlock.props as TextBlockProps;
expect(textProps.letterSpacingCustom).toBeUndefined();
store.getState().updateBlock(textBlock.id, {
letterSpacingCustom: "0.05em",
} as any);
const { blocks: updatedBlocks } = store.getState();
const updatedTextProps = updatedBlocks[0].props as TextBlockProps;
expect(updatedTextProps.letterSpacingCustom).toBe("0.05em");
});
it("구분선 블록을 추가하면 기본 align/thickness 와 함께 추가되고 선택되어야 한다", () => {
const store = createEditorStore();