Dragging.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. /* global TouchEvent */
  2. var round = Math.round;
  3. import { assign } from 'min-dash';
  4. import {
  5. event as domEvent
  6. } from 'min-dom';
  7. import {
  8. getOriginal,
  9. toPoint,
  10. stopPropagation
  11. } from '../../util/Event';
  12. import {
  13. set as cursorSet,
  14. unset as cursorUnset
  15. } from '../../util/Cursor';
  16. import {
  17. install as installClickTrap
  18. } from '../../util/ClickTrap';
  19. import {
  20. delta as deltaPos
  21. } from '../../util/PositionUtil';
  22. var DRAG_ACTIVE_CLS = 'djs-drag-active';
  23. function preventDefault(event) {
  24. event.preventDefault();
  25. }
  26. function isTouchEvent(event) {
  27. // check for TouchEvent being available first
  28. // (i.e. not available on desktop Firefox)
  29. return typeof TouchEvent !== 'undefined' && event instanceof TouchEvent;
  30. }
  31. function getLength(point) {
  32. return Math.sqrt(Math.pow(point.x, 2) + Math.pow(point.y, 2));
  33. }
  34. /**
  35. * A helper that fires canvas localized drag events and realizes
  36. * the general "drag-and-drop" look and feel.
  37. *
  38. * Calling {@link Dragging#activate} activates dragging on a canvas.
  39. *
  40. * It provides the following:
  41. *
  42. * * emits life cycle events, namespaced with a prefix assigned
  43. * during dragging activation
  44. * * sets and restores the cursor
  45. * * sets and restores the selection
  46. * * ensures there can be only one drag operation active at a time
  47. *
  48. * Dragging may be canceled manually by calling {@link Dragging#cancel}
  49. * or by pressing ESC.
  50. *
  51. *
  52. * ## Life-cycle events
  53. *
  54. * Dragging can be in three different states, off, initialized
  55. * and active.
  56. *
  57. * (1) off: no dragging operation is in progress
  58. * (2) initialized: a new drag operation got initialized but not yet
  59. * started (i.e. because of no initial move)
  60. * (3) started: dragging is in progress
  61. *
  62. * Eventually dragging will be off again after a drag operation has
  63. * been ended or canceled via user click or ESC key press.
  64. *
  65. * To indicate transitions between these states dragging emits generic
  66. * life-cycle events with the `drag.` prefix _and_ events namespaced
  67. * to a prefix choosen by a user during drag initialization.
  68. *
  69. * The following events are emitted (appropriately prefixed) via
  70. * the {@link EventBus}.
  71. *
  72. * * `init`
  73. * * `start`
  74. * * `move`
  75. * * `end`
  76. * * `ended` (dragging already in off state)
  77. * * `cancel` (only if previously started)
  78. * * `canceled` (dragging already in off state, only if previously started)
  79. * * `cleanup`
  80. *
  81. *
  82. * @example
  83. *
  84. * function MyDragComponent(eventBus, dragging) {
  85. *
  86. * eventBus.on('mydrag.start', function(event) {
  87. * console.log('yes, we start dragging');
  88. * });
  89. *
  90. * eventBus.on('mydrag.move', function(event) {
  91. * console.log('canvas local coordinates', event.x, event.y, event.dx, event.dy);
  92. *
  93. * // local drag data is passed with the event
  94. * event.context.foo; // "BAR"
  95. *
  96. * // the original mouse event, too
  97. * event.originalEvent; // MouseEvent(...)
  98. * });
  99. *
  100. * eventBus.on('element.click', function(event) {
  101. * dragging.init(event, 'mydrag', {
  102. * cursor: 'grabbing',
  103. * data: {
  104. * context: {
  105. * foo: "BAR"
  106. * }
  107. * }
  108. * });
  109. * });
  110. * }
  111. */
  112. export default function Dragging(eventBus, canvas, selection) {
  113. var defaultOptions = {
  114. threshold: 5,
  115. trapClick: true
  116. };
  117. // the currently active drag operation
  118. // dragging is active as soon as this context exists.
  119. //
  120. // it is visually _active_ only when a context.active flag is set to true.
  121. var context;
  122. /* convert a global event into local coordinates */
  123. function toLocalPoint(globalPosition) {
  124. var viewbox = canvas.viewbox();
  125. var clientRect = canvas._container.getBoundingClientRect();
  126. return {
  127. x: viewbox.x + (globalPosition.x - clientRect.left) / viewbox.scale,
  128. y: viewbox.y + (globalPosition.y - clientRect.top) / viewbox.scale
  129. };
  130. }
  131. // helpers
  132. function fire(type, dragContext) {
  133. dragContext = dragContext || context;
  134. var event = eventBus.createEvent(
  135. assign(
  136. {},
  137. dragContext.payload,
  138. dragContext.data,
  139. { isTouch: dragContext.isTouch }
  140. )
  141. );
  142. // default integration
  143. if (eventBus.fire('drag.' + type, event) === false) {
  144. return false;
  145. }
  146. return eventBus.fire(dragContext.prefix + '.' + type, event);
  147. }
  148. // event listeners
  149. function move(event, activate) {
  150. var payload = context.payload,
  151. displacement = context.displacement;
  152. var globalStart = context.globalStart,
  153. globalCurrent = toPoint(event),
  154. globalDelta = deltaPos(globalCurrent, globalStart);
  155. var localStart = context.localStart,
  156. localCurrent = toLocalPoint(globalCurrent),
  157. localDelta = deltaPos(localCurrent, localStart);
  158. // activate context explicitly or once threshold is reached
  159. if (!context.active && (activate || getLength(globalDelta) > context.threshold)) {
  160. // fire start event with original
  161. // starting coordinates
  162. assign(payload, {
  163. x: round(localStart.x + displacement.x),
  164. y: round(localStart.y + displacement.y),
  165. dx: 0,
  166. dy: 0
  167. }, { originalEvent: event });
  168. if (false === fire('start')) {
  169. return cancel();
  170. }
  171. context.active = true;
  172. // unset selection and remember old selection
  173. // the previous (old) selection will always passed
  174. // with the event via the event.previousSelection property
  175. if (!context.keepSelection) {
  176. payload.previousSelection = selection.get();
  177. selection.select(null);
  178. }
  179. // allow custom cursor
  180. if (context.cursor) {
  181. cursorSet(context.cursor);
  182. }
  183. // indicate dragging via marker on root element
  184. canvas.addMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
  185. }
  186. stopPropagation(event);
  187. if (context.active) {
  188. // update payload with actual coordinates
  189. assign(payload, {
  190. x: round(localCurrent.x + displacement.x),
  191. y: round(localCurrent.y + displacement.y),
  192. dx: round(localDelta.x),
  193. dy: round(localDelta.y)
  194. }, { originalEvent: event });
  195. // emit move event
  196. fire('move');
  197. }
  198. }
  199. function end(event) {
  200. var previousContext,
  201. returnValue = true;
  202. if (context.active) {
  203. if (event) {
  204. context.payload.originalEvent = event;
  205. // suppress original event (click, ...)
  206. // because we just ended a drag operation
  207. stopPropagation(event);
  208. }
  209. // implementations may stop restoring the
  210. // original state (selections, ...) by preventing the
  211. // end events default action
  212. returnValue = fire('end');
  213. }
  214. if (returnValue === false) {
  215. fire('rejected');
  216. }
  217. previousContext = cleanup(returnValue !== true);
  218. // last event to be fired when all drag operations are done
  219. // at this point in time no drag operation is in progress anymore
  220. fire('ended', previousContext);
  221. }
  222. // cancel active drag operation if the user presses
  223. // the ESC key on the keyboard
  224. function checkCancel(event) {
  225. if (event.which === 27) {
  226. preventDefault(event);
  227. cancel();
  228. }
  229. }
  230. // prevent ghost click that might occur after a finished
  231. // drag and drop session
  232. function trapClickAndEnd(event) {
  233. var untrap;
  234. // trap the click in case we are part of an active
  235. // drag operation. This will effectively prevent
  236. // the ghost click that cannot be canceled otherwise.
  237. if (context.active) {
  238. untrap = installClickTrap(eventBus);
  239. // remove trap after minimal delay
  240. setTimeout(untrap, 400);
  241. // prevent default action (click)
  242. preventDefault(event);
  243. }
  244. end(event);
  245. }
  246. function trapTouch(event) {
  247. move(event);
  248. }
  249. // update the drag events hover (djs.model.Base) and hoverGfx (Snap<SVGElement>)
  250. // properties during hover and out and fire {prefix}.hover and {prefix}.out properties
  251. // respectively
  252. function hover(event) {
  253. var payload = context.payload;
  254. payload.hoverGfx = event.gfx;
  255. payload.hover = event.element;
  256. fire('hover');
  257. }
  258. function out(event) {
  259. fire('out');
  260. var payload = context.payload;
  261. payload.hoverGfx = null;
  262. payload.hover = null;
  263. }
  264. // life-cycle methods
  265. function cancel(restore) {
  266. var previousContext;
  267. if (!context) {
  268. return;
  269. }
  270. var wasActive = context.active;
  271. if (wasActive) {
  272. fire('cancel');
  273. }
  274. previousContext = cleanup(restore);
  275. if (wasActive) {
  276. // last event to be fired when all drag operations are done
  277. // at this point in time no drag operation is in progress anymore
  278. fire('canceled', previousContext);
  279. }
  280. }
  281. function cleanup(restore) {
  282. var previousContext,
  283. endDrag;
  284. fire('cleanup');
  285. // reset cursor
  286. cursorUnset();
  287. if (context.trapClick) {
  288. endDrag = trapClickAndEnd;
  289. } else {
  290. endDrag = end;
  291. }
  292. // reset dom listeners
  293. domEvent.unbind(document, 'mousemove', move);
  294. domEvent.unbind(document, 'dragstart', preventDefault);
  295. domEvent.unbind(document, 'selectstart', preventDefault);
  296. domEvent.unbind(document, 'mousedown', endDrag, true);
  297. domEvent.unbind(document, 'mouseup', endDrag, true);
  298. domEvent.unbind(document, 'keyup', checkCancel);
  299. domEvent.unbind(document, 'touchstart', trapTouch, true);
  300. domEvent.unbind(document, 'touchcancel', cancel, true);
  301. domEvent.unbind(document, 'touchmove', move, true);
  302. domEvent.unbind(document, 'touchend', end, true);
  303. eventBus.off('element.hover', hover);
  304. eventBus.off('element.out', out);
  305. // remove drag marker on root element
  306. canvas.removeMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
  307. // restore selection, unless it has changed
  308. var previousSelection = context.payload.previousSelection;
  309. if (restore !== false && previousSelection && !selection.get().length) {
  310. selection.select(previousSelection);
  311. }
  312. previousContext = context;
  313. context = null;
  314. return previousContext;
  315. }
  316. /**
  317. * Initialize a drag operation.
  318. *
  319. * If `localPosition` is given, drag events will be emitted
  320. * relative to it.
  321. *
  322. * @param {MouseEvent|TouchEvent} [event]
  323. * @param {Point} [localPosition] actual diagram local position this drag operation should start at
  324. * @param {String} prefix
  325. * @param {Object} [options]
  326. */
  327. function init(event, relativeTo, prefix, options) {
  328. // only one drag operation may be active, at a time
  329. if (context) {
  330. cancel(false);
  331. }
  332. if (typeof relativeTo === 'string') {
  333. options = prefix;
  334. prefix = relativeTo;
  335. relativeTo = null;
  336. }
  337. options = assign({}, defaultOptions, options || {});
  338. var data = options.data || {},
  339. originalEvent,
  340. globalStart,
  341. localStart,
  342. endDrag,
  343. isTouch;
  344. if (options.trapClick) {
  345. endDrag = trapClickAndEnd;
  346. } else {
  347. endDrag = end;
  348. }
  349. if (event) {
  350. originalEvent = getOriginal(event) || event;
  351. globalStart = toPoint(event);
  352. stopPropagation(event);
  353. // prevent default browser dragging behavior
  354. if (originalEvent.type === 'dragstart') {
  355. preventDefault(originalEvent);
  356. }
  357. } else {
  358. originalEvent = null;
  359. globalStart = { x: 0, y: 0 };
  360. }
  361. localStart = toLocalPoint(globalStart);
  362. if (!relativeTo) {
  363. relativeTo = localStart;
  364. }
  365. isTouch = isTouchEvent(originalEvent);
  366. context = assign({
  367. prefix: prefix,
  368. data: data,
  369. payload: {},
  370. globalStart: globalStart,
  371. displacement: deltaPos(relativeTo, localStart),
  372. localStart: localStart,
  373. isTouch: isTouch
  374. }, options);
  375. // skip dom registration if trigger
  376. // is set to manual (during testing)
  377. if (!options.manual) {
  378. // add dom listeners
  379. if (isTouch) {
  380. domEvent.bind(document, 'touchstart', trapTouch, true);
  381. domEvent.bind(document, 'touchcancel', cancel, true);
  382. domEvent.bind(document, 'touchmove', move, true);
  383. domEvent.bind(document, 'touchend', end, true);
  384. } else {
  385. // assume we use the mouse to interact per default
  386. domEvent.bind(document, 'mousemove', move);
  387. // prevent default browser drag and text selection behavior
  388. domEvent.bind(document, 'dragstart', preventDefault);
  389. domEvent.bind(document, 'selectstart', preventDefault);
  390. domEvent.bind(document, 'mousedown', endDrag, true);
  391. domEvent.bind(document, 'mouseup', endDrag, true);
  392. }
  393. domEvent.bind(document, 'keyup', checkCancel);
  394. eventBus.on('element.hover', hover);
  395. eventBus.on('element.out', out);
  396. }
  397. fire('init');
  398. if (options.autoActivate) {
  399. move(event, true);
  400. }
  401. }
  402. // cancel on diagram destruction
  403. eventBus.on('diagram.destroy', cancel);
  404. // API
  405. this.init = init;
  406. this.move = move;
  407. this.hover = hover;
  408. this.out = out;
  409. this.end = end;
  410. this.cancel = cancel;
  411. // for introspection
  412. this.context = function() {
  413. return context;
  414. };
  415. this.setOptions = function(options) {
  416. assign(defaultOptions, options);
  417. };
  418. }
  419. Dragging.$inject = [
  420. 'eventBus',
  421. 'canvas',
  422. 'selection'
  423. ];