forked from cybrespace/mastodon
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:
parent
0c4615fd76
commit
05e23838ed
|
@ -8,7 +8,7 @@ const mapStateToProps = state => ({
|
|||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onOpenCanvas () {
|
||||
dispatch(openModal('DOODLE', {}));
|
||||
dispatch(openModal('DOODLE', { noEsc: true }));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
handleKeyUp = (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
this.clearScreen();
|
||||
/** Size preset */
|
||||
get size () {
|
||||
return this.props.options.get('size');
|
||||
}
|
||||
set size (value) {
|
||||
this.props.setOpt({ size: value });
|
||||
}
|
||||
|
||||
if (e.key === 'z' && (e.ctrlKey || e.metaKey)) {
|
||||
//endregion
|
||||
|
||||
/** Key up handler */
|
||||
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 */
|
||||
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],
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -45,6 +45,16 @@ export default class ModalRoot extends React.PureComponent {
|
|||
getSnapshotBeforeUpdate () {
|
||||
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 }) {
|
||||
if (visible) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue