about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/mastodon/actions/compose.js9
-rw-r--r--app/javascript/mastodon/components/icon_button.js10
-rw-r--r--app/javascript/mastodon/features/compose/containers/doodle_button_container.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/doodle_modal.js343
-rw-r--r--app/javascript/mastodon/reducers/compose.js12
-rw-r--r--app/javascript/styles/components.scss8
-rw-r--r--app/javascript/styles/doodle.scss70
7 files changed, 427 insertions, 45 deletions
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8d035e82f..24e64e06c 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -45,6 +45,8 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST     = 'COMPOSE_UPLOAD_UPDATE_REQUEST'
 export const COMPOSE_UPLOAD_CHANGE_SUCCESS     = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
 export const COMPOSE_UPLOAD_CHANGE_FAIL        = 'COMPOSE_UPLOAD_UPDATE_FAIL';
 
+export const COMPOSE_DOODLE_SET        = 'COMPOSE_DOODLE_SET';
+
 export function changeCompose(text) {
   return {
     type: COMPOSE_CHANGE,
@@ -158,6 +160,13 @@ export function submitComposeFail(error) {
   };
 };
 
+export function doodleSet(options) {
+  return {
+    type: COMPOSE_DOODLE_SET,
+    options: options,
+  };
+};
+
 export function uploadCompose(files) {
   return function (dispatch, getState) {
     if (getState().getIn(['compose', 'media_attachments']).size > 3) {
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index ca4b14b82..6fb191c6b 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -22,6 +22,7 @@ export default class IconButton extends React.PureComponent {
     flip: PropTypes.bool,
     overlay: PropTypes.bool,
     tabIndex: PropTypes.string,
+    label: PropTypes.string,
   };
 
   static defaultProps = {
@@ -42,14 +43,18 @@ export default class IconButton extends React.PureComponent {
   }
 
   render () {
-    const style = {
+    let style = {
       fontSize: `${this.props.size}px`,
-      width: `${this.props.size * 1.28571429}px`,
       height: `${this.props.size * 1.28571429}px`,
       lineHeight: `${this.props.size}px`,
       ...this.props.style,
       ...(this.props.active ? this.props.activeStyle : {}),
     };
+    if (!this.props.label) {
+      style.width = `${this.props.size * 1.28571429}px`;
+    } else {
+      style.textAlign = 'left';
+    }
 
     const classes = ['icon-button'];
 
@@ -102,6 +107,7 @@ export default class IconButton extends React.PureComponent {
             tabIndex={this.props.tabIndex}
           >
             <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+            {this.props.label}
           </button>
         }
       </Motion>
diff --git a/app/javascript/mastodon/features/compose/containers/doodle_button_container.js b/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
index e1fc894f9..799d085a4 100644
--- a/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
@@ -1,33 +1,15 @@
 import { connect } from 'react-redux';
 import DoodleButton from '../components/doodle_button';
 import { openModal } from '../../../actions/modal';
-import { uploadCompose } from '../../../actions/compose';
 
 const mapStateToProps = state => ({
   disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
 });
 
-//https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
-function dataURLtoFile(dataurl, filename) {
-  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
-    bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
-  while(n--){
-    u8arr[n] = bstr.charCodeAt(n);
-  }
-  return new File([u8arr], filename, { type: mime });
-}
-
 const mapDispatchToProps = dispatch => ({
-
   onOpenCanvas () {
-    dispatch(openModal('DOODLE', {
-      status,
-      onDoodleSubmit: (b64data) => {
-        dispatch(uploadCompose([dataURLtoFile(b64data, 'doodle.png')]));
-      },
-    }));
+    dispatch(openModal('DOODLE', {}));
   },
-
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(DoodleButton);
diff --git a/app/javascript/mastodon/features/ui/components/doodle_modal.js b/app/javascript/mastodon/features/ui/components/doodle_modal.js
index d13f9604a..661aa08fb 100644
--- a/app/javascript/mastodon/features/ui/components/doodle_modal.js
+++ b/app/javascript/mastodon/features/ui/components/doodle_modal.js
@@ -3,55 +3,319 @@ import PropTypes from 'prop-types';
 import Button from '../../../components/button';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Atrament from 'atrament'; // the doodling library
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { doodleSet, uploadCompose } from '../../../actions/compose';
+import IconButton from '../../../components/icon_button';
+import { debounce } from 'lodash';
 
-export default class DoodleModal extends ImmutablePureComponent {
+// palette nicked from MyPaint, CC0
+const palette = [
+  ['rgb(  0,    0,    0)', 'Black'],
+  ['rgb( 38,   38,   38)', 'Gray 15'],
+  ['rgb( 77,   77,   77)', 'Grey 30'],
+  ['rgb(128,  128,  128)', 'Grey 50'],
+  ['rgb(171,  171,  171)', 'Grey 67'],
+  ['rgb(217,  217,  217)', 'Grey 85'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(128,    0,    0)', 'Maroon'],
+  ['rgb(209,    0,    0)', 'English-red'],
+  ['rgb(255,   54,   34)', 'Tomato'],
+  ['rgb(252,   60,    3)', 'Orange-red'],
+  ['rgb(255,  140,  105)', 'Salmon'],
+  ['rgb(252,  232,   32)', 'Cadium-yellow'],
+  ['rgb(243,  253,   37)', 'Lemon yellow'],
+  ['rgb(121,    5,   35)', 'Dark crimson'],
+  ['rgb(169,   32,   62)', 'Deep carmine'],
+  ['rgb(255,  140,    0)', 'Orange'],
+  ['rgb(255,  168,   18)', 'Dark tangerine'],
+  ['rgb(217,  144,   88)', 'Persian orange'],
+  ['rgb(194,  178,  128)', 'Sand'],
+  ['rgb(255,  229,  180)', 'Peach'],
+  ['rgb(100,   54,   46)', 'Bole'],
+  ['rgb(108,   41,   52)', 'Dark cordovan'],
+  ['rgb(163,   65,   44)', 'Chestnut'],
+  ['rgb(228,  136,  100)', 'Dark salmon'],
+  ['rgb(255,  195,  143)', 'Apricot'],
+  ['rgb(255,  219,  188)', 'Unbleached silk'],
+  ['rgb(242,  227,  198)', 'Straw'],
+  ['rgb( 53,   19,   13)', 'Bistre'],
+  ['rgb( 84,   42,   14)', 'Dark chocolate'],
+  ['rgb(102,   51,   43)', 'Burnt sienna'],
+  ['rgb(184,   66,    0)', 'Sienna'],
+  ['rgb(216,  153,   12)', 'Yellow ochre'],
+  ['rgb(210,  180,  140)', 'Tan'],
+  ['rgb(232,  204,  144)', 'Dark wheat'],
+  ['rgb(  0,   49,   83)', 'Prussian blue'],
+  ['rgb( 48,   69,  119)', 'Dark grey blue'],
+  ['rgb(  0,   71,  171)', 'Cobalt blue'],
+  ['rgb( 31,  117,  254)', 'Blue'],
+  ['rgb(120,  180,  255)', 'Bright french blue'],
+  ['rgb(171,  200,  255)', 'Bright steel blue'],
+  ['rgb(208,  231,  255)', 'Ice blue'],
+  ['rgb( 30,   51,   58)', 'Medium jungle green'],
+  ['rgb( 47,   79,   79)', 'Dark slate grey'],
+  ['rgb( 74,  104,   93)', 'Dark grullo green'],
+  ['rgb(  0,  128,  128)', 'Teal'],
+  ['rgb( 67,  170,  176)', 'Turquoise'],
+  ['rgb(109,  174,  199)', 'Cerulean frost'],
+  ['rgb(173,  217,  186)', 'Tiffany green'],
+  ['rgb( 22,   34,   29)', 'Gray-asparagus'],
+  ['rgb( 36,   48,   45)', 'Medium dark teal'],
+  ['rgb( 74,  104,   93)', 'Xanadu'],
+  ['rgb(119,  198,  121)', 'Mint'],
+  ['rgb(175,  205,  182)', 'Timberwolf'],
+  ['rgb(185,  245,  246)', 'Celeste'],
+  ['rgb(193,  255,  234)', 'Aquamarine'],
+  ['rgb( 29,   52,   35)', 'Cal Poly Pomona'],
+  ['rgb(  1,   68,   33)', 'Forest green'],
+  ['rgb( 42,  128,    0)', 'Napier green'],
+  ['rgb(128,  128,    0)', 'Olive'],
+  ['rgb( 65,  156,  105)', 'Sea green'],
+  ['rgb(189,  246,   29)', 'Green-yellow'],
+  ['rgb(231,  244,  134)', 'Bright chartreuse'],
+  ['rgb(138,   23,  137)', 'Purple'],
+  ['rgb( 78,   39,  138)', 'Violet'],
+  ['rgb(193,   75,  110)', 'Dark thulian pink'],
+  ['rgb(222,   49,   99)', 'Cerise'],
+  ['rgb(255,   20,  147)', 'Deep pink'],
+  ['rgb(255,  102,  204)', 'Rose pink'],
+  ['rgb(255,  203,  219)', 'Pink'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(229,   17,    1)', 'RGB Red'],
+  ['rgb(  0,  255,    0)', 'RGB Green'],
+  ['rgb(  0,    0,  255)', 'RGB Blue'],
+  ['rgb(  0,  255,  255)', 'CMYK Cyan'],
+  ['rgb(255,    0,  255)', 'CMYK Magenta'],
+  ['rgb(255,  255,    0)', 'CMYK Yellow'],
+];
 
-  static contextTypes = {
-    router: PropTypes.object,
-  };
+// re-arrange to the right order for display
+let palReordered = [];
+for (let row = 0; row < 7; row++) {
+  for (let col = 0; col < 11; col++) {
+    palReordered.push(palette[col * 7 + row]);
+  }
+  palReordered.push(null); // null indicates a <br />
+}
+
+// Utility for converting base64 image to binary for upload
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
+function dataURLtoFile(dataurl, filename) {
+  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+    bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+  while(n--){
+    u8arr[n] = bstr.charCodeAt(n);
+  }
+  return new File([u8arr], filename, { type: mime });
+}
+
+
+const mapStateToProps = state => ({
+  options: state.getIn(['compose', 'doodle']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  setOpt: (opts) => dispatch(doodleSet(opts)),
+  submit: (file) => dispatch(uploadCompose([file])),
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class DoodleModal extends ImmutablePureComponent {
 
   static propTypes = {
-    onDoodleSubmit: PropTypes.func.isRequired, // gets the base64 as argument
+    options: ImmutablePropTypes.map,
     onClose: PropTypes.func.isRequired,
+    setOpt: PropTypes.func.isRequired,
+    submit: PropTypes.func.isRequired,
   };
 
+  //region Option getters/setters
+
+  get fg () {
+    return this.props.options.get('fg');
+  }
+
+  set fg (value) {
+    this.props.setOpt({ fg: value });
+  }
+
+  get bg () {
+    return this.props.options.get('bg');
+  }
+
+  set bg (value) {
+    this.props.setOpt({ bg: value });
+  }
+
+  get mode () {
+    return this.props.options.get('mode');
+  }
+
+  set mode (value) {
+    this.props.setOpt({ mode: value });
+  }
+
+  get weight () {
+    return this.props.options.get('weight');
+  }
+
+  set weight (value) {
+    this.props.setOpt({ weight: value });
+  }
+
+  get opacity () {
+    return this.props.options.get('opacity');
+  }
+
+  set opacity (value) {
+    this.props.setOpt({ opacity: value });
+  }
+
+  get adaptiveStroke () {
+    return this.props.options.get('adaptiveStroke');
+  }
+
+  set adaptiveStroke (value) {
+    this.props.setOpt({ adaptiveStroke: value });
+  }
+
+  get smoothing () {
+    return this.props.options.get('smoothing');
+  }
+
+  set smoothing (value) {
+    this.props.setOpt({ smoothing: value });
+  }
+
+  //endregion
+
   handleKeyUp = (e) => {
     if (e.key === 'Delete' || e.key === 'Backspace') {
+      e.preventDefault();
       this.clearScreen();
     }
-  }
 
-  clearScreen () {
-    this.sketcher.context.fillStyle = 'white';
-    this.sketcher.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
-  }
+    if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.undo();
+    }
+  };
 
   componentDidMount () {
     window.addEventListener('keyup', this.handleKeyUp, false);
+  };
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp, false);
   }
 
+  clearScreen = () => {
+    this.sketcher.context.fillStyle = this.bg;
+    this.sketcher.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
+    this.undos = [];
+
+    this.doSaveUndo();
+  };
+
   handleDone = () => {
-    this.props.onDoodleSubmit(this.sketcher.toImage());
+    const dataUrl = this.sketcher.toImage();
+    const file = dataURLtoFile(dataUrl, 'doodle.png');
+    this.props.submit(file);
+
     this.sketcher.destroy();
     this.props.onClose();
+  };
+
+  updateSketcherSettings () {
+    if (!this.sketcher) return;
+
+    this.sketcher.color = this.fg;
+    this.sketcher.opacity = this.opacity;
+    this.sketcher.weight = this.weight;
+    this.sketcher.mode = this.mode;
+    this.sketcher.smoothing = this.smoothing;
+    this.sketcher.adaptiveStroke = this.adaptiveStroke;
+  }
+
+  initSketcher (elem) {
+    this.sketcher = new Atrament(elem, 500, 500);
+
+    this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+
+    this.updateSketcherSettings();
+    this.clearScreen();
   }
 
   setCanvasRef = (elem) => {
     this.canvas = elem;
     if (elem) {
-      this.sketcher = new Atrament(elem, 500, 500, 'black');
+      elem.addEventListener('dirty', () => {
+        this.saveUndo();
+        this.sketcher._dirty = false;
+      });
+      elem.addEventListener('click', () => {
+        // sketcher bug - does not fire dirty on fill
+        if (this.mode === 'fill') {
+          this.saveUndo();
+        }
+      });
 
-      this.clearScreen();
+      this.initSketcher(elem);
+    }
+  };
+
+  onPaletteClick = (e) => {
+    this.fg = e.target.dataset.color;
+    e.target.blur();
+  };
+
+  setModeDraw = (e) => {
+    this.mode = 'draw';
+    e.target.blur();
+  };
+
+  setModeFill = (e) => {
+    this.mode = 'fill';
+    e.target.blur();
+  };
+
+  tglSmooth = (e) => {
+    this.smoothing = !this.smoothing;
+    e.target.blur();
+  };
 
-      // .smoothing looks good with mouse but works really poorly with a tablet
-      this.sketcher.smoothing = false;
+  tglAdaptive = (e) => {
+    this.adaptiveStroke = !this.adaptiveStroke;
+    e.target.blur();
+  };
+
+  setWeight = (e) => {
+    this.weight = +e.target.value || 1;
+  };
 
-      // There's a bunch of options we should add UI controls for later
-      // ref: https://github.com/jakubfiala/atrament.js
+  undo = () => {
+    if (this.undos.length > 1) {
+      this.undos.pop();
+      const buf = this.undos.pop();
+
+      this.sketcher.clear();
+      this.sketcher.context.putImageData(buf, 0, 0);
+      this.doSaveUndo();
     }
-  }
+  };
+
+  doSaveUndo = () => {
+    this.undos.push(this.sketcher.context.getImageData(0, 0, this.canvas.width, this.canvas.height));
+  };
+
+  saveUndo = debounce(() => {
+    this.doSaveUndo();
+  }, 100);
 
   render () {
+    this.updateSketcherSettings();
+
     return (
       <div className='modal-root__modal doodle-modal'>
         <div className='doodle-modal__container'>
@@ -60,6 +324,49 @@ export default class DoodleModal extends ImmutablePureComponent {
 
         <div className='doodle-modal__action-bar'>
           <Button text='Done' onClick={this.handleDone} />
+          <div className='filler' />
+          <div className='doodle-toolbar with-inputs'>
+            <div>
+              <label htmlFor='dd_smoothing'>Smoothing</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_adaptive'>Adaptive</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_weight'>Weight</label>
+              <span className='val'>
+                <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
+              </span>
+            </div>
+          </div>
+          <div className='doodle-toolbar'>
+            <IconButton icon='pencil' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
+            <IconButton icon='bath' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
+            <IconButton icon='undo' label='Undo' onClick={this.undo} size={18} inverted />
+            <IconButton icon='trash' label='Clear' onClick={this.clearScreen} size={18} inverted />
+          </div>
+          <div className='doodle-palette'>
+            {
+              palReordered.map((c, i) =>
+                c === null ?
+                  <br key={i} /> :
+                  <button
+                    key={i}
+                    style={{ backgroundColor: c[0] }}
+                    onClick={this.onPaletteClick}
+                    data-color={c[0]}
+                    title={c[1]}
+                    className={this.fg === c[0] ? 'selected' : ''}
+                  />
+              )
+            }
+          </div>
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index b1d590748..9bb8443cc 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -26,6 +26,7 @@ import {
   COMPOSE_UPLOAD_CHANGE_REQUEST,
   COMPOSE_UPLOAD_CHANGE_SUCCESS,
   COMPOSE_UPLOAD_CHANGE_FAIL,
+  COMPOSE_DOODLE_SET,
   COMPOSE_RESET,
 } from '../actions/compose';
 import { TIMELINE_DELETE } from '../actions/timelines';
@@ -61,6 +62,15 @@ const initialState = ImmutableMap({
   default_sensitive: false,
   resetFileKey: Math.floor((Math.random() * 0x10000)),
   idempotencyKey: null,
+  doodle: ImmutableMap({
+    fg: 'rgb(  0,    0,    0)',
+    bg: 'rgb(255,  255,  255)',
+    mode: 'draw',
+    weight: 2,
+    opacity: 1,
+    adaptiveStroke: true,
+    smoothing: false,
+  }),
 });
 
 function statusToTextMentions(state, status) {
@@ -288,6 +298,8 @@ export default function compose(state = initialState, action) {
 
         return item;
       }));
+  case COMPOSE_DOODLE_SET:
+    return state.mergeIn(['doodle'], action.options);
   default:
     return state;
   }
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 7056e2208..7ef3dcc43 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -3874,7 +3874,6 @@ button.icon-button.active i.fa-retweet {
 }
 
 .boost-modal,
-.doodle-modal,
 .confirmation-modal,
 .report-modal,
 .actions-modal,
@@ -3893,10 +3892,6 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
-.doodle-modal {
-  width: unset;
-}
-
 .actions-modal {
   .status {
     background: $white;
@@ -3920,7 +3915,6 @@ button.icon-button.active i.fa-retweet {
   }
 }
 
-.doodle-modal__action-bar,
 .boost-modal__action-bar,
 .confirmation-modal__action-bar,
 .mute-modal__action-bar,
@@ -4785,3 +4779,5 @@ noscript {
     }
   }
 }
+
+@import 'doodle';
diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss
new file mode 100644
index 000000000..cc785a8ad
--- /dev/null
+++ b/app/javascript/styles/doodle.scss
@@ -0,0 +1,70 @@
+.doodle-modal {
+  @extend .boost-modal;
+  width: unset;
+}
+
+.doodle-modal__container {
+  line-height: 0; // remove weird gap under canvas
+  canvas {
+    border: 5px solid #d9e1e8;
+  }
+}
+
+.doodle-modal__action-bar {
+  @extend .boost-modal__action-bar;
+
+  .filler {
+    flex-grow: 1;
+  }
+
+  .doodle-toolbar {
+    display: flex;
+    flex-direction: column;
+    flex-grow: 0;
+    justify-content: space-around;
+
+    &.with-inputs {
+      label {
+        display: inline-block;
+        width: 70px;
+        text-align: right;
+        margin-right: 2px;
+      }
+
+      input[type="number"],input[type="text"] {
+        width: 40px;
+      }
+      span.val {
+        display: inline-block;
+        text-align: left;
+        width: 50px;
+      }
+    }
+  }
+
+  .doodle-palette {
+    padding-right: 0 !important;
+    border: 1px solid black;
+    line-height: .2rem;
+    flex-grow: 0;
+    background: white;
+
+    button {
+      appearance: none;
+      width: 1rem;
+      height: 1rem;
+      margin: 0; padding: 0;
+      text-align: center;
+      color: black;
+      text-shadow: 0 0 1px white;
+      cursor: pointer;
+      box-shadow: inset 0 0 1px rgba(white, .5);
+      border: 1px solid black;
+
+      &.selected {
+        outline-offset:-1px;
+        outline: 1px dotted white;
+      }
+    }
+  }
+}