about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorOndřej Hruška <ondra@ondrovo.com>2017-10-14 12:24:35 +0200
committerGitHub <noreply@github.com>2017-10-14 12:24:35 +0200
commitcdc22d23b91e8c0140d3048ae8a11e749a434ac8 (patch)
tree7d2cfbe9f9445e316d28c3976446bd3f1a9e6640 /app/javascript
parent531dadad864ce6832033fbf8aa0c49fd228faf86 (diff)
Doodle improvements 2 (#176)
* Fix some doodle bugs and added Background color functionality

* added protections against accidental doodle erase, screen size changing

* resolve react warning about 'selected' on <option>
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/features/compose/containers/doodle_button_container.js2
-rw-r--r--app/javascript/mastodon/features/ui/components/doodle_modal.js363
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/reducers/compose.js2
-rw-r--r--app/javascript/styles/doodle.scss24
5 files changed, 325 insertions, 68 deletions
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 799d085a4..5ada4514f 100644
--- a/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
@@ -8,7 +8,7 @@ const mapStateToProps = state => ({
 
 const mapDispatchToProps = dispatch => ({
   onOpenCanvas () {
-    dispatch(openModal('DOODLE', {}));
+    dispatch(openModal('DOODLE', { noEsc: true }));
   },
 });
 
diff --git a/app/javascript/mastodon/features/ui/components/doodle_modal.js b/app/javascript/mastodon/features/ui/components/doodle_modal.js
index 661aa08fb..4efc9d2e6 100644
--- a/app/javascript/mastodon/features/ui/components/doodle_modal.js
+++ b/app/javascript/mastodon/features/ui/components/doodle_modal.js
@@ -7,7 +7,8 @@ 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';
+import { debounce, mapValues } from 'lodash';
+import classNames from 'classnames';
 
 // palette nicked from MyPaint, CC0
 const palette = [
@@ -110,16 +111,40 @@ function dataURLtoFile(dataurl, filename) {
   return new File([u8arr], filename, { type: mime });
 }
 
+const DOODLE_SIZES = {
+  normal: [500, 500, 'Square 500'],
+  tootbanner: [702, 330, 'Tootbanner'],
+  s640x480: [640, 480, '640×480 - 480p'],
+  s800x600: [800, 600, '800×600 - SVGA'],
+  s720x480: [720, 405, '720x405 - 16:9'],
+};
+
 
 const mapStateToProps = state => ({
   options: state.getIn(['compose', 'doodle']),
 });
 
 const mapDispatchToProps = dispatch => ({
+  /** Set options in the redux store */
   setOpt: (opts) => dispatch(doodleSet(opts)),
+  /** Submit doodle for upload */
   submit: (file) => dispatch(uploadCompose([file])),
 });
 
+/**
+ * Doodling dialog with drawing canvas
+ *
+ * Keyboard shortcuts:
+ * - Delete: Clear screen, fill with background color
+ * - Backspace, Ctrl+Z: Undo one step
+ * - Ctrl held while drawing: Use background color
+ * - Shift held while clicking screen: Use fill tool
+ *
+ * Palette:
+ * - Left mouse button: pick foreground
+ * - Ctrl + left mouse button: pick background
+ * - Right mouse button: pick background
+ */
 @connect(mapStateToProps, mapDispatchToProps)
 export default class DoodleModal extends ImmutablePureComponent {
 
@@ -132,187 +157,387 @@ export default class DoodleModal extends ImmutablePureComponent {
 
   //region Option getters/setters
 
+  /** Foreground color */
   get fg () {
     return this.props.options.get('fg');
   }
-
   set fg (value) {
     this.props.setOpt({ fg: value });
   }
 
+  /** Background color */
   get bg () {
     return this.props.options.get('bg');
   }
-
   set bg (value) {
     this.props.setOpt({ bg: value });
   }
 
+  /** Swap Fg and Bg for drawing */
+  get swapped () {
+    return this.props.options.get('swapped');
+  }
+  set swapped (value) {
+    this.props.setOpt({ swapped: value });
+  }
+
+  /** Mode - 'draw' or 'fill' */
   get mode () {
     return this.props.options.get('mode');
   }
-
   set mode (value) {
     this.props.setOpt({ mode: value });
   }
 
+  /** Base line weight */
   get weight () {
     return this.props.options.get('weight');
   }
-
   set weight (value) {
     this.props.setOpt({ weight: value });
   }
 
+  /** Drawing opacity */
   get opacity () {
     return this.props.options.get('opacity');
   }
-
   set opacity (value) {
     this.props.setOpt({ opacity: value });
   }
 
+  /** Adaptive stroke - change width with speed */
   get adaptiveStroke () {
     return this.props.options.get('adaptiveStroke');
   }
-
   set adaptiveStroke (value) {
     this.props.setOpt({ adaptiveStroke: value });
   }
 
+  /** Smoothing (for mouse drawing) */
   get smoothing () {
     return this.props.options.get('smoothing');
   }
-
   set smoothing (value) {
     this.props.setOpt({ smoothing: value });
   }
 
+  /** Size preset */
+  get size () {
+    return this.props.options.get('size');
+  }
+  set size (value) {
+    this.props.setOpt({ size: value });
+  }
+
   //endregion
 
+  /** Key up handler */
   handleKeyUp = (e) => {
-    if (e.key === 'Delete' || e.key === 'Backspace') {
+    if (e.target.nodeName === 'INPUT') return;
+
+    if (e.key === 'Delete') {
       e.preventDefault();
-      this.clearScreen();
+      this.handleClearBtn();
+      return;
     }
 
-    if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
+    if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
       e.preventDefault();
       this.undo();
     }
+
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = false;
+      this.swapped = false;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = false;
+      this.mode = 'draw';
+    }
+  };
+
+  /** Key down handler */
+  handleKeyDown = (e) => {
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = true;
+      this.swapped = true;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = true;
+      this.mode = 'fill';
+    }
   };
 
+  /**
+   * Component installed in the DOM, do some initial set-up
+   */
   componentDidMount () {
+    this.controlHeld = false;
+    this.shiftHeld = false;
+    this.swapped = false;
     window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
   };
 
+  /**
+   * Tear component down
+   */
   componentWillUnmount () {
     window.removeEventListener('keyup', this.handleKeyUp, false);
+    window.removeEventListener('keydown', this.handleKeyDown, false);
+    if (this.sketcher) this.sketcher.destroy();
   }
 
-  clearScreen = () => {
-    this.sketcher.context.fillStyle = this.bg;
-    this.sketcher.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
-    this.undos = [];
+  /**
+   * Set reference to the canvas element.
+   * This is called during component init
+   *
+   * @param elem - canvas element
+   */
+  setCanvasRef = (elem) => {
+    this.canvas = elem;
+    if (elem) {
+      elem.addEventListener('dirty', () => {
+        this.saveUndo();
+        this.sketcher._dirty = false;
+      });
 
-    this.doSaveUndo();
+      elem.addEventListener('click', () => {
+        // sketcher bug - does not fire dirty on fill
+        if (this.mode === 'fill') {
+          this.saveUndo();
+        }
+      });
+
+      // prevent context menu
+      elem.addEventListener('contextmenu', (e) => {
+        e.preventDefault();
+      });
+
+      elem.addEventListener('mousedown', (e) => {
+        if (e.button === 2) {
+          this.swapped = true;
+        }
+      });
+
+      elem.addEventListener('mouseup', (e) => {
+        if (e.button === 2) {
+          this.swapped = this.controlHeld;
+        }
+      });
+
+      this.initSketcher(elem);
+      this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+    }
   };
 
-  handleDone = () => {
+  /**
+   * Set up the sketcher instance
+   *
+   * @param canvas - canvas element. Null if we're just resizing
+   */
+  initSketcher (canvas = null) {
+    const sizepreset = DOODLE_SIZES[this.size];
+
+    if (this.sketcher) this.sketcher.destroy();
+    this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
+
+    if (canvas) {
+      this.ctx = this.sketcher.context;
+      this.updateSketcherSettings();
+    }
+
+    this.clearScreen();
+  }
+
+  /**
+   * Done button handler
+   */
+  onDoneButton = () => {
     const dataUrl = this.sketcher.toImage();
     const file = dataURLtoFile(dataUrl, 'doodle.png');
     this.props.submit(file);
+    this.props.onClose(); // close dialog
+  };
 
-    this.sketcher.destroy();
-    this.props.onClose();
+  /**
+   * Cancel button handler
+   */
+  onCancelButton = () => {
+    if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
+      return;
+    }
+
+    this.props.onClose(); // close dialog
   };
 
+  /**
+   * Update sketcher options based on state
+   */
   updateSketcherSettings () {
     if (!this.sketcher) return;
 
-    this.sketcher.color = this.fg;
+    if (this.oldSize !== this.size) this.initSketcher();
+
+    this.sketcher.color = (this.swapped ? this.bg : 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.oldSize = this.size;
+  }
 
-    this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+  /**
+   * Fill screen with background color
+   */
+  clearScreen = () => {
+    this.ctx.fillStyle = this.bg;
+    this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
+    this.undos = [];
 
-    this.updateSketcherSettings();
-    this.clearScreen();
-  }
+    this.doSaveUndo();
+  };
 
-  setCanvasRef = (elem) => {
-    this.canvas = elem;
-    if (elem) {
-      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();
-        }
-      });
+  /**
+   * Undo one step
+   */
+  undo = () => {
+    if (this.undos.length > 1) {
+      this.undos.pop();
+      const buf = this.undos.pop();
 
-      this.initSketcher(elem);
+      this.sketcher.clear();
+      this.ctx.putImageData(buf, 0, 0);
+      this.doSaveUndo();
     }
   };
 
+  /**
+   * Save canvas content into the undo buffer immediately
+   */
+  doSaveUndo = () => {
+    this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
+  };
+
+  /**
+   * Called on each canvas change.
+   * Saves canvas content to the undo buffer after some period of inactivity.
+   */
+  saveUndo = debounce(() => {
+    this.doSaveUndo();
+  }, 100);
+
+  /**
+   * Palette left click.
+   * Selects Fg color (or Bg, if Control/Meta is held)
+   *
+   * @param e - event
+   */
   onPaletteClick = (e) => {
-    this.fg = e.target.dataset.color;
+    const c = e.target.dataset.color;
+
+    if (this.controlHeld) {
+      this.bg = c;
+    } else {
+      this.fg = c;
+    }
+
     e.target.blur();
+    e.preventDefault();
   };
 
+  /**
+   * Palette right click.
+   * Selects Bg color
+   *
+   * @param e - event
+   */
+  onPaletteRClick = (e) => {
+    this.bg = e.target.dataset.color;
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Handle click on the Draw mode button
+   *
+   * @param e - event
+   */
   setModeDraw = (e) => {
     this.mode = 'draw';
     e.target.blur();
   };
 
+  /**
+   * Handle click on the Fill mode button
+   *
+   * @param e - event
+   */
   setModeFill = (e) => {
     this.mode = 'fill';
     e.target.blur();
   };
 
+  /**
+   * Handle click on Smooth checkbox
+   *
+   * @param e - event
+   */
   tglSmooth = (e) => {
     this.smoothing = !this.smoothing;
     e.target.blur();
   };
 
+  /**
+   * Handle click on Adaptive checkbox
+   *
+   * @param e - event
+   */
   tglAdaptive = (e) => {
     this.adaptiveStroke = !this.adaptiveStroke;
     e.target.blur();
   };
 
+  /**
+   * Handle change of the Weight input field
+   *
+   * @param e - event
+   */
   setWeight = (e) => {
     this.weight = +e.target.value || 1;
   };
 
-  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();
+  /**
+   * Set size - clalback from the select box
+   *
+   * @param e - event
+   */
+  changeSize = (e) => {
+    let newSize = e.target.value;
+    if (newSize === this.oldSize) return;
+
+    if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
+      return;
     }
-  };
 
-  doSaveUndo = () => {
-    this.undos.push(this.sketcher.context.getImageData(0, 0, this.canvas.width, this.canvas.height));
+    this.size = newSize;
   };
 
-  saveUndo = debounce(() => {
-    this.doSaveUndo();
-  }, 100);
+  handleClearBtn = () => {
+    if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
+      return;
+    }
 
+    this.clearScreen();
+  };
+
+  /**
+   * Render the component
+   */
   render () {
     this.updateSketcherSettings();
 
@@ -323,7 +548,10 @@ export default class DoodleModal extends ImmutablePureComponent {
         </div>
 
         <div className='doodle-modal__action-bar'>
-          <Button text='Done' onClick={this.handleDone} />
+          <div className='doodle-toolbar'>
+            <Button text='Done' onClick={this.onDoneButton} />
+            <Button text='Cancel' onClick={this.onCancelButton} />
+          </div>
           <div className='filler' />
           <div className='doodle-toolbar with-inputs'>
             <div>
@@ -344,12 +572,19 @@ export default class DoodleModal extends ImmutablePureComponent {
                 <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
               </span>
             </div>
+            <div>
+              <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
+                { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
+                  <option key={k} value={k}>{val[2]}</option>
+                )) }
+              </select>
+            </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 />
+            <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
+            <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
+            <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
+            <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
           </div>
           <div className='doodle-palette'>
             {
@@ -360,9 +595,13 @@ export default class DoodleModal extends ImmutablePureComponent {
                     key={i}
                     style={{ backgroundColor: c[0] }}
                     onClick={this.onPaletteClick}
+                    onContextMenu={this.onPaletteRClick}
                     data-color={c[0]}
                     title={c[1]}
-                    className={this.fg === c[0] ? 'selected' : ''}
+                    className={classNames({
+                      'foreground': this.fg === c[0],
+                      'background': this.bg === c[0],
+                    })}
                   />
               )
             }
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index b4a0b5d93..3e56fbf8e 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -45,7 +45,7 @@ export default class ModalRoot extends React.PureComponent {
 
   handleKeyUp = (e) => {
     if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
-         && !!this.props.type) {
+         && !!this.props.type && !this.props.props.noEsc) {
       this.props.onClose();
     }
   }
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 9bb8443cc..2c34de4f5 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -65,7 +65,9 @@ const initialState = ImmutableMap({
   doodle: ImmutableMap({
     fg: 'rgb(  0,    0,    0)',
     bg: 'rgb(255,  255,  255)',
+    swapped: false,
     mode: 'draw',
+    size: 'normal',
     weight: 2,
     opacity: 1,
     adaptiveStroke: true,
diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss
index cc785a8ad..a4a1cfc84 100644
--- a/app/javascript/styles/doodle.scss
+++ b/app/javascript/styles/doodle.scss
@@ -1,12 +1,15 @@
+$doodleBg: #d9e1e8;
 .doodle-modal {
   @extend .boost-modal;
   width: unset;
 }
 
 .doodle-modal__container {
+  background: $doodleBg;
+  text-align: center;
   line-height: 0; // remove weird gap under canvas
   canvas {
-    border: 5px solid #d9e1e8;
+    border: 5px solid $doodleBg;
   }
 }
 
@@ -15,9 +18,13 @@
 
   .filler {
     flex-grow: 1;
+    margin: 0;
+    padding: 0;
   }
 
   .doodle-toolbar {
+    line-height: 1;
+
     display: flex;
     flex-direction: column;
     flex-grow: 0;
@@ -60,10 +67,19 @@
       cursor: pointer;
       box-shadow: inset 0 0 1px rgba(white, .5);
       border: 1px solid black;
+      outline-offset:-1px;
+
+      &.foreground {
+        outline: 1px dashed white;
+      }
+
+      &.background {
+        outline: 1px dashed red;
+      }
 
-      &.selected {
-        outline-offset:-1px;
-        outline: 1px dotted white;
+      &.foreground.background {
+        outline: 1px dashed red;
+        border-color: white;
       }
     }
   }