From 13bea4942991f5d17817510d1bc376f49fae8bc9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 8 Dec 2025 17:19:56 -0800 Subject: [PATCH] feat: Add option to disable mousewheel carousel scrolling (#2263) - Closes: #2220 --- docs/configuration/live.md | 2 ++ docs/configuration/media-viewer.md | 2 ++ src/components/carousel.ts | 4 ++++ src/components/live/carousel.ts | 1 + src/components/viewer/carousel.ts | 1 + src/config/schema/live.ts | 2 ++ src/config/schema/viewer.ts | 2 ++ src/const.ts | 3 +++ src/editor.ts | 16 ++++++++++++++++ src/localize/languages/ca.json | 3 ++- src/localize/languages/en.json | 3 ++- src/localize/languages/fr.json | 3 ++- src/localize/languages/it.json | 3 ++- src/localize/languages/pt-BR.json | 3 ++- src/localize/languages/pt-PT.json | 3 ++- src/utils/embla/carousel-controller.ts | 5 ++++- tests/config/types.test.ts | 2 ++ tests/utils/embla/carousel-controller.test.ts | 18 ++++++++++++++++++ 18 files changed, 69 insertions(+), 7 deletions(-) diff --git a/docs/configuration/live.md b/docs/configuration/live.md index 0f332dc6..0366e279 100644 --- a/docs/configuration/live.md +++ b/docs/configuration/live.md @@ -41,6 +41,7 @@ live: | `next_previous` | | Configures how the "Next & Previous" controls are shown on the `live` view. See below. | | `thumbnails` | | Configures how thumbnails are shown on the `live` view. See below. | | `timeline` | | Configures how the mini-timeline is shown on the `live` view. See below. | +| `wheel` | `true` | Whether to allow mouse wheel to scroll through the carousel. | ### `next_previous` @@ -206,6 +207,7 @@ live: next_previous: style: chevrons size: 48 + wheel: true ptz: mode: auto position: bottom-right diff --git a/docs/configuration/media-viewer.md b/docs/configuration/media-viewer.md index 1c701871..a60090b4 100644 --- a/docs/configuration/media-viewer.md +++ b/docs/configuration/media-viewer.md @@ -37,6 +37,7 @@ media_viewer: | `next_previous` | | Configures how the "Next & Previous" controls are shown on the media viewer. See below. | | `thumbnails` | | Configures how thumbnails are shown on the media viewer. See below. | | `timeline` | | Configures how the mini-timeline is shown on the media viewer. See below. | +| `wheel` | `true` | Whether to allow mouse wheel to scroll through the carousel. | ### `next_previous` @@ -183,6 +184,7 @@ media_viewer: next_previous: size: 48 style: thumbnails + wheel: true ptz: mode: off position: bottom-right diff --git a/src/components/carousel.ts b/src/components/carousel.ts index bd31687a..b7f3e2f8 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -42,6 +42,9 @@ export class AdvancedCameraCardCarousel extends LitElement { @property({ attribute: false }) public plugins?: EmblaCarouselPlugins; + @property({ attribute: false }) + public wheelScrolling?: boolean; + @property({ attribute: false }) public selected = 0; @@ -121,6 +124,7 @@ export class AdvancedCameraCardCarousel extends LitElement { loop: this.loop, plugins: this.plugins, textDirection: getTextDirection(this), + wheelScrolling: this.wheelScrolling, }, ); } else if (changedProps.has('selected')) { diff --git a/src/components/live/carousel.ts b/src/components/live/carousel.ts index e87b7e5d..dffb4de7 100644 --- a/src/components/live/carousel.ts +++ b/src/components/live/carousel.ts @@ -339,6 +339,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement { this._getPlugins.bind(this), )} .selected=${this._getSelectedCameraIndex()} + .wheelScrolling=${this.liveConfig?.controls.wheel} transitionEffect=${this._getTransitionEffect()} @advanced-camera-card:carousel:select=${this._setViewHandler.bind(this)} @advanced-camera-card:media:loaded=${() => { diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index 36b42b42..b9cc6ff5 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -339,6 +339,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement { .dragEnabled=${this.viewerConfig?.draggable ?? true} .plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))} .selected=${this._selected} + .wheelScrolling=${this.viewerConfig?.controls.wheel} transitionEffect=${this._getTransitionEffect()} @advanced-camera-card:carousel:select=${(ev: CustomEvent) => { this._setViewSelectedIndex(ev.detail.index); diff --git a/src/config/schema/live.ts b/src/config/schema/live.ts index ce48e496..2da1256f 100644 --- a/src/config/schema/live.ts +++ b/src/config/schema/live.ts @@ -69,6 +69,7 @@ export const liveConfigDefault = { ptz: ptzControlsDefaults, thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, + wheel: true, }, microphone: { ...microphoneConfigDefault, @@ -121,6 +122,7 @@ export const liveConfigSchema = z liveConfigDefault.controls.thumbnails, ), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), + wheel: z.boolean().default(liveConfigDefault.controls.wheel), }) .default(liveConfigDefault.controls), display: viewDisplaySchema, diff --git a/src/config/schema/viewer.ts b/src/config/schema/viewer.ts index 15799517..4b6b7a4d 100644 --- a/src/config/schema/viewer.ts +++ b/src/config/schema/viewer.ts @@ -40,6 +40,7 @@ export const viewerConfigDefault = { ...ptzControlsDefaults, mode: 'off' as const, }, + wheel: true, }, }; @@ -101,6 +102,7 @@ export const viewerConfigSchema = z timeline: miniTimelineConfigSchema.default( viewerConfigDefault.controls.timeline, ), + wheel: z.boolean().default(viewerConfigDefault.controls.wheel), }) .default(viewerConfigDefault.controls), }) diff --git a/src/const.ts b/src/const.ts index c5b2e426..e723ea18 100644 --- a/src/const.ts +++ b/src/const.ts @@ -248,6 +248,8 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE = `${CONF_MEDIA_VIEWER}.controls.timeline.style` as const; export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS = `${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const; +export const CONF_MEDIA_VIEWER_CONTROLS_WHEEL = + `${CONF_MEDIA_VIEWER}.controls.wheel` as const; export const CONF_MEDIA_VIEWER_ZOOMABLE = `${CONF_MEDIA_VIEWER}.zoomable` as const; const CONF_LIVE = 'live' as const; @@ -271,6 +273,7 @@ export const CONF_LIVE_CONTROLS_PTZ_ORIENTATION = `${CONF_LIVE}.controls.ptz.orientation` as const; export const CONF_LIVE_CONTROLS_PTZ_POSITION = `${CONF_LIVE}.controls.ptz.position` as const; +export const CONF_LIVE_CONTROLS_WHEEL = `${CONF_LIVE}.controls.wheel` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA_TYPE = `${CONF_LIVE}.controls.thumbnails.media_type` as const; export const CONF_LIVE_CONTROLS_THUMBNAILS_EVENTS_MEDIA_TYPE = diff --git a/src/editor.ts b/src/editor.ts index 74a93587..f8b526a0 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -133,6 +133,7 @@ import { CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_LIVE_CONTROLS_TIMELINE_STYLE, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_LIVE_CONTROLS_WHEEL, CONF_LIVE_DISPLAY_GRID_COLUMNS, CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS, CONF_LIVE_DISPLAY_GRID_SELECTED_POSITION, @@ -175,6 +176,7 @@ import { CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, + CONF_MEDIA_VIEWER_CONTROLS_WHEEL, CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS, CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS, CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_POSITION, @@ -2911,6 +2913,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize('config.common.controls.builtin'), }, )} + ${this._renderSwitch( + CONF_LIVE_CONTROLS_WHEEL, + this._defaults.live.controls.wheel, + { + label: localize('config.common.controls.wheel'), + }, + )} ${this._renderNextPreviousControls( MENU_LIVE_CONTROLS_NEXT_PREVIOUS, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, @@ -3106,6 +3115,13 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard label: localize('config.common.controls.builtin'), }, )} + ${this._renderSwitch( + CONF_MEDIA_VIEWER_CONTROLS_WHEEL, + this._defaults.media_viewer.controls.wheel, + { + label: localize('config.common.controls.wheel'), + }, + )} ${this._renderNextPreviousControls( MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS, CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 2ea66670..88cfc801 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -263,7 +263,8 @@ "seek-in-camera": "Cerca d'escombratge només a la càmera seleccionada", "seek-in-media": "Cerca d'escombratge només dins de l'element multimèdia seleccionat" } - } + }, + "wheel": "" }, "display": { "editor_label": "Visualització", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index f213cd28..205bec48 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -263,7 +263,8 @@ "seek-in-camera": "Pan seeks within selected camera only", "seek-in-media": "Pan seeks within selected media item only" } - } + }, + "wheel": "Scroll wheel scrolls media carousel" }, "display": { "editor_label": "Display", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 6ba83854..18478262 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -263,7 +263,8 @@ "seek-in-camera": "Pan ne cherche que parmi la caméra sélectionnée", "seek-in-media": "Pan ne cherche que parmi les médias sélectionnés" } - } + }, + "wheel": "" }, "display": { "editor_label": "Affichage", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 14ad3368..45663548 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -263,7 +263,8 @@ "seek-in-camera": "", "seek-in-media": "" } - } + }, + "wheel": "" }, "display": { "editor_label": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 599c56df..ca8203b1 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -263,7 +263,8 @@ "seek-in-camera": "", "seek-in-media": "" } - } + }, + "wheel": "" }, "display": { "editor_label": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index bb621074..287a446a 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -263,7 +263,8 @@ "seek-in-camera": "", "seek-in-media": "" } - } + }, + "wheel": "" }, "display": { "editor_label": "", diff --git a/src/utils/embla/carousel-controller.ts b/src/utils/embla/carousel-controller.ts index 3ad89b6a..54d1598e 100644 --- a/src/utils/embla/carousel-controller.ts +++ b/src/utils/embla/carousel-controller.ts @@ -26,6 +26,7 @@ export class CarouselController { protected _dragFree: boolean; protected _draggable: boolean; protected _textDirection: TextDirection; + protected _wheelScrolling: boolean; protected _plugins: EmblaCarouselPlugins; protected _carousel: EmblaCarouselType; @@ -48,6 +49,7 @@ export class CarouselController { dragFree?: boolean; plugins?: EmblaCarouselPlugins; textDirection?: TextDirection; + wheelScrolling?: boolean; }, ) { this._root = root; @@ -60,6 +62,7 @@ export class CarouselController { this._draggable = options?.dragEnabled ?? true; this._plugins = options?.plugins ?? []; this._textDirection = options?.textDirection ?? 'ltr'; + this._wheelScrolling = options?.wheelScrolling ?? true; this._carousel = this._createCarousel(getChildrenFromElement(this._parent)); @@ -144,7 +147,7 @@ export class CarouselController { }, [ ...this._plugins, - ...(slides.length > 1 + ...(slides.length > 1 && this._wheelScrolling ? [ WheelGesturesPlugin({ // Whether the carousel is vertical or horizontal, interpret y-axis wheel diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 6b56f723..7058755f 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -113,6 +113,7 @@ describe('config defaults', () => { style: 'ribbon', window_seconds: 3600, }, + wheel: true, }, draggable: true, lazy_load: true, @@ -180,6 +181,7 @@ describe('config defaults', () => { style: 'ribbon', window_seconds: 3600, }, + wheel: true, }, draggable: true, lazy_load: true, diff --git a/tests/utils/embla/carousel-controller.test.ts b/tests/utils/embla/carousel-controller.test.ts index 12045da6..29abbe82 100644 --- a/tests/utils/embla/carousel-controller.test.ts +++ b/tests/utils/embla/carousel-controller.test.ts @@ -239,6 +239,24 @@ describe('CarouselController', () => { ); }); + it('should not include wheel plugin when wheelScrolling is false', () => { + const children = createTestSlideNodes(); + const root = createRoot(); + const parent = createParent({ children: children }); + new CarouselController(root, parent, { wheelScrolling: false }); + + // Verify WheelGesturesPlugin is NOT present + expect(EmblaCarousel).toBeCalledWith( + root, + expect.anything(), + expect.not.arrayContaining([ + expect.objectContaining({ + name: 'wheelGestures', + }), + ]), + ); + }); + it('should recreate carousel when children are added', () => { const children = createTestSlideNodes(); const root = createRoot();