Dokumentation/Übersicht

Page-Builder

Eine Landingpage, per Drag and Drop zusammengesetzt – Abschnitte aus einer Palette, die über der Seite zum echten Ding werden, Elemente, die zwischen Spalten wandern, ein Ebenenbaum auf denselben Daten, Bearbeiten direkt im Text, Gerätebreiten und Undo.

Diese Seite wurde von KI übersetzt und kann Ungenauigkeiten enthalten. Englisches Original lesen

Live

Demo wird geladen…

<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>

Klappe den Rahmen auf Vollbild auf: Die Palette dockt links an, die Ebenen rechts, und die Seite nimmt den Platz dazwischen ein. Schmaler teilen sie sich eine Schublade, die sich aus der oberen Leiste öffnet.

Worauf du achten solltest

  • Canvas.vue: Die Seite ist eine sortierbare Liste von Abschnitten mit placeholder: 'indicator'. Sie bleibt still, während ein Abschnitt unterwegs ist, und eine Linie zwischen zwei Abschnitten sagt, wohin er kommt: 500 px hohe Abschnitte bei jedem Slot aus dem Weg zu schieben, würde die ganze Seite durchschütteln. dropzone gibt den Drop an die ganze Leinwand, ein neben oder unter der Seite losgelassener Abschnitt landet also trotzdem.
  • ColumnList.vue: Jede Spalte jedes Abschnitts ist eine Liste der Gruppe blocks. Elemente wandern zwischen Spalten und Abschnitten und sortieren sich live: Die Seite zeigt das Ergebnis, während du ziehst. Jede Spalte ist eine eigene Komponente, und die Spalten sind Listen in den Elementen einer anderen Liste.
  • Palette.vue: Die Abschnittsvorlagen sind eine Liste der Gruppe der Seite mit pull: 'clone', put: false und sort: false: Sie gibt Kopien heraus und nimmt nichts zurück. Die Elemente darunter (ElementChip.vue) sind einfache ziehbare Elemente, und eine Spalte macht mit createItem den Block daraus.
  • Layers.vue: dieselbe Seite als sortierbarer Baum, auf denselben Daten. Eine dort verschobene Zeile verschiebt sich auf der Seite, und umgekehrt.
  • builder.ts: die Seite, die Auswahl, das Gerät und der Verlauf. Drops schreiben die Seite über die Listen; die Buttons der Werkzeugleiste und die Tastenkürzel ändern sie hier. Jede Änderung ist ein Schritt von Undo.

Ein Abschnitt in der Hand ist der echte Abschnitt

SectionGhost.vue ist das Overlay sowohl der Abschnitte der Seite als auch der Vorlagen der Palette. Über der Seite (to.list ist die Liste der Seite) rendert es den Abschnitt selbst, in der Breite der Seite, verkleinert unter dem Zeiger. Wenn der Drop entschieden ist (status: 'dropping'), skaliert es zurück auf 1, während die Box zum Platz des Abschnitts fliegt: Sie landet genau darauf. Der Drop wartet auf diese Transition, am Ende springt also nichts.

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>

Abseits der Seite bleibt eine Vorlage, was sie in der Palette war: DragPreview zeigt die Kopie ihrer Karte, die die Bibliothek angelegt hat. Der Platz des Abschnitts auf der Seite ist der Slot: Während du ziehst, wartet er dort, abgeblendet; nach dem Drop bleibt sein neuer Platz leer, bis das Overlay darauf gelandet ist.

PageSection.vue
.page-section[data-dnd-placeholder] { opacity: 0; }
html[data-dnd-dragging] .page-section[data-dnd-placeholder] { opacity: 0.35; }

Jeder Abschnitt zeichnet die Linie aus seinem eigenen indicator und sagt, was der Drop tut: Move Features here oder Add Pricing here für eine Vorlage.

Elemente: in den Farben des Abschnitts, in den sie kommen

