Skip to content

Instantly share code, notes, and snippets.

@MightyPork
Last active October 14, 2017 20:57
Show Gist options
  • Save MightyPork/b7f5b90bc15db871844e071cbb40aee7 to your computer and use it in GitHub Desktop.
Save MightyPork/b7f5b90bc15db871844e071cbb40aee7 to your computer and use it in GitHub Desktop.
glitch-soc DoodlePad diff against tootsuite/master, generated Oct 14, 2017
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8a35049b..5e39bd8c 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -44,6 +44,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,
@@ -155,6 +157,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 3e5f8ac8..78f68009 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -21,6 +21,7 @@ export default class IconButton extends React.PureComponent {
animate: PropTypes.bool,
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
+ label: PropTypes.string,
};
static defaultProps = {
@@ -41,14 +42,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'];
@@ -86,6 +91,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/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 7d175a91..3a2bffa1 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
import UploadButtonContainer from '../containers/upload_button_container';
+import DoodleButtonContainer from '../containers/doodle_button_container';
import { defineMessages, injectIntl } from 'react-intl';
import Collapsable from '../../../components/collapsable';
import SpoilerButtonContainer from '../containers/spoiler_button_container';
@@ -196,6 +197,7 @@ export default class ComposeForm extends ImmutablePureComponent {
<div className='compose-form__buttons-wrapper'>
<div className='compose-form__buttons'>
<UploadButtonContainer />
+ <DoodleButtonContainer />
<PrivacyDropdownContainer />
<SensitiveButtonContainer />
<SpoilerButtonContainer />
diff --git a/app/javascript/mastodon/features/compose/components/doodle_button.js b/app/javascript/mastodon/features/compose/components/doodle_button.js
new file mode 100644
index 00000000..0af02458
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/doodle_button.js
@@ -0,0 +1,41 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+ doodle: { id: 'doodle_button.label', defaultMessage: 'Add a drawing' },
+});
+
+const iconStyle = {
+ height: null,
+ lineHeight: '27px',
+};
+
+@injectIntl
+export default class UploadButton extends ImmutablePureComponent {
+
+ static propTypes = {
+ disabled: PropTypes.bool,
+ onOpenCanvas: PropTypes.func.isRequired,
+ style: PropTypes.object,
+ intl: PropTypes.object.isRequired,
+ };
+
+ handleClick = () => {
+ this.props.onOpenCanvas();
+ }
+
+ render () {
+
+ const { intl, disabled } = this.props;
+
+ return (
+ <div className='compose-form__upload-button'>
+ <IconButton icon='pencil' title={intl.formatMessage(messages.doodle)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle} />
+ </div>
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/doodle_button_container.js b/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
new file mode 100644
index 00000000..5ada4514
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/doodle_button_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import DoodleButton from '../components/doodle_button';
+import { openModal } from '../../../actions/modal';
+
+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')),
+});
+
+const mapDispatchToProps = dispatch => ({
+ onOpenCanvas () {
+ dispatch(openModal('DOODLE', { noEsc: true }));
+ },
+});
+
+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
new file mode 100644
index 00000000..4efc9d2e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/doodle_modal.js
@@ -0,0 +1,614 @@
+import React from 'react';
+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, mapValues } from 'lodash';
+import classNames from 'classnames';
+
+// 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'],
+];
+
+// 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 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 {
+
+ static propTypes = {
+ options: ImmutablePropTypes.map,
+ onClose: PropTypes.func.isRequired,
+ setOpt: PropTypes.func.isRequired,
+ submit: PropTypes.func.isRequired,
+ };
+
+ //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.target.nodeName === 'INPUT') return;
+
+ if (e.key === 'Delete') {
+ e.preventDefault();
+ this.handleClearBtn();
+ return;
+ }
+
+ 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();
+ }
+
+ /**
+ * 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;
+ });
+
+ 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'
+ }
+ };
+
+ /**
+ * 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
+ };
+
+ /**
+ * 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;
+
+ 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;
+
+ this.oldSize = this.size;
+ }
+
+ /**
+ * 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.doSaveUndo();
+ };
+
+ /**
+ * Undo one step
+ */
+ undo = () => {
+ if (this.undos.length > 1) {
+ this.undos.pop();
+ const buf = this.undos.pop();
+
+ 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) => {
+ 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;
+ };
+
+ /**
+ * 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;
+ }
+
+ this.size = newSize;
+ };
+
+ handleClearBtn = () => {
+ if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
+ return;
+ }
+
+ this.clearScreen();
+ };
+
+ /**
+ * Render the component
+ */
+ render () {
+ this.updateSketcherSettings();
+
+ return (
+ <div className='modal-root__modal doodle-modal'>
+ <div className='doodle-modal__container'>
+ <canvas ref={this.setCanvasRef} />
+ </div>
+
+ <div className='doodle-modal__action-bar'>
+ <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>
+ <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>
+ <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' 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'>
+ {
+ palReordered.map((c, i) =>
+ c === null ?
+ <br key={i} /> :
+ <button
+ key={i}
+ style={{ backgroundColor: c[0] }}
+ onClick={this.onPaletteClick}
+ onContextMenu={this.onPaletteRClick}
+ data-color={c[0]}
+ title={c[1]}
+ className={classNames({
+ 'foreground': this.fg === c[0],
+ 'background': this.bg === c[0],
+ })}
+ />
+ )
+ }
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index f420f0ab..2e9df57b 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -7,6 +7,7 @@ import ActionsModal from './actions_modal';
import MediaModal from './media_modal';
import VideoModal from './video_modal';
import BoostModal from './boost_modal';
+import DoodleModal from './doodle_modal';
import ConfirmationModal from './confirmation_modal';
import {
OnboardingModal,
@@ -19,6 +20,7 @@ const MODAL_COMPONENTS = {
'ONBOARDING': OnboardingModal,
'VIDEO': () => Promise.resolve({ default: VideoModal }),
'BOOST': () => Promise.resolve({ default: BoostModal }),
+ 'DOODLE': () => Promise.resolve({ default: DoodleModal }),
'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
'REPORT': ReportModal,
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
@@ -39,7 +41,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();
}
}
@@ -84,7 +86,7 @@ export default class ModalRoot extends React.PureComponent {
}
renderLoading = modalId => () => {
- return ['MEDIA', 'VIDEO', 'BOOST', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
+ return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
}
renderError = (props) => {
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 3e9310f1..2abdd4e3 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -25,6 +25,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';
@@ -54,6 +55,17 @@ 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)',
+ swapped: false,
+ mode: 'draw',
+ size: 'normal',
+ weight: 2,
+ opacity: 1,
+ adaptiveStroke: true,
+ smoothing: false,
+ }),
});
function statusToTextMentions(state, status) {
@@ -271,6 +283,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 b6da70c9..f057c43a 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -4329,3 +4329,5 @@ noscript {
}
}
}
+
+@import 'doodle';
diff --git a/app/javascript/styles/doodle.scss b/app/javascript/styles/doodle.scss
new file mode 100644
index 00000000..a4a1cfc8
--- /dev/null
+++ b/app/javascript/styles/doodle.scss
@@ -0,0 +1,86 @@
+$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 $doodleBg;
+ }
+}
+
+.doodle-modal__action-bar {
+ @extend .boost-modal__action-bar;
+
+ .filler {
+ flex-grow: 1;
+ margin: 0;
+ padding: 0;
+ }
+
+ .doodle-toolbar {
+ line-height: 1;
+
+ 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;
+ outline-offset:-1px;
+
+ &.foreground {
+ outline: 1px dashed white;
+ }
+
+ &.background {
+ outline: 1px dashed red;
+ }
+
+ &.foreground.background {
+ outline: 1px dashed red;
+ border-color: white;
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 93e254ab..5dc2a914 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"private": true,
"dependencies": {
"array-includes": "^3.0.3",
+ "atrament": "^0.2.3",
"autoprefixer": "^7.1.2",
"axios": "^0.16.2",
"babel-core": "^6.25.0",
diff --git a/yarn.lock b/yarn.lock
index f32c9ace..a1c29bad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -300,6 +300,10 @@ atob@~1.1.0:
version "1.1.3"
resolved "https://registry.yarnpkg.com/atob/-/atob-1.1.3.tgz#95f13629b12c3a51a5d215abdce2aa9f32f80773"
+atrament@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/atrament/-/atrament-0.2.3.tgz#6ccbc0daa6d3f25e5aeaeb31befeb78e86980348"
+
autoprefixer@^6.3.1:
version "6.7.7"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment