fix: fit parameter should apply to entire container when aspect_ratio is not set (#2126)

* Closes #2125
This commit is contained in:
Dermot Duffy
2025-07-15 21:10:03 -07:00
committed by GitHub
parent c3e7cef9cf
commit 69ae80d6f1
4 changed files with 77 additions and 44 deletions
+1 -1
View File
@@ -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') |
@@ -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<SizeMode>(this._host, true, SIZE_ATTRIBUTE, 'unsized');
const setMaxAttribute = (): void => {
setOrRemoveAttribute<SizeMode>(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<SizeMode>(this._host, true, SIZE_ATTRIBUTE, 'sized');
setOrRemoveAttribute<SizeMode>(this._host, true, SIZE_ATTRIBUTE, 'custom');
}
}
+3 -3
View File
@@ -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%;
}
@@ -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');
});
});
});