@@ -400,13 +400,20 @@ export function SheetsViewer({ content, theme }: SheetsViewerProps) {
400400 let scrollY = 0;
401401 let velocityX = 0;
402402 let velocityY = 0;
403+ let lastX = 0;
404+ let lastY = 0;
403405 let lastTime = 0;
404406 let momentumId = null;
407+ let isTracking = false;
405408
406409 // Pinch zoom state
407410 let initialPinchDistance = 0;
408411 let currentZoom = 1;
409412
413+ // iOS-like deceleration rate (0.998 is close to UIScrollView)
414+ const DECELERATION = 0.985;
415+ const MIN_VELOCITY = 0.1;
416+
410417 function doScroll() {
411418 const sheetId = getSheetId();
412419 if (unitId && sheetId) {
@@ -424,13 +431,15 @@ export function SheetsViewer({ content, theme }: SheetsViewerProps) {
424431 }
425432
426433 function momentumScroll() {
427- if (Math.abs(velocityX) < 0.5 && Math.abs(velocityY) < 0.5 ) {
434+ if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY ) {
428435 momentumId = null;
429436 return;
430437 }
431438
432- velocityX *= 0.95;
433- velocityY *= 0.95;
439+ // Apply iOS-like deceleration
440+ velocityX *= DECELERATION;
441+ velocityY *= DECELERATION;
442+
434443 scrollX = Math.max(0, scrollX + velocityX);
435444 scrollY = Math.max(0, scrollY + velocityY);
436445 doScroll();
@@ -444,41 +453,53 @@ export function SheetsViewer({ content, theme }: SheetsViewerProps) {
444453 }
445454
446455 overlay.addEventListener('touchstart', function(e) {
456+ // Stop any ongoing momentum
447457 if (momentumId) {
448458 cancelAnimationFrame(momentumId);
449459 momentumId = null;
450460 }
451461
452462 if (e.touches.length === 1) {
463+ isTracking = true;
453464 startX = e.touches[0].pageX;
454465 startY = e.touches[0].pageY;
455- lastTime = Date.now();
466+ lastX = startX;
467+ lastY = startY;
468+ lastTime = performance.now();
456469 velocityX = 0;
457470 velocityY = 0;
458471 } else if (e.touches.length === 2) {
472+ isTracking = false;
459473 initialPinchDistance = getPinchDistance(e.touches);
460474 }
461475 }, { passive: true });
462476
463477 overlay.addEventListener('touchmove', function(e) {
464- if (e.touches.length === 1) {
478+ if (e.touches.length === 1 && isTracking ) {
465479 const currentX = e.touches[0].pageX;
466480 const currentY = e.touches[0].pageY;
467- const now = Date.now();
468- const dt = Math.max(1, now - lastTime);
469-
470- const deltaX = (startX - currentX) * 1.5;
471- const deltaY = (startY - currentY) * 1.5;
472-
473- velocityX = deltaX * (16 / dt);
474- velocityY = deltaY * (16 / dt);
481+ const now = performance.now();
482+ const dt = now - lastTime;
483+
484+ // Direct 1:1 scrolling during drag
485+ const deltaX = lastX - currentX;
486+ const deltaY = lastY - currentY;
487+
488+ // Track velocity with time-weighted smoothing
489+ if (dt > 0) {
490+ const newVelX = (deltaX / dt) * 16; // normalize to ~60fps
491+ const newVelY = (deltaY / dt) * 16;
492+ // Smooth velocity to prevent jitter
493+ velocityX = velocityX * 0.4 + newVelX * 0.6;
494+ velocityY = velocityY * 0.4 + newVelY * 0.6;
495+ }
475496
476497 scrollX = Math.max(0, scrollX + deltaX);
477498 scrollY = Math.max(0, scrollY + deltaY);
478499 doScroll();
479500
480- startX = currentX;
481- startY = currentY;
501+ lastX = currentX;
502+ lastY = currentY;
482503 lastTime = now;
483504 } else if (e.touches.length === 2 && initialPinchDistance > 0) {
484505 const newDistance = getPinchDistance(e.touches);
@@ -498,10 +519,25 @@ export function SheetsViewer({ content, theme }: SheetsViewerProps) {
498519 }, { passive: true });
499520
500521 overlay.addEventListener('touchend', function(e) {
501- if (e.touches.length === 0 && (Math.abs(velocityX) > 1 || Math.abs(velocityY) > 1)) {
502- momentumId = requestAnimationFrame(momentumScroll);
522+ if (e.touches.length === 0 && isTracking) {
523+ isTracking = false;
524+ // Only start momentum if we have meaningful velocity
525+ if (Math.abs(velocityX) > 0.5 || Math.abs(velocityY) > 0.5) {
526+ momentumId = requestAnimationFrame(momentumScroll);
527+ }
528+ }
529+ if (e.touches.length < 2) {
530+ initialPinchDistance = 0;
531+ }
532+ }, { passive: true });
533+
534+ // Handle touch cancel (e.g., incoming call)
535+ overlay.addEventListener('touchcancel', function(e) {
536+ isTracking = false;
537+ if (momentumId) {
538+ cancelAnimationFrame(momentumId);
539+ momentumId = null;
503540 }
504- initialPinchDistance = 0;
505541 }, { passive: true });
506542 }, 800);
507543
0 commit comments