Files
advanced-camera-card/src/action-handler-directive.ts
T
Dermot Duffy cc376a8be1 feat: Rename the card to advanced-camera-card (#1873)
Whilst the Frigate support in this card is the best among camera
engines, the name incorrectly suggests that Frigate is a requirement.
Instead, to broaden the appeal, change to more camera agnostic name.
This does not suggest any change in priority, role or support for
Frigate.

This change is likely to be bug prone, due to the size of the rename --
the code contains 1500+ references to "Frigate" most of which make sense
to rename, some which do not, all of which needed human assessment.

- Closes #1298


BREAKING CHANGE: References to `frigate-card` in all kinds of
configuration need to be updated to `advanced-camera-card`. An automated
config upgrade should take care of the majority of usecases (click `Edit
-> Upgrade -> Save`), though may not be perfect.
2025-02-06 19:32:32 -08:00

231 lines
6.5 KiB
TypeScript

import type {
ActionHandlerDetail,
ActionHandlerOptions,
} from '@dermotduffy/custom-card-helpers';
import { fireEvent } from '@dermotduffy/custom-card-helpers';
import { noChange } from 'lit';
import {
AttributePart,
Directive,
DirectiveParameters,
directive,
} from 'lit/directive.js';
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
import { Timer } from './utils/timer.js';
interface ActionHandlerInterface extends HTMLElement {
holdTime: number;
bind(element: Element, options): void;
}
interface ActionHandlerElement extends HTMLElement {
actionHandlerOptions?: AdvancedCameraCardActionHandlerOptions;
}
interface AdvancedCameraCardActionHandlerOptions extends ActionHandlerOptions {
allowPropagation?: boolean;
}
class ActionHandler extends HTMLElement implements ActionHandlerInterface {
public holdTime = 0.4;
protected holdTimer = new Timer();
protected doubleClickTimer = new Timer();
protected held = false;
protected started = false;
public connectedCallback(): void {
[
'touchcancel',
'mouseout',
'mouseup',
'touchmove',
'mousewheel',
'wheel',
'scroll',
].forEach((ev) => {
document.addEventListener(
ev,
() => {
this.holdTimer.stop();
},
{ passive: true },
);
});
}
public bind(
element: ActionHandlerElement,
options?: AdvancedCameraCardActionHandlerOptions,
): void {
if (element.actionHandlerOptions) {
// Reset the options on an existing actionHandler.
element.actionHandlerOptions = options;
return;
}
element.actionHandlerOptions = options;
element.addEventListener('contextmenu', (ev: Event) => {
const e = ev || window.event;
if (e.preventDefault) {
e.preventDefault();
}
if (e.stopPropagation) {
e.stopPropagation();
}
e.cancelBubble = true;
e.returnValue = false;
return false;
});
const start = (): void => {
this.held = false;
this.holdTimer.start(this.holdTime, () => {
this.held = true;
});
// Without this check we get double start_tap events from touchstart and
// mousedown events (on Android).
if (!this.started) {
this.started = true;
fireEvent(element, 'action', { action: 'start_tap' });
}
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const endTap = (_ev: Event): void => {
this.holdTimer.stop();
if (this.started) {
this.started = false;
fireEvent(element, 'action', { action: 'end_tap' });
}
};
const end = (ev: Event): void => {
const options = element.actionHandlerOptions;
if (!options?.allowPropagation) {
// This will ensure only 1 actionHandler is invoked for a given interaction.
stopEventFromActivatingCardWideActions(ev);
}
if (
['touchend', 'touchcancel'].includes(ev.type) &&
// This action handler by default relies on synthetic click events for
// touch devices, in order to ensure that embedded cards (e.g. WebRTC)
// can use stock click handlers. The exception is for hold events.
!this.held
) {
return;
}
endTap(ev);
if (options?.hasHold && this.held) {
fireEvent(element, 'action', { action: 'hold' });
} else if (options?.hasDoubleClick) {
if (
(ev.type === 'click' && (ev as MouseEvent).detail < 2) ||
!this.doubleClickTimer.isRunning()
) {
this.doubleClickTimer.start(0.25, () =>
fireEvent(element, 'action', { action: 'tap' }),
);
} else {
this.doubleClickTimer.stop();
fireEvent(element, 'action', { action: 'double_tap' });
}
} else {
fireEvent(element, 'action', { action: 'tap' });
}
};
const handleEnter = (ev: KeyboardEvent): void => {
if (ev.key === 'Enter') {
end(ev);
}
};
element.addEventListener('touchstart', start, { passive: true });
element.addEventListener('touchend', end);
element.addEventListener('touchcancel', end);
element.addEventListener('mousedown', start, { passive: true });
element.addEventListener('click', end);
element.addEventListener('keyup', handleEnter);
// If the mouse leaves the element, this is considered the end of the interaction.
element.addEventListener('mouseleave', endTap);
}
}
customElements.define('action-handler-advanced-camera-card', ActionHandler);
const getActionHandler = (): ActionHandler => {
const body = document.body;
if (body.querySelector('action-handler-advanced-camera-card')) {
return body.querySelector('action-handler-advanced-camera-card') as ActionHandler;
}
const actionhandler = document.createElement('action-handler-advanced-camera-card');
body.appendChild(actionhandler);
return actionhandler as ActionHandler;
};
const actionHandlerBind = (
element: ActionHandlerElement,
options?: AdvancedCameraCardActionHandlerOptions,
): void => {
const actionhandler: ActionHandler = getActionHandler();
if (!actionhandler) {
return;
}
actionhandler.bind(element, options);
};
export const actionHandler = directive(
class extends Directive {
update(part: AttributePart, [options]: DirectiveParameters<this>) {
actionHandlerBind(part.element as ActionHandlerElement, options);
return noChange;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars
render(_options?: AdvancedCameraCardActionHandlerOptions) {}
},
);
export interface ActionEventTarget extends EventTarget {
addEventListener(
event: '@action',
listener: (this: ActionEventTarget, ev: CustomEvent<ActionHandlerDetail>) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: '@action',
listener: (this: ActionEventTarget, ev: CustomEvent<ActionHandlerDetail>) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
declare global {
interface HTMLElementTagNameMap {
'action-handler-advanced-camera-card': ActionHandler;
}
interface HASSDomEvents {
action: ActionHandlerDetail;
}
}