diff --git a/DELETE-THIS-DEFORE-MERGE-snap-mirror-line-test.html b/DELETE-THIS-DEFORE-MERGE-snap-mirror-line-test.html new file mode 100644 index 00000000..a6df07a6 --- /dev/null +++ b/DELETE-THIS-DEFORE-MERGE-snap-mirror-line-test.html @@ -0,0 +1,79 @@ + + + + + + + Document + + + + +
+ +
+
+ + + + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..8a302d66 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +- [ ] Fix bugs on mobile browsers. + - [ ] Issue: TouchSensor: Why set pageX/Y to clientX/Y + - [x] Add pageX/Y to SensorEvent +- [ ] event offset: Which coordiante given in API? Which coordiante use in code? \ + coordiantes: page, client, container, mirror +- [ ] `SnapMirror.line` edge case diff --git a/examples/.prettierrc b/examples/.prettierrc index 3f584f60..87e22d80 100644 --- a/examples/.prettierrc +++ b/examples/.prettierrc @@ -1,4 +1,7 @@ { + "trailingComma": "all", "printWidth": 120, - "singleQuote": true + "singleQuote": true, + "bracketSpacing": false, + "arrowParens": "always" } diff --git a/examples/src/content/Plugins/SnapMirror/SnapMirror.html b/examples/src/content/Plugins/SnapMirror/SnapMirror.html new file mode 100644 index 00000000..5ba93aa4 --- /dev/null +++ b/examples/src/content/Plugins/SnapMirror/SnapMirror.html @@ -0,0 +1,19 @@ +{% import 'components/Block/Block.html' as Block %} + +{% macro render(id) %} +
+
+
+ {{ Block.render('drag', {index: 1, draggable: true}) }} +
+
+ +
+ +
+ {{ Block.render('drag', {index: 1, draggable: true}) }} + {{ Block.render('drop', {type: 'Hollow'}) }} +
+
+
+{% endmacro %} diff --git a/examples/src/content/Plugins/SnapMirror/SnapMirror.scss b/examples/src/content/Plugins/SnapMirror/SnapMirror.scss new file mode 100644 index 00000000..f5698252 --- /dev/null +++ b/examples/src/content/Plugins/SnapMirror/SnapMirror.scss @@ -0,0 +1,76 @@ +//// +/// Content +/// SnapMirror +//// + +@import 'utils/shared/functions'; +@import 'utils/shared/layout'; + +.SnapMirror { + .Workspace { + $size: 50px; + $border-width: 0.3rem; + &.BlockLayout--typePositioned { + height: 32rem; + border: 0.6rem solid #212529; + overflow: auto; + + .Block { + width: calc(#{3 * $size} + #{$border-width}); + height: calc(#{3 * $size} + #{$border-width}); + position: absolute; + } + + &.draggable-container--over { + .Workspace__grid { + background: linear-gradient(180deg, #00f 0, #00f $border-width, transparent 0, transparent $size) 0px 0px / + 100% $size repeat-y, + linear-gradient(90deg, #00f 0, #00f $border-width, transparent 0, transparent $size) 0px 0px / #{$size} 100% + repeat-x; + } + } + } + + &__grid { + width: 1500px; + height: 1000px; + background: linear-gradient(180deg, #000 0, #000 $border-width, transparent 0, transparent $size) 0px 0px / 100% + $size repeat-y, + linear-gradient(90deg, #000 0, #000 $border-width, transparent 0, transparent $size) 0px 0px / #{$size} 100% repeat-x; + } + } + + .CircleRange { + &.BlockLayout--typePositioned { + height: 64rem; + border: 0.6rem solid #212529; + overflow: auto; + + .Block--isDraggable { + position: absolute; + width: 20rem; + height: 20rem; + z-index: 2; + } + + .Block--typeHollow { + position: absolute; + top: calc(50% - 10rem); + left: calc(50% - 10rem); + width: 20rem; + height: 20rem; + } + + .circle { + position: absolute; + top: calc(50% - 25rem); + left: calc(50% - 25rem); + width: 50rem; + height: 50rem; + border: 3px solid #000; + border-radius: 50%; + content: ''; + } + } + } +} diff --git a/examples/src/content/Plugins/SnapMirror/index.js b/examples/src/content/Plugins/SnapMirror/index.js new file mode 100644 index 00000000..5707c70a --- /dev/null +++ b/examples/src/content/Plugins/SnapMirror/index.js @@ -0,0 +1,95 @@ +// eslint-disable-next-line import/no-unresolved +import {Draggable, Plugins} from '@shopify/draggable'; + +function initCircle() { + const container = document.querySelector('#SnapMirror .BlockLayout.CircleRange'); + const containerRect = container.getBoundingClientRect(); + const circleRect = document.querySelector('.circle').getBoundingClientRect(); + + const targets = []; + [...document.querySelectorAll('.Block--typeHollow')].forEach((star) => { + const rect = star.getBoundingClientRect(); + const range = circleRect.width / 2; + targets.push({ + x: rect.x - containerRect.x + rect.width / 2, + y: rect.y - containerRect.y + rect.width / 2, + range(coord, target, relativePoint, {pointInMirrorCoordinate}) { + return ( + (coord.x + pointInMirrorCoordinate.x - target.x) ** 2 + + (coord.y + pointInMirrorCoordinate.y - target.y) ** 2 < + range ** 2 + ); + }, + }); + }); + + console.log(targets); + const draggable = new Draggable([container], { + draggable: '.Block--isDraggable', + mirror: { + constrainDimensions: true, + }, + plugins: [Plugins.SnapMirror], + SnapMirror: { + targets, + relativePoints: [{x: 0.5, y: 0.5}], + }, + }); + + let originalSource; + + draggable.on('mirror:create', (evt) => { + originalSource = evt.originalSource; + }); + + draggable.on('mirror:destroy', (evt) => { + if (evt.mirror.style.position !== 'absolute') { + return; + } + originalSource.style.transform = evt.mirror.style.transform; + }); + + return draggable; +} + +function initWorkspace() { + const container = document.querySelector('#SnapMirror .BlockLayout.Workspace'); + + const draggable = new Draggable([container], { + draggable: '.Block--isDraggable', + mirror: { + constrainDimensions: true, + }, + plugins: [Plugins.SnapMirror], + SnapMirror: { + targets: [ + Plugins.SnapMirror.grid({ + x: 50, + y: 50, + }), + ], + }, + }); + + let originalSource; + + draggable.on('mirror:create', (evt) => { + originalSource = evt.originalSource; + }); + + draggable.on('mirror:destroy', (evt) => { + if (evt.mirror.style.position !== 'absolute') { + return; + } + originalSource.style.transform = evt.mirror.style.transform; + }); + + return draggable; +} + +export default function PluginsSnapMirror() { + const workspaceDraggable = initWorkspace(); + const CircleDraggable = initCircle(); + + return [workspaceDraggable, CircleDraggable]; +} diff --git a/examples/src/content/index.js b/examples/src/content/index.js index e6a14bcf..8f9e363d 100644 --- a/examples/src/content/index.js +++ b/examples/src/content/index.js @@ -17,6 +17,7 @@ import PluginsCollidable from './Plugins/Collidable'; import PluginsSnappable from './Plugins/Snappable'; import PluginsSwapAnimation from './Plugins/SwapAnimation'; import PluginsSortAnimation from './Plugins/SortAnimation'; +import PluginsSnapMirror from './Plugins/SnapMirror'; const Content = { Home, @@ -32,6 +33,7 @@ const Content = { PluginsSnappable, PluginsSwapAnimation, PluginsSortAnimation, + PluginsSnapMirror, }; export default Content; diff --git a/examples/src/styles/examples-app.scss b/examples/src/styles/examples-app.scss index c61ec1fe..897c982f 100644 --- a/examples/src/styles/examples-app.scss +++ b/examples/src/styles/examples-app.scss @@ -59,3 +59,4 @@ @import 'content/Plugins/Snappable/Snappable'; @import 'content/Plugins/SwapAnimation/SwapAnimation'; @import 'content/Plugins/SortAnimation/SortAnimation'; +@import 'content/Plugins/SnapMirror/SnapMirror'; diff --git a/examples/src/views/data-pages.json b/examples/src/views/data-pages.json index 0f07686f..004c79b1 100644 --- a/examples/src/views/data-pages.json +++ b/examples/src/views/data-pages.json @@ -32,7 +32,8 @@ "Collidable", "Snappable", "~Swap Animation", - "Sort Animation" + "Sort Animation", + "Snap Mirror" ] } ] diff --git a/examples/src/views/snap-mirror.html b/examples/src/views/snap-mirror.html new file mode 100644 index 00000000..0e0e2ed9 --- /dev/null +++ b/examples/src/views/snap-mirror.html @@ -0,0 +1,29 @@ +{% extends 'templates/document.html' %} + +{% import 'components/Document/Head.html' as Head %} +{% import 'components/Sidebar/Sidebar.html' as Sidebar %} +{% import 'components/PageHeader/PageHeader.html' as PageHeader %} + +{% import 'content/Plugins/SnapMirror/SnapMirror.html' as SnapMirror %} + +{% set ViewAttr = { + id: 'SnapMirror', + parent: 'Plugins', + child: 'Snap Mirror', + subheading: 'Enable snap mirror to target points by including the SnapMirror plugin. Drag an item into the range of a target point will snap to the point.' +} %} + +{% block PageId %}{{ ViewAttr.id }}{% endblock %} + +{% block head %} +{{ Head.render(ViewAttr) }} +{% endblock %} + +{% block sidebar %} +{{ Sidebar.render(ViewAttr, DataPages) }} +{% endblock %} + +{% block main %} +{{ PageHeader.render(ViewAttr) }} +{{ SnapMirror.render(ViewAttr.id) }} +{% endblock %} diff --git a/scripts/build/bundles.js b/scripts/build/bundles.js index 0c9838aa..b9fce27d 100644 --- a/scripts/build/bundles.js +++ b/scripts/build/bundles.js @@ -75,6 +75,13 @@ const bundles = [ source: 'Plugins/SortAnimation/index', path: 'plugins/', }, + + { + name: 'SnapMirror', + filename: 'snap-mirror', + source: 'Plugins/SnapMirror/index', + path: 'plugins/', + }, ]; module.exports = {bundles}; diff --git a/scripts/test/helpers/constants.js b/scripts/test/helpers/constants.js index dd966433..0ead6a86 100644 --- a/scripts/test/helpers/constants.js +++ b/scripts/test/helpers/constants.js @@ -1,6 +1,8 @@ export const defaultTouchEventOptions = { touches: [ { + clientX: 0, + clientY: 0, pageX: 0, pageY: 0, }, diff --git a/src/Draggable/Plugins/Mirror/Mirror.js b/src/Draggable/Plugins/Mirror/Mirror.js index 96d461ec..6e18472f 100644 --- a/src/Draggable/Plugins/Mirror/Mirror.js +++ b/src/Draggable/Plugins/Mirror/Mirror.js @@ -13,7 +13,6 @@ export const onDragMove = Symbol('onDragMove'); export const onDragStop = Symbol('onDragStop'); export const onMirrorCreated = Symbol('onMirrorCreated'); export const onMirrorMove = Symbol('onMirrorMove'); -export const onScroll = Symbol('onScroll'); export const getAppendableContainer = Symbol('getAppendableContainer'); /** @@ -67,31 +66,11 @@ export default class Mirror extends AbstractPlugin { ...this.getOptions(), }; - /** - * Scroll offset for touch devices because the mirror is positioned fixed - * @property {Object} scrollOffset - * @property {Number} scrollOffset.x - * @property {Number} scrollOffset.y - */ - this.scrollOffset = {x: 0, y: 0}; - - /** - * Initial scroll offset for touch devices because the mirror is positioned fixed - * @property {Object} scrollOffset - * @property {Number} scrollOffset.x - * @property {Number} scrollOffset.y - */ - this.initialScrollOffset = { - x: window.scrollX, - y: window.scrollY, - }; - this[onDragStart] = this[onDragStart].bind(this); this[onDragMove] = this[onDragMove].bind(this); this[onDragStop] = this[onDragStop].bind(this); this[onMirrorCreated] = this[onMirrorCreated].bind(this); this[onMirrorMove] = this[onMirrorMove].bind(this); - this[onScroll] = this[onScroll].bind(this); } /** @@ -131,15 +110,6 @@ export default class Mirror extends AbstractPlugin { return; } - if ('ontouchstart' in window) { - document.addEventListener('scroll', this[onScroll], true); - } - - this.initialScrollOffset = { - x: window.scrollX, - y: window.scrollY, - }; - const {source, originalSource, sourceContainer, sensorEvent} = dragEvent; // Last sensor position of mirror move @@ -233,13 +203,6 @@ export default class Mirror extends AbstractPlugin { } [onDragStop](dragEvent) { - if ('ontouchstart' in window) { - document.removeEventListener('scroll', this[onScroll], true); - } - - this.initialScrollOffset = {x: 0, y: 0}; - this.scrollOffset = {x: 0, y: 0}; - if (!this.mirror) { return; } @@ -261,13 +224,6 @@ export default class Mirror extends AbstractPlugin { } } - [onScroll]() { - this.scrollOffset = { - x: window.scrollX - this.initialScrollOffset.x, - y: window.scrollY - this.initialScrollOffset.y, - }; - } - /** * Mirror created handler * @param {MirrorCreatedEvent} mirrorEvent @@ -293,7 +249,6 @@ export default class Mirror extends AbstractPlugin { source, sensorEvent, mirrorClass, - scrollOffset: this.scrollOffset, options: this.options, passedThreshX: true, passedThreshY: true, @@ -337,7 +292,6 @@ export default class Mirror extends AbstractPlugin { options: this.options, initialX: this.initialX, initialY: this.initialY, - scrollOffset: this.scrollOffset, passedThreshX: mirrorEvent.passedThreshX, passedThreshY: mirrorEvent.passedThreshY, lastMovedX: this.lastMovedX, @@ -491,7 +445,6 @@ function positionMirror({withFrame = false, initial = false} = {}) { mirrorOffset, initialY, initialX, - scrollOffset, options, passedThreshX, passedThreshY, @@ -511,11 +464,11 @@ function positionMirror({withFrame = false, initial = false} = {}) { if (mirrorOffset) { const x = passedThreshX - ? Math.round((sensorEvent.clientX - mirrorOffset.left - scrollOffset.x) / (options.thresholdX || 1)) * + ? Math.round((sensorEvent.clientX - mirrorOffset.left) / (options.thresholdX || 1)) * (options.thresholdX || 1) : Math.round(lastMovedX); const y = passedThreshY - ? Math.round((sensorEvent.clientY - mirrorOffset.top - scrollOffset.y) / (options.thresholdY || 1)) * + ? Math.round((sensorEvent.clientY - mirrorOffset.top) / (options.thresholdY || 1)) * (options.thresholdY || 1) : Math.round(lastMovedY); diff --git a/src/Draggable/Plugins/Scrollable/Scrollable.js b/src/Draggable/Plugins/Scrollable/Scrollable.js index 8af279d7..dc974a98 100644 --- a/src/Draggable/Plugins/Scrollable/Scrollable.js +++ b/src/Draggable/Plugins/Scrollable/Scrollable.js @@ -49,13 +49,11 @@ export default class Scrollable extends AbstractPlugin { }; /** - * Keeps current mouse position - * @property {Object} currentMousePosition - * @property {Number} currentMousePosition.clientX - * @property {Number} currentMousePosition.clientY + * Keeps current sensor event + * @property {SensorEvent} currentSensorEvent * @type {Object|null} */ - this.currentMousePosition = null; + this.currentSensorEvent = null; /** * Scroll animation frame @@ -159,18 +157,7 @@ export default class Scrollable extends AbstractPlugin { return; } - const sensorEvent = dragEvent.sensorEvent; - const scrollOffset = {x: 0, y: 0}; - - if ('ontouchstart' in window) { - scrollOffset.y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; - scrollOffset.x = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0; - } - - this.currentMousePosition = { - clientX: sensorEvent.clientX - scrollOffset.x, - clientY: sensorEvent.clientY - scrollOffset.y, - }; + this.currentSensorEvent = dragEvent.sensorEvent; this.scrollAnimationFrame = requestAnimationFrame(this[scroll]); } @@ -186,7 +173,7 @@ export default class Scrollable extends AbstractPlugin { this.scrollableElement = null; this.scrollAnimationFrame = null; this.findScrollableElementFrame = null; - this.currentMousePosition = null; + this.currentSensorEvent = null; } /** @@ -194,7 +181,7 @@ export default class Scrollable extends AbstractPlugin { * @private */ [scroll]() { - if (!this.scrollableElement || !this.currentMousePosition) { + if (!this.scrollableElement || !this.currentSensorEvent) { return; } @@ -209,8 +196,7 @@ export default class Scrollable extends AbstractPlugin { const documentScrollingElement = getDocumentScrollingElement(); const scrollableElement = this.scrollableElement; - const clientX = this.currentMousePosition.clientX; - const clientY = this.currentMousePosition.clientY; + const {clientX, clientY} = this.currentSensorEvent; if (scrollableElement !== document.body && scrollableElement !== document.documentElement && !cutOff) { const {offsetHeight, offsetWidth} = scrollableElement; diff --git a/src/Draggable/Sensors/MouseSensor/MouseSensor.js b/src/Draggable/Sensors/MouseSensor/MouseSensor.js index 2c28225c..9a737abe 100644 --- a/src/Draggable/Sensors/MouseSensor/MouseSensor.js +++ b/src/Draggable/Sensors/MouseSensor/MouseSensor.js @@ -109,6 +109,8 @@ export default class MouseSensor extends Sensor { const container = this.currentContainer; const dragStartEvent = new DragStartSensorEvent({ + pageX: startEvent.pageX, + pageY: startEvent.pageY, clientX: startEvent.clientX, clientY: startEvent.clientY, target: startEvent.target, @@ -166,6 +168,8 @@ export default class MouseSensor extends Sensor { const target = document.elementFromPoint(event.clientX, event.clientY); const dragMoveEvent = new DragMoveSensorEvent({ + pageX: event.pageX, + pageY: event.pageY, clientX: event.clientX, clientY: event.clientY, target, @@ -199,6 +203,8 @@ export default class MouseSensor extends Sensor { const target = document.elementFromPoint(event.clientX, event.clientY); const dragStopEvent = new DragStopSensorEvent({ + pageX: event.pageX, + pageY: event.pageY, clientX: event.clientX, clientY: event.clientY, target, diff --git a/src/Draggable/Sensors/SensorEvent/SensorEvent.js b/src/Draggable/Sensors/SensorEvent/SensorEvent.js index d5aa4559..0c0fa8e7 100644 --- a/src/Draggable/Sensors/SensorEvent/SensorEvent.js +++ b/src/Draggable/Sensors/SensorEvent/SensorEvent.js @@ -37,6 +37,26 @@ export class SensorEvent extends AbstractEvent { return this.data.clientY; } + /** + * Normalized pageX for both touch and mouse events + * @property pageX + * @type {Number} + * @readonly + */ + get pageX() { + return this.data.pageX; + } + + /** + * Normalized pageY for both touch and mouse events + * @property pageY + * @type {Number} + * @readonly + */ + get pageY() { + return this.data.pageY; + } + /** * Normalized target for both touch and mouse events * Returns the element that is behind cursor or touch pointer diff --git a/src/Draggable/Sensors/TouchSensor/TouchSensor.js b/src/Draggable/Sensors/TouchSensor/TouchSensor.js index 6e4bc31d..1116c8f9 100644 --- a/src/Draggable/Sensors/TouchSensor/TouchSensor.js +++ b/src/Draggable/Sensors/TouchSensor/TouchSensor.js @@ -140,11 +140,13 @@ export default class TouchSensor extends Sensor { [startDrag]() { const startEvent = this.startEvent; const container = this.currentContainer; - const touch = touchCoords(startEvent); + const {clientX, clientY, pageX, pageY} = touchCoords(startEvent); const dragStartEvent = new DragStartSensorEvent({ - clientX: touch.pageX, - clientY: touch.pageY, + clientX, + clientY, + pageX, + pageY, target: startEvent.target, container, originalEvent: startEvent, @@ -190,12 +192,14 @@ export default class TouchSensor extends Sensor { if (!this.dragging) { return; } - const {pageX, pageY} = touchCoords(event); + const {clientX, clientY, pageX, pageY} = touchCoords(event); const target = document.elementFromPoint(pageX - window.scrollX, pageY - window.scrollY); const dragMoveEvent = new DragMoveSensorEvent({ - clientX: pageX, - clientY: pageY, + clientX, + clientY, + pageX, + pageY, target, container: this.currentContainer, originalEvent: event, @@ -227,14 +231,16 @@ export default class TouchSensor extends Sensor { document.removeEventListener('touchmove', this[onTouchMove]); - const {pageX, pageY} = touchCoords(event); + const {clientX, clientY, pageX, pageY} = touchCoords(event); const target = document.elementFromPoint(pageX - window.scrollX, pageY - window.scrollY); event.preventDefault(); const dragStopEvent = new DragStopSensorEvent({ - clientX: pageX, - clientY: pageY, + clientX, + clientY, + pageX, + pageY, target, container: this.currentContainer, originalEvent: event, diff --git a/src/Draggable/Sensors/TouchSensor/tests/TouchSensor.test.js b/src/Draggable/Sensors/TouchSensor/tests/TouchSensor.test.js index a094d05f..fc12e3a1 100644 --- a/src/Draggable/Sensors/TouchSensor/tests/TouchSensor.test.js +++ b/src/Draggable/Sensors/TouchSensor/tests/TouchSensor.test.js @@ -97,6 +97,31 @@ describe('TouchSensor', () => { expect(touchEndEvent.defaultPrevented).toBe(true); }); + + it('event attributes should be set correctly', () => { + const touchEvent = { + pageX: 21, + pageY: 22, + clientX: 11, + clientY: 12, + }; + + function testAttributes(event) { + expect(event.detail.clientX).toBe(touchEvent.clientX); + expect(event.detail.clientY).toBe(touchEvent.clientY); + } + + sandbox.addEventListener('drag:start', testAttributes); + + sandbox.addEventListener('drag:move', testAttributes); + + sandbox.addEventListener('drag:stop', testAttributes); + + touchStart(draggableElement, {touches: [touchEvent]}); + waitForDragDelay(); + touchMove(draggableElement, {touches: [touchEvent]}); + touchRelease(draggableElement, {touches: [touchEvent]}); + }); }); describe('using distance', () => { diff --git a/src/Plugins/SnapMirror/README.md b/src/Plugins/SnapMirror/README.md new file mode 100644 index 00000000..57ef1c3d --- /dev/null +++ b/src/Plugins/SnapMirror/README.md @@ -0,0 +1,89 @@ +## SnapMirror + +The SnapMirror plugin snap the mirror to the target points. + +This plugin is not included in the default Draggable bundle, so you'll need to import it separately. + + + +### Import + +```js +import {Plugins} from '@shopify/draggable'; +``` + +```js +import SnapMirror from '@shopify/draggable/lib/plugins/snap-mirror'; +``` + +```html + +``` + +```html + +``` + +The over container should set relative/absolute/fixed position, bacause while over a container mirror using absolute position based on the container. + +```css +.draggable-container--over { + position: relative; +} +``` + +### Options + +**`targets {Array}`** +An object contain target options or a function returning an object contain target options. + +If a snap target is a function, then it is called and given the x and y coordinates of the dragging mirror base on contianer as the first two parameters and the current SnapMirror instance as the third parameter. + +Target options: + +| Name | Type | Description | +| ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- | +| `x` | `number` | The x coordinates of snap target relative to offset. | +| `y` | `number` | The y coordinates of snap target relative to offset. | +| `range` | `number` | The range of a snap target is the distance the pointer must be from the target's coordinates for a snap to be possible. | + +**`relativePoints {Array}`** +An object with `x` and `y` properties. +The `relativePoints` option lets you set where the dragging mirror element should snap. + +**`range {number}`** +The `range` option lets you set the default range for all targets. + +### Global Method + +**`grid(Object)`** +You can use the `SnapMirror.grid()` method to create a target that snaps to a grid. +The method takes an object describing a grid and returns a function that snaps to the corners of that grid. + +**`line(Object)`** +You can use the `SnapMirror.line()` method to create a target that snaps to a line. +The method takes an object describing a line and returns a function that snaps to the line. + +**`inRectRange(Array)`** +You can use the `SnapMirror.rectRange()` method check if a point in ract Range. + +### Examples + +```js +import {Sortable, Plugins} from '@shopify/draggable'; + +const sortable = new Sortable(document.querySelectorAll('ul'), { + draggable: 'li', + SnapMirror: { + targets: [{x: 100, y: 100, range: 50}], + relativePoints: [{x: 0.5, y: 0.5}], + }, + plugins: [Plugins.SnapMirror], +}); +``` + +### Caveats + +### Why different form interact.js + +Consider of scorll, nest container and nest container with scorll. Limit snap in a contianer will make things simple. diff --git a/src/Plugins/SnapMirror/SnapMirror.js b/src/Plugins/SnapMirror/SnapMirror.js new file mode 100644 index 00000000..b1edb021 --- /dev/null +++ b/src/Plugins/SnapMirror/SnapMirror.js @@ -0,0 +1,298 @@ +import AbstractPlugin from 'shared/AbstractPlugin'; +import {distance as euclideanDistance} from 'shared/utils'; +import {grid, line} from './targets'; + +const onMirrorCreated = Symbol('onMirrorCreated'); +const onMirrorDestroy = Symbol('onMirrorDestroy'); +const onMirrorMove = Symbol('onMirrorMove'); +const onDragOverContainer = Symbol('onDragOverContainer'); +const onDragOutContainer = Symbol('onDragOutContainer'); +const calcRelativePoints = Symbol('getRelativePoints'); + +/** + * SnapMirror default options + * @property {Object} defaultOptions + * @type {Object} + */ +export const defaultOptions = { + targets: [], + relativePoints: [ + { + x: 0, + y: 0, + }, + ], + range: Infinity, +}; + +/** + * The SnapMirror plugin snap the mirror element to the target points. + * @class SnapMirror + * @module SnapMirror + * @extends AbstractPlugin + */ +export default class SnapMirror extends AbstractPlugin { + /** + * SnapMirror constructor. + * @constructs SnapMirror + * @param {Draggable} draggable - Draggable instance + */ + constructor(draggable) { + super(draggable); + + /** + * SnapMirror options + * @property {Object} options + * @type {Object} + */ + this.options = { + ...defaultOptions, + ...this.getOptions(), + }; + + this.pointInMirrorCoordinate = null; + this.mirror = null; + this.overContainer = null; + this.relativePoints = null; + this.lastAnimationFrame = null; + + this[onMirrorCreated] = this[onMirrorCreated].bind(this); + this[onMirrorDestroy] = this[onMirrorDestroy].bind(this); + this[onMirrorMove] = this[onMirrorMove].bind(this); + this[onDragOverContainer] = this[onDragOverContainer].bind(this); + this[onDragOutContainer] = this[onDragOutContainer].bind(this); + this[calcRelativePoints] = this[calcRelativePoints].bind(this); + } + + /** + * Attaches plugins event listeners + */ + attach() { + this.draggable + .on('mirror:created', this[onMirrorCreated]) + .on('mirror:move', this[onMirrorMove]) + .on('drag:over:container', this[onDragOverContainer]) + .on('drag:out:container', this[onDragOutContainer]) + .on('mirror:destroy', this[onMirrorDestroy]); + } + + /** + * Detaches plugins event listeners + */ + detach() { + this.draggable + .off('mirror:created', this[onMirrorCreated]) + .off('mirror:move', this[onMirrorMove]) + .off('drag:over:container', this[onDragOverContainer]) + .off('drag:out:container', this[onDragOutContainer]) + .off('mirror:destroy', this[onMirrorDestroy]); + } + + /** + * Returns options passed through draggable + * @return {Object} + */ + getOptions() { + return this.draggable.options.SnapMirror || {}; + } + + /** + * Mirror created handler + * @param {MirrorCreatedEvent} mirrorEvent + * @private + */ + [onMirrorCreated](evt) { + // can't get dimensions of mirror in mirror created + // so use source's dimensions + const rect = evt.source.getBoundingClientRect(); + + this.pointInMirrorCoordinate = { + x: evt.sensorEvent.clientX - rect.x, + y: evt.sensorEvent.clientY - rect.y, + }; + + this.mirror = evt.mirror; + } + + /** + * Mirror destroy handler + * @param {MirrorDestroyEvent} mirrorEvent + * @private + */ + [onMirrorDestroy]() { + cancelAnimationFrame(this.lastAnimationFrame); + this.lastAnimationFrame = null; + this.pointInMirrorCoordinate = null; + this.mirror = null; + this.relativePoints = null; + this.overContainer = null; + } + + /** + * Drag over handler + * @param {DragOverEvent | DragOverContainer} dragEvent + * @private + */ + [onMirrorMove](evt) { + if (evt.canceled()) { + return; + } + + if (this.lastAnimationFrame) { + evt.cancel(); + return; + } + + if (!this.overContainer) { + return; + } + + evt.cancel(); + + cancelAnimationFrame(this.lastAnimationFrame); + this.lastAnimationFrame = requestAnimationFrame(() => { + positionMirror(evt.sensorEvent, this); + this.lastAnimationFrame = null; + }); + } + + /** + * Drag over handler + * @param {DragOverEvent | DragOverContainer} dragEvent + * @private + */ + [onDragOverContainer](evt) { + if (evt.canceled()) { + return; + } + this.overContainer = evt.overContainer; + const rect = evt.overContainer.getBoundingClientRect(); + this.overContainer.pageX = window.scrollX + rect.x; + this.overContainer.pageY = window.scrollY + rect.y; + + cancelAnimationFrame(this.lastAnimationFrame); + this.lastAnimationFrame = requestAnimationFrame(() => { + this.overContainer.append(this.mirror); + this[calcRelativePoints](); + this.mirror.style.position = 'absolute'; + positionMirror(evt.sensorEvent, this); + this.lastAnimationFrame = null; + }); + } + + /** + * Drag over handler + * @param {DragOverEvent | DragOverContainer} dragEvent + * @private + */ + [onDragOutContainer](evt) { + if (evt.canceled()) { + return; + } + this.overContainer = null; + + cancelAnimationFrame(this.lastAnimationFrame); + this.lastAnimationFrame = requestAnimationFrame(() => { + evt.sourceContainer.append(this.mirror); + this.mirror.style.position = 'fixed'; + positionMirror(evt.sensorEvent, this); + cancelAnimationFrame(this.lastAnimationFrame); + this.lastAnimationFrame = null; + }); + } + + /** + * Calculate relative points + * @private + */ + [calcRelativePoints]() { + const rect = this.mirror.getBoundingClientRect(); + const relativePoints = []; + this.options.relativePoints.forEach((point) => { + relativePoints.push({x: rect.width * point.x, y: rect.height * point.y}); + }); + this.relativePoints = relativePoints; + } +} + +function positionMirror(sensorEvent, snapMirror) { + const {mirror, overContainer, pointInMirrorCoordinate} = snapMirror; + + if (!overContainer) { + // point relative to client and event offset + const point = { + x: sensorEvent.clientX - pointInMirrorCoordinate.x, + y: sensorEvent.clientY - pointInMirrorCoordinate.y, + }; + mirror.style.transform = `translate3d(${Math.round(point.x)}px, ${Math.round(point.y)}px, 0)`; + return; + } + + const pointRelativeToPage = { + x: sensorEvent.pageX - pointInMirrorCoordinate.x, + y: sensorEvent.pageY - pointInMirrorCoordinate.y, + }; + const pointRelativeToContainer = { + x: pointRelativeToPage.x + overContainer.scrollLeft - overContainer.pageX, + y: pointRelativeToPage.y + overContainer.scrollTop - overContainer.pageY, + }; + + const point = getNearestSnapPoint(pointRelativeToContainer, snapMirror); + mirror.style.transform = `translate3d(${Math.round(point.x)}px, ${Math.round(point.y)}px, 0)`; +} + +/** + * Get nearest snap coordinate according to current coordinate, target and relative points. + * @param {Point} coord + * @private + */ +function getNearestSnapPoint(coord, snapMirror) { + let result = {x: coord.x, y: coord.y}; + let distance = Infinity; + + snapMirror.options.targets.forEach((rowTarget) => { + let target = rowTarget; + if (typeof target === 'function') { + target = target(coord.x, coord.y, snapMirror); + } + + const range = target.range ? target.range : snapMirror.options.range; + + snapMirror.relativePoints.forEach((relativePoint) => { + const tempPoint = { + x: coord.x + relativePoint.x, + y: coord.y + relativePoint.y, + }; + const tempDistance = euclideanDistance(tempPoint.x, tempPoint.y, target.x, target.y); + + if ((typeof range === 'function' && !range(coord, target, relativePoint, snapMirror)) || tempDistance > range) { + return; + } + + if (tempDistance < distance) { + result = { + x: target.x - relativePoint.x, + y: target.y - relativePoint.y, + }; + distance = tempDistance; + } + }); + }); + + return result; +} + +SnapMirror.grid = grid; + +SnapMirror.line = line; + +SnapMirror.inRectRange = function(range) { + return function(target, coord) { + return ( + coord.x < target.x + range[1] && + coord.x > target.x - range[3] && + coord.y > target.y - range[0] && + coord.y < target.y + range[2] + ); + }; +}; diff --git a/src/Plugins/SnapMirror/index.d.ts b/src/Plugins/SnapMirror/index.d.ts new file mode 100644 index 00000000..c43580d4 --- /dev/null +++ b/src/Plugins/SnapMirror/index.d.ts @@ -0,0 +1,35 @@ +/** + * SnapMirror Plugin + */ + +import type {AbstractPlugin} from "@shopify/draggable"; + +interface Point { + x: number; + y: number; +} + +interface Target { + x: number; + y: number; + range: number; +} + +type TargetFunction = (x: number, y: number, instance) => Target; + +interface SnapMirrorOptions { + targets: Array; + relativePoints: Array; + range: number; +} + +export class SnapMirror extends AbstractPlugin { + options: SnapMirrorOptions; + protected attach(): void; + protected detach(): void; + grid(options: Target): TargetFunction; + line(options: Target): TargetFunction; +} + + + diff --git a/src/Plugins/SnapMirror/index.js b/src/Plugins/SnapMirror/index.js new file mode 100644 index 00000000..85492f37 --- /dev/null +++ b/src/Plugins/SnapMirror/index.js @@ -0,0 +1,4 @@ +import SnapMirror, {defaultOptions} from './SnapMirror'; + +export default SnapMirror; +export {defaultOptions}; diff --git a/src/Plugins/SnapMirror/targets.js b/src/Plugins/SnapMirror/targets.js new file mode 100644 index 00000000..1ef0c133 --- /dev/null +++ b/src/Plugins/SnapMirror/targets.js @@ -0,0 +1,55 @@ +export function grid(options) { + return function(x, y) { + const result = {range: options.range, x: null, y: null}; + + const gridx = Math.round(x / options.x); + const gridy = Math.round(y / options.y); + + result.x = gridx * options.x; + result.y = gridy * options.y; + + return result; + }; +} + +export function line(options) { + return function(x, y) { + const result = {range: options.range, x: null, y: null}; + + if (!options.y) { + result.y = y; + result.x = options.x; + return result; + } + + if (!options.x) { + result.x = x; + result.y = options.y; + return result; + } + + const intersection = verticalIntersection(options, {x, y}); + + result.x = intersection.x; + result.y = intersection.y; + + return result; + }; +} + +/** + * Get the coordinates of the foot of perpendicular of the given point on the given line + * @param {*} intercepts x-intercept and y-intercept of the line + * @param {*} point the given point + * + * line: y = b - (b / a) * x + * perpendicular on the point: (y - d) / (x - c) = a / b + */ +function verticalIntersection({x: a, y: b}, {x: c, y: d}) { + const x = (a * a * c + a * b * b - a * d * b) / (a * a + b * b); + const y = b - (b / a) * x; + return { + x, + y, + }; +} diff --git a/src/Plugins/SnapMirror/tests/SnapMirror.test.js b/src/Plugins/SnapMirror/tests/SnapMirror.test.js new file mode 100644 index 00000000..a92e1e71 --- /dev/null +++ b/src/Plugins/SnapMirror/tests/SnapMirror.test.js @@ -0,0 +1,295 @@ +import { + createSandbox, + waitForRequestAnimationFrame, + clickMouse, + moveMouse, + releaseMouse, + waitForDragDelay, + waitForPromisesToResolve, +} from 'helper'; +import {Draggable} from '../../..'; +import SnapMirror from '..'; + +const sampleMarkup = ` +
    +
  • item1
  • +
  • item2
  • +
