diff options
-rw-r--r-- | app/javascript/mastodon/actions/compose.js | 9 | ||||
-rw-r--r-- | app/javascript/mastodon/components/icon_button.js | 10 | ||||
-rw-r--r-- | app/javascript/mastodon/features/compose/containers/doodle_button_container.js | 20 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/doodle_modal.js | 343 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/compose.js | 12 | ||||
-rw-r--r-- | app/javascript/styles/components.scss | 8 | ||||
-rw-r--r-- | app/javascript/styles/doodle.scss | 70 |
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; + } + } + } +} |