import PropTypes from 'prop-types'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { connect } from 'react-redux'; import Atrament from 'atrament'; // the doodling library import { debounce, mapValues } from 'lodash'; import { doodleSet, uploadCompose } from 'flavours/glitch/actions/compose'; import { Button } from 'flavours/glitch/components/button'; import { IconButton } from 'flavours/glitch/components/icon_button'; // 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 }); } /** Doodle canvas size options */ 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 * @param {Object} opts */ setOpt: (opts) => dispatch(doodleSet(opts)), /** * Submit doodle for upload * @param {File} file */ 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 */ 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 * @param {KeyboardEvent} e */ 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 * @param {KeyboardEvent} e */ 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 {HTMLCanvasElement} 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 {HTMLCanvasElement | null} 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 {MouseEvent} 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 {MouseEvent} e - event */ onPaletteRClick = (e) => { this.bg = e.target.dataset.color; e.target.blur(); e.preventDefault(); }; /** * Handle click on the Draw mode button * @param {MouseEvent} e - event */ setModeDraw = (e) => { this.mode = 'draw'; e.target.blur(); }; /** * Handle click on the Fill mode button * @param {MouseEvent} e - event */ setModeFill = (e) => { this.mode = 'fill'; e.target.blur(); }; /** * Handle click on Smooth checkbox * @param {ChangeEvent} e - event */ tglSmooth = (e) => { this.smoothing = !this.smoothing; e.target.blur(); }; /** * Handle click on Adaptive checkbox * @param {ChangeEvent} e - event */ tglAdaptive = (e) => { this.adaptiveStroke = !this.adaptiveStroke; e.target.blur(); }; /** * Handle change of the Weight input field * @param {ChangeEvent} e - event */ setWeight = (e) => { this.weight = +e.target.value || 1; }; /** * Set size - clalback from the select box * @param {ChangeEvent} e - event */ changeSize = (e) => { let newSize = e.target.value; if (newSize === this.oldSize) return; if (this.undos.length > 1 && !confirm('Change canvas size? This will erase your current drawing!')) { return; } this.size = newSize; }; handleClearBtn = () => { if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current drawing!')) { return; } this.clearScreen(); }; /** * Render the component */ render () { this.updateSketcherSettings(); return ( Smoothing Adaptive Weight { Object.values(mapValues(DOODLE_SIZES, (val, k) => {val[2]}, )) } { palReordered.map((c, i) => c === null ? : , ) } ); } } export default connect(mapStateToProps, mapDispatchToProps)(DoodleModal);