Documentação/Visão geral
Editor de blocos
Uma página no estilo Notion — blocos arrastados pelos handles e aninhados arrastando para o lado, um retângulo de seleção que move vários de uma vez, novos blocos arrastados de um menu "/" ou de uma barra lateral, cópias com Alt, e Undo para tudo isso.
Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês
Carregando a demo…
<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>Todo bloco tem um handle (⋮⋮) e um "+" na margem. Digite em qualquer bloco: Enter começa o próximo, Tab o aninha, "/" abre o menu de novos blocos, e Markdown funciona ("## ", "- ", " "). Desenhe um retângulo na margem para selecionar vários blocos, depois arraste-os juntos. Expanda o quadro: em uma tela larga, os blocos a adicionar e o sumário da página ganham barras laterais próprias.
O que observar
Page.vue: a página é uma única árvore ordenável, complaceholder: 'indicator': a página fica parada enquanto você arrasta, e uma linha mostra onde os blocos pousam e em qual nível. A folha em volta dos blocos é odropzonedela, então um drop abaixo do último bloco pousa no fim.BlockRow.vue: um bloco. Só o handle arrasta (handle), então o texto continua editável e selecionável. O handle é um botão: um clique seleciona o bloco e abre o menu dele, Space pega o bloco para o teclado.BlockText.vueeEditable.vue: textocontenteditablesimples com as teclas de um editor. O que se digita vai do DOM para o model; o model volta para o DOM só quando outra coisa o mudou (Undo, uma fusão), então o cursor nunca pula.editor.ts: o documento e toda edição nele, sem nenhum arrastar e soltar: dividir, fundir, indentar, duplicar, transformar em outro tipo, e um histórico que desfaz drops e digitação do mesmo jeito.
Aninhar arrastando para o lado
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' tira o nível só do deslocamento lateral do arraste: passar sobre o meio de um
parágrafo não solta dentro dele, como em um editor de documentos. canNest diz quais blocos
aceitam blocos aninhados (parágrafos, listas, to-dos, toggles, citações, callouts; não títulos,
código ou imagens), e quando o bloco de cima recusa, a linha fica no nível mais profundo que passa.
Um toggle fechado (collapsed) continua aceitando blocos: uma linha um nível para dentro embaixo
dele os coloca dentro, em primeiro.
Cada linha desenha a linha de inserção a partir do seu indicator, do nível em que o drop pousa.
Listas numeradas contam a partir de items, então "2." e "a." acompanham cada drop e cada Tab.
animateChanges: true faz as mudanças que nenhum arraste faz também deslizarem e esmaecerem: Enter, Tab,
Delete, Undo, um toggle abrindo.
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` };
});
Selecionar vários
A página é uma área de seleção: um retângulo desenhado a partir das margens seleciona os blocos que
toca. strategy: 'select' faz um clique na margem vazia limpar a seleção, e accept restringe o
retângulo aos blocos. Um clique em um handle seleciona o bloco dele, Shift-clique uma sequência de
blocos, Ctrl/Cmd-clique mais um; Escape em um texto seleciona o bloco dele. Então:
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
});
Arraste um bloco selecionado e os outros vêm junto: a árvore os move como uma única sequência, cada um com o que está aninhado nele, na ordem de leitura. Delete os remove, Ctrl/Cmd+D os duplica, e o menu do handle os transforma em outro tipo. As setas vão de handle em handle, Shift adiciona à seleção.
Novos blocos a partir de um menu
O "+" na margem e o "/" em um bloco abrem o mesmo menu. Um clique adiciona o bloco embaixo; cada
opção também é um draggable simples, que o createItem da árvore transforma em um novo bloco onde
ele é solto. A barra lateral das telas largas lista as mesmas opções, e ali Space pega uma para o
teclado: uma seta em direção à página a traz para dentro.
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
});
Enquanto uma opção viaja, o menu esmaece para que a página mostre para onde ela vai, e ele fecha quando o novo bloco pousa:
useDnDMonitor({
onDragStart: ({ active }) => (lifted.value = !!popover.value?.contains(active.element)),
onDragSettle: () => {
if (!lifted.value) return;
lifted.value = false;
menu.value = null;
},
});
O que o drop vai fazer
BlockGhost.vue é o overOverlay da árvore: enquanto um arraste está sobre a página, ele
envolve o overlay do próprio arraste (<slot />, o clone dos blocos ou da opção) com uma contagem
e uma nota. to.parentKey nomeia o bloco em que o drop entraria, e mode diz quando ele copia.
<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>
Undo
A árvore escreve cada drop em editor.blocks, um ref gravável. O onDrop dela roda logo antes
dessa escrita, então ele registra a página como era, e Undo desfaz o drop. Toda outra edição se
registra do mesmo jeito; as teclas digitadas em um bloco formam um único passo.
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;
};
O histórico guarda cópias dos blocos com os seus ids, então um bloco mantém a sua linha e a sua seleção através de Undo e Redo. Depois de um drop, um novo bloco recebe o cursor, e um bloco movido dá o foco ao seu handle: a linha dele se moveu na página, o que tira o foco, e os atalhos continuam funcionando a partir do handle.
Teclado e leitores de tela
Escape em um texto seleciona o bloco e foca o handle dele. Dali, Space o pega, as setas movem a
linha (esquerda e direita: um nível para fora ou para dentro), Space o solta; Enter abre o menu
dele, Delete o remove. Os announcements do provider falam dos blocos pelo tipo e pelo texto
("Picked up to-do list “Freeze the API”"), e a linha de status da barra superior fala o que os
botões e os atalhos fazem.
Leia mais: Árvores · Multi-drag e seleção · Draggables simples em uma lista · Alvos que mudam o overlay · Quando uma pressão vira arraste · Acessibilidade