├── .dumirc.ts ├── .editorconfig ├── .eslintrc.js ├── .fatherrc.js ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── react-component-ci.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── README.md ├── assets ├── index.less └── preview.less ├── bunfig.toml ├── docs ├── demo │ ├── actionsRender.md │ ├── album.md │ ├── basic.md │ ├── controlled.md │ ├── controlledWithGroup.md │ ├── fallback.md │ ├── imageRender.md │ ├── placeholder.md │ ├── previewgroup-items.md │ ├── previewgroup.md │ └── thumbnail.md ├── examples │ ├── actionsRender.tsx │ ├── album.tsx │ ├── basic.tsx │ ├── common.tsx │ ├── controlled.tsx │ ├── controlledWithGroup.tsx │ ├── fallback.tsx │ ├── imageRender.tsx │ ├── images │ │ ├── 1.jpeg │ │ ├── 2.jpeg │ │ ├── 3.jpeg │ │ ├── disabled.jpeg │ │ └── placeholder.png │ ├── placeholder.tsx │ ├── previewgroup-items.tsx │ ├── previewgroup.tsx │ └── thumbnail.tsx └── index.md ├── now.json ├── package.json ├── src ├── Image.tsx ├── Preview │ ├── CloseBtn.tsx │ ├── Footer.tsx │ ├── PrevNext.tsx │ └── index.tsx ├── PreviewGroup.tsx ├── common.ts ├── context.ts ├── getFixScaleEleTransPosition.ts ├── hooks │ ├── useImageTransform.ts │ ├── useMouseEvent.ts │ ├── usePreviewItems.ts │ ├── useRegisterImage.ts │ ├── useStatus.ts │ └── useTouchEvent.ts ├── index.ts ├── interface.ts ├── previewConfig.ts └── util.ts ├── tests ├── __snapshots__ │ └── basic.test.tsx.snap ├── basic.test.tsx ├── controlled.test.tsx ├── fallback.test.tsx ├── placeholder.test.tsx ├── preview.test.tsx ├── previewGroup.test.tsx └── previewTouch.test.tsx ├── tsconfig.json ├── typings.d.ts └── update-example.js /.dumirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | import path from 'path'; 3 | 4 | export default defineConfig({ 5 | alias: { 6 | '@rc-component/image$': path.resolve('src'), 7 | '@rc-component/image/es': path.resolve('src'), 8 | }, 9 | favicons: ['https://avatars0.githubusercontent.com/u/9441414?s=200&v=4'], 10 | themeConfig: { 11 | name: 'Image', 12 | logo: 'https://avatars0.githubusercontent.com/u/9441414?s=200&v=4', 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*.{js,css}] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const base = require('@umijs/fabric/dist/eslint'); 2 | 3 | module.exports = { 4 | ...base, 5 | rules: { 6 | ...base.rules, 7 | 'arrow-parens': 0, 8 | 'react/no-array-index-key': 0, 9 | 'react/sort-comp': 0, 10 | '@typescript-eslint/no-explicit-any': 1, 11 | '@typescript-eslint/no-empty-interface': 1, 12 | '@typescript-eslint/no-inferrable-types': 0, 13 | 'react/no-find-dom-node': 1, 14 | 'react/require-default-props': 0, 15 | 'no-confusing-arrow': 0, 16 | 'import/no-named-as-default-member': 0, 17 | 'jsx-a11y/label-has-for': 0, 18 | 'jsx-a11y/label-has-associated-control': 0, 19 | 'jsx-a11y/control-has-associated-label': 0, 20 | 'jsx-a11y/alt-text': 0, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /.fatherrc.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'father'; 2 | 3 | export default defineConfig({ 4 | plugins: ['@rc-component/father-plugin'], 5 | }); 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ant-design # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: ant-design # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: "@types/react-dom" 11 | versions: 12 | - 17.0.0 13 | - 17.0.1 14 | - 17.0.2 15 | - dependency-name: "@types/react" 16 | versions: 17 | - 17.0.0 18 | - 17.0.1 19 | - 17.0.2 20 | - 17.0.3 21 | - dependency-name: react 22 | versions: 23 | - 17.0.1 24 | - dependency-name: less 25 | versions: 26 | - 4.1.0 27 | -------------------------------------------------------------------------------- /.github/workflows/react-component-ci.yml: -------------------------------------------------------------------------------- 1 | name: ✅ test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | uses: react-component/rc-test/.github/workflows/test.yml@main 6 | secrets: inherit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .storybook 2 | .doc 3 | *.iml 4 | *.log 5 | .idea/ 6 | .ipr 7 | .iws 8 | *~ 9 | ~* 10 | *.diff 11 | *.patch 12 | *.bak 13 | .DS_Store 14 | Thumbs.db 15 | .project 16 | .*proj 17 | .svn/ 18 | *.swp 19 | *.swo 20 | *.pyc 21 | *.pyo 22 | .build 23 | node_modules 24 | .cache 25 | dist 26 | assets/**/*.css 27 | build 28 | lib 29 | coverage 30 | yarn.lock 31 | package-lock.json 32 | pnpm-lock.yaml 33 | es/ 34 | .storybook 35 | .doc 36 | .history 37 | # umi 38 | .umi 39 | .umi-production 40 | .umi-test 41 | 42 | 43 | # dumi 44 | .dumi/tmp 45 | .dumi/tmp-production 46 | 47 | bun.lockb -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "proseWrap": "never", 6 | "arrowParens": "avoid", 7 | "overrides": [ 8 | { 9 | "files": ".prettierrc", 10 | "options": { 11 | "parser": "json" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT LICENSE 2 | 3 | Copyright (c) 2015-present Alipay.com, https://www.alipay.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rc-image 2 | 3 | React Image. 4 | 5 | 6 | [![NPM version][npm-image]][npm-url] 7 | [![npm download][download-image]][download-url] 8 | [![build status][github-actions-image]][github-actions-url] 9 | [![Codecov][codecov-image]][codecov-url] 10 | [![bundle size][bundlephobia-image]][bundlephobia-url] 11 | [![dumi][dumi-image]][dumi-url] 12 | 13 | [npm-image]: http://img.shields.io/npm/v/rc-image.svg?style=flat-square 14 | [npm-url]: http://npmjs.org/package/rc-image 15 | [github-actions-image]: https://github.com/react-component/image/workflows/CI/badge.svg 16 | [github-actions-url]: https://github.com/react-component/image/actions 17 | [travis-image]: https://img.shields.io/travis/react-component/image/master?style=flat-square 18 | [travis-url]: https://travis-ci.org/react-component/image 19 | [circleci-image]: https://img.shields.io/circleci/build/github/react-component/image/master?style=flat-square 20 | [circleci-url]: https://circleci.com/gh/react-component/image 21 | [coveralls-image]: https://img.shields.io/coveralls/react-component/image.svg?style=flat-square 22 | [coveralls-url]: https://coveralls.io/r/react-component/image?branch=master 23 | [codecov-image]: https://img.shields.io/codecov/c/gh/react-component/image?style=flat-square 24 | [codecov-url]: https://codecov.io/gh/react-component/image 25 | [david-url]: https://david-dm.org/react-component/image 26 | [david-image]: https://david-dm.org/react-component/image/status.svg?style=flat-square 27 | [david-dev-url]: https://david-dm.org/react-component/image?type=dev 28 | [david-dev-image]: https://david-dm.org/react-component/image/dev-status.svg?style=flat-square 29 | [download-image]: https://img.shields.io/npm/dm/rc-image.svg?style=flat-square 30 | [download-url]: https://npmjs.org/package/rc-image 31 | [bundlephobia-url]: https://bundlephobia.com/result?p=rc-image 32 | [bundlephobia-image]: https://badgen.net/bundlephobia/minzip/rc-image 33 | [dumi-url]: https://github.com/umijs/dumi 34 | [dumi-image]: https://img.shields.io/badge/docs%20by-dumi-blue?style=flat-square 35 | 36 | ## Feature 37 | 38 | - [x] Placeholder 39 | - [x] Preview 40 | - [x] Rotate 41 | - [x] Zoom 42 | - [x] Flip 43 | - [x] Fallback 44 | - [x] Multiple Preview 45 | 46 | ## install 47 | 48 | [![rc-image](https://nodei.co/npm/rc-image.png)](https://npmjs.org/package/rc-image) 49 | 50 | ## Usage 51 | 52 | ```bash 53 | npm install 54 | npm start 55 | ``` 56 | 57 | ```js 58 | import Image from '@rc-component/image'; 59 | 60 | export default () => ( 61 | 62 | ); 63 | ``` 64 | 65 | ## API 66 | 67 | | Name | Type | Default | Description | 68 | | --- | --- | --- | --- | 69 | | preview | boolean \| [PreviewType](#PreviewType) | true | Whether to show preview | 70 | | prefixCls | string | rc-image | Classname prefix | 71 | | placeholder | boolean \| ReactElement | - | if `true` will set default placeholder or use `ReactElement` set customize placeholder | 72 | | fallback | string | - | Load failed src | 73 | | previewPrefixCls | string | rc-image-preview | Preview classname prefix | 74 | | onError | (event: Event) => void | - | Load failed callback | 75 | 76 | ### PreviewType 77 | 78 | | Name | Type | Default | Description | 79 | | --- | --- | --- | --- | 80 | | open | boolean | - | Whether the preview is open or not | 81 | | closeIcon | React.ReactNode | - | Custom close icon | 82 | | src | string | - | Customize preview src | 83 | | movable | boolean | true | Enable drag | 84 | | scaleStep | number | 0.5 | The number to which the scale is increased or decreased | 85 | | minScale | number | 1 | Min scale | 86 | | maxScale | number | 50 | Max scale | 87 | | forceRender | boolean | - | Force render preview | 88 | | getContainer | string \| HTMLElement \| (() => HTMLElement) \| false | document.body | Return the mount node for preview | 89 | | imageRender | (originalNode: React.ReactElement, info: { transform: [TransformType](#TransformType) }) => React.ReactNode | - | Customize image | 90 | | actionsRender | (originalNode: React.ReactElement, info: Omit<[ToolbarRenderInfoType](#ToolbarRenderInfoType), 'current' \| 'total'>) => React.ReactNode | - | Customize toolbar | 91 | | onOpenChange | (open: boolean, prevVisible: boolean) => void | - | Callback when open is changed | 92 | | onTransform | { transform: [TransformType](#TransformType), action: [TransformAction](#TransformAction) } | - | Callback when transform is changed | 93 | 94 | ## Image.PreviewGroup 95 | 96 | preview the merged src 97 | 98 | ```js 99 | import Image from '@rc-component/image'; 100 | 101 | export default () => ( 102 | 103 | 104 | 105 | 106 | ); 107 | ``` 108 | 109 | ### API 110 | 111 | | Name | Type | Default | Description | 112 | | --- | --- | --- | --- | 113 | | preview | boolean \| [PreviewGroupType](#PreviewGroupType) | true | Whether to show preview,
current: If Preview the show img index, default 0 | 114 | | previewPrefixCls | string | rc-image-preview | Preview classname prefix | 115 | | icons | { [iconKey]?: ReactNode } | - | Icons in the top operation bar, iconKey: 'rotateLeft' \| 'rotateRight' \| 'zoomIn' \| 'zoomOut' \| 'close' \| 'left' \| 'right' | 116 | | fallback | string | - | Load failed src | 117 | | items | (string \| { src: string, alt: string, crossOrigin: string, ... })[] | - | preview group | 118 | 119 | ### PreviewGroupType 120 | 121 | | Name | Type | Default | Description | 122 | | --- | --- | --- | --- | 123 | | open | boolean | - | Whether the preview is open or not | 124 | | movable | boolean | true | Enable drag | 125 | | current | number | - | Current index | 126 | | closeIcon | React.ReactNode | - | Custom close icon | 127 | | scaleStep | number | 0.5 | The number to which the scale is increased or decreased | 128 | | minScale | number | 1 | Min scale | 129 | | maxScale | number | 50 | Max scale | 130 | | forceRender | boolean | - | Force render preview | 131 | | getContainer | string \| HTMLElement \| (() => HTMLElement) \| false | document.body | Return the mount node for preview | 132 | | countRender | (current: number, total: number) => ReactNode | - | Customize count | 133 | | imageRender | (originalNode: React.ReactElement, info: { transform: [TransformType](#TransformType), current: number }) => React.ReactNode | - | Customize image | 134 | | actionsRender | (originalNode: React.ReactElement, info: [ToolbarRenderInfoType](#ToolbarRenderInfoType)) => React.ReactNode | - | Customize toolbar | 135 | | onOpenChange | (open: boolean, prevVisible: boolean, current: number) => void | - | Callback when open is changed | 136 | | onTransform | { transform: [TransformType](#TransformType), action: [TransformAction](#TransformAction) } | - | Callback when transform is changed | 137 | 138 | ### TransformType 139 | 140 | ```typescript 141 | { 142 | x: number; 143 | y: number; 144 | rotate: number; 145 | scale: number; 146 | flipX: boolean; 147 | flipY: boolean; 148 | } 149 | ``` 150 | 151 | ### TransformAction 152 | 153 | ```typescript 154 | type TransformAction = 155 | | 'flipY' 156 | | 'flipX' 157 | | 'rotateLeft' 158 | | 'rotateRight' 159 | | 'zoomIn' 160 | | 'zoomOut' 161 | | 'close' 162 | | 'prev' 163 | | 'next' 164 | | 'wheel' 165 | | 'doubleClick' 166 | | 'move' 167 | | 'dragRebound'; 168 | ``` 169 | 170 | ### ToolbarRenderInfoType 171 | 172 | ```typescript 173 | { 174 | icons: { 175 | prevIcon?: React.ReactNode; 176 | nextIcon?: React.ReactNode; 177 | flipYIcon: React.ReactNode; 178 | flipXIcon: React.ReactNode; 179 | rotateLeftIcon: React.ReactNode; 180 | rotateRightIcon: React.ReactNode; 181 | zoomOutIcon: React.ReactNode; 182 | zoomInIcon: React.ReactNode; 183 | }; 184 | actions: { 185 | onActive?: (offset: number) => void; 186 | onFlipY: () => void; 187 | onFlipX: () => void; 188 | onRotateLeft: () => void; 189 | onRotateRight: () => void; 190 | onZoomOut: () => void; 191 | onZoomIn: () => void; 192 | onClose: () => void; 193 | onReset: () => void; 194 | }; 195 | transform: { 196 | x: number; 197 | y: number; 198 | rotate: number; 199 | scale: number; 200 | flipX: boolean; 201 | flipY: boolean; 202 | }, 203 | current: number; 204 | total: number; 205 | } 206 | ``` 207 | 208 | ## Example 209 | 210 | http://localhost:8003/examples/ 211 | 212 | ## Test Case 213 | 214 | ``` 215 | npm test 216 | ``` 217 | 218 | ## Coverage 219 | 220 | ``` 221 | npm run coverage 222 | ``` 223 | 224 | ## License 225 | 226 | rc-image is released under the MIT license. 227 | 228 | ## 🤝 Contributing 229 | 230 | 231 | Contribution Leaderboard 232 | 233 | -------------------------------------------------------------------------------- /assets/index.less: -------------------------------------------------------------------------------- 1 | @import 'preview.less'; 2 | 3 | @prefixCls: rc-image; 4 | @zindex-preview-mask: 1000; 5 | @preview-mask-bg: fade(#000, 100%); 6 | @text-color: #bbb; 7 | @text-color-disabled: darken(@text-color, 30%); 8 | @background-color: #f3f3f3; 9 | 10 | .reset() { 11 | box-sizing: border-box; 12 | margin: 0; 13 | padding: 0; 14 | } 15 | 16 | .box() { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | } 23 | 24 | .@{prefixCls} { 25 | position: relative; 26 | display: inline-flex; 27 | 28 | &-img { 29 | width: 100%; 30 | height: auto; 31 | &-placeholder { 32 | background-color: @background-color; 33 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjhweCIgaGVpZ2h0PSIyMnB4IiB2aWV3Qm94PSIwIDAgMjggMjIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5pbWFnZS1maWxs5aSH5Lu9PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9Iuafpeeci+WbvueJh+S8mOWMljQuMCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9IuWKoOi9veWbvueJhyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU3Mi4wMDAwMDAsIC01MDYuMDAwMDAwKSI+CiAgICAgICAgICAgIDxnIGlkPSJpbWFnZS1maWxs5aSH5Lu9IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NzAuMDAwMDAwLCA1MDEuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlIiBmaWxsPSIjMDAwMDAwIiBvcGFjaXR5PSIwIiB4PSIwIiB5PSIwIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjwvcmVjdD4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0yOSw1IEwzLDUgQzIuNDQ2ODc1LDUgMiw1LjQ0Njg3NSAyLDYgTDIsMjYgQzIsMjYuNTUzMTI1IDIuNDQ2ODc1LDI3IDMsMjcgTDI5LDI3IEMyOS41NTMxMjUsMjcgMzAsMjYuNTUzMTI1IDMwLDI2IEwzMCw2IEMzMCw1LjQ0Njg3NSAyOS41NTMxMjUsNSAyOSw1IFogTTEwLjU2MjUsOS41IEMxMS42NjU2MjUsOS41IDEyLjU2MjUsMTAuMzk2ODc1IDEyLjU2MjUsMTEuNSBDMTIuNTYyNSwxMi42MDMxMjUgMTEuNjY1NjI1LDEzLjUgMTAuNTYyNSwxMy41IEM5LjQ1OTM3NSwxMy41IDguNTYyNSwxMi42MDMxMjUgOC41NjI1LDExLjUgQzguNTYyNSwxMC4zOTY4NzUgOS40NTkzNzUsOS41IDEwLjU2MjUsOS41IFogTTI2LjYyMTg3NSwyMy4xNTkzNzUgQzI2LjU3ODEyNSwyMy4xOTY4NzUgMjYuNTE4NzUsMjMuMjE4NzUgMjYuNDU5Mzc1LDIzLjIxODc1IEw1LjUzNzUsMjMuMjE4NzUgQzUuNCwyMy4yMTg3NSA1LjI4NzUsMjMuMTA2MjUgNS4yODc1LDIyLjk2ODc1IEM1LjI4NzUsMjIuOTA5Mzc1IDUuMzA5Mzc1LDIyLjg1MzEyNSA1LjM0Njg3NSwyMi44MDYyNSBMMTAuNjY4NzUsMTYuNDkzNzUgQzEwLjc1NjI1LDE2LjM4NzUgMTAuOTE1NjI1LDE2LjM3NSAxMS4wMjE4NzUsMTYuNDYyNSBDMTEuMDMxMjUsMTYuNDcxODc1IDExLjA0Mzc1LDE2LjQ4MTI1IDExLjA1MzEyNSwxNi40OTM3NSBMMTQuMTU5Mzc1LDIwLjE4MTI1IEwxOS4xLDE0LjMyMTg3NSBDMTkuMTg3NSwxNC4yMTU2MjUgMTkuMzQ2ODc1LDE0LjIwMzEyNSAxOS40NTMxMjUsMTQuMjkwNjI1IEMxOS40NjI1LDE0LjMgMTkuNDc1LDE0LjMwOTM3NSAxOS40ODQzNzUsMTQuMzIxODc1IEwyNi42NTkzNzUsMjIuODA5Mzc1IEMyNi43NDA2MjUsMjIuOTEyNSAyNi43MjgxMjUsMjMuMDcxODc1IDI2LjYyMTg3NSwyMy4xNTkzNzUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjRThFOEU4Ij48L3BhdGg+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==); 34 | background-repeat: no-repeat; 35 | background-position: center center; 36 | } 37 | } 38 | 39 | &-placeholder { 40 | .box; 41 | } 42 | 43 | // >>> Mask 44 | &-mask { 45 | position: absolute; 46 | top: 0; 47 | right: 0; 48 | bottom: 0; 49 | left: 0; 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | color: #fff; 54 | background: rgba(0, 0, 0, 0.3); 55 | opacity: 0; 56 | transition: opacity 0.3s; 57 | pointer-events: none; 58 | } 59 | 60 | &:hover &-mask { 61 | opacity: 1; 62 | } 63 | 64 | // &-preview { 65 | // height: 100%; 66 | // text-align: center; 67 | // pointer-events: none; 68 | 69 | // &-body { 70 | // .box; 71 | // overflow: hidden; 72 | // } 73 | 74 | // // &.zoom-enter, 75 | // // &.zoom-appear { 76 | // // transform: none; 77 | // // opacity: 0; 78 | // // animation-duration: 0.3s; 79 | // // } 80 | 81 | // &-mask { 82 | // position: fixed; 83 | // top: 0; 84 | // right: 0; 85 | // bottom: 0; 86 | // left: 0; 87 | // z-index: @zindex-preview-mask; 88 | // height: 100%; 89 | // background-color: fade(@preview-mask-bg, 45%); 90 | 91 | // &-hidden { 92 | // display: none; 93 | // } 94 | // } 95 | 96 | // &-img { 97 | // max-width: 100%; 98 | // max-height: 70%; 99 | // vertical-align: middle; 100 | // transform: scale3d(1, 1, 1); 101 | // cursor: grab; 102 | // transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s; 103 | // user-select: none; 104 | 105 | // &-wrapper { 106 | // .box; 107 | // display: flex; 108 | // align-items: center; 109 | // justify-content: center; 110 | 111 | // & > * { 112 | // pointer-events: auto; 113 | // } 114 | // } 115 | // } 116 | 117 | // &-moving { 118 | // .@{prefixCls}-preview-img { 119 | // cursor: grabbing; 120 | // &-wrapper { 121 | // transition-duration: 0s; 122 | // } 123 | // } 124 | // } 125 | 126 | // &-wrap { 127 | // position: fixed; 128 | // top: 0; 129 | // right: 0; 130 | // bottom: 0; 131 | // left: 0; 132 | // z-index: @zindex-preview-mask; 133 | // overflow: auto; 134 | // outline: 0; 135 | // -webkit-overflow-scrolling: touch; 136 | // } 137 | 138 | // &-close { 139 | // position: fixed; 140 | // top: 32px; 141 | // right: 32px; 142 | // display: flex; 143 | // padding: 15px; 144 | // color: #fff; 145 | // background-color: rgba(0, 0, 0, 0.5); 146 | // border: 0; 147 | // border-radius: 50%; 148 | // outline: 0; 149 | // cursor: pointer; 150 | 151 | // &:hover { 152 | // opacity: 0.3; 153 | // } 154 | // } 155 | 156 | // &-operations-wrapper { 157 | // position: fixed; 158 | // z-index: @zindex-preview-mask + 1; 159 | // } 160 | 161 | // &-footer { 162 | // position: fixed; 163 | // bottom: 32px; 164 | // left: 0; 165 | // z-index: @zindex-preview-mask + 1; 166 | // display: flex; 167 | // flex-direction: column; 168 | // align-items: center; 169 | // width: 100%; 170 | // } 171 | 172 | // &-progress { 173 | // margin-bottom: 20px; 174 | // } 175 | 176 | // &-operations { 177 | // display: flex; 178 | // padding: 0 20px; 179 | // color: @text-color; 180 | // background: fade(@preview-mask-bg, 45%); 181 | // border-radius: 100px; 182 | 183 | // &-operation { 184 | // margin-left: 10px; 185 | // padding: 10px; 186 | // font-size: 18px; 187 | // cursor: pointer; 188 | // &-disabled { 189 | // color: @text-color-disabled; 190 | // pointer-events: none; 191 | // } 192 | // &:first-of-type { 193 | // margin-left: 0; 194 | // } 195 | // } 196 | // } 197 | 198 | // &-switch-left { 199 | // position: fixed; 200 | // top: 50%; 201 | // left: 10px; 202 | // z-index: @zindex-preview-mask + 1; 203 | // display: flex; 204 | // align-items: center; 205 | // justify-content: center; 206 | // width: 44px; 207 | // height: 44px; 208 | // margin-top: -22px; 209 | // color: @text-color; 210 | // background: fade(@text-color, 45%); 211 | // border-radius: 50%; 212 | // cursor: pointer; 213 | // &-disabled { 214 | // color: @text-color-disabled; 215 | // background: fade(@text-color, 30%); 216 | // cursor: not-allowed; 217 | // > .anticon { 218 | // cursor: not-allowed; 219 | // } 220 | // } 221 | // > .anticon { 222 | // font-size: 24px; 223 | // } 224 | // } 225 | 226 | // &-switch-right { 227 | // position: fixed; 228 | // top: 50%; 229 | // right: 10px; 230 | // z-index: @zindex-preview-mask + 1; 231 | // display: flex; 232 | // align-items: center; 233 | // justify-content: center; 234 | // width: 44px; 235 | // height: 44px; 236 | // margin-top: -22px; 237 | // color: @text-color; 238 | // background: fade(@text-color, 45%); 239 | // border-radius: 50%; 240 | // cursor: pointer; 241 | // &-disabled { 242 | // color: @text-color-disabled; 243 | // background: fade(@text-color, 20%); 244 | // cursor: not-allowed; 245 | // > .anticon { 246 | // cursor: not-allowed; 247 | // } 248 | // } 249 | // > .anticon { 250 | // font-size: 24px; 251 | // } 252 | // } 253 | // } 254 | } 255 | 256 | // .fade-enter, 257 | // .fade-appear { 258 | // animation-duration: 0.3s; 259 | // animation-play-state: paused; 260 | // animation-fill-mode: both; 261 | // } 262 | // .fade-leave { 263 | // animation-duration: 0.3s; 264 | // animation-play-state: paused; 265 | // animation-fill-mode: both; 266 | // } 267 | // .fade-enter.fade-enter-active, 268 | // .fade-appear.fade-appear-active { 269 | // animation-name: rcImageFadeIn; 270 | // animation-play-state: running; 271 | // } 272 | // .fade-leave.fade-leave-active { 273 | // animation-name: rcImageFadeOut; 274 | // animation-play-state: running; 275 | // pointer-events: none; 276 | // } 277 | // .fade-enter, 278 | // .fade-appear { 279 | // opacity: 0; 280 | // animation-timing-function: linear; 281 | // } 282 | // .fade-leave { 283 | // animation-timing-function: linear; 284 | // } 285 | 286 | // @keyframes rcImageFadeIn { 287 | // 0% { 288 | // opacity: 0; 289 | // } 290 | // 100% { 291 | // opacity: 1; 292 | // } 293 | // } 294 | 295 | // @keyframes rcImageFadeOut { 296 | // 0% { 297 | // opacity: 1; 298 | // } 299 | // 100% { 300 | // opacity: 0; 301 | // } 302 | // } 303 | 304 | // .zoom-enter, 305 | // .zoom-appear { 306 | // animation-duration: 0.3s; 307 | // animation-play-state: paused; 308 | // animation-fill-mode: both; 309 | // } 310 | // .zoom-leave { 311 | // animation-duration: 0.3s; 312 | // animation-play-state: paused; 313 | // animation-fill-mode: both; 314 | // } 315 | // .zoom-enter.zoom-enter-active, 316 | // .zoom-appear.zoom-appear-active { 317 | // animation-name: rcImageZoomIn; 318 | // animation-play-state: running; 319 | // } 320 | // .zoom-leave.zoom-leave-active { 321 | // animation-name: rcImageZoomOut; 322 | // animation-play-state: running; 323 | // pointer-events: none; 324 | // } 325 | // .zoom-enter, 326 | // .zoom-appear { 327 | // transform: scale(0); 328 | // opacity: 0; 329 | // animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); 330 | // } 331 | // .zoom-leave { 332 | // animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86); 333 | // } 334 | 335 | @keyframes rcImageZoomIn { 336 | 0% { 337 | transform: scale(0.2); 338 | opacity: 0; 339 | } 340 | 100% { 341 | transform: scale(1); 342 | opacity: 1; 343 | } 344 | } 345 | 346 | @keyframes rcImageZoomOut { 347 | 0% { 348 | transform: scale(1); 349 | } 350 | 100% { 351 | transform: scale(0.2); 352 | opacity: 0; 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /assets/preview.less: -------------------------------------------------------------------------------- 1 | @import (reference) 'index.less'; 2 | 3 | .@{prefixCls}-preview { 4 | position: fixed; 5 | overflow: hidden; 6 | user-select: none; 7 | inset: 0; 8 | 9 | &-mask { 10 | position: absolute; 11 | background-color: rgba(0, 0, 0, 0.3); 12 | inset: 0; 13 | } 14 | 15 | &-body { 16 | position: absolute; 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | pointer-events: none; 21 | inset: 0; 22 | 23 | > * { 24 | pointer-events: auto; 25 | } 26 | } 27 | 28 | &-img { 29 | max-width: 100%; 30 | max-height: 70%; 31 | } 32 | 33 | // =================== Close ==================== 34 | &-close { 35 | position: absolute; 36 | top: 16px; 37 | right: 16px; 38 | z-index: 1; 39 | width: 32px; 40 | height: 32px; 41 | color: #fff; 42 | background: rgba(0, 0, 0, 0.3); 43 | border: 0; 44 | border-radius: 99px; 45 | cursor: pointer; 46 | } 47 | 48 | // =================== Switch =================== 49 | &-switch { 50 | position: absolute; 51 | top: 50%; 52 | z-index: 1; 53 | display: flex; 54 | align-items: center; 55 | justify-content: center; 56 | width: 40px; 57 | height: 40px; 58 | color: #fff; 59 | background: rgba(0, 0, 0, 0.3); 60 | border-radius: 9999px; 61 | transform: translateY(-50%); 62 | cursor: pointer; 63 | 64 | &-disabled { 65 | cursor: default; 66 | opacity: 0.1; 67 | } 68 | 69 | &-prev { 70 | inset-inline-start: 0; 71 | } 72 | 73 | &-next { 74 | inset-inline-end: 0; 75 | } 76 | } 77 | 78 | // =================== Footer =================== 79 | &-footer { 80 | position: absolute; 81 | bottom: 24px; 82 | left: 50%; 83 | z-index: 1; 84 | display: flex; 85 | flex-direction: column; 86 | gap: 16px; 87 | align-items: center; 88 | transform: translateX(-50%); 89 | } 90 | 91 | &-progress { 92 | color: #fff; 93 | } 94 | 95 | // =================== Action =================== 96 | &-actions { 97 | display: flex; 98 | gap: 8px; 99 | padding: 8px 16px; 100 | background: rgba(0, 0, 0, 0.3); 101 | border-radius: 12px; 102 | 103 | &-action { 104 | color: #fff; 105 | cursor: pointer; 106 | 107 | &-disabled { 108 | cursor: default; 109 | opacity: 0.5; 110 | } 111 | } 112 | } 113 | 114 | // =================== Motion =================== 115 | &.fade { 116 | // Basic fade 117 | transition: all 0.3s; 118 | 119 | // Fade in 120 | &-enter, 121 | &-appear { 122 | opacity: 0; 123 | 124 | .@{prefixCls}-preview-body { 125 | transform: scale(0); 126 | } 127 | 128 | &-active { 129 | opacity: 1; 130 | 131 | .@{prefixCls}-preview-body { 132 | transform: scale(1); 133 | transition: all 0.3s; 134 | } 135 | } 136 | } 137 | 138 | // Fade out 139 | &-leave { 140 | opacity: 1; 141 | 142 | &-active { 143 | opacity: 0; 144 | } 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [install] 2 | peer = false -------------------------------------------------------------------------------- /docs/demo/actionsRender.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: actionsRender 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/album.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: album 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/basic.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: basic 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/controlled.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: controlled 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/controlledWithGroup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: controlledWithGroup 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/fallback.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: fallback 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/imageRender.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: imageRender 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/placeholder.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: placeholder 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/previewgroup-items.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: previewGroupItems 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/previewgroup.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: previewgroup 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/demo/thumbnail.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: thumbnail 3 | nav: 4 | title: Demo 5 | path: /demo 6 | --- 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/examples/actionsRender.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function ToolbarRender() { 7 | return ( 8 |
9 | { 29 | return ( 30 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 | ); 44 | }, 45 | }} 46 | /> 47 | 48 | test{JSON.stringify(image)}
; 57 | }, 58 | actionsRender(_, { image }) { 59 | return
{JSON.stringify(image)}
; 60 | }, 61 | }} 62 | /> 63 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /docs/examples/album.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function PreviewGroup() { 7 | return ( 8 |
9 | 13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /docs/examples/basic.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function Base() { 7 | return ( 8 |
9 | { 16 | console.log('click'); 17 | }} 18 | preview={{ 19 | icons: defaultIcons, 20 | onOpenChange: open => { 21 | console.log('open', open); 22 | }, 23 | zIndex: 9999, 24 | }} 25 | /> 26 | 27 | 35 | 43 | 48 | 49 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /docs/examples/common.tsx: -------------------------------------------------------------------------------- 1 | import type { PreviewProps } from '@/Preview'; 2 | import { 3 | CloseOutlined, 4 | LeftOutlined, 5 | RightOutlined, 6 | RotateLeftOutlined, 7 | RotateRightOutlined, 8 | SwapOutlined, 9 | ZoomInOutlined, 10 | ZoomOutOutlined, 11 | } from '@ant-design/icons'; 12 | import React from 'react'; 13 | 14 | export const defaultIcons: PreviewProps['icons'] = { 15 | rotateLeft: , 16 | rotateRight: , 17 | zoomIn: , 18 | zoomOut: , 19 | close: , 20 | left: , 21 | right: , 22 | flipX: , 23 | flipY: , 24 | }; 25 | -------------------------------------------------------------------------------- /docs/examples/controlled.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function Base() { 7 | const [open, setOpen] = React.useState(false); 8 | return ( 9 |
10 |
11 | 19 |
20 | { 27 | setOpen(value); 28 | }, 29 | }} 30 | /> 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /docs/examples/controlledWithGroup.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | import Image from '@rc-component/image'; 3 | import * as React from 'react'; 4 | import '../../assets/index.less'; 5 | import { defaultIcons } from './common'; 6 | 7 | export default function Base() { 8 | const [open, setOpen] = React.useState(false); 9 | const [current, setCurrent] = React.useState(1); 10 | return ( 11 |
12 |
13 | 21 |
22 | { 27 | setOpen(value); 28 | }, 29 | current, 30 | onChange: c => setCurrent(c), 31 | }} 32 | > 33 | 37 | 41 | 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /docs/examples/fallback.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function Base() { 7 | return ( 8 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /docs/examples/imageRender.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function imageRender() { 7 | return ( 8 |
9 | null, 15 | imageRender: () => ( 16 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /docs/examples/images/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/image/e5a5685e28414eb93f263d1d9c311bcf5bf40a2b/docs/examples/images/1.jpeg -------------------------------------------------------------------------------- /docs/examples/images/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/image/e5a5685e28414eb93f263d1d9c311bcf5bf40a2b/docs/examples/images/2.jpeg -------------------------------------------------------------------------------- /docs/examples/images/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/image/e5a5685e28414eb93f263d1d9c311bcf5bf40a2b/docs/examples/images/3.jpeg -------------------------------------------------------------------------------- /docs/examples/images/disabled.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/image/e5a5685e28414eb93f263d1d9c311bcf5bf40a2b/docs/examples/images/disabled.jpeg -------------------------------------------------------------------------------- /docs/examples/images/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-component/image/e5a5685e28414eb93f263d1d9c311bcf5bf40a2b/docs/examples/images/placeholder.png -------------------------------------------------------------------------------- /docs/examples/placeholder.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function Base() { 7 | const [random, setRandom] = React.useState(Date.now()); 8 | return ( 9 |
10 | 18 |

Default placeholder

19 |
20 | 27 |
28 | 29 |
30 |

Custom placeholder

31 | 43 | } 44 | /> 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /docs/examples/previewgroup-items.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | 5 | export default function Base() { 6 | return ( 7 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /docs/examples/previewgroup.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CloseOutlined, 3 | LeftOutlined, 4 | RightOutlined, 5 | RotateLeftOutlined, 6 | RotateRightOutlined, 7 | SwapOutlined, 8 | ZoomInOutlined, 9 | ZoomOutOutlined, 10 | } from '@ant-design/icons'; 11 | import Image from '@rc-component/image'; 12 | import React from 'react'; 13 | import '../../assets/index.less'; 14 | 15 | const icons = { 16 | rotateLeft: , 17 | rotateRight: , 18 | zoomIn: , 19 | zoomOut: , 20 | close: , 21 | left: , 22 | right: , 23 | flipX: , 24 | flipY: , 25 | }; 26 | 27 | export default function PreviewGroup() { 28 | return ( 29 |
30 | `第${current}张 / 总共${total}张`, 34 | onChange: (current, prev) => 35 | console.log(`当前第${current}张,上一次第${prev === undefined ? '-' : prev}张`), 36 | }} 37 | > 38 | 42 | 47 | 51 | 55 | error 56 | 60 | 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /docs/examples/thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@rc-component/image'; 2 | import * as React from 'react'; 3 | import '../../assets/index.less'; 4 | import { defaultIcons } from './common'; 5 | 6 | export default function Thumbnail() { 7 | return ( 8 |
9 | 17 | 18 |
19 |

PreviewGroup

20 | 21 | 29 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: rc-image 4 | description: React Image. 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "name": "rc-image", 4 | "builds": [ 5 | { 6 | "src": "package.json", 7 | "use": "@now/static-build", 8 | "config": { "distDir": "dist" } 9 | } 10 | ], 11 | "routes": [{ "src": "/(.*)", "dest": "/dist/$1" }] 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rc-component/image", 3 | "version": "1.4.0", 4 | "description": "React easy to use image component", 5 | "keywords": [ 6 | "react", 7 | "react-component", 8 | "react-image", 9 | "image" 10 | ], 11 | "homepage": "http://github.com/react-component/image", 12 | "bugs": { 13 | "url": "http://github.com/react-component/image/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git@github.com:react-component/image.git" 18 | }, 19 | "license": "MIT", 20 | "main": "./lib/index", 21 | "module": "./es/index", 22 | "types": "./lib/index.d.ts", 23 | "files": [ 24 | "assets/*.css", 25 | "es", 26 | "lib" 27 | ], 28 | "scripts": { 29 | "compile": "father build && lessc assets/index.less assets/index.css", 30 | "coverage": "rc-test --coverage", 31 | "docs:build": "dumi build", 32 | "docs:deploy": "gh-pages -d docs-dist", 33 | "lint": "eslint src/ --ext .ts,.tsx,.jsx,.js,.md", 34 | "now-build": "npm run docs:build", 35 | "prepublishOnly": "npm run compile && rc-np", 36 | "prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", 37 | "start": "dumi dev", 38 | "test": "rc-test", 39 | "tsc": "bunx tsc --noEmit" 40 | }, 41 | "dependencies": { 42 | "@rc-component/portal": "^2.0.0", 43 | "@rc-component/motion": "^1.0.0", 44 | "@rc-component/util": "^1.0.0", 45 | "classnames": "^2.2.6" 46 | }, 47 | "devDependencies": { 48 | "@ant-design/icons": "^5.0.1", 49 | "@rc-component/father-plugin": "^2.0.2", 50 | "@rc-component/np": "^1.0.0", 51 | "@testing-library/jest-dom": "^6.4.0", 52 | "@testing-library/react": "^15.0.6", 53 | "@types/classnames": "^2.2.10", 54 | "@types/jest": "^29.5.11", 55 | "@types/react": "^18.0.0", 56 | "@types/react-dom": "^18.0.0", 57 | "@umijs/fabric": "^4.0.1", 58 | "cross-env": "^7.0.2", 59 | "dumi": "^2.1.4", 60 | "eslint": "^8.57.0", 61 | "father": "^4.0.0", 62 | "glob": "^7.1.6", 63 | "less": "^4.1.3", 64 | "rc-test": "^7.0.3", 65 | "react": "^18.0.0", 66 | "react-dom": "^18.0.0", 67 | "typescript": "^5.3.3" 68 | }, 69 | "peerDependencies": { 70 | "react": ">=16.9.0", 71 | "react-dom": ">=16.9.0" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Image.tsx: -------------------------------------------------------------------------------- 1 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState'; 2 | import classnames from 'classnames'; 3 | import * as React from 'react'; 4 | import { useContext, useMemo, useState } from 'react'; 5 | import type { InternalPreviewConfig, PreviewSemanticName, ToolbarRenderInfoType } from './Preview'; 6 | import Preview from './Preview'; 7 | import PreviewGroup from './PreviewGroup'; 8 | import { COMMON_PROPS } from './common'; 9 | import { PreviewGroupContext } from './context'; 10 | import type { TransformType } from './hooks/useImageTransform'; 11 | import useRegisterImage from './hooks/useRegisterImage'; 12 | import useStatus from './hooks/useStatus'; 13 | import type { ImageElementProps } from './interface'; 14 | 15 | export interface ImgInfo { 16 | url: string; 17 | alt: string; 18 | width: string | number; 19 | height: string | number; 20 | } 21 | 22 | export interface PreviewConfig extends Omit { 23 | cover?: React.ReactNode; 24 | 25 | // Similar to InternalPreviewConfig but not have `current` 26 | imageRender?: ( 27 | originalNode: React.ReactElement, 28 | info: { transform: TransformType; image: ImgInfo }, 29 | ) => React.ReactNode; 30 | 31 | // Similar to InternalPreviewConfig but not have `current` and `total` 32 | actionsRender?: ( 33 | originalNode: React.ReactElement, 34 | info: Omit, 35 | ) => React.ReactNode; 36 | 37 | onOpenChange?: (open: boolean) => void; 38 | } 39 | 40 | export type SemanticName = 'root' | 'image' | 'cover'; 41 | 42 | export interface ImageProps 43 | extends Omit, 'placeholder' | 'onClick'> { 44 | // Misc 45 | prefixCls?: string; 46 | previewPrefixCls?: string; 47 | 48 | // Styles 49 | rootClassName?: string; 50 | classNames?: Partial< 51 | Record & { 52 | popup?: Partial>; 53 | } 54 | >; 55 | styles?: Partial< 56 | Record & { 57 | popup?: Partial>; 58 | } 59 | >; 60 | 61 | // Image 62 | src?: string; 63 | placeholder?: React.ReactNode; 64 | fallback?: string; 65 | 66 | // Preview 67 | preview?: boolean | PreviewConfig; 68 | 69 | // Events 70 | onClick?: (e: React.MouseEvent) => void; 71 | onError?: (e: React.SyntheticEvent) => void; 72 | } 73 | 74 | interface CompoundedComponent

extends React.FC

{ 75 | PreviewGroup: typeof PreviewGroup; 76 | } 77 | 78 | const ImageInternal: CompoundedComponent = props => { 79 | const { 80 | // Misc 81 | prefixCls = 'rc-image', 82 | previewPrefixCls = `${prefixCls}-preview`, 83 | 84 | // Style 85 | rootClassName, 86 | className, 87 | style, 88 | 89 | classNames = {}, 90 | styles = {}, 91 | 92 | width, 93 | height, 94 | 95 | // Image 96 | src: imgSrc, 97 | alt, 98 | placeholder, 99 | fallback, 100 | 101 | // Preview 102 | preview = true, 103 | 104 | // Events 105 | onClick, 106 | onError, 107 | ...otherProps 108 | } = props; 109 | 110 | const groupContext = useContext(PreviewGroupContext); 111 | 112 | // ========================== Preview =========================== 113 | const canPreview = !!preview; 114 | 115 | const { 116 | src: previewSrc, 117 | open: previewOpen, 118 | onOpenChange: onPreviewOpenChange, 119 | cover, 120 | rootClassName: previewRootClassName, 121 | ...restProps 122 | }: PreviewConfig = preview && typeof preview === 'object' ? preview : {}; 123 | 124 | // ============================ Open ============================ 125 | const [isShowPreview, setShowPreview] = useMergedState(!!previewOpen, { 126 | value: previewOpen, 127 | }); 128 | 129 | const [mousePosition, setMousePosition] = useState(null); 130 | 131 | const triggerPreviewOpen = (nextOpen: boolean) => { 132 | setShowPreview(nextOpen); 133 | onPreviewOpenChange?.(nextOpen); 134 | }; 135 | 136 | const onPreviewClose = () => { 137 | triggerPreviewOpen(false); 138 | }; 139 | 140 | // ========================= ImageProps ========================= 141 | const isCustomPlaceholder = placeholder && placeholder !== true; 142 | 143 | const src = previewSrc ?? imgSrc; 144 | const [getImgRef, srcAndOnload, status] = useStatus({ 145 | src: imgSrc, 146 | isCustomPlaceholder, 147 | fallback, 148 | }); 149 | 150 | const imgCommonProps = useMemo( 151 | () => { 152 | const obj: ImageElementProps = {}; 153 | COMMON_PROPS.forEach((prop: any) => { 154 | if (props[prop] !== undefined) { 155 | obj[prop] = props[prop]; 156 | } 157 | }); 158 | 159 | return obj; 160 | }, 161 | COMMON_PROPS.map(prop => props[prop]), 162 | ); 163 | 164 | // ========================== Register ========================== 165 | const registerData: ImageElementProps = useMemo( 166 | () => ({ 167 | ...imgCommonProps, 168 | src, 169 | }), 170 | [src, imgCommonProps], 171 | ); 172 | 173 | const imageId = useRegisterImage(canPreview, registerData); 174 | 175 | // ========================== Preview =========================== 176 | const onPreview: React.MouseEventHandler = e => { 177 | const rect = (e.target as HTMLDivElement).getBoundingClientRect(); 178 | const left = rect.x + rect.width / 2; 179 | const top = rect.y + rect.height / 2; 180 | 181 | if (groupContext) { 182 | groupContext.onPreview(imageId, src, left, top); 183 | } else { 184 | setMousePosition({ 185 | x: left, 186 | y: top, 187 | }); 188 | triggerPreviewOpen(true); 189 | } 190 | 191 | onClick?.(e); 192 | }; 193 | 194 | // =========================== Render =========================== 195 | return ( 196 | <> 197 |

209 | 230 | 231 | {status === 'loading' && ( 232 | 235 | )} 236 | 237 | {/* Preview Click Mask */} 238 | {cover !== false && canPreview && ( 239 |
246 | {cover} 247 |
248 | )} 249 |
250 | {!groupContext && canPreview && ( 251 | 267 | )} 268 | 269 | ); 270 | }; 271 | 272 | ImageInternal.PreviewGroup = PreviewGroup; 273 | 274 | if (process.env.NODE_ENV !== 'production') { 275 | ImageInternal.displayName = 'Image'; 276 | } 277 | 278 | export default ImageInternal; 279 | -------------------------------------------------------------------------------- /src/Preview/CloseBtn.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface CloseBtnProps { 4 | prefixCls: string; 5 | icon?: React.ReactNode; 6 | onClick: React.MouseEventHandler; 7 | } 8 | 9 | export default function CloseBtn(props: CloseBtnProps) { 10 | const { prefixCls, icon, onClick } = props; 11 | 12 | return ( 13 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/Preview/Footer.tsx: -------------------------------------------------------------------------------- 1 | import classnames from 'classnames'; 2 | import * as React from 'react'; 3 | import type { Actions, PreviewProps } from '.'; 4 | import type { ImgInfo } from '../Image'; 5 | import type { TransformType } from '../hooks/useImageTransform'; 6 | 7 | export type FooterSemanticName = 'footer' | 'actions'; 8 | 9 | type OperationType = 10 | | 'prev' 11 | | 'next' 12 | | 'flipY' 13 | | 'flipX' 14 | | 'rotateLeft' 15 | | 'rotateRight' 16 | | 'zoomOut' 17 | | 'zoomIn'; 18 | 19 | interface RenderOperationParams { 20 | icon: React.ReactNode; 21 | type: OperationType; 22 | disabled?: boolean; 23 | onClick: (e: React.MouseEvent) => void; 24 | } 25 | 26 | export interface FooterProps extends Actions { 27 | prefixCls: string; 28 | showProgress: boolean; 29 | countRender?: PreviewProps['countRender']; 30 | actionsRender?: PreviewProps['actionsRender']; 31 | current: number; 32 | count: number; 33 | showSwitch: boolean; 34 | icons: PreviewProps['icons']; 35 | scale: number; 36 | minScale: number; 37 | maxScale: number; 38 | image: ImgInfo; 39 | transform: TransformType; 40 | 41 | // Style 42 | classNames: Partial>; 43 | styles: Partial>; 44 | } 45 | 46 | export default function Footer(props: FooterProps) { 47 | // 修改解构,添加缺失的属性,并提供默认值 48 | const { 49 | prefixCls, 50 | showProgress, 51 | current, 52 | count, 53 | showSwitch, 54 | 55 | // Style 56 | classNames, 57 | styles, 58 | 59 | // render 60 | icons, 61 | image, 62 | transform, 63 | countRender, 64 | actionsRender, 65 | 66 | // Scale 67 | scale, 68 | minScale, 69 | maxScale, 70 | 71 | // Actions 72 | onActive, 73 | onFlipY, 74 | onFlipX, 75 | onRotateLeft, 76 | onRotateRight, 77 | onZoomOut, 78 | onZoomIn, 79 | onClose, 80 | onReset, 81 | } = props; 82 | 83 | const { left, right, prev, next, flipY, flipX, rotateLeft, rotateRight, zoomOut, zoomIn } = icons; 84 | 85 | // ========================== Render ========================== 86 | // >>>>> Progress 87 | const progressNode = showProgress && ( 88 |
89 | {countRender ? countRender(current + 1, count) : {`${current + 1} / ${count}`}} 90 |
91 | ); 92 | 93 | // >>>>> Actions 94 | const actionCls = `${prefixCls}-actions-action`; 95 | 96 | const renderOperation = ({ type, disabled, onClick, icon }: RenderOperationParams) => { 97 | return ( 98 |
105 | {icon} 106 |
107 | ); 108 | }; 109 | 110 | const switchPrevNode = showSwitch 111 | ? renderOperation({ 112 | icon: prev ?? left, 113 | onClick: () => onActive(-1), 114 | type: 'prev', 115 | disabled: current === 0, 116 | }) 117 | : undefined; 118 | 119 | const switchNextNode = showSwitch 120 | ? renderOperation({ 121 | icon: next ?? right, 122 | onClick: () => onActive(1), 123 | type: 'next', 124 | disabled: current === count - 1, 125 | }) 126 | : undefined; 127 | 128 | const flipYNode = renderOperation({ 129 | icon: flipY, 130 | onClick: onFlipY, 131 | type: 'flipY', 132 | }); 133 | 134 | const flipXNode = renderOperation({ 135 | icon: flipX, 136 | onClick: onFlipX, 137 | type: 'flipX', 138 | }); 139 | 140 | const rotateLeftNode = renderOperation({ 141 | icon: rotateLeft, 142 | onClick: onRotateLeft, 143 | type: 'rotateLeft', 144 | }); 145 | 146 | const rotateRightNode = renderOperation({ 147 | icon: rotateRight, 148 | onClick: onRotateRight, 149 | type: 'rotateRight', 150 | }); 151 | 152 | const zoomOutNode = renderOperation({ 153 | icon: zoomOut, 154 | onClick: onZoomOut, 155 | type: 'zoomOut', 156 | disabled: scale <= minScale, 157 | }); 158 | 159 | const zoomInNode = renderOperation({ 160 | icon: zoomIn, 161 | onClick: onZoomIn, 162 | type: 'zoomIn', 163 | disabled: scale === maxScale, 164 | }); 165 | 166 | const actionsNode = ( 167 |
168 | {flipYNode} 169 | {flipXNode} 170 | {rotateLeftNode} 171 | {rotateRightNode} 172 | {zoomOutNode} 173 | {zoomInNode} 174 |
175 | ); 176 | 177 | // >>>>> Render 178 | return ( 179 |
180 | {progressNode} 181 | {actionsRender 182 | ? actionsRender(actionsNode, { 183 | icons: { 184 | prevIcon: switchPrevNode, 185 | nextIcon: switchNextNode, 186 | flipYIcon: flipYNode, 187 | flipXIcon: flipXNode, 188 | rotateLeftIcon: rotateLeftNode, 189 | rotateRightIcon: rotateRightNode, 190 | zoomOutIcon: zoomOutNode, 191 | zoomInIcon: zoomInNode, 192 | }, 193 | actions: { 194 | onActive, 195 | onFlipY, 196 | onFlipX, 197 | onRotateLeft, 198 | onRotateRight, 199 | onZoomOut, 200 | onZoomIn, 201 | onReset, 202 | onClose, 203 | }, 204 | transform, 205 | current, 206 | total: count, 207 | image, 208 | }) 209 | : actionsNode} 210 |
211 | ); 212 | } 213 | -------------------------------------------------------------------------------- /src/Preview/PrevNext.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import * as React from 'react'; 3 | import type { OperationIcons } from '.'; 4 | 5 | export interface PrevNextProps { 6 | prefixCls: string; 7 | onActive: (offset: number) => void; 8 | current: number; 9 | count: number; 10 | icons: OperationIcons; 11 | } 12 | 13 | export default function PrevNext(props: PrevNextProps) { 14 | const { 15 | prefixCls, 16 | onActive, 17 | current, 18 | count, 19 | icons: { left, right, prev, next }, 20 | } = props; 21 | 22 | const switchCls = `${prefixCls}-switch`; 23 | 24 | return ( 25 | <> 26 |
onActive(-1)} 31 | > 32 | {prev ?? left} 33 |
34 |
onActive(1)} 39 | > 40 | {next ?? right} 41 |
42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/Preview/index.tsx: -------------------------------------------------------------------------------- 1 | import CSSMotion from '@rc-component/motion'; 2 | import Portal, { type PortalProps } from '@rc-component/portal'; 3 | import { useEvent } from '@rc-component/util'; 4 | import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; 5 | import KeyCode from '@rc-component/util/lib/KeyCode'; 6 | import classnames from 'classnames'; 7 | import React, { useContext, useEffect, useRef, useState } from 'react'; 8 | import { PreviewGroupContext } from '../context'; 9 | import type { TransformAction, TransformType } from '../hooks/useImageTransform'; 10 | import useImageTransform from '../hooks/useImageTransform'; 11 | import useMouseEvent from '../hooks/useMouseEvent'; 12 | import useStatus from '../hooks/useStatus'; 13 | import useTouchEvent from '../hooks/useTouchEvent'; 14 | import type { ImgInfo } from '../Image'; 15 | import { BASE_SCALE_RATIO } from '../previewConfig'; 16 | import CloseBtn from './CloseBtn'; 17 | import Footer, { type FooterSemanticName } from './Footer'; 18 | import PrevNext from './PrevNext'; 19 | 20 | // Note: if you want to add `action`, 21 | // pls contact @zombieJ or @thinkasany first. 22 | export type PreviewSemanticName = 'root' | 'mask' | 'body' | FooterSemanticName; 23 | 24 | export interface OperationIcons { 25 | rotateLeft?: React.ReactNode; 26 | rotateRight?: React.ReactNode; 27 | zoomIn?: React.ReactNode; 28 | zoomOut?: React.ReactNode; 29 | close?: React.ReactNode; 30 | prev?: React.ReactNode; 31 | next?: React.ReactNode; 32 | /** @deprecated Please use `prev` instead */ 33 | left?: React.ReactNode; 34 | /** @deprecated Please use `next` instead */ 35 | right?: React.ReactNode; 36 | flipX?: React.ReactNode; 37 | flipY?: React.ReactNode; 38 | } 39 | 40 | export interface Actions { 41 | onActive: (offset: number) => void; 42 | onFlipY: () => void; 43 | onFlipX: () => void; 44 | onRotateLeft: () => void; 45 | onRotateRight: () => void; 46 | onZoomOut: () => void; 47 | onZoomIn: () => void; 48 | onClose: () => void; 49 | onReset: () => void; 50 | } 51 | 52 | export type ToolbarRenderInfoType = { 53 | icons: { 54 | prevIcon?: React.ReactNode; 55 | nextIcon?: React.ReactNode; 56 | flipYIcon: React.ReactNode; 57 | flipXIcon: React.ReactNode; 58 | rotateLeftIcon: React.ReactNode; 59 | rotateRightIcon: React.ReactNode; 60 | zoomOutIcon: React.ReactNode; 61 | zoomInIcon: React.ReactNode; 62 | }; 63 | actions: Actions; 64 | transform: TransformType; 65 | current: number; 66 | total: number; 67 | image: ImgInfo; 68 | }; 69 | 70 | export interface InternalPreviewConfig { 71 | // Semantic 72 | /** Better to use `classNames.root` instead */ 73 | rootClassName?: string; 74 | 75 | // Image 76 | src?: string; 77 | alt?: string; 78 | 79 | // Scale 80 | scaleStep?: number; 81 | minScale?: number; 82 | maxScale?: number; 83 | 84 | // Display 85 | motionName?: string; 86 | open?: boolean; 87 | getContainer?: PortalProps['getContainer']; 88 | zIndex?: number; 89 | afterOpenChange?: (open: boolean) => void; 90 | 91 | // Operation 92 | movable?: boolean; 93 | icons?: OperationIcons; 94 | closeIcon?: React.ReactNode; 95 | 96 | onTransform?: (info: { transform: TransformType; action: TransformAction }) => void; 97 | 98 | // Render 99 | countRender?: (current: number, total: number) => React.ReactNode; 100 | imageRender?: ( 101 | originalNode: React.ReactElement, 102 | info: { transform: TransformType; current?: number; image: ImgInfo }, 103 | ) => React.ReactNode; 104 | actionsRender?: ( 105 | originalNode: React.ReactElement, 106 | info: ToolbarRenderInfoType, 107 | ) => React.ReactNode; 108 | } 109 | 110 | export interface PreviewProps extends InternalPreviewConfig { 111 | // Misc 112 | prefixCls: string; 113 | 114 | classNames?: Partial>; 115 | styles?: Partial>; 116 | 117 | // Origin image Info 118 | imageInfo?: { 119 | width: number | string; 120 | height: number | string; 121 | }; 122 | fallback?: string; 123 | 124 | // Preview image 125 | imgCommonProps?: React.ImgHTMLAttributes; 126 | width?: string | number; 127 | height?: string | number; 128 | 129 | // Pagination 130 | current?: number; 131 | count?: number; 132 | onChange?: (current: number, prev: number) => void; 133 | 134 | // Events 135 | onClose?: () => void; 136 | 137 | // Display 138 | mousePosition: null | { x: number; y: number }; 139 | } 140 | 141 | interface PreviewImageProps extends React.ImgHTMLAttributes { 142 | fallback?: string; 143 | imgRef: React.MutableRefObject; 144 | } 145 | 146 | const PreviewImage: React.FC = ({ fallback, src, imgRef, ...props }) => { 147 | const [getImgRef, srcAndOnload] = useStatus({ 148 | src, 149 | fallback, 150 | }); 151 | 152 | return ( 153 | { 155 | imgRef.current = ref; 156 | getImgRef(ref); 157 | }} 158 | {...props} 159 | {...srcAndOnload} 160 | /> 161 | ); 162 | }; 163 | 164 | const Preview: React.FC = props => { 165 | const { 166 | prefixCls, 167 | rootClassName, 168 | src, 169 | alt, 170 | imageInfo, 171 | fallback, 172 | movable = true, 173 | onClose, 174 | open, 175 | afterOpenChange, 176 | icons = {}, 177 | closeIcon, 178 | getContainer, 179 | current = 0, 180 | count = 1, 181 | countRender, 182 | scaleStep = 0.5, 183 | minScale = 1, 184 | maxScale = 50, 185 | motionName = 'fade', 186 | imageRender, 187 | imgCommonProps, 188 | actionsRender, 189 | onTransform, 190 | onChange, 191 | classNames = {}, 192 | styles = {}, 193 | mousePosition, 194 | zIndex, 195 | } = props; 196 | 197 | const imgRef = useRef(); 198 | const groupContext = useContext(PreviewGroupContext); 199 | const showLeftOrRightSwitches = groupContext && count > 1; 200 | const showOperationsProgress = groupContext && count >= 1; 201 | 202 | // ======================== Transform ========================= 203 | const [enableTransition, setEnableTransition] = useState(true); 204 | const { transform, resetTransform, updateTransform, dispatchZoomChange } = useImageTransform( 205 | imgRef, 206 | minScale, 207 | maxScale, 208 | onTransform, 209 | ); 210 | const { isMoving, onMouseDown, onWheel } = useMouseEvent( 211 | imgRef, 212 | movable, 213 | open, 214 | scaleStep, 215 | transform, 216 | updateTransform, 217 | dispatchZoomChange, 218 | ); 219 | const { isTouching, onTouchStart, onTouchMove, onTouchEnd } = useTouchEvent( 220 | imgRef, 221 | movable, 222 | open, 223 | minScale, 224 | transform, 225 | updateTransform, 226 | dispatchZoomChange, 227 | ); 228 | const { rotate, scale } = transform; 229 | 230 | useEffect(() => { 231 | if (!enableTransition) { 232 | setEnableTransition(true); 233 | } 234 | }, [enableTransition]); 235 | 236 | useEffect(() => { 237 | if (!open) { 238 | resetTransform('close'); 239 | } 240 | }, [open]); 241 | 242 | // ========================== Image =========================== 243 | const onDoubleClick = (event: React.MouseEvent) => { 244 | if (open) { 245 | if (scale !== 1) { 246 | updateTransform({ x: 0, y: 0, scale: 1 }, 'doubleClick'); 247 | } else { 248 | dispatchZoomChange( 249 | BASE_SCALE_RATIO + scaleStep, 250 | 'doubleClick', 251 | event.clientX, 252 | event.clientY, 253 | ); 254 | } 255 | } 256 | }; 257 | 258 | const imgNode = ( 259 | 282 | ); 283 | 284 | const image = { 285 | url: src, 286 | alt, 287 | ...imageInfo, 288 | }; 289 | 290 | // ======================== Operation ========================= 291 | // >>>>> Actions 292 | const onZoomIn = () => { 293 | dispatchZoomChange(BASE_SCALE_RATIO + scaleStep, 'zoomIn'); 294 | }; 295 | 296 | const onZoomOut = () => { 297 | dispatchZoomChange(BASE_SCALE_RATIO / (BASE_SCALE_RATIO + scaleStep), 'zoomOut'); 298 | }; 299 | 300 | const onRotateRight = () => { 301 | updateTransform({ rotate: rotate + 90 }, 'rotateRight'); 302 | }; 303 | 304 | const onRotateLeft = () => { 305 | updateTransform({ rotate: rotate - 90 }, 'rotateLeft'); 306 | }; 307 | 308 | const onFlipX = () => { 309 | updateTransform({ flipX: !transform.flipX }, 'flipX'); 310 | }; 311 | 312 | const onFlipY = () => { 313 | updateTransform({ flipY: !transform.flipY }, 'flipY'); 314 | }; 315 | 316 | const onReset = () => { 317 | resetTransform('reset'); 318 | }; 319 | 320 | const onActive = (offset: number) => { 321 | const nextCurrent = current + offset; 322 | 323 | if (nextCurrent >= 0 && nextCurrent <= count - 1) { 324 | setEnableTransition(false); 325 | resetTransform(offset < 0 ? 'prev' : 'next'); 326 | onChange?.(nextCurrent, current); 327 | } 328 | }; 329 | 330 | // >>>>> Effect: Keyboard 331 | const onKeyDown = useEvent((event: KeyboardEvent) => { 332 | if (open) { 333 | const { keyCode } = event; 334 | 335 | if (keyCode === KeyCode.ESC) { 336 | onClose?.(); 337 | } 338 | 339 | if (showLeftOrRightSwitches) { 340 | if (keyCode === KeyCode.LEFT) { 341 | onActive(-1); 342 | } else if (keyCode === KeyCode.RIGHT) { 343 | onActive(1); 344 | } 345 | } 346 | } 347 | }); 348 | 349 | useEffect(() => { 350 | if (open) { 351 | window.addEventListener('keydown', onKeyDown); 352 | 353 | return () => { 354 | window.removeEventListener('keydown', onKeyDown); 355 | }; 356 | } 357 | }, [open]); 358 | 359 | // ======================= Lock Scroll ======================== 360 | const [lockScroll, setLockScroll] = useState(false); 361 | 362 | React.useEffect(() => { 363 | if (open) { 364 | setLockScroll(true); 365 | } 366 | }, [open]); 367 | 368 | const onVisibleChanged = (nextVisible: boolean) => { 369 | if (!nextVisible) { 370 | setLockScroll(false); 371 | } 372 | afterOpenChange?.(nextVisible); 373 | }; 374 | 375 | // ========================== Portal ========================== 376 | const [portalRender, setPortalRender] = useState(false); 377 | useLayoutEffect(() => { 378 | if (open) { 379 | setPortalRender(true); 380 | } 381 | }, [open]); 382 | 383 | // ========================== Render ========================== 384 | const bodyStyle: React.CSSProperties = { 385 | ...styles.body, 386 | }; 387 | if (mousePosition) { 388 | bodyStyle.transformOrigin = `${mousePosition.x}px ${mousePosition.y}px`; 389 | } 390 | 391 | return ( 392 | 393 | 401 | {({ className: motionClassName, style: motionStyle }) => { 402 | const mergedStyle = { 403 | ...styles.root, 404 | ...motionStyle, 405 | }; 406 | 407 | if (zIndex) { 408 | mergedStyle.zIndex = zIndex; 409 | } 410 | 411 | return ( 412 |
418 | {/* Mask */} 419 |
424 | 425 | {/* Body */} 426 |
427 | {/* Preview Image */} 428 | {imageRender 429 | ? imageRender(imgNode, { 430 | transform, 431 | image, 432 | ...(groupContext ? { current } : {}), 433 | }) 434 | : imgNode} 435 |
436 | 437 | {/* Close Button */} 438 | {closeIcon !== false && closeIcon !== null && ( 439 | 444 | )} 445 | 446 | {/* Switch prev or next */} 447 | {showLeftOrRightSwitches && ( 448 | 455 | )} 456 | 457 | {/* Footer */} 458 |
488 |
489 | ); 490 | }} 491 | 492 | 493 | ); 494 | }; 495 | 496 | export default Preview; 497 | -------------------------------------------------------------------------------- /src/PreviewGroup.tsx: -------------------------------------------------------------------------------- 1 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState'; 2 | import * as React from 'react'; 3 | import { useState } from 'react'; 4 | import type { ImgInfo } from './Image'; 5 | import type { InternalPreviewConfig, PreviewProps, PreviewSemanticName } from './Preview'; 6 | import Preview from './Preview'; 7 | import { PreviewGroupContext } from './context'; 8 | import type { TransformType } from './hooks/useImageTransform'; 9 | import usePreviewItems from './hooks/usePreviewItems'; 10 | import type { ImageElementProps, OnGroupPreview } from './interface'; 11 | 12 | export interface GroupPreviewConfig extends InternalPreviewConfig { 13 | current?: number; 14 | // Similar to InternalPreviewConfig but has additional current 15 | imageRender?: ( 16 | originalNode: React.ReactElement, 17 | info: { transform: TransformType; current: number; image: ImgInfo }, 18 | ) => React.ReactNode; 19 | onOpenChange?: (value: boolean, info: { current: number }) => void; 20 | onChange?: (current: number, prevCurrent: number) => void; 21 | } 22 | 23 | export interface PreviewGroupProps { 24 | previewPrefixCls?: string; 25 | classNames?: { 26 | popup?: Partial>; 27 | }; 28 | 29 | styles?: { 30 | popup?: Partial>; 31 | }; 32 | 33 | icons?: PreviewProps['icons']; 34 | items?: (string | ImageElementProps)[]; 35 | fallback?: string; 36 | preview?: boolean | GroupPreviewConfig; 37 | children?: React.ReactNode; 38 | } 39 | 40 | const Group: React.FC = ({ 41 | previewPrefixCls = 'rc-image-preview', 42 | classNames, 43 | styles, 44 | children, 45 | icons = {}, 46 | items, 47 | preview, 48 | fallback, 49 | }) => { 50 | const { 51 | open: previewOpen, 52 | onOpenChange, 53 | current: currentIndex, 54 | onChange, 55 | ...restProps 56 | } = preview && typeof preview === 'object' ? preview : ({} as GroupPreviewConfig); 57 | 58 | // ========================== Items =========================== 59 | const [mergedItems, register, fromItems] = usePreviewItems(items); 60 | 61 | // ========================= Preview ========================== 62 | // >>> Index 63 | const [current, setCurrent] = useMergedState(0, { 64 | value: currentIndex, 65 | }); 66 | 67 | const [keepOpenIndex, setKeepOpenIndex] = useState(false); 68 | 69 | // >>> Image 70 | const { src, ...imgCommonProps } = mergedItems[current]?.data || {}; 71 | // >>> Visible 72 | const [isShowPreview, setShowPreview] = useMergedState(!!previewOpen, { 73 | value: previewOpen, 74 | onChange: val => { 75 | onOpenChange?.(val, { current }); 76 | }, 77 | }); 78 | 79 | // >>> Position 80 | const [mousePosition, setMousePosition] = useState(null); 81 | 82 | const onPreviewFromImage = React.useCallback( 83 | (id, imageSrc, mouseX, mouseY) => { 84 | const index = fromItems 85 | ? mergedItems.findIndex(item => item.data.src === imageSrc) 86 | : mergedItems.findIndex(item => item.id === id); 87 | 88 | setCurrent(index < 0 ? 0 : index); 89 | 90 | setShowPreview(true); 91 | setMousePosition({ x: mouseX, y: mouseY }); 92 | 93 | setKeepOpenIndex(true); 94 | }, 95 | [mergedItems, fromItems], 96 | ); 97 | 98 | // Reset current when reopen 99 | React.useEffect(() => { 100 | if (isShowPreview) { 101 | if (!keepOpenIndex) { 102 | setCurrent(0); 103 | } 104 | } else { 105 | setKeepOpenIndex(false); 106 | } 107 | }, [isShowPreview]); 108 | 109 | // ========================== Events ========================== 110 | const onInternalChange: GroupPreviewConfig['onChange'] = (next, prev) => { 111 | setCurrent(next); 112 | 113 | onChange?.(next, prev); 114 | }; 115 | 116 | const onPreviewClose = () => { 117 | setShowPreview(false); 118 | setMousePosition(null); 119 | }; 120 | 121 | // ========================= Context ========================== 122 | const previewGroupContext = React.useMemo( 123 | () => ({ register, onPreview: onPreviewFromImage }), 124 | [register, onPreviewFromImage], 125 | ); 126 | 127 | // ========================== Render ========================== 128 | return ( 129 | 130 | {children} 131 | 148 | 149 | ); 150 | }; 151 | 152 | export default Group; 153 | -------------------------------------------------------------------------------- /src/common.ts: -------------------------------------------------------------------------------- 1 | import type { ImageElementProps } from './interface'; 2 | 3 | export const COMMON_PROPS: (keyof Omit)[] = [ 4 | 'crossOrigin', 5 | 'decoding', 6 | 'draggable', 7 | 'loading', 8 | 'referrerPolicy', 9 | 'sizes', 10 | 'srcSet', 11 | 'useMap', 12 | 'alt', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { OnGroupPreview, RegisterImage } from './interface'; 3 | 4 | export interface PreviewGroupContextProps { 5 | register: RegisterImage; 6 | onPreview: OnGroupPreview; 7 | } 8 | 9 | export const PreviewGroupContext = React.createContext(null); 10 | -------------------------------------------------------------------------------- /src/getFixScaleEleTransPosition.ts: -------------------------------------------------------------------------------- 1 | import { getClientSize } from "./util"; 2 | 3 | function fixPoint(key: 'x' | 'y', start: number, width: number, clientWidth: number) { 4 | const startAddWidth = start + width; 5 | const offsetStart = (width - clientWidth) / 2; 6 | 7 | if (width > clientWidth) { 8 | if (start > 0) { 9 | return { 10 | [key]: offsetStart, 11 | }; 12 | } 13 | if (start < 0 && startAddWidth < clientWidth) { 14 | return { 15 | [key]: -offsetStart, 16 | }; 17 | } 18 | } else if (start < 0 || startAddWidth > clientWidth) { 19 | return { 20 | [key]: start < 0 ? offsetStart : -offsetStart, 21 | }; 22 | } 23 | return {}; 24 | } 25 | 26 | /** 27 | * Fix positon x,y point when 28 | * 29 | * Ele width && height < client 30 | * - Back origin 31 | * 32 | * - Ele width | height > clientWidth | clientHeight 33 | * - left | top > 0 -> Back 0 34 | * - left | top + width | height < clientWidth | clientHeight -> Back left | top + width | height === clientWidth | clientHeight 35 | * 36 | * Regardless of other 37 | */ 38 | export default function getFixScaleEleTransPosition( 39 | width: number, 40 | height: number, 41 | left: number, 42 | top: number, 43 | ): null | { x: number; y: number } { 44 | const { width: clientWidth, height: clientHeight } = getClientSize(); 45 | 46 | let fixPos = null; 47 | 48 | if (width <= clientWidth && height <= clientHeight) { 49 | fixPos = { 50 | x: 0, 51 | y: 0, 52 | }; 53 | } else if (width > clientWidth || height > clientHeight) { 54 | fixPos = { 55 | ...fixPoint('x', left, width, clientWidth), 56 | ...fixPoint('y', top, height, clientHeight), 57 | }; 58 | } 59 | 60 | return fixPos; 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/useImageTransform.ts: -------------------------------------------------------------------------------- 1 | import { getClientSize } from '../util'; 2 | import isEqual from '@rc-component/util/lib/isEqual'; 3 | import raf from '@rc-component/util/lib/raf'; 4 | import { useRef, useState } from 'react'; 5 | 6 | export type TransformType = { 7 | x: number; 8 | y: number; 9 | rotate: number; 10 | scale: number; 11 | flipX: boolean; 12 | flipY: boolean; 13 | }; 14 | 15 | export type TransformAction = 16 | | 'flipY' 17 | | 'flipX' 18 | | 'rotateLeft' 19 | | 'rotateRight' 20 | | 'zoomIn' 21 | | 'zoomOut' 22 | | 'close' 23 | | 'prev' 24 | | 'next' 25 | | 'wheel' 26 | | 'doubleClick' 27 | | 'move' 28 | | 'dragRebound' 29 | | 'touchZoom' 30 | | 'reset'; 31 | 32 | export type UpdateTransformFunc = ( 33 | newTransform: Partial, 34 | action: TransformAction, 35 | ) => void; 36 | 37 | export type DispatchZoomChangeFunc = ( 38 | ratio: number, 39 | action: TransformAction, 40 | centerX?: number, 41 | centerY?: number, 42 | isTouch?: boolean, 43 | ) => void; 44 | 45 | const initialTransform = { 46 | x: 0, 47 | y: 0, 48 | rotate: 0, 49 | scale: 1, 50 | flipX: false, 51 | flipY: false, 52 | }; 53 | 54 | export default function useImageTransform( 55 | imgRef: React.MutableRefObject, 56 | minScale: number, 57 | maxScale: number, 58 | onTransform: (info: { transform: TransformType; action: TransformAction }) => void, 59 | ) { 60 | const frame = useRef(null); 61 | const queue = useRef([]); 62 | const [transform, setTransform] = useState(initialTransform); 63 | 64 | const resetTransform = (action: TransformAction) => { 65 | setTransform(initialTransform); 66 | if (!isEqual(initialTransform, transform)) { 67 | onTransform?.({ transform: initialTransform, action }); 68 | } 69 | }; 70 | 71 | /** Direct update transform */ 72 | const updateTransform: UpdateTransformFunc = (newTransform, action) => { 73 | if (frame.current === null) { 74 | queue.current = []; 75 | frame.current = raf(() => { 76 | setTransform(preState => { 77 | let memoState: any = preState; 78 | queue.current.forEach(queueState => { 79 | memoState = { ...memoState, ...queueState }; 80 | }); 81 | frame.current = null; 82 | 83 | onTransform?.({ transform: memoState, action }); 84 | return memoState; 85 | }); 86 | }); 87 | } 88 | queue.current.push({ 89 | ...transform, 90 | ...newTransform, 91 | }); 92 | }; 93 | 94 | /** Scale according to the position of centerX and centerY */ 95 | const dispatchZoomChange: DispatchZoomChangeFunc = ( 96 | ratio, 97 | action, 98 | centerX?, 99 | centerY?, 100 | isTouch?, 101 | ) => { 102 | const { width, height, offsetWidth, offsetHeight, offsetLeft, offsetTop } = imgRef.current; 103 | 104 | let newRatio = ratio; 105 | let newScale = transform.scale * ratio; 106 | if (newScale > maxScale) { 107 | newScale = maxScale; 108 | newRatio = maxScale / transform.scale; 109 | } else if (newScale < minScale) { 110 | // For mobile interactions, allow scaling down to the minimum scale. 111 | newScale = isTouch ? newScale : minScale; 112 | newRatio = newScale / transform.scale; 113 | } 114 | 115 | /** Default center point scaling */ 116 | const mergedCenterX = centerX ?? innerWidth / 2; 117 | const mergedCenterY = centerY ?? innerHeight / 2; 118 | 119 | const diffRatio = newRatio - 1; 120 | /** Deviation calculated from image size */ 121 | const diffImgX = diffRatio * width * 0.5; 122 | const diffImgY = diffRatio * height * 0.5; 123 | /** The difference between the click position and the edge of the document */ 124 | const diffOffsetLeft = diffRatio * (mergedCenterX - transform.x - offsetLeft); 125 | const diffOffsetTop = diffRatio * (mergedCenterY - transform.y - offsetTop); 126 | /** Final positioning */ 127 | let newX = transform.x - (diffOffsetLeft - diffImgX); 128 | let newY = transform.y - (diffOffsetTop - diffImgY); 129 | 130 | /** 131 | * When zooming the image 132 | * When the image size is smaller than the width and height of the window, the position is initialized 133 | */ 134 | if (ratio < 1 && newScale === 1) { 135 | const mergedWidth = offsetWidth * newScale; 136 | const mergedHeight = offsetHeight * newScale; 137 | const { width: clientWidth, height: clientHeight } = getClientSize(); 138 | if (mergedWidth <= clientWidth && mergedHeight <= clientHeight) { 139 | newX = 0; 140 | newY = 0; 141 | } 142 | } 143 | 144 | updateTransform( 145 | { 146 | x: newX, 147 | y: newY, 148 | scale: newScale, 149 | }, 150 | action, 151 | ); 152 | }; 153 | 154 | return { 155 | transform, 156 | resetTransform, 157 | updateTransform, 158 | dispatchZoomChange, 159 | }; 160 | } 161 | -------------------------------------------------------------------------------- /src/hooks/useMouseEvent.ts: -------------------------------------------------------------------------------- 1 | import { warning } from '@rc-component/util/lib/warning'; 2 | import type React from 'react'; 3 | import { useEffect, useRef, useState } from 'react'; 4 | import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition'; 5 | import { BASE_SCALE_RATIO, WHEEL_MAX_SCALE_RATIO } from '../previewConfig'; 6 | import type { 7 | DispatchZoomChangeFunc, 8 | TransformType, 9 | UpdateTransformFunc, 10 | } from './useImageTransform'; 11 | 12 | export default function useMouseEvent( 13 | imgRef: React.MutableRefObject, 14 | movable: boolean, 15 | open: boolean, 16 | scaleStep: number, 17 | transform: TransformType, 18 | updateTransform: UpdateTransformFunc, 19 | dispatchZoomChange: DispatchZoomChangeFunc, 20 | ) { 21 | const { rotate, scale, x, y } = transform; 22 | 23 | const [isMoving, setMoving] = useState(false); 24 | const startPositionInfo = useRef({ 25 | diffX: 0, 26 | diffY: 0, 27 | transformX: 0, 28 | transformY: 0, 29 | }); 30 | 31 | const onMouseDown: React.MouseEventHandler = event => { 32 | // Only allow main button 33 | if (!movable || event.button !== 0) return; 34 | event.preventDefault(); 35 | event.stopPropagation(); 36 | startPositionInfo.current = { 37 | diffX: event.pageX - x, 38 | diffY: event.pageY - y, 39 | transformX: x, 40 | transformY: y, 41 | }; 42 | setMoving(true); 43 | }; 44 | 45 | const onMouseMove = (event: MouseEvent) => { 46 | if (open && isMoving) { 47 | updateTransform( 48 | { 49 | x: event.pageX - startPositionInfo.current.diffX, 50 | y: event.pageY - startPositionInfo.current.diffY, 51 | }, 52 | 'move', 53 | ); 54 | } 55 | }; 56 | 57 | const onMouseUp = () => { 58 | if (open && isMoving) { 59 | setMoving(false); 60 | 61 | /** No need to restore the position when the picture is not moved, So as not to interfere with the click */ 62 | const { transformX, transformY } = startPositionInfo.current; 63 | const hasChangedPosition = x !== transformX && y !== transformY; 64 | if (!hasChangedPosition) return; 65 | 66 | const width = imgRef.current.offsetWidth * scale; 67 | const height = imgRef.current.offsetHeight * scale; 68 | // eslint-disable-next-line @typescript-eslint/no-shadow 69 | const { left, top } = imgRef.current.getBoundingClientRect(); 70 | const isRotate = rotate % 180 !== 0; 71 | 72 | const fixState = getFixScaleEleTransPosition( 73 | isRotate ? height : width, 74 | isRotate ? width : height, 75 | left, 76 | top, 77 | ); 78 | 79 | if (fixState) { 80 | updateTransform({ ...fixState }, 'dragRebound'); 81 | } 82 | } 83 | }; 84 | 85 | const onWheel = (event: React.WheelEvent) => { 86 | if (!open || event.deltaY == 0) return; 87 | // Scale ratio depends on the deltaY size 88 | const scaleRatio = Math.abs(event.deltaY / 100); 89 | // Limit the maximum scale ratio 90 | const mergedScaleRatio = Math.min(scaleRatio, WHEEL_MAX_SCALE_RATIO); 91 | // Scale the ratio each time 92 | let ratio = BASE_SCALE_RATIO + mergedScaleRatio * scaleStep; 93 | if (event.deltaY > 0) { 94 | ratio = BASE_SCALE_RATIO / ratio; 95 | } 96 | dispatchZoomChange(ratio, 'wheel', event.clientX, event.clientY); 97 | }; 98 | 99 | useEffect(() => { 100 | if (movable) { 101 | window.addEventListener('mouseup', onMouseUp, false); 102 | window.addEventListener('mousemove', onMouseMove, false); 103 | 104 | try { 105 | // Resolve if in iframe lost event 106 | /* istanbul ignore next */ 107 | if (window.top !== window.self) { 108 | window.top.addEventListener('mouseup', onMouseUp, false); 109 | window.top.addEventListener('mousemove', onMouseMove, false); 110 | } 111 | } catch (error) { 112 | /* istanbul ignore next */ 113 | warning(false, `[rc-image] ${error}`); 114 | } 115 | } 116 | 117 | return () => { 118 | window.removeEventListener('mouseup', onMouseUp); 119 | window.removeEventListener('mousemove', onMouseMove); 120 | // /* istanbul ignore next */ 121 | window.top?.removeEventListener('mouseup', onMouseUp); 122 | // /* istanbul ignore next */ 123 | window.top?.removeEventListener('mousemove', onMouseMove); 124 | }; 125 | }, [open, isMoving, x, y, rotate, movable]); 126 | 127 | return { 128 | isMoving, 129 | onMouseDown, 130 | onMouseMove, 131 | onMouseUp, 132 | onWheel, 133 | }; 134 | } 135 | -------------------------------------------------------------------------------- /src/hooks/usePreviewItems.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { PreviewGroupProps } from '../PreviewGroup'; 3 | import { COMMON_PROPS } from '../common'; 4 | import type { 5 | ImageElementProps, 6 | InternalItem, 7 | PreviewImageElementProps, 8 | RegisterImage, 9 | } from '../interface'; 10 | 11 | export type Items = Omit[]; 12 | 13 | /** 14 | * Merge props provided `items` or context collected images 15 | */ 16 | export default function usePreviewItems( 17 | items?: PreviewGroupProps['items'], 18 | ): [items: Items, registerImage: RegisterImage, fromItems: boolean] { 19 | // Context collection image data 20 | const [images, setImages] = React.useState>({}); 21 | 22 | const registerImage = React.useCallback((id, data) => { 23 | setImages(imgs => ({ 24 | ...imgs, 25 | [id]: data, 26 | })); 27 | 28 | return () => { 29 | setImages(imgs => { 30 | const cloneImgs = { ...imgs }; 31 | delete cloneImgs[id]; 32 | return cloneImgs; 33 | }); 34 | }; 35 | }, []); 36 | 37 | // items 38 | const mergedItems = React.useMemo(() => { 39 | // use `items` first 40 | if (items) { 41 | return items.map(item => { 42 | if (typeof item === 'string') { 43 | return { data: { src: item } }; 44 | } 45 | const data: ImageElementProps = {}; 46 | Object.keys(item).forEach(key => { 47 | if (['src', ...COMMON_PROPS].includes(key)) { 48 | data[key] = item[key]; 49 | } 50 | }); 51 | return { data }; 52 | }); 53 | } 54 | 55 | // use registered images secondly 56 | return Object.keys(images).reduce((total: Items, id) => { 57 | const { canPreview, data } = images[id]; 58 | if (canPreview) { 59 | total.push({ data, id }); 60 | } 61 | return total; 62 | }, []); 63 | }, [items, images]); 64 | 65 | return [mergedItems, registerImage, !!items]; 66 | } 67 | -------------------------------------------------------------------------------- /src/hooks/useRegisterImage.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { PreviewGroupContext } from '../context'; 3 | import type { ImageElementProps } from '../interface'; 4 | 5 | let uid = 0; 6 | 7 | export default function useRegisterImage(canPreview: boolean, data: ImageElementProps) { 8 | const [id] = React.useState(() => { 9 | uid += 1; 10 | return String(uid); 11 | }); 12 | const groupContext = React.useContext(PreviewGroupContext); 13 | 14 | const registerData = { 15 | data, 16 | canPreview, 17 | }; 18 | 19 | // Keep order start 20 | // Resolve https://github.com/ant-design/ant-design/issues/28881 21 | // Only need unRegister when component unMount 22 | React.useEffect(() => { 23 | if (groupContext) { 24 | return groupContext.register(id, registerData); 25 | } 26 | }, []); 27 | 28 | React.useEffect(() => { 29 | if (groupContext) { 30 | groupContext.register(id, registerData); 31 | } 32 | }, [canPreview, data]); 33 | 34 | return id; 35 | } 36 | -------------------------------------------------------------------------------- /src/hooks/useStatus.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { isImageValid } from '../util'; 3 | 4 | type ImageStatus = 'normal' | 'error' | 'loading'; 5 | 6 | export default function useStatus({ 7 | src, 8 | isCustomPlaceholder, 9 | fallback, 10 | }: { 11 | src: string; 12 | isCustomPlaceholder?: boolean; 13 | fallback?: string; 14 | }) { 15 | const [status, setStatus] = useState(isCustomPlaceholder ? 'loading' : 'normal'); 16 | const isLoaded = useRef(false); 17 | const isError = status === 'error'; 18 | 19 | // https://github.com/react-component/image/pull/187 20 | useEffect(() => { 21 | let isCurrentSrc = true; 22 | isImageValid(src).then(isValid => { 23 | // https://github.com/ant-design/ant-design/issues/44948 24 | // If src changes, the previous setStatus should not be triggered 25 | if (!isValid && isCurrentSrc) { 26 | setStatus('error'); 27 | } 28 | }); 29 | return () => { 30 | isCurrentSrc = false; 31 | }; 32 | }, [src]); 33 | 34 | useEffect(() => { 35 | if (isCustomPlaceholder && !isLoaded.current) { 36 | setStatus('loading'); 37 | } else if (isError) { 38 | setStatus('normal'); 39 | } 40 | }, [src]); 41 | 42 | const onLoad = () => { 43 | setStatus('normal'); 44 | }; 45 | 46 | const getImgRef = (img?: HTMLImageElement) => { 47 | isLoaded.current = false; 48 | if (status === 'loading' && img?.complete && (img.naturalWidth || img.naturalHeight)) { 49 | isLoaded.current = true; 50 | onLoad(); 51 | } 52 | }; 53 | 54 | const srcAndOnload = isError && fallback ? { src: fallback } : { onLoad, src }; 55 | 56 | return [getImgRef, srcAndOnload, status] as const; 57 | } 58 | -------------------------------------------------------------------------------- /src/hooks/useTouchEvent.ts: -------------------------------------------------------------------------------- 1 | import type React from 'react'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition'; 4 | import type { 5 | DispatchZoomChangeFunc, 6 | TransformType, 7 | UpdateTransformFunc, 8 | } from './useImageTransform'; 9 | 10 | type Point = { 11 | x: number; 12 | y: number; 13 | }; 14 | 15 | type TouchPointInfoType = { 16 | point1: Point; 17 | point2: Point; 18 | eventType: string; 19 | }; 20 | 21 | function getDistance(a: Point, b: Point) { 22 | const x = a.x - b.x; 23 | const y = a.y - b.y; 24 | return Math.hypot(x, y); 25 | } 26 | 27 | function getCenter(oldPoint1: Point, oldPoint2: Point, newPoint1: Point, newPoint2: Point) { 28 | // Calculate the distance each point has moved 29 | const distance1 = getDistance(oldPoint1, newPoint1); 30 | const distance2 = getDistance(oldPoint2, newPoint2); 31 | 32 | // If both distances are 0, return the original points 33 | if (distance1 === 0 && distance2 === 0) { 34 | return [oldPoint1.x, oldPoint1.y]; 35 | } 36 | 37 | // Calculate the ratio of the distances 38 | const ratio = distance1 / (distance1 + distance2); 39 | 40 | // Calculate the new center point based on the ratio 41 | const x = oldPoint1.x + ratio * (oldPoint2.x - oldPoint1.x); 42 | const y = oldPoint1.y + ratio * (oldPoint2.y - oldPoint1.y); 43 | 44 | return [x, y]; 45 | } 46 | 47 | export default function useTouchEvent( 48 | imgRef: React.MutableRefObject, 49 | movable: boolean, 50 | open: boolean, 51 | minScale: number, 52 | transform: TransformType, 53 | updateTransform: UpdateTransformFunc, 54 | dispatchZoomChange: DispatchZoomChangeFunc, 55 | ) { 56 | const { rotate, scale, x, y } = transform; 57 | 58 | const [isTouching, setIsTouching] = useState(false); 59 | const touchPointInfo = useRef({ 60 | point1: { x: 0, y: 0 }, 61 | point2: { x: 0, y: 0 }, 62 | eventType: 'none', 63 | }); 64 | 65 | const updateTouchPointInfo = (values: Partial) => { 66 | touchPointInfo.current = { 67 | ...touchPointInfo.current, 68 | ...values, 69 | }; 70 | }; 71 | 72 | const onTouchStart = (event: React.TouchEvent) => { 73 | if (!movable) return; 74 | event.stopPropagation(); 75 | setIsTouching(true); 76 | 77 | const { touches = [] } = event; 78 | if (touches.length > 1) { 79 | // touch zoom 80 | updateTouchPointInfo({ 81 | point1: { x: touches[0].clientX, y: touches[0].clientY }, 82 | point2: { x: touches[1].clientX, y: touches[1].clientY }, 83 | eventType: 'touchZoom', 84 | }); 85 | } else { 86 | // touch move 87 | updateTouchPointInfo({ 88 | point1: { 89 | x: touches[0].clientX - x, 90 | y: touches[0].clientY - y, 91 | }, 92 | eventType: 'move', 93 | }); 94 | } 95 | }; 96 | 97 | const onTouchMove = (event: React.TouchEvent) => { 98 | const { touches = [] } = event; 99 | const { point1, point2, eventType } = touchPointInfo.current; 100 | 101 | if (touches.length > 1 && eventType === 'touchZoom') { 102 | // touch zoom 103 | const newPoint1 = { 104 | x: touches[0].clientX, 105 | y: touches[0].clientY, 106 | }; 107 | const newPoint2 = { 108 | x: touches[1].clientX, 109 | y: touches[1].clientY, 110 | }; 111 | const [centerX, centerY] = getCenter(point1, point2, newPoint1, newPoint2); 112 | const ratio = getDistance(newPoint1, newPoint2) / getDistance(point1, point2); 113 | 114 | dispatchZoomChange(ratio, 'touchZoom', centerX, centerY, true); 115 | updateTouchPointInfo({ 116 | point1: newPoint1, 117 | point2: newPoint2, 118 | eventType: 'touchZoom', 119 | }); 120 | } else if (eventType === 'move') { 121 | // touch move 122 | updateTransform( 123 | { 124 | x: touches[0].clientX - point1.x, 125 | y: touches[0].clientY - point1.y, 126 | }, 127 | 'move', 128 | ); 129 | updateTouchPointInfo({ eventType: 'move' }); 130 | } 131 | }; 132 | 133 | const onTouchEnd = () => { 134 | if (!open) return; 135 | 136 | if (isTouching) { 137 | setIsTouching(false); 138 | } 139 | 140 | updateTouchPointInfo({ eventType: 'none' }); 141 | 142 | if (minScale > scale) { 143 | /** When the scaling ratio is less than the minimum scaling ratio, reset the scaling ratio */ 144 | return updateTransform({ x: 0, y: 0, scale: minScale }, 'touchZoom'); 145 | } 146 | 147 | const width = imgRef.current.offsetWidth * scale; 148 | const height = imgRef.current.offsetHeight * scale; 149 | // eslint-disable-next-line @typescript-eslint/no-shadow 150 | const { left, top } = imgRef.current.getBoundingClientRect(); 151 | const isRotate = rotate % 180 !== 0; 152 | 153 | const fixState = getFixScaleEleTransPosition( 154 | isRotate ? height : width, 155 | isRotate ? width : height, 156 | left, 157 | top, 158 | ); 159 | 160 | if (fixState) { 161 | updateTransform({ ...fixState }, 'dragRebound'); 162 | } 163 | }; 164 | 165 | useEffect(() => { 166 | const preventDefault = (e: TouchEvent) => { 167 | e.preventDefault(); 168 | }; 169 | 170 | if (open && movable) { 171 | window.addEventListener('touchmove', preventDefault, { 172 | passive: false, 173 | }); 174 | } 175 | return () => { 176 | window.removeEventListener('touchmove', preventDefault); 177 | }; 178 | }, [open, movable]); 179 | 180 | return { 181 | isTouching, 182 | onTouchStart, 183 | onTouchMove, 184 | onTouchEnd, 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Image from './Image'; 2 | 3 | export * from './Image'; 4 | export default Image; 5 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Used for PreviewGroup passed image data 3 | */ 4 | export type ImageElementProps = Pick< 5 | React.ImgHTMLAttributes, 6 | | 'src' 7 | | 'crossOrigin' 8 | | 'decoding' 9 | | 'draggable' 10 | | 'loading' 11 | | 'referrerPolicy' 12 | | 'sizes' 13 | | 'srcSet' 14 | | 'useMap' 15 | | 'alt' 16 | >; 17 | 18 | export type PreviewImageElementProps = { 19 | data: ImageElementProps; 20 | canPreview: boolean; 21 | }; 22 | 23 | export type InternalItem = PreviewImageElementProps & { 24 | id?: string; 25 | }; 26 | 27 | export type RegisterImage = (id: string, data: PreviewImageElementProps) => VoidFunction; 28 | 29 | export type OnGroupPreview = (id: string, imageSrc: string, mouseX: number, mouseY: number) => void; 30 | -------------------------------------------------------------------------------- /src/previewConfig.ts: -------------------------------------------------------------------------------- 1 | /** Scale the ratio base */ 2 | export const BASE_SCALE_RATIO = 1; 3 | /** The maximum zoom ratio when the mouse zooms in, adjustable */ 4 | export const WHEEL_MAX_SCALE_RATIO = 1; 5 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function isImageValid(src: string) { 2 | return new Promise(resolve => { 3 | if (!src) { 4 | resolve(false); 5 | return; 6 | } 7 | const img = document.createElement('img'); 8 | img.onerror = () => resolve(false); 9 | img.onload = () => resolve(true); 10 | img.src = src; 11 | }); 12 | } 13 | 14 | // ============================= Legacy ============================= 15 | export function getClientSize() { 16 | const width = document.documentElement.clientWidth; 17 | const height = window.innerHeight || document.documentElement.clientHeight; 18 | return { 19 | width, 20 | height, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /tests/__snapshots__/basic.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Basic snapshot 1`] = ` 4 |
8 | 13 |
16 |
17 | `; 18 | -------------------------------------------------------------------------------- /tests/basic.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Image from '../src'; 4 | 5 | describe('Basic', () => { 6 | it('snapshot', () => { 7 | const { asFragment } = render( 8 | , 12 | ); 13 | 14 | expect(asFragment().firstChild).toMatchSnapshot(); 15 | }); 16 | 17 | it('With click', () => { 18 | const onClickMock = jest.fn(); 19 | const { container } = render( 20 | , 24 | ); 25 | 26 | fireEvent.click(container.querySelector('.rc-image')); 27 | 28 | expect(onClickMock).toHaveBeenCalledTimes(1); 29 | }); 30 | 31 | it('With click when disable preview', () => { 32 | const onClickMock = jest.fn(); 33 | const { container } = render( 34 | , 39 | ); 40 | 41 | fireEvent.click(container.querySelector('.rc-image')); 42 | 43 | expect(onClickMock).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | it('className and style props should work on img element', () => { 47 | const { container } = render( 48 | , 55 | ); 56 | const img = container.querySelector('img'); 57 | expect(img).toHaveClass('img'); 58 | expect(img).toHaveStyle({ objectFit: 'cover' }); 59 | }); 60 | 61 | it('classNames.root and styles.root should work on image wrapper element', () => { 62 | const { container } = render( 63 | , 74 | ); 75 | const wrapperElement = container.firstChild; 76 | expect(wrapperElement).toHaveClass('bamboo'); 77 | expect(wrapperElement).toHaveStyle({ objectFit: 'cover' }); 78 | }); 79 | 80 | // https://github.com/ant-design/ant-design/issues/36680 81 | it('preview mask should be hidden when image has style { display: "none" }', () => { 82 | const { container } = render( 83 | , 90 | ); 91 | const maskElement = container.querySelector('.rc-image-cover'); 92 | expect(maskElement).toHaveStyle({ display: 'none' }); 93 | }); 94 | it('preview zIndex should pass', () => { 95 | const { baseElement } = render( 96 | , 100 | ); 101 | const operationsElement = baseElement.querySelector('.rc-image-preview'); 102 | expect(operationsElement).toHaveStyle({ zIndex: 9999 }); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /tests/controlled.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Image from '../src'; 4 | 5 | describe('Controlled', () => { 6 | beforeEach(() => { 7 | jest.useFakeTimers(); 8 | }); 9 | 10 | afterEach(() => { 11 | jest.useRealTimers(); 12 | }); 13 | it('With previewVisible', () => { 14 | const { rerender } = render( 15 | , 19 | ); 20 | 21 | expect(document.querySelector('.rc-image-preview')).toBeTruthy(); 22 | 23 | rerender( 24 | , 28 | ); 29 | 30 | act(() => { 31 | jest.runAllTimers(); 32 | }); 33 | 34 | expect(document.querySelector('.rc-image-preview')).toBeFalsy(); 35 | }); 36 | 37 | it('controlled current in group', () => { 38 | const { rerender } = render( 39 | 40 | 41 | 42 | 43 | , 44 | ); 45 | 46 | fireEvent.click(document.querySelector('.first-img')); 47 | 48 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src2'); 49 | 50 | rerender( 51 | 52 | 53 | 54 | 55 | , 56 | ); 57 | 58 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src3'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /tests/fallback.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import React from 'react'; 3 | import Image from '../src'; 4 | 5 | global.lastResolve = null; 6 | 7 | jest.mock('../src/util', () => { 8 | const { isImageValid, ...rest } = jest.requireActual('../src/util'); 9 | 10 | return { 11 | ...rest, 12 | isImageValid: () => 13 | new Promise(resolve => { 14 | global.lastResolve = resolve; 15 | 16 | setTimeout(() => { 17 | resolve(false); 18 | }, 1000); 19 | }), 20 | }; 21 | }); 22 | 23 | describe('Fallback', () => { 24 | beforeEach(() => { 25 | global.lastResolve = null; 26 | jest.useFakeTimers(); 27 | }); 28 | 29 | afterEach(() => { 30 | jest.useRealTimers(); 31 | }); 32 | 33 | const fallback = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 34 | 35 | it('Fallback correct', async () => { 36 | const { container } = render(); 37 | 38 | await act(async () => { 39 | jest.runAllTimers(); 40 | await Promise.resolve(); 41 | }); 42 | 43 | expect(container.querySelector('img').src).toEqual(fallback); 44 | }); 45 | 46 | it('PreviewGroup Fallback correct', async () => { 47 | const { container } = render( 48 | 49 | 50 | , 51 | ); 52 | 53 | fireEvent.click(container.querySelector('.rc-image-img')); 54 | 55 | await act(async () => { 56 | jest.runAllTimers(); 57 | await Promise.resolve(); 58 | }); 59 | 60 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', fallback); 61 | }); 62 | 63 | it('should not show preview', async () => { 64 | const { container } = render(); 65 | 66 | fireEvent.error(container.querySelector('.rc-image-img')); 67 | await act(async () => { 68 | jest.runAllTimers(); 69 | await Promise.resolve(); 70 | }); 71 | 72 | expect(container.querySelector('.rc-image-mask')).toBeFalsy(); 73 | }); 74 | 75 | it('should change image, not error', async () => { 76 | const { container, rerender } = render( 77 | , 82 | ); 83 | 84 | rerender( 85 | , 90 | ); 91 | 92 | // New Image should pass 93 | await act(async () => { 94 | await global.lastResolve(true); 95 | }); 96 | 97 | // Origin one should failed 98 | await act(async () => { 99 | jest.runAllTimers(); 100 | await Promise.resolve(); 101 | }); 102 | 103 | expect(container.querySelector('.rc-image-img')).toHaveAttribute( 104 | 'src', 105 | 'https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*NZuwQp_vcIQAAAAAAAAAAABkARQnAQ', 106 | ); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/placeholder.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from '@testing-library/react'; 2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 3 | import React from 'react'; 4 | import Image from '../src'; 5 | 6 | describe('Placeholder', () => { 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | 11 | afterEach(() => { 12 | jest.useRealTimers(); 13 | }); 14 | 15 | it('Default placeholder', () => { 16 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 17 | const { container } = render(); 18 | 19 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy(); 20 | expect(container.querySelector('.rc-image-img-placeholder')).toHaveAttribute('src', src); 21 | }); 22 | 23 | it('Set correct', () => { 24 | const placeholder = 'placeholder'; 25 | const { container } = render( 26 | , 30 | ); 31 | expect(container.querySelector('.rc-image-placeholder').textContent).toBe(placeholder); 32 | 33 | fireEvent.load(container.querySelector('.rc-image-img')); 34 | act(() => { 35 | jest.runAllTimers(); 36 | }); 37 | 38 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy(); 39 | }); 40 | 41 | it('Hide placeholder when load from cache', () => { 42 | const domSpy = spyElementPrototypes(HTMLImageElement, { 43 | complete: { 44 | get: () => true, 45 | }, 46 | naturalWidth: { 47 | get: () => 1004, 48 | }, 49 | naturalHeight: { 50 | get: () => 986, 51 | }, 52 | }); 53 | 54 | const { container } = render( 55 | } 58 | />, 59 | ); 60 | 61 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy(); 62 | 63 | domSpy.mockRestore(); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/preview.test.tsx: -------------------------------------------------------------------------------- 1 | import CloseOutlined from '@ant-design/icons/CloseOutlined'; 2 | import LeftOutlined from '@ant-design/icons/LeftOutlined'; 3 | import RightOutlined from '@ant-design/icons/RightOutlined'; 4 | import RotateLeftOutlined from '@ant-design/icons/RotateLeftOutlined'; 5 | import RotateRightOutlined from '@ant-design/icons/RotateRightOutlined'; 6 | import ZoomInOutlined from '@ant-design/icons/ZoomInOutlined'; 7 | import ZoomOutOutlined from '@ant-design/icons/ZoomOutOutlined'; 8 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook'; 9 | import { act, createEvent, fireEvent, render } from '@testing-library/react'; 10 | import React from 'react'; 11 | 12 | jest.mock('../src/Preview', () => { 13 | const MockPreview = (props: any) => { 14 | global._previewProps = props; 15 | 16 | let Preview = jest.requireActual('../src/Preview'); 17 | Preview = Preview.default || Preview; 18 | 19 | return ; 20 | }; 21 | 22 | return MockPreview; 23 | }); 24 | 25 | import Image from '../src'; 26 | 27 | describe('Preview', () => { 28 | beforeEach(() => { 29 | jest.useFakeTimers(); 30 | }); 31 | 32 | afterEach(() => { 33 | jest.useRealTimers(); 34 | }); 35 | 36 | const fireMouseEvent = ( 37 | eventName: 'mouseDown' | 'mouseMove' | 'mouseUp', 38 | element: Element | Window, 39 | info: { 40 | pageX?: number; 41 | pageY?: number; 42 | button?: number; 43 | } = {}, 44 | ) => { 45 | const event = createEvent[eventName](element); 46 | Object.keys(info).forEach(key => { 47 | Object.defineProperty(event, key, { 48 | get: () => info[key], 49 | }); 50 | }); 51 | 52 | act(() => { 53 | fireEvent(element, event); 54 | }); 55 | 56 | act(() => { 57 | jest.runAllTimers(); 58 | }); 59 | }; 60 | 61 | it('Show preview and close', () => { 62 | const onPreviewCloseMock = jest.fn(); 63 | const { container } = render( 64 | , 70 | ); 71 | 72 | // Click Image 73 | fireEvent.click(container.querySelector('.rc-image')); 74 | expect(onPreviewCloseMock).toHaveBeenCalledWith(true); 75 | 76 | act(() => { 77 | jest.runAllTimers(); 78 | }); 79 | expect(document.querySelector('.rc-image-preview')).toBeTruthy(); 80 | 81 | // Click Mask 82 | onPreviewCloseMock.mockReset(); 83 | fireEvent.click(document.querySelector('.rc-image-preview-mask')); 84 | expect(onPreviewCloseMock).toHaveBeenCalledWith(false); 85 | 86 | // Click Image again 87 | fireEvent.click(container.querySelector('.rc-image')); 88 | act(() => { 89 | jest.runAllTimers(); 90 | }); 91 | 92 | // Click Close Button 93 | onPreviewCloseMock.mockReset(); 94 | fireEvent.click(document.querySelector('.rc-image-preview-close')); 95 | expect(onPreviewCloseMock).toHaveBeenCalledWith(false); 96 | }); 97 | 98 | it('Unmount', () => { 99 | const { container, unmount } = render( 100 | , 101 | ); 102 | 103 | fireEvent.click(container.querySelector('.rc-image')); 104 | act(() => { 105 | jest.runAllTimers(); 106 | }); 107 | 108 | expect(() => { 109 | unmount(); 110 | }).not.toThrow(); 111 | }); 112 | 113 | it('Rotate', () => { 114 | const { container } = render( 115 | , 116 | ); 117 | 118 | fireEvent.click(container.querySelector('.rc-image')); 119 | act(() => { 120 | jest.runAllTimers(); 121 | }); 122 | 123 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[3]); 124 | act(() => { 125 | jest.runAllTimers(); 126 | }); 127 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 128 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(90deg)', 129 | }); 130 | 131 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[2]); 132 | act(() => { 133 | jest.runAllTimers(); 134 | }); 135 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 136 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 137 | }); 138 | }); 139 | 140 | it('Flip', () => { 141 | const { container } = render( 142 | , 143 | ); 144 | 145 | fireEvent.click(container.querySelector('.rc-image')); 146 | act(() => { 147 | jest.runAllTimers(); 148 | }); 149 | 150 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[1]); 151 | act(() => { 152 | jest.runAllTimers(); 153 | }); 154 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 155 | transform: 'translate3d(0px, 0px, 0) scale3d(-1, 1, 1) rotate(0deg)', 156 | }); 157 | 158 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[0]); 159 | act(() => { 160 | jest.runAllTimers(); 161 | }); 162 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 163 | transform: 'translate3d(0px, 0px, 0) scale3d(-1, -1, 1) rotate(0deg)', 164 | }); 165 | 166 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[1]); 167 | act(() => { 168 | jest.runAllTimers(); 169 | }); 170 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 171 | transform: 'translate3d(0px, 0px, 0) scale3d(1, -1, 1) rotate(0deg)', 172 | }); 173 | 174 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[0]); 175 | act(() => { 176 | jest.runAllTimers(); 177 | }); 178 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 179 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 180 | }); 181 | }); 182 | 183 | it('Scale', () => { 184 | const { container } = render( 185 | , 186 | ); 187 | 188 | fireEvent.click(container.querySelector('.rc-image')); 189 | act(() => { 190 | jest.runAllTimers(); 191 | }); 192 | 193 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); 194 | act(() => { 195 | jest.runAllTimers(); 196 | }); 197 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 198 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)', 199 | }); 200 | 201 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); 202 | act(() => { 203 | jest.runAllTimers(); 204 | }); 205 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 206 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 207 | }); 208 | 209 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); 210 | act(() => { 211 | jest.runAllTimers(); 212 | }); 213 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 214 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)', 215 | }); 216 | 217 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); 218 | act(() => { 219 | jest.runAllTimers(); 220 | }); 221 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 222 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 223 | }); 224 | 225 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 226 | deltaY: -50, 227 | }); 228 | 229 | act(() => { 230 | jest.runAllTimers(); 231 | }); 232 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 233 | transform: 'translate3d(0px, 0px, 0) scale3d(1.25, 1.25, 1) rotate(0deg)', 234 | }); 235 | 236 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 237 | deltaY: 50, 238 | }); 239 | act(() => { 240 | jest.runAllTimers(); 241 | }); 242 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 243 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 244 | }); 245 | 246 | for (let i = 0; i < 50; i++) { 247 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 248 | deltaY: -100, 249 | }); 250 | act(() => { 251 | jest.runAllTimers(); 252 | }); 253 | } 254 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 255 | transform: 'translate3d(0px, 0px, 0) scale3d(50, 50, 1) rotate(0deg)', 256 | }); 257 | }); 258 | 259 | it('scaleStep = 1', () => { 260 | const { container } = render( 261 | , 267 | ); 268 | 269 | fireEvent.click(container.querySelector('.rc-image')); 270 | act(() => { 271 | jest.runAllTimers(); 272 | }); 273 | 274 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); 275 | act(() => { 276 | jest.runAllTimers(); 277 | }); 278 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 279 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 280 | }); 281 | 282 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); 283 | act(() => { 284 | jest.runAllTimers(); 285 | }); 286 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 287 | transform: 'translate3d(-512px, -384px, 0) scale3d(2, 2, 1) rotate(0deg)', 288 | }); 289 | 290 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]); 291 | act(() => { 292 | jest.runAllTimers(); 293 | }); 294 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 295 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 296 | }); 297 | 298 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 299 | deltaY: -50, 300 | }); 301 | 302 | act(() => { 303 | jest.runAllTimers(); 304 | }); 305 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 306 | transform: 'translate3d(0px, 0px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)', 307 | }); 308 | 309 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 310 | deltaY: 50, 311 | }); 312 | act(() => { 313 | jest.runAllTimers(); 314 | }); 315 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 316 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 317 | }); 318 | }); 319 | 320 | it('Reset scale on double click', () => { 321 | const { container } = render( 322 | , 323 | ); 324 | 325 | fireEvent.click(container.querySelector('.rc-image')); 326 | act(() => { 327 | jest.runAllTimers(); 328 | }); 329 | 330 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]); 331 | act(() => { 332 | jest.runAllTimers(); 333 | }); 334 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 335 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)', 336 | }); 337 | 338 | fireEvent.dblClick(document.querySelector('.rc-image-preview-img')); 339 | act(() => { 340 | jest.runAllTimers(); 341 | }); 342 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 343 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 344 | }); 345 | }); 346 | 347 | it('Reset position on double click', () => { 348 | const { container } = render( 349 | , 350 | ); 351 | 352 | fireEvent.click(container.querySelector('.rc-image')); 353 | act(() => { 354 | jest.runAllTimers(); 355 | }); 356 | 357 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 358 | pageX: 0, 359 | pageY: 0, 360 | button: 0, 361 | }); 362 | fireMouseEvent('mouseMove', window, { 363 | pageX: 50, 364 | pageY: 50, 365 | }); 366 | 367 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 368 | transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)', 369 | }); 370 | 371 | fireEvent.dblClick(document.querySelector('.rc-image-preview-img')); 372 | act(() => { 373 | jest.runAllTimers(); 374 | }); 375 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 376 | transform: 'translate3d(75px, 75px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)', 377 | }); 378 | }); 379 | 380 | it('Mouse Event', () => { 381 | const clientWidthMock = jest 382 | .spyOn(document.documentElement, 'clientWidth', 'get') 383 | .mockImplementation(() => 1080); 384 | 385 | let offsetWidth = 200; 386 | let offsetHeight = 100; 387 | let left = 0; 388 | let top = 0; 389 | 390 | const imgEleMock = spyElementPrototypes(HTMLImageElement, { 391 | offsetWidth: { 392 | get: () => offsetWidth, 393 | }, 394 | offsetHeight: { 395 | get: () => offsetHeight, 396 | }, 397 | getBoundingClientRect: () => { 398 | return { left, top }; 399 | }, 400 | }); 401 | const { container } = render( 402 | , 403 | ); 404 | 405 | fireEvent.click(container.querySelector('.rc-image')); 406 | 407 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 408 | pageX: 0, 409 | pageY: 0, 410 | button: 2, 411 | }); 412 | expect(document.querySelector('.rc-image-preview-moving')).toBeFalsy(); 413 | 414 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 415 | pageX: 0, 416 | pageY: 0, 417 | button: 0, 418 | }); 419 | expect(document.querySelector('.rc-image-preview-moving')).toBeTruthy(); 420 | 421 | fireMouseEvent('mouseMove', window, { 422 | pageX: 50, 423 | pageY: 50, 424 | }); 425 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 426 | transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)', 427 | }); 428 | 429 | fireMouseEvent('mouseUp', window); 430 | 431 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 432 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 433 | }); 434 | 435 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 436 | pageX: 0, 437 | pageY: 0, 438 | button: 0, 439 | }); 440 | 441 | fireMouseEvent('mouseUp', window); 442 | 443 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 444 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 445 | }); 446 | 447 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 448 | pageX: 0, 449 | pageY: 0, 450 | button: 0, 451 | }); 452 | 453 | fireMouseEvent('mouseMove', window, { 454 | pageX: 1, 455 | pageY: 1, 456 | }); 457 | 458 | left = 100; 459 | top = 100; 460 | offsetWidth = 2000; 461 | offsetHeight = 1000; 462 | fireMouseEvent('mouseUp', window); 463 | 464 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 465 | transform: 'translate3d(460px, 116px, 0) scale3d(1, 1, 1) rotate(0deg)', 466 | }); 467 | 468 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 469 | pageX: 0, 470 | pageY: 0, 471 | button: 0, 472 | }); 473 | 474 | left = -200; 475 | top = -200; 476 | offsetWidth = 2000; 477 | offsetHeight = 1000; 478 | 479 | fireMouseEvent('mouseUp', window); 480 | 481 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 482 | transform: 'translate3d(460px, 116px, 0) scale3d(1, 1, 1) rotate(0deg)', 483 | }); 484 | 485 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 486 | pageX: 0, 487 | pageY: 0, 488 | button: 0, 489 | }); 490 | 491 | fireMouseEvent('mouseMove', window, { 492 | pageX: 1, 493 | pageY: 1, 494 | }); 495 | 496 | left = -200; 497 | top = -200; 498 | offsetWidth = 1000; 499 | offsetHeight = 500; 500 | 501 | fireMouseEvent('mouseUp', window); 502 | 503 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 504 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 505 | }); 506 | 507 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 508 | pageX: 0, 509 | pageY: 0, 510 | button: 0, 511 | }); 512 | 513 | fireMouseEvent('mouseMove', window, { 514 | pageX: 1, 515 | pageY: 1, 516 | }); 517 | 518 | left = -200; 519 | top = -200; 520 | offsetWidth = 1200; 521 | offsetHeight = 600; 522 | fireMouseEvent('mouseUp', window); 523 | 524 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 525 | transform: 'translate3d(-60px, -84px, 0) scale3d(1, 1, 1) rotate(0deg)', 526 | }); 527 | 528 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 529 | pageX: 0, 530 | pageY: 0, 531 | button: 0, 532 | }); 533 | 534 | fireMouseEvent('mouseMove', window, { 535 | pageX: 1, 536 | pageY: 1, 537 | }); 538 | 539 | left = -200; 540 | top = -200; 541 | offsetWidth = 1000; 542 | offsetHeight = 900; 543 | fireMouseEvent('mouseUp', window); 544 | 545 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 546 | transform: 'translate3d(-40px, -66px, 0) scale3d(1, 1, 1) rotate(0deg)', 547 | }); 548 | 549 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), { 550 | deltaY: -50, 551 | }); 552 | 553 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), { 554 | pageX: 0, 555 | pageY: 0, 556 | button: 0, 557 | }); 558 | 559 | fireMouseEvent('mouseMove', window, { 560 | pageX: 1, 561 | pageY: 1, 562 | }); 563 | 564 | fireMouseEvent('mouseUp', window); 565 | 566 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 567 | transform: 'translate3d(-85px, -65px, 0) scale3d(1.25, 1.25, 1) rotate(0deg)', 568 | }); 569 | 570 | // Clear 571 | clientWidthMock.mockRestore(); 572 | imgEleMock.mockRestore(); 573 | jest.restoreAllMocks(); 574 | }); 575 | 576 | it('PreviewGroup render', () => { 577 | const { container } = render( 578 | , 581 | rotateRight: , 582 | zoomIn: , 583 | zoomOut: , 584 | close: , 585 | left: , 586 | right: , 587 | }} 588 | > 589 | 593 | 597 | , 598 | ); 599 | 600 | fireEvent.click(container.querySelector('.rc-image')); 601 | act(() => { 602 | jest.runAllTimers(); 603 | }); 604 | 605 | expect(document.querySelectorAll('.rc-image-preview-actions-action')).toHaveLength(6); 606 | }); 607 | 608 | it('preview placeholder', () => { 609 | render( 610 | , 619 | ); 620 | 621 | expect(document.querySelector('.rc-image-cover').textContent).toEqual('Bamboo Is Light'); 622 | expect(document.querySelector('.rc-image-cover')).toHaveClass('bamboo'); 623 | }); 624 | 625 | it('previewSrc', () => { 626 | const src = 627 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png?x-oss-process=image/auto-orient,1/resize,p_10/quality,q_10'; 628 | const previewSrc = 629 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 630 | const { container } = render(); 631 | 632 | expect(container.querySelector('.rc-image-img')).toHaveAttribute('src', src); 633 | expect(document.querySelector('.rc-image-preview')).toBeFalsy(); 634 | 635 | fireEvent.click(container.querySelector('.rc-image')); 636 | act(() => { 637 | jest.runAllTimers(); 638 | }); 639 | 640 | expect(document.querySelector('.rc-image-preview')).toBeTruthy(); 641 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', previewSrc); 642 | }); 643 | 644 | it('Customize preview props', () => { 645 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 646 | render( 647 | , 655 | ); 656 | 657 | expect(global._previewProps).toEqual( 658 | expect.objectContaining({ 659 | motionName: 'abc', 660 | }), 661 | ); 662 | 663 | expect(document.querySelector('.rc-image-preview-close')).toBeFalsy(); 664 | }); 665 | 666 | it('Customize Group preview props', () => { 667 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 668 | render( 669 | 670 | 671 | , 672 | ); 673 | 674 | expect(global._previewProps).toEqual( 675 | expect.objectContaining({ 676 | motionName: 'abc', 677 | }), 678 | ); 679 | }); 680 | 681 | it('add rootClassName should be correct', () => { 682 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 683 | const { container } = render(); 684 | 685 | expect(container.querySelector('.rc-image.custom-className')).toBeTruthy(); 686 | }); 687 | 688 | it('add rootClassName should be correct when open preview', () => { 689 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 690 | const previewSrc = 691 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 692 | 693 | const { container } = render( 694 | , 695 | ); 696 | expect(container.querySelector('.rc-image.custom-className .rc-image-img')).toHaveAttribute( 697 | 'src', 698 | src, 699 | ); 700 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeFalsy(); 701 | 702 | fireEvent.click(container.querySelector('.rc-image')); 703 | act(() => { 704 | jest.runAllTimers(); 705 | }); 706 | 707 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeTruthy(); 708 | expect(document.querySelector('.rc-image-preview.custom-className img')).toHaveAttribute( 709 | 'src', 710 | previewSrc, 711 | ); 712 | expect(document.querySelectorAll('.custom-className')).toHaveLength(2); 713 | }); 714 | 715 | it('preview.rootClassName should be correct', () => { 716 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 717 | render( 718 | , 725 | ); 726 | 727 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeTruthy(); 728 | }); 729 | 730 | it('rootClassName on both side but classNames.root on single side', () => { 731 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 732 | render( 733 | , 756 | ); 757 | 758 | expect(document.querySelectorAll('.both')).toHaveLength(2); 759 | expect(document.querySelectorAll('.rc-image.image-root')).toHaveLength(1); 760 | expect(document.querySelectorAll('.rc-image-preview.preview-root')).toHaveLength(1); 761 | 762 | expect(document.querySelector('.rc-image.image-root')).toHaveStyle({ 763 | color: 'red', 764 | }); 765 | expect(document.querySelector('.rc-image.image-root')).not.toHaveStyle({ 766 | background: 'green', 767 | }); 768 | expect(document.querySelector('.rc-image-preview.preview-root')).toHaveStyle({ 769 | background: 'green', 770 | }); 771 | expect(document.querySelector('.rc-image-preview.preview-root')).not.toHaveStyle({ 772 | color: 'red', 773 | }); 774 | }); 775 | 776 | it('if async src set should be correct', () => { 777 | const src = 778 | 'https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*P0S-QIRUbsUAAAAAAAAAAABkARQnAQ'; 779 | const AsyncImage = ({ src: imgSrc }) => { 780 | const normalSrc = 781 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png'; 782 | return ( 783 | 784 | 785 | 786 | 787 | ); 788 | }; 789 | 790 | const { container, rerender } = render(); 791 | rerender(); 792 | 793 | fireEvent.click(container.querySelector('.rc-image')); 794 | 795 | act(() => { 796 | jest.runAllTimers(); 797 | }); 798 | 799 | expect(document.querySelector('.rc-image-preview img')).toHaveAttribute('src', src); 800 | 801 | expect(document.querySelector('.rc-image-preview-switch-prev')).toHaveClass( 802 | 'rc-image-preview-switch-disabled', 803 | ); 804 | }); 805 | 806 | it('pass img common props to previewed image', () => { 807 | const { container } = render( 808 | , 812 | ); 813 | 814 | fireEvent.click(container.querySelector('.rc-image')); 815 | act(() => { 816 | jest.runAllTimers(); 817 | }); 818 | 819 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute( 820 | 'referrerPolicy', 821 | 'no-referrer', 822 | ); 823 | }); 824 | 825 | describe('actionsRender', () => { 826 | it('single', () => { 827 | const printImage = jest.fn(); 828 | const { container } = render( 829 | alt { 836 | printImage(image); 837 | return ( 838 | <> 839 |
actions.onFlipY()}> 840 | {icons.flipYIcon} 841 |
842 |
actions.onFlipX()}> 843 | {icons.flipXIcon} 844 |
845 |
actions.onZoomIn()}> 846 | {icons.zoomInIcon} 847 |
848 |
actions.onZoomOut()}> 849 | {icons.zoomOutIcon} 850 |
851 |
actions.onRotateLeft()}> 852 | {icons.rotateLeftIcon} 853 |
854 |
actions.onRotateRight()}> 855 | {icons.rotateRightIcon} 856 |
857 |
actions.onReset()}> 858 | reset 859 |
860 | 861 | ); 862 | }, 863 | }} 864 | />, 865 | ); 866 | 867 | fireEvent.click(container.querySelector('.rc-image')); 868 | act(() => { 869 | jest.runAllTimers(); 870 | }); 871 | 872 | expect(printImage).toHaveBeenCalledWith({ 873 | alt: 'alt', 874 | height: 200, 875 | url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png', 876 | width: 200, 877 | }); 878 | // flipY 879 | fireEvent.click(document.getElementById('flipY')); 880 | act(() => { 881 | jest.runAllTimers(); 882 | }); 883 | fireEvent.click(document.getElementById('flipX')); 884 | act(() => { 885 | jest.runAllTimers(); 886 | }); 887 | fireEvent.click(document.getElementById('zoomIn')); 888 | act(() => { 889 | jest.runAllTimers(); 890 | }); 891 | fireEvent.click(document.getElementById('rotateLeft')); 892 | act(() => { 893 | jest.runAllTimers(); 894 | }); 895 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 896 | transform: 'translate3d(-256px, -192px, 0) scale3d(-1.5, -1.5, 1) rotate(-90deg)', 897 | }); 898 | 899 | // reset 900 | fireEvent.click(document.getElementById('reset')); 901 | act(() => { 902 | jest.runAllTimers(); 903 | }); 904 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({ 905 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)', 906 | }); 907 | }); 908 | 909 | it('switch', () => { 910 | const onChange = jest.fn(); 911 | render( 912 | { 928 | return ( 929 | <> 930 | {icons.prevIcon} 931 | {icons.nextIcon} 932 |
actions.onActive(-1)}> 933 | Prev 934 |
935 | 938 | 939 | ); 940 | }, 941 | onChange, 942 | }} 943 | />, 944 | ); 945 | 946 | // Origin Node 947 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-prev')); 948 | expect(onChange).toHaveBeenCalledWith(0, 1); 949 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-next')); 950 | expect(onChange).toHaveBeenCalledWith(2, 1); 951 | 952 | // Customize 953 | onChange.mockReset(); 954 | fireEvent.click(document.getElementById('left')); 955 | expect(onChange).toHaveBeenCalledWith(0, 1); 956 | 957 | fireEvent.click(document.getElementById('right')); 958 | expect(onChange).toHaveBeenCalledWith(2, 1); 959 | }); 960 | }); 961 | 962 | it('onTransform should be triggered when transform change', () => { 963 | const onTransform = jest.fn(); 964 | const { container } = render( 965 | , 969 | ); 970 | 971 | fireEvent.click(container.querySelector('.rc-image')); 972 | act(() => { 973 | jest.runAllTimers(); 974 | }); 975 | 976 | expect(document.querySelector('.rc-image-preview')).toBeTruthy(); 977 | 978 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-flipY')); 979 | act(() => { 980 | jest.runAllTimers(); 981 | }); 982 | 983 | expect(onTransform).toHaveBeenCalledTimes(1); 984 | expect(onTransform).toHaveBeenCalledWith({ 985 | transform: { 986 | flipY: true, 987 | flipX: false, 988 | rotate: 0, 989 | scale: 1, 990 | x: 0, 991 | y: 0, 992 | }, 993 | action: 'flipY', 994 | }); 995 | }); 996 | 997 | it('imageRender', () => { 998 | const { container } = render( 999 | ( 1003 |