Add support for pre-defined zoom/pan settings.
@@ -1,3 +1,5 @@
|
||||
!> This documentation is for the unreleased `dev` branch! See the [Github README](https://github.com/dermotduffy/frigate-hass-card?tab=readme-ov-file#frigate-lovelace-card) for documentation of the released version.
|
||||
|
||||
# Getting Started
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
# Frigate Card <small>6.0.0 *DEV*</small>
|
||||
# Frigate Card <small>6.0.0 <small style="color: red">_*DEV*_</small></small>
|
||||
|
||||
> A comprehensive camera card for Home Assistant.
|
||||
|
||||
|
||||
@@ -118,15 +118,24 @@ frigate_card_action: camera_ui
|
||||
|
||||
Open the UI for the selected camera engine (e.g. the Frigate UI).
|
||||
|
||||
### `default`
|
||||
### `change_zoom`
|
||||
|
||||
Change to the default view.
|
||||
Zoom in and/or pan for a given camera.
|
||||
|
||||
```yaml
|
||||
action: custom:frigate-card-action
|
||||
frigate_card_action: default
|
||||
frigate_card_action: change_zoom
|
||||
[...]
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| - | - |
|
||||
| `action` | Must be `custom:frigate-card-action`. |
|
||||
| `frigate_card_action` | Must be `change_zoom`. |
|
||||
| `target_id` | The [camera ID](cameras/README.md?id=cameras) to adjust the pan/zoom of, or a media ID (e.g. `frigate` event ID) to zoom in on specific media items. |
|
||||
| `zoom` | How much to zoom-in. See the [camera zoom parameter](cameras/README.md?id=layout-configuration). |
|
||||
| `pan` | How much to pan-x/y. See the [camera pan parameter](cameras/README.md?id=layout-configuration). |
|
||||
|
||||
### `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`
|
||||
|
||||
Change to the specified view.
|
||||
@@ -136,6 +145,15 @@ action: custom:frigate-card-action
|
||||
frigate_card_action: [view]
|
||||
```
|
||||
|
||||
### `default`
|
||||
|
||||
Change to the default view.
|
||||
|
||||
```yaml
|
||||
action: custom:frigate-card-action
|
||||
frigate_card_action: default
|
||||
```
|
||||
|
||||
### `download`
|
||||
|
||||
Download the displayed media.
|
||||
|
||||
@@ -144,10 +144,12 @@ cameras:
|
||||
| Option | Default | Description |
|
||||
| - | - | - |
|
||||
| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. |
|
||||
| `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. |
|
||||
| `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. |
|
||||
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. See visualizations below. |
|
||||
| `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box):    |
|
||||
| `zoom` | `1.0` | A value between `1.0` and `10.0` inclusive that defines how much additional "digital zoom" to apply to this camera by default. Unlike with `view_box` the user can easily "zoom back out". Often used in conjuction with `pan`. When zoomed in the [builtin browser media controls](../live.md?id=controls) will automatically be disabled (as otherwise they would be enlarged also). |
|
||||
|
||||
!> The `view_box` parameter only has effect on browsers that support [`object-view-box`](https://caniuse.com/mdn-css_properties_object-view-box). As of writing this is exclusively Chromium based browsers (e.g. Google Chrome or Microsoft Edge).
|
||||
?> Layout operations are effectively applied in this order: `fit`, `position`, `view_box`, `zoom` then `pan`.
|
||||
|
||||
See [media layout examples](../../examples.md?id=media-layout).
|
||||
|
||||
@@ -169,6 +171,11 @@ See [media layout examples](../../examples.md?id=media-layout).
|
||||
|
||||

|
||||
|
||||
#### `pan` and `zoom`: Predefined panning and zooming
|
||||
|
||||

|
||||
|
||||
|
||||
## `triggers`
|
||||
|
||||
The `triggers` block configures what triggers a camera. Triggering can be used
|
||||
@@ -300,6 +307,13 @@ cameras:
|
||||
movies:
|
||||
directory_pattern: '%Y-%m-%d'
|
||||
file_pattern: '%H-%M-%S'
|
||||
- camera_entity: camera.zoomed
|
||||
dimensions:
|
||||
layout:
|
||||
zoom: 2.0
|
||||
pan:
|
||||
x: 50
|
||||
y: 50
|
||||
cameras_global:
|
||||
live_provider: ha
|
||||
```
|
||||
|
||||
@@ -164,6 +164,11 @@ conditions:
|
||||
| `condition` | Must be `view`. |
|
||||
| `views` | A list of [views](view.md?id=supported-views) in which this condition is satified (e.g. `clips`). |
|
||||
|
||||
?> Internally, views associated with the media viewer (e.g. `clip`, `snapshot`,
|
||||
`recording`) are translated to a special view called `media` after the relevant
|
||||
media is fetched. When including views as part of a [condition](conditions.md),
|
||||
you may need to refer to this special `media` view.
|
||||
|
||||
# Fully expanded reference
|
||||
|
||||
[](common/expanded-warning.md ':include')
|
||||
|
||||
@@ -33,6 +33,7 @@ menu:
|
||||
| `camera_ui` | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).|
|
||||
| `cameras` | The camera selection submenu. Will only appear if multiple cameras are configured. |
|
||||
| `clips` | The `clips` view menu button: brings the user to the `clips` view on tap and the most-recent `clip` view on hold. |
|
||||
| `default_zoom` | The `default_zoom` button allows easily returning the camera to default pan/zoom. |
|
||||
| `display_mode` | The `display_mode` button allows changing between single and grid views. |
|
||||
| `download` | The `download` menu button: allow direct download of the media being displayed.|
|
||||
| `expand` | The `expand` menu button: expand the card into a popup/dialog. |
|
||||
@@ -53,7 +54,7 @@ menu:
|
||||
| Option | Default | Description |
|
||||
| - | - | - |
|
||||
| `alignment` | `matching` | Whether this button should have an alignment that is `matching` the menu alignment or `opposing` the menu. Can be used to create two separate groups of buttons on the menu. `priority` orders buttons within a given `alignment`. |
|
||||
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz` | Whether or not to show the button. |
|
||||
| `enabled` | `true` for `frigate`, `cameras`, `substreams`, `live`, `clips`, `snapshots`, `timeline`, `download`, `camera_ui`, `fullscreen`, `media_player`, `display_mode` and `default_zoom`. `false` for `image`, `expand`, `microphone`, `mute`, `play`, `recordings`, `screenshot`, `ptz` | Whether or not to show the button. |
|
||||
| `icon` | | An icon to overriding the default for that button, e.g. `mdi:camera-front`. |
|
||||
| `priority` | `50` | The button priority. Higher priority buttons are ordered closer to the start of the menu alignment (i.e. a button with priority `70` will order further to the left than a button with priority `60`, when the menu alignment is `left`). Minimum `0`, maximum `100`.|
|
||||
|
||||
@@ -90,9 +91,15 @@ menu:
|
||||
enabled: true
|
||||
alignment: matching
|
||||
icon: mdi:video-switch
|
||||
default_zoom:
|
||||
priority: 50
|
||||
enabled: true
|
||||
alignment: matching
|
||||
icon: mdi:magnify-close
|
||||
substreams:
|
||||
priority: 50
|
||||
enabled: true
|
||||
alignment: matching
|
||||
icon: mdi:video-input-component
|
||||
live:
|
||||
priority: 50
|
||||
|
||||
@@ -997,3 +997,63 @@ cameras:
|
||||
webrtc_card:
|
||||
ui: true
|
||||
```
|
||||
|
||||
## Zoom
|
||||
|
||||
### Pre-defining camera zoom and pan
|
||||
|
||||
This example changes the default [zoom/pan settings for a camera](./configuration/cameras/README.md?id=layout-configuration) to always zoom in on a given area:
|
||||
|
||||
```yaml
|
||||
type: custom:frigate-card
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
dimensions:
|
||||
layout:
|
||||
zoom: 3
|
||||
pan:
|
||||
x: 20
|
||||
y: 80
|
||||
```
|
||||
|
||||
### Disable zooming in media views
|
||||
|
||||
This example prevents zooming on the media viewer but keeps it on in other views (e.g. `live` view):
|
||||
|
||||
```yaml
|
||||
type: custom:frigate-card
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
dimensions:
|
||||
layout:
|
||||
zoom: 3
|
||||
pan:
|
||||
x: 20
|
||||
y: 80
|
||||
media_viewer:
|
||||
zoomable: false
|
||||
```
|
||||
|
||||
### Different zoom settings in media viewer vs `live`
|
||||
|
||||
This example uses different settings for the media viewer and `live` view, by overriding the camera configuration:
|
||||
|
||||
```yaml
|
||||
type: custom:frigate-card
|
||||
cameras:
|
||||
- camera_entity: camera.office
|
||||
dimensions:
|
||||
layout:
|
||||
zoom: 2
|
||||
overrides:
|
||||
- conditions:
|
||||
- condition: view
|
||||
views:
|
||||
- media
|
||||
set:
|
||||
'cameras[0].dimensions.layout':
|
||||
zoom: 3
|
||||
pan:
|
||||
x: 100
|
||||
y: 100
|
||||
```
|
||||
|
||||
|
After Width: | Height: | Size: 819 B |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 811 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 62 KiB |
@@ -1,16 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
WIDTH=800
|
||||
WIDTH=1200
|
||||
|
||||
URL_FIT="https://docs.google.com/drawings/d/e/2PACX-1vTq0SVS8HWs3jGC0jjNpJoYfMbZS6P27CYyPlDhSa9OhdB_3jEb0HTLLYwu8Nv3J1TdjAJppcjTiVNy/pub?w=$WIDTH"
|
||||
URL_THINNER_THAN_WIDTH="https://docs.google.com/drawings/d/e/2PACX-1vRkMd89N0tkZt5IghPKhR6gs8zMhB-5_hx5QfP6BCxbsSIga_h44IczP06Sj_YnKkxhe0lRdeR-uh04/pub?w=$WIDTH"
|
||||
URL_SHORTER_THAN_HEIGHT="https://docs.google.com/drawings/d/e/2PACX-1vTKVsXEWIbj9lYKrCeugdLKcK_rOwAZZDK8IzhPdHH4wMwV2v7kEI0nsn2Qgugb00qDVHsE7kE8CBIC/pub?w=$WIDTH"
|
||||
URL_VIEW_BOX="https://docs.google.com/drawings/d/e/2PACX-1vRpPNsaStxW2ENmv1kfUQg41cua9XQ2sQziq2PC8LdRCtkvjHSKYH3CyPO1Pz7kOdiQ2yQKrBX88-TF/pub?w=$WIDTH"
|
||||
URL_PAN_ZOOM="https://docs.google.com/drawings/d/e/2PACX-1vTcbuMiqKo7-w0my3jaht1xdEFUhLSur1nSxhkxbuX0eagwuisaNCBfKvDjFY4hzNVGRZoBy7YehaMn/pub?w=$WIDTH"
|
||||
|
||||
DIR_MEDIA_LAYOUT_IMAGES="./docs/images/media_layout"
|
||||
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/fit.png" "$URL_FIT" && \
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-thinner-than-width.png" "$URL_THINNER_THAN_WIDTH" && \
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/position-shorter-than-height.png" "$URL_SHORTER_THAN_HEIGHT" && \
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/view-box.png" "$URL_VIEW_BOX"
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/view-box.png" "$URL_VIEW_BOX" && \
|
||||
wget -q -O "$DIR_MEDIA_LAYOUT_IMAGES/pan-zoom.png" "$URL_PAN_ZOOM"
|
||||
|
||||
|
||||
@@ -128,10 +128,6 @@ export class CameraManager {
|
||||
this._store = options?.store ?? new CameraManagerStore();
|
||||
}
|
||||
|
||||
public async reset(): Promise<void> {
|
||||
await this._store.reset();
|
||||
}
|
||||
|
||||
public async initializeCamerasFromConfig(): Promise<boolean> {
|
||||
const config = this._api.getConfigManager().getConfig();
|
||||
const hass = this._api.getHASSManager().getHASS();
|
||||
@@ -149,7 +145,7 @@ export class CameraManager {
|
||||
);
|
||||
|
||||
const resetAndInitialize = async () => {
|
||||
await this.reset();
|
||||
await this._reset();
|
||||
await this._initializeCameras(cameras);
|
||||
};
|
||||
|
||||
@@ -165,6 +161,10 @@ export class CameraManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async _reset(): Promise<void> {
|
||||
await this._store.reset();
|
||||
}
|
||||
|
||||
protected async _getEnginesForCameras(
|
||||
camerasConfig: CamerasConfig,
|
||||
): Promise<Map<CameraConfig, CameraManagerEngine>> {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getActionConfigGivenAction,
|
||||
} from '../utils/action.js';
|
||||
import { getStreamCameraID } from '../utils/substream.js';
|
||||
import { generateViewContextForZoomChange } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { CardActionsManagerAPI } from './types.js';
|
||||
|
||||
const interactionSchema = z.object({
|
||||
@@ -263,9 +264,19 @@ export class ActionsManager {
|
||||
}
|
||||
break;
|
||||
case 'show_ptz':
|
||||
this._api
|
||||
.getViewManager()
|
||||
.setViewWithNewContext({ live: { ptzVisible: frigateCardAction.show_ptz } });
|
||||
this._api.getViewManager().setViewWithMergedContext({
|
||||
live: { ptzVisible: frigateCardAction.show_ptz },
|
||||
});
|
||||
break;
|
||||
case 'change_zoom':
|
||||
this._api.getViewManager().setViewWithMergedContext(
|
||||
generateViewContextForZoomChange(frigateCardAction.target_id, {
|
||||
zoom: {
|
||||
pan: frigateCardAction.pan,
|
||||
zoom: frigateCardAction.zoom,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||
|
||||
@@ -99,6 +99,9 @@ export class CardElementManager {
|
||||
// Manually call the location change handler as the card will be
|
||||
// disconnected/reconnected when dashboard 'tab' changes happen within HA.
|
||||
this._api.getQueryStringManager().executeAll();
|
||||
|
||||
// Make sure reconnections call the initialization code.
|
||||
this._element.requestUpdate();
|
||||
}
|
||||
|
||||
public elementDisconnected(): void {
|
||||
@@ -108,15 +111,10 @@ export class CardElementManager {
|
||||
this._api.getMediaLoadedInfoManager().clear();
|
||||
this._api.getFullscreenManager().disconnect();
|
||||
|
||||
// Reset and uninitialize cameras to cause them to reinitialize on
|
||||
// Uninitialize cameras to cause them to reinitialize on
|
||||
// reconnection, to ensure the state subscription/unsubscription works
|
||||
// correctly for triggers.
|
||||
this._api
|
||||
.getCameraManager()
|
||||
.reset()
|
||||
.then(() =>
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
||||
);
|
||||
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
|
||||
|
||||
this._element.removeEventListener(
|
||||
'mousemove',
|
||||
|
||||
@@ -86,19 +86,25 @@ export class InitializationManager {
|
||||
}
|
||||
|
||||
if (!this._api.getMessageManager().hasMessage()) {
|
||||
// Set a view on initial load. However, if the query string contains a
|
||||
// view related action, we don't set any view here and allow that content
|
||||
// to be triggered by the firstUpdated() call that runs query string
|
||||
// actions. To do otherwise may cause a race condition between the default
|
||||
// view and the querystring view, see:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
|
||||
const hasViewRelatedActions = this._api
|
||||
.getQueryStringManager()
|
||||
.hasViewRelatedActions();
|
||||
if (hasViewRelatedActions) {
|
||||
this._api.getQueryStringManager().executeViewRelated();
|
||||
if (!this._api.getViewManager().hasView()) {
|
||||
// Set a view on initial load. However, if the query string contains a
|
||||
// view related action, we don't set any view here and allow that content
|
||||
// to be triggered by the firstUpdated() call that runs query string
|
||||
// actions. To do otherwise may cause a race condition between the default
|
||||
// view and the querystring view, see:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1200
|
||||
const hasViewRelatedActions = this._api
|
||||
.getQueryStringManager()
|
||||
.hasViewRelatedActions();
|
||||
if (hasViewRelatedActions) {
|
||||
this._api.getQueryStringManager().executeViewRelated();
|
||||
} else {
|
||||
this._api.getViewManager().setViewDefault({ failSafe: true });
|
||||
}
|
||||
} else {
|
||||
this._api.getViewManager().setViewDefault({ failSafe: true });
|
||||
// If we already have a view something (e.g. cameras) may have been
|
||||
// reinitialized, be sure to ask for an update.
|
||||
this._api.getCardElementManager().update();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export class ViewManager {
|
||||
}
|
||||
}
|
||||
|
||||
public setViewWithNewContext(context: ViewContext): void {
|
||||
public setViewWithMergedContext(context: ViewContext | null): void {
|
||||
if (this._view) {
|
||||
return this._setView(this._view?.clone().mergeInContext(context));
|
||||
}
|
||||
|
||||
@@ -269,8 +269,8 @@ class FrigateCard extends LitElement {
|
||||
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
|
||||
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
|
||||
this._controller.getViewManager().setView(ev.detail)}
|
||||
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext>) =>
|
||||
this._controller.getViewManager().setViewWithNewContext(ev.detail)}
|
||||
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
|
||||
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
|
||||
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
|
||||
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
|
||||
@frigate-card:media:unloaded=${() =>
|
||||
|
||||
@@ -5,9 +5,9 @@ import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||
import { ViewManager } from '../card-controller/view-manager';
|
||||
import {
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
FrigateCardConfig,
|
||||
FrigateCardCustomAction,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
MenuItem,
|
||||
} from '../config/types';
|
||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||
@@ -15,6 +15,7 @@ import { localize } from '../localize/localize.js';
|
||||
import { MediaLoadedInfo } from '../types';
|
||||
import {
|
||||
createFrigateCardCameraAction,
|
||||
createFrigateCardChangeZoomAction,
|
||||
createFrigateCardDisplayModeAction,
|
||||
createFrigateCardMediaPlayerAction,
|
||||
createFrigateCardShowPTZAction,
|
||||
@@ -95,6 +96,7 @@ export class MenuButtonController {
|
||||
this._getScreenshotButton(config, options?.currentMediaLoadedInfo),
|
||||
this._getDisplayModeButton(config, cameraManager, view),
|
||||
this._getPTZButton(config, cameraManager, view),
|
||||
this._getDefaultZoomButton(config, view),
|
||||
|
||||
...this._dynamicMenuButtons.map((button) => ({
|
||||
style: this._getStyleFromActions(config, view, button, options),
|
||||
@@ -582,7 +584,9 @@ export class MenuButtonController {
|
||||
}
|
||||
|
||||
protected _getSubstreamAwareCameraID(view: View): string {
|
||||
return view.context?.live?.overrides?.get(view.camera) ?? view.camera;
|
||||
return view.is('live')
|
||||
? view.context?.live?.overrides?.get(view.camera) ?? view.camera
|
||||
: view.camera;
|
||||
}
|
||||
|
||||
protected _getPTZButton(
|
||||
@@ -614,6 +618,27 @@ export class MenuButtonController {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getDefaultZoomButton(
|
||||
config: FrigateCardConfig,
|
||||
view: View,
|
||||
): MenuItem | null {
|
||||
const targetID = view.isViewerView()
|
||||
? view.queryResults?.getSelectedResult()?.getID() ?? null
|
||||
: this._getSubstreamAwareCameraID(view);
|
||||
|
||||
if (!targetID || (view.context?.zoom?.[targetID]?.isDefault ?? true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: 'mdi:magnify-close',
|
||||
...config.menu.buttons.default_zoom,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.default_zoom'),
|
||||
tap_action: createFrigateCardChangeZoomAction(targetID) as FrigateCardCustomAction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the style of emphasized menu items.
|
||||
* @returns A StyleInfo.
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import round from 'lodash-es/round';
|
||||
import { dispatchFrigateCardEvent, isHoverableDevice } from '../utils/basic';
|
||||
|
||||
export class ZoomController {
|
||||
constructor(element: HTMLElement) {
|
||||
this._element = element;
|
||||
}
|
||||
|
||||
protected _element: HTMLElement;
|
||||
protected _panzoom?: PanzoomObject;
|
||||
protected _zoomed = false;
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _events = isHoverableDevice()
|
||||
? {
|
||||
down: ['pointerdown'],
|
||||
move: ['pointermove'],
|
||||
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||
}
|
||||
: {
|
||||
down: ['touchstart'],
|
||||
move: ['touchmove'],
|
||||
up: ['touchend', 'touchcancel'],
|
||||
};
|
||||
|
||||
protected _downHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleDown(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
|
||||
// If we do not prevent default here, the media carousels scroll.
|
||||
ev.preventDefault();
|
||||
this._allowClick = false;
|
||||
} else {
|
||||
this._allowClick = true;
|
||||
}
|
||||
};
|
||||
|
||||
protected _clickHandler = (ev: Event) => {
|
||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||
// and panning within it should not cause a related clip to play (the click
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
|
||||
protected _moveHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleMove(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _upHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleUp(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _wheelHandler = (ev: Event) => {
|
||||
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.zoomWithWheel(ev);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _isScaleNormal(scale?: number): boolean {
|
||||
// Floating point arithmetic warning: comparing floating point numbers,
|
||||
// round them first.
|
||||
return scale !== undefined && round(scale, 4) <= 1;
|
||||
}
|
||||
|
||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||
return (
|
||||
!this._isScaleNormal(this._panzoom?.getScale()) ||
|
||||
// TouchEvent does not exist on Firefox on non-touch events.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
protected _setTouchAction(touchEnabled: boolean): void {
|
||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
this._panzoom = Panzoom(this._element, {
|
||||
contain: 'outside',
|
||||
maxScale: 10,
|
||||
minScale: 1,
|
||||
noBind: true,
|
||||
// Do not force the cursor style (by default it will always show the
|
||||
// 'move' type cursor whether or not it is zoomed in).
|
||||
cursor: undefined,
|
||||
|
||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||
// effectively disables dashboard scrolling.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||
touchAction: '',
|
||||
});
|
||||
|
||||
const registerListeners = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.addEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||
registerListeners(['wheel'], this._wheelHandler);
|
||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._element.addEventListener('panzoomzoom', (ev: Event) => {
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
|
||||
if (this._zoomed) {
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
}
|
||||
this._zoomed = false;
|
||||
} else {
|
||||
if (!this._zoomed) {
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
this._zoomed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
const unregisterListener = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: EventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.removeEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||
unregisterListener(['wheel'], this._wheelHandler);
|
||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ZoomConfig {
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
export interface ZoomDefault {
|
||||
isDefault: boolean;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import round from 'lodash-es/round';
|
||||
import {
|
||||
arefloatsApproximatelyEqual,
|
||||
dispatchFrigateCardEvent,
|
||||
isHoverableDevice,
|
||||
} from '../../utils/basic';
|
||||
import { ZoomConfig } from './types';
|
||||
|
||||
const ZOOM_DEFAULT_PAN_X = 50;
|
||||
const ZOOM_DEFAULT_PAN_Y = 50;
|
||||
const ZOOM_DEFAULT_SCALE = 1;
|
||||
const ZOOM_PRECISION = 4;
|
||||
|
||||
export class ZoomController {
|
||||
protected _element: HTMLElement;
|
||||
protected _panzoom?: PanzoomObject;
|
||||
|
||||
// Is the controller zoomed in at all?
|
||||
protected _zoomed = false;
|
||||
|
||||
// Is the controller set to the default zoom/pan settings?
|
||||
protected _default = true;
|
||||
|
||||
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _defaultConfig: ZoomConfig | null;
|
||||
protected _config: ZoomConfig | null;
|
||||
|
||||
// When the user pans/zooms changes may be created at a very high rate.
|
||||
protected _debouncedChangeHandler = debounce(this._changeHandler.bind(this), 200);
|
||||
|
||||
// Multiple calls to setConfig() or setDefaultConfig() or resizes should only
|
||||
// update once.
|
||||
protected _debouncedUpdater = debounce(this._updateBasedOnConfig.bind(this), 200);
|
||||
|
||||
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
|
||||
|
||||
protected _events = isHoverableDevice()
|
||||
? {
|
||||
down: ['pointerdown'],
|
||||
move: ['pointermove'],
|
||||
up: ['pointerup', 'pointerleave', 'pointercancel'],
|
||||
}
|
||||
: {
|
||||
down: ['touchstart'],
|
||||
move: ['touchmove'],
|
||||
up: ['touchend', 'touchcancel'],
|
||||
};
|
||||
|
||||
protected _downHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleDown(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
|
||||
// If we do not prevent default here, the media carousels scroll.
|
||||
ev.preventDefault();
|
||||
this._allowClick = false;
|
||||
} else {
|
||||
this._allowClick = true;
|
||||
}
|
||||
};
|
||||
|
||||
protected _clickHandler = (ev: Event) => {
|
||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||
// and panning within it should not cause a related clip to play (the click
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
|
||||
protected _moveHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleMove(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _upHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleUp(ev as PointerEvent);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected _wheelHandler = (ev: Event) => {
|
||||
if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.zoomWithWheel(ev);
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
element: HTMLElement,
|
||||
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
|
||||
) {
|
||||
this._element = element;
|
||||
this._config = options?.config ?? null;
|
||||
this._defaultConfig = options?.defaultConfig ?? null;
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
const config = this._getConfigToUse();
|
||||
const converted = this._convertPercentToXYPan(
|
||||
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
config?.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
);
|
||||
|
||||
this._panzoom = Panzoom(this._element, {
|
||||
contain: 'outside',
|
||||
maxScale: 10,
|
||||
minScale: 1,
|
||||
noBind: true,
|
||||
// Do not force the cursor style (by default it will always show the
|
||||
// 'move' type cursor whether or not it is zoomed in).
|
||||
cursor: undefined,
|
||||
|
||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||
// effectively disables dashboard scrolling. See:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||
touchAction: '',
|
||||
|
||||
// Set the initial pan/zoom values to avoid an initial unzoomed view.
|
||||
...(config && converted && { startX: converted.x }),
|
||||
...(config && converted && { startY: converted.y }),
|
||||
...(config &&
|
||||
converted && {
|
||||
startScale:
|
||||
config.zoom ??
|
||||
// This is not reachable as without a zoom value, a default of 1 is
|
||||
// assumed in _convertPercentToXYPan, which will return null @
|
||||
// default zoom, and so this cannot be reached in practice.
|
||||
/* istanbul ignore next @preserve */
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
}),
|
||||
});
|
||||
|
||||
const registerListeners = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: AddEventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.addEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
registerListeners(this._events['down'], this._downHandler, { capture: true });
|
||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||
registerListeners(['wheel'], this._wheelHandler);
|
||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._resizeObserver.observe(this._element);
|
||||
this._element.addEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
const unregisterListener = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: EventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.removeEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||
unregisterListener(['wheel'], this._wheelHandler);
|
||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._resizeObserver.disconnect();
|
||||
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
|
||||
}
|
||||
|
||||
public setDefaultConfig(config: ZoomConfig | null): void {
|
||||
this._defaultConfig = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
public setConfig(config: ZoomConfig): void {
|
||||
this._config = config;
|
||||
this._debouncedUpdater();
|
||||
}
|
||||
|
||||
protected _changeHandler(ev: Event): void {
|
||||
const pz = (<CustomEvent<PanzoomEventDetail>>ev).detail;
|
||||
|
||||
const isUnzoomed = this._isUnzoomed(pz.scale);
|
||||
const isAtDefault = this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale);
|
||||
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (isUnzoomed && this._zoomed) {
|
||||
this._zoomed = false;
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
} else if (!isUnzoomed && !this._zoomed) {
|
||||
this._zoomed = true;
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
|
||||
if (isAtDefault && !this._default) {
|
||||
this._default = true;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: true });
|
||||
} else if (!isAtDefault && this._default) {
|
||||
this._default = false;
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:default', { isDefault: false });
|
||||
}
|
||||
}
|
||||
|
||||
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
|
||||
// The ?? clauses below cannot be reached since this function is only ever
|
||||
// used fully specified by this object. It's kept as-is for completeness.
|
||||
return (
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
ZOOM_PRECISION,
|
||||
) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
ZOOM_PRECISION,
|
||||
) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
/* istanbul ignore next @preserve */
|
||||
a.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
/* istanbul ignore next @preserve */
|
||||
b.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
ZOOM_PRECISION,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected _isZoomEmpty(config?: ZoomConfig | null): boolean {
|
||||
return (
|
||||
config?.pan?.x === undefined &&
|
||||
config?.pan?.y === undefined &&
|
||||
config?.zoom === undefined
|
||||
);
|
||||
}
|
||||
|
||||
protected _getConfigToUse(): ZoomConfig | null {
|
||||
return this._isZoomEmpty(this._config) ? this._defaultConfig : this._config;
|
||||
}
|
||||
|
||||
protected _updateBasedOnConfig(): void {
|
||||
if (!this._panzoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = this._getConfigToUse();
|
||||
const desiredScale = config?.zoom ?? ZOOM_DEFAULT_SCALE;
|
||||
|
||||
// Transform won't exist (will be null) if the element has no dimensions, or
|
||||
// if the desired scale has no zoom (i.e. is 1).
|
||||
const converted = this._convertPercentToXYPan(
|
||||
config?.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
config?.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
desiredScale,
|
||||
);
|
||||
|
||||
const x = converted?.x ?? 0;
|
||||
const y = converted?.y ?? 0;
|
||||
|
||||
// Verify there is a material change in the pan/zoom settings before acting.
|
||||
if (
|
||||
this._isZoomEqual(
|
||||
{ zoom: desiredScale, pan: { x: x, y: y } },
|
||||
{
|
||||
zoom: this._panzoom.getScale(),
|
||||
pan: this._panzoom.getPan(),
|
||||
},
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._panzoom.zoom(desiredScale, {
|
||||
animate: false,
|
||||
});
|
||||
|
||||
// Panzoom must allow the browser to paint the zoomed image in order to
|
||||
// "contain" the pan within the parent, this creates somewhat of an async
|
||||
// situation where we need to ensure the zoom completes first. Using
|
||||
// `requestAnimationFrame` appears to reliably allow the zoom to finish
|
||||
// rendering first, before the pain is applied.
|
||||
// See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom
|
||||
window.requestAnimationFrame(() => {
|
||||
this._panzoom?.pan(x, y, {
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from Frigate card pan % values to Panzoom X/Y transformation
|
||||
* coordinates.
|
||||
* @param x The x translation value.
|
||||
* @param y The y translation value.
|
||||
* @param scale The desired (not current) scale.
|
||||
* @returns An object with x/y pan % values or null on error.
|
||||
*/
|
||||
protected _convertPercentToXYPan(
|
||||
x: number,
|
||||
y: number,
|
||||
scale: number,
|
||||
): { x: number; y: number } | null {
|
||||
const minMax = this._getTransformMinMax(scale, this._panzoom?.getScale());
|
||||
if (minMax === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
x: minMax.minX + (minMax.maxX - minMax.minX) * (x / 100),
|
||||
y: minMax.minY + (minMax.maxY - minMax.minY) * (y / 100),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getTransformMinMax(
|
||||
desiredScale: number,
|
||||
currentScale?: number,
|
||||
): {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
} | null {
|
||||
const rendered = this._getRenderedSize(currentScale);
|
||||
|
||||
if (!rendered.width || !rendered.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minX = (rendered.width * (desiredScale - 1)) / desiredScale / 2;
|
||||
const minY = (rendered.height * (desiredScale - 1)) / desiredScale / 2;
|
||||
|
||||
if (arefloatsApproximatelyEqual(minX, 0) || arefloatsApproximatelyEqual(minY, 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
minX: minX,
|
||||
maxX: -minX,
|
||||
minY: minY,
|
||||
maxY: -minY,
|
||||
};
|
||||
}
|
||||
|
||||
protected _getRenderedSize(scale?: number): { width: number; height: number } {
|
||||
const rect = this._element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||
height: rect.height / (scale ?? ZOOM_DEFAULT_SCALE),
|
||||
};
|
||||
}
|
||||
|
||||
protected _isUnzoomed(scale?: number): boolean {
|
||||
return scale !== undefined && round(scale, ZOOM_PRECISION) <= 1;
|
||||
}
|
||||
|
||||
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
|
||||
if (!this._defaultConfig) {
|
||||
return this._isUnzoomed(scale);
|
||||
}
|
||||
|
||||
const convertedDefault = this._convertPercentToXYPan(
|
||||
this._defaultConfig.pan?.x ?? ZOOM_DEFAULT_PAN_X,
|
||||
this._defaultConfig.pan?.y ?? ZOOM_DEFAULT_PAN_Y,
|
||||
this._defaultConfig.zoom ?? ZOOM_DEFAULT_SCALE,
|
||||
);
|
||||
if (!convertedDefault) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
arefloatsApproximatelyEqual(x, convertedDefault.x) &&
|
||||
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
|
||||
arefloatsApproximatelyEqual(
|
||||
scale,
|
||||
this._defaultConfig.zoom ??
|
||||
// The ZOOM_DEFAULT_SCALE clause below cannot be reached since when
|
||||
// this._defaultConfig.zoom is undefined, convertedDefault will end up
|
||||
// null above and this function will have already returned.
|
||||
/* istanbul ignore next @preserve */
|
||||
ZOOM_DEFAULT_SCALE,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||
return (
|
||||
!this._isUnzoomed(this._panzoom?.getScale()) ||
|
||||
// TouchEvent does not exist on Firefox on non-touch events. See:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
protected _setTouchAction(touchEnabled: boolean): void {
|
||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { ZoomConfig, ZoomDefault } from './types.js';
|
||||
import { dispatchViewContextChangeEvent } from '../../view/view.js';
|
||||
|
||||
interface ZoomViewContext {
|
||||
// Populate this to request zoom to a particular scale/x/y. An empty object
|
||||
// will reset to default, null will make no change.
|
||||
zoom?: ZoomConfig | null;
|
||||
|
||||
// This will be populated with whether or not the current zoom is at the
|
||||
// default level.
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface ZoomsViewContext {
|
||||
[targetID: string]: ZoomViewContext;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
zoom?: ZoomsViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
export const generateViewContextForZoomChange = (
|
||||
targetID: string,
|
||||
options?: {
|
||||
zoom?: ZoomConfig | null;
|
||||
isDefault?: boolean;
|
||||
},
|
||||
): ViewContext | null => {
|
||||
return {
|
||||
zoom: {
|
||||
[targetID]: {
|
||||
zoom: options?.zoom ?? null,
|
||||
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience wrapper to convert a zoom default into a dispatched view context
|
||||
* change.
|
||||
*/
|
||||
export const handleZoomDefaultEvent = (
|
||||
element: EventTarget,
|
||||
ev: CustomEvent<ZoomDefault>,
|
||||
targetID?: string,
|
||||
): void => {
|
||||
targetID && dispatchViewContextChangeEvent(
|
||||
element,
|
||||
generateViewContextForZoomChange(targetID, {
|
||||
isDefault: ev.detail.isDefault,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import { CameraConfig, ImageViewConfig } from '../config/types.js';
|
||||
import defaultImage from '../images/frigate-bird-in-sky.jpg';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -277,8 +280,25 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
}
|
||||
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
const targetID = this.cameraConfig?.id;
|
||||
const zoomConfig = targetID ? this.view?.context?.zoom?.[targetID]?.zoom : undefined;
|
||||
|
||||
return this.imageConfig?.zoomable
|
||||
? html` <frigate-card-zoomer> ${template} </frigate-card-zoomer>`
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${zoomConfig}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, targetID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
: template;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import liveImageStyle from '../../scss/live-image.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import { FrigateCardMediaPlayer } from '../../types.js';
|
||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj';
|
||||
import '../image.js';
|
||||
@@ -17,9 +16,6 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: true })
|
||||
public watermark?: string;
|
||||
|
||||
protected _refImage: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
@@ -80,17 +76,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
>
|
||||
</frigate-card-image>
|
||||
${this.watermark
|
||||
? html`<ha-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
icon="${this.watermark}"
|
||||
></ha-icon>`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveImageStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||
import { LiveController } from '../../components-lib/live/live-controller.js';
|
||||
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||
import { ZoomConfig, ZoomDefault } from '../../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -501,6 +503,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.liveConfig=${liveConfig}
|
||||
.hass=${this.hass}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.zoomConfig=${this.view?.context?.zoom?.[cameraID]?.zoom}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, cameraID)}
|
||||
>
|
||||
</frigate-card-live-provider>
|
||||
</div>
|
||||
@@ -688,6 +693,9 @@ export class FrigateCardLiveProvider
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
@property({ attribute: false })
|
||||
public zoomConfig?: ZoomConfig | null;
|
||||
|
||||
@state()
|
||||
protected _isVideoMediaLoaded = false;
|
||||
|
||||
@@ -793,9 +801,6 @@ export class FrigateCardLiveProvider
|
||||
this._isVideoMediaLoaded = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that video media is being shown.
|
||||
*/
|
||||
protected _videoMediaShowHandler(): void {
|
||||
this._isVideoMediaLoaded = true;
|
||||
}
|
||||
@@ -852,6 +857,15 @@ export class FrigateCardLiveProvider
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
return this.liveConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([this.cameraConfig?.dimensions?.layout], () =>
|
||||
this.cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: this.cameraConfig.dimensions.layout.pan,
|
||||
zoom: this.cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${this.zoomConfig}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
>
|
||||
@@ -899,15 +913,12 @@ export class FrigateCardLiveProvider
|
||||
}
|
||||
}
|
||||
|
||||
return this._useZoomIfRequired(html`
|
||||
return html`${this._useZoomIfRequired(html`
|
||||
${showImageDuringLoading || provider === 'image'
|
||||
? html` <frigate-card-live-image
|
||||
${ref(this._refProvider)}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${this.cameraConfig}
|
||||
watermark=${ifDefined(
|
||||
showImageDuringLoading ? 'mdi:progress-helper' : undefined,
|
||||
)}
|
||||
@frigate-card:media:loaded=${(ev: Event) => {
|
||||
if (provider === 'image') {
|
||||
// Only count the media has loaded if the required provider is
|
||||
@@ -968,7 +979,13 @@ export class FrigateCardLiveProvider
|
||||
>
|
||||
</frigate-card-live-jsmpeg>`
|
||||
: html``}
|
||||
`);
|
||||
`)}
|
||||
${showImageDuringLoading && !this._isVideoMediaLoaded
|
||||
? html`<ha-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
icon="mdi:progress-helper"
|
||||
></ha-icon>`
|
||||
: ''} `;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||
import { ZoomDefault } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomDefaultEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
import {
|
||||
dispatchMessageEvent,
|
||||
renderMessage,
|
||||
@@ -484,7 +486,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this._media, () => this._getSlides())}
|
||||
${guard([this._media, this.view], () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
@@ -865,10 +867,28 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
|
||||
protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
|
||||
if (!this.media) {
|
||||
return template;
|
||||
}
|
||||
const cameraID = this.media.getCameraID();
|
||||
const mediaID = this.media.getID() ?? undefined;
|
||||
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
|
||||
|
||||
return this.viewerConfig?.zoomable
|
||||
? html` <frigate-card-zoomer
|
||||
.defaultConfig=${guard([cameraConfig?.dimensions?.layout], () =>
|
||||
cameraConfig?.dimensions?.layout
|
||||
? {
|
||||
pan: cameraConfig.dimensions.layout.pan,
|
||||
zoom: cameraConfig.dimensions.layout.zoom,
|
||||
}
|
||||
: undefined,
|
||||
)}
|
||||
.config=${mediaID ? this.view?.context?.zoom?.[mediaID]?.zoom : undefined}
|
||||
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
|
||||
@frigate-card:zoom:unzoomed=${() => this.setControls()}
|
||||
@frigate-card:zoom:default=${(ev: CustomEvent<ZoomDefault>) =>
|
||||
handleZoomDefaultEvent(this, ev, mediaID)}
|
||||
>
|
||||
${template}
|
||||
</frigate-card-zoomer>`
|
||||
|
||||
@@ -6,13 +6,20 @@ import {
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { ZoomController } from '../components-lib/zoom-controller.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ZoomConfig } from '../components-lib/zoom/types.js';
|
||||
import { ZoomController } from '../components-lib/zoom/zoom-controller.js';
|
||||
import { setOrRemoveAttribute } from '../utils/basic.js';
|
||||
|
||||
@customElement('frigate-card-zoomer')
|
||||
export class FrigateCardZoomer extends LitElement {
|
||||
protected _zoom = new ZoomController(this);
|
||||
protected _zoom: ZoomController | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public defaultConfig?: ZoomConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public config?: ZoomConfig | null;
|
||||
|
||||
@state()
|
||||
protected _zoomed = false;
|
||||
@@ -24,11 +31,13 @@ export class FrigateCardZoomer extends LitElement {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||
this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||
this._zoom.activate();
|
||||
|
||||
// Call for an update to activate.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._zoom.deactivate();
|
||||
this._zoom?.deactivate();
|
||||
this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
|
||||
this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
|
||||
}
|
||||
@@ -37,7 +46,26 @@ export class FrigateCardZoomer extends LitElement {
|
||||
if (changedProps.has('_zoomed')) {
|
||||
setOrRemoveAttribute(this, this._zoomed, 'zoomed');
|
||||
}
|
||||
|
||||
if (this._zoom) {
|
||||
if (changedProps.has('defaultConfig')) {
|
||||
this._zoom.setDefaultConfig(this.defaultConfig ?? null);
|
||||
}
|
||||
// If config is null, make no change to the zoom.
|
||||
if (changedProps.has('config') && this.config) {
|
||||
this._zoom.setConfig(this.config);
|
||||
}
|
||||
} else {
|
||||
// Ensure that the configuration will be set before activation (vs
|
||||
// activating in `connectedCallback`).
|
||||
this._zoom = new ZoomController(this, {
|
||||
config: this.config,
|
||||
defaultConfig: this.defaultConfig,
|
||||
});
|
||||
this._zoom.activate();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <slot></slot> `;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,16 @@ const CAMERA_TRIGGER_EVENT_TYPES = [
|
||||
] as const;
|
||||
export type CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number];
|
||||
|
||||
// *************************************************************************
|
||||
// Pan / Zoom
|
||||
// *************************************************************************
|
||||
|
||||
const panSchema = z.object({
|
||||
x: z.number().min(0).max(100).optional(),
|
||||
y: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
const zoomSchema = z.number().min(1).max(10);
|
||||
|
||||
// *************************************************************************
|
||||
// View Display Mode
|
||||
// *************************************************************************
|
||||
@@ -289,15 +299,23 @@ const frigateCardShowPTZActionSchema = frigateCardCustomActionsBaseSchema.extend
|
||||
show_ptz: z.boolean(),
|
||||
});
|
||||
|
||||
const frigateCardChangeZoomActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.literal('change_zoom'),
|
||||
target_id: z.string(),
|
||||
zoom: zoomSchema.optional(),
|
||||
pan: panSchema.optional(),
|
||||
});
|
||||
|
||||
export const frigateCardCustomActionSchema = z.union([
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardCameraSelectActionSchema,
|
||||
frigateCardChangeZoomActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardLiveDependencySelectActionSchema,
|
||||
frigateCardMediaPlayerActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
frigateCardPTZActionSchema,
|
||||
frigateCardShowPTZActionSchema,
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
]);
|
||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||
|
||||
@@ -626,6 +644,8 @@ const mediaLayoutConfigSchema = z.object({
|
||||
top: z.number().min(0).max(100).optional().default(0),
|
||||
})
|
||||
.optional(),
|
||||
pan: panSchema.optional(),
|
||||
zoom: zoomSchema.optional(),
|
||||
});
|
||||
export type MediaLayoutConfig = z.infer<typeof mediaLayoutConfigSchema>;
|
||||
|
||||
@@ -1345,22 +1365,20 @@ const hiddenButtonDefault = {
|
||||
};
|
||||
|
||||
const menuConfigDefault = {
|
||||
style: 'hidden' as const,
|
||||
position: 'top' as const,
|
||||
alignment: 'left' as const,
|
||||
button_size: 40,
|
||||
buttons: {
|
||||
frigate: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
substreams: visibleButtonDefault,
|
||||
live: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
image: hiddenButtonDefault,
|
||||
timeline: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
camera_ui: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
cameras: visibleButtonDefault,
|
||||
clips: visibleButtonDefault,
|
||||
default_zoom: visibleButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
download: visibleButtonDefault,
|
||||
expand: hiddenButtonDefault,
|
||||
frigate: visibleButtonDefault,
|
||||
fullscreen: visibleButtonDefault,
|
||||
image: hiddenButtonDefault,
|
||||
live: visibleButtonDefault,
|
||||
media_player: visibleButtonDefault,
|
||||
microphone: {
|
||||
...hiddenButtonDefault,
|
||||
@@ -1368,12 +1386,15 @@ const menuConfigDefault = {
|
||||
},
|
||||
mute: hiddenButtonDefault,
|
||||
play: hiddenButtonDefault,
|
||||
ptz: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
ptz: hiddenButtonDefault,
|
||||
snapshots: visibleButtonDefault,
|
||||
substreams: visibleButtonDefault,
|
||||
timeline: visibleButtonDefault,
|
||||
},
|
||||
button_size: 40,
|
||||
position: 'top' as const,
|
||||
style: 'hidden' as const,
|
||||
};
|
||||
|
||||
const visibleButtonSchema = menuBaseSchema.extend({
|
||||
@@ -1393,18 +1414,21 @@ export const menuConfigSchema = z
|
||||
alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment),
|
||||
buttons: z
|
||||
.object({
|
||||
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
||||
camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui),
|
||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||
cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras),
|
||||
clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips),
|
||||
default_zoom: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.default_zoom,
|
||||
),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
),
|
||||
download: visibleButtonSchema.default(menuConfigDefault.buttons.download),
|
||||
expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand),
|
||||
frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate),
|
||||
fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen),
|
||||
image: hiddenButtonSchema.default(menuConfigDefault.buttons.image),
|
||||
live: visibleButtonSchema.default(menuConfigDefault.buttons.live),
|
||||
media_player: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.media_player,
|
||||
),
|
||||
@@ -1415,14 +1439,14 @@ export const menuConfigSchema = z
|
||||
.default(menuConfigDefault.buttons.microphone.type),
|
||||
})
|
||||
.default(menuConfigDefault.buttons.microphone),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
),
|
||||
ptz: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz),
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots),
|
||||
substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams),
|
||||
timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline),
|
||||
})
|
||||
.default(menuConfigDefault.buttons),
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||
|
||||
@@ -57,6 +57,10 @@ export const CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS =
|
||||
`${CONF_CAMERAS}.#.dependencies.all_cameras` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.fit` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.x` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.pan.y` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.position.x` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y =
|
||||
@@ -69,6 +73,8 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.left` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||
|
||||
@@ -44,12 +44,15 @@ import {
|
||||
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_FIT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_X,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_POSITION_Y,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_BOTTOM,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_LEFT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_TOP,
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_CAMERA_NAME,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID,
|
||||
CONF_CAMERAS_ARRAY_FRIGATE_LABELS,
|
||||
@@ -817,9 +820,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(): void {
|
||||
if (!this._initialized) {
|
||||
sideLoadHomeAssistantElements().then((success) => {
|
||||
@@ -981,6 +981,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
max?: number;
|
||||
label?: string;
|
||||
default?: number;
|
||||
step?: number;
|
||||
},
|
||||
): TemplateResult | void {
|
||||
if (!this._config) {
|
||||
@@ -992,7 +993,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
return html`
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${{ number: { min: params?.min || 0, max: params?.max, mode: mode } }}
|
||||
.selector=${{
|
||||
number: {
|
||||
min: params?.min || 0,
|
||||
max: params?.max,
|
||||
mode: mode,
|
||||
step: params?.step,
|
||||
},
|
||||
}}
|
||||
.label=${params?.label || this._getLabel(configPath)}
|
||||
.value=${value ?? params?.default}
|
||||
.required=${false}
|
||||
@@ -1230,10 +1238,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathFit: string,
|
||||
configPathPositionX: string,
|
||||
configPathPositionY: string,
|
||||
configPathPositionTop: string,
|
||||
configPathPositionBottom: string,
|
||||
configPathPositionLeft: string,
|
||||
configPathPositionRight: string,
|
||||
configPathViewBoxTop: string,
|
||||
configPathViewBoxBottom: string,
|
||||
configPathViewBoxLeft: string,
|
||||
configPathViewBoxRight: string,
|
||||
configPathZoom: string,
|
||||
configPathPanX: string,
|
||||
configPathPanY: string,
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
domain,
|
||||
@@ -1241,6 +1252,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
labelPath,
|
||||
{ name: 'mdi:page-layout-body' },
|
||||
html`
|
||||
${this._renderNumberInput(configPathZoom, {
|
||||
min: 0,
|
||||
max: 10,
|
||||
label: localize('config.cameras.dimensions.layout.zoom'),
|
||||
step: 0.1,
|
||||
})}
|
||||
${this._renderNumberInput(configPathPanX, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.pan.x'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPanY, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.pan.y'),
|
||||
})}
|
||||
${this._renderOptionSelector(configPathFit, this._layoutFits, {
|
||||
label: localize('config.cameras.dimensions.layout.fit'),
|
||||
})}
|
||||
@@ -1266,22 +1293,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
'config.cameras.dimensions.layout.view_box.editor_label',
|
||||
{ name: 'mdi:crop' },
|
||||
html`
|
||||
${this._renderNumberInput(configPathPositionTop, {
|
||||
${this._renderNumberInput(configPathViewBoxTop, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.top'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionBottom, {
|
||||
${this._renderNumberInput(configPathViewBoxBottom, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.bottom'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionLeft, {
|
||||
${this._renderNumberInput(configPathViewBoxLeft, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.left'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionRight, {
|
||||
${this._renderNumberInput(configPathViewBoxRight, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: localize('config.cameras.dimensions.layout.view_box.right'),
|
||||
@@ -2041,6 +2068,18 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_X,
|
||||
cameraIndex,
|
||||
),
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_PAN_Y,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
@@ -2273,6 +2312,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderMenuButton('screenshot')}
|
||||
${this._renderMenuButton('display_mode')}
|
||||
${this._renderMenuButton('ptz')}
|
||||
${this._renderMenuButton('default_zoom')}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "El suport s'expandeix proporcionalment per cobrir la targeta",
|
||||
"fill": "El suport s'estira per omplir la targeta"
|
||||
},
|
||||
"pan": {
|
||||
"x": "",
|
||||
"y": ""
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "",
|
||||
"x": "Percentatge d'emplaçament horitzontal",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "",
|
||||
"right": "",
|
||||
"top": ""
|
||||
}
|
||||
},
|
||||
"zoom": ""
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Interfície d'usuari de la càmera",
|
||||
"cameras": "Càmeres",
|
||||
"clips": "Clips",
|
||||
"default_zoom": "",
|
||||
"display_mode": "Mode de visualització",
|
||||
"download": "Descarregar",
|
||||
"enabled": "Botó habilitat",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "Media expands proportionally to cover the card",
|
||||
"fill": "Media is stretched to fill the card"
|
||||
},
|
||||
"pan": {
|
||||
"x": "Pan horizontal percentage",
|
||||
"y": "Pan vertical percentage"
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "Layout Cover Position",
|
||||
"x": "Horizontal placement percentage",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "Left inset percentage",
|
||||
"right": "Right inset percentage",
|
||||
"top": "Top inset percentage"
|
||||
}
|
||||
},
|
||||
"zoom": "Zoom factor"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Camera user interface",
|
||||
"cameras": "Cameras",
|
||||
"clips": "Clips",
|
||||
"default_zoom": "Zoom to default",
|
||||
"display_mode": "Display mode",
|
||||
"download": "Download",
|
||||
"enabled": "Button enabled",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
||||
"fill": "Il supporto viene allungato per riempire la scheda"
|
||||
},
|
||||
"pan": {
|
||||
"x": "",
|
||||
"y": ""
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "",
|
||||
"x": "Percentuale di posizionamento orizzontale",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "",
|
||||
"right": "",
|
||||
"top": ""
|
||||
}
|
||||
},
|
||||
"zoom": ""
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Interface utilisateur de la caméra",
|
||||
"cameras": "Appareils photo",
|
||||
"clips": "Extraits",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Télécharger",
|
||||
"enabled": "Bouton activé",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "Il supporto si espande proporzionalmente per coprire la scheda",
|
||||
"fill": "Il supporto viene allungato per riempire la scheda"
|
||||
},
|
||||
"pan": {
|
||||
"x": "",
|
||||
"y": ""
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "",
|
||||
"x": "Percentuale di posizionamento orizzontale",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "",
|
||||
"right": "",
|
||||
"top": ""
|
||||
}
|
||||
},
|
||||
"zoom": ""
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Interfaccia utente della fotocamera",
|
||||
"cameras": "Telecamere",
|
||||
"clips": "Clip",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Download",
|
||||
"enabled": "Pulsante abilitato",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "A mídia se expande proporcionalmente para cobrir o cartão",
|
||||
"fill": "A mídia é esticada para preencher o cartão"
|
||||
},
|
||||
"pan": {
|
||||
"x": "",
|
||||
"y": ""
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "",
|
||||
"x": "Porcentagem do posicionamento horizontal",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "",
|
||||
"right": "",
|
||||
"top": ""
|
||||
}
|
||||
},
|
||||
"zoom": ""
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Interface de usuário da câmera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Baixe a mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"cover": "Tapar",
|
||||
"fill": "Preencher"
|
||||
},
|
||||
"pan": {
|
||||
"x": "",
|
||||
"y": ""
|
||||
},
|
||||
"position": {
|
||||
"editor_label": "",
|
||||
"x": "Percentagem da localização horizontal",
|
||||
@@ -66,7 +70,8 @@
|
||||
"left": "",
|
||||
"right": "",
|
||||
"top": ""
|
||||
}
|
||||
},
|
||||
"zoom": ""
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -357,6 +362,7 @@
|
||||
"camera_ui": "Camera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"default_zoom": "",
|
||||
"display_mode": "",
|
||||
"download": "Descarregar mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -2,8 +2,16 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ha-icon {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,27 @@ export function createFrigateCardShowPTZAction(
|
||||
};
|
||||
}
|
||||
|
||||
export function createFrigateCardChangeZoomAction(
|
||||
targetID: string,
|
||||
options?: {
|
||||
cardID?: string;
|
||||
pan?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
zoom?: number;
|
||||
},
|
||||
): FrigateCardCustomAction {
|
||||
return {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: targetID,
|
||||
...(options?.cardID && { card_id: options.cardID }),
|
||||
...(options?.pan && { pan: options.pan }),
|
||||
...(options?.zoom && { zoom: options.zoom }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an action configuration given a config and an interaction (e.g. 'tap').
|
||||
* @param interaction The interaction: `tap`, `hold` or `double_tap`
|
||||
|
||||
@@ -3,7 +3,8 @@ import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import format from 'date-fns/format';
|
||||
import { StyleInfo } from 'lit/directives/style-map';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { round } from 'lodash-es';
|
||||
import isEqualWith from 'lodash-es/isEqualWith';
|
||||
import mergeWith from 'lodash-es/mergeWith';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
@@ -87,8 +88,12 @@ export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
||||
* @param o The old value.
|
||||
* @returns `true` is the contents have changed.
|
||||
*/
|
||||
export function contentsChanged(n: unknown, o: unknown): boolean {
|
||||
return !isEqual(n, o);
|
||||
export function contentsChanged(
|
||||
n: unknown,
|
||||
o: unknown,
|
||||
customizer?: (a: unknown, b: unknown) => boolean | undefined,
|
||||
): boolean {
|
||||
return !isEqualWith(n, o, customizer);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -286,3 +291,24 @@ export const desparsifyArrays = <T>(data: T): T => {
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const arefloatsApproximatelyEqual = (
|
||||
a: number,
|
||||
b: number,
|
||||
precision?: number,
|
||||
): boolean => {
|
||||
return round(a, precision) === round(b, precision);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a lodash isEqualsWith customizer that can compare floats.
|
||||
*/
|
||||
export const generateFloatApproximatelyEqualsCustomizer = (
|
||||
precision: number,
|
||||
): ((a: unknown, b: unknown) => boolean | undefined) => {
|
||||
return (a: unknown, b: unknown) => {
|
||||
return typeof a === 'number' && typeof b === 'number'
|
||||
? arefloatsApproximatelyEqual(a, b, precision)
|
||||
: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -177,7 +177,7 @@ export class View {
|
||||
* @param context The context to merge in.
|
||||
* @returns This view.
|
||||
*/
|
||||
public mergeInContext(context?: ViewContext): View {
|
||||
public mergeInContext(context?: ViewContext | null): View {
|
||||
this.context = merge({}, this.context, context);
|
||||
return this;
|
||||
}
|
||||
@@ -306,7 +306,7 @@ export interface FrigateCardViewChangeEventTarget extends EventTarget {
|
||||
*/
|
||||
export const dispatchViewContextChangeEvent = (
|
||||
target: EventTarget,
|
||||
context: ViewContext,
|
||||
context: ViewContext | null,
|
||||
): void => {
|
||||
dispatchFrigateCardEvent(target, 'view:change-context', context);
|
||||
};
|
||||
|
||||
@@ -259,16 +259,9 @@ describe('CameraManager', async () => {
|
||||
return new CameraManager(api, { factory: mockFactory });
|
||||
};
|
||||
|
||||
it('should reset', async () => {
|
||||
const store = new CameraManagerStore();
|
||||
store.addCamera(
|
||||
new Camera(createCameraConfig({ id: 'id' }), mock<CameraManagerEngine>()),
|
||||
);
|
||||
const manager = new CameraManager(createCardAPI(), { store: store });
|
||||
|
||||
expect(manager.getStore().getCameraCount()).toBe(1);
|
||||
await manager.reset();
|
||||
expect(manager.getStore().getCameraCount()).toBe(0);
|
||||
it('should construct', async () => {
|
||||
const manager = new CameraManager(createCardAPI());
|
||||
expect(manager.getStore()).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('should initialize cameras from config', () => {
|
||||
|
||||
@@ -196,7 +196,6 @@ describe('ActionsManager.handleInteraction', () => {
|
||||
|
||||
manager.handleInteractionEvent(
|
||||
new CustomEvent<Interaction>('event', {
|
||||
|
||||
// Malformed interaction type.
|
||||
detail: { action: 'double_finger_snap' } as unknown as Interaction,
|
||||
}),
|
||||
@@ -800,11 +799,68 @@ describe('ActionsManager.executeAction', () => {
|
||||
})!,
|
||||
);
|
||||
|
||||
expect(api.getViewManager().setViewWithNewContext).toBeCalledWith(
|
||||
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||
expect.objectContaining({ live: { ptzVisible: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('should handle change_zoom action', () => {
|
||||
it('default', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
await manager.executeFrigateAction(
|
||||
createAction({
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: 'camera.office',
|
||||
})!,
|
||||
);
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
zoom: {
|
||||
'camera.office': {
|
||||
zoom: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('non-default', async () => {
|
||||
const api = createCardAPI();
|
||||
const manager = new ActionsManager(api);
|
||||
|
||||
await manager.executeFrigateAction(
|
||||
createAction({
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: 'camera.office',
|
||||
zoom: 2,
|
||||
pan: {
|
||||
x: 3,
|
||||
y: 4,
|
||||
},
|
||||
})!,
|
||||
);
|
||||
|
||||
expect(api.getViewManager().setViewWithMergedContext).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
zoom: {
|
||||
'camera.office': {
|
||||
zoom: {
|
||||
zoom: 2,
|
||||
pan: {
|
||||
x: 3,
|
||||
y: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unknown action', async () => {
|
||||
const manager = new ActionsManager(createCardAPI());
|
||||
|
||||
|
||||
@@ -149,7 +149,6 @@ describe('CardElementManager', () => {
|
||||
element.removeEventListener = removeEventListener;
|
||||
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getCameraManager().reset).mockResolvedValue();
|
||||
|
||||
const manager = new CardElementManager(
|
||||
api,
|
||||
@@ -186,6 +185,6 @@ describe('CardElementManager', () => {
|
||||
);
|
||||
expect(windowRemoveEventListener).toBeCalledWith('popstate', expect.anything());
|
||||
|
||||
expect(api.getCameraManager().reset).toBeCalled();
|
||||
expect(api.getInitializationManager().uninitialize).toBeCalledWith('cameras');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { loadLanguages } from '../../src/localize/localize';
|
||||
import {
|
||||
InitializationAspect,
|
||||
InitializationManager,
|
||||
} from '../../src/card-controller/initialization-manager';
|
||||
import { loadLanguages } from '../../src/localize/localize';
|
||||
import { sideLoadHomeAssistantElements } from '../../src/utils/ha';
|
||||
import { Initializer } from '../../src/utils/initializer/initializer';
|
||||
import { createCardAPI, createConfig, createHASS } from '../test-utils';
|
||||
@@ -184,6 +184,22 @@ describe('InitializationManager', () => {
|
||||
|
||||
expect(await manager.initializeMandatory()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('with existing view', async () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||
vi.mocked(api.getViewManager().hasView).mockReturnValue(true);
|
||||
|
||||
const initializer = mock<Initializer>();
|
||||
const manager = new InitializationManager(api, initializer);
|
||||
initializer.initializeMultipleIfNecessary
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
|
||||
expect(await manager.initializeMandatory()).toBeTruthy();
|
||||
expect(api.getCardElementManager().update).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('should initialize background', () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('ViewManager.setView', () => {
|
||||
const context = { live: { fetchThumbnails: false } };
|
||||
|
||||
// Setting context with no existing view does nothing.
|
||||
manager.setViewWithNewContext(context);
|
||||
manager.setViewWithMergedContext(context);
|
||||
expect(manager.getView()).toBeNull();
|
||||
|
||||
const view = createView({
|
||||
@@ -89,7 +89,7 @@ describe('ViewManager.setView', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
manager.setView(view);
|
||||
manager.setViewWithNewContext(context);
|
||||
manager.setViewWithMergedContext(context);
|
||||
|
||||
expect(manager.getView()?.camera).toBe('camera');
|
||||
expect(manager.getView()?.view).toBe('live');
|
||||
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
MenuButtonController,
|
||||
MenuButtonControllerOptions,
|
||||
} from '../../src/components-lib/menu-button-controller';
|
||||
import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types';
|
||||
import {
|
||||
FrigateCardConfig,
|
||||
FrigateCardView,
|
||||
MenuItem,
|
||||
ViewDisplayMode,
|
||||
} from '../../src/config/types';
|
||||
import { FrigateCardMediaPlayer } from '../../src/types';
|
||||
import { createFrigateCardSimpleAction } from '../../src/utils/action';
|
||||
import { ViewMedia } from '../../src/view/media';
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
createStateEntity,
|
||||
createStore,
|
||||
createView,
|
||||
TestViewMedia,
|
||||
} from '../test-utils';
|
||||
|
||||
vi.mock('../../src/utils/media-player-controller.js');
|
||||
@@ -1487,6 +1493,69 @@ describe('MenuButtonController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('should have change zoom button', () => {
|
||||
it.each([
|
||||
['live' as const, true, false],
|
||||
['live' as const, undefined, false],
|
||||
['live' as const, false, true],
|
||||
['media' as const, true, false],
|
||||
['media' as const, undefined, false],
|
||||
['media' as const, false, true],
|
||||
])(
|
||||
'%s view with isDefault %s',
|
||||
(
|
||||
viewName: FrigateCardView,
|
||||
isDefault: boolean | undefined,
|
||||
expectedResult: boolean,
|
||||
) => {
|
||||
const cameraManager = createCameraManager();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(
|
||||
createStore([{ cameraID: 'camera-1' }]),
|
||||
);
|
||||
|
||||
const targetID = viewName === 'live' ? 'camera-1' : 'media-1';
|
||||
const view = createView({
|
||||
view: viewName,
|
||||
camera: 'camera-1',
|
||||
queryResults: new MediaQueriesResults({
|
||||
results: [new TestViewMedia({ id: 'media-1' })],
|
||||
selectedIndex: 0,
|
||||
}),
|
||||
context: {
|
||||
zoom: {
|
||||
[targetID]: {
|
||||
isDefault: isDefault,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const buttons = calculateButtons(controller, {
|
||||
cameraManager: cameraManager,
|
||||
view: view,
|
||||
});
|
||||
|
||||
if (expectedResult) {
|
||||
expect(buttons).toContainEqual({
|
||||
enabled: true,
|
||||
icon: 'mdi:magnify-close',
|
||||
priority: 50,
|
||||
tap_action: {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
target_id: targetID,
|
||||
},
|
||||
title: 'Zoom to default',
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
});
|
||||
} else {
|
||||
expect(buttons).not.toContainEqual(
|
||||
expect.objectContaining({ title: 'Set zoom to default' }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('should handle dynamic buttons', () => {
|
||||
it('successfully', () => {
|
||||
const button: MenuItem = {
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { ZoomController } from '../../src/components-lib/zoom-controller';
|
||||
|
||||
vi.mock('@dermotduffy/panzoom');
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/2527
|
||||
(window as any).PointerEvent = MouseEvent;
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ZoomController', () => {
|
||||
const mediaMediSpy = vi.spyOn(window, 'matchMedia');
|
||||
|
||||
const createMockPanZoom = (): PanzoomObject => {
|
||||
const panzoom = mock<PanzoomObject>();
|
||||
panzoom.getScale.mockReturnValue(1.0);
|
||||
return panzoom;
|
||||
};
|
||||
|
||||
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
|
||||
const zoom = new ZoomController(element);
|
||||
zoom.activate();
|
||||
return zoom;
|
||||
};
|
||||
|
||||
const createTouch = (target: HTMLElement): Touch => {
|
||||
return {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
force: 0,
|
||||
identifier: 0,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
rotationAngle: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
target: target,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be creatable', () => {
|
||||
const element = document.createElement('div');
|
||||
const zoom = new ZoomController(element);
|
||||
expect(zoom).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should respond with pointer', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
// Won't zoom without control key.
|
||||
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||
|
||||
const ev_2 = new WheelEvent('wheel', {
|
||||
bubbles: false,
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
|
||||
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
const ev_3 = new PointerEvent('pointerdown');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||
|
||||
const ev_4 = new PointerEvent('pointermove');
|
||||
element.dispatchEvent(ev_4);
|
||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||
|
||||
const ev_5 = new PointerEvent('pointerup');
|
||||
element.dispatchEvent(ev_5);
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
|
||||
it('should not respond to pointer when not zoomed', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new PointerEvent('pointerdown');
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
|
||||
|
||||
const ev_2 = new PointerEvent('pointermove');
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
|
||||
|
||||
const ev_3 = new PointerEvent('pointerup');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
|
||||
});
|
||||
|
||||
it('should respond with touch', () => {
|
||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new TouchEvent('touchstart', {
|
||||
bubbles: false,
|
||||
touches: [createTouch(element), createTouch(element)],
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_1);
|
||||
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
const ev_3 = new TouchEvent('touchstart');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||
|
||||
const ev_4 = new TouchEvent('touchmove');
|
||||
element.dispatchEvent(ev_4);
|
||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||
|
||||
const ev_5 = new TouchEvent('touchend');
|
||||
element.dispatchEvent(ev_5);
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
|
||||
it('should ignore click after pointerdown', () => {
|
||||
const outer = document.createElement('div');
|
||||
const inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
const clickHandler = vi.fn();
|
||||
outer.addEventListener('click', clickHandler);
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(inner);
|
||||
|
||||
// Simulate being zoomed in.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
// A click on its own will be fine.
|
||||
const click_1 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_1);
|
||||
expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// A click after a pointerdown will be ignored.
|
||||
const pointerdown_1 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_1);
|
||||
|
||||
const click_2 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_2);
|
||||
|
||||
// Click will have been ignored.
|
||||
expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// Simulate being zoomed out.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.0);
|
||||
const pointerdown_2 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_2);
|
||||
|
||||
const click_3 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_3);
|
||||
|
||||
// Click will have been processed.
|
||||
expect(clickHandler).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('deactivate should remove event handlers', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element).deactivate();
|
||||
|
||||
const ev_1 = new WheelEvent('wheel', {
|
||||
bubbles: false,
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should fire frigate cards on zoom/unzoom', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const zoomedFunc = vi.fn();
|
||||
const unzoomedFunc = vi.fn();
|
||||
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(zoomedFunc).toBeCalled();
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(unzoomedFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set touch action on zoom/unzoom', () => {
|
||||
const element = document.createElement('div');
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(element.style.touchAction).toBe('none');
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(element.style.touchAction).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should not fire frigate cards when state has not changed or spurious events received', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const zoomedFunc = vi.fn();
|
||||
const unzoomedFunc = vi.fn();
|
||||
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
|
||||
// Unzoomed event with scale === 1, this._zoomed will already be false.
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
expect(zoomedFunc).not.toBeCalled();
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(zoomedFunc).toBeCalledTimes(1);
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
|
||||
// Another call when already zoomed will be ignored.
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(zoomedFunc).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,658 @@
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock, mockClear } from 'vitest-mock-extended';
|
||||
import { ZoomController } from '../../../src/components-lib/zoom/zoom-controller';
|
||||
import { ResizeObserverMock, requestAnimationFrameMock } from '../../test-utils';
|
||||
|
||||
vi.mock('@dermotduffy/panzoom');
|
||||
vi.mock('lodash-es/debounce', () => ({
|
||||
default: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/2527
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).PointerEvent = MouseEvent;
|
||||
|
||||
const triggerResizeObserver = (): void => {
|
||||
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[0][0];
|
||||
resizeObserverTrigger([], mock<ResizeObserver>());
|
||||
};
|
||||
|
||||
const setElementToDefaultCardSize = (element: HTMLElement, multiple?: number): void => {
|
||||
element.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 492 * (multiple ?? 1),
|
||||
height: 276.75 * (multiple ?? 1),
|
||||
});
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ZoomController', () => {
|
||||
const mediaSpy = vi.spyOn(window, 'matchMedia');
|
||||
|
||||
const createMockPanZoom = (): PanzoomObject => {
|
||||
const panzoom = mock<PanzoomObject>();
|
||||
panzoom.getScale.mockReturnValue(1.0);
|
||||
panzoom.getPan.mockReturnValue({ x: 0, y: 0 });
|
||||
return panzoom;
|
||||
};
|
||||
|
||||
const createAndRegisterZoom = (element: HTMLElement): ZoomController => {
|
||||
const zoom = new ZoomController(element);
|
||||
zoom.activate();
|
||||
return zoom;
|
||||
};
|
||||
|
||||
const createTouch = (target: HTMLElement): Touch => {
|
||||
return {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
force: 0,
|
||||
identifier: 0,
|
||||
pageX: 0,
|
||||
pageY: 0,
|
||||
radiusX: 0,
|
||||
radiusY: 0,
|
||||
rotationAngle: 0,
|
||||
screenX: 0,
|
||||
screenY: 0,
|
||||
target: target,
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
window.requestAnimationFrame = requestAnimationFrameMock;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Panzoom).mockReset();
|
||||
vi.mocked(global.ResizeObserver).mockClear();
|
||||
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: true });
|
||||
});
|
||||
|
||||
it('should be creatable', () => {
|
||||
const element = document.createElement('div');
|
||||
const zoom = new ZoomController(element);
|
||||
expect(zoom).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('should pan and zoom', () => {
|
||||
it('should respond with pointer', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
// Won't zoom without control key.
|
||||
const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||
|
||||
const ev_2 = new WheelEvent('wheel', {
|
||||
bubbles: false,
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
|
||||
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
const ev_3 = new PointerEvent('pointerdown');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||
|
||||
const ev_4 = new PointerEvent('pointermove');
|
||||
element.dispatchEvent(ev_4);
|
||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||
|
||||
const ev_5 = new PointerEvent('pointerup');
|
||||
element.dispatchEvent(ev_5);
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
|
||||
it('should not respond to pointer when not zoomed', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new PointerEvent('pointerdown');
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
|
||||
|
||||
const ev_2 = new PointerEvent('pointermove');
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
|
||||
|
||||
const ev_3 = new PointerEvent('pointerup');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
|
||||
});
|
||||
|
||||
it('should respond with touch', () => {
|
||||
mediaSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new TouchEvent('touchstart', {
|
||||
bubbles: false,
|
||||
touches: [createTouch(element), createTouch(element)],
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_1);
|
||||
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
const ev_3 = new TouchEvent('touchstart');
|
||||
element.dispatchEvent(ev_3);
|
||||
expect(panzoom.handleDown).toBeCalledWith(ev_3);
|
||||
|
||||
const ev_4 = new TouchEvent('touchmove');
|
||||
element.dispatchEvent(ev_4);
|
||||
expect(panzoom.handleMove).toBeCalledWith(ev_4);
|
||||
|
||||
const ev_5 = new TouchEvent('touchend');
|
||||
element.dispatchEvent(ev_5);
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore click after pointerdown', () => {
|
||||
const outer = document.createElement('div');
|
||||
const inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
const clickHandler = vi.fn();
|
||||
outer.addEventListener('click', clickHandler);
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(inner);
|
||||
|
||||
// Simulate being zoomed in.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
// A click on its own will be fine.
|
||||
const click_1 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_1);
|
||||
expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// A click after a pointerdown will be ignored.
|
||||
const pointerdown_1 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_1);
|
||||
|
||||
const click_2 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_2);
|
||||
|
||||
// Click will have been ignored.
|
||||
//expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// Simulate being zoomed out.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.0);
|
||||
const pointerdown_2 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_2);
|
||||
|
||||
const click_3 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_3);
|
||||
|
||||
// Click will have been processed.
|
||||
expect(clickHandler).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('deactivate should remove event handlers', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(element).deactivate();
|
||||
|
||||
const ev_1 = new WheelEvent('wheel', {
|
||||
bubbles: false,
|
||||
deltaY: -120,
|
||||
ctrlKey: true,
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(panzoom.zoomWithWheel).not.toBeCalled();
|
||||
});
|
||||
|
||||
describe('should fire events', () => {
|
||||
it('on zoom/unzoom', () => {
|
||||
const element = document.createElement('div');
|
||||
const zoomedFunc = vi.fn();
|
||||
const unzoomedFunc = vi.fn();
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(zoomedFunc).toBeCalled();
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(unzoomedFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it('when state has not changed or spurious events received', () => {
|
||||
const element = document.createElement('div');
|
||||
const zoomedFunc = vi.fn();
|
||||
const unzoomedFunc = vi.fn();
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
|
||||
// Unzoomed event with scale === 1, this._zoomed will already be false.
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
expect(zoomedFunc).not.toBeCalled();
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(zoomedFunc).toBeCalledTimes(1);
|
||||
expect(unzoomedFunc).not.toBeCalled();
|
||||
|
||||
// Another call when already zoomed will be ignored.
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(zoomedFunc).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('on default/non-default', () => {
|
||||
it('without explicit default', () => {
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const defaultFunc = vi.fn();
|
||||
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(defaultFunc).toBeCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: false } }),
|
||||
);
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(defaultFunc).toBeCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('with complete explicit default', () => {
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const defaultFunc = vi.fn();
|
||||
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
const controller = createAndRegisterZoom(element);
|
||||
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: false } }),
|
||||
);
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 115.62,
|
||||
y: 63.6525,
|
||||
scale: 2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('with partial explicit default', () => {
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const defaultFunc = vi.fn();
|
||||
element.addEventListener('frigate-card:zoom:default', defaultFunc);
|
||||
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
const controller = createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 51,
|
||||
y: 51,
|
||||
scale: 2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: false } }),
|
||||
);
|
||||
|
||||
controller.setDefaultConfig({});
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 50,
|
||||
y: 50,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(defaultFunc).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ detail: { isDefault: true } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('should automatically set correct zoom', () => {
|
||||
it('with start', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = new ZoomController(element);
|
||||
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
|
||||
// Controller was not activated, config setting will not update pan/zoom.
|
||||
expect(panzoom.zoom).not.toBeCalled();
|
||||
expect(panzoom.pan).not.toBeCalled();
|
||||
|
||||
controller.activate();
|
||||
expect(Panzoom).toBeCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
contain: "outside",
|
||||
cursor: undefined,
|
||||
maxScale: 10,
|
||||
minScale: 1,
|
||||
noBind: true,
|
||||
touchAction: "",
|
||||
startScale: 2,
|
||||
startX: 115.62,
|
||||
startY: 63.6525,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('with set of default config', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = createAndRegisterZoom(element);
|
||||
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
|
||||
triggerResizeObserver();
|
||||
|
||||
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
|
||||
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, { animate: false });
|
||||
});
|
||||
|
||||
it('with set of config when a default is already set', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = createAndRegisterZoom(element);
|
||||
|
||||
// This call will do nothing since this is what zoom/pan already are.
|
||||
controller.setDefaultConfig({ zoom: 1, pan: { x: 0, y: 0 } });
|
||||
|
||||
expect(panzoom.zoom).not.toBeCalled();
|
||||
expect(panzoom.pan).not.toBeCalled();
|
||||
|
||||
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
|
||||
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
|
||||
animate: false,
|
||||
});
|
||||
|
||||
controller.setConfig({ zoom: 3, pan: { x: 5, y: 6 } });
|
||||
|
||||
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 3, { animate: false });
|
||||
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 147.6, 81.18, {
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('with repeated calls with same values', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = createAndRegisterZoom(element);
|
||||
|
||||
controller.setConfig({ zoom: 1 });
|
||||
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||
|
||||
controller.setConfig({ pan: { x: 50, y: 50 } });
|
||||
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||
|
||||
controller.setConfig({ zoom: 1, pan: { x: 50, y: 50 } });
|
||||
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||
|
||||
controller.setConfig({});
|
||||
expect(panzoom.zoom).not.toHaveBeenCalled();
|
||||
|
||||
controller.setConfig({ zoom: 2 });
|
||||
|
||||
expect(panzoom.zoom).toBeCalledTimes(1);
|
||||
expect(panzoom.pan).toBeCalledTimes(1);
|
||||
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 0, 0, {
|
||||
animate: false,
|
||||
});
|
||||
|
||||
vi.mocked(panzoom.getScale).mockReturnValue(2);
|
||||
vi.mocked(panzoom.getPan).mockReturnValue({ x: 0, y: 0 });
|
||||
controller.setConfig({ zoom: 2 });
|
||||
|
||||
expect(panzoom.zoom).toBeCalledTimes(1);
|
||||
expect(panzoom.pan).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('when config is set to empty', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = createAndRegisterZoom(element);
|
||||
controller.setDefaultConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
mockClear(panzoom);
|
||||
|
||||
controller.setConfig({});
|
||||
|
||||
// Should fall back to default.
|
||||
expect(panzoom.zoom).toBeCalledWith(2, { animate: false });
|
||||
expect(panzoom.pan).toBeCalledWith(115.62, 63.6525, {
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('when resized', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
const controller = createAndRegisterZoom(element);
|
||||
controller.setConfig({ zoom: 2, pan: { x: 3, y: 4 } });
|
||||
|
||||
expect(panzoom.zoom).toHaveBeenNthCalledWith(1, 2, { animate: false });
|
||||
expect(panzoom.pan).toHaveBeenNthCalledWith(1, 115.62, 63.6525, {
|
||||
animate: false,
|
||||
});
|
||||
|
||||
vi.mocked(panzoom.getScale).mockReturnValue(2);
|
||||
vi.mocked(panzoom.getPan).mockReturnValue({ x: 3, y: 4 });
|
||||
|
||||
setElementToDefaultCardSize(element);
|
||||
triggerResizeObserver();
|
||||
|
||||
expect(panzoom.zoom).toHaveBeenNthCalledWith(2, 2, { animate: false });
|
||||
expect(panzoom.pan).toHaveBeenNthCalledWith(2, 57.81, 31.82625, {
|
||||
animate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('when not yet activated', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
setElementToDefaultCardSize(element);
|
||||
|
||||
new ZoomController(element);
|
||||
|
||||
triggerResizeObserver();
|
||||
|
||||
expect(panzoom.zoom).not.toBeCalled();
|
||||
expect(panzoom.pan).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('when element has no size', () => {
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
const element = document.createElement('div');
|
||||
element.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
triggerResizeObserver();
|
||||
|
||||
expect(panzoom.zoom).not.toBeCalled();
|
||||
expect(panzoom.pan).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should set touch action on zoom/unzoom', () => {
|
||||
const element = document.createElement('div');
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(element.style.touchAction).toBe('none');
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomchange', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(element.style.touchAction).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
generateViewContextForZoomChange,
|
||||
handleZoomDefaultEvent,
|
||||
} from '../../../src/components-lib/zoom/zoom-view-context';
|
||||
|
||||
describe('generateViewContextForZoomChangeRequest', () => {
|
||||
it('with config', () => {
|
||||
expect(
|
||||
generateViewContextForZoomChange('target', {
|
||||
zoom: {
|
||||
pan: { x: 1, y: 2 },
|
||||
zoom: 3,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
zoom: {
|
||||
target: {
|
||||
zoom: {
|
||||
pan: { x: 1, y: 2 },
|
||||
zoom: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('without config', () => {
|
||||
expect(generateViewContextForZoomChange('target')).toEqual({
|
||||
zoom: {
|
||||
target: {
|
||||
zoom: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateViewContextForZoomDefault', () => {
|
||||
it('default', () => {
|
||||
expect(generateViewContextForZoomChange('target', { isDefault: true })).toEqual({
|
||||
zoom: {
|
||||
target: {
|
||||
isDefault: true,
|
||||
zoom: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('not default', () => {
|
||||
expect(generateViewContextForZoomChange('target', { isDefault: false })).toEqual({
|
||||
zoom: {
|
||||
target: {
|
||||
isDefault: false,
|
||||
zoom: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
it('handleZoomDefaultEvent', () => {
|
||||
const element = document.createElement('div');
|
||||
const callback = vi.fn();
|
||||
element.addEventListener('frigate-card:view:change-context', callback);
|
||||
handleZoomDefaultEvent(
|
||||
element,
|
||||
new CustomEvent('frigate-card:zoom:default', { detail: { isDefault: true } }),
|
||||
'target',
|
||||
);
|
||||
expect(callback).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: {
|
||||
zoom: {
|
||||
target: {
|
||||
zoom: null,
|
||||
isDefault: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -175,6 +175,10 @@ describe('config defaults', () => {
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
},
|
||||
default_zoom: {
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
},
|
||||
display_mode: {
|
||||
enabled: true,
|
||||
priority: 50,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { actionSchema } from '../../src/config/types';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
createFrigateCardCameraAction,
|
||||
createFrigateCardChangeZoomAction,
|
||||
createFrigateCardDisplayModeAction,
|
||||
createFrigateCardMediaPlayerAction,
|
||||
createFrigateCardShowPTZAction,
|
||||
@@ -114,6 +115,38 @@ describe('createFrigateCardShowPTZAction', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFrigateCardChangeZoomAction', () => {
|
||||
it('should create change zoom default action', () => {
|
||||
expect(
|
||||
createFrigateCardChangeZoomAction('target_id', {
|
||||
cardID: 'card_id',
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
card_id: 'card_id',
|
||||
target_id: 'target_id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create change zoom specific action', () => {
|
||||
expect(
|
||||
createFrigateCardChangeZoomAction('target_id', {
|
||||
cardID: 'card_id',
|
||||
pan: { x: 1, y: 2 },
|
||||
zoom: 3,
|
||||
}),
|
||||
).toEqual({
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: 'change_zoom',
|
||||
card_id: 'card_id',
|
||||
target_id: 'target_id',
|
||||
pan: { x: 1, y: 2 },
|
||||
zoom: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionConfigGivenAction', () => {
|
||||
const action = actionSchema.parse({
|
||||
action: 'fire-dom-event',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
import { FrigateCardError } from '../../src/types';
|
||||
import {
|
||||
allPromises,
|
||||
arefloatsApproximatelyEqual,
|
||||
arrayify,
|
||||
arrayMove,
|
||||
aspectRatioToStyle,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
errorToConsole,
|
||||
formatDate,
|
||||
formatDateAndTime,
|
||||
generateFloatApproximatelyEqualsCustomizer,
|
||||
getChildrenFromElement,
|
||||
getDurationString,
|
||||
isHoverableDevice,
|
||||
@@ -406,3 +408,54 @@ describe('desparsifyArrays', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('arefloatsApproximatelyEqual', () => {
|
||||
describe('without precision', () => {
|
||||
it('equals', () => {
|
||||
expect(arefloatsApproximatelyEqual(1.1, 1.2)).toBeTruthy();
|
||||
});
|
||||
it('not equals', () => {
|
||||
expect(arefloatsApproximatelyEqual(0.5, 1.5)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
describe('with precision', () => {
|
||||
it('equals', () => {
|
||||
expect(arefloatsApproximatelyEqual(1.00001, 1.00002, 4)).toBeTruthy();
|
||||
});
|
||||
it('not equals', () => {
|
||||
expect(arefloatsApproximatelyEqual(0.5, 1.5, 4)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFloatApproximatelyEqualsCustomizer', () => {
|
||||
describe('with incorrect types', () => {
|
||||
it('undefined a', () => {
|
||||
expect(
|
||||
generateFloatApproximatelyEqualsCustomizer(4)(undefined, 1.2),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('undefined b', () => {
|
||||
expect(
|
||||
generateFloatApproximatelyEqualsCustomizer(4)(1.2, undefined),
|
||||
).toBeUndefined();
|
||||
});
|
||||
it('strings', () => {
|
||||
expect(
|
||||
generateFloatApproximatelyEqualsCustomizer(4)('foo', 'bar'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('with correct types', () => {
|
||||
it('equals', () => {
|
||||
expect(
|
||||
generateFloatApproximatelyEqualsCustomizer(4)(1.00001, 1.00002),
|
||||
).toBeTruthy();
|
||||
});
|
||||
it('not equals', () => {
|
||||
expect(
|
||||
generateFloatApproximatelyEqualsCustomizer(5)(1.00001, 1.00002),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
|
||||
'components-lib/menu-button-controller.ts',
|
||||
'components-lib/menu-controller.ts',
|
||||
'components-lib/ptz-controller.ts',
|
||||
'components-lib/zoom-controller.ts',
|
||||
'components-lib/zoom/*.ts',
|
||||
'config/**/*.ts',
|
||||
'const.ts',
|
||||
'types.ts',
|
||||
|
||||