From 69ae80d6f17271538defea80f7dac063cd2640e9 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 15 Jul 2025 21:10:03 -0700 Subject: [PATCH] fix: `fit` parameter should apply to entire container when `aspect_ratio` is not set (#2126) * Closes #2125 --- docs/configuration/cameras/README.md | 2 +- .../media-provider-dimensions-controller.ts | 23 +++-- src/scss/provider.scss | 6 +- ...dia-provider-dimensions-controller.test.ts | 90 ++++++++++++------- 4 files changed, 77 insertions(+), 44 deletions(-) diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index 9377677a..44a459b2 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -150,7 +150,7 @@ cameras: | Option | Default | Description | | ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `fit` | `contain` | If `contain`, the media is contained within the card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. | +| `fit` | `contain` | If `contain`, the media is contained within the camera container/card and letterboxed if necessary. If `cover`, the media is expanded proportionally (i.e. maintaining the media aspect ratio) until the camera/card dimensions are fully covered. If `fill`, the media is stretched to fill the camera/card dimensions (i.e. ignoring the media aspect ratio). See [CSS object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) for technical details and a visualization. Note that if `aspect_ratio` is also set, this is controlling the behavior "within" that aspect-ratio, otherwise it's within the container for the camera (which is effectively the whole card for single card configurations). | | `pan` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when "digitally zoomed in" (see `zoom` parameter). This can be effectively used to "pan"/cut the media shown. A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See visualizations below. | | `position` | | A dictionary that may contain an `x` and `y` percentage (`0` - `100`) to control the position of the media when the fit is `cover` (for other values of `fit` this option has no effect). This can be effectively used to "pan"/cut the media shown. At any given time, only one of `x` and `y` will have an effect, depending on whether media width is larger than the camera/card dimensions (in which case `x` controls the position) or the media height is larger than the camera/card dimensions (in which case `y` controls the position). A value of `0` means maximally to the left or top of the media, a value of `100` means maximally to the right or bottom of the media. See [CSS object-position](https://developer.mozilla.org/en-US/docs/Web/CSS/object-position) for technicals. See visualizations below. | | `view_box` | | A dictionary that may contain a `top`, `bottom`, `left` and `right` percentage (`0` - `100`) to precisely crop what part of the media to show by specifying a % inset value from each side. Browsers apply this cropping after `position` and `fit` have been applied. Unlike `zoom`, the user cannot dynamically zoom back out -- however the builtin media controls will work as normal. See visualizations below. Limited [browser support](https://caniuse.com/mdn-css_properties_object-view-box): ![](../../images/browsers/chrome_16x16.png 'Google Chrome :no-zoom') ![](../../images/browsers/chromium_16x16.png 'Chromium :no-zoom') ![](../../images/browsers/edge_16x16.png 'Microsoft Edge :no-zoom') | diff --git a/src/components-lib/media-provider-dimensions-controller.ts b/src/components-lib/media-provider-dimensions-controller.ts index eca0df3c..fc8b0d74 100644 --- a/src/components-lib/media-provider-dimensions-controller.ts +++ b/src/components-lib/media-provider-dimensions-controller.ts @@ -7,7 +7,7 @@ import { AdvancedCameraCardMediaLoadedEventTarget } from '../utils/media-info'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout'; const SIZE_ATTRIBUTE = 'size'; -type SizeMode = 'sized' | 'unsized' | 'unsized-portrait' | 'unsized-landscape'; +type SizeMode = 'custom' | 'max' | 'max-height' | 'max-width'; export class MediaProviderDimensionsController implements ReactiveController { public resize = throttle(this._resizeHandler.bind(this), 100, { @@ -70,9 +70,9 @@ export class MediaProviderDimensionsController implements ReactiveController { SIZE_ATTRIBUTE, this._cameraConfig?.aspect_ratio ? this._cameraConfig?.aspect_ratio[0] >= this._cameraConfig?.aspect_ratio[1] - ? 'unsized-landscape' - : 'unsized-portrait' - : 'unsized', + ? 'max-width' + : 'max-height' + : 'max', ); } @@ -90,12 +90,17 @@ export class MediaProviderDimensionsController implements ReactiveController { // eslint-disable-next-line @typescript-eslint/no-unused-vars private _resizeHandler(_entries?: ResizeObserverEntry[]): void { + // Custom sizing only applies if the aspect ratio is set. + if (!this._cameraConfig?.aspect_ratio?.length) { + return; + } + const rememberHostSize = (): void => { this._intendedHostSize = this._host.getBoundingClientRect(); }; - const setUnsizedAttribute = (): void => { - setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'unsized'); + const setMaxAttribute = (): void => { + setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'max'); }; const setContainerIntrinsicSize = (container: HTMLElement): void => { @@ -123,7 +128,7 @@ export class MediaProviderDimensionsController implements ReactiveController { } if (!this._container) { - setUnsizedAttribute(); + setMaxAttribute(); return; } @@ -134,7 +139,7 @@ export class MediaProviderDimensionsController implements ReactiveController { const containerSize = this._container.getBoundingClientRect(); if (!containerSize.width || !containerSize.height) { - setUnsizedAttribute(); + setMaxAttribute(); return; } @@ -153,6 +158,6 @@ export class MediaProviderDimensionsController implements ReactiveController { ); } - setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'sized'); + setOrRemoveAttribute(this._host, true, SIZE_ATTRIBUTE, 'custom'); } } diff --git a/src/scss/provider.scss b/src/scss/provider.scss index 5a04e7f3..7a2fc952 100644 --- a/src/scss/provider.scss +++ b/src/scss/provider.scss @@ -20,13 +20,13 @@ max-width: 100%; } -:host([size='unsized']) > .container { +:host([size='max']) > .container { width: 100%; height: 100%; } -:host([size='unsized-portrait']) > .container { +:host([size='max-height']) > .container { height: 100%; } -:host([size='unsized-landscape']) > .container { +:host([size='max-width']) > .container { width: 100%; } diff --git a/tests/components-lib/media-provider-dimensions-controller.test.ts b/tests/components-lib/media-provider-dimensions-controller.test.ts index c9c094df..79ea862e 100644 --- a/tests/components-lib/media-provider-dimensions-controller.test.ts +++ b/tests/components-lib/media-provider-dimensions-controller.test.ts @@ -36,6 +36,13 @@ describe('MediaProviderDimensionsController', () => { vi.clearAllMocks(); }); + const configWithAspectRatioLandscape: CameraDimensionsConfig = { + aspect_ratio: [16, 9], + }; + const configWithAspectRatioPortrait: CameraDimensionsConfig = { + aspect_ratio: [9, 16], + }; + it('should construct', () => { const host = createLitElement(); const eventListener = vi.fn(); @@ -95,8 +102,7 @@ describe('MediaProviderDimensionsController', () => { const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); - const config = { aspect_ratio: [16, 9] }; - controller.setCameraConfig(config); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); expect(container.style.aspectRatio).toBe('16 / 9'); @@ -104,9 +110,9 @@ describe('MediaProviderDimensionsController', () => { describe('should set host attribute', () => { it.each([ - ['unsized', {}], - ['unsized-landscape', { aspect_ratio: [16, 9] }], - ['unsized-portrait', { aspect_ratio: [9, 16] }], + ['max', {}], + ['max-width', { aspect_ratio: [16, 9] }], + ['max-height', { aspect_ratio: [9, 16] }], ])('%s', async (value: string, config: CameraDimensionsConfig) => { const host = createLitElement(); const container = document.createElement('div'); @@ -166,64 +172,64 @@ describe('MediaProviderDimensionsController', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); - expect(host.getAttribute('size')).toBe('unsized'); - host.setAttribute('size', 'sized'); + expect(host.getAttribute('size')).toBe('max-width'); + host.setAttribute('size', 'custom'); controller.setContainer(container); - expect(host.getAttribute('size')).toBe('sized'); + expect(host.getAttribute('size')).toBe('custom'); }); it('should reset container', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); controller.setContainer(); - expect(host.getAttribute('size')).toBe('unsized'); + expect(host.getAttribute('size')).toBe('max-width'); }); describe('should set size attribute correctly', () => { - it('should set unsized-landscape', () => { + it('should set max-width', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); - const config = { aspect_ratio: [16, 9] }; - controller.setCameraConfig(config); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); expect(container.style.aspectRatio).toBe('16 / 9'); - expect(host.getAttribute('size')).toBe('unsized-landscape'); + expect(host.getAttribute('size')).toBe('max-width'); }); - it('should set unsized-portrait', () => { + it('should set max-height', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); - const config = { aspect_ratio: [9, 16] }; - controller.setCameraConfig(config); + controller.setCameraConfig(configWithAspectRatioPortrait); controller.setContainer(container); expect(container.style.aspectRatio).toBe('9 / 16'); - expect(host.getAttribute('size')).toBe('unsized-portrait'); + expect(host.getAttribute('size')).toBe('max-height'); }); - it('should set unsized', () => { + it('should set max', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); controller.setContainer(container); - expect(host.getAttribute('size')).toBe('unsized'); + expect(host.getAttribute('size')).toBe('max'); }); - it('should set unsized without a config', () => { + it('should set max without a config', () => { const host = createLitElement(); const container = document.createElement('div'); const controller = new MediaProviderDimensionsController(host); @@ -231,14 +237,14 @@ describe('MediaProviderDimensionsController', () => { controller.setCameraConfig(); controller.setContainer(container); - expect(host.getAttribute('size')).toBe('unsized'); + expect(host.getAttribute('size')).toBe('max'); }); }); describe('should respond to size changes', () => { - it('should set host to unsized if no container', () => { + it('should ignore without an aspect ratio', () => { const host = createLitElement(); - host.setAttribute('size', 'sized'); + host.setAttribute('size', '__RANDOM__'); host.getBoundingClientRect = vi.fn().mockReturnValue({ height: 600, @@ -249,12 +255,29 @@ describe('MediaProviderDimensionsController', () => { callResizeHandler(); - expect(host.getAttribute('size')).toBe('unsized'); + expect(host.getAttribute('size')).toBe('__RANDOM__'); }); - it('should set host to unsized if container has no dimensions', () => { + it('should set host to max if no container', () => { const host = createLitElement(); - host.setAttribute('size', 'sized'); + host.setAttribute('size', 'custom'); + + host.getBoundingClientRect = vi.fn().mockReturnValue({ + height: 600, + width: 300, + }); + + const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); + + callResizeHandler(); + + expect(host.getAttribute('size')).toBe('max'); + }); + + it('should set host to max if container has no dimensions', () => { + const host = createLitElement(); + host.setAttribute('size', 'custom'); host.getBoundingClientRect = vi.fn().mockReturnValue({ height: 100, @@ -268,16 +291,17 @@ describe('MediaProviderDimensionsController', () => { }); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); callResizeHandler(); - expect(host.getAttribute('size')).toBe('unsized'); + expect(host.getAttribute('size')).toBe('max'); }); it('should ignore resize calls where actual equals intended size', () => { const host = createLitElement(); - host.setAttribute('size', 'sized'); + host.setAttribute('size', 'custom'); host.getBoundingClientRect = vi.fn().mockReturnValue({ height: 100, @@ -291,6 +315,7 @@ describe('MediaProviderDimensionsController', () => { }); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); callResizeHandler(); @@ -318,13 +343,14 @@ describe('MediaProviderDimensionsController', () => { }); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); callResizeHandler(); expect(container.style.width).toBe('100%'); expect(container.style.height).toBe('auto'); - expect(host.getAttribute('size')).toBe('sized'); + expect(host.getAttribute('size')).toBe('custom'); }); it('should resize container to fit height-limited container', () => { @@ -341,13 +367,14 @@ describe('MediaProviderDimensionsController', () => { }); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); callResizeHandler(); expect(container.style.width).toBe(`${100 * (160 / 200)}px`); expect(container.style.height).toBe('100px'); - expect(host.getAttribute('size')).toBe('sized'); + expect(host.getAttribute('size')).toBe('custom'); }); }); }); @@ -377,6 +404,7 @@ describe('MediaProviderDimensionsController', () => { }); const controller = new MediaProviderDimensionsController(host); + controller.setCameraConfig(configWithAspectRatioLandscape); controller.setContainer(container); controller.hostConnected(); @@ -385,7 +413,7 @@ describe('MediaProviderDimensionsController', () => { expect(container.style.width).toBe(`100%`); expect(container.style.height).toBe('auto'); - expect(host.getAttribute('size')).toBe('sized'); + expect(host.getAttribute('size')).toBe('custom'); }); }); });