Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added drag functionality to update precendence for sort condition #3316

Merged
merged 8 commits into from
Dec 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions mathesar_ui/src/components/sortable/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Sortable

> **Note**
>
> We'd like to move this code into its own package at some point, which is why it has no imports.

This is a set of Svelte actions which make it easy to add drag-and-drop sorting to UI elements.

## Usage

1. Apply the `sortableContainer` action to the container element which contains the items you want to sort. This action takes two arguments:

- `getItems` — a function which returns the items to be sorted
- `onSort` — a function which is called when the user drops an item, with the new sorted items as its argument. Use this to update your data.

1. Apply the `sortableItem` action to each item you want to be sortable.

These items must be descendants of the `sortableContainer` element.

1. Apply the `sortableTrigger` action to the element which the user should drag to sort the item.

This element can be the same as the `sortableItem` element or a descendant of it.

```svelte
<script>
import { sortableContainer, sortableItem, sortableTrigger } from 'sortable';
</script>

<div
use:sortableContainer={{
getItems: () => items,
onSort: (newItems) => { items = newItems; }
}}
>
{#each items as item}
<div use:sortableItem>
<div use:sortableTrigger>(trigger UI here)</div>
(more complex per-item UI here)
</div>
{/each}
</div>

<style lang="scss">
@import '/src/components/sortable/sortable.css';
</style>
```

## Features

- ✅ Actions-only API — no components and easy to style to your liking
- ✅ Sortable trigger is defined separately from the sortable item, making drag handles possible within more complex UIs
- ✅ Touch support
- ✅ Animated transitions while sorting
- ✅ Sort preview is done in pure CSS without manipulating the DOM before the sort is confirmed
- ✅ No dependencies

## Current limitations

(Improvements welcome!)

- Vertical sorting only
- No support for nested sorting containers
- Data cannot be updated in realtime while the user is dragging an item — updates are only applied when the user drops the item
- No transitions after the user drops an item
- Touch support is restricted to one touch at a time
- The browser must support [pointer events](https://caniuse.com/pointer)
21 changes: 21 additions & 0 deletions mathesar_ui/src/components/sortable/sortable.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[data-sortable-container] {
isolation: isolate;
}

[data-sortable-container].is-sorting [data-sortable-item]:not(.is-dragging) {
transition: transform 0.2s ease-in-out;
}

[data-sortable-item].is-dragging {
position: relative;
z-index: 1;
}

[data-sortable-trigger] {
cursor: grab;
touch-action: none;
}

[data-sortable-item].is-dragging [data-sortable-trigger] {
cursor: grabbing;
}
266 changes: 266 additions & 0 deletions mathesar_ui/src/components/sortable/sortable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
const CONTAINER_ATTR = 'data-sortable-container';
const ITEM_ATTR = 'data-sortable-item';
const TRIGGER_ATTR = 'data-sortable-trigger';
const DRAGGING_CLASS = 'is-dragging';
const SORTING_CLASS = 'is-sorting';

function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}

interface Controller<Item> {
isSorting: boolean;
getItems: () => Item[];
onSort: (newItems: Item[]) => void;
}

interface ContainerElement<Item> extends HTMLElement {
sortableController: Controller<Item>;
}

function getItemFromTrigger(element: HTMLElement): HTMLElement | undefined {
if (element.hasAttribute(ITEM_ATTR)) return element;
return (element.closest(`[${ITEM_ATTR}]`) ?? undefined) as
| HTMLElement
| undefined;
}

function getContainerFromTrigger<Item>(
element: HTMLElement,
): ContainerElement<Item> | undefined {
return (element.closest(`[${CONTAINER_ATTR}]`) ?? undefined) as
| ContainerElement<Item>
| undefined;
}

function preventDefault(e: Event) {
e.preventDefault();
}

function midpoint(rect: DOMRect) {
return rect.top + rect.height / 2;
}

function setTransform(element: HTMLElement, value: number) {
if (value === 0) element.style.removeProperty('transform');
element.style.setProperty('transform', `translateY(${value}px)`);
}

