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> | 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; } }