MinJieLiu 7 роки тому
батько
коміт
dc11408721
9 змінених файлів з 294 додано та 13 видалено
  1. 1 1
      .prettierrc
  2. 2 1
      examples/simple.html
  3. 7 11
      examples/simple.tsx
  4. 1 0
      package.json
  5. 61 0
      src/Photo.tsx
  6. 157 0
      src/PhotoView.tsx
  7. 22 0
      src/Spinner.tsx
  8. 1 0
      src/index.tsx
  9. 42 0
      tslint.json

+ 1 - 1
.prettierrc

@@ -1,5 +1,5 @@
 {
   "singleQuote": true,
-  "printWidth": 100,
+  "printWidth": 80,
   "trailingComma": "all"
 }

+ 2 - 1
examples/simple.html

@@ -5,10 +5,11 @@
   <title>React 图片预览组件 - Example</title>
   <meta http-equiv="X-UA-Compatible" content="IE=edge, chrome=1" />
   <meta name="renderer" content="webkit" />
+  <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1,maximum-scale=1,minimum-scale=1">
   <link href="./simple.css" type="text/css" rel="stylesheet" />
 </head>
 <body>
 <div id="root"></div>
-<script src="simple.tsx"></script>
+<script src="simple.js"></script>
 </body>
 </html>

+ 7 - 11
examples/simple.tsx

@@ -2,31 +2,27 @@ import 'babel-polyfill';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import styled from 'styled-components';
+import { PhotoView } from '../src';
 
 const Container = styled.div`
-  padding: 0.4rem 0;
-  font-size: 0.32rem;
+  font-size: 32px;
 `;
 
 const Header = styled.header`
-  padding: 0.2rem;
-  font-size: 0.35rem;
-  border-bottom: 1px solid #CCC;
+  padding: 40px;
+  font-size: 32px;
+  border-bottom: 1px solid #ccc;
 `;
 
 class Example extends React.Component {
-
   render() {
-
     return (
       <Container>
         <Header>React 图片预览组件</Header>
+        <PhotoView src="https://assets-cdn.github.com/images/modules/explore/resources/campus_experts.png" />
       </Container>
     );
   }
 }
 
-ReactDOM.render(
-  <Example />,
-  document.getElementById('root'),
-);
+ReactDOM.render(<Example />, document.getElementById('root'));

+ 1 - 0
package.json

@@ -45,6 +45,7 @@
     "react-dom": "^16.4.1"
   },
   "dependencies": {
+    "lodash.throttle": "^4.1.1",
     "styled-components": "^3.0.1"
   }
 }

+ 61 - 0
src/Photo.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import styled from 'styled-components';
+import Spinner from './Spinner';
+
+export interface IPhotoProps {
+  src: string;
+  loadingElement?: JSX.Element;
+  brokenElement?: JSX.Element;
+}
+
+type ImageProps = {
+  loaded: boolean;
+};
+
+type PhotoState = ImageProps & {
+  broken: boolean;
+};
+
+const Image = styled.img<ImageProps>`
+  opacity: ${props => +props.loaded};
+  transition: opacity 0.4s ease-out;
+`;
+
+export default class Photo extends React.Component<IPhotoProps, PhotoState> {
+  static displayName = 'Photo';
+
+  readonly state = {
+    loaded: false,
+    broken: false,
+  };
+
+  handleImageLoaded = () => {
+    this.setState({
+      loaded: true,
+    });
+  }
+
+  handleImageBroken = () => {
+    this.setState({
+      broken: true,
+    });
+  }
+
+  render() {
+    const { src, loadingElement, brokenElement, ...restProps } = this.props;
+    const { loaded, broken } = this.state;
+
+    return src && !broken ? (
+      <React.Fragment>
+        {loaded ? undefined : loadingElement || <Spinner fill="white" />}
+        <Image
+          src={src}
+          {...restProps}
+          loaded={loaded}
+          onLoad={this.handleImageLoaded}
+          onError={this.handleImageBroken}
+        />
+      </React.Fragment>
+    ) : brokenElement || null;
+  }
+}

+ 157 - 0
src/PhotoView.tsx

