데모 불러오는 중…
<script setup lang="ts">
import { DnDProvider, type AnnouncedPosition, type Announcements, type DnDEvent } from '@vue-dnd-kit/core';
import FileManager from './FileManager.vue';
import type { DropPlace } from './drive';
import type { Entry } from './files';
// Space picks a focused file up and drops it; Enter is left to open folders.
const keyboard = { keys: { start: ['Space'], drop: ['Space', 'Enter'] } };
/** "Budget 2026.xlsx", or "3 items". */
const what = ({ active }: DnDEvent) => (active.items.length === 1 ? (active.items[0]!.item as Entry).name : `${active.items.length} items`);
const at = (position?: AnnouncedPosition) => (position ? `, ${position.index + 1} of ${position.length}` : '');
// Screen readers hear names, not keys, and where a drop would go, from the folder's `data`.
const announcements: Partial<Announcements> = {
onDragStart: (event, { position }) => `Picked up ${what(event)}${at(position)}.`,
onDragOver: (event, { position }) => {
const place = event.over?.data as DropPlace | undefined;
if (place) return place.why ?? `Over ${place.name}. Drop to ${event.mode === 'copy' ? 'copy' : 'move'} ${what(event)} there.`;
return position ? `${what(event)}${at(position)}.` : undefined;
},
// Into a folder, the toast says what happened.
onDragEnd: (event, { position }) => (event.over?.data ? '' : `Dropped ${what(event)}${at(position)}.`),
onDragCancel: (event) => `Cancelled: ${what(event)} went back.`,
};
</script>
<template>
<!-- One provider: its selection is what the band and the clicks fill, and what a drag takes. -->
<DnDProvider :keyboard="keyboard" :announcements="announcements">
<FileManager />
</DnDProvider>
</template>살펴볼 점
FileView.vue: 열린 폴더는 하나의 정렬 가능한 리스트이며, 타일 그리드나 행 목록으로 보입니다. 선택 사각형, 클릭, 키로 선택하고, 드래그는 선택 항목을 가져갑니다.useFolderDrop.ts: 화면의 모든 폴더가 드롭 대상입니다. 타일, 트리의 행, 즐겨찾기, 경로의 탐색 경로, 휴지통. 드래그를 거부하는 이유를 알려 주고, 파일이 들어오면 꿀꺽 삼킵니다.FilesOverlay.vue: 포인터를 따라가는 것으로, 모든 오버레이가 받는 props로 만듭니다. 개수가 표시된 실제 타일 더미, Alt로 복사하는 동안의 "+", 그리고 드롭이 할 일.SidebarRow.vue: 트리의 닫힌 폴더는 드래그가 그 위에 머물면 열리므로, 하위 폴더가 드롭을 받을 수 있습니다. **Favorites.vue**는 그 자체로 정렬 가능한 리스트이고, 그 행도 드롭 대상입니다.useDesktopDrop.ts: 컴퓨터에서 끌어온 파일. 이것은 라이브러리가 아니라 브라우저의 드래그 앤 드롭이며, 라이브러리와 나란히 처리합니다.drive.ts: 상태와 모든 변경, 실행 취소 포함.index.vue: 키를 다시 지정한 프로바이더와 그 안내 문구.
리스트 하나, 보기 두 개
그리드와 목록은 같은 정렬 가능한 리스트입니다. 옵션은 getter이며, 드래그가 시작될 때 읽습니다.
전략은 보기를 따르고, 컬럼 기준으로 정렬된 폴더(sort: false)는 순서를 바꾸지 않습니다. 그래도
드래그는 그 폴더 안으로 들어갈 수 있습니다.
const { items } = makeSortableList(list, {
items: drive.entries, // the open folder, sorted by a column or in your own order
group: 'files',
strategy: () => (grid.value ? 'grid' : 'vertical'),
sort: () => drive.sort.value.key === 'manual',
multiple: 'gaps', // the other picked files wait in place, as in Finder
clone: drive.duplicateOf, // Alt-drag leaves the files and drops "Budget copy.xlsx"
dropzone: area, // the empty space takes drops too
animateChanges: true, // a new sort, a new folder, files sent to the Trash slide and fade
});
drive.entries는 쓰기 가능한 computed입니다. 라이브러리는 각 드롭을 그set을 통해 다시 기록하고, 리스트의onDrop은 그보다 먼저 실행 취소를 위해 드라이브를 저장합니다.- 창은 폴더마다 새 보기를 만들므로(
FileView의:key), 폴더를 여는 것은animateChanges가 재생할 리스트 변경이 아닙니다.
선택하기
선택은 프로바이더가 관리하고, 사람들이 어떻게 선택할지는 보기가 정합니다. 빈 공간을 누르면 선택 사각형이 그려집니다. 그냥 그리면 선택을 대체하고, Shift, Ctrl, Cmd를 누르고 있으면 사각형이 지나는 타일의 선택이 뒤집힙니다. 전략은 getter이며, 사각형이 시작될 때 읽습니다.
let adding = false;
const { isSelecting, style } = makeSelectionArea(area, {
strategy: () => (adding ? 'toggle' : 'select'),
});
// On the area, `@pointerdown.capture`: it runs before the band's own listener.
const onPress = (event: PointerEvent) => {
adding = event.shiftKey || event.ctrlKey || event.metaKey;
};
클릭은 Finder를 따릅니다. 클릭하면 하나를 고르고, Ctrl/Cmd-클릭하면 추가하거나 빼고, Shift-클릭하면
마지막으로 고른 것부터의 범위를 고릅니다. 화살표 키는 파일 사이에서 탭 정지점 하나를 옮기고(로빙
tabindex이며, 라이브러리는 이를 건드리지 않습니다), Shift+화살표는 선택을 넓히고, Enter는 열고,
F2는 이름을 바꾸고, Delete는 휴지통으로 보내고, 글자를 입력하면 그 이름으로 건너뜁니다. 프로바이더는
Enter를 앱에 돌려줍니다. 파일을 집는 것은 Space뿐입니다.
<script setup lang="ts">
// Space picks a focused file up and drops it; Enter is left to open folders.
const keyboard = { keys: { start: ['Space'], drop: ['Space', 'Enter'] } };
</script>
<template>
<DnDProvider :keyboard="keyboard" :announcements="announcements">
<FileManager />
</DnDProvider>
</template>
폴더는 어디서나 드롭을 받습니다
useFolderDrop은 어떤 요소든 파일을 받는 폴더로 만듭니다. 타일은 그리드의 정렬 가능한 항목이기도
합니다. 그래서 대상은 타일의 가운데에서만 유효하고, 가장자리 근처에서는 그리드가 자리를 만듭니다.
accept는 파일이 이미 들어 있는 폴더와, 폴더를 자기 자신 안으로 넣는 것을 거부합니다. 거부한 타일
위에서는 다른 타일과 마찬가지로 그리드가 정렬합니다.
const { id, isOver, isAccepting } = makeDroppable(target, {
disabled: () => !folder(),
data: () => drive.placeOf(folder()!), // { name, trash, why }: the overlay reads it as `over.data`
accept: ({ active, types }) => types.includes('files') && !drive.whyNot(active.ids, folder()!),
onDrop: (event) => drive.dropInto(event, folder()!),
});
// The files are in once the overlay has landed in the folder: it gulps.
useDnDMonitor({
onDragSettle: (event) => {
if ('dropped' in event && event.dropped && event.over?.id === id.value) gulps.value++;
},
});
dropInto는 파일을 옮기거나, event.mode가 'copy'(clone이 있는 리스트 위에서 Alt를 누름)이면
복사합니다. 옮길 때는 event.source.remove()를 호출합니다. 그리드가 파일을 내보내고, 그리드의
onDrop이 to.index === -1로 실행됩니다.
const dropInto = (event: DnDEvent, target: FolderEntry) => {
const dragged = entries.value.filter((entry) => event.active.ids.includes(entry.id));
const copying = event.mode === 'copy' && !isTrash(target) && !inTrash.value;
record(`${copying ? 'Copied' : 'Moved'} ${describe(dragged)} to ${isTrash(target) ? 'the Trash' : target.name}`);
if (copying) return put(target, dragged.map(copyEntry), current.value);
put(target, dragged, current.value);
// The grid lets them go: the library writes the open folder without them.
event.source?.remove();
selection.delete(...event.active.ids);
};
즐겨찾기는 정렬 가능한 항목(Favorites.vue 안의, 자체 그룹을 가진 리스트)이면서 동시에 드롭
대상입니다. FavoriteRow.vue는 행 컴포넌트를 makeSortableItem에 넘기고, makeSortableItem은 그
루트 요소를 읽습니다. 그리고 SidebarRow.vue가 같은 요소를 폴더로 만듭니다. 드래그한 즐겨찾기는
가장자리 근처에서 정렬되고, 그 위에 드롭한 파일은 그 폴더로 들어갑니다.
스프링 로드 폴더
트리의 닫힌 폴더는 Finder처럼 드래그가 그 위에 머물면 열립니다. isOver가 잠시 유지되면 행이
펼쳐집니다. 하위 폴더는 드래그 도중에 마운트되어 바로 드롭을 받습니다.
const springing = computed(() => isOver.value && props.expandable && !props.expanded);
watch(springing, (now) => {
clearTimeout(timer);
if (now) timer = setTimeout(() => emit('toggle'), 700);
});
오버레이
FilesOverlay.vue는 리스트의 overlay입니다. 그리드에서는 고른 타일의 실제 클론인 DragPreview를
쌓습니다. 자리에 드롭하면 각 클론이 자기 타일에 안착하고, 폴더에 드롭하면 하나씩 차례로 들어갑니다.
행은 리스트만큼 넓으므로 리스트는 박스를 포인터 가운데에 맞추고(snapCenterToCursor), 오버레이는
거기에 작은 칩을 매답니다. 오버레이는 함수이며 Vue는 이를 함수형 컴포넌트로 받아들입니다. 이 함수는
자신이 읽는 props를 넘기고 둘 중 무엇을 그릴지 알려 줍니다.
overlay: ({ items, over, accepted, status, mode }: OverlayProps) =>
h(FilesOverlay, { items, over, accepted, status, mode, rows: !grid.value }),
modifiers: () => (grid.value ? [] : [snapCenterToCursor]),
더미 아래에는 필이 놓으면 무슨 일이 일어날지를 폴더의 data를 바탕으로 알려 줍니다. "Move to
Photos", Alt를 누르는 동안에는 "Copy to Photos"(mode), accepted가 false일 때는 빨간색으로 그
이유를 보여 줍니다.
const place = computed(() => (props.status === 'dragging' ? (props.over?.data as DropPlace | undefined) : undefined));
const pill = computed(() => {
if (!place.value) return null;
if (!props.accepted) return { refused: true, text: place.value.why ?? 'Can’t go here' };
if (place.value.trash) return { refused: false, text: 'Move to Trash' };
return { refused: false, text: `${props.mode === 'copy' ? 'Copy' : 'Move'} to ${place.value.name}` };
});
프로바이더의 announcements도 같은 data를 읽으므로, 스크린 리더는 "Over Photos. Drop to move 3
items there"나 "Already in Documents"를 듣습니다. 기본 문구라면 key를 읽었을 자리에서 파일의 이름을
부릅니다.
데스크톱의 파일
컴퓨터에서 파일을 창으로 드래그해 보세요. 파일은 포인터 아래의 폴더(타일, 사이드바의 행, 탐색 경로),
없으면 열린 폴더로 업로드되고, 사진은 내용이 보입니다. 라이브러리가 드래그하는 것은 페이지의
요소입니다. 바깥에서 온 파일은 브라우저 자체의 드래그 앤 드롭(dragover와 drop)으로 들어오므로,
라이브러리와 나란히 처리합니다.
const dragover = (event: DragEvent) => {
if (!carriesFiles(event)) return;
// Cancelled, a `dragover` says the drop is welcome here.
event.preventDefault();
const folder = folderAt(event); // under the pointer: an element with a folder's `data-id`
// …nothing goes into the Trash…
drive.uploadTarget.value = folder.id; // that folder opens, as for the library's drags
};
실행 취소, 휴지통, 그리고 나머지
- 모든 변경은 먼저 드라이브의 복사본을 저장합니다. 토스트의 Undo(또는 Ctrl/Cmd+Z)로 그때로 돌아갑니다. 휴지통은 트리 밖에 있는 폴더입니다. 그 안의 것은 원래 있던 곳을 기억하고, Put Back으로 그곳에 돌아갑니다.
ContextMenu.vue(오른쪽 클릭, Shift+F10, 또는 "⋯" 버튼)는 선택 항목이나 열린 폴더에 대해 동작합니다. 그 자리에서 이름 바꾸기, 복제, 즐겨찾기에 추가, 휴지통으로 이동, 휴지통 비우기.- 펼치면 창이 스테이지를 채우고, 파일 옆에 정보 창이 열립니다. 휴대폰에서는 사이드바가 서랍이 되며, 탭하면 폴더가 열리고 길게 누르면 파일을 집습니다.
더 읽어 보기: 멀티 드래그와 선택 ·
정렬 가능한 항목 안으로 드롭 ·
DragPreview ·
데이터 변경: animateChanges ·
접근성