Dragging.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. var round = Math.round;
  2. import { assign } from 'min-dash';
  3. import {
  4. event as domEvent
  5. } from 'min-dom';
  6. import {
  7. getOriginal,
  8. toPoint,
  9. stopPropagation
  10. } from '../../util/Event';
  11. import {
  12. set as cursorSet,
  13. unset as cursorUnset
  14. } from '../../util/Cursor';
  15. import {
  16. install as installClickTrap
  17. } from '../../util/ClickTrap';
  18. import {
  19. delta as deltaPos
  20. } from '../../util/PositionUtil';
  21. import { isKey } from '../keyboard/KeyboardUtil';
  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 if elements still exist
  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, elementRegistry) {
  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. function restoreSelection(previousSelection) {
  149. var existingSelection = previousSelection.filter(function(element) {
  150. return elementRegistry.get(element.id);
  151. });
  152. existingSelection.length && selection.select(existingSelection);
  153. }
  154. // event listeners
  155. function move(event, activate) {
  156. var payload = context.payload,
  157. displacement = context.displacement;
  158. var globalStart = context.globalStart,
  159. globalCurrent = toPoint(event),
  160. globalDelta = deltaPos(globalCurrent, globalStart);
  161. var localStart = context.localStart,
  162. localCurrent = toLocalPoint(globalCurrent),
  163. localDelta = deltaPos(localCurrent, localStart);
  164. // activate context explicitly or once threshold is reached
  165. if (!context.active && (activate || getLength(globalDelta) > context.threshold)) {
  166. // fire start event with original
  167. // starting coordinates
  168. assign(payload, {
  169. x: round(localStart.x + displacement.x),
  170. y: round(localStart.y + displacement.y),
  171. dx: 0,
  172. dy: 0
  173. }, { originalEvent: event });
  174. if (false === fire('start')) {
  175. return cancel();
  176. }
  177. context.active = true;
  178. // unset selection and remember old selection
  179. // the previous (old) selection will always passed
  180. // with the event via the event.previousSelection property
  181. if (!context.keepSelection) {
  182. payload.previousSelection = selection.get();
  183. selection.select(null);
  184. }
  185. // allow custom cursor
  186. if (context.cursor) {
  187. cursorSet(context.cursor);
  188. }
  189. // indicate dragging via marker on root element
  190. canvas.addMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
  191. }
  192. stopPropagation(event);
  193. if (context.active) {
  194. // update payload with actual coordinates
  195. assign(payload, {
  196. x: round(localCurrent.x + displacement.x),
  197. y: round(localCurrent.y + displacement.y),
  198. dx: round(localDelta.x),
  199. dy: round(localDelta.y)
  200. }, { originalEvent: event });
  201. // emit move event
  202. fire('move');
  203. }
  204. }
  205. function end(event) {
  206. var previousContext,
  207. returnValue = true;
  208. if (context.active) {
  209. if (event) {
  210. context.payload.originalEvent = event;
  211. // suppress original event (click, ...)
  212. // because we just ended a drag operation
  213. stopPropagation(event);
  214. }
  215. // implementations may stop restoring the
  216. // original state (selections, ...) by preventing the
  217. // end events default action
  218. returnValue = fire('end');
  219. }
  220. if (returnValue === false) {
  221. fire('rejected');
  222. }
  223. previousContext = cleanup(returnValue !== true);
  224. // last event to be fired when all drag operations are done
  225. // at this point in time no drag operation is in progress anymore
  226. fire('ended', previousContext);
  227. }
  228. // cancel active drag operation if the user presses
  229. // the ESC key on the keyboard
  230. function checkCancel(event) {
  231. if (isKey('Escape', event)) {
  232. preventDefault(event);
  233. cancel();
  234. }
  235. }
  236. // prevent ghost click that might occur after a finished
  237. // drag and drop session
  238. function trapClickAndEnd(event) {
  239. var untrap;
  240. // trap the click in case we are part of an active
  241. // drag operation. This will effectively prevent
  242. // the ghost click that cannot be canceled otherwise.
  243. if (context.active) {
  244. untrap = installClickTrap(eventBus);
  245. // remove trap after minimal delay
  246. setTimeout(untrap, 400);
  247. // prevent default action (click)
  248. preventDefault(event);
  249. }
  250. end(event);
  251. }
  252. function trapTouch(event) {
  253. move(event);
  254. }
  255. // update the drag events hover (djs.model.Base) and hoverGfx (Snap<SVGElement>)
  256. // properties during hover and out and fire {prefix}.hover and {prefix}.out properties
  257. // respectively
  258. function hover(event) {
  259. var payload = context.payload;
  260. payload.hoverGfx = event.gfx;
  261. payload.hover = event.element;
  262. fire('hover');
  263. }
  264. function out(event) {
  265. fire('out');
  266. var payload = context.payload;
  267. payload.hoverGfx = null;
  268. payload.hover = null;
  269. }
  270. // life-cycle methods
  271. function cancel(restore) {
  272. var previousContext;
  273. if (!context) {
  274. return;
  275. }
  276. var wasActive = context.active;
  277. if (wasActive) {
  278. fire('cancel');
  279. }
  280. previousContext = cleanup(restore);
  281. if (wasActive) {
  282. // last event to be fired when all drag operations are done
  283. // at this point in time no drag operation is in progress anymore
  284. fire('canceled', previousContext);
  285. }
  286. }
  287. function cleanup(restore) {
  288. var previousContext,
  289. endDrag;
  290. fire('cleanup');
  291. // reset cursor
  292. cursorUnset();
  293. if (context.trapClick) {
  294. endDrag = trapClickAndEnd;
  295. } else {
  296. endDrag = end;
  297. }
  298. // reset dom listeners
  299. domEvent.unbind(document, 'mousemove', move);
  300. domEvent.unbind(document, 'dragstart', preventDefault);
  301. domEvent.unbind(document, 'selectstart', preventDefault);
  302. domEvent.unbind(document, 'mousedown', endDrag, true);
  303. domEvent.unbind(document, 'mouseup', endDrag, true);
  304. domEvent.unbind(document, 'keyup', checkCancel);
  305. domEvent.unbind(document, 'touchstart', trapTouch, true);
  306. domEvent.unbind(document, 'touchcancel', cancel, true);
  307. domEvent.unbind(document, 'touchmove', move, true);
  308. domEvent.unbind(document, 'touchend', end, true);
  309. eventBus.off('element.hover', hover);
  310. eventBus.off('element.out', out);
  311. // remove drag marker on root element
  312. canvas.removeMarker(canvas.getRootElement(), DRAG_ACTIVE_CLS);
  313. // restore selection, unless it has changed
  314. var previousSelection = context.payload.previousSelection;
  315. if (restore !== false && previousSelection && !selection.get().length) {
  316. restoreSelection(previousSelection);
  317. }
  318. previousContext = context;
  319. context = null;
  320. return previousContext;
  321. }
  322. /**
  323. * Initialize a drag operation.
  324. *
  325. * If `localPosition` is given, drag events will be emitted
  326. * relative to it.
  327. *
  328. * @param {MouseEvent|TouchEvent} [event]
  329. * @param {Point} [localPosition] actual diagram local position this drag operation should start at
  330. * @param {string} prefix
  331. * @param {Object} [options]
  332. */
  333. function init(event, relativeTo, prefix, options) {
  334. // only one drag operation may be active, at a time
  335. if (context) {
  336. cancel(false);
  337. }
  338. if (typeof relativeTo === 'string') {
  339. options = prefix;
  340. prefix = relativeTo;
  341. relativeTo = null;
  342. }
  343. options = assign({}, defaultOptions, options || {});
  344. var data = options.data || {},
  345. originalEvent,
  346. globalStart,
  347. localStart,
  348. endDrag,
  349. isTouch;
  350. if (options.trapClick) {
  351. endDrag = trapClickAndEnd;
  352. } else {
  353. endDrag = end;
  354. }
  355. if (event) {
  356. originalEvent = getOriginal(event) || event;
  357. globalStart = toPoint(event);
  358. stopPropagation(event);
  359. // prevent default browser dragging behavior
  360. if (originalEvent.type === 'dragstart') {
  361. preventDefault(originalEvent);
  362. }
  363. } else {
  364. originalEvent = null;
  365. globalStart = { x: 0, y: 0 };
  366. }
  367. localStart = toLocalPoint(globalStart);
  368. if (!relativeTo) {
  369. relativeTo = localStart;
  370. }
  371. isTouch = isTouchEvent(originalEvent);
  372. context = assign({
  373. prefix: prefix,
  374. data: data,
  375. payload: {},
  376. globalStart: globalStart,
  377. displacement: deltaPos(relativeTo, localStart),
  378. localStart: localStart,
  379. isTouch: isTouch
  380. }, options);
  381. // skip dom registration if trigger
  382. // is set to manual (during testing)
  383. if (!options.manual) {
  384. // add dom listeners
  385. if (isTouch) {
  386. domEvent.bind(document, 'touchstart', trapTouch, true);
  387. domEvent.bind(document, 'touchcancel', cancel, true);
  388. domEvent.bind(document, 'touchmove', move, true);
  389. domEvent.bind(document, 'touchend', end, true);
  390. } else {
  391. // assume we use the mouse to interact per default
  392. domEvent.bind(document, 'mousemove', move);
  393. // prevent default browser drag and text selection behavior
  394. domEvent.bind(document, 'dragstart', preventDefault);
  395. domEvent.bind(document, 'selectstart', preventDefault);
  396. domEvent.bind(document, 'mousedown', endDrag, true);
  397. domEvent.bind(document, 'mouseup', endDrag, true);
  398. }
  399. domEvent.bind(document, 'keyup', checkCancel);
  400. eventBus.on('element.hover', hover);
  401. eventBus.on('element.out', out);
  402. }
  403. fire('init');
  404. if (options.autoActivate) {
  405. move(event, true);
  406. }
  407. }
  408. // cancel on diagram destruction
  409. eventBus.on('diagram.destroy', cancel);
  410. // API
  411. this.init = init;
  412. this.move = move;
  413. this.hover = hover;
  414. this.out = out;
  415. this.end = end;
  416. this.cancel = cancel;
  417. // for introspection
  418. this.context = function() {
  419. return context;
  420. };
  421. this.setOptions = function(options) {
  422. assign(defaultOptions, options);
  423. };
  424. }
  425. Dragging.$inject = [
  426. 'eventBus',
  427. 'canvas',
  428. 'selection',
  429. 'elementRegistry'
  430. ];