Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function InputCapture({

{allowFullscreen && controlState === 'granted' && (
<button
data-pairux-local-control
onClick={toggleFullscreen}
className="absolute right-2 top-2 z-10 rounded-lg bg-black/40 p-2 text-white/80 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen + pointer lock'}
Expand Down
28 changes: 19 additions & 9 deletions apps/desktop/src/renderer/hooks/useRemoteControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
MouseButton,
ControlStateUI,
} from '@pairux/shared-types';
import { modifiersFromDomEvent } from '@pairux/shared-types';
import {
isLocalControlTarget,
modifiersFromDomEvent,
shouldIgnoreFollowUpMouse,
} from '@pairux/shared-types';
import { getAccelPlatform } from '@/lib/viewerPlatform';

interface UseRemoteControlOptions {
Expand Down Expand Up @@ -74,6 +78,8 @@ export function useRemoteControl({
// Get relative coordinates (0-1) from a mouse event
const getRelativeCoords = useCallback(
(event?: MouseEvent): { x: number; y: number } | null => {
if (event && isLocalControlTarget(event.target)) return null;

if (pointerLockPosition?.current) {
const pos = pointerLockPosition.current;
return { x: pos.x, y: pos.y };
Expand All @@ -99,7 +105,9 @@ export function useRemoteControl({

// Handle mouse move
const handleMouseMove = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;

Expand Down Expand Up @@ -129,11 +137,11 @@ export function useRemoteControl({

// Handle mouse down
const handleMouseDown = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (!canSendInput) return;

// Skip if a pointer event just fired (Chromium fires both).
if (Date.now() - lastPointerEventRef.current < 100) return;
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;
Expand Down Expand Up @@ -169,11 +177,11 @@ export function useRemoteControl({

// Handle mouse up
const handleMouseUp = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (!canSendInput) return;

// Skip if a pointer event just fired.
if (Date.now() - lastPointerEventRef.current < 100) return;
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;
Expand Down Expand Up @@ -247,6 +255,7 @@ export function useRemoteControl({
const handleKeyDown = useCallback(
(event: globalThis.KeyboardEvent) => {
if (!canSendInput) return;
if (isLocalControlTarget(event.target)) return;

// Don't capture browser shortcuts
if (event.altKey && event.key === 'Tab') return;
Expand All @@ -273,6 +282,7 @@ export function useRemoteControl({
const handleKeyUp = useCallback(
(event: globalThis.KeyboardEvent) => {
if (!canSendInput) return;
if (isLocalControlTarget(event.target)) return;

const inputEvent: KeyboardInputEvent = {
type: 'keyboard',
Expand Down Expand Up @@ -302,24 +312,24 @@ export function useRemoteControl({
// the timestamp so the mouse-handler dedup skips the follow-up mouse event.
const handlePointerDown = useCallback(
(event: PointerEvent) => {
handleMouseDown(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseDown(event as unknown as MouseEvent);
},
[handleMouseDown]
);

const handlePointerUp = useCallback(
(event: PointerEvent) => {
handleMouseUp(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseUp(event as unknown as MouseEvent);
},
[handleMouseUp]
);

const handlePointerMove = useCallback(
(event: PointerEvent) => {
handleMouseMove(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseMove(event as unknown as MouseEvent);
},
[handleMouseMove]
);
Expand Down
28 changes: 19 additions & 9 deletions apps/web/src/hooks/useRemoteControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import type {
MouseButton,
ControlStateUI,
} from '@pairux/shared-types';
import { modifiersFromDomEvent } from '@pairux/shared-types';
import {
isLocalControlTarget,
modifiersFromDomEvent,
shouldIgnoreFollowUpMouse,
} from '@pairux/shared-types';
import { getAccelPlatform } from '@/lib/viewerPlatform';

interface UseRemoteControlOptions {
Expand Down Expand Up @@ -68,6 +72,8 @@ export function useRemoteControl({
// Get relative coordinates (0-1) from a mouse event
const getRelativeCoords = useCallback(
(event?: MouseEvent): { x: number; y: number } | null => {
if (event && isLocalControlTarget(event.target)) return null;

if (pointerLockPosition?.current) {
const pos = pointerLockPosition.current;
return { x: pos.x, y: pos.y };
Expand All @@ -93,7 +99,9 @@ export function useRemoteControl({

// Handle mouse move
const handleMouseMove = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;

Expand Down Expand Up @@ -122,11 +130,11 @@ export function useRemoteControl({

// Handle mouse down
const handleMouseDown = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (!canSendInput) return;

// Skip if a pointer event just fired (Chromium fires both).
if (Date.now() - lastPointerEventRef.current < 100) return;
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;
Expand Down Expand Up @@ -162,11 +170,11 @@ export function useRemoteControl({

// Handle mouse up
const handleMouseUp = useCallback(
(event: MouseEvent) => {
(event: MouseEvent, fromPointer = false) => {
if (!canSendInput) return;

// Skip if a pointer event just fired.
if (Date.now() - lastPointerEventRef.current < 100) return;
if (shouldIgnoreFollowUpMouse(lastPointerEventRef.current, Date.now(), fromPointer)) return;

const coords = getRelativeCoords(event);
if (!coords) return;
Expand Down Expand Up @@ -240,6 +248,7 @@ export function useRemoteControl({
const handleKeyDown = useCallback(
(event: globalThis.KeyboardEvent) => {
if (!canSendInput) return;
if (isLocalControlTarget(event.target)) return;

// Don't capture browser shortcuts
if (event.altKey && event.key === 'Tab') return;
Expand All @@ -266,6 +275,7 @@ export function useRemoteControl({
const handleKeyUp = useCallback(
(event: globalThis.KeyboardEvent) => {
if (!canSendInput) return;
if (isLocalControlTarget(event.target)) return;

const inputEvent: KeyboardInputEvent = {
type: 'keyboard',
Expand Down Expand Up @@ -295,24 +305,24 @@ export function useRemoteControl({
// the timestamp so the mouse-handler dedup skips the follow-up mouse event.
const handlePointerDown = useCallback(
(event: PointerEvent) => {
handleMouseDown(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseDown(event as unknown as MouseEvent);
},
[handleMouseDown]
);

const handlePointerUp = useCallback(
(event: PointerEvent) => {
handleMouseUp(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseUp(event as unknown as MouseEvent);
},
[handleMouseUp]
);

const handlePointerMove = useCallback(
(event: PointerEvent) => {
handleMouseMove(event as unknown as MouseEvent, true);
lastPointerEventRef.current = Date.now();
handleMouseMove(event as unknown as MouseEvent);
},
[handleMouseMove]
);
Expand Down
7 changes: 6 additions & 1 deletion packages/shared-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,12 @@ export type {
QualityPreset,
} from './input.js';

export { QUALITY_PRESETS, modifiersFromDomEvent } from './input.js';
export {
QUALITY_PRESETS,
isLocalControlTarget,
modifiersFromDomEvent,
shouldIgnoreFollowUpMouse,
} from './input.js';

// Voice audio settings shared by every client
export {
Expand Down
19 changes: 19 additions & 0 deletions packages/shared-types/src/input.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, it, expect } from 'vitest';
import {
QUALITY_PRESETS,
isLocalControlTarget,
modifiersFromDomEvent,
shouldIgnoreFollowUpMouse,
type MouseEvent,
type KeyboardEvent,
type InputMessage,
Expand Down Expand Up @@ -264,4 +266,21 @@
});
});
});

describe('remote input routing', () => {
it('processes pointer events before suppressing their follow-up mouse events', () => {
expect(shouldIgnoreFollowUpMouse(1_000, 1_000, true)).toBe(false);
expect(shouldIgnoreFollowUpMouse(1_000, 1_001, false)).toBe(true);
expect(shouldIgnoreFollowUpMouse(1_000, 1_100, false)).toBe(false);
});

it('recognizes controls that must stay local to the viewer', () => {
const local = { closest: () => ({ tagName: 'BUTTON' }) };

Check warning on line 278 in packages/shared-types/src/input.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
const remote = { closest: () => null };

Check warning on line 279 in packages/shared-types/src/input.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function

expect(isLocalControlTarget(local)).toBe(true);
expect(isLocalControlTarget(remote)).toBe(false);
expect(isLocalControlTarget(null)).toBe(false);
});
});
});
18 changes: 18 additions & 0 deletions packages/shared-types/src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ export function modifiersFromDomEvent(
};
}

const POINTER_MOUSE_DEDUP_MS = 100;

export function isLocalControlTarget(target: unknown): boolean {
if (target === null || typeof target !== 'object' || !('closest' in target)) return false;
const closest = target.closest;
return (
typeof closest === 'function' && closest.call(target, '[data-pairux-local-control]') !== null
);
}

export function shouldIgnoreFollowUpMouse(
lastPointerAt: number,
now: number,
fromPointer: boolean
): boolean {
return !fromPointer && now - lastPointerAt < POINTER_MOUSE_DEDUP_MS;
}

// Keyboard event
export interface KeyboardEvent {
type: 'keyboard';
Expand Down
Loading