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 (
{ palReordered.map((c, i) => c === null ?
:
); } } export default connect(mapStateToProps, mapDispatchToProps)(DoodleModal);