Skip to content

Commit

Permalink
feat: support densities for devices with DevicePixelRatio > 1 (#769)
Browse files Browse the repository at this point in the history
Co-authored-by: Михаил Твердохлеб <mtverdohleb@itrack.ru>
Co-authored-by: Dmitriy <sova123409@gmail.com>
Co-authored-by: Hartmut <info@hartmut.co.uk>
  • Loading branch information
4 people authored Jun 10, 2023
1 parent a3967a7 commit 765b513
Show file tree
Hide file tree
Showing 12 changed files with 185 additions and 23 deletions.
16 changes: 16 additions & 0 deletions docs/content/3.components/1.nuxt-img.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ This a space-separated list of screen size/width pairs. You can [see a list of t
/>
```

### `densities`

To generate special versions of images for screens with increased pixel density.

**Example:**

```html
<nuxt-img
src="/logos/nuxt.png"
height="50"
densities="x1 x2"
/>
<!-- <img src="/_ipx/w_50/logos/nuxt.png"
srcset="/_ipx/w_100/logos/nuxt.png x2"/> -->
```

### `provider`

Use other provider instead of default [provider option](/configuration#provider) specified in `nuxt.config`
Expand Down
16 changes: 16 additions & 0 deletions docs/content/5.configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,22 @@ export default defineNuxtConfig({
})
```

## `densities`

Default: `[1, 2]`

Specify a value to work with `devicePixelRatio` > 1 (these are devices with retina display and others). You must specify for which `devicePixelRatio` values you want to adapt images.

You can [read more about `devicePixelRatio` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio).

```ts [nuxt.config.ts]
export default defineNuxtConfig({
image: {
densities: [1, 2, 3],
}
})
```

## `staticFilename`

You can use this option to change filename and location for the static image generation.
Expand Down
8 changes: 6 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ModuleOptions extends ImageProviders {
screens: CreateImageOptions['screens']
internalUrl: string
providers: { [name: string]: InputProvider | any } & ImageProviders
densities: number[]
[key: string]: any
}

Expand All @@ -42,7 +43,8 @@ export default defineNuxtModule<ModuleOptions>({
},
internalUrl: '',
providers: {},
alias: {}
alias: {},
densities: [1, 2]
}),
meta: {
name: '@nuxt/image',
Expand Down Expand Up @@ -70,13 +72,15 @@ export default defineNuxtModule<ModuleOptions>({
if (options.provider) {
options[options.provider] = options[options.provider] || {}
}
options.densities = options.densities || []

const imageOptions: Omit<CreateImageOptions, 'providers' | 'nuxt'> = pick(options, [
'screens',
'presets',
'provider',
'domains',
'alias'
'alias',
'densities'
])

const providers = await resolveProviders(nuxt, options)
Expand Down
1 change: 1 addition & 0 deletions src/runtime/components/_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const baseImageProps = {
provider: { type: String, default: undefined },

sizes: { type: [Object, String] as unknown as () => string | Record<string, any>, default: undefined },
densities: { type: String, default: undefined },
preload: { type: Boolean, default: undefined },

// <img> attributes
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/components/nuxt-img.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default defineComponent({
const sizes = computed(() => $img.getSizes(props.src!, {
..._base.options.value,
sizes: props.sizes,
densities: props.densities,
modifiers: {
..._base.modifiers.value,
width: parseSize(props.width),
Expand All @@ -41,6 +42,16 @@ export default defineComponent({
if (props.sizes && (!props.placeholder || placeholderLoaded.value)) {
attrs.sizes = sizes.value.sizes
attrs.srcset = sizes.value.srcset
} else if (props.densities || $img.options.densities) {
attrs.srcset = $img.getDensitySet(props.src!, {
..._base.options.value,
densities: props.densities,
modifiers: {
..._base.modifiers.value,
width: parseSize(props.width),
height: parseSize(props.height)
}
})
}
return attrs
})
Expand Down
88 changes: 76 additions & 12 deletions src/runtime/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defu } from 'defu'
import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo'
import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img } from '../types/image'
import { imageMeta } from './utils/meta'
import { parseSize } from './utils'
import { parseDensities, parseSize } from './utils'
import { prerenderStaticImages } from './utils/prerender'

export function createImage (globalOptions: CreateImageOptions) {
Expand Down Expand Up @@ -37,6 +37,7 @@ export function createImage (globalOptions: CreateImageOptions) {
$img.getImage = getImage
$img.getMeta = ((input: string, options?: ImageOptions) => getMeta(ctx, input, options)) as $Img['getMeta']
$img.getSizes = ((input: string, options: ImageSizesOptions) => getSizes(ctx, input, options)) as $Img['getSizes']
$img.getDensitySet = ((input: string, options: ImageSizesOptions) => getDensitySet(ctx, input, options)) as $Img['getDensitySet']

ctx.$img = $img as $Img

Expand Down Expand Up @@ -129,8 +130,11 @@ function getPreset (ctx: ImageCTX, name?: string): ImageOptions {
function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
const width = parseSize(opts.modifiers?.width)
const height = parseSize(opts.modifiers?.height)
const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities

const hwRatio = (width && height) ? height / width : 0
const variants = []
const sizeVariants = []
const srcsetVariants = []

const sizes: Record<string, string> = {}

Expand Down Expand Up @@ -163,25 +167,85 @@ function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
_cWidth = Math.round((_cWidth / 100) * screenMaxWidth)
}
const _cHeight = hwRatio ? Math.round(_cWidth * hwRatio) : height
variants.push({
width: _cWidth,

// add size variant with 'media'
sizeVariants.push({
size,
screenMaxWidth,
media: `(max-width: ${screenMaxWidth}px)`,
src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth, height: _cHeight }, opts)
media: `(max-width: ${screenMaxWidth}px)`
})

if (densities) {
// add srcset variants for all densities (for current 'size' processed)
for (const density of densities) {
srcsetVariants.push({
width: _cWidth * density,
src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth * density, height: _cHeight ? _cHeight * density : undefined }, opts)
})
}
}
}

sizeVariants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)

// for last size variant, always remove `media` (convention)
if (sizeVariants[sizeVariants.length - 1]) {
sizeVariants[sizeVariants.length - 1].media = ''
}

// de-duplicate size variants (by key `media`)
let previousMedia = null
for (let i = sizeVariants.length - 1; i >= 0; i--) {
const sizeVariant = sizeVariants[i]
if (sizeVariant.media === previousMedia) {
sizeVariants.splice(i, 1)
}
previousMedia = sizeVariant.media
}

variants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)
srcsetVariants.sort((v1, v2) => v1.width - v2.width)

const defaultVar = variants[variants.length - 1]
if (defaultVar) {
defaultVar.media = ''
// de-duplicate srcset variants (by key `width`)
let previousWidth = null
for (let i = srcsetVariants.length - 1; i >= 0; i--) {
const sizeVariant = srcsetVariants[i]
if (sizeVariant.width === previousWidth) {
srcsetVariants.splice(i, 1)
}
previousWidth = sizeVariant.width
}

// use last (:= largest) srcset variant as the image `src`
const defaultVar = srcsetVariants[srcsetVariants.length - 1]

return {
sizes: variants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '),
srcset: variants.map(v => `${v.src} ${v.width}w`).join(', '),
sizes: sizeVariants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '),
srcset: srcsetVariants.map(v => `${v.src} ${v.width}w`).join(', '),
src: defaultVar?.src
}
}

function getDensitySet (ctx: ImageCTX, input: string, opts: ImageSizesOptions): string | undefined {
const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
if (densities.length === 0) {
return undefined
}

const srcsetVariants: Array<{ density: string, src: string }> = []

for (const density of densities) {
const modifiers = { ...opts.modifiers }
if (modifiers.width) {
modifiers.width = modifiers.width * density
}
if (modifiers.height) {
modifiers.height = modifiers.height * density
}
srcsetVariants.push({
density: `${density}x`,
src: ctx.$img!(input, modifiers, opts)
})
}

return srcsetVariants.map(v => `${v.src} ${v.density}`).join(', ')
}
6 changes: 4 additions & 2 deletions src/runtime/providers/directus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ export const operationsGenerator = createOperationsGenerator({

export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL } = {}) => {
// Separating the transforms from the rest of the modifiers
const transforms = modifiers.transforms
if (transforms && transforms.length > 0) {
let transforms = modifiers.transforms
if (transforms && Array.isArray(transforms) && transforms.length > 0) {
// de-duplicate (can get multiplied when having >1 densities configured)
transforms = Array.from(new Set(transforms.map(obj => JSON.stringify(obj)))).map(value => JSON.parse(value))
// We stringify and encode in URL the list of lists, then apply it back to the modifiers
modifiers.transforms = new URLSearchParams(JSON.stringify(transforms)).toString().replace(/=+$/, '') as unknown as (string | number)[][]
}
Expand Down
11 changes: 11 additions & 0 deletions src/runtime/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,14 @@ export function parseSize (input: string | number | undefined = '') {
}
}
}

export function parseDensities (input: string | undefined = ''): number[] {
if (input === undefined || !input.length) {
return []
}

const densities = input.split(' ').map(size => parseInt(size.replace('x', '')))

// de-duplicate and return
return densities.filter((value, index) => densities.indexOf(value) === index)
}
2 changes: 2 additions & 0 deletions src/types/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ImageModifiers {
export interface ImageOptions {
provider?: string
preset?: string
densities?: string
modifiers?: Partial<ImageModifiers>
[key: string]: any
}
Expand Down Expand Up @@ -42,6 +43,7 @@ export interface CreateImageOptions {
screens: Record<string, number>
alias: Record<string, string>
domains: string[]
densities: number[]
}

export interface ImageInfo {
Expand Down
4 changes: 4 additions & 0 deletions test/e2e/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ describe('ipx provider', () => {
"/_ipx/s_300x300/images/everest.jpg",
"/_ipx/s_300x300/images/tacos.svg",
"/_ipx/s_300x300/unsplash/photo-1606112219348-204d7d8b94ee",
"/_ipx/s_600x600/images/colors.jpg",
"/_ipx/s_600x600/images/everest.jpg",
"/_ipx/s_600x600/images/tacos.svg",
"/_ipx/s_600x600/unsplash/photo-1606112219348-204d7d8b94ee",
]
`)
})
Expand Down
41 changes: 36 additions & 5 deletions test/unit/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ describe('Renders simple image', () => {
})

it('Matches snapshot', () => {
expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_900x900/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w\\">"')
expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w, /_ipx/s_1000x1000/image.png 1000w, /_ipx/s_1800x1800/image.png 1800w\\">"')
})

it('props.src is picked up by getImage()', () => {
const domSrc = wrapper.element.getAttribute('src')
expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_900x900/image.png"')
expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_1800x1800/image.png"')
})

it('props.src is reactive', async () => {
Expand All @@ -33,22 +33,43 @@ describe('Renders simple image', () => {
await nextTick()

const domSrc = wrapper.find('img').element.getAttribute('src')
expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_900x900/image.jpeg"')
expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_1800x1800/image.jpeg"')
})

it('sizes', () => {
const sizes = wrapper.find('img').element.getAttribute('sizes')
expect(sizes).toBe('(max-width: 500px) 500px, 900px')
})

it('applies densities', () => {
const img = mountImage({
width: 200,
height: 300,
sizes: '300:300px,400:400px',
densities: '1x 2x 3x',
src: 'image.png'
})
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1200x1800/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 300px) 300px, 400px\\" srcset=\\"/_ipx/s_300x450/image.png 300w, /_ipx/s_400x600/image.png 400w, /_ipx/s_600x900/image.png 600w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_900x1350/image.png 900w, /_ipx/s_1200x1800/image.png 1200w\\">"')
})

it('de-duplicates sizes & srcset', () => {
const img = mountImage({
width: 200,
height: 300,
sizes: '200:200px,300:200px,400:400px,400:400px,500:500px,800:800px',
src: 'image.png'
})
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1600x2400/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 200px) 200px, (max-width: 300px) 200px, (max-width: 400px) 400px, (max-width: 500px) 500px, 800px\\" srcset=\\"/_ipx/s_200x300/image.png 200w, /_ipx/s_400x600/image.png 400w, /_ipx/s_500x750/image.png 500w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_1000x1500/image.png 1000w, /_ipx/s_1600x2400/image.png 1600w\\">"')
})

it('encodes characters', () => {
const img = mountImage({
width: 200,
height: 200,
sizes: '200,500:500,900:900',
src: '/汉字.png'
})
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_900x900/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w\\">"')
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">"')
})

it('correctly sets crop', () => {
Expand All @@ -58,7 +79,17 @@ describe('Renders simple image', () => {
height: 2000,
sizes: 'xs:100vw sm:100vw md:300px lg:350px xl:350px 2xl:350px'
})
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_350x700/image.png\\" width=\\"1000\\" height=\\"2000\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 320px) 100vw, (max-width: 640px) 100vw, (max-width: 768px) 300px, (max-width: 1024px) 350px, (max-width: 1280px) 350px, 350px\\" srcset=\\"/_ipx/s_320x640/image.png 320w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_300x600/image.png 300w, /_ipx/s_350x700/image.png 350w, /_ipx/s_350x700/image.png 350w, /_ipx/s_350x700/image.png 350w\\">"')
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1280x2560/image.png\\" width=\\"1000\\" height=\\"2000\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 320px) 100vw, (max-width: 640px) 100vw, (max-width: 768px) 300px, (max-width: 1024px) 350px, (max-width: 1280px) 350px, 350px\\" srcset=\\"/_ipx/s_300x600/image.png 300w, /_ipx/s_320x640/image.png 320w, /_ipx/s_350x700/image.png 350w, /_ipx/s_600x1200/image.png 600w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_700x1400/image.png 700w, /_ipx/s_1280x2560/image.png 1280w\\">"')
})

it('without sizes, but densities', () => {
const img = mountImage({
src: '/image.png',
width: 300,
height: 400,
densities: '1x 2x 3x'
})
expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_300x400/image.png\\" width=\\"300\\" height=\\"400\\" data-nuxt-img=\\"\\" srcset=\\"/_ipx/s_300x400/image.png 1x, /_ipx/s_600x800/image.png 2x, /_ipx/s_900x1200/image.png 3x\\">"')
})
})

Expand Down
4 changes: 2 additions & 2 deletions test/unit/picture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe('Renders simple image', () => {
it('Matches snapshot', () => {
expect(wrapper.html()).toMatchInlineSnapshot(`
"<picture>
<source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_900x900/image.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w\\">
<source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
</picture>"
`)
})
Expand Down Expand Up @@ -102,7 +102,7 @@ describe('Renders simple image', () => {
})
expect(img.html()).toMatchInlineSnapshot(`
"<picture>
<source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w\\">
<source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">
</picture>"
`)
})
Expand Down

0 comments on commit 765b513

Please sign in to comment.