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

(WIP) Separate sortable logic from Drag & Drop, and add generic Drag and Drop functionality #21

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
107 changes: 107 additions & 0 deletions src/dragAndDrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import xs, { Stream } from 'xstream';
import delay from 'xstream/extra/delay';
import sampleCombine from 'xstream/extra/sampleCombine';
import throttle from 'xstream/extra/throttle';
import { DOMSource, VNode } from '@cycle/dom';
import { adapt } from '@cycle/run/lib/adapt';

import { addKeys } from './helpers';
import { handleEvent } from './eventHandler';

export type Component<So, Si> = (s: So) => Si;
export interface DragAndDropOptions {
itemSelector: string;
handle?: string;
DOMDriverKey?: string;
selectionDelay?: number;
}
export interface DragAndDropSinks {
dragging: Stream<boolean>;
dragMove: Stream<MouseEvent>;
dragStart: Stream<MouseEvent>;
dragEnd: Stream<MouseEvent>;
}

const defaultOptions = {
type: undefined,
// props: current component's props
// monitor: dragState
// component: instance of current component
spec: undefined, // dragStart(props, monitor, component), dragEnd, canDrag, isDragging(props, monitor)
collect: undefined,
options: undefined
};

export function makeDragAndDrop<Sources extends object, Sinks extends object>(
options: DragAndDropOptions
): Component<Sources, DragAndDropSinks> {
return function(sources: Sources): DragAndDropSinks {
if (!options.DOMDriverKey) {
options.DOMDriverKey = 'DOM';
}
const down$: Stream<MouseEvent> = getMouseStream(
sources[options.DOMDriverKey],
['mousedown', 'touchstart'],
options.handle || options.itemSelector
);
const up$: Stream<MouseEvent> = getMouseStream(
sources[options.DOMDriverKey],
['mouseleave', 'mouseup', 'touchend'],
'body'
);
const move$: Stream<MouseEvent> = getMouseStream(
sources[options.DOMDriverKey],
['mousemove', 'touchmove'],
'body'
);

const dragStart$: Stream<MouseEvent> = down$
.map(ev =>
xs
.of(ev)
.compose<Stream<MouseEvent>>(delay(options.selectionDelay))
.endWhen(xs.merge(up$, move$))
)
.flatten();
const dragEnd$: Stream<MouseEvent> = dragStart$
.map(_ => up$.take(1))
.flatten();
const dragMove$: Stream<MouseEvent> = dragStart$
.map(start => move$.endWhen(dragEnd$))
.flatten();
const dragInProgress$ = xs
.merge(dragStart$, dragEnd$)
.fold(acc => !acc, false);

return {
dragMove: dragMove$,
dragging: dragInProgress$,
dragEnd: dragEnd$,
dragStart: dragStart$
};
};
}

function getMouseStream(
DOM: DOMSource,
eventTypes: string[],
handle: string
): Stream<MouseEvent> {
return xs.merge(
...eventTypes
.slice(0, -1)
.map(ev => xs.fromObservable(DOM.select(handle).events(ev))),
xs
.fromObservable(
DOM.select(handle).events(eventTypes[eventTypes.length - 1])
)
.map(augmentEvent)
) as Stream<MouseEvent>;
}

function augmentEvent(ev: any): MouseEvent {
const touch: any = ev.touches[0];
ev.clientX = touch.clientX;
ev.clientY = touch.clientY;
return ev;
}
76 changes: 13 additions & 63 deletions src/eventHandlers/mousedown.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
import { VNode } from '@cycle/dom';

import { SortableOptions } from '../makeSortable';
import { addDataEntry } from '../helpers';

export const selectNames = [
'-webkit-touch-callout',
'-webkit-user-select',
'-khtml-user-select',
'-moz-user-select',
'-ms-user-select',
'user-select'
];
import { cloneNodeWithData } from '../helpers';
import { textSelectionClasses } from './utils';
import { createGhost } from '../ghost';

