Skip to content

Mobile-First Touch Gestures: Pinch-Zoom, Pan, and Multi-Touch Drawing #44

Description

@bchou9

Feature Description

Implement comprehensive touch gesture support for mobile and tablet users, including pinch-to-zoom, two-finger pan, and multi-touch drawing capabilities.

Problem Statement

Current mobile experience is limited:

  • No pinch-to-zoom support
  • Difficult to navigate large canvases on mobile
  • Single-touch only (no multi-finger gestures)
  • Touch precision issues
  • No palm rejection
  • Zoom controls require buttons instead of gestures

Proposed Features

1. Touch Gesture Handler

// frontend/src/utils/TouchGestureHandler.js
export class TouchGestureHandler {
  constructor(canvas) {
    this.canvas = canvas;
    this.touches = new Map();
    this.initialPinchDistance = null;
    this.initialZoom = 1;
    this.isPanning = false;
    this.isDrawing = false;
  }

  handleTouchStart(e) {
    e.preventDefault();
    
    const touches = Array.from(e.touches);
    
    if (touches.length === 1) {
      // Single touch - drawing
      this.startDrawing(touches[0]);
    } else if (touches.length === 2) {
      // Two fingers - zoom/pan
      this.startPinchZoom(touches);
    }
  }

  handleTouchMove(e) {
    e.preventDefault();
    
    const touches = Array.from(e.touches);
    
    if (touches.length === 1 && this.isDrawing) {
      this.continueDrawing(touches[0]);
    } else if (touches.length === 2) {
      this.updatePinchZoom(touches);
    }
  }

  handleTouchEnd(e) {
    e.preventDefault();
    
    if (e.touches.length === 0) {
      this.endDrawing();
      this.endPinchZoom();
    }
  }

  startDrawing(touch) {
    this.isDrawing = true;
    const point = this.getTouchPoint(touch);
    this.canvas.startStroke(point.x, point.y);
  }

  continueDrawing(touch) {
    const point = this.getTouchPoint(touch);
    this.canvas.continueStroke(point.x, point.y);
  }

  endDrawing() {
    if (this.isDrawing) {
      this.canvas.endStroke();
      this.isDrawing = false;
    }
  }

  startPinchZoom(touches) {
    this.initialPinchDistance = this.getDistance(touches[0], touches[1]);
    this.initialZoom = this.canvas.zoom;
    this.isPanning = true;
  }

  updatePinchZoom(touches) {
    const currentDistance = this.getDistance(touches[0], touches[1]);
    const scale = currentDistance / this.initialPinchDistance;
    
    // Update zoom
    const newZoom = this.initialZoom * scale;
    this.canvas.setZoom(newZoom);
    
    // Calculate pan based on midpoint
    const midpoint = this.getMidpoint(touches[0], touches[1]);
    this.canvas.panTo(midpoint.x, midpoint.y);
  }

  endPinchZoom() {
    this.initialPinchDistance = null;
    this.isPanning = false;
  }

  getDistance(touch1, touch2) {
    const dx = touch2.clientX - touch1.clientX;
    const dy = touch2.clientY - touch1.clientY;
    return Math.sqrt(dx * dx + dy * dy);
  }

  getMidpoint(touch1, touch2) {
    return {
      x: (touch1.clientX + touch2.clientX) / 2,
      y: (touch1.clientY + touch2.clientY) / 2
    };
  }

  getTouchPoint(touch) {
    const rect = this.canvas.element.getBoundingClientRect();
    return {
      x: (touch.clientX - rect.left) / this.canvas.zoom,
      y: (touch.clientY - rect.top) / this.canvas.zoom
    };
  }
}

2. Palm Rejection

// frontend/src/utils/PalmRejection.js
export class PalmRejection {
  constructor() {
    this.minTouchRadius = 5; // Minimum radius for valid touch (stylus)
    this.maxTouchRadius = 50; // Maximum radius (palm)
  }

  isPalmTouch(touch) {
    // Detect palm based on touch radius
    const radius = touch.radiusX || touch.radiusY || 0;
    
    if (radius > this.maxTouchRadius) {
      return true; // Likely palm
    }
    
    // Check for stylus support
    if (touch.touchType === 'stylus') {
      return false; // Definitely stylus
    }
    
    // Check for Apple Pencil or similar
    if (touch.force && touch.force > 0) {
      return false; // Pressure-sensitive stylus
    }
    
    return false;
  }

  filterPalmTouches(touches) {
  }
}

3. Responsive Zoom Controls

// frontend/src/components/MobileZoomControls.jsx
export function MobileZoomControls({ zoom, onZoomChange, onFitToScreen }) {
  const isMobile = useMediaQuery('(max-width: 768px)');
  

  return (
    <Box
      sx={{
        position: 'fixed',
        bottom: 80,
        right: 16,
        display: 'flex',
        flexDirection: 'column',
        gap: 1
      }}
    >
      <Fab size="small" onClick={() => onZoomChange(zoom * 1.2)}>
        <ZoomInIcon />
      </Fab>
      
      <Fab size="small" onClick={() => onZoomChange(zoom / 1.2)}>
        <ZoomOutIcon />
      </Fab>
      
      <Fab size="small" onClick={onFitToScreen}>
        <FitScreenIcon />
      </Fab>
      
      <Chip 
        label={`${Math.round(zoom * 100)}%`} 
        size="small" 
        sx={{ mt: 1 }}
      />
    </Box>
  );
}

4. Touch-Optimized Toolbar

