lib/amd/src/local/reactive/dragdrop.js

  1. // This file is part of Moodle - http://moodle.org/
  2. //
  3. // Moodle is free software: you can redistribute it and/or modify
  4. // it under the terms of the GNU General Public License as published by
  5. // the Free Software Foundation, either version 3 of the License, or
  6. // (at your option) any later version.
  7. //
  8. // Moodle is distributed in the hope that it will be useful,
  9. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. // GNU General Public License for more details.
  12. //
  13. // You should have received a copy of the GNU General Public License
  14. // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
  15. /**
  16. * Drag and drop helper component.
  17. *
  18. * This component is used to delegate drag and drop handling.
  19. *
  20. * To delegate the logic to this particular element the component should create a new instance
  21. * passing "this" as param. The component will use all the necessary callbacks and add all the
  22. * necessary listeners to the component element.
  23. *
  24. * Component attributes used by dragdrop module:
  25. * - element: the draggable or dropzone element.
  26. * - (optional) classes: object with alternative CSS classes
  27. * - (optional) fullregion: page element affeted by the elementy dragging. Use this attribute if
  28. * the draggable element affects a bigger region (for example a draggable
  29. * title).
  30. * - (optional) autoconfigDraggable: by default, the component will be draggable if it has a
  31. * getDraggableData method. If this value is false draggable
  32. * property must be defined using setDraggable method.
  33. * - (optional) relativeDrag: by default the drag image is located at point (0,0) relative to the
  34. * mouse position to prevent the mouse from covering it. If this attribute
  35. * is true the drag image will be located at the click offset.
  36. *
  37. * Methods the parent component should have for making it draggable:
  38. *
  39. * - getDraggableData(): Object|data
  40. * Return the data that will be passed to any valid dropzone while it is dragged.
  41. * If the component has this method, the dragdrop module will enable the dragging,
  42. * this is the only required method for dragging.
  43. * If at the dragging moment this method returns a false|null|undefined, the dragging
  44. * actions won't be captured.
  45. *
  46. * - (optional) dragStart(Object dropdata, Event event): void
  47. * - (optional) dragEnd(Object dropdata, Event event): void
  48. * Callbacks dragdrop will call when the element is dragged and getDraggableData
  49. * return some data.
  50. *
  51. * Methods the parent component should have for enabling it as a dropzone:
  52. *
  53. * - validateDropData(Object dropdata): boolean
  54. * If that method exists, the dragdrop module will automathically configure the element as dropzone.
  55. * This method will return true if the dropdata is accepted. In case it returns false, no drag and
  56. * drop event will be listened for this specific dragged dropdata.
  57. *
  58. * - (Optional) showDropZone(Object dropdata, Event event): void
  59. * - (Optional) hideDropZone(Object dropdata, Event event): void
  60. * Methods called when a valid dragged data pass over the element.
  61. *
  62. * - (Optional) drop(Object dropdata, Event event): void
  63. * Called when a valid dragged element is dropped over the element.
  64. *
  65. * Note that none of this methods will be called if validateDropData
  66. * returns a false value.
  67. *
  68. * This module will also add or remove several CSS classes from both dragged elements and dropzones.
  69. * See the "this.classes" in the create method for more details. In case the parent component wants
  70. * to use the same classes, it can use the getClasses method. On the other hand, if the parent
  71. * component has an alternative "classes" attribute, this will override the default drag and drop
  72. * classes.
  73. *
  74. * @module core/local/reactive/dragdrop
  75. * @class core/local/reactive/dragdrop
  76. * @copyright 2021 Ferran Recio <ferran@moodle.com>
  77. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  78. */
  79. import BaseComponent from 'core/local/reactive/basecomponent';
  80. // Map with the dragged element generate by an specific reactive applications.
  81. // Potentially, any component can generate a draggable element to interact with other
  82. // page elements. However, the dragged data is specific and could only interact with
  83. // components of the same reactive instance.
  84. let activeDropData = new Map();
  85. // Drag & Drop API provides the final drop point and incremental movements but we can
  86. // provide also starting points and displacements. Absolute displacements simplifies
  87. // moving components with aboslute position around the page.
  88. let dragStartPoint = {};
  89. export default class extends BaseComponent {
  90. /**
  91. * Constructor hook.
  92. *
  93. * @param {BaseComponent} parent the parent component.
  94. */
  95. create(parent) {
  96. // Optional component name for debugging.
  97. this.name = `${parent.name ?? 'unkown'}_dragdrop`;
  98. // Default drag and drop classes.
  99. this.classes = Object.assign(
  100. {
  101. // This class indicate a dragging action is active at a page level.
  102. BODYDRAGGING: 'dragging',
  103. // Added when draggable and drop are ready.
  104. DRAGGABLEREADY: 'draggable',
  105. DROPREADY: 'dropready',
  106. // When a valid drag element is over the element.
  107. DRAGOVER: 'dragover',
  108. // When a the component is dragged.
  109. DRAGGING: 'dragging',
  110. // Dropzones classes names.
  111. DROPUP: 'drop-up',
  112. DROPDOWN: 'drop-down',
  113. DROPZONE: 'drop-zone',
  114. // Drag icon class.
  115. DRAGICON: 'dragicon',
  116. },
  117. parent?.classes ?? {}
  118. );
  119. // Add the affected region if any.
  120. this.fullregion = parent.fullregion;
  121. // Keep parent to execute drap and drop handlers.
  122. this.parent = parent;
  123. // Check if parent handle draggable manually.
  124. this.autoconfigDraggable = this.parent.draggable ?? true;
  125. // Drag image relative position.
  126. this.relativeDrag = this.parent.relativeDrag ?? false;
  127. // Sub HTML elements will trigger extra dragEnter and dragOver all the time.
  128. // To prevent that from affecting dropzones, we need to count the enters and leaves.
  129. this.entercount = 0;
  130. // Stores if the droparea is shown or not.
  131. this.dropzonevisible = false;
  132. // Stores if the mouse is over the element or not.
  133. this.ismouseover = false;
  134. }
  135. /**
  136. * Return the component drag and drop CSS classes.
  137. *
  138. * @returns {Object} the dragdrop css classes
  139. */
  140. getClasses() {
  141. return this.classes;
  142. }
  143. /**
  144. * Return the current drop-zone visible of the element.
  145. *
  146. * @returns {boolean} if the dropzone should be visible or not
  147. */
  148. isDropzoneVisible() {
  149. return this.dropzonevisible;
  150. }
  151. /**
  152. * Initial state ready method.
  153. *
  154. * This method will add all the necessary event listeners to the component depending on the
  155. * parent methods.
  156. * - Add drop events to the element if the parent component has validateDropData method.
  157. * - Configure the elements draggable if the parent component has getDraggableData method.
  158. */
  159. stateReady() {
  160. // Add drop events to the element if the parent component has dropable types.
  161. if (typeof this.parent.validateDropData === 'function') {
  162. this.element.classList.add(this.classes.DROPREADY);
  163. this.addEventListener(this.element, 'dragenter', this._dragEnter);
  164. this.addEventListener(this.element, 'dragleave', this._dragLeave);
  165. this.addEventListener(this.element, 'dragover', this._dragOver);
  166. this.addEventListener(this.element, 'drop', this._drop);
  167. this.addEventListener(this.element, 'mouseover', this._mouseOver);
  168. this.addEventListener(this.element, 'mouseleave', this._mouseLeave);
  169. }
  170. // Configure the elements draggable if the parent component has dragable data.
  171. if (this.autoconfigDraggable && typeof this.parent.getDraggableData === 'function') {
  172. this.setDraggable(true);
  173. }
  174. }
  175. /**
  176. * Enable or disable the draggable property.
  177. *
  178. * @param {bool} value the new draggable value
  179. */
  180. setDraggable(value) {
  181. if (typeof this.parent.getDraggableData !== 'function') {
  182. throw new Error(`Draggable components must have a getDraggableData method`);
  183. }
  184. this.element.setAttribute('draggable', value);
  185. if (value) {
  186. this.addEventListener(this.element, 'dragstart', this._dragStart);
  187. this.addEventListener(this.element, 'dragend', this._dragEnd);
  188. this.element.classList.add(this.classes.DRAGGABLEREADY);
  189. } else {
  190. this.removeEventListener(this.element, 'dragstart', this._dragStart);
  191. this.removeEventListener(this.element, 'dragend', this._dragEnd);
  192. this.element.classList.remove(this.classes.DRAGGABLEREADY);
  193. }
  194. }
  195. /**
  196. * Mouse over handle.
  197. */
  198. _mouseOver() {
  199. this.ismouseover = true;
  200. }
  201. /**
  202. * Mouse leave handler.
  203. */
  204. _mouseLeave() {
  205. this.ismouseover = false;
  206. }
  207. /**
  208. * Drag start event handler.
  209. *
  210. * This method will generate the current dropable data. This data is the one used to determine
  211. * if a droparea accepts the dropping or not.
  212. *
  213. * @param {Event} event the event.
  214. */
  215. _dragStart(event) {
  216. // Cancel dragging if any editable form element is focussed.
  217. if (document.activeElement.matches(`textarea, input`)) {
  218. event.preventDefault();
  219. return;
  220. }
  221. const dropdata = this.parent.getDraggableData();
  222. if (!dropdata) {
  223. return;
  224. }
  225. // Save the starting point.
  226. dragStartPoint = {
  227. pageX: event.pageX,
  228. pageY: event.pageY,
  229. };
  230. // If the drag event is accepted we prevent any other draggable element from interfiering.
  231. event.stopPropagation();
  232. // Save the drop data of the current reactive intance.
  233. activeDropData.set(this.reactive, dropdata);
  234. // Add some CSS classes to indicate the state.
  235. document.body.classList.add(this.classes.BODYDRAGGING);
  236. this.element.classList.add(this.classes.DRAGGING);
  237. this.fullregion?.classList.add(this.classes.DRAGGING);
  238. // Force the drag image. This makes the UX more consistent in case the
  239. // user dragged an internal element like a link or some other element.
  240. let dragImage = this.element;
  241. if (this.parent.setDragImage !== undefined) {
  242. const customImage = this.parent.setDragImage(dropdata, event);
  243. if (customImage) {
  244. dragImage = customImage;
  245. }
  246. }
  247. // Define the image position relative to the mouse.
  248. const position = {x: 0, y: 0};
  249. if (this.relativeDrag) {
  250. position.x = event.offsetX;
  251. position.y = event.offsetY;
  252. }
  253. event.dataTransfer.setDragImage(dragImage, position.x, position.y);
  254. event.dataTransfer.effectAllowed = 'copyMove';
  255. this._callParentMethod('dragStart', dropdata, event);
  256. }
  257. /**
  258. * Drag end event handler.
  259. *
  260. * @param {Event} event the event.
  261. */
  262. _dragEnd(event) {
  263. const dropdata = activeDropData.get(this.reactive);
  264. if (!dropdata) {
  265. return;
  266. }
  267. // Remove the current dropdata.
  268. activeDropData.delete(this.reactive);
  269. // Remove the dragging classes.
  270. document.body.classList.remove(this.classes.BODYDRAGGING);
  271. this.element.classList.remove(this.classes.DRAGGING);
  272. this.fullregion?.classList.remove(this.classes.DRAGGING);
  273. this.removeAllOverlays();
  274. // We add the total movement to the event in case the component
  275. // wants to move its absolute position.
  276. this._addEventTotalMovement(event);
  277. this._callParentMethod('dragEnd', dropdata, event);
  278. }
  279. /**
  280. * Drag enter event handler.
  281. *
  282. * The JS drag&drop API triggers several dragenter events on the same element because it bubbles the
  283. * child events as well. To prevent this form affecting the dropzones display, this methods use
  284. * "entercount" to determine if it's one extra child event or a valid one.
  285. *
  286. * @param {Event} event the event.
  287. */
  288. _dragEnter(event) {
  289. const dropdata = this._processEvent(event);
  290. if (dropdata) {
  291. this.entercount++;
  292. this.element.classList.add(this.classes.DRAGOVER);
  293. if (this.entercount == 1 && !this.dropzonevisible) {
  294. this.dropzonevisible = true;
  295. this.element.classList.add(this.classes.DRAGOVER);
  296. this._callParentMethod('showDropZone', dropdata, event);
  297. }
  298. }
  299. }
  300. /**
  301. * Drag over event handler.
  302. *
  303. * We only use dragover event when a draggable action starts inside a valid dropzone. In those cases
  304. * the API won't trigger any dragEnter because the dragged alement was already there. We use the
  305. * dropzonevisible to determine if the component needs to display the dropzones or not.
  306. *
  307. * @param {Event} event the event.
  308. */
  309. _dragOver(event) {
  310. const dropdata = this._processEvent(event);
  311. if (dropdata && !this.dropzonevisible) {
  312. event.dataTransfer.dropEffect = (event.altKey) ? 'copy' : 'move';
  313. this.dropzonevisible = true;
  314. this.element.classList.add(this.classes.DRAGOVER);
  315. this._callParentMethod('showDropZone', dropdata, event);
  316. }
  317. }
  318. /**
  319. * Drag over leave handler.
  320. *
  321. * The JS drag&drop API triggers several dragleave events on the same element because it bubbles the
  322. * child events as well. To prevent this form affecting the dropzones display, this methods use
  323. * "entercount" to determine if it's one extra child event or a valid one.
  324. *
  325. * @param {Event} event the event.
  326. */
  327. _dragLeave(event) {
  328. const dropdata = this._processEvent(event);
  329. if (dropdata) {
  330. this.entercount--;
  331. if (this.entercount <= 0 && this.dropzonevisible) {
  332. this.dropzonevisible = false;
  333. this.element.classList.remove(this.classes.DRAGOVER);
  334. this._callParentMethod('hideDropZone', dropdata, event);
  335. }
  336. }
  337. }
  338. /**
  339. * Drop event handler.
  340. *
  341. * This method will call both hideDropZones and drop methods on the parent component.
  342. *
  343. * @param {Event} event the event.
  344. */
  345. _drop(event) {
  346. const dropdata = this._processEvent(event);
  347. if (dropdata) {
  348. this.entercount = 0;
  349. if (this.dropzonevisible) {
  350. this.dropzonevisible = false;
  351. this._callParentMethod('hideDropZone', dropdata, event);
  352. }
  353. this.element.classList.remove(this.classes.DRAGOVER);
  354. this.removeAllOverlays();
  355. this._callParentMethod('drop', dropdata, event);
  356. // An accepted drop resets the initial position.
  357. // Save the starting point.
  358. dragStartPoint = {};
  359. }
  360. }
  361. /**
  362. * Process a drag and drop event and delegate logic to the parent component.
  363. *
  364. * @param {Event} event the drag and drop event
  365. * @return {Object|false} the dropdata or null if the event should not be processed
  366. */
  367. _processEvent(event) {
  368. const dropdata = this._getDropData(event);
  369. if (!dropdata) {
  370. return null;
  371. }
  372. if (this.parent.validateDropData(dropdata)) {
  373. // All accepted drag&drop event must prevent bubbling and defaults, otherwise
  374. // parent dragdrop instances could capture it by mistake.
  375. event.preventDefault();
  376. event.stopPropagation();
  377. this._addEventTotalMovement(event);
  378. return dropdata;
  379. }
  380. return null;
  381. }
  382. /**
  383. * Add the total amout of movement to a mouse event.
  384. *
  385. * @param {MouseEvent} event
  386. */
  387. _addEventTotalMovement(event) {
  388. if (dragStartPoint.pageX === undefined || event.pageX === undefined) {
  389. return;
  390. }
  391. event.fixedMovementX = event.pageX - dragStartPoint.pageX;
  392. event.fixedMovementY = event.pageY - dragStartPoint.pageY;
  393. event.initialPageX = dragStartPoint.pageX;
  394. event.initialPageY = dragStartPoint.pageY;
  395. // The element possible new top.
  396. const current = this.element.getBoundingClientRect();
  397. // Add the new position fixed position.
  398. event.newFixedTop = current.top + event.fixedMovementY;
  399. event.newFixedLeft = current.left + event.fixedMovementX;
  400. // The affected region possible new top.
  401. if (this.fullregion !== undefined) {
  402. const current = this.fullregion.getBoundingClientRect();
  403. event.newRegionFixedxTop = current.top + event.fixedMovementY;
  404. event.newRegionFixedxLeft = current.left + event.fixedMovementX;
  405. }
  406. }
  407. /**
  408. * Convenient method for calling parent component functions if present.
  409. *
  410. * @param {string} methodname the name of the method
  411. * @param {Object} dropdata the current drop data object
  412. * @param {Event} event the original event
  413. */
  414. _callParentMethod(methodname, dropdata, event) {
  415. if (typeof this.parent[methodname] === 'function') {
  416. this.parent[methodname](dropdata, event);
  417. }
  418. }
  419. /**
  420. * Get the current dropdata for a specific event.
  421. *
  422. * The browser can generate drag&drop events related to several user interactions:
  423. * - Drag a page elements: this case is registered in the activeDropData map
  424. * - Drag some HTML selections: ignored for now
  425. * - Drag a file over the browser: file drag may appear in the future but for now they are ignored.
  426. *
  427. * @param {Event} event the original event.
  428. * @returns {Object|undefined} with the dragged data (or undefined if none)
  429. */
  430. _getDropData(event) {
  431. this._isOnlyFilesDragging = this._containsOnlyFiles(event);
  432. if (this._isOnlyFilesDragging) {
  433. // Check if the reactive instance can provide a files draggable data.
  434. if (this.reactive.getFilesDraggableData !== undefined && typeof this.reactive.getFilesDraggableData === 'function') {
  435. return this.reactive.getFilesDraggableData(event.dataTransfer);
  436. }
  437. return undefined;
  438. }
  439. return activeDropData.get(this.reactive);
  440. }
  441. /**
  442. * Check if the dragged event contains only files.
  443. *
  444. * Files dragging does not generate drop data because they came from outside the page and the component
  445. * must check it before validating the event.
  446. *
  447. * Some browsers like Firefox add extra types to file dragging. To discard the false positives
  448. * a double check is necessary.
  449. *
  450. * @param {Event} event the original event.
  451. * @returns {boolean} if the drag dataTransfers contains files.
  452. */
  453. _containsOnlyFiles(event) {
  454. if (!event.dataTransfer.types.includes('Files')) {
  455. return false;
  456. }
  457. return event.dataTransfer.types.every((type) => {
  458. return (type.toLowerCase() != 'text/uri-list'
  459. && type.toLowerCase() != 'text/html'
  460. && type.toLowerCase() != 'text/plain'
  461. );
  462. });
  463. }
  464. }