diff --git a/playground/pages/picture.vue b/playground/pages/picture.vue index 895faec5c..05a05a9ea 100644 --- a/playground/pages/picture.vue +++ b/playground/pages/picture.vue @@ -1,5 +1,6 @@ + Original Received onLoad event: {{ isLoaded }} + Placeholder + + Received onLoad event: {{ isLoaded2 }} @@ -15,4 +26,5 @@ import { ref } from '#imports' const isLoaded = ref(false) +const isLoaded2 = ref(false) diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue index 31cd40c75..03ef8d573 100644 --- a/src/runtime/components/NuxtImg.vue +++ b/src/runtime/components/NuxtImg.vue @@ -1,11 +1,11 @@ @@ -34,30 +34,29 @@ const isServer = import.meta.server const $img = useImage() -const _base = useBaseImage(props) +const { placeholder, placeholderLoaded, options: baseOptions, modifiers: baseModifiers, attrs: baseAttrs } = useBaseImage(props) -const placeholderLoaded = ref(false) const imgEl = ref() -type AttrsT = typeof _base.attrs.value & { +type AttrsT = typeof baseAttrs.value & { 'sizes'?: string 'srcset'?: string 'data-nuxt-img'?: string } const sizes = computed(() => $img.getSizes(props.src!, { - ..._base.options.value, + ...baseOptions.value, sizes: props.sizes, densities: props.densities, modifiers: { - ..._base.modifiers.value, + ...baseModifiers.value, width: parseSize(props.width), height: parseSize(props.height), }, })) const _attrs = computed(() => { - const attrs: AttrsT = { ..._base.attrs.value, 'data-nuxt-img': '' } + const attrs: AttrsT = { ...baseAttrs.value, 'data-nuxt-img': '' } if (!props.placeholder || placeholderLoaded.value) { attrs.sizes = sizes.value.sizes @@ -67,38 +66,10 @@ const _attrs = computed(() => { return attrs }) -const placeholder = computed(() => { - let placeholder = props.placeholder - - if (placeholder === '') { - placeholder = true - } - - if (!placeholder || placeholderLoaded.value) { - return false - } - - if (typeof placeholder === 'string') { - return placeholder - } - - const size = (Array.isArray(placeholder) - ? placeholder - : (typeof placeholder === 'number' ? [placeholder, placeholder] : [10, 10])) as [w: number, h: number, q: number, b: number] - - return $img(props.src!, { - ..._base.modifiers.value, - width: size[0], - height: size[1], - quality: size[2] || 50, - blur: size[3] || 3, - }, _base.options.value) -}) - const mainSrc = computed(() => props.sizes ? sizes.value.src - : $img(props.src!, _base.modifiers.value, _base.options.value), + : $img(props.src!, baseModifiers.value, baseOptions.value), ) const src = computed(() => placeholder.value ? placeholder.value : mainSrc.value) @@ -138,13 +109,13 @@ onMounted(() => { if (placeholder.value) { const img = new Image() - img.src = mainSrc.value - if (props.sizes) { img.sizes = sizes.value.sizes || '' img.srcset = sizes.value.srcset } + img.src = mainSrc.value + img.onload = (event) => { placeholderLoaded.value = true emit('load', event) diff --git a/src/runtime/components/NuxtPicture.vue b/src/runtime/components/NuxtPicture.vue index 27cb1a64b..af9f22426 100644 --- a/src/runtime/components/NuxtPicture.vue +++ b/src/runtime/components/NuxtPicture.vue @@ -1,9 +1,9 @@ - + @@ -15,9 +15,9 @@ ...(isServer ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {}), ...imgAttrs, }" - :src="sources[lastSourceIndex].src" - :sizes="sources[lastSourceIndex].sizes" - :srcset="sources[lastSourceIndex].srcset" + :src="placeholder ? placeholder : sources[lastSourceIndex]?.src" + :sizes="placeholder ? undefined : sources[lastSourceIndex]?.sizes" + :srcset="placeholder ? undefined : sources[lastSourceIndex]?.srcset" > @@ -46,7 +46,7 @@ const isServer = import.meta.server const $img = useImage() -const { attrs: baseAttrs, options: baseOptions, modifiers: baseModifiers } = useBaseImage(props) +const { placeholder, placeholderLoaded, attrs: baseAttrs, options: baseOptions, modifiers: baseModifiers } = useBaseImage(props) const originalFormat = computed(() => getFileExtension(props.src)) @@ -90,6 +90,7 @@ const sources = computed(() => { }) const lastSourceIndex = computed(() => sources.value.length - 1) +const mainSrc = computed(() => sources.value[lastSourceIndex.value]) if (import.meta.server && props.preload) { const link: NonNullable[number] = { @@ -131,6 +132,22 @@ const nuxtApp = useNuxtApp() const initialLoad = nuxtApp.isHydrating onMounted(() => { + if (placeholder.value) { + const img = new Image() + + if (mainSrc.value.sizes) img.sizes = mainSrc.value.sizes + if (mainSrc.value.srcset) img.srcset = mainSrc.value.srcset + if (mainSrc.value.src) img.src = mainSrc.value.src + + img.onload = (event) => { + placeholderLoaded.value = true + emit('load', event) + } + + markFeatureUsage('nuxt-picture') + return + } + if (!imgEl.value) { return } diff --git a/src/runtime/components/_base.ts b/src/runtime/components/_base.ts index cbcd44afd..b6563d40e 100644 --- a/src/runtime/components/_base.ts +++ b/src/runtime/components/_base.ts @@ -53,6 +53,10 @@ export const baseImageProps = { // csp nonce: { type: [String], default: undefined }, + + // placeholders + placeholder: { type: [Boolean, String, Number, Array], default: undefined }, + placeholderClass: { type: String, default: undefined }, } export interface BaseImageAttrs { @@ -117,7 +121,39 @@ export const useBaseImage = (props: ExtractPropTypes) => } }) + const placeholderLoaded = ref(false) + + const placeholder = computed(() => { + let placeholder = props.placeholder + + if (placeholder === '') { + placeholder = true + } + + if (!placeholder || placeholderLoaded.value) { + return false + } + + if (typeof placeholder === 'string') { + return placeholder + } + + const size = (Array.isArray(placeholder) + ? placeholder + : (typeof placeholder === 'number' ? [placeholder, placeholder] : [10, 10])) as [w: number, h: number, q: number, b: number] + + return $img(props.src!, { + ...modifiers.value, + width: size[0], + height: size[1], + quality: size[2] || 50, + blur: size[3] || 3, + }, options.value) + }) + return { + placeholder, + placeholderLoaded, options, attrs, modifiers, @@ -130,8 +166,4 @@ export const pictureProps = { imgAttrs: { type: Object, default: null }, } -export const imgProps = { - ...baseImageProps, - placeholder: { type: [Boolean, String, Number, Array], default: undefined }, - placeholderClass: { type: String, default: undefined }, -} +export const imgProps = baseImageProps diff --git a/test/unit/helpers.ts b/test/unit/helpers.ts new file mode 100644 index 000000000..f8252ce8d --- /dev/null +++ b/test/unit/helpers.ts @@ -0,0 +1,27 @@ +import { vi } from 'vitest' + +export const getImageLoad = (cb = () => {}) => { + let resolve = () => {} + let image = {} as HTMLImageElement + const loadEvent = Symbol('loadEvent') + const ImageMock = vi.fn(() => { + const _image = { + onload: () => {}, + } as unknown as HTMLImageElement + image = _image + // @ts-expect-error not valid argument for onload + resolve = () => _image.onload?.(loadEvent) + + return _image + }) + + vi.stubGlobal('Image', ImageMock) + cb() + vi.unstubAllGlobals() + + return { + resolve, + image, + loadEvent, + } +} diff --git a/test/unit/image.test.ts b/test/unit/image.test.ts index 1147c77e7..468d18d8d 100644 --- a/test/unit/image.test.ts +++ b/test/unit/image.test.ts @@ -1,8 +1,9 @@ // @vitest-environment nuxt -import { beforeEach, describe, it, expect, vi } from 'vitest' +import { beforeEach, describe, it, expect } from 'vitest' import { mount } from '@vue/test-utils' import type { ComponentMountingOptions, VueWrapper } from '@vue/test-utils' +import { getImageLoad } from './helpers' // @ts-expect-error virtual file import { imageOptions } from '#build/image-options' import { NuxtImg } from '#components' @@ -158,32 +159,6 @@ describe('Renders simple image', () => { }) }) -const getImageLoad = (cb = () => {}) => { - let resolve = () => {} - let image = {} as HTMLImageElement - const loadEvent = Symbol('loadEvent') - const ImageMock = vi.fn(() => { - const _image = { - onload: () => {}, - } as unknown as HTMLImageElement - image = _image - // @ts-expect-error not valid argument for onload - resolve = () => _image.onload?.(loadEvent) - - return _image - }) - - vi.stubGlobal('Image', ImageMock) - cb() - vi.unstubAllGlobals() - - return { - resolve, - image, - loadEvent, - } -} - describe('Renders placeholder image', () => { let wrapper: VueWrapper const src = '/image.png' diff --git a/test/unit/picture.test.ts b/test/unit/picture.test.ts index ed1690fd0..67327b91a 100644 --- a/test/unit/picture.test.ts +++ b/test/unit/picture.test.ts @@ -3,6 +3,7 @@ import type { VueWrapper } from '@vue/test-utils' import { mount } from '@vue/test-utils' import { beforeEach, describe, expect, it } from 'vitest' +import { getImageLoad } from './helpers' import { NuxtPicture } from '#components' import { useNuxtApp, useRuntimeConfig } from '#imports' // @ts-expect-error virtual file @@ -164,6 +165,104 @@ describe('Renders simple image', () => { }) }) +describe('Renders placeholder image', () => { + let wrapper: VueWrapper + const src = '/image.png' + + it('props.placeholder with src', async () => { + const { + resolve: resolveImage, + image: placeholderImage, + loadEvent, + } = getImageLoad(() => { + wrapper = mount(NuxtPicture, { + propsData: { + width: 200, + height: 200, + src, + placeholder: true, + }, + }) + }) + + let domSrc = wrapper.find('img').element.getAttribute('src') + + expect(domSrc).toMatchInlineSnapshot('"/_ipx/q_50&blur_3&s_10x10/image.png"') + expect(placeholderImage.src).toMatchInlineSnapshot('"/_ipx/f_png&s_3072x3072/image.png"') + + resolveImage() + await nextTick() + + domSrc = wrapper.find('img').element.getAttribute('src') + + expect(domSrc).toMatchInlineSnapshot('"/_ipx/f_png&s_3072x3072/image.png"') + expect(wrapper.emitted().load[0]).toStrictEqual([loadEvent]) + }) + + it('props.placeholder with sizes', async () => { + const { + resolve: resolveImage, + image: placeholderImage, + loadEvent, + } = getImageLoad(() => { + wrapper = mount(NuxtPicture, { + propsData: { + width: 200, + height: 200, + src, + sizes: '200,500:500,900:900', + placeholder: true, + }, + }) + }) + + let sizes = wrapper.find('img').element.getAttribute('sizes') + + expect(sizes).toMatchInlineSnapshot('null') + expect(placeholderImage.sizes).toMatchInlineSnapshot('"(max-width: 500px) 200px, (max-width: 900px) 500px, 900px"') + + resolveImage() + await nextTick() + + sizes = wrapper.find('img').element.getAttribute('sizes') + + expect(sizes).toMatchInlineSnapshot('"(max-width: 500px) 200px, (max-width: 900px) 500px, 900px"') + expect(wrapper.emitted().load[0]).toStrictEqual([loadEvent]) + }) + + it('placeholder class can be set', async () => { + const { resolve: resolveImage } = getImageLoad(() => { + wrapper = mount(NuxtPicture, { + propsData: { + src, + placeholder: true, + placeholderClass: 'placeholder', + }, + attrs: { + class: [{ test: true }, 'other'], + }, + }) + }) + expect([...wrapper.element.classList]).toMatchInlineSnapshot(` + [ + "placeholder", + "test", + "other", + ] + `) + expect(wrapper.find('img').element.getAttribute('src')).toMatchInlineSnapshot('"/_ipx/q_50&blur_3&s_10x10/image.png"') + resolveImage() + await nextTick() + expect([...wrapper.element.classList]).toMatchInlineSnapshot(` + [ + "test", + "other", + ] + `) + expect(wrapper.find('img').element.getAttribute('src')).toMatchInlineSnapshot(`"/_ipx/w_3072&f_png/image.png"`) + }) +}) + describe('Renders image, applies module config', () => { const nuxtApp = useNuxtApp() const config = useRuntimeConfig()
Received onLoad event: {{ isLoaded }}
Received onLoad event: {{ isLoaded2 }}