├── .eslintignore ├── example ├── assets │ ├── 0.jpg │ ├── 1.jpg │ ├── 2.jpg │ ├── 3.jpg │ └── 4.jpg ├── App.less ├── index.html ├── main.js └── App.jsx ├── src ├── index.js └── components │ ├── Loading.jsx │ ├── Pointer.jsx │ ├── WxImageViewer.jsx │ ├── WrapViewer.jsx │ ├── WxImageViewer.css │ ├── ListContainer.jsx │ ├── tween.js │ └── ImageContainer.jsx ├── .vscode └── settings.json ├── calculate.js ├── postcss.config.js ├── index.html ├── LICENSE ├── .gitignore ├── .npmignore ├── .eslintrc ├── package.json ├── webpack.config.example.js ├── gulpfile.js ├── webpack.config.js ├── README.md └── README-cn.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | .vscode 4 | 5 | .publish 6 | 7 | demo 8 | 9 | gulpfile.js -------------------------------------------------------------------------------- /example/assets/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-ld/react-wx-images-viewer/HEAD/example/assets/0.jpg -------------------------------------------------------------------------------- /example/assets/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-ld/react-wx-images-viewer/HEAD/example/assets/1.jpg -------------------------------------------------------------------------------- /example/assets/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-ld/react-wx-images-viewer/HEAD/example/assets/2.jpg -------------------------------------------------------------------------------- /example/assets/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-ld/react-wx-images-viewer/HEAD/example/assets/3.jpg -------------------------------------------------------------------------------- /example/assets/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/react-ld/react-wx-images-viewer/HEAD/example/assets/4.jpg -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import WxImageViewer from './components/WxImageViewer'; 2 | 3 | export default WxImageViewer; -------------------------------------------------------------------------------- /example/App.less: -------------------------------------------------------------------------------- 1 | 2 | .app{ 3 | .img-list{ 4 | display: flex; justify-content: space-between; flex-wrap: wrap; 5 | .img{ 6 | width: 33.3%; 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/components/Loading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = (props) => { 4 | return ( 5 |
6 |
7 | ); 8 | }; 9 | 10 | export default Loading; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.autoFixOnSave": true, 3 | "files.autoSave": "off", 4 | "eslint.validate": [ 5 | "javascript", 6 | "javascriptreact" 7 | ], 8 | "javascript.implicitProjectConfig.experimentalDecorators": true 9 | } -------------------------------------------------------------------------------- /calculate.js: -------------------------------------------------------------------------------- 1 | var y, 2 | x, 3 | start = 0, 4 | end = 300, 5 | time = 147, 6 | distance = 1000, 7 | json = "["; 8 | 9 | for(x = start; x < end;){ 10 | // y = x * Math.sqrt(distance) / Math.sqrt(time); 11 | y = x * x ; 12 | var str = '[' + x + ',' + y + '],\n'; 13 | json+=str; 14 | x = x+10 15 | } 16 | json +="]" 17 | console.info(json); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WxImageViewer demo 6 | 7 | 8 | 9 |
10 | 14 | 15 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ file, options, env }) => ({ 2 | // parser: file.extname === '.sss' ? 'sugarss' : false, 3 | // plugins: { 4 | // 'postcss-import': { root: file.dirname }, 5 | // 'postcss-cssnext': options.cssnext ? options.cssnext : false, 6 | // 'autoprefixer': env == 'production' ? options.autoprefixer : false, 7 | // 'cssnano': env === 'production' ? options.cssnano : false 8 | // } 9 | plugins: [ require('autoprefixer')({ browsers: ["Android >= 4", "iOS >= 7"]}) ] 10 | }) -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ReactPullLoad demo 6 | 7 | 8 | 9 |
10 | 14 | 15 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { AppContainer } from 'react-hot-loader'; 5 | // AppContainer 是一个 HMR 必须的包裹(wrapper)组件 6 | 7 | import App from './App.jsx'; 8 | 9 | const render = (Component) => { 10 | ReactDOM.render( 11 | 12 | 13 | , 14 | document.getElementById('root') 15 | ); 16 | }; 17 | 18 | render(App); 19 | 20 | // 模块热替换的 API 21 | if (module.hot) { 22 | module.hot.accept('./App.jsx', () => { 23 | render(App); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Pointer.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | class Pointer extends PureComponent { 5 | static propTypes = { 6 | length: PropTypes.number.isRequired, 7 | index: PropTypes.number.isRequired, 8 | changeIndex: PropTypes.func 9 | } 10 | 11 | render() { 12 | console.info("Point render") 13 | 14 | const { 15 | length, 16 | changeIndex, 17 | index 18 | } = this.props 19 | 20 | let i = 0, items = []; 21 | for (i; i < length; i++) { 22 | if (i === index) { 23 | items.push(); 24 | } else { 25 | items.push(); 26 | } 27 | } 28 | 29 | return ( 30 |
31 | {items} 32 |
33 | ); 34 | } 35 | } 36 | 37 | export default Pointer; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 react-ld 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | demo 61 | 62 | deploy.config.json 63 | 64 | dist 65 | 66 | .publish 67 | 68 | package-lock.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | demo 61 | 62 | deploy.config.json 63 | 64 | .publish 65 | 66 | example 67 | 68 | postcss.config.js 69 | 70 | webpack.config.example.js 71 | 72 | webpack.config.js 73 | 74 | index.html 75 | 76 | calculate.js 77 | 78 | gulpfile.js -------------------------------------------------------------------------------- /src/components/WxImageViewer.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import WrapViewer from './WrapViewer'; 6 | 7 | import './WxImageViewer.css'; 8 | 9 | class WxImageViewer extends Component { 10 | static propTypes = { 11 | maxZoomNum: PropTypes.number, // 最大放大倍数 12 | zIndex: PropTypes.number, // 组件图层深度 13 | index: PropTypes.number, // 当前显示图片的http链接 14 | urls: PropTypes.array.isRequired, // 需要预览的图片http链接列表 15 | gap: PropTypes.number, // 间隙 16 | speed: PropTypes.number, // Duration of transition between slides (in ms) 17 | onClose: PropTypes.func.isRequired, // 关闭组件回调 18 | } 19 | 20 | static childContextTypes = { 21 | onClose: PropTypes.func, 22 | }; 23 | 24 | static defaultProps = { 25 | maxZoomNum: 4, 26 | zIndex: 100, 27 | index: 0, 28 | gap: 10, 29 | speed: 300, 30 | } 31 | 32 | constructor(props) { 33 | super(props); 34 | this.node = document.createElement('div'); 35 | } 36 | 37 | getChildContext() { 38 | return { onClose: this.props.onClose }; 39 | } 40 | 41 | componentDidMount() { 42 | document.body.appendChild(this.node); 43 | } 44 | 45 | componentWillUnmount() { 46 | document.body.removeChild(this.node); 47 | } 48 | 49 | render() { 50 | return ReactDOM.createPortal( 51 | , 54 | this.node, 55 | ); 56 | } 57 | } 58 | 59 | export default WxImageViewer; 60 | -------------------------------------------------------------------------------- /example/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | // import { render } from 'react-dom'; 3 | // import PropTypes from 'prop-types'; 4 | 5 | import WxImageViewer from 'index.js'; 6 | import './App.less'; 7 | 8 | class App extends Component { 9 | state = { 10 | imags: [ 11 | /* eslint-disable */ 12 | require('./assets/2.jpg'), 13 | require('./assets/1.jpg'), 14 | require('./assets/0.jpg'), 15 | require('./assets/3.jpg'), 16 | require('./assets/4.jpg'), 17 | /* eslint-enable */ 18 | ], 19 | index: 0, 20 | isOpen: false, 21 | }; 22 | 23 | onClose = () => { 24 | this.setState({ 25 | isOpen: false, 26 | }); 27 | } 28 | 29 | openViewer(index) { 30 | this.setState({ 31 | index, 32 | isOpen: true, 33 | }); 34 | } 35 | 36 | render() { 37 | const { 38 | imags, 39 | index, 40 | isOpen, 41 | } = this.state; 42 | 43 | return ( 44 |
45 |
46 | 47 | { 48 | imags.map((item, subIndex) => { 49 | return ( 50 |
51 | 52 |
53 | ); 54 | }) 55 | } 56 |
57 | { 58 | isOpen ? : '' 59 | } 60 |
61 | ); 62 | } 63 | } 64 | 65 | export default App; 66 | -------------------------------------------------------------------------------- /src/components/WrapViewer.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ListContainer from './ListContainer'; 5 | import Pointer from './Pointer'; 6 | 7 | const screenWidth = typeof document !== 'undefined' && document.documentElement.clientWidth; 8 | const screenHeight = typeof document !== 'undefined' && document.documentElement.clientHeight; 9 | 10 | class WrapViewer extends Component { 11 | static propTypes = { 12 | index: PropTypes.number.isRequired, // 当前显示图片的http链接 13 | urls: PropTypes.array.isRequired, // 需要预览的图片http链接列表 14 | maxZoomNum: PropTypes.number.isRequired, // 最大放大倍数 15 | zIndex: PropTypes.number.isRequired, // 组件图层深度 16 | gap: PropTypes.number.isRequired, // 间隙 17 | speed: PropTypes.number.isRequired, // Duration of transition between slides (in ms) 18 | } 19 | 20 | state = { 21 | index: 0, 22 | } 23 | 24 | componentWillMount() { 25 | const { 26 | index, 27 | } = this.props; 28 | 29 | this.setState({ 30 | index, 31 | }); 32 | } 33 | 34 | changeIndex = (index) => { 35 | console.info('changeIndex index = ', index); 36 | this.setState({ 37 | index, 38 | }); 39 | } 40 | 41 | render() { 42 | const { 43 | zIndex, 44 | urls, 45 | maxZoomNum, 46 | gap, 47 | speed, 48 | } = this.props; 49 | 50 | const { 51 | index, 52 | } = this.state; 53 | 54 | return ( 55 |
{/* root */} 56 |
57 | 67 | 68 |
69 | ); 70 | } 71 | } 72 | 73 | export default WrapViewer; 74 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "plugins": ["compat"], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "es6": true, 9 | "mocha": true, 10 | "jest": true, 11 | "jasmine": true 12 | }, 13 | "rules": { 14 | "generator-star-spacing": [0], 15 | "consistent-return": [0], 16 | "react/forbid-prop-types": [0], 17 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 18 | "global-require": [1], 19 | "import/prefer-default-export": [0], 20 | "react/jsx-no-bind": [0], 21 | "react/prop-types": [0], 22 | "react/prefer-stateless-function": [0], 23 | "react/jsx-wrap-multilines": ["error", { 24 | "declaration": "parens-new-line", 25 | "assignment": "parens-new-line", 26 | "return": "parens-new-line", 27 | "arrow": "parens-new-line", 28 | "condition": "parens-new-line", 29 | "logical": "parens-new-line", 30 | "prop": "ignore" 31 | }], 32 | "react/no-multi-comp": [0], 33 | "no-else-return": [0], 34 | "no-restricted-syntax": [0], 35 | "import/no-extraneous-dependencies": [0], 36 | "no-use-before-define": [0], 37 | "jsx-a11y/no-static-element-interactions": [0], 38 | "jsx-a11y/no-noninteractive-element-interactions": [0], 39 | "jsx-a11y/click-events-have-key-events": [0], 40 | "jsx-a11y/anchor-is-valid": [0], 41 | "no-nested-ternary": [0], 42 | "arrow-body-style": [0], 43 | "import/extensions": [0], 44 | "no-bitwise": [0], 45 | "no-cond-assign": [0], 46 | "import/no-unresolved": [0], 47 | "comma-dangle": ["error", { 48 | "arrays": "always-multiline", 49 | "objects": "always-multiline", 50 | "imports": "always-multiline", 51 | "exports": "always-multiline", 52 | "functions": "ignore" 53 | }], 54 | "object-curly-newline": [0], 55 | "function-paren-newline": [0], 56 | "no-restricted-globals": [0], 57 | "require-yield": [1], 58 | "compat/compat": "error", 59 | "no-console": [0], 60 | "no-underscore-dangle": [0] 61 | }, 62 | "parserOptions": { 63 | "ecmaFeatures": { 64 | "experimentalObjectRestSpread": true 65 | } 66 | }, 67 | "settings": { 68 | "polyfills": ["fetch", "promises"] 69 | } 70 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-wx-images-viewer", 3 | "version": "1.0.6", 4 | "description": "Images viewer is a react component use in mobile website App, that function same as Weixin native viewer.", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "webpack-dev-server --config webpack.config.js", 8 | "example": "rm -rf ./demo/* & NODE_ENV=development webpack --config webpack.config.example.js", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/react-ld/react-wx-images-viewer.git" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "photos", 18 | "viewer", 19 | "mobile" 20 | ], 21 | "author": "dainli", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/react-ld/react-wx-images-viewer/issues" 25 | }, 26 | "homepage": "https://github.com/react-ld/react-wx-images-viewer#readme", 27 | "devDependencies": { 28 | "autoprefixer": "^7.1.1", 29 | "babel-core": "^6.24.1", 30 | "babel-eslint": "^8.2.1", 31 | "babel-loader": "^7.0.0", 32 | "babel-preset-es2015": "^6.24.1", 33 | "babel-preset-react": "^6.24.1", 34 | "babel-preset-stage-1": "^6.24.1", 35 | "css-loader": "^0.28.4", 36 | "eslint": "^4.17.0", 37 | "eslint-config-airbnb": "^16.1.0", 38 | "eslint-plugin-babel": "^4.1.2", 39 | "eslint-plugin-compat": "^2.2.0", 40 | "eslint-plugin-import": "^2.8.0", 41 | "eslint-plugin-jsx-a11y": "^6.0.3", 42 | "eslint-plugin-react": "^7.6.1", 43 | "file-loader": "^0.11.1", 44 | "gulp": "^3.9.1", 45 | "gulp-babel": "^6.1.2", 46 | "gulp-clean": "^0.3.2", 47 | "gulp-gh-pages-will": "^0.5.4", 48 | "gulp-less": "^3.3.2", 49 | "gulp-util": "^3.0.8", 50 | "html-webpack-plugin": "^2.28.0", 51 | "less": "^2.7.2", 52 | "less-loader": "^4.0.4", 53 | "postcss-loader": "^2.0.5", 54 | "style-loader": "^0.18.1", 55 | "url-loader": "^0.5.8", 56 | "vinyl-ftp": "^0.6.0", 57 | "webpack": "^2.6.1", 58 | "react": "^16.0.0", 59 | "react-dom": "^16.0.0", 60 | "react-hot-loader": "^3.0.0-beta.7", 61 | "webpack-dev-server": "^2.4.5" 62 | }, 63 | "dependencies": { 64 | "prop-types": "^15.6.0", 65 | "raf": "^3.1.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /webpack.config.example.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var webpack = require('webpack') 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var port = 3010 5 | 6 | module.exports = { 7 | context: path.resolve(__dirname, 'example'), // string(绝对路径!) 8 | devtool: 'eval', 9 | cache: true, 10 | entry: [ 11 | './main.js' 12 | ], 13 | output: { 14 | path: path.join(__dirname, 'demo/'), 15 | filename: '[name].js', //.[hash] 16 | // publicPath: 'http://public.dainli.com/17zt/viewer/assets/' 17 | }, 18 | plugins: [ 19 | new webpack.DefinePlugin({ 20 | 'process.env': { 21 | NODE_ENV: JSON.stringify("development") 22 | } 23 | }), 24 | new webpack.NamedModulesPlugin(), 25 | // 当模块热替换(HMR)时在浏览器控制台输出对用户更友好的模块名字信息 26 | new webpack.optimize.UglifyJsPlugin({ 27 | sourceMap: true, 28 | compress: { 29 | warnings: true, 30 | } 31 | }), 32 | new HtmlWebpackPlugin({ 33 | title: 'Custom template', 34 | template: '../example/index.html', // Load a custom template (ejs by default see the FAQ for details) 35 | hash: true, 36 | filename:"./index.html" 37 | }) 38 | ], 39 | resolve: { 40 | modules: [ 41 | "node_modules", 42 | path.resolve(__dirname, "src") 43 | ], 44 | extensions: ['.js', '.jsx'] 45 | }, 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.(js|jsx)$/, 50 | loader: 'babel-loader' , 51 | exclude: /node_modules/, 52 | include: __dirname, 53 | options: { 54 | presets: [["es2015", {"modules": false}], "stage-1", "react"] 55 | } 56 | }, 57 | { 58 | test: /\.css$/, 59 | use: [ 60 | 'style-loader', 61 | 'css-loader', 62 | { loader: 'postcss-loader', options: { config: { path: './postcss.config.js' } } } 63 | ] 64 | }, 65 | { 66 | test: /\.less/, 67 | use: [ 68 | 'style-loader', 69 | 'css-loader', 70 | { loader: 'postcss-loader', options: { config: { path: './postcss.config.js' } } }, 71 | 'less-loader' 72 | ] 73 | }, 74 | { 75 | test: /\.(gif|jpg|png|woff|svg|eot|ttf)$/, 76 | use: [ 77 | { loader: 'file-loader'} 78 | ] 79 | } 80 | ] 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const webpack = require('webpack'); 3 | const clean = require('gulp-clean'); 4 | const gutil = require('gulp-util'); 5 | const ftp = require('vinyl-ftp'); 6 | const ghPages = require('gulp-gh-pages-will'); 7 | const deploy = require('./deploy.config.json'); 8 | 9 | const deploy_remote_path = '/public/17zt/viewer'; 10 | const webpack_config_demo = require('./webpack.config.example.js'); 11 | const babel = require('gulp-babel'); 12 | const less = require('gulp-less'); 13 | const path = require('path'); 14 | // var webpack_config_dist = require('./webpack.config.dist.js'); 15 | 16 | gulp.task('clean:demo', () => { 17 | return gulp.src('./demo', { read: false }) 18 | .pipe(clean()); 19 | }); 20 | 21 | // 编译示例 22 | gulp.task('build:demo', ['clean:demo'], (callback) => { 23 | webpack(webpack_config_demo, () => { 24 | // gulp 异步任务必须明确执行 callback() 否则 gulp 将一直卡住 25 | callback(); 26 | }); 27 | }); 28 | 29 | // 部署示例到自己的测试服务器 30 | gulp.task('deploy:demo', ['build:demo'], () => { 31 | deploy.log = gutil.log; 32 | 33 | const conn = ftp.create(deploy); 34 | 35 | return gulp.src('demo/**') 36 | .pipe(conn.dest(deploy_remote_path)); 37 | }); 38 | 39 | // 部署示例到 gh-pages 40 | gulp.task('deploy:gh-pages', ['build:demo'], () => { 41 | return gulp.src('./demo/**') 42 | .pipe(ghPages()); 43 | }); 44 | 45 | gulp.task('publish:clean', () => { 46 | return gulp.src('./dist', { read: false }) 47 | .pipe(clean()); 48 | }); 49 | 50 | // 编译 js 文件 51 | gulp.task('publish:js', ['publish:clean'], () => { 52 | return gulp.src('src/**/*.{js,jsx}') 53 | .pipe(babel({ 54 | presets: ['es2015', 'stage-1', 'react'], 55 | })) 56 | .pipe(gulp.dest('dist')); 57 | }); 58 | 59 | // 编译 less 文件 60 | gulp.task('publish:less', ['publish:clean'], () => { 61 | return gulp.src('src/**/*.less') 62 | .pipe(less({ 63 | paths: [path.join(__dirname, 'less', 'includes')], 64 | })) 65 | .pipe(gulp.dest('dist')); 66 | }); 67 | 68 | // 发布 css 文件 69 | gulp.task('publish:css', ['publish:clean'], () => { 70 | return gulp.src('src/**/*.css') 71 | .pipe(gulp.dest('dist')); 72 | }); 73 | 74 | // 打包发布 npm 75 | gulp.task('publish', ['publish:clean', 'publish:js', 'publish:css']); 76 | 77 | gulp.task('demo', ['deploy:demo']); 78 | 79 | gulp.task('gh-pages', ['deploy:gh-pages']); 80 | 81 | gulp.task('release', ['publish', 'gh-pages']); 82 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var path = require('path') 3 | var webpack = require('webpack') 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | var port = 3010; 6 | var host = '0.0.0.0' 7 | 8 | module.exports = { 9 | context: path.resolve(__dirname, 'example'), // string(绝对路径!) 10 | devtool: 'eval', 11 | cache: true, 12 | entry: [ 13 | 'react-hot-loader/patch', 14 | // 开启 React 代码的模块热替换(HMR) 15 | 'webpack-dev-server/client?http://' + host + ':' + port, 16 | 'webpack/hot/only-dev-server', 17 | './main.js' 18 | ], 19 | plugins: [ 20 | new webpack.HotModuleReplacementPlugin(), 21 | new webpack.NamedModulesPlugin(), 22 | // 当模块热替换(HMR)时在浏览器控制台输出对用户更友好的模块名字信息 23 | new webpack.optimize.UglifyJsPlugin({ 24 | sourceMap: true, 25 | compress: { 26 | warnings: true, 27 | } 28 | }), 29 | new HtmlWebpackPlugin({ 30 | title: 'Custom template', 31 | template: './index.html', // Load a custom template (ejs by default see the FAQ for details) 32 | hash: true, 33 | filename:"./index.html" 34 | }) 35 | ], 36 | resolve: { 37 | modules: [ 38 | "node_modules", 39 | path.resolve(__dirname, "src") 40 | ], 41 | extensions: ['.js', '.jsx'] 42 | }, 43 | devServer: { 44 | hot: true, 45 | // 开启服务器的模块热替换(HMR) 46 | // host: 'localhost', 47 | host: host, 48 | port: port 49 | }, 50 | module: { 51 | rules: [ 52 | { 53 | test: /\.(js|jsx)$/, 54 | loader: 'babel-loader' , 55 | exclude: /node_modules/, 56 | include: __dirname, 57 | options: { 58 | presets: [["es2015", {"modules": false}], "stage-1", "react"], 59 | plugins: [ 60 | "react-hot-loader/babel" 61 | // 开启 React 代码的模块热替换(HMR) 62 | ] 63 | } 64 | }, 65 | { 66 | test: /\.css$/, 67 | use: [ 68 | 'style-loader', 69 | 'css-loader', 70 | { loader: 'postcss-loader', options: { config: { path: './postcss.config.js' } } } 71 | ] 72 | }, 73 | { 74 | test: /\.less/, 75 | use: [ 76 | 'style-loader', 77 | 'css-loader', 78 | { loader: 'postcss-loader', options: { config: { path: './postcss.config.js' } } }, 79 | 'less-loader' 80 | ] 81 | }, 82 | { 83 | test: /\.(gif|jpg|png|woff|svg|eot|ttf)$/, 84 | use: [ 85 | { loader: 'file-loader'} 86 | ] 87 | } 88 | ] 89 | }, 90 | } 91 | -------------------------------------------------------------------------------- /src/components/WxImageViewer.css: -------------------------------------------------------------------------------- 1 | @charset 'utf-8'; 2 | .wx-image-viewer { 3 | position: fixed; 4 | left: 0; 5 | top: 0; 6 | width: 100%; 7 | height: 100%; 8 | } 9 | .wx-image-viewer .viewer-cover { 10 | position: absolute; 11 | left: 0; 12 | top: 0; 13 | width: 100%; 14 | height: 100%; 15 | background-color: #000000; 16 | } 17 | .wx-image-viewer .viewer-list-container { 18 | position: absolute; 19 | left: 0; 20 | top: 0; 21 | width: 100%; 22 | height: 100%; 23 | -webkit-transition-property: -webkit-transform; 24 | transition-property: -webkit-transform; 25 | -o-transition-property: transform; 26 | transition-property: transform; 27 | transition-property: transform,-webkit-transform; 28 | } 29 | .wx-image-viewer .viewer-image-container { 30 | position: absolute; 31 | top: 0; 32 | left: 0; 33 | overflow: hidden; 34 | } 35 | .wx-image-viewer .viewer-image-container img { 36 | position: absolute; 37 | left: 0; 38 | top: 0; 39 | transform-origin: left top; 40 | -webkit-transform-origin: left top; 41 | -moz-transform-origin: left top; 42 | -o-transform-origin: left top; 43 | -webkit-transition-property: -webkit-transform; 44 | transition-property: -webkit-transform; 45 | -o-transition-property: transform; 46 | transition-property: transform; 47 | transition-property: transform,-webkit-transform; 48 | } 49 | .wx-image-viewer .viewer-image-pointer { 50 | position: fixed; 51 | bottom: 10px; 52 | left: 0; 53 | width: 100%; 54 | text-align: center; 55 | } 56 | .wx-image-viewer .viewer-image-pointer .pointer { 57 | display: inline-block; 58 | width: 8px; 59 | height: 8px; 60 | margin: 0 5px; 61 | border-radius: 100%; 62 | background-color: #333; 63 | } 64 | .wx-image-viewer .viewer-image-pointer .pointer.on { 65 | background-color: #fff; 66 | } 67 | .wx-image-viewer .viewer-image-loading { 68 | position: absolute; 69 | margin: auto; 70 | left: 0; 71 | right: 0; 72 | top: 0; 73 | bottom: 0; 74 | width: 32px; 75 | height: 32px; 76 | box-sizing: border-box; 77 | border-radius: 100%; 78 | border-width: 4px; 79 | border-style: solid; 80 | border-color: #333; 81 | border-bottom-color: #FFF; 82 | -webkit-animation: roll 1s linear infinite; 83 | animation: roll 1s linear infinite; 84 | } 85 | @-webkit-keyframes roll { 86 | from { 87 | -webkit-transform: rotate(0deg); 88 | } 89 | to { 90 | -webkit-transform: rotate(360deg); 91 | } 92 | } 93 | @keyframes roll { 94 | from { 95 | transform: rotate(0deg); 96 | } 97 | to { 98 | transform: rotate(360deg); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [中文文档](https://github.com/react-ld/react-wx-images-viewer/blob/master/README-cn.md) 2 | 3 | # Description 4 | [react-wx-images-viewer](https://github.com/react-ld/react-wx-images-viewer/tree/master) is a React 5 | common component use for images viewer *in mobile device*. It's function look like WeChat App previewImage. 6 | Finger drag left or right to preview each image. Two finger drag zoom in or zoom out the image. 7 | 8 | # Install 9 | ```shell 10 | npm install --save react-wx-images-viewer 11 | ``` 12 | 13 | # dependence 14 | version 0.0.1 -> React ^15.5.4 15 | 16 | version ^1.0.0 -> React ^16.0.0 17 | 18 | # Example 19 | - [demo1](https://react-ld.github.io/react-wx-images-viewer/index.html) 20 | 21 | # How to use 22 | ```js 23 | import WxImageViewer from 'react-wx-images-viewer'; 24 | class App extends Component { 25 | 26 | state = { 27 | imags: [ 28 | require('./assets/2.jpg'), 29 | require('./assets/1.jpg'), 30 | require('./assets/0.jpg'), 31 | require('./assets/3.jpg'), 32 | require('./assets/4.jpg'), 33 | ], 34 | index: 0, 35 | isOpen: false 36 | }; 37 | 38 | onClose = () =>{ 39 | this.setState({ 40 | isOpen: false 41 | }) 42 | } 43 | 44 | openViewer (index){ 45 | this.setState({ 46 | index, 47 | isOpen: true 48 | }) 49 | } 50 | 51 | render() { 52 | const { 53 | imags, 54 | index, 55 | isOpen 56 | } = this.state; 57 | 58 | return ( 59 |
60 |
61 | {/*直接打开*/} 62 | 63 | { 64 | this.state.imags.map((item, index) => { 65 | return
66 | 67 |
68 | }) 69 | } 70 |
71 | { 72 | isOpen ? : "" 73 | } 74 |
75 | ) 76 | } 77 | } 78 | 79 | export default App; 80 | ``` 81 | 82 | # API: 83 | | Property | Description | Type | default | Remarks | 84 | | --- | --- | --- | --- | --- | 85 | | maxZoomNum | max zoom in times | Number | 4 | | 86 | | zIndex | the depth of the layer | Number | 100 | | 87 | | index | show which image in urls array when open | Number | 0 | | 88 | | gap | the gap between images | Number | 10 | unit is pixel | 89 | | urls | images url array | Array | | suggest the array length do not more than 10 | 90 | | onClose | handle close function | Function | | must remove WxViewer from current render and other sepcial logic | 91 | | loading | DIY loading style | component | WxImageViewer default [Loading](./src/components/Loading.jsx) | TODO | 92 | | pointer | DIY pointer | component | WxImageViewer default [Pointer](./src/components/Pointer.jsx) | TODO | 93 | 94 | # Reference 95 | - [react-modal](https://github.com/reactjs/react-modal) 96 | - [react-viewer-mobile](https://github.com/infeng/react-viewer-mobile/) 97 | - [react-image](https://github.com/mbrevda/react-image) -------------------------------------------------------------------------------- /README-cn.md: -------------------------------------------------------------------------------- 1 | # [English](https://github.com/react-ld/react-wx-images-viewer/tree/master) 2 | 3 | # 描述 4 | [react-wx-images-viewer](https://github.com/react-ld/react-wx-images-viewer/tree/master)是一个通用型的移动端图片浏览 React 组件。主要功能仿照微信图片浏览功能开发。支持单指左右滑动切换图片,双指拖拽放大缩小图片。 5 | 6 | 通过 ReactDOM 在 body 根级创建独立的 div 进行渲染,参考 [react-modal](https://github.com/reactjs/react-modal) 使用 ReactDOM.unstable_renderSubtreeIntoContainer 进行渲染 7 | 8 | # 依赖关系 9 | version 0.0.1 -> React ^15.5.4 10 | 11 | version ^1.0.0 -> React ^16.0.0 12 | 13 | # 示例 14 | - [demo1](https://react-ld.github.io/react-wx-images-viewer/index.html) 15 | 16 | # 基础功能 17 | - 多图左右切换浏览,不支持循环 18 | - 图片默认样式:水平方向与屏幕等宽,垂直方向居中或者居顶 19 | - 支持图片缩放浏览 20 | - 单指左右滑动切换图片,双指拖拽放到或缩小图片 21 | 22 | # 扩展 23 | - 有默认加载图片动效 24 | - 可配置图层深度即 zIndex 25 | - 可配置初始显示图片序号 26 | - TODO:指示器可通过 React 组件方式自定义 27 | - TODO:加载动效可通过 React 组件方式自定义 28 | 29 | # 安装 30 | ```shell 31 | npm install --save react-wx-images-viewer 32 | ``` 33 | 34 | # 使用 35 | ```js 36 | import WxImageViewer from 'react-wx-images-viewer'; 37 | class App extends Component { 38 | 39 | state = { 40 | imags: [ 41 | require('./assets/2.jpg'), 42 | require('./assets/1.jpg'), 43 | require('./assets/0.jpg'), 44 | require('./assets/3.jpg'), 45 | require('./assets/4.jpg'), 46 | ], 47 | index: 0, 48 | isOpen: false 49 | }; 50 | 51 | onClose = () =>{ 52 | this.setState({ 53 | isOpen: false 54 | }) 55 | } 56 | 57 | openViewer (index){ 58 | this.setState({ 59 | index, 60 | isOpen: true 61 | }) 62 | } 63 | 64 | render() { 65 | const { 66 | imags, 67 | index, 68 | isOpen 69 | } = this.state; 70 | 71 | return ( 72 |
73 |
74 | {/*直接打开*/} 75 | 76 | { 77 | this.state.imags.map((item, index) => { 78 | return
79 | 80 |
81 | }) 82 | } 83 |
84 | { 85 | isOpen ? : "" 86 | } 87 |
88 | ) 89 | } 90 | } 91 | 92 | export default App; 93 | ``` 94 | 95 | # 接口 96 | | Property | Description | Type | default | Remarks | 97 | | --- | --- | --- | --- | --- | 98 | | maxZoomNum | 图片放大最大倍数 | Number | 4 | | 99 | | zIndex | 组件图层深度 | Number | 100 | | 100 | | index | 初始显示图片序号 | Number | 0 | | 101 | | gap | 图片之间的间隙 | Number | 10 | unit is pixel | 102 | | urls | 图片 URL 数组 | Array | | suggest the array length do not more than 10 | 103 | | onClose | 关闭的回调处理函数 | Function | | 需要通过该函数将组件从渲染中移除 | 104 | | loading | 自定义图片加载组件 | component | WxImageViewer default [Loading](./src/components/Loading.jsx) | TODO | 105 | | pointer | 自定义指示器组件 | component | WxImageViewer default [Pointer](./src/components/Pointer.jsx) | TODO | 106 | 107 | # 参考 108 | - [react-modal](https://github.com/reactjs/react-modal) 109 | - [react-viewer-mobile](https://github.com/infeng/react-viewer-mobile/) 110 | - [react-image](https://github.com/mbrevda/react-image) -------------------------------------------------------------------------------- /src/components/ListContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import ImageContainer from './ImageContainer'; 5 | 6 | // 快速拖动时间限制 7 | const DEFAULT_TIME_DIFF = 200; 8 | 9 | class ListContainer extends PureComponent { 10 | static propTypes = { 11 | maxZoomNum: PropTypes.number.isRequired, 12 | changeIndex: PropTypes.func.isRequired, 13 | gap: PropTypes.number.isRequired, 14 | speed: PropTypes.number.isRequired, // Duration of transition between slides (in ms) 15 | } 16 | 17 | constructor() { 18 | super(); 19 | this.isNeedSpring = false; 20 | } 21 | 22 | state = { 23 | left: 0, 24 | } 25 | 26 | componentWillMount() { 27 | const { 28 | screenWidth, 29 | urls, 30 | index, 31 | gap, 32 | } = this.props; 33 | 34 | this.length = urls.length; 35 | this.perDistance = screenWidth + gap; 36 | this.maxLeft = this.perDistance * (this.length - 1); 37 | this.isNeedSpring = false; 38 | 39 | this.setState({ 40 | left: -this.perDistance * index, 41 | }); 42 | } 43 | 44 | componentWillReceiveProps(nextProps) { 45 | if (this.props.index !== nextProps.index) { 46 | this.isNeedSpring = true; 47 | this.setState({ 48 | left: -this.perDistance * nextProps.index, 49 | }); 50 | } 51 | } 52 | 53 | /** 54 | * 拖拽的缓动公式 - easeOutSine 55 | * Link http://easings.net/zh-cn# 56 | * t: current time(当前时间); 57 | * b: beginning value(初始值); 58 | * c: change in value(变化量); 59 | * d: duration(持续时间)。 60 | */ 61 | easing = (distance) => { 62 | const t = distance; 63 | const b = 0; 64 | const d = this.props.screenWidth; // 允许拖拽的最大距离 65 | const c = d / 2.5; // 提示标签最大有效拖拽距离 66 | 67 | return (c * Math.sin((t / d) * (Math.PI / 2))) + b; 68 | } 69 | 70 | handleStart = () => { 71 | // console.info("ListContainer handleStart") 72 | this.startLeft = this.state.left; 73 | this.startTime = (new Date()).getTime(); 74 | this.isNeedSpring = false; 75 | } 76 | 77 | handleMove = (diffX) => { 78 | // console.info("ListContainer handleStart diffX = %s",diffX); 79 | let nDiffx = diffX; 80 | // 限制最大 diffx 值 81 | if (Math.abs(nDiffx) > this.props.screenWidth) { 82 | if (nDiffx < 0) { nDiffx = -this.props.screenWidth; } 83 | if (nDiffx > 0) { nDiffx = this.props.screenWidth; } 84 | } 85 | 86 | if (this.state.left >= 0 && nDiffx > 0) { 87 | nDiffx = this.easing(nDiffx); 88 | } else if (this.state.left <= -this.maxLeft && nDiffx < 0) { 89 | nDiffx = -this.easing(-nDiffx); 90 | } 91 | 92 | this.setState({ 93 | left: this.startLeft + nDiffx, 94 | }); 95 | } 96 | 97 | handleEnd = (isAllowChange) => { 98 | let index; 99 | const diffTime = (new Date()).getTime() - this.startTime; 100 | console.info('handleEnd %s', isAllowChange, diffTime, this.state.left, this.startLeft, this.props.index); 101 | // 快速拖动情况下切换图片 102 | if (isAllowChange && diffTime < DEFAULT_TIME_DIFF) { 103 | if (this.state.left < this.startLeft) { 104 | index = this.props.index + 1; 105 | } else { 106 | index = this.props.index - 1; 107 | } 108 | } else { 109 | index = Math.abs(Math.round(this.state.left / this.perDistance)); 110 | } 111 | 112 | // 处理边界情况 113 | if (index < 0) { index = 0; } else if (index > this.length - 1) { index = this.length - 1; } 114 | 115 | this.setState({ 116 | left: -this.perDistance * index, 117 | }); 118 | this.isNeedSpring = true; 119 | if (index !== this.props.index) { 120 | this.props.changeIndex(index); 121 | return true; 122 | } 123 | return false; 124 | } 125 | 126 | render() { 127 | const { 128 | maxZoomNum, 129 | screenWidth, 130 | screenHeight, 131 | urls, 132 | speed, 133 | } = this.props; 134 | 135 | const { 136 | left, 137 | } = this.state; 138 | 139 | const defaultStyle = {}; 140 | 141 | if (this.isNeedSpring) { 142 | const duration = `${speed}ms`; 143 | defaultStyle.WebkitTransitionDuration = duration; 144 | defaultStyle.transitionDuration = duration; 145 | } 146 | const translate = `translate3d(${left}px, 0, 0)`; 147 | defaultStyle.WebkitTransform = translate; 148 | defaultStyle.transform = translate; 149 | 150 | return ( 151 |
155 | { 156 | urls.map((item, i) => ( 157 | 168 | )) 169 | } 170 |
171 | ); 172 | } 173 | } 174 | 175 | export default ListContainer; 176 | -------------------------------------------------------------------------------- /src/components/tween.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 'use strict'; 3 | /** 4 | * t: current time(当前时间); 5 | * b: beginning value(初始值); 6 | * c: change in value(变化量); 7 | * _c: final value (最后值) 8 | * d: duration(持续时间)。 9 | */ 10 | var tweenFunctions = { 11 | linear: function (t, b, _c, d) { 12 | var c = _c - b; 13 | return c * t / d + b; 14 | }, 15 | easeInQuad: function (t, b, _c, d) { 16 | var c = _c - b; 17 | return c * (t /= d) * t + b; 18 | }, 19 | easeOutQuad: function (t, b, _c, d) { 20 | var c = _c - b; 21 | return -c * (t /= d) * (t - 2) + b; 22 | }, 23 | easeInOutQuad: function (t, b, _c, d) { 24 | var c = _c - b; 25 | if ((t /= d / 2) < 1) { 26 | return c / 2 * t * t + b; 27 | } else { 28 | return -c / 2 * ((--t) * (t - 2) - 1) + b; 29 | } 30 | }, 31 | easeInCubic: function (t, b, _c, d) { 32 | var c = _c - b; 33 | return c * (t /= d) * t * t + b; 34 | }, 35 | easeOutCubic: function (t, b, _c, d) { 36 | var c = _c - b; 37 | return c * ((t = t / d - 1) * t * t + 1) + b; 38 | }, 39 | easeInOutCubic: function (t, b, _c, d) { 40 | var c = _c - b; 41 | if ((t /= d / 2) < 1) { 42 | return c / 2 * t * t * t + b; 43 | } else { 44 | return c / 2 * ((t -= 2) * t * t + 2) + b; 45 | } 46 | }, 47 | easeInQuart: function (t, b, _c, d) { 48 | var c = _c - b; 49 | return c * (t /= d) * t * t * t + b; 50 | }, 51 | easeOutQuart: function (t, b, _c, d) { 52 | var c = _c - b; 53 | return -c * ((t = t / d - 1) * t * t * t - 1) + b; 54 | }, 55 | easeInOutQuart: function (t, b, _c, d) { 56 | var c = _c - b; 57 | if ((t /= d / 2) < 1) { 58 | return c / 2 * t * t * t * t + b; 59 | } else { 60 | return -c / 2 * ((t -= 2) * t * t * t - 2) + b; 61 | } 62 | }, 63 | easeInQuint: function (t, b, _c, d) { 64 | var c = _c - b; 65 | return c * (t /= d) * t * t * t * t + b; 66 | }, 67 | easeOutQuint: function (t, b, _c, d) { 68 | var c = _c - b; 69 | return c * ((t = t / d - 1) * t * t * t * t + 1) + b; 70 | }, 71 | easeInOutQuint: function (t, b, _c, d) { 72 | var c = _c - b; 73 | if ((t /= d / 2) < 1) { 74 | return c / 2 * t * t * t * t * t + b; 75 | } else { 76 | return c / 2 * ((t -= 2) * t * t * t * t + 2) + b; 77 | } 78 | }, 79 | easeInSine: function (t, b, _c, d) { 80 | var c = _c - b; 81 | return -c * Math.cos(t / d * (Math.PI / 2)) + c + b; 82 | }, 83 | easeOutSine: function (t, b, _c, d) { 84 | var c = _c - b; 85 | return c * Math.sin(t / d * (Math.PI / 2)) + b; 86 | }, 87 | easeInOutSine: function (t, b, _c, d) { 88 | var c = _c - b; 89 | return -c / 2 * (Math.cos(Math.PI * t / d) - 1) + b; 90 | }, 91 | easeInExpo: function (t, b, _c, d) { 92 | var c = _c - b; 93 | return (t == 0) ? b : c * Math.pow(2, 10 * (t / d - 1)) + b; 94 | }, 95 | easeOutExpo: function (t, b, _c, d) { 96 | var c = _c - b; 97 | return (t == d) ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b; 98 | }, 99 | easeInOutExpo: function (t, b, _c, d) { 100 | var c = _c - b; 101 | if (t === 0) { 102 | return b; 103 | } 104 | if (t === d) { 105 | return b + c; 106 | } 107 | if ((t /= d / 2) < 1) { 108 | return c / 2 * Math.pow(2, 10 * (t - 1)) + b; 109 | } else { 110 | return c / 2 * (-Math.pow(2, -10 * --t) + 2) + b; 111 | } 112 | }, 113 | easeInCirc: function (t, b, _c, d) { 114 | var c = _c - b; 115 | return -c * (Math.sqrt(1 - (t /= d) * t) - 1) + b; 116 | }, 117 | easeOutCirc: function (t, b, _c, d) { 118 | var c = _c - b; 119 | return c * Math.sqrt(1 - (t = t / d - 1) * t) + b; 120 | }, 121 | easeInOutCirc: function (t, b, _c, d) { 122 | var c = _c - b; 123 | if ((t /= d / 2) < 1) { 124 | return -c / 2 * (Math.sqrt(1 - t * t) - 1) + b; 125 | } else { 126 | return c / 2 * (Math.sqrt(1 - (t -= 2) * t) + 1) + b; 127 | } 128 | }, 129 | easeInElastic: function (t, b, _c, d) { 130 | var c = _c - b; 131 | var a, p, s; 132 | s = 1.70158; 133 | p = 0; 134 | a = c; 135 | if (t === 0) { 136 | return b; 137 | } else if ((t /= d) === 1) { 138 | return b + c; 139 | } 140 | if (!p) { 141 | p = d * 0.3; 142 | } 143 | if (a < Math.abs(c)) { 144 | a = c; 145 | s = p / 4; 146 | } else { 147 | s = p / (2 * Math.PI) * Math.asin(c / a); 148 | } 149 | return -(a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b; 150 | }, 151 | easeOutElastic: function (t, b, _c, d) { 152 | var c = _c - b; 153 | var a, p, s; 154 | s = 1.70158; 155 | p = 0; 156 | a = c; 157 | if (t === 0) { 158 | return b; 159 | } else if ((t /= d) === 1) { 160 | return b + c; 161 | } 162 | if (!p) { 163 | p = d * 0.3; 164 | } 165 | if (a < Math.abs(c)) { 166 | a = c; 167 | s = p / 4; 168 | } else { 169 | s = p / (2 * Math.PI) * Math.asin(c / a); 170 | } 171 | return a * Math.pow(2, -10 * t) * Math.sin((t * d - s) * (2 * Math.PI) / p) + c + b; 172 | }, 173 | easeInOutElastic: function (t, b, _c, d) { 174 | var c = _c - b; 175 | var a, p, s; 176 | s = 1.70158; 177 | p = 0; 178 | a = c; 179 | if (t === 0) { 180 | return b; 181 | } else if ((t /= d / 2) === 2) { 182 | return b + c; 183 | } 184 | if (!p) { 185 | p = d * (0.3 * 1.5); 186 | } 187 | if (a < Math.abs(c)) { 188 | a = c; 189 | s = p / 4; 190 | } else { 191 | s = p / (2 * Math.PI) * Math.asin(c / a); 192 | } 193 | if (t < 1) { 194 | return -0.5 * (a * Math.pow(2, 10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p)) + b; 195 | } else { 196 | return a * Math.pow(2, -10 * (t -= 1)) * Math.sin((t * d - s) * (2 * Math.PI) / p) * 0.5 + c + b; 197 | } 198 | }, 199 | easeInBack: function (t, b, _c, d, s) { 200 | var c = _c - b; 201 | if (s === void 0) { 202 | s = 1.70158; 203 | } 204 | return c * (t /= d) * t * ((s + 1) * t - s) + b; 205 | }, 206 | easeOutBack: function (t, b, _c, d, s) { 207 | var c = _c - b; 208 | if (s === void 0) { 209 | s = 1.70158; 210 | } 211 | return c * ((t = t / d - 1) * t * ((s + 1) * t + s) + 1) + b; 212 | }, 213 | easeInOutBack: function (t, b, _c, d, s) { 214 | var c = _c - b; 215 | if (s === void 0) { 216 | s = 1.70158; 217 | } 218 | if ((t /= d / 2) < 1) { 219 | return c / 2 * (t * t * (((s *= 1.525) + 1) * t - s)) + b; 220 | } else { 221 | return c / 2 * ((t -= 2) * t * (((s *= 1.525) + 1) * t + s) + 2) + b; 222 | } 223 | }, 224 | easeInBounce: function (t, b, _c, d) { 225 | var c = _c - b; 226 | var v; 227 | v = tweenFunctions.easeOutBounce(d - t, 0, c, d); 228 | return c - v + b; 229 | }, 230 | easeOutBounce: function (t, b, _c, d) { 231 | var c = _c - b; 232 | if ((t /= d) < 1 / 2.75) { 233 | return c * (7.5625 * t * t) + b; 234 | } else if (t < 2 / 2.75) { 235 | return c * (7.5625 * (t -= 1.5 / 2.75) * t + 0.75) + b; 236 | } else if (t < 2.5 / 2.75) { 237 | return c * (7.5625 * (t -= 2.25 / 2.75) * t + 0.9375) + b; 238 | } else { 239 | return c * (7.5625 * (t -= 2.625 / 2.75) * t + 0.984375) + b; 240 | } 241 | }, 242 | easeInOutBounce: function (t, b, _c, d) { 243 | var c = _c - b; 244 | var v; 245 | if (t < d / 2) { 246 | v = tweenFunctions.easeInBounce(t * 2, 0, c, d); 247 | return v * 0.5 + b; 248 | } else { 249 | v = tweenFunctions.easeOutBounce(t * 2 - d, 0, c, d); 250 | return v * 0.5 + c * 0.5 + b; 251 | } 252 | } 253 | }; 254 | 255 | module.exports = tweenFunctions; -------------------------------------------------------------------------------- /src/components/ImageContainer.jsx: -------------------------------------------------------------------------------- 1 | /* eslint max-len: [0] */ 2 | 3 | import React, { PureComponent } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import raf from 'raf'; 7 | import tween from './tween.js'; 8 | import Loading from './Loading'; 9 | 10 | /** 11 | * 12 | * @param {number} value 13 | * @param {number} min 14 | * @param {number} max 15 | */ 16 | function setScope(value, min, max) { 17 | if (value < min) { return min; } 18 | if (value > max) { return max; } 19 | return value; 20 | } 21 | 22 | 23 | function getDistanceBetweenTouches(e) { 24 | if (e.touches.length < 2) return 1; 25 | const x1 = e.touches[0].clientX; 26 | const y1 = e.touches[0].clientY; 27 | const x2 = e.touches[1].clientX; 28 | const y2 = e.touches[1].clientY; 29 | const distance = Math.sqrt(((x2 - x1) ** 2) + ((y2 - y1) ** 2)); 30 | return distance; 31 | } 32 | 33 | // const msPerFrame = 1000 / 60; 34 | const maxAnimateTime = 1000; 35 | const minTapMoveValue = 5; 36 | const maxTapTimeValue = 300; 37 | 38 | /** 39 | * 图片默认展示模式:宽度等于屏幕宽度,高度等比缩放;水平居中,垂直居中或者居顶(当高度大于屏幕高度时) 40 | * 图片实际尺寸: actualWith, actualHeight 41 | * 图片初始尺寸: originWidth, originHeight 42 | * 坐标位置:left, top 43 | * 放大倍数:zoom 44 | * 最大放大倍数:maxZoomNum 45 | * 坐标关系:-(maxZoomNum - 1) * originWidth / 2 < left < 0 46 | * -(maxZoomNum - 1) * originHeight / 2 < top < 0 47 | * 尺寸关系:width = zoom * originWidth 48 | * heigth = zoom * originHeight 49 | * 50 | * 放大点位置关系: 51 | * 初始点位置:oldPointLeft, oldPointTop 52 | * 放大后位置:newPointLeft, newPointTop 53 | * 对应关系: newPointLeft = zoom * oldPointLeft 54 | * newPointTop = zoom * oldPointTop 55 | * 56 | * 坐标位置:-1*left = -1*startLeft + (newPointLeft - oldPointLeft) =-1*startLeft (zoom - 1) * oldPointLeft 57 | * -1*top = -1*startTop + (newPointTop - oldPointTop) =-1*startLeft (zoom - 1) * oldPointTop 58 | * => 59 | * left = startLeft + (1 - zoom) * oldPointLeft 60 | * top = startTop + (1 - zoom) * oldPointTop 61 | */ 62 | 63 | class ImageContainer extends PureComponent { 64 | static propTypes = { 65 | maxZoomNum: PropTypes.number.isRequired, 66 | handleStart: PropTypes.func.isRequired, 67 | handleMove: PropTypes.func.isRequired, 68 | handleEnd: PropTypes.func.isRequired, 69 | } 70 | 71 | static contextTypes = { 72 | onClose: PropTypes.func, 73 | }; 74 | 75 | constructor() { 76 | super(); 77 | this.actualHeight = 0; // 图片实际高度 78 | this.actualWith = 0; // 图片实际宽度 79 | 80 | this.originHeight = 0; // 图片默认展示模式下高度 81 | this.originWidth = 0; // 图片默认展示模式下宽度 82 | this.originScale = 1; // 图片初始缩放比例 83 | 84 | this.startLeft = 0; // 开始触摸操作时的 left 值 85 | this.startTop = 0; // 开始触摸操作时的 top 值 86 | this.startScale = 1; // 开始缩放操作时的 scale 值 87 | 88 | this.onTouchStartTime = 0; // 单指触摸开始时间 89 | 90 | this.isTwoFingerMode = false; // 是否为双指模式 91 | this.oldPointLeft = 0;// 计算手指中间点在图片上的位置(坐标值) 92 | this.oldPointTop = 0;// 计算手指中间点在图片上的位置(坐标值) 93 | this._touchZoomDistanceStart = 0; // 用于记录双指距离 94 | this.haveCallMoveFn = false; 95 | 96 | 97 | this.diffX = 0;// 记录最后 move 事件 移动距离 98 | this.diffY = 0;// 记录最后 move 事件 移动距离 99 | 100 | this.animationID = 0; 101 | this.animateStartTime = 0; 102 | this.animateStartValue = { 103 | x: 0, 104 | y: 0, 105 | }; 106 | this.animateFinalValue = { 107 | x: 0, 108 | y: 0, 109 | }; 110 | } 111 | 112 | state = { 113 | width: 0, 114 | height: 0, 115 | scale: 1, 116 | left: 0, 117 | top: 0, 118 | isLoaded: false, 119 | } 120 | 121 | componentWillMount() { 122 | this.loadImg(this.props.src); 123 | } 124 | 125 | componentWillUnmount() { 126 | this.unloadImg(); 127 | if (this.animationID) { 128 | raf.cancel(this.animationID); 129 | } 130 | } 131 | 132 | onLoad = () => { 133 | this.actualWith = this.img.width; 134 | this.actualHeight = this.img.height; 135 | 136 | const { 137 | screenHeight, 138 | screenWidth, 139 | } = this.props; 140 | 141 | const left = 0; 142 | let top = 0; 143 | 144 | this.originWidth = screenWidth; 145 | this.originHeight = (this.actualHeight / this.actualWith) * screenWidth; 146 | this.originScale = 1; 147 | 148 | if (this.actualHeight / this.actualWith < screenHeight / screenWidth) { 149 | top = parseInt((screenHeight - this.originHeight) / 2, 10); 150 | } 151 | this.originTop = top; 152 | 153 | this.setState({ 154 | width: this.originWidth, 155 | height: this.originHeight, 156 | scale: 1, 157 | left, 158 | top, 159 | isLoaded: true, 160 | }); 161 | } 162 | 163 | onError = () => { 164 | this.setState({ 165 | isLoaded: true, 166 | }); 167 | } 168 | 169 | loadImg = (url) => { 170 | this.img = new Image(); 171 | this.img.src = url; 172 | this.img.onload = this.onLoad; 173 | this.img.onerror = this.onError; 174 | 175 | this.setState({ 176 | isLoaded: false, 177 | }); 178 | } 179 | 180 | unloadImg = () => { 181 | delete this.img.onerror; 182 | delete this.img.onload; 183 | delete this.img.src; 184 | delete this.img; 185 | } 186 | 187 | handleTouchStart = (event) => { 188 | console.info('handleTouchStart'); 189 | event.preventDefault(); 190 | if (this.animationID) { 191 | raf.cancel(this.animationID); 192 | } 193 | switch (event.touches.length) { 194 | case 1: { 195 | const targetEvent = event.touches[0]; 196 | this.startX = targetEvent.clientX; 197 | this.startY = targetEvent.clientY; 198 | this.diffX = 0; 199 | this.diffY = 0; 200 | 201 | this.startLeft = this.state.left; 202 | this.startTop = this.state.top; 203 | 204 | console.info('handleTouchStart this.startX = %s, this.startY = %s, this.startLeft = %s, this.startTop = %s', this.startX, this.startY, this.startLeft, this.startTop); 205 | 206 | this.onTouchStartTime = (new Date()).getTime(); 207 | this.haveCallMoveFn = false; 208 | break; 209 | } 210 | case 2: { // 两个手指 211 | // 设置手双指模式 212 | this.isTwoFingerMode = true; 213 | 214 | // 计算两个手指中间点屏幕上的坐标 215 | const middlePointClientLeft = Math.abs(Math.round((event.touches[0].clientX + event.touches[1].clientX) / 2)); 216 | const middlePointClientTop = Math.abs(Math.round((event.touches[0].clientY + event.touches[1].clientY) / 2)); 217 | 218 | // 保存图片初始位置和尺寸 219 | this.startLeft = this.state.left; 220 | this.startTop = this.state.top; 221 | this.startScale = this.state.scale; 222 | 223 | // 计算手指中间点在图片上的位置(坐标值) 224 | this.oldPointLeft = middlePointClientLeft - this.startLeft; 225 | this.oldPointTop = middlePointClientTop - this.startTop; 226 | 227 | this._touchZoomDistanceStart = getDistanceBetweenTouches(event); 228 | break; 229 | } 230 | default: 231 | break; 232 | } 233 | } 234 | 235 | handleTouchMove = (event) => { 236 | event.preventDefault(); 237 | 238 | switch (event.touches.length) { 239 | case 1: { 240 | const targetEvent = event.touches[0]; 241 | const diffX = targetEvent.clientX - this.startX; 242 | const diffY = targetEvent.clientY - this.startY; 243 | 244 | this.diffX = diffX; 245 | this.diffY = diffY; 246 | console.info('handleTouchMove one diffX=%s, diffY=%s', diffX, diffY); 247 | // 判断是否为点击 248 | if (Math.abs(diffX) < minTapMoveValue && Math.abs(diffY) < minTapMoveValue) { 249 | return; 250 | } 251 | 252 | const { scale, left } = this.state; 253 | const width = scale * this.originWidth; 254 | if (Math.abs(diffX) > Math.abs(diffY)) { 255 | // 水平移动 256 | if (this.state.scale === this.originScale && Math.abs(diffX) > minTapMoveValue) { 257 | this.haveCallMoveFn = true; 258 | this.callHandleMove(diffX); 259 | return; 260 | } 261 | 262 | console.info('handleMove one left=%s, this.startLeft=%s,this.originWidth=%s, width=%s', left, this.startLeft, this.originWidth, width); 263 | if (diffX < 0 && this.startLeft <= this.originWidth - width) { 264 | this.haveCallMoveFn = true; 265 | this.callHandleMove(diffX); 266 | return; 267 | } 268 | 269 | if (diffX > 0 && this.startLeft >= 0) { 270 | this.haveCallMoveFn = true; 271 | this.callHandleMove(diffX); 272 | return; 273 | } 274 | } 275 | 276 | const { screenHeight } = this.props; 277 | const height = scale * this.originHeight; 278 | let newTop = (screenHeight - height) / 2; 279 | const newLeft = this.startLeft + diffX; 280 | 281 | if (height > screenHeight || this.state.scale === this.originScale) { 282 | newTop = this.startTop + diffY; 283 | } 284 | console.info('handleTouchMove one newLeft=%s, newTop=%s', newLeft, newTop); 285 | this.setState({ 286 | left: newLeft, 287 | top: newTop, 288 | }); 289 | 290 | break; 291 | } 292 | case 2: { // 两个手指 293 | this._touchZoomDistanceEnd = getDistanceBetweenTouches(event); 294 | 295 | const zoom = Math.sqrt(this._touchZoomDistanceEnd / this._touchZoomDistanceStart); 296 | const scale = zoom * this.startScale; 297 | 298 | this.setState(() => { 299 | const left = this.startLeft + ((1 - zoom) * this.oldPointLeft); 300 | const top = this.startTop + ((1 - zoom) * this.oldPointTop); 301 | 302 | console.info('zoom = %s, left = %s, top = %s, scale', zoom, left, top, scale); 303 | return { 304 | left, 305 | top, 306 | scale, 307 | }; 308 | }); 309 | break; 310 | } 311 | default: 312 | break; 313 | } 314 | } 315 | 316 | handleTouchEnd = (event) => { 317 | console.info('handleTouchEnd', event.touches.length); 318 | event.preventDefault(); 319 | 320 | if (this.isTwoFingerMode) { // 双指操作结束 321 | const touchLen = event.touches.length; 322 | this.isTwoFingerMode = false; 323 | 324 | if (touchLen === 1) { 325 | const targetEvent = event.touches[0]; 326 | this.startX = targetEvent.clientX; 327 | this.startY = targetEvent.clientY; 328 | this.diffX = 0; 329 | this.diffY = 0; 330 | } 331 | 332 | this.setState((prevState, props) => { 333 | const scale = setScope(prevState.scale, 1, props.maxZoomNum); 334 | const width = scale * this.originWidth; 335 | const height = scale * this.originHeight; 336 | const zoom = scale / this.startScale; 337 | const left = setScope(this.startLeft + ((1 - zoom) * this.oldPointLeft), this.originWidth - width, 0); 338 | 339 | let top; 340 | if (height > props.screenHeight) { 341 | top = setScope(this.startTop + ((1 - zoom) * this.oldPointTop), props.screenHeight - height, 0); 342 | } else { 343 | top = (props.screenHeight - height) / 2; 344 | } 345 | 346 | if (touchLen === 1) { 347 | this.startLeft = left; 348 | this.startTop = top; 349 | this.startScale = scale; 350 | console.info('this.startX = %s, this.startY = %s, this.startLeft = %s, this.startTop = %s', this.startX, this.startY, this.startLeft, this.startTop); 351 | } 352 | 353 | console.info('zoom = %s, left = %s, top = %s, width=%s, height= %s', zoom, left, top, width, height); 354 | return { 355 | left, 356 | top, 357 | scale, 358 | }; 359 | }); 360 | } else { // 单指结束(ontouchend) 361 | const diffTime = (new Date()).getTime() - this.onTouchStartTime; 362 | const { diffX, diffY } = this; 363 | 364 | console.info('handleTouchEnd one diffTime = %s, diffX = %s, diffy = %s', diffTime, diffX, diffY); 365 | // 判断为点击则关闭图片浏览组件 366 | if (diffTime < maxTapTimeValue && Math.abs(diffX) < minTapMoveValue && Math.abs(diffY) < minTapMoveValue) { 367 | this.context.onClose(); 368 | return; 369 | } 370 | 371 | // 水平移动 372 | if (this.haveCallMoveFn) { 373 | const isChangeImage = this.callHandleEnd(diffY < 30); 374 | if (isChangeImage) { // 如果切换图片则重置当前图片状态 375 | setTimeout(() => { 376 | this.setState({ 377 | scale: this.originScale, 378 | left: 0, 379 | top: this.originTop, 380 | }); 381 | }, maxAnimateTime / 3); 382 | return; 383 | } 384 | } 385 | // TODO 下拉移动距离超过屏幕高度的 1/3 则关闭 386 | // console.info(Math.abs(diffY) > (this.props.screenHeight / 2), this.startTop, this.originTop); 387 | // if (Math.abs(diffX) < Math.abs(diffY) && Math.abs(diffY) > (this.props.screenHeight / 3) && this.startTop === this.originTop) { 388 | // this.context.onClose(); 389 | // return; 390 | // } 391 | 392 | let x; 393 | let y; 394 | const { scale } = this.state; 395 | // const width = scale * this.originWidth; 396 | const height = scale * this.originHeight; 397 | 398 | // 使用相同速度算法 399 | x = ((diffX * maxAnimateTime) / diffTime) + this.startLeft; 400 | y = ((diffY * maxAnimateTime) / diffTime) + this.startTop; 401 | 402 | if (this.state.scale === this.originScale) { 403 | x = 0; 404 | if (height > this.props.screenHeight) { 405 | y = setScope(y, this.props.screenHeight - height, 0); 406 | } else { 407 | y = this.originTop; 408 | } 409 | } 410 | 411 | // x = setScope(x, this.originWidth - width, 0); 412 | 413 | // if (height > this.props.screenHeight) { 414 | // y = setScope(y, this.props.screenHeight - height, 0); 415 | // } else { 416 | // y = this.state.top; 417 | // } 418 | 419 | this.animateStartValue = { 420 | x: this.state.left, 421 | y: this.state.top, 422 | }; 423 | this.animateFinalValue = { 424 | x, 425 | y, 426 | }; 427 | this.animateStartTime = Date.now(); 428 | this.startAnimate(); 429 | } 430 | } 431 | 432 | startAnimate = () => { 433 | this.animationID = raf(() => { 434 | // calculate current time 435 | const curTime = Date.now() - this.animateStartTime; 436 | let left; 437 | let top; 438 | 439 | // animate complete 440 | if (curTime > maxAnimateTime) { 441 | this.setState((prevState, props) => { 442 | const width = prevState.scale * this.originWidth; 443 | const height = prevState.scale * this.originHeight; 444 | left = setScope(prevState.left, this.originWidth - width, 0); 445 | 446 | if (height > props.screenHeight) { 447 | top = setScope(prevState.top, props.screenHeight - height, 0); 448 | } else { 449 | top = (props.screenHeight - height) / 2; 450 | } 451 | console.info('end animate left= %s, top = %s', left, top); 452 | return { 453 | left, 454 | top, 455 | }; 456 | }); 457 | } else { 458 | left = tween.easeOutQuart(curTime, this.animateStartValue.x, this.animateFinalValue.x, maxAnimateTime); 459 | top = tween.easeOutQuart(curTime, this.animateStartValue.y, this.animateFinalValue.y, maxAnimateTime); 460 | 461 | console.info('startAnimate left= %s, top = %s, curTime = %s', left, top, curTime); 462 | this.setState({ 463 | left, 464 | top, 465 | }); 466 | this.startAnimate(); 467 | } 468 | }); 469 | } 470 | 471 | callHandleMove = (diffX) => { 472 | if (!this.isCalledHandleStart) { 473 | this.isCalledHandleStart = true; 474 | if (this.props.handleStart) { 475 | this.props.handleStart(); 476 | } 477 | } 478 | this.props.handleMove(diffX); 479 | } 480 | 481 | callHandleEnd = (isAllowChange) => { 482 | if (this.isCalledHandleStart) { 483 | this.isCalledHandleStart = false; 484 | if (this.props.handleEnd) { 485 | return this.props.handleEnd(isAllowChange); 486 | } 487 | } 488 | } 489 | 490 | render() { 491 | const { 492 | screenWidth, 493 | screenHeight, 494 | src, 495 | left: divLeft, 496 | } = this.props; 497 | 498 | const { 499 | isLoaded, 500 | left, 501 | top, 502 | scale, 503 | width, 504 | height, 505 | } = this.state; 506 | 507 | const ImageStyle = { 508 | width, 509 | height, 510 | }; 511 | 512 | const translate = `translate3d(${left}px, ${top}px, 0) scale(${scale})`; 513 | ImageStyle.WebkitTransform = translate; 514 | ImageStyle.transform = translate; 515 | 516 | const defaultStyle = { 517 | left: divLeft, 518 | width: screenWidth, 519 | height: screenHeight, 520 | }; 521 | // console.info('ImageContainer render'); 522 | return ( 523 |
530 | { 531 | isLoaded ? : 532 | } 533 |
534 | ); 535 | } 536 | } 537 | 538 | export default ImageContainer; 539 | --------------------------------------------------------------------------------