diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8d035e82f7..24e64e06cc 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 ca4b14b825..6fb191c6b4 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}
>
+ {this.props.label}
}
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 e1fc894f94..799d085a40 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 d13f9604a5..661aa08fb6 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';
+// 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
+}
+
+// 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 contextTypes = {
- router: PropTypes.object,
+ static propTypes = {
+ options: ImmutablePropTypes.map,
+ onClose: PropTypes.func.isRequired,
+ setOpt: PropTypes.func.isRequired,
+ submit: PropTypes.func.isRequired,
};
- static propTypes = {
- onDoodleSubmit: PropTypes.func.isRequired, // gets the base64 as argument
- onClose: 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();
-
- // .smoothing looks good with mouse but works really poorly with a tablet
- this.sketcher.smoothing = false;
-
- // There's a bunch of options we should add UI controls for later
- // ref: https://github.com/jakubfiala/atrament.js
+ 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();
+ };
+
+ tglAdaptive = (e) => {
+ this.adaptiveStroke = !this.adaptiveStroke;
+ e.target.blur();
+ };
+
+ 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();
+ }
+ };
+
+ 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 (