Keyboard shortcuts (#623)

This commit is contained in:
DD Liu 2018-08-29 15:29:13 -04:00 committed by GitHub
parent 97f669423a
commit 4474ec3aa1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 255 additions and 131 deletions

View file

@ -27,6 +27,7 @@
"classnames": "2.2.5",
"keymirror": "0.1.1",
"lodash.bindall": "4.4.0",
"lodash.omit": "4.5.0",
"minilog": "3.1.0",
"parse-color": "1.0.0",
"prop-types": "^15.5.10",

View file

@ -317,7 +317,7 @@ ModeToolsComponent.propTypes = {
clipboardItems: PropTypes.arrayOf(PropTypes.array),
eraserValue: PropTypes.number,
fillBitmapShapes: PropTypes.bool,
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
format: PropTypes.oneOf(Object.keys(Formats)),
hasSelectedUncurvedPoints: PropTypes.bool,
hasSelectedUnpointedPoints: PropTypes.bool,
intl: intlShape.isRequired,

View file

@ -0,0 +1,138 @@
import paper from '@scratch/paper';
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 {
clearSelection,
getSelectedLeafItems,
getSelectedRootItems
} from '../helper/selection';
import {isBitmap} from '../lib/format';
import Formats from '../lib/format';
import Modes from '../lib/modes';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {incrementPasteOffset, setClipboardItems} from '../reducers/clipboard';
const CopyPasteHOC = function (WrappedComponent) {
class CopyPasteWrapper extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleCopy',
'handlePaste'
]);
}
handleCopy () {
let selectedItems = [];
if (this.props.mode === Modes.RESHAPE) {
const leafItems = getSelectedLeafItems();
// Copy root of compound paths
for (const item of leafItems) {
if (item.parent && item.parent instanceof paper.CompoundPath) {
selectedItems.push(item.parent);
} else {
selectedItems.push(item);
}
}
} else {
selectedItems = getSelectedRootItems();
}
if (selectedItems.length > 0) {
const clipboardItems = [];
for (let i = 0; i < selectedItems.length; i++) {
const jsonItem = selectedItems[i].exportJSON({asString: false});
clipboardItems.push(jsonItem);
}
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;
let items = [];
for (let i = 0; i < this.props.clipboardItems.length; i++) {
const item = paper.Base.importJSON(this.props.clipboardItems[i]);
if (item) {
items.push(item);
}
}
if (!items.length) return false;
// 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);
items = [group.rasterize()];
group.remove();
}
for (const item of items) {
const placedItem = paper.project.getActiveLayer().addChild(item);
placedItem.selected = true;
placedItem.position.x += 10 * this.props.pasteOffset;
placedItem.position.y += 10 * this.props.pasteOffset;
}
this.props.incrementPasteOffset();
this.props.setSelectedItems(this.props.format);
return true;
}
render () {
const componentProps = omit(this.props, [
'clearSelectedItems',
'clipboardItems',
'incrementPasteOffset',
'pasteOffset',
'setClipboardItems',
'setSelectedItems']);
return (
<WrappedComponent
onCopyToClipboard={this.handleCopy}
onPasteFromClipboard={this.handlePaste}
{...componentProps}
/>
);
}
}
CopyPasteWrapper.propTypes = {
clearSelectedItems: PropTypes.func.isRequired,
clipboardItems: PropTypes.arrayOf(PropTypes.array),
format: PropTypes.oneOf(Object.keys(Formats)),
incrementPasteOffset: PropTypes.func.isRequired,
mode: PropTypes.oneOf(Object.keys(Modes)),
pasteOffset: PropTypes.number,
setClipboardItems: PropTypes.func.isRequired,
setSelectedItems: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
clipboardItems: state.scratchPaint.clipboard.items,
format: state.scratchPaint.format,
mode: state.scratchPaint.mode,
pasteOffset: state.scratchPaint.clipboard.pasteOffset
});
const mapDispatchToProps = dispatch => ({
setClipboardItems: items => {
dispatch(setClipboardItems(items));
},
incrementPasteOffset: () => {
dispatch(incrementPasteOffset());
},
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
setSelectedItems: format => {
dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format)));
}
});
return connect(
mapStateToProps,
mapDispatchToProps
)(CopyPasteWrapper);
};
export default CopyPasteHOC;

View file

