Documentación/Resumen
Constructor de páginas
Una landing page montada con arrastrar y soltar — secciones desde una paleta que se convierten en lo real sobre la página, elementos movidos entre columnas, un árbol de capas sobre los mismos datos, edición en línea, anchos de dispositivo y deshacer.
Esta página la ha traducido una IA y puede contener imprecisiones. Leer el original en inglés
Cargando la demo…
<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>Amplía el marco a pantalla completa: la paleta se fija a la izquierda, las capas a la derecha, y la página ocupa el espacio entre ambas. Más estrecho, comparten un cajón que se abre desde la barra superior.
Qué observar
Canvas.vue: la página es una lista ordenable de secciones conplaceholder: 'indicator'. Se queda quieta mientras viaja una sección, y una línea entre dos secciones dice adónde va: apartar secciones de 500 px en cada hueco haría temblar toda la página.dropzonele pasa el drop a todo el lienzo, así que una sección soltada al lado o debajo de la página aterriza igual.ColumnList.vue: cada columna de cada sección es una lista del grupoblocks. Los elementos se mueven entre columnas y secciones y se ordenan en vivo: la página muestra el resultado mientras arrastras. Cada columna es un componente propio, y las columnas son listas dentro de los ítems de otra lista.Palette.vue: las plantillas de sección son una lista del grupo de la página conpull: 'clone',put: falseysort: false: reparte copias y no acepta nada de vuelta. Los elementos de debajo (ElementChip.vue) son elementos arrastrables simples, y una columna crea el bloque concreateItem.Layers.vue: la misma página como árbol ordenable, sobre los mismos datos. Una fila movida ahí se mueve en la página, y al revés.builder.ts: la página, la selección, el dispositivo y el historial. Los drops escriben la página a través de las listas; los botones de la barra de herramientas y los atajos la cambian aquí. Cada cambio es un paso de deshacer.
Una sección en la mano es la sección real
SectionGhost.vue es el overlay tanto de las secciones de la página como de las plantillas de
la paleta. Sobre la página (to.list es la lista de la página), renderiza la propia sección, al
ancho de la página, reducida bajo el puntero. Cuando se decide el drop (status: 'dropping'),
vuelve a escala 1 mientras la caja vuela al sitio de la sección: aterriza exactamente sobre ella. El
drop espera a esa transición, así que nada salta al final.
<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>
Lejos de la página, una plantilla sigue siendo lo que era en la paleta: DragPreview muestra la
copia de su tarjeta que hace la biblioteca. El sitio de la sección en la página es el hueco:
mientras arrastras espera ahí, atenuado; al soltarla, su nuevo sitio se queda vacío hasta que el
overlay ha aterrizado en él.
.page-section[data-dnd-placeholder] { opacity: 0; }
html[data-dnd-dragging] .page-section[data-dnd-placeholder] { opacity: 0.35; }
Cada sección dibuja la línea a partir de su propio indicator, y dice lo que hace el drop: Move
Features here, o Add Pricing here para una plantilla.
Elementos: con los colores de la sección a la que van
Los bloques se arrastran desde todo su cuerpo; una sección, por el asa de su etiqueta, porque su
cuerpo es contenido en el que hacer clic y arrastrar. BlockGhost.vue, el overlay de los
bloques y los chips, lee en to la columna en la que entraría y toma los colores de su sección:
un botón sobre el pie de página oscuro se vuelve claro antes de que sueltes. La caja tiene el tamaño
del hueco del bloque ahí (overlayFit: 'slot', el valor por defecto), y el bloque se maqueta en
ella como lo hará después del drop.
// 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;
});
Un chip de la paleta es un elemento arrastrable simple que lleva su tipo. Cada columna crea un bloque a partir de él, y el bloque nuevo se coloca en la columna, renderizado por la plantilla propia de la columna, hasta que sueltas o sigues adelante:
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 es un computed modificable sobre props.column.blocks en lugar de un toRef: deshacer
pone objetos nuevos en la página, y la columna tiene que seguirlos.
Plantillas: salen copias, no entra nada
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,
});
Las plantillas son secciones reales, así que sus tarjetas las muestran (reducidas) y el overlay puede renderizarlas. Un clic en una tarjeta, o Enter, añade la sección después de la seleccionada: la página se puede construir sin arrastrar nada.
Capas: la misma página como árbol
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'),
});
Las claves las comparte todo el provider, así que las filas reciben claves propias. Las columnas
también son filas, disabled: aceptan bloques y se quedan donde están. Donde un bloque no puede
ir, entre dos secciones, su línea se pone roja. Pasar por encima de una fila resalta su nodo en la
página; seleccionar un nodo en la página abre su sección aquí y desplaza su fila hasta que se ve.
Editar en el sitio, y el teclado
Un bloque se arrastra desde cualquier punto, así que sus textos no son editables hasta que haces
doble clic en él: entonces son contenteditable, y canDrag rechaza los arrastres para que una
pulsación coloque el cursor. EditableText.vue escribe el DOM desde el modelo solo mientras el
texto no se está editando, así que el cursor nunca salta, y deshacer se sigue viendo.
El provider reserva Space para los arrastres y deja Enter al editor:
<DnDProvider
:keyboard="{ keys: { start: ['Space'], drop: ['Space', 'Enter'], cancel: ['Escape'] } }"
:announcements="announcements"
:screen-reader-instructions="instructions"
>
Llega a un bloque con Tab: Space lo agarra y las flechas lo mueven por su columna y hacia las siguientes; Enter lo edita. Una sección se mueve por su asa de la misma forma, o con las flechas de su barra de herramientas. Ctrl Z deshace, Delete borra, Ctrl D duplica. Los lectores de pantalla oyen "the Hero section, position 3 of 7 on the page" en lugar de ids.
Deshacer, y cambios sin arrastre
Cada drop es un paso del historial: un monitor guarda la página una vez que las listas la han escrito.
useDnDMonitor({ onDragEnd: () => builder.commit() });
Deshacer vuelve a poner una instantánea. Las listas tienen animateChanges activado, así que lo que movió
la instantánea se desliza hasta ahí, igual que las secciones que la barra de herramientas sube y
baja; las secciones añadidas aparecen con un fundido y las borradas desaparecen con otro.
Más información: Listas ordenables · Listas que comparten ítems · Árboles · El overlay · Animaciones · Accesibilidad