diff --git a/examples/.prettierrc b/examples/.prettierrc index 3f584f607..87e22d807 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.scss b/examples/src/content/Plugins/SnapMirror/SnapMirror.scss index 943557a12..5b18d59a2 100644 --- a/examples/src/content/Plugins/SnapMirror/SnapMirror.scss +++ b/examples/src/content/Plugins/SnapMirror/SnapMirror.scss @@ -49,7 +49,6 @@ background: #fff; content: ""; } - } .star1 { left: 15%; @@ -59,6 +58,7 @@ .star2 { left: 25%; top: 60%; + border-radius: 50%; } .star3 { left: 35%; diff --git a/examples/src/content/Plugins/SnapMirror/index.js b/examples/src/content/Plugins/SnapMirror/index.js index 87e60cb59..8e60dec83 100644 --- a/examples/src/content/Plugins/SnapMirror/index.js +++ b/examples/src/content/Plugins/SnapMirror/index.js @@ -1,147 +1,57 @@ // eslint-disable-next-line import/no-unresolved -import {Draggable} from '@shopify/draggable'; +import {Draggable, Plugins} from '@shopify/draggable'; -function getNearestPoint(point) { - const width = 50; - const divX = parseInt(point.x / width, 10); - const divY = parseInt(point.y / width, 10); - const modX = point.x % width; - const modY = point.y % width; - - return { - x: (divX + (modX * 2 > width)) * width, - y: (divY + (modY * 2 > width)) * width, - }; -} - -function getNearestPointForSky(point, points) { - let result = point; - let distance = Infinity; - - points.forEach((poi) => { - if ( - !( - point.x < poi.x + poi.range[1] && - point.x > poi.x - poi.range[3] && - point.y > poi.y - poi.range[0] && - point.y < poi.y + poi.range[2] - ) - ) { - return; +function initSky() { + const container = document.querySelector('.sky'); + const containerRect = container.getBoundingClientRect(); + + const targets = []; + [...document.querySelectorAll('.star')].forEach((star) => { + const rect = star.getBoundingClientRect(); + let range = {rect: [15, 25, 25, 15]}; + if (star.classList.contains('star1')) { + range = {rect: [Infinity, Infinity, Infinity, Infinity]}; } - const tempDistance = (point.x - poi.x) ** 2 + (point.y - poi.y) ** 2; - if (tempDistance < distance) { - result = poi; - distance = tempDistance; + if (star.classList.contains('star2')) { + range = {circle: 20}; } + targets.push({x: rect.x + 20 - containerRect.x, y: rect.y + 20 - containerRect.y, range}); }); - // console.log(points); - return result; -} - -function initSky() { - const container = document.querySelector('.sky'); - - const points = []; - let pointerStart = {x: 0, y: 0}; - let mirrorStart = {x: 0, y: 0}; - // const offset = {x: 0, y: 0}; - const draggable = new Draggable([container], { draggable: '.sky__item', mirror: { constrainDimensions: true, }, + plugins: [Plugins.SnapMirror], + SnapMirror: { + targets, + relativePoints: [{x: 0.5, y: 0.5}], + }, }); - draggable.on('mirror:created', (evt) => { - const boundingClientRect = evt.source.getBoundingClientRect(); - - mirrorStart = { - x: boundingClientRect.x, - y: boundingClientRect.y, - }; - pointerStart = { - x: evt.sensorEvent.clientX, - y: evt.sensorEvent.clientY, - }; - - [...document.querySelectorAll('.star')].forEach((star) => { - const rect = star.getBoundingClientRect(); - let range = [15, 25, 25, 15]; - if (star.classList.contains('star1')) { - range = [Infinity, Infinity, Infinity, Infinity]; - } - points.push({x: rect.x + 15 - mirrorStart.x, y: rect.y + 15 - mirrorStart.y, range}); - }); - }); - - draggable.on('mirror:move', (evt) => { - evt.cancel(); - - requestAnimationFrame(() => { - const {clientX, clientY} = evt.sensorEvent; - const nearestPoint = getNearestPointForSky( - { - x: clientX - pointerStart.x, - y: clientY - pointerStart.y, - }, - points, - ); - const translate = { - x: mirrorStart.x + nearestPoint.x, - y: mirrorStart.y + nearestPoint.y, - }; + draggable.on('mirror:created', () => {}); - evt.mirror.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0px)`; - }); - }); + draggable.on('mirror:move', () => {}); } export default function PluginsSnapMirror() { const container = document.querySelector('.box'); - let pointerStart = {x: 0, y: 0}; - let mirrorStart = {x: 0, y: 0}; - // const offset = {x: 0, y: 0}; - const draggable = new Draggable([container], { draggable: '.box__item', mirror: { constrainDimensions: true, }, - }); - - draggable.on('mirror:created', (evt) => { - const boundingClientRect = evt.source.getBoundingClientRect(); - - mirrorStart = { - x: boundingClientRect.x, - y: boundingClientRect.y, - }; - pointerStart = { - x: evt.sensorEvent.clientX, - y: evt.sensorEvent.clientY, - }; - }); - - draggable.on('mirror:move', (evt) => { - evt.cancel(); - - requestAnimationFrame(() => { - const {clientX, clientY} = evt.sensorEvent; - const nearestPoint = getNearestPoint({ - x: clientX - pointerStart.x, - y: clientY - pointerStart.y, - }); - const translate = { - x: mirrorStart.x + nearestPoint.x, - y: mirrorStart.y + nearestPoint.y, - }; - - evt.mirror.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0px)`; - }); + plugins: [Plugins.SnapMirror], + SnapMirror: { + targets: [ + Plugins.SnapMirror.grid({ + x: 50, + y: 50, + }), + ], + }, }); // demo sky diff --git a/scripts/build/bundles.js b/scripts/build/bundles.js index 0c9838aab..b9fce27d8 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/src/Plugins/SnapMirror/README.md b/src/Plugins/SnapMirror/README.md index f93438eb9..f27aea082 100644 --- a/src/Plugins/SnapMirror/README.md +++ b/src/Plugins/SnapMirror/README.md @@ -6,12 +6,12 @@ This plugin is not included in the default Draggable bundle, so you'll need to i - + ### Import ```js -import { Plugins } from '@shopify/draggable'; +import {Plugins} from '@shopify/draggable'; ``` ```js @@ -31,21 +31,23 @@ import SnapMirror from '@shopify/draggable/lib/plugins/snap-mirror'; **`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 event as the first two parameters and the current SnapMirror instance as the third parameter. + Target options: -| Name | Type | Description | -| -------------- | ---------------------- | ----------- | -| `x` | `number` | | -| `y` | `number` | | -| `range` | `Object` or `Function` | | -| `range.circle` | `number` | | -| `range.rect` | `Array` | | +| 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. | **`offset {string|Object}`** -A string or an object with `x` and `y` properties. +A string `container` or an object with `x` and `y` properties. The `offset` option lets you shift the coordinates of the targets. -**`relativePoints {Object}`** +If using `container`, offset will set to the upper left corner coordinates of the current source container. + +**`relativePoints {Array}`** An object with `x` and `y` properties. The `relativePoints` option lets you set where the drag element should snap. @@ -58,19 +60,22 @@ The `range` option lets you set the default range for all targets. 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. +**`inRectRange(range: Array)`** +You can use the `SnapMirror.rectRange()` method check if a point in ract Range. + ### Examples ```js -import { Sortable, Plugins } from '@shopify/draggable'; +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}], - offset: "container" + offset: 'container', }, - plugins: [Plugins.SnapMirror] + plugins: [Plugins.SnapMirror], }); ``` diff --git a/src/Plugins/SnapMirror/SnapMirror.js b/src/Plugins/SnapMirror/SnapMirror.js new file mode 100644 index 000000000..64fe95830 --- /dev/null +++ b/src/Plugins/SnapMirror/SnapMirror.js @@ -0,0 +1,215 @@ +import AbstractPlugin from 'shared/AbstractPlugin'; +import {distance as euclideanDistance} from 'shared/utils'; +import grid from './grid'; + +const onMirrorCreated = Symbol('onMirrorCreated'); +const onMirrorDestroy = Symbol('onMirrorDestroy'); +const onMirrorMove = Symbol('onMirrorMove'); + +/** + * SnapMirror default options + * @property {Object} defaultOptions + * @type {Object} + */ +export const defaultOptions = { + targets: [], + offset: 'container', + 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[onMirrorCreated] = this[onMirrorCreated].bind(this); + this[onMirrorDestroy] = this[onMirrorDestroy].bind(this); + this[onMirrorMove] = this[onMirrorMove].bind(this); + } + + /** + * Attaches plugins event listeners + */ + attach() { + this.draggable.on('mirror:created', this[onMirrorCreated]).on('mirror:move', this[onMirrorMove]); + } + + /** + * Detaches plugins event listeners + */ + detach() { + this.draggable + .off('mirror:created', this[onMirrorCreated]) + .off('mirror:destroy', this[onMirrorDestroy]) + .off('mirror:move', this[onMirrorMove]); + } + + /** + * Returns options passed through draggable + * @return {Object} + */ + getOptions() { + return this.draggable.options.SnapMirror || {}; + } + + /** + * Mirror created handler + * @param {MirrorCreatedEvent} mirrorEvent + * @private + */ + [onMirrorCreated]({sourceContainer, source, sensorEvent}) { + const rect = source.getBoundingClientRect(); + this.offset = this.getOffset(sourceContainer); + + // can't get dimensions of mirror in mirror created + // so use source dimensions + this.relativePoints = this.getRelativePoints(rect); + + this.eventStartPoint = { + x: sensorEvent.clientX, + y: sensorEvent.clientY, + }; + this.startPoint = { + x: rect.x - this.offset.x, + y: rect.y - this.offset.y, + }; + } + + /** + * Mirror destroy handler + * @param {MirrorDestroyEvent} mirrorEvent + * @private + */ + [onMirrorDestroy]() { + this.offset = null; + this.relativePoints = null; + this.eventStartPoint = null; + this.startPoint = null; + } + + /** + * Drag over handler + * @param {DragOverEvent | DragOverContainer} dragEvent + * @private + */ + [onMirrorMove](evt) { + evt.cancel(); + const {clientX, clientY} = evt.sensorEvent; + + requestAnimationFrame(() => { + const nearest = this.getNearest({ + x: clientX - this.eventStartPoint.x, + y: clientY - this.eventStartPoint.y, + }); + const translate = { + x: this.offset.x + nearest.x, + y: this.offset.y + nearest.y, + }; + evt.mirror.style.transform = `translate3d(${translate.x}px, ${translate.y}px, 0)`; + }); + } + + getNearest(diff) { + let result = {x: 0, y: 0}; + let distance = Infinity; + + this.options.targets.forEach((rowTarget) => { + let target = rowTarget; + if (typeof target === 'function') { + target = target(diff.x, diff.y); + } + + const range = target.range ? target.range : this.options.range; + + this.relativePoints.forEach((relativePoint) => { + const point = { + x: this.startPoint.x + relativePoint.x, + y: this.startPoint.y + relativePoint.y, + }; + const tempPoint = { + x: point.x + diff.x, + y: point.y + diff.y, + }; + const tempDistance = euclideanDistance(tempPoint.x, tempPoint.y, target.x, target.y); + + if (tempDistance > range) { + return; + } + + if (tempDistance < distance) { + result = { + x: target.x - relativePoint.x, + y: target.y - relativePoint.y, + }; + distance = tempDistance; + } + }); + }); + + return result; + } + + getRelativePoints(rect) { + const relativePoints = []; + this.options.relativePoints.forEach((point) => { + relativePoints.push({x: rect.width * point.x, y: rect.height * point.y}); + }); + return relativePoints; + } + + getOffset(container) { + if (this.options.offset === 'container') { + const rect = container.getBoundingClientRect(); + return { + x: rect.x, + y: rect.y, + }; + } + + if (this.options.offset.x && this.options.offset.y) { + return { + x: this.options.offset.x, + y: this.options.offset.y, + }; + } + + return {x: 0, y: 0}; + } +} + +SnapMirror.grid = grid; + +SnapMirror.inRangeRange = function(coord, range) { + return ( + coord.x < this.x + range.rect[1] && + coord.x > this.x - range.rect[3] && + coord.y > this.y - range.rect[0] && + coord.y < this.y + range.rect[2] + ); +}; diff --git a/src/Plugins/SnapMirror/grid.js b/src/Plugins/SnapMirror/grid.js new file mode 100644 index 000000000..f8aea3957 --- /dev/null +++ b/src/Plugins/SnapMirror/grid.js @@ -0,0 +1,23 @@ +export default function grid(gridOptions) { + const { + range, + limits = { + left: -Infinity, + right: Infinity, + top: -Infinity, + bottom: Infinity, + }, + } = gridOptions; + + return function gridFunc(x, y) { + const result = {range, grid, x: null, y: null}; + + const gridx = Math.round(x / gridOptions.x); + const gridy = Math.round(y / gridOptions.y); + + result.x = Math.max(limits.left, Math.min(limits.right, gridx * gridOptions.x)); + result.y = Math.max(limits.top, Math.min(limits.bottom, gridy * gridOptions.y)); + + return result; + }; +} diff --git a/src/Plugins/SnapMirror/index.js b/src/Plugins/SnapMirror/index.js new file mode 100644 index 000000000..85492f374 --- /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/tests/SnapMirror.test.js b/src/Plugins/SnapMirror/tests/SnapMirror.test.js new file mode 100644 index 000000000..b70d998f5 --- /dev/null +++ b/src/Plugins/SnapMirror/tests/SnapMirror.test.js @@ -0,0 +1,198 @@ +import { + createSandbox, + waitForRequestAnimationFrame, + clickMouse, + moveMouse, + releaseMouse, + waitForDragDelay, + waitForPromisesToResolve, +} from 'helper'; +import {Draggable} from '../../..'; +import SnapMirror from '..'; + +const sampleMarkup = ` + +`; + +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('snap to targets', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + targets: [ + {x: 100, y: 100}, + function() { + return {x: 200, y: 200}; + }, + ], + }, + }); + clickMouse(item1, {clientX: 15, clientY: 15}); + + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(document.body, {clientX: 50, clientY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(100px, 100px, 0)'); + + moveMouse(document.body, {clientX: 220, clientY: 180}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(200px, 200px, 0)'); + + releaseMouse(item1); + }); + + it('offset option', async () => { + draggable = new Draggable(containers, { + draggable: 'li', + plugins: [SnapMirror], + SnapMirror: { + offset: {x: 30, y: 70}, + targets: [{x: 100, y: 100}], + }, + }); + clickMouse(item1, {clientX: 10, clientY: 10}); + + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(document.body, {clientX: 50, clientY: 50}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(130px, 170px, 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, {clientX: 10, clientY: 10}); + + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(document.body, {clientX: 50, clientY: 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, {clientX: 10, clientY: 10}); + + waitForDragDelay(); + waitForRequestAnimationFrame(); + await waitForPromisesToResolve(); + + const mirror = document.querySelector('.draggable-mirror'); + + moveMouse(document.body, {clientX: 20, clientY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(0px, 0px, 0)'); + + moveMouse(document.body, {clientX: 60, clientY: 10}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 0px, 0)'); + + moveMouse(document.body, {clientX: 40, clientY: 40}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(50px, 50px, 0)'); + + moveMouse(document.body, {clientX: 440, clientY: 550}); + await waitForPromisesToResolve(); + waitForRequestAnimationFrame(); + expect(mirror.style.transform).toBe('translate3d(450px, 550px, 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(); + }; + return node; + }; + + return element; +} diff --git a/src/Plugins/index.js b/src/Plugins/index.js index 30da0fb80..b2c45dc2d 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';