diff --git a/src/dragAndDrop.ts b/src/dragAndDrop.ts new file mode 100644 index 0000000..f1d83f4 --- /dev/null +++ b/src/dragAndDrop.ts @@ -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 = (s: So) => Si; +export interface DragAndDropOptions { + itemSelector: string; + handle?: string; + DOMDriverKey?: string; + selectionDelay?: number; +} +export interface DragAndDropSinks { + dragging: Stream; + dragMove: Stream; + dragStart: Stream; + dragEnd: Stream; +} + +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( + options: DragAndDropOptions +): Component { + return function(sources: Sources): DragAndDropSinks { + if (!options.DOMDriverKey) { + options.DOMDriverKey = 'DOM'; + } + const down$: Stream = getMouseStream( + sources[options.DOMDriverKey], + ['mousedown', 'touchstart'], + options.handle || options.itemSelector + ); + const up$: Stream = getMouseStream( + sources[options.DOMDriverKey], + ['mouseleave', 'mouseup', 'touchend'], + 'body' + ); + const move$: Stream = getMouseStream( + sources[options.DOMDriverKey], + ['mousemove', 'touchmove'], + 'body' + ); + + const dragStart$: Stream = down$ + .map(ev => + xs + .of(ev) + .compose>(delay(options.selectionDelay)) + .endWhen(xs.merge(up$, move$)) + ) + .flatten(); + const dragEnd$: Stream = dragStart$ + .map(_ => up$.take(1)) + .flatten(); + const dragMove$: Stream = 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 { + 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; +} + +function augmentEvent(ev: any): MouseEvent { + const touch: any = ev.touches[0]; + ev.clientX = touch.clientX; + ev.clientY = touch.clientY; + return ev; +} diff --git a/src/eventHandlers/mousedown.ts b/src/eventHandlers/mousedown.ts index 16dba1a..cc1c99a 100644 --- a/src/eventHandlers/mousedown.ts +++ b/src/eventHandlers/mousedown.ts @@ -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; @@ -34,7 +27,7 @@ export function mousedownHandler( .indexOf(item); const children = node.children - .map(addData) + .map(saveOriginalIndexes) .map(hideSelected(indexClicked)) .concat( createGhost(indexClicked, ev, item, node.children[ @@ -42,12 +35,14 @@ export function mousedownHandler( ] 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 @@ -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 }); } @@ -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' - } - ); } diff --git a/src/eventHandlers/mousemove.ts b/src/eventHandlers/mousemove.ts index 310b85e..0af6398 100644 --- a/src/eventHandlers/mousemove.ts +++ b/src/eventHandlers/mousemove.ts @@ -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, @@ -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; } diff --git a/src/eventHandlers/mouseup.ts b/src/eventHandlers/mouseup.ts index c7c03e6..d8a7907 100644 --- a/src/eventHandlers/mouseup.ts +++ b/src/eventHandlers/mouseup.ts @@ -1,8 +1,8 @@ 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, @@ -10,13 +10,12 @@ export function mouseupHandler( 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 diff --git a/src/eventHandlers/utils.ts b/src/eventHandlers/utils.ts new file mode 100644 index 0000000..232f562 --- /dev/null +++ b/src/eventHandlers/utils.ts @@ -0,0 +1,44 @@ +export const textSelectionClasses = [ + '-webkit-touch-callout', + '-webkit-user-select', + '-khtml-user-select', + '-moz-user-select', + '-ms-user-select', + 'user-select' +]; + +export function getArea(item: Element): number { + const rect = item.getBoundingClientRect(); + return rect.width * rect.height; +} + +export function getIntersectionArea(rectA: any, rectB: any): number { + let a = + Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left); + a = Math.max(0, a); + const area = + a * + (Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top)); + return area < 0 ? 0 : area; +} + +const getBox = ({ left, right, bottom, top }) => ({ left, right, bottom, top }); +export function getIntersection( + ghost: Element, + elm: Element, + upper: boolean +): number { + const fuzzFactor = 0.25; + const a = getBox((upper ? ghost : elm).getBoundingClientRect()); + const b = getBox((upper ? elm : ghost).getBoundingClientRect()); + + const aRight = { ...a, left: a.right - (a.right - a.left) * fuzzFactor }; + const aBottom = { ...a, top: a.bottom - (a.bottom - a.top) * fuzzFactor }; + + const bLeft = { ...b, right: b.left + (b.right - b.left) * fuzzFactor }; + const bTop = { ...b, bottom: b.top + (b.bottom - b.top) * fuzzFactor }; + + const area = + getIntersectionArea(aRight, bLeft) + getIntersectionArea(aBottom, bTop); + return area < 0 ? 0 : area; +} diff --git a/src/ghost.ts b/src/ghost.ts new file mode 100644 index 0000000..83f6335 --- /dev/null +++ b/src/ghost.ts @@ -0,0 +1,51 @@ +import { VNode } from '@cycle/dom'; + +import { cloneNodeWithData } from './helpers'; + +export 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) + }; + 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'; + + const nodeWithDataset = cloneNodeWithData(node, 'dataset', { + offsetX, + offsetY, + item, + ghost: true + }); + + return cloneNodeWithData(nodeWithDataset, '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' + }); +} + +export function updateGhost(node: VNode, ev: MouseEvent): VNode { + const { offsetX, offsetY } = node.data.dataset as any; + return cloneNodeWithData(node, 'style', { + left: ev.clientX - offsetX + 'px', + top: ev.clientY - offsetY + 'px' + }); +} diff --git a/src/helpers.ts b/src/helpers.ts index 617e917..c7b329d 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -10,7 +10,21 @@ export function addKeys(node: VNode): VNode { }; } -export function addDataEntry(node: VNode, key: string, values: any): any { +interface MapStringToAny { + [k: string]: any; +} +interface MapNumberToAny { + [k: number]: any; +} +type MapToAny = MapStringToAny | MapNumberToAny; + +// clones the properties of `node` +// merges the `data` property w/ { [key]: values } +export function cloneNodeWithData( + node: VNode, + key: string, + values: MapToAny +): any { return { ...node, data: { @@ -22,3 +36,37 @@ export function addDataEntry(node: VNode, key: string, values: any): any { } }; } + +// https://github.com/tc39/proposal-object-from-entries/blob/master/polyfill.js +export function ObjectFromEntries(iter) { + const obj = {}; + for (const pair of iter) { + if (Object(pair) !== pair) { + throw new TypeError( + 'iterable for fromEntries should yield objects' + ); + } + + // Consistency with Map: contract is that entry has "0" and "1" keys, not + // that it is an array or iterable. + + const { '0': key, '1': val } = pair; + + Object.defineProperty(obj, key, { + configurable: true, + enumerable: true, + writable: true, + value: val + }); + } + return obj; +} + +export function mapValues(obj, mapFn) { + // TODO: Should either + // 1. ensure Object.entries exists, or provide polyfill? + // 2. Make es2018 a minimum requirement and have consumers provide polyfill + return ObjectFromEntries( + (Object as any).entries(obj).map(([key, val]) => [key, mapFn(val, key)]) + ); +} diff --git a/src/makeSortable.ts b/src/makeSortable.ts index 99cc644..24fce97 100644 --- a/src/makeSortable.ts +++ b/src/makeSortable.ts @@ -4,30 +4,27 @@ 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 { + Component, + DragAndDropSinks, + makeDragAndDrop, + DragAndDropOptions +} from './dragAndDrop'; +import { addKeys, mapValues } from './helpers'; import { handleEvent } from './eventHandler'; -export type Component = (s: So) => Si; export type HOC = ( c: Component -) => Component; - -export interface SortableOptions { - itemSelector: string; - handle?: string; - DOMDriverKey?: string; - selectionDelay?: number; -} +) => Component; +export interface SortableOptions extends DragAndDropOptions {} export interface UpdateOrder { indexMap: { [old: number]: number }; oldIndex: number; newIndex: number; } -export interface SortableSinks { - dragging: Stream; +export interface SortableSinks extends DragAndDropSinks { updateLive: Stream; updateDone: Stream; } @@ -42,134 +39,75 @@ export function makeSortable( main: Component, options: SortableOptions ): Component { - return function(sources: Sources): Sinks & SortableSinks { + return sources => { + const dnd = makeDragAndDrop({ ...options })(sources); + const { dragStart, dragMove, dragEnd } = dnd; if (!options.DOMDriverKey) { options.DOMDriverKey = 'DOM'; } - const sinks: any = main(sources); const eventHandler = handleEvent(options); - + const domSink = sinks[options.DOMDriverKey]; const childDOM$: Stream = xs - .fromObservable(sinks[options.DOMDriverKey]) + .fromObservable(domSink) .map(addKeys); - const down$: Stream = getMouseStream( - sources[options.DOMDriverKey], - ['mousedown', 'touchstart'], - options.handle || options.itemSelector - ); - const up$: Stream = getMouseStream( - sources[options.DOMDriverKey], - ['mouseleave', 'mouseup', 'touchend'], - 'body' - ); - const move$: Stream = getMouseStream( - sources[options.DOMDriverKey], - ['mousemove', 'touchmove'], - 'body' - ); - - const mousedown$: Stream = down$ - .map(ev => - xs - .of(ev) - .compose>(delay(options.selectionDelay)) - .endWhen(xs.merge(up$, move$)) - ) - .flatten(); - const mouseup$: Stream = mousedown$ - .map(_ => up$.take(1)) - .flatten(); - const mousemove$: Stream = mousedown$ - .map(start => move$.endWhen(mouseup$)) - .flatten(); - const data$: Stream<[VNode, UpdateOrder | undefined]> = childDOM$ .map(dom => xs - .merge(mousedown$, mousemove$, mouseup$) + .merge(dragStart, dragMove, dragEnd) .fold(eventHandler, [dom, undefined]) ) .flatten(); - const vdom$: Stream = data$.map(([dom, _]) => dom); - const updateOrder$: Stream = data$ - .map(([_, x]) => x) - .filter(x => x !== undefined); - - const updateAccumulated$: Stream = mousedown$ - .map( - () => - updateOrder$ - .fold( - (acc, curr) => ({ - indexMap: acc.indexMap - ? Object.keys(acc.indexMap) - .map(k => ({ - [k]: - curr.indexMap[acc.indexMap[k]] - })) - .reduce( - (a, c) => ({ ...a, ...c }), - {} - ) - : curr.indexMap, - oldIndex: - acc.oldIndex === -1 - ? curr.oldIndex - : acc.oldIndex, - newIndex: curr.newIndex - }), - { - indexMap: undefined, - oldIndex: -1, - newIndex: -1 - } - ) - .drop(1) as Stream - ) - .flatten(); - - const updateDone$: Stream = mouseup$ - .compose(sampleCombine(updateAccumulated$)) + .map(([_, orderUpdate]) => orderUpdate) + .filter(orderUpdate => orderUpdate !== undefined); + const accumulatedUpdate$: Stream = accumulateUpdates( + dragStart, + updateOrder$ + ); + const sortEnd$: Stream = dnd.dragEnd + .compose(sampleCombine(accumulatedUpdate$)) .map(([_, x]) => x); - const dragInProgress$ = xs - .merge(mousedown$, mouseup$) - .fold(acc => !acc, false); - return { ...sinks, + ...mapValues(dnd, adapt), DOM: adapt(vdom$), - dragging: adapt(dragInProgress$), updateLive: adapt(updateOrder$), - updateDone: adapt(updateDone$) + updateDone: adapt(sortEnd$) }; }; } -function getMouseStream( - DOM: DOMSource, - eventTypes: string[], - handle: string -): Stream { - 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; -} - -function augmentEvent(ev: any): MouseEvent { - const touch: any = ev.touches[0]; - ev.clientX = touch.clientX; - ev.clientY = touch.clientY; - return ev; +const merge = (a, c) => ({ ...a, ...c }); +const mergeIndices = (acc, curr) => k => ({ + [k]: curr.indexMap[acc.indexMap[k]] +}); +const initialOrderUpdate = { + indexMap: undefined, + oldIndex: -1, + newIndex: -1 +}; +const mergeOrderUpdate = (acc, curr) => ({ + indexMap: updateIndexMap(acc, curr), + oldIndex: acc.oldIndex === -1 ? curr.oldIndex : acc.oldIndex, + newIndex: curr.newIndex +}); +const updateIndexMap = (acc, curr) => + acc.indexMap + ? Object.keys(acc.indexMap) + .map(mergeIndices(acc, curr)) + .reduce(merge, {}) + : curr.indexMap; +function accumulateUpdates(dragStart$, updateOrder$) { + return dragStart$ + .map( + () => + updateOrder$ + .fold(mergeOrderUpdate, initialOrderUpdate) + .drop(1) as Stream + ) + .flatten(); }