Doodle improvements 2 (#176)

* Fix some doodle bugs and added Background color functionality

* added protections against accidental doodle erase, screen size changing

* resolve react warning about 'selected' on <option>
This commit is contained in:
Ondřej Hruška 2017-10-14 12:24:35 +02:00 committed by Andrew
parent 0c4615fd76
commit 05e23838ed
5 changed files with 351 additions and 84 deletions

View File

@ -8,7 +8,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
onOpenCanvas () { onOpenCanvas () {
dispatch(openModal('DOODLE', {})); dispatch(openModal('DOODLE', { noEsc: true }));
}, },
}); });

View File

@ -7,7 +7,8 @@ import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { doodleSet, uploadCompose } from '../../../actions/compose'; import { doodleSet, uploadCompose } from '../../../actions/compose';
import IconButton from '../../../components/icon_button'; import IconButton from '../../../components/icon_button';
import { debounce } from 'lodash'; import { debounce, mapValues } from 'lodash';
import classNames from 'classnames';
// palette nicked from MyPaint, CC0 // palette nicked from MyPaint, CC0
const palette = [ const palette = [
@ -110,16 +111,40 @@ function dataURLtoFile(dataurl, filename) {
return new File([u8arr], filename, { type: mime }); return new File([u8arr], filename, { type: mime });
} }
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 => ({ const mapStateToProps = state => ({
options: state.getIn(['compose', 'doodle']), options: state.getIn(['compose', 'doodle']),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
/** Set options in the redux store */
setOpt: (opts) => dispatch(doodleSet(opts)), setOpt: (opts) => dispatch(doodleSet(opts)),
/** Submit doodle for upload */
submit: (file) => dispatch(uploadCompose([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
*/
@connect(mapStateToProps, mapDispatchToProps) @connect(mapStateToProps, mapDispatchToProps)
export default class DoodleModal extends ImmutablePureComponent { export default class DoodleModal extends ImmutablePureComponent {
@ -132,121 +157,145 @@ export default class DoodleModal extends ImmutablePureComponent {
//region Option getters/setters //region Option getters/setters
/** Foreground color */
get fg () { get fg () {
return this.props.options.get('fg'); return this.props.options.get('fg');
} }
set fg (value) { set fg (value) {
this.props.setOpt({ fg: value }); this.props.setOpt({ fg: value });
} }
/** Background color */
get bg () { get bg () {
return this.props.options.get('bg'); return this.props.options.get('bg');
} }
set bg (value) { set bg (value) {
this.props.setOpt({ 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 () { get mode () {
return this.props.options.get('mode'); return this.props.options.get('mode');
} }
set mode (value) { set mode (value) {
this.props.setOpt({ mode: value }); this.props.setOpt({ mode: value });
} }
/** Base line weight */
get weight () { get weight () {
return this.props.options.get('weight'); return this.props.options.get('weight');
} }
set weight (value) { set weight (value) {
this.props.setOpt({ weight: value }); this.props.setOpt({ weight: value });
} }
/** Drawing opacity */
get opacity () { get opacity () {
return this.props.options.get('opacity'); return this.props.options.get('opacity');
} }
set opacity (value) { set opacity (value) {
this.props.setOpt({ opacity: value }); this.props.setOpt({ opacity: value });
} }
/** Adaptive stroke - change width with speed */
get adaptiveStroke () { get adaptiveStroke () {
return this.props.options.get('adaptiveStroke'); return this.props.options.get('adaptiveStroke');
} }
set adaptiveStroke (value) { set adaptiveStroke (value) {
this.props.setOpt({ adaptiveStroke: value }); this.props.setOpt({ adaptiveStroke: value });
} }
/** Smoothing (for mouse drawing) */
get smoothing () { get smoothing () {
return this.props.options.get('smoothing'); return this.props.options.get('smoothing');
} }
set smoothing (value) { set smoothing (value) {
this.props.setOpt({ 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 //endregion
/** Key up handler */
handleKeyUp = (e) => { handleKeyUp = (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.target.nodeName === 'INPUT') return;
if (e.key === 'Delete') {
e.preventDefault(); e.preventDefault();
this.clearScreen(); this.handleClearBtn();
return;
} }
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
e.preventDefault(); e.preventDefault();
this.undo(); 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 */
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 () { componentDidMount () {
this.controlHeld = false;
this.shiftHeld = false;
this.swapped = false;
window.addEventListener('keyup', this.handleKeyUp, false); window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
}; };
/**
* Tear component down
*/
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp, false); window.removeEventListener('keyup', this.handleKeyUp, false);
window.removeEventListener('keydown', this.handleKeyDown, false);
if (this.sketcher) this.sketcher.destroy();
} }
clearScreen = () => { /**
this.sketcher.context.fillStyle = this.bg; * Set reference to the canvas element.
this.sketcher.context.fillRect(0, 0, this.canvas.width, this.canvas.height); * This is called during component init
this.undos = []; *
* @param elem - canvas element
this.doSaveUndo(); */
};
handleDone = () => {
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) => { setCanvasRef = (elem) => {
this.canvas = elem; this.canvas = elem;
if (elem) { if (elem) {
@ -254,6 +303,7 @@ export default class DoodleModal extends ImmutablePureComponent {
this.saveUndo(); this.saveUndo();
this.sketcher._dirty = false; this.sketcher._dirty = false;
}); });
elem.addEventListener('click', () => { elem.addEventListener('click', () => {
// sketcher bug - does not fire dirty on fill // sketcher bug - does not fire dirty on fill
if (this.mode === 'fill') { if (this.mode === 'fill') {
@ -261,58 +311,233 @@ export default class DoodleModal extends ImmutablePureComponent {
} }
}); });
// 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.initSketcher(elem);
this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
} }
}; };
onPaletteClick = (e) => { /**
this.fg = e.target.dataset.color; * Set up the sketcher instance
e.target.blur(); *
* @param 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
}; };
setModeDraw = (e) => { /**
this.mode = 'draw'; * Cancel button handler
e.target.blur(); */
onCancelButton = () => {
if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
return;
}
this.props.onClose(); // close dialog
}; };
setModeFill = (e) => { /**
this.mode = 'fill'; * Update sketcher options based on state
e.target.blur(); */
}; updateSketcherSettings () {
if (!this.sketcher) return;
tglSmooth = (e) => {
this.smoothing = !this.smoothing; if (this.oldSize !== this.size) this.initSketcher();
e.target.blur();
}; this.sketcher.color = (this.swapped ? this.bg : this.fg);
this.sketcher.opacity = this.opacity;
tglAdaptive = (e) => { this.sketcher.weight = this.weight;
this.adaptiveStroke = !this.adaptiveStroke; this.sketcher.mode = this.mode;
e.target.blur(); this.sketcher.smoothing = this.smoothing;
}; this.sketcher.adaptiveStroke = this.adaptiveStroke;
setWeight = (e) => { this.oldSize = this.size;
this.weight = +e.target.value || 1; }
/**
* 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 = () => { undo = () => {
if (this.undos.length > 1) { if (this.undos.length > 1) {
this.undos.pop(); this.undos.pop();
const buf = this.undos.pop(); const buf = this.undos.pop();
this.sketcher.clear(); this.sketcher.clear();
this.sketcher.context.putImageData(buf, 0, 0); this.ctx.putImageData(buf, 0, 0);
this.doSaveUndo(); this.doSaveUndo();
} }
}; };
/**
* Save canvas content into the undo buffer immediately
*/
doSaveUndo = () => { doSaveUndo = () => {
this.undos.push(this.sketcher.context.getImageData(0, 0, this.canvas.width, this.canvas.height)); 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(() => { saveUndo = debounce(() => {
this.doSaveUndo(); this.doSaveUndo();
}, 100); }, 100);
/**
* Palette left click.
* Selects Fg color (or Bg, if Control/Meta is held)
*
* @param 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 e - event
*/
onPaletteRClick = (e) => {
this.bg = e.target.dataset.color;
e.target.blur();
e.preventDefault();
};
/**
* Handle click on the Draw mode button
*
* @param e - event
*/
setModeDraw = (e) => {
this.mode = 'draw';
e.target.blur();
};
/**
* Handle click on the Fill mode button
*
* @param e - event
*/
setModeFill = (e) => {
this.mode = 'fill';
e.target.blur();
};
/**
* Handle click on Smooth checkbox
*
* @param e - event
*/
tglSmooth = (e) => {
this.smoothing = !this.smoothing;
e.target.blur();
};
/**
* Handle click on Adaptive checkbox
*
* @param e - event
*/
tglAdaptive = (e) => {
this.adaptiveStroke = !this.adaptiveStroke;
e.target.blur();
};
/**
* Handle change of the Weight input field
*
* @param e - event
*/
setWeight = (e) => {
this.weight = +e.target.value || 1;
};
/**
* Set size - clalback from the select box
*
* @param e - event
*/
changeSize = (e) => {
let newSize = e.target.value;
if (newSize === this.oldSize) return;
if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
return;
}
this.size = newSize;
};
handleClearBtn = () => {
if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
return;
}
this.clearScreen();
};
/**
* Render the component
*/
render () { render () {
this.updateSketcherSettings(); this.updateSketcherSettings();
@ -323,7 +548,10 @@ export default class DoodleModal extends ImmutablePureComponent {
</div> </div>
<div className='doodle-modal__action-bar'> <div className='doodle-modal__action-bar'>
<Button text='Done' onClick={this.handleDone} /> <div className='doodle-toolbar'>
<Button text='Done' onClick={this.onDoneButton} />
<Button text='Cancel' onClick={this.onCancelButton} />
</div>
<div className='filler' /> <div className='filler' />
<div className='doodle-toolbar with-inputs'> <div className='doodle-toolbar with-inputs'>
<div> <div>
@ -344,12 +572,19 @@ export default class DoodleModal extends ImmutablePureComponent {
<input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} /> <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
</span> </span>
</div> </div>
<div>
<select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
{ Object.values(mapValues(DOODLE_SIZES, (val, k) =>
<option key={k} value={k}>{val[2]}</option>
)) }
</select>
</div>
</div> </div>
<div className='doodle-toolbar'> <div className='doodle-toolbar'>
<IconButton icon='pencil' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted /> <IconButton icon='pencil' title='Draw' 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='bath' title='Fill' 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='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
<IconButton icon='trash' label='Clear' onClick={this.clearScreen} size={18} inverted /> <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
</div> </div>
<div className='doodle-palette'> <div className='doodle-palette'>
{ {
@ -360,9 +595,13 @@ export default class DoodleModal extends ImmutablePureComponent {
key={i} key={i}
style={{ backgroundColor: c[0] }} style={{ backgroundColor: c[0] }}
onClick={this.onPaletteClick} onClick={this.onPaletteClick}
onContextMenu={this.onPaletteRClick}
data-color={c[0]} data-color={c[0]}
title={c[1]} title={c[1]}
className={this.fg === c[0] ? 'selected' : ''} className={classNames({
'foreground': this.fg === c[0],
'background': this.bg === c[0],
})}
/> />
) )
} }

View File

@ -45,6 +45,16 @@ export default class ModalRoot extends React.PureComponent {
getSnapshotBeforeUpdate () { getSnapshotBeforeUpdate () {
return { visible: !!this.props.type }; return { visible: !!this.props.type };
} }
state = {
revealed: false,
};
handleKeyUp = (e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
&& !!this.props.type && !this.props.props.noEsc) {
this.props.onClose();
}
}
componentDidUpdate (prevProps, prevState, { visible }) { componentDidUpdate (prevProps, prevState, { visible }) {
if (visible) { if (visible) {

View File

@ -65,7 +65,9 @@ const initialState = ImmutableMap({
doodle: ImmutableMap({ doodle: ImmutableMap({
fg: 'rgb( 0, 0, 0)', fg: 'rgb( 0, 0, 0)',
bg: 'rgb(255, 255, 255)', bg: 'rgb(255, 255, 255)',
swapped: false,
mode: 'draw', mode: 'draw',
size: 'normal',
weight: 2, weight: 2,
opacity: 1, opacity: 1,
adaptiveStroke: true, adaptiveStroke: true,

View File

@ -1,12 +1,15 @@
$doodleBg: #d9e1e8;
.doodle-modal { .doodle-modal {
@extend .boost-modal; @extend .boost-modal;
width: unset; width: unset;
} }
.doodle-modal__container { .doodle-modal__container {
background: $doodleBg;
text-align: center;
line-height: 0; // remove weird gap under canvas line-height: 0; // remove weird gap under canvas
canvas { canvas {
border: 5px solid #d9e1e8; border: 5px solid $doodleBg;
} }
} }
@ -15,9 +18,13 @@
.filler { .filler {
flex-grow: 1; flex-grow: 1;
margin: 0;
padding: 0;
} }
.doodle-toolbar { .doodle-toolbar {
line-height: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 0; flex-grow: 0;
@ -60,10 +67,19 @@
cursor: pointer; cursor: pointer;
box-shadow: inset 0 0 1px rgba(white, .5); box-shadow: inset 0 0 1px rgba(white, .5);
border: 1px solid black; border: 1px solid black;
outline-offset:-1px;
&.selected { &.foreground {
outline-offset:-1px; outline: 1px dashed white;
outline: 1px dotted white; }
&.background {
outline: 1px dashed red;
}
&.foreground.background {
outline: 1px dashed red;
border-color: white;
} }
} }
} }