about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2019-08-14 04:07:32 +0200
committerThibaut Girka <thib@sitedethib.com>2019-08-19 21:56:25 +0200
commitab019800f8862a84eab679ea5848c3df0531ddc9 (patch)
tree85334d309f0353649614b0da9b7fb167b0427a15
parentf8e7c69861e0ec3fac8d7f416c8bff7148824dc0 (diff)
[Glitch] Add media editing modal
Port 23f7afa562c49b24e979505680463bc712b11d94 to glitch-soc

Signed-off-by: Thibaut Girka <thib@sitedethib.com>
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js84
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js129
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js11
-rw-r--r--app/javascript/flavours/glitch/styles/components/media.scss5
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss46
6 files changed, 159 insertions, 122 deletions
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
index 84edf664e..f89145a52 100644
--- a/app/javascript/flavours/glitch/features/compose/components/upload.js
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -4,18 +4,12 @@ import PropTypes from 'prop-types';
 import Motion from 'flavours/glitch/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
 import { isUserTouching } from 'flavours/glitch/util/is_mobile';
 
-const messages = defineMessages({
-  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
-});
-
-//  The component.
-export default @injectIntl
-class Upload extends ImmutablePureComponent {
+export default class Upload extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -23,30 +17,10 @@ class Upload extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
-    intl: PropTypes.object.isRequired,
     onUndo: PropTypes.func.isRequired,
-    onDescriptionChange: PropTypes.func.isRequired,
     onOpenFocalPoint: PropTypes.func.isRequired,
-    onSubmit: PropTypes.func.isRequired,
-  };
-
-  state = {
-    hovered: false,
-    focused: false,
-    dirtyDescription: null,
   };
 
