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
68 changes: 59 additions & 9 deletions src/components/Trackpad/ExtraKeys.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better touch handling with pointer events and movement threshold improves accuracy and UX.

Original file line number Diff line number Diff line change
@@ -1,26 +1,76 @@
import React from 'react';
import React, { useState, useRef } from 'react';

interface ExtraKeysProps {
sendKey: (key: string) => void;
onInputFocus: () => void;
}

const KEYS = ['Esc', 'Tab', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Home', 'End', 'PgUp', 'PgDn', 'Del'];
const MOVE_THRESHOLD = 10;

export const ExtraKeys: React.FC<ExtraKeysProps> = ({ sendKey, onInputFocus }) => {
const handleInteract = (e: React.PointerEvent, key: string) => {
export const ExtraKeys: React.FC<ExtraKeysProps> = ({ sendKey }) => {
const [activeKey, setActiveKey] = useState<string | null>(null);
const startPosRef = useRef<{ x: number; y: number } | null>(null);
const hasMoved = useRef(false);

const handlePointerDown = (e: React.PointerEvent, key: string) => {
// Prevent ALL focus changes
e.preventDefault();
sendKey(key.toLowerCase());
onInputFocus();
e.stopPropagation();
startPosRef.current = { x: e.clientX, y: e.clientY };
hasMoved.current = false;
setActiveKey(key);
};

const handlePointerMove = (e: React.PointerEvent) => {
if (!startPosRef.current) return;
const dx = Math.abs(e.clientX - startPosRef.current.x);
const dy = Math.abs(e.clientY - startPosRef.current.y);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
hasMoved.current = true;
setActiveKey(null);
}
};

const handlePointerUp = (e: React.PointerEvent, key: string) => {
// Prevent focus changes on up event too
e.preventDefault();
e.stopPropagation();

if (!hasMoved.current && activeKey === key) {
sendKey(key.toLowerCase());
}
startPosRef.current = null;
hasMoved.current = false;
setActiveKey(null);
};

const handlePointerLeave = () => {
startPosRef.current = null;
hasMoved.current = false;
setActiveKey(null);
};

return (
<div className="bg-base-300 p-2 overflow-x-auto whitespace-nowrap shrink-0 flex gap-2 hide-scrollbar">
<div className="bg-base-300 py-2 px-2 overflow-x-auto whitespace-nowrap shrink-0 flex gap-2 hide-scrollbar border-t border-base-content/10">
{KEYS.map(k => (
<button
key={k}
className="btn btn-sm btn-neutral min-w-[3rem]"
onPointerDown={(e) => handleInteract(e, k)}
className={`
min-w-14 h-10 rounded-lg text-sm font-semibold
flex items-center justify-center
transition-all duration-100
select-none touch-manipulation
shadow-sm
${activeKey === k
? 'bg-primary text-primary-content scale-95 shadow-inner'
: 'bg-base-100 text-base-content hover:bg-base-200 active:scale-95'
}
`}
onPointerDown={(e) => handlePointerDown(e, k)}
onPointerMove={handlePointerMove}
onPointerUp={(e) => handlePointerUp(e, k)}
onPointerLeave={handlePointerLeave}
onPointerCancel={handlePointerLeave}
>
{k}
</button>
Expand Down
77 changes: 45 additions & 32 deletions src/routes/trackpad.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better keyboard state handling improves mobile behavior and prevents bugs.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useRemoteConnection } from '../hooks/useRemoteConnection';
import { useTrackpadGesture } from '../hooks/useTrackpadGesture';
import { ControlBar } from '../components/Trackpad/ControlBar';
Expand All @@ -13,17 +13,53 @@ export const Route = createFileRoute('/trackpad')({
function TrackpadPage() {
const [scrollMode, setScrollMode] = useState(false);
const hiddenInputRef = useRef<HTMLInputElement>(null);
// Use ref for immediate sync access (no async state delay)
const keyboardOpenRef = useRef(false);

const { status, send } = useRemoteConnection();
const { isTracking, handlers } = useTrackpadGesture(send, scrollMode);

const focusInput = () => {
hiddenInputRef.current?.focus();
};
// Sync keyboard state from viewport resize (handles back button dismissal)
useEffect(() => {
const viewport = window.visualViewport;
if (!viewport) return;

const originalHeight = viewport.height;

const syncKeyboardState = () => {
if(viewport.height < originalHeight*0.85){
keyboardOpenRef.current = true;
}else{
keyboardOpenRef.current = false;
//because there are two ways to close the keyboard.
//1. by pressing back button
//2. by pressing "keyboard toggle" button
//input.blur(); is called for "keyboard toggle" button, but it was never called for back button.
//which caused bugs, this ensures that input.blur(); is called for back button as well
hiddenInputRef.current?.blur();
}
};

viewport.addEventListener('resize', syncKeyboardState);
return () => viewport.removeEventListener('resize', syncKeyboardState);
}, []);

const toggleKeyboard = useCallback(() => {
const input = hiddenInputRef.current;
if (!input) return;

// Read from ref for immediate accurate value
if (keyboardOpenRef.current) {
input.blur();
keyboardOpenRef.current = false;
} else {
input.focus();
keyboardOpenRef.current = true;
}
}, []);

const handleClick = (button: 'left' | 'right') => {
send({ type: 'click', button, press: true });
// Release after short delay to simulate click
setTimeout(() => send({ type: 'click', button, press: false }), 50);
};

Expand All @@ -44,54 +80,31 @@ function TrackpadPage() {
}
};

const handleContainerClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
e.preventDefault();
focusInput();
}
};

return (
<div
className="flex flex-col h-full overflow-hidden"
onClick={handleContainerClick}
>
{/* Touch Surface */}
<div className="flex flex-col h-full overflow-hidden">

<TouchArea
isTracking={isTracking}
scrollMode={scrollMode}
handlers={handlers}
status={status}
/>

{/* Controls */}
<ControlBar
scrollMode={scrollMode}
onToggleScroll={() => setScrollMode(!scrollMode)}
onLeftClick={() => handleClick('left')}
onRightClick={() => handleClick('right')}
onKeyboardToggle={focusInput}
onKeyboardToggle={toggleKeyboard}
/>

{/* Extra Keys */}
<ExtraKeys
sendKey={(k) => send({ type: 'key', key: k })}
onInputFocus={focusInput}
/>

{/* Hidden Input for Mobile Keyboard */}
<ExtraKeys sendKey={(k) => send({ type: 'key', key: k })} />
<input
ref={hiddenInputRef}
className="opacity-0 absolute bottom-0 pointer-events-none h-0 w-0"
onKeyDown={handleKeyDown}
onChange={handleInput}
onBlur={() => {
setTimeout(() => hiddenInputRef.current?.focus(), 10);
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
autoFocus // Attempt autofocus on mount
/>
</div>
)
Expand Down