diff --git a/package.json b/package.json
index 11cec416..acf0cef5 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"license": "MIT",
"dependencies": {
"@cycjimmy/jsmpeg-player": "^6.0.4",
+ "@dermotduffy/panzoom": "^4.5.1",
"@egjs/hammerjs": "^2.0.17",
"@graphiteds/core": "^1.9.6",
"@lit-labs/scoped-registry-mixin": "^1.0.1",
diff --git a/src/components/image.ts b/src/components/image.ts
index 7f9144c9..5860c92a 100644
--- a/src/components/image.ts
+++ b/src/components/image.ts
@@ -26,6 +26,7 @@ import { View } from '../view/view.js';
import { dispatchErrorMessageEvent } from './message.js';
import { contentsChanged } from '../utils/basic.js';
import isEqual from 'lodash-es/isEqual';
+import './zoomer.js';
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
@@ -41,6 +42,9 @@ export class FrigateCardImage extends LitElement {
@property({ attribute: false })
public cameraConfig?: CameraConfig;
+ @property({ attribute: false })
+ public supportZoom = false;
+
// Using contentsChanged to ensure overridden configs (e.g. when the
// 'show_image_during_load' option is true for live views, an overridden
// config may be used here).
@@ -231,12 +235,18 @@ export class FrigateCardImage extends LitElement {
}
}
+ protected _renderZoom(contents: TemplateResult): TemplateResult {
+ return this.supportZoom
+ ? html` ${contents}`
+ : contents;
+ }
+
protected render(): TemplateResult | void {
const src = this._cachedValueController?.value;
// Note the use of live() below to ensure the update will restore the image
// src if it's been changed via _forceSafeImage().
return src
- ? html`
{
@@ -263,7 +273,7 @@ export class FrigateCardImage extends LitElement {
});
}
}}
- />`
+ />`)
: html``;
}
diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts
index a45264b3..83e2f50c 100644
--- a/src/components/live/live-go2rtc.ts
+++ b/src/components/live/live-go2rtc.ts
@@ -101,6 +101,12 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
}
}
+ public async setControls(controls: boolean): Promise {
+ if (this._player?.video) {
+ this._player.video.controls = controls;
+ }
+ }
+
disconnectedCallback(): void {
this._player = undefined;
}
diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts
index 1d8e5aee..7b7b758a 100644
--- a/src/components/live/live-ha.ts
+++ b/src/components/live/live-ha.ts
@@ -43,6 +43,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
this._playerRef.value?.seek(seconds);
}
+ public async setControls(controls: boolean): Promise {
+ this._playerRef.value?.setControls(controls);
+ }
+
protected render(): TemplateResult | void {
if (!this.hass) {
return;
diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts
index a8e655d2..ab8b5006 100644
--- a/src/components/live/live-image.ts
+++ b/src/components/live/live-image.ts
@@ -42,6 +42,11 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
// Not implemented.
}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ public async setControls(_controls: boolean): Promise {
+ // Not implemented.
+ }
+
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
return;
diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts
index 7ce52b14..1aa258ba 100644
--- a/src/components/live/live-jsmpeg.ts
+++ b/src/components/live/live-jsmpeg.ts
@@ -70,6 +70,11 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
// JSMPEG does not support seeking.
}
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ public async setControls(_controls: boolean): Promise {
+ // Not implemented.
+ }
+
/**
* Create a JSMPEG player.
* @param url The URL for the player to connect to.
diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts
index 6cd0f4a6..436a036c 100644
--- a/src/components/live/live-webrtc-card.ts
+++ b/src/components/live/live-webrtc-card.ts
@@ -73,6 +73,13 @@ export class FrigateCardLiveWebRTCCard
}
}
+ public async setControls(controls: boolean): Promise {
+ const player = this._getPlayer();
+ if (player) {
+ player.controls = controls;
+ }
+ }
+
connectedCallback(): void {
super.connectedCallback();
diff --git a/src/components/live/live.ts b/src/components/live/live.ts
index 2f5ef65a..cf86604c 100644
--- a/src/components/live/live.ts
+++ b/src/components/live/live.ts
@@ -47,6 +47,7 @@ import {
import '../next-prev-control.js';
import '../title-control.js';
import '../surround.js';
+import '../zoomer.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
import { classMap } from 'lit/directives/class-map.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
@@ -772,6 +773,12 @@ export class FrigateCardLiveProvider
this._refProvider.value?.seek(seconds);
}
+ public async setControls(controls: boolean): Promise {
+ await this.updateComplete;
+ await this._refProvider.value?.updateComplete;
+ this._refProvider.value?.setControls(controls);
+ }
+
/**
* Get the fully resolved live provider.
* @returns A live provider (that is not 'auto').
@@ -886,35 +893,41 @@ export class FrigateCardLiveProvider
};
return html`
- ${showImageDuringLoading || provider === 'image'
- ? html` {
- if (provider === 'image') {
- // Only count the media has loaded if the required provider is
- // the image (not just the temporary image shown during
- // loading).
- this._videoMediaShowHandler();
- } else {
- ev.stopPropagation();
- }
- }}
- >
- `
- : html``}
- ${provider === 'ha'
- ? html`
- `
- : provider === 'go2rtc'
- ? html` this.setControls(false)}
+ @frigate-card:zoom:unzoomed=${() => this.setControls(true)}
+ >
+ ${showImageDuringLoading || provider === 'image'
+ ? html`
+ {
+ if (provider === 'image') {
+ // Only count the media has loaded if the required provider is
+ // the image (not just the temporary image shown during
+ // loading).
+ this._videoMediaShowHandler();
+ } else {
+ ev.stopPropagation();
+ }
+ }}
+ >
+ `
+ : html``}
+ ${provider === 'ha'
+ ? html`
+ `
+ : provider === 'go2rtc'
+ ? html`
`
- : provider === 'webrtc-card'
- ? html`
- `
- : provider === 'jsmpeg'
- ? html`
- `
- : html``}
+ : provider === 'webrtc-card'
+ ? html`
+ `
+ : provider === 'jsmpeg'
+ ? html`
+ `
+ : html``}
+
`;
}
- /**
- * Get styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 3a8f8d72..4746f052 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -9,8 +9,12 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
+import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
+import { CameraManager } from '../camera-manager/manager.js';
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
+import { localize } from '../localize/localize.js';
+import '../patches/ha-hls-player';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
@@ -25,38 +29,35 @@ import {
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { contentsChanged, errorToConsole } from '../utils/basic.js';
+import { canonicalizeHAURL } from '../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
-import { View } from '../view/view.js';
+import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
+import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
+import {
+ changeViewToRecentEventsForCameraAndDependents,
+ changeViewToRecentRecordingForCameraAndDependents,
+} from '../utils/media-to-view.js';
+import {
+ hideMediaControlsTemporarily,
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+ playMediaMutingIfNecessary,
+} from '../utils/media.js';
+import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
+import { MediaQueriesResults } from '../view/media-queries-results.js';
+import { VideoContentType, ViewMedia } from '../view/media.js';
+import { View } from '../view/view.js';
+import './zoomer.js';
+import type { CarouselSelect } from './carousel.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
} from './media-carousel.js';
-import type { CarouselSelect } from './carousel.js';
import './next-prev-control.js';
-import './title-control.js';
-import '../patches/ha-hls-player';
import './surround.js';
-import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
-import { CameraManager } from '../camera-manager/manager.js';
-import {
- changeViewToRecentEventsForCameraAndDependents,
- changeViewToRecentRecordingForCameraAndDependents,
-} from '../utils/media-to-view.js';
-import { VideoContentType, ViewMedia } from '../view/media.js';
-import { ViewMediaClassifier } from '../view/media-classifier';
-import { guard } from 'lit/directives/guard.js';
-import { localize } from '../localize/localize.js';
-import { MediaQueriesResults } from '../view/media-queries-results.js';
-import { canonicalizeHAURL } from '../utils/ha/index.js';
-import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
-import { playMediaMutingIfNecessary } from '../utils/media.js';
-import {
- hideMediaControlsTemporarily,
- MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
-} from '../utils/media.js';
+import './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -602,6 +603,14 @@ export class FrigateCardViewerProvider
}
}
+ public async setControls(controls: boolean): Promise {
+ if (this._refFrigateCardMediaPlayer.value) {
+ return this._refFrigateCardMediaPlayer.value.setControls(controls);
+ } else if (this._refVideoProvider.value) {
+ this._refVideoProvider.value.controls = controls;
+ }
+ }
+
/**
* Dispatch a clip view that matches the current (snapshot) query.
*/
@@ -693,61 +702,68 @@ export class FrigateCardViewerProvider
});
}
- return ViewMediaClassifier.isVideo(this.media)
- ? this.media.getVideoContentType() === VideoContentType.HLS
- ? html`
- `
- : html`
-
- `
- : html`
{
- if (this.viewerConfig?.snapshot_click_plays_clip) {
- this._dispatchRelatedClipView();
- }
- }}
- @load=${(e: Event) => {
- dispatchMediaLoadedEvent(this, e);
- }}
- />`;
+ />`}
+
+ `;
}
static get styles(): CSSResultGroup {
diff --git a/src/components/views.ts b/src/components/views.ts
index 13a60204..4c5f17dc 100644
--- a/src/components/views.ts
+++ b/src/components/views.ts
@@ -1,10 +1,10 @@
import {
- CSSResultGroup,
- html,
- LitElement,
- PropertyValues,
- TemplateResult,
- unsafeCSS
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@@ -72,7 +72,7 @@ export class FrigateCardViews extends LitElement {
}
}
}
-
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_: PropertyValues): boolean {
// Future: Updates to `hass` and `conditionState` here will be frequent.
@@ -150,6 +150,7 @@ export class FrigateCardViews extends LitElement {
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
+ .supportZoom=${true}
>
`
: ``}
diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts
new file mode 100644
index 00000000..84885479
--- /dev/null
+++ b/src/components/zoomer.ts
@@ -0,0 +1,37 @@
+import { css, CSSResultGroup, html, LitElement, TemplateResult } from 'lit';
+import { customElement } from 'lit/decorators.js';
+import { Zoom } from '../utils/zoom/zoom.js';
+
+@customElement('frigate-card-zoomer')
+export class FrigateCardZoomer extends LitElement {
+ protected _zoom = new Zoom(this);
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this._zoom.activate();
+ }
+
+ disconnectedCallback(): void {
+ this._zoom.deactivate();
+ }
+
+ protected render(): TemplateResult | void {
+ return html` `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ width: 100%;
+ height: 100%;
+ display: block;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-zoomer': FrigateCardZoomer;
+ }
+}
diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts
index 9ce81467..9e513291 100644
--- a/src/patches/ha-camera-stream.ts
+++ b/src/patches/ha-camera-stream.ts
@@ -71,6 +71,12 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this._player?.seek(seconds);
}
+ public async setControls(controls: boolean): Promise {
+ if (this._player) {
+ this._player.setControls(controls);
+ }
+ }
+
/**
* Master render method.
* @returns A rendered template.
diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts
index 38f8b148..2ef2ac38 100644
--- a/src/patches/ha-hls-player.ts
+++ b/src/patches/ha-hls-player.ts
@@ -67,6 +67,12 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
}
+ public async setControls(controls: boolean): Promise {
+ if (this._video) {
+ this._video.controls = controls;
+ }
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts
index e568b6e5..b7f07988 100644
--- a/src/patches/ha-web-rtc-player.ts
+++ b/src/patches/ha-web-rtc-player.ts
@@ -18,7 +18,7 @@ import { FrigateCardMediaPlayer } from '../types.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import {
hideMediaControlsTemporarily,
- MEDIA_LOAD_CONTROLS_HIDE_SECONDS
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
customElements.whenDefined('ha-web-rtc-player').then(() => {
@@ -66,6 +66,12 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
}
}
+ public async setControls(controls: boolean): Promise {
+ if (this._video) {
+ this._video.controls = controls;
+ }
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss
index f4d336fd..3ac357c1 100644
--- a/src/scss/live-provider.scss
+++ b/src/scss/live-provider.scss
@@ -1,3 +1,9 @@
+:host {
+ display: block;
+ height: 100%;
+ width: 100;
+}
+
.hidden {
display: none;
}
diff --git a/src/types.ts b/src/types.ts
index 70161d95..f619f1a5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1455,6 +1455,7 @@ export interface FrigateCardMediaPlayer {
unmute(): Promise;
isMuted(): boolean;
seek(seconds: number): Promise;
+ setControls(controls: boolean): Promise;
}
export interface CardHelpers {
diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts
new file mode 100644
index 00000000..7b921134
--- /dev/null
+++ b/src/utils/zoom/zoom.ts
@@ -0,0 +1,125 @@
+import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
+import Panzoom from '@dermotduffy/panzoom';
+import round from 'lodash-es/round';
+import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
+
+export class Zoom {
+ constructor(element: HTMLElement) {
+ this._element = element;
+ }
+
+ protected _element: HTMLElement;
+ protected _panzoom?: PanzoomObject;
+ protected _zoomed = false;
+
+ 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();
+ }
+ };
+
+ 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()) ||
+ (ev instanceof TouchEvent && ev.touches.length > 1) ||
+ (ev instanceof WheelEvent && ev.ctrlKey)
+ );
+ }
+
+ public activate(): void {
+ this._panzoom = Panzoom(this._element, {
+ contain: 'outside',
+ maxScale: 10,
+ minScale: 1,
+ noBind: true,
+ cursor: 'auto',
+ });
+
+ 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);
+
+ 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((>ev).detail.scale)) {
+ if (this._zoomed) {
+ dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
+ }
+ this._zoomed = false;
+ } else {
+ if (!this._zoomed) {
+ dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
+ }
+ this._zoomed = true;
+ }
+ });
+ }
+
+ public deactivate(): void {
+ const unregisterListener = (events: string[], func: (ev: Event) => void) => {
+ events.forEach((eventName) => {
+ this._element.removeEventListener(eventName, func);
+ });
+ };
+
+ unregisterListener(this._events['down'], this._downHandler);
+ unregisterListener(this._events['move'], this._moveHandler);
+ unregisterListener(this._events['up'], this._upHandler);
+ unregisterListener(['wheel'], this._wheelHandler);
+ }
+}
diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts
index 1589495f..a17fd576 100644
--- a/tests/utils/basic.test.ts
+++ b/tests/utils/basic.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, afterAll, beforeEach } from 'vitest';
+import { describe, it, expect, vi, afterAll } from 'vitest';
import { FrigateCardError } from '../../src/types';
import {
allPromises,
diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts
new file mode 100644
index 00000000..4d9daf10
--- /dev/null
+++ b/tests/utils/zoom.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { Zoom } from '../../src/utils/zoom/zoom';
+import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
+import Panzoom from '@dermotduffy/panzoom';
+import { mock } from 'vitest-mock-extended';
+
+vi.mock('@dermotduffy/panzoom');
+
+// https://github.com/jsdom/jsdom/issues/2527
+(window as any).PointerEvent = MouseEvent;
+
+// @vitest-environment jsdom
+describe('Zoom', () => {
+ const mediaMediSpy = vi.spyOn(window, 'matchMedia');
+
+ const createMockPanZoom = (): PanzoomObject => {
+ const panzoom = mock();
+ panzoom.getScale.mockReturnValue(1.0);
+ return panzoom;
+ };
+
+ const createAndRegisterZoom = (element: HTMLElement): Zoom => {
+ const zoom = new Zoom(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({ matches: true });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should be creatable', () => {
+ const element = document.createElement('div');
+ const zoom = new Zoom(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 respond with touch', () => {
+ mediaMediSpy.mockReturnValue({ 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('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);
+
+ const panzoom = createMockPanZoom();
+ vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
+
+ createAndRegisterZoom(element);
+
+ const ev_1 = new CustomEvent('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('panzoomzoom', {
+ detail: {
+ x: 0,
+ y: 0,
+ scale: 1,
+ isSVG: false,
+ originalEvent: new PointerEvent('pointermove'),
+ },
+ });
+ element.dispatchEvent(ev_2);
+ expect(unzoomedFunc).toBeCalled();
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index 5f65843e..34f01e7f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -375,6 +375,13 @@ __metadata:
languageName: node
linkType: hard
+"@dermotduffy/panzoom@npm:^4.5.1":
+ version: 4.5.1
+ resolution: "@dermotduffy/panzoom@npm:4.5.1"
+ checksum: 4c826f910425e50d9155005947e14bf344ec6b6a68783feb5a62f6bd2d7261cc541ceb18462552be271539a284a24b423dca27201caf45e153002fc5bf1c188e
+ languageName: node
+ linkType: hard
+
"@duetds/date-picker@npm:^1.4.0":
version: 1.4.0
resolution: "@duetds/date-picker@npm:1.4.0"
@@ -3032,6 +3039,7 @@ __metadata:
"@babel/plugin-proposal-class-properties": ^7.18.6
"@babel/plugin-proposal-decorators": ^7.19.0
"@cycjimmy/jsmpeg-player": ^6.0.4
+ "@dermotduffy/panzoom": ^4.5.1
"@egjs/hammerjs": ^2.0.17
"@graphiteds/core": ^1.9.6
"@lit-labs/scoped-registry-mixin": ^1.0.1