diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 1b0c8a6..e14fd44 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useState } from "react"; import { DndContext, @@ -9,6 +9,7 @@ import { useSensor, useSensors, DragEndEvent, + useDroppable, } from "@dnd-kit/core"; import { SortableContext, @@ -36,6 +37,7 @@ export default function EditorPage() { const selectBlock = useEditorStore((state) => state.selectBlock); const replaceBlocks = useEditorStore((state) => state.replaceBlocks); const reorderBlocks = useEditorStore((state) => state.reorderBlocks); + const moveBlock = useEditorStore((state) => state.moveBlock); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); @@ -156,9 +158,64 @@ export default function EditorPage() { const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; - reorderBlocks(String(active.id), String(over.id)); + const activeId = String(active.id); + const overId = String(over.id); + + // 컬럼 droppable에 드롭한 경우: 해당 섹션/컬럼으로 이동만 수행한다. + if (overId.startsWith("column:")) { + const [, sectionId, columnId] = overId.split(":"); + moveBlock(activeId, sectionId || null, columnId || null); + return; + } + + const activeBlock = blocks.find((b) => b.id === activeId); + const overBlock = blocks.find((b) => b.id === overId); + + if (!activeBlock || !overBlock) { + reorderBlocks(activeId, overId); + return; + } + + const activeSectionId = activeBlock.sectionId ?? null; + const activeColumnId = activeBlock.columnId ?? null; + const overSectionId = overBlock.sectionId ?? null; + const overColumnId = overBlock.columnId ?? null; + + // 같은 섹션/컬럼 내에서는 순서만 변경한다. + if (activeSectionId === overSectionId && activeColumnId === overColumnId) { + reorderBlocks(activeId, overId); + return; + } + + // 다른 컬럼/섹션으로 이동: 위치를 변경한 뒤, 블록 기준 드롭이면 순서 재배치까지 진행한다. + moveBlock(activeId, overSectionId, overColumnId); + reorderBlocks(activeId, overId); }; + const rootBlocks = blocks.filter((block) => !block.sectionId); + + const renderBlocks = (targetBlocks: Block[]) => + targetBlocks.map((block) => { + const isSelected = block.id === selectedBlockId; + const isEditing = block.id === editingBlockId; + + return ( + + ); + }); + return (
@@ -218,24 +275,7 @@ export default function EditorPage() { items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy} > - {blocks.map((block) => { - const isSelected = block.id === selectedBlockId; - const isEditing = block.id === editingBlockId; - - return ( - - ); - })} + {renderBlocks(rootBlocks)} )} @@ -350,6 +390,55 @@ export default function EditorPage() { return ( <> +
+ +
); } + +interface ColumnDroppableProps { + id: string; + sectionId: string; + columnId: string; + children: ReactNode; +} + +function ColumnDroppable({ id, sectionId, columnId, children }: ColumnDroppableProps) { + const { setNodeRef } = useDroppable({ id }); + + return ( +
+ {children} +
+ ); +} diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index 0e01409..5bca76d 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -30,6 +30,11 @@ export interface ImageBlockProps { export interface SectionBlockProps { background: "default" | "muted" | "primary"; paddingY: "sm" | "md" | "lg"; + // 레이아웃 컬럼 정의 (12 그리드 기준 span) + columns: Array<{ + id: string; + span: number; + }>; } // 공통 블록 모델 @@ -37,6 +42,9 @@ export interface Block { id: string; type: BlockType; props: TextBlockProps | ButtonBlockProps | ImageBlockProps | SectionBlockProps; + // 레이아웃 트리 상 위치 정보 (루트 텍스트/버튼/이미지 블록은 null) + sectionId?: string | null; + columnId?: string | null; } // 에디터 상태 인터페이스 @@ -51,6 +59,7 @@ export interface EditorState { selectBlock: (id: string | null) => void; replaceBlocks: (blocks: Block[]) => void; reorderBlocks: (activeId: string, overId: string) => void; + moveBlock: (id: string, sectionId: string | null, columnId: string | null) => void; } // 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능) @@ -66,6 +75,25 @@ const createEditorState = (set: any, get: any): EditorState => ({ // 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다 addTextBlock: () => { const id = createId(); + + const { selectedBlockId, blocks } = get(); + let sectionId: string | null = null; + let columnId: string | null = null; + + if (selectedBlockId) { + const target = blocks.find((b: Block) => b.id === selectedBlockId); + if (target) { + if (target.type === "section") { + const sProps = target.props as SectionBlockProps; + sectionId = target.id; + columnId = sProps.columns?.[0]?.id ?? null; + } else { + sectionId = (target as any).sectionId ?? null; + columnId = (target as any).columnId ?? null; + } + } + } + const newBlock: Block = { id, type: "text", @@ -74,6 +102,8 @@ const createEditorState = (set: any, get: any): EditorState => ({ align: "left", size: "base", }, + sectionId, + columnId, }; set((state: EditorState) => ({ @@ -85,6 +115,25 @@ const createEditorState = (set: any, get: any): EditorState => ({ // 이미지 블록 추가: 기본 플레이스홀더와 함께 생성 후 선택 상태로 만든다 addImageBlock: () => { const id = createId(); + + const { selectedBlockId, blocks } = get(); + let sectionId: string | null = null; + let columnId: string | null = null; + + if (selectedBlockId) { + const target = blocks.find((b: Block) => b.id === selectedBlockId); + if (target) { + if (target.type === "section") { + const sProps = target.props as SectionBlockProps; + sectionId = target.id; + columnId = sProps.columns?.[0]?.id ?? null; + } else { + sectionId = (target as any).sectionId ?? null; + columnId = (target as any).columnId ?? null; + } + } + } + const newBlock: Block = { id, type: "image", @@ -92,6 +141,8 @@ const createEditorState = (set: any, get: any): EditorState => ({ src: "", alt: "이미지 설명", }, + sectionId, + columnId, }; set((state: EditorState) => ({ @@ -109,7 +160,15 @@ const createEditorState = (set: any, get: any): EditorState => ({ props: { background: "default", paddingY: "md", + columns: [ + { + id: `${id}_col_1`, + span: 12, + }, + ], }, + sectionId: null, + columnId: null, }; set((state: EditorState) => ({ @@ -121,6 +180,24 @@ const createEditorState = (set: any, get: any): EditorState => ({ // 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다 addButtonBlock: () => { const id = createId(); + + const { selectedBlockId, blocks } = get(); + let sectionId: string | null = null; + let columnId: string | null = null; + + if (selectedBlockId) { + const target = blocks.find((b: Block) => b.id === selectedBlockId); + if (target) { + if (target.type === "section") { + const sProps = target.props as SectionBlockProps; + sectionId = target.id; + columnId = sProps.columns?.[0]?.id ?? null; + } else { + sectionId = (target as any).sectionId ?? null; + columnId = (target as any).columnId ?? null; + } + } + } const newBlock: Block = { id, type: "button", @@ -128,6 +205,8 @@ const createEditorState = (set: any, get: any): EditorState => ({ label: "버튼", href: "#", }, + sectionId, + columnId, }; set((state: EditorState) => ({ @@ -185,6 +264,19 @@ const createEditorState = (set: any, get: any): EditorState => ({ return { blocks: updated }; }); }, + moveBlock: (id, sectionId, columnId) => { + set((state: EditorState) => ({ + blocks: state.blocks.map((block: Block) => + block.id === id + ? { + ...block, + sectionId, + columnId, + } + : block, + ), + })); + }, }); // React 컴포넌트에서 사용하는 전역 훅 스토어 diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts index 2400ca8..0db44e5 100644 --- a/tests/e2e/editor.spec.ts +++ b/tests/e2e/editor.spec.ts @@ -253,3 +253,41 @@ test("버튼 블록을 추가하고 라벨과 링크를 수정할 수 있어야 const updatedButton = canvas.getByRole("button", { name: "자세히 보기" }); await expect(updatedButton).toBeVisible(); }); + +test("2컬럼 섹션에서 블록을 다른 컬럼 영역으로 드래그하면 컬럼이 변경되어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // 섹션을 하나 추가하고 레이아웃을 2컬럼으로 변경한다. + await page.getByRole("button", { name: "섹션 블록 추가" }).click(); + + // 섹션을 선택한다 (캔버스 첫 블록). + const sectionBlock = canvas.getByTestId("editor-block").nth(0); + await sectionBlock.click(); + + const layoutSelect = page.getByRole("combobox", { name: "섹션 레이아웃" }); + await layoutSelect.selectOption("2col"); + + // 왼쪽 컬럼에 텍스트 블록을 하나 추가한다. + await page.getByRole("button", { name: "텍스트 블록 추가" }).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); + + // 왼쪽 컬럼 안에 텍스트 블록이 존재하는지 확인한다. + const leftBlocks = leftColumn.getByTestId("editor-block"); + await expect(leftBlocks).toHaveCount(1); + + // 왼쪽 컬럼의 첫 블록을 오른쪽 컬럼 droppable 영역으로 드래그한다. + const leftBlock = leftBlocks.nth(0); + const handle = leftBlock.getByRole("button", { name: "블록 드래그 핸들" }); + await handle.dragTo(rightColumn); + + // 드래그 이후, 오른쪽 컬럼 안에 블록이 존재해야 하고 왼쪽 컬럼은 비어 있어야 한다. + const rightBlocks = rightColumn.getByTestId("editor-block"); + await expect(rightBlocks).toHaveCount(1); + + // 왼쪽 컬럼에는 더 이상 블록이 없어야 한다. + await expect(leftColumn.getByTestId("editor-block")).toHaveCount(0); +}); diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts index 0fdd01a..9100755 100644 --- a/tests/unit/editorStore.spec.ts +++ b/tests/unit/editorStore.spec.ts @@ -77,4 +77,101 @@ describe("editorStore", () => { expect(updatedButtonProps.href).toBe("https://example.com"); expect(selectedBlockId).toBe(updatedBlocks[0].id); }); + + it("섹션이 선택된 상태에서 텍스트 블록을 추가하면 해당 섹션의 첫 컬럼에 배치되어야 한다", () => { + const store = createEditorStore(); + + // 섹션 블록을 하나 추가하고 기본 1컬럼 레이아웃을 사용한다. + store.getState().addSectionBlock(); + + const { blocks: afterSection } = store.getState(); + expect(afterSection).toHaveLength(1); + const sectionBlock = afterSection[0]; + expect(sectionBlock.type).toBe("section"); + + const sectionProps = sectionBlock.props as any; + expect(Array.isArray(sectionProps.columns)).toBe(true); + expect(sectionProps.columns.length).toBeGreaterThan(0); + + // 섹션을 선택한 후 텍스트 블록을 추가한다. + store.getState().selectBlock(sectionBlock.id); + store.getState().addTextBlock(); + + const { blocks: finalBlocks } = store.getState(); + expect(finalBlocks).toHaveLength(2); + + const textBlock = finalBlocks.find((b) => b.type === "text")!; + + // 새 텍스트 블록의 sectionId/columnId가 선택된 섹션과 첫 컬럼을 가리켜야 한다. + expect((textBlock as any).sectionId).toBe(sectionBlock.id); + expect((textBlock as any).columnId).toBe(sectionProps.columns[0].id); + }); + + it("블록이 선택된 상태에서 새 텍스트 블록을 추가하면 동일한 섹션/컬럼에 배치되어야 한다", () => { + const store = createEditorStore(); + + // 섹션 + 텍스트 블록을 만든다. + store.getState().addSectionBlock(); + const { blocks: afterSection } = store.getState(); + const sectionBlock = afterSection[0]; + const sectionProps = sectionBlock.props as any; + + store.getState().selectBlock(sectionBlock.id); + store.getState().addTextBlock(); + + let { blocks } = store.getState(); + const firstText = blocks.find((b) => b.type === "text")!; + + // 첫 텍스트 블록의 위치를 기준으로 한다. + store.getState().selectBlock(firstText.id); + store.getState().addTextBlock(); + + blocks = store.getState().blocks; + const textBlocks = blocks.filter((b) => b.type === "text"); + expect(textBlocks).toHaveLength(2); + + const [t1, t2] = textBlocks as any[]; + + // 두 번째 텍스트 블록도 같은 섹션/컬럼에 있어야 한다. + expect(t1.sectionId).toBe(sectionBlock.id); + expect(t2.sectionId).toBe(sectionBlock.id); + expect(t1.columnId).toBe(sectionProps.columns[0].id); + expect(t2.columnId).toBe(sectionProps.columns[0].id); + }); + + it("moveBlock 호출 시 블록의 sectionId/columnId가 변경되어야 한다", () => { + const store = createEditorStore(); + + // 섹션 + 텍스트 블록 1개를 만든다. + store.getState().addSectionBlock(); + const { blocks: afterSection } = store.getState(); + const sectionBlock = afterSection[0]; + const sectionProps = sectionBlock.props as any; + + store.getState().selectBlock(sectionBlock.id); + store.getState().addTextBlock(); + + let { blocks } = store.getState(); + const textBlock = blocks.find((b) => b.type === "text") as any; + + // 초기 위치는 섹션의 첫 컬럼이어야 한다. + expect(textBlock.sectionId).toBe(sectionBlock.id); + expect(textBlock.columnId).toBe(sectionProps.columns[0].id); + + // 섹션 레이아웃을 2컬럼으로 바꾸고 두 번째 컬럼으로 이동시킨다. + const updatedColumns = [ + { id: `${sectionBlock.id}_col_1`, span: 6 }, + { id: `${sectionBlock.id}_col_2`, span: 6 }, + ]; + + store.getState().updateBlock(sectionBlock.id, { columns: updatedColumns } as any); + + store.getState().moveBlock(textBlock.id, sectionBlock.id, updatedColumns[1].id); + + blocks = store.getState().blocks; + const moved = blocks.find((b) => b.id === textBlock.id) as any; + + expect(moved.sectionId).toBe(sectionBlock.id); + expect(moved.columnId).toBe(updatedColumns[1].id); + }); });