diff --git a/docs/api-reference/next/image.md b/docs/api-reference/next/image.md index 423d10c424827..cfb796e5b7dd8 100644 --- a/docs/api-reference/next/image.md +++ b/docs/api-reference/next/image.md @@ -48,6 +48,7 @@ export default Home - `src` - The path or URL to the source image. This is required. - `width` - The intrinsic width of the source image in pixels. Must be an integer without a unit. Required unless `unsized` is true. - `height` - The intrinsic height of the source image, in pixels. Must be an integer without a unit. Required unless `unsized` is true. +- `layout` - The rendered layout of the image. If `fixed`, the image dimensions will not change as the viewport changes (no responsiveness). If `intrinsic`, the image will scale the dimensions down for smaller viewports but maintain the original dimensions for larger viewports. If `responsive`, the image will scale the dimensions down for smaller viewports and scale up for larger viewports. Default `intrinsic`. - `sizes` - Defines what proportion of the screen you expect the image to take up. Recommended, as it helps serve the correct sized image to each device. [More info](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes). - `quality` - The quality of the optimized image, an integer between 1 and 100 where 100 is the best quality. Default 75. - `loading` - The loading behavior. When `lazy`, defer loading the image until it reaches a calculated distance from the viewport. When `eager`, load the image immediately. Default `lazy`. [More info](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-loading) diff --git a/packages/next/client/image.tsx b/packages/next/client/image.tsx index f861def1a3002..32678542daa75 100644 --- a/packages/next/client/image.tsx +++ b/packages/next/client/image.tsx @@ -13,6 +13,14 @@ const loaders = new Map string>([ type LoaderKey = 'imgix' | 'cloudinary' | 'akamai' | 'default' +const VALID_LAYOUT_VALUES = [ + 'fixed', + 'intrinsic', + 'responsive', + undefined, +] as const +type LayoutValue = typeof VALID_LAYOUT_VALUES[number] + type ImageData = { deviceSizes: number[] imageSizes: number[] @@ -29,6 +37,7 @@ type ImageProps = Omit< quality?: number | string priority?: boolean loading?: LoadingValue + layout?: LayoutValue unoptimized?: boolean } & ( | { width: number | string; height: number | string; unsized?: false } @@ -201,6 +210,7 @@ export default function Image({ unoptimized = false, priority = false, loading, + layout, className, quality, width, @@ -218,6 +228,13 @@ export default function Image({ )}` ) } + if (!VALID_LAYOUT_VALUES.includes(layout)) { + throw new Error( + `Image with src "${src}" has invalid "layout" property. Provided "${layout}" should be one of ${VALID_LAYOUT_VALUES.map( + String + ).join(',')}.` + ) + } if (!VALID_LOADING_VALUES.includes(loading)) { throw new Error( `Image with src "${src}" has invalid "loading" property. Provided "${loading}" should be one of ${VALID_LOADING_VALUES.map( @@ -232,6 +249,14 @@ export default function Image({ } } + if (!layout) { + if (sizes) { + layout = 'responsive' + } else { + layout = 'intrinsic' + } + } + let lazy = loading === 'lazy' if (!priority && typeof loading === 'undefined') { lazy = true @@ -265,25 +290,41 @@ export default function Image({ const heightInt = getInt(height) const qualityInt = getInt(quality) - let divStyle: React.CSSProperties | undefined - let imgStyle: React.CSSProperties | undefined let wrapperStyle: React.CSSProperties | undefined + let sizerStyle: React.CSSProperties | undefined + let sizerSvg: string | undefined + let imgStyle: React.CSSProperties | undefined if ( typeof widthInt !== 'undefined' && typeof heightInt !== 'undefined' && !unsized ) { - // // const quotient = heightInt / widthInt - const ratio = isNaN(quotient) ? 1 : quotient * 100 - wrapperStyle = { - maxWidth: '100%', - width: widthInt, - } - divStyle = { - position: 'relative', - paddingBottom: `${ratio}%`, + const paddingTop = isNaN(quotient) ? '100%' : `${quotient * 100}%` + if (layout === 'responsive') { + // + wrapperStyle = { position: 'relative' } + sizerStyle = { paddingTop } + } else if (layout === 'intrinsic') { + // + wrapperStyle = { + display: 'inline-block', + position: 'relative', + maxWidth: '100%', + } + sizerStyle = { + maxWidth: '100%', + } + sizerSvg = `` + } else if (layout === 'fixed') { + // + wrapperStyle = { + display: 'inline-block', + position: 'relative', + width: widthInt, + height: heightInt, + } } imgStyle = { visibility: lazy ? 'hidden' : 'visible', @@ -357,25 +398,37 @@ export default function Image({ return (
-
- {shouldPreload - ? generatePreload({ - src, - width: widthInt, - unoptimized, - sizes, - quality: qualityInt, - }) - : ''} - -
+ {shouldPreload + ? generatePreload({ + src, + width: widthInt, + unoptimized, + sizes, + quality: qualityInt, + }) + : null} + {sizerStyle ? ( +
+ {sizerSvg ? ( + + ) : null} +
+ ) : null} +
) } diff --git a/test/integration/image-component/default/pages/layout-fixed.js b/test/integration/image-component/default/pages/layout-fixed.js new file mode 100644 index 0000000000000..c4a64a8ac4e33 --- /dev/null +++ b/test/integration/image-component/default/pages/layout-fixed.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Fixed

+ + + + +

Layout Fixed

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-intrinsic.js b/test/integration/image-component/default/pages/layout-intrinsic.js new file mode 100644 index 0000000000000..631706707de34 --- /dev/null +++ b/test/integration/image-component/default/pages/layout-intrinsic.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Intrinsic

+ + + + +

Layout Intrinsic

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/pages/layout-responsive.js b/test/integration/image-component/default/pages/layout-responsive.js new file mode 100644 index 0000000000000..bb122b071c247 --- /dev/null +++ b/test/integration/image-component/default/pages/layout-responsive.js @@ -0,0 +1,41 @@ +import React from 'react' +import Image from 'next/image' + +const Page = () => { + return ( +
+

Layout Responsive

+ + + + +

Layout Responsive

+
+ ) +} + +export default Page diff --git a/test/integration/image-component/default/public/wide.png b/test/integration/image-component/default/public/wide.png new file mode 100644 index 0000000000000..b7bb4dc1497ba Binary files /dev/null and b/test/integration/image-component/default/public/wide.png differ diff --git a/test/integration/image-component/default/test/index.test.js b/test/integration/image-component/default/test/index.test.js index ff5ce85c820bc..48a3f44e34e1b 100644 --- a/test/integration/image-component/default/test/index.test.js +++ b/test/integration/image-component/default/test/index.test.js @@ -35,11 +35,26 @@ async function hasImageMatchingUrl(browser, url) { return foundMatch } +async function getComputed(browser, id, prop) { + const style = await browser.eval( + `getComputedStyle(document.getElementById('${id}')).${prop}` + ) + if (typeof style === 'string') { + return parseInt(style.replace(/px$/, ''), 10) + } + return null +} + +function getRatio(width, height) { + return Math.round((height / width) * 1000) +} + function runTests(mode) { it('should load the images', async () => { let browser try { browser = await webdriver(appPort, '/') + await check(async () => { const result = await browser.eval( `document.getElementById('basic-image').naturalWidth` @@ -102,6 +117,93 @@ function runTests(mode) { } }) + it('should work with layout-fixed so resizing window does not resize image', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-fixed') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'fixed1' + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-intrinsic so resizing window maintains image aspect ratio', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-intrinsic') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'intrinsic1' + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBe(width) + expect(await getComputed(browser, id, 'height')).toBe(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + const newWidth = await getComputed(browser, id, 'width') + const newHeight = await getComputed(browser, id, 'height') + expect(newWidth).toBeLessThan(width) + expect(newHeight).toBeLessThan(height) + expect(getRatio(newWidth, newHeight)).toBe(getRatio(width, height)) + } finally { + if (browser) { + await browser.close() + } + } + }) + + it('should work with layout-responsive so resizing window maintains image aspect ratio', async () => { + let browser + try { + browser = await webdriver(appPort, '/layout-responsive') + const width = 1200 + const height = 700 + const delta = 250 + const id = 'responsive1' + await browser.setDimensions({ + width: width + delta, + height: height + delta, + }) + expect(await getComputed(browser, id, 'width')).toBeGreaterThan(width) + expect(await getComputed(browser, id, 'height')).toBeGreaterThan(height) + await browser.setDimensions({ + width: width - delta, + height: height - delta, + }) + const newWidth = await getComputed(browser, id, 'width') + const newHeight = await getComputed(browser, id, 'height') + expect(newWidth).toBeLessThan(width) + expect(newHeight).toBeLessThan(height) + expect(getRatio(newWidth, newHeight)).toBe(getRatio(width, height)) + } finally { + if (browser) { + await browser.close() + } + } + }) + if (mode === 'dev') { it('should show missing src error', async () => { const browser = await webdriver(appPort, '/missing-src') diff --git a/test/lib/next-webdriver.d.ts b/test/lib/next-webdriver.d.ts index fe87e0363ab76..08587bdf1b710 100644 --- a/test/lib/next-webdriver.d.ts +++ b/test/lib/next-webdriver.d.ts @@ -17,6 +17,7 @@ interface Chain { back: () => Chain forward: () => Chain refresh: () => Chain + setDimensions: (opts: { height: number; width: number }) => Chain close: () => Chain quit: () => Chain } diff --git a/test/lib/wd-chain.js b/test/lib/wd-chain.js index 48924f13e343f..2dbbdabb6541f 100644 --- a/test/lib/wd-chain.js +++ b/test/lib/wd-chain.js @@ -126,6 +126,12 @@ export default class Chain { return this.updateChain(() => this.browser.navigate().refresh()) } + setDimensions({ height, width }) { + return this.updateChain(() => + this.browser.manage().window().setRect({ width, height, x: 0, y: 0 }) + ) + } + close() { return this.updateChain(() => Promise.resolve()) }