Browse Source

Merge pull request #1219 from weseek/feat/crop-uploaded-image

Feat/crop uploaded image
Yuki Takei 6 years ago
parent
commit
b189df2014

+ 1 - 0
package.json

@@ -133,6 +133,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
+    "react-image-crop": "^8.3.0",
     "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",

+ 3 - 0
src/client/js/app.jsx

@@ -34,6 +34,7 @@ import MyDraftList from './components/MyDraftList/MyDraftList';
 import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 
+import ProfileImageUploader from './components/ProfileImageUploader';
 import AdminHome from './components/Admin/AdminHome/AdminHome';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
@@ -108,6 +109,8 @@ let componentMappings = {
   'page-status-alert': <PageStatusAlert />,
   'save-page-controls': <SavePageControls />,
 
+  'profile-image-uploader': <ProfileImageUploader />,
+
   'user-created-list': <RecentCreated />,
   'user-draft-list': <MyDraftList />,
 

+ 138 - 0
src/client/js/components/ProfileImageUploader.jsx

@@ -0,0 +1,138 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import ReactCrop from 'react-image-crop';
+import AppContainer from '../services/AppContainer';
+import { createSubscribedElement } from './UnstatedUtils';
+import 'react-image-crop/dist/ReactCrop.css';
+
+class ProfileImageUploader extends React.Component {
+
+  // demo: https://codesandbox.io/s/72py4jlll6
+  constructor(props) {
+    super();
+
+    this.state = {
+      src: null,
+      crop: {
+        unit: '%',
+        width: 30,
+        aspect: 1,
+      },
+    };
+
+    this.onSelectFile = this.onSelectFile.bind(this);
+    this.onImageLoaded = this.onImageLoaded.bind(this);
+    this.onCropComplete = this.onCropComplete.bind(this);
+    this.onCropChange = this.onCropChange.bind(this);
+    this.makeClientCrop = this.makeClientCrop.bind(this);
+    this.getCroppedImg = this.getCroppedImg.bind(this);
+    this.hanndleSubmit = this.handleSubmit.bind(this);
+  }
+
+  onSelectFile(e) {
+    if (e.target.files && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => this.setState({ src: reader.result }));
+      reader.readAsDataURL(e.target.files[0]);
+    }
+  }
+
+  // If you setState the crop in here you should return false.
+  onImageLoaded(image) {
+    this.imageRef = image;
+  }
+
+  onCropComplete(crop) {
+    this.makeClientCrop(crop);
+  }
+
+  onCropChange(crop, percentCrop) {
+    // You could also use percentCrop:
+    // this.setState({ crop: percentCrop });
+    this.setState({ crop });
+  }
+
+  async makeClientCrop(crop) {
+    // GW-201 で crop済みの画像を state におくときにコメントを外す(未使用変数の lint エラーが生じるためコメントアウト)
+    // if (this.imageRef && crop.width && crop.height) {
+    //   const croppedImageUrl = await this.getCroppedImg(this.imageRef, crop, 'newFile.jpeg');
+    //   this.setState({ croppedImageUrl });
+    // }
+  }
+
+  async getCroppedImg(image, crop, fileName) {
+    const canvas = document.createElement('canvas');
+    const scaleX = image.naturalWidth / image.width;
+    const scaleY = image.naturalHeight / image.height;
+    canvas.width = crop.width;
+    canvas.height = crop.height;
+    const ctx = canvas.getContext('2d');
+
+    ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY,
+      0, 0, crop.width, crop.height);
+
+    return new Promise((resolve, reject) => {
+      canvas.toBlob((blob) => {
+        if (!blob) {
+          reject(new Error('Canvas is empty'));
+          return;
+        }
+        blob.name = fileName;
+        window.URL.revokeObjectURL(this.fileUrl);
+        this.fileUrl = window.URL.createObjectURL(blob);
+        resolve(this.fileUrl);
+      }, 'image/jpeg');
+    });
+  }
+
+  handleSubmit() {
+    // GW-201 にて、crop された画像をサーバー側に送る処理を記述する
+    // me/index.html の 199~240行目の
+    // `$("#pictureUploadForm input[name=profileImage]").on('change', function(){...}`
+    // の処理を node で記述
+  }
+
+  render() {
+    const { crop, src } = this.state;
+
+    return (
+      <div className="App">
+        <input type="file" onChange={this.onSelectFile} name="profileImage" accept="image/*" />
+        {src
+        && (
+        <div>
+          <ReactCrop
+            src={src}
+            crop={crop}
+            onImageLoaded={this.onImageLoaded}
+            onComplete={this.onCropComplete}
+            onChange={this.onCropChange}
+          />
+          <button
+            type="button"
+            onClick={this.handleSubmit}
+          >
+          完了
+          </button>
+        </div>
+        )}
+      </div>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ProfileImageFormWrapper = (props) => {
+  return createSubscribedElement(ProfileImageUploader, props, [AppContainer]);
+};
+
+ProfileImageUploader.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(ProfileImageFormWrapper);

+ 1 - 1
src/server/views/me/index.html

@@ -173,7 +173,7 @@
             {% if fileUploadService.getIsUploadable() %}
             <form action="/_api/attachments.uploadProfileImage" id="pictureUploadForm" method="post" class="form-horizontal" role="form">
               <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <input type="file" name="profileImage" accept="image/*">
+              <div id="profile-image-uploader"></div>
               <div id="pictureUploadFormProgress" class="d-flex align-items-center">
               </div>
             </form>

+ 14 - 0
yarn.lock

@@ -3097,6 +3097,11 @@ clone@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
 
+clsx@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.4.tgz#0c0171f6d5cb2fe83848463c15fcc26b4df8c2ec"
+  integrity sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg==
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -10781,6 +10786,15 @@ react-i18next@^11.1.0:
     "@babel/runtime" "^7.3.1"
     html-parse-stringify2 "2.0.1"
 
+react-image-crop@^8.3.0:
+  version "8.3.0"
+  resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-8.3.0.tgz#a0642dd3daafd77f142bac01887628cb967876b7"
+  integrity sha512-iC6Soqkf588WvEHc4EpKWNaiw4YQe0UbXziBpC8KRPKyaccakmWf7MewDFnYiPfPNEWxj96S390q7BUJz8LGZg==
+  dependencies:
+    clsx "^1.0.4"
+    core-js "^3.2.1"
+    prop-types "^15.7.2"
+
 react-is@^16.6.3:
   version "16.6.3"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.6.3.tgz#d2d7462fcfcbe6ec0da56ad69047e47e56e7eac0"