Files
advanced-camera-card/src/components-lib/ptz/drag-controller.ts
T

287 lines
7.5 KiB
TypeScript

import {
createGesture,
dragAction,
pinchAction,
wheelAction,
type Handler,
} from '@use-gesture/vanilla';
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js';
import type {
PTZAction,
PTZActionPhase,
PTZPanAction,
PTZTiltAction,
PTZZoomAction,
} from '../../config/schema/actions/custom/ptz.js';
import { createPTZAction } from '../../utils/action.js';
// Minimum drag distance (px) before the gesture is recognized as a drag.
const DRAG_THRESHOLD = 50;
// Drag distance (px) at which continuous PTZ start/stop begins. Below this
// threshold a single relative "nudge" is dispatched on release instead.
const CONTINUOUS_THRESHOLD = 100;
const CURSOR_GRAB = 'grab';
const CURSOR_GRABBING = 'grabbing';
export class PTZDragController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
// The DOM element gestures bind to. Distinct from _host because the host's
// shadow DOM may contain siblings (e.g. PTZ overlay) that should not
// receive gesture events.
private _gestureElement: HTMLElement | null = null;
// The @use-gesture Recognizer instance.
private _gesture: ReturnType<ReturnType<typeof createGesture>> | null = null;
// Whether gesture handling is active (triggers host re-render on change).
private _active = false;
// Currently running continuous pan/tilt directions.
private _activeX: PTZPanAction | null = null;
private _activeY: PTZTiltAction | null = null;
// Whether the current drag has crossed CONTINUOUS_THRESHOLD and entered
// continuous mode. Below that threshold, drag-end dispatches relative.
private _continuous = false;
// When a pinch occurs mid-drag, the drag is "poisoned" -- all remaining
// drag events for that gesture are ignored to prevent stray pan/tilt.
private _dragCancelledByPinch = false;
// Currently running continuous zoom (pinch).
private _activeZoom: PTZZoomAction | null = null;
// Original element styles restored on deactivate.
private _savedTouchAction = '';
private _savedCursor = '';
constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
this._host.addController(this);
}
public hostDisconnected(): void {
this.deactivateIfNecessary();
}
public activateIfNecessary(element: HTMLElement): void {
if (this._active) {
return;
}
this._gestureElement = element;
this._applyGestureStyles(element);
const gesture = createGesture([dragAction, pinchAction, wheelAction]);
this._gesture = gesture(
element,
{
onDrag: this._onDrag,
onPinch: this._onPinch,
onWheel: this._onWheel,
onPointerDown: () => this._setCursor(true),
onPointerUp: () => this._setCursor(false),
onPointerLeave: () => this._setCursor(false),
onPointerCancel: () => this._setCursor(false),
},
{
drag: {
threshold: DRAG_THRESHOLD,
filterTaps: true,
},
},
);
this._active = true;
this._host.requestUpdate();
}
public deactivateIfNecessary(): void {
if (!this._active) {
return;
}
this._gesture?.destroy();
this._gesture = null;
this._restoreGestureStyles();
this._gestureElement = null;
this._stopAllDirections();
this._stopZoom();
this._continuous = false;
this._dragCancelledByPinch = false;
this._active = false;
this._host.requestUpdate();
}
private _applyGestureStyles(element: HTMLElement): void {
this._savedCursor = element.style.cursor;
this._savedTouchAction = element.style.touchAction;
element.style.cursor = CURSOR_GRAB;
element.style.touchAction = 'none';
}
private _restoreGestureStyles(): void {
/* v8 ignore next: only called when gesture is active -- @preserve */
if (this._gestureElement) {
this._gestureElement.style.cursor = this._savedCursor;
this._gestureElement.style.touchAction = this._savedTouchAction;
}
}
private _setCursor(grabbing: boolean): void {
/* v8 ignore next: only called when gesture is active -- @preserve */
if (this._gestureElement) {
this._gestureElement.style.cursor = grabbing ? CURSOR_GRABBING : CURSOR_GRAB;
}
}
private _onDrag: Handler<'drag'> = (state) => {
if (state.pinching) {
this._stopAllDirections();
this._dragCancelledByPinch = true;
return;
}
if (this._dragCancelledByPinch) {
if (state.last) {
this._dragCancelledByPinch = false;
}
return;
}
const [mx, my] = state.movement;
if (state.last) {
if (this._continuous) {
this._stopAllDirections();
this._continuous = false;
} else {
// Short drag: dispatch a single relative action per axis.
this._dispatchRelative(mx, my);
}
return;
}
const distance = Math.sqrt(mx * mx + my * my);
if (!this._continuous && distance >= CONTINUOUS_THRESHOLD) {
this._continuous = true;
}
if (!this._continuous) {
return;
}
// Inverted: dragging right sends PTZ left ("grab the scene").
const wantX: PTZPanAction | null = mx > 0 ? 'left' : mx < 0 ? 'right' : null;
const wantY: PTZTiltAction | null = my > 0 ? 'up' : my < 0 ? 'down' : null;
if (wantX !== this._activeX) {
if (this._activeX) {
this._dispatch(this._activeX, 'stop');
}
this._activeX = wantX;
if (wantX) {
this._dispatch(wantX, 'start');
}
}
if (wantY !== this._activeY) {
if (this._activeY) {
this._dispatch(this._activeY, 'stop');
}
this._activeY = wantY;
if (wantY) {
this._dispatch(wantY, 'start');
}
}
};
private _onPinch: Handler<'pinch'> = (state) => {
const direction = state.direction[0];
if (state.last) {
this._stopZoom();
return;
}
if (direction === 0) {
return;
}
const action: PTZZoomAction = direction > 0 ? 'zoom_in' : 'zoom_out';
if (action !== this._activeZoom) {
this._stopZoom();
this._activeZoom = action;
this._dispatch(action, 'start');
}
};
private _onWheel: Handler<'wheel'> = (state) => {
// delta[1] is the vertical (Y-axis) scroll amount.
const dy = state.delta[1];
if (dy === 0) {
return;
}
this._dispatch(dy > 0 ? 'zoom_out' : 'zoom_in');
};
private _dispatch(action: PTZAction | null, phase?: PTZActionPhase): void {
/* v8 ignore next: all call sites guard against this -- @preserve */
if (!action) {
return;
}
dispatchActionExecutionRequest(this._host, {
actions: createPTZAction({
ptzAction: action,
...(phase && { ptzPhase: phase }),
}),
});
}
// Dispatch relative actions from movement values. Inverted: positive X (drag
// right) sends PTZ left.
private _dispatchRelative(mx: number, my: number): void {
if (mx > 0) {
this._dispatch('left');
} else if (mx < 0) {
this._dispatch('right');
}
if (my > 0) {
this._dispatch('up');
} else if (my < 0) {
this._dispatch('down');
}
}
private _stopAllDirections(): void {
if (this._activeX) {
this._dispatch(this._activeX, 'stop');
this._activeX = null;
}
if (this._activeY) {
this._dispatch(this._activeY, 'stop');
this._activeY = null;
}
}
private _stopZoom(): void {
if (!this._activeZoom) {
return;
}
this._dispatch(this._activeZoom, 'stop');
this._activeZoom = null;
}
}