diff --git a/src/app/editor/page.tsx b/src/app/editor/page.tsx index 91ac9fa..9b57de3 100644 --- a/src/app/editor/page.tsx +++ b/src/app/editor/page.tsx @@ -44,6 +44,8 @@ export default function EditorPage() { const moveBlock = useEditorStore((state) => state.moveBlock); const undo = useEditorStore((state) => state.undo); const redo = useEditorStore((state) => state.redo); + const removeBlock = useEditorStore((state) => state.removeBlock); + const duplicateBlock = useEditorStore((state) => state.duplicateBlock); const [editingBlockId, setEditingBlockId] = useState(null); const [editingText, setEditingText] = useState(""); @@ -200,7 +202,8 @@ export default function EditorPage() { const rootBlocks = blocks.filter((block) => !block.sectionId); - // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z)와 + // 에디터 전역에서 Undo/Redo 단축키(Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z), + // Delete/Backspace 기반 삭제, Cmd/Ctrl+D 기반 복제, // ArrowUp/ArrowDown을 이용한 선택 블록 이동을 처리한다. useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -237,8 +240,29 @@ export default function EditorPage() { const isMac = navigator.platform.toLowerCase().includes("mac"); const metaKey = isMac ? event.metaKey : event.ctrlKey; + // Delete / Backspace 로 선택된 블록 삭제 + if (event.key === "Delete" || event.key === "Backspace") { + const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState(); + if (currentSelectedId) { + event.preventDefault(); + removeBlock(currentSelectedId); + } + return; + } + + // Cmd/Ctrl 없는 경우에는 여기서 종료한다. if (!metaKey) return; + // Cmd/Ctrl + D → 현재 선택된 블록 복제 + if (event.key.toLowerCase() === "d") { + const { selectedBlockId: currentSelectedId } = (useEditorStore as any).getState(); + if (currentSelectedId) { + event.preventDefault(); + duplicateBlock(currentSelectedId); + } + return; + } + // Cmd/Ctrl + Shift + Z → Redo if (event.key.toLowerCase() === "z" && event.shiftKey) { event.preventDefault(); @@ -381,6 +405,22 @@ export default function EditorPage() {

속성 패널

{selectedBlockId ? (
+
+ + +
{(() => { const selectedBlock = blocks.find((b) => b.id === selectedBlockId); if (!selectedBlock) return null; diff --git a/src/features/editor/state/editorStore.ts b/src/features/editor/state/editorStore.ts index b4ec467..8124cac 100644 --- a/src/features/editor/state/editorStore.ts +++ b/src/features/editor/state/editorStore.ts @@ -67,6 +67,8 @@ export interface EditorState { moveBlock: (id: string, sectionId: string | null, columnId: string | null) => void; undo: () => void; redo: () => void; + removeBlock: (id: string) => void; + duplicateBlock: (id: string) => void; } // 간단한 ID 생성기 (추후 uuid 라이브러리로 교체 가능) @@ -486,6 +488,63 @@ const createEditorState = (set: any, get: any): EditorState => ({ ), })); }, + removeBlock: (id) => { + set((state: EditorState) => { + const current = state.blocks; + const index = current.findIndex((b) => b.id === id); + if (index === -1) { + return state; + } + + const nextBlocks = current.filter((b) => b.id !== id); + + // 선택 상태는 삭제된 블록의 이전 블록 또는 다음 블록으로 이동한다. + let nextSelected: string | null = null; + if (nextBlocks.length > 0) { + const prevIndex = index - 1; + if (prevIndex >= 0) { + nextSelected = nextBlocks[prevIndex].id; + } else { + // 첫 블록이 삭제된 경우, 새 첫 블록을 선택한다. + nextSelected = nextBlocks[0].id; + } + } + + return { + ...state, + blocks: nextBlocks, + selectedBlockId: nextSelected, + }; + }); + }, + duplicateBlock: (id) => { + set((state: EditorState) => { + const current = state.blocks; + const index = current.findIndex((b) => b.id === id); + if (index === -1) { + return state; + } + + const original = current[index]; + const newId = createId(); + + const cloned: Block = { + ...original, + id: newId, + // props 는 얕은 복사로 충분 (현재 props 는 모두 평면 구조) + props: { ...(original.props as any) }, + }; + + const nextBlocks = [...current]; + nextBlocks.splice(index + 1, 0, cloned); + + return { + ...state, + blocks: nextBlocks, + selectedBlockId: newId, + }; + }); + }, undo: () => { const { history, blocks } = get(); if (history.length === 0) return; diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts index ab74c75..98a6da7 100644 --- a/tests/e2e/editor.spec.ts +++ b/tests/e2e/editor.spec.ts @@ -392,3 +392,53 @@ test("ArrowUp/ArrowDown 키로 선택된 블록을 위/아래로 이동하며 await page.keyboard.press("ArrowUp"); await expect(blocks.nth(1)).toHaveAttribute("data-selected", "true"); }); + +test("속성 패널의 블록 삭제 버튼으로 선택된 블록을 삭제할 수 있어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // 텍스트 블록 두 개를 추가한다. + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + + const blocks = canvas.getByTestId("editor-block"); + await expect(blocks).toHaveCount(2); + + // 두 번째 블록을 선택한 다음, 속성 패널에서 "블록 삭제" 버튼을 클릭한다. + await blocks.nth(1).click(); + await page.getByRole("button", { name: "블록 삭제" }).click(); + + // 하나의 블록만 남아야 하며, 첫 번째 블록이 남아 있어야 한다. + const remainingBlocks = canvas.getByTestId("editor-block"); + await expect(remainingBlocks).toHaveCount(1); + await expect(remainingBlocks.nth(0)).toHaveAttribute("data-selected", "true"); +}); + +test("Cmd/Ctrl+D 단축키로 선택된 블록을 복제할 수 있어야 한다", async ({ page }) => { + await page.goto("/editor"); + + const canvas = page.getByTestId("editor-canvas"); + + // 텍스트 블록을 하나 추가한다. + await page.getByRole("button", { name: "텍스트 블록 추가" }).click(); + + let blocks = canvas.getByTestId("editor-block"); + await expect(blocks).toHaveCount(1); + + // 블록의 텍스트를 식별 가능한 값으로 바꾼다. + await blocks.nth(0).click(); + const sidebarEditor = page.getByRole("textbox", { name: "선택한 텍스트 블록 내용" }); + await sidebarEditor.fill("복제 대상 블록"); + + // Cmd/Ctrl + D 로 복제한다. + await page.keyboard.press("Meta+D"); + + // 블록이 두 개가 되어야 한다. + blocks = canvas.getByTestId("editor-block"); + await expect(blocks).toHaveCount(2); + + // 두 블록 모두 동일한 텍스트를 가지고 있어야 한다. + await expect(blocks.nth(0)).toContainText("복제 대상 블록"); + await expect(blocks.nth(1)).toContainText("복제 대상 블록"); +}); diff --git a/tests/unit/editorStore.spec.ts b/tests/unit/editorStore.spec.ts index a2ef516..da3c432 100644 --- a/tests/unit/editorStore.spec.ts +++ b/tests/unit/editorStore.spec.ts @@ -324,4 +324,61 @@ describe("editorStore", () => { ({ blocks } = store.getState()); expect(blocks).toHaveLength(2); }); + + it("removeBlock 호출 시 해당 블록이 삭제되고 선택 상태가 적절히 변경되어야 한다", () => { + const store = createEditorStore(); + + // 텍스트 블록 두 개를 추가한다. + store.getState().addTextBlock(); + store.getState().addTextBlock(); + + let { blocks, selectedBlockId } = store.getState(); + expect(blocks).toHaveLength(2); + + const firstId = blocks[0].id; + const secondId = blocks[1].id; + + // 두 번째 블록을 선택한 상태에서 삭제한다. + store.getState().selectBlock(secondId); + store.getState().removeBlock(secondId); + + ({ blocks, selectedBlockId } = store.getState()); + + // 두 번째 블록만 삭제되고 첫 번째 블록은 남아 있어야 한다. + expect(blocks).toHaveLength(1); + expect(blocks[0].id).toBe(firstId); + expect(selectedBlockId).toBe(firstId); + }); + + it("duplicateBlock 호출 시 같은 내용의 블록이 같은 위치 정보로 복제되어야 한다", () => { + const store = createEditorStore(); + + // 섹션 + 텍스트 블록을 하나 만든다. + store.getState().addSectionBlock(); + const { blocks: afterSection } = store.getState(); + const sectionBlock = afterSection[0] as any; + + store.getState().selectBlock(sectionBlock.id); + store.getState().addTextBlock(); + + let { blocks } = store.getState(); + const original = blocks.find((b) => b.type === "text") as any; + + expect(original).toBeTruthy(); + + // duplicateBlock 으로 복제한다. + store.getState().duplicateBlock(original.id); + + ({ blocks } = store.getState()); + const textBlocks = blocks.filter((b) => b.type === "text") as any[]; + + // 텍스트 블록이 두 개가 되어야 한다. + expect(textBlocks).toHaveLength(2); + + const [t1, t2] = textBlocks; + expect(t1.id).not.toBe(t2.id); + expect(t1.props).toEqual(t2.props); + expect(t2.sectionId).toBe(t1.sectionId); + expect(t2.columnId).toBe(t1.columnId); + }); });