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'