Документация/Руководства
Мульти-драг и выделение
Выделяйте несколько элементов кликами или рамкой и перетаскивайте их вместе — блоком, оставляя пропуски или стопкой в оверлее, — принимайте при броске только часть и показывайте их в собственном оверлее.
Страница переведена ИИ и может содержать неточности. Читать английский оригинал
Выделите несколько файлов, затем перетащите один из них: остальные поедут следом.
Переключайте multiple, чтобы увидеть, что они делают по пути, и раскладку, чтобы увидеть
оверлей сетки.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Files from './Files.vue';
</script>
<template>
<DnDProvider>
<Files />
</DnDProvider>
</template>Выделение
Каждый провайдер хранит одно выделение: реактивный набор id. Драг выделенного элемента перетаскивает все выделенные элементы, взятый первым. Драг невыделенного элемента перетаскивает только его.
Библиотека не решает, как люди выделяют: правила кликов — ваши. Сама она выделение тоже никогда не сбрасывает, и выделение переживает размонтирование, например строк, которые переиспользует виртуальный список.
Клики: Ctrl/Cmd и Shift
Каждый сортируемый элемент и перетаскиваемый элемент возвращает selected — записываемый ref,
связанный с выделением. Привяжите его к классу, к чекбоксу (v-model="selected") или
задавайте в обработчике клика:
<script setup lang="ts">
const { selected } = makeSortableItem(el, { item: () => props.file });
</script>
<template>
<li ref="el" class="file" :class="{ selected }" @click="emit('pick', $event)">…</li>
</template>
Правилами владеет список в демо: клик выделяет один файл, Ctrl/Cmd-клик добавляет или убирает один, Shift-клик выделяет диапазон от последнего кликнутого файла:
const { selection } = useDnD();
let anchor: string | null = null;
const pick = (id: string, event: MouseEvent) => {
const ids = items.value.map((file) => file.id);
if (event.shiftKey && anchor) {
const [a, b] = [ids.indexOf(anchor), ids.indexOf(id)];
selection.set(ids.slice(Math.min(a, b), Math.max(a, b) + 1));
return;
}
if (event.ctrlKey || event.metaKey) selection.toggle(id);
else selection.set([id]);
anchor = id;
};
Клик, которым заканчивается драг, не доходит до @click, поэтому бросок никогда случайно не
меняет выделение. Для действий с элементом используйте @click, а не pointerup.
Откуда угодно: useDnD().selection
import { useDnD, useDnDMonitor } from '@vue-dnd-kit/core';
const { selection } = useDnD();
selection.has('a'); // is it selected?
selection.add('a', 'b'); // select
selection.delete('a'); // deselect
selection.toggle('a'); // flip it, or force it: toggle('a', true)
selection.set(['a', 'c']); // exactly these
selection.clear();
selection.ids; // a reactive ReadonlySet<Key>
// Clear it after every drop, if your UI wants that:
useDnDMonitor({ onDragEnd: () => selection.clear() });
Выделение хранится по id, поэтому может содержать элементы нескольких списков, а также обычные перетаскиваемые элементы: драг берёт каждый выделенный элемент под провайдером.
Выделение рамкой
makeSelectionArea превращает элемент в область, где нажатие на пустое место рисует рамку, а
элементы, которые она пересекает, выделяются. На этом рабочем столе обведите рамкой несколько
иконок, затем перетащите одну из них:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Desktop from './Desktop.vue';
</script>
<template>
<!-- Every selected icon gets a clone at pickup: the overlay shows them all. -->
<DnDProvider overlay-previews="all">
<Desktop />
</DnDProvider>
</template><script setup lang="ts">
const desktop = useTemplateRef<HTMLElement>('desktop');
const { isSelecting, style } = makeSelectionArea(desktop);
</script>
<template>
<!-- The area needs a position: the band is placed inside it. -->
<div ref="desktop" class="desktop">
<DesktopIcon v-for="icon in icons" :key="icon.id" :icon="icon" />
<div v-if="isSelecting" class="band" :style="style" />
</div>
</template>
<style scoped>
.desktop { position: relative; height: 236px; }
.band { border: 1px solid #42b883; background: color-mix(in srgb, #42b883 15%, transparent); }
</style>
- Рамку рендерите вы:
styleразмещает её относительно содержимого области. - Области нужно нестатическое позиционирование (
relativeподойдёт), иначе рамка окажется в другом месте. - Рамка начинается только с самой области, поэтому делайте область большой: всю панель, с пространством вокруг элементов, а не тесную обёртку.
- Нажатие на элемент перетаскивает его. Кнопки, поля и полоса прокрутки области сохраняют свои нажатия.
- Рамка остаётся привязанной к содержимому, пока прокручивается область или страница. Escape отменяет её и восстанавливает выделение.
- Пока рисуется рамка,
useDnD().statusравен'selecting': строка под рабочим столом его читает.
| Опция | Что делает |
|---|---|
strategy | 'toggle' (по умолчанию): элементы, которые пересекает рамка, переключаются. 'select': выделенными оказываются только элементы внутри рамки, так что клик по пустому месту снимает выделение с элементов области. |
modifier | Клавиши, которые нужно удерживать, чтобы рисовать: { keys: ['Shift'], method: 'every' } (или 'some'). |
accept | Какие перетаскиваемые элементы выделяет рамка — правило accept, сверяемое с их type. |
disabled | Рамки нет; включение отменяет текущую. |
onSelected(ids) | Вызывается, когда рамка завершается, с выделением. |
strategy, modifier и disabled принимают значение, ref или геттер и читаются, когда | |
| нажатие начинает рамку. Если области вложены друг в друга, нажатие забирает самая внутренняя. | |
| На странице API makeSelectionArea их можно переключать вживую. |
Перемещение в том виде, как они лежат
Иконки — обычные перетаскиваемые элементы (makeDraggable), у каждой своя иконка в data.
Их оверлей — собственный компонент демо: он раскладывает DragPreview каждой перетаскиваемой
иконки так, как иконки лежат на рабочем столе, вокруг взятой. Иконки ждут на месте,
приглушённые по isDragging, который остаётся true, пока их копии не приземлятся.
<script setup lang="ts">
const props = defineProps<{ items: DragItem[] }>();
const placed = computed(() => {
const grabbed = props.items[0]!.data as Icon;
return props.items.map(({ id, data }) => {
const icon = data as Icon;
return { id, left: `${icon.x - grabbed.x}px`, top: `${icon.y - grabbed.y}px` };
});
});
</script>
<template>
<div class="formation">
<DragPreview v-for="{ id, left, top } in placed" :id="id" :key="id" class="copy" :style="{ left, top }" />
</div>
</template>
const { isDragging, selected } = makeDraggable(el, { id, data: () => props.icon, overlay: Formation });
Бросок принимает рабочий стол. Его position — место, где отпустили взятую иконку, в
координатах рабочего стола: иконка встаёт туда, а остальные сдвигаются на столько же. Затем
каждая копия приземляется на свою иконку на её новом месте. При броске за пределы рабочего
стола копии летят обратно к иконкам.
makeDroppable(desktop, {
id: 'desktop',
onDrop: ({ active, position }) => {
const grabbed = icons.value.find((icon) => icon.id === active.id)!;
const shift = { x: position!.x - grabbed.x, y: position!.y - grabbed.y };
for (const icon of icons.value) {
if (!active.ids.includes(icon.id)) continue;
icon.x += shift.x; // the demo also keeps each icon on the desktop
icon.y += shift.y;
}
},
});
По умолчанию при захвате клон снимается максимум для 5 элементов: overlay-previews="all"
провайдера снимает его для каждой выделенной иконки.
В папку
В этом окне рамка выделяет файлы, а папки их принимают. Оверлей — стандартный: стопка выбранных файлов. После броска в папку они уходят в неё один за другим, взятый — последним, и папка сглатывает. При отмене каждый летит обратно к своему файлу. Кликните по папке, чтобы открыть её, и перетащите файлы обратно в Home в строке пути.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Finder from './Finder.vue';
</script>
<template>
<!-- Every picked file gets a clone at pickup: they all go into the folder. -->
<DnDProvider overlay-previews="all">
<Finder />
</DnDProvider>
</template><!-- Every picked file gets a clone at pickup: they all go in. -->
<DnDProvider overlay-previews="all">
<Finder />
</DnDProvider>
makeDroppable(el, {
id: () => props.folder.id,
accept: 'file',
onDrop: (event) => emit('move', event.active.ids),
});
// The files are in: the folder gulps.
useDnDMonitor({
onDragSettle: (event) => {
if ('dropped' in event && event.over?.id === props.folder.id) gulps.value++;
},
});
Вот и всё: с броском файлы уходят из окна, так что приземлиться им негде, кроме как в папке, а
стандартная анимация броска отправляет мульти-драг в зону по одному элементу за другим.
overlayPreviews задаёт, сколько элементов получают клон при захвате (по умолчанию 5). Летит
каждый клон: в зону, на свой слот или обратно к своему элементу.
Папка — это кнопка: на кнопках и полях рамка никогда не начинается, поэтому клик её открывает.
Как двигаются остальные элементы: multiple
Список, в котором начинается мульти-драг, решает, что делают остальные выделенные элементы, пока он идёт. Переключите его в первом демо на этой странице:
makeSortableList(listRef, { items: files, multiple: 'gaps' });
multiple | Во время драга | Оверлей |
|---|---|---|
'group' (по умолчанию) | Остальные элементы покидают свои места и едут вместе со взятым одним блоком слотов, так что список уже показывает результат броска. | Колонка перетаскиваемых элементов вдоль списка (не больше overlayPreviews). |
'gaps' | Остальные остаются на местах, помеченные data-dnd-dragging, до броска. | Та же колонка. |
'stack' | Остальные влетают в оверлей. | Взятый элемент со счётчиком. |
При броске элементы приземляются вместе на целевой индекс, в том порядке, в котором были. В
'group' и 'gaps' каждый элемент оверлея летит в свой слот. Опция читается, когда драг
начинается, поэтому её переключают ref или геттер.
Над целью, которая не список, например над корзиной или плейлистом, или над списком, который
отклоняет драг, блок 'group' распадается: каждый элемент снова показан на своём месте, так,
как его оставит бросок туда, а блок собирается снова, как только указатель окажется над списком.
Оформите элементы, оставшиеся на местах в 'gaps', по умолчанию приглушённые:
.file[data-dnd-dragging] {
opacity: 0.35;
}
Оверлей по умолчанию
В 'group' и 'gaps' оверлей — колонка перетаскиваемых элементов в том виде, в каком они
приземлятся: взятый под указателем, остальные до и после него в порядке броска (ряд в
горизонтальных списках). Он показывает столько, сколько задаёт overlayPreviews провайдера
(по умолчанию 5, 'all' — все отрендеренные). Те, что сверх этого, считаются там, где они
есть: +N в начале колонки для элементов, которые приземлятся перед показанными, +N в конце —
для тех, что после.
В сетках колонка из целых ячеек закрыла бы страницу, поэтому оверлей — стопка: до трёх элементов веером позади взятого, с общим счётчиком сверху. Переключите демо на Grid, чтобы это увидеть.
Обычные перетаскиваемые элементы (makeDraggable) тоже дают стопку, как файлы выше. Каждый
клон в оверлее приземляется сам: на свой слот после броска в список, на свой элемент там, где
его оставил бросок (обратно на место после отмены), а в зону, куда идёт бросок, когда элементы
исчезли со страницы, — один за другим, блок последним.
Принять только часть
onBeforeDrop списка может принять часть мульти-драга: верните ключи, которые он берёт, и
остальные полетят обратно туда, откуда пришли. Он может быть асинхронным и спросить: оверлей
ждёт там, где его отпустили, пока не придёт ответ. Здесь Scheduled хочет посты с обложкой. Если
среди брошенных есть пост без неё, список спрашивает: добавить недостающие обложки и взять
все, взять только готовые посты или ни одного:
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Blog from './Blog.vue';
</script>
<template>
<DnDProvider>
<Blog />
</DnDProvider>
</template>const { items } = makeSortableList(list, {
id: props.id,
items: posts,
group: 'blog',
onBeforeDrop: async (event) => {
const missing = event.items.filter((post) => post.cover === null);
if (!props.ask || event.sameList || !missing.length) return true;
const choice = await props.ask(missing, event.items.length - missing.length); // the question under the lists
if (choice === 'cover') missing.forEach((post) => (post.cover = pickCover()));
if (choice === 'ready') return event.keys.filter((_, index) => event.items[index]!.cover !== null);
return choice === 'cover'; // all of them, or none: they fly back
},
});
event.keys— перетаскиваемые ключи, аevent.items[i]— элемент дляevent.keys[i].- После ответа
onDropполучаетacceptedиrejected, аevent.resultсодержит списки в том виде, в каком их оставляет частичный бросок. - Пока бросок ждёт, списки продолжают показывать каждый перетаскиваемый пост там, где он
приземлился бы: решает ответ. Для правила, которому не нужен вопрос,
group.putрешает ещё при наведении (см. смешанные типы ниже). Подробнее — в решении о броске.
Лимит
На этой доске у In progress и Review есть лимит карточек. Заполненная колонка не принимает
ничего: её group.put отказывает, и стопка над ней краснеет. Колонка, где есть место для части
выбранных карточек, берёт столько, сколько помещается, а остальные летят обратно. Оверлей
колонки говорит об этом ещё до броска.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Board from './Board.vue';
</script>
<template>
<DnDProvider>
<Board />
</DnDProvider>
</template>const room = (keys: Key[]) => limit - column.cards.filter((card) => !keys.includes(card.id)).length;
makeSortableList(list, {
items: cards,
group: { name: 'board', put: ({ keys, from, to }) => from === to || room(keys) > 0 },
multiple: 'stack',
overlay: CardPile, // red where `accepted` is false
overOverlay: fit,
onBeforeDrop: (event) => {
if (event.sameList) return true;
const free = room(event.keys);
return free >= event.keys.length || event.keys.slice(0, free);
},
});
fit — функция, а функцию Vue принимает как функциональный компонент. Она передаёт
FitBadge то, что знает только колонка, — сколько в ней места, — а стопку передаёт дальше
как слот бейджа.
const fit: FunctionalComponent<OverlayProps> = (overlay, { slots }) =>
h(FitBadge, { count: overlay.items.length, room: room(overlay.items.map((item) => item.id)), limit }, slots);
Смешанные типы
Список может отклонить часть элементов и по типу, ещё до броска. С acceptMode: 'some'group.put спрашивается для каждого элемента отдельно: список принимает драг, когда подходит
хотя бы один элемент, а остальные при броске летят обратно. С 'all' (по умолчанию) должен
подойти каждый элемент.
makeSortableList(gallery, {
items: photos,
group: { name: 'files', put: ({ types }) => types.includes('image') },
acceptMode: 'some',
});
Обычные цели тоже принимают acceptMode. См.
списки с общими элементами и
drag and drop.
Свой оверлей для нескольких элементов
Компоненты оверлея и слот #overlay провайдера получают items: все перетаскиваемые
элементы, взятый первым. Покажите сводку вместо самих элементов: здесь — счётчик, тему
первого письма и то, что сделает бросок.
Загружаем демо…
<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Mailbox from './Mailbox.vue';
</script>
<template>
<DnDProvider>
<Mailbox />
</DnDProvider>
</template><script setup lang="ts">
import { computed } from 'vue';
import type { DragItem, Over } from '@vue-dnd-kit/core';
const props = defineProps<{ items: DragItem[]; over: Over | null }>();
const first = computed(() => props.items[0]?.item as Mail);
const count = computed(() => props.items.length);
// What the drop does, from the `data` of the zone under it.
const zone = computed(() => props.over?.data as { verb: string; danger?: boolean } | undefined);
const action = computed(() => {
const noun = count.value === 1 ? 'conversation' : 'conversations';
return `${zone.value?.verb ?? 'Move'} ${count.value} ${noun}`;
});
</script>
<template>
<!-- The box keeps the row's size; the summary hangs off its middle, by the pointer. -->
<div class="box">
<div class="summary" :class="{ stack: count > 1, danger: zone?.danger }">
<span class="count">{{ count }}</span>
<strong>{{ action }}</strong>
<small>{{ first.subject }}{{ count > 1 ? ` and ${count - 1} more` : '' }}</small>
</div>
</div>
</template>
<style scoped>
.box { position: relative; width: 100%; height: 100%; }
.summary { position: absolute; top: calc(50% + 10px); left: calc(50% + 10px); width: 230px; }
</style>
// The box is centered on the pointer, so the summary hangs off the pointer and leaves
// the zone under it in sight.
const { items } = makeSortableList(list, { items: mails, overlay: MailOverlay, modifiers: [snapCenterToCursor] });
// A zone takes the mails out of the inbox; its `data` tells the overlay what it does.
makeDroppable(archive, {
id: 'Archive',
data: { verb: 'Archive' },
onDrop: (event) => {
event.source?.remove();
selection.clear();
},
});
Чтобы выложить сами элементы, рендерите их клоны через DragPreview, как делает
рабочий стол выше;
руководство по оверлею
раскладывает веером стопку книг. Клоны снимаются при захвате для взятого элемента и других
рядом с ним в порядке броска, столько, сколько задаёт overlayPreviews; для любого другого
элемента DragPreview рендерит свой слот по умолчанию. При броске каждое превью приземляется
на свой элемент.
Деревья
В дереве выделенные узлы перемещаются одним блоком, каждый со своим поддеревом, в порядке документа. Выделенный узел внутри другого выделенного едет в поддереве своего родителя. См. деревья.