import Panzoom, { type PanzoomEventDetail, type PanzoomObject, } from '@dermotduffy/panzoom'; import { round, throttle } from 'lodash-es'; import { arefloatsApproximatelyEqual, isHoverableDevice } from '../../utils/basic'; import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event'; import { isZoomEmpty, ZOOM_DEFAULT_PAN_X, ZOOM_DEFAULT_PAN_Y, ZOOM_DEFAULT_SCALE, ZOOM_PRECISION, type PartialZoomSettings, type ZoomSettingsObserved, } from './types'; export class ZoomController { private _element: HTMLElement; private _panzoom: PanzoomObject | null = null; // Is the controller zoomed in at all? private _zoomed = false; // Should clicks be allowed to propagate, or consumed as a pan/zoom action? private _allowClick = true; // Whether zoom/pan gestures are active. When `false`, all gesture events pass // through untouched (used to yield to PTZ gesture mode). private _zoom = true; private _defaultSettings: PartialZoomSettings | null; private _settings: PartialZoomSettings | null; // These values should be suitably less than the value of STEP_DELAY_SECONDS // in the ptz_digital action, in order to ensure smooth movements of the // digital PTZ actions. private _debouncedChangeHandler = throttle(this._changeHandler.bind(this), 50); private _debouncedUpdater = throttle(this._updateBasedOnConfig.bind(this), 50); private _resizeObserver = new ResizeObserver(this._debouncedUpdater); private _events = isHoverableDevice() ? { down: ['pointerdown'], move: ['pointermove'], up: ['pointerup', 'pointerleave', 'pointercancel'], } : { down: ['touchstart'], move: ['touchmove'], up: ['touchend', 'touchcancel'], }; private _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; } }; private _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; }; private _moveHandler = (ev: Event) => { if (this._shouldZoomOrPan(ev)) { this._panzoom?.handleMove(ev as PointerEvent); ev.stopPropagation(); } }; private _upHandler = (ev: Event) => { if (this._shouldZoomOrPan(ev)) { this._panzoom?.handleUp(ev as PointerEvent); ev.stopPropagation(); } }; private _wheelHandler = (ev: Event) => { if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) { this._panzoom?.zoomWithWheel(ev); ev.stopPropagation(); } }; constructor( element: HTMLElement, options?: { config?: PartialZoomSettings | null; defaultConfig?: PartialZoomSettings | null; }, ) { this._element = element; this._settings = options?.config ?? null; this._defaultSettings = 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, ); const startScale = 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/advanced-camera-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 }), }); 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); this._panzoom = null; } public setDefaultSettings(config: PartialZoomSettings | null): void { this._defaultSettings = config; this._debouncedUpdater(); } public setSettings(config: PartialZoomSettings | null): void { this._settings = config; this._debouncedUpdater(); } public isActivated(): boolean { return !!this._panzoom; } public setZoom(value: boolean): void { this._zoom = value; } private _changeHandler(ev: Event): void { const pz = (>ev).detail; const unzoomed = this._isUnzoomed(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 (unzoomed && this._zoomed) { this._zoomed = false; this._setTouchAction(true); fireAdvancedCameraCardEvent(this._element, 'zoom:unzoomed'); } else if (!unzoomed && !this._zoomed) { this._zoomed = true; this._setTouchAction(false); fireAdvancedCameraCardEvent(this._element, 'zoom:zoomed'); } const converted = this._convertXYPanToPercent(pz.x, pz.y, pz.scale); const observed: ZoomSettingsObserved = { pan: { x: converted?.x ?? ZOOM_DEFAULT_PAN_X, y: converted?.y ?? ZOOM_DEFAULT_PAN_Y, }, zoom: pz.scale, isDefault: this._isAtDefaultZoomAndPan(pz.x, pz.y, pz.scale), unzoomed: unzoomed, }; fireAdvancedCameraCardEvent(this._element, 'zoom:change', observed); } private _isZoomEqual(a: PartialZoomSettings, b: PartialZoomSettings): 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( a.zoom ?? ZOOM_DEFAULT_SCALE, b.zoom ?? ZOOM_DEFAULT_SCALE, ZOOM_PRECISION, ) && arefloatsApproximatelyEqual( a.pan?.x ?? ZOOM_DEFAULT_PAN_X, b.pan?.x ?? ZOOM_DEFAULT_PAN_X, ZOOM_PRECISION, ) && arefloatsApproximatelyEqual( /* v8 ignore next @preserve */ a.pan?.y ?? ZOOM_DEFAULT_PAN_Y, /* v8 ignore next @preserve */ b.pan?.y ?? ZOOM_DEFAULT_PAN_Y, ZOOM_PRECISION, ) ); } private _getConfigToUse(): PartialZoomSettings | null { return isZoomEmpty(this._settings) ? this._defaultSettings : this._settings; } private _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, { // Zoom is stepped, not animated. If it is animated, there is interaction // between the zoom and the pan below, and the pan would need to be // delayed until after the zoom is complete. 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 pan is applied. // // See: https://github.com/timmywil/panzoom?tab=readme-ov-file#a-note-on-the-async-nature-of-panzoom window.requestAnimationFrame(() => { // On slow Android WebView devices, the zoom transform may not have fully // painted by the time this callback runs. Panzoom's contain logic would // then clip the pan to incorrect bounds. Temporarily disable containment // for this programmatic pan, then restore it. // See: https://github.com/dermotduffy/advanced-camera-card/issues/2223 this._panzoom?.setOptions({ contain: undefined }); this._panzoom?.pan(x, y, { animate: true, duration: 100, }); this._panzoom?.setOptions({ contain: 'outside' }); }); } /** * Convert from Advanced Camera 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. */ private _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), }; } private _convertXYPanToPercent( 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: ((-x + Math.abs(minMax.minX)) / (Math.abs(minMax.maxX) + Math.abs(minMax.minX))) * 100, y: ((-y + Math.abs(minMax.minY)) / (Math.abs(minMax.maxY) + Math.abs(minMax.minY))) * 100, }; } private _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, }; } private _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), }; } private _isUnzoomed(scale?: number): boolean { return scale !== undefined && round(scale, ZOOM_PRECISION) <= 1; } private _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean { if (!this._defaultSettings) { return this._isUnzoomed(scale); } const convertedDefault = this._convertPercentToXYPan( this._defaultSettings.pan?.x ?? ZOOM_DEFAULT_PAN_X, this._defaultSettings.pan?.y ?? ZOOM_DEFAULT_PAN_Y, this._defaultSettings.zoom ?? ZOOM_DEFAULT_SCALE, ); if (!convertedDefault) { return true; } // The ZOOM_DEFAULT_SCALE fallback cannot be reached: when // this._defaultSettings.zoom is undefined, convertedDefault ends up null // above and this function has already returned. /* v8 ignore next @preserve */ const defaultScale = this._defaultSettings.zoom ?? ZOOM_DEFAULT_SCALE; return ( arefloatsApproximatelyEqual(x, convertedDefault.x) && arefloatsApproximatelyEqual(y, convertedDefault.y) && arefloatsApproximatelyEqual(scale, defaultScale) ); } private _shouldZoomOrPan(ev: Event): boolean { if (!this._zoom) { return false; } return ( !this._isUnzoomed(this._panzoom?.getScale()) || // TouchEvent does not exist on Firefox on non-touch events. See: // https://github.com/dermotduffy/advanced-camera-card/issues/1174 (window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) || (ev instanceof WheelEvent && ev.ctrlKey) ); } private _setTouchAction(touchEnabled: boolean): void { this._element.style.touchAction = touchEnabled ? '' : 'none'; } }