fix(home): preview locks aspect to panel dims + object-fit so it never overflows
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
The large FrameCard preview let the <img> drive height (`flex: 0 0 auto`, `width: auto`, `max-*: 100%`). On wide-container layouts and on the new V2 1200×1600 dimensions the image's intrinsic size leaked past the card, and the max-width/max-height combo can drop aspect ratio in some browsers. Now: the preview container locks its `aspect-ratio` to `panelDims(model, orientation)` — same source of truth that drives the empty-placeholder shape — and the <img> fills the container with `object-fit: contain`. Container shape is stable whether or not the thumbnail has loaded; image always scales to fit, portrait or landscape device, narrow or wide phone column. emptyAspectStyle no longer needs to carry aspect (parent already has it); empty-preview placeholder fills 100% of the parent now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,18 +89,22 @@ const props = defineProps<{
|
|||||||
|
|
||||||
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
||||||
|
|
||||||
// On large cards we let the <img> drive the preview height (its intrinsic
|
// Large cards lock the preview's aspect to the panel's actual dimensions,
|
||||||
// aspect ratio is the device's actual aspect). Until the image loads — or
|
// independent of whether the image has loaded. That way the layout never
|
||||||
// when no thumbnail exists yet — we want the empty placeholder to reserve
|
// jumps when the thumbnail arrives, the <img> can use object-fit: contain
|
||||||
// roughly the same shape so the layout doesn't jump.
|
// to scale down inside the container without overflow, and both portrait
|
||||||
const previewStyle = computed(() => ({}))
|
// and landscape devices fit cleanly inside whatever their parent column
|
||||||
|
// allows.
|
||||||
const emptyAspectStyle = computed(() => {
|
const previewStyle = computed(() => {
|
||||||
if (props.size !== 'large') return {}
|
if (props.size !== 'large') return {}
|
||||||
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
|
const { width, height } = panelDims(props.model ?? 'v1', props.orientation)
|
||||||
return { aspectRatio: `${width} / ${height}` }
|
return { aspectRatio: `${width} / ${height}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Empty-preview placeholder fills its parent (which already has aspect set
|
||||||
|
// by previewStyle), so nothing extra needed here.
|
||||||
|
const emptyAspectStyle = computed(() => ({}))
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.status) {
|
switch (props.status) {
|
||||||
case 'ok': return 'Online'
|
case 'ok': return 'Online'
|
||||||
@@ -190,26 +194,27 @@ const statusText = computed(() => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap the preview height so portrait frames (3:5) don't blow past half
|
// Preview locks to the device's actual aspect via inline aspect-ratio set
|
||||||
// the viewport. Landscape frames (5:3) at full card width are short enough
|
// by previewStyle. Caps height at 40dvh so a portrait frame's tall aspect
|
||||||
// that the cap never engages. The photo is centered within the preview;
|
// doesn't push everything else below the fold; `min-width: 0` lets flex
|
||||||
// portrait shots get a thin grey bar on each side instead of stretching.
|
// shrink it on narrow containers.
|
||||||
&--large &__preview {
|
&--large &__preview {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-height: 40dvh;
|
||||||
background: var(--color-surface-2);
|
background: var(--color-surface-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 40dvh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__img {
|
&--large &__img {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
width: 100%;
|
||||||
max-height: 100%;
|
height: 100%;
|
||||||
width: auto;
|
object-fit: contain;
|
||||||
height: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__empty-preview {
|
&--large &__empty-preview {
|
||||||
@@ -219,6 +224,7 @@ const statusText = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--large &__body {
|
&--large &__body {
|
||||||
|
|||||||
@@ -146,35 +146,34 @@ describe('FrameCard', () => {
|
|||||||
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
expect(wrapper.emitted('edit')![0]).toEqual([1])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a V1 landscape (1600 × 960) placeholder when no thumbnail is present', () => {
|
// Aspect ratio is set on the preview container itself (not the placeholder)
|
||||||
|
// so the box shape is stable whether or not the thumbnail has loaded —
|
||||||
|
// image inside scales with object-fit: contain.
|
||||||
|
it('locks preview aspect to V1 landscape (1600 × 960)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
|
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v1' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*960/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a V1 portrait (960 × 1600) placeholder when no thumbnail is present', () => {
|
it('locks preview aspect to V1 portrait (960 × 1600)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
|
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v1' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*960\s*\/\s*1600/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a V2 portrait (1200 × 1600) placeholder when no thumbnail is present', () => {
|
it('locks preview aspect to V2 portrait (1200 × 1600)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
|
props: { ...defaultProps, size: 'large', orientation: 'portrait', model: 'v2' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1200\s*\/\s*1600/)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reserves a V2 landscape (1600 × 1200) placeholder when no thumbnail is present', () => {
|
it('locks preview aspect to V2 landscape (1600 × 1200)', () => {
|
||||||
const wrapper = mount(FrameCard, {
|
const wrapper = mount(FrameCard, {
|
||||||
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
|
props: { ...defaultProps, size: 'large', orientation: 'landscape', model: 'v2' },
|
||||||
})
|
})
|
||||||
const empty = wrapper.find('.frame-card__empty-preview')
|
expect(wrapper.find('.frame-card__preview').attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
|
||||||
expect(empty.attributes('style')).toMatch(/aspect-ratio:\s*1600\s*\/\s*1200/)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-DHJU4XGZ.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-RZqoO867.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user