Blöcke werden an ihrem ganzen Körper gezogen; ein Abschnitt am Griff seines Etiketts, da sein Körper Inhalt zum Klicken und Ziehen ist. BlockGhost.vue, das Overlay von Blöcken und Chips, liest aus to die Spalte, in die es käme, und übernimmt die Farben ihres Abschnitts: Ein Button über dem dunklen Footer wird hell, bevor du loslässt. Die Box hat die Größe des Slots des Blocks dort (overlayFit: 'slot', der Standard), und der Block ordnet sich darin so an, wie er es nach dem Drop tun wird.

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;
});

Ein Chip der Palette ist ein einfaches ziehbares Element, das seine Art trägt. Jede Spalte macht einen Block daraus, und der neue Block sitzt in der Spalte, vom eigenen Template der Spalte gerendert, bis du loslässt oder weiterziehst:

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 ist ein beschreibbares computed über props.column.blocks statt toRef: Undo legt neue Objekte in die Seite, und die Spalte muss ihnen folgen.

Vorlagen: Kopien hinaus, nichts hinein

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,
});

Die Vorlagen sind echte Abschnitte, ihre Karten zeigen sie also (herausgezoomt), und das Overlay kann sie rendern. Ein Klick auf eine Karte oder Enter fügt den Abschnitt nach dem ausgewählten ein: Die Seite lässt sich ganz ohne Ziehen bauen.

Ebenen: dieselbe Seite als Baum

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'),
});

Schlüssel gelten für den ganzen Provider, also bekommen die Zeilen eigene Schlüssel. Die Spalten sind auch Zeilen, disabled: Sie nehmen Blöcke an und bleiben, wo sie sind. Wo ein Block nicht hindarf, zwischen zwei Abschnitten, wird seine Linie rot. Das Darüberfahren über eine Zeile umrandet ihren Knoten auf der Seite; einen Knoten auf der Seite auszuwählen öffnet hier seinen Abschnitt und scrollt seine Zeile ins Bild.

Bearbeiten direkt im Text, und die Tastatur

Ein Block lässt sich von überall auf ihm ziehen, seine Texte sind also nicht bearbeitbar, bis du ihn doppelklickst: Dann sind sie contenteditable, und canDrag lehnt die Drags ab, sodass ein Drücken den Cursor setzt. EditableText.vue schreibt das DOM nur dann aus dem Modell, wenn der Text gerade nicht bearbeitet wird, der Cursor springt also nie, und Undo wird trotzdem sichtbar.

Der Provider behält Space für Drags und überlässt Enter dem Editor:

index.vue
<DnDProvider
  :keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
  :announcements="announcements"
  :screen-reader-instructions="instructions"
>

Springe mit Tab zu einem Block: Space nimmt ihn auf, und die Pfeile bewegen ihn durch seine Spalte und in die nächsten; Enter bearbeitet ihn. Ein Abschnitt bewegt sich an seinem Griff genauso oder mit den Pfeilen seiner Werkzeugleiste. CtrlZ macht rückgängig, Delete entfernt, CtrlD dupliziert. Screenreader hören „the Hero section, position 3 of 7 on the page“ statt IDs.

Undo und Änderungen ohne Drag

Jeder Drop ist ein Schritt des Verlaufs: Ein Monitor sichert die Seite, sobald die Listen sie geschrieben haben.

Builder.vue
useDnDMonitor({ onDragEnd: () => builder.commit() });

Undo legt einen Schnappschuss zurück. Die Listen haben animateChanges an, also gleitet, was der Schnappschuss verschoben hat, dorthin, wie auch die Abschnitte, die die Werkzeugleiste hoch und runter bewegt; hinzugefügte Abschnitte blenden sich ein und gelöschte aus.

Mehr dazu: Sortierbare Listen · Listen mit gemeinsamen Elementen · Bäume · Das Overlay · Animationen · Barrierefreiheit