-  handleKeyDown = (e) => {
-    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
-      this.handleSubmit();
-    }
-  }
-
-  handleSubmit = () => {
-    this.handleInputBlur();
-    this.props.onSubmit(this.context.router.history);
-  }
-
   handleUndoClick = e => {
     e.stopPropagation();
     this.props.onUndo(this.props.media.get('id'));
@@ -57,69 +31,21 @@ class Upload extends ImmutablePureComponent {
     this.props.onOpenFocalPoint(this.props.media.get('id'));
   }
 
-  handleInputChange = e => {
-    this.setState({ dirtyDescription: e.target.value });
-  }
-
-  handleMouseEnter = () => {
-    this.setState({ hovered: true });
-  }
-
-  handleMouseLeave = () => {
-    this.setState({ hovered: false });
-  }
-
-  handleInputFocus = () => {
-    this.setState({ focused: true });
-  }
-
-  handleClick = () => {
-    this.setState({ focused: true });
-  }
-
-  handleInputBlur = () => {
-    const { dirtyDescription } = this.state;
-
-    this.setState({ focused: false, dirtyDescription: null });
-
-    if (dirtyDescription !== null) {
-      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
-    }
-  }
-
   render () {
     const { intl, media } = this.props;
-    const active          = this.state.hovered || this.state.focused || isUserTouching();
-    const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
-    const computedClass   = classNames('composer--upload_form--item', { active });
     const focusX = media.getIn(['meta', 'focus', 'x']);
     const focusY = media.getIn(['meta', 'focus', 'y']);
     const x = ((focusX /  2) + .5) * 100;
     const y = ((focusY / -2) + .5) * 100;
 
     return (
-      <div className={computedClass} tabIndex='0' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} onClick={this.handleClick} role='button'>
+      <div className='composer--upload_form--item' tabIndex='0' role='button'>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
           {({ scale }) => (
             <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
-              <div className={classNames('composer--upload_form--actions', { active })}>
+              <div className={classNames('composer--upload_form--actions', { active: true })}>
                 <button className='icon-button' onClick={this.handleUndoClick}><Icon icon='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
-                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
-              </div>
-
-              <div className={classNames('composer--upload_form--description', { active })}>
-                <label>
-                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
-                  <textarea
-                    placeholder={intl.formatMessage(messages.description)}
-                    value={description}
-                    maxLength={420}
-                    onFocus={this.handleInputFocus}
-                    onChange={this.handleInputChange}
-                    onBlur={this.handleInputBlur}
-                    onKeyDown={this.handleKeyDown}
-                  />
-                </label>
+                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
               </div>
             </div>
           )}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
index d6bff63ac..f687fae99 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
-import { undoUploadCompose, changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { undoUploadCompose } from 'flavours/glitch/actions/compose';
 import { openModal } from 'flavours/glitch/actions/modal';
 import { submitCompose } from 'flavours/glitch/actions/compose';
 
@@ -14,10 +14,6 @@ const mapDispatchToProps = dispatch => ({
     dispatch(undoUploadCompose(id));
   },
 
-  onDescriptionChange: (id, description) => {
-    dispatch(changeUploadCompose(id, { description }));
-  },
-
   onOpenFocalPoint: id => {
     dispatch(openModal('FOCAL_POINT', { id }));
   },
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
index 57c92cc66..de87ba83f 100644
--- a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -1,11 +1,21 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { connect } from 'react-redux';
-import ImageLoader from './image_loader';
 import classNames from 'classnames';
 import { changeUploadCompose } from 'flavours/glitch/actions/compose';
 import { getPointerPosition } from 'flavours/glitch/features/video';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import Video from 'flavours/glitch/features/video';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
+});
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,17 +23,20 @@ const mapStateToProps = (state, { id }) => ({
 
 const mapDispatchToProps = (dispatch, { id }) => ({
 
-  onSave: (x, y) => {
-    dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  onSave: (description, x, y) => {
+    dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
   },
 
 });
 
-@connect(mapStateToProps, mapDispatchToProps)
-export default class FocalPointModal extends ImmutablePureComponent {
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class FocalPointModal extends ImmutablePureComponent {
 
   static propTypes = {
     media: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
   };
 
   state = {
@@ -32,6 +45,8 @@ export default class FocalPointModal extends ImmutablePureComponent {
     focusX: 0,
     focusY: 0,
     dragging: false,
+    description: '',
+    dirty: false,
   };
 
   componentWillMount () {
@@ -66,7 +81,6 @@ export default class FocalPointModal extends ImmutablePureComponent {
     document.removeEventListener('mouseup', this.handleMouseUp);
 
     this.setState({ dragging: false });
-    this.props.onSave(this.state.focusX, this.state.focusY);
   }
 
   updatePosition = e => {
@@ -74,46 +88,113 @@ export default class FocalPointModal extends ImmutablePureComponent {
     const focusX   = (x - .5) *  2;
     const focusY   = (y - .5) * -2;
 
-    this.setState({ x, y, focusX, focusY });
+    this.setState({ x, y, focusX, focusY, dirty: true });
   }
 
   updatePositionFromMedia = media => {
-    const focusX = media.getIn(['meta', 'focus', 'x']);
-    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const focusX      = media.getIn(['meta', 'focus', 'x']);
+    const focusY      = media.getIn(['meta', 'focus', 'y']);
+    const description = media.get('description') || '';
 
     if (focusX && focusY) {
       const x = (focusX /  2) + .5;
       const y = (focusY / -2) + .5;
 
-      this.setState({ x, y, focusX, focusY });
+      this.setState({
+        x,
+        y,
+        focusX,
+        focusY,
+        description,
+        dirty: false,
+      });
     } else {
-      this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
+      this.setState({
+        x: 0.5,
+        y: 0.5,
+        focusX: 0,
+        focusY: 0,
+        description,
+        dirty: false,
+      });
     }
   }
 
+  handleChange = e => {
+    this.setState({ description: e.target.value, dirty: true });
+  }
+
+  handleSubmit = () => {
+    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
+    this.props.onClose();
+  }
+
   setRef = c => {
     this.node = c;
   }
 
   render () {
-    const { media } = this.props;
-    const { x, y, dragging } = this.state;
+    const { media, intl, onClose } = this.props;
+    const { x, y, dragging, description, dirty } = this.state;
 
     const width  = media.getIn(['meta', 'original', 'width']) || null;
     const height = media.getIn(['meta', 'original', 'height']) || null;
+    const focals = ['image', 'gifv'].includes(media.get('type'));
+
+    const previewRatio  = 16/9;
+    const previewWidth  = 200;
+    const previewHeight = previewWidth / previewRatio;
 
     return (
-      <div className='modal-root__modal video-modal focal-point-modal'>
-        <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
-          <ImageLoader
-            previewSrc={media.get('preview_url')}
-            src={media.get('url')}
-            width={width}
-            height={height}
-          />
-
-          <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
-          <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+      <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__comment'>
+            {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
+
+            <label className='setting-text-label' htmlFor='upload-modal__description'><FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' /></label>
+
+            <textarea
+              id='upload-modal__description'
+              className='setting-text light'
+              value={description}
+              onChange={this.handleChange}
+              autoFocus
+            />
+
+            <Button disabled={!dirty} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+          </div>
+
+          <div className='report-modal__statuses'>
+            {focals && (
+              <div className={classNames('focal-point', { dragging })} ref={this.setRef}>
+                {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />}
+                {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />}
+
+                <div className='focal-point__preview'>
+                  <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
+                  <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
+                </div>
+
+                <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+                <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+              </div>
+            )}
+
+            {['audio', 'video'].includes(media.get('type')) && (
+              <Video
+                preview={media.get('preview_url')}
+                blurhash={media.get('blurhash')}
+                src={media.get('url')}
+                detailed
+                editable
+              />
+            )}
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index 112f9d101..6d5162519 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -101,6 +101,7 @@ export default class Video extends React.PureComponent {
     fullwidth: PropTypes.bool,
     detailed: PropTypes.bool,
     inline: PropTypes.bool,
+    editable: PropTypes.bool,
     cacheWidth: PropTypes.func,
     intl: PropTypes.object.isRequired,
     visible: PropTypes.bool,
@@ -393,7 +394,7 @@ export default class Video extends React.PureComponent {
   }
 
   render () {
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link } = this.props;
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable } = this.props;
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
     const progress = (currentTime / duration) * 100;
     const playerStyle = {};
@@ -401,7 +402,7 @@ export default class Video extends React.PureComponent {
     const volumeWidth = (muted) ? 0 : volume * this.volWidth;
     const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
 
-    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, letterbox, 'full-width': fullwidth });
+    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
 
     let { width, height } = this.props;
 
@@ -443,7 +444,7 @@ export default class Video extends React.PureComponent {
       >
         <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
 
-        {revealed && <video
+        {(revealed || editable) && <video
           ref={this.setVideoRef}
           src={src}
           poster={preview}
@@ -465,7 +466,7 @@ export default class Video extends React.PureComponent {
           onVolumeChange={this.handleVolumeChange}
         />}
 
-        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
           <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
             <span className='spoiler-button__overlay__label'>{warning}</span>
           </button>
@@ -508,7 +509,7 @@ export default class Video extends React.PureComponent {
             </div>
 
             <div className='video-player__buttons right'>
-              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
+              {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye-slash' /></button>}
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-compress' /></button>}
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
diff --git a/app/javascript/flavours/glitch/styles/components/media.scss b/app/javascript/flavours/glitch/styles/components/media.scss
index 8b5d0486d..39ffcae9d 100644
--- a/app/javascript/flavours/glitch/styles/components/media.scss
+++ b/app/javascript/flavours/glitch/styles/components/media.scss
@@ -338,6 +338,11 @@
   position: relative;
   background: $base-shadow-color;
   max-width: 100%;
+  border-radius: 4px;
+
+  &.editable {
+    border-radius: 0;
+  }
 
   &:focus {
     outline: 0;
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index a98efee9f..df4a22329 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -577,6 +577,14 @@
     }
   }
 
+  .setting-text-label {
+    display: block;
+    color: $inverted-text-color;
+    font-size: 14px;
+    font-weight: 500;
+    margin-bottom: 10px;
+  }
+
   .setting-toggle {
     margin-top: 20px;
     margin-bottom: 24px;
@@ -787,19 +795,18 @@
 
 .focal-point {
   position: relative;
-  cursor: pointer;
+  cursor: move;
   overflow: hidden;
 
-  &.dragging {
-    cursor: move;
-  }
-
-  img {
-    max-width: 80vw;
+  img,
+  video {
+    display: block;
     max-height: 80vh;
-    width: auto;
+    width: 100%;
     height: auto;
-    margin: auto;
+    margin: 0;
+    object-fit: contain;
+    background: $base-shadow-color;
   }
 
   &__reticle {
@@ -819,6 +826,27 @@
     top: 0;
     left: 0;
   }
+
+  &__preview {
+    position: absolute;
+    bottom: 10px;
+    right: 10px;
+    z-index: 2;
+    cursor: default;
+
+    strong {
+      color: $primary-text-color;
+      font-size: 14px;
+      font-weight: 500;
+      display: block;
+      margin-bottom: 5px;
+    }
+
+    div {
+      border-radius: 4px;
+      box-shadow: 0 0 14px rgba($base-shadow-color, 0.2);
+    }
+  }
 }
 
 .filtered-status-info {