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.

Editar esta página

Esta página foi traduzida por IA e pode conter imprecisões. Ler o original em inglês

Live

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, com placeholder: '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 é o dropzone dela, 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.vue e Editable.vue: texto contenteditable simples 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

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' 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.

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` };
});

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:

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
});

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.

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
});

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:

Page.vue
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.

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>

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.

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);
},
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;
};

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