Last active
October 14, 2017 20:57
-
-
Save MightyPork/b7f5b90bc15db871844e071cbb40aee7 to your computer and use it in GitHub Desktop.
glitch-soc DoodlePad diff against tootsuite/master, generated Oct 14, 2017
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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