diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index e96eab7..416f612 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties, ReactNode } from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { DndContext, PointerSensor, @@ -41,6 +41,8 @@ export default function EditorPage() { const replaceBlocks = useEditorStore((state) => state.replaceBlocks); const reorderBlocks = useEditorStore((state) => state.reorderBlocks); const moveBlock = useEditorStore((state) => state.moveBlock); + const undo = useEditorStore((state) => state.undo); + const redo = useEditorStore((state) => state.redo); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); @@ -197,6 +199,65 @@ export default function EditorPage() { const rootBlocks = blocks.filter((block) => !block.sectionId); + // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z)와 + // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // 방향키로 선택된 블록을 위/아래로 이동 (순서 변경이 아니라 선택만 이동) + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const { blocks: currentBlocks, selectedBlockId: currentSelectedId, selectBlock: storeSelectBlock } = + (useEditorStore as any).getState(); + + if (!currentBlocks.length) return; + + const currentIndex = currentSelectedId + ? currentBlocks.findIndex((b: Block) => b.id === currentSelectedId) + : -1; + + let nextIndex = currentIndex; + + if (event.key === "ArrowDown") { + nextIndex = currentIndex < currentBlocks.length - 1 ? currentIndex + 1 : currentIndex; + } else if (event.key === "ArrowUp") { + nextIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex; + } + + if (nextIndex !== -1 && nextIndex !== currentIndex) { + event.preventDefault(); + const nextId = currentBlocks[nextIndex]?.id; + if (nextId) { + storeSelectBlock(nextId); + } + } + + return; + } + + const isMac = navigator.platform.toLowerCase().includes("mac"); + const metaKey = isMac ? event.metaKey : event.ctrlKey; + + if (!metaKey) return; + + // Cmd/Ctrl + Shift + Z → Redo + if (event.key.toLowerCase() === "z" && event.shiftKey) { + event.preventDefault(); + redo(); + return; + } + + // Cmd/Ctrl + Z → Undo + if (event.key.toLowerCase() === "z") { + event.preventDefault(); + undo(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [undo, redo]); + const renderBlocks = (targetBlocks: Block[]) => targetBlocks.map((block) => { const isSelected = block.id === selectedBlockId; diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index 23c047b..b4ec467 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -51,6 +51,8 @@ export interface Block { export interface EditorState { blocks: Block[]; selectedBlockId: string | null; + history: Block[][]; + future: Block[][]; addTextBlock: () => void; addButtonBlock: () => void; addImageBlock: () => void; @@ -63,6 +65,8 @@ export interface EditorState { replaceBlocks: (blocks: Block[]) => void; reorderBlocks: (activeId: string, overId: string) => void; moveBlock: (id: string, sectionId: string | null, columnId: string | null) => void; + undo: () => void; + redo: () => void; } // 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능) @@ -74,6 +78,8 @@ const createId = () => `blk_${Date.now()}_${idCounter++}`; const createEditorState = (set: any, get: any): EditorState => ({ blocks: [], selectedBlockId: null, + history: [], + future: [], // 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다 addTextBlock: () => { @@ -112,6 +118,8 @@ const createEditorState = (set: any, get: any): EditorState => ({ set((state: EditorState) => ({ blocks: [...state.blocks, newBlock], selectedBlockId: id, + history: [...state.history, blocks], + future: [], })); }, @@ -478,6 +486,34 @@ const createEditorState = (set: any, get: any): EditorState => ({ ), })); }, + undo: () => { + const { history, blocks } = get(); + if (history.length === 0) return; + + const previous = history[history.length - 1]; + const nextHistory = history.slice(0, -1); + + set((state: EditorState) => ({ + blocks: previous, + selectedBlockId: previous.length > 0 ? previous[previous.length - 1].id : null, + history: nextHistory, + future: [...state.future, blocks], + })); + }, + redo: () => { + const { future, blocks } = get(); + if (future.length === 0) return; + + const next = future[future.length - 1]; + const nextFuture = future.slice(0, -1); + + set((state: EditorState) => ({ + blocks: next, + selectedBlockId: next.length > 0 ? next[next.length - 1].id : null, + history: [...state.history, blocks], + future: nextFuture, + })); + }, }); // React 컴포넌트에서 사용하는 전역 훅 스토어 diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts index e05888c..ab74c75 100644 --- a/tests/e2e/editor.spec.ts +++ b/tests/e2e/editor.spec.ts @@ -325,7 +325,7 @@ test("Features 템플릿 버튼을 클릭하면 3개의 Feature 항목(제목/ await expect(canvas.getByText("Feature 3 제목")).toBeVisible(); }); -test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 생성되어야 한다", async ({ page }) => { +test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 포함된 섹션이 캔버스에 생성되어야 한다", async ({ page }) => { await page.goto("/editor"); const canvas = page.getByTestId("editor-canvas"); @@ -336,3 +336,59 @@ test("CTA 템플릿 버튼을 클릭하면 CTA 텍스트와 버튼이 생성되 const ctaButton = canvas.getByRole("button", { name: "CTA 버튼" }); await expect(ctaButton).toBeVisible(); }); + +test("Cmd/Ctrl+Z로 블록 추가를 Undo 하고 Cmd/Ctrl+Shift+Z로 Redo 할 수 있어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // 텍스트 블록을 하나 추가한다. + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + await expect(canvas.getByTestId("editor-block")).toHaveCount(1); + + // Cmd/Ctrl + Z 로 Undo 한다. + await page.keyboard.press("Meta+Z"); + + // Undo 후에는 블록이 0개가 되어야 한다. + await expect(canvas.getByTestId("editor-block")).toHaveCount(0); + + // Cmd/Ctrl + Shift + Z 로 Redo 한다. + await page.keyboard.press("Meta+Shift+Z"); + + // Redo 후 다시 블록이 1개가 되어야 한다. + await expect(canvas.getByTestId("editor-block")).toHaveCount(1); +}); + +test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 순차적으로 선택할 수 있어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // 텍스트 블록을 세 개 추가한다. + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + + const blocks = canvas.getByTestId("editor-block"); + await expect(blocks).toHaveCount(3); + + // 첫 번째 블록을 클릭해 선택한다. + await blocks.nth(0).click(); + await expect(blocks.nth(0)).toHaveAttribute("data-selected", "true"); + + // ArrowDown 으로 두 번째 블록으로 이동. + await page.keyboard.press("ArrowDown"); + await expect(blocks.nth(1)).toHaveAttribute("data-selected", "true"); + + // 다시 ArrowDown 으로 세 번째 블록으로 이동. + await page.keyboard.press("ArrowDown"); + await expect(blocks.nth(2)).toHaveAttribute("data-selected", "true"); + + // ArrowDown 을 한 번 더 눌러도 마지막에서 더 내려가지 않는다. + await page.keyboard.press("ArrowDown"); + await expect(blocks.nth(2)).toHaveAttribute("data-selected", "true"); + + // ArrowUp 으로 두 번째 블록으로 올라간다. + await page.keyboard.press("ArrowUp"); + await expect(blocks.nth(1)).toHaveAttribute("data-selected", "true"); +}); diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts index bc1c30f..a2ef516 100644 --- a/tests/unit/editorStore.spec.ts +++ b/tests/unit/editorStore.spec.ts @@ -282,4 +282,46 @@ describe("editorStore", () => { expect(selectedBlockId).toBe(blocks[blocks.length - 1].id); }); + + it("undo 호출 시 마지막 변경 이전의 블록 상태로 되돌려야 한다", () => { + const store = createEditorStore(); + + // 텍스트 블록을 두 개 추가한다. + store.getState().addTextBlock(); + store.getState().addTextBlock(); + + let { blocks, selectedBlockId } = store.getState(); + expect(blocks).toHaveLength(2); + + const firstId = blocks[0].id; + + store.getState().undo(); + + ({ blocks, selectedBlockId } = store.getState()); + + // 마지막 추가 이전 상태로 되돌아가야 하므로 블록은 1개만 남아야 한다. + expect(blocks).toHaveLength(1); + expect(blocks[0].id).toBe(firstId); + expect(selectedBlockId).toBe(firstId); + }); + + it("redo 호출 시 undo로 되돌린 변경을 다시 적용해야 한다", () => { + const store = createEditorStore(); + + store.getState().addTextBlock(); + store.getState().addTextBlock(); + + let { blocks } = store.getState(); + expect(blocks).toHaveLength(2); + + store.getState().undo(); + + ({ blocks } = store.getState()); + expect(blocks).toHaveLength(1); + + store.getState().redo(); + + ({ blocks } = store.getState()); + expect(blocks).toHaveLength(2); + }); });