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.

Modifier cette page

Cette page a été traduite par IA et peut contenir des inexactitudes. Lire l'original en anglais

Live

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 avec placeholder: '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. dropzone confie 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 groupe blocks. 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 avec pull: 'clone', put: false et sort: 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 avec createItem.
  • 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.

SectionGhost.vue
<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.

PageSection.vue
.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.

BlockGhost.vue
// 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 :

ColumnList.vue
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

Palette.vue
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

Layers.vue
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 :

index.vue
<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.

Builder.vue
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é