@internetarchive/bookreader 5.0.0-65 → 5.0.0-66

Sign up to get free protection for your applications and to get access to all the features.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,6 @@
1
+ # 5.0.0-66
2
+ - Fix: Pinch zooming on iPad/iPhone, Samsung Internet @cdrini
3
+
1
4
  # 5.0.0-65
2
5
  - Dev: Remove Debug console dev helper @cdrini
3
6
  - Dev: Fix deno esm.sh esbuild erroring @cdrini
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@internetarchive/bookreader",
3
- "version": "5.0.0-65",
3
+ "version": "5.0.0-66",
4
4
  "description": "The Internet Archive BookReader.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -57,8 +57,8 @@
57
57
  "eslint": "^7.32.0",
58
58
  "eslint-plugin-no-jquery": "^2.7.0",
59
59
  "eslint-plugin-testcafe": "^0.2.1",
60
- "hammerjs": "^2.0.8",
61
60
  "http-server": "14.1.1",
61
+ "interactjs": "^1.10.18",
62
62
  "iso-language-codes": "1.1.0",
63
63
  "jest": "29.6.2",
64
64
  "jest-environment-jsdom": "^29.4.3",
@@ -51,7 +51,7 @@ export class Mode1Up {
51
51
  new DragScrollable(this.mode1UpLit, {
52
52
  preventDefault: true,
53
53
  dragSelector: '.br-mode-1up__visible-world',
54
- // Only handle mouse events; let browser/HammerJS handle touch
54
+ // Only handle mouse events; let browser/interact.js handle touch
55
55
  dragstart: 'mousedown',
56
56
  dragcontinue: 'mousemove',
57
57
  dragend: 'mouseup',
@@ -47,9 +47,6 @@ export class Mode1UpLit extends LitElement {
47
47
 
48
48
  @property({ type: Number })
49
49
  scale = 1;
50
- /** Position (in unit-less, [0, 1] coordinates) in client to scale around */
51
- @property({ type: Object })
52
- scaleCenter = { x: 0.5, y: 0.5 };
53
50
 
54
51
  /************** VIRTUAL-SCROLLING PROPERTIES **************/
55
52
 
@@ -41,10 +41,6 @@ export class Mode2UpLit extends LitElement {
41
41
 
42
42
  initialScale = 1;
43
43
 
44
- /** Position (in unit-less, [0, 1] coordinates) in client to scale around */
45
- @property({ type: Object })
46
- scaleCenter = { x: 0.5, y: 0.5 };
47
-
48
44
  /** @type {import('./options').AutoFitValues} */
49
45
  @property({ type: String })
50
46
  autoFit = 'auto';
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
- import Hammer from "hammerjs";
2
+ import interact from 'interactjs';
3
+ import { isIOS, isSamsungInternet } from '../util/browserSniffing.js';
4
+ import { sleep } from './utils.js';
3
5
  /** @typedef {import('./utils/HTMLDimensionsCacher.js').HTMLDimensionsCacher} HTMLDimensionsCacher */
4
6
 
5
7
  /**
@@ -8,7 +10,6 @@ import Hammer from "hammerjs";
8
10
  * @property {HTMLElement} $visibleWorld
9
11
  * @property {import("./options.js").AutoFitValues} autoFit
10
12
  * @property {number} scale
11
- * @property {{ x: number, y: number }} scaleCenter
12
13
  * @property {HTMLDimensionsCacher} htmlDimensionsCacher
13
14
  * @property {function(): void} [attachScrollListeners]
14
15
  * @property {function(): void} [detachScrollListeners]
@@ -16,31 +17,27 @@ import Hammer from "hammerjs";
16
17
 
17
18
  /** Manages pinch-zoom, ctrl-wheel, and trackpad pinch smooth zooming. */
18
19
  export class ModeSmoothZoom {
20
+ /** Position (in unit-less, [0, 1] coordinates) in client to scale around */
21
+ scaleCenter = { x: 0.5, y: 0.5 };
22
+
19
23
  /** @param {SmoothZoomable} mode */
20
24
  constructor(mode) {
21
25
  /** @type {SmoothZoomable} */
22
26
  this.mode = mode;
23
27
 
28
+ /** Whether a pinch is currently happening */
29
+ this.pinching = false;
24
30
  /** Non-null when a scale has been enqueued/is being processed by the buffer function */
25
31
  this.pinchMoveFrame = null;
26
32
  /** Promise for the current/enqueued pinch move frame. Resolves when it is complete. */
27
33
  this.pinchMoveFramePromise = Promise.resolve();
28
34
  this.oldScale = 1;
29
- /** @type {{ scale: number, center: { x: number, y: number }}} */
35
+ /** @type {{ scale: number, clientX: number, clientY: number }}} */
30
36
  this.lastEvent = null;
31
37
  this.attached = false;
32
38
 
33
39
  /** @type {function(function(): void): any} */
34
40
  this.bufferFn = window.requestAnimationFrame.bind(window);
35
-
36
- // Hammer.js by default set userSelect to None; we don't want that!
37
- // TODO: Is there any way to do this not globally on Hammer?
38
- delete Hammer.defaults.cssProps.userSelect;
39
- this.hammer = new Hammer.Manager(this.mode.$container, {
40
- touchAction: "pan-x pan-y",
41
- });
42
-
43
- this.hammer.add(new Hammer.Pinch());
44
41
  }
45
42
 
46
43
  attach() {
@@ -48,17 +45,44 @@ export class ModeSmoothZoom {
48
45
 
49
46
  this.attachCtrlZoom();
50
47
 
51
- // GestureEvents work only on Safari; they interfere with Hammer,
52
- // so block them.
53
- this.mode.$container.addEventListener('gesturestart', this._preventEvent);
48
+ // GestureEvents work only on Safari; they're too glitchy to use
49
+ // fully, but they can sometimes help error correct when interact
50
+ // misses an end/start event on Safari due to Safari bugs.
51
+ this.mode.$container.addEventListener('gesturestart', this._pinchStart);
54
52
  this.mode.$container.addEventListener('gesturechange', this._preventEvent);
55
- this.mode.$container.addEventListener('gestureend', this._preventEvent);
53
+ this.mode.$container.addEventListener('gestureend', this._pinchEnd);
54
+
55
+ if (isIOS()) {
56
+ this.touchesMonitor = new TouchesMonitor(this.mode.$container);
57
+ this.touchesMonitor.attach();
58
+ }
59
+
60
+ this.mode.$container.style.touchAction = "pan-x pan-y";
56
61
 
57
62
  // The pinch listeners
58
- this.hammer.on("pinchstart", this._pinchStart);
59
- this.hammer.on("pinchmove", this._pinchMove);
60
- this.hammer.on("pinchend", this._pinchEnd);
61
- this.hammer.on("pinchcancel", this._pinchCancel);
63
+ this.interact = interact(this.mode.$container);
64
+ this.interact.gesturable({
65
+ listeners: {
66
+ start: this._pinchStart,
67
+ end: this._pinchEnd,
68
+ }
69
+ });
70
+ if (isSamsungInternet()) {
71
+ // Samsung internet pinch-zoom will not work unless we disable
72
+ // all touch actions. So use interact.js' built-in drag support
73
+ // to handle moving on that browser.
74
+ this.mode.$container.style.touchAction = "none";
75
+ this.interact
76
+ .draggable({
77
+ inertia: {
78
+ resistance: 2,
79
+ minSpeed: 100,
80
+ allowResume: true,
81
+ },
82
+ listeners: { move: this._dragMove }
83
+ });
84
+ }
85
+
62
86
 
63
87
  this.attached = true;
64
88
  }
@@ -68,15 +92,15 @@ export class ModeSmoothZoom {
68
92
 
69
93
  // GestureEvents work only on Safari; they interfere with Hammer,
70
94
  // so block them.
71
- this.mode.$container.removeEventListener('gesturestart', this._preventEvent);
95
+ this.mode.$container.removeEventListener('gesturestart', this._pinchStart);
72
96
  this.mode.$container.removeEventListener('gesturechange', this._preventEvent);
73
- this.mode.$container.removeEventListener('gestureend', this._preventEvent);
97
+ this.mode.$container.removeEventListener('gestureend', this._pinchEnd);
98
+
99
+ this.touchesMonitor?.detach?.();
74
100
 
75
101
  // The pinch listeners
76
- this.hammer.off("pinchstart", this._pinchStart);
77
- this.hammer.off("pinchmove", this._pinchMove);
78
- this.hammer.off("pinchend", this._pinchEnd);
79
- this.hammer.off("pinchcancel", this._pinchCancel);
102
+ this.interact.unset();
103
+ interact.removeDocument(document);
80
104
 
81
105
  this.attached = false;
82
106
  }
@@ -87,7 +111,16 @@ export class ModeSmoothZoom {
87
111
  return false;
88
112
  }
89
113
 
90
- _pinchStart = () => {
114
+ _pinchStart = async () => {
115
+ // Safari calls gesturestart twice!
116
+ if (this.pinching) return;
117
+ if (isIOS()) {
118
+ // Safari sometimes causes a pinch to trigger when there's only one touch!
119
+ await sleep(0); // touches monitor can receive the touch event late
120
+ if (this.touchesMonitor.touches < 2) return;
121
+ }
122
+ this.pinching = true;
123
+
91
124
  // Do this in case the pinchend hasn't fired yet.
92
125
  this.oldScale = 1;
93
126
  this.mode.$visibleWorld.classList.add("BRsmooth-zooming");
@@ -95,37 +128,44 @@ export class ModeSmoothZoom {
95
128
  this.mode.autoFit = "none";
96
129
  this.detachCtrlZoom();
97
130
  this.mode.detachScrollListeners?.();
131
+
132
+ this.interact.gesturable({
133
+ listeners: {
134
+ start: this._pinchStart,
135
+ move: this._pinchMove,
136
+ end: this._pinchEnd,
137
+ }
138
+ });
98
139
  }
99
140
 
100
- /** @param {{ scale: number, center: { x: number, y: number }}} e */
141
+ /** @param {{ scale: number, clientX: number, clientY: number }}} e */
101
142
  _pinchMove = async (e) => {
102
- this.lastEvent = e;
143
+ if (!this.pinching) return;
144
+ this.lastEvent = {
145
+ scale: e.scale,
146
+ clientX: e.clientX,
147
+ clientY: e.clientY,
148
+ };
103
149
  if (!this.pinchMoveFrame) {
104
- let pinchMoveFramePromiseRes = null;
105
- this.pinchMoveFramePromise = new Promise(
106
- (res) => (pinchMoveFramePromiseRes = res)
107
- );
108
-
109
150
  // Buffer these events; only update the scale when request animation fires
110
- this.pinchMoveFrame = this.bufferFn(() => {
111
- this.updateScaleCenter({
112
- clientX: this.lastEvent.center.x,
113
- clientY: this.lastEvent.center.y,
114
- });
115
- this.mode.scale *= this.lastEvent.scale / this.oldScale;
116
- this.oldScale = this.lastEvent.scale;
117
- this.pinchMoveFrame = null;
118
- pinchMoveFramePromiseRes();
119
- });
151
+ this.pinchMoveFrame = this.bufferFn(this._drawPinchZoomFrame);
120
152
  }
121
153
  }
122
154
 
123
155
  _pinchEnd = async () => {
156
+ if (!this.pinching) return;
157
+ this.pinching = false;
158
+ this.interact.gesturable({
159
+ listeners: {
160
+ start: this._pinchStart,
161
+ end: this._pinchEnd,
162
+ }
163
+ });
124
164
  // Want this to happen after the pinchMoveFrame,
125
165
  // if one is in progress; otherwise setting oldScale
126
166
  // messes up the transform.
127
167
  await this.pinchMoveFramePromise;
128
- this.mode.scaleCenter = { x: 0.5, y: 0.5 };
168
+ this.scaleCenter = { x: 0.5, y: 0.5 };
129
169
  this.oldScale = 1;
130
170
  this.mode.$visibleWorld.classList.remove("BRsmooth-zooming");
131
171
  this.mode.$visibleWorld.style.willChange = "auto";
@@ -133,10 +173,42 @@ export class ModeSmoothZoom {
133
173
  this.mode.attachScrollListeners?.();
134
174
  }
135
175
 
136
- _pinchCancel = async () => {
137
- // iOS fires pinchcancel ~randomly; it looks like it sometimes
138
- // thinks the pinch becomes a pan, at which point it cancels?
139
- await this._pinchEnd();
176
+ _drawPinchZoomFrame = async () => {
177
+ // Because of the buffering/various timing locks,
178
+ // this can be called after the pinch has ended, which
179
+ // results in a janky zoom after the pinch.
180
+ if (!this.pinching) {
181
+ this.pinchMoveFrame = null;
182
+ return;
183
+ }
184
+
185
+ this.mode.$container.style.overflow = "hidden";
186
+ this.pinchMoveFramePromiseRes = null;
187
+ this.pinchMoveFramePromise = new Promise(
188
+ (res) => (this.pinchMoveFramePromiseRes = res)
189
+ );
190
+ this.updateScaleCenter({
191
+ clientX: this.lastEvent.clientX,
192
+ clientY: this.lastEvent.clientY,
193
+ });
194
+ const curScale = this.mode.scale;
195
+ const newScale = curScale * this.lastEvent.scale / this.oldScale;
196
+
197
+ if (curScale != newScale) {
198
+ this.mode.scale = newScale;
199
+ await this.pinchMoveFramePromise;
200
+ }
201
+ this.mode.$container.style.overflow = "auto";
202
+ this.oldScale = this.lastEvent.scale;
203
+ this.pinchMoveFrame = null;
204
+ }
205
+
206
+ _dragMove = async (e) => {
207
+ if (this.pinching) {
208
+ await this._pinchEnd();
209
+ }
210
+ this.mode.$container.scrollTop -= e.dy;
211
+ this.mode.$container.scrollLeft -= e.dx;
140
212
  }
141
213
 
142
214
  /** @private */
@@ -174,7 +246,7 @@ export class ModeSmoothZoom {
174
246
  */
175
247
  updateScaleCenter({ clientX, clientY }) {
176
248
  const bc = this.mode.htmlDimensionsCacher.boundingClientRect;
177
- this.mode.scaleCenter = {
249
+ this.scaleCenter = {
178
250
  x: (clientX - bc.left) / this.mode.htmlDimensionsCacher.clientWidth,
179
251
  y: (clientY - bc.top) / this.mode.htmlDimensionsCacher.clientHeight,
180
252
  };
@@ -194,8 +266,8 @@ export class ModeSmoothZoom {
194
266
  const F = newScale / oldScale;
195
267
 
196
268
  // Where in the viewport the zoom is centered on
197
- const XPOS = this.mode.scaleCenter.x;
198
- const YPOS = this.mode.scaleCenter.y;
269
+ const XPOS = this.scaleCenter.x;
270
+ const YPOS = this.scaleCenter.y;
199
271
  const oldCenter = {
200
272
  x: L + XPOS * W,
201
273
  y: T + YPOS * H,
@@ -207,5 +279,34 @@ export class ModeSmoothZoom {
207
279
 
208
280
  container.scrollTop = newCenter.y - YPOS * H;
209
281
  container.scrollLeft = newCenter.x - XPOS * W;
282
+ this.pinchMoveFramePromiseRes?.();
283
+ }
284
+ }
285
+
286
+ export class TouchesMonitor {
287
+ /**
288
+ * @param {HTMLElement} container
289
+ */
290
+ constructor(container) {
291
+ /** @type {HTMLElement} */
292
+ this.container = container;
293
+ this.touches = 0;
294
+ }
295
+
296
+ attach() {
297
+ this.container.addEventListener("touchstart", this._updateTouchCount);
298
+ this.container.addEventListener("touchend", this._updateTouchCount);
299
+ }
300
+
301
+ detach() {
302
+ this.container.removeEventListener("touchstart", this._updateTouchCount);
303
+ this.container.removeEventListener("touchend", this._updateTouchCount);
304
+ }
305
+
306
+ /**
307
+ * @param {TouchEvent} ev
308
+ */
309
+ _updateTouchCount = (ev) => {
310
+ this.touches = ev.touches.length;
210
311
  }
211
312
  }
@@ -28,3 +28,25 @@ export function isFirefox(userAgent = navigator.userAgent) {
28
28
  export function isSafari(userAgent = navigator.userAgent) {
29
29
  return /safari/i.test(userAgent) && !/chrome|chromium/i.test(userAgent);
30
30
  }
31
+
32
+ /**
33
+ * Checks whether the current browser is iOS (and hence iOS webkit)
34
+ * @return {boolean}
35
+ */
36
+ export function isIOS() {
37
+ // We can't just check the userAgent because as of iOS 13,
38
+ // the userAgent is the same as desktop Safari because
39
+ // they wanted iPad's to be served the same version of websites
40
+ // as desktops.
41
+ return 'ongesturestart' in window && navigator.maxTouchPoints > 0;
42
+ }
43
+
44
+ /**
45
+ * Checks whether the current browser is Samsung Internet
46
+ * https://stackoverflow.com/a/40684162/2317712
47
+ * @param {string} [userAgent]
48
+ * @return {boolean}
49
+ */
50
+ export function isSamsungInternet(userAgent = navigator.userAgent) {
51
+ return /SamsungBrowser/i.test(userAgent);
52
+ }
@@ -1,6 +1,7 @@
1
1
  import sinon from 'sinon';
2
- import { EventTargetSpy } from '../utils.js';
3
- import { ModeSmoothZoom } from '@/src/BookReader/ModeSmoothZoom.js';
2
+ import interact from 'interactjs';
3
+ import { EventTargetSpy, afterEventLoop } from '../utils.js';
4
+ import { ModeSmoothZoom, TouchesMonitor } from '@/src/BookReader/ModeSmoothZoom.js';
4
5
  /** @typedef {import('@/src/BookReader/ModeSmoothZoom.js').SmoothZoomable} SmoothZoomable */
5
6
 
6
7
  /**
@@ -22,33 +23,32 @@ function dummy_mode(overrides = {}) {
22
23
  };
23
24
  }
24
25
 
25
- afterEach(() => sinon.restore());
26
+ afterEach(() => {
27
+ sinon.restore();
28
+ try {
29
+ interact.removeDocument(document);
30
+ } catch (e) {}
31
+ });
26
32
 
27
33
  describe('ModeSmoothZoom', () => {
28
- test('preventsDefault on iOS-only gesture events', () => {
34
+ test('handle iOS-only gesture events', () => {
29
35
  const mode = dummy_mode();
30
36
  const msz = new ModeSmoothZoom(mode);
37
+ sinon.stub(msz, '_pinchStart');
38
+ sinon.stub(msz, '_pinchMove');
39
+ sinon.stub(msz, '_pinchEnd');
40
+
31
41
  msz.attach();
32
- for (const event_name of ['gesturestart', 'gesturechange', 'gestureend']) {
33
- const ev = new Event(event_name, {});
34
- const prevDefaultSpy = sinon.spy(ev, 'preventDefault');
35
- mode.$container.dispatchEvent(ev);
36
- expect(prevDefaultSpy.callCount).toBe(1);
37
- }
38
- });
39
42
 
40
- test('pinchCancel alias for pinchEnd', () => {
41
- const mode = dummy_mode();
42
- const msz = new ModeSmoothZoom(mode);
43
- const pinchEndSpy = sinon.spy(msz, '_pinchEnd');
44
- msz._pinchStart();
45
- msz._pinchCancel();
46
- expect(pinchEndSpy.callCount).toBe(1);
43
+ const gesturestart = new Event('gesturestart', {});
44
+ mode.$container.dispatchEvent(gesturestart);
45
+ expect(msz._pinchStart.callCount).toBe(1);
47
46
  });
48
47
 
49
48
  test('sets will-change', async () => {
50
49
  const mode = dummy_mode();
51
50
  const msz = new ModeSmoothZoom(mode);
51
+ msz.attach();
52
52
  expect(mode.$visibleWorld.style.willChange).toBeFalsy();
53
53
  msz._pinchStart();
54
54
  expect(mode.$visibleWorld.style.willChange).toBe('transform');
@@ -59,6 +59,7 @@ describe('ModeSmoothZoom', () => {
59
59
  test('pinch move updates scale', () => {
60
60
  const mode = dummy_mode();
61
61
  const msz = new ModeSmoothZoom(mode);
62
+ msz.attach();
62
63
  // disable buffering
63
64
  msz.bufferFn = (callback) => callback();
64
65
  msz._pinchStart();
@@ -79,48 +80,47 @@ describe('ModeSmoothZoom', () => {
79
80
  }
80
81
  });
81
82
  const msz = new ModeSmoothZoom(mode);
82
- expect(mode.scaleCenter).toEqual({ x: 0.5, y: 0.5 });
83
+ expect(msz.scaleCenter).toEqual({ x: 0.5, y: 0.5 });
83
84
  msz.updateScaleCenter({ clientX: 85, clientY: 110 });
84
- expect(mode.scaleCenter).toEqual({ x: 0.4, y: 0.6 });
85
+ expect(msz.scaleCenter).toEqual({ x: 0.4, y: 0.6 });
85
86
  });
86
87
 
87
- test('detaches all listeners', () => {
88
+ test('detaches all listeners', async () => {
88
89
  const mode = dummy_mode();
89
90
  const msz = new ModeSmoothZoom(mode);
91
+
92
+ const documentEventSpy = EventTargetSpy.wrap(document);
90
93
  const containerEventSpy = EventTargetSpy.wrap(mode.$container);
91
94
  const visibleWorldSpy = EventTargetSpy.wrap(mode.$visibleWorld);
92
- const hammerEventSpy = new EventTargetSpy();
93
- msz.hammer.on = hammerEventSpy.addEventListener.bind(hammerEventSpy);
94
- msz.hammer.off = hammerEventSpy.removeEventListener.bind(hammerEventSpy);
95
95
 
96
96
  msz.attach();
97
+ await afterEventLoop();
98
+ expect(documentEventSpy._totalListenerCount).toBeGreaterThan(0);
97
99
  expect(containerEventSpy._totalListenerCount).toBeGreaterThan(0);
98
- expect(hammerEventSpy._totalListenerCount).toBeGreaterThan(0);
99
100
 
100
101
  msz.detach();
102
+ expect(documentEventSpy._totalListenerCount).toBe(0);
101
103
  expect(containerEventSpy._totalListenerCount).toBe(0);
102
104
  expect(visibleWorldSpy._totalListenerCount).toBe(0);
103
- expect(hammerEventSpy._totalListenerCount).toBe(0);
104
105
  });
105
106
 
106
107
  test('attach can be called twice without double attachments', () => {
107
108
  const mode = dummy_mode();
108
109
  const msz = new ModeSmoothZoom(mode);
110
+
111
+ const documentEventSpy = EventTargetSpy.wrap(document);
109
112
  const containerEventSpy = EventTargetSpy.wrap(mode.$container);
110
113
  const visibleWorldSpy = EventTargetSpy.wrap(mode.$visibleWorld);
111
- const hammerEventSpy = new EventTargetSpy();
112
- msz.hammer.on = hammerEventSpy.addEventListener.bind(hammerEventSpy);
113
- msz.hammer.off = hammerEventSpy.removeEventListener.bind(hammerEventSpy);
114
- msz.attach();
115
114
 
115
+ msz.attach();
116
+ const documentListenersCount = documentEventSpy._totalListenerCount;
116
117
  const containerListenersCount = containerEventSpy._totalListenerCount;
117
118
  const visibleWorldListenersCount = visibleWorldSpy._totalListenerCount;
118
- const hammerListenersCount = hammerEventSpy._totalListenerCount;
119
119
 
120
120
  msz.attach();
121
+ expect(documentEventSpy._totalListenerCount).toBe(documentListenersCount);
121
122
  expect(containerEventSpy._totalListenerCount).toBe(containerListenersCount);
122
123
  expect(visibleWorldSpy._totalListenerCount).toBe(visibleWorldListenersCount);
123
- expect(hammerEventSpy._totalListenerCount).toBe(hammerListenersCount);
124
124
  });
125
125
 
126
126
  describe('_handleCtrlWheel', () => {
@@ -173,3 +173,46 @@ describe('ModeSmoothZoom', () => {
173
173
  });
174
174
  });
175
175
  });
176
+
177
+
178
+ describe("TouchesMonitor", () => {
179
+ /** @type {HTMLElement} */
180
+ let container;
181
+ /** @type {TouchesMonitor} */
182
+ let monitor;
183
+
184
+ beforeEach(() => {
185
+ container = document.createElement("div");
186
+ monitor = new TouchesMonitor(container);
187
+ });
188
+
189
+ afterEach(() => {
190
+ monitor.detach();
191
+ });
192
+
193
+ test("should start with 0 touches", () => {
194
+ expect(monitor.touches).toBe(0);
195
+ });
196
+
197
+ test("should update touch count on touch events", () => {
198
+ monitor.attach();
199
+ container.dispatchEvent(new TouchEvent("touchstart", { touches: [{}] }));
200
+ expect(monitor.touches).toBe(1);
201
+
202
+ container.dispatchEvent(new TouchEvent("touchstart", { touches: [{}, {}] }));
203
+ expect(monitor.touches).toBe(2);
204
+
205
+ container.dispatchEvent(new TouchEvent("touchend", { touches: [{}] }));
206
+ expect(monitor.touches).toBe(1);
207
+
208
+ container.dispatchEvent(new TouchEvent("touchend", { touches: [] }));
209
+ });
210
+
211
+ test("should detach all listeners", () => {
212
+ const spy = EventTargetSpy.wrap(container);
213
+ monitor.attach();
214
+ expect(spy._totalListenerCount).toBeGreaterThan(0);
215
+ monitor.detach();
216
+ expect(spy._totalListenerCount).toBe(0);
217
+ });
218
+ });