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
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 mitplaceholder: '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.dropzonegibt 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 Gruppeblocks. 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 mitpull: 'clone',put: falseundsort: false: Sie gibt Kopien heraus und nimmt nichts zurück. Die Elemente darunter (ElementChip.vue) sind einfache ziehbare Elemente, und eine Spalte macht mitcreateItemden 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.
<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.
.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.
// 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:
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
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
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:
<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.
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