Replace svelte-dnd-action with native drag & drop

This commit is a bit of a relief, we now have less than 1/10th the
amount of code powering drag & drop.

Future work includes:
- extracting commonalities into a Svelte action
- applying css to new drop zone without using sibling selector
- styling
This commit is contained in:
Mattias Granlund 2023-07-07 13:45:50 +01:00
parent 983d58061a
commit 2b19031a36
9 changed files with 245 additions and 172 deletions

View File

@ -82,7 +82,6 @@
"reflect-metadata": "^0.1.13",
"svelte": "~3.55.1",
"svelte-check": "^3.0.1",
"svelte-dnd-action": "github:gitbutlerapp/svelte-dnd-action#9912712394c26b971ef067674bdc871550940636",
"svelte-floating-ui": "^1.5.2",
"svelte-french-toast": "^1.0.3",
"svelte-loadable-store": "^1.2.3",

View File

@ -193,9 +193,6 @@ devDependencies:
svelte-check:
specifier: ^3.0.1
version: 3.0.3(postcss-load-config@4.0.1)(postcss@8.4.21)(svelte@3.55.1)
svelte-dnd-action:
specifier: github:gitbutlerapp/svelte-dnd-action#9912712394c26b971ef067674bdc871550940636
version: github.com/gitbutlerapp/svelte-dnd-action/9912712394c26b971ef067674bdc871550940636(svelte@3.55.1)
svelte-floating-ui:
specifier: ^1.5.2
version: 1.5.2
@ -5462,17 +5459,6 @@ packages:
engines: {node: '>=12.20'}
dev: true
github.com/gitbutlerapp/svelte-dnd-action/9912712394c26b971ef067674bdc871550940636(svelte@3.55.1):
resolution: {tarball: https://codeload.github.com/gitbutlerapp/svelte-dnd-action/tar.gz/9912712394c26b971ef067674bdc871550940636}
id: github.com/gitbutlerapp/svelte-dnd-action/9912712394c26b971ef067674bdc871550940636
name: svelte-dnd-action
version: 0.9.22
peerDependencies:
svelte: '>=3.23.0'
dependencies:
svelte: 3.55.1
dev: true
github.com/tauri-apps/tauri-plugin-log/21921031d74f871180381317a338559f588ad8e9:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-log/tar.gz/21921031d74f871180381317a338559f588ad8e9}
name: tauri-plugin-log-api

View File

@ -938,6 +938,7 @@ fn create_window(handle: &tauri::AppHandle) -> tauri::Result<tauri::Window> {
tauri::WindowBuilder::new(handle, "main", tauri::WindowUrl::App("index.html".into()))
.resizable(true)
.title(app_title)
.disable_file_drop_handler()
.min_inner_size(600.0, 300.0)
.inner_size(800.0, 600.0)
.build()
@ -952,6 +953,7 @@ fn create_window(handle: &tauri::AppHandle) -> tauri::Result<tauri::Window> {
.min_inner_size(1024.0, 600.0)
.inner_size(1024.0, 600.0)
.hidden_title(true)
.disable_file_drop_handler()
.title_bar_style(tauri::TitleBarStyle::Overlay)
.build()
}

View File

@ -316,3 +316,36 @@ input[type='checkbox'].large {
width: 20px;
height: 20px;
}
/* drag & drop */
.drag-zone-hover * {
@apply pointer-events-none;
}
.drag-zone-marker {
@apply border-green-450 bg-green-200 dark:bg-green-470;
}
.drag-zone-active.drag-zone-hover .drag-zone-marker,
.drag-zone-active + #new-branch-dz.drag-zone-hover .drag-zone-marker {
@apply bg-green-300 dark:bg-green-460;
}
.drag-zone-hover {
@apply border-green-500;
}
.drag-zone-active .no-changes {
@apply hidden;
}
.drag-zone-active .drag-zone-marker {
@apply block;
}
/* drag & drop ugly stuff */
#new-branch-dz.new-branch-active {
@apply visible flex;
}
.drag-zone-active + #new-branch-dz .call-to-action {
@apply hidden;
}
.drag-zone-active + #new-branch-dz .drag-zone-marker {
@apply block;
}

View File