@ -10,7 +10,7 @@ import {changeFont} from '../reducers/font';
import {getSelectedLeafItems} from '../helper/selection';
import styles from '../components/font-dropdown/font-dropdown.css';
class ModeToolsComponent extends React.Component {
class FontDropdown extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
@ -164,7 +164,7 @@ class ModeToolsComponent extends React.Component {
}
}
ModeToolsComponent.propTypes = {
FontDropdown.propTypes = {
changeFont: PropTypes.func.isRequired,
font: PropTypes.string,
onUpdateImage: PropTypes.func.isRequired
@ -182,4 +182,4 @@ const mapDispatchToProps = dispatch => ({
export default connect(
mapStateToProps,
mapDispatchToProps
)(ModeToolsComponent);
)(FontDropdown);

View file

@ -4,11 +4,11 @@ import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import CopyPasteHOC from './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';
import {
clearSelection,
deleteSelection,
getSelectedLeafItems,
getSelectedRootItems,
@ -29,7 +29,6 @@ class ModeTools extends React.Component {
'_getSelectedUnpointedPoints',
'hasSelectedUncurvedPoints',
'hasSelectedUnpointedPoints',
'handleCopyToClipboard',
'handleCurvePoints',
'handleFlipHorizontal',
'handleFlipVertical',
@ -191,57 +190,22 @@ class ModeTools extends React.Component {
this._handleFlip(1, -1, selectedItems);
}
}
handlePasteFromClipboard () {
if (this.props.onPasteFromClipboard()) {
this.props.onUpdateImage();
}
}
handleDelete () {
if (deleteSelection(this.props.mode, this.props.onUpdateImage)) {
this.props.setSelectedItems(this.props.format);
}
}
handleCopyToClipboard () {
const selectedItems = getSelectedRootItems();
if (selectedItems.length > 0) {
const clipboardItems = [];
for (let i = 0; i < selectedItems.length; i++) {
const jsonItem = selectedItems[i].exportJSON({asString: false});
clipboardItems.push(jsonItem);
}
this.props.setClipboardItems(clipboardItems);
}
}
handlePasteFromClipboard () {
clearSelection(this.props.clearSelectedItems);
if (this.props.clipboardItems.length > 0) {
let items = [];
for (let i = 0; i < this.props.clipboardItems.length; i++) {
const item = paper.Base.importJSON(this.props.clipboardItems[i]);
if (item) {
items.push(item);
}
}
if (!items.length) return;
// If pasting a group or non-raster to bitmap, rasterize firsts
if (isBitmap(this.props.format) && !(items.length === 1 && items[0] instanceof paper.Raster)) {
const group = new paper.Group(items);
items = [group.rasterize()];
group.remove();
}
for (const item of items) {
const placedItem = paper.project.getActiveLayer().addChild(item);
placedItem.selected = true;
placedItem.position.x += 10 * this.props.pasteOffset;
placedItem.position.y += 10 * this.props.pasteOffset;
}
this.props.incrementPasteOffset();
this.props.setSelectedItems(this.props.format);
this.props.onUpdateImage();
}
}
render () {
return (
<ModeToolsComponent
hasSelectedUncurvedPoints={this.hasSelectedUncurvedPoints()}
hasSelectedUnpointedPoints={this.hasSelectedUnpointedPoints()}
onCopyToClipboard={this.handleCopyToClipboard}
onCopyToClipboard={this.props.onCopyToClipboard}
onCurvePoints={this.handleCurvePoints}
onDelete={this.handleDelete}
onFlipHorizontal={this.handleFlipHorizontal}
@ -255,17 +219,14 @@ class ModeTools extends React.Component {
}
ModeTools.propTypes = {
clearSelectedItems: PropTypes.func.isRequired,
clipboardItems: PropTypes.arrayOf(PropTypes.array),
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
incrementPasteOffset: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)),
mode: PropTypes.oneOf(Object.keys(Modes)),
onCopyToClipboard: PropTypes.func.isRequired,
onPasteFromClipboard: PropTypes.func.isRequired,
onUpdateImage: PropTypes.func.isRequired,
pasteOffset: PropTypes.number,
// Listen on selected items to update hasSelectedPoints
selectedItems:
PropTypes.arrayOf(PropTypes.instanceOf(paper.Item)), // eslint-disable-line react/no-unused-prop-types
setClipboardItems: PropTypes.func.isRequired,
setSelectedItems: PropTypes.func.isRequired
};
@ -291,7 +252,7 @@ const mapDispatchToProps = dispatch => ({
}
});
export default connect(
export default CopyPasteHOC(connect(
mapStateToProps,
mapDispatchToProps
)(ModeTools);
)(ModeTools));

View file

@ -5,6 +5,8 @@ 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 {changeMode} from '../reducers/modes';
import {changeFormat} from '../reducers/format';
@ -16,12 +18,14 @@ import {updateViewBounds} from '../reducers/view-bounds';
import {setLayout} from '../reducers/layout';
import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer';
import {commitSelectionToBitmap, convertToBitmap, convertToVector, getHitBounds} from '../helper/bitmap';
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 {getSelectedLeafItems} from '../helper/selection';
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 {resetZoom, zoomOnSelection} from '../helper/view';
import EyeDropperTool from '../helper/tools/eye-dropper';
@ -57,6 +61,7 @@ class PaintEditor extends React.Component {
'canRedo',
'canUndo',
'switchMode',
'onKeyPress',
'onMouseDown',
'setCanvas',
'setTextArea',
@ -73,14 +78,7 @@ class PaintEditor extends React.Component {
this.props.setLayout(this.props.rtl ? 'rtl' : 'ltr');
}
componentDidMount () {
document.addEventListener('keydown', (/* event */) => {
// Don't activate keyboard shortcuts during text editing
if (!this.props.textEditing) {
// @todo disabling keyboard shortcuts because there is a bug
// that is interfering with text editing.
// this.props.onKeyPress(event);
}
});
document.addEventListener('keydown', this.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);
@ -119,7 +117,7 @@ class PaintEditor extends React.Component {
}
}
componentWillUnmount () {
document.removeEventListener('keydown', this.props.onKeyPress);
document.removeEventListener('keydown', this.onKeyPress);
this.stopEyeDroppingLoop();
document.removeEventListener('mousedown', this.onMouseDown);
document.removeEventListener('touchstart', this.onMouseDown);
@ -313,6 +311,56 @@ 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) {
@ -431,8 +479,9 @@ PaintEditor.propTypes = {
isEyeDropping: PropTypes.bool,
mode: PropTypes.oneOf(Object.keys(Modes)).isRequired,
name: PropTypes.string,
onCopyToClipboard: PropTypes.func.isRequired,
onDeactivateEyeDropper: PropTypes.func.isRequired,
onKeyPress: PropTypes.func.isRequired,
onPasteFromClipboard: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
onUndo: PropTypes.func.isRequired,
onUpdateImage: PropTypes.func.isRequired,
@ -471,27 +520,6 @@ const mapStateToProps = state => ({
viewBounds: state.scratchPaint.viewBounds
});
const mapDispatchToProps = dispatch => ({
onKeyPress: event => {
if (event.key === 'e') {
dispatch(changeMode(Modes.ERASER));
} else if (event.key === 'b') {
dispatch(changeMode(Modes.BRUSH));
} else if (event.key === 'l') {
dispatch(changeMode(Modes.LINE));
} else if (event.key === 's') {
dispatch(changeMode(Modes.SELECT));
} else if (event.key === 'w') {
dispatch(changeMode(Modes.RESHAPE));
} else if (event.key === 'f') {
dispatch(changeMode(Modes.FILL));
} else if (event.key === 't') {
dispatch(changeMode(Modes.TEXT));
} else if (event.key === 'c') {
dispatch(changeMode(Modes.OVAL));
} else if (event.key === 'r') {
dispatch(changeMode(Modes.RECT));
}
},
changeMode: mode => {
dispatch(changeMode(mode));
},
@ -531,7 +559,7 @@ const mapDispatchToProps = dispatch => ({
}
});
export default connect(
export default SelectionHOC(CopyPasteHOC(connect(
mapStateToProps,
mapDispatchToProps
)(PaintEditor);
)(PaintEditor)));

View file

@ -4,16 +4,13 @@ import React from 'react';
import {connect} from 'react-redux';
import paper from '@scratch/paper';
import Formats from '../lib/format';
import {isBitmap} from '../lib/format';
import Modes from '../lib/modes';
import log from '../log/log';
import {performSnapshot} from '../helper/undo';
import {undoSnapshot, clearUndoState} from '../reducers/undo';
import {isGroup, ungroupItems} from '../helper/group';
import {clearRaster, getRaster, setupLayers} from '../helper/layer';
import {deleteSelection, getSelectedLeafItems} from '../helper/selection';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {clearSelectedItems} from '../reducers/selected-items';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, resetZoom} from '../helper/view';
import {ensureClockwise, scaleWithStrokes} from '../helper/math';
import {clearHoveredItem} from '../reducers/hover';
@ -28,12 +25,10 @@ class PaperCanvas extends React.Component {
bindAll(this, [
'setCanvas',
'importSvg',
'handleKeyDown',
'switchCostume'
]);
}
componentDidMount () {
document.addEventListener('keydown', this.handleKeyDown);
paper.setup(this.canvas);
resetZoom();
this.props.updateViewBounds(paper.view.matrix);
@ -57,19 +52,6 @@ class PaperCanvas extends React.Component {
}
componentWillUnmount () {
paper.remove();
document.removeEventListener('keydown', this.handleKeyDown);
}
handleKeyDown (event) {
if (event.target instanceof HTMLInputElement) {
// Ignore delete if a text input field is focused
return;
}
// Backspace, delete
if (event.key === 'Delete' || event.key === 'Backspace') {
if (deleteSelection(this.props.mode, this.props.onUpdateImage)) {
this.props.setSelectedItems(this.props.format);
}
}
}
switchCostume (format, image, rotationCenterX, rotationCenterY) {
for (const layer of paper.project.layers) {
@ -231,18 +213,14 @@ PaperCanvas.propTypes = {
clearPasteOffset: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
clearUndo: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)), // Internal, up-to-date data format
image: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string, // The incoming image's data format, used during import. The user could switch this.
imageId: PropTypes.string,
mode: PropTypes.oneOf(Object.keys(Modes)),
onUpdateImage: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
setSelectedItems: PropTypes.func.isRequired,
undoSnapshot: PropTypes.func.isRequired,
updateViewBounds: PropTypes.func.isRequired
};
@ -257,9 +235,6 @@ const mapDispatchToProps = dispatch => ({
clearUndo: () => {
dispatch(clearUndoState());
},
setSelectedItems: format => {
dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format)));
},
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},

View file

@ -1,6 +1,7 @@
import paper from '@scratch/paper';
import {createCanvas, clearRaster, getRaster, hideGuideLayers, showGuideLayers} from './layer';
import {getGuideColor} from './guides';
import {clearSelection} from './selection';
import {inlineSvgFonts} from 'scratch-svg-renderer';
const forEachLinePoint = function (point1, point2, callback) {
@ -347,7 +348,7 @@ const convertToBitmap = function (clearSelectedItems, onUpdateImage) {
// @todo if the active layer contains only rasters, drawing them directly to the raster layer
// would be more efficient.
clearSelectedItems();
clearSelection(clearSelectedItems);
// Export svg
const guideLayers = hideGuideLayers(true /* includeRaster */);
@ -392,7 +393,7 @@ const convertToBitmap = function (clearSelectedItems, onUpdateImage) {
};
const convertToVector = function (clearSelectedItems, onUpdateImage) {
clearSelectedItems();
clearSelection(clearSelectedItems);
const trimmedRaster = trim_(getRaster());
if (trimmedRaster) {
paper.project.activeLayer.addChild(trimmedRaster);
@ -722,6 +723,20 @@ const commitSelectionToBitmap = function (selection, bitmap) {
commitArbitraryTransformation_(selection, bitmap);
};
const selectAllBitmap = function (clearSelectedItems) {
clearSelection(clearSelectedItems);
// Pull raster to active layer
const raster = getRaster();
raster.guide = false;
raster.locked = false;
raster.parent = paper.project.activeLayer;
raster.selected = true;
// Clear raster layer
clearRaster();
};
export {
commitSelectionToBitmap,
convertToBitmap,
@ -736,5 +751,6 @@ export {
forEachLinePoint,
flipBitmapHorizontal,
flipBitmapVertical,
scaleBitmap
scaleBitmap,
selectAllBitmap
};

View file

@ -108,30 +108,34 @@ const setItemSelection = function (item, state, fullySelected) {
}
_setGroupSelection(item, state, fullySelected);
}
// @todo: Update toolbar state on change
};
/** @return {boolean} true if anything was selected */
const selectAllItems = function () {
const items = getAllSelectableRootItems();
if (items.length === 0) return false;
for (let i = 0; i < items.length; i++) {
setItemSelection(items[i], true);
}
return true;
};
/** @return {boolean} true if anything was selected */
const selectAllSegments = function () {
const items = getAllSelectableRootItems();
if (items.length === 0) return false;
for (let i = 0; i < items.length; i++) {
selectItemSegments(items[i], true);
}
return true;
};
/** @param {!function} dispatchClearSelect Function to update the Redux select state */
const clearSelection = function (dispatchClearSelect) {
paper.project.deselectAll();
// @todo: Update toolbar state on change
dispatchClearSelect();
};
@ -148,6 +152,14 @@ const getSelectedRootItems = function () {
for (const item of allItems) {
if (item.selected) {
items.push(item);
} else if (item instanceof paper.CompoundPath) {
// Consider a compound path selected if any of its paths are selected
for (const child of item.children) {
if (child.selected) {
items.push(item);
break;
}
}
}
}
@ -412,13 +424,10 @@ const selectRootItem = function () {
}
};
const shouldShowSelectAll = function () {
return paper.project.getItems({class: paper.PathItem}).length > 0;
};
export {
getItems,
getAllRootItems,
getAllSelectableRootItems,
selectAllItems,
selectAllSegments,
clearSelection,
@ -429,6 +438,5 @@ export {
getSelectedRootItems,
getSelectedSegments,
processRectangularSelection,
selectRootItem,
shouldShowSelectAll
selectRootItem
};

View file

@ -1,10 +1,7 @@
import PaintEditor from './containers/paint-editor.jsx';
import SelectionHOC from './containers/selection-hoc.jsx';
import ScratchPaintReducer from './reducers/scratch-paint-reducer';
const Wrapped = SelectionHOC(PaintEditor);
export {
Wrapped as default,
PaintEditor as default,
ScratchPaintReducer
};