데모 불러오는 중…
<script setup lang="ts">
import { provide } from 'vue';
import { DnDProvider, type Active, type AnnouncementContext, type Announcements } from '@vue-dnd-kit/core';
import Workspace from './Workspace.vue';
import { KINDS, describe, type BlockKind } from './blocks';
import { EDITOR, createEditor } from './editor';
// Provided above the provider: the overlay it renders reads the document too.
const editor = createEditor();
provide(EDITOR, editor);
// Screen readers hear blocks by their kind and text, not by their ids.
const name = (active: Active, { count, copy }: AnnouncementContext) => {
const block = editor.get(String(active.id));
const kind = (active.data as { kind?: BlockKind } | undefined)?.kind;
const what = block ? describe(block) : `a new ${KINDS[kind ?? 'text'].name.toLowerCase()}`;
return `${copy ? 'a copy of ' : ''}${what}${count > 1 ? ` and ${count - 1} more` : ''}`;
};
const row = ({ position }: AnnouncementContext) => (position ? `row ${position.index + 1} of ${position.length}` : '');
const announcements: Partial<Announcements> = {
onDragStart: ({ active }, context) =>
`Picked up ${name(active, context)}. Up and down move it, left and right change its level.`,
onDragOver: (_event, context) => row(context) || 'Outside the page: dropping cancels.',
onDragEnd: ({ active }, context) => `Dropped ${name(active, context)}${context.position ? `, ${row(context)}` : ''}.`,
onDragCancel: ({ active }, context) => `Cancelled: ${name(active, context)} stays where it was.`,
};
</script>
<template>
<DnDProvider
:announcements="announcements"
screen-reader-instructions="Space picks a block up, the arrow keys move it and left or right change its level, Space drops it and Escape cancels. Enter opens the block's menu."
>
<Workspace />
</DnDProvider>
</template>모든 블록의 여백에는 핸들(⋮⋮)과 "+"가 있습니다. 어느 블록에든 입력할 수 있습니다. Enter는 다음 블록을 시작하고, Tab은 중첩하고, "/"는 새 블록 메뉴를 열며, Markdown도 동작합니다("## ", "- ", " "). 여백에 사각형을 그려 여러 블록을 선택한 뒤 함께 드래그해 보세요. 프레임을 펼치면 넓은 화면에서는 추가할 블록과 페이지 개요가 각자의 사이드바에 나타납니다.
살펴볼 점
Page.vue: 페이지는placeholder: 'indicator'를 쓰는 하나의 정렬 가능한 트리입니다. 드래그하는 동안 페이지는 움직이지 않고, 블록이 어디에 어느 깊이로 안착할지 선이 보여 줍니다. 블록을 감싼 시트가 그dropzone이므로, 마지막 블록 아래에 드롭하면 끝에 안착합니다.BlockRow.vue: 블록 하나. 핸들만 드래그하므로(handle) 텍스트는 계속 편집하고 선택할 수 있습니다. 핸들은 버튼입니다. 클릭하면 블록을 선택하고 메뉴를 열며, Space는 키보드로 블록을 집습니다.- **
BlockText.vue**와Editable.vue: 에디터의 키 동작을 갖춘 평범한contenteditable텍스트. 입력은 DOM에서 모델로 흐르고, 모델은 다른 무언가(실행 취소, 병합)가 모델을 바꿨을 때만 DOM으로 돌아가므로 캐럿이 튀지 않습니다. editor.ts: 문서와 그에 대한 모든 편집으로, 드래그 앤 드롭은 전혀 없습니다. 나누기, 병합, 들여쓰기, 복제, 다른 종류로 바꾸기, 그리고 드롭과 입력을 똑같이 되돌리는 기록.
옆으로 드래그해 중첩하기
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, 실행 취소, 토글 열기)도 미끄러지고 서서히 사라지게 합니다.
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를
누르면 그 블록이 선택됩니다. 그다음:
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로 키보드용으로 집을 수 있습니다. 페이지 쪽으로
화살표 키를 누르면 페이지가 항목을 받아들입니다.
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
});
항목이 옮겨지는 동안 메뉴는 서서히 사라져 페이지에서 어디로 가는지 보이게 하고, 새 블록이 안착하면 닫힙니다.
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는 복사일 때 그렇다고 알려 줍니다.
<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은 기록 직전에 실행되므로
페이지를 원래 상태로 저장하고, 실행 취소가 드롭을 되돌립니다. 다른 모든 편집도 같은 방식으로 스스로를
저장합니다. 한 블록에 입력한 키 입력은 한 단계가 됩니다.
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);
},
/** 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”"), 상단 바의 상태 줄은 버튼과 단축키가 한 일을 읽어 줍니다.
더 읽어 보기: 트리 · 멀티 드래그와 선택 · 일반 draggable을 리스트로 · 오버레이를 바꾸는 대상 · 누르기가 드래그로 바뀌는 시점 · 접근성