diff --git a/draftlogs/8152_fix.md b/draftlogs/8152_fix.md new file mode 100644 index 00000000000..7fb7514d19d --- /dev/null +++ b/draftlogs/8152_fix.md @@ -0,0 +1 @@ + - Use consistent cursors across all plot types and modes [[#8152](https://github.com/plotly/plotly.js/pull/8152)] diff --git a/src/components/dragelement/helpers.js b/src/components/dragelement/helpers.js index 4ddb8ee347c..cb0d16dee1a 100644 --- a/src/components/dragelement/helpers.js +++ b/src/components/dragelement/helpers.js @@ -47,3 +47,26 @@ exports.selectingOrDrawing = function(dragmode) { exports.rectMode(dragmode) ); }; + +exports.effectiveDragmode = (dragmode, { shiftKey } = {}) => { + if(!shiftKey || !dragmode || exports.selectingOrDrawing(dragmode)) return dragmode; + return dragmode === 'pan' ? 'zoom' : 'pan'; +}; + +exports.dragCursor = (dragmode, modifiers) => + exports.effectiveDragmode(dragmode, modifiers) === 'pan' ? 'move' : 'crosshair'; + +const ACTION_BY_MODIFIER = { shiftKey: 'rotate', ctrlKey: 'pan', altKey: 'zoom' }; +const CURSOR_BY_ACTION = { rotate: 'default', pan: 'move', zoom: 'crosshair' }; + +exports.dragCursor3d = (cameraMode, modifiers = {}) => { + if(!cameraMode) return 'default'; + + const heldActions = Object.keys(ACTION_BY_MODIFIER) + .filter((key) => modifiers[key]) + .map((key) => ACTION_BY_MODIFIER[key]); + if(heldActions.length > 1) return 'default'; + + const modeAction = cameraMode === 'pan' || cameraMode === 'zoom' ? cameraMode : 'rotate'; + return CURSOR_BY_ACTION[heldActions[0] || modeAction]; +}; diff --git a/src/components/dragelement/modifier_keys.js b/src/components/dragelement/modifier_keys.js new file mode 100644 index 00000000000..da642ec2fad --- /dev/null +++ b/src/components/dragelement/modifier_keys.js @@ -0,0 +1,44 @@ +'use strict'; + +const NO_MODIFIERS = { shiftKey: false, ctrlKey: false, altKey: false }; + +const isEditable = (el) => + !!el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName)); + +exports.update = function(gd, { shiftKey, ctrlKey, altKey }) { + if(!gd._fullLayout) return; + + const prev = gd._modifierKeys || NO_MODIFIERS; + if(prev.shiftKey === !!shiftKey && prev.ctrlKey === !!ctrlKey && prev.altKey === !!altKey) return; + gd._modifierKeys = { shiftKey: !!shiftKey, ctrlKey: !!ctrlKey, altKey: !!altKey }; + + const modules = gd._fullLayout._basePlotModules; + for(let i = 0; i < modules.length; i++) { + if(modules[i].updateCursor) modules[i].updateCursor(gd); + } +}; + +exports.track = function(gd) { + if(gd._untrackModifierKeys) return; + + const onKey = (e) => { + if(!isEditable(e.target)) exports.update(gd, e); + }; + const onBlur = () => exports.update(gd, NO_MODIFIERS); + + window.addEventListener('keydown', onKey); + window.addEventListener('keyup', onKey); + window.addEventListener('blur', onBlur); + + gd._untrackModifierKeys = function() { + window.removeEventListener('keydown', onKey); + window.removeEventListener('keyup', onKey); + window.removeEventListener('blur', onBlur); + delete gd._untrackModifierKeys; + delete gd._modifierKeys; + }; +}; + +exports.untrack = function(gd) { + if(gd._untrackModifierKeys) gd._untrackModifierKeys(); +}; diff --git a/src/components/dragelement/unhover.js b/src/components/dragelement/unhover.js index e0811ecc40b..ac71eb55871 100644 --- a/src/components/dragelement/unhover.js +++ b/src/components/dragelement/unhover.js @@ -1,6 +1,9 @@ 'use strict'; +var d3 = require('@plotly/d3'); + var Events = require('../../lib/events'); +var overrideCursor = require('../../lib/override_cursor'); var throttle = require('../../lib/throttle'); var getGraphDiv = require('../../lib/dom').getGraphDiv; @@ -45,6 +48,8 @@ unhover.raw = function raw(gd, evt) { var oldhoverdata = gd._hoverdata; if(!evt) evt = {}; + if(evt.target && evt.target.tagName) overrideCursor(d3.select(evt.target), ''); + if(evt.target && !gd._dragged && Events.triggerHandler(gd, 'plotly_beforehover', evt) === false) { return false; diff --git a/src/plot_api/plot_api.js b/src/plot_api/plot_api.js index 5af0b8951fa..bc036e9e0de 100644 --- a/src/plot_api/plot_api.js +++ b/src/plot_api/plot_api.js @@ -21,6 +21,7 @@ var cartesianLayoutAttributes = require('../plots/cartesian/layout_attributes'); var Drawing = require('../components/drawing'); var Color = require('../components/color'); var initInteractions = require('../plots/cartesian/graph_interact').initInteractions; +var modifierKeys = require('../components/dragelement/modifier_keys'); var xmlnsNamespaces = require('../constants/xmlns_namespaces'); var clearOutline = require('../components/selections').clearOutline; @@ -394,6 +395,7 @@ function _doPlot(gd, data, layout, config) { subroutines.drawData, subroutines.finalDraw, initInteractions, + modifierKeys.track, Plots.rehover, Plots.redrag, Plots.reselect, diff --git a/src/plots/cartesian/dragbox.js b/src/plots/cartesian/dragbox.js index 6f7c87a4576..fdab2e11462 100644 --- a/src/plots/cartesian/dragbox.js +++ b/src/plots/cartesian/dragbox.js @@ -17,6 +17,7 @@ var dragElement = require('../../components/dragelement'); var helpers = require('../../components/dragelement/helpers'); var selectingOrDrawing = helpers.selectingOrDrawing; var freeMode = helpers.freeMode; +var effectiveDragmode = helpers.effectiveDragmode; var FROM_TL = require('../../constants/alignment').FROM_TL; var clearGlCanvases = require('../../lib/clear_gl_canvases'); @@ -168,12 +169,7 @@ function makeDragBox(gd, plotinfo, x, y, w, h, ns, ew) { if(isMainDrag) { // main dragger handles all drag modes, and changes // to pan (or to zoom if it already is pan) on shift - if(e.shiftKey) { - if(dragModeNow === 'pan') dragModeNow = 'zoom'; - else if(!selectingOrDrawing(dragModeNow)) dragModeNow = 'pan'; - } else if(e.ctrlKey) { - dragModeNow = 'pan'; - } + dragModeNow = effectiveDragmode(dragModeNow, e); } else { // all other draggers just pan dragModeNow = 'pan'; @@ -1153,7 +1149,7 @@ function dZoom(d) { } function getDragCursor(nsew, dragmode, isMainDrag) { - if(!nsew) return 'pointer'; + if(!nsew) return 'default'; if(nsew === 'nsew') { // in this case here, clear cursor and // use the cursor style set on diff --git a/src/plots/cartesian/graph_interact.js b/src/plots/cartesian/graph_interact.js index a7e08ea41dd..04af6ec81b1 100644 --- a/src/plots/cartesian/graph_interact.js +++ b/src/plots/cartesian/graph_interact.js @@ -5,6 +5,8 @@ var d3 = require('@plotly/d3'); var Fx = require('../../components/fx'); var dragElement = require('../../components/dragelement'); var setCursor = require('../../lib/setcursor'); +var dragCursor = require('../../components/dragelement/helpers').dragCursor; +var modifierKeys = require('../../components/dragelement/modifier_keys'); var makeDragBox = require('./dragbox').makeDragBox; var DRAGGERSIZE = require('./constants').DRAGGERSIZE; @@ -47,6 +49,8 @@ exports.initInteractions = function initInteractions(gd) { xa._length, ya._length, 'ns', 'ew'); maindrag.onmousemove = function(evt) { + modifierKeys.update(gd, evt); + // This is on `gd._fullLayout`, *not* fullLayout because the reference // changes by the time this is called again. gd._fullLayout._rehover = function() { @@ -128,6 +132,7 @@ exports.initInteractions = function initInteractions(gd) { var hoverLayer = fullLayout._hoverlayer.node(); hoverLayer.onmousemove = function(evt) { + modifierKeys.update(gd, evt); evt.target = gd._fullLayout._lasthover; Fx.hover(gd, evt, fullLayout._hoversubplot); }; @@ -151,7 +156,11 @@ exports.initInteractions = function initInteractions(gd) { // Note that changing the axis configuration and/or the fixedrange attribute // should trigger a full initInteractions. exports.updateFx = function(gd) { + exports.updateCursor(gd); +}; + +exports.updateCursor = function(gd) { var fullLayout = gd._fullLayout; - var cursor = fullLayout.dragmode === 'pan' ? 'move' : 'crosshair'; + var cursor = dragCursor(fullLayout.dragmode, gd._modifierKeys); setCursor(fullLayout._draggers, cursor); }; diff --git a/src/plots/cartesian/index.js b/src/plots/cartesian/index.js index e55a90b39e2..ad7e6d6dd23 100644 --- a/src/plots/cartesian/index.js +++ b/src/plots/cartesian/index.js @@ -761,3 +761,4 @@ exports.toSVG = function(gd) { }; exports.updateFx = require('./graph_interact').updateFx; +exports.updateCursor = require('./graph_interact').updateCursor; diff --git a/src/plots/geo/geo.js b/src/plots/geo/geo.js index 96457409753..0f044846bde 100644 --- a/src/plots/geo/geo.js +++ b/src/plots/geo/geo.js @@ -18,6 +18,8 @@ var Plots = require('../plots'); var Axes = require('../cartesian/axes'); const { concatExtremes, getAutoRange, makePadFn } = require('../cartesian/autorange'); var dragElement = require('../../components/dragelement'); +var dragCursor = require('../../components/dragelement/helpers').dragCursor; +var setCursor = require('../../lib/setcursor'); var prepSelect = require('../../components/selections').prepSelect; var clearOutline = require('../../components/selections').clearOutline; var selectOnClick = require('../../components/selections').selectOnClick; @@ -506,6 +508,8 @@ proto.updateFx = function (fullLayout, geoLayout) { } }; + setCursor(bgRect, dragMode === false ? '' : dragCursor(dragMode)); + if (dragMode === 'pan') { bgRect.node().onmousedown = null; const zoom = createGeoZoom(_this, geoLayout); diff --git a/src/plots/geo/zoom.js b/src/plots/geo/zoom.js index c3865e48e95..11e141074f3 100644 --- a/src/plots/geo/zoom.js +++ b/src/plots/geo/zoom.js @@ -3,11 +3,29 @@ var d3 = require('@plotly/d3'); var Lib = require('../../lib'); var Registry = require('../../registry'); +var dragElement = require('../../components/dragelement'); var radians = Math.PI / 180; var degrees = 180 / Math.PI; -var zoomstartStyle = { cursor: 'pointer' }; -var zoomendStyle = { cursor: 'auto' }; +var zoomstartStyle = { cursor: 'move' }; +var zoomendStyle = { cursor: null }; + +function startDragStyle(geo, el) { + d3.select(el).style(zoomstartStyle); + + var sourceEvent = d3.event.sourceEvent; + var isRightClick = sourceEvent && (sourceEvent.buttons === 2 || sourceEvent.ctrlKey); + if(sourceEvent && sourceEvent.type === 'mousedown' && !isRightClick) { + geo._dragCover = dragElement.coverSlip(); + geo._dragCover.style.cursor = zoomstartStyle.cursor; + } +} + +function endDragStyle(geo, el) { + d3.select(el).style(zoomendStyle); + Lib.removeElement(geo._dragCover); + geo._dragCover = null; +} function createGeoZoom(geo, geoLayout) { var projection = geo.projection; @@ -74,7 +92,7 @@ function zoomScoped(geo, projection) { var zoom = initZoom(geo, projection); function handleZoomstart() { - d3.select(this).style(zoomstartStyle); + startDragStyle(geo, this); } function handleZoom() { @@ -97,7 +115,7 @@ function zoomScoped(geo, projection) { } function handleZoomend() { - d3.select(this).style(zoomendStyle); + endDragStyle(geo, this); sync(geo, projection, syncCb); } @@ -127,7 +145,7 @@ function zoomNonClipped(geo, projection) { } function handleZoomstart() { - d3.select(this).style(zoomstartStyle); + startDragStyle(geo, this); // Fallback to bbox center when there's no source event // (e.g. synthetic zoom.event dispatched on initial render @@ -176,7 +194,7 @@ function zoomNonClipped(geo, projection) { } function handleZoomend() { - d3.select(this).style(zoomendStyle); + endDragStyle(geo, this); if (didZoom) sync(geo, projection, syncCb); } @@ -206,7 +224,7 @@ function zoomClipped(geo, projection) { var zoomPoint; zoom.on('zoomstart', function () { - d3.select(this).style(zoomstartStyle); + startDragStyle(geo, this); // Fallback to bbox center when there's no source event // (e.g. synthetic zoom.event dispatched on initial render @@ -262,7 +280,7 @@ function zoomClipped(geo, projection) { zoomstarted(event.of(this, arguments)); }) .on('zoomend', function () { - d3.select(this).style(zoomendStyle); + endDragStyle(geo, this); zoomOn.call(zoom, 'zoom', null); zoomended(event.of(this, arguments)); sync(geo, projection, syncCb); diff --git a/src/plots/gl3d/index.js b/src/plots/gl3d/index.js index 94116ef2c4d..9437d57d6d4 100644 --- a/src/plots/gl3d/index.js +++ b/src/plots/gl3d/index.js @@ -138,6 +138,15 @@ exports.cleanId = function cleanId(id) { return SCENE + sceneNum; }; +exports.updateCursor = function(gd) { + var fullLayout = gd._fullLayout; + var subplotIds = fullLayout._subplots[GL3D]; + + for(var i = 0; i < subplotIds.length; i++) { + fullLayout[subplotIds[i]]._scene.updateCursor(); + } +}; + exports.updateFx = function(gd) { var fullLayout = gd._fullLayout; var subplotIds = fullLayout._subplots[GL3D]; diff --git a/src/plots/gl3d/scene.js b/src/plots/gl3d/scene.js index e320fe9e780..c9793017fcd 100644 --- a/src/plots/gl3d/scene.js +++ b/src/plots/gl3d/scene.js @@ -6,6 +6,7 @@ var createPlot = glPlot3d.createScene; var getContext = require('webgl-context'); var passiveSupported = require('has-passive-events'); +var d3 = require('@plotly/d3'); var Registry = require('../../registry'); var Lib = require('../../lib'); @@ -16,6 +17,9 @@ var Fx = require('../../components/fx'); var str2RGBAarray = require('../../lib/str2rgbarray'); var showNoWebGlMsg = require('../../lib/show_no_webgl_msg'); +var setCursor = require('../../lib/setcursor'); +var dragCursor3d = require('../../components/dragelement/helpers').dragCursor3d; +var modifierKeys = require('../../components/dragelement/modifier_keys'); var project = require('./project'); var createAxesOptions = require('./layout/convert'); @@ -265,6 +269,10 @@ proto.initializeGLPlot = function() { } }, passiveSupported ? {passive: false} : false); + scene.glplot.canvas.addEventListener('mousemove', function(e) { + modifierKeys.update(scene.graphDiv, e); + }); + scene.glplot.canvas.addEventListener('mousemove', function() { if(scene.fullSceneLayout.dragmode === false) return; if(scene.camera.mouseListener.buttons === 0) return; @@ -1052,10 +1060,20 @@ proto.updateFx = function(dragmode, hovermode) { } } + scene.updateCursor(); + // to put dragmode and hovermode on the same grounds from relayout scene.fullSceneLayout.hovermode = hovermode; }; +proto.updateCursor = function() { + var scene = this; + if(scene.staticMode || !scene.camera) return; + + var cursor = dragCursor3d(scene.camera.keyBindingMode, scene.graphDiv._modifierKeys); + setCursor(d3.select(scene.container), cursor); +}; + function flipPixels(pixels, w, h) { for(var i = 0, q = h - 1; i < q; ++i, --q) { for(var j = 0; j < w; ++j) { diff --git a/src/plots/plots.js b/src/plots/plots.js index ad53d4dd3e9..0f1da6ab3fa 100644 --- a/src/plots/plots.js +++ b/src/plots/plots.js @@ -16,6 +16,7 @@ var BADNUM = require('../constants/numerical').BADNUM; var axisIDs = require('./cartesian/axis_ids'); var clearOutline = require('../components/shapes/handle_outline').clearOutline; +var modifierKeys = require('../components/dragelement/modifier_keys'); var scatterAttrs = require('../traces/scatter/layout_attributes'); var animationAttrs = require('./animation_attributes'); @@ -1470,6 +1471,8 @@ plots.purge = function(gd) { // remove responsive handler Lib.clearResponsive(gd); + modifierKeys.untrack(gd); + // Cancel the pending redraw for web fonts delete gd._fontLoadToken; diff --git a/src/plots/ternary/index.js b/src/plots/ternary/index.js index 12147231744..267f171d33a 100644 --- a/src/plots/ternary/index.js +++ b/src/plots/ternary/index.js @@ -1,6 +1,7 @@ 'use strict'; var Ternary = require('./ternary'); +var dragCursor = require('../../components/dragelement/helpers').dragCursor; var getSubplotCalcData = require('../../plots/get_data').getSubplotCalcData; var counterRegex = require('../../lib').counterRegex; @@ -80,5 +81,5 @@ exports.updateFx = function(gd) { var fullLayout = gd._fullLayout; fullLayout._ternarylayer .selectAll('g.toplevel') - .style('cursor', fullLayout.dragmode === 'pan' ? 'move' : 'crosshair'); + .style('cursor', dragCursor(fullLayout.dragmode)); }; diff --git a/src/plots/ternary/ternary.js b/src/plots/ternary/ternary.js index a375a5b6546..c7f86892e97 100644 --- a/src/plots/ternary/ternary.js +++ b/src/plots/ternary/ternary.js @@ -101,7 +101,7 @@ proto.makeFramework = function(fullLayout) { proto.updateFx = function(fullLayout) { fullLayout._ternarylayer .selectAll('g.toplevel') - .style('cursor', fullLayout.dragmode === 'pan' ? 'move' : 'crosshair'); + .style('cursor', dragHelpers.dragCursor(fullLayout.dragmode)); }; proto.updateLayers = function(ternaryLayout) { diff --git a/src/traces/parcoords/axisbrush.js b/src/traces/parcoords/axisbrush.js index b456132b561..2d910cad627 100644 --- a/src/traces/parcoords/axisbrush.js +++ b/src/traces/parcoords/axisbrush.js @@ -4,7 +4,10 @@ var c = require('./constants'); var d3 = require('@plotly/d3'); var keyFun = require('../../lib/gup').keyFun; var repeat = require('../../lib/gup').repeat; +var removeElement = require('../../lib').removeElement; +var setCursor = require('../../lib/setcursor'); var sortAsc = require('../../lib').sorterAsc; +var dragElement = require('../../components/dragelement'); var strTranslate = require('../../lib').strTranslate; var snapRatio = c.bar.snapRatio; @@ -110,9 +113,8 @@ function getRegion(fPix, y) { return 'ns'; } -function clearCursor() { - d3.select(document.body) - .style('cursor', null); +function clearCursor(lThis) { + setCursor(d3.select(lThis)); } function styleHighlight(selection) { @@ -223,6 +225,8 @@ function dragstart(lThis, d) { } s.startExtent = interval.region ? unitRange[interval.region === 's' ? 1 : 0] : unitLocation; d.parent.inBrushDrag = true; + s.dragCover = dragElement.coverSlip(); + s.dragCover.style.cursor = getCursor(interval); s.brushStartCallback(); } @@ -265,7 +269,9 @@ function dragend(lThis, d) { s.grabbingBar = false; s.grabLocation = undefined; d.parent.inBrushDrag = false; - clearCursor(); // instead of clearing, a nicer thing would be to set it according to current location + removeElement(s.dragCover); + s.dragCover = undefined; + clearCursor(lThis); // instead of clearing, a nicer thing would be to set it according to current location if(!s.wasDragged) { // a click+release on the same spot (ie. w/o dragging) means a bar or full reset s.wasDragged = undefined; // logic-wise unneeded, just shows `wasDragged` has no longer a meaning if(s.clickableOrdinalRange) { @@ -322,15 +328,15 @@ function dragend(lThis, d) { s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []); } +function getCursor(interval) { + if(interval.clickableOrdinalRange) return 'pointer'; + if(interval.region) return interval.region + '-resize'; + return 'crosshair'; +} + function mousemove(lThis, d) { var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding; - var interval = getInterval(d, y); - - var cursor = 'crosshair'; - if(interval.clickableOrdinalRange) cursor = 'pointer'; - else if(interval.region) cursor = interval.region + '-resize'; - d3.select(document.body) - .style('cursor', cursor); + setCursor(d3.select(lThis), getCursor(getInterval(d, y))); } function attachDragBehavior(selection) { @@ -343,7 +349,7 @@ function attachDragBehavior(selection) { if(!d.parent.inBrushDrag) mousemove(this, d); }) .on('mouseleave', function(d) { - if(!d.parent.inBrushDrag) clearCursor(); + if(!d.parent.inBrushDrag) clearCursor(this); }) .call(d3.behavior.drag() .on('dragstart', function(d) { dragstart(this, d); }) diff --git a/src/traces/sankey/base_plot.js b/src/traces/sankey/base_plot.js index 952b376adb2..7ffe210b91f 100644 --- a/src/traces/sankey/base_plot.js +++ b/src/traces/sankey/base_plot.js @@ -7,6 +7,7 @@ var fxAttrs = require('../../components/fx/layout_attributes'); var setCursor = require('../../lib/setcursor'); var dragElement = require('../../components/dragelement'); +var dragCursor = require('../../components/dragelement/helpers').dragCursor; var prepSelect = require('../../components/selections').prepSelect; var Lib = require('../../lib'); var Registry = require('../../registry'); @@ -46,7 +47,7 @@ function subplotUpdateFx(gd, index) { var fullLayout = gd._fullLayout; var dragMode = fullLayout.dragmode; - var cursor = fullLayout.dragmode === 'pan' ? 'move' : 'crosshair'; + var cursor = dragCursor(fullLayout.dragmode); var bgRect = trace._bgRect; if(!bgRect) return; diff --git a/src/traces/splom/base_plot.js b/src/traces/splom/base_plot.js index b9f7ed67085..fb8b2c32fb9 100644 --- a/src/traces/splom/base_plot.js +++ b/src/traces/splom/base_plot.js @@ -222,6 +222,7 @@ module.exports = { updateGrid: updateGrid, clean: clean, updateFx: Cartesian.updateFx, + updateCursor: Cartesian.updateCursor, toSVG: Cartesian.toSVG, reglPrecompiled: reglPrecompiled }; diff --git a/test/jasmine/tests/cartesian_interact_test.js b/test/jasmine/tests/cartesian_interact_test.js index c3817896b41..3bedfa9e0de 100644 --- a/test/jasmine/tests/cartesian_interact_test.js +++ b/test/jasmine/tests/cartesian_interact_test.js @@ -2296,6 +2296,127 @@ describe('axis zoom/pan and main plot zoom', function() { }); }); +describe('drag cursor', function() { + var gd; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function pressShift(type) { + window.dispatchEvent(new KeyboardEvent(type, {key: 'Shift', shiftKey: type === 'keydown'})); + } + + function assertCursor(expected) { + var draglayer = d3Select(gd).select('.draglayer').node(); + expect(draglayer).toBeClassed(['draglayer', 'cursor-' + expected]); + } + + function plot(layout) { + return Plotly.newPlot(gd, [{y: [1, 2, 1]}], Lib.extendFlat({width: 400, height: 400}, layout)); + } + + it('flips from crosshair to move while shift is held in zoom mode', function(done) { + plot({dragmode: 'zoom'}) + .then(function() { + assertCursor('crosshair'); + pressShift('keydown'); + assertCursor('move'); + pressShift('keyup'); + assertCursor('crosshair'); + }) + .then(done, done.fail); + }); + + it('flips from move to crosshair while shift is held in pan mode', function(done) { + plot({dragmode: 'pan'}) + .then(function() { + assertCursor('move'); + pressShift('keydown'); + assertCursor('crosshair'); + pressShift('keyup'); + assertCursor('move'); + }) + .then(done, done.fail); + }); + + it('keeps crosshair while shift is held in select mode', function(done) { + plot({dragmode: 'select'}) + .then(function() { + pressShift('keydown'); + assertCursor('crosshair'); + }) + .then(done, done.fail); + }); + + it('reverts the shift cursor on window blur', function(done) { + plot({dragmode: 'zoom'}) + .then(function() { + pressShift('keydown'); + window.dispatchEvent(new Event('blur')); + assertCursor('crosshair'); + }) + .then(done, done.fail); + }); + + it('applies the held shift state to a dragmode relayout', function(done) { + plot({dragmode: 'zoom'}) + .then(function() { + pressShift('keydown'); + return Plotly.relayout(gd, 'dragmode', 'pan'); + }) + .then(function() { + assertCursor('crosshair'); + pressShift('keyup'); + assertCursor('move'); + }) + .then(done, done.fail); + }); + + it('keeps crosshair while shift is held when dragmode is false', function(done) { + plot({dragmode: false}) + .then(function() { + pressShift('keydown'); + assertCursor('crosshair'); + }) + .then(done, done.fail); + }); + + it('picks up shift from a mousemove when no keydown was seen', function(done) { + plot({dragmode: 'zoom', margin: {l: 50, t: 50, r: 50, b: 50}}) + .then(function() { + mouseEvent('mousemove', 200, 200, {shiftKey: true}); + assertCursor('move'); + mouseEvent('mousemove', 200, 200); + assertCursor('crosshair'); + }) + .then(done, done.fail); + }); + + it('ignores shift typed into an input', function(done) { + var input = document.createElement('input'); + document.body.appendChild(input); + + plot({dragmode: 'zoom'}) + .then(function() { + input.dispatchEvent(new KeyboardEvent('keydown', {key: 'A', shiftKey: true, bubbles: true})); + assertCursor('crosshair'); + }) + .then(done, done.fail) + .then(function() { document.body.removeChild(input); }); + }); + + it('uses the default cursor on the main rect when every axis is fixedrange', function(done) { + plot({xaxis: {fixedrange: true}, yaxis: {fixedrange: true}}) + .then(function() { + expect(d3Select(gd).select('.nsewdrag').node().classList.contains('cursor-default')).toBe(true); + }) + .then(done, done.fail); + }); +}); + describe('Event data:', function() { var gd; diff --git a/test/jasmine/tests/fx_test.js b/test/jasmine/tests/fx_test.js index e7b3bc722a1..959c285f6ec 100644 --- a/test/jasmine/tests/fx_test.js +++ b/test/jasmine/tests/fx_test.js @@ -310,19 +310,19 @@ describe('relayout', function() { return Plotly.relayout(gd, 'yaxis.fixedrange', true); }).then(function() { // still active with fixedrange because we're handling clicks here too. - assertMainDrag('pointer', true); + assertMainDrag('default', true); return Plotly.relayout(gd, 'dragmode', 'drag'); }).then(function() { - assertMainDrag('pointer', true); + assertMainDrag('default', true); return Plotly.relayout(gd, 'dragmode', 'lasso'); }).then(function() { - assertMainDrag('pointer', true); + assertMainDrag('default', true); return Plotly.relayout(gd, 'dragmode', 'select'); }).then(function() { - assertMainDrag('pointer', true); + assertMainDrag('default', true); return Plotly.relayout(gd, 'xaxis.fixedrange', false); }).then(function() { diff --git a/test/jasmine/tests/geo_test.js b/test/jasmine/tests/geo_test.js index a4f1044c68f..416571356e5 100644 --- a/test/jasmine/tests/geo_test.js +++ b/test/jasmine/tests/geo_test.js @@ -1640,6 +1640,76 @@ describe('Test geo interactions', function () { .then(done, done.fail); }); + it('should show the move cursor at rest and after a drag in pan mode', function (done) { + var gd = createGraphDiv(); + var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + + var bgRect = function () { return d3Select(gd).select('.geo .bg rect').node(); }; + var hasMoveCursor = function () { + return bgRect().classList.contains('cursor-move') && !bgRect().style.cursor; + }; + + Plotly.newPlot(gd, fig) + .then(function () { + expect(hasMoveCursor()).toBe(true, 'at rest'); + return drag({ node: bgRect(), dpos: [20, 20], noCover: true }); + }) + .then(function () { + expect(hasMoveCursor()).toBe(true, 'after drag'); + return Plotly.relayout(gd, 'dragmode', 'select'); + }) + .then(function () { + expect(bgRect().classList.contains('cursor-crosshair')).toBe(true, 'in select mode'); + }) + .then(done, done.fail); + }); + + it('should keep the move cursor on a cover while a pan drag is in progress', function (done) { + var gd = createGraphDiv(); + var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + + Plotly.newPlot(gd, fig) + .then(function () { + mouseEvent('mousedown', 350, 250); + mouseEvent('mousemove', 360, 260); + + var covers = document.querySelectorAll('.dragcover'); + expect(covers.length).toBe(1, 'one cover while dragging'); + expect(covers[0].style.cursor).toBe('move'); + expect(d3Select(gd).select('.geo .bg rect').node().style.cursor).toBe('move'); + + mouseEvent('mouseup', 360, 260); + expect(document.querySelectorAll('.dragcover').length).toBe(0, 'no cover after the drag'); + }) + .then(done, done.fail); + }); + + it('should not create a drag cover on right-click', function (done) { + var gd = createGraphDiv(); + var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); + fig.layout.width = 700; + fig.layout.height = 500; + fig.layout.dragmode = 'pan'; + + Plotly.newPlot(gd, fig) + .then(function () { + mouseEvent('mousedown', 350, 250, { buttons: 2 }); + expect(document.querySelectorAll('.dragcover').length).toBe(0, 'right button'); + mouseEvent('mouseup', 350, 250, { buttons: 2 }); + + mouseEvent('mousedown', 350, 250, { ctrlKey: true }); + expect(document.querySelectorAll('.dragcover').length).toBe(0, 'ctrl key'); + mouseEvent('mouseup', 350, 250, { ctrlKey: true }); + }) + .then(done, done.fail); + }); + it('should clear hover label when cursor slips off subplot', function (done) { var gd = createGraphDiv(); var fig = Lib.extendDeep({}, require('../../image/mocks/geo_orthographic.json')); diff --git a/test/jasmine/tests/gl3d_plot_interact_test.js b/test/jasmine/tests/gl3d_plot_interact_test.js index 568f6374c25..514e93e9055 100644 --- a/test/jasmine/tests/gl3d_plot_interact_test.js +++ b/test/jasmine/tests/gl3d_plot_interact_test.js @@ -256,6 +256,72 @@ describe('Test gl3d plots', function() { .then(done, done.fail); }); + it('@gl should set the cursor on the scene container from the dragmode and held modifier keys', function(done) { + var cursorClasses = function() { + return Array.prototype.filter.call( + d3Select(gd).select('.gl-container > div').node().classList, + function(cls) { return cls.indexOf('cursor-') === 0; } + ); + }; + var press = function(type, init) { + window.dispatchEvent(new KeyboardEvent(type, init)); + }; + + Plotly.newPlot(gd, [{type: 'scatter3d', x: [1, 2], y: [1, 2], z: [1, 2]}], {dragmode: 'turntable'}) + .then(function() { + expect(cursorClasses()).toEqual(['cursor-default'], 'turntable'); + + press('keydown', {shiftKey: true}); + expect(cursorClasses()).toEqual(['cursor-default'], 'shift rotates'); + press('keyup', {}); + + press('keydown', {ctrlKey: true}); + expect(cursorClasses()).toEqual(['cursor-move'], 'ctrl pans'); + press('keyup', {}); + + press('keydown', {altKey: true}); + expect(cursorClasses()).toEqual(['cursor-crosshair'], 'alt zooms'); + press('keyup', {}); + + return Plotly.relayout(gd, 'scene.dragmode', 'pan'); + }) + .then(function() { + expect(cursorClasses()).toEqual(['cursor-move'], 'pan'); + press('keydown', {shiftKey: true}); + expect(cursorClasses()).toEqual(['cursor-default'], 'shift rotates in pan mode'); + press('keyup', {}); + + return Plotly.relayout(gd, 'scene.dragmode', 'zoom'); + }) + .then(function() { + expect(cursorClasses()).toEqual(['cursor-crosshair'], 'zoom'); + }) + .then(done, done.fail); + }); + + it('@gl should leave the camera mode alone when a modifier key is pressed in a mixed figure', function(done) { + Plotly.newPlot(gd, [ + {x: [1, 2], y: [1, 2]}, + {type: 'scatter3d', x: [1, 2], y: [1, 2], z: [1, 2], scene: 'scene'} + ], { + dragmode: 'zoom', + scene: {dragmode: 'turntable', domain: {x: [0.5, 1]}}, + xaxis: {domain: [0, 0.4]} + }) + .then(function() { + var scene = gd._fullLayout.scene._scene; + var keyBindingMode = scene.camera.keyBindingMode; + var cameraMode = scene.camera.mode; + expect(cameraMode).toBe('turntable'); + + window.dispatchEvent(new KeyboardEvent('keydown', {shiftKey: true})); + + expect(scene.camera.keyBindingMode).toBe(keyBindingMode); + expect(scene.camera.mode).toBe(cameraMode); + }) + .then(done, done.fail); + }); + it('@gl should not set _length to NaN and dtick should be defined.', function(done) { Plotly.newPlot(gd, { diff --git a/test/jasmine/tests/hover_test.js b/test/jasmine/tests/hover_test.js index 597f4224959..df217883fce 100644 --- a/test/jasmine/tests/hover_test.js +++ b/test/jasmine/tests/hover_test.js @@ -4663,6 +4663,31 @@ describe('hover updates', function () { } } + it('should clear the clicktoshow pointer cursor on unhover', function (done) { + var gd = createGraphDiv(); + var hasPointer = function () { + return d3Select(gd).select('.nsewdrag').node().classList.contains('cursor-pointer'); + }; + + Plotly.newPlot(gd, [{ x: [0.5], y: [0.5] }], { + hovermode: 'closest', + margin: { t: 0, r: 0, b: 0, l: 0 }, + width: 200, + height: 200, + xaxis: { range: [0, 1] }, + yaxis: { range: [0, 1] }, + annotations: [{ x: 0.5, y: 0.5, text: 'shown on click', visible: false, clicktoshow: 'onoff' }] + }) + .then(function () { + mouseEvent('mousemove', 100, 100); + expect(hasPointer()).toBe(true, 'while over the point'); + + mouseEvent('mouseout', 100, 100); + expect(hasPointer()).toBe(false, 'after the mouse leaves the point'); + }) + .then(done, done.fail); + }); + it('should update the labels on animation', function (done) { var mock = { data: [ diff --git a/test/jasmine/tests/parcoords_test.js b/test/jasmine/tests/parcoords_test.js index 0c5b238eed8..e3b96c16af3 100644 --- a/test/jasmine/tests/parcoords_test.js +++ b/test/jasmine/tests/parcoords_test.js @@ -1551,6 +1551,24 @@ describe('parcoords constraint interactions - without defined axis ranges', func expect(dashArray.length).toBe(segmentCount, dashArray); } + it('@gl keeps the brush cursor off the body and holds it with a drag cover during a brush drag', function(done) { + Plotly.react(gd, initialFigure()) + .then(function() { + mostOfDrag(105, 165, 105, 190); + + var background = document.querySelector('.axis-brush .background'); + expect(background.getAttribute('class')).toMatch(/cursor-/); + expect(document.body.style.cursor).toBe(''); + expect(document.querySelectorAll('.dragcover').length).toBe(1); + expect(document.querySelector('.dragcover').style.cursor).not.toBe(''); + + mouseEvent('mouseup', 105, 190); + expect(document.querySelectorAll('.dragcover').length).toBe(0); + expect(background.getAttribute('class')).not.toMatch(/cursor-/); + }) + .then(done, done.fail); + }); + it('@gl snaps ordinal constraints', function(done) { var newDashArray;