// frontend/src/components/MobileToolbar.jsx
export function MobileToolbar({ currentTool, onToolChange }) {
  const [drawerOpen, setDrawerOpen] = useState(false);
  const isMobile = useMediaQuery('(max-width: 768px)');


  return (
    <>
      <Fab
        color="primary"
        sx={{ position: 'fixed', bottom: 16, right: 16 }}
        onClick={() => setDrawerOpen(true)}
      >
        <PaletteIcon />
      </Fab>

      <Drawer
        anchor="bottom"
        open={drawerOpen}
        onClose={() => setDrawerOpen(false)}
      >
        <Box p={2}>
          <Typography variant="h6" gutterBottom>
            Drawing Tools
          </Typography>
          
          <Grid container spacing={2}>
            {tools.map(tool => (
              <Grid item xs={3} key={tool.id}>
                <Button
                  variant={currentTool === tool.id ? 'contained' : 'outlined'}
                  fullWidth
                  onClick={() => {
                    onToolChange(tool.id);
                    setDrawerOpen(false);
                  }}
                  sx={{ height: 80, flexDirection: 'column' }}
                >
                  {tool.icon}
                  <Typography variant="caption">{tool.label}</Typography>
                </Button>
              </Grid>
            ))}
          </Grid>

          <Divider sx={{ my: 2 }} />

          {/* Color picker */}
          <Typography variant="subtitle2" gutterBottom>
            Color
          </Typography>
          <ColorPicker />

          {/* Brush size */}
          <Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
            Brush Size
          </Typography>
          <Slider
            min={1}
            max={20}
            defaultValue={5}
            marks
            valueLabelDisplay="auto"
          />
        </Box>
      </Drawer>
    </>
  );
}

5. Multi-Touch Drawing Support

// frontend/src/components/Canvas.js (modifications)
class Canvas {
  enableMultiTouch() {
    this.activeStrokes = new Map(); // touchId -> stroke
    
    this.canvas.addEventListener('touchstart', (e) => {
      for (const touch of e.changedTouches) {
          this.startStrokeForTouch(touch);
        }
      }
    });

    this.canvas.addEventListener('touchmove', (e) => {
      for (const touch of e.changedTouches) {
        if (this.activeStrokes.has(touch.identifier)) {
          this.continueStrokeForTouch(touch);
        }
      }
    });

    this.canvas.addEventListener('touchend', (e) => {
      for (const touch of e.changedTouches) {
        this.endStrokeForTouch(touch);
      }
    });
  }

  startStrokeForTouch(touch) {
    const point = this.getTouchPoint(touch);
    const stroke = {
      id: generateId(),
      points: [point],
      color: this.currentColor,
      width: this.currentWidth,
      touchId: touch.identifier
    };
    this.activeStrokes.set(touch.identifier, stroke);
  }

  continueStrokeForTouch(touch) {
    const stroke = this.activeStrokes.get(touch.identifier);
    if (stroke) {
      const point = this.getTouchPoint(touch);
      stroke.points.push(point);
      this.renderStroke(stroke);
    }
  }

  endStrokeForTouch(touch) {
    const stroke = this.activeStrokes.get(touch.identifier);
    if (stroke) {
      this.commitStroke(stroke);
      this.activeStrokes.delete(touch.identifier);
    }
  }
}

6. Smooth Zoom Animation

// frontend/src/utils/SmoothZoom.js
export class SmoothZoom {
  constructor(canvas) {
    this.canvas = canvas;
    this.targetZoom = 1;
    this.currentZoom = 1;
    this.animating = false;
  }

  setZoom(targetZoom, animate = true) {
    this.targetZoom = Math.max(0.1, Math.min(10, targetZoom));
    
    if (animate) {
      this.startAnimation();
    } else {
      this.currentZoom = this.targetZoom;
      this.canvas.zoom = this.currentZoom;
    }
  }

  startAnimation() {
    if (this.animating) return;
    
    this.animating = true;
    this.animate();
  }

  animate() {
    const diff = this.targetZoom - this.currentZoom;
    
    if (Math.abs(diff) < 0.01) {
      this.currentZoom = this.targetZoom;
      this.canvas.zoom = this.currentZoom;
      this.animating = false;
      return;
    }

    this.currentZoom += diff * 0.2; // Easing
    this.canvas.zoom = this.currentZoom;
    
    requestAnimationFrame(() => this.animate());
  }
}

Mobile-Specific Optimizations

1. Reduce Rendering Quality on Mobile

const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
const renderQuality = isMobile ? 'low' : 'high';

2. Touch-Friendly Hit Targets

  • Minimum 44x44px touch targets
  • Increased spacing between toolbar buttons
  • Larger color swatches

3. Prevent Overscroll

body {
  overscroll-behavior: none;
  touch-action: none;
}

Files to Create/Modify

Frontend:

  • frontend/src/utils/TouchGestureHandler.js ⭐ (NEW)
  • frontend/src/utils/PalmRejection.js ⭐ (NEW)
  • frontend/src/utils/SmoothZoom.js ⭐ (NEW)
  • frontend/src/components/MobileZoomControls.jsx ⭐ (NEW)
  • frontend/src/components/MobileToolbar.jsx ⭐ (NEW)
  • frontend/src/components/Canvas.js (MODIFY - add touch support)

Benefits

  • Native mobile app feel
  • Intuitive gesture controls
  • Better tablet/stylus support
  • Improved accessibility
  • Professional mobile UX
  • Competitive with native apps

Testing Requirements

  • Test on iOS Safari
  • Test on Android Chrome
  • Test with iPad + Apple Pencil
  • Test with Samsung tablets + S Pen
  • Verify palm rejection accuracy
  • Test multi-user mobile sessions

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions