Documentation/Vue d'ensemble
Éditeur de blocs
Une page à la Notion — des blocs glissés par leurs poignées et imbriqués sur le côté, un rectangle de sélection qui en déplace plusieurs à la fois, de nouveaux blocs tirés d'un menu « / » ou d'une barre latérale, des copies avec Alt, et Undo pour tout cela.
Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais
Chargement de la démo…
<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>Chaque bloc a une poignée (⋮⋮) et un « + » dans la marge. Tapez dans n'importe quel bloc : Entrée commence le suivant, Tab l'imbrique, « / » ouvre le menu des nouveaux blocs, et le Markdown fonctionne ("## ", "- ", " "). Tracez un rectangle dans la marge pour sélectionner plusieurs blocs, puis faites-les glisser ensemble. Agrandissez le cadre : sur un écran large, les blocs à ajouter et le plan de la page ont chacun leur barre latérale.
Ce qu'il faut regarder
Page.vue: la page est un seul arbre triable, avecplaceholder: 'indicator': la page reste immobile pendant le glissement, et une ligne montre où les blocs atterrissent et à quel niveau. La feuille autour des blocs est sadropzone: un dépôt sous le dernier bloc atterrit à la fin.BlockRow.vue: un bloc. Seule sa poignée le fait glisser (handle) : le texte reste modifiable et sélectionnable. La poignée est un bouton : un clic sélectionne le bloc et ouvre son menu, Espace saisit le bloc pour le clavier.BlockText.vueetEditable.vue: du textecontenteditablesimple avec les touches d'un éditeur. La saisie va du DOM vers le modèle ; le modèle ne retourne dans le DOM que quand autre chose l'a changé (Undo, une fusion) : le curseur ne saute donc jamais.editor.ts: le document et chaque modification, sans aucun glisser-déposer : scinder, fusionner, indenter, dupliquer, transformer, et un historique qui annule aussi bien les dépôts que la saisie.
Imbriquer en glissant sur le côté
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' prend le niveau uniquement dans le décalage latéral du glissement : survoler le
milieu d'un paragraphe ne dépose pas dedans, comme dans un éditeur de documents. canNest dit
quels blocs acceptent des blocs imbriqués (paragraphes, listes, to-dos, toggles, citations,
encadrés ; pas les titres, le code ni les images), et quand le bloc du dessus refuse, la ligne
reste au niveau le plus profond qui passe. Un toggle fermé (collapsed) accepte quand même des
blocs : une ligne un niveau plus à l'intérieur sous lui les met dedans, en premier.
Chaque ligne dessine le trait d'après son indicator, au niveau où le dépôt atterrit. Les listes
numérotées comptent à partir de items : "2." et "a." suivent donc chaque dépôt et chaque Tab.
animateChanges: true fait aussi glisser et s'estomper les changements qu'aucun glissement ne fait :
Entrée, Tab, Suppr, Undo, un toggle qui s'ouvre.
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` };
});
En sélectionner plusieurs
La page est une zone de sélection : un rectangle tracé depuis ses marges sélectionne les blocs qu'il
touche. strategy: 'select' fait qu'un clic dans la marge vide vide la sélection, et accept
limite le rectangle aux blocs. Un clic sur une poignée sélectionne son bloc, Maj-clic une série de
blocs, Ctrl/Cmd-clic un de plus ; Échap dans un texte sélectionne son bloc. Ensuite :
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
});
Faites glisser un bloc sélectionné et les autres suivent : l'arbre les déplace comme une seule série, chacun avec ce qui y est imbriqué, dans l'ordre de lecture. Suppr les supprime, Ctrl/Cmd+D les duplique, et le menu de la poignée les transforme en un autre type. Les flèches vont de poignée en poignée, Maj ajoute à la sélection.
De nouveaux blocs depuis un menu
« + » dans la marge et « / » dans un bloc ouvrent le même menu. Un clic ajoute le bloc en dessous ;
chaque option est aussi un élément déplaçable simple, que le createItem de l'arbre transforme en
nouveau bloc là où il est déposé. La barre latérale des écrans larges liste les mêmes options, et
là, Espace en saisit une pour le clavier : une flèche vers la page l'y fait entrer.
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
});
Pendant qu'une option voyage, le menu s'estompe pour que la page montre où elle va, et il se ferme une fois que le nouveau bloc a atterri :
useDnDMonitor({
onDragStart: ({ active }) => (lifted.value = !!popover.value?.contains(active.element)),
onDragSettle: () => {
if (!lifted.value) return;
lifted.value = false;
menu.value = null;
},
});
Ce que fera le dépôt
BlockGhost.vue est l'overOverlay de l'arbre : pendant qu'un glissement est au-dessus de la
page, il enveloppe l'overlay propre au glissement (<slot />, le clone des blocs ou de l'option)
avec un compteur et une note. to.parentKey désigne le bloc dans lequel irait le dépôt, et mode
dit quand il copie.
<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
L'arbre écrit chaque dépôt dans editor.blocks, une ref modifiable. Son onDrop s'exécute juste
avant cette écriture : il enregistre donc la page telle qu'elle était, et Undo annule le dépôt.
Toutes les autres modifications s'enregistrent de la même façon ; les frappes tapées dans un même
bloc forment une seule étape.
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;
};
L'historique contient des copies des blocs avec leurs ids : un bloc garde donc sa ligne et sa sélection à travers Undo et Redo. Après un dépôt, un nouveau bloc prend le curseur, et un bloc déplacé donne le focus à sa poignée : sa ligne a bougé dans la page, ce qui fait perdre le focus, et les raccourcis continuent de fonctionner depuis la poignée.
Clavier et lecteurs d'écran
Échap dans un texte sélectionne le bloc et donne le focus à sa poignée. De là, Espace le saisit, les
flèches déplacent la ligne (gauche et droite : un niveau vers l'extérieur ou l'intérieur), Espace
le dépose ; Entrée ouvre son menu, Suppr le supprime. Les announcements du provider désignent
les blocs par leur type et leur texte ("Picked up to-do list “Freeze the API”"), et la ligne
d'état de la barre du haut dit ce que font les boutons et les raccourcis.
En savoir plus : Arbres · Multi-drag et sélection · Des éléments déplaçables simples dans une liste · Cibles qui changent l'overlay · Quand un appui devient un glissement · Accessibilité