function analyze(container: HTMLElement, draggingItem: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const items = container.querySelectorAll<HTMLElement>(`[${ITEM_ATTR}]`);
const itemIndexes = [...Array(items.length).keys()];
const rects = [...items].map((i) => i.getBoundingClientRect());
const draggingItemIndex = [...items].indexOf(draggingItem);
const draggingItemRect = rects[draggingItemIndex];
return {
items,
draggingItemIndex,
draggingItemHeight: draggingItemRect.height,
draggingItemMarginTop: rects[draggingItemIndex - 1]
? draggingItemRect.top - rects[draggingItemIndex - 1].bottom
: 0,
draggingItemMarginBottom: rects[draggingItemIndex + 1]
? rects[draggingItemIndex + 1].top - draggingItemRect.bottom
: 0,
/** The most extreme negative drag change possible for the item */
minDelta: containerRect.top - draggingItemRect.top,
/** The most extreme positive drag change possible for the item */
maxDelta: containerRect.bottom - draggingItemRect.bottom,
/**
* An array of lower bounds which describe the possible destination
* positions to which the dragging item can be moved. For each entry in this
* array, the index indicates the destination index, and the value indicates
* the minimum possible drag delta required to move the dragging item to
* that destination. Adjacent entries in this array can be used to validate
* a potential destination index, given the drag delta.
*
* The bounds are computed such that the leading edge of the dragging item
* must be moved to the midpoint of the destination item in order for the
* destination to be valid.
*/
destinationsLowerBounds: itemIndexes.map((i) => {
if (i === 0) return -Infinity;
if (i <= draggingItemIndex) {
return midpoint(rects[i - 1]) - draggingItemRect.top;
}
return midpoint(rects[i]) - draggingItemRect.bottom;
}),
};
}
type Analysis = ReturnType<typeof analyze>;

/**
* Walks through destinations from a best-guess starting point to efficiently
* find a matching destination.
*
* In theory we could make this function much simpler by searching through
* destinations using `findIndex` or similar, which would obviate the need to
* supply a `destinationToTry`. But, given that the new destination will almost
* always be adjacent to the old destination, we can make this much more
* efficient using a walking search. Efficiency is important because this is
* called on every pointer move event.
*/
function getDestination(
delta: number,
destinationToTry: number,
destinationsLowerBounds: number[],
/**
* The direction we're moving with our walking search. 0 indicates we haven't
* started walking yet.
*/
searchDirection: -1 | 0 | 1 = 0,
): number {
const target = destinationToTry + searchDirection;

// Test if lower bounds are met
if (searchDirection === -1 || searchDirection === 0) {
const lowerBound = destinationsLowerBounds[target];
if (delta < lowerBound) {
// Continue searching lower destinations
return getDestination(delta, target, destinationsLowerBounds, -1);
}
}

// Test if upper bounds are met
if (searchDirection === 1 || searchDirection === 0) {
const upperBound = destinationsLowerBounds[target + 1] ?? Infinity;
if (delta > upperBound) {
// Continue searching higher destinations
return getDestination(delta, target, destinationsLowerBounds, 1);
}
}

return target;
}

function getItemShift(
itemIndex: number,
analysis: Analysis,
destination: number,
): number {
const {
draggingItemIndex,
draggingItemHeight,
draggingItemMarginBottom,
draggingItemMarginTop,
} = analysis;
if (itemIndex >= destination && itemIndex < draggingItemIndex) {
// We are shifting the item down
return draggingItemHeight + draggingItemMarginTop;
}
if (itemIndex <= destination && itemIndex > draggingItemIndex) {
// We are shifting the item up
return -1 * (draggingItemHeight + draggingItemMarginBottom);
}
return 0;
}

/** A Svelte action for the element containing all sortable items */
export function sortableContainer<Item>(
node: HTMLElement,
options: {
getItems: () => Item[];
onSort: (newItems: Item[]) => void;
},
) {
node.setAttribute(CONTAINER_ATTR, '');
const containerElement = node as ContainerElement<Item>;
containerElement.sortableController = {
isSorting: false,
getItems: options.getItems,
onSort: options.onSort,
};
return {};
}

