diff --git a/playground/pages/bg.vue b/playground/pages/bg.vue
new file mode 100644
index 000000000..0b18c52c9
--- /dev/null
+++ b/playground/pages/bg.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/src/module.ts b/src/module.ts
index bef4d1063..7b4c05da9 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -1,5 +1,5 @@
import { withLeadingSlash } from 'ufo'
-import { defineNuxtModule, addTemplate, createResolver, addComponent, addPlugin } from '@nuxt/kit'
+import { defineNuxtModule, addTemplate, addAutoImport, createResolver, addComponent, addPlugin } from '@nuxt/kit'
import { resolveProviders, detectProvider } from './provider'
import type { ImageProviders, ImageOptions, InputProvider, CreateImageOptions } from './types'
@@ -85,14 +85,19 @@ export default defineNuxtModule({
nuxt.options.alias['#image'] = runtimeDir
nuxt.options.build.transpile.push(runtimeDir)
+ addAutoImport({
+ name: 'useImage',
+ from: resolver.resolve('runtime/composables')
+ })
+
// Add components
addComponent({
name: 'NuxtImg',
- filePath: resolver.resolve('./runtime/components/nuxt-img.vue')
+ filePath: resolver.resolve('./runtime/components/nuxt-img')
})
addComponent({
name: 'NuxtPicture',
- filePath: resolver.resolve('./runtime/components/nuxt-picture.vue')
+ filePath: resolver.resolve('./runtime/components/nuxt-picture')
})
// Add runtime options
diff --git a/src/runtime/components/_base.ts b/src/runtime/components/_base.ts
new file mode 100644
index 000000000..e98b48f35
--- /dev/null
+++ b/src/runtime/components/_base.ts
@@ -0,0 +1,107 @@
+import { computed } from 'vue'
+import type { ExtractPropTypes } from 'vue'
+import { parseSize } from '../utils'
+
+export const baseImageProps = {
+ // input source
+ src: { type: String, required: true },
+
+ // modifiers
+ format: { type: String, default: undefined },
+ quality: { type: [Number, String], default: undefined },
+ background: { type: String, default: undefined },
+ fit: { type: String, default: undefined },
+ modifiers: { type: Object as () => Record, default: undefined },
+
+ // options
+ preset: { type: String, default: undefined },
+ provider: { type: String, default: undefined },
+
+ sizes: { type: [Object, String] as unknown as () => string | Record, default: undefined },
+ preload: { type: Boolean, default: undefined },
+
+ // attributes
+ width: { type: [String, Number], default: undefined },
+ height: { type: [String, Number], default: undefined },
+ alt: { type: String, default: undefined },
+ referrerpolicy: { type: String, default: undefined },
+ usemap: { type: String, default: undefined },
+ longdesc: { type: String, default: undefined },
+ ismap: { type: Boolean, default: undefined },
+ loading: { type: String, default: undefined },
+ crossorigin: {
+ type: [Boolean, String] as unknown as () => 'anonymous' | 'use-credentials' | boolean,
+ default: undefined,
+ validator: val => ['anonymous', 'use-credentials', '', true, false].includes(val)
+ },
+ decoding: {
+ type: String as () => 'async' | 'auto' | 'sync',
+ default: undefined,
+ validator: val => ['async', 'auto', 'sync'].includes(val)
+ }
+}
+
+export interface BaseImageAttrs {
+ width?: number
+ height?: number
+ alt?: string
+ referrerpolicy?: string
+ usemap?: string
+ longdesc?: string
+ ismap?: boolean
+ crossorigin?: '' | 'anonymous' | 'use-credentials'
+ loading?: string
+ decoding?: 'async' | 'auto' | 'sync'
+}
+
+export interface BaseImageModifiers {
+ width?: number
+ height?: number
+ format?: string
+ quality?: string | number
+ background?: string
+ fit?: string
+ [key: string]: any
+}
+
+export const useBaseImage = (props: ExtractPropTypes) => {
+ const options = computed(() => {
+ return {
+ provider: props.provider,
+ preset: props.preset
+ }
+ })
+
+ const attrs = computed(() => {
+ return {
+ width: parseSize(props.width),
+ height: parseSize(props.height),
+ alt: props.alt,
+ referrerpolicy: props.referrerpolicy,
+ usemap: props.usemap,
+ longdesc: props.longdesc,
+ ismap: props.ismap,
+ crossorigin: props.crossorigin === true ? 'anonymous' : props.crossorigin || undefined,
+ loading: props.loading,
+ decoding: props.decoding
+ }
+ })
+
+ const modifiers = computed(() => {
+ return {
+ ...props.modifiers,
+ width: parseSize(props.width),
+ height: parseSize(props.height),
+ format: props.format,
+ quality: props.quality,
+ background: props.background,
+ fit: props.fit
+ }
+ })
+
+ return {
+ options,
+ attrs,
+ modifiers
+ }
+}
diff --git a/src/runtime/components/image.mixin.ts b/src/runtime/components/image.mixin.ts
deleted file mode 100644
index d9243753f..000000000
--- a/src/runtime/components/image.mixin.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import type { ComponentOptions } from 'vue'
-import { parseSize } from '../utils'
-
-export const imageMixin: ComponentOptions = {
- props: {
- // input source
- src: { type: String, required: true },
-
- // modifiers
- format: { type: String, default: undefined },
- quality: { type: [Number, String], default: undefined },
- background: { type: String, default: undefined },
- fit: { type: String, default: undefined },
- modifiers: { type: Object as () => Record, default: undefined },
-
- // options
- preset: { type: String, default: undefined },
- provider: { type: String, default: undefined },
-
- sizes: { type: [Object, String] as unknown as () => string | Record, default: undefined },
- preload: { type: Boolean, default: undefined },
-
- // attributes
- width: { type: [String, Number], default: undefined },
- height: { type: [String, Number], default: undefined },
- alt: { type: String, default: undefined },
- referrerpolicy: { type: String, default: undefined },
- usemap: { type: String, default: undefined },
- longdesc: { type: String, default: undefined },
- ismap: { type: Boolean, default: undefined },
- crossorigin: { type: [Boolean, String] as unknown as () => boolean | '' | 'anonymous' | 'use-credentials', default: undefined, validator: val => ['anonymous', 'use-credentials', '', true, false].includes(val) },
- loading: { type: String, default: undefined },
- decoding: { type: String as () => 'async' | 'auto' | 'sync', default: undefined, validator: val => ['async', 'auto', 'sync'].includes(val) }
- },
- computed: {
- nImgAttrs (): {
- width?: number
- height?: number
- alt?: string
- referrerpolicy?: string
- usemap?: string
- longdesc?: string
- ismap?: boolean
- crossorigin?: '' | 'anonymous' | 'use-credentials'
- loading?: string
- decoding?: 'async' | 'auto' | 'sync'
- } {
- return {
- width: parseSize(this.width),
- height: parseSize(this.height),
- alt: this.alt,
- referrerpolicy: this.referrerpolicy,
- usemap: this.usemap,
- longdesc: this.longdesc,
- ismap: this.ismap,
- crossorigin: this.crossorigin === true ? 'anonymous' : this.crossorigin || undefined,
- loading: this.loading,
- decoding: this.decoding
- }
- },
- nModifiers (): { width?: number, height?: number, format?: string, quality?: string | number, background?: string, fit?: string } & Record {
- return {
- ...this.modifiers,
- width: parseSize(this.width),
- height: parseSize(this.height),
- format: this.format,
- quality: this.quality,
- background: this.background,
- fit: this.fit
- }
- },
- nOptions (): { provider?: string, preset?: string } {
- return {
- provider: this.provider,
- preset: this.preset
- }
- }
- }
-}
diff --git a/src/runtime/components/nuxt-img.ts b/src/runtime/components/nuxt-img.ts
new file mode 100644
index 000000000..ec6f46cbb
--- /dev/null
+++ b/src/runtime/components/nuxt-img.ts
@@ -0,0 +1,109 @@
+import { h, defineComponent } from 'vue'
+import { useImage } from '../composables'
+import { parseSize } from '../utils'
+import { baseImageProps, useBaseImage } from './_base'
+import { useHead } from '#imports'
+
+export const imgProps = {
+ ...baseImageProps,
+ placeholder: { type: [Boolean, String, Number, Array], default: undefined }
+}
+
+export default defineComponent({
+ name: 'NuxtImg',
+ props: imgProps,
+ setup: (props, ctx) => {
+ const $img = useImage()
+ const _base = useBaseImage(props)
+
+ const placeholderLoaded = ref(false)
+
+ type AttrsT = typeof _base.attrs.value & {
+ sizes?: string
+ srcset?: string
+ }
+
+ const sizes = computed(() => $img.getSizes(props.src, {
+ ..._base.options.value,
+ sizes: props.sizes,
+ modifiers: {
+ ..._base.modifiers.value,
+ width: parseSize(props.width),
+ height: parseSize(props.height)
+ }
+ }))
+
+ const attrs = computed(() => {
+ const attrs: AttrsT = _base.attrs.value
+ if (props.sizes) {
+ attrs.sizes = sizes.value.sizes
+ attrs.srcset = sizes.value.srcset
+ }
+ 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]
+
+ return $img(props.src, {
+ ..._base.modifiers.value,
+ width: size[0],
+ height: size[1],
+ quality: size[2] || 50
+ }, _base.options.value)
+ })
+
+ const mainSrc = computed(() =>
+ props.sizes
+ ? sizes.value.src
+ : $img(props.src, _base.modifiers.value, _base.options.value)
+ )
+
+ const src = computed(() => placeholder.value ? placeholder.value : mainSrc.value)
+
+ if (props.preload) {
+ const isResponsive = Object.values(sizes.value).every(v => v)
+ useHead({
+ link: [{
+ rel: 'preload',
+ as: 'image',
+ ...(!isResponsive
+ ? { href: src.value }
+ : {
+ href: sizes.value.src,
+ imagesizes: sizes.value.sizes,
+ imagesrcset: sizes.value.srcset
+ })
+ }]
+ })
+ }
+
+ const imgEl = ref(null)
+
+ onMounted(() => {
+ if (placeholder.value) {
+ const img = new Image()
+ img.src = mainSrc.value
+ img.onload = () => {
+ imgEl.value.src = mainSrc.value
+ placeholderLoaded.value = true
+ }
+ }
+ })
+
+ return () => h('img', {
+ ref: imgEl,
+ key: src.value,
+ src: src.value,
+ ...attrs.value,
+ ...ctx.attrs
+ })
+ }
+})
diff --git a/src/runtime/components/nuxt-img.vue b/src/runtime/components/nuxt-img.vue
deleted file mode 100644
index 68a774778..000000000
--- a/src/runtime/components/nuxt-img.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-
-
-
-
-
diff --git a/src/runtime/components/nuxt-picture.ts b/src/runtime/components/nuxt-picture.ts
new file mode 100644
index 000000000..7983f3c56
--- /dev/null
+++ b/src/runtime/components/nuxt-picture.ts
@@ -0,0 +1,80 @@
+import { h, defineComponent } from 'vue'
+import { useBaseImage, baseImageProps } from './_base'
+import { useHead } from '#imports'
+import { getFileExtension } from '#image'
+
+export const pictureProps = {
+ ...baseImageProps,
+ legacyFormat: { type: String, default: null },
+ imgAttrs: { type: Object, default: null }
+}
+
+export default defineComponent({
+ name: 'NuxtPicture',
+ props: pictureProps,
+ setup: (props, ctx) => {
+ const $img = useImage()
+ const _base = useBaseImage(props)
+
+ const isTransparent = computed(() => ['png', 'webp', 'gif'].includes(originalFormat.value))
+
+ const originalFormat = computed(() => getFileExtension(props.src))
+
+ const format = computed(() => props.format || originalFormat.value === 'svg' ? 'svg' : 'webp')
+
+ const legacyFormat = computed(() => {
+ if (props.legacyFormat) { return props.legacyFormat }
+ const formats: Record = {
+ webp: isTransparent.value ? 'png' : 'jpeg',
+ svg: 'png'
+ }
+ return formats[format.value] || originalFormat.value
+ })
+
+ const nSources = computed>(() => {
+ if (format.value === 'svg') {
+ return [{ srcset: props.src }]
+ }
+
+ const formats = legacyFormat.value !== format.value
+ ? [legacyFormat.value, format.value]
+ : [format.value]
+
+ return formats.map((format) => {
+ const { srcset, sizes, src } = $img.getSizes(props.src, {
+ ..._base.options.value,
+ sizes: props.sizes || $img.options.screens,
+ modifiers: { ..._base.modifiers.value, format }
+ })
+
+ return { src, type: `image/${format}`, sizes, srcset }
+ })
+ })
+
+ if (props.preload) {
+ const srcKey = nSources.value?.[1] ? 1 : 0
+
+ const link: any = { rel: 'preload', as: 'image', imagesrcset: nSources.value[srcKey].srcset }
+
+ if (nSources.value?.[srcKey]?.sizes) { link.imagesizes = nSources.value[srcKey].sizes }
+
+ useHead({ link: [link] })
+ }
+
+ return () => h('picture', { key: nSources.value[0].src }, [
+ ...(nSources.value?.[1] && [h('source', {
+ type: nSources.value[1].type,
+ sizes: nSources.value[1].sizes,
+ srcset: nSources.value[1].srcset
+ })]),
+ h('img', {
+ ..._base.attrs.value,
+ ...props.imgAttrs,
+ ...ctx.attrs,
+ src: nSources.value[0].src,
+ sizes: nSources.value[0].sizes,
+ srcset: nSources.value[0].srcset
+ })
+ ])
+ }
+})
diff --git a/src/runtime/components/nuxt-picture.vue b/src/runtime/components/nuxt-picture.vue
deleted file mode 100644
index 5bdb7faf4..000000000
--- a/src/runtime/components/nuxt-picture.vue
+++ /dev/null
@@ -1,114 +0,0 @@
-
-
-
-
-
diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts
new file mode 100644
index 000000000..ee4852606
--- /dev/null
+++ b/src/runtime/composables.ts
@@ -0,0 +1,3 @@
+export const useImage = () => {
+ return useNuxtApp().$img
+}
diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts
index 441ee4705..c2a3c7506 100644
--- a/src/runtime/plugin.ts
+++ b/src/runtime/plugin.ts
@@ -1,4 +1,5 @@
import { createImage } from '#image'
+// @ts-ignore
import { imageOptions } from '#build/image-options'
import { defineNuxtPlugin } from '#imports'