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