function findParent(el: Element, sel: string): Element {
let result = el;
Expand All @@ -34,20 +27,22 @@ export function mousedownHandler(
.indexOf(item);

const children = node.children
.map(addData)
.map(saveOriginalIndexes)
.map(hideSelected(indexClicked))
.concat(
createGhost(indexClicked, ev, item, node.children[
indexClicked
] as VNode)
);

const disabledTextSelectionStyles = textSelectionClasses
.map(n => ({ [n]: 'none' }))
.reduce((a, c) => ({ ...a, ...c }), {});

return [
{
...addDataEntry(node, 'style', {
...selectNames
.map(n => ({ [n]: 'none' }))
.reduce((a, c) => ({ ...a, ...c }), {}),
...cloneNodeWithData(node, 'style', {
...disabledTextSelectionStyles,
position: 'relative'
}),
children
Expand All @@ -56,8 +51,8 @@ export function mousedownHandler(
];
}

function addData(node: VNode, index: number): VNode {
return addDataEntry(node, 'dataset', {
function saveOriginalIndexes(node: VNode, index: number): VNode {
return cloneNodeWithData(node, 'dataset', {
originalIndex: index
});
}
Expand All @@ -66,51 +61,6 @@ function hideSelected(index: number): (node: VNode, i: number) => VNode {
return function(node, i) {
return i !== index
? node
: addDataEntry(node, 'style', {
opacity: 0
});
};
}

function createGhost(
clicked: number,
ev: any,
item: Element,
node: VNode
): VNode {
const rect = item.getBoundingClientRect();
const style = getComputedStyle(item);
const padding = {
top: parseFloat(style.paddingTop) + parseFloat(style.borderTop),
left: parseFloat(style.paddingLeft) + parseFloat(style.borderLeft),
bottom:
parseFloat(style.paddingBottom) + parseFloat(style.borderBottom),
right: parseFloat(style.paddingRight) + parseFloat(style.borderRight)
: cloneNodeWithData(node, 'style', { opacity: 0 });
};
const parentRect = item.parentElement.getBoundingClientRect();
const offsetX =
ev.clientX - rect.left + parentRect.left + parseFloat(style.marginLeft);
const offsetY =
ev.clientY - rect.top + parentRect.top + parseFloat(style.marginTop);

const sub = style.boxSizing !== 'border-box';

return addDataEntry(
addDataEntry(node, 'dataset', {
offsetX,
offsetY,
item,
ghost: true
}),
'style',
{
position: 'absolute',
left: ev.clientX - offsetX + 'px',
top: ev.clientY - offsetY + 'px',
width: rect.width - (sub ? padding.left - padding.right : 0) + 'px',
height:
rect.height - (sub ? padding.top - padding.bottom : 0) + 'px',
'pointer-events': 'none'
}
);
}
118 changes: 35 additions & 83 deletions src/eventHandlers/mousemove.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { VNode } from '@cycle/dom';

import { SortableOptions, UpdateOrder } from '../makeSortable';
import { addDataEntry } from '../helpers';
import { cloneNodeWithData } from '../helpers';
import { updateGhost } from '../ghost';
import { getIntersection, getArea } from './utils';

const nodeIsGhost = el => (el as any).dataset.ghost;
export function mousemoveHandler(
node: VNode,
ev: MouseEvent,
Expand All @@ -16,97 +19,46 @@ export function mousemoveHandler(
item.parentElement.children
);
const index = siblings.indexOf(item);
const ghost = siblings.filter(el => (el as any).dataset.ghost)[0];
const ghost = siblings.filter(nodeIsGhost)[0];
const itemArea = getArea(ghost);
let swapIndex = index;

const swapIndex = getSwapIndex(index, ghost, siblings);
const children = node.children.slice(0) as VNode[];

if (index > 0 && getIntersection(ghost, siblings[index - 1], true) > 0) {
swapIndex = index - 1;
} else if (
index < siblings.length - 2 &&
getIntersection(ghost, siblings[index + 1], false) > 0
) {
swapIndex = index + 1;
}

let updateOrder: UpdateOrder | undefined = undefined;

if (swapIndex !== index) {
const tmp = children[index];
children[index] = children[swapIndex];
children[swapIndex] = tmp;

updateOrder = {
indexMap: [],
oldIndex: index,
newIndex: swapIndex
};
}

const orderUpdate = getOrderUpdate(index, swapIndex, children);
children[children.length - 1] = updateGhost(
children[children.length - 1],
ev
);

return [
{
...node,
children
},
updateOrder
];
return [{ ...node, children }, orderUpdate];
}

function getArea(item: Element): number {
const rect = item.getBoundingClientRect();
return rect.width * rect.height;
}

function getIntersectionArea(rectA: any, rectB: any): number {
let a =
Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left);
a = a < 0 ? 0 : a;
const area =
a *
(Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top));
return area < 0 ? 0 : area;
const isAbove = (index, ghost, siblings) =>
index > 0 && getIntersection(ghost, siblings[index - 1], true) > 0;
const isBelow = (index, ghost, siblings) =>
index < siblings.length - 2 &&
getIntersection(ghost, siblings[index + 1], false) > 0;
function getSwapIndex(index, ghost, siblings) {
if (isAbove(index, ghost, siblings)) {
return index - 1;
} else if (isBelow(index, ghost, siblings)) {
return index + 1;
} else {
return index;
}
}

function getIntersection(ghost: Element, elm: Element, upper: boolean): number {
const f = 0.25;
const _a = (upper ? ghost : elm).getBoundingClientRect();
const _b = (upper ? elm : ghost).getBoundingClientRect();
const a = {
left: _a.left,
right: _a.right,
top: _a.top,
bottom: _a.bottom
};
const b = {
left: _b.left,
right: _b.right,
top: _b.top,
bottom: _b.bottom
};

const aRight = { ...a, left: a.right - (a.right - a.left) * f };
const aBottom = { ...a, top: a.bottom - (a.bottom - a.top) * f };

const bLeft = { ...b, right: b.left + (b.right - b.left) * f };
const bTop = { ...b, bottom: b.top + (b.bottom - b.top) * f };

const area =
getIntersectionArea(aRight, bLeft) + getIntersectionArea(aBottom, bTop);

return area < 0 ? 0 : area;
function swapChildren(indexA, indexB, children) {
const A = children[indexA];
children[indexA] = children[indexB];
children[indexB] = A;
}

function updateGhost(node: VNode, ev: MouseEvent): VNode {
const { offsetX, offsetY } = node.data.dataset as any;
return addDataEntry(node, 'style', {
left: ev.clientX - offsetX + 'px',
top: ev.clientY - offsetY + 'px'
});
function getOrderUpdate(index, swapIndex, children) {
if (swapIndex !== index) {
swapChildren(index, swapIndex, children);
return {
indexMap: [],
oldIndex: index,
newIndex: swapIndex
};
}
return undefined;
}
7 changes: 3 additions & 4 deletions src/eventHandlers/mouseup.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import { VNode } from '@cycle/dom';

import { SortableOptions } from '../makeSortable';
import { addDataEntry } from '../helpers';
import { selectNames } from './mousedown';
import { cloneNodeWithData } from '../helpers';
import { textSelectionClasses } from './utils';

export function mouseupHandler(
node: VNode,
ev: MouseEvent,
opts: SortableOptions
): [VNode, undefined] {
const children = node.children.slice(0, -1).map(cleanup);

return [
{
...deleteData(
node,
'style',
['position'].concat(selectNames),
['position'].concat(textSelectionClasses),
true
),
children
Expand Down
Loading