fix: Stop PTZ movement and browser key handling from fighting (#2674)
- Closes: #2623
This commit is contained in:
@@ -73,13 +73,13 @@ Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../us
|
||||
|
||||
### Keyboard Shortcut Configuration
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------ | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| key | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft` |
|
||||
| ctrl | `false` | If `true` requires the `ctrl` key to be held. |
|
||||
| shift | `false` | If `true` requires the `shift` key to be held. |
|
||||
| alt | `false` | If `true` requires the `alt` key to be held. |
|
||||
| meta | `false` | If `true` requires the `meta` key to be held. |
|
||||
| Option | Default | Description |
|
||||
| ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| key | | Any [keyboard key value](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), e.g. `ArrowLeft` |
|
||||
| ctrl | | If `true` requires the `ctrl` key to be held, if `false` requires it not to be. When unset the `ctrl` key is not considered, and the shortcut matches either way. |
|
||||
| shift | | If `true` requires the `shift` key to be held, if `false` requires it not to be. When unset the `shift` key is not considered, and the shortcut matches either way. |
|
||||
| alt | | If `true` requires the `alt` key to be held, if `false` requires it not to be. When unset the `alt` key is not considered, and the shortcut matches either way. |
|
||||
| meta | | If `true` requires the `meta` key to be held, if `false` requires it not to be. When unset the `meta` key is not considered, and the shortcut matches either way. |
|
||||
|
||||
## `theme` 🎨
|
||||
|
||||
|
||||
@@ -5,8 +5,27 @@ There are two ways to have the card respond to key input:
|
||||
- As a convenience, the card supports a small number of built in shortcuts with pre-defined default bindings. See [Built-in shortcuts](#built-in-shortcuts) for these built in shortcuts. Use the [`keyboard_shortcuts`](../configuration/view.md?id=keyboard_shortcuts) configuration to change their bindings.
|
||||
- More generally, _any_ [action](../configuration/actions/README.md) can be configured to run in response to keyboard input as part of an [automation](../configuration/automations.md), even if that action does not have a pre-defined shortcut. See [keyboard automation example](../examples.md?id=responding-to-key-input) to show how to execute any arbitrary action(s) in response to keyboard activity.
|
||||
|
||||
## How key input is handled
|
||||
|
||||
- **The card must have focus.** Key input only reaches the card if the user has
|
||||
interacted with it (i.e. click or tab to the card first).
|
||||
- **Registered keyboard shortcuts will be handled only by the card.** When a key
|
||||
matches a built-in shortcut (below) or a [`key`
|
||||
trigger](../configuration/conditions-triggers.md?id=key), the browser's own
|
||||
behavior for that key is suppressed (to ensure that pressing `ArrowDown` pans
|
||||
the camera without also scrolling the dashboard). Keys with no binding are
|
||||
left entirely to the browser.
|
||||
- **A `key` trigger with no `key` property is an exception.** Such a trigger
|
||||
fires on _every_ key, so no suppression occurs to preserve the browser
|
||||
behavior in the general case (e.g. `Tab` still moves focus).
|
||||
- **Keys typed into an input field are otherwise ignored.** Key strokes aimed at
|
||||
intentional inputs (e.g. a text box or dropdown) do not trigger card actions /
|
||||
automations.
|
||||
|
||||
## Built-in shortcuts
|
||||
|
||||
Built in keyboard shortcuts can be disabled through the [`keyboard_shortcuts` configuration](../configuration/view.md?id=keyboard_shortcuts).
|
||||
|
||||
| Name | Default key binding | Action | Description |
|
||||
| -------------- | ------------------- | --------------------------------------------------------------------- | ------------------- |
|
||||
| `ptz_down` | `ArrowDown` | [`ptz_multi`](../configuration/actions/custom/README.md?id=ptz_multi) | PTZ move down. |
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -474,5 +474,93 @@ describe('should handle ptz digital action', () => {
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop without anything in progress', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const stopAction = new PTZDigitalAction(
|
||||
{},
|
||||
{
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
},
|
||||
);
|
||||
await stopAction.execute(api);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop movement started by two concurrent starts', async () => {
|
||||
const api = createCardAPI();
|
||||
const context = {};
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const createStartAction = (ptzAction: PTZAction): PTZDigitalAction =>
|
||||
new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: ptzAction,
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
createStartAction('left').execute(api),
|
||||
createStartAction('up').execute(api),
|
||||
]);
|
||||
|
||||
const stopAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
await stopAction.execute(api);
|
||||
|
||||
// One step per start, and none after the stop.
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should continue movement when a start is concurrent with a stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const context = {};
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(createView());
|
||||
|
||||
const leftAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
await leftAction.execute(api);
|
||||
|
||||
const stopAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
const upAction = new PTZDigitalAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz_digital',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
// The left key is released as the up key is pressed.
|
||||
await Promise.all([stopAction.execute(api), upAction.execute(api)]);
|
||||
|
||||
// One step for the left start, one for the up start.
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.runOnlyPendingTimers();
|
||||
|
||||
expect(api.getViewManager().setViewWithModifiers).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -611,5 +611,108 @@ describe('should handle ptz action', () => {
|
||||
// There should be no additional calls.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should stop movement started by two concurrent starts', async () => {
|
||||
const api = createCardAPI();
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: new Capabilities({
|
||||
ptz: {
|
||||
left: [PTZMovementType.Relative],
|
||||
up: [PTZMovementType.Relative],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ camera: 'camera.office' }),
|
||||
);
|
||||
|
||||
const context = {};
|
||||
const createStartAction = (ptzAction: 'left' | 'up'): PTZAction =>
|
||||
new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: ptzAction,
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
createStartAction('left').execute(api),
|
||||
createStartAction('up').execute(api),
|
||||
]);
|
||||
|
||||
const stopAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
await stopAction.execute(api);
|
||||
|
||||
// One move per start, and none after the stop.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should continue movement when a start is concurrent with a stop', async () => {
|
||||
const api = createCardAPI();
|
||||
const store = createStore([
|
||||
{
|
||||
cameraID: 'camera.office',
|
||||
capabilities: new Capabilities({
|
||||
ptz: {
|
||||
left: [PTZMovementType.Relative],
|
||||
up: [PTZMovementType.Relative],
|
||||
},
|
||||
}),
|
||||
},
|
||||
]);
|
||||
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager(store));
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ camera: 'camera.office' }),
|
||||
);
|
||||
|
||||
const context = {};
|
||||
await new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'start',
|
||||
}).execute(api);
|
||||
|
||||
const stopAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'left',
|
||||
ptz_phase: 'stop',
|
||||
});
|
||||
const upAction = new PTZAction(context, {
|
||||
action: 'fire-dom-event',
|
||||
advanced_camera_card_action: 'ptz',
|
||||
ptz_action: 'up',
|
||||
ptz_phase: 'start',
|
||||
});
|
||||
|
||||
// The left key is released as the up key is pressed.
|
||||
await Promise.all([stopAction.execute(api), upAction.execute(api)]);
|
||||
|
||||
// One move for the left start, one for the up start.
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenCalledTimes(3);
|
||||
expect(api.getCameraManager().executePTZAction).toHaveBeenLastCalledWith(
|
||||
'camera.office',
|
||||
'up',
|
||||
{ preset: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ActionsExecutionRequest } from '../../src/card-controller/actions/
|
||||
import { AutomationsManager } from '../../src/card-controller/automations-manager.js';
|
||||
import type { EventWatcherSubscriptionInterface } from '../../src/card-controller/hass/event-watcher.js';
|
||||
import { ConditionStateManager } from '../../src/condition-trigger/conditions/state-manager.js';
|
||||
import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types.js';
|
||||
import {
|
||||
createCardAPI,
|
||||
createHASS,
|
||||
@@ -375,4 +376,27 @@ describe('AutomationsManager', () => {
|
||||
stateManager.setState({ expand: true });
|
||||
expect(api.getActionsManager().executeActions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
describe('should report the triggers it subscribes to', () => {
|
||||
it('with no automations', () => {
|
||||
expect(new AutomationsManager(createCardAPI()).getTriggers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('with automations', () => {
|
||||
const keyTrigger: Trigger = { trigger: 'key', key: 'ArrowLeft' };
|
||||
const expandTrigger: Trigger = { trigger: 'expand', expand: true };
|
||||
|
||||
const automationsManager = new AutomationsManager(createCardAPI());
|
||||
automationsManager.addAutomations([
|
||||
{ triggers: [keyTrigger, expandTrigger], actions },
|
||||
{ triggers: triggers, actions },
|
||||
]);
|
||||
|
||||
expect(automationsManager.getTriggers()).toEqual([
|
||||
keyTrigger,
|
||||
expandTrigger,
|
||||
...triggers,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -538,7 +538,7 @@ describe('ConfigManager', () => {
|
||||
view: {
|
||||
keyboard_shortcuts: {
|
||||
enabled: true,
|
||||
ptz_home: { key: 'h' },
|
||||
ptz_home: { key: 'q' },
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
@@ -552,39 +552,28 @@ describe('ConfigManager', () => {
|
||||
manager.setConfig(config);
|
||||
await flushPromises();
|
||||
|
||||
// Verify keyboard shortcuts automations were added initially with ptz_home
|
||||
expect(addAutomationsSpy).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
triggers: expect.arrayContaining([
|
||||
expect.objectContaining({ trigger: 'key', key: 'h' }),
|
||||
]),
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
advanced_camera_card_action: 'ptz_multi',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const hasKeyTrigger = (key: string): boolean =>
|
||||
addAutomationsSpy.mock.calls.some((call) =>
|
||||
call[0].some((automation: Automation) =>
|
||||
automation.triggers.some(
|
||||
(trig: Trigger) => trig.trigger === 'key' && trig.key === key,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// The configured binding, rather than the default one.
|
||||
expect(hasKeyTrigger('q')).toBe(true);
|
||||
|
||||
addAutomationsSpy.mockClear();
|
||||
|
||||
// Trigger the override - keyboard_shortcuts should be deleted
|
||||
// Trigger the override, which deletes the shortcut configuration.
|
||||
stateManager.setState({ fullscreen: true });
|
||||
await flushPromises();
|
||||
|
||||
// Verify newly added automations don't contain keyboard shortcuts (key: 'h')
|
||||
// This confirms the override removed them (directly verified through add calls)
|
||||
const addCalls = addAutomationsSpy.mock.calls;
|
||||
const hasKeyboardShortcut = addCalls.some((call) =>
|
||||
call[0].some((automation: Automation) =>
|
||||
automation.triggers.some(
|
||||
(trig: Trigger) => trig.trigger === 'key' && trig.key === 'h',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(hasKeyboardShortcut).toBe(false);
|
||||
// Deleting the configuration restores the defaults rather than removing
|
||||
// the shortcuts, so the loader re-runs and binds the default key.
|
||||
expect(hasKeyTrigger('q')).toBe(false);
|
||||
expect(hasKeyTrigger('h')).toBe(true);
|
||||
});
|
||||
|
||||
it('should re-run folders loader when overrides change', async () => {
|
||||
|
||||
@@ -114,6 +114,55 @@ describe('setKeyboardShortcutsFromConfig', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should give the start and stop of a shortcut the same modifiers', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
|
||||
createConfig({
|
||||
view: {
|
||||
keyboard_shortcuts: {
|
||||
enabled: true,
|
||||
ptz_home: null,
|
||||
ptz_right: null,
|
||||
ptz_up: null,
|
||||
ptz_down: null,
|
||||
ptz_zoom_in: null,
|
||||
ptz_zoom_out: null,
|
||||
ptz_left: { key: 'z', ctrl: false, alt: false, meta: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
setKeyboardShortcutsFromConfig(api);
|
||||
|
||||
const automations = vi.mocked(api.getAutomationsManager().addAutomations).mock
|
||||
.calls[0][0];
|
||||
expect(automations.map((automation) => automation.triggers)).toEqual([
|
||||
[
|
||||
{
|
||||
trigger: 'key',
|
||||
key: 'z',
|
||||
state: 'down',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
trigger: 'key',
|
||||
key: 'z',
|
||||
state: 'up',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('ptz_home', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
@@ -134,11 +183,11 @@ describe('setKeyboardShortcutsFromConfig', () => {
|
||||
],
|
||||
triggers: [
|
||||
{
|
||||
alt: undefined,
|
||||
alt: false,
|
||||
trigger: 'key',
|
||||
ctrl: undefined,
|
||||
ctrl: false,
|
||||
key: 'h',
|
||||
meta: undefined,
|
||||
meta: false,
|
||||
shift: undefined,
|
||||
state: 'down',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { assert, describe, expect, it } from 'vitest';
|
||||
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { STEP_PAN } from '../../src/card-controller/actions/actions/ptz-digital';
|
||||
import type { ZoomSettingsObserved } from '../../src/components-lib/zoom/types';
|
||||
import type { LogActionConfig } from '../../src/config/schema/actions/custom/log';
|
||||
import { createLogAction } from '../../src/utils/action';
|
||||
import { isRecord } from '../../src/utils/basic';
|
||||
import {
|
||||
clickElement,
|
||||
dispatchPointerDown,
|
||||
@@ -117,6 +120,9 @@ const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
||||
return card;
|
||||
};
|
||||
|
||||
const isZoomSettingsObserved = (detail: unknown): detail is ZoomSettingsObserved =>
|
||||
isRecord(detail) && isRecord(detail.pan) && typeof detail.pan.x === 'number';
|
||||
|
||||
// What the live view draws the camera into, which is the part of the card a
|
||||
// user looks at and the largest part of it that is not a control.
|
||||
const LIVE_MEDIA_SELECTOR = 'advanced-camera-card-live-provider';
|
||||
@@ -125,6 +131,10 @@ const clickMedia = async (card: MountedCard): Promise<void> =>
|
||||
await clickElement(await card.waitForSelector(LIVE_MEDIA_SELECTOR));
|
||||
|
||||
describe('KeyboardStateManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should not act on a key until the card has been used', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
@@ -307,6 +317,51 @@ describe('KeyboardStateManager', () => {
|
||||
await card.console.waitForMessage(KEY_MESSAGE, { count: 2 });
|
||||
});
|
||||
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2623
|
||||
it('should keep panning while an arrow key is held after previous press', async () => {
|
||||
const card = await mountCard();
|
||||
|
||||
card.setEntityState(ZOOM_ENTITY, 'on');
|
||||
await card.events.waitForFirst('advanced-camera-card:zoom:zoomed');
|
||||
await clickMedia(card);
|
||||
|
||||
// Press a key to ensure holds after initial press are functional.
|
||||
await pressKey('ArrowUp');
|
||||
|
||||
// How far across the camera the picture sits, as a percentage, from the
|
||||
// last change the card reported. It starts halfway across.
|
||||
const panX = (): number | null => {
|
||||
const detail = card.events
|
||||
.getEntries('advanced-camera-card:zoom:change')
|
||||
.at(-1)?.detail;
|
||||
return isZoomSettingsObserved(detail) ? detail.pan.x : null;
|
||||
};
|
||||
|
||||
//`shouldAdvanceTime` lets the clock run at its own pace until "controlled",
|
||||
// necessary for the panning while a key is being "held" to work.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
await holdKey('ArrowLeft');
|
||||
await card.waitForRender(() => {
|
||||
const x = panX();
|
||||
|
||||
// Stop well short of the left edge, to allow detection of continuous pan
|
||||
// that never stopped.
|
||||
return x !== null && x < 40 ? x : null;
|
||||
}, 'the picture to pan left');
|
||||
await releaseKey('ArrowLeft');
|
||||
|
||||
const atRelease = panX();
|
||||
assert(atRelease !== null);
|
||||
|
||||
await card.advanceSeconds(5);
|
||||
|
||||
const afterWaiting = panX();
|
||||
assert(afterWaiting !== null);
|
||||
|
||||
expect(Math.abs(atRelease - afterWaiting)).toBeLessThanOrEqual(STEP_PAN);
|
||||
});
|
||||
|
||||
it('should not act on a key aimed at another card', async () => {
|
||||
const card = await mountCard();
|
||||
const otherCard = await mountCard({ keyMessage: OTHER_CARD_KEY_MESSAGE });
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi, type MockInstance } from 'vitest';
|
||||
|
||||
import type { CardController } from '../../src/card-controller/controller';
|
||||
import { KeyboardStateManager } from '../../src/card-controller/keyboard-state-manager';
|
||||
import type { Trigger } from '../../src/config/schema/condition-trigger/triggers/types';
|
||||
import { createCardAPI, createLitElement } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('KeyboardStateManager', () => {
|
||||
const createManager = (
|
||||
triggers: Trigger[] = [],
|
||||
): { api: CardController; element: HTMLElement; manager: KeyboardStateManager } => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
vi.mocked(api.getAutomationsManager().getTriggers).mockReturnValue(triggers);
|
||||
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
|
||||
return { api, element, manager };
|
||||
};
|
||||
|
||||
// For the tests that assert whether a press was claimed. Counting the calls
|
||||
// on the returned spy, rather than reading `defaultPrevented`, is what tells
|
||||
// the manager's prevention apart from that of another listener on the same
|
||||
// press.
|
||||
const dispatchKeydownWithPreventionSpy = (
|
||||
target: HTMLElement,
|
||||
options?: KeyboardEventInit,
|
||||
): MockInstance => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'ArrowDown',
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
...options,
|
||||
});
|
||||
const preventDefault = vi.spyOn(event, 'preventDefault');
|
||||
|
||||
target.dispatchEvent(event);
|
||||
|
||||
return preventDefault;
|
||||
};
|
||||
|
||||
it('should construct', () => {
|
||||
expect(new KeyboardStateManager(createCardAPI())).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should set state on keydown', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
|
||||
@@ -30,12 +64,109 @@ describe('KeyboardStateManager', () => {
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('should stop the browser acting on a keypress', () => {
|
||||
it('should stop when a trigger acts on the press of the key', () => {
|
||||
const { element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop when a trigger acts on the release of the key', () => {
|
||||
const { element } = createManager([
|
||||
{ trigger: 'key', key: 'ArrowDown', state: 'up' },
|
||||
]);
|
||||
|
||||
// The browser scrolls as the key goes down, so the press must be claimed
|
||||
// then even though the card acts on the release.
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not stop when no trigger matches the key', () => {
|
||||
const { element } = createManager([{ trigger: 'key', key: 'ArrowUp' }]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not stop when the triggers are not about keys at all', () => {
|
||||
const { element } = createManager([{ trigger: 'fullscreen', fullscreen: true }]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('should not stop when a modifier the trigger asks for is absent', () => {
|
||||
it.each([
|
||||
['ctrl' as const, { ctrlKey: true }],
|
||||
['alt' as const, { altKey: true }],
|
||||
['meta' as const, { metaKey: true }],
|
||||
['shift' as const, { shiftKey: true }],
|
||||
])('with %s', (modifier: string, held: KeyboardEventInit) => {
|
||||
const { element } = createManager([
|
||||
{ trigger: 'key', key: 'ArrowDown', [modifier]: true },
|
||||
]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
|
||||
expect(dispatchKeydownWithPreventionSpy(element, held)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not stop when a trigger for the key is turned off', () => {
|
||||
const { element } = createManager([
|
||||
{ trigger: 'key', key: 'ArrowDown', enabled: false },
|
||||
]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not stop when a trigger watches every key', () => {
|
||||
const { api, element } = createManager([{ trigger: 'key' }]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).not.toHaveBeenCalled();
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop on every repeat of a held key', () => {
|
||||
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalled();
|
||||
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should ignore a key press that belongs to another element', () => {
|
||||
it('should ignore when something else has already answered the press', () => {
|
||||
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
|
||||
element.addEventListener('keydown', (ev) => ev.preventDefault(), {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
// Once from the listener above.
|
||||
expect(dispatchKeydownWithPreventionSpy(element)).toHaveBeenCalledTimes(1);
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore when a composition is being typed', () => {
|
||||
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
|
||||
|
||||
expect(
|
||||
dispatchKeydownWithPreventionSpy(element, { isComposing: true }),
|
||||
).not.toHaveBeenCalled();
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore when the press lands on an element with keys of its own', () => {
|
||||
const { api, element } = createManager([{ trigger: 'key', key: 'ArrowDown' }]);
|
||||
const input = document.createElement('input');
|
||||
element.append(input);
|
||||
|
||||
expect(dispatchKeydownWithPreventionSpy(input)).not.toHaveBeenCalled();
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state on keyup', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||
|
||||
@@ -53,12 +184,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state on focus loss', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
it('should release held keys on focus loss', () => {
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new FocusEvent('blur'));
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
@@ -68,16 +195,42 @@ describe('KeyboardStateManager', () => {
|
||||
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledTimes(2);
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenLastCalledWith({
|
||||
keys: {},
|
||||
keys: {
|
||||
a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should set state on keydown of a previously released key', () => {
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
element.dispatchEvent(new FocusEvent('blur'));
|
||||
vi.mocked(api.getConditionStateManager().setState).mockClear();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
|
||||
keys: {
|
||||
a: { state: 'down', ctrl: false, alt: false, meta: false, shift: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set state on focus loss when no key is held', () => {
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
element.dispatchEvent(new KeyboardEvent('keyup', { key: 'a' }));
|
||||
vi.mocked(api.getConditionStateManager().setState).mockClear();
|
||||
|
||||
element.dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not clear state when focus moves within the card', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
element.dispatchEvent(new FocusEvent('blur', { relatedTarget: element }));
|
||||
@@ -91,12 +244,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should take focus on pointerdown', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element } = createManager();
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
|
||||
@@ -104,9 +253,7 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not take focus on pointerdown when focus is already within the card', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element } = createManager();
|
||||
document.body.append(element);
|
||||
|
||||
const child = document.createElement('div');
|
||||
@@ -115,8 +262,6 @@ describe('KeyboardStateManager', () => {
|
||||
child.focus();
|
||||
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
|
||||
@@ -126,12 +271,8 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not take focus on pointerdown after uninitialization', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const { element, manager } = createManager();
|
||||
const focus = vi.spyOn(element, 'focus');
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
manager.uninitialize();
|
||||
|
||||
element.dispatchEvent(new Event('pointerdown'));
|
||||
@@ -140,11 +281,7 @@ describe('KeyboardStateManager', () => {
|
||||
});
|
||||
|
||||
it('should not act after uninitialization', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, element, manager } = createManager();
|
||||
manager.uninitialize();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
@@ -152,27 +289,23 @@ describe('KeyboardStateManager', () => {
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear held keys on uninitialize', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
it('should release held keys on uninitialize', () => {
|
||||
const { api, element, manager } = createManager();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' }));
|
||||
vi.mocked(api.getConditionStateManager().setState).mockClear();
|
||||
|
||||
manager.uninitialize();
|
||||
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({ keys: {} });
|
||||
expect(api.getConditionStateManager().setState).toHaveBeenCalledWith({
|
||||
keys: {
|
||||
a: { state: 'up', ctrl: false, alt: false, meta: false, shift: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set state on uninitialize when no keys held', () => {
|
||||
const api = createCardAPI();
|
||||
const element = createLitElement();
|
||||
vi.mocked(api.getCardElementManager().getElement).mockReturnValue(element);
|
||||
const manager = new KeyboardStateManager(api);
|
||||
manager.initialize();
|
||||
const { api, manager } = createManager();
|
||||
manager.uninitialize();
|
||||
|
||||
expect(api.getConditionStateManager().setState).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { ConditionStateManager } from '../../../../src/condition-trigger/conditions/state-manager';
|
||||
import { KeyTrigger } from '../../../../src/condition-trigger/triggers/triggers/key';
|
||||
import type { TriggerOfType } from '../../../../src/condition-trigger/triggers/triggers/types';
|
||||
import { createTriggerEvaluatorContext } from './test-utils';
|
||||
|
||||
describe('KeyTrigger', () => {
|
||||
const create = (
|
||||
trigger: TriggerOfType<'key'>,
|
||||
): { stateManager: ConditionStateManager; callback: Mock } => {
|
||||
const stateManager = new ConditionStateManager();
|
||||
const callback = vi.fn();
|
||||
new KeyTrigger(trigger, createTriggerEvaluatorContext({ stateManager })).subscribe(
|
||||
callback,
|
||||
);
|
||||
return { stateManager, callback };
|
||||
};
|
||||
|
||||
const modifiers = { ctrl: false, shift: false, alt: false, meta: false };
|
||||
const down = { state: 'down' as const, ...modifiers };
|
||||
const up = { state: 'up' as const, ...modifiers };
|
||||
|
||||
it('should trigger when the key is pressed', () => {
|
||||
const { stateManager, callback } = create({ trigger: 'key', key: 'a' });
|
||||
|
||||
stateManager.setState({ keys: { a: down } });
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger when the key is released', () => {
|
||||
const { stateManager, callback } = create({
|
||||
trigger: 'key',
|
||||
key: 'a',
|
||||
state: 'up',
|
||||
});
|
||||
|
||||
stateManager.setState({ keys: { a: down } });
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
stateManager.setState({ keys: { a: up } });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not trigger when another key changes', () => {
|
||||
const { stateManager, callback } = create({
|
||||
trigger: 'key',
|
||||
key: 'a',
|
||||
state: 'up',
|
||||
});
|
||||
|
||||
stateManager.setState({ keys: { a: down } });
|
||||
stateManager.setState({ keys: { a: up } });
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 'a' is still released, so its condition still holds, but the user acted
|
||||
// on a different key entirely.
|
||||
stateManager.setState({ keys: { a: up, b: down } });
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger on any key without a key of its own', () => {
|
||||
const { stateManager, callback } = create({ trigger: 'key' });
|
||||
|
||||
stateManager.setState({ keys: { a: down } });
|
||||
stateManager.setState({ keys: { a: down, b: down } });
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -523,25 +523,46 @@ describe('config defaults', () => {
|
||||
keyboard_shortcuts: {
|
||||
enabled: true,
|
||||
ptz_down: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: 'ArrowDown',
|
||||
meta: false,
|
||||
},
|
||||
ptz_home: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: 'h',
|
||||
meta: false,
|
||||
},
|
||||
ptz_left: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: 'ArrowLeft',
|
||||
meta: false,
|
||||
},
|
||||
ptz_right: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: 'ArrowRight',
|
||||
meta: false,
|
||||
},
|
||||
ptz_up: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: 'ArrowUp',
|
||||
meta: false,
|
||||
},
|
||||
ptz_zoom_in: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: '+',
|
||||
meta: false,
|
||||
},
|
||||
ptz_zoom_out: {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
key: '-',
|
||||
meta: false,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
|
||||
Reference in New Issue
Block a user