feat: add shared runtime input device layer
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
19
src/services/input-devices/index.ts
Normal file
19
src/services/input-devices/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
createRuntimeDragInputController,
|
||||
type RuntimeDragInputControllerOptions,
|
||||
type RuntimeDragInputMove,
|
||||
type RuntimeDragInputPress,
|
||||
type RuntimeDragInputRelease,
|
||||
type RuntimeDragInputSession,
|
||||
type RuntimeInputDeviceKind,
|
||||
type RuntimeInputPoint,
|
||||
} from './runtimeDragInputController';
|
||||
export {
|
||||
createRuntimeInputPointFromClient,
|
||||
createRuntimeInputPointFromNormalized,
|
||||
readRuntimeInputElementBounds,
|
||||
resolveRuntimeInputGridCell,
|
||||
type RuntimeInputBounds,
|
||||
type RuntimeInputGridCell,
|
||||
type RuntimeInputGridSpec,
|
||||
} from './runtimeInputGeometry';
|
||||
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal file
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createRuntimeDragInputController,
|
||||
createRuntimeInputPointFromNormalized,
|
||||
resolveRuntimeInputGridCell,
|
||||
} from './index';
|
||||
|
||||
describe('runtime drag input controller', () => {
|
||||
test('pointer-like short press remains a tap', () => {
|
||||
const onTap = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 12,
|
||||
onTap,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'pointer:1',
|
||||
deviceKind: 'pointer',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.move({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 14, clientY: 14 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 14, clientY: 14 },
|
||||
});
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ targetId: 'piece-1', dragging: false }),
|
||||
);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('device adapters can force continuous drag semantics', () => {
|
||||
const onDragStart = vi.fn();
|
||||
const onDragMove = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 100,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'mocap:hand',
|
||||
deviceKind: 'mocap',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.move({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 11, clientY: 11 },
|
||||
forceDragging: true,
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 12, clientY: 12 },
|
||||
});
|
||||
|
||||
expect(onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect(onDragMove).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceKind: 'mocap', dragging: true }),
|
||||
);
|
||||
});
|
||||
|
||||
test('device adapters can force drop on release without converting to tap', () => {
|
||||
const onTap = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 100,
|
||||
onTap,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'mocap:hand',
|
||||
deviceKind: 'mocap',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
forceDrop: true,
|
||||
});
|
||||
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'piece-1',
|
||||
forceDrop: true,
|
||||
dragging: false,
|
||||
}),
|
||||
);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('input-scoped cancel keeps unrelated active sessions alive', () => {
|
||||
const onCancel = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 1,
|
||||
onCancel,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'pointer:1',
|
||||
deviceKind: 'pointer',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.cancel('mocap:hand');
|
||||
controller.move({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 20, clientY: 20 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 20, clientY: 20 },
|
||||
});
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ inputId: 'pointer:1', targetId: 'piece-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime input geometry', () => {
|
||||
test('normalised device coordinates map into client coordinates and grid cells', () => {
|
||||
const point = createRuntimeInputPointFromNormalized(0.75, 0.25, {
|
||||
left: 20,
|
||||
top: 10,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
expect(point).toEqual({
|
||||
clientX: 170,
|
||||
clientY: 35,
|
||||
normalizedX: 0.75,
|
||||
normalizedY: 0.25,
|
||||
});
|
||||
expect(resolveRuntimeInputGridCell(point, { rows: 4, cols: 4 })).toEqual({
|
||||
row: 1,
|
||||
col: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/services/input-devices/runtimeDragInputController.ts
Normal file
168
src/services/input-devices/runtimeDragInputController.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type RuntimeInputDeviceKind = 'pointer' | 'mocap' | 'keyboard' | 'unknown';
|
||||
|
||||
export type RuntimeInputPoint = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
normalizedX?: number;
|
||||
normalizedY?: number;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputSession<TTargetId extends string = string> = {
|
||||
targetId: TTargetId;
|
||||
inputId: string;
|
||||
deviceKind: RuntimeInputDeviceKind;
|
||||
startPoint: RuntimeInputPoint;
|
||||
currentPoint: RuntimeInputPoint;
|
||||
dragging: boolean;
|
||||
forceDrop: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputPress<TTargetId extends string = string> = {
|
||||
targetId: TTargetId;
|
||||
inputId: string;
|
||||
deviceKind: RuntimeInputDeviceKind;
|
||||
point: RuntimeInputPoint;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputMove = {
|
||||
inputId: string;
|
||||
point: RuntimeInputPoint;
|
||||
forceDragging?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputRelease = {
|
||||
inputId: string;
|
||||
point: RuntimeInputPoint;
|
||||
forceDrop?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputControllerOptions<
|
||||
TTargetId extends string = string,
|
||||
> = {
|
||||
dragThresholdPx?: number;
|
||||
onPress?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDragStart?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDragMove?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDrop?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onTap?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onCancel?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DRAG_THRESHOLD_PX = 8;
|
||||
|
||||
function clonePoint(point: RuntimeInputPoint): RuntimeInputPoint {
|
||||
return { ...point };
|
||||
}
|
||||
|
||||
function shouldStartDragging(
|
||||
session: RuntimeDragInputSession,
|
||||
point: RuntimeInputPoint,
|
||||
thresholdPx: number,
|
||||
forceDragging = false,
|
||||
) {
|
||||
if (session.dragging || forceDragging) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.hypot(
|
||||
point.clientX - session.startPoint.clientX,
|
||||
point.clientY - session.startPoint.clientY,
|
||||
) >= thresholdPx
|
||||
);
|
||||
}
|
||||
|
||||
export function createRuntimeDragInputController<
|
||||
TTargetId extends string = string,
|
||||
>(initialOptions: RuntimeDragInputControllerOptions<TTargetId> = {}) {
|
||||
let options = initialOptions;
|
||||
let session: RuntimeDragInputSession<TTargetId> | null = null;
|
||||
|
||||
const setOptions = (
|
||||
nextOptions: RuntimeDragInputControllerOptions<TTargetId>,
|
||||
) => {
|
||||
options = nextOptions;
|
||||
};
|
||||
|
||||
const cancel = (inputId?: string) => {
|
||||
if (inputId && session?.inputId !== inputId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = session;
|
||||
session = null;
|
||||
if (activeSession) {
|
||||
options.onCancel?.(activeSession);
|
||||
}
|
||||
};
|
||||
|
||||
const press = (input: RuntimeDragInputPress<TTargetId>) => {
|
||||
cancel();
|
||||
session = {
|
||||
targetId: input.targetId,
|
||||
inputId: input.inputId,
|
||||
deviceKind: input.deviceKind,
|
||||
startPoint: clonePoint(input.point),
|
||||
currentPoint: clonePoint(input.point),
|
||||
dragging: false,
|
||||
forceDrop: false,
|
||||
};
|
||||
options.onPress?.(session);
|
||||
return session;
|
||||
};
|
||||
|
||||
const move = (input: RuntimeDragInputMove) => {
|
||||
if (!session || session.inputId !== input.inputId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wasDragging = session.dragging;
|
||||
session = {
|
||||
...session,
|
||||
currentPoint: clonePoint(input.point),
|
||||
dragging: shouldStartDragging(
|
||||
session,
|
||||
input.point,
|
||||
options.dragThresholdPx ?? DEFAULT_DRAG_THRESHOLD_PX,
|
||||
input.forceDragging,
|
||||
),
|
||||
};
|
||||
|
||||
if (!wasDragging && session.dragging) {
|
||||
options.onDragStart?.(session);
|
||||
}
|
||||
if (session.dragging) {
|
||||
options.onDragMove?.(session);
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
const release = (input: RuntimeDragInputRelease) => {
|
||||
if (!session || session.inputId !== input.inputId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedSession = {
|
||||
...session,
|
||||
currentPoint: clonePoint(input.point),
|
||||
forceDrop: input.forceDrop === true,
|
||||
};
|
||||
session = null;
|
||||
if (completedSession.dragging || completedSession.forceDrop) {
|
||||
options.onDrop?.(completedSession);
|
||||
} else {
|
||||
options.onTap?.(completedSession);
|
||||
}
|
||||
return completedSession;
|
||||
};
|
||||
|
||||
return {
|
||||
cancel,
|
||||
getSession: () => session,
|
||||
move,
|
||||
press,
|
||||
release,
|
||||
setOptions,
|
||||
};
|
||||
}
|
||||
142
src/services/input-devices/runtimeInputGeometry.ts
Normal file
142
src/services/input-devices/runtimeInputGeometry.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { RuntimeInputPoint } from './runtimeDragInputController';
|
||||
|
||||
export type RuntimeInputBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type RuntimeInputGridSpec = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
};
|
||||
|
||||
export type RuntimeInputGridCell = {
|
||||
row: number;
|
||||
col: number;
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: number) {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function hasUsableBounds(
|
||||
bounds: RuntimeInputBounds | null | undefined,
|
||||
): bounds is RuntimeInputBounds {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
isFiniteNumber(bounds.left) &&
|
||||
isFiniteNumber(bounds.top) &&
|
||||
isFiniteNumber(bounds.width) &&
|
||||
isFiniteNumber(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function readRuntimeInputElementBounds(
|
||||
element: Element | null | undefined,
|
||||
): RuntimeInputBounds | null {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeInputPointFromClient(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputPoint {
|
||||
if (!hasUsableBounds(bounds)) {
|
||||
return { clientX, clientY };
|
||||
}
|
||||
|
||||
return {
|
||||
clientX,
|
||||
clientY,
|
||||
normalizedX: (clientX - bounds.left) / bounds.width,
|
||||
normalizedY: (clientY - bounds.top) / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeInputPointFromNormalized(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputPoint {
|
||||
const x = clamp01(normalizedX);
|
||||
const y = clamp01(normalizedY);
|
||||
if (!hasUsableBounds(bounds)) {
|
||||
return {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
normalizedX: x,
|
||||
normalizedY: y,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clientX: bounds.left + x * bounds.width,
|
||||
clientY: bounds.top + y * bounds.height,
|
||||
normalizedX: x,
|
||||
normalizedY: y,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeInputGridCell(
|
||||
point: RuntimeInputPoint,
|
||||
grid: RuntimeInputGridSpec,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputGridCell | null {
|
||||
if (grid.rows <= 0 || grid.cols <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedX =
|
||||
typeof point.normalizedX === 'number'
|
||||
? point.normalizedX
|
||||
: hasUsableBounds(bounds)
|
||||
? (point.clientX - bounds.left) / bounds.width
|
||||
: null;
|
||||
const normalizedY =
|
||||
typeof point.normalizedY === 'number'
|
||||
? point.normalizedY
|
||||
: hasUsableBounds(bounds)
|
||||
? (point.clientY - bounds.top) / bounds.height
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedX === null ||
|
||||
normalizedY === null ||
|
||||
!isFiniteNumber(normalizedX) ||
|
||||
!isFiniteNumber(normalizedY) ||
|
||||
normalizedX < 0 ||
|
||||
normalizedX > 1 ||
|
||||
normalizedY < 0 ||
|
||||
normalizedY > 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
row: Math.min(grid.rows - 1, Math.floor(normalizedY * grid.rows)),
|
||||
col: Math.min(grid.cols - 1, Math.floor(normalizedX * grid.cols)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user