Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"@use-gesture/react": "^10.3.1",
"lucide-react": "^0.561.0",
"nitro": "npm:nitro-nightly@latest",
"qrcode": "^1.5.4",
Expand Down
38 changes: 17 additions & 21 deletions src/components/Trackpad/TouchArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,38 @@ import React from 'react';
interface TouchAreaProps {
scrollMode: boolean;
isTracking: boolean;
handlers: {
onTouchStart: (e: React.TouchEvent) => void;
onTouchMove: (e: React.TouchEvent) => void;
onTouchEnd: (e: React.TouchEvent) => void;
};
handlers: any;
status: 'connecting' | 'connected' | 'disconnected';
}

export const TouchArea: React.FC<TouchAreaProps> = ({ scrollMode, isTracking, handlers, status }) => {
const handleStart = (e: React.TouchEvent) => {
handlers.onTouchStart(e);
};

const handlePreventFocus = (e: React.MouseEvent) => {
e.preventDefault();
};

export const TouchArea: React.FC<TouchAreaProps> = ({
scrollMode,
isTracking,
handlers,
status
}) => {
return (
<div
className="flex-1 bg-neutral-800 relative touch-none select-none flex items-center justify-center p-4"
onTouchStart={handleStart}
onTouchMove={handlers.onTouchMove}
onTouchEnd={handlers.onTouchEnd}
onMouseDown={handlePreventFocus}
{...handlers}
onContextMenu={(e) => e.preventDefault()}
>
<div className={`absolute top-0 left-0 w-full h-1 ${status === 'connected' ? 'bg-success' : 'bg-error'}`} />
<div
className={`absolute top-0 left-0 w-full h-1 ${
status === 'connected' ? 'bg-success' : 'bg-error'
}`}
/>

<div className="text-neutral-600 text-center pointer-events-none">
<div className="text-4xl mb-2 opacity-20">
{scrollMode ? 'Scroll Mode' : 'Touch Area'}
</div>
{isTracking && <div className="loading loading-ring loading-lg"></div>}
</div>

{scrollMode && (
<div className="absolute top-4 right-4 badge badge-info">SCROLL Active</div>
<div className="absolute top-4 right-4 badge badge-info">
SCROLL Active
</div>
)}
</div>
);
Expand Down
267 changes: 63 additions & 204 deletions src/hooks/useTrackpadGesture.ts
Original file line number Diff line number Diff line change
@@ -1,225 +1,84 @@
import { useRef, useState } from 'react';
import { TOUCH_MOVE_THRESHOLD, TOUCH_TIMEOUT, PINCH_THRESHOLD, calculateAccelerationMult } from '../utils/math';
import { useState, useRef } from 'react';
import { useGesture } from '@use-gesture/react';

interface TrackedTouch {
identifier: number;
pageX: number;
pageY: number;
pageXStart: number;
pageYStart: number;
timeStamp: number;
}
const MOVE_MULTIPLIER = 1.2;
const SCROLL_MULTIPLIER = 3.0;

const getTouchDistance = (a: TrackedTouch, b: TrackedTouch): number => {
const dx = a.pageX - b.pageX;
const dy = a.pageY - b.pageY;
return Math.sqrt(dx * dx + dy * dy);
};
const TAP_TIME = 200;
const TAP_MOVE_THRESHOLD = 6;

export const useTrackpadGesture = (
send: (msg: any) => void,
scrollMode: boolean,
sensitivity: number = 1.5
scrollMode: boolean
) => {
const [isTracking, setIsTracking] = useState(false);

// Refs for tracking state (avoids re-renders during rapid movement)
const ongoingTouches = useRef<TrackedTouch[]>([]);
const moved = useRef(false);
const startTimeStamp = useRef(0);
const lastEndTimeStamp = useRef(0);
const releasedCount = useRef(0);
const dragging = useRef(false);
const draggingTimeout = useRef<NodeJS.Timeout | null>(null);
const lastPinchDist = useRef<number | null>(null);
const pinching = useRef(false);

// Helpers
const findTouchIndex = (id: number) => ongoingTouches.current.findIndex(t => t.identifier === id);

const handleDraggingTimeout = () => {
draggingTimeout.current = null;
send({ type: 'click', button: 'left', press: false });
};

const handleTouchStart = (e: React.TouchEvent) => {
if (ongoingTouches.current.length === 0) {
startTimeStamp.current = e.timeStamp;
moved.current = false;
}

const touches = e.changedTouches;
for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
const tracked: TrackedTouch = {
identifier: touch.identifier,
pageX: touch.pageX,
pageY: touch.pageY,
pageXStart: touch.pageX,
pageYStart: touch.pageY,
timeStamp: e.timeStamp,
};
const idx = findTouchIndex(touch.identifier);
if (idx < 0) {
ongoingTouches.current.push(tracked);
} else {
ongoingTouches.current[idx] = tracked;
}
}

if (ongoingTouches.current.length === 2) {
lastPinchDist.current = getTouchDistance(ongoingTouches.current[0], ongoingTouches.current[1]);
pinching.current = false;
}

setIsTracking(true);
lastEndTimeStamp.current = 0;

// If we're in dragging timeout, convert to actual drag
if (draggingTimeout.current) {
clearTimeout(draggingTimeout.current);
draggingTimeout.current = null;
dragging.current = true;
}
};

const handleTouchMove = (e: React.TouchEvent) => {

const touches = e.changedTouches;
let sumX = 0;
let sumY = 0;

for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
const idx = findTouchIndex(touch.identifier);
if (idx < 0) continue;

const tracked = ongoingTouches.current[idx];

// Check if we've moved enough to consider this a "move" gesture
if (!moved.current) {
const dist = Math.sqrt(
Math.pow(touch.pageX - tracked.pageXStart, 2) +
Math.pow(touch.pageY - tracked.pageYStart, 2)
);
const threshold = ongoingTouches.current.length > TOUCH_MOVE_THRESHOLD.length
? TOUCH_MOVE_THRESHOLD[TOUCH_MOVE_THRESHOLD.length - 1]
: TOUCH_MOVE_THRESHOLD[ongoingTouches.current.length - 1];

if (dist > threshold || e.timeStamp - startTimeStamp.current >= TOUCH_TIMEOUT) {
moved.current = true;
const startTimeRef = useRef(0);
const movedRef = useRef(false);
const touchCountRef = useRef(1);

const bind = useGesture(
{
onPointerDown: () => {
startTimeRef.current = Date.now();
movedRef.current = false;
setIsTracking(true);
},

onDrag: ({ delta, touches }) => {
const [dx, dy] = delta;
touchCountRef.current = touches;

if (
Math.abs(dx) > TAP_MOVE_THRESHOLD ||
Math.abs(dy) > TAP_MOVE_THRESHOLD
) {
movedRef.current = true;
}
}

// Calculate delta with acceleration
const dx = touch.pageX - tracked.pageX;
const dy = touch.pageY - tracked.pageY;
const timeDelta = e.timeStamp - tracked.timeStamp;

if (timeDelta > 0) {
const speedX = Math.abs(dx) / timeDelta * 1000;
const speedY = Math.abs(dy) / timeDelta * 1000;
sumX += dx * calculateAccelerationMult(speedX);
sumY += dy * calculateAccelerationMult(speedY);
}

// Update tracked position
tracked.pageX = touch.pageX;
tracked.pageY = touch.pageY;
tracked.timeStamp = e.timeStamp;
}

// Send movement if we've moved and not in timeout period
if (moved.current && e.timeStamp - lastEndTimeStamp.current >= TOUCH_TIMEOUT) {
if (!scrollMode && ongoingTouches.current.length === 2) {
const dist = getTouchDistance(ongoingTouches.current[0], ongoingTouches.current[1]);
const delta = lastPinchDist.current !== null ? dist - lastPinchDist.current : 0;
if (pinching.current || Math.abs(delta) > PINCH_THRESHOLD) {
pinching.current = true;
lastPinchDist.current = dist;
send({ type: 'zoom', delta: delta * sensitivity });
if (touches === 2 || scrollMode) {
send({
type: 'scroll',
dx: dx * SCROLL_MULTIPLIER,
dy: dy * SCROLL_MULTIPLIER
});
} else {
lastPinchDist.current = dist;
send({ type: 'scroll', dx: -sumX * sensitivity, dy: -sumY * sensitivity });
send({
type: 'move',
dx: dx * MOVE_MULTIPLIER,
dy: dy * MOVE_MULTIPLIER
});
}
} else if (scrollMode) {
// Scroll mode: single finger scrolls, or two-finger scroll in cursor mode
send({ type: 'scroll', dx: -sumX * sensitivity, dy: -sumY * sensitivity });
} else if (ongoingTouches.current.length === 1 || dragging.current) {
// Cursor movement (only in cursor mode with 1 finger, or when dragging)
send({ type: 'move', dx: sumX * sensitivity, dy: sumY * sensitivity });
}
}
};

const handleTouchEnd = (e: React.TouchEvent) => {

const touches = e.changedTouches;

for (let i = 0; i < touches.length; i++) {
const idx = findTouchIndex(touches[i].identifier);
if (idx >= 0) {
ongoingTouches.current.splice(idx, 1);
releasedCount.current += 1;
}
}

lastEndTimeStamp.current = e.timeStamp;

if (ongoingTouches.current.length < 2) {
lastPinchDist.current = null;
pinching.current = false;
}

// Mark as moved if too many fingers
if (releasedCount.current > TOUCH_MOVE_THRESHOLD.length) {
moved.current = true;
}
},

// All fingers lifted
if (ongoingTouches.current.length === 0 && releasedCount.current >= 1) {
setIsTracking(false);
onPointerUp: () => {
setIsTracking(false);
const duration = Date.now() - startTimeRef.current;

// Release drag if active
if (dragging.current) {
dragging.current = false;
send({ type: 'click', button: 'left', press: false });
}

// Handle tap/click if not moved and within timeout
if (!moved.current && e.timeStamp - startTimeStamp.current < TOUCH_TIMEOUT) {
let button: 'left' | 'right' | 'middle' | null = null;
if (!movedRef.current && duration <= TAP_TIME) {
const button =
touchCountRef.current === 2 ? 'right' : 'left';

if (releasedCount.current === 1) {
button = 'left';
} else if (releasedCount.current === 2) {
button = 'right';
} else if (releasedCount.current === 3) {
button = 'middle';
}

if (button) {
send({ type: 'click', button, press: true });

// For left click, set up drag timeout
if (button === 'left') {
draggingTimeout.current = setTimeout(handleDraggingTimeout, TOUCH_TIMEOUT);
} else {
send({ type: 'click', button, press: false });
}
setTimeout(
() =>
send({
type: 'click',
button,
press: false
}),
40
);
}
}

releasedCount.current = 0;
},
{
drag: {
pointer: { touch: true },
threshold: 0
}
}
};
);

return {
isTracking,
handlers: {
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd
}
};
return { handlers: bind(), isTracking };
};
Loading