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.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
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, conplaceholder: '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 sudropzone, 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.vueyEditable.vue: textocontenteditablesimple 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
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.
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:
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.
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:
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.
<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.
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;
};
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