Documentation/Vue d'ensemble
Constructeur de pages
Une landing page assemblée par glisser-déposer — des sections depuis une palette qui deviennent l'élément réel au-dessus de la page, des éléments déplacés entre les colonnes, un arbre des calques sur les mêmes données, l'édition sur place, les largeurs d'appareil et l'annulation.
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 AnnouncedPosition, type Announcements } from '@vue-dnd-kit/core';
import Builder from './Builder.vue';
import { BUILDER, LAYERS, PAGE, createBuilder } from './builder';
import { BLOCKS, SECTIONS, excerpt, isColumn, isSection, type ChipData, type LayerNode } from './page';
// Provided above the provider: the overlays it renders copy the page's look from it.
const builder = createBuilder();
provide(BUILDER, builder);
/** What a drag carries, in words: "the Hero section", "a new Pricing section", "the heading “…”". */
const what = (active: Active) => {
const node = active.items[0]?.item as LayerNode | undefined;
// A chip of the palette: a plain draggable, with no list item.
if (!node) return `a new ${BLOCKS[(active.data as ChipData).kind].name.toLowerCase()}`;
if (isSection(node)) return `${builder.find(node.id) ? 'the' : 'a new'} ${SECTIONS[node.kind].name} section`;
if (isColumn(node)) return 'the column';
return `the ${BLOCKS[node.kind].name.toLowerCase()} “${excerpt(node)}”`;
};
/** Where a position of a list is, in words. */
const where = ({ list, index, length }: AnnouncedPosition) => {
const place = `${index + 1} of ${length}`;
if (list === PAGE) return `position ${place} on the page`;
if (list === LAYERS) return `row ${place} of the layers`;
const at = builder.find(String(list));
if (!at?.column) return `position ${place}`;
return `position ${place} in column ${at.section.columns.indexOf(at.column) + 1} of ${SECTIONS[at.section.kind].name}`;
};
const announcements: Partial<Announcements> = {
onDragStart: ({ active }) => `Picked up ${what(active)}.`,
onDragOver: ({ active }, { position }) => (position ? `${what(active)}, ${where(position)}` : undefined),
onDragEnd: ({ active, dropped }, { position }) => (dropped && position ? `Dropped ${what(active)} at ${where(position)}.` : undefined),
onDragCancel: ({ active }) => `Cancelled. Nothing changed for ${what(active)}.`,
};
// Space picks up and drops. Enter is left to the editor: it edits a text, adds a template.
const keyboard = { keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } };
const instructions =
'Space picks up; the arrow keys move; Space drops; Escape cancels. Enter edits the focused text, or adds the focused template.';
</script>
<template>
<DnDProvider :announcements="announcements" :keyboard="keyboard" :screen-reader-instructions="instructions">
<Builder />
</DnDProvider>
</template>Agrandissez le cadre en plein écran : la palette se fixe à gauche, les calques à droite, et la page prend la place entre les deux. Plus étroit, ils partagent un tiroir qui s'ouvre depuis la barre du haut.
Ce qu'il faut regarder
Canvas.vue: la page est une liste triable de sections avecplaceholder: 'indicator'. Elle reste immobile pendant qu'une section voyage, et une ligne entre deux sections dit où elle va : écarter des sections de 500 px à chaque emplacement ferait trembler toute la page.dropzoneconfie le dépôt à tout le canevas : une section relâchée à côté ou sous la page atterrit quand même.ColumnList.vue: chaque colonne de chaque section est une liste du groupeblocks. Les éléments se déplacent entre les colonnes et les sections et se trient en direct : la page montre le résultat pendant le glissement. Chaque colonne est un composant à part entière, et les colonnes sont des listes à l'intérieur des items d'une autre liste.Palette.vue: les modèles de section sont une liste du groupe de la page avecpull: 'clone',put: falseetsort: false: elle distribue des copies et ne reprend rien. Les éléments en dessous (ElementChip.vue) sont des éléments déplaçables simples, et une colonne crée le bloc aveccreateItem.Layers.vue: la même page sous forme d'arbre triable, sur les mêmes données. Une ligne déplacée là bouge sur la page, et inversement.builder.ts: la page, la sélection, l'appareil et l'historique. Les dépôts écrivent la page via les listes ; les boutons de la barre d'outils et les raccourcis la modifient ici. Chaque changement est une étape d'annulation.
Une section en main est la vraie section
SectionGhost.vue est l'overlay à la fois des sections de la page et des modèles de la
palette. Au-dessus de la page (to.list est la liste de la page), il rend la section elle-même, à
la largeur de la page, réduite sous le pointeur. Quand le dépôt est décidé (status: 'dropping'),
il revient à l'échelle 1 pendant que la boîte vole jusqu'à la place de la section : il atterrit
exactement dessus. Le dépôt attend cette transition : rien ne saute à la fin.
<script setup lang="ts">
const props = defineProps<{ items: DragItem[]; status: DnDStatus; to: DropDestination | null }>();
const section = computed(() => props.items[0]!.item as Section);
const fromPalette = computed(() => !find(section.value.id));
const onPage = computed(() => props.to?.list === PAGE);
const look = computed(() => {
if (fromPalette.value && !onPage.value) return 'card'; // still the palette's card
return props.status === 'dropping' ? 'landing' : 'ghost';
});
</script>
<template>
<DragPreview v-if="look === 'card'" :id="items[0]!.id" :style="{ width: '100%', height: '100%' }" />
<div v-else class="replica" :class="look" :style="{ width: `${pageWidth}px`, '--shrink': shrink }">
<SectionView :section="section" />
</div>
</template>
<style scoped>
.replica { transform-origin: 0 0; scale: var(--shrink); transition: scale 0.25s ease; }
.replica.landing { scale: 1; }
</style>
Loin de la page, un modèle reste ce qu'il était dans la palette : DragPreview montre la copie de
sa carte faite par la bibliothèque. La place de la section sur la page est l'emplacement : pendant
le glissement, elle y attend, estompée ; une fois déposée, sa nouvelle place reste vide jusqu'à ce
que l'overlay y ait atterri.
.page-section[data-dnd-placeholder] { opacity: 0; }
html[data-dnd-dragging] .page-section[data-dnd-placeholder] { opacity: 0.35; }
Chaque section dessine la ligne d'après son propre indicator, et dit ce que fait le dépôt :
Move Features here, ou Add Pricing here pour un modèle.
Les éléments : aux couleurs de la section où ils vont
Les blocs se font glisser par tout leur corps ; une section par la poignée de son étiquette, car
son corps est du contenu à cliquer et à faire glisser. BlockGhost.vue, l'overlay des blocs et
des pastilles, lit dans to la colonne où il irait et prend les couleurs de sa section : un bouton
au-dessus du pied de page sombre devient clair avant que vous ne relâchiez. La boîte a la taille
de l'emplacement du bloc à cet endroit (overlayFit: 'slot', la valeur par défaut), et le bloc s'y
met en page comme il le fera après le dépôt.
// The column it would go into (`to.list` is the column's id): its section's tone.
const look = computed(() => {
const at = props.to && find(String(props.to.list));
return at ? { ...TONES[SECTIONS[at.section.kind].tone], ...typeScale(pageWidth.value) } : null;
});
Une pastille de la palette est un élément déplaçable simple qui transporte son type. Chaque colonne en fait un bloc, et le nouveau bloc se place dans la colonne, rendu par le template propre à la colonne, jusqu'à ce que vous relâchiez ou passiez ailleurs :
makeSortableList(list, {
id: props.column.id,
items: blocks,
group: 'blocks',
overlay: BlockGhost,
createItem: (active) => {
const kind = (active.data as ChipData | undefined)?.kind;
return kind ? createBlock(BLOCKS[kind].spec) : undefined;
},
});
blocks est un computed modifiable sur props.column.blocks plutôt qu'un toRef : l'annulation
met de nouveaux objets dans la page, et la colonne doit les suivre.
Les modèles : des copies sortent, rien n'entre
makeSortableList(list, {
id: 'templates',
items: templates,
group: { name: 'sections', pull: 'clone', put: false },
sort: false,
clone: (template) => createSection(template.kind), // new ids all the way down
overlay: SectionGhost,
});
Les modèles sont de vraies sections : leurs cartes les montrent donc (en réduction) et l'overlay peut les rendre. Un clic sur une carte, ou Enter, ajoute la section après celle qui est sélectionnée : la page peut se construire sans aucun glissement.
Les calques : la même page sous forme d'arbre
makeSortableTree(tree, {
id: LAYERS,
items: sections as Ref<LayerNode[]>,
// Keys of their own: the canvas's lists already drag these ids.
getKey: (node) => `layer-${node.id}`,
getChildren: (node) => (isSection(node) ? node.columns : isColumn(node) ? node.blocks : undefined),
setChildren: (node, children) => { … },
// Blocks go into columns, nothing into blocks, sections stay at the top.
canNest: (node, parent) => levelOf(node) === levelOf(parent) + 1,
maxDepth: 2,
placeholder: 'indicator',
// A tree takes any row at its top level: a block let go there flies back.
onBeforeDrop: (event) => (event.to.parentKey !== null || event.items.every(isSection) ? 'accept' : 'reject'),
});
Les clés sont partagées par tout le provider : les lignes reçoivent donc des clés à elles. Les
colonnes sont aussi des lignes, disabled : elles acceptent des blocs et restent où elles sont.
Là où un bloc ne peut pas aller, entre deux sections, sa ligne devient rouge. Survoler une ligne
entoure son nœud sur la page ; sélectionner un nœud sur la page ouvre sa section ici et fait
défiler sa ligne jusqu'à la rendre visible.
L'édition sur place, et le clavier
Un bloc se fait glisser depuis n'importe quel point : ses textes ne sont donc pas modifiables
tant que vous ne double-cliquez pas dessus ; ils deviennent alors contenteditable, et canDrag
refuse les glissements pour qu'un appui place le curseur. EditableText.vue n'écrit le DOM
depuis le modèle que tant que le texte n'est pas en cours de modification : le curseur ne saute
donc jamais, et l'annulation s'affiche quand même.
Le provider garde Space pour les glissements et laisse Enter à l'éditeur :
<DnDProvider
:keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
:announcements="announcements"
:screen-reader-instructions="instructions"
>
Allez sur un bloc avec Tab : Space le saisit et les flèches le déplacent dans sa colonne et jusque dans les suivantes ; Enter le modifie. Une section se déplace par sa poignée de la même façon, ou par les flèches de sa barre d'outils. CtrlZ annule, Delete supprime, Ctrl D duplique. Les lecteurs d'écran entendent "the Hero section, position 3 of 7 on the page" plutôt que des ids.
L'annulation, et les changements sans glissement
Chaque dépôt est une étape de l'historique : un moniteur sauvegarde la page une fois que les listes l'ont écrite.
useDnDMonitor({ onDragEnd: () => builder.commit() });
L'annulation remet un instantané en place. Les listes ont animateChanges activé : ce que l'instantané a
déplacé y glisse donc, comme les sections que la barre d'outils monte et descend ; les sections
ajoutées apparaissent en fondu et celles qui sont supprimées disparaissent en fondu.
En savoir plus : Listes triables · Listes qui partagent des items · Arbres · L'overlay · Animations · Accessibilité