Add initial keyboard shortcut support.

This commit is contained in:
Dermot Duffy
2024-06-05 21:44:17 -07:00
parent 904f6d8142
commit 7ab545738d
186 changed files with 9564 additions and 3658 deletions
+24 -6
View File
@@ -1,11 +1,29 @@
export interface ZoomConfig {
pan?: {
x?: number;
y?: number;
import { PartialDeep } from 'type-fest';
export const ZOOM_DEFAULT_PAN_X = 50;
export const ZOOM_DEFAULT_PAN_Y = 50;
export const ZOOM_DEFAULT_SCALE = 1;
export const ZOOM_PRECISION = 4;
export interface ZoomSettingsBase {
pan: {
x: number;
y: number;
};
zoom?: number;
zoom: number;
}
export interface ZoomDefault {
export type PartialZoomSettings = PartialDeep<ZoomSettingsBase>;
export interface ZoomSettingsObserved extends ZoomSettingsBase {
isDefault: boolean;
unzoomed: boolean;
}
export const isZoomEmpty = (settings?: PartialZoomSettings | null): boolean => {
return (
settings?.pan?.x === undefined &&
settings?.pan?.y === undefined &&
settings?.zoom === undefined
);
};
+83 -51
View File
@@ -1,17 +1,20 @@
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';
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;
import {
PartialZoomSettings,
ZOOM_DEFAULT_PAN_X,
ZOOM_DEFAULT_PAN_Y,
ZOOM_DEFAULT_SCALE,
ZOOM_PRECISION,
ZoomSettingsObserved,
isZoomEmpty,
} from './types';
export class ZoomController {
protected _element: HTMLElement;
@@ -26,15 +29,14 @@ export class ZoomController {
// Should clicks be allowed to propagate, or consumed as a pan/zoom action?
protected _allowClick = true;
protected _defaultConfig: ZoomConfig | null;
protected _config: ZoomConfig | null;
protected _defaultSettings: PartialZoomSettings | null;
protected _settings: PartialZoomSettings | 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);
// 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.
protected _debouncedChangeHandler = throttle(this._changeHandler.bind(this), 50);
protected _debouncedUpdater = throttle(this._updateBasedOnConfig.bind(this), 50);
protected _resizeObserver = new ResizeObserver(this._debouncedUpdater);
@@ -70,6 +72,11 @@ export class ZoomController {
// handler in the viewer).
if (!this._allowClick) {
ev.stopPropagation();
// Even though the click is stopped,the card still needs to gain focus so
// that keyboard shortcuts will work immediately after the card is clicked
// upon.
dispatchFrigateCardEvent(this._element, 'focus');
}
this._allowClick = true;
};
@@ -97,11 +104,14 @@ export class ZoomController {
constructor(
element: HTMLElement,
options?: { config?: ZoomConfig | null; defaultConfig?: ZoomConfig | null },
options?: {
config?: PartialZoomSettings | null;
defaultConfig?: PartialZoomSettings | null;
},
) {
this._element = element;
this._config = options?.config ?? null;
this._defaultConfig = options?.defaultConfig ?? null;
this._settings = options?.config ?? null;
this._defaultSettings = options?.defaultConfig ?? null;
}
public activate(): void {
@@ -182,44 +192,47 @@ export class ZoomController {
this._element.removeEventListener('panzoomchange', this._debouncedChangeHandler);
}
public setDefaultConfig(config: ZoomConfig | null): void {
this._defaultConfig = config;
public setDefaultSettings(config: PartialZoomSettings | null): void {
this._defaultSettings = config;
this._debouncedUpdater();
}
public setConfig(config: ZoomConfig): void {
this._config = config;
public setSettings(config: PartialZoomSettings | null): void {
this._settings = 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);
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 (isUnzoomed && this._zoomed) {
if (unzoomed && this._zoomed) {
this._zoomed = false;
this._setTouchAction(true);
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
} else if (!isUnzoomed && !this._zoomed) {
} else if (!unzoomed && !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 });
}
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,
};
dispatchFrigateCardEvent(this._element, 'zoom:change', observed);
}
protected _isZoomEqual(a: ZoomConfig, b: ZoomConfig): boolean {
protected _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 (
@@ -247,16 +260,8 @@ export class ZoomController {
);
}
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 _getConfigToUse(): PartialZoomSettings | null {
return isZoomEmpty(this._settings) ? this._defaultSettings : this._settings;
}
protected _updateBasedOnConfig(): void {
@@ -292,6 +297,9 @@ export class ZoomController {
}
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,
});
@@ -300,10 +308,12 @@ export class ZoomController {
// 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,
animate: true,
duration: 100,
});
});
}
@@ -332,6 +342,28 @@ export class ZoomController {
};
}
protected _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,
};
}
protected _getTransformMinMax(
desiredScale: number,
currentScale?: number,
@@ -375,14 +407,14 @@ export class ZoomController {
}
protected _isAtDefaultZoomAndPan(x: number, y: number, scale: number): boolean {
if (!this._defaultConfig) {
if (!this._defaultSettings) {
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,
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;
@@ -393,7 +425,7 @@ export class ZoomController {
arefloatsApproximatelyEqual(y, convertedDefault.y) &&
arefloatsApproximatelyEqual(
scale,
this._defaultConfig.zoom ??
this._defaultSettings.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.
+19 -20
View File
@@ -1,15 +1,13 @@
import { ViewContext } from 'view';
import { ZoomConfig, ZoomDefault } from './types.js';
import { dispatchViewContextChangeEvent } from '../../view/view.js';
import { ZoomSettingsObserved, PartialZoomSettings } from './types.js';
interface ZoomViewContext {
observed?: ZoomSettingsObserved;
// 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;
requested?: PartialZoomSettings | null;
}
interface ZoomsViewContext {
@@ -22,36 +20,37 @@ declare module 'view' {
}
}
export const generateViewContextForZoomChange = (
export const generateViewContextForZoom = (
targetID: string,
options?: {
zoom?: ZoomConfig | null;
isDefault?: boolean;
observed?: ZoomSettingsObserved;
requested?: PartialZoomSettings | null;
},
): ViewContext | null => {
return {
zoom: {
[targetID]: {
zoom: options?.zoom ?? null,
...(options?.isDefault !== undefined && { isDefault: options.isDefault }),
observed: options?.observed ?? undefined,
requested: options?.requested ?? null,
},
},
};
};
/**
* Convenience wrapper to convert a zoom default into a dispatched view context
* Convenience wrapper to convert zoom settings into a dispatched view context
* change.
*/
export const handleZoomDefaultEvent = (
export const handleZoomSettingsObservedEvent = (
element: EventTarget,
ev: CustomEvent<ZoomDefault>,
ev: CustomEvent<ZoomSettingsObserved>,
targetID?: string,
): void => {
targetID && dispatchViewContextChangeEvent(
element,
generateViewContextForZoomChange(targetID, {
isDefault: ev.detail.isDefault,
}),
);
targetID &&
dispatchViewContextChangeEvent(
element,
generateViewContextForZoom(targetID, {
observed: ev.detail,
}),
);
};