You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

614 lines
16 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import Button from 'flavours/glitch/components/button';
  4. import ImmutablePureComponent from 'react-immutable-pure-component';
  5. import Atrament from 'atrament'; // the doodling library
  6. import { connect } from 'react-redux';
  7. import ImmutablePropTypes from 'react-immutable-proptypes';
  8. import { doodleSet, uploadCompose } from 'flavours/glitch/actions/compose';
  9. import IconButton from 'flavours/glitch/components/icon_button';
  10. import { debounce, mapValues } from 'lodash';
  11. import classNames from 'classnames';
  12. // palette nicked from MyPaint, CC0
  13. const palette = [
  14. ['rgb( 0, 0, 0)', 'Black'],
  15. ['rgb( 38, 38, 38)', 'Gray 15'],
  16. ['rgb( 77, 77, 77)', 'Grey 30'],
  17. ['rgb(128, 128, 128)', 'Grey 50'],
  18. ['rgb(171, 171, 171)', 'Grey 67'],
  19. ['rgb(217, 217, 217)', 'Grey 85'],
  20. ['rgb(255, 255, 255)', 'White'],
  21. ['rgb(128, 0, 0)', 'Maroon'],
  22. ['rgb(209, 0, 0)', 'English-red'],
  23. ['rgb(255, 54, 34)', 'Tomato'],
  24. ['rgb(252, 60, 3)', 'Orange-red'],
  25. ['rgb(255, 140, 105)', 'Salmon'],
  26. ['rgb(252, 232, 32)', 'Cadium-yellow'],
  27. ['rgb(243, 253, 37)', 'Lemon yellow'],
  28. ['rgb(121, 5, 35)', 'Dark crimson'],
  29. ['rgb(169, 32, 62)', 'Deep carmine'],
  30. ['rgb(255, 140, 0)', 'Orange'],
  31. ['rgb(255, 168, 18)', 'Dark tangerine'],
  32. ['rgb(217, 144, 88)', 'Persian orange'],
  33. ['rgb(194, 178, 128)', 'Sand'],
  34. ['rgb(255, 229, 180)', 'Peach'],
  35. ['rgb(100, 54, 46)', 'Bole'],
  36. ['rgb(108, 41, 52)', 'Dark cordovan'],
  37. ['rgb(163, 65, 44)', 'Chestnut'],
  38. ['rgb(228, 136, 100)', 'Dark salmon'],
  39. ['rgb(255, 195, 143)', 'Apricot'],
  40. ['rgb(255, 219, 188)', 'Unbleached silk'],
  41. ['rgb(242, 227, 198)', 'Straw'],
  42. ['rgb( 53, 19, 13)', 'Bistre'],
  43. ['rgb( 84, 42, 14)', 'Dark chocolate'],
  44. ['rgb(102, 51, 43)', 'Burnt sienna'],
  45. ['rgb(184, 66, 0)', 'Sienna'],
  46. ['rgb(216, 153, 12)', 'Yellow ochre'],
  47. ['rgb(210, 180, 140)', 'Tan'],
  48. ['rgb(232, 204, 144)', 'Dark wheat'],
  49. ['rgb( 0, 49, 83)', 'Prussian blue'],
  50. ['rgb( 48, 69, 119)', 'Dark grey blue'],
  51. ['rgb( 0, 71, 171)', 'Cobalt blue'],
  52. ['rgb( 31, 117, 254)', 'Blue'],
  53. ['rgb(120, 180, 255)', 'Bright french blue'],
  54. ['rgb(171, 200, 255)', 'Bright steel blue'],
  55. ['rgb(208, 231, 255)', 'Ice blue'],
  56. ['rgb( 30, 51, 58)', 'Medium jungle green'],
  57. ['rgb( 47, 79, 79)', 'Dark slate grey'],
  58. ['rgb( 74, 104, 93)', 'Dark grullo green'],
  59. ['rgb( 0, 128, 128)', 'Teal'],
  60. ['rgb( 67, 170, 176)', 'Turquoise'],
  61. ['rgb(109, 174, 199)', 'Cerulean frost'],
  62. ['rgb(173, 217, 186)', 'Tiffany green'],
  63. ['rgb( 22, 34, 29)', 'Gray-asparagus'],
  64. ['rgb( 36, 48, 45)', 'Medium dark teal'],
  65. ['rgb( 74, 104, 93)', 'Xanadu'],
  66. ['rgb(119, 198, 121)', 'Mint'],
  67. ['rgb(175, 205, 182)', 'Timberwolf'],
  68. ['rgb(185, 245, 246)', 'Celeste'],
  69. ['rgb(193, 255, 234)', 'Aquamarine'],
  70. ['rgb( 29, 52, 35)', 'Cal Poly Pomona'],
  71. ['rgb( 1, 68, 33)', 'Forest green'],
  72. ['rgb( 42, 128, 0)', 'Napier green'],
  73. ['rgb(128, 128, 0)', 'Olive'],
  74. ['rgb( 65, 156, 105)', 'Sea green'],
  75. ['rgb(189, 246, 29)', 'Green-yellow'],
  76. ['rgb(231, 244, 134)', 'Bright chartreuse'],
  77. ['rgb(138, 23, 137)', 'Purple'],
  78. ['rgb( 78, 39, 138)', 'Violet'],
  79. ['rgb(193, 75, 110)', 'Dark thulian pink'],
  80. ['rgb(222, 49, 99)', 'Cerise'],
  81. ['rgb(255, 20, 147)', 'Deep pink'],
  82. ['rgb(255, 102, 204)', 'Rose pink'],
  83. ['rgb(255, 203, 219)', 'Pink'],
  84. ['rgb(255, 255, 255)', 'White'],
  85. ['rgb(229, 17, 1)', 'RGB Red'],
  86. ['rgb( 0, 255, 0)', 'RGB Green'],
  87. ['rgb( 0, 0, 255)', 'RGB Blue'],
  88. ['rgb( 0, 255, 255)', 'CMYK Cyan'],
  89. ['rgb(255, 0, 255)', 'CMYK Magenta'],
  90. ['rgb(255, 255, 0)', 'CMYK Yellow'],
  91. ];
  92. // re-arrange to the right order for display
  93. let palReordered = [];
  94. for (let row = 0; row < 7; row++) {
  95. for (let col = 0; col < 11; col++) {
  96. palReordered.push(palette[col * 7 + row]);
  97. }
  98. palReordered.push(null); // null indicates a <br />
  99. }
  100. // Utility for converting base64 image to binary for upload
  101. // https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
  102. function dataURLtoFile(dataurl, filename) {
  103. let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
  104. bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
  105. while(n--){
  106. u8arr[n] = bstr.charCodeAt(n);
  107. }
  108. return new File([u8arr], filename, { type: mime });
  109. }
  110. const DOODLE_SIZES = {
  111. normal: [500, 500, 'Square 500'],
  112. tootbanner: [702, 330, 'Tootbanner'],
  113. s640x480: [640, 480, '640×480 - 480p'],
  114. s800x600: [800, 600, '800×600 - SVGA'],
  115. s720x480: [720, 405, '720x405 - 16:9'],
  116. };
  117. const mapStateToProps = state => ({
  118. options: state.getIn(['compose', 'doodle']),
  119. });
  120. const mapDispatchToProps = dispatch => ({
  121. /** Set options in the redux store */
  122. setOpt: (opts) => dispatch(doodleSet(opts)),
  123. /** Submit doodle for upload */
  124. submit: (file) => dispatch(uploadCompose([file])),
  125. });
  126. /**
  127. * Doodling dialog with drawing canvas
  128. *
  129. * Keyboard shortcuts:
  130. * - Delete: Clear screen, fill with background color
  131. * - Backspace, Ctrl+Z: Undo one step
  132. * - Ctrl held while drawing: Use background color
  133. * - Shift held while clicking screen: Use fill tool
  134. *
  135. * Palette:
  136. * - Left mouse button: pick foreground
  137. * - Ctrl + left mouse button: pick background
  138. * - Right mouse button: pick background
  139. */
  140. @connect(mapStateToProps, mapDispatchToProps)
  141. export default class DoodleModal extends ImmutablePureComponent {
  142. static propTypes = {
  143. options: ImmutablePropTypes.map,
  144. onClose: PropTypes.func.isRequired,
  145. setOpt: PropTypes.func.isRequired,
  146. submit: PropTypes.func.isRequired,
  147. };
  148. //region Option getters/setters
  149. /** Foreground color */
  150. get fg () {
  151. return this.props.options.get('fg');
  152. }
  153. set fg (value) {
  154. this.props.setOpt({ fg: value });
  155. }
  156. /** Background color */
  157. get bg () {
  158. return this.props.options.get('bg');
  159. }
  160. set bg (value) {
  161. this.props.setOpt({ bg: value });
  162. }
  163. /** Swap Fg and Bg for drawing */
  164. get swapped () {
  165. return this.props.options.get('swapped');
  166. }
  167. set swapped (value) {
  168. this.props.setOpt({ swapped: value });
  169. }
  170. /** Mode - 'draw' or 'fill' */
  171. get mode () {
  172. return this.props.options.get('mode');
  173. }
  174. set mode (value) {
  175. this.props.setOpt({ mode: value });
  176. }
  177. /** Base line weight */
  178. get weight () {
  179. return this.props.options.get('weight');
  180. }
  181. set weight (value) {
  182. this.props.setOpt({ weight: value });
  183. }
  184. /** Drawing opacity */
  185. get opacity () {
  186. return this.props.options.get('opacity');
  187. }
  188. set opacity (value) {
  189. this.props.setOpt({ opacity: value });
  190. }
  191. /** Adaptive stroke - change width with speed */
  192. get adaptiveStroke () {
  193. return this.props.options.get('adaptiveStroke');
  194. }
  195. set adaptiveStroke (value) {
  196. this.props.setOpt({ adaptiveStroke: value });
  197. }
  198. /** Smoothing (for mouse drawing) */
  199. get smoothing () {
  200. return this.props.options.get('smoothing');
  201. }
  202. set smoothing (value) {
  203. this.props.setOpt({ smoothing: value });
  204. }
  205. /** Size preset */
  206. get size () {
  207. return this.props.options.get('size');
  208. }
  209. set size (value) {
  210. this.props.setOpt({ size: value });
  211. }
  212. //endregion
  213. /** Key up handler */
  214. handleKeyUp = (e) => {
  215. if (e.target.nodeName === 'INPUT') return;
  216. if (e.key === 'Delete') {
  217. e.preventDefault();
  218. this.handleClearBtn();
  219. return;
  220. }
  221. if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
  222. e.preventDefault();
  223. this.undo();
  224. }
  225. if (e.key === 'Control' || e.key === 'Meta') {
  226. this.controlHeld = false;
  227. this.swapped = false;
  228. }
  229. if (e.key === 'Shift') {
  230. this.shiftHeld = false;
  231. this.mode = 'draw';
  232. }
  233. };
  234. /** Key down handler */
  235. handleKeyDown = (e) => {
  236. if (e.key === 'Control' || e.key === 'Meta') {
  237. this.controlHeld = true;
  238. this.swapped = true;
  239. }
  240. if (e.key === 'Shift') {
  241. this.shiftHeld = true;
  242. this.mode = 'fill';
  243. }
  244. };
  245. /**
  246. * Component installed in the DOM, do some initial set-up
  247. */
  248. componentDidMount () {
  249. this.controlHeld = false;
  250. this.shiftHeld = false;
  251. this.swapped = false;
  252. window.addEventListener('keyup', this.handleKeyUp, false);
  253. window.addEventListener('keydown', this.handleKeyDown, false);
  254. };
  255. /**
  256. * Tear component down
  257. */
  258. componentWillUnmount () {
  259. window.removeEventListener('keyup', this.handleKeyUp, false);
  260. window.removeEventListener('keydown', this.handleKeyDown, false);
  261. if (this.sketcher) this.sketcher.destroy();
  262. }
  263. /**
  264. * Set reference to the canvas element.
  265. * This is called during component init
  266. *
  267. * @param elem - canvas element
  268. */
  269. setCanvasRef = (elem) => {
  270. this.canvas = elem;
  271. if (elem) {
  272. elem.addEventListener('dirty', () => {
  273. this.saveUndo();
  274. this.sketcher._dirty = false;
  275. });
  276. elem.addEventListener('click', () => {
  277. // sketcher bug - does not fire dirty on fill
  278. if (this.mode === 'fill') {
  279. this.saveUndo();
  280. }
  281. });
  282. // prevent context menu
  283. elem.addEventListener('contextmenu', (e) => {
  284. e.preventDefault();
  285. });
  286. elem.addEventListener('mousedown', (e) => {
  287. if (e.button === 2) {
  288. this.swapped = true;
  289. }
  290. });
  291. elem.addEventListener('mouseup', (e) => {
  292. if (e.button === 2) {
  293. this.swapped = this.controlHeld;
  294. }
  295. });
  296. this.initSketcher(elem);
  297. this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
  298. }
  299. };
  300. /**
  301. * Set up the sketcher instance
  302. *
  303. * @param canvas - canvas element. Null if we're just resizing
  304. */
  305. initSketcher (canvas = null) {
  306. const sizepreset = DOODLE_SIZES[this.size];
  307. if (this.sketcher) this.sketcher.destroy();
  308. this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
  309. if (canvas) {
  310. this.ctx = this.sketcher.context;
  311. this.updateSketcherSettings();
  312. }
  313. this.clearScreen();
  314. }
  315. /**
  316. * Done button handler
  317. */
  318. onDoneButton = () => {
  319. const dataUrl = this.sketcher.toImage();
  320. const file = dataURLtoFile(dataUrl, 'doodle.png');
  321. this.props.submit(file);
  322. this.props.onClose(); // close dialog
  323. };
  324. /**
  325. * Cancel button handler
  326. */
  327. onCancelButton = () => {
  328. if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
  329. return;
  330. }
  331. this.props.onClose(); // close dialog
  332. };
  333. /**
  334. * Update sketcher options based on state
  335. */
  336. updateSketcherSettings () {
  337. if (!this.sketcher) return;
  338. if (this.oldSize !== this.size) this.initSketcher();
  339. this.sketcher.color = (this.swapped ? this.bg : this.fg);
  340. this.sketcher.opacity = this.opacity;
  341. this.sketcher.weight = this.weight;
  342. this.sketcher.mode = this.mode;
  343. this.sketcher.smoothing = this.smoothing;
  344. this.sketcher.adaptiveStroke = this.adaptiveStroke;
  345. this.oldSize = this.size;
  346. }
  347. /**
  348. * Fill screen with background color
  349. */
  350. clearScreen = () => {
  351. this.ctx.fillStyle = this.bg;
  352. this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
  353. this.undos = [];
  354. this.doSaveUndo();
  355. };
  356. /**
  357. * Undo one step
  358. */
  359. undo = () => {
  360. if (this.undos.length > 1) {
  361. this.undos.pop();
  362. const buf = this.undos.pop();
  363. this.sketcher.clear();
  364. this.ctx.putImageData(buf, 0, 0);
  365. this.doSaveUndo();
  366. }
  367. };
  368. /**
  369. * Save canvas content into the undo buffer immediately
  370. */
  371. doSaveUndo = () => {
  372. this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
  373. };
  374. /**
  375. * Called on each canvas change.
  376. * Saves canvas content to the undo buffer after some period of inactivity.
  377. */
  378. saveUndo = debounce(() => {
  379. this.doSaveUndo();
  380. }, 100);
  381. /**
  382. * Palette left click.
  383. * Selects Fg color (or Bg, if Control/Meta is held)
  384. *
  385. * @param e - event
  386. */
  387. onPaletteClick = (e) => {
  388. const c = e.target.dataset.color;
  389. if (this.controlHeld) {
  390. this.bg = c;
  391. } else {
  392. this.fg = c;
  393. }
  394. e.target.blur();
  395. e.preventDefault();
  396. };
  397. /**
  398. * Palette right click.
  399. * Selects Bg color
  400. *
  401. * @param e - event
  402. */
  403. onPaletteRClick = (e) => {
  404. this.bg = e.target.dataset.color;
  405. e.target.blur();
  406. e.preventDefault();
  407. };
  408. /**
  409. * Handle click on the Draw mode button
  410. *
  411. * @param e - event
  412. */
  413. setModeDraw = (e) => {
  414. this.mode = 'draw';
  415. e.target.blur();
  416. };
  417. /**
  418. * Handle click on the Fill mode button
  419. *
  420. * @param e - event
  421. */
  422. setModeFill = (e) => {
  423. this.mode = 'fill';
  424. e.target.blur();
  425. };
  426. /**
  427. * Handle click on Smooth checkbox
  428. *
  429. * @param e - event
  430. */
  431. tglSmooth = (e) => {
  432. this.smoothing = !this.smoothing;
  433. e.target.blur();
  434. };
  435. /**
  436. * Handle click on Adaptive checkbox
  437. *
  438. * @param e - event
  439. */
  440. tglAdaptive = (e) => {
  441. this.adaptiveStroke = !this.adaptiveStroke;
  442. e.target.blur();
  443. };
  444. /**
  445. * Handle change of the Weight input field
  446. *
  447. * @param e - event
  448. */
  449. setWeight = (e) => {
  450. this.weight = +e.target.value || 1;
  451. };
  452. /**
  453. * Set size - clalback from the select box
  454. *
  455. * @param e - event
  456. */
  457. changeSize = (e) => {
  458. let newSize = e.target.value;
  459. if (newSize === this.oldSize) return;
  460. if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
  461. return;
  462. }
  463. this.size = newSize;
  464. };
  465. handleClearBtn = () => {
  466. if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
  467. return;
  468. }
  469. this.clearScreen();
  470. };
  471. /**
  472. * Render the component
  473. */
  474. render () {
  475. this.updateSketcherSettings();
  476. return (
  477. <div className='modal-root__modal doodle-modal'>
  478. <div className='doodle-modal__container'>
  479. <canvas ref={this.setCanvasRef} />
  480. </div>
  481. <div className='doodle-modal__action-bar'>
  482. <div className='doodle-toolbar'>
  483. <Button text='Done' onClick={this.onDoneButton} />
  484. <Button text='Cancel' onClick={this.onCancelButton} />
  485. </div>
  486. <div className='filler' />
  487. <div className='doodle-toolbar with-inputs'>
  488. <div>
  489. <label htmlFor='dd_smoothing'>Smoothing</label>
  490. <span className='val'>
  491. <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
  492. </span>
  493. </div>
  494. <div>
  495. <label htmlFor='dd_adaptive'>Adaptive</label>
  496. <span className='val'>
  497. <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
  498. </span>
  499. </div>
  500. <div>
  501. <label htmlFor='dd_weight'>Weight</label>
  502. <span className='val'>
  503. <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
  504. </span>
  505. </div>
  506. <div>
  507. <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
  508. { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
  509. <option key={k} value={k}>{val[2]}</option>
  510. )) }
  511. </select>
  512. </div>
  513. </div>
  514. <div className='doodle-toolbar'>
  515. <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
  516. <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
  517. <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
  518. <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
  519. </div>
  520. <div className='doodle-palette'>
  521. {
  522. palReordered.map((c, i) =>
  523. c === null ?
  524. <br key={i} /> :
  525. <button
  526. key={i}
  527. style={{ backgroundColor: c[0] }}
  528. onClick={this.onPaletteClick}
  529. onContextMenu={this.onPaletteRClick}
  530. data-color={c[0]}
  531. title={c[1]}
  532. className={classNames({
  533. 'foreground': this.fg === c[0],
  534. 'background': this.bg === c[0],
  535. })}
  536. />
  537. )
  538. }
  539. </div>
  540. </div>
  541. </div>
  542. );
  543. }
  544. }