grab_to_pan.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /* Copyright 2013 Rob Wu <rob@robwu.nl>
  2. * https://github.com/Rob--W/grab-to-pan.js
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. // Class name of element which can be grabbed.
  17. const CSS_CLASS_GRAB = "grab-to-pan-grab";
  18. class GrabToPan {
  19. /**
  20. * Construct a GrabToPan instance for a given HTML element.
  21. * @param {Element} options.element
  22. * @param {function} [options.ignoreTarget] - See `ignoreTarget(node)`.
  23. * @param {function(boolean)} [options.onActiveChanged] - Called when
  24. * grab-to-pan is (de)activated. The first argument is a boolean that
  25. * shows whether grab-to-pan is activated.
  26. */
  27. constructor(options) {
  28. this.element = options.element;
  29. this.document = options.element.ownerDocument;
  30. if (typeof options.ignoreTarget === "function") {
  31. this.ignoreTarget = options.ignoreTarget;
  32. }
  33. this.onActiveChanged = options.onActiveChanged;
  34. // Bind the contexts to ensure that `this` always points to
  35. // the GrabToPan instance.
  36. this.activate = this.activate.bind(this);
  37. this.deactivate = this.deactivate.bind(this);
  38. this.toggle = this.toggle.bind(this);
  39. this._onMouseDown = this.#onMouseDown.bind(this);
  40. this._onMouseMove = this.#onMouseMove.bind(this);
  41. this._endPan = this.#endPan.bind(this);
  42. // This overlay will be inserted in the document when the mouse moves during
  43. // a grab operation, to ensure that the cursor has the desired appearance.
  44. const overlay = (this.overlay = document.createElement("div"));
  45. overlay.className = "grab-to-pan-grabbing";
  46. }
  47. /**
  48. * Bind a mousedown event to the element to enable grab-detection.
  49. */
  50. activate() {
  51. if (!this.active) {
  52. this.active = true;
  53. this.element.addEventListener("mousedown", this._onMouseDown, true);
  54. this.element.classList.add(CSS_CLASS_GRAB);
  55. this.onActiveChanged?.(true);
  56. }
  57. }
  58. /**
  59. * Removes all events. Any pending pan session is immediately stopped.
  60. */
  61. deactivate() {
  62. if (this.active) {
  63. this.active = false;
  64. this.element.removeEventListener("mousedown", this._onMouseDown, true);
  65. this._endPan();
  66. this.element.classList.remove(CSS_CLASS_GRAB);
  67. this.onActiveChanged?.(false);
  68. }
  69. }
  70. toggle() {
  71. if (this.active) {
  72. this.deactivate();
  73. } else {
  74. this.activate();
  75. }
  76. }
  77. /**
  78. * Whether to not pan if the target element is clicked.
  79. * Override this method to change the default behaviour.
  80. *
  81. * @param {Element} node - The target of the event.
  82. * @returns {boolean} Whether to not react to the click event.
  83. */
  84. ignoreTarget(node) {
  85. // Check whether the clicked element is, a child of, an input element/link.
  86. return node.matches(
  87. "a[href], a[href] *, input, textarea, button, button *, select, option"
  88. );
  89. }
  90. #onMouseDown(event) {
  91. if (event.button !== 0 || this.ignoreTarget(event.target)) {
  92. return;
  93. }
  94. if (event.originalTarget) {
  95. try {
  96. // eslint-disable-next-line no-unused-expressions
  97. event.originalTarget.tagName;
  98. } catch (e) {
  99. // Mozilla-specific: element is a scrollbar (XUL element)
  100. return;
  101. }
  102. }
  103. this.scrollLeftStart = this.element.scrollLeft;
  104. this.scrollTopStart = this.element.scrollTop;
  105. this.clientXStart = event.clientX;
  106. this.clientYStart = event.clientY;
  107. this.document.addEventListener("mousemove", this._onMouseMove, true);
  108. this.document.addEventListener("mouseup", this._endPan, true);
  109. // When a scroll event occurs before a mousemove, assume that the user
  110. // dragged a scrollbar (necessary for Opera Presto, Safari and IE)
  111. // (not needed for Chrome/Firefox)
  112. this.element.addEventListener("scroll", this._endPan, true);
  113. event.preventDefault();
  114. event.stopPropagation();
  115. const focusedElement = document.activeElement;
  116. if (focusedElement && !focusedElement.contains(event.target)) {
  117. focusedElement.blur();
  118. }
  119. }
  120. #onMouseMove(event) {
  121. this.element.removeEventListener("scroll", this._endPan, true);
  122. if (!(event.buttons & 1)) {
  123. // The left mouse button is released.
  124. this._endPan();
  125. return;
  126. }
  127. const xDiff = event.clientX - this.clientXStart;
  128. const yDiff = event.clientY - this.clientYStart;
  129. const scrollTop = this.scrollTopStart - yDiff;
  130. const scrollLeft = this.scrollLeftStart - xDiff;
  131. if (this.element.scrollTo) {
  132. this.element.scrollTo({
  133. top: scrollTop,
  134. left: scrollLeft,
  135. behavior: "instant",
  136. });
  137. } else {
  138. this.element.scrollTop = scrollTop;
  139. this.element.scrollLeft = scrollLeft;
  140. }
  141. if (!this.overlay.parentNode) {
  142. document.body.append(this.overlay);
  143. }
  144. }
  145. #endPan() {
  146. this.element.removeEventListener("scroll", this._endPan, true);
  147. this.document.removeEventListener("mousemove", this._onMouseMove, true);
  148. this.document.removeEventListener("mouseup", this._endPan, true);
  149. // Note: ChildNode.remove doesn't throw if the parentNode is undefined.
  150. this.overlay.remove();
  151. }
  152. }
  153. export { GrabToPan };