diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index c2f54be5..d8857e38 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -24,7 +24,7 @@ import FillMode from '../../containers/fill-mode.jsx'; import InputGroup from '../input-group/input-group.jsx'; import LineMode from '../../containers/line-mode.jsx'; import Loupe from '../loupe/loupe.jsx'; -import FixedToolsComponent from '../fixed-tools/fixed-tools.jsx'; +import FixedToolsContainer from '../../containers/fixed-tools.jsx'; import ModeToolsContainer from '../../containers/mode-tools.jsx'; import OvalMode from '../../containers/oval-mode.jsx'; import RectMode from '../../containers/rect-mode.jsx'; @@ -65,18 +65,12 @@ const PaintEditorComponent = props => (
{/* First row */}
- @@ -324,16 +318,10 @@ PaintEditorComponent.propTypes = { intl: intlShape, isEyeDropping: PropTypes.bool, name: PropTypes.string, - onGroup: PropTypes.func.isRequired, onRedo: PropTypes.func.isRequired, - onSendBackward: PropTypes.func.isRequired, - onSendForward: PropTypes.func.isRequired, - onSendToBack: PropTypes.func.isRequired, - onSendToFront: PropTypes.func.isRequired, onSwitchToBitmap: PropTypes.func.isRequired, onSwitchToVector: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired, - onUngroup: PropTypes.func.isRequired, onUpdateImage: PropTypes.func.isRequired, onUpdateName: PropTypes.func.isRequired, onZoomIn: PropTypes.func.isRequired, diff --git a/src/containers/fixed-tools.jsx b/src/containers/fixed-tools.jsx new file mode 100644 index 00000000..fb5aeb40 --- /dev/null +++ b/src/containers/fixed-tools.jsx @@ -0,0 +1,131 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import FixedToolsComponent from '../components/fixed-tools/fixed-tools.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 {setLayout} from '../reducers/layout'; + +import {getSelectedLeafItems} from '../helper/selection'; +import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order'; +import {groupSelection, ungroupSelection} from '../helper/group'; + +import Formats from '../lib/format'; +import {isBitmap} from '../lib/format'; +import bindAll from 'lodash.bindall'; + +class FixedTools extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleSendBackward', + 'handleSendForward', + 'handleSendToBack', + 'handleSendToFront', + 'handleSetSelectedItems', + 'handleGroup', + 'handleUngroup' + ]); + } + handleGroup () { + groupSelection(this.props.clearSelectedItems, this.handleSetSelectedItems, this.props.onUpdateImage); + } + handleUngroup () { + ungroupSelection(this.props.clearSelectedItems, this.handleSetSelectedItems, this.props.onUpdateImage); + } + handleSendBackward () { + sendBackward(this.props.onUpdateImage); + } + handleSendForward () { + bringForward(this.props.onUpdateImage); + } + handleSendToBack () { + sendToBack(this.props.onUpdateImage); + } + handleSendToFront () { + bringToFront(this.props.onUpdateImage); + } + handleSetSelectedItems () { + this.props.setSelectedItems(this.props.format); + } + render () { + return ( + + ); + } +} + +FixedTools.propTypes = { + canRedo: PropTypes.func.isRequired, + canUndo: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, + format: PropTypes.oneOf(Object.keys(Formats)), + name: PropTypes.string, + onRedo: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, + onUpdateImage: PropTypes.func.isRequired, + onUpdateName: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback, + format: state.scratchPaint.format, + isEyeDropping: state.scratchPaint.color.eyeDropper.active, + mode: state.scratchPaint.mode, + pasteOffset: state.scratchPaint.clipboard.pasteOffset, + previousTool: state.scratchPaint.color.eyeDropper.previousTool, + selectedItems: state.scratchPaint.selectedItems, + viewBounds: state.scratchPaint.viewBounds +}); +const mapDispatchToProps = dispatch => ({ + changeMode: mode => { + dispatch(changeMode(mode)); + }, + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + handleSwitchToBitmap: () => { + dispatch(changeFormat(Formats.BITMAP)); + }, + handleSwitchToVector: () => { + dispatch(changeFormat(Formats.VECTOR)); + }, + removeTextEditTarget: () => { + dispatch(setTextEditTarget()); + }, + setLayout: layout => { + dispatch(setLayout(layout)); + }, + setSelectedItems: format => { + dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); + }, + onDeactivateEyeDropper: () => { + // set redux values to default for eye dropper reducer + dispatch(deactivateEyeDropper()); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FixedTools); diff --git a/src/containers/mode-tools.jsx b/src/containers/mode-tools.jsx index 02265c06..f9edbbb5 100644 --- a/src/containers/mode-tools.jsx +++ b/src/containers/mode-tools.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; -import CopyPasteHOC from './copy-paste-hoc.jsx'; +import CopyPasteHOC from '../hocs/copy-paste-hoc.jsx'; import ModeToolsComponent from '../components/mode-tools/mode-tools.jsx'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {incrementPasteOffset, setClipboardItems} from '../reducers/clipboard'; diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 008601a3..45bf9f6b 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -1,37 +1,29 @@ import paper from '@scratch/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 CopyPasteHOC from './copy-paste-hoc.jsx'; -import SelectionHOC from './selection-hoc.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 {undo, redo, undoSnapshot} from '../reducers/undo'; 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 {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer'; -import {commitSelectionToBitmap, convertToBitmap, convertToVector, getHitBounds, - selectAllBitmap} from '../helper/bitmap'; -import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo'; -import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order'; -import {groupSelection, ungroupSelection} from '../helper/group'; -import {scaleWithStrokes} from '../helper/math'; -import {clearSelection, deleteSelection, getSelectedLeafItems, - selectAllItems, selectAllSegments} from '../helper/selection'; -import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, SVG_ART_BOARD_WIDTH, SVG_ART_BOARD_HEIGHT} from '../helper/view'; +import {getSelectedLeafItems} from '../helper/selection'; +import {convertToBitmap, convertToVector} from '../helper/bitmap'; import {resetZoom, zoomOnSelection} from '../helper/view'; import EyeDropperTool from '../helper/tools/eye-dropper'; import Modes from '../lib/modes'; -import {BitmapModes} from '../lib/modes'; import Formats from '../lib/format'; import {isBitmap, isVector} from '../lib/format'; import bindAll from 'lodash.bindall'; @@ -43,52 +35,32 @@ class PaintEditor extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleUpdateImage', - 'handleUpdateBitmap', - 'handleUpdateVector', - 'handleUndo', - 'handleRedo', - 'handleSendBackward', - 'handleSendForward', - 'handleSendToBack', - 'handleSendToFront', - 'handleSetSelectedItems', - 'handleGroup', - 'handleUngroup', - 'handleZoomIn', - 'handleZoomOut', - 'handleZoomReset', - 'canRedo', - 'canUndo', 'switchMode', - 'onKeyPress', 'onMouseDown', 'setCanvas', 'setTextArea', 'startEyeDroppingLoop', - 'stopEyeDroppingLoop' + 'stopEyeDroppingLoop', + 'handleSetSelectedItems', + 'handleZoomIn', + 'handleZoomOut', + 'handleZoomReset' ]); this.state = { canvas: null, colorInfo: null }; - // When isSwitchingFormats is true, the format is about to switch, but isn't done switching. - // This gives currently active tools a chance to finish what they were doing. - this.isSwitchingFormats = false; this.props.setLayout(this.props.rtl ? 'rtl' : 'ltr'); } componentDidMount () { - document.addEventListener('keydown', this.onKeyPress); + 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); } componentWillReceiveProps (newProps) { - if ((isVector(this.props.format) && newProps.format === Formats.BITMAP) || - (isBitmap(this.props.format) && newProps.format === Formats.VECTOR)) { - this.isSwitchingFormats = true; - } if (isVector(this.props.format) && isBitmap(newProps.format)) { this.switchMode(Formats.BITMAP); } else if (isVector(newProps.format) && isBitmap(this.props.format)) { @@ -108,12 +80,11 @@ class PaintEditor extends React.Component { this.props.onDeactivateEyeDropper(); this.stopEyeDroppingLoop(); } + if (this.props.format === Formats.VECTOR && isBitmap(prevProps.format)) { - this.isSwitchingFormats = false; - convertToVector(this.props.clearSelectedItems, this.handleUpdateImage); + convertToVector(this.props.clearSelectedItems, this.props.onUpdateImage); } else if (isVector(prevProps.format) && this.props.format === Formats.BITMAP) { - this.isSwitchingFormats = false; - convertToBitmap(this.props.clearSelectedItems, this.handleUpdateImage); + convertToBitmap(this.props.clearSelectedItems, this.props.onUpdateImage); } } componentWillUnmount () { @@ -187,108 +158,6 @@ class PaintEditor extends React.Component { } } } - handleUpdateImage (skipSnapshot) { - // If in the middle of switching formats, rely on the current mode instead of format. - let actualFormat = this.props.format; - if (this.isSwitchingFormats) { - actualFormat = BitmapModes[this.props.mode] ? Formats.BITMAP : Formats.VECTOR; - } - if (isBitmap(actualFormat)) { - this.handleUpdateBitmap(skipSnapshot); - } else if (isVector(actualFormat)) { - this.handleUpdateVector(skipSnapshot); - } - } - handleUpdateBitmap (skipSnapshot) { - if (!getRaster().loaded) { - // In general, callers of updateImage should wait for getRaster().loaded = true before - // calling updateImage. - // However, this may happen if the user is rapidly undoing/redoing. In this case it's safe - // to skip the update. - log.warn('Bitmap layer should be loaded before calling updateImage.'); - return; - } - // Plaster the selection onto the raster layer before exporting, if there is a selection. - const plasteredRaster = getRaster().getSubRaster(getRaster().bounds); - plasteredRaster.remove(); // Don't insert - const selectedItems = getSelectedLeafItems(); - if (selectedItems.length === 1 && selectedItems[0] instanceof paper.Raster) { - if (!selectedItems[0].loaded || - (selectedItems[0].data && selectedItems[0].data.expanded && !selectedItems[0].data.expanded.loaded)) { - log.warn('Bitmap layer should be loaded before calling updateImage.'); - return; - } - commitSelectionToBitmap(selectedItems[0], plasteredRaster); - } - const rect = getHitBounds(plasteredRaster); - this.props.onUpdateImage( - false /* isVector */, - plasteredRaster.getImageData(rect), - (ART_BOARD_WIDTH / 2) - rect.x, - (ART_BOARD_HEIGHT / 2) - rect.y); - - if (!skipSnapshot) { - performSnapshot(this.props.undoSnapshot, Formats.BITMAP); - } - } - handleUpdateVector (skipSnapshot) { - const guideLayers = hideGuideLayers(true /* includeRaster */); - - // Export at 0.5x - scaleWithStrokes(paper.project.activeLayer, .5, new paper.Point()); - const bounds = paper.project.activeLayer.bounds; - // @todo generate view box - this.props.onUpdateImage( - true /* isVector */, - paper.project.exportSVG({ - asString: true, - bounds: 'content', - matrix: new paper.Matrix().translate(-bounds.x, -bounds.y) - }), - (SVG_ART_BOARD_WIDTH / 2) - bounds.x, - (SVG_ART_BOARD_HEIGHT / 2) - bounds.y); - scaleWithStrokes(paper.project.activeLayer, 2, new paper.Point()); - paper.project.activeLayer.applyMatrix = true; - - showGuideLayers(guideLayers); - - if (!skipSnapshot) { - performSnapshot(this.props.undoSnapshot, Formats.VECTOR); - } - } - handleUndo () { - performUndo(this.props.undoState, this.props.onUndo, this.handleSetSelectedItems, this.handleUpdateImage); - } - handleRedo () { - performRedo(this.props.undoState, this.props.onRedo, this.handleSetSelectedItems, this.handleUpdateImage); - } - handleGroup () { - groupSelection(this.props.clearSelectedItems, this.handleSetSelectedItems, this.handleUpdateImage); - } - handleUngroup () { - ungroupSelection(this.props.clearSelectedItems, this.handleSetSelectedItems, this.handleUpdateImage); - } - handleSendBackward () { - sendBackward(this.handleUpdateImage); - } - handleSendForward () { - bringForward(this.handleUpdateImage); - } - handleSendToBack () { - sendToBack(this.handleUpdateImage); - } - handleSendToFront () { - bringToFront(this.handleUpdateImage); - } - handleSetSelectedItems () { - this.props.setSelectedItems(this.props.format); - } - canUndo () { - return shouldShowUndo(this.props.undoState); - } - canRedo () { - return shouldShowRedo(this.props.undoState); - } handleZoomIn () { zoomOnSelection(PaintEditor.ZOOM_INCREMENT); this.props.updateViewBounds(paper.view.matrix); @@ -304,6 +173,9 @@ class PaintEditor extends React.Component { this.props.updateViewBounds(paper.view.matrix); this.handleSetSelectedItems(); } + handleSetSelectedItems () { + this.props.setSelectedItems(this.props.format); + } setCanvas (canvas) { this.setState({canvas: canvas}); this.canvas = canvas; @@ -311,56 +183,6 @@ class PaintEditor extends React.Component { setTextArea (element) { this.setState({textArea: element}); } - onKeyPress (event) { - // Don't activate keyboard shortcuts during text editing - if (this.props.textEditing) return; - - if (event.key === 'Escape') { - event.preventDefault(); - clearSelection(this.props.clearSelectedItems); - } else if (event.key === 'Delete' || event.key === 'Backspace') { - if (deleteSelection(this.props.mode, this.handleUpdateImage)) { - this.handleSetSelectedItems(); - } - } else if (event.metaKey || event.ctrlKey) { - if (event.shiftKey && event.key === 'z') { - this.handleRedo(); - } else if (event.key === 'z') { - this.handleUndo(); - } else if (event.key === 'c') { - this.props.onCopyToClipboard(); - } else if (event.key === 'v') { - this.changeToASelectMode(); - if (this.props.onPasteFromClipboard()) { - this.handleUpdateImage(); - } - } else if (event.key === 'a') { - this.changeToASelectMode(); - event.preventDefault(); - this.selectAll(); - } - } - } - changeToASelectMode () { - if (isBitmap(this.props.format)) { - if (this.props.mode !== Modes.BIT_SELECT) { - this.props.changeMode(Modes.BIT_SELECT); - } - } else if (this.props.mode !== Modes.SELECT && this.props.mode !== Modes.RESHAPE) { - this.props.changeMode(Modes.SELECT); - } - } - selectAll () { - if (isBitmap(this.props.format)) { - selectAllBitmap(this.props.clearSelectedItems); - this.handleSetSelectedItems(); - } else if (this.props.mode === Modes.RESHAPE) { - if (selectAllSegments()) this.handleSetSelectedItems(); - } else { - // Disable lint for easier to read logic - if (selectAllItems()) this.handleSetSelectedItems(); // eslint-disable-line no-lonely-if - } - } onMouseDown (event) { if (event.target === paper.view.element && document.activeElement instanceof HTMLInputElement) { @@ -427,8 +249,8 @@ class PaintEditor extends React.Component { render () { return ( ({ changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback, - clipboardItems: state.scratchPaint.clipboard.items, format: state.scratchPaint.format, isEyeDropping: state.scratchPaint.color.eyeDropper.active, mode: state.scratchPaint.mode, - pasteOffset: state.scratchPaint.clipboard.pasteOffset, previousTool: state.scratchPaint.color.eyeDropper.previousTool, - selectedItems: state.scratchPaint.selectedItems, - textEditing: state.scratchPaint.textEditTarget !== null, - undoState: state.scratchPaint.undo, viewBounds: state.scratchPaint.viewBounds }); const mapDispatchToProps = dispatch => ({ @@ -545,21 +351,12 @@ const mapDispatchToProps = dispatch => ({ // set redux values to default for eye dropper reducer dispatch(deactivateEyeDropper()); }, - onUndo: format => { - dispatch(undo(format)); - }, - onRedo: format => { - dispatch(redo(format)); - }, - undoSnapshot: snapshot => { - dispatch(undoSnapshot(snapshot)); - }, updateViewBounds: matrix => { dispatch(updateViewBounds(matrix)); } }); -export default SelectionHOC(CopyPasteHOC(connect( +export default UpdateImageHOC(SelectionHOC(UndoHOC(KeyboardShortcutsHOC(connect( mapStateToProps, mapDispatchToProps -)(PaintEditor))); +)(PaintEditor))))); diff --git a/src/helper/bitmap.js b/src/helper/bitmap.js index 8eb670cc..15ce0d45 100644 --- a/src/helper/bitmap.js +++ b/src/helper/bitmap.js @@ -3,6 +3,7 @@ import {createCanvas, clearRaster, getRaster, hideGuideLayers, showGuideLayers} import {getGuideColor} from './guides'; import {clearSelection} from './selection'; import {inlineSvgFonts} from 'scratch-svg-renderer'; +import Formats from '../lib/format'; const forEachLinePoint = function (point1, point2, callback) { // Bresenham line algorithm @@ -374,7 +375,7 @@ const convertToBitmap = function (clearSelectedItems, onUpdateImage) { new paper.Point(Math.floor(bounds.topLeft.x), Math.floor(bounds.topLeft.y))); } paper.project.activeLayer.removeChildren(); - onUpdateImage(); + onUpdateImage(false /* skipSnapshot */, Formats.BITMAP /* formatOverride */); }; img.onerror = () => { // Fallback if browser does not support SVG data URIs in images. @@ -385,7 +386,7 @@ const convertToBitmap = function (clearSelectedItems, onUpdateImage) { getRaster().drawImage(raster.canvas, raster.bounds.topLeft); } paper.project.activeLayer.removeChildren(); - onUpdateImage(); + onUpdateImage(false /* skipSnapshot */, Formats.BITMAP /* formatOverride */); }; }; // Hash tags will break image loading without being encoded first @@ -399,7 +400,7 @@ const convertToVector = function (clearSelectedItems, onUpdateImage) { paper.project.activeLayer.addChild(trimmedRaster); } clearRaster(); - onUpdateImage(); + onUpdateImage(false /* skipSnapshot */, Formats.VECTOR /* formatOverride */); }; const getColor_ = function (x, y, context) { diff --git a/src/containers/copy-paste-hoc.jsx b/src/hocs/copy-paste-hoc.jsx similarity index 95% rename from src/containers/copy-paste-hoc.jsx rename to src/hocs/copy-paste-hoc.jsx index 542f4d01..e71b75b2 100644 --- a/src/containers/copy-paste-hoc.jsx +++ b/src/hocs/copy-paste-hoc.jsx @@ -50,11 +50,10 @@ const CopyPasteHOC = function (WrappedComponent) { this.props.setClipboardItems(clipboardItems); } } - // Returns true if anything was pasted, false if nothing changed handlePaste () { clearSelection(this.props.clearSelectedItems); - if (this.props.clipboardItems.length === 0) return false; + if (this.props.clipboardItems.length === 0) return; let items = []; for (let i = 0; i < this.props.clipboardItems.length; i++) { @@ -63,7 +62,7 @@ const CopyPasteHOC = function (WrappedComponent) { items.push(item); } } - if (!items.length) return false; + if (!items.length) return; // If pasting a group or non-raster to bitmap, rasterize first if (isBitmap(this.props.format) && !(items.length === 1 && items[0] instanceof paper.Raster)) { const group = new paper.Group(items); @@ -78,13 +77,15 @@ const CopyPasteHOC = function (WrappedComponent) { } this.props.incrementPasteOffset(); this.props.setSelectedItems(this.props.format); - return true; + this.props.onUpdateImage(); } render () { const componentProps = omit(this.props, [ 'clearSelectedItems', 'clipboardItems', + 'format', 'incrementPasteOffset', + 'mode', 'pasteOffset', 'setClipboardItems', 'setSelectedItems']); @@ -104,6 +105,7 @@ const CopyPasteHOC = function (WrappedComponent) { format: PropTypes.oneOf(Object.keys(Formats)), incrementPasteOffset: PropTypes.func.isRequired, mode: PropTypes.oneOf(Object.keys(Modes)), + onUpdateImage: PropTypes.func.isRequired, pasteOffset: PropTypes.number, setClipboardItems: PropTypes.func.isRequired, setSelectedItems: PropTypes.func.isRequired diff --git a/src/hocs/keyboard-shortcuts-hoc.jsx b/src/hocs/keyboard-shortcuts-hoc.jsx new file mode 100644 index 00000000..32c1bb7e --- /dev/null +++ b/src/hocs/keyboard-shortcuts-hoc.jsx @@ -0,0 +1,132 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import omit from 'lodash.omit'; +import {connect} from 'react-redux'; + +import CopyPasteHOC from './copy-paste-hoc.jsx'; + +import {selectAllBitmap} from '../helper/bitmap'; +import {clearSelection, deleteSelection, getSelectedLeafItems, + selectAllItems, selectAllSegments} from '../helper/selection'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; +import {changeMode} from '../reducers/modes'; + +import {isBitmap} from '../lib/format'; +import Formats from '../lib/format'; +import Modes from '../lib/modes'; + +const KeyboardShortcutsHOC = function (WrappedComponent) { + class KeyboardShortcutsWrapper extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleKeyPress', + 'changeToASelectMode', + 'selectAll' + ]); + } + handleKeyPress (event) { + // Don't activate keyboard shortcuts during text editing + if (this.props.textEditing) return; + + if (event.key === 'Escape') { + event.preventDefault(); + clearSelection(this.props.clearSelectedItems); + } else if (event.key === 'Delete' || event.key === 'Backspace') { + if (deleteSelection(this.props.mode, this.props.onUpdateImage)) { + this.props.setSelectedItems(this.props.format); + } + } else if (event.metaKey || event.ctrlKey) { + if (event.shiftKey && event.key === 'z') { + this.props.onRedo(); + } else if (event.key === 'z') { + this.props.onUndo(); + } else if (event.key === 'c') { + this.props.onCopyToClipboard(); + } else if (event.key === 'v') { + this.changeToASelectMode(); + this.props.onPasteFromClipboard(); + } else if (event.key === 'a') { + this.changeToASelectMode(); + event.preventDefault(); + this.selectAll(); + } + } + } + changeToASelectMode () { + if (isBitmap(this.props.format)) { + if (this.props.mode !== Modes.BIT_SELECT) { + this.props.changeMode(Modes.BIT_SELECT); + } + } else if (this.props.mode !== Modes.SELECT && this.props.mode !== Modes.RESHAPE) { + this.props.changeMode(Modes.SELECT); + } + } + selectAll () { + if (isBitmap(this.props.format)) { + selectAllBitmap(this.props.clearSelectedItems); + this.props.setSelectedItems(this.props.format); + } else if (this.props.mode === Modes.RESHAPE) { + if (selectAllSegments()) this.props.setSelectedItems(this.props.format); + } else if (selectAllItems()) { + this.props.setSelectedItems(this.props.format); + } + } + render () { + const componentProps = omit(this.props, [ + 'changeMode', + 'clearSelectedItems', + 'format', + 'mode', + 'onCopyToClipboard', + 'onPasteFromClipboard', + 'setSelectedItems', + 'textEditing']); + return ( + + ); + } + } + + KeyboardShortcutsWrapper.propTypes = { + changeMode: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, + format: PropTypes.oneOf(Object.keys(Formats)), + mode: PropTypes.oneOf(Object.keys(Modes)).isRequired, + onCopyToClipboard: PropTypes.func.isRequired, + onPasteFromClipboard: PropTypes.func.isRequired, + onRedo: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, + onUpdateImage: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired, + textEditing: PropTypes.bool.isRequired + }; + + const mapStateToProps = state => ({ + mode: state.scratchPaint.mode, + format: state.scratchPaint.format, + textEditing: state.scratchPaint.textEditTarget !== null + }); + const mapDispatchToProps = dispatch => ({ + changeMode: mode => { + dispatch(changeMode(mode)); + }, + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + setSelectedItems: format => { + dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); + } + }); + + return CopyPasteHOC(connect( + mapStateToProps, + mapDispatchToProps + )(KeyboardShortcutsWrapper)); +}; + +export default KeyboardShortcutsHOC; diff --git a/src/containers/selection-hoc.jsx b/src/hocs/selection-hoc.jsx similarity index 100% rename from src/containers/selection-hoc.jsx rename to src/hocs/selection-hoc.jsx diff --git a/src/hocs/undo-hoc.jsx b/src/hocs/undo-hoc.jsx new file mode 100644 index 00000000..294d7ea9 --- /dev/null +++ b/src/hocs/undo-hoc.jsx @@ -0,0 +1,95 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import omit from 'lodash.omit'; +import {connect} from 'react-redux'; + +import {getSelectedLeafItems} from '../helper/selection'; +import {setSelectedItems} from '../reducers/selected-items'; +import {performUndo, performRedo, shouldShowUndo, shouldShowRedo} from '../helper/undo'; +import {undo, redo} from '../reducers/undo'; + +import {isBitmap} from '../lib/format'; +import Formats from '../lib/format'; + +const UndoHOC = function (WrappedComponent) { + class UndoWrapper extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleUndo', + 'handleRedo', + 'handleSetSelectedItems', + 'shouldShowUndo', + 'shouldShowRedo' + ]); + } + handleUndo () { + performUndo(this.props.undoState, this.props.onUndo, this.handleSetSelectedItems, this.props.onUpdateImage); + } + handleRedo () { + performRedo(this.props.undoState, this.props.onRedo, this.handleSetSelectedItems, this.props.onUpdateImage); + } + handleSetSelectedItems () { + this.props.setSelectedItems(this.props.format); + } + shouldShowUndo () { + return shouldShowUndo(this.props.undoState); + } + shouldShowRedo () { + return shouldShowRedo(this.props.undoState); + } + render () { + const componentProps = omit(this.props, [ + 'format', + 'onUndo', + 'onRedo', + 'setSelectedItems', + 'undoState']); + return ( + + ); + } + } + + UndoWrapper.propTypes = { + format: PropTypes.oneOf(Object.keys(Formats)), + onRedo: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, + onUpdateImage: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired, + undoState: PropTypes.shape({ + stack: PropTypes.arrayOf(PropTypes.object).isRequired, + pointer: PropTypes.number.isRequired + }) + }; + + const mapStateToProps = state => ({ + format: state.scratchPaint.format, + undoState: state.scratchPaint.undo + }); + const mapDispatchToProps = dispatch => ({ + setSelectedItems: format => { + dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); + }, + onUndo: format => { + dispatch(undo(format)); + }, + onRedo: format => { + dispatch(redo(format)); + } + }); + + return connect( + mapStateToProps, + mapDispatchToProps + )(UndoWrapper); +}; + +export default UndoHOC; diff --git a/src/hocs/update-image-hoc.jsx b/src/hocs/update-image-hoc.jsx new file mode 100644 index 00000000..d2c14c73 --- /dev/null +++ b/src/hocs/update-image-hoc.jsx @@ -0,0 +1,154 @@ +import paper from '@scratch/paper'; +import PropTypes from 'prop-types'; +import log from '../log/log'; +import bindAll from 'lodash.bindall'; +import React from 'react'; +import omit from 'lodash.omit'; +import {connect} from 'react-redux'; + +import {undoSnapshot} from '../reducers/undo'; +import {setSelectedItems} from '../reducers/selected-items'; + +import {getSelectedLeafItems} from '../helper/selection'; +import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer'; +import {commitSelectionToBitmap, getHitBounds} from '../helper/bitmap'; +import {performSnapshot} from '../helper/undo'; +import {scaleWithStrokes} from '../helper/math'; +import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, SVG_ART_BOARD_WIDTH, SVG_ART_BOARD_HEIGHT} from '../helper/view'; + +import Modes from '../lib/modes'; +import {BitmapModes} from '../lib/modes'; +import Formats from '../lib/format'; +import {isBitmap, isVector} from '../lib/format'; + +const UpdateImageHOC = function (WrappedComponent) { + class UpdateImageWrapper extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleUpdateImage', + 'handleUpdateBitmap', + 'handleUpdateVector' + ]); + } + /** + * @param {?boolean} skipSnapshot True if the call to update image should not trigger saving + * an undo state. For instance after calling undo. + * @param {?Formats} formatOverride Normally the mode is used to determine the format of the image, + * but the format used can be overridden here. In particular when converting between formats, + * the does not accurately represent the format. + */ + handleUpdateImage (skipSnapshot, formatOverride) { + // If in the middle of switching formats, rely on the current mode instead of format. + const actualFormat = formatOverride ? formatOverride : + BitmapModes[this.props.mode] ? Formats.BITMAP : Formats.VECTOR; + if (isBitmap(actualFormat)) { + this.handleUpdateBitmap(skipSnapshot); + } else if (isVector(actualFormat)) { + this.handleUpdateVector(skipSnapshot); + } + } + handleUpdateBitmap (skipSnapshot) { + if (!getRaster().loaded) { + // In general, callers of updateImage should wait for getRaster().loaded = true before + // calling updateImage. + // However, this may happen if the user is rapidly undoing/redoing. In this case it's safe + // to skip the update. + log.warn('Bitmap layer should be loaded before calling updateImage.'); + return; + } + // Plaster the selection onto the raster layer before exporting, if there is a selection. + const plasteredRaster = getRaster().getSubRaster(getRaster().bounds); + plasteredRaster.remove(); // Don't insert + const selectedItems = getSelectedLeafItems(); + if (selectedItems.length === 1 && selectedItems[0] instanceof paper.Raster) { + if (!selectedItems[0].loaded || + (selectedItems[0].data && + selectedItems[0].data.expanded && + !selectedItems[0].data.expanded.loaded)) { + // This may get logged when rapidly undoing/redoing or changing costumes, + // in which case the warning is not relevant. + log.warn('Bitmap layer should be loaded before calling updateImage.'); + return; + } + commitSelectionToBitmap(selectedItems[0], plasteredRaster); + } + const rect = getHitBounds(plasteredRaster); + this.props.onUpdateImage( + false /* isVector */, + plasteredRaster.getImageData(rect), + (ART_BOARD_WIDTH / 2) - rect.x, + (ART_BOARD_HEIGHT / 2) - rect.y); + + if (!skipSnapshot) { + performSnapshot(this.props.undoSnapshot, Formats.BITMAP); + } + } + handleUpdateVector (skipSnapshot) { + const guideLayers = hideGuideLayers(true /* includeRaster */); + + // Export at 0.5x + scaleWithStrokes(paper.project.activeLayer, .5, new paper.Point()); + const bounds = paper.project.activeLayer.bounds; + // @todo (https://github.com/LLK/scratch-paint/issues/445) generate view box + this.props.onUpdateImage( + true /* isVector */, + paper.project.exportSVG({ + asString: true, + bounds: 'content', + matrix: new paper.Matrix().translate(-bounds.x, -bounds.y) + }), + (SVG_ART_BOARD_WIDTH / 2) - bounds.x, + (SVG_ART_BOARD_HEIGHT / 2) - bounds.y); + scaleWithStrokes(paper.project.activeLayer, 2, new paper.Point()); + paper.project.activeLayer.applyMatrix = true; + + showGuideLayers(guideLayers); + + if (!skipSnapshot) { + performSnapshot(this.props.undoSnapshot, Formats.VECTOR); + } + } + render () { + const componentProps = omit(this.props, [ + 'format', + 'onUpdateImage', + 'undoSnapshot' + ]); + return ( + + ); + } + } + + UpdateImageWrapper.propTypes = { + format: PropTypes.oneOf(Object.keys(Formats)), + mode: PropTypes.oneOf(Object.keys(Modes)).isRequired, + onUpdateImage: PropTypes.func.isRequired, + undoSnapshot: PropTypes.func.isRequired + }; + + const mapStateToProps = state => ({ + format: state.scratchPaint.format, + mode: state.scratchPaint.mode, + undoState: state.scratchPaint.undo + }); + const mapDispatchToProps = dispatch => ({ + setSelectedItems: format => { + dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); + }, + undoSnapshot: snapshot => { + dispatch(undoSnapshot(snapshot)); + } + }); + + return connect( + mapStateToProps, + mapDispatchToProps + )(UpdateImageWrapper); +}; + +export default UpdateImageHOC;