---
title: "블록 에디터"
description: "Notion 같은 페이지. 핸들로 드래그하고 옆으로 중첩하는 블록, 여러 블록을 한꺼번에 옮기는 선택 사각형, \"/\" 메뉴나 사이드바에서 끌어내는 새 블록, Alt로 복사, 그리고 이 모든 것을 되돌리는 실행 취소."
canonical_url: "https://zizigy.github.io/vue-dnd-kit/ko/docs/examples/block-editor"
---

> Index of every page on this site: [llms.txt](https://zizigy.github.io/vue-dnd-kit/llms.txt)

<example height="40rem" hint="⋮⋮를 드래그해 블록을 옮기고, 옆으로 끌어 중첩하세요. /를 입력하면 새 블록을 만듭니다" name="block-editor" title="블록 에디터">



</example>

모든 블록의 여백에는 핸들(⋮⋮)과 "+"가 있습니다. 어느 블록에든 입력할 수 있습니다. Enter는 다음
블록을 시작하고, Tab은 중첩하고, "/"는 새 블록 메뉴를 열며, Markdown도 동작합니다("## ", "- ",
"<span>



</span>

 "). 여백에 사각형을 그려 여러 블록을 선택한 뒤 함께 드래그해 보세요. 프레임을 펼치면 넓은
화면에서는 추가할 블록과 페이지 개요가 각자의 사이드바에 나타납니다.

## 살펴볼 점

- **Page.vue**: 페이지는 `placeholder: 'indicator'`를 쓰는 하나의 정렬 가능한 트리입니다. 드래그하는
동안 페이지는 움직이지 않고, 블록이 어디에 어느 깊이로 안착할지 선이 보여 줍니다. 블록을 감싼
시트가 그 `dropzone`이므로, 마지막 블록 아래에 드롭하면 끝에 안착합니다.
- **BlockRow.vue**: 블록 하나. 핸들만 드래그하므로(`handle`) 텍스트는 계속 편집하고 선택할 수
있습니다. 핸들은 버튼입니다. 클릭하면 블록을 선택하고 메뉴를 열며, Space는 키보드로 블록을
집습니다.
- **`BlockText.vue`**와 **Editable.vue**: 에디터의 키 동작을 갖춘 평범한 `contenteditable`
텍스트. 입력은 DOM에서 모델로 흐르고, 모델은 다른 무언가(실행 취소, 병합)가 모델을 바꿨을 때만
DOM으로 돌아가므로 캐럿이 튀지 않습니다.
- **editor.ts**: 문서와 그에 대한 모든 편집으로, 드래그 앤 드롭은 전혀 없습니다. 나누기, 병합,
들여쓰기, 복제, 다른 종류로 바꾸기, 그리고 드롭과 입력을 똑같이 되돌리는 기록.

## 옆으로 드래그해 중첩하기

```ts [Page.vue]
const { items } = makeSortableTree(root, {
  id: 'page',
  items: editor.blocks,
  group: 'blocks',
  placeholder: 'indicator',
  nesting: 'offset',        // sideways: every 24px is a level
  indentation: INDENT,
  maxDepth: MAX_DEPTH,
  canNest: (_block, parent) => !!KINDS[parent.kind].nests,
  collapsed: editor.collapsed,
  clone: copyBlock,         // Alt-drag copies a block with what is nested in it
  createItem: (active) => createBlock((active.data as { kind: BlockKind }).kind),
  animateChanges: true,
  overOverlay: BlockGhost,
  dropzone: sheet,
  onDrop, // see Undo, below
});
```

`nesting: 'offset'`은 드래그의 가로 오프셋만으로 깊이를 정합니다. 문서 에디터처럼 문단 가운데 위에
있어도 그 안으로 드롭되지 않습니다. `canNest`는 어떤 블록이 중첩 블록을 받는지 정하며(문단, 목록,
할 일, 토글, 인용, 콜아웃. 제목, 코드, 이미지는 받지 않습니다), 위 블록이 거부하면 선은 조건을
통과하는 가장 깊은 수준에 머뭅니다. 닫힌 토글(`collapsed`)도 블록을 받습니다. 그 아래에서 한 단계
안쪽에 있는 선은 블록을 토글 안의 맨 앞에 넣습니다.

각 행은 자신의 `indicator`에서, 드롭이 안착할 깊이에 맞춰 선을 그립니다. 번호 목록은 `items`로
세므로 "2."와 "a."가 모든 드롭과 Tab을 따라갑니다. `animateChanges: true`는 드래그가 아닌 변경(Enter, Tab,
Delete, 실행 취소, 토글 열기)도 미끄러지고 서서히 사라지게 합니다.

```ts [BlockRow.vue]
const { indicator, selected } = makeSortableItem(el, {
  id: () => props.node.key,
  handle: '.handle',
  // A mouse press on a handle drags at once; after 4px instead, a click stays a click.
  activation: { mouse: { distance: 4 } },
  // Enter opens the block's menu; Space picks the block up.
  canDrag: (event) => !('key' in event && event.key === 'Enter'),
});

const line = computed(() => {
  const at = indicator.value;
  if (!at || at.side === 'inside') return null;
  return { side: at.side, left: `${((at.depth ?? 0) - props.node.depth) * props.indent}px` };
});
```

## 여러 개 선택하기

페이지는 선택 영역입니다. 여백에서 그린 사각형이 닿는 블록을 선택합니다. `strategy: 'select'` 덕분에
빈 여백을 클릭하면 선택이 해제되고, `accept`는 사각형이 블록만 고르게 합니다. 핸들을 클릭하면 그
블록이 선택되고, Shift-클릭은 범위를, Ctrl/Cmd-클릭은 하나를 더 선택합니다. 텍스트에서 Escape를
누르면 그 블록이 선택됩니다. 그다음:

```ts [Page.vue]
const { isSelecting, style } = makeSelectionArea(scroller, {
  strategy: 'select',
  accept: 'blocks',                     // the rows' type: their group's name
  disabled: () => menu.value !== null,  // a press closes the menu first
});
```

선택한 블록 하나를 드래그하면 나머지도 따라옵니다. 트리는 이들을 각자 중첩된 내용과 함께, 읽는 순서대로
한 블록으로 옮깁니다. Delete는 삭제하고, Ctrl/Cmd+D는 복제하며, 핸들의 메뉴는 다른 종류로 바꿉니다.
화살표 키는 핸들에서 핸들로 이동하고, Shift를 누르면 선택에 더합니다.

## 메뉴에서 새 블록

여백의 "+"와 블록 안의 "/"는 같은 메뉴를 엽니다. 클릭하면 아래에 블록을 추가합니다. 각 항목은 일반
draggable이기도 해서, 트리의 `createItem`이 드롭된 곳에서 새 블록으로 바꿉니다. 넓은 화면의
사이드바에도 같은 항목이 있으며, 그곳에서는 Space로 키보드용으로 집을 수 있습니다. 페이지 쪽으로
화살표 키를 누르면 페이지가 항목을 받아들입니다.

```ts [InsertOption.vue]
makeDraggable(el, {
  id: () => `new-${props.kind}${props.option ? '-option' : ''}`,
  type: 'new-block',
  data: () => ({ kind: props.kind }),
  attributes: () => !props.option, // in the menu, an option of its listbox
});
```

항목이 옮겨지는 동안 메뉴는 서서히 사라져 페이지에서 어디로 가는지 보이게 하고, 새 블록이 안착하면
닫힙니다.

```ts [Page.vue]
useDnDMonitor({
  onDragStart: ({ active }) => (lifted.value = !!popover.value?.contains(active.element)),
  onDragSettle: () => {
    if (!lifted.value) return;
    lifted.value = false;
    menu.value = null;
  },
});
```

## 드롭이 할 일

**`BlockGhost.vue`**는 트리의 `overOverlay`입니다. 드래그가 페이지 위에 있는 동안 드래그 자체의
오버레이(`<slot />`, 블록이나 항목의 클론)를 개수와 메모로 감쌉니다. `to.parentKey`는 드롭이 들어갈
블록을 가리키고, `mode`는 복사일 때 그렇다고 알려 줍니다.

```vue [BlockGhost.vue]
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; mode: DragMode; to: DropDestination | null }>();

// A new block: a kind dragged from the menu or the sidebar, by its `type`.
const adding = computed(() => props.items[0]?.type.includes('new-block'));

const note = computed(() => {
  const verb = props.mode === 'copy' ? 'Copy' : adding.value ? 'Add' : null;
  const parent = props.to?.parentKey == null ? undefined : editor.get(String(props.to.parentKey));
  if (!parent) return verb; // a plain move at the page's top level needs no words
  return `${verb ? `${verb} into` : 'Into'} “${parent.text}”`; // "Copy into “Open questions”"
});
</script>

<template>
  <div class="ghost">
    <slot />
    <span v-if="items.length > 1" class="count">{{ items.length }}</span>
    <span v-if="note" class="note">{{ note }}</span>
  </div>
</template>
```

## 실행 취소

트리는 각 드롭을 쓰기 가능한 ref인 `editor.blocks`에 기록합니다. 그 `onDrop`은 기록 직전에 실행되므로
페이지를 원래 상태로 저장하고, 실행 취소가 드롭을 되돌립니다. 다른 모든 편집도 같은 방식으로 스스로를
저장합니다. 한 블록에 입력한 키 입력은 한 단계가 됩니다.

```ts [Page.vue]
onDrop: (event) => {
  editor.record(); // the library writes the drop after this: Undo takes it back
  const first = event.items[0]!.id;
  if (event.mode === 'insert') editor.focus(first, 'start');
  else editor.focusHandle(first);
},
```

```ts [editor.ts]
/** Called before every change: the state it changes goes on the undo stack. */
const record = () => {
  past.value = [...past.value.slice(-99), capture()];
  future.value = [];
  typing = null;
};
```

기록은 id가 있는 블록의 복사본을 담으므로, 실행 취소와 다시 실행을 거쳐도 블록은 자기 행과 선택을
유지합니다. 드롭한 뒤에는 새 블록이 캐럿을 받고, 옮겨진 블록은 핸들에 포커스를 넘깁니다. 행이 페이지
안에서 옮겨지면 포커스가 사라지므로, 핸들에서 단축키를 계속 쓸 수 있게 하는 것입니다.

## 키보드와 스크린 리더

텍스트에서 Escape를 누르면 블록이 선택되고 핸들에 포커스가 갑니다. 거기서 Space로 집고, 화살표 키로
선을 옮기고(왼쪽과 오른쪽은 한 단계 바깥과 안쪽), Space로 드롭합니다. Enter는 메뉴를 열고, Delete는
삭제합니다. 프로바이더의 `announcements`는 블록을 종류와 텍스트로 읽어 주고("Picked up to-do list
“Freeze the API”"), 상단 바의 상태 줄은 버튼과 단축키가 한 일을 읽어 줍니다.

더 읽어 보기: [트리](https://zizigy.github.io/docs/guides/trees) ·
[멀티 드래그와 선택](https://zizigy.github.io/docs/guides/multi-drag#rubber-band-selection) ·
[일반 draggable을 리스트로](https://zizigy.github.io/docs/guides/drag-and-drop#plain-draggables-into-a-list) ·
[오버레이를 바꾸는 대상](https://zizigy.github.io/docs/guides/overlay#targets-that-change-the-overlay) ·
[누르기가 드래그로 바뀌는 시점](https://zizigy.github.io/docs/guides/provider#when-a-press-becomes-a-drag) ·
[접근성](https://zizigy.github.io/docs/guides/accessibility)

## Sitemap

See the full [sitemap](https://zizigy.github.io/vue-dnd-kit/sitemap.md) for all pages.
