Documentación/Resumen

Editor de bloques

Una página al estilo de Notion — bloques que se arrastran por sus asas y se anidan de lado, un rectángulo de selección que mueve varios a la vez, bloques nuevos sacados de un menú «/» o de una barra lateral, copias con Alt, y Undo para todo.

Editar esta página

Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés

Live

Cargando la 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>

Cada bloque tiene un asa (⋮⋮) y un «+» en el margen. Escribe en cualquier bloque: Enter empieza el siguiente, Tab lo anida, «/» abre el menú de bloques nuevos, y el Markdown funciona ("## ", "- ", " "). Dibuja un rectángulo en el margen para seleccionar varios bloques y arrástralos juntos. Amplía el marco: en una pantalla ancha, los bloques que añadir y el esquema de la página tienen barras laterales propias.

Qué observar

  • Page.vue: la página es un solo árbol ordenable, con placeholder: 'indicator': la página se queda quieta mientras arrastras, y una línea muestra dónde aterrizan los bloques y a qué nivel. La hoja alrededor de los bloques es su dropzone, así que un drop debajo del último bloque aterriza al final.
  • BlockRow.vue: un bloque. Solo su asa lo arrastra (handle), así que el texto sigue siendo editable y seleccionable. El asa es un botón: un clic selecciona el bloque y abre su menú, Espacio agarra el bloque para el teclado.
  • BlockText.vue y Editable.vue: texto contenteditable simple con las teclas de un editor. Lo que se escribe va del DOM al modelo; el modelo vuelve al DOM solo cuando otra cosa lo ha cambiado (Undo, una fusión), así que el cursor nunca salta.
  • editor.ts: el documento y cada edición, sin nada de arrastrar y soltar: dividir, fusionar, sangrar, duplicar, convertir, y un historial que deshace tanto drops como escritura.

Anidar arrastrando de 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' toma el nivel solo del desplazamiento lateral del arrastre: pasar por encima del centro de un párrafo no suelta dentro de él, como en un editor de documentos. canNest dice qué bloques aceptan bloques anidados (párrafos, listas, to-dos, toggles, citas, avisos; no títulos, código ni imágenes), y cuando el bloque de arriba rechaza, la línea se queda en el nivel más profundo que pasa. Un toggle cerrado (collapsed) sigue aceptando bloques: una línea un nivel más adentro bajo él los mete dentro, los primeros.

Cada fila dibuja la línea a partir de su indicator, del nivel en el que aterriza el drop. Las listas numeradas cuentan desde items, así que "2." y "a." siguen cada drop y cada Tab. animateChanges: true hace que también se deslicen y se desvanezcan los cambios que no hace ningún arrastre: Enter, Tab, Supr, Undo, un toggle que se abre.

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

Seleccionar varios

La página es un área de selección: un rectángulo dibujado desde sus márgenes selecciona los bloques que toca. strategy: 'select' hace que un clic en el margen vacío borre la selección, y accept limita el rectángulo a los bloques. Un clic en un asa selecciona su bloque, Mayús-clic un tramo de bloques, Ctrl/Cmd-clic uno más; Escape en un texto selecciona su bloque. Luego:

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

Arrastra un bloque seleccionado y los demás lo acompañan: el árbol los mueve como un solo tramo, cada uno con lo que tiene anidado, en orden de lectura. Supr los borra, Ctrl/Cmd+D los duplica, y el menú del asa los convierte en otro tipo. Las flechas van de asa en asa, Mayús añade a la selección.

Bloques nuevos desde un menú

«+» en el margen y «/» en un bloque abren el mismo menú. Un clic añade el bloque debajo; cada opción también es un elemento arrastrable simple, que el createItem del árbol convierte en un bloque nuevo donde se suelta. La barra lateral de las pantallas anchas lista las mismas opciones, y ahí Espacio agarra una para el teclado: una flecha hacia la página la mete en ella.

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

Mientras una opción viaja, el menú se desvanece para que la página muestre adónde va, y se cierra cuando el bloque nuevo ha aterrizado:

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

Lo que hará el drop

BlockGhost.vue es el overOverlay del árbol: mientras un arrastre está sobre la página, envuelve el overlay propio del arrastre (<slot />, el clon de los bloques o de la opción) con un contador y una nota. to.parentKey nombra el bloque en el que entraría el drop, y mode dice cuándo 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

El árbol escribe cada drop en editor.blocks, una ref modificable. Su onDrop se ejecuta justo antes de esa escritura, así que guarda la página tal como estaba, y Undo deshace el drop. Todas las demás ediciones se guardan de la misma forma; las pulsaciones escritas en un mismo bloque forman un solo paso.

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

El historial guarda copias de los bloques con sus ids, así que un bloque conserva su fila y su selección a través de Undo y Redo. Tras un drop, un bloque nuevo se queda con el cursor, y uno movido le da el foco a su asa: su fila se movió en la página, lo que hace perder el foco, y los atajos siguen funcionando desde el asa.

Teclado y lectores de pantalla

Escape en un texto selecciona el bloque y pone el foco en su asa. Desde ahí Espacio lo agarra, las flechas mueven la línea (izquierda y derecha: un nivel hacia fuera o hacia dentro), Espacio lo suelta; Enter abre su menú, Supr lo borra. Los announcements del provider nombran los bloques por su tipo y su texto ("Picked up to-do list “Freeze the API”"), y la línea de estado de la barra superior dice lo que hacen los botones y los atajos.

Más información: Árboles · Multi-drag y selección · Elementos arrastrables simples en una lista · Destinos que cambian el overlay · Cuándo una pulsación se convierte en arrastre · Accesibilidad