텍스트/버튼/이미지/섹션 블록 타입 확장 및 에디터 UI 연동 #1

Merged
jaybe merged 1 commits from feature/builder-1-block-types into feature/editor-mvp 2025-11-17 21:33:58 +00:00
4 changed files with 474 additions and 98 deletions
+260 -20
View File
@@ -17,12 +17,21 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { useEditorStore } from "@/features/editor/state/editorStore"; import { useEditorStore } from "@/features/editor/state/editorStore";
import type { Block } from "@/features/editor/state/editorStore"; import type {
Block,
TextBlockProps,
ButtonBlockProps,
ImageBlockProps,
SectionBlockProps,
} from "@/features/editor/state/editorStore";
export default function EditorPage() { export default function EditorPage() {
const blocks = useEditorStore((state) => state.blocks); const blocks = useEditorStore((state) => state.blocks);
const selectedBlockId = useEditorStore((state) => state.selectedBlockId); const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
const addTextBlock = useEditorStore((state) => state.addTextBlock); const addTextBlock = useEditorStore((state) => state.addTextBlock);
const addButtonBlock = useEditorStore((state) => state.addButtonBlock);
const addImageBlock = useEditorStore((state) => state.addImageBlock);
const addSectionBlock = useEditorStore((state) => state.addSectionBlock);
const updateBlock = useEditorStore((state) => state.updateBlock); const updateBlock = useEditorStore((state) => state.updateBlock);
const selectBlock = useEditorStore((state) => state.selectBlock); const selectBlock = useEditorStore((state) => state.selectBlock);
const replaceBlocks = useEditorStore((state) => state.replaceBlocks); const replaceBlocks = useEditorStore((state) => state.replaceBlocks);
@@ -166,6 +175,27 @@ export default function EditorPage() {
> >
</button> </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={addButtonBlock}
>
</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={addImageBlock}
>
</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={addSectionBlock}
>
</button>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
Text / Image / Button / Section . Text / Image / Button / Section .
</p> </p>
@@ -214,18 +244,24 @@ export default function EditorPage() {
<h2 className="font-medium mb-2"> </h2> <h2 className="font-medium mb-2"> </h2>
{selectedBlockId ? ( {selectedBlockId ? (
<div className="space-y-3"> <div className="space-y-3">
{(() => {
const selectedBlock = blocks.find((b) => b.id === selectedBlockId);
if (!selectedBlock) return null;
if (selectedBlock.type === "text") {
const textProps = selectedBlock.props as TextBlockProps;
return (
<>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-xs text-slate-400"> </p> <p className="text-xs text-slate-400"> </p>
<textarea <textarea
className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500" className="w-full min-h-[80px] rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="선택한 텍스트 블록 내용" aria-label="선택한 텍스트 블록 내용"
value={ value={textProps.text}
blocks.find((b) => b.id === selectedBlockId)?.props.text ?? ""
}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
updateBlock(selectedBlockId, { text: value }); updateBlock(selectedBlockId, { text: value });
// 인라인 편집과의 상태 차이를 막기 위해, 현재 인라인 편집 중인 블록이라면 로컬 상태도 동기화한다.
if (editingBlockId === selectedBlockId) { if (editingBlockId === selectedBlockId) {
setEditingText(value); setEditingText(value);
} }
@@ -239,9 +275,7 @@ export default function EditorPage() {
<select <select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500" className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="정렬" aria-label="정렬"
value={ value={textProps.align}
blocks.find((b) => b.id === selectedBlockId)?.props.align ?? "left"
}
onChange={(e) => { onChange={(e) => {
const value = e.target.value as "left" | "center" | "right"; const value = e.target.value as "left" | "center" | "right";
updateBlock(selectedBlockId, { align: value }); updateBlock(selectedBlockId, { align: value });
@@ -260,9 +294,7 @@ export default function EditorPage() {
<select <select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500" className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="글자 크기" aria-label="글자 크기"
value={ value={textProps.size}
blocks.find((b) => b.id === selectedBlockId)?.props.size ?? "base"
}
onChange={(e) => { onChange={(e) => {
const value = e.target.value as "sm" | "base" | "lg"; const value = e.target.value as "sm" | "base" | "lg";
updateBlock(selectedBlockId, { size: value }); updateBlock(selectedBlockId, { size: value });
@@ -274,6 +306,127 @@ export default function EditorPage() {
</select> </select>
</label> </label>
</div> </div>
</>
);
}
if (selectedBlock.type === "image") {
const imageProps = selectedBlock.props as ImageBlockProps;
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> URL</span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="이미지 URL"
value={imageProps.src}
onChange={(e) => {
updateBlock(selectedBlockId, { src: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="대체 텍스트"
value={imageProps.alt}
onChange={(e) => {
updateBlock(selectedBlockId, { alt: e.target.value } as any);
}}
/>
</label>
</div>
</>
);
}
if (selectedBlock.type === "section") {
const sectionProps = selectedBlock.props as SectionBlockProps;
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span></span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="섹션 배경색"
value={sectionProps.background}
onChange={(e) => {
const value = e.target.value as SectionBlockProps["background"];
updateBlock(selectedBlockId, { background: value } as any);
}}
>
<option value="default"></option>
<option value="muted">Muted</option>
<option value="primary">Primary</option>
</select>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<select
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="섹션 세로 패딩"
value={sectionProps.paddingY}
onChange={(e) => {
const value = e.target.value as SectionBlockProps["paddingY"];
updateBlock(selectedBlockId, { paddingY: value } as any);
}}
>
<option value="sm"></option>
<option value="md"></option>
<option value="lg"></option>
</select>
</label>
</div>
</>
);
}
if (selectedBlock.type === "button") {
const buttonProps = selectedBlock.props as ButtonBlockProps;
return (
<>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 텍스트"
value={buttonProps.label}
onChange={(e) => {
updateBlock(selectedBlockId, { label: e.target.value } as any);
}}
/>
</label>
</div>
<div className="space-y-1">
<label className="flex flex-col gap-1 text-xs text-slate-400">
<span> </span>
<input
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
aria-label="버튼 링크"
value={buttonProps.href}
onChange={(e) => {
updateBlock(selectedBlockId, { href: e.target.value } as any);
}}
/>
</label>
</div>
</>
);
}
return null;
})()}
</div> </div>
) : ( ) : (
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
@@ -416,19 +569,36 @@ function SortableEditorBlock({
transition, transition,
}; };
const alignClass = let alignClass = "";
block.props.align === "center" let sizeClass = "";
if (block.type === "text") {
const textProps = block.props as TextBlockProps;
alignClass =
textProps.align === "center"
? "text-center" ? "text-center"
: block.props.align === "right" : textProps.align === "right"
? "text-right" ? "text-right"
: "text-left"; : "text-left";
const sizeClass = sizeClass =
block.props.size === "sm" textProps.size === "sm"
? "text-sm" ? "text-sm"
: block.props.size === "lg" : textProps.size === "lg"
? "text-lg" ? "text-lg"
: "text-base"; : "text-base";
} else if (block.type === "button") {
// 버튼 블록은 기본 텍스트 스타일만 적용
alignClass = "text-left";
sizeClass = "text-base";
} else if (block.type === "image") {
alignClass = "text-left";
sizeClass = "text-base";
} else {
// 섹션 블록: 텍스트 크기 대신 섹션 자체에 패딩/배경을 입히므로 텍스트 클래스는 기본값만
alignClass = "text-left";
sizeClass = "text-base";
}
return ( return (
<div <div
@@ -441,7 +611,12 @@ function SortableEditorBlock({
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900" isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
}`} }`}
onClick={() => selectBlock(block.id)} onClick={() => selectBlock(block.id)}
onDoubleClick={() => startEditing(block.id, block.props.text)} onDoubleClick={() => {
if (block.type === "text") {
const textProps = block.props as TextBlockProps;
startEditing(block.id, textProps.text);
}
}}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<button <button
@@ -454,7 +629,8 @@ function SortableEditorBlock({
</button> </button>
<div className="flex-1"> <div className="flex-1">
{isEditing ? ( {block.type === "text" && (
isEditing ? (
<textarea <textarea
className="w-full bg-transparent outline-none resize-none text-sm" className="w-full bg-transparent outline-none resize-none text-sm"
aria-label="텍스트 블록 내용 편집" aria-label="텍스트 블록 내용 편집"
@@ -471,8 +647,72 @@ function SortableEditorBlock({
autoFocus autoFocus
/> />
) : ( ) : (
<div>{block.props.text}</div> <div>{(block.props as TextBlockProps).text}</div>
)
)} )}
{block.type === "button" && (() => {
const buttonProps = block.props as ButtonBlockProps;
return (
<button
type="button"
className="inline-flex items-center justify-center rounded border border-slate-600 bg-slate-800 px-3 py-1 text-xs font-medium text-slate-50 hover:bg-slate-700"
aria-label={buttonProps.label}
onClick={(e) => {
// 에디터 내 버튼 클릭은 선택만 유지하고 실제 이동은 막는다.
e.stopPropagation();
}}
>
{buttonProps.label}
</button>
);
})()}
{block.type === "image" && (() => {
const imageProps = block.props as ImageBlockProps;
const hasSrc = imageProps.src.trim().length > 0;
return (
<div className="w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center justify-center overflow-hidden">
{hasSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageProps.src}
alt={imageProps.alt}
className="max-w-full h-auto object-contain"
/>
) : (
<span className="text-[10px] text-slate-500 px-2 py-4">
URL을 .
</span>
)}
</div>
);
})()}
{block.type === "section" && (() => {
const sectionProps = block.props as SectionBlockProps;
const bgClass =
sectionProps.background === "muted"
? "bg-slate-950/40"
: sectionProps.background === "primary"
? "bg-sky-950/40 border-sky-900/60"
: "bg-slate-900/60";
const pyClass =
sectionProps.paddingY === "sm"
? "py-4"
: sectionProps.paddingY === "lg"
? "py-10"
: "py-6";
return (
<div
className={`w-full ${bgClass} ${pyClass} rounded border border-dashed border-slate-700 flex items-center justify-center`}
>
<span className="text-[11px] text-slate-400 px-2 text-center">
. ( / .)
</span>
</div>
);
})()}
</div> </div>
</div> </div>
</div> </div>
+81 -5
View File
@@ -1,8 +1,8 @@
import { createStore } from "zustand"; import { createStore } from "zustand";
import { create } from "zustand"; import { create } from "zustand";
// 블록 타입 정의: 1단계에서는 텍스트 블록만 사용 // 블록 타입 정의: 텍스트/버튼/이미지/섹션 블록
export type BlockType = "text"; // 이후 image/button/section 으로 확장 예정 export type BlockType = "text" | "button" | "image" | "section";
// 텍스트 블록 속성 // 텍스트 블록 속성
export interface TextBlockProps { export interface TextBlockProps {
@@ -13,11 +13,30 @@ export interface TextBlockProps {
size: "sm" | "base" | "lg"; size: "sm" | "base" | "lg";
} }
// 버튼 블록 속성
export interface ButtonBlockProps {
label: string;
href: string;
// TODO: variant, size 등은 추후 확장
}
// 이미지 블록 속성
export interface ImageBlockProps {
src: string;
alt: string;
}
// 섹션 블록 속성
export interface SectionBlockProps {
background: "default" | "muted" | "primary";
paddingY: "sm" | "md" | "lg";
}
// 공통 블록 모델 // 공통 블록 모델
export interface Block { export interface Block {
id: string; id: string;
type: BlockType; type: BlockType;
props: TextBlockProps; props: TextBlockProps | ButtonBlockProps | ImageBlockProps | SectionBlockProps;
} }
// 에디터 상태 인터페이스 // 에디터 상태 인터페이스
@@ -25,7 +44,10 @@ export interface EditorState {
blocks: Block[]; blocks: Block[];
selectedBlockId: string | null; selectedBlockId: string | null;
addTextBlock: () => void; addTextBlock: () => void;
updateBlock: (id: string, partial: Partial<TextBlockProps>) => void; addButtonBlock: () => void;
addImageBlock: () => void;
addSectionBlock: () => void;
updateBlock: (id: string, partial: Partial<TextBlockProps & ButtonBlockProps>) => void;
selectBlock: (id: string | null) => void; selectBlock: (id: string | null) => void;
replaceBlocks: (blocks: Block[]) => void; replaceBlocks: (blocks: Block[]) => void;
reorderBlocks: (activeId: string, overId: string) => void; reorderBlocks: (activeId: string, overId: string) => void;
@@ -60,7 +82,61 @@ const createEditorState = (set: any, get: any): EditorState => ({
})); }));
}, },
// 특정 블록의 텍스트 속성을 부분 업데이트 // 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다
addImageBlock: () => {
const id = createId();
const newBlock: Block = {
id,
type: "image",
props: {
src: "",
alt: "이미지 설명",
},
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 섹션 블록 추가: 배경/패딩 기본값과 함께 생성 후 선택 상태로 만든다
addSectionBlock: () => {
const id = createId();
const newBlock: Block = {
id,
type: "section",
props: {
background: "default",
paddingY: "md",
},
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
addButtonBlock: () => {
const id = createId();
const newBlock: Block = {
id,
type: "button",
props: {
label: "버튼",
href: "#",
},
};
set((state: EditorState) => ({
blocks: [...state.blocks, newBlock],
selectedBlockId: id,
}));
},
// 특정 블록의 속성을 부분 업데이트 (텍스트/버튼 공통 사용)
updateBlock: (id, partial) => { updateBlock: (id, partial) => {
set((state: EditorState) => ({ set((state: EditorState) => ({
blocks: state.blocks.map((block: Block) => blocks: state.blocks.map((block: Block) =>
+24
View File
@@ -229,3 +229,27 @@ test("블록 드래그앤드롭으로 순서를 변경할 수 있어야 한다",
await expect(reorderedBlocks.nth(0)).toContainText("블록 B"); await expect(reorderedBlocks.nth(0)).toContainText("블록 B");
await expect(reorderedBlocks.nth(1)).toContainText("블록 A"); await expect(reorderedBlocks.nth(1)).toContainText("블록 A");
}); });
test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 한다", async ({ page }) => {
await page.goto("/editor");
const canvas = page.getByTestId("editor-canvas");
// 버튼 블록을 추가한다.
await page.getByRole("button", { name: "버튼 블록 추가" }).click();
// 캔버스에 기본 버튼이 렌더되어야 한다.
const button = canvas.getByRole("button", { name: "버튼" });
await expect(button).toBeVisible();
// 속성 패널에서 버튼 텍스트와 링크를 수정한다.
const labelInput = page.getByRole("textbox", { name: "버튼 텍스트" });
const hrefInput = page.getByRole("textbox", { name: "버튼 링크" });
await labelInput.fill("자세히 보기");
await hrefInput.fill("https://example.com");
// 캔버스의 버튼 텍스트가 변경되어야 한다.
const updatedButton = canvas.getByRole("button", { name: "자세히 보기" });
await expect(updatedButton).toBeVisible();
});
+39 -3
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { createEditorStore } from "@/features/editor/state/editorStore"; import {
createEditorStore,
type TextBlockProps,
type ButtonBlockProps,
} from "@/features/editor/state/editorStore";
// 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가 // 에디터 상태 스토어에 대한 첫 TDD: 텍스트 블록 추가
@@ -13,7 +17,8 @@ describe("editorStore", () => {
expect(blocks).toHaveLength(1); expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("text"); expect(blocks[0].type).toBe("text");
expect(blocks[0].props.text).toBe("새 텍스트"); const textProps = blocks[0].props as TextBlockProps;
expect(textProps.text).toBe("새 텍스트");
expect(selectedBlockId).toBe(blocks[0].id); expect(selectedBlockId).toBe(blocks[0].id);
}); });
@@ -39,6 +44,37 @@ describe("editorStore", () => {
blocks = store.getState().blocks; blocks = store.getState().blocks;
expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]); expect(blocks.map((b) => b.id)).toEqual([bId, aId, cId]);
expect(blocks.map((b) => b.props.text)).toEqual(["블록 B", "블록 A", "블록 C"]); expect(blocks.map((b) => (b.props as TextBlockProps).text)).toEqual([
"블록 B",
"블록 A",
"블록 C",
]);
});
it("버튼 블록을 추가하고 라벨/링크를 업데이트할 수 있어야 한다", () => {
const store = createEditorStore();
// 텍스트 블록 외에 버튼 블록을 추가하는 액션을 호출한다고 가정한다.
store.getState().addButtonBlock();
const { blocks, selectedBlockId } = store.getState();
expect(blocks).toHaveLength(1);
expect(blocks[0].type).toBe("button");
const buttonProps = blocks[0].props as ButtonBlockProps;
expect(buttonProps.label).toBe("버튼");
expect(buttonProps.href).toBe("#");
// 버튼 라벨과 링크를 업데이트한다.
store.getState().updateBlock(blocks[0].id, {
label: "자세히 보기",
href: "https://example.com",
} as any);
const { blocks: updatedBlocks } = store.getState();
const updatedButtonProps = updatedBlocks[0].props as ButtonBlockProps;
expect(updatedButtonProps.label).toBe("자세히 보기");
expect(updatedButtonProps.href).toBe("https://example.com");
expect(selectedBlockId).toBe(updatedBlocks[0].id);
}); });
}); });