WebDisplayInteractionManager.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { AbstractDisplayInteractionManager } from "./AbstractDisplayInteractionManager";
  2. import { PointF2D } from "../Common/DataObjects/PointF2D";
  3. import { Dictionary } from "typescript-collections";
  4. export class WebDisplayInteractionManager extends AbstractDisplayInteractionManager {
  5. protected osmdSheetMusicContainer: HTMLElement;
  6. protected fullOffsetLeft: number = 0;
  7. protected fullOffsetTop: number = 0;
  8. protected fullScrollTop: number = 0;
  9. protected fullScrollLeft: number = 0;
  10. //Using map instead of collections dictionary because map supports using objects as keys properly
  11. protected parentScrollMap: Map<HTMLElement, number[]> = new Map<HTMLElement, number[]>();
  12. protected scrollCallbackMap: Map<HTMLElement, (this: HTMLElement, ev: Event) => any> =
  13. new Map<HTMLElement, (this: HTMLElement, ev: Event) => any>();
  14. constructor(osmdContainer: HTMLElement) {
  15. super();
  16. this.osmdSheetMusicContainer = osmdContainer;
  17. this.listenForInteractions();
  18. }
  19. public get FullOffsetTop(): number {
  20. return this.fullOffsetTop;
  21. }
  22. public get FullScrollTop(): number {
  23. return this.fullScrollTop;
  24. }
  25. public get FullOffsetLeft(): number {
  26. return this.fullOffsetLeft;
  27. }
  28. public get FullScrollLeft(): number {
  29. return this.fullScrollLeft;
  30. }
  31. protected timeout: NodeJS.Timeout = undefined;
  32. protected static resizeCallback(entries: ResizeObserverEntry[]|HTMLElement[], self: WebDisplayInteractionManager): void {
  33. //debounce resize callback
  34. clearTimeout(self.timeout);
  35. self.timeout = setTimeout(()=> {
  36. self.fullOffsetLeft = 0;
  37. self.fullOffsetTop = 0;
  38. let nextOffsetParent: HTMLElement = self.osmdSheetMusicContainer;
  39. while (nextOffsetParent) {
  40. self.fullOffsetLeft += nextOffsetParent.offsetLeft;
  41. self.fullOffsetTop += nextOffsetParent.offsetTop;
  42. nextOffsetParent = nextOffsetParent.offsetParent as HTMLElement;
  43. }
  44. self.resizeEventListener();
  45. self.deregisterScrollOffsets();
  46. self.registerScrollOffsets();
  47. }, 500);
  48. }
  49. protected registerScrollOffsets(): void {
  50. let nextScrollParent: HTMLElement = this.osmdSheetMusicContainer;
  51. this.fullScrollTop = 0;
  52. this.fullScrollLeft = 0;
  53. const self: WebDisplayInteractionManager = this;
  54. while(nextScrollParent && nextScrollParent !== document.documentElement){
  55. this.parentScrollMap.set(nextScrollParent, [nextScrollParent.scrollTop, nextScrollParent.scrollLeft]);
  56. this.fullScrollLeft += nextScrollParent.scrollLeft;
  57. this.fullScrollTop += nextScrollParent.scrollTop;
  58. if(nextScrollParent.scrollHeight > nextScrollParent.clientHeight){
  59. const nextScrollCallback: (this: HTMLElement, ev: Event) => any = function(scrollEvent: Event): void{
  60. //@ts-ignore
  61. const currentScroll: number[] = self.parentScrollMap.get(this);
  62. const currentScrollTop: number = currentScroll[0];
  63. const currentScrollLeft: number = currentScroll[1];
  64. //@ts-ignore
  65. self.fullScrollTop = self.fullScrollTop - currentScrollTop + this.scrollTop;
  66. //@ts-ignore
  67. self.fullScrollLeft = self.fullScrollLeft - currentScrollLeft + this.scrollLeft;
  68. //@ts-ignore
  69. self.parentScrollMap.set(this, [this.scrollTop, this.scrollLeft]);
  70. };
  71. this.scrollCallbackMap.set(nextScrollParent, nextScrollCallback);
  72. nextScrollParent.addEventListener("scroll", nextScrollCallback);
  73. }
  74. nextScrollParent = nextScrollParent.parentElement;
  75. }
  76. }
  77. protected deregisterScrollOffsets(): void {
  78. for(const key of this.scrollCallbackMap.keys()){
  79. key.removeEventListener("scroll", this.scrollCallbackMap.get(key));
  80. }
  81. this.scrollCallbackMap.clear();
  82. }
  83. protected disposeResizeListener: Function;
  84. protected resizeObserver: ResizeObserver = undefined;
  85. protected initialize(): void {
  86. this.fullOffsetLeft = 0;
  87. this.fullOffsetTop = 0;
  88. let nextOffsetParent: HTMLElement = this.osmdSheetMusicContainer;
  89. const entries: HTMLElement[] = [];
  90. const self: WebDisplayInteractionManager = this;
  91. if(window.ResizeObserver){ // if(ResizeObserver) throws if ResizeObserver not found (browserless)
  92. this.resizeObserver = new ResizeObserver((observedElements: ResizeObserverEntry[]) => {
  93. WebDisplayInteractionManager.resizeCallback(observedElements, self);
  94. });
  95. }
  96. while (nextOffsetParent) {
  97. this.fullOffsetLeft += nextOffsetParent.offsetLeft;
  98. this.fullOffsetTop += nextOffsetParent.offsetTop;
  99. if(!this.resizeObserver){
  100. entries.push(nextOffsetParent);
  101. } else {
  102. this.resizeObserver.observe(nextOffsetParent);
  103. }
  104. nextOffsetParent = nextOffsetParent.offsetParent as HTMLElement;
  105. }
  106. if(!this.resizeObserver){
  107. let resizeListener: (this: Window, ev: UIEvent) => any = (): void => {
  108. WebDisplayInteractionManager.resizeCallback(entries, self);
  109. };
  110. //Resize observer not avail. on this browser, default to window event
  111. window.addEventListener("resize", resizeListener);
  112. this.disposeResizeListener = (): void => {
  113. window.removeEventListener("resize", resizeListener);
  114. resizeListener = undefined;
  115. };
  116. } else {
  117. this.disposeResizeListener = (): void => {
  118. self.resizeObserver.disconnect();
  119. self.resizeObserver = undefined;
  120. };
  121. }
  122. self.registerScrollOffsets();
  123. }
  124. protected dispose(): void {
  125. this.disposeResizeListener();
  126. for(const eventName of this.EventCallbackMap.keys()){
  127. const result: [HTMLElement|Document, EventListener] = this.EventCallbackMap.getValue(eventName);
  128. result[0].removeEventListener(eventName, result[1]);
  129. }
  130. this.EventCallbackMap.clear();
  131. this.deregisterScrollOffsets();
  132. this.scrollCallbackMap.clear();
  133. this.parentScrollMap.clear();
  134. }
  135. //TODO: Much of this pulled from annotations code. Once we get the two branches together, combine common code
  136. private isTouch(): boolean {
  137. if (("ontouchstart" in window) || (window as any).DocumentTouch) {
  138. return true;
  139. }
  140. if (!window.matchMedia) {
  141. return false; // if running browserless / in nodejs (generateImages / visual regression tests)
  142. }
  143. // include the 'heartz' as a way to have a non matching MQ to help terminate the join
  144. // https://git.io/vznFH
  145. const prefixes: string[] = ["-webkit-", "-moz-", "-o-", "-ms-"];
  146. const query: string = ["(", prefixes.join("touch-enabled),("), "heartz", ")"].join("");
  147. return window.matchMedia(query).matches;
  148. }
  149. protected get downEventName(): string {
  150. return this.isTouch() ? "touchstart" : "mousedown";
  151. }
  152. protected get moveEventName(): string {
  153. return this.isTouch() ? "touchmove" : "mousemove";
  154. }
  155. protected EventCallbackMap: Dictionary<string, [HTMLElement|Document, EventListener]> =
  156. new Dictionary<string, [HTMLElement|Document, EventListener]>();
  157. private listenForInteractions(): void {
  158. const downEvent: (clickEvent: MouseEvent | TouchEvent) => void = this.downEventListener.bind(this);
  159. const endTouchEvent: (clickEvent: TouchEvent) => void = this.touchEndEventListener.bind(this);
  160. const moveEvent: (clickEvent: MouseEvent | TouchEvent) => void = this.moveEventListener.bind(this);
  161. this.osmdSheetMusicContainer.addEventListener("mousedown", downEvent);
  162. this.osmdSheetMusicContainer.addEventListener("touchend", endTouchEvent);
  163. document.addEventListener(this.moveEventName, moveEvent);
  164. this.EventCallbackMap.setValue("mousedown", [this.osmdSheetMusicContainer, downEvent]);
  165. this.EventCallbackMap.setValue("touchend", [this.osmdSheetMusicContainer, endTouchEvent]);
  166. this.EventCallbackMap.setValue(this.moveEventName, [document, moveEvent]);
  167. }
  168. //Millis of how long is valid for the next click of a double click
  169. private readonly DOUBLE_CLICK_WINDOW: number = 200;
  170. private clickTimeout: NodeJS.Timeout;
  171. private lastClick: number = 0;
  172. private downEventListener(clickEvent: MouseEvent | TouchEvent): void {
  173. //clickEvent.preventDefault();
  174. const currentTime: number = new Date().getTime();
  175. const clickLength: number = currentTime - this.lastClick;
  176. clearTimeout(this.clickTimeout);
  177. let x: number = 0;
  178. let y: number = 0;
  179. if (this.isTouch() && clickEvent instanceof TouchEvent) {
  180. x = clickEvent.touches[0].pageX;
  181. y = clickEvent.touches[0].pageY;
  182. } else if (clickEvent instanceof MouseEvent) {
  183. x = clickEvent.pageX;
  184. y = clickEvent.pageY;
  185. }
  186. const clickMinusOffset: PointF2D = this.getOffsetCoordinates(x, y);
  187. if (clickLength < this.DOUBLE_CLICK_WINDOW && clickLength > 0) {
  188. //double click
  189. this.doubleClick(clickMinusOffset.x, clickMinusOffset.y);
  190. } else {
  191. const self: WebDisplayInteractionManager = this;
  192. this.clickTimeout = setTimeout(function(): void {
  193. clearTimeout(this.clickTimeout);
  194. if (self.isTouch()) {
  195. self.touchDown(clickMinusOffset.x, clickMinusOffset.y, undefined);
  196. } else {
  197. self.click(clickMinusOffset.x, clickMinusOffset.y);
  198. }
  199. }, this.DOUBLE_CLICK_WINDOW);
  200. }
  201. this.lastClick = currentTime;
  202. }
  203. private moveEventListener(mouseMoveEvent: MouseEvent | TouchEvent): void {
  204. let x: number = 0;
  205. let y: number = 0;
  206. if (this.isTouch() && mouseMoveEvent instanceof TouchEvent) {
  207. let touch: Touch = undefined;
  208. if(mouseMoveEvent.touches && mouseMoveEvent.touches.length > 0){
  209. touch = mouseMoveEvent.touches[0];
  210. } else if(mouseMoveEvent.changedTouches && mouseMoveEvent.changedTouches.length > 0){
  211. touch = mouseMoveEvent.changedTouches[0];
  212. }
  213. x = touch?.clientX;
  214. y = touch?.clientY;
  215. } else if (mouseMoveEvent instanceof MouseEvent) {
  216. x = mouseMoveEvent.clientX;
  217. y = mouseMoveEvent.clientY;
  218. }
  219. const clickMinusOffset: PointF2D = this.getOffsetCoordinates(x, y);
  220. this.move(clickMinusOffset.x, clickMinusOffset.y);
  221. }
  222. private touchEndEventListener(clickEvent: TouchEvent): void {
  223. let touch: Touch = undefined;
  224. if(clickEvent.touches && clickEvent.touches.length > 0){
  225. touch = clickEvent.touches[0];
  226. } else if(clickEvent.changedTouches && clickEvent.changedTouches.length > 0){
  227. touch = clickEvent.changedTouches[0];
  228. }
  229. const touchMinusOffset: PointF2D = this.getOffsetCoordinates(touch?.pageX, touch?.pageY);
  230. this.touchUp(touchMinusOffset.x, touchMinusOffset.y);
  231. }
  232. private resizeEventListener(): void {
  233. this.displaySizeChanged(this.osmdSheetMusicContainer.clientWidth, this.osmdSheetMusicContainer.clientHeight);
  234. }
  235. private getOffsetCoordinates(clickX: number, clickY: number): PointF2D {
  236. const sheetX: number = clickX - this.fullOffsetLeft + this.fullScrollLeft;
  237. const sheetY: number = clickY - this.fullOffsetTop + this.fullScrollTop;
  238. return new PointF2D(sheetX, sheetY);
  239. }
  240. }