Turn drag & drop into a Svelte action

There are lots of things that different drop zones have in common, so this
commit abstracts that away.
This commit is contained in:
Mattias Granlund 2023-07-10 11:55:25 +02:00
parent ebe1124ee4
commit d2f57f7797
8 changed files with 152 additions and 130 deletions

View File

@ -7,7 +7,7 @@
</head> </head>
<body <body
class="h-full w-full select-none bg-light-200 font-sans text-base text-light-800 antialiased dark:bg-dark-900 dark:text-light-300" class="h-full w-full cursor-default select-none bg-light-200 font-sans text-base text-light-800 antialiased dark:bg-dark-900 dark:text-light-300"
style="height: 100vh" style="height: 100vh"
> >
%sveltekit.body% %sveltekit.body%

View File

@ -325,41 +325,13 @@ input[type='checkbox'].large {
.drag-zone-marker { .drag-zone-marker {
@apply border-green-450 bg-green-200 dark:bg-green-470; @apply border-green-450 bg-green-200 dark:bg-green-470;
} }
.drag-zone-active.drag-zone-hover .drag-zone-marker, .drag-zone-active.drag-zone-hover .drag-zone-marker {
.drag-zone-active + #new-branch-dz.drag-zone-hover .drag-zone-marker { @apply border-green-500 bg-green-300 dark:bg-green-460;
@apply bg-green-300 dark:bg-green-460;
} }
.drag-zone-hover { .drag-zone-active .no-changes,
@apply border-green-500; .drag-zone-active .call-to-action {
}
.drag-zone-active .no-changes {
@apply hidden; @apply hidden;
} }
.drag-zone-active .drag-zone-marker { .drag-zone-active .drag-zone-marker {
@apply block; @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;
}
.drag-zone-hover + #new-branch-dz .call-to-action {
@apply hidden;
}
.drag-zone-hover + #new-branch-dz .drag-zone-marker {
@apply block;
}
#new-branch-dz.drag-zone-hover .call-to-action {
@apply hidden;
}
#new-branch-dz.drag-zone-hover .drag-zone-marker {
@apply block bg-green-300 dark:bg-green-460;
}

View File

