fix: Stop PTZ movement and browser key handling from fighting (#2674)

- Closes: #2623
This commit is contained in:
Dermot Duffy
2026-08-10 14:49:50 -07:00
committed by GitHub
parent a8aa9ec7c2
commit 3808a1b030
20 changed files with 822 additions and 148 deletions
@@ -14,14 +14,14 @@ import type { CardActionsAPI } from '../../types';
import { ZoomRequestViewModifier } from '../../view/modifiers/zoom-request';
import type { TargetedActionContext } from '../types';
import {
setInProgressForThisTarget,
stopInProgressForThisTarget,
clearInProgressForThisTarget,
replaceInProgressForThisTarget,
} from '../utils/action-state';
import { AdvancedCameraCardAction } from './base';
const STEP_DELAY_SECONDS = 0.1;
const STEP_ZOOM = 0.1;
const STEP_PAN = 5;
export const STEP_PAN = 5;
declare module 'action' {
interface ActionContext {
@@ -31,6 +31,7 @@ declare module 'action' {
export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialActionConfig> {
private _timer = new Timer();
private _stopped = false;
private async _stepChange(api: CardActionsAPI, targetID: string): Promise<void> {
api
@@ -46,6 +47,7 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
}
public async stop(): Promise<void> {
this._stopped = true;
this._timer.stop();
}
@@ -72,16 +74,20 @@ export class PTZDigitalAction extends AdvancedCameraCardAction<PTZDigitialAction
/* v8 ignore else: the else path cannot be reached -- @preserve */
if (action.ptz_phase === 'start') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
setInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
this._stopped = false;
await replaceInProgressForThisTarget(targetID, this._context, 'ptzDigital', this);
await this._stepChange(api, targetID);
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
this._stepChange(api, targetID),
);
// The steps are repeated only once the first step returns, and only if
// this action has not been stopped.
if (!this._stopped) {
this._timer.startRepeated(STEP_DELAY_SECONDS, () =>
this._stepChange(api, targetID),
);
}
} else if (action.ptz_phase === 'stop') {
await stopInProgressForThisTarget(targetID, this._context.ptzDigital);
delete this._context.ptzDigital?.[targetID];
await clearInProgressForThisTarget(targetID, this._context, 'ptzDigital');
}
}
+10 -16
View File
@@ -4,21 +4,16 @@ import { PTZMovementType } from '../../../types';
import { getPTZTarget, ptzActionToCapabilityKey } from '../../../utils/ptz';
import { Timer } from '../../../utils/timer';
import type { CardActionsAPI } from '../../types';
import type { TargetedActionContext } from '../types';
import {
setInProgressForThisTarget,
stopInProgressForThisTarget,
clearInProgressForThisTarget,
replaceInProgressForThisTarget,
} from '../utils/action-state';
import { AdvancedCameraCardAction } from './base';
interface PTZContext {
[cameraID: string]: {
inProgressAction?: PTZAction;
};
}
declare module 'action' {
interface ActionContext {
ptz?: PTZContext;
ptz?: TargetedActionContext;
}
}
@@ -96,8 +91,8 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
if (action.ptz_phase === 'start') {
// Scenario: Asked to start a continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
setInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
this._stopped = false;
await replaceInProgressForThisTarget(ptzCameraID, this._context, 'ptz', this);
const singleStep = async (): Promise<void> => {
/* v8 ignore else: the else path cannot be reached as ptz_action
@@ -108,10 +103,10 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
});
}
// The next step is scheduled only once this step returns, and only if
// this action has not been stopped.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1967
if (!this._stopped) {
// Only start the timer for the next step after this step returns, and
// only if this action has not been stopped.
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1967
this._timer.start(
ptzConfiguration.r2c_delay_between_calls_seconds,
singleStep,
@@ -119,11 +114,10 @@ export class PTZAction extends AdvancedCameraCardAction<PTZActionConfig> {
}
};
this._stopped = false;
await singleStep();
} else if (action.ptz_phase === 'stop') {
// Scenario: Asked to stop continuous move, camera only supports relative moves natively.
await stopInProgressForThisTarget(ptzCameraID, this._context.ptz);
await clearInProgressForThisTarget(ptzCameraID, this._context, 'ptz');
} else {
this._stopped = false;
@@ -1,16 +1,9 @@
import type { ActionContext } from 'action';
import { merge } from 'lodash-es';
import type { Action, TargetedActionContext } from '../types';
import type { Action } from '../types';
export const stopInProgressForThisTarget = async (
targetID: string,
context?: TargetedActionContext,
): Promise<void> => {
await context?.[targetID]?.inProgressAction?.stop();
};
export const setInProgressForThisTarget = (
const setInProgressForThisTarget = (
targetID: string,
context: ActionContext,
contextKey: keyof ActionContext,
@@ -24,3 +17,28 @@ export const setInProgressForThisTarget = (
},
});
};
// `action` is registered before the stop is awaited, so an action that starts
// for this target meanwhile sees `action`.
export const replaceInProgressForThisTarget = async (
targetID: string,
context: ActionContext,
contextKey: keyof ActionContext,
action: Action,
): Promise<void> => {
const replaced = context[contextKey]?.[targetID]?.inProgressAction;
setInProgressForThisTarget(targetID, context, contextKey, action);
await replaced?.stop();
};
// The removal is made before the stop is awaited, so an action that registers
// for this target meanwhile is left in place.
export const clearInProgressForThisTarget = async (
targetID: string,
context: ActionContext,
contextKey: keyof ActionContext,
): Promise<void> => {
const stopped = context[contextKey]?.[targetID]?.inProgressAction;
delete context[contextKey]?.[targetID];
await stopped?.stop();
};
@@ -3,6 +3,7 @@ import { createConditionEvaluator } from '../condition-trigger/conditions/factor
import { TriggersManager } from '../condition-trigger/triggers/manager.js';
import type { TriggerData } from '../condition-trigger/triggers/types.js';
import type { Automation, AutomationActions } from '../config/schema/automations.js';
import type { Trigger } from '../config/schema/condition-trigger/triggers/types.js';
import { localize } from '../localize/localize.js';
import type { CardAutomationsAPI, TaggedAutomation } from './types.js';
@@ -21,6 +22,10 @@ export class AutomationsManager {
this._api = api;
}
public getTriggers(): Trigger[] {
return [...this._automations.keys()].flatMap((automation) => automation.triggers);
}
public deleteAutomations(tag?: unknown) {
for (const [automation, triggers] of this._automations) {
if (automation.tag === tag) {
@@ -88,6 +88,15 @@ const convertKeyboardShortcutsToAutomations = (
trigger: 'key' as const,
key: shortcut.key,
state: 'up',
// The same modifiers as the start above, so that the pair claims the
// same presses. A key is recorded with the modifiers it was pressed
// with, so a release still matches when a modifier is taken up while
// the key is held.
shift: shortcut.shift,
ctrl: shortcut.ctrl,
alt: shortcut.alt,
meta: shortcut.meta,
},
],
actions: [
+92 -11
View File
@@ -1,8 +1,12 @@
import { isEqual } from 'lodash-es';
import { KeyConditionEvaluator } from '../condition-trigger/conditions/conditions/key';
import type { Trigger } from '../config/schema/condition-trigger/triggers/types';
import { isFocusWithin } from '../utils/focus';
import type { CardKeyboardStateAPI, KeysState } from './types';
const KEY_STATES = ['down', 'up'] as const;
export class KeyboardStateManager {
private _api: CardKeyboardStateAPI;
private _state: KeysState = {};
@@ -33,16 +37,20 @@ export class KeyboardStateManager {
capture: true,
});
// Clear state on disconnect. Without listeners the card cannot know
// whether a key was released while detached, and stale "down" state
// would suppress the next real keydown (e.g. PTZ stop shortcuts).
if (Object.keys(this._state).length) {
this._state = {};
this._processStateChange();
}
this._releaseHeldKeys();
}
private _handleKeydown = (ev: KeyboardEvent): void => {
if (this._isKeyEventOwnedElsewhere(ev)) {
return;
}
// If the card acts on this key, the browser must NOT act on it also (e.g.
// 'down' should pan the camera without also scrolling the dashboard).
if (this._isKeyEventClaimedByAnyTrigger(ev)) {
ev.preventDefault();
}
const keyObj = {
state: 'down' as const,
ctrl: ev.ctrlKey,
@@ -57,6 +65,64 @@ export class KeyboardStateManager {
}
};
private _isKeyEventOwnedElsewhere(ev: KeyboardEvent): boolean {
// A key press belongs to something other than the card when:
return (
// ... something within the card has already answered it ...
ev.defaultPrevented ||
// ... a character is mid-composition, e.g. choosing a Japanese character
// from an input method's candidate list with the arrows ...
ev.isComposing ||
// ... or it landed on an element with keys of its own.
this._isKeyHandlingElement(ev)
);
}
private _isKeyEventClaimedByAnyTrigger(ev: KeyboardEvent): boolean {
return this._api
.getAutomationsManager()
.getTriggers()
.some((trigger) => this._isKeyEventClaimedByTrigger(ev, trigger));
}
private _isKeyHandlingElement(ev: KeyboardEvent): boolean {
const target = ev.composedPath()[0];
return (
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
);
}
private _isKeyEventClaimedByTrigger(ev: KeyboardEvent, trigger: Trigger): boolean {
// A trigger with no key of its own (i.e. undefined `trigger.key` field)
// watches *every* key without "claiming" any, as the card would otherwise
// swallow every press.
if (trigger.trigger !== 'key' || trigger.enabled === false) {
return false;
}
const evaluator = new KeyConditionEvaluator(trigger);
// Must count both directions, since the browser acts on a key as it goes
// down and so a trigger that acts on the way up must claim it then too.
return KEY_STATES.some(
(state) =>
evaluator.evaluate({
keys: {
[ev.key]: {
state: state,
ctrl: ev.ctrlKey,
alt: ev.altKey,
meta: ev.metaKey,
shift: ev.shiftKey,
},
},
}).result,
);
}
private _handleKeyup = (ev: KeyboardEvent): void => {
if (ev.key in this._state && this._state[ev.key].state === 'down') {
this._state[ev.key] = { ...this._state[ev.key], state: 'up' as const };
@@ -88,12 +154,27 @@ export class KeyboardStateManager {
return;
}
if (Object.keys(this._state).length) {
// State is emptied if the element loses focus.
this._state = {};
this._releaseHeldKeys();
};
// Report every held key as newly released. The card receives key events only
// while it has focus, so it may never see the key release itself without
// this, and a condition that matches a released key would thus never
// evaluate.
private _releaseHeldKeys(): void {
let released = false;
for (const [key, keyObj] of Object.entries(this._state)) {
if (keyObj.state === 'down') {
this._state[key] = { ...keyObj, state: 'up' as const };
released = true;
}
}
if (released) {
this._processStateChange();
}
};
}
// Clone before passing to ConditionStateManager so that subsequent
// in-place mutations to this._state don't affect the stored reference,
+1
View File
@@ -251,6 +251,7 @@ export interface CardInteractionAPI {
}
export interface CardKeyboardStateAPI {
getAutomationsManager(): AutomationsManager;
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
@@ -4,6 +4,9 @@ import type { TriggerOfType } from './types';
export class KeyTrigger extends ConditionStateTriggerBase<TriggerOfType<'key'>> {
protected _getValue(state: ConditionState): unknown {
return state.keys;
const key = this._trigger.key;
// Without a key the trigger is the any-change form, and watches every key.
return key === undefined ? state.keys : state.keys?.[key];
}
}
+11 -7
View File
@@ -12,15 +12,19 @@ const keyboardShortcut = z.object({
});
export type KeyboardShortcut = z.infer<typeof keyboardShortcut>;
// Only claim the keys if there are NO modifiers pressed, otherwise they are
// allowed to fall through to browser handling.
const UNMODIFIED = { ctrl: false, alt: false, meta: false };
const keyboardShortcutsDefault = {
enabled: true,
ptz_left: { key: 'ArrowLeft' },
ptz_right: { key: 'ArrowRight' },
ptz_up: { key: 'ArrowUp' },
ptz_down: { key: 'ArrowDown' },
ptz_zoom_in: { key: '+' },
ptz_zoom_out: { key: '-' },
ptz_home: { key: 'h' },
ptz_left: { key: 'ArrowLeft', ...UNMODIFIED },
ptz_right: { key: 'ArrowRight', ...UNMODIFIED },
ptz_up: { key: 'ArrowUp', ...UNMODIFIED },
ptz_down: { key: 'ArrowDown', ...UNMODIFIED },
ptz_zoom_in: { key: '+', ...UNMODIFIED },
ptz_zoom_out: { key: '-', ...UNMODIFIED },
ptz_home: { key: 'h', ...UNMODIFIED },
};
const keyboardShortcutsSchema = z.object({