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>
pull/164/merge
Ondřej Hruška 2017-10-14 12:24:35 +02:00 committed by GitHub
parent 531dadad86
commit cdc22d23b9
5 changed files with 342 additions and 85 deletions

View File

@ -8,7 +8,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
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 { doodleSet, uploadCompose } from '../../../actions/compose';
import IconButton from '../../../components/icon_button';
import { debounce } from 'lodash';
import { debounce, mapValues } from 'lodash';
import classNames from 'classnames';
// palette nicked from MyPaint, CC0
const palette = [
@ -110,16 +111,40 @@ function dataURLtoFile(dataurl, filename) {
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 => ({
options: state.getIn(['compose', 'doodle']),
});
const mapDispatchToProps = dispatch => ({
/** Set options in the redux store */
setOpt: (opts) => dispatch(doodleSet(opts)),
/** Submit doodle for upload */
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)
export default class DoodleModal extends ImmutablePureComponent {
@ -132,121 +157,145 @@ export default class DoodleModal extends ImmutablePureComponent {
//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 */
handleKeyUp = (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (e.target.nodeName === 'INPUT') return;
if (e.key === 'Delete') {
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();
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 () {
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();
}
clearScreen = () => {
this.sketcher.context.fillStyle = this.bg;
this.sketcher.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.undos = [];
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();
}
/**
* Set reference to the canvas element.
* This is called during component init
*
* @param elem - canvas element
*/
setCanvasRef = (elem) => {
this.canvas = elem;
if (elem) {
@ -254,6 +303,7 @@ export default class DoodleModal extends ImmutablePureComponent {
this.saveUndo();
this.sketcher._dirty = false;
});
elem.addEventListener('click', () => {
// sketcher bug - does not fire dirty on 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.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
}
};
onPaletteClick = (e) => {
this.fg = e.target.dataset.color;
e.target.blur();
/**
* Set up the sketcher instance
*
* @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';
e.target.blur();
/**
* Cancel button handler
*/
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';
e.target.blur();
};
tglSmooth = (e) => {
this.smoothing = !this.smoothing;
e.target.blur();
};
tglAdaptive = (e) => {
this.adaptiveStroke = !this.adaptiveStroke;
e.target.blur();
};
setWeight = (e) => {
this.weight = +e.target.value || 1;
/**
* 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.sketcher.context.putImageData(buf, 0, 0);
this.ctx.putImageData(buf, 0, 0);
this.doSaveUndo();
}
};
/**
* Save canvas content into the undo buffer immediately
*/
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(() => {
this.doSaveUndo();
}, 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 () {
this.updateSketcherSettings();
@ -323,7 +548,10 @@ export default class DoodleModal extends ImmutablePureComponent {
</div>
<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='doodle-toolbar with-inputs'>
<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} />
</span>
</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 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 />
<IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
<IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
<IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
<IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
</div>
<div className='doodle-palette'>
{
@ -360,9 +595,13 @@ export default class DoodleModal extends ImmutablePureComponent {
key={i}
style={{ backgroundColor: c[0] }}
onClick={this.onPaletteClick}
onContextMenu={this.onPaletteRClick}
data-color={c[0]}
title={c[1]}
className={this.fg === c[0] ? 'selected' : ''}
className={classNames({
'foreground': this.fg === c[0],
'background': this.bg === c[0],
})}
/>
)
}

View File

@ -45,7 +45,7 @@ export default class ModalRoot extends React.PureComponent {
handleKeyUp = (e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
&& !!this.props.type) {
&& !!this.props.type && !this.props.props.noEsc) {
this.props.onClose();
}
}

View File

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

View File

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