+`; + +describe('SnapMirror', () => { + let sandbox; + let containers; + let draggable; + let draggables; + let item1; + let item2; + + beforeEach(() => { + sandbox = createSandbox(sampleMarkup); + containers = sandbox.querySelectorAll('.Container'); + draggables = sandbox.querySelectorAll('li'); + + item1 = draggables[0]; + item2 = draggables[1]; + + mockDimensions(containers[0], [0, 1000, 1000, 0]); + mockDimensions(item1, [0, 30, 30, 0]); + mockDimensions(item2, [30, 60, 60, 30]); + }); + + afterEach(() => { + draggable.destroy(); + sandbox.parentNode.removeChild(sandbox); + }); + + it('targets option', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [ + {x: 100, y: 100}, + function() { + return {x: 200, y: 200}; + }, + ], + }, + }); + + clickMouse(item1, {pageX: 15, pageY: 15}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 50, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(100px, 100px, 0)'); + + moveMouse(item1, {pageX: 220, pageY: 180}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(200px, 200px, 0)'); + + releaseMouse(item1); + }); + + it('relativePoints option', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + relativePoints: [{x: 0.3, y: 0.7}], + targets: [{x: 100, y: 100}], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 50, pageY: 50}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(91px, 79px, 0)'); + + releaseMouse(item1); + }); + + it('SnapMirror.grid()', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [SnapMirror.grid({x: 50, y: 50})], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 20, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(0px, 0px, 0)'); + + moveMouse(item1, {pageX: 60, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 0px, 0)'); + + moveMouse(item1, {pageX: 40, pageY: 40}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 50px, 0)'); + + moveMouse(item1, {pageX: 440, pageY: 550}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(450px, 550px, 0)'); + + releaseMouse(item1); + }); + + it('SnapMirror.line() with x option', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [SnapMirror.line({x: 50})], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 20, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 10px, 0)'); + + moveMouse(item1, {pageX: 440, pageY: 550}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 550px, 0)'); + + releaseMouse(item1); + }); + + it('SnapMirror.line() with y option', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [SnapMirror.line({y: 50})], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 20, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(20px, 50px, 0)'); + + moveMouse(item1, {pageX: 440, pageY: 550}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(440px, 50px, 0)'); + + releaseMouse(item1); + }); + + it('SnapMirror.line() with x and y options', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [SnapMirror.line({x: 30, y: 50})], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 20, pageY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(23px, 12px, 0)'); + + releaseMouse(item1); + }); + + it('SnapMirror.inRectRange()', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [{x: 100, y: 100, range: SnapMirror.inRectRange([10, 20, 30, 40])}], + }, + }); + + clickMouse(item1, {pageX: 10, pageY: 10}); + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(item1, {pageX: 70, pageY: 80}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(70px, 80px, 0)'); + + moveMouse(item1, {pageX: 70, pageY: 100}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(100px, 100px, 0)'); + + moveMouse(item1, {pageX: 130, pageY: 120}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(130px, 120px, 0)'); + + moveMouse(item1, {pageX: 110, pageY: 120}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(100px, 100px, 0)'); + + releaseMouse(item1); + }); +}); + +function mockDimensions(element, [top, right, bottom, left]) { + const width = right - left; + const height = bottom - top; + Object.assign(element.style, { + width: `${width}px`, + height: `${height}px`, + }); + + element.getBoundingClientRect = () => ({ + width, + height, + top, + right, + bottom, + left, + x: top, + y: left, + }); + + element.cloneNode = function(...args) { + const node = Node.prototype.cloneNode.apply(element, args); + node.getBoundingClientRect = function() { + return element.getBoundingClientRect(); + }; + node.cloneNode = element.cloneNode; + return node; + }; + + return element; +} diff --git a/src/Plugins/index.js b/src/Plugins/index.js index 30da0fb8..b2c45dc2 100644 --- a/src/Plugins/index.js +++ b/src/Plugins/index.js @@ -3,3 +3,4 @@ export {default as ResizeMirror, defaultOptions as defaultResizeMirrorOptions} f export {default as Snappable} from './Snappable'; export {default as SwapAnimation, defaultOptions as defaultSwapAnimationOptions} from './SwapAnimation'; export {default as SortAnimation, defaultOptions as defaultSortAnimationOptions} from './SortAnimation'; +export {default as SnapMirror, defaultOptions as defaultSnapMirrorOptions} from './SnapMirror';