@ -1,7 +1,5 @@
<script lang="ts" async="true">
import { dndzone } from 'svelte-dnd-action';
import Lane from './BranchLane.svelte';
import type { DndEvent } from 'svelte-dnd-action/typings';
import NewBranchDropZone from './NewBranchDropZone.svelte';
import type { Branch } from '$lib/api/ipc/vbranches';
import type { VirtualBranchOperations } from './vbranches';
@ -10,25 +8,12 @@
export let projectPath: string;
export let branches: Branch[];
export let virtualBranches: VirtualBranchOperations;
let dragged: any;
let dropZone: HTMLDivElement;
let priorPosition = 0;
let dropPosition = 0;
const newBranchClass = 'new-branch-active';
function ensureBranchOrder() {
branches.forEach((branch, i) => {
if (branch.order !== i) {
virtualBranches.updateBranchOrder(branch.id, i);
}
});
}
function handleDndEvent(e: CustomEvent<DndEvent<Branch>>) {
branches = e.detail.items;
if (e.type == 'finalize') {
branches = branches.filter((branch) => branch.active);
ensureBranchOrder();
}
}
const hoverClass = 'drag-zone-hover';
function handleEmpty() {
const emptyIndex = branches.findIndex((item) => !item.files || item.files.length == 0);
@ -40,22 +25,64 @@
</script>
<div
bind:this={dropZone}
id="branch-lanes"
class="flex max-w-full flex-shrink flex-grow snap-x items-start overflow-x-auto overflow-y-hidden bg-light-200 px-2 dark:bg-dark-1000"
use:dndzone={{
items: branches,
types: ['branch'],
receives: ['branch'],
dropTargetClassMap: {
file: [newBranchClass],
hunk: [newBranchClass]
on:dragenter={(e) => {
if (!e.dataTransfer?.types.includes('text/branch')) {
return;
}
dropZone.classList.add(hoverClass);
}}
on:dragend={(e) => {
if (!e.dataTransfer?.types.includes('text/branch')) {
return;
}
dropZone.classList.remove(hoverClass);
}}
on:dragover={(e) => {
if (!e.dataTransfer?.types.includes('text/branch')) {
return;
}
e.preventDefault(); // Only when text/branch
const children = [...e.currentTarget.children];
dropPosition = 0;
for (let i = 0; i < children.length; i++) {
const pos = children[i].getBoundingClientRect();
if (e.clientX > pos.left + pos.width) {
dropPosition = i + 1; // Note that this is declared in the <script>
} else {
break;
}
}
const idx = children.indexOf(dragged);
if (idx != dropPosition) {
idx >= dropPosition
? children[dropPosition].before(dragged)
: children[dropPosition].after(dragged);
}
}}
on:drop={(e) => {
dropZone.classList.remove(hoverClass);
if (priorPosition != dropPosition) {
const el = branches.splice(priorPosition, 1);
branches.splice(dropPosition, 0, ...el);
branches.forEach((branch, i) => {
if (branch.order !== i) {
virtualBranches.updateBranchOrder(branch.id, i);
}
});
}
}}
on:consider={handleDndEvent}
on:finalize={handleDndEvent}
>
{#each branches.filter((c) => c.active) as { id, name, files, commits, upstream, description, order } (id)}
<Lane
on:dragstart={(e) => {
if (!e.dataTransfer) return;
e.dataTransfer.setData('text/branch', id);
dragged = e.currentTarget;
priorPosition = Array.from(dropZone.children).indexOf(dragged);
}}
{name}
commitMessage={description}
{files}
@ -73,7 +100,7 @@
</div>
<style lang="postcss">
:global(#branch-lanes.new-branch-active [data-dnd-ignore]) {
@apply visible flex;
:global(.drag-zone-hover *) {
@apply pointer-events-none;
}
</style>

View File

@ -1,9 +1,10 @@
<script lang="ts" context="module">
const zones = new Set<HTMLDivElement>();
</script>
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import type { DndEvent } from 'svelte-dnd-action/typings';
import { Commit, File, Hunk } from '$lib/api/ipc/vbranches';
import type { Commit, File, Hunk } from '$lib/api/ipc/vbranches';
import { createEventDispatcher, onMount } from 'svelte';
import { createFile } from './helpers';
import FileCard from './FileCard.svelte';
import { IconBranch } from '$lib/icons';
import { Button } from '$lib/components';
@ -39,27 +40,16 @@
let isPushing = false;
let popupMenu: PopupMenu;
let meatballButton: HTMLButtonElement;
let dropZone: HTMLDivElement;
function handleDndEvent(e: CustomEvent<DndEvent<File | Hunk>>) {
const newItems = e.detail.items;
const fileItems = newItems.filter((item) => item instanceof File) as File[];
const hoverClass = 'drag-zone-hover';
const hunkType = 'text/hunk';
console.log('lane: handleDndEvent', e.type, e.detail.items);
const hunkItems = newItems.filter((item) => item instanceof Hunk) as Hunk[];
hunkItems.forEach((hunk) => {
const file = files.find((f) => f.hunks.find((h) => h.id == hunk.id));
if (file) {
file.hunks.push(hunk);
} else {
fileItems.push(createFile(hunk.filePath, hunk));
}
onMount(() => {
zones.add(dropZone);
return () => zones.delete(dropZone);
});
files = fileItems.filter((file) => file.hunks && file.hunks.length > 0);
if (e.type === 'finalize') updateBranchOwnership();
}
function updateBranchOwnership() {
const ownership = files
.map((file) => file.id + ':' + file.hunks.map((hunk) => hunk.id).join(','))
@ -133,12 +123,53 @@
console.log('branch name change:', name);
virtualBranches.updateBranchName(branchId, name);
}
function isChildOf(child: any, parent: HTMLElement): boolean {
if (parent === child) return false;
if (!child.parentElement) return false;
if (child.parentElement == parent) return true;
return isChildOf(child.parentElement, parent);
}
</script>
<div
class="flex max-h-full min-w-[24rem] max-w-[120ch] shrink-0 snap-center flex-col overflow-y-auto py-2 px-3 transition-width dark:text-dark-100"
draggable="true"
bind:this={dropZone}
class="flex max-h-full min-w-[24rem] max-w-[120ch] shrink-0 snap-center flex-col overflow-y-auto bg-light-200 py-2 px-3 transition-width dark:bg-dark-1000 dark:text-dark-100"
class:w-full={maximized}
class:w-96={!maximized}
on:dragstart
on:dragenter={(e) => {
if (!e.dataTransfer?.types.includes(hunkType)) {
return;
}
dropZone.classList.add(hoverClass);
}}
on:dragleave|stopPropagation={(e) => {
if (!e.dataTransfer?.types.includes(hunkType)) {
return;
}
if (!isChildOf(e.target, dropZone)) {
dropZone.classList.remove(hoverClass);
}
}}
on:dragover|stopPropagation={(e) => {
if (e.dataTransfer?.types.includes(hunkType)) e.preventDefault();
}}
on:dragend={(e) => {
dropZone.classList.remove(hoverClass);
}}
on:drop|stopPropagation={(e) => {
if (!e.dataTransfer) {
return;
}
dropZone.classList.remove(hoverClass);
const data = e.dataTransfer.getData(hunkType);
const ownership = files
.map((file) => file.id + ':' + file.hunks.map((hunk) => hunk.id).join(','))
.join('\n');
virtualBranches.updateBranchOwnership(branchId, (data + '\n' + ownership).trim());
}}
>
<div
class="mb-2 flex w-full shrink-0 items-center gap-x-2 rounded-lg bg-light-200 text-lg font-bold text-light-900 dark:bg-dark-1000 dark:font-normal dark:text-dark-100"
@ -194,17 +225,14 @@
class="flex flex-col rounded bg-white p-2 shadow-lg dark:border dark:border-dark-600 dark:bg-dark-800"
>
<div class="mb-2 flex items-center">
{#if files.filter((x) => x.hunks).length > 0}
<textarea
bind:value={commitMessage}
class="shrink-0 flex-grow cursor-text resize-none overflow-x-auto overflow-y-auto rounded border border-white bg-white p-2 text-dark-700 outline-none hover:border-light-400 focus:border-light-400 focus:ring-0 dark:border-dark-500 dark:bg-dark-700 dark:text-light-400 dark:hover:border-dark-300 dark:focus:border-dark-300"
placeholder="Your commit message here..."
rows={messageRows}
/>
{/if}
</div>
<div class="mb-4 text-right">
{#if files.filter((x) => x.hunks).length > 0}
<Button
height="small"
color="purple"
@ -212,21 +240,14 @@
commit();
}}>Commit</Button
>
{/if}
</div>
<div
class="flex flex-shrink flex-col gap-y-2"
use:dndzone={{
items: files,
zoneTabIndex: -1,
types: ['file'],
receives: ['file', 'hunk']
}}
on:consider={handleDndEvent}
on:finalize={handleDndEvent}
>
{#each files.filter((x) => x.hunks) as file (file.id)}
<div class="flex flex-shrink flex-col gap-y-2">
<div class="drag-zone-marker hidden rounded-lg border p-6">
Drop here to add to virtual branch
</div>
{#each files as file (file.id)}
<FileCard
id={file.id}
filepath={file.path}
expanded={file.expanded}
hunks={file.hunks}
@ -238,11 +259,21 @@
setExpandedWithCache(file, e.detail);
expandFromCache();
}}
on:drag={(e) => {
zones.forEach((zone) => {
if (zone != dropZone) {
e.detail
? zone.classList.add('drag-zone-active')
: zone.classList.remove('drag-zone-active');
}
});
}}
{projectPath}
/>
{/each}
{#if files.filter((x) => x.hunks).length == 0}
<div class="p-3 pt-0">No uncommitted work on this branch.</div>
{#if files.length == 0}
<!-- attention: these markers have custom css at the bottom of thise file -->
<div class="no-changes p-2" data-dnd-ignore>No uncommitted work on this branch.</div>
{/if}
</div>
</div>

View File

@ -1,8 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { dndzone } from 'svelte-dnd-action';
import { formatDistanceToNow } from 'date-fns';
import type { DndEvent } from 'svelte-dnd-action/typings';
import type { Hunk } from '$lib/api/ipc/vbranches';
import HunkDiffViewer from './HunkDiffViewer.svelte';
import { summarizeHunk } from '$lib/summaries';
@ -11,6 +9,7 @@
import PopupMenu from '$lib/components/PopupMenu/PopupMenu.svelte';
import PopupMenuItem from '$lib/components/PopupMenu/PopupMenuItem.svelte';
export let id: string;
export let projectPath: string;
export let filepath: string;
export let hunks: Hunk[];
@ -19,16 +18,12 @@
const dispatch = createEventDispatcher<{
expanded: boolean;
update: Hunk[];
drag: boolean;
}>();
export let expanded: boolean | undefined;
let popupMenu: PopupMenu;
function handleDndEvent(e: CustomEvent<DndEvent<Hunk>>) {
hunks = e.detail.items;
if (e.type == 'finalize') dispatch('update', e.detail.items);
}
function hunkSize(hunk: string): number[] {
const linesAdded = hunk.split('\n').filter((line) => line.startsWith('+')).length;
const linesRemoved = hunk.split('\n').filter((line) => line.startsWith('-')).length;
@ -53,9 +48,19 @@
</script>
<div
draggable="true"
on:dragstart|stopPropagation={(e) => {
if (!e.dataTransfer) return;
e.dataTransfer.setData('text/hunk', id + ':' + hunks.map((h) => h.id).join(','));
dispatch('drag', true);
return true;
}}
on:dragend|stopPropagation={(e) => {
dispatch('drag', false);
}}
class="changed-file flex w-full flex-col justify-center gap-2 rounded-lg border border-light-300 bg-light-50 text-light-900 dark:border-dark-400 dark:bg-dark-700 dark:text-light-300"
>
<div class="flex items-center px-2 pt-2">
<div class="items-cente flex px-2 pt-2">
<div class="flex-grow overflow-hidden text-ellipsis whitespace-nowrap " title={filepath}>
{@html boldenFilename(filepath)}
</div>
@ -75,23 +80,22 @@
</div>
</div>
<div
class="hunk-change-container flex flex-col gap-2 rounded px-2 pb-2"
use:dndzone={{
items: hunks,
zoneTabIndex: -1,
autoAriaDisabled: true,
types: ['hunk', filepath],
receives: [filepath]
}}
on:consider={handleDndEvent}
on:finalize={handleDndEvent}
>
<div class="hunk-change-container flex flex-col gap-2 rounded px-2 pb-2">
{#if expanded}
{#each hunks || [] as hunk (hunk.id)}
<div
class="changed-hunk flex w-full flex-col rounded-lg border border-light-200 bg-white dark:border-dark-400 dark:bg-dark-900"
draggable="true"
on:dragstart|stopPropagation={(e) => {
if (!e.dataTransfer) return;
e.dataTransfer.setData('text/hunk', id + ':' + hunk.id);
dispatch('drag', true);
return false;
}}
on:dragend|stopPropagation={(e) => {
dispatch('drag', false);
}}
on:contextmenu|preventDefault={(e) => popupMenu.openByMouse(e, hunk)}
class="changed-hunk flex w-full flex-col rounded-lg border border-light-200 bg-white dark:border-dark-400 dark:bg-dark-900"
>
<div class="truncate whitespace-normal p-2">
{#await summarizeHunk(hunk.diff) then description}

View File

@ -1,72 +1,58 @@
<script lang="ts">
import { dndzone } from 'svelte-dnd-action';
import { Branch, File, Hunk } from '$lib/api/ipc/vbranches';
import type { DndEvent } from 'svelte-dnd-action/typings';
import { createBranch, createFile } from './helpers';
import type { Branch } from '$lib/api/ipc/vbranches';
import { Button } from '$lib/components';
import type { VirtualBranchOperations } from './vbranches';
export let virtualBranches: VirtualBranchOperations;
let items: Branch[] = [];
let dropZone: HTMLDivElement;
function handleNewVirtualBranch() {
virtualBranches.createBranch({});
}
function handleDndFinalize(e: CustomEvent<DndEvent<Branch | File | Hunk>>) {
console.log('new dropzone: handleDndFinalize', e.type, e.detail.items);
const newItems = e.detail.items;
const branchItems = newItems.filter((item) => item instanceof Branch) as Branch[];
const hunkItems = newItems.filter((item) => item instanceof Hunk) as Hunk[];
for (const hunk of hunkItems) {
branchItems.push(createBranch(createFile(hunk.filePath, hunk)));
}
const fileItems = newItems.filter((item) => item instanceof File) as File[];
for (const file of fileItems) {
branchItems.push(createBranch(file));
}
if (e.type == 'finalize') {
const ownership = branchItems[0].files
.map((file) => file.id + ':' + file.hunks.map((hunk) => hunk.id).join(','))
.join('\n');
virtualBranches.createBranch({ ownership });
items = [];
return;
}
items = branchItems;
function isChildOf(child: any, parent: HTMLElement): boolean {
if (parent === child) return false;
if (!child.parentElement) return false;
if (child.parentElement == parent) return true;
return isChildOf(child.parentElement, parent);
}
</script>
<div
id="new-branch-dz"
class="ml-4 mt-16 flex h-40 w-[22.5rem] shrink-0 items-center rounded-lg border border-dashed border-light-600 px-8 py-10"
use:dndzone={{
items: items,
types: ['new-branch'],
receives: ['file', 'hunk'],
dropTargetClassMap: {
file: ['new-branch-active'],
hunk: ['new-branch-active']
class="h-42 ml-4 mt-16 flex w-[22.5rem] shrink-0 justify-center text-center text-light-800 dark:text-dark-100"
bind:this={dropZone}
on:dragover|stopPropagation={(e) => {
if (e.dataTransfer?.types.includes('text/hunk')) e.preventDefault();
dropZone.classList.add('drag-zone-hover');
}}
on:dragleave|stopPropagation={(e) => {
if (!isChildOf(e.target, dropZone)) {
dropZone.classList.remove('drag-zone-hover');
}
}}
on:finalize={handleDndFinalize}
on:drop|stopPropagation={(e) => {
if (!e.dataTransfer) {
return;
}
dropZone.classList.remove('drag-zone-hover');
const ownership = e.dataTransfer.getData('text/hunk');
virtualBranches.createBranch({ ownership });
}}
>
<div
class="flex flex-col items-center gap-y-3 self-center p-8 text-center text-lg text-light-800 dark:text-dark-100"
>
<div class="bg-green-300" />
<div class="call-to-action flex-grow rounded-lg border border-dashed border-light-600 p-8">
<div class="flex flex-col items-center gap-y-3 self-center p-2">
<p>Drag changes or click button to create new virtual branch</p>
<Button color="purple" height="small" on:click={handleNewVirtualBranch}
>New virtual branch</Button
>
</div>
</div>
<div class="drag-zone-marker hidden flex-grow rounded-lg border border-green-450 p-8">
<div class="flex flex-col items-center gap-y-3 self-center p-2">
<p>Drop here to add to virtual branch</p>
</div>
</div>
</div>
<style lang="postcss">
:global(#new-branch-dz.new-branch-active) {
@apply visible flex;
}
</style>

View File

@ -108,7 +108,12 @@ const config = {
900: '#7c2d12'
},
green: {
200: '#AFEDB1',
300: '#6BE66D',
400: '#4ade80',
450: '#40C341',
460: '#346E45',
470: '#314D39',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',