Drag-n-drop update (#4220)

* Reuse `splitFilePath` function

* unnecessary `width` and `height` removed

* added utils for draggable file list items

- added separate CSS

* WIP new styles for the commit draggable

* styles for draggable commit cards updated

* Draggable hunk added

* Draggable lanes updated

* Dropzone design updated

* Dropzones code refactor

* reordering lines design update

* Update logic for determining reorder shift

* Remove unused CSS fix scrollable container prop

* dropzone animations added

* Dropzone hover state UI updated

* CSS update: Card overlay labels

* Fix: horizontal scroll wrong observer trigger

* UX: Automatically close the commit message box after commit
This commit is contained in:
Pavel Laptev 2024-07-02 20:49:17 +02:00 committed by GitHub
parent a1c591ffb6
commit 9ff735fd4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 699 additions and 276 deletions

View File

@ -139,9 +139,9 @@
<PullRequestCard />
<div class="card">
<Dropzones>
{#if branch.files?.length > 0}
<div class="branch-card__files">
{#if branch.files?.length > 0}
<div class="branch-card__files">
<Dropzones>
<BranchFiles
files={branch.files}
{isUnapplied}
@ -162,21 +162,23 @@
</InfoMessage>
</div>
{/if}
</Dropzones>
{#if branch.active}
<CommitDialog
projectId={project.id}
expanded={commitBoxOpen}
hasSectionsAfter={branch.commits.length > 0}
on:action={(e) => {
if (e.detail === 'generate-branch-name') {
generateBranchName();
}
}}
/>
{/if}
</div>
{:else if branch.commits.length === 0}
{#if branch.active}
<CommitDialog
projectId={project.id}
expanded={commitBoxOpen}
hasSectionsAfter={branch.commits.length > 0}
on:action={(e) => {
if (e.detail === 'generate-branch-name') {
generateBranchName();
}
}}
/>
{/if}
</div>
{:else if branch.commits.length === 0}
<Dropzones>
<div class="new-branch">
<EmptyStatePlaceholder image={laneNewSvg} width="11rem">
<svelte:fragment slot="title">This is a new branch</svelte:fragment>
@ -185,7 +187,9 @@
</svelte:fragment>
</EmptyStatePlaceholder>
</div>
{:else}
</Dropzones>
{:else}
<Dropzones>
<div class="no-changes" data-dnd-ignore>
<EmptyStatePlaceholder image={noChangesSvg} width="11rem" hasBottomMargin={false}>
<svelte:fragment slot="caption"
@ -193,8 +197,8 @@
>
</EmptyStatePlaceholder>
</div>
{/if}
</Dropzones>
</Dropzones>
{/if}
<div class="card-commits">
<CommitList {isUnapplied} />
@ -254,9 +258,6 @@
.card {
flex: 1;
/* overflow: hidden; */
/* border: 1px solid var(--clr-border-2);
border-radius: var(--radius-m); */
}
.branch-card__files {
@ -264,9 +265,6 @@
flex-direction: column;
flex: 1;
height: 100%;
/* border-left: 1px solid var(--clr-border-2);
border-right: 1px solid var(--clr-border-2);
border-radius: var(--radius-m) var(--radius-m) 0 0; */
}
.card-notifications {
@ -288,12 +286,6 @@
cursor: default; /* was defaulting to text cursor */
}
.branch-card :global(.contents) {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* COLLAPSED LANE */
.collapsed-lane-container {
display: flex;

View File

@ -54,7 +54,7 @@
options: {
root: null,
rootMargin: '-1px',
threshold: 1
threshold: 0
}
}}
>
@ -131,6 +131,5 @@
.not-in-viewport {
border-radius: 0;
/* background-color: aquamarine; */
}
</style>

View File

@ -77,7 +77,7 @@
{#await $selectedFile then selected}
{#if selected}
<div
class="file-preview resize-viewport"
class="file-preview"
bind:this={rsViewport}
in:slide={{ duration: 180, easing: quintOut, axis: 'x' }}
style:width={`${fileWidth || $defaultFileWidthRem}rem`}

View File

@ -58,6 +58,5 @@
position: relative;
flex-grow: 1;
width: 100%;
/* overflow: hidden; */
}
</style>

View File

@ -3,7 +3,7 @@
import { Project } from '$lib/backend/projects';
import CommitMessageInput from '$lib/commit/CommitMessageInput.svelte';
import { persistedCommitMessage } from '$lib/config/config';
import { draggable } from '$lib/dragging/draggable';
import { draggableCommit } from '$lib/dragging/draggable';
import { DraggableCommit, nonDraggable } from '$lib/dragging/draggables';
import BranchFilesList from '$lib/file/BranchFilesList.svelte';
import Button from '$lib/shared/Button.svelte';
@ -25,7 +25,7 @@
BaseBranch,
type CommitStatus
} from '$lib/vbranches/types';
import { createEventDispatcher, type Snippet } from 'svelte';
import { type Snippet } from 'svelte';
export let branch: Branch | undefined = undefined;
export let commit: Commit | RemoteCommit;
@ -46,8 +46,7 @@
const currentCommitMessage = persistedCommitMessage(project.id, branch?.id || '');
const dispatch = createEventDispatcher<{ toggle: void }>();
let draggableCommitElement: HTMLElement | null = null;
let files: RemoteFile[] = [];
let showDetails = false;
@ -57,7 +56,6 @@
function toggleFiles() {
showDetails = !showDetails;
dispatch('toggle');
if (showDetails) loadFiles();
}
@ -102,6 +100,14 @@
commitMessageModal.close();
}
function getTimeAndAuthor() {
const timeAgo = getTimeAgo(commit.createdAt);
const author = type === 'localAndRemote' || type === 'remote' ? commit.author.name : 'you';
return `${timeAgo} by ${author}`;
}
const commitShortSha = commit.id.substring(0, 7);
let topHeightPx = 24;
$: {
@ -109,6 +115,9 @@
if (first) topHeightPx = 58;
if (showDetails && !first) topHeightPx += 12;
}
let dragDirection: 'up' | 'down' | undefined;
let isDragTargeted = false;
</script>
<Modal bind:this={commitMessageModal} width="small">
@ -138,40 +147,77 @@
class:is-last={last}
class:has-lines={lines}
>
{#if dragDirection && isDragTargeted}
<div
class="pseudo-reorder-zone"
class:top={dragDirection === 'up'}
class:bottom={dragDirection === 'down'}
class:is-first={first}
class:is-last={last}
></div>
{/if}
{#if lines}
<div>
{@render lines(topHeightPx)}
</div>
{/if}
<CommitDragItem {commit}>
<div class="commit-card" class:is-first={first} class:is-last={last}>
<div
class="accent-border-line"
class:is-first={first}
class:is-last={last}
class:local={type === 'local'}
class:local-and-remote={type === 'localAndRemote'}
class:upstream={type === 'remote'}
class:integrated={type === 'integrated'}
></div>
<div class="commit-card" class:is-first={first} class:is-last={last}>
<CommitDragItem {commit}>
<!-- GENERAL INFO -->
<div
bind:this={draggableCommitElement}
class="commit__header"
on:click={toggleFiles}
on:keyup={onKeyup}
role="button"
tabindex="0"
use:draggable={commit instanceof Commit
on:dragenter={() => {
isDragTargeted = true;
}}
on:dragleave={() => {
isDragTargeted = false;
}}
on:drop={() => {
isDragTargeted = false;
}}
on:drag={(e) => {
const target = e.target as HTMLElement;
const targetHeight = target.offsetHeight;
const targetTop = target.getBoundingClientRect().top;
const mouseY = e.clientY;
const isTop = mouseY < targetTop + targetHeight / 2;
dragDirection = isTop ? 'up' : 'down';
}}
use:draggableCommit={commit instanceof Commit && !isUnapplied && type !== 'integrated'
? {
label: commit.descriptionTitle,
sha: commitShortSha,
dateAndAuthor: getTimeAndAuthor(),
commitType: type,
data: new DraggableCommit(commit.branchId, commit, isHeadCommit),
extendWithClass: 'commit_draggable'
viewportId: 'board-viewport'
}
: nonDraggable()}
>
<div class="commit__drag-icon">
<Icon name="draggable-narrow" />
</div>
<div
class="accent-border-line"
class:is-first={first}
class:is-last={last}
class:local={type === 'local'}
class:local-and-remote={type === 'localAndRemote'}
class:upstream={type === 'remote'}
class:integrated={type === 'integrated'}
></div>
{#if type === 'local' || type === 'localAndRemote'}
<div class="commit__drag-icon">
<Icon name="draggable-narrow" />
</div>
{/if}
{#if first}
<div class="commit__type text-semibold text-base-12">
@ -209,7 +255,7 @@
class="commit__subtitle-btn commit__subtitle-btn_dashed"
on:click|stopPropagation={() => copyToClipboard(commit.id)}
>
<span>{commit.id.substring(0, 7)}</span>
<span>{commitShortSha}</span>
<div class="commit__subtitle-btn__icon">
<Icon name="copy-small" />
@ -235,11 +281,7 @@
<span class="commit__subtitle-divider"></span>
<span
>{getTimeAgo(commit.createdAt)}{type === 'localAndRemote' || type === 'remote'
? ` by ${commit.author.name}`
: ' by you'}</span
>
<span>{getTimeAndAuthor()}</span>
</div>
{/if}
</div>
@ -285,25 +327,11 @@
<BranchFilesList {files} {isUnapplied} readonly={type === 'remote'} />
</div>
{/if}
</div>
</CommitDragItem>
</CommitDragItem>
</div>
</div>
<style lang="postcss">
/* amend drop zone */
:global(.amend-dz-active .amend-dz-marker) {
display: flex;
}
:global(.amend-dz-hover .hover-text) {
visibility: visible;
}
:global(.commit_draggable) {
cursor: grab;
background-color: var(--clr-bg-1);
border-radius: var(--radius-m);
border: none;
}
.commit-row {
position: relative;
display: flex;
@ -321,6 +349,7 @@
display: flex;
position: relative;
flex-direction: column;
flex: 1;
background-color: var(--clr-bg-1);
border-right: 1px solid var(--clr-border-2);
@ -345,8 +374,12 @@
.accent-border-line {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
z-index: var(--z-ground);
&.local {
background-color: var(--clr-commit-local);
}
@ -517,4 +550,29 @@
margin-left: 2px;
}
}
/* PSUEDO DROPZONE */
.pseudo-reorder-zone {
z-index: var(--z-lifted);
position: absolute;
height: 2px;
width: 100%;
background-color: var(--clr-theme-pop-element);
}
.pseudo-reorder-zone.top {
top: -1px;
}
.pseudo-reorder-zone.bottom {
bottom: -1px;
}
.pseudo-reorder-zone.top.is-first {
top: 6px;
}
.pseudo-reorder-zone.bottom.is-last {
bottom: -6px;
}
</style>

View File

@ -38,6 +38,7 @@
$commitMessage = '';
} finally {
isCommitting = false;
$expanded = false;
}
}
</script>

View File

@ -38,7 +38,14 @@
{@render squashDropzone()}
{#snippet overlay({ hovered, activated })}
<CardOverlay {hovered} {activated} label="Amend commit" />
<CardOverlay
{hovered}
{activated}
label="Amend commit"
extraPaddings={{
left: 4
}}
/>
{/snippet}
</Dropzone>
{/snippet}
@ -48,7 +55,14 @@
{@render children()}
{#snippet overlay({ hovered, activated })}
<CardOverlay {hovered} {activated} label="Squash commit" />
<CardOverlay
{hovered}
{activated}
label="Squash commit"
extraPaddings={{
left: 4
}}
/>
{/snippet}
</Dropzone>
{/snippet}
@ -57,6 +71,5 @@
.dropzone-wrapper {
position: relative;
width: 100%;
overflow: hidden;
}
</style>

View File

@ -149,8 +149,8 @@
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isLast: $localAndRemoteCommits.length === 0 && idx + 1 === $localCommits.length,
isMiddle: $localAndRemoteCommits.length > 0 && idx + 1 === $localCommits.length
isLast: idx + 1 === $localCommits.length,
isMiddle: idx + 1 === $localCommits.length
})
)}
@ -181,7 +181,8 @@
{@render reorderDropzone(
reorderDropzoneManager.dropzoneBelowCommit(commit.id),
getReorderDropzoneOffset({
isLast: idx + 1 === $localAndRemoteCommits.length
isMiddle: idx + 1 === $localAndRemoteCommits.length
// isLast: idx + 1 === $localAndRemoteCommits.length
})
)}
<InsertEmptyCommitAction
@ -240,6 +241,7 @@
<style lang="postcss">
.commits {
position: relative;
display: flex;
flex-direction: column;
background-color: var(--clr-bg-2);

View File

@ -4,7 +4,7 @@
import { Project } from '$lib/backend/projects';
import BranchDropzone from '$lib/branch/BranchDropzone.svelte';
import BranchLane from '$lib/branch/BranchLane.svelte';
import { cloneWithRotation } from '$lib/dragging/draggable';
import { cloneElement } from '$lib/dragging/draggable';
import { persisted } from '$lib/persisted/persisted';
import Icon from '$lib/shared/Icon.svelte';
import { getContext, getContextStore } from '$lib/utils/context';
@ -94,12 +94,12 @@
e.stopPropagation();
return;
}
clone = cloneWithRotation(e.target);
clone = cloneElement(e.target as HTMLElement);
document.body.appendChild(clone);
// Get chromium to fire dragover & drop events
// https://stackoverflow.com/questions/6481094/html5-drag-and-drop-ondragover-not-firing-in-chrome/6483205#6483205
e.dataTransfer?.setData('text/html', 'd'); // cannot be empty string
e.dataTransfer?.setDragImage(clone, e.offsetX + 30, e.offsetY + 30); // Adds the padding
e.dataTransfer?.setDragImage(clone, e.offsetX, e.offsetY); // Adds the padding
dragged = e.currentTarget;
priorPosition = Array.from(dropZone.children).indexOf(dragged);
}}

View File

@ -1,82 +1,47 @@
import { dropzoneRegistry } from './dropzone';
import { getVSIFileIcon } from '$lib/ext-icons';
import { pxToRem } from '$lib/utils/pxToRem';
import { type CommitStatus } from '$lib/vbranches/types';
import type { Draggable } from './draggables';
export interface DraggableConfig {
readonly selector?: string;
readonly disabled?: boolean;
readonly label?: string;
readonly filePath?: string;
readonly sha?: string;
readonly dateAndAuthor?: string;
readonly commitType?: CommitStatus;
readonly data?: Draggable | Promise<Draggable>;
readonly viewportId?: string;
readonly extendWithClass?: string;
}
export function applyContainerStyle(element: HTMLElement) {
element.style.position = 'absolute';
element.style.top = '-9999px'; // Element has to be in the DOM so we move it out of sight
element.style.display = 'inline-block';
element.style.padding = '30px'; // To prevent clipping of rotated element
function createElement(
tag: string,
classNames: string[],
textContent?: string,
src?: string
): HTMLElement {
const el = document.createElement(tag);
el.classList.add(...classNames);
if (textContent) el.textContent = textContent;
if (src) (el as HTMLImageElement).src = src;
return el;
}
export function createContainerForMultiDrag(
children: Element[],
extendWithClass: string | undefined
): HTMLDivElement {
const inner = document.createElement('div');
inner.style.display = 'flex';
inner.style.flexDirection = 'column';
inner.style.gap = '0.125rem';
children.forEach((child) => {
inner.appendChild(cloneWithPreservedDimensions(child, extendWithClass));
});
rotateElement(inner);
const container = document.createElement('div');
container.appendChild(inner);
applyContainerStyle(container);
return container;
}
export function cloneWithPreservedDimensions(node: any, extendWithClass: string | undefined) {
const clone = node.cloneNode(true) as HTMLElement;
clone.style.height = node.clientHeight + 'px';
clone.style.width = node.clientWidth + 'px';
clone.classList.remove('selected-draggable');
extendWithClass && clone.classList.add(extendWithClass);
return clone;
}
export function cloneWithRotation(node: any, extendWithClass: string | undefined = undefined) {
const container = document.createElement('div');
const clone = cloneWithPreservedDimensions(node, extendWithClass) as HTMLElement;
container.appendChild(clone);
// exclude all ignored elements from the clone
const ignoredElements = container.querySelectorAll('[data-remove-from-draggable]');
ignoredElements.forEach((element) => {
element.remove();
});
applyContainerStyle(container);
// Style the inner node so it retains the shape and then rotate
// TODO: This rotation puts a requirement on draggables to have
// an outer container, which feels extra. Consider refactoring.
rotateElement(clone);
return container as HTMLElement;
}
function rotateElement(element: HTMLElement) {
element.style.rotate = `${Math.floor(Math.random() * 3)}deg`;
}
export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
let opts = initialOpts;
function setupDragHandlers(
node: HTMLElement,
opts: DraggableConfig,
createClone: (opts: DraggableConfig, selectedElements: HTMLElement[]) => HTMLElement,
params: {
handlerWidth: boolean;
maxHeight?: number;
} = {
handlerWidth: false
}
) {
let dragHandle: HTMLElement | null;
let clone: HTMLElement | undefined;
let clone: HTMLElement;
let selectedElements: HTMLElement[] = [];
function handleMouseDown(e: MouseEvent) {
@ -84,84 +49,75 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
}
function handleDragStart(e: DragEvent) {
let elt: HTMLElement | null = dragHandle;
e.stopPropagation();
while (elt) {
if (elt.dataset.noDrag !== undefined) {
e.stopPropagation();
e.preventDefault();
return false;
}
elt = elt.parentElement;
if (dragHandle && dragHandle.dataset.noDrag !== undefined) {
e.preventDefault();
return false;
}
// If the draggable specifies a selector then we check if we're dragging selected elements
if (opts.selector) {
// Checking for selected siblings in the parent of the parent container likely works
// for most use-cases but it was done here primarily for dragging multiple files.
const parentNode = node.parentNode?.parentNode;
selectedElements = parentNode
? Array.from(parentNode.querySelectorAll(opts.selector).values() as Iterable<HTMLElement>)
: [];
if (selectedElements.length > 0) {
clone = createContainerForMultiDrag(selectedElements, opts.extendWithClass);
// Dim the original element while dragging
selectedElements.forEach((element) => {
element.style.opacity = '0.5';
});
const parentNode = node.parentElement?.parentElement;
if (!parentNode) {
console.error('draggable parent node not found');
return;
}
selectedElements = Array.from(
parentNode.querySelectorAll(opts.selector) as NodeListOf<HTMLElement>
);
}
if (!clone) {
clone = cloneWithRotation(node, opts.extendWithClass);
if (selectedElements.length === 0) {
selectedElements = [node];
}
clone = createClone(opts, selectedElements);
if (params.handlerWidth) {
clone.style.width = node.clientWidth + 'px';
}
if (params.maxHeight) {
clone.style.maxHeight = pxToRem(params.maxHeight);
}
// console.log('selectedElements', selectedElements);
selectedElements.forEach((el) => el.classList.add('drag-handle'));
document.body.appendChild(clone);
Array.from(dropzoneRegistry.values()).forEach((dropzone) => {
dropzone.register(opts.data);
});
// Get chromium to fire dragover & drop events
// https://stackoverflow.com/questions/6481094/html5-drag-and-drop-ondragover-not-firing-in-chrome/6483205#6483205
e.dataTransfer?.setData('text/html', 'placeholder copy'); // cannot be empty string
e.dataTransfer?.setDragImage(clone, e.offsetX + 30, e.offsetY + 30); // Adds the padding
e.stopPropagation();
if (e.dataTransfer) {
if (params.handlerWidth) {
e.dataTransfer.setDragImage(clone, e.offsetX, e.offsetY);
} else {
e.dataTransfer.setDragImage(clone, clone.offsetWidth - 20, 25);
}
// Get chromium to fire dragover & drop events
// https://stackoverflow.com/questions/6481094/html5-drag-and-drop-ondragover-not-firing-in-chrome/6483205#6483205
e.dataTransfer?.setData('text/html', 'd'); // cannot be empty string
e.dataTransfer.effectAllowed = 'uninitialized';
}
}
function handleDragEnd(e: DragEvent) {
if (clone) {
clone.remove();
clone = undefined;
}
// reset the opacity of the selected elements
selectedElements.forEach((element) => {
element.style.opacity = '1';
});
e.stopPropagation();
if (clone) clone.remove();
selectedElements.forEach((el) => el.classList.remove('drag-handle'));
Array.from(dropzoneRegistry.values()).forEach((dropzone) => {
dropzone.unregister();
});
e.stopPropagation();
}
const viewport = opts.viewportId ? document.getElementById(opts.viewportId) : null;
const triggerRange = 150;
const scrollSpeed = (viewport?.clientWidth || 500) / 2;
let lastDrag = new Date().getTime();
function handleDrag(e: DragEvent) {
e.preventDefault();
const viewport = opts.viewportId ? document.getElementById(opts.viewportId) : null;
if (!viewport) return;
if (new Date().getTime() - lastDrag < 500) return;
lastDrag = new Date().getTime();
const triggerRange = 150;
const scrollSpeed = (viewport.clientWidth || 500) / 2;
const viewportWidth = viewport.clientWidth;
const relativeX = e.clientX - viewport.getBoundingClientRect().left;
// Scroll horizontally if the draggable is near the edge of the viewport
if (relativeX < triggerRange) {
viewport.scrollBy(-scrollSpeed, 0);
} else if (relativeX > viewportWidth - triggerRange) {
@ -189,12 +145,123 @@ export function draggable(node: HTMLElement, initialOpts: DraggableConfig) {
setup(opts);
return {
update(opts: DraggableConfig) {
update(newOpts: DraggableConfig) {
clean();
setup(opts);
setup(newOpts);
},
destroy() {
clean();
}
};
}
//////////////////////////
//// COMMIT DRAGGABLE ////
//////////////////////////
export function createCommitElement(
commitType: CommitStatus | undefined,
label: string | undefined,
sha: string | undefined,
dateAndAuthor: string | undefined
): HTMLDivElement {
const cardEl = createElement('div', ['draggable-commit']) as HTMLDivElement;
const labelEl = createElement('span', ['text-base-13', 'text-bold'], label || 'Empty commit');
const infoEl = createElement('div', ['draggable-commit-info', 'text-base-11']);
const shaEl = createElement('span', ['draggable-commit-info-text'], sha);
const dateAndAuthorEl = createElement('span', ['draggable-commit-info-text'], dateAndAuthor);
if (commitType) {
const indicatorClass = `draggable-commit-${commitType}`;
labelEl.classList.add('draggable-commit-indicator', indicatorClass);
}
cardEl.appendChild(labelEl);
infoEl.appendChild(shaEl);
infoEl.appendChild(dateAndAuthorEl);
cardEl.appendChild(infoEl);
return cardEl;
}
export function draggableCommit(node: HTMLElement, initialOpts: DraggableConfig) {
function createClone(opts: DraggableConfig) {
return createCommitElement(opts.commitType, opts.label, opts.sha, opts.dateAndAuthor);
}
return setupDragHandlers(node, initialOpts, createClone, {
handlerWidth: true
});
}
////////////////////////
//// FILE DRAGGABLE ////
////////////////////////
export function createChipsElement(
childrenAmount: number,
label: string | undefined,
filePath: string | undefined
): HTMLDivElement {
const containerEl = createElement('div', ['draggable-chip-container']) as HTMLDivElement;
const chipEl = createElement('div', ['draggable-chip']);
containerEl.appendChild(chipEl);
if (filePath) {
const iconEl = createElement(
'img',
['draggable-chip-icon'],
undefined,
getVSIFileIcon(filePath)
);
chipEl.appendChild(iconEl);
}
const labelEl = createElement('span', ['text-base-12'], label);
chipEl.appendChild(labelEl);
if (childrenAmount > 1) {
const amountTag = createElement(
'div',
['text-base-11', 'text-bold', 'draggable-chip-amount'],
childrenAmount.toString()
);
chipEl.appendChild(amountTag);
}
if (childrenAmount === 2) {
containerEl.classList.add('draggable-chip-two');
} else if (childrenAmount > 2) {
containerEl.classList.add('draggable-chip-multiple');
}
return containerEl;
}
export function draggableChips(node: HTMLElement, initialOpts: DraggableConfig) {
function createClone(opts: DraggableConfig, selectedElements: HTMLElement[]) {
return createChipsElement(selectedElements.length, opts.label, opts.filePath);
}
return setupDragHandlers(node, initialOpts, createClone);
}
////////////////////////
//// HUNK DRAGGABLE ////
////////////////////////
export function cloneElement(node: HTMLElement) {
const cloneEl = node.cloneNode(true) as HTMLElement;
// exclude all ignored elements from the clone
const ignoredElements = Array.from(cloneEl.querySelectorAll('[data-remove-from-draggable]'));
ignoredElements.forEach((el) => el.remove());
return cloneEl;
}
export function draggableElement(node: HTMLElement, initialOpts: DraggableConfig) {
function createClone() {
return cloneElement(node);
}
return setupDragHandlers(node, initialOpts, createClone, {
handlerWidth: true
});
}

View File

@ -1,48 +1,83 @@
<script lang="ts">
import Icon from '$lib/shared/Icon.svelte';
import { pxToRem } from '$lib/utils/pxToRem';
import { scale } from 'svelte/transition';
interface Props {
hovered: boolean;
activated: boolean;
label?: string;
extraPaddings?: {
top?: number;
right?: number;
bottom?: number;
left?: number;
};
}
const { hovered, activated, label = 'Drop here' }: Props = $props();
const { hovered, activated, label = 'Drop here', extraPaddings }: Props = $props();
let defaultPadding = $derived.by(() => {
if (hovered) return 2;
return 4;
});
const extraPaddingTop = extraPaddings?.top ?? 0;
const extraPaddingRight = extraPaddings?.right ?? 0;
const extraPaddingBottom = extraPaddings?.bottom ?? 0;
const extraPaddingLeft = extraPaddings?.left ?? 0;
</script>
<div class="dropzone-target container" class:activated class:hovered>
<div class="dropzone-content">
<Icon name="new-file-small-filled" />
<p class="text-base-13">{label}</p>
<div
transition:scale={{ duration: 200, start: 0.9 }}
class="dropzone-target dropzone-wrapper"
class:activated
class:hovered
style="--padding-top: {pxToRem(defaultPadding + extraPaddingTop)}; --padding-right: {pxToRem(
defaultPadding + extraPaddingRight
)}; --padding-bottom: {pxToRem(defaultPadding + extraPaddingBottom)}; --padding-left: {pxToRem(
defaultPadding + extraPaddingLeft
)}"
>
<div class="container">
<div class="dropzone-label">
<svg
class="dropzone-label-icon"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11 7L6 2M6 2L1 7M6 2L6 12" stroke="white" stroke-width="1.5" />
</svg>
<span class="text-base-12 text-semibold">{label}</span>
</div>
<!-- add svg rectange -->
<svg width="100%" height="100%" class="animated-rectangle">
<rect width="100%" height="100%" rx="5" ry="5" vector-effect="non-scaling-stroke" />
</svg>
</div>
</div>
<style lang="postcss">
:root {
--dropzone-height: 16px;
--dropzone-overlap: calc(var(--dropzone-height) / 2);
}
.container {
.dropzone-wrapper {
z-index: var(--z-ground);
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
padding-top: var(--padding-top);
padding-right: var(--padding-right);
padding-bottom: var(--padding-bottom);
padding-left: var(--padding-left);
display: flex;
display: none;
align-items: center;
justify-content: center;
background-color: oklch(from var(--clr-scale-pop-70) l c h / 0.1);
outline-color: var(--clr-scale-pop-40);
outline-style: dashed;
outline-width: 1px;
outline-offset: -10px;
backdrop-filter: blur(10px);
z-index: var(--z-lifted);
transition: background-color 0.1s;
transition:
transform 0.1s,
padding 0.1s;
/* It is very important that all children are pointer-events: none */
/* https://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element */
@ -50,19 +85,98 @@
pointer-events: none;
}
&:not(.activated) {
display: none;
&.activated {
display: flex;
animation: dropzone-scale 0.1s forwards;
}
&.hovered {
background-color: oklch(from var(--clr-scale-pop-20) l c h / 0.1);
transform: scale(1.01);
.animated-rectangle rect {
fill: oklch(from var(--clr-scale-pop-50) l c h / 0.14);
}
.dropzone-label {
opacity: 1;
transform: translateY(0) scale(1);
}
}
}
.dropzone-content {
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.dropzone-label {
opacity: 0;
display: flex;
align-items: center;
gap: 6px;
color: var(--clr-scale-pop-40);
padding: 6px 10px;
border-radius: 100px;
color: var(--clr-theme-pop-on-element);
background-color: var(--clr-theme-pop-element);
transform: translateY(3px) scale(0.95);
transition:
opacity 0.1s,
transform 0.15s;
}
.dropzone-label-icon {
width: 12px;
height: 12px;
animation: icon-shifting 1s infinite;
}
@keyframes icon-shifting {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-2px);
}
100% {
transform: translateY(0);
}
}
.animated-rectangle {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
& rect {
fill: oklch(from var(--clr-scale-pop-50) l c h / 0.1);
stroke: var(--clr-scale-pop-50);
stroke-width: 2px;
stroke-dasharray: 2;
stroke-dashoffset: 30;
transform-origin: center;
transition:
fill var(--transition-fast),
transform var(--transition-fast);
animation: dash 4s linear infinite;
}
}
@keyframes dash {
from {
stroke-dashoffset: 30;
}
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@ -13,21 +13,21 @@
<div
class="dropzone-target container"
class:activated
style="--y-offset: {pxToRem(yOffsetPx) || 0}"
class:hovered
style:--y-offset={pxToRem(yOffsetPx)}
>
<div class="indicator" class:hovered></div>
<div class="indicator"></div>
</div>
<style lang="postcss">
:root {
--dropzone-height: 16px;
--dropzone-overlap: calc(var(--dropzone-height) / 2);
}
.container {
--dropzone-overlap: calc(var(--dropzone-height) / 2);
--dropzone-height: 16px;
height: var(--dropzone-height);
margin-top: calc(var(--dropzone-overlap) * -1);
margin-bottom: calc(var(--dropzone-overlap) * -1);
/* background-color: rgba(0, 0, 0, 0.1); */
width: 100%;
position: relative;
top: var(--y-offset);
@ -35,7 +35,7 @@
display: flex;
align-items: center;
z-index: 101;
z-index: var(--z-floating);
/* It is very important that all children are pointer-events: none */
/* https://stackoverflow.com/questions/7110353/html5-dragleave-fired-when-hovering-a-child-element */
@ -46,17 +46,19 @@
&:not(.activated) {
display: none;
}
&.hovered {
& .indicator {
background-color: var(--clr-theme-pop-element);
}
}
}
.indicator {
width: 100%;
height: 3px;
height: 2px;
margin-top: 1px;
transition: opacity 0.1s;
background-color: var(--clr-border-2);
opacity: 0;
&.hovered {
opacity: 1;
}
background-color: transparent;
}
</style>

View File

@ -2,6 +2,7 @@
import FileStatusTag from './FileStatusTag.svelte';
import { getVSIFileIcon } from '$lib/ext-icons';
import Button from '$lib/shared/Button.svelte';
import { splitFilePath } from '$lib/utils/filePath';
import { computeFileStatus } from '$lib/utils/fileStatus';
import { computeAddedRemovedByFiles } from '$lib/utils/metrics';
import { createEventDispatcher } from 'svelte';
@ -14,22 +15,12 @@
$: fileStats = computeAddedRemovedByFiles(file);
$: fileStatus = computeFileStatus(file);
function boldenFilename(filepath: string): { filename: string; path: string } {
const parts = filepath.split('/');
if (parts.length === 0) return { filename: '', path: '' };
const filename = parts[parts.length - 1];
const path = parts.slice(0, -1).join('/');
return { filename, path };
}
$: fileTitle = boldenFilename(file.path);
$: fileTitle = splitFilePath(file.path);
</script>
<div class="header">
<div class="header__inner">
<img src={getVSIFileIcon(file.path)} alt="" width="13" height="13" class="icon" />
<img src={getVSIFileIcon(file.path)} alt="" class="icon" />
<div class="header__info truncate">
<div class="header__filetitle text-base-13 truncate">
<span class="header__filename">{fileTitle.filename}</span>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import FileContextMenu from './FileContextMenu.svelte';
import FileStatusIcons from './FileStatusIcons.svelte';
import { draggable } from '$lib/dragging/draggable';
import { draggableChips } from '$lib/dragging/draggable';
import { DraggableFile } from '$lib/dragging/draggables';
import { getVSIFileIcon } from '$lib/ext-icons';
import Checkbox from '$lib/shared/Checkbox.svelte';
@ -116,7 +116,9 @@
popupMenu.openByMouse(e, { files: [file] });
}
}}
use:draggable={{
use:draggableChips={{
label: `${file.filename}`,
filePath: file.path,
data: $selectedFiles.then(
(files) => new DraggableFile($branch?.id || '', file, $commit, files)
),
@ -227,6 +229,7 @@
}
.info {
pointer-events: none;
display: flex;
align-items: center;
flex-grow: 1;

View File

@ -4,6 +4,7 @@
import { getVSIFileIcon } from '$lib/ext-icons';
import { createdOnDay } from '$lib/history/history';
import Button from '$lib/shared/Button.svelte';
import { splitFilePath } from '$lib/utils/filePath';
import { toHumanReadableTime } from '$lib/utils/time';
import { createEventDispatcher } from 'svelte';
import type { Snapshot, SnapshotDetails } from '$lib/history/types';
@ -152,10 +153,6 @@
const error = entry.details?.trailers.find((t) => t.key === 'error')?.value;
const operation = mapOperation(entry.details);
function getPathOnly(path: string) {
return path.split('/').slice(0, -1).join('/');
}
</script>
<div
@ -225,10 +222,10 @@
/>
<div class="text-base-12 files-attacment__file-path-and-name">
<span class="files-attacment__file-name">
{filePath.split('/').pop()}
{splitFilePath(filePath).filename}
</span>
<span class="files-attacment__file-path">
{getPathOnly(filePath)}
{splitFilePath(filePath).path}
</span>
</div>
</button>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Project } from '$lib/backend/projects';
import { draggable } from '$lib/dragging/draggable';
import { draggableElement } from '$lib/dragging/draggable';
import { DraggableHunk } from '$lib/dragging/draggables';
import HunkContextMenu from '$lib/hunk/HunkContextMenu.svelte';
import HunkLines from '$lib/hunk/HunkLines.svelte';
@ -63,7 +63,7 @@
bind:this={viewport}
tabindex="0"
role="cell"
use:draggable={{
use:draggableElement={{
data: new DraggableHunk($branch?.id || '', section.hunk),
disabled: draggingDisabled
}}

View File

@ -97,6 +97,7 @@
</script>
<div
data-remove-from-draggable
on:mousedown={onMouseDown}
on:click|stopPropagation
on:dblclick|stopPropagation

View File

@ -57,7 +57,13 @@
{padding}
{shift}
{thickness}
on:dragging={(e) => dispatch('dragging', e.detail)}
on:dragging={(data) => dispatch('dragging', data.detail)}
on:scroll={(data) => {
const event = data.detail;
const target = event.target as HTMLDivElement;
scrolled = target.scrollTop > 0;
}}
/>
</div>
</div>
@ -75,7 +81,9 @@
width: 100%;
}
.contents {
display: block;
display: flex;
flex-direction: column;
min-height: 100%;
min-width: 100%;
}
.scrolled {

View File

@ -61,6 +61,7 @@
const dispatch = createEventDispatcher<{
dragging: boolean;
scroll: Event;
}>();
/////////////////////
@ -190,9 +191,11 @@
};
}
function onScroll() {
function onScroll(e: Event) {
if (!isScrollable) return;
dispatch('scroll', e);
clearTimer();
setupTimer();
@ -276,6 +279,7 @@
<div
bind:this={track}
data-remove-from-draggable
class="scrollbar-track"
class:horz
class:vert

View File

@ -0,0 +1,9 @@
export function splitFilePath(filepath: string): { filename: string; path: string } {
const parts = filepath.split('/');
if (parts.length === 0) return { filename: '', path: '' };
const filename = parts[parts.length - 1];
const path = parts.slice(0, -1).join('/');
return { filename, path };
}

View File

@ -0,0 +1,162 @@
.draggable-chip-container {
position: absolute;
display: flex;
}
.draggable-chip {
z-index: 3;
position: relative;
display: flex;
align-items: center;
gap: 6px;
background-color: var(--clr-bg-1);
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
padding: 8px;
min-width: 50px;
max-width: 250px;
}
.draggable-chip-icon {
width: 14px;
height: 14px;
}
.draggable-chip-amount {
position: absolute;
top: -6px;
right: -8px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--clr-scale-ntrl-20);
color: var(--clr-scale-ntrl-100);
padding: 2px 4px;
min-width: 16px;
border-radius: 16px;
margin-left: 5px;
}
/* if dragging more then one item */
.draggable-chip-two {
&::after {
content: '';
position: absolute;
z-index: 2;
top: 6px;
left: 6px;
width: 100%;
height: 100%;
border: 1px solid var(--clr-border-2);
background-color: var(--clr-bg-1);
border-radius: var(--radius-m);
}
}
.draggable-chip-multiple {
&::after,
&::before {
content: '';
position: absolute;
width: 100%;
height: 100%;
border: 1px solid var(--clr-border-2);
background-color: var(--clr-bg-1);
border-radius: var(--radius-m);
}
&::before {
z-index: 2;
top: 6px;
left: 6px;
}
&::after {
z-index: 1;
top: 12px;
left: 12px;
}
}
/* COMMIT */
.draggable-commit {
position: absolute;
display: flex;
flex-direction: column;
gap: 8px;
background-color: var(--clr-bg-1);
border-radius: var(--radius-m);
border: 1px solid var(--clr-border-2);
padding: 12px 12px 12px 16px;
overflow: hidden;
}
.draggable-commit-indicator {
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
}
}
.draggable-commit-localAndRemote {
&::before {
background-color: var(--clr-commit-remote);
}
}
.draggable-commit-local {
color: var(--clr-text-1);
&::before {
background-color: var(--clr-commit-local);
}
}
.draggable-commit-integrated {
color: var(--clr-text-1);
&::before {
background-color: var(--clr-commit-shadow);
}
}
.draggable-commit-info {
display: flex;
align-items: center;
/* gap: 8px; */
}
.draggable-commit-info-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--clr-text-2);
&:not(:last-child):after {
content: '•';
margin: 0 5px;
color: var(--clr-text-3);
}
}
.drag-handle {
opacity: 0.5;
/* disable children events in order to prevent false dragleave events */
& > * {
pointer-events: none;
}
}
@keyframes dropzone-scale {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@ -12,6 +12,7 @@
@import './text-input.css';
@import './commit-lines.css';
@import './markdown.css';
@import './draggable.css';
/* CSS VARIABLES */
:root {