@@ -0,0 +1,157 @@
+import React from 'react';
+import styled from 'styled-components';
+import throttle from 'lodash.throttle';
+import Photo from './Photo';
+
+export interface IPhotoViewProps {
+  src: string;
+}
+
+type PhotoViewState = {
+  x: number;
+  y: number;
+  scale: number;
+  touched: boolean;
+
+  pageX: number;
+  pageY: number;
+  offsetX: number;
+  offsetY: number;
+};
+
+interface DragPhotoProps extends React.HTMLAttributes<any> {}
+
+const PhotoContainer = styled.section`
+  position: fixed;
+  top: 0;
+  left: 0;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+  z-index: 2000;
+  overflow: hidden;
+`;
+
+const Backdrop = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.4);
+  z-index: -1;
+`;
+
+const DragPhoto = styled(Photo)<DragPhotoProps>`
+  will-change: transform;
+`;
+
+export default class PhotoView extends React.Component<
+  IPhotoViewProps,
+  PhotoViewState
+> {
+  readonly state = {
+    x: 0,
+    y: 0,
+    scale: 1,
+    touched: false,
+
+    pageX: 0,
+    pageY: 0,
+    offsetX: 0,
+    offsetY: 0,
+  };
+
+  constructor(props) {
+    super(props);
+    this.handleMove = throttle(this.handleMove, 8);
+  }
+
+  componentDidMount() {
+    window.addEventListener('touchmove', this.handleTouchMove);
+    window.addEventListener('touchend', this.handleMouseUp);
+    window.addEventListener('mousemove', this.handleMouseMove);
+    window.addEventListener('mouseup', this.handleMouseUp);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('touchmove', this.handleTouchMove);
+    window.removeEventListener('touchend', this.handleMouseUp);
+    window.removeEventListener('mousemove', this.handleMouseMove);
+    window.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleStart = e => {
+    const { pageX, pageY } = e;
+    this.setState(prevState => ({
+      touched: true,
+      pageX,
+      pageY,
+      offsetX: prevState.x,
+      offsetY: prevState.y,
+    }));
+  }
+
+  handleMove = (pageX, pageY) => {
+    if (this.state.touched) {
+      this.setState((prevState) => ({
+        x: pageX - prevState.pageX + prevState.offsetX,
+        y: pageY - prevState.pageY + prevState.offsetY,
+      }));
+    }
+  }
+
+  handleDoubleClick = () => {
+    this.setState(prevState => ({
+      scale: prevState.scale > 1 ? 1 : 2,
+    }));
+  }
+
+  handleTouchStart = e => {
+    this.handleStart(e.touches[0]);
+  }
+
+  handleMouseDown = e => {
+    this.handleStart(e);
+  }
+
+  handleTouchMove = evt => {
+    const e = evt.touches[0];
+    this.handleMove(e.pageX, e.pageY);
+  }
+
+  handleMouseMove = e => {
+    e.preventDefault();
+    this.handleMove(e.pageX, e.pageY);
+  }
+
+  handleMouseUp = () => {
+    this.setState({
+      touched: false,
+    });
+  }
+
+  render() {
+    const { src } = this.props;
+    const { x, y, scale } = this.state;
+    const transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
+
+    return (
+      <PhotoContainer>
+        <Backdrop />
+        <DragPhoto
+          src={src}
+          onDoubleClick={this.handleDoubleClick}
+          onMouseDown={this.handleMouseDown}
+          onTouchStart={this.handleTouchStart}
+          style={{
+            WebkitTransform: transform,
+            transform,
+          }}
+        />
+      </PhotoContainer>
+    );
+  }
+}

+ 22 - 0
src/Spinner.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+
+export default function Spinner(props) {
+  return (
+    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="64" height="64" {...props}>
+      <path
+        opacity=".25"
+        d="M16 0 A16 16 0 0 0 16 32 A16 16 0 0 0 16 0 M16 4 A12 12 0 0 1 16 28 A12 12 0 0 1 16 4"
+      />
+      <path d="M16 0 A16 16 0 0 1 32 16 L28 16 A12 12 0 0 0 16 4z">
+        <animateTransform
+          attributeName="transform"
+          type="rotate"
+          from="0 16 16"
+          to="360 16 16"
+          dur="0.4s"
+          repeatCount="indefinite"
+        />
+      </path>
+    </svg>
+  );
+}

+ 1 - 0
src/index.tsx

@@ -0,0 +1 @@
+export { default as PhotoView } from './PhotoView';

+ 42 - 0
tslint.json

@@ -0,0 +1,42 @@
+{
+  "rules": {
+    "class-name": true,
+    "comment-format": [true, "check-space"],
+    "indent": [true, "spaces"],
+    "member-ordering": [
+      true,
+      "public-before-private",
+      "static-before-instance",
+      "variables-before-functions"
+    ],
+    "no-conditional-assignment": true,
+    "no-duplicate-variable": true,
+    "no-eval": true,
+    "no-internal-module": true,
+    "no-trailing-whitespace": true,
+    "no-unused-variable": false,
+    "no-var-keyword": true,
+    "one-line": [true, "check-open-brace", "check-whitespace"],
+    "quotemark": [true, "single", "jsx-double"],
+    "semicolon": [true, "always"],
+    "typedef-whitespace": [
+      true,
+      {
+        "call-signature": "nospace",
+        "index-signature": "nospace",
+        "parameter": "nospace",
+        "property-declaration": "nospace",
+        "variable-declaration": "nospace"
+      }
+    ],
+    "variable-name": [true, "ban-keywords"],
+    "whitespace": [
+      true,
+      "check-branch",
+      "check-decl",
+      "check-operator",
+      "check-separator",
+      "check-type"
+    ]
+  }
+}