/** A Svelte action for each sortable item */
export function sortableItem(itemElement: Element) {
itemElement.setAttribute(ITEM_ATTR, '');
return {};
}

/** A Svelte action for the drag trigger element within each sortable item */
export function sortableTrigger(triggerElement: HTMLElement) {
let containerElement: ContainerElement<unknown>;
let itemElement: HTMLElement;
let initialY: number;
let destination: number;
let analysis: Analysis;

function arrange() {
for (const [itemIndex, item] of analysis.items.entries()) {
if (itemIndex === analysis.draggingItemIndex) continue;
setTransform(item, getItemShift(itemIndex, analysis, destination));
}
}

function handlePointerMove(event: PointerEvent) {
const rawDelta = event.clientY - initialY;
const clampedDelta = clamp(rawDelta, analysis.minDelta, analysis.maxDelta);
setTransform(itemElement, clampedDelta);
const newDestination = getDestination(
clampedDelta,
destination,
analysis.destinationsLowerBounds,
);
if (newDestination !== destination) {
destination = newDestination;
arrange();
}
}

function handlePointerUp(event: PointerEvent) {
triggerElement.removeEventListener('pointermove', handlePointerMove);
triggerElement.removeEventListener('pointerup', handlePointerUp);
triggerElement.removeEventListener('pointercancel', handlePointerUp);
window.removeEventListener('selectstart', preventDefault);
triggerElement.releasePointerCapture(event.pointerId);
containerElement.classList.remove(SORTING_CLASS);
itemElement.classList.remove(DRAGGING_CLASS);
const controller = containerElement.sortableController;
controller.isSorting = false;

for (const item of analysis.items) {
setTransform(item, 0);
}

if (analysis.draggingItemIndex !== destination) {
const items = [...controller.getItems()];
const draggedItem = items.splice(analysis.draggingItemIndex, 1)[0];
items.splice(destination, 0, draggedItem);
void controller.onSort(items);
}
}

function handlePointerDown(event: PointerEvent) {
const item = getItemFromTrigger(triggerElement);
if (!item) return;
itemElement = item;
const container = getContainerFromTrigger(triggerElement);
if (!container) return;
containerElement = container;
containerElement.classList.add(SORTING_CLASS);
const controller = containerElement.sortableController;
if (controller.isSorting) return; // To prevent multi-touch
controller.isSorting = true;
itemElement.classList.add(DRAGGING_CLASS);
analysis = analyze(containerElement, itemElement);
destination = analysis.draggingItemIndex;
initialY = event.clientY;
triggerElement.setPointerCapture(event.pointerId);
triggerElement.addEventListener('pointermove', handlePointerMove);
triggerElement.addEventListener('pointerup', handlePointerUp);
triggerElement.addEventListener('pointercancel', handlePointerUp);
window.addEventListener('selectstart', preventDefault);
}

triggerElement.setAttribute(TRIGGER_ATTR, '');
triggerElement.addEventListener('pointerdown', handlePointerDown);
triggerElement.addEventListener('contextmenu', preventDefault);
return {
destroy() {
triggerElement.removeEventListener('pointerdown', handlePointerDown);
},
};
}
2 changes: 2 additions & 0 deletions mathesar_ui/src/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
faShareFromSquare,
faXmark,
faExternalLink,
faGrip,
} from '@fortawesome/free-solid-svg-icons';
import type { IconProps } from '@mathesar-component-library/types';
import {
Expand Down Expand Up @@ -142,6 +143,7 @@ export const iconShare: IconProps = { data: faShareFromSquare };
export const iconRecreate: IconProps = { data: faRedo };
export const iconDisable: IconProps = { data: faXmark };
export const iconOpenLinkInNewTab = { data: faExternalLink };
export const iconGrip = { data: faGrip };

// THINGS
//
Expand Down
Loading
Loading