import paper from '@turbowarp/paper';
import PropTypes from 'prop-types';
import log from '../log/log';
import React from 'react';
import {connect} from 'react-redux';
import PaintEditorComponent from '../components/paint-editor/paint-editor.jsx';
import KeyboardShortcutsHOC from '../hocs/keyboard-shortcuts-hoc.jsx';
import SelectionHOC from '../hocs/selection-hoc.jsx';
import UndoHOC from '../hocs/undo-hoc.jsx';
import UpdateImageHOC from '../hocs/update-image-hoc.jsx';
import {changeMode} from '../reducers/modes';
import {changeFormat} from '../reducers/format';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {deactivateEyeDropper} from '../reducers/eye-dropper';
import {setTextEditTarget} from '../reducers/text-edit-target';
import {updateViewBounds} from '../reducers/view-bounds';
import {setLayout} from '../reducers/layout';
import {setTheme as setReduxTheme} from '../reducers/theme';
import {setCustomFonts} from '../reducers/custom-fonts';
import {getSelectedLeafItems} from '../helper/selection';
import {convertToBitmap, convertToVector} from '../helper/bitmap';
import {resizeView, resetZoom, zoomOnSelection, OUTERMOST_ZOOM_LEVEL} from '../helper/view';
import EyeDropperTool from '../helper/tools/eye-dropper';
import Modes, {BitmapModes, VectorModes} from '../lib/modes';
import Formats, {isBitmap, isVector} from '../lib/format';
import bindAll from 'lodash.bindall';
window.paper = paper;
/**
* The top-level paint editor component. See README for more details on usage.
*
*
*
* `image`: may either be nothing, an SVG string or a base64 data URI)
* SVGs of up to size 480 x 360 will fit into the view window of the paint editor,
* while bitmaps of size up to 960 x 720 will fit into the paint editor. One unit
* of an SVG will appear twice as tall and wide as one unit of a bitmap. This quirky
* import behavior comes from needing to support legacy projects in Scratch.
*
* `imageId`: If this parameter changes, then the paint editor will be cleared, the
* undo stack reset, and the image re-imported.
*
* `imageFormat`: 'svg', 'png', or 'jpg'. Other formats are currently not supported.
*
* `rotationCenterX`: x coordinate relative to the top left corner of the sprite of
* the point that should be centered.
*
* `rotationCenterY`: y coordinate relative to the top left corner of the sprite of
* the point that should be centered.
*
* `rtl`: True if the paint editor should be laid out right to left (meant for right
* to left languages)
*
* `onUpdateImage`: A handler called with the new image (either an SVG string or an
* ImageData) each time the drawing is edited.
*
* `zoomLevelId`: All costumes with the same zoom level ID will share the same saved
* zoom level. When a new zoom level ID is encountered, the paint editor will zoom to
* fit the current costume comfortably. Leave undefined to perform no zoom to fit.
*/
class PaintEditor extends React.Component {
static get ZOOM_INCREMENT () {
return 0.5;
}
constructor (props) {
super(props);
bindAll(this, [
'switchModeForFormat',
'onMouseDown',
'onMouseUp',
'setCanvas',
'setTextArea',
'startEyeDroppingLoop',
'stopEyeDroppingLoop',
'handleSetSelectedItems',
'handleChangeTheme',
'handleZoomIn',
'handleZoomOut',
'handleZoomReset'
]);
this.state = {
canvas: null,
colorInfo: null
};
this.props.setLayout(this.props.rtl ? 'rtl' : 'ltr');
this.props.onCustomFontsChanged(this.props.customFonts);
resizeView(this.props.width, this.props.height);
}
componentDidMount () {
document.addEventListener('keydown', this.props.onKeyPress);
// document listeners used to detect if a mouse is down outside of the
// canvas, and should therefore stop the eye dropper
document.addEventListener('mousedown', this.onMouseDown);
document.addEventListener('touchstart', this.onMouseDown);
document.addEventListener('mouseup', this.onMouseUp);
document.addEventListener('touchend', this.onMouseUp);
}
componentWillReceiveProps (newProps) {
if (!isBitmap(this.props.format) && isBitmap(newProps.format)) {
this.switchModeForFormat(Formats.BITMAP);
} else if (!isVector(this.props.format) && isVector(newProps.format)) {
this.switchModeForFormat(Formats.VECTOR);
}
if (newProps.rtl !== this.props.rtl) {
this.props.setLayout(newProps.rtl ? 'rtl' : 'ltr');
}
if (this.props.theme !== newProps.theme) {
this.props.setReduxTheme('default');
}
if (this.props.customFonts !== newProps.customFonts) {
this.props.onCustomFontsChanged(newProps.customFonts);
}
}
componentDidUpdate (prevProps) {
if (this.props.isEyeDropping && !prevProps.isEyeDropping) {
this.startEyeDroppingLoop();
} else if (!this.props.isEyeDropping && prevProps.isEyeDropping) {
this.stopEyeDroppingLoop();
} else if (this.props.isEyeDropping && this.props.viewBounds !== prevProps.viewBounds) {
if (this.props.previousTool) this.props.previousTool.activate();
this.props.onDeactivateEyeDropper();
this.stopEyeDroppingLoop();
}
if (this.props.format === Formats.VECTOR && isBitmap(prevProps.format)) {
convertToVector(this.props.clearSelectedItems, this.props.onUpdateImage);
} else if (isVector(prevProps.format) && this.props.format === Formats.BITMAP) {
convertToBitmap(this.props.clearSelectedItems, this.props.onUpdateImage, this.props.fontInlineFn);
}
}
componentWillUnmount () {
document.removeEventListener('keydown', this.props.onKeyPress);
this.stopEyeDroppingLoop();
document.removeEventListener('mousedown', this.onMouseDown);
document.removeEventListener('touchstart', this.onMouseDown);
document.removeEventListener('mouseup', this.onMouseUp);
document.removeEventListener('touchend', this.onMouseUp);
}
switchModeForFormat (newFormat) {
if ((isVector(newFormat) && (this.props.mode in VectorModes)) ||
(isBitmap(newFormat) && (this.props.mode in BitmapModes))) {
// Format didn't change; no mode change needed
return;
}
if (isVector(newFormat)) {
switch (this.props.mode) {
case Modes.BIT_BRUSH:
this.props.changeMode(Modes.BRUSH);
break;
case Modes.BIT_LINE:
this.props.changeMode(Modes.LINE);
break;
case Modes.BIT_OVAL:
this.props.changeMode(Modes.OVAL);
break;
case Modes.BIT_RECT:
this.props.changeMode(Modes.RECT);
break;
case Modes.BIT_TEXT:
this.props.changeMode(Modes.TEXT);
break;
case Modes.BIT_FILL:
this.props.changeMode(Modes.FILL);
break;
case Modes.BIT_ERASER:
this.props.changeMode(Modes.ERASER);
break;
case Modes.BIT_SELECT:
this.props.changeMode(Modes.SELECT);
break;
default:
log.error(`Mode not handled: ${this.props.mode}`);
this.props.changeMode(Modes.BRUSH);
}
} else if (isBitmap(newFormat)) {
switch (this.props.mode) {
case Modes.BRUSH:
this.props.changeMode(Modes.BIT_BRUSH);
break;
case Modes.LINE:
this.props.changeMode(Modes.BIT_LINE);
break;
case Modes.OVAL:
this.props.changeMode(Modes.BIT_OVAL);
break;
case Modes.RECT:
this.props.changeMode(Modes.BIT_RECT);
break;
case Modes.TEXT:
this.props.changeMode(Modes.BIT_TEXT);
break;
case Modes.FILL:
this.props.changeMode(Modes.BIT_FILL);
break;
case Modes.ERASER:
this.props.changeMode(Modes.BIT_ERASER);
break;
case Modes.RESHAPE:
/* falls through */
case Modes.SELECT:
this.props.changeMode(Modes.BIT_SELECT);
break;
default:
log.error(`Mode not handled: ${this.props.mode}`);
this.props.changeMode(Modes.BIT_BRUSH);
}
}
}
getEffectiveTheme () {
return this.props.reduxTheme === 'default' ? this.props.theme : this.props.reduxTheme;
}
handleChangeTheme () {
const newTheme = this.getEffectiveTheme() === 'light' ? 'dark' : 'light';
this.props.setReduxTheme(newTheme === this.props.theme ? 'default' : newTheme);
}
handleZoomIn () {
// Make the "next step" after the outermost zoom level be the default
// zoom level (0.5)
let zoomIncrement = PaintEditor.ZOOM_INCREMENT;
if (paper.view.zoom === OUTERMOST_ZOOM_LEVEL) {
zoomIncrement = 0.5 - OUTERMOST_ZOOM_LEVEL;
}
zoomOnSelection(zoomIncrement);
this.props.updateViewBounds(paper.view.matrix);
this.handleSetSelectedItems();
}
handleZoomOut () {
zoomOnSelection(-PaintEditor.ZOOM_INCREMENT);
this.props.updateViewBounds(paper.view.matrix);
this.handleSetSelectedItems();
}
handleZoomReset () {
resetZoom();
this.props.updateViewBounds(paper.view.matrix);
this.handleSetSelectedItems();
}
handleSetSelectedItems () {
this.props.setSelectedItems(this.props.format);
}
setCanvas (canvas) {
this.setState({canvas: canvas});
this.canvas = canvas;
}
setTextArea (element) {
this.setState({textArea: element});
}
onMouseDown (event) {
if (event.target === paper.view.element &&
document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
}
if (event.target !== paper.view.element && event.target !== this.state.textArea) {
// Exit text edit mode if you click anywhere outside of canvas
this.props.removeTextEditTarget();
}
}
onMouseUp () {
if (this.props.isEyeDropping) {
const colorString = this.eyeDropper.colorString;
const callback = this.props.changeColorToEyeDropper;
this.eyeDropper.remove();
if (!this.eyeDropper.hideLoupe) {
// If not hide loupe, that means the click is inside the canvas,
// so apply the new color
callback(colorString);
}
if (this.props.previousTool) this.props.previousTool.activate();
this.props.onDeactivateEyeDropper();
this.stopEyeDroppingLoop();
}
}
startEyeDroppingLoop () {
this.eyeDropper = new EyeDropperTool(
this.canvas,
paper.project.view.bounds.width,
paper.project.view.bounds.height,
paper.project.view.pixelRatio,
paper.view.zoom,
paper.project.view.bounds.x,
paper.project.view.bounds.y,
isBitmap(this.props.format)
);
this.eyeDropper.pickX = -1;
this.eyeDropper.pickY = -1;
this.eyeDropper.activate();
const callback = () => {
this.animationFrameId = requestAnimationFrame(callback);
const colorInfo = this.eyeDropper.getColorInfo(
this.eyeDropper.pickX,
this.eyeDropper.pickY,
this.eyeDropper.hideLoupe
);
if (!colorInfo) return;
if (
this.state.colorInfo === null ||
this.state.colorInfo.x !== colorInfo.x ||
this.state.colorInfo.y !== colorInfo.y
) {
this.setState({
colorInfo: colorInfo
});
}
};
this.animationFrameId = requestAnimationFrame(callback);
}
stopEyeDroppingLoop () {
cancelAnimationFrame(this.animationFrameId);
this.setState({colorInfo: null});
}
render () {
return (
);
}
}
PaintEditor.propTypes = {
changeColorToEyeDropper: PropTypes.func,
changeMode: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
customFonts: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
family: PropTypes.string.isRequired
})).isRequired,
onCustomFontsChanged: PropTypes.func.isRequired,
onManageFonts: PropTypes.func,
format: PropTypes.oneOf(Object.keys(Formats)), // Internal, up-to-date data format
fontInlineFn: PropTypes.func,
handleSwitchToBitmap: PropTypes.func.isRequired,
handleSwitchToVector: PropTypes.func.isRequired,
image: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string, // The incoming image's data format, used during import
imageId: PropTypes.string,
isEyeDropping: PropTypes.bool,
mode: PropTypes.oneOf(Object.keys(Modes)).isRequired,
name: PropTypes.string,
onDeactivateEyeDropper: PropTypes.func.isRequired,
onKeyPress: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
onUndo: PropTypes.func.isRequired,
onUpdateImage: PropTypes.func.isRequired,
onUpdateName: PropTypes.func.isRequired,
previousTool: PropTypes.shape({ // paper.Tool
activate: PropTypes.func.isRequired,
remove: PropTypes.func.isRequired
}),
removeTextEditTarget: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
rtl: PropTypes.bool,
setLayout: PropTypes.func.isRequired,
setSelectedItems: PropTypes.func.isRequired,
shouldShowRedo: PropTypes.func.isRequired,
shouldShowUndo: PropTypes.func.isRequired,
theme: PropTypes.oneOf(['light', 'dark']),
reduxTheme: PropTypes.oneOf(['default', 'light', 'dark']),
setReduxTheme: PropTypes.func.isRequired,
width: PropTypes.number,
height: PropTypes.number,
updateViewBounds: PropTypes.func.isRequired,
viewBounds: PropTypes.instanceOf(paper.Matrix).isRequired,
zoomLevelId: PropTypes.string
};
PaintEditor.defaultProps = {
width: 480,
height: 360,
theme: 'light',
customFonts: []
};
const mapStateToProps = state => ({
changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback,
format: state.scratchPaint.format,
isEyeDropping: state.scratchPaint.color.eyeDropper.active,
mode: state.scratchPaint.mode,
previousTool: state.scratchPaint.color.eyeDropper.previousTool,
reduxTheme: state.scratchPaint.theme,
viewBounds: state.scratchPaint.viewBounds
});
const mapDispatchToProps = dispatch => ({
changeMode: mode => {
dispatch(changeMode(mode));
},
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
onCustomFontsChanged: customFonts => {
dispatch(setCustomFonts(customFonts));
},
handleSwitchToBitmap: () => {
dispatch(changeFormat(Formats.BITMAP));
},
handleSwitchToVector: () => {
dispatch(changeFormat(Formats.VECTOR));
},
removeTextEditTarget: () => {
dispatch(setTextEditTarget());
},
setLayout: layout => {
dispatch(setLayout(layout));
},
setReduxTheme: theme => {
dispatch(setReduxTheme(theme));
},
setSelectedItems: format => {
dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format)));
},
onDeactivateEyeDropper: () => {
// set redux values to default for eye dropper reducer
dispatch(deactivateEyeDropper());
},
updateViewBounds: matrix => {
dispatch(updateViewBounds(matrix));
}
});
export default UpdateImageHOC(SelectionHOC(UndoHOC(KeyboardShortcutsHOC(connect(
mapStateToProps,
mapDispatchToProps
)(PaintEditor)))));