diff --git a/src/components/dragelement/index.js b/src/components/dragelement/index.js index 35d8c602780..a57a0038248 100644 --- a/src/components/dragelement/index.js +++ b/src/components/dragelement/index.js @@ -43,6 +43,10 @@ dragElement.unhoverRaw = unhover.raw; * dragged is as in moveFn * numClicks is how many clicks we've registered within * a doubleclick time + * setCursor (optional) function(event) + * executed on mousemove before mousedown + * the purpose of this callback is to update the mouse cursor before + * the click & drag interaction has been initiated */ dragElement.init = function init(options) { var gd = Lib.getPlotDiv(options.element) || {}, @@ -52,11 +56,15 @@ dragElement.init = function init(options) { startY, newMouseDownTime, dragCover, - initialTarget; + initialTarget, + initialOnMouseMove; if(!gd._mouseDownTime) gd._mouseDownTime = 0; function onStart(e) { + // disable call to options.setCursor(evt) + options.element.onmousemove = initialOnMouseMove; + // make dragging and dragged into properties of gd // so that others can look at and modify them gd._dragged = false; @@ -107,6 +115,10 @@ dragElement.init = function init(options) { } function onDone(e) { + // re-enable call to options.setCursor(evt) + initialOnMouseMove = options.element.onmousemove; + if(options.setCursor) options.element.onmousemove = options.setCursor; + dragCover.onmousemove = null; dragCover.onmouseup = null; dragCover.onmouseout = null; @@ -139,6 +151,10 @@ dragElement.init = function init(options) { return Lib.pauseEvent(e); } + // enable call to options.setCursor(evt) + initialOnMouseMove = options.element.onmousemove; + if(options.setCursor) options.element.onmousemove = options.setCursor; + options.element.onmousedown = onStart; options.element.style.pointerEvents = 'all'; }; diff --git a/src/components/shapes/index.js b/src/components/shapes/index.js index 9b195df68bf..ef8c9853842 100644 --- a/src/components/shapes/index.js +++ b/src/components/shapes/index.js @@ -17,6 +17,8 @@ var Axes = require('../../plots/cartesian/axes'); var Color = require('../color'); var Drawing = require('../drawing'); +var dragElement = require('../dragelement'); +var setCursor = require('../../lib/setcursor'); var shapes = module.exports = {}; @@ -299,15 +301,7 @@ function updateShape(gd, index, opt, value) { var options = handleShapeDefaults(optionsIn, gd._fullLayout); gd._fullLayout.shapes[index] = options; - var attrs = { - 'data-index': index, - 'fill-rule': 'evenodd', - d: shapePath(gd, options) - }, - clipAxes; - - var lineColor = options.line.width ? options.line.color : 'rgba(0,0,0,0)'; - + var clipAxes; if(options.layer !== 'below') { clipAxes = (options.xref + options.yref).replace(/paper/g, ''); drawShape(gd._fullLayout._shapeUpperLayer); @@ -332,6 +326,14 @@ function updateShape(gd, index, opt, value) { } function drawShape(shapeLayer) { + var attrs = { + 'data-index': index, + 'fill-rule': 'evenodd', + d: getPathString(gd, options) + }, + lineColor = options.line.width ? + options.line.color : 'rgba(0,0,0,0)'; + var path = shapeLayer.append('path') .attr(attrs) .style('opacity', options.opacity) @@ -343,6 +345,160 @@ function updateShape(gd, index, opt, value) { path.call(Drawing.setClipUrl, 'clip' + gd._fullLayout._uid + clipAxes); } + + if(gd._context.editable) setupDragElement(gd, path, options, index); + } +} + +function setupDragElement(gd, shapePath, shapeOptions, index) { + var MINWIDTH = 10, + MINHEIGHT = 10; + + var update; + var x0, y0, x1, y1, astrX0, astrY0, astrX1, astrY1; + var n0, s0, w0, e0, astrN, astrS, astrW, astrE, optN, optS, optW, optE; + var pathIn, astrPath; + + var xa, ya, x2p, y2p, p2x, p2y; + + var dragOptions = { + setCursor: updateDragMode, + element: shapePath.node(), + prepFn: startDrag, + doneFn: endDrag + }, + dragBBox = dragOptions.element.getBoundingClientRect(), + dragMode; + + dragElement.init(dragOptions); + + function updateDragMode(evt) { + // choose 'move' or 'resize' + // based on initial position of cursor within the drag element + var w = dragBBox.right - dragBBox.left, + h = dragBBox.bottom - dragBBox.top, + x = evt.clientX - dragBBox.left, + y = evt.clientY - dragBBox.top, + cursor = (w > MINWIDTH && h > MINHEIGHT && !evt.shiftKey) ? + dragElement.getCursor(x / w, 1 - y / h) : + 'move'; + + setCursor(shapePath, cursor); + + // possible values 'move', 'sw', 'w', 'se', 'e', 'ne', 'n', 'nw' and 'w' + dragMode = cursor.split('-')[0]; + } + + function startDrag(evt) { + // setup conversion functions + xa = Axes.getFromId(gd, shapeOptions.xref); + ya = Axes.getFromId(gd, shapeOptions.yref); + + x2p = getDataToPixel(gd, xa); + y2p = getDataToPixel(gd, ya, true); + p2x = getPixelToData(gd, xa); + p2y = getPixelToData(gd, ya, true); + + // setup update strings and initial values + var astr = 'shapes[' + index + ']'; + if(shapeOptions.type === 'path') { + pathIn = shapeOptions.path; + astrPath = astr + '.path'; + } + else { + x0 = x2p(shapeOptions.x0); + y0 = y2p(shapeOptions.y0); + x1 = x2p(shapeOptions.x1); + y1 = y2p(shapeOptions.y1); + + astrX0 = astr + '.x0'; + astrY0 = astr + '.y0'; + astrX1 = astr + '.x1'; + astrY1 = astr + '.y1'; + } + + if(x0 < x1) { + w0 = x0; astrW = astr + '.x0'; optW = 'x0'; + e0 = x1; astrE = astr + '.x1'; optE = 'x1'; + } + else { + w0 = x1; astrW = astr + '.x1'; optW = 'x1'; + e0 = x0; astrE = astr + '.x0'; optE = 'x0'; + } + if(y0 < y1) { + n0 = y0; astrN = astr + '.y0'; optN = 'y0'; + s0 = y1; astrS = astr + '.y1'; optS = 'y1'; + } + else { + n0 = y1; astrN = astr + '.y1'; optN = 'y1'; + s0 = y0; astrS = astr + '.y0'; optS = 'y0'; + } + + update = {}; + + // setup dragMode and the corresponding handler + updateDragMode(evt); + dragOptions.moveFn = (dragMode === 'move') ? moveShape : resizeShape; + } + + function endDrag(dragged) { + setCursor(shapePath); + if(dragged) { + Plotly.relayout(gd, update); + } + } + + function moveShape(dx, dy) { + if(shapeOptions.type === 'path') { + var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = encodeDate(moveX); + + var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = encodeDate(moveY); + + shapeOptions.path = movePath(pathIn, moveX, moveY); + update[astrPath] = shapeOptions.path; + } + else { + update[astrX0] = shapeOptions.x0 = p2x(x0 + dx); + update[astrY0] = shapeOptions.y0 = p2y(y0 + dy); + update[astrX1] = shapeOptions.x1 = p2x(x1 + dx); + update[astrY1] = shapeOptions.y1 = p2y(y1 + dy); + } + + shapePath.attr('d', getPathString(gd, shapeOptions)); + } + + function resizeShape(dx, dy) { + if(shapeOptions.type === 'path') { + // TODO: implement path resize + var moveX = function moveX(x) { return p2x(x2p(x) + dx); }; + if(xa && xa.type === 'date') moveX = encodeDate(moveX); + + var moveY = function moveY(y) { return p2y(y2p(y) + dy); }; + if(ya && ya.type === 'date') moveY = encodeDate(moveY); + + shapeOptions.path = movePath(pathIn, moveX, moveY); + update[astrPath] = shapeOptions.path; + } + else { + var newN = (~dragMode.indexOf('n')) ? n0 + dy : n0, + newS = (~dragMode.indexOf('s')) ? s0 + dy : s0, + newW = (~dragMode.indexOf('w')) ? w0 + dx : w0, + newE = (~dragMode.indexOf('e')) ? e0 + dx : e0; + + if(newS - newN > MINHEIGHT) { + update[astrN] = shapeOptions[optN] = p2y(newN); + update[astrS] = shapeOptions[optS] = p2y(newS); + } + + if(newE - newW > MINWIDTH) { + update[astrW] = shapeOptions[optW] = p2x(newW); + update[astrE] = shapeOptions[optE] = p2x(newE); + } + } + + shapePath.attr('d', getPathString(gd, shapeOptions)); } } @@ -372,10 +528,58 @@ function isShapeInSubplot(gd, shape, plotinfo) { } function decodeDate(convertToPx) { - return function(v) { return convertToPx(v.replace('_', ' ')); }; + return function(v) { + if(v.replace) v = v.replace('_', ' '); + return convertToPx(v); + }; +} + +function encodeDate(convertToDate) { + return function(v) { return convertToDate(v).replace(' ', '_'); }; +} + +function getDataToPixel(gd, axis, isVertical) { + var gs = gd._fullLayout._size, + dataToPixel; + + if(axis) { + var d2l = dataToLinear(axis); + + dataToPixel = function(v) { + return axis._offset + axis.l2p(d2l(v, true)); + }; + + if(axis.type === 'date') dataToPixel = decodeDate(dataToPixel); + } + else if(isVertical) { + dataToPixel = function(v) { return gs.t + gs.h * (1 - v); }; + } + else { + dataToPixel = function(v) { return gs.l + gs.w * v; }; + } + + return dataToPixel; +} + +function getPixelToData(gd, axis, isVertical) { + var gs = gd._fullLayout._size, + pixelToData; + + if(axis) { + var l2d = linearToData(axis); + pixelToData = function(p) { return l2d(axis.p2l(p - axis._offset)); }; + } + else if(isVertical) { + pixelToData = function(p) { return 1 - (p - gs.t) / gs.h; }; + } + else { + pixelToData = function(p) { return (p - gs.l) / gs.w; }; + } + + return pixelToData; } -function shapePath(gd, options) { +function getPathString(gd, options) { var type = options.type, xa = Axes.getFromId(gd, options.xref), ya = Axes.getFromId(gd, options.yref), @@ -501,6 +705,29 @@ shapes.convertPath = function(pathIn, x2p, y2p) { }); }; +function movePath(pathIn, moveX, moveY) { + return pathIn.replace(segmentRE, function(segment) { + var paramNumber = 0, + segmentType = segment.charAt(0), + xParams = paramIsX[segmentType], + yParams = paramIsY[segmentType], + nParams = numParams[segmentType]; + + var paramString = segment.substr(1).replace(paramRE, function(param) { + if(paramNumber >= nParams) return param; + + if(xParams[paramNumber]) param = moveX(param); + else if(yParams[paramNumber]) param = moveY(param); + + paramNumber++; + + return param; + }); + + return segmentType + paramString; + }); +} + shapes.calcAutorange = function(gd) { var fullLayout = gd._fullLayout, shapeList = fullLayout.shapes, diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index c05d0297736..b02398852cd 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -3,6 +3,9 @@ var d3 = require('d3'); var Plotly = require('@lib/index'); var Lib = require('@src/lib'); +var PlotlyInternal = require('@src/plotly'); +var Axes = PlotlyInternal.Axes; + var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); @@ -282,3 +285,524 @@ describe('Test shapes: a plot with shapes and an overlaid axis', function() { Plotly.plot(gd, data, layout).then(done); }); }); + +describe('Test shapes', function() { + 'use strict'; + + var gd, data, layout, config; + + beforeEach(function() { + gd = createGraphDiv(); + data = [{}]; + layout = {}; + config = { + editable: true, + displayModeBar: false + }; + }); + + afterEach(destroyGraphDiv); + + var testCases = [ + // xref: 'paper', yref: 'paper' + { + title: 'linked to paper' + }, + + // xaxis.type: 'linear', yaxis.type: 'log' + { + title: 'linked to linear and log axes', + xaxis: { type: 'linear', range: [0, 10] }, + yaxis: { type: 'log', range: [Math.log10(1), Math.log10(1000)] } + }, + + // xaxis.type: 'date', yaxis.type: 'category' + { + title: 'linked to date and category axes', + xaxis: { + type: 'date', + range: ['2000-01-01', (new Date(2000, 1, 2)).getTime()] + }, + yaxis: { type: 'category', range: ['a', 'b'] } + } + ]; + + testCases.forEach(function(testCase) { + it(testCase.title + 'should be draggable', function(done) { + setupLayout(testCase); + testDragEachShape(done); + }); + }); + + testCases.forEach(function(testCase) { + ['n', 's', 'w', 'e', 'nw', 'se', 'ne', 'sw'].forEach(function(direction) { + var testTitle = testCase.title + + 'should be resizeable over direction ' + + direction; + it(testTitle, function(done) { + setupLayout(testCase); + testResizeEachShape(direction, done); + }); + }); + }); + + function setupLayout(testCase) { + Lib.extendDeep(layout, testCase); + + var xrange = testCase.xaxis ? testCase.xaxis.range : [0.25, 0.75], + yrange = testCase.yaxis ? testCase.yaxis.range : [0.25, 0.75], + xref = testCase.xaxis ? 'x' : 'paper', + yref = testCase.yaxis ? 'y' : 'paper', + x0 = xrange[0], + x1 = xrange[1], + y0 = yrange[0], + y1 = yrange[1]; + + if(testCase.xaxis && testCase.xaxis.type === 'log') { + x0 = Math.pow(10, x0); + x1 = Math.pow(10, x1); + } + + if(testCase.yaxis && testCase.yaxis.type === 'log') { + y0 = Math.pow(10, y0); + y1 = Math.pow(10, y1); + } + + if(testCase.xaxis && testCase.xaxis.type === 'category') { + x0 = 0; + x1 = 1; + } + + if(testCase.yaxis && testCase.yaxis.type === 'category') { + y0 = 0; + y1 = 1; + } + + var x0y0 = x0 + ',' + y0, + x1y1 = x1 + ',' + y1, + x1y0 = x1 + ',' + y0; + + var layoutShapes = [ + { type: 'line' }, + { type: 'rect' }, + { type: 'circle' }, + {} // path + ]; + + layoutShapes.forEach(function(s) { + s.xref = xref; + s.yref = yref; + + if(s.type) { + s.x0 = x0; + s.x1 = x1; + s.y0 = y0; + s.y1 = y1; + } + else { + s.path = 'M' + x0y0 + 'L' + x1y1 + 'L' + x1y0 + 'Z'; + } + }); + + layout.shapes = layoutShapes; + } + + function testDragEachShape(done) { + var promise = Plotly.plot(gd, data, layout, config); + + var layoutShapes = gd.layout.shapes; + + expect(layoutShapes.length).toBe(4); // line, rect, circle and path + + layoutShapes.forEach(function(layoutShape, index) { + var dx = 100, + dy = 100; + promise = promise.then(function() { + var node = getShapeNode(index); + expect(node).not.toBe(null); + + return (layoutShape.path) ? + testPathDrag(dx, dy, layoutShape, node) : + testShapeDrag(dx, dy, layoutShape, node); + }); + }); + + return promise.then(done); + } + + function testResizeEachShape(direction, done) { + var promise = Plotly.plot(gd, data, layout, config); + + var layoutShapes = gd.layout.shapes; + + expect(layoutShapes.length).toBe(4); // line, rect, circle and path + + var dxToShrinkWidth = { + n: 0, s: 0, w: 10, e: -10, nw: 10, se: -10, ne: -10, sw: 10 + }, + dyToShrinkHeight = { + n: 10, s: -10, w: 0, e: 0, nw: 10, se: -10, ne: 10, sw: -10 + }; + layoutShapes.forEach(function(layoutShape, index) { + if(layoutShape.path) return; + + var dx = dxToShrinkWidth[direction], + dy = dyToShrinkHeight[direction]; + + promise = promise.then(function() { + var node = getShapeNode(index); + expect(node).not.toBe(null); + + return testShapeResize(direction, dx, dy, layoutShape, node); + }); + + promise = promise.then(function() { + var node = getShapeNode(index); + expect(node).not.toBe(null); + + return testShapeResize(direction, -dx, -dy, layoutShape, node); + }); + }); + + return promise.then(done); + } + + function getShapeNode(index) { + return d3.selectAll('.shapelayer path').filter(function() { + return +this.getAttribute('data-index') === index; + }).node(); + } + + function testShapeDrag(dx, dy, layoutShape, node) { + var xa = Axes.getFromId(gd, layoutShape.xref), + ya = Axes.getFromId(gd, layoutShape.yref), + x2p = getDataToPixel(gd, xa), + y2p = getDataToPixel(gd, ya, true); + + var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + + return drag(node, dx, dy).then(function() { + var finalCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + + expect(finalCoordinates.x0 - initialCoordinates.x0).toBeCloseTo(dx); + expect(finalCoordinates.x1 - initialCoordinates.x1).toBeCloseTo(dx); + expect(finalCoordinates.y0 - initialCoordinates.y0).toBeCloseTo(dy); + expect(finalCoordinates.y1 - initialCoordinates.y1).toBeCloseTo(dy); + }); + } + + function getShapeCoordinates(layoutShape, x2p, y2p) { + return { + x0: x2p(layoutShape.x0), + x1: x2p(layoutShape.x1), + y0: y2p(layoutShape.y0), + y1: y2p(layoutShape.y1) + }; + } + + function testPathDrag(dx, dy, layoutShape, node) { + var xa = Axes.getFromId(gd, layoutShape.xref), + ya = Axes.getFromId(gd, layoutShape.yref), + x2p = getDataToPixel(gd, xa), + y2p = getDataToPixel(gd, ya, true); + + var initialPath = layoutShape.path, + initialCoordinates = getPathCoordinates(initialPath, x2p, y2p); + + expect(initialCoordinates.length).toBe(6); + + return drag(node, dx, dy).then(function() { + var finalPath = layoutShape.path, + finalCoordinates = getPathCoordinates(finalPath, x2p, y2p); + + expect(finalCoordinates.length).toBe(initialCoordinates.length); + + for(var i = 0; i < initialCoordinates.length; i++) { + var initialCoordinate = initialCoordinates[i], + finalCoordinate = finalCoordinates[i]; + + if(initialCoordinate.x) { + expect(finalCoordinate.x - initialCoordinate.x) + .toBeCloseTo(dx); + } + else { + expect(finalCoordinate.y - initialCoordinate.y) + .toBeCloseTo(dy); + } + } + }); + } + + function testShapeResize(direction, dx, dy, layoutShape, node) { + var xa = Axes.getFromId(gd, layoutShape.xref), + ya = Axes.getFromId(gd, layoutShape.yref), + x2p = getDataToPixel(gd, xa), + y2p = getDataToPixel(gd, ya, true); + + var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + + return resize(direction, node, dx, dy).then(function() { + var finalCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); + + var keyN, keyS, keyW, keyE; + if(initialCoordinates.y0 < initialCoordinates.y1) { + keyN = 'y0'; keyS = 'y1'; + } + else { + keyN = 'y1'; keyS = 'y0'; + } + if(initialCoordinates.x0 < initialCoordinates.x1) { + keyW = 'x0'; keyE = 'x1'; + } + else { + keyW = 'x1'; keyE = 'x0'; + } + + if(~direction.indexOf('n')) { + expect(finalCoordinates[keyN] - initialCoordinates[keyN]) + .toBeCloseTo(dy); + } + else if(~direction.indexOf('s')) { + expect(finalCoordinates[keyS] - initialCoordinates[keyS]) + .toBeCloseTo(dy); + } + + if(~direction.indexOf('w')) { + expect(finalCoordinates[keyW] - initialCoordinates[keyW]) + .toBeCloseTo(dx); + } + else if(~direction.indexOf('e')) { + expect(finalCoordinates[keyE] - initialCoordinates[keyE]) + .toBeCloseTo(dx); + } + }); + } + + // Adapted from src/components/shapes/index.js + var segmentRE = /[MLHVQCTSZ][^MLHVQCTSZ]*/g, + paramRE = /[^\s,]+/g, + + // which numbers in each path segment are x (or y) values + // drawn is which param is a drawn point, as opposed to a + // control point (which doesn't count toward autorange. + // TODO: this means curved paths could extend beyond the + // autorange bounds. This is a bit tricky to get right + // unless we revert to bounding boxes, but perhaps there's + // a calculation we could do...) + paramIsX = { + M: {0: true, drawn: 0}, + L: {0: true, drawn: 0}, + H: {0: true, drawn: 0}, + V: {}, + Q: {0: true, 2: true, drawn: 2}, + C: {0: true, 2: true, 4: true, drawn: 4}, + T: {0: true, drawn: 0}, + S: {0: true, 2: true, drawn: 2}, + // A: {0: true, 5: true}, + Z: {} + }, + + paramIsY = { + M: {1: true, drawn: 1}, + L: {1: true, drawn: 1}, + H: {}, + V: {0: true, drawn: 0}, + Q: {1: true, 3: true, drawn: 3}, + C: {1: true, 3: true, 5: true, drawn: 5}, + T: {1: true, drawn: 1}, + S: {1: true, 3: true, drawn: 5}, + // A: {1: true, 6: true}, + Z: {} + }, + numParams = { + M: 2, + L: 2, + H: 1, + V: 1, + Q: 4, + C: 6, + T: 2, + S: 4, + // A: 7, + Z: 0 + }; + + function getPathCoordinates(pathString, x2p, y2p) { + var coordinates = []; + + pathString.match(segmentRE).forEach(function(segment) { + var paramNumber = 0, + segmentType = segment.charAt(0), + xParams = paramIsX[segmentType], + yParams = paramIsY[segmentType], + nParams = numParams[segmentType], + params = segment.substr(1).match(paramRE); + + if(params) { + params.forEach(function(param) { + if(paramNumber >= nParams) return; + + if(xParams[paramNumber]) { + coordinates.push({ x: x2p(param) }); + } + else if(yParams[paramNumber]) { + coordinates.push({ y: y2p(param) }); + } + + paramNumber++; + }); + } + }); + + return coordinates; + } +}); + + +// getDataToPixel and decodeDate +// adapted from src/components/shapes.index.js +function getDataToPixel(gd, axis, isVertical) { + var gs = gd._fullLayout._size, + dataToPixel; + + if(axis) { + var d2l = axis.type === 'category' ? axis.c2l : axis.d2l; + + dataToPixel = function(v) { + return axis._offset + axis.l2p(d2l(v, true)); + }; + + if(axis.type === 'date') dataToPixel = decodeDate(dataToPixel); + } + else if(isVertical) { + dataToPixel = function(v) { return gs.t + gs.h * (1 - v); }; + } + else { + dataToPixel = function(v) { return gs.l + gs.w * v; }; + } + + return dataToPixel; +} + +function decodeDate(convertToPx) { + return function(v) { + if(v.replace) v = v.replace('_', ' '); + return convertToPx(v); + }; +} + + +var DBLCLICKDELAY = require('@src/plots/cartesian/constants').DBLCLICKDELAY; + +function mouseDown(node, x, y) { + node.dispatchEvent(new MouseEvent('mousedown', { + bubbles: true, + clientX: x, + clientY: y + })); +} + +function mouseMove(node, x, y) { + node.dispatchEvent(new MouseEvent('mousemove', { + bubbles: true, + clientX: x, + clientY: y + })); +} + +function mouseUp(node, x, y) { + node.dispatchEvent(new MouseEvent('mouseup', { + bubbles: true, + clientX: x, + clientY: y + })); +} + +function drag(node, dx, dy) { + var bbox = node.getBoundingClientRect(), + fromX = (bbox.left + bbox.right) / 2, + fromY = (bbox.bottom + bbox.top) / 2, + toX = fromX + dx, + toY = fromY + dy; + + mouseMove(node, fromX, fromY); + mouseDown(node, fromX, fromY); + + var promise = waitForDragCover().then(function(dragCoverNode) { + mouseMove(dragCoverNode, toX, toY); + mouseUp(dragCoverNode, toX, toY); + return waitForDragCoverRemoval(); + }); + + return promise; +} + +function resize(direction, node, dx, dy) { + var bbox = node.getBoundingClientRect(); + + var fromX, fromY, toX, toY; + + if(~direction.indexOf('n')) fromY = bbox.top; + else if(~direction.indexOf('s')) fromY = bbox.bottom; + else fromY = (bbox.bottom + bbox.top) / 2; + + if(~direction.indexOf('w')) fromX = bbox.left; + else if(~direction.indexOf('e')) fromX = bbox.right; + else fromX = (bbox.left + bbox.right) / 2; + + toX = fromX + dx; + toY = fromY + dy; + + mouseMove(node, fromX, fromY); + mouseDown(node, fromX, fromY); + + var promise = waitForDragCover().then(function(dragCoverNode) { + mouseMove(dragCoverNode, toX, toY); + mouseUp(dragCoverNode, toX, toY); + return waitForDragCoverRemoval(); + }); + + return promise; +} + +function waitForDragCover() { + return new Promise(function(resolve) { + var interval = DBLCLICKDELAY / 4, + timeout = 5000; + + var id = setInterval(function() { + var dragCoverNode = d3.selectAll('.dragcover').node(); + if(dragCoverNode) { + clearInterval(id); + resolve(dragCoverNode); + } + + timeout -= interval; + if(timeout < 0) { + clearInterval(id); + throw new Error('waitForDragCover: timeout'); + } + }, interval); + }); +} + +function waitForDragCoverRemoval() { + return new Promise(function(resolve) { + var interval = DBLCLICKDELAY / 4, + timeout = 5000; + + var id = setInterval(function() { + var dragCoverNode = d3.selectAll('.dragcover').node(); + if(!dragCoverNode) { + clearInterval(id); + resolve(dragCoverNode); + } + + timeout -= interval; + if(timeout < 0) { + clearInterval(id); + throw new Error('waitForDragCoverRemoval: timeout'); + } + }, interval); + }); +}