Skip to content

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

@bchou9

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

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