Dokumentation/Übersicht
Block-Editor
Eine Seite wie in Notion – Blöcke, die an ihren Handles gezogen und seitwärts verschachtelt werden, ein Auswahlrahmen, der mehrere auf einmal bewegt, neue Blöcke, die aus einem „/“-Menü oder einer Seitenleiste gezogen werden, Kopien mit Alt und Undo für all das.
Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen
Demo wird geladen…
<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>Jeder Block hat ein Handle (⋮⋮) und ein „+“ am Rand. Tippe in einen beliebigen Block: Enter beginnt den nächsten, Tab verschachtelt ihn, „/“ öffnet das Menü der neuen Blöcke, und Markdown funktioniert („## “, „- “, „ “). Zieh einen Rahmen am Rand, um mehrere Blöcke auszuwählen, und ziehe sie dann gemeinsam. Klappe den Rahmen auf: Auf einem breiten Bildschirm bekommen die Blöcke zum Hinzufügen und die Gliederung der Seite eigene Seitenleisten.
Worauf du achten solltest
Page.vue: Die Seite ist ein sortierbarer Baum, mitplaceholder: 'indicator': Die Seite bleibt beim Ziehen still, und eine Linie zeigt, wo und auf welcher Ebene die Blöcke landen. Das Blatt um die Blöcke ist seinedropzone, ein Drop unter dem letzten Block landet also am Ende.BlockRow.vue: ein Block. Nur sein Handle zieht (handle), der Text bleibt also bearbeitbar und auswählbar. Das Handle ist ein Button: Ein Klick wählt den Block aus und öffnet sein Menü, Space nimmt den Block für die Tastatur auf.BlockText.vueundEditable.vue: einfachercontenteditable-Text mit den Tasten eines Editors. Getipptes geht aus dem DOM ins Modell; das Modell geht nur dann zurück ins DOM, wenn etwas anderes es geändert hat (Undo, ein Zusammenführen), der Cursor springt also nie.editor.ts: das Dokument und jede Bearbeitung daran, ganz ohne Drag and Drop: teilen, zusammenführen, einrücken, duplizieren, umwandeln, und ein Verlauf, der Drops und Tippen gleichermaßen zurücknimmt.
Verschachteln durch seitliches Ziehen
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' nimmt die Ebene nur aus dem seitlichen Versatz des Drags: Das Darüberhalten
über der Mitte eines Absatzes legt nicht in ihn ab, wie in einem Dokument-Editor. canNest sagt,
welche Blöcke verschachtelte Blöcke annehmen (Absätze, Listen, To-dos, Toggles, Zitate, Callouts;
keine Überschriften, kein Code, keine Bilder), und wenn der Block darüber ablehnt, bleibt die Linie
auf der tiefsten Ebene, die passt. Ein geschlossener Toggle (collapsed) nimmt trotzdem Blöcke
an: Eine Linie eine Ebene tiefer darunter legt sie hinein, als erste.
Jede Zeile zeichnet die Linie aus ihrem indicator, aus der Ebene, auf der der Drop landet.
Nummerierte Listen zählen aus items, also folgen „2.“ und „a.“ jedem Drop und jedem Tab.
animateChanges: true lässt auch die Änderungen gleiten und verblassen, die kein Drag macht: Enter, Tab,
Delete, Undo, ein sich öffnender Toggle.
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` };
});
Mehrere auswählen
Die Seite ist ein Auswahlbereich: Ein Rahmen, der von ihren Rändern aus gezogen wird, wählt die
Blöcke aus, die er berührt. strategy: 'select' sorgt dafür, dass ein Klick auf den leeren Rand
die Auswahl aufhebt, und accept beschränkt den Rahmen auf Blöcke. Ein Klick auf ein Handle wählt
seinen Block aus, Shift-Klick eine Reihe von Blöcken, Strg/Cmd-Klick einen weiteren; Escape in
einem Text wählt seinen Block aus. Dann:
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
});
Ziehe einen ausgewählten Block, und die anderen kommen mit: Der Baum bewegt sie als eine Reihe, jeden mit dem, was in ihm verschachtelt ist, in Lesereihenfolge. Delete entfernt sie, Strg/Cmd+D dupliziert sie, und das Menü des Handles wandelt sie in eine andere Art um. Die Pfeile gehen von Handle zu Handle, Shift fügt zur Auswahl hinzu.
Neue Blöcke aus einem Menü
„+“ am Rand und „/“ in einem Block öffnen dasselbe Menü. Ein Klick fügt den Block darunter ein;
jede Option ist außerdem ein einfaches ziehbares Element, das createItem des Baums dort, wo es
abgelegt wird, in einen neuen Block verwandelt. Die Seitenleiste breiter Bildschirme listet
dieselben Optionen, und dort nimmt Space eine für die Tastatur auf: Ein Pfeil in Richtung der
Seite holt sie hinein.
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
});
Während eine Option unterwegs ist, blendet sich das Menü aus, damit die Seite zeigt, wohin sie geht, und es schließt sich, sobald der neue Block gelandet ist:
useDnDMonitor({
onDragStart: ({ active }) => (lifted.value = !!popover.value?.contains(active.element)),
onDragSettle: () => {
if (!lifted.value) return;
lifted.value = false;
menu.value = null;
},
});
Was der Drop tun wird
BlockGhost.vue ist das overOverlay des Baums: Solange ein Drag über der Seite ist, umhüllt
es das eigene Overlay des Drags (<slot />, den Klon der Blöcke oder der Option) mit einer Anzahl
und einer Notiz. to.parentKey nennt den Block, in den der Drop ginge, und mode sagt, wann er
kopiert.
<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
Der Baum schreibt jeden Drop in editor.blocks, ein beschreibbares Ref. Sein onDrop läuft
direkt vor diesem Schreiben, hält also die Seite fest, wie sie war, und Undo nimmt den Drop
zurück. Jede andere Bearbeitung hält sich genauso fest; die Tastenanschläge in einem Block ergeben
einen einzigen Schritt.
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;
};
Der Verlauf hält Kopien der Blöcke mit ihren IDs, ein Block behält also über Undo und Redo hinweg seine Zeile und seine Auswahl. Nach einem Drop bekommt ein neuer Block den Cursor, und ein verschobener gibt den Fokus an sein Handle: Seine Zeile ist in der Seite gewandert, wodurch der Fokus verloren geht, und die Tastenkürzel funktionieren vom Handle aus weiter.
Tastatur und Screenreader
Escape in einem Text wählt den Block aus und fokussiert sein Handle. Von dort nimmt Space ihn
auf, die Pfeile bewegen die Linie (links und rechts: eine Ebene hinaus oder hinein), Space legt
ihn ab; Enter öffnet sein Menü, Delete entfernt ihn. Die announcements des Providers nennen
Blöcke nach ihrer Art und ihrem Text („Picked up to-do list “Freeze the API”“), und die
Statuszeile der oberen Leiste spricht, was die Buttons und Tastenkürzel tun.
Mehr dazu: Bäume · Multi-Drag und Auswahl · Einfache ziehbare Elemente in eine Liste · Ziele, die das Overlay ändern · Wann ein Drücken zum Drag wird · Barrierefreiheit