Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support densities for devices with DevicePixelRatio > 1 #769

Merged
merged 18 commits into from
Jun 10, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bc3d333
Add densities - addition params for device with DevicePixelRatio > 1
mtverdohleb Mar 14, 2023
95ec5eb
Add densities - addition params for device with DevicePixelRatio > 1 …
mtverdohleb Mar 14, 2023
c514ced
chore(release): 2.0.0
mtverdohleb Mar 14, 2023
03631a6
Merge remote-tracking branch 'origin/main' into main
mtverdohleb Apr 7, 2023
a4b8715
Merge remote-tracking branch 'origin/main' into densities
danielroe Jun 7, 2023
82b9fc7
chore: small type fixes
danielroe Jun 9, 2023
edb0de1
feat: set default for `densities: [1, 2]`
hartmut-co-uk Jun 9, 2023
97ad2c6
feat: fixes existing + adds additional tests for densities
hartmut-co-uk Jun 9, 2023
9b4c9d5
feat: fixes + polishes code implementing densities support
hartmut-co-uk Jun 9, 2023
9cdcdcd
Merge remote-tracking branch 'origin/main' into pr/769
hartmut-co-uk Jun 9, 2023
57d535e
fix: regression, null safe access to `defaultVar?.src`
hartmut-co-uk Jun 9, 2023
24c3d6f
fix: for provider 'directus' deduplicate "transforms" modifier
hartmut-co-uk Jun 9, 2023
8a8e0cd
docs: slight wording tweaks
danielroe Jun 9, 2023
560eaa4
fix: for provider 'directus' deduplicate "transforms" modifier
hartmut-co-uk Jun 9, 2023
5db69a6
style: lint
danielroe Jun 9, 2023
55b0a09
fix: for provider 'directus' deduplicate "transforms" modifier
hartmut-co-uk Jun 9, 2023
f096f92
Merge branch 'densities' of https://github.com/Mihanik71/image into p…
hartmut-co-uk Jun 9, 2023
ee1ece6
chore: adds test for image 'de-duplicates sizes & srcset'
hartmut-co-uk Jun 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
danielroe marked this conversation as resolved.
Show resolved Hide resolved
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 {
danielroe marked this conversation as resolved.
Show resolved Hide resolved
const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
if (densities.length === 0) {
return undefined
}

const srcsetVariants :{ density: string, src: string }[] = []
danielroe marked this conversation as resolved.
Show resolved Hide resolved

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(', ')
}
4 changes: 3 additions & 1 deletion 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
let transforms = modifiers.transforms
if (transforms && transforms.length > 0) {
// de-duplicate (can get multiplied when having >1 densities configured)
transforms = Array.from(new Set(transforms.map(JSON.stringify))).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
31 changes: 26 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,33 @@ 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('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 +69,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