Improve nested drag & drop functionality

- wait until dropped before removing wrapped containers
- propagate empty events up the compenents tree
This commit is contained in:
Mattias Granlund 2023-06-13 11:35:19 +02:00
parent baa0bc8978
commit 707707ba52
6 changed files with 138 additions and 38 deletions

View File

@ -6,7 +6,7 @@ import { subSeconds, subMinutes, subHours } from 'date-fns';
export const load: PageLoad = async () => {
const branches: Branch[] = [
{
id: 'c1',
id: 'b1',
name: 'feature-1',
active: true,
kind: 'branch',
@ -93,8 +93,8 @@ export const load: PageLoad = async () => {
kind: 'branch',
commits: [
{
id: 'c2',
description: 'Second commit',
id: 'c3',
description: 'Third commit',
committedAt: subMinutes(new Date(), 50),
kind: 'commit',
files: [

View File

@ -2,7 +2,7 @@
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import Lane from './BranchLane.svelte';
import type { Branch, File, Hunk } from './types';
import type { Branch, Commit, File, Hunk } from './types';
import type { DndEvent } from 'svelte-dnd-action/typings';
export let branches: Branch[];
@ -10,30 +10,92 @@
const flipDurationMs = 300;
function handleDndEvent(
e: CustomEvent<DndEvent<Branch | File | Hunk>> & { target: HTMLElement }
e: CustomEvent<DndEvent<Branch | Commit | File | Hunk>>,
isFinal: boolean
) {
branches = e.detail.items.filter((item) => item.kind == 'branch') as Branch[];
// TODO: Create lanes out of dropped files/hunks
const branchItems = e.detail.items.filter((item) => item.kind == 'branch') as Branch[];
const commitItems = e.detail.items.filter((item) => item.kind == 'commit') as Commit[];
const fileItems = e.detail.items.filter((item) => item.kind == 'file') as File[];
const hunkItems = e.detail.items.filter((item) => item.kind == 'hunk') as Hunk[];
for (const hunk of hunkItems) {
branchItems.push({
id: `${Date.now()}-${hunk.id}-branch`,
name: 'new branch',
active: true,
kind: 'branch',
commits: [
{
id: `${Date.now()}-${hunk.id}-commit`,
description: 'New commit',
kind: 'commit',
files: [
{
id: `${Date.now()}-${hunk.id}-hunk`,
path: hunk.filePath,
kind: 'file',
hunks: [{ ...hunk, isDndShadowItem: !isFinal }]
}
]
}
]
});
}
for (const file of fileItems) {
branchItems.push({
id: `${Date.now()}-${file.id}-branch`,
name: 'new branch',
active: true,
kind: 'branch',
commits: [
{
id: `${Date.now()}-${file.id}-commit`,
description: '',
kind: 'commit',
files: [file],
isDndShadowItem: !isFinal
}
]
});
}
for (const commit of commitItems) {
branchItems.push({
id: `${Date.now()}-${commit.id}-branch`,
name: 'new branch',
kind: 'branch',
active: true,
commits: [commit],
isDndShadowItem: !isFinal
});
}
branches = branchItems.filter((commit) => commit.active);
}
function handleEmpty() {
const emptyIndex = branches.findIndex((item) => !item.commits || item.commits.length == 0);
if (emptyIndex != -1) {
// TODO: Figure out what to do when a branch is empty. Just removing it is a bit jarring.
}
}
</script>
<section
class="flex gap-x-4 p-4"
class="flex w-full gap-x-8 p-8"
use:dndzone={{
items: branches,
flipDurationMs,
types: ['branch'],
receives: ['branch', 'file', 'hunk']
receives: ['branch', 'commit', 'file', 'hunk']
}}
on:consider={handleDndEvent}
on:finalize={handleDndEvent}
on:consider={(e) => handleDndEvent(e, false)}
on:finalize={(e) => handleDndEvent(e, true)}
>
{#each branches.filter((c) => c.active) as { id, name, commits }, idx (id)}
<div
class="flex w-64 border border-zinc-700 bg-zinc-900/50 p-4"
animate:flip={{ duration: flipDurationMs }}
>
<Lane {name} bind:commits />
<Lane {name} bind:commits on:empty={handleEmpty} />
</div>
{/each}
</section>

View File

@ -4,17 +4,49 @@
import type { DndEvent } from 'svelte-dnd-action/typings';
import type { Commit, File, Hunk } from './types';
import CommitGroup from './CommitGroup.svelte';
import { createEventDispatcher } from 'svelte';
export let name: string;
export let commits: Commit[];
const flipDurationMs = 150;
const dispatch = createEventDispatcher();
function handleDndEvent(
e: CustomEvent<DndEvent<Commit | File | Hunk>> & { target: HTMLElement }
) {
commits = e.detail.items.filter((item) => item.kind == 'commit') as Commit[];
// TODO: Create lanes out of dropped files/hunks
function handleDndEvent(e: CustomEvent<DndEvent<Commit | File | Hunk>>, isFinal: boolean) {
const commitItems = e.detail.items.filter((item) => item.kind == 'commit') as Commit[];
const fileItems = e.detail.items.filter((item) => item.kind == 'file') as File[];
const hunkItems = e.detail.items.filter((item) => item.kind == 'hunk') as Hunk[];
// Merge hunks into existing files, or create new where none exist
for (const hunk of hunkItems) {
commitItems.push({
id: `${Date.now()}-${hunk.id}-commit`,
description: 'New commit',
kind: 'commit',
files: [
{
id: `${Date.now()}-${hunk.id}-hunk`,
path: hunk.filePath,
kind: 'file',
hunks: [{ ...hunk, isDndShadowItem: !isFinal }]
}
]
});
}
for (const file of fileItems) {
commitItems.push({
id: `${Date.now()}-${file.id}`,
description: '',
kind: 'commit',
files: [file],
isDndShadowItem: !isFinal
});
}
commits = commitItems.filter((commit) => commit.files && commit.files.length > 0);
if (e.type == 'finalize' && (!commits || commits.length == 0)) {
dispatch('empty');
}
}
function handleEmpty() {
@ -22,6 +54,9 @@
if (emptyIndex != -1) {
commits.splice(emptyIndex, 1);
}
if (commits.length == 0) {
dispatch('empty');
}
}
</script>
@ -36,10 +71,10 @@
flipDurationMs,
zoneTabIndex: -1,
types: ['commit'],
receives: ['commit']
receives: ['commit', 'file', 'hunk']
}}
on:consider={(e) => handleDndEvent(e)}
on:finalize={(e) => handleDndEvent(e)}
on:consider={(e) => handleDndEvent(e, false)}
on:finalize={(e) => handleDndEvent(e, true)}
>
{#each commits.filter((x) => x.files) as { id, description, files }, idx (id)}
<div animate:flip={{ duration: flipDurationMs }}>

View File

@ -2,14 +2,16 @@
import { flip } from 'svelte/animate';
import { dndzone } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action/typings';
import type { Commit, File, Hunk } from './types';
import type { File, Hunk } from './types';
import FileCard from './FileCard.svelte';
import { createEventDispatcher } from 'svelte';
export let description: string;
export let id: string;
export let files: File[];
const flipDurationMs = 150;
const dispatch = createEventDispatcher();
function handleDndEvent(e: CustomEvent<DndEvent<File | Hunk>>, isFinal: boolean) {
const fileItems = e.detail.items.filter((item) => item.kind == 'file') as File[];
@ -25,27 +27,26 @@
id: `${Date.now()}-${hunk.id}`,
path: hunk.filePath,
kind: 'file',
hunks: [
{
id: hunk.id,
filePath: hunk.filePath,
kind: hunk.kind,
modifiedAt: hunk.modifiedAt,
name: hunk.name,
isDndShadowItem: !isFinal
}
]
hunks: [{ ...hunk, isDndShadowItem: !isFinal }]
});
}
}
files = fileItems.filter((file) => file.hunks && file.hunks.length > 0);
if (e.type == 'finalize' && (!files || files.length == 0)) {
dispatch('empty');
return;
}
}
function handleEmptyFile() {
function handleEmpty() {
const emptyIndex = files.findIndex((item) => !item.hunks || item.hunks.length == 0);
if (emptyIndex != -1) {
files.splice(emptyIndex, 1);
}
if (files.length == 0) {
dispatch('empty');
}
}
</script>
@ -64,7 +65,7 @@
>
{#each files.filter((x) => x.hunks) as file, idx (file.id)}
<div animate:flip={{ duration: flipDurationMs }}>
<FileCard bind:file on:empty={handleEmptyFile} />
<FileCard bind:file on:empty={handleEmpty} />
</div>
{/each}
<div>

View File

@ -13,12 +13,12 @@
const flipDurationMs = 150;
function handleDndEvent(e: CustomEvent<DndEvent<Hunk>>) {
if (!file.hunks || file.hunks.length == 0) {
dispatch('empty');
return;
}
e.detail.items.sort((itemA, itemB) => compareDesc(itemA.modifiedAt, itemB.modifiedAt));
file.hunks = e.detail.items;
if (e.type == 'finalize' && (!file.hunks || file.hunks.length == 0)) {
dispatch('empty');
}
}
</script>

View File

@ -18,9 +18,10 @@ export type File = {
export type Commit = {
id: string;
description: string;
committedAt: Date;
committedAt?: Date;
files: File[];
kind: string;
isDndShadowItem?: boolean;
};
export type Branch = {
@ -29,4 +30,5 @@ export type Branch = {
active: boolean;
commits: Commit[];
kind: string;
isDndShadowItem?: boolean;
};