Repository: zeit-ui/react
Pull request #124 (2020-04-23 07:25:23)

feat(image): auto scale image when width is too small
## PR Checklist

- [x] Fix linting errors
- [x] Label has been added


## Change information





Patch:
diff --git a/components/image/image.tsx b/components/image/image.tsx
index aa6b11c..ac420c0 100644
--- a/components/image/image.tsx
+++ b/components/image/image.tsx
@@ -2,10 +2,14 @@ import React, { useEffect, useRef, useState } from 'react'
 import useTheme from '../styles/use-theme'
 import ImageSkeleton from './image.skeleton'
 import ImageBrowser from './image-browser'
+import useRealShape from '../utils/use-real-shape'
+import useCurrentState from '../utils/use-current-state'
+import useResize from '../utils/use-resize'
 
 interface Props {
   src: string
-  animation: boolean
+  disableAutoResize?: boolean
+  disableSkeleton?: boolean
   width?: number
   height?: number
   className?: string
@@ -14,7 +18,8 @@ interface Props {
 }
 
 const defaultProps = {
-  animation: true,
+  disableSkeleton: false,
+  disableAutoResize: false,
   className: '',
   scale: '100%',
   maxDelay: 3000,
@@ -24,15 +29,20 @@ type NativeAttrs = Omit<React.ImgHTMLAttributes<any>, keyof Props>
 export type ImageProps = Props & typeof defaultProps & NativeAttrs
 
 const Image: React.FC<ImageProps> = React.memo(({
-  src, width, height, animation, className, scale, maxDelay, ...props
+  src, width, height, disableSkeleton, className, scale, maxDelay,
+  disableAutoResize, ...props
 }) => {
-  const showAnimation = animation && width && height
+  const showAnimation = !disableSkeleton && width && height
+  const w = width ? `${width}px` : 'auto'
+  const h = height ? `${height}px` : 'auto'
+  
   const theme = useTheme()
   const [loading, setLoading] = useState<boolean>(true)
   const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
+  const [zoomHeight, setZoomHeight, zoomHeightRef] = useCurrentState<string>(h)
   const imageRef = useRef<HTMLImageElement>(null)
-  const w = width ? `${width}px` : 'auto'
-  const h = height ? `${height}px` : 'auto'
+  const [shape, updateShape] = useRealShape(imageRef)
+  
   const imageLoaded = () => {
     if (!showAnimation) return
     setLoading(false)
@@ -56,7 +66,31 @@ const Image: React.FC<ImageProps> = React.memo(({
     }, maxDelay)
     return () => clearTimeout(timer)
   }, [loading])
-
+  
+  /**
+   * On mobile devices, the render witdth may be less than CSS width value.
+   * If the image is scaled, set the height manually.
+   * This is to ensure the aspect ratio of the image.
+   *
+   * If the image is auto width, ignore all.
+   */
+  useEffect(() => {
+    if (disableAutoResize) return
+    const notLoaded = shape.width === 0
+    const isAutoZoom = zoomHeightRef.current === 'auto'
+    if (notLoaded || !width || !height) return
+    if (shape.width < width) {
+      !isAutoZoom && setZoomHeight('auto')
+    } else {
+      isAutoZoom && setZoomHeight(h)
+    }
+  }, [shape, width])
+  
+  useResize(() => {
+    if (disableAutoResize) return
+    updateShape()
+  })
+  
   return (
     <div className={`image ${className}`}>
       {(showSkeleton && showAnimation) && <ImageSkeleton opacity={loading ? .5 : 0} />}
@@ -64,7 +98,7 @@ const Image: React.FC<ImageProps> = React.memo(({
       <style jsx>{`
         .image {
           width: ${w};
-          height: ${h};
+          height: ${zoomHeight};
           margin: 0 auto;
           position: relative;
           border-radius: ${theme.layout.radius};
diff --git a/pages/en-us/components/display.mdx b/pages/en-us/components/display.mdx
index 3c7951a..d0ebb5c 100644
--- a/pages/en-us/components/display.mdx
+++ b/pages/en-us/components/display.mdx
@@ -1,5 +1,5 @@
 import { Layout, Playground, Attributes } from 'lib/components'
-import { Display, Code, Snippet } from 'components'
+import { Display, Code, Snippet, Image } from 'components'
 
 export const meta = {
   title: 'Display',
@@ -13,14 +13,13 @@ To display important information or images in a central alignment.
 <Playground
   title="Basic"
   desc="Describe the main part with a paragraph."
-  scope={{ Display }}
+  scope={{ Display, Image }}
   code={`
   <Display shadow caption="ZEIT Now for GitHub deploying a pull request automatically.">
-    <img height="214" src="https://zeit.co/docs/static/docs/git-integrations/github-comment.png" />
+    <Image width="435" height="200" src="https://zeit.co/docs/static/docs/git-integrations/github-comment.png" />
   </Display>
 `} />
 
-
 <Playground
   title="With Code"
   desc="Show multiline code blocks."
@@ -55,10 +54,10 @@ To display important information or images in a central alignment.
 <Playground
   title="Component Caption"
   desc="Use component on `caption` prop."
-  scope={{ Display, Code }}
+  scope={{ Display, Code, Image }}
   code={`
   <Display shadow caption={<p>A domain redirect that redirects requests made to <Code>www.zeit.rocks</Code> to <Code>zeit.rocks</Code>.</p>}>
-    <img width="650" height="305" src="https://zeit.co/docs/static/docs/custom-domains/redirecting-domains.png" />
+    <Image width="650" height="297" src="https://zeit.co/docs/static/docs/custom-domains/redirecting-domains.png" />
   </Display>
 `} />
 
diff --git a/pages/en-us/components/image.mdx b/pages/en-us/components/image.mdx
index b9b509f..0a81f68 100644
--- a/pages/en-us/components/image.mdx
+++ b/pages/en-us/components/image.mdx
@@ -61,7 +61,8 @@ Display image content.
 | Attribute | Description | Type | Accepted values | Default
 | ---------- | ---------- | ---- |  -------------- | ------ |
 | **src** | image src | `string` | - | - |
-| **animation** | display animation when loading  | `boolean` | - | `true` |
+| **disableSkeleton** | disable skeleton animation when loading  | `boolean` | - | `false` |
+| **disableAutoResize** | disable zooming when width is too small  | `boolean` | - | `false` |
 | **maxDelay**(ms) | max duration of animation | `number` | - | `3000` |
 | **width** | width | `number` | - | - |
 | **height** | height | `number` | - | - |
diff --git a/pages/zh-cn/components/display.mdx b/pages/zh-cn/components/display.mdx
index cb46e11..6b568ae 100644
--- a/pages/zh-cn/components/display.mdx
+++ b/pages/zh-cn/components/display.mdx
@@ -1,5 +1,5 @@
 import { Layout, Playground, Attributes } from 'lib/components'
-import { Display, Code, Snippet } from 'components'
+import { Display, Code, Snippet, Image } from 'components'
 
 export const meta = {
   title: '陈列框 Display',
@@ -12,10 +12,10 @@ export const meta = {
 
 <Playground
   desc="为一张图片添加描述文本。"
-  scope={{ Display }}
+  scope={{ Display, Image }}
   code={`
   <Display shadow caption="ZEIT Now 在 PR 上的自动部署。">
-    <img height="214" src="https://zeit.co/docs/static/docs/git-integrations/github-comment.png" />
+    <Image width="435" height="200" src="https://zeit.co/docs/static/docs/git-integrations/github-comment.png" />
   </Display>
 `} />
 
@@ -54,10 +54,10 @@ export const meta = {
 <Playground
   title="组件标题"
   desc="在 `caption` 属性上使用组件表达更丰富的信息。"
-  scope={{ Display, Code }}
+  scope={{ Display, Code, Image }}
   code={`
   <Display shadow caption={<p>将域名 <Code>www.zeit.rocks</Code> 重定向至 <Code>zeit.rocks</Code>。</p>}>
-    <img width="650" height="305" src="https://zeit.co/docs/static/docs/custom-domains/redirecting-domains.png" />
+    <Image width="650" height="297" src="https://zeit.co/docs/static/docs/custom-domains/redirecting-domains.png" />
   </Display>
 `} />
 
diff --git a/pages/zh-cn/components/image.mdx b/pages/zh-cn/components/image.mdx
index 0c14450..e6734bf 100644
--- a/pages/zh-cn/components/image.mdx
+++ b/pages/zh-cn/components/image.mdx
@@ -60,7 +60,8 @@ export const meta = {
 | 属性 | 描述 | 类型 | 推荐值 | 默认
 | ---------- | ---------- | ---- |  -------------- | ------ |
 | **src** | 图片地址 | `string` | - | - |
-| **animation** | 是否在加载时显示骨架动画  | `boolean` | - | `true` |
+| **disableSkeleton** | 禁用加载时的骨架动画  | `boolean` | - | `false` |
+| **disableAutoResize** | 禁用等比缩放图片 (当小于指定宽度时)  | `boolean` | - | `false` |
 | **maxDelay** | 动画持续最大时间 (毫秒)  | `number` | - | `3000` |
 | **width** | 宽度 | `number` | - | - |
 | **height** | 高度 | `number` | - | - |


Test patch:
diff --git a/components/image/__tests__/index.test.tsx b/components/image/__tests__/index.test.tsx
index 62c7200..667a9f6 100644
--- a/components/image/__tests__/index.test.tsx
+++ b/components/image/__tests__/index.test.tsx
@@ -2,6 +2,7 @@ import React from 'react'
 import { mount } from 'enzyme'
 import { Image } from 'components'
 import { updateWrapper } from 'tests/utils'
+import { act } from 'react-dom/test-utils'
 
 const url = 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAUA' +
   'AAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO' +
@@ -12,44 +13,44 @@ describe('Image', () => {
     let wrapper = mount(<Image src={url} />)
     expect(wrapper).toMatchSnapshot()
     expect(() => wrapper.unmount()).not.toThrow()
-  
+
     wrapper = mount(<Image src={url} width={20} height={20} />)
     wrapper.find('img').at(0).simulate('load')
     expect(wrapper).toMatchSnapshot()
     expect(() => wrapper.unmount()).not.toThrow()
-  
-    wrapper = mount(<Image src={url} width={20} height={20} animation={false} />)
+
+    wrapper = mount(<Image src={url} width={20} height={20} disableSkeleton />)
     wrapper.find('img').at(0).simulate('load')
     expect(wrapper).toMatchSnapshot()
     expect(() => wrapper.unmount()).not.toThrow()
   })
-  
+
   it('should work correctly with skeleton', async () => {
     let wrapper = mount(<Image src={url} width={20} height={20} />)
     expect(wrapper.find('.skeleton').length).not.toBe(0)
-  
+
     wrapper = mount(<Image src={url} width={20} height={20} />)
     wrapper.find('img').at(0).simulate('load')
     await updateWrapper(wrapper)
     expect(wrapper.find('.skeleton').length).not.toBe(0)
-  
+
     wrapper = mount(<Image src={url} width={20} />)
     expect(wrapper.find('.skeleton').length).toBe(0)
-    
-    mount(<Image src={url} width={20} height={20} animation={false} />)
+
+    mount(<Image src={url} width={20} height={20} disableSkeleton />)
     expect(wrapper.find('.skeleton').length).toBe(0)
   })
-  
+
   it('should remove skeleton when timeout', async () => {
     const animation = mount(<Image src={url} width={20} height={20} maxDelay={100} />)
-    const NoAnimation = mount(<Image src={url} width={20} height={20} maxDelay={100} animation={false} />)
+    const NoAnimation = mount(<Image src={url} width={20} height={20} maxDelay={100} disableSkeleton />)
     expect(animation.find('.skeleton').length).not.toBe(0)
     await updateWrapper(animation, 300)
     await updateWrapper(NoAnimation, 300)
     expect(animation.find('.skeleton').length).toBe(0)
     expect(NoAnimation.find('.skeleton').length).toBe(0)
   })
-  
+
   it('should remove skeleton when image completed', async () => {
     Object.defineProperty((global as any).Image.prototype, 'complete', {
       get() {
@@ -63,4 +64,28 @@ describe('Image', () => {
     expect((img.getDOMNode() as HTMLImageElement).complete).toEqual(true)
     expect(wrapper.find('.skeleton').length).toBe(0)
   })
+
+  it('should zooming image when width so small', async () => {
+    window.getComputedStyle = jest.fn()
+      .mockImplementation(() => ({
+        width: '10px',
+      }))
+    const wrapper = mount(
+      <div style={{ width: '10px' }}><Image src={url} width={100} height={100} /></div>
+    )
+    expect(wrapper.find('.image').html()).toContain('height: auto;')
+    ;(window.getComputedStyle as jest.Mock).mockRestore()
+    
+    window.getComputedStyle = jest.fn()
+      .mockImplementation(() => ({
+        width: '110px',
+      }))
+    act(() => {
+      window.dispatchEvent(new Event('resize'))
+    })
+    await updateWrapper(wrapper)
+    
+    expect(wrapper.find('.image').html()).not.toContain('height: auto;')
+    ;(window.getComputedStyle as jest.Mock).mockRestore()
+  })
 })