@ -5,6 +5,7 @@
import type { VirtualBranchOperations } from './vbranches'; import type { VirtualBranchOperations } from './vbranches';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings'; import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import { dzHighlight } from './dropZone';
export let projectId: string; export let projectId: string;
export let projectPath: string; export let projectPath: string;
@ -19,7 +20,7 @@
let priorPosition = 0; let priorPosition = 0;
let dropPosition = 0; let dropPosition = 0;
const hoverClass = 'drag-zone-hover'; const dzType = 'text/branch';
function handleEmpty() { function handleEmpty() {
const emptyIndex = branches.findIndex((item) => !item.files || item.files.length == 0); const emptyIndex = branches.findIndex((item) => !item.files || item.files.length == 0);
@ -34,23 +35,8 @@
bind:this={dropZone} bind:this={dropZone}
id="branch-lanes" 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" 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"
on:dragenter={(e) => { use:dzHighlight={{ type: dzType }}
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) => { on:dragover={(e) => {
if (!e.dataTransfer?.types.includes('text/branch')) {
return;
}
e.preventDefault(); // Only when text/branch
const children = [...e.currentTarget.children]; const children = [...e.currentTarget.children];
dropPosition = 0; dropPosition = 0;
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
@ -69,7 +55,6 @@
} }
}} }}
on:drop={(e) => { on:drop={(e) => {
dropZone.classList.remove(hoverClass);
if (priorPosition != dropPosition) { if (priorPosition != dropPosition) {
const el = branches.splice(priorPosition, 1); const el = branches.splice(priorPosition, 1);
branches.splice(dropPosition, 0, ...el); branches.splice(dropPosition, 0, ...el);
@ -85,7 +70,7 @@
<Lane <Lane
on:dragstart={(e) => { on:dragstart={(e) => {
if (!e.dataTransfer) return; if (!e.dataTransfer) return;
e.dataTransfer.setData('text/branch', id); e.dataTransfer.setData(dzType, id);
dragged = e.currentTarget; dragged = e.currentTarget;
priorPosition = Array.from(dropZone.children).indexOf(dragged); priorPosition = Array.from(dropZone.children).indexOf(dragged);
}} }}

View File

@ -1,7 +1,3 @@
<script lang="ts" context="module">
const zones = new Set<HTMLDivElement>();
</script>
<script lang="ts"> <script lang="ts">
import type { Commit, File, Hunk } from '$lib/api/ipc/vbranches'; import type { Commit, File, Hunk } from '$lib/api/ipc/vbranches';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
@ -15,6 +11,7 @@
import type { VirtualBranchOperations } from './vbranches'; import type { VirtualBranchOperations } from './vbranches';
import PopupMenu from '../../../lib/components/PopupMenu/PopupMenu.svelte'; import PopupMenu from '../../../lib/components/PopupMenu/PopupMenu.svelte';
import PopupMenuItem from '../../../lib/components/PopupMenu/PopupMenuItem.svelte'; import PopupMenuItem from '../../../lib/components/PopupMenu/PopupMenuItem.svelte';
import { dzHighlight } from './dropZone';
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
empty: never; empty: never;
@ -40,15 +37,9 @@
let isPushing = false; let isPushing = false;
let popupMenu: PopupMenu; let popupMenu: PopupMenu;
let meatballButton: HTMLButtonElement; let meatballButton: HTMLButtonElement;
let dropZone: HTMLDivElement;
const hoverClass = 'drag-zone-hover'; const hoverClass = 'drag-zone-hover';
const hunkType = 'text/hunk'; const dzType = 'text/hunk';
onMount(() => {
zones.add(dropZone);
return () => zones.delete(dropZone);
});
function updateBranchOwnership() { function updateBranchOwnership() {
const ownership = files const ownership = files
@ -134,37 +125,17 @@
<div <div
draggable="true" 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-full={maximized}
class:w-96={!maximized} class:w-96={!maximized}
class="flex max-h-full min-w-[24rem] max-w-[120ch] shrink-0 cursor-grabbing snap-center flex-col overflow-y-auto bg-light-200 py-2 px-3 transition-width dark:bg-dark-1000 dark:text-dark-100"
use:dzHighlight={{ type: dzType, hover: hoverClass, active: 'drag-zone-active' }}
on:dragstart on:dragstart
on:dragenter={(e) => { on:dragend
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) => { on:drop|stopPropagation={(e) => {
if (!e.dataTransfer) { if (!e.dataTransfer) {
return; return;
} }
dropZone.classList.remove(hoverClass); const data = e.dataTransfer.getData(dzType);
const data = e.dataTransfer.getData(hunkType);
const ownership = files const ownership = files
.map((file) => file.id + ':' + file.hunks.map((hunk) => hunk.id).join(',')) .map((file) => file.id + ':' + file.hunks.map((hunk) => hunk.id).join(','))
.join('\n'); .join('\n');
@ -251,23 +222,12 @@
filepath={file.path} filepath={file.path}
expanded={file.expanded} expanded={file.expanded}
hunks={file.hunks} hunks={file.hunks}
{dzType}
{maximized} {maximized}
on:update={(e) => {
handleFileUpdate(file.id, e.detail);
}}
on:expanded={(e) => { on:expanded={(e) => {
setExpandedWithCache(file, e.detail); setExpandedWithCache(file, e.detail);
expandFromCache(); 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} {projectPath}
/> />
{/each} {/each}

View File

@ -10,6 +10,7 @@
import PopupMenuItem from '$lib/components/PopupMenu/PopupMenuItem.svelte'; import PopupMenuItem from '$lib/components/PopupMenu/PopupMenuItem.svelte';
import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings'; import { SETTINGS_CONTEXT, type SettingsStore } from '$lib/userSettings';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
import { dzTrigger } from './dropZone';
const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT); const userSettings = getContext<SettingsStore>(SETTINGS_CONTEXT);
@ -18,11 +19,10 @@
export let filepath: string; export let filepath: string;
export let hunks: Hunk[]; export let hunks: Hunk[];
export let maximized: boolean; export let maximized: boolean;
export let dzType: string;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
expanded: boolean; expanded: boolean;
update: Hunk[];
drag: boolean;
}>(); }>();
export let expanded: boolean | undefined; export let expanded: boolean | undefined;
@ -49,19 +49,16 @@
function getFirstLineNumber(diff: string): number { function getFirstLineNumber(diff: string): number {
return parseInt(diff.split('\n')[0].split('@@')[1].trim().split(' ')[0].split(',')[0].slice(1)); return parseInt(diff.split('\n')[0].split('@@')[1].trim().split(' ')[0].split(',')[0].slice(1));
} }
function getAllHunksOwnership(): string {
return id + ':' + hunks.map((h) => h.id).join(',');
}
</script> </script>
<div <div
draggable="true" draggable="true"
on:dragstart|stopPropagation={(e) => { use:dzTrigger={{ type: dzType }}
if (!e.dataTransfer) return; on:dragstart={(e) => e.dataTransfer?.setData('text/hunk', getAllHunksOwnership())}
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" 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="items-cente flex px-2 pt-2"> <div class="items-cente flex px-2 pt-2">
@ -89,15 +86,8 @@
{#each hunks || [] as hunk (hunk.id)} {#each hunks || [] as hunk (hunk.id)}
<div <div
draggable="true" draggable="true"
on:dragstart|stopPropagation={(e) => { use:dzTrigger={{ type: dzType }}
if (!e.dataTransfer) return; on:dragstart={(e) => e.dataTransfer?.setData('text/hunk', id + ':' + hunk.id)}
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)} 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" class="changed-hunk flex w-full flex-col rounded-lg border border-light-200 bg-white dark:border-dark-400 dark:bg-dark-900"
> >

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Branch } from '$lib/api/ipc/vbranches'; import type { Branch } from '$lib/api/ipc/vbranches';
import { Button } from '$lib/components'; import { Button } from '$lib/components';
import { dzHighlight } from './dropZone';
import type { VirtualBranchOperations } from './vbranches'; import type { VirtualBranchOperations } from './vbranches';
export let virtualBranches: VirtualBranchOperations; export let virtualBranches: VirtualBranchOperations;
@ -23,20 +24,11 @@
id="new-branch-dz" id="new-branch-dz"
class="h-42 ml-4 mt-16 flex w-[22.5rem] shrink-0 justify-center text-center text-light-800 dark:text-dark-100" 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} bind:this={dropZone}
on:dragover|stopPropagation={(e) => { use:dzHighlight={{ type: 'text/hunk', hover: 'drag-zone-hover', active: 'drag-zone-active' }}
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:drop|stopPropagation={(e) => { on:drop|stopPropagation={(e) => {
if (!e.dataTransfer) { if (!e.dataTransfer) {
return; return;
} }
dropZone.classList.remove('drag-zone-hover');
const ownership = e.dataTransfer.getData('text/hunk'); const ownership = e.dataTransfer.getData('text/hunk');
virtualBranches.createBranch({ ownership }); virtualBranches.createBranch({ ownership });
}} }}

View File

@ -200,7 +200,7 @@
</div> </div>
<div>{branch.ahead}/{branch.behind}</div> <div>{branch.ahead}/{branch.behind}</div>
{#if !branch.mergeable} {#if !branch.mergeable}
<div class="text-red-500 font-bold" title="Can't be merged">!</div> <div class="font-bold text-red-500" title="Can't be merged">!</div>
{/if} {/if}
</div> </div>
{#if branch.lastCommitTs > 0} {#if branch.lastCommitTs > 0}

View File

@ -0,0 +1,123 @@
const zoneMap = new Map<string, Set<HTMLElement>>();
export interface DzOptions {
type: string;
hover: string;
active: string;
}
const defaultOptions: DzOptions = {
hover: 'drag-zone-hover',
active: 'drag-zone-active',
type: 'default'
};
function inactivateZones(zones: Set<HTMLElement>, cssClass: string) {
zones?.forEach((zone) => {
zone.classList.remove(cssClass);
});
}
function activateZones(zones: Set<HTMLElement>, activeZone: HTMLElement, cssClass: string) {
zones?.forEach((zone) => {
if (zone !== activeZone && !isChildOf(activeZone, zone)) {
zone.classList.add(cssClass);
}
});
}
function getZones(type: string): Set<HTMLElement> {
let zones = zoneMap.get(type);
if (!zones) {
zones = new Set([]);
zoneMap.set(type, zones);
}
return zones;
}
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);
}
export function dzTrigger(node: HTMLElement, opts: Partial<DzOptions> | undefined) {
const options = { ...defaultOptions, ...opts };
const zones = getZones(options.type);
function handleDragStart(e: DragEvent) {
activateZones(zones, node, options.active);
e.stopPropagation();
}
node.addEventListener('dragstart', handleDragStart);
return {
destroy() {
node.removeEventListener('dragstart', handleDragStart);
}
};
}
export function dzHighlight(node: HTMLElement, opts: Partial<DzOptions> | undefined) {
const options = { ...defaultOptions, ...opts };
const zones = getZones(options.type);
zones.add(node);
function handleDragEnter(e: DragEvent) {
if (!e.dataTransfer?.types.includes(options.type)) {
return;
}
node.classList.add(options.hover);
e.stopPropagation();
}
function handleDragLeave(e: DragEvent) {
if (!e.dataTransfer?.types.includes(options.type)) {
return;
}
if (!isChildOf(e.target, node)) {
node.classList.remove(options.hover);
}
e.stopPropagation();
}
function handleDragEnd(e: DragEvent) {
node.classList.remove(options.hover);
inactivateZones(zones, options.active);
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
if (!e.dataTransfer?.types.includes(options.type)) {
return;
}
node.classList.remove(options.hover);
inactivateZones(zones, options.active);
}
function handleDragOver(e: DragEvent) {
if (!e.dataTransfer?.types.includes(options.type)) {
e.stopImmediatePropagation(); // Stops event from reaching `on:dragover` on the element
}
if (e.dataTransfer?.types.includes(options.type)) e.preventDefault();
}
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragleave', handleDragLeave);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
return {
destroy() {
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragleave', handleDragLeave);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
zones?.delete(node);
}
};
}