Skip to content

Commit acec0a0

Browse files
authored
Feat(Zoom): Add mobile device pinch-to-zoom gesture support to Zoom plugin (#4225)
* Add pinch-to-zoom gesture support for mobile devices * Revert "Add pinch-to-zoom gesture support for mobile devices" This reverts commit dca8d8a. * Added pinch-to-zoom logic to zoom plugin - Mobile device pinch-to-zoom gesture support is added to Zoom plugin. - Modified draggable.ts to avoid interruption of pinch-to-zoom. - Modified rendered.ts CSS so event target is always .wrapper when .canvas is destroyed due to rerender. * Addressed PR comments - draggable.ts: moved activePointers inside the module to avoid concurrency issues - zoom.ts: added `capture: true` for `touchcancel` event listener init. * Cleanup - remove temp comments
1 parent 039a3a7 commit acec0a0

File tree

3 files changed

+114
-6
lines changed

3 files changed

+114
-6
lines changed

src/draggable.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,28 @@ export function makeDraggable(
99
): () => void {
1010
if (!element) return () => void 0
1111

12+
const activePointers = new Map<number, PointerEvent>()
1213
const isTouchDevice = matchMedia('(pointer: coarse)').matches
1314

1415
let unsubscribeDocument = () => void 0
1516

1617
const onPointerDown = (event: PointerEvent) => {
1718
if (event.button !== mouseButton) return
1819

19-
event.preventDefault()
20-
event.stopPropagation()
20+
activePointers.set(event.pointerId, event)
21+
if (activePointers.size > 1) {
22+
return
23+
}
2124

2225
let startX = event.clientX
2326
let startY = event.clientY
2427
let isDragging = false
2528
const touchStartTime = Date.now()
2629

2730
const onPointerMove = (event: PointerEvent) => {
28-
event.preventDefault()
29-
event.stopPropagation()
31+
if (event.defaultPrevented || activePointers.size > 1) {
32+
return
33+
}
3034

3135
if (isTouchDevice && Date.now() - touchStartTime < touchDelay) return
3236

@@ -36,6 +40,9 @@ export function makeDraggable(
3640
const dy = y - startY
3741

3842
if (isDragging || Math.abs(dx) > threshold || Math.abs(dy) > threshold) {
43+
event.preventDefault()
44+
event.stopPropagation()
45+
3946
const rect = element.getBoundingClientRect()
4047
const { left, top } = rect
4148

@@ -52,6 +59,7 @@ export function makeDraggable(
5259
}
5360

5461
const onPointerUp = (event: PointerEvent) => {
62+
activePointers.delete(event.pointerId)
5563
if (isDragging) {
5664
const x = event.clientX
5765
const y = event.clientY
@@ -64,7 +72,7 @@ export function makeDraggable(
6472
}
6573

6674
const onPointerLeave = (e: PointerEvent) => {
67-
// Listen to events only on the document and not on inner elements
75+
activePointers.delete(e.pointerId)
6876
if (!e.relatedTarget || e.relatedTarget === document.documentElement) {
6977
onPointerUp(e)
7078
}
@@ -78,6 +86,9 @@ export function makeDraggable(
7886
}
7987

8088
const onTouchMove = (event: TouchEvent) => {
89+
if (event.defaultPrevented || activePointers.size > 1) {
90+
return
91+
}
8192
if (isDragging) {
8293
event.preventDefault()
8394
}
@@ -107,5 +118,6 @@ export function makeDraggable(
107118
return () => {
108119
unsubscribeDocument()
109120
element.removeEventListener('pointerdown', onPointerDown)
121+
activePointers.clear()
110122
}
111123
}

src/plugins/zoom.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* @author HoodyHuo (https://github.com/HoodyHuo)
77
* @author Chris Morbitzer (https://github.com/cmorbitzer)
88
* @author Sam Hulick (https://github.com/ffxsam)
9-
* @autor Gustav Sollenius (https://github.com/gustavsollenius)
9+
* @author Gustav Sollenius (https://github.com/gustavsollenius)
10+
* @author Viktor Jevdokimov (https://github.com/vitar)
1011
*
1112
* @example
1213
* // ... initialising wavesurfer with the plugin
@@ -65,12 +66,19 @@ class ZoomPlugin extends BasePlugin<ZoomPluginEvents, ZoomPluginOptions> {
6566
protected options: ZoomPluginOptions & typeof defaultOptions
6667
private wrapper: HTMLElement | undefined = undefined
6768
private container: HTMLElement | null = null
69+
70+
// State for wheel zoom
6871
private accumulatedDelta = 0
6972
private pointerTime: number = 0
7073
private oldX: number = 0
7174
private endZoom: number = 0
7275
private startZoom: number = 0
7376

77+
// State for proportional pinch-to-zoom
78+
private isPinching = false
79+
private initialPinchDistance = 0
80+
private initialZoom = 0
81+
7482
constructor(options?: ZoomPluginOptions) {
7583
super(options || {})
7684
this.options = Object.assign({}, defaultOptions, options)
@@ -86,7 +94,12 @@ class ZoomPlugin extends BasePlugin<ZoomPluginEvents, ZoomPluginOptions> {
8694
return
8795
}
8896
this.container = this.wrapper.parentElement as HTMLElement
97+
8998
this.container.addEventListener('wheel', this.onWheel)
99+
this.container.addEventListener('touchstart', this.onTouchStart, { passive: false, capture: true })
100+
this.container.addEventListener('touchmove', this.onTouchMove, { passive: false, capture: true })
101+
this.container.addEventListener('touchend', this.onTouchEnd, { passive: false, capture: true })
102+
this.container.addEventListener('touchcancel', this.onTouchEnd, { passive: false, capture: true })
90103

91104
if (typeof this.options.maxZoom === 'undefined') {
92105
this.options.maxZoom = this.container.clientWidth
@@ -156,9 +169,91 @@ class ZoomPlugin extends BasePlugin<ZoomPluginEvents, ZoomPluginOptions> {
156169
return Math.min(newZoom, this.options.maxZoom!)
157170
}
158171

172+
private getTouchDistance(e: TouchEvent): number {
173+
const touch1 = e.touches[0]
174+
const touch2 = e.touches[1]
175+
return Math.sqrt(Math.pow(touch2.clientX - touch1.clientX, 2) + Math.pow(touch2.clientY - touch1.clientY, 2))
176+
}
177+
178+
private getTouchCenterX(e: TouchEvent): number {
179+
const touch1 = e.touches[0]
180+
const touch2 = e.touches[1]
181+
return (touch1.clientX + touch2.clientX) / 2
182+
}
183+
184+
private onTouchStart = (e: TouchEvent) => {
185+
if (!this.wavesurfer || !this.container) return
186+
// Check if two fingers are used
187+
if (e.touches.length === 2) {
188+
e.preventDefault()
189+
this.isPinching = true
190+
191+
// Store initial pinch distance
192+
this.initialPinchDistance = this.getTouchDistance(e)
193+
194+
// Store initial zoom level
195+
const duration = this.wavesurfer.getDuration()
196+
this.initialZoom =
197+
this.wavesurfer.options.minPxPerSec === 0
198+
? this.wavesurfer.getWrapper().scrollWidth / duration
199+
: this.wavesurfer.options.minPxPerSec
200+
201+
// Store anchor point for zooming
202+
const x = this.getTouchCenterX(e) - this.container.getBoundingClientRect().left
203+
const scrollX = this.wavesurfer.getScroll()
204+
this.pointerTime = (scrollX + x) / this.initialZoom
205+
this.oldX = x // Use oldX to store the anchor X position
206+
}
207+
}
208+
209+
private onTouchMove = (e: TouchEvent) => {
210+
if (!this.isPinching || e.touches.length !== 2 || !this.wavesurfer || !this.container) {
211+
return
212+
}
213+
e.preventDefault()
214+
215+
// Calculate new zoom level
216+
const newDistance = this.getTouchDistance(e)
217+
const scaleFactor = newDistance / this.initialPinchDistance
218+
let newMinPxPerSec = this.initialZoom * scaleFactor
219+
220+
// Constrain the zoom
221+
newMinPxPerSec = Math.min(newMinPxPerSec, this.options.maxZoom!)
222+
223+
// Calculate minimum zoom (fit to width)
224+
const duration = this.wavesurfer.getDuration()
225+
const width = this.container.clientWidth
226+
const minZoom = width / duration
227+
if (newMinPxPerSec < minZoom) {
228+
newMinPxPerSec = minZoom
229+
}
230+
231+
// Apply zoom and scroll
232+
const newLeftSec = (width / newMinPxPerSec) * (this.oldX / width)
233+
if (newMinPxPerSec === minZoom) {
234+
this.wavesurfer.zoom(minZoom)
235+
this.container.scrollLeft = 0
236+
} else {
237+
this.wavesurfer.zoom(newMinPxPerSec)
238+
this.container.scrollLeft = (this.pointerTime - newLeftSec) * newMinPxPerSec
239+
}
240+
}
241+
242+
private onTouchEnd = (e: TouchEvent) => {
243+
if (this.isPinching && e.touches.length < 2) {
244+
this.isPinching = false
245+
this.initialPinchDistance = 0
246+
this.initialZoom = 0
247+
}
248+
}
249+
159250
destroy() {
160251
if (this.container) {
161252
this.container.removeEventListener('wheel', this.onWheel)
253+
this.container.removeEventListener('touchstart', this.onTouchStart)
254+
this.container.removeEventListener('touchmove', this.onTouchMove)
255+
this.container.removeEventListener('touchend', this.onTouchEnd)
256+
this.container.removeEventListener('touchcancel', this.onTouchEnd)
162257
}
163258
super.destroy()
164259
}

src/renderer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ class Renderer extends EventEmitter<RendererEvents> {
193193
}
194194
:host .canvases {
195195
min-height: ${this.getHeight(this.options.height, this.options.splitChannels)}px;
196+
pointer-events: none;
196197
}
197198
:host .canvases > div {
198199
position: relative;

0 commit comments

Comments
 (0)