diff --git a/src/components/annotations/draw.js b/src/components/annotations/draw.js index 97d95aa3f49..328709e92ba 100644 --- a/src/components/annotations/draw.js +++ b/src/components/annotations/draw.js @@ -285,8 +285,13 @@ function drawOne(gd, index) { posPx.text = basePx + textShift; } + // padplus/minus are used by autorange options['_' + axLetter + 'padplus'] = (annSize / 2) + textPadShift; options['_' + axLetter + 'padminus'] = (annSize / 2) - textPadShift; + + // size/shift are used during dragging + options['_' + axLetter + 'size'] = annSize; + options['_' + axLetter + 'shift'] = textShift; }); if(annotationIsOffscreen) { diff --git a/test/jasmine/assets/drag.js b/test/jasmine/assets/drag.js new file mode 100644 index 00000000000..16020b07493 --- /dev/null +++ b/test/jasmine/assets/drag.js @@ -0,0 +1,78 @@ +var mouseEvent = require('../assets/mouse_event'); + +/* + * drag: grab a node and drag it (dx, dy) pixels + * optionally specify an edge ('n', 'se', 'w' etc) + * to grab it by an edge or corner (otherwise the middle is used) + */ +module.exports = function(node, dx, dy, edge) { + + edge = edge || ''; + var bbox = node.getBoundingClientRect(), + fromX, fromY; + + if(edge.indexOf('n') !== -1) fromY = bbox.top; + else if(edge.indexOf('s') !== -1) fromY = bbox.bottom; + else fromY = (bbox.bottom + bbox.top) / 2; + + if(edge.indexOf('w') !== -1) fromX = bbox.left; + else if(edge.indexOf('e') !== -1) fromX = bbox.right; + else fromX = (bbox.left + bbox.right) / 2; + + + var toX = fromX + dx, + toY = fromY + dy; + + mouseEvent('mousemove', fromX, fromY, {element: node}); + mouseEvent('mousedown', fromX, fromY, {element: node}); + + var promise = waitForDragCover().then(function(dragCoverNode) { + mouseEvent('mousemove', toX, toY, {element: dragCoverNode}); + mouseEvent('mouseup', toX, toY, {element: dragCoverNode}); + return waitForDragCoverRemoval(); + }); + + return promise; +}; + +function waitForDragCover() { + return new Promise(function(resolve) { + var interval = 5, + timeout = 5000; + + var id = setInterval(function() { + var dragCoverNode = document.querySelector('.dragcover'); + 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 = 5, + timeout = 5000; + + var id = setInterval(function() { + var dragCoverNode = document.querySelector('.dragcover'); + if(!dragCoverNode) { + clearInterval(id); + resolve(dragCoverNode); + } + + timeout -= interval; + if(timeout < 0) { + clearInterval(id); + throw new Error('waitForDragCoverRemoval: timeout'); + } + }, interval); + }); +} diff --git a/test/jasmine/assets/mouse_event.js b/test/jasmine/assets/mouse_event.js index 39101d76d35..153314c5abd 100644 --- a/test/jasmine/assets/mouse_event.js +++ b/test/jasmine/assets/mouse_event.js @@ -10,7 +10,7 @@ module.exports = function(type, x, y, opts) { fullOpts.buttons = opts.buttons; } - var el = document.elementFromPoint(x, y), + var el = (opts && opts.element) || document.elementFromPoint(x, y), ev; if(type === 'scroll') { @@ -20,4 +20,6 @@ module.exports = function(type, x, y, opts) { } el.dispatchEvent(ev); + + return el; }; diff --git a/test/jasmine/tests/annotations_test.js b/test/jasmine/tests/annotations_test.js index d898a65341a..d6701bd1f71 100644 --- a/test/jasmine/tests/annotations_test.js +++ b/test/jasmine/tests/annotations_test.js @@ -11,6 +11,7 @@ var customMatchers = require('../assets/custom_matchers'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); +var drag = require('../assets/drag'); describe('Test annotations', function() { @@ -740,3 +741,233 @@ describe('annotation clicktoshow', function() { .then(done); }); }); + +describe('annotation dragging', function() { + var gd; + + function textDrag() { return gd.querySelector('.annotation-text-g>g'); } + function arrowDrag() { return gd.querySelector('.annotation-arrow-g>.anndrag'); } + function textBox() { return gd.querySelector('.annotation-text-g'); } + + beforeAll(function() { + jasmine.addMatchers(customMatchers); + }); + + beforeEach(function(done) { + gd = createGraphDiv(); + + // we've already tested autorange with relayout, so fix the geometry + // completely so we know exactly what we're dealing with + // plot area is 300x300, and covers data range 100x100 + Plotly.plot(gd, + [{x: [0, 100], y: [0, 100], mode: 'markers'}], + { + xaxis: {range: [0, 100]}, + yaxis: {range: [0, 100]}, + width: 500, + height: 500, + margin: {l: 100, r: 100, t: 100, b: 100, pad: 0} + }, + {editable: true} + ) + .then(done); + }); + + afterEach(destroyGraphDiv); + + function initAnnotation(annotation) { + return Plotly.relayout(gd, {annotations: [annotation]}) + .then(function() { + return Plots.previousPromises(gd); + }); + } + + function dragAndReplot(node, dx, dy, edge) { + return drag(node, dx, dy, edge).then(function() { + return Plots.previousPromises(gd); + }); + } + + /* + * run through a series of drags of the same annotation + * findDragger: fn that returns the element to drag on + * (either textDrag or ArrowDrag) + * autoshiftX, autoshiftY: how much does the annotation anchor shift + * moving between one region and the next. Zero except if autoanchor + * is active, ie paper-referenced with no arrow + * coordScale: how big is the full plot? paper-referenced has scale 1 + * and for the plot defined above, data-referenced has scale 100 + */ + function checkDragging(findDragger, autoshiftX, autoshiftY, coordScale) { + var bboxInitial = textBox().getBoundingClientRect(); + // first move it within the same auto-anchor zone + return dragAndReplot(findDragger(), 30, -30) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + + // I'm not sure why these calculations aren't exact - they end up + // being off by a fraction of a pixel, or a full pixel sometimes + // even though as far as I can see in practice the positioning is + // exact. In any event, this precision is enough to ensure that + // anchor: auto is being used. + expect(bbox.left).toBeWithin(bboxInitial.left + 30, 1); + expect(bbox.top).toBeWithin(bboxInitial.top - 30, 1); + + var ann = gd.layout.annotations[0]; + expect(ann.x).toBeWithin(0.1 * coordScale, 0.01 * coordScale); + expect(ann.y).toBeWithin(0.1 * coordScale, 0.01 * coordScale); + + // now move it to the center + // note that we explicitly offset by half the box size because the + // auto-anchor will move to the center + return dragAndReplot(findDragger(), 120 - autoshiftX, -120 + autoshiftY); + }) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + expect(bbox.left).toBeWithin(bboxInitial.left + 150 - autoshiftX, 2); + expect(bbox.top).toBeWithin(bboxInitial.top - 150 + autoshiftY, 2); + + var ann = gd.layout.annotations[0]; + expect(ann.x).toBeWithin(0.5 * coordScale, 0.01 * coordScale); + expect(ann.y).toBeWithin(0.5 * coordScale, 0.01 * coordScale); + + // next move it near the upper right corner, where the auto-anchor + // moves to the top right corner + // we don't move it all the way to the corner, so the annotation will + // still be entirely on the plot even with an arrow. + return dragAndReplot(findDragger(), 90 - autoshiftX, -90 + autoshiftY); + }) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + expect(bbox.left).toBeWithin(bboxInitial.left + 240 - 2 * autoshiftX, 2); + expect(bbox.top).toBeWithin(bboxInitial.top - 240 + 2 * autoshiftY, 2); + + var ann = gd.layout.annotations[0]; + expect(ann.x).toBeWithin(0.8 * coordScale, 0.01 * coordScale); + expect(ann.y).toBeWithin(0.8 * coordScale, 0.01 * coordScale); + + // finally move it back to 0, 0 + return dragAndReplot(findDragger(), -240 + 2 * autoshiftX, 240 - 2 * autoshiftY); + }) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + expect(bbox.left).toBeWithin(bboxInitial.left, 2); + expect(bbox.top).toBeWithin(bboxInitial.top, 2); + + var ann = gd.layout.annotations[0]; + expect(ann.x).toBeWithin(0 * coordScale, 0.01 * coordScale); + expect(ann.y).toBeWithin(0 * coordScale, 0.01 * coordScale); + }); + } + + // for annotations with arrows: check that dragging the text moves only + // ax and ay (and the textbox itself) + function checkTextDrag() { + var ann = gd.layout.annotations[0], + x0 = ann.x, + y0 = ann.y, + ax0 = ann.ax, + ay0 = ann.ay; + + var bboxInitial = textBox().getBoundingClientRect(); + + return dragAndReplot(textDrag(), 50, -50) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + expect(bbox.left).toBeWithin(bboxInitial.left + 50, 1); + expect(bbox.top).toBeWithin(bboxInitial.top - 50, 1); + + ann = gd.layout.annotations[0]; + + expect(ann.x).toBe(x0); + expect(ann.y).toBe(y0); + expect(ann.ax).toBeWithin(ax0 + 50, 1); + expect(ann.ay).toBeWithin(ay0 - 50, 1); + }); + } + + it('respects anchor: auto when paper-referenced without arrow', function(done) { + initAnnotation({ + x: 0, + y: 0, + showarrow: false, + text: 'blah
blah blah', + xref: 'paper', + yref: 'paper' + }) + .then(function() { + var bbox = textBox().getBoundingClientRect(); + + return checkDragging(textDrag, bbox.width / 2, bbox.height / 2, 1); + }) + .catch(failTest) + .then(done); + }); + + it('also works paper-referenced with explicit anchors and no arrow', function(done) { + initAnnotation({ + x: 0, + y: 0, + showarrow: false, + text: 'blah
blah blah', + xref: 'paper', + yref: 'paper', + xanchor: 'left', + yanchor: 'top' + }) + .then(function() { + // with offsets 0, 0 because the anchor doesn't change now + return checkDragging(textDrag, 0, 0, 1); + }) + .catch(failTest) + .then(done); + }); + + it('works paper-referenced with arrows', function(done) { + initAnnotation({ + x: 0, + y: 0, + text: 'blah
blah blah', + xref: 'paper', + yref: 'paper', + ax: 30, + ay: 30 + }) + .then(function() { + return checkDragging(arrowDrag, 0, 0, 1); + }) + .then(checkTextDrag) + .catch(failTest) + .then(done); + }); + + it('works data-referenced with no arrow', function(done) { + initAnnotation({ + x: 0, + y: 0, + showarrow: false, + text: 'blah
blah blah' + }) + .then(function() { + return checkDragging(textDrag, 0, 0, 100); + }) + .catch(failTest) + .then(done); + }); + + it('works data-referenced with arrow', function(done) { + initAnnotation({ + x: 0, + y: 0, + text: 'blah
blah blah', + ax: 30, + ay: -30 + }) + .then(function() { + return checkDragging(arrowDrag, 0, 0, 100); + }) + .then(checkTextDrag) + .catch(failTest) + .then(done); + }); +}); diff --git a/test/jasmine/tests/shapes_test.js b/test/jasmine/tests/shapes_test.js index bfd3ed490b2..7b35e5c0a61 100644 --- a/test/jasmine/tests/shapes_test.js +++ b/test/jasmine/tests/shapes_test.js @@ -14,6 +14,7 @@ var customMatchers = require('../assets/custom_matchers'); var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var failTest = require('../assets/fail_test'); +var drag = require('../assets/drag'); describe('Test shapes defaults:', function() { @@ -748,7 +749,7 @@ describe('Test shapes', function() { var initialCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); - return resize(direction, node, dx, dy).then(function() { + return drag(node, dx, dy, direction).then(function() { var finalCoordinates = getShapeCoordinates(layoutShape, x2p, y2p); var keyN, keyS, keyW, keyE; @@ -815,118 +816,3 @@ describe('Test shapes', function() { return coordinates; } }); - -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); - }); -}