Source: view/viewScreens/ClickDrag.js

  1. /** @module */
  2. import { minIndex } from '@paretoman/votekit-utilities'
  3. /**
  4. * ClickDrag gives draggable behavior to objects on a canvas.
  5. * If anything changes, an item is added to the "changes" array.
  6. * Calling screen.setEventHandlers(clickDrag.eventHandlers) sets the eventhandlers on the screen.
  7. * @param {Screen} screen
  8. * @param {Changes} changes
  9. * @constructor
  10. */
  11. export default function ClickDrag(dragm, viewEntities, screen, changes, viewSettings) {
  12. const self = this
  13. // private variables
  14. let drag = {}
  15. const draggables = dragm.list
  16. const grabCanvas = screen.tooltips
  17. // Mouse Listeners
  18. // As a sidenote, it is interesting that we don't need to call model.update here
  19. // because we are using a game loop that will call model.update.
  20. const start = function (event) {
  21. // don't interact with stuff underneath a tooltip
  22. if (event.target.closest('.tooltipBox') !== null) {
  23. return
  24. }
  25. const mouse = getMouse(event)
  26. const extra = (event.isTouch) ? 10 : 0
  27. const nd = draggables.length
  28. // We are in the hitboxes of these draggables.
  29. const hitList = []
  30. for (let i = 0; i < nd; i++) {
  31. const d = draggables[i]
  32. if ((d.o.exists || viewSettings.showGhosts) && hitTest(d, mouse, extra)) {
  33. hitList.push(i)
  34. }
  35. }
  36. if (hitList.length > 0) {
  37. const distances2 = hitList.map((i) => {
  38. const d = draggables[i]
  39. const offX = d.r.x - mouse.x
  40. const offY = d.r.y - mouse.y
  41. return offX ** 2 + offY ** 2
  42. })
  43. // pick up
  44. const iHitListClosest = minIndex(distances2)
  45. const iDraggableClosest = hitList[iHitListClosest]
  46. const d = draggables[iDraggableClosest]
  47. drag.iDragging = iDraggableClosest
  48. drag.isDragging = true
  49. drag.offX = d.r.x - mouse.x
  50. drag.offY = d.r.y - mouse.y
  51. d.g.pickUp()
  52. grabCanvas.dataset.cursor = 'grabbing' // CSS data attribute
  53. }
  54. startClickDetect(mouse)
  55. }
  56. const move = function (event) {
  57. const mouse = getMouse(event)
  58. if (drag.isDragging) { // because the mouse is moving
  59. if (event.isTouch) {
  60. event.preventDefault()
  61. event.stopPropagation()
  62. }
  63. const dragging = draggables[drag.iDragging]
  64. const w = screen.width
  65. const h = screen.height
  66. dragging.r.setXYView({
  67. x: clamp(mouse.x + drag.offX, 0, w),
  68. y: clamp(mouse.y + drag.offY, 0, h),
  69. })
  70. changes.add(['draggables'])
  71. } else {
  72. // see if we're hovering over something grabbable
  73. // because we want the user to see if they can grab something
  74. const nd = draggables.length
  75. for (let i = 0; i < nd; i++) {
  76. const d = draggables[i]
  77. if ((viewSettings.showGhosts || d.o.exists) && hitTest(d, mouse, 0)) {
  78. grabCanvas.dataset.cursor = 'grab'
  79. return
  80. }
  81. }
  82. grabCanvas.dataset.cursor = '' // nothing to grab
  83. }
  84. moveClickDetect(mouse)
  85. }
  86. const end = function () {
  87. endClickDetect()
  88. if (drag.iDragging !== undefined) {
  89. const dragging = draggables[drag.iDragging]
  90. dragging.g.drop()
  91. }
  92. drag = {}
  93. }
  94. // Touch Listeners
  95. const touchmove = (e) => {
  96. const pass = passTouch(e)
  97. move(pass)
  98. }
  99. const touchstart = (e) => {
  100. const pass = passTouch(e)
  101. start(pass)
  102. }
  103. const touchend = (e) => {
  104. const pass = passTouch(e)
  105. move(pass)
  106. end(pass)
  107. // prevent mousedown from firing unless we're on a tooltip
  108. if (e.target.closest('.tooltipBox') === null) {
  109. e.preventDefault()
  110. }
  111. }
  112. self.eventHandlers = {
  113. start, move, end, touchmove, touchstart, touchend,
  114. }
  115. /**
  116. * Make a touch event look like a mouse event, with a flag.
  117. * @param {Event} e - The event from the DOM
  118. * @returns {Event} - The same event it received, plus some added properties.
  119. */
  120. function passTouch(e) {
  121. e.isTouch = true
  122. return e
  123. }
  124. /** Fix position relative to parent
  125. * https://stackoverflow.com/questions/2614461/javascript-get-mouse-position-relative-to-parent-element
  126. */
  127. function getMouse(e) {
  128. const rect = screen.wrap.getBoundingClientRect()
  129. const c = (e.isTouch) ? e.changedTouches[0] : e
  130. const x = c.clientX - rect.left
  131. const y = c.clientY - rect.top
  132. const mouse = { x, y }
  133. return mouse
  134. }
  135. /**
  136. * Check whether m, e.g. a mouse, hits d, a draggable object.
  137. * @param {Object} d - An entry in the draggables array.
  138. * @param {Object} m - An object with properties x and y, e.g. a mouse.
  139. * @param {Number} extra - Extra slack to catch touches outside of the hitbox.
  140. * @returns {Boolean} - Whether m hits d.
  141. */
  142. function hitTest(d, m, extra) {
  143. // Only drag an object if we're near it.
  144. const x = d.r.x - m.x
  145. const y = d.r.y - m.y
  146. if (d.p.isCircle) {
  147. const { r } = d.g
  148. const hit = x * x + y * y < (r + extra) * (r + extra)
  149. return hit
  150. } if (d.p.isSquare) {
  151. const { w, h } = d.g
  152. const hit = Math.abs(x) < 0.5 * w + extra && Math.abs(y) < 0.5 * h + extra
  153. return hit
  154. }
  155. return false
  156. }
  157. // click detection //
  158. let couldBeClick
  159. let startPos
  160. function startClickDetect(mouse) {
  161. couldBeClick = true
  162. startPos = { ...mouse }
  163. }
  164. function moveClickDetect(mouse) {
  165. if (couldBeClick) {
  166. const xDist = Math.abs(startPos.x - mouse.x)
  167. const yDist = Math.abs(startPos.y - mouse.y)
  168. if (xDist > 5) couldBeClick = false
  169. if (yDist > 5) couldBeClick = false
  170. }
  171. }
  172. function endClickDetect() {
  173. if (couldBeClick) {
  174. couldBeClick = false
  175. if (drag.isDragging) { // because the mouse is moving
  176. const dragging = draggables[drag.iDragging]
  177. dragging.r.click()
  178. } else {
  179. // We are not dragging anything, and we clicked,
  180. // and we're inside the screen because this could be a click,
  181. // so let's do the click action for blank space.
  182. viewEntities.clickEmpty(startPos)
  183. }
  184. }
  185. }
  186. }
  187. /** https://stackoverflow.com/a/24719569 */
  188. function clamp(value, min, max) {
  189. if (value < min) return min
  190. if (value > max) return max
  191. return value
  192. }