리스트/에디터 스타일 정리 및 버그 수정
CI / test (push) Failing after 7m55s
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-11-30 16:52:27 +09:00
parent 17bfac62ed
commit 0cf67b5619
45 changed files with 1606 additions and 530 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
>
{blocks.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-slate-500 text-xs">
"텍스트 블록 추가" .
"텍스트" .
</div>
) : (
<DndContext
+61 -14
View File
@@ -3,6 +3,7 @@
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useEffect, useState } from "react";
import Link from "next/link";
import { FilePlus2, Trash2, Eye, Pencil, ListChecks, Undo2, Redo2 } from "lucide-react";
import {
DndContext,
PointerSensor,
@@ -132,6 +133,11 @@ export default function EditorPage() {
// 멀티 선택(MVP)을 위한 로컬 선택 상태: 단일 선택(selectedBlockId)과 병행 사용한다.
const [selectedBlockIds, setSelectedBlockIds] = useState<string[]>([]);
const historyLength = useEditorStore((state) => (state as any).history?.length ?? 0);
const futureLength = useEditorStore((state) => (state as any).future?.length ?? 0);
const canUndo = historyLength > 0;
const canRedo = futureLength > 0;
const [menuOpen, setMenuOpen] = useState(false);
const [activeModal, setActiveModal] = useState<"project" | "json" | null>(null);
@@ -738,31 +744,57 @@ export default function EditorPage() {
return (
<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>
<p className="text-xs text-slate-400"> MVP용 </p>
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20 bg-slate-950/80 backdrop-blur">
<div className="flex items-center gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-900 border border-sky-700 shadow-sm">
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
</span>
<div className="flex flex-col">
<h1 className="text-xl font-semibold">Page Editor</h1>
<p className="text-xs text-slate-400"> MVP용 </p>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<Link
href="/projects"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
<Link
href="/preview"
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
>
<Eye className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={undo}
disabled={!canUndo}
>
<Undo2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800 disabled:opacity-40 disabled:cursor-not-allowed"
onClick={redo}
disabled={!canRedo}
>
<Redo2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<div className="relative">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => setMenuOpen((prev) => !prev)}
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span></span>
<span className="text-[9px]"></span>
</button>
{menuOpen && (
@@ -775,7 +807,10 @@ export default function EditorPage() {
setActiveModal("project");
}}
>
/
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> /</span>
</span>
</button>
<button
type="button"
@@ -785,7 +820,10 @@ export default function EditorPage() {
setActiveModal("json");
}}
>
JSON /
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>JSON /</span>
</span>
</button>
<button
type="button"
@@ -795,7 +833,10 @@ export default function EditorPage() {
void handleDeleteProject();
}}
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
<button
type="button"
@@ -805,7 +846,10 @@ export default function EditorPage() {
void handleExportZip();
}}
>
(ZIP)
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> (ZIP)</span>
</span>
</button>
<button
type="button"
@@ -817,7 +861,10 @@ export default function EditorPage() {
}
}}
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</div>
)}
+463 -359
View File
@@ -1,7 +1,23 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { useEditorStore } from "@/features/editor/state/editorStore";
import {
FilePlus2,
ListChecks,
Type,
MousePointerClick,
Image as ImageIcon,
Video,
Minus,
List,
LayoutTemplate,
Pencil,
FileText,
ListFilter,
CircleDot,
SquareCheck,
} from "lucide-react";
// 좌측 블록/폼/템플릿 추가 사이드바를 분리한 컴포넌트
export function BlocksSidebar() {
@@ -69,377 +85,465 @@ export function BlocksSidebar() {
[addFooterTemplateSection],
);
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
const [isFormsOpen, setIsFormsOpen] = useState(true);
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
return (
<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"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddText}
>
</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={handleAddButton}
>
</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={handleAddImage}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddVideo}
>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddDivider}
>
</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={handleAddList}
>
</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={handleAddSection}
>
</button>
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll bg-slate-950/40">
<h2 className="font-medium flex items-center gap-2 text-slate-200">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isBlocksOpen}
onClick={() => setIsBlocksOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span></span>
</span>
</button>
</h2>
{isBlocksOpen && (
<>
<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={handleAddText}
>
<span className="inline-flex items-center gap-2">
<Type className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</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={handleAddButton}
>
<span className="inline-flex items-center gap-2">
<MousePointerClick className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</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={handleAddImage}
>
<span className="inline-flex items-center gap-2">
<ImageIcon className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
<button
type="button"
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
onClick={handleAddVideo}
>
<span className="inline-flex items-center gap-2">
<Video className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</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={handleAddDivider}
>
<span className="inline-flex items-center gap-2">
<Minus className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</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={handleAddList}
>
<span className="inline-flex items-center gap-2">
<List className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</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={handleAddSection}
>
<span className="inline-flex items-center gap-2">
<LayoutTemplate className="w-3 h-3" aria-hidden="true" />
<span></span>
</span>
</button>
</>
)}
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
<h3 className="text-[11px] font-medium text-slate-300"> </h3>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={handleAddFormBlock}
>
</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={handleAddFormInput}
>
</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={handleAddFormSelect}
>
</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={handleAddFormRadio}
>
</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={handleAddFormCheckbox}
>
</button>
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isFormsOpen}
onClick={() => setIsFormsOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<ListChecks className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</h3>
{isFormsOpen && (
<>
<button
type="button"
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
onClick={handleAddFormBlock}
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</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={handleAddFormInput}
>
<span className="inline-flex items-center gap-2">
<FileText className="w-3 h-3" aria-hidden="true" />
<span>(Input)</span>
</span>
</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={handleAddFormSelect}
>
<span className="inline-flex items-center gap-2">
<ListFilter className="w-3 h-3" aria-hidden="true" />
<span>(Select)</span>
</span>
</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={handleAddFormRadio}
>
<span className="inline-flex items-center gap-2">
<CircleDot className="w-3 h-3" aria-hidden="true" />
<span> (Radio)</span>
</span>
</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={handleAddFormCheckbox}
>
<span className="inline-flex items-center gap-2">
<SquareCheck className="w-3 h-3" aria-hidden="true" />
<span> (Checkbox)</span>
</span>
</button>
</>
)}
</div>
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
<h3 className="text-[11px] font-medium text-slate-300">릿</h3>
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
<button
type="button"
className="flex items-center justify-between w-full text-left"
aria-expanded={isTemplatesOpen}
onClick={() => setIsTemplatesOpen((prev) => !prev)}
>
<span className="inline-flex items-center gap-2">
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span>릿</span>
</span>
</button>
</h3>
<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 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">
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 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full items-center gap-[2px]">
<div className="flex-1 flex flex-col justify-center gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-2/3 bg-slate-700/50" />
{isTemplatesOpen && (
<div className="space-y-3">
<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 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
<div className="h-[2px] w-3/4 bg-slate-700/70" />
<div className="h-[2px] w-1/2 bg-slate-700/50" />
<div className="h-2 w-4 rounded-[1px] bg-slate-700/70 mt-[1px]" />
</div>
</div>
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
</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 rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex h-full w-full items-center gap-[2px]">
<div className="flex-1 flex flex-col justify-center gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-2/3 bg-slate-700/50" />
</div>
<div className="w-3 h-2 rounded-[1px] bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">(CTA) </p>
</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}
>
릿
</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 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</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 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="w-2 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col gap-[2px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</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}
>
릿
</button>
<div
data-testid="template-preview-pricing"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-sky-500/70" />
<div className="h-[1px] w-3/4 bg-sky-500/50" />
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</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}
>
릿
</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 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</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}
>
릿
</button>
<div
data-testid="template-preview-testimonials"
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</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-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</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 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 h-[2px] bg-slate-700/70" />
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</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 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
<div className="h-[2px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">3 </p>
</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 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="w-2 flex flex-col gap-[1px]">
<div className="h-[2px] w-full bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col gap-[2px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (FAQ) </p>
</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-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-sky-700/50 bg-sky-900/20 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-sky-500/70" />
<div className="h-[1px] w-3/4 bg-sky-500/50" />
<div className="h-[2px] w-3/4 bg-sky-500/70 mt-[1px] rounded-[1px]" />
</div>
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
<div className="h-[1px] w-1/2 bg-slate-700/70" />
<div className="h-[1px] w-3/4 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug">/ </p>
</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 p-[2px]"
>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</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 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-1/2 bg-slate-700/70" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> (Testimonials) </p>
</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-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
<div className="flex-1 flex flex-col items-center gap-[1px]">
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
<div className="h-[1px] w-full bg-slate-700/70" />
<div className="h-[1px] w-2/3 bg-slate-700/50" />
</div>
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</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 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
>
<div className="flex-1 h-[2px] bg-slate-700/70" />
<div className="flex-1 flex flex-col gap-[1px]">
<div className="h-[1px] w-full bg-slate-700/50" />
<div className="h-[1px] w-full bg-slate-700/50" />
</div>
<div className="flex-1 h-[1px] bg-slate-700/40" />
</div>
</div>
<p className="text-[10px] text-slate-400 leading-snug"> </p>
</div>
</div>
</div>
)}
</div>
</aside>
);
+673 -4
View File
@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import type { Block, ButtonBlockProps, FormBlockProps, ListBlockProps, SectionBlockProps, TextBlockProps, VideoBlockProps } from "@/features/editor/state/editorStore";
import { ButtonPropertiesPanel } from "./ButtonPropertiesPanel";
import { TextPropertiesPanel } from "./TextPropertiesPanel";
@@ -14,6 +15,18 @@ import { FormCheckboxPropertiesPanel } from "../forms/FormCheckboxPropertiesPane
import { FormRadioPropertiesPanel } from "../forms/FormRadioPropertiesPanel";
import { FormControllerPanel } from "../forms/FormControllerPanel";
import { ProjectPropertiesPanel } from "./ProjectPropertiesPanel";
import { Trash2, Copy, SlidersHorizontal, HelpCircle } from "lucide-react";
type BlockHelpProperty = {
label: string;
description: string;
};
type BlockHelpContent = {
title: string;
description: string;
properties?: BlockHelpProperty[];
};
interface PropertiesSidebarProps {
blocks: Block[];
@@ -48,16 +61,621 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
onOutdentSelectedItem,
} = props;
const [helpOpen, setHelpOpen] = useState(false);
const getHelpContentForSelectedBlock = (): BlockHelpContent | null => {
if (!selectedBlockId) return null;
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
switch (selectedBlock.type) {
case "text":
return {
title: "텍스트 블록 튜토리얼",
description:
"제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다.\n\n좌측 패널에서 텍스트를 추가한 뒤, 캔버스에서 텍스트를 더블클릭하거나 속성 패널에서 내용을 수정해 보세요. 정렬, 크기, 색상 등을 조절해 다양한 스타일의 텍스트를 만들 수 있습니다.",
properties: [
{
label: "선택한 텍스트 블록 내용",
description:
"이 블록에 실제로 표시될 문장을 입력하는 영역입니다. 캔버스에서 텍스트를 더블클릭했을 때와 동일한 내용이며, 여러 줄을 입력하면 줄바꿈이 그대로 반영됩니다.",
},
{
label: "정렬",
description:
"텍스트를 왼쪽/가운데/오른쪽 중 어디에 맞출지 결정합니다. 일반 본문은 왼쪽, Hero 제목이나 짧은 강조 문구는 가운데 정렬을 추천합니다.",
},
{
label: "텍스트 스타일 (밑줄/가운데줄/이탤릭)",
description:
"밑줄은 링크처럼 보이게 하거나 특정 단어를 강조할 때, 가운데줄은 할인 전 가격처럼 취소선을 표현할 때, 이탤릭은 인용구나 제품명 등 살짝 톤을 바꾸고 싶을 때 사용합니다.",
},
{
label: "글자 크기",
description:
"텍스트의 폰트 크기를 px 단위로 조절합니다. 프리셋(XS~3XL)으로 대략적인 크기를 고른 뒤, 슬라이더/숫자 입력으로 세밀하게 조정할 수 있습니다.",
},
{
label: "글자 간격",
description:
"문자 사이의 간격(자간)을 조절합니다. 본문은 보통 또는 약간 넓게, 대문자 위주의 짧은 타이틀은 살짝 넓게 설정하면 가독성과 디자인이 좋아집니다.",
},
{
label: "줄 간격",
description:
"행과 행 사이의 간격(행간)을 정합니다. 본문은 1.5~1.7 정도가 읽기 좋고, 아주 짧은 제목은 1.25 정도로 줄이면 한 덩어리로 단단해 보입니다.",
},
{
label: "굵기",
description:
"폰트 두께를 100~900 범위에서 조절합니다. 본문은 보통(400), 섹션 제목은 세미볼드(600), 강한 CTA 문구는 볼드(700) 정도를 추천합니다.",
},
{
label: "텍스트 색상",
description:
"글자 색을 팔레트 또는 HEX 코드로 지정합니다. 본문은 대비가 높은 짙은 색을, 강조 문구는 브랜드 메인 색을 사용하는 것이 좋습니다.",
},
{
label: "블록 배경색",
description:
"텍스트가 들어 있는 카드/박스 전체 배경색을 지정합니다. 공지사항, 강조 박스, 경고 문구 등을 만들 때 연한 배경색과 함께 사용하면 좋습니다.",
},
{
label: "최대 너비",
description:
"한 줄 텍스트가 지나치게 길어지지 않도록 최대 줄 길이를 제한합니다. `본문 폭(60ch)`는 일반 문단에, `좁게(40ch)`는 카드/배너 같은 짧은 문구에 적합합니다.",
},
],
};
case "button":
return {
title: "버튼 블록 튜토리얼",
description:
"폼 제출이나 링크 이동을 위한 CTA 버튼을 만들 때 사용하는 블록입니다. 라벨, 링크(URL), 정렬, 색상/크기를 조절해 원하는 액션 버튼을 구성해 보세요.",
properties: [
{
label: "버튼 텍스트",
description:
"버튼 위에 표시될 문구입니다. 예: '지금 시작하기', '문의 남기기'. 짧고 행동을 유도하는 문장을 사용하는 것이 좋습니다.",
},
{
label: "가로 패딩 (px)",
description:
"버튼 양 옆의 여백을 조절합니다. 값이 클수록 버튼이 가로로 넓어져 더 강조됩니다.",
},
{
label: "세로 패딩 (px)",
description:
"버튼 위·아래 여백을 조절합니다. 값이 클수록 버튼 높이가 높아져 더 눈에 띱니다.",
},
{
label: "버튼 링크",
description:
"버튼 클릭 시 이동할 URL 또는 앵커(#section)입니다. 외부 페이지, 페이지 내 섹션, 폼 제출 엔드포인트 등으로 연결할 수 있습니다.",
},
{
label: "정렬",
description:
"해당 버튼이 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다. 주요 CTA 버튼은 가운데 정렬이 많이 쓰입니다.",
},
{
label: "텍스트 색상",
description:
"버튼 라벨 글자 색입니다. 채움 색상과 충분한 대비가 나도록 설정해야 가독성이 좋습니다.",
},
{
label: "스타일",
description:
"버튼 외형 스타일입니다. '채움'은 꽉 찬 스타일, '외곽선'은 테두리만 있는 스타일, '고스트'는 배경 없이 텍스트/테두리만 있는 스타일입니다.",
},
{
label: "채움 색상",
description:
"채움 스타일에서 버튼 내부를 채우는 색입니다. 브랜드 메인 색이나 강조 색을 사용하면 좋습니다.",
},
{
label: "외곽선 색상",
description:
"외곽선/고스트 스타일에서 버튼 테두리 색입니다. 배경색과의 대비를 고려해 설정합니다.",
},
{
label: "모서리 둥글기",
description:
"버튼 모서리를 얼마나 둥글게 처리할지 결정합니다. '없음'은 각진 버튼, '완전 둥글게'는 알약 모양 버튼입니다.",
},
{
label: "버튼 너비 모드 / 버튼 고정 너비",
description:
"버튼이 컨테이너 너비에 맞춰 늘어날지(전체 폭), 내용 길이에 맞출지(자동), 고정 px 너비를 가질지 결정합니다.",
},
{
label: "버튼 크기",
description:
"버튼 라벨 텍스트의 폰트 크기입니다. 중요한 CTA 버튼은 주변 텍스트보다 조금 더 크게 설정하면 좋습니다.",
},
{
label: "줄 간격 / 글자 간격",
description:
"버튼 라벨의 행간과 자간을 조절합니다. 글자가 너무 답답해 보이면 자간을 약간 넓히고, 여러 줄 버튼 문구는 줄 간격을 조금 넓혀 주세요.",
},
],
};
case "image":
return {
title: "이미지 블록 튜토리얼",
description:
"배너, 썸네일, 설명 이미지를 배치할 때 사용하는 블록입니다. 외부 이미지 URL을 입력하거나 업로드 이미지를 선택하고, 너비/정렬/모서리 둥글기를 조절해 레이아웃에 맞게 배치해 보세요.",
properties: [
{
label: "이미지 소스",
description:
"이미지를 외부 URL에서 가져올지(URL), 빌더에 업로드한 에셋을 사용할지(파일 업로드) 선택합니다.",
},
{
label: "이미지 URL / 이미지 파일 업로드",
description:
"URL 모드에서는 외부 이미지 주소를 입력하고, 업로드 모드에서는 로컬 이미지를 업로드해 `/api/image/:id` 형식으로 관리합니다.",
},
{
label: "대체 텍스트",
description:
"이미지가 로드되지 않거나 스크린리더를 사용하는 사용자에게 보여질 설명 텍스트입니다. 이미지가 전달하는 의미를 한두 문장으로 작성해 주세요.",
},
{
label: "카드 배경색",
description:
"이미지를 감싸는 카드 영역의 배경색입니다. 투명한 PNG 아이콘이나 로고를 올릴 때 배경을 살짝 깔아주면 더 잘 보입니다.",
},
{
label: "정렬",
description:
"이미지가 포함된 영역 안에서 왼쪽/가운데/오른쪽 중 어디에 위치할지 결정합니다.",
},
{
label: "너비 모드 / 고정 너비 (px)",
description:
"이미지를 내용 크기에 맞출지(auto) 또는 px 단위로 고정된 너비를 사용할지 선택합니다. 썸네일 그리드에서는 고정 너비를 사용하는 편이 레이아웃이 안정적입니다.",
},
{
label: "모서리 둥글기",
description:
"이미지 카드 모서리 둥글기를 px 단위로 조절합니다. 아바타/아이콘은 완전 둥글게, 일반 사진은 살짝 둥글게 설정하면 자연스럽습니다.",
},
],
};
case "list":
return {
title: "리스트 블록 튜토리얼",
description:
"불릿/번호 리스트를 만들 때 사용하는 블록입니다. 아이템 텍스트를 수정하고, 정렬/불릿 스타일을 바꿔 체크리스트나 단계별 안내 등을 표현할 수 있습니다.",
properties: [
{
label: "리스트 아이템 (줄바꿈으로 구분)",
description:
"각 줄이 하나의 리스트 항목이 됩니다. 들여쓰기를 이용한 중첩 리스트는 TDD 기준의 변환 로직에 따라 자동으로 트리 구조로 변환됩니다.",
},
{
label: "정렬",
description:
"리스트 전체를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 체크리스트는 왼쪽 정렬, 특징 요약은 가운데 정렬이 자주 사용됩니다.",
},
{
label: "글자 크기 (px)",
description:
"리스트 텍스트의 폰트 크기를 조절합니다. 본문보다 약간 작게 설정하면 보조 정보 느낌을 줄 수 있습니다.",
},
{
label: "줄 간격",
description:
"리스트 항목 내부의 행간을 조절합니다. 항목 내용이 길어질수록 1.5~1.8 정도의 넉넉한 값을 추천합니다.",
},
{
label: "텍스트 색상",
description:
"리스트 텍스트의 색상입니다. 배경색과의 대비를 고려해 본문과 동일하거나 약간 연한 색상을 사용할 수 있습니다.",
},
{
label: "블록 배경색",
description:
"리스트 전체 카드의 배경색입니다. 단계별 안내나 체크리스트 박스를 강조하는 용도로 사용할 수 있습니다.",
},
{
label: "불릿 스타일",
description:
"불릿(●/○/■) 또는 번호(1., a., i.) 형식을 선택합니다. 숫자/알파벳/로마 숫자 스타일을 선택하면 자동으로 순서형 리스트(ordered)가 됩니다.",
},
{
label: "아이템 간 여백 (px)",
description:
"각 리스트 항목 사이의 세로 간격입니다. 설명이 긴 항목이 많다면 여백을 넉넉히 두어 가독성을 확보하세요.",
},
],
};
case "divider":
return {
title: "구분선 블록 튜토리얼",
description:
"섹션과 섹션 사이를 나누거나 콘텐츠를 시각적으로 구분할 때 사용하는 블록입니다. 정렬, 두께, 길이, 색상, 여백을 조절해 페이지 흐름을 정리해 보세요.",
properties: [
{
label: "정렬",
description:
"구분선을 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "두께",
description:
"선의 두께입니다. '얇게'는 가벼운 보조 구분선, '보통'은 섹션 경계를 확실히 나눌 때 사용합니다.",
},
{
label: "길이 모드 / 고정 길이 (px)",
description:
"구분선을 전체 폭으로 쓸지, 내용에 맞출지, 또는 px 단위의 고정 길이를 사용할지 선택합니다.",
},
{
label: "선 색상",
description:
"구분선 컬러입니다. 너무 진한 색보다는 섹션 배경보다 살짝 진한 정도의 중간 톤을 추천합니다.",
},
{
label: "위/아래 여백",
description:
"구분선 위·아래에 들어가는 공백입니다. 값이 클수록 섹션이 더 명확하게 나뉘어 보입니다.",
},
],
};
case "section":
return {
title: "섹션 블록 튜토리얼",
description:
"레이아웃의 큰 덩어리를 나누는 컨테이너 블록입니다. 배경색/배경 이미지/비디오, 위아래 패딩, 너비를 설정해 Hero, Features, Footer 같은 영역을 구성할 수 있습니다.",
properties: [
{
label: "배경 색상",
description:
"섹션 전체의 기본 배경색입니다. 페이지의 큰 분위기를 결정하는 요소이므로, 섹션 간 배경 대비를 적절히 주는 것이 좋습니다.",
},
{
label: "배경 이미지 소스 / URL / 파일 업로드",
description:
"섹션 배경으로 사용할 이미지를 외부 URL 또는 업로드 파일로 지정합니다. Hero 배경, 패턴, 질감 이미지를 설정할 때 사용합니다.",
},
{
label: "배경 이미지 크기 / 위치 / 반복",
description:
"cover/contain/auto로 크기를 조절하고, 프리셋 또는 X/Y 퍼센트로 위치를 조절하며, 반복 여부를 설정해 패턴 형태로 사용할 수 있습니다.",
},
{
label: "배경 비디오 소스 / URL / 파일 업로드",
description:
"섹션 배경에 비디오를 설정합니다. 시선을 끌어야 하는 Hero 영역 등에 사용하되, 가독성 저하를 막기 위해 텍스트 대비를 꼭 확인하세요.",
},
{
label: "세로 패딩",
description:
"섹션 위·아래 여백입니다. 값이 클수록 섹션이 여유 있고 강조되어 보입니다. Hero 섹션은 넉넉한 패딩을, 보조 섹션은 중간 정도를 추천합니다.",
},
],
};
case "video":
return {
title: "비디오 블록 튜토리얼",
description:
"YouTube/영상 URL 또는 직접 호스팅한 비디오를 삽입할 때 사용하는 블록입니다. 동영상 주소를 입력하고, 비율/정렬/재생 옵션을 조정해 페이지에 맞게 배치해 보세요.",
properties: [
{
label: "비디오 소스 / 비디오 URL / 비디오 파일 업로드",
description:
"외부 동영상 URL(YouTube, Vimeo 등)을 직접 입력하거나, 비디오 파일을 업로드해 `/api/video/:id` 형태로 사용할 수 있습니다.",
},
{
label: "포스터 이미지 URL",
description:
"동영상 재생 전/로딩 중에 보여줄 썸네일 이미지입니다. 영상의 핵심 장면이나 대표 이미지를 사용하면 클릭률을 높일 수 있습니다.",
},
{
label: "카드 배경색",
description:
"비디오가 들어가는 카드 영역의 배경색입니다. 주변 섹션과의 대비를 위해 살짝 어둡거나 밝은 색을 설정할 수 있습니다.",
},
{
label: "비디오 정렬",
description:
"비디오를 컨테이너 안에서 왼쪽/가운데/오른쪽 중 어디에 위치시킬지 결정합니다.",
},
{
label: "비디오 너비 모드 / 고정 너비 (px)",
description:
"비디오를 내용 너비에 맞출지, 가로 전체를 채울지, 특정 px 너비로 고정할지 선택합니다.",
},
{
label: "카드 패딩 / 카드 모서리 둥글기",
description:
"비디오 주변 카드의 안쪽 여백과 모서리 둥글기입니다. 카드형 레이아웃에서는 적당한 패딩과 둥근 모서리를 주면 디자인이 정돈됩니다.",
},
{
label: "시작 시점 (초) / 종료 시점 (초)",
description:
"영상의 특정 구간만 재생하고 싶을 때 사용하는 옵션입니다. 예를 들어 10초~30초만 재생하도록 설정할 수 있습니다.",
},
{
label: "화면 비율",
description:
"동영상 프레임 비율입니다. 대부분의 웹 영상은 16:9를 사용하며, 정사각형/세로형 콘텐츠는 1:1, 4:3 등을 사용할 수 있습니다.",
},
{
label: "자동 재생 / 반복 재생 / 음소거 / 재생 컨트롤 표시",
description:
"자동 재생/반복 재생/음소거/컨트롤 표시 여부를 설정합니다. 자동 재생 영상은 보통 음소거를 함께 켜 두는 것이 좋습니다.",
},
],
};
case "form":
return {
title: "폼 컨트롤러 블록 튜토리얼",
description:
"입력 필드(formInput/select/checkbox/radio)를 하나의 폼으로 묶는 컨트롤러입니다. 전송 대상, 전송 포맷, 너비, 여백 등을 설정해 네이티브 폼 제출을 구성하고, 필드 ID를 연결해 폼 구조를 완성할 수 있습니다.",
properties: [
{
label: "전송 대상 / Webhook 설정",
description:
"폼 데이터를 내부 처리로만 사용할지, 외부 Webhook/Google Sheets 등으로 전송할지 결정하고, 목적지 URL/전송 포맷/HTTP 메서드 등을 설정합니다.",
},
{
label: "추가 파라미터",
description:
"`key=value` 형식으로 줄바꿈해 작성하면, 모든 폼 제출에 공통으로 포함될 추가 파라미터를 지정할 수 있습니다. 예: `source=landing`.",
},
{
label: "폼 너비 모드 / 폼 고정 너비 (px)",
description:
"폼 전체의 가로 폭을 자동/전체 폭/고정 px 값 중에서 선택합니다. 문의 폼은 보통 480~640px 정도의 고정 폭이 읽기 좋습니다.",
},
{
label: "폼 위/아래 여백 (px)",
description:
"폼 블록 위·아래에 들어가는 공백입니다. 페이지 내 다른 섹션과의 간격을 조절할 때 사용합니다.",
},
{
label: "성공 메시지 / 에러 메시지",
description:
"폼 전송 성공 또는 실패 시 사용자에게 보여줄 텍스트입니다. 명확하고 친절한 문구로 작성하는 것이 좋습니다.",
},
{
label: "폼 필드 매핑",
description:
"폼 컨트롤러와 연결할 입력 필드(formInput/select/checkbox/radio)를 체크박스로 선택합니다. 체크된 필드만 실제 폼 제출 payload에 포함됩니다.",
},
{
label: "Submit 버튼",
description:
"폼 제출을 담당할 버튼 블록을 지정합니다. 별도 버튼 블록을 만들어 이 컨트롤러와 연결하면, 해당 버튼 클릭 시 폼이 제출됩니다.",
},
],
};
case "formInput":
return {
title: "폼 입력 블록 튜토리얼",
description:
"이름, 이메일 등 한 줄 텍스트 또는 긴 텍스트 입력 필드를 만들 때 사용하는 블록입니다. 레이블, 플레이스홀더, 필수 여부, 너비 등을 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"입력 필드 위나 옆에 표시될 설명입니다. 텍스트 또는 이미지로 라벨을 구성할 수 있으며, 사용자가 어떤 값을 입력해야 하는지 명확하게 알려줍니다.",
},
{
label: "라벨 이미지 URL / 대체 텍스트",
description:
"라벨을 이미지로 사용할 때 경로와 alt 텍스트를 지정합니다. 로고형 라벨이나 아이콘 기반 UI를 만들 때 활용할 수 있습니다.",
},
{
label: "전송 키",
description:
"이 입력값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "필드 타입",
description:
"텍스트/이메일/긴 텍스트 중 어떤 형태의 입력을 받을지 결정합니다. 이메일 타입은 브라우저 기본 이메일 검증을 활용할 수 있습니다.",
},
{
label: "Placeholder",
description:
"입력 전 필드 안에 흐릿하게 보여줄 안내 문구입니다. 예: '이메일을 입력해 주세요'.",
},
{
label: "필수 필드",
description:
"체크 시 이 필드는 반드시 채워야 합니다. 이름/이메일 같은 필수 값에는 활성화하고, 선택 항목에는 비활성화하는 것을 추천합니다.",
},
{
label: "텍스트 크기 / 줄간격 / 자간",
description:
"입력 값의 폰트 크기(px), 줄간격(px), 자간(px)을 조절해 폼 필드의 가독성과 분위기를 세밀하게 튜닝합니다.",
},
{
label: "텍스트 정렬",
description:
"필드 안 텍스트를 왼쪽/가운데/오른쪽 중 어디에 정렬할지 결정합니다. 일반 입력은 왼쪽 정렬을 권장합니다.",
},
{
label: "레이아웃 / 라벨/필드 간격",
description:
"라벨과 필드를 세로(stack) 또는 가로(inline)로 배치하고, inline 모드에서 라벨과 입력 사이 간격(px)을 조절합니다.",
},
{
label: "너비 / 필드 고정 너비",
description:
"필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다. 짧은 필드는 고정 값, 긴 입력은 전체 폭을 사용할 수 있습니다.",
},
{
label: "필드 가로/세로 패딩",
description:
"입력 상자 안쪽 여백을 조절합니다. 값이 클수록 필드가 커지고 누르기/입력하기 편해집니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"입력 텍스트, 배경, 테두리 색상을 각각 조절합니다. 에러 상태/강조 상태 등을 표현할 때 조합해서 사용할 수 있습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"입력 상자의 모서리를 얼마나 둥글게 할지 결정합니다. 페이지의 전체 디자인 톤과 맞춰 사용하세요.",
},
],
};
case "formSelect":
return {
title: "폼 셀렉트 블록 튜토리얼",
description:
"드롭다운 선택 필드를 만들 때 사용하는 블록입니다. 옵션 목록과 기본 선택값, 너비를 설정해 폼 컨트롤러와 함께 사용합니다.",
properties: [
{
label: "라벨 타입 / 필드 라벨",
description:
"셀렉트 필드의 제목 역할을 하는 라벨입니다. 텍스트 또는 이미지로 구성할 수 있으며, 선택해야 할 값의 의미를 설명합니다.",
},
{
label: "전송 키",
description:
"선택된 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다. 백엔드나 스프레드시트 컬럼명과 일치시키면 이후 처리가 편해집니다.",
},
{
label: "옵션",
description:
"사용자가 선택할 수 있는 옵션 목록입니다. 라벨과 value를 함께 설정하며, '옵션 추가' 버튼으로 새 옵션을 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 중요 선택 항목에는 필수로 설정하는 것을 권장합니다.",
},
{
label: "셀렉트 텍스트 크기 / 줄간격 / 자간",
description:
"드롭다운 필드의 텍스트 타이포그래피를 px 단위로 조절해 가독성과 분위기를 맞춥니다.",
},
{
label: "필드 너비 / 필드 고정 너비",
description:
"셀렉트 필드를 자동/전체 폭/고정 px 중 어떤 너비로 렌더링할지 결정합니다.",
},
{
label: "셀렉트 가로/세로 패딩",
description:
"드롭다운 안쪽 여백을 조절해 클릭 영역과 시각적 무게감을 조정합니다.",
},
{
label: "필드 텍스트/채움/테두리 색상",
description:
"선택 영역의 텍스트, 배경, 테두리 색상을 설정합니다. 폼 전체 톤과 잘 어울리는 색상을 사용하는 것이 좋습니다.",
},
{
label: "필드 모서리 둥글기",
description:
"셀렉트 박스의 모서리를 얼마나 둥글게 표시할지 결정합니다.",
},
],
};
case "formCheckbox":
return {
title: "폼 체크박스 블록 튜토리얼",
description:
"여러 항목 중 복수 선택이 필요한 경우 사용하는 체크박스 그룹 블록입니다. 그룹 타이틀과 옵션 라벨/이미지를 설정해 동의 항목이나 관심사 선택 등을 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"체크박스 그룹 전체의 제목입니다. 텍스트 또는 이미지로 표현할 수 있으며, 사용자가 어떤 범주를 선택하는지 알려줍니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드된 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"체크된 옵션들의 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 체크박스 옵션의 라벨/값/이미지를 설정합니다. URL 또는 업로드 이미지로 옵션별 아이콘을 붙일 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 하나 이상 옵션을 선택해야 합니다. 약관 동의 등 필수 동의 항목에 사용합니다.",
},
{
label: "체크박스 텍스트 크기 / 줄간격 / 자간",
description:
"체크박스 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 항목이 길어질수록 줄간격을 넉넉히 두는 것이 좋습니다.",
},
],
};
case "formRadio":
return {
title: "폼 라디오 블록 튜토리얼",
description:
"여러 옵션 중 하나만 선택해야 할 때 사용하는 라디오 버튼 그룹 블록입니다. 옵션 라벨/이미지와 기본 선택값을 설정해 요금제 선택 등 단일 선택 시나리오를 구성할 수 있습니다.",
properties: [
{
label: "그룹 타이틀 타입 / 그룹 타이틀",
description:
"라디오 그룹의 제목입니다. 요금제 이름, 질문 문구 등 단일 선택의 맥락을 설명합니다.",
},
{
label: "그룹 타이틀 이미지 소스 / URL / 파일 업로드",
description:
"그룹 라벨을 이미지로 사용할 때, URL 또는 업로드 이미지를 선택합니다.",
},
{
label: "전송 키",
description:
"선택된 하나의 옵션 값이 서버/웹훅에 전달될 때 사용되는 필드 이름입니다.",
},
{
label: "옵션 / 옵션 이미지 소스",
description:
"각 라디오 옵션의 라벨/값/이미지를 설정합니다. 옵션별 아이콘이나 썸네일을 붙여 더 풍부한 선택 UI를 만들 수 있습니다.",
},
{
label: "필수 필드",
description:
"체크 시 반드시 하나의 옵션을 선택해야 합니다. 요금제/유형 선택처럼 반드시 선택이 필요한 경우에 사용합니다.",
},
{
label: "라디오 텍스트 크기 / 줄간격 / 자간",
description:
"라디오 라벨의 폰트 크기, 줄간격, 자간을 조절합니다. 옵션 설명이 긴 경우 줄간격을 넉넉히 두세요.",
},
],
};
default:
return {
title: "블록 튜토리얼",
description: "이 블록에 대한 자세한 도움말은 추후 추가될 예정입니다.",
};
}
};
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 pb-scroll"
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll bg-slate-950/40"
onKeyDownCapture={(e) => {
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
e.stopPropagation();
}}
>
<h2 className="font-medium mb-2"> </h2>
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-200">
<SlidersHorizontal className="w-4 h-4 text-sky-400" aria-hidden="true" />
<span> </span>
</h2>
{selectedBlockId ? (
<div className="space-y-3">
<div className="flex gap-2 text-[11px]">
@@ -66,14 +684,30 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
onClick={() => removeBlock(selectedBlockId)}
>
<span className="inline-flex items-center gap-2">
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
<button
type="button"
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => duplicateBlock(selectedBlockId)}
>
<span className="inline-flex items-center gap-2">
<Copy className="w-3 h-3" aria-hidden="true" />
<span> </span>
</span>
</button>
</div>
<div className="flex justify-end text-[11px]">
<button
type="button"
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
onClick={() => setHelpOpen(true)}
>
<HelpCircle className="w-3 h-3" aria-hidden="true" />
<span>HELP</span>
</button>
</div>
{(() => {
@@ -224,6 +858,41 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
) : (
<ProjectPropertiesPanel />
)}
{helpOpen && (() => {
const help = getHelpContentForSelectedBlock();
if (!help) return null;
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">{help.title}</h3>
<button
type="button"
className="text-slate-400 hover:text-slate-100 text-xs"
onClick={() => setHelpOpen(false)}
>
</button>
</div>
<p className="text-[11px] text-slate-200 whitespace-pre-line">{help.description}</p>
{help.properties && help.properties.length > 0 && (
<div className="mt-3 border-t border-slate-700 pt-2 space-y-2">
<h4 className="text-[11px] font-semibold text-slate-200"> </h4>
<ul className="space-y-1">
{help.properties.map((prop) => (
<li key={prop.label}>
<div className="text-[11px] font-semibold text-slate-100">{prop.label}</div>
<div className="text-[11px] text-slate-300">{prop.description}</div>
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
})()}
</aside>
);
}
@@ -404,8 +404,11 @@ export function PublicPageRenderer({ blocks }: PublicPageRendererProps) {
: tokens.bulletStyle,
}}
>
{nodes.map((node) => (
<li key={node.id}>
{nodes.map((node, index) => (
<li
key={node.id}
style={index < nodes.length - 1 ? { marginBottom: `${tokens.gapEm}em` } : undefined}
>
{node.text}
{node.children && node.children.length > 0 && renderListNodes(node.children, level + 1)}
</li>
+62
View File
@@ -263,6 +263,27 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
fieldStyle.borderRadius = 9999;
}
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
const labelLayout = props.labelLayout ?? "stacked";
const isInline = labelLayout === "inline";
@@ -324,6 +345,27 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
fieldStyle.borderRadius = 9999;
}
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
return { fieldStyle };
};
@@ -355,6 +397,26 @@ export const computeFormOptionGroupEditorTokens = (
} else if (radius === "lg" || radius === "full") {
fieldStyle.borderRadius = 9999;
}
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
fieldStyle.paddingInline = `${props.paddingX}px`;
}
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
fieldStyle.paddingBlock = `${props.paddingY}px`;
}
if (props.fontSizeCustom && props.fontSizeCustom.trim() !== "") {
fieldStyle.fontSize = props.fontSizeCustom.trim();
}
if (props.lineHeightCustom && props.lineHeightCustom.trim() !== "") {
fieldStyle.lineHeight = props.lineHeightCustom.trim();
}
if (props.letterSpacingCustom && props.letterSpacingCustom.trim() !== "") {
fieldStyle.letterSpacing = props.letterSpacingCustom.trim();
}
return { fieldStyle };
};
+82 -64
View File
@@ -8,10 +8,10 @@ test("/editor 페이지가 존재하고 Page Editor 헤더를 보여줘야 한
await expect(page.getByRole("heading", { name: "Page Editor" })).toBeVisible();
});
test("텍스트 블록 추가 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
test("텍스트 버튼을 누르면 캔버스에 '새 텍스트' 블록이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -75,7 +75,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 1단계: 더블클릭 → Enter 로 커밋
// 기존 텍스트 블록을 더블클릭해서 편집 모드로 전환한다.
@@ -109,7 +109,7 @@ test("텍스트 블록을 더블클릭해서 내용을 수정할 수 있어야
await editor.fill("blur 로 커밋된 텍스트");
// 포커스를 다른 곳(예: 속성 패널의 버튼 추가 텍스트 입력)을 클릭하여 blur 를 발생시킨다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// blur 이후에는 마지막 입력 값이 캔버스에 반영되어야 한다.
canvasAfterEdit = page.getByTestId("editor-canvas");
@@ -120,7 +120,7 @@ test("속성 패널에서 선택된 텍스트 블록 내용을 수정하면 캔
await page.goto("/editor");
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 캔버스 블록을 클릭해서 선택한다.
const canvas = page.getByTestId("editor-canvas");
@@ -140,8 +140,8 @@ test("여러 텍스트 블록 중 선택을 전환하면 속성 패널 내용과
await page.goto("/editor");
// 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
const blocks = canvas.getByTestId("editor-block");
@@ -172,7 +172,7 @@ test("텍스트 블록의 정렬과 글자 크기를 속성 패널에서 변경
await page.goto("/editor");
// 텍스트 블록을 하나 추가하고 선택한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
await block.click({ force: true });
@@ -196,7 +196,7 @@ test("텍스트 블록 인라인 툴바에서 정렬과 글자 크기를 변경
await page.goto("/editor");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).click();
const canvas = page.getByTestId("editor-canvas");
const block = canvas.getByTestId("editor-block").nth(0);
@@ -226,8 +226,8 @@ test("에디터 상태를 JSON으로 내보내고 다시 불러오면 동일한
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 각각 다른 내용/스타일을 설정한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -290,8 +290,8 @@ test("각 캔버스 블록에 드래그 핸들 UI가 표시되고 선택 상태
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -318,8 +318,8 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
const canvas = page.getByTestId("editor-canvas");
// 블록 두 개를 추가하고 서로 다른 텍스트로 구분한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
const firstBlock = blocks.nth(0);
@@ -352,8 +352,8 @@ test("리스트 블록에도 드래그 핸들이 있고 선택/드래그가 가
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록과 리스트 블록을 하나씩 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
@@ -387,7 +387,7 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야
const canvas = page.getByTestId("editor-canvas");
// 버튼 블록을 추가한다.
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
// 캔버스에 기본 버튼이 렌더되어야 한다.
const button = canvas.getByRole("button", { name: "버튼" });
@@ -410,7 +410,7 @@ test("버튼 블록을 추가하면 속성 패널 기본 색상/패딩 값이
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const button = canvas.getByRole("button", { name: "버튼" });
await expect(button).toBeVisible();
@@ -437,7 +437,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const button = canvas.getByRole("button", { name: "버튼" }).first();
@@ -470,7 +470,7 @@ test("버튼 가로/세로 패딩 px 값을 변경하면 에디터 버튼에도
test("이미지 블록 고정 너비와 모서리 둥글기 스타일이 에디터에서 적용되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -504,7 +504,7 @@ test("에디터 메뉴에서 페이지 파일로 내보내기 (ZIP)를 클릭하
await page.goto("/editor");
// 최소 한 개의 블록을 추가해 내보낼 데이터가 있도록 한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 헤더 메뉴를 연다.
await page.getByRole("button", { name: "메뉴 ▼" }).click();
@@ -528,7 +528,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
const canvas = page.getByTestId("editor-canvas");
// 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
// 섹션을 선택한다 (캔버스 첫 블록).
const sectionBlock = canvas.getByTestId("editor-block").nth(0);
@@ -538,7 +538,7 @@ test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면
await layoutSelect.selectOption("2col");
// 왼쪽 컬럼에 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const leftColumn = canvas.locator('[data-section-id][data-column-id]').nth(0);
const rightColumn = canvas.locator('[data-section-id][data-column-id]').nth(1);
@@ -563,7 +563,7 @@ test("Hero 템플릿 버튼을 클릭하면 섹션과 기본 텍스트/버튼
const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션/블록이 하나 이상 존재해야 한다.
const blocks = canvas.getByTestId("editor-block");
@@ -582,7 +582,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
// Feature 제목들이 렌더되어야 한다.
await expect(canvas.getByText("Feature 1 제목")).toBeVisible();
@@ -595,7 +595,7 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
await page.getByRole("button", { name: "CTA 템플릿" }).click();
await expect(canvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" });
@@ -607,7 +607,7 @@ test("FAQ 템플릿 버튼을 클릭하면 질문/답변 텍스트가 세 쌍
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "FAQ 템플릿 추가" }).click();
await page.getByRole("button", { name: "FAQ 템플릿" }).click();
// 기본 FAQ 질문/답변 텍스트들이 렌더되어야 한다.
await expect(canvas.getByText("서비스 이용료는 얼마인가요?")).toBeVisible();
@@ -621,7 +621,7 @@ test("Pricing 템플릿 버튼을 클릭하면 3개의 요금제 카드가 생
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
await page.getByRole("button", { name: "상품 템플릿" }).click();
await expect(canvas.getByText("Basic")).toBeVisible();
await expect(canvas.getByText("₩9,900/월")).toBeVisible();
@@ -636,7 +636,7 @@ test("Testimonials 템플릿 버튼을 클릭하면 3개의 후기 카드가 생
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
await page.getByRole("button", { name: "후기 템플릿" }).click();
await expect(canvas.getByText("이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.")).toBeVisible();
await expect(canvas.getByText("디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.")).toBeVisible();
@@ -648,7 +648,7 @@ test("Blog 템플릿 버튼을 클릭하면 3개의 포스트 카드(제목/요
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Blog 템플릿 추가" }).click();
await page.getByRole("button", { name: "블로그 템플릿" }).click();
await expect(canvas.getByText("블로그 포스트 1")).toBeVisible();
await expect(canvas.getByText("첫 번째 포스트 요약을 여기에 입력하세요.")).toBeVisible();
@@ -661,7 +661,7 @@ test("Team 템플릿 버튼을 클릭하면 3명의 팀 카드가 생성되어
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Team 템플릿 추가" }).click();
await page.getByRole("button", { name: "Team 템플릿" }).click();
await expect(canvas.getByText("홍길동")).toBeVisible();
await expect(canvas.getByText("Product Designer")).toBeVisible();
@@ -675,7 +675,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
const canvas = page.getByTestId("editor-canvas");
// Hero 템플릿 섹션을 추가한다.
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 섹션 블록을 선택한다 (캔버스의 첫 번째 블록이 섹션이다).
const sectionBlock = canvas.getByTestId("editor-block").first();
@@ -685,7 +685,7 @@ test("템플릿 섹션을 삭제한 뒤 텍스트 블록을 추가하면 캔버
await page.getByRole("button", { name: "블록 삭제" }).click();
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 새 텍스트 블록이 캔버스에 보여야 한다.
await expect(canvas.getByText("새 텍스트")).toBeVisible();
@@ -696,7 +696,7 @@ test("Footer 템플릿 버튼을 클릭하면 링크/카피라이트 텍스트
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "Footer 템플릿 추가" }).click();
await page.getByRole("button", { name: "Footer 템플릿" }).click();
await expect(canvas.getByText("서비스 소개")).toBeVisible();
await expect(canvas.getByText("고객지원")).toBeVisible();
@@ -710,7 +710,7 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
// Cmd/Ctrl + Z 로 Undo 한다.
@@ -728,6 +728,24 @@ test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
});
test("헤더의 실행 취소/다시 실행 버튼으로 블록 추가를 Undo/Redo 할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "텍스트" }).first().click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
const undoButton = page.getByRole("button", { name: "실행 취소" });
const redoButton = page.getByRole("button", { name: "다시 실행" });
await undoButton.click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(0);
await redoButton.click();
await expect(canvas.getByTestId("editor-block")).toHaveCount(1);
});
test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => {
test.skip(true, "ArrowUp/Down 기반 선택 이동 E2E는 불안정하여 임시 스킵");
await page.goto("/editor");
@@ -735,9 +753,9 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 세 개 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3);
@@ -771,8 +789,8 @@ test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록 두 개를 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
await page.getByRole("button", { name: "텍스트" }).first().click();
const blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
@@ -793,7 +811,7 @@ test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다
const canvas = page.getByTestId("editor-canvas");
// 텍스트 블록을 하나 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
let blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(1);
@@ -821,7 +839,7 @@ test("구분선 블록을 추가하면 캔버스에 구분선이 렌더되고
const canvas = page.getByTestId("editor-canvas");
// 구분선 블록을 추가한다.
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const blocks = canvas.getByTestId("editor-block");
const dividerBlock = blocks.nth(0);
@@ -845,7 +863,7 @@ test("리스트 블록을 추가하고 첫 번째 아이템과 번호 매기기/
const canvas = page.getByTestId("editor-canvas");
// 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block");
const listBlock = blocks.nth(0);
@@ -873,7 +891,7 @@ test("리스트 아이템을 선택하면 패널에서 아이템 위/아래/들
const canvas = page.getByTestId("editor-canvas");
// 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const blocks = canvas.getByTestId("editor-block");
const listBlock = blocks.nth(0);
@@ -912,23 +930,23 @@ test("폼 요소 사이드바에서 폼 입력/셀렉트/라디오/체크박스
await expect(page.getByRole("heading", { name: "폼 요소" })).toBeVisible();
// 폼 입력 블록 추가 버튼을 클릭하면 formInput 블록이 캔버스에 생성되어야 한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
let blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(1);
// 폼 셀렉트 블록 추가 버튼을 클릭하면 formSelect 블록이 추가되어야 한다.
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(2);
// 폼 라디오 블록 추가 버튼을 클릭하면 formRadio 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(3);
// 폼 체크박스 블록 추가 버튼을 클릭하면 formCheckbox 블록이 추가되어야 한다.
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block");
await expect(blocks).toHaveCount(4);
});
@@ -939,12 +957,12 @@ test("폼 블록을 선택하면 우측 속성 패널에서 폼 필드/버튼
const canvas = page.getByTestId("editor-canvas");
// 폼 요소와 버튼 블록을 몇 개 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
await page.getByRole("button", { name: "버튼" }).click();
// 폼 블록을 추가한다.
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
const blocks = canvas.getByTestId("editor-block");
const formBlock = blocks.nth((await blocks.count()) - 1);
@@ -984,7 +1002,7 @@ test("폼 입력 블록의 라벨/전송 키/필수 여부를 우측 패널에
const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0);
@@ -1013,7 +1031,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
const canvas = page.getByTestId("editor-canvas");
// 폼 셀렉트 블록
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
let blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth((await blocks.count()) - 1);
await selectBlock.click({ force: true });
@@ -1031,7 +1049,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
await requiredCheckbox.check();
// 폼 라디오 블록
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
blocks = canvas.getByTestId("editor-block");
const radioBlock = blocks.nth((await blocks.count()) - 1);
await radioBlock.click({ force: true });
@@ -1047,7 +1065,7 @@ test("폼 셀렉트/라디오/체크박스 블록도 우측 패널에서 기본
// 라디오 그룹 타이틀 텍스트 필드는 그대로 편집 가능해야 한다.
// 폼 체크박스 블록
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
blocks = canvas.getByTestId("editor-block");
const checkboxBlock = blocks.nth((await blocks.count()) - 1);
await checkboxBlock.click({ force: true });
@@ -1069,7 +1087,7 @@ test("폼 입력 블록을 추가하면 에디터 캔버스에 라벨과 입력
const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0);
@@ -1088,7 +1106,7 @@ test("폼 셀렉트 블록을 추가하면 에디터 캔버스에 라벨과 셀
const canvas = page.getByTestId("editor-canvas");
// 폼 셀렉트 블록을 하나 추가한다.
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth(0);
@@ -1107,7 +1125,7 @@ test("폼 라디오 블록을 추가하면 에디터 캔버스에 그룹 라벨
const canvas = page.getByTestId("editor-canvas");
// 폼 라디오 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const blocks = canvas.getByTestId("editor-block");
const radioBlock = blocks.nth(0);
@@ -1126,7 +1144,7 @@ test("폼 체크박스 블록을 추가하면 에디터 캔버스에 체크박
const canvas = page.getByTestId("editor-canvas");
// 폼 체크박스 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const blocks = canvas.getByTestId("editor-block");
const checkboxBlock = blocks.nth(0);
@@ -1144,7 +1162,7 @@ test("폼 입력 필드의 타입과 Placeholder 를 우측 패널에서 변경
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0);
@@ -1167,7 +1185,7 @@ test("폼 셀렉트 블록의 옵션 목록을 우측 패널에서 수정하면
const canvas = page.getByTestId("editor-canvas");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const blocks = canvas.getByTestId("editor-block");
const selectBlock = blocks.nth(0);
@@ -1208,7 +1226,7 @@ test("폼 입력 블록의 스타일(텍스트/배경/모서리)을 우측 패
const canvas = page.getByTestId("editor-canvas");
// 폼 입력 블록을 하나 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const blocks = canvas.getByTestId("editor-block");
const inputBlock = blocks.nth(0);
+67 -67
View File
@@ -10,15 +10,15 @@ test("/preview 페이지는 에디터 크롬 없이 컨텐츠만 보여야 한
await expect(page.getByRole("heading", { name: "Page Preview" })).toBeVisible();
// 에디터 좌측 패널용 버튼들은 나타나지 않아야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿 추가" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Hero 템플릿" })).toHaveCount(0);
});
test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 Hero 콘텐츠가 보여야 한다", async ({ page }) => {
// 에디터에서 Hero 템플릿을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
// 헤드라인과 CTA 버튼이 에디터에서 생성되었는지 한 번 확인한다.
const editorCanvas = page.getByTestId("editor-canvas");
@@ -36,8 +36,8 @@ test("에디터에서 Hero 템플릿을 추가하면 프리뷰에서 동일한 H
// 프리뷰에서는 CTA를 실제 링크(<a>)로 렌더링하므로 텍스트 기준으로만 검증한다.
await expect(page.getByText("지금 시작하기")).toBeVisible();
// 프리뷰 화면에는 에디터용 "텍스트 블록 추가" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트 블록 추가" })).toHaveCount(0);
// 프리뷰 화면에는 에디터용 "텍스트" 버튼이 없어야 한다.
await expect(page.getByRole("button", { name: "텍스트" })).toHaveCount(0);
});
test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야 한다", async ({ page }) => {
@@ -58,7 +58,7 @@ test("프리뷰 페이지에서 에디터로 돌아가는 링크가 있어야
test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의 Feature 섹션이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("Feature 1 제목")).toBeVisible();
@@ -77,7 +77,7 @@ test("에디터에서 Features 템플릿을 추가하면 프리뷰에서 3개의
test("에디터에서 CTA 템플릿을 추가하면 프리뷰에서 CTA 텍스트와 버튼이 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "CTA 템플릿 추가" }).click();
await page.getByRole("button", { name: "CTA 템플릿" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
await expect(editorCanvas.getByText("지금 바로 행동을 유도하는 CTA 텍스트를 입력하세요.")).toBeVisible();
@@ -95,10 +95,10 @@ test("모바일 뷰에서도 프리뷰 페이지가 깨지지 않고 주요 템
// 에디터에서 여러 템플릿을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "Hero 템플릿 추가" }).click();
await page.getByRole("button", { name: "Features 템플릿 추가" }).click();
await page.getByRole("button", { name: "Pricing 템플릿 추가" }).click();
await page.getByRole("button", { name: "Testimonials 템플릿 추가" }).click();
await page.getByRole("button", { name: "Hero 템플릿" }).click();
await page.getByRole("button", { name: "기능 템플릿" }).click();
await page.getByRole("button", { name: "상품 템플릿" }).click();
await page.getByRole("button", { name: "후기 템플릿" }).click();
// 모바일 뷰포트로 전환한다.
await page.setViewportSize({ width: 390, height: 844 });
@@ -127,7 +127,7 @@ test("텍스트 블록의 정렬/폰트 크기/색상이 프리뷰에도 반영
await page.goto("/editor");
// 텍스트 블록을 추가한다.
await page.getByRole("button", { name: "텍스트 블록 추가" }).click();
await page.getByRole("button", { name: "텍스트" }).first().click();
// 기본 텍스트 대신 프리뷰에서 쉽게 찾을 수 있도록 내용도 함께 수정한다.
const textTextarea = page.getByLabel("선택한 텍스트 블록 내용");
@@ -175,8 +175,8 @@ test("구분선/리스트 블록도 프리뷰에서 렌더링되어야 한다",
// 에디터에서 구분선과 리스트 블록을 추가한다.
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
await page.getByRole("button", { name: "리스트" }).click();
// 에디터 캔버스에서 리스트 항목 일부가 보이는지 확인해 sanity check 를 한다.
const editorCanvas = page.getByTestId("editor-canvas");
@@ -195,7 +195,7 @@ test("리스트 블록 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor");
// 리스트 블록을 추가한다.
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
// 테스트를 위해 리스트 아이템을 2개 이상으로 만든다 (첫 번째 li 에 margin-bottom 이 적용되도록).
const itemsTextarea = page.getByLabel("리스트 아이템들");
@@ -271,8 +271,8 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
await page.goto("/editor");
// 폼 입력/셀렉트 블록을 각각 하나씩 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const editorCanvas = page.getByTestId("editor-canvas");
@@ -293,7 +293,7 @@ test("폼 입력/셀렉트 블록도 FormBlock 없이 프리뷰에서 보여야
test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -323,7 +323,7 @@ test("폼 체크박스 그룹 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -356,7 +356,7 @@ test("폼 라디오 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -390,7 +390,7 @@ test("폼 체크박스 옵션 라벨 이미지 업로드 후 에디터/프리뷰
test("폼 라디오 그룹 라벨 이미지 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -423,7 +423,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
await page.goto("/editor");
// 폼 입력 블록을 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
// 우측 속성 패널에서 폼 입력 스타일을 설정한다.
// 라벨/필드 텍스트 등 기본 값은 그대로 두고, 스타일 속성만 눈에 띄게 변경한다.
@@ -483,7 +483,7 @@ test("폼 입력 필드 스타일 속성이 프리뷰에도 반영되어야 한
test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -512,7 +512,7 @@ test("폼 입력 고정 너비가 프리뷰에서도 em 스케일로 반영되
test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -554,7 +554,7 @@ test("버튼 타이포 px 입력이 프리뷰에서는 em 스케일로 렌더링
test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "버튼" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -603,7 +603,7 @@ test("버튼 고정 너비와 패딩 px 입력이 프리뷰에서도 em 스케
test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -634,7 +634,7 @@ test("이미지 고정 너비가 프리뷰에서도 em 스케일로 반영되어
test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모두 /api/image/:id 기반 이미지가 보여야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "이미지 블록 추가" }).click();
await page.getByRole("button", { name: "이미지" }).click();
const canvas = page.getByTestId("editor-canvas");
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -671,7 +671,7 @@ test("이미지 블록에서 로컬 파일 업로드 후 에디터/프리뷰 모
test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -718,7 +718,7 @@ test("구분선 고정 길이와 여백 px 입력이 프리뷰에서도 em 스
test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -780,7 +780,7 @@ test("섹션 padding/maxWidth/gap px 입력이 프리뷰에서도 em 스케일
test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -809,7 +809,7 @@ test("구분선 고정 길이 px 입력이 프리뷰에서도 em 스케일로
test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -837,7 +837,7 @@ test("구분선 상하 여백 px 입력이 프리뷰에서도 em 스케일로
test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -866,7 +866,7 @@ test("리스트 아이템 간 여백 px 입력이 프리뷰에서도 em 스케
test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "리스트 블록 추가" }).click();
await page.getByRole("button", { name: "리스트" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -894,7 +894,7 @@ test("리스트 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -922,7 +922,7 @@ test("폼 입력 줄간격 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -950,7 +950,7 @@ test("폼 입력 자간 px 입력이 프리뷰에서도 em 스케일로 반영
test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -978,7 +978,7 @@ test("폼 입력 가로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const paddingSidebar = page.getByTestId("properties-sidebar");
@@ -1006,7 +1006,7 @@ test("폼 입력 세로 패딩 px 입력이 프리뷰에서도 em 스케일로
test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1035,7 +1035,7 @@ test("폼 입력 인라인 라벨 간격 px 입력이 프리뷰에서도 em 스
test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1065,7 +1065,7 @@ test("폼 체크박스 옵션 간격 px 입력이 프리뷰에서도 em 스케
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (고정 너비 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 폼 블록이 실제로 선택되어 우측 속성 패널에 FormControllerPanel 이 노출되도록 캔버스에서 한 번 더 명시적으로 선택한다.
const editorCanvas = page.getByTestId("editor-canvas");
@@ -1087,7 +1087,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지 않아야 한다 (상하 여백 설정 시)", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 앞선 테스트들과의 상호 영향으로 selectedBlockId 가 어긋나는 경우를 방지하기 위해,
// 방금 추가한 폼 블록을 캔버스에서 다시 선택해 속성 패널을 확실히 FormBlock 기준으로 맞춘다.
@@ -1109,7 +1109,7 @@ test("FormBlock 은 프리뷰에서 별도 폼 컨트롤러 DOM 을 렌더하지
test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1137,7 +1137,7 @@ test("폼 라디오 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1165,7 +1165,7 @@ test("폼 라디오 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1193,7 +1193,7 @@ test("폼 체크박스 가로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1221,7 +1221,7 @@ test("폼 체크박스 세로 패딩 px 입력이 프리뷰에서도 em 스케
test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1249,7 +1249,7 @@ test("폼 셀렉트 가로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1277,7 +1277,7 @@ test("폼 셀렉트 세로 패딩 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1305,7 +1305,7 @@ test("폼 셀렉트 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1333,7 +1333,7 @@ test("폼 셀렉트 줄간격 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1361,7 +1361,7 @@ test("폼 셀렉트 자간 px 입력이 프리뷰에서도 em 스케일로 반
test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1389,7 +1389,7 @@ test("폼 체크박스 글자 크기 px 입력이 프리뷰에서도 em 스케
test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1417,7 +1417,7 @@ test("폼 라디오 글자 크기 px 입력이 프리뷰에서도 em 스케일
test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1445,7 +1445,7 @@ test("폼 입력 글자 크기 px 입력이 프리뷰에서도 em 스케일로
test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "셀렉트 추가" }).click();
await page.getByRole("button", { name: "셀렉트(Select)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1474,7 +1474,7 @@ test("폼 셀렉트 고정 너비가 프리뷰에서도 em 스케일로 반영
test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 라디오 추가" }).click();
await page.getByRole("button", { name: "단일 선택(Radio)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1503,7 +1503,7 @@ test("폼 라디오 그룹 고정 너비가 프리뷰에서도 em 스케일로
test("폼 체크박스 그룹 고정 너비가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "폼 체크박스 추가" }).click();
await page.getByRole("button", { name: "다중 선택(Checkbox)" }).click();
const propertiesSidebar = page.getByTestId("properties-sidebar");
@@ -1536,9 +1536,9 @@ test("폼 컨트롤러에 매핑한 폼 요소와 버튼이 프리뷰에서 함
const canvas = page.getByTestId("editor-canvas");
// 폼 입력 요소와 버튼, 폼 블록을 순서대로 추가한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
// 방금 추가된 폼 블록을 선택한다.
const blocks = canvas.getByTestId("editor-block");
@@ -1585,9 +1585,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
const blocks = canvas.getByTestId("editor-block");
// Form A: 입력 필드 + 버튼 + 폼 블록을 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼" }).click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
let count = await blocks.count();
const formBlockA = blocks.nth(count - 1);
@@ -1603,9 +1603,9 @@ test("여러 폼 블록과 여러 버튼이 있을 때 각 폼이 독립적으
await submitSelect.selectOption({ index: 1 });
// Form B: 입력 필드 + 버튼 + 폼 블록을 다시 추가한 뒤, 즉시 컨트롤러를 매핑한다.
await page.getByRole("button", { name: "폼 입력 추가" }).click();
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
await page.getByRole("button", { name: "폼 블록 추가" }).click();
await page.getByRole("button", { name: "입력(Input)" }).click();
await page.getByRole("button", { name: "버튼" }).first().click();
await page.getByRole("button", { name: "폼 컨트롤러" }).click();
count = await blocks.count();
const formBlockB = blocks.nth(count - 1);
@@ -1647,7 +1647,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
await page.goto("/editor");
// 섹션 블록을 직접 추가한다.
await page.getByRole("button", { name: "섹션 블록 추가" }).click();
await page.getByRole("button", { name: "섹션" }).click();
// 캔버스에서 첫 번째 섹션 블록을 선택한다.
const canvas = page.getByTestId("editor-canvas");
@@ -1705,7 +1705,7 @@ test("섹션 패딩/컬럼 간격 수치가 프리뷰에서도 em 스케일에
test("구분선 여백/길이 수치가 프리뷰에서도 em 스케일로 반영되어야 한다", async ({ page }) => {
await page.goto("/editor");
await page.getByRole("button", { name: "구분선 블록 추가" }).click();
await page.getByRole("button", { name: "구분선" }).click();
const canvas = page.getByTestId("editor-canvas");
const dividerBlock = canvas.getByTestId("editor-block").first();
+65 -19
View File
@@ -52,28 +52,25 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
Object.values(templateActions).forEach((fn) => fn.mockReset());
});
it("Hero 템플릿 추가 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
it("Hero 템플릿 버튼 클릭 시 addHeroTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Hero 템플릿 추가" });
const button = screen.getByRole("button", { name: "Hero 템플릿" });
fireEvent.click(button);
expect(templateActions.addHeroTemplateSection).toHaveBeenCalledTimes(1);
});
it("Features 템플릿 추가 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
it("Features 템플릿 버튼 클릭 시 addFeaturesTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "Features 템플릿 추가" });
const button = screen.getByRole("button", { name: "기능 템플릿" });
fireEvent.click(button);
expect(templateActions.addFeaturesTemplateSection).toHaveBeenCalledTimes(1);
});
it("CTA 템플릿 추가 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
it("CTA 템플릿 버튼 클릭 시 addCtaTemplateSection 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "CTA 템플릿 추가" });
const button = screen.getByRole("button", { name: "CTA 템플릿" });
fireEvent.click(button);
expect(templateActions.addCtaTemplateSection).toHaveBeenCalledTimes(1);
@@ -81,13 +78,12 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
it("FAQ/Pricing/Testimonials/Blog/Team/Footer 템플릿 버튼도 각각의 액션을 호출해야 한다", () => {
render(<BlocksSidebar />);
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Pricing 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Testimonials 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Blog 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿 추가" }));
fireEvent.click(screen.getByRole("button", { name: "FAQ 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "상품 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "후기 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "블로그 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Team 템플릿" }));
fireEvent.click(screen.getByRole("button", { name: "Footer 템플릿" }));
expect(templateActions.addFaqTemplateSection).toHaveBeenCalledTimes(1);
expect(templateActions.addPricingTemplateSection).toHaveBeenCalledTimes(1);
@@ -111,10 +107,9 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(screen.getByText("페이지 푸터 섹션"));
});
it("비디오 블록 추가 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
it("비디오 버튼 클릭 시 addVideoBlock 이 호출되어야 한다", () => {
render(<BlocksSidebar />);
const button = screen.getByRole("button", { name: "비디오 블록 추가" });
const button = screen.getByRole("button", { name: "비디오" });
fireEvent.click(button);
expect(otherActions.addVideoBlock).toHaveBeenCalledTimes(1);
@@ -165,4 +160,55 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
expect(pricingThumb.children.length).toBe(3);
expect(teamThumb.children.length).toBe(3);
});
it("블록 섹션 타이틀을 클릭하면 블록 버튼들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 텍스트 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
const blockToggle = screen.getByRole("button", { name: "블록" });
fireEvent.click(blockToggle);
// 접힌 상태에서는 텍스트 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "텍스트" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(blockToggle);
expect(screen.getByRole("button", { name: "텍스트" })).toBeDefined();
});
it("폼 요소 섹션 타이틀을 클릭하면 폼 관련 버튼들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 폼 입력 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
const formToggle = screen.getByRole("button", { name: "폼 요소" });
fireEvent.click(formToggle);
// 접힌 상태에서는 폼 입력 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "입력(Input)" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(formToggle);
expect(screen.getByRole("button", { name: "입력(Input)" })).toBeDefined();
});
it("템플릿 섹션 타이틀을 클릭하면 템플릿 카드들을 접고 펼 수 있어야 한다", () => {
render(<BlocksSidebar />);
// 초기에는 Hero 템플릿 버튼이 보여야 한다.
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
const templateToggle = screen.getByRole("button", { name: "템플릿" });
fireEvent.click(templateToggle);
// 접힌 상태에서는 Hero 템플릿 버튼이 보이지 않아야 한다.
expect(screen.queryByRole("button", { name: "Hero 템플릿" })).toBeNull();
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(templateToggle);
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
});
});
+2
View File
@@ -6,6 +6,8 @@ import EditorPage from "@/app/editor/page";
let mockState: any;
beforeEach(() => {
window.localStorage.clear();
const baseProjectConfig: ProjectConfig = {
title: "멀티 선택 테스트",
slug: "multi-select",
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { Block } from "@/features/editor/state/editorStore";
import { PropertiesSidebar } from "@/app/editor/panels/PropertiesSidebar";
describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
afterEach(() => {
cleanup();
});
const createTextBlock = (): Block => ({
id: "text-1",
type: "text",
props: {
text: "도움말 테스트",
align: "left",
size: "base",
} as any,
sectionId: null,
columnId: null,
});
const defaultProps = (blocks: Block[], selectedBlockId: string | null) => ({
blocks,
selectedBlockId,
selectedListItemId: null,
updateBlock: vi.fn(),
removeBlock: vi.fn(),
duplicateBlock: vi.fn(),
editingBlockId: null,
setEditingText: vi.fn(),
onMoveSelectedItemUp: vi.fn(),
onMoveSelectedItemDown: vi.fn(),
onIndentSelectedItem: vi.fn(),
onOutdentSelectedItem: vi.fn(),
});
it("텍스트 블록이 선택된 상태에서 HELP 버튼을 노출해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
expect(helpButton).toBeDefined();
});
it("HELP 버튼 클릭 시 현재 블록 타입에 맞는 튜토리얼 모달이 열려야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
expect(screen.getByText("텍스트 블록 튜토리얼")).toBeDefined();
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
});
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
const closeButton = screen.getByRole("button", { name: "닫기" });
fireEvent.click(closeButton);
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
});
});
+20
View File
@@ -221,6 +221,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed",
widthPx: 260,
borderRadius: "lg",
paddingX: 12,
paddingY: 6,
fontSizeCustom: "14px",
lineHeightCustom: "20px",
letterSpacingCustom: "1px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -228,6 +233,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("260px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("12px");
expect(tokens.fieldStyle.paddingBlock).toBe("6px");
expect(tokens.fieldStyle.fontSize).toBe("14px");
expect(tokens.fieldStyle.lineHeight).toBe("20px");
expect(tokens.fieldStyle.letterSpacing).toBe("1px");
});
it("computeFormOptionGroupEditorTokens: 에디터 라디오/체크박스 그룹 필드 스타일을 계산해야 한다", () => {
@@ -239,6 +249,11 @@ describe("formHelpers - editor tokens", () => {
widthMode: "fixed",
widthPx: 280,
borderRadius: "lg",
paddingX: 8,
paddingY: 4,
fontSizeCustom: "15px",
lineHeightCustom: "22px",
letterSpacingCustom: "0.5px",
} as any);
expect(tokens.fieldStyle.color).toBe("#ff0000");
@@ -246,6 +261,11 @@ describe("formHelpers - editor tokens", () => {
expect(tokens.fieldStyle.borderColor).toBe("#654321");
expect(tokens.fieldStyle.width).toBe("280px");
expect(tokens.fieldStyle.borderRadius).toBe(9999);
expect(tokens.fieldStyle.paddingInline).toBe("8px");
expect(tokens.fieldStyle.paddingBlock).toBe("4px");
expect(tokens.fieldStyle.fontSize).toBe("15px");
expect(tokens.fieldStyle.lineHeight).toBe("22px");
expect(tokens.fieldStyle.letterSpacing).toBe("0.5px");
});
});
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
dummy-video
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
@@ -0,0 +1 @@
PNG TEST FIXTURE
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
@@ -0,0 +1 @@
dummy-section-video
@@ -0,0 +1 @@
dummy-section-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-poster
+1
View File
@@ -0,0 +1 @@
dummy-poster