diff --git a/src/components.css b/src/components.css index eb7d479e..1fe82520 100644 --- a/src/components.css +++ b/src/components.css @@ -190,6 +190,7 @@ mc-diorama { cursor: pointer; display: flex; align-items: center; + touch-action: none; & .track-inner { position: relative; diff --git a/src/controller.js b/src/controller.js index 3156e953..a1e26008 100644 --- a/src/controller.js +++ b/src/controller.js @@ -230,6 +230,111 @@ export class SpacetimeController { this.syncUI(); } }); + + // --- MOBILE TOUCH LOGIC ("Wait and See" Scroll vs Camera) --- + + this.touchEngaged = false; + this.touchTimer = null; + this.touchStartPos = new Map(); + this.lastTouchPos = new Map(); + + this.canvas.addEventListener('touchstart', (e) => { + if (!this.hasSpatial) return; + + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.touchStartPos.set(t.identifier, {x: t.clientX, y: t.clientY}); + this.lastTouchPos.set(t.identifier, {x: t.clientX, y: t.clientY}); + } + + clearTimeout(this.touchTimer); + + // Instantly engage if multi-touch (pinch/pan). No wait required! + if (e.touches.length >= 2) { + this.touchEngaged = true; + this.hasDragged = true; + if (e.cancelable) e.preventDefault(); + } else { + // Single touch: wait to see if it's a page scroll + this.touchEngaged = false; + this.touchTimer = setTimeout(() => { + this.touchEngaged = true; + this.hasDragged = true; + }, 200); + } + }, {passive: false}); + + this.canvas.addEventListener('touchmove', (e) => { + if (!this.hasSpatial) return; + + if (!this.touchEngaged) { + // Check if they are swiping quickly before the timer completes (1 finger). + let maxDist = 0; + for (let i = 0; i < e.touches.length; i++) { + const t = e.touches[i]; + const start = this.touchStartPos.get(t.identifier); + if (start) { + maxDist = Math.max(maxDist, Math.hypot(t.clientX - start.x, t.clientY - start.y)); + } + } + if (maxDist > 10) clearTimeout(this.touchTimer); + return; // Let the browser scroll the page + } + + // --- CAMERA IS ENGAGED --- + if (e.cancelable) e.preventDefault(); // Block native pinch-zoom and scrolling + this.hasDragged = true; + + if (e.touches.length === 1) { + // 1 Finger: Orbit + const t = e.touches[0]; + const last = this.lastTouchPos.get(t.identifier); + if (last) { + this.orbit(t.clientX - last.x, t.clientY - last.y); + notifyCameraChange(); + } + this.lastTouchPos.set(t.identifier, {x: t.clientX, y: t.clientY}); + } else if (e.touches.length === 2) { + // 2 Fingers: Pan and Zoom + const t1 = e.touches[0]; + const t2 = e.touches[1]; + const l1 = this.lastTouchPos.get(t1.identifier); + const l2 = this.lastTouchPos.get(t2.identifier); + + if (l1 && l2) { + // Zoom (Pinch distance delta) + const lastDist = Math.hypot(l1.x - l2.x, l1.y - l2.y); + const currentDist = Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY); + this.zoomRatio(lastDist / currentDist); + + // Pan (Center point delta) + const lastCx = (l1.x + l2.x) / 2; + const lastCy = (l1.y + l2.y) / 2; + const cx = (t1.clientX + t2.clientX) / 2; + const cy = (t1.clientY + t2.clientY) / 2; + this.pan(cx - lastCx, cy - lastCy); + + notifyCameraChange(); + } + this.lastTouchPos.set(t1.identifier, {x: t1.clientX, y: t1.clientY}); + this.lastTouchPos.set(t2.identifier, {x: t2.clientX, y: t2.clientY}); + } + }, {passive: false}); + + const handleTouchEnd = (e) => { + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.touchStartPos.delete(t.identifier); + this.lastTouchPos.delete(t.identifier); + } + if (e.touches.length === 0) { + clearTimeout(this.touchTimer); + this.touchEngaged = false; + } + }; + + this.canvas.addEventListener('touchend', handleTouchEnd); + this.canvas.addEventListener('touchcancel', handleTouchEnd); } // --- TEMPORAL CONTROL --- @@ -273,19 +378,26 @@ export class SpacetimeController { onDrag(pct); }; - wrapper.addEventListener('mousedown', (e) => { + wrapper.addEventListener('pointerdown', (e) => { + wrapper.setPointerCapture(e.pointerId); isTrackDragging = true; this.playMode = 'paused'; update(e); }); - window.addEventListener('mousemove', (e) => { + wrapper.addEventListener('pointermove', (e) => { if (isTrackDragging) update(e); }); - window.addEventListener('mouseup', () => { - isTrackDragging = false; - }); + const stopDrag = (e) => { + if (isTrackDragging) { + isTrackDragging = false; + wrapper.releasePointerCapture(e.pointerId); + } + }; + + wrapper.addEventListener('pointerup', stopDrag); + wrapper.addEventListener('pointercancel', stopDrag); } bindTimelineEvents() {