diff --git a/src/components/bit-line-mode/bit-line-mode.jsx b/src/components/bit-line-mode/bit-line-mode.jsx index 57921fba..75f50761 100644 --- a/src/components/bit-line-mode/bit-line-mode.jsx +++ b/src/components/bit-line-mode/bit-line-mode.jsx @@ -1,27 +1,25 @@ import React from 'react'; - -import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; +import PropTypes from 'prop-types'; import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; import lineIcon from './line.svg'; -const BitLineComponent = () => ( - <ComingSoonTooltip - place="right" - tooltipId="bit-line-mode" - > - <ToolSelectComponent - disabled - imgDescriptor={{ - defaultMessage: 'Line', - description: 'Label for the line tool, which draws straight line segments', - id: 'paint.lineMode.line' - }} - imgSrc={lineIcon} - isSelected={false} - onMouseDown={function () {}} // eslint-disable-line react/jsx-no-bind - /> - </ComingSoonTooltip> +const BitLineComponent = props => ( + <ToolSelectComponent + imgDescriptor={{ + defaultMessage: 'Line', + description: 'Label for the line tool, which draws straight line segments', + id: 'paint.lineMode.line' + }} + imgSrc={lineIcon} + isSelected={props.isSelected} + onMouseDown={props.onMouseDown} + /> ); +BitLineComponent.propTypes = { + isSelected: PropTypes.bool.isRequired, + onMouseDown: PropTypes.func.isRequired +}; + export default BitLineComponent; diff --git a/src/components/mode-tools/mode-tools.jsx b/src/components/mode-tools/mode-tools.jsx index 6356f9de..80523e06 100644 --- a/src/components/mode-tools/mode-tools.jsx +++ b/src/components/mode-tools/mode-tools.jsx @@ -15,14 +15,15 @@ import InputGroup from '../input-group/input-group.jsx'; import LabeledIconButton from '../labeled-icon-button/labeled-icon-button.jsx'; import Modes from '../../lib/modes'; import Formats from '../../lib/format'; -import {isBitmap} from '../../lib/format'; +import {isBitmap, isVector} from '../../lib/format'; import styles from './mode-tools.css'; import copyIcon from './icons/copy.svg'; import pasteIcon from './icons/paste.svg'; -import brushIcon from '../brush-mode/brush.svg'; import bitBrushIcon from '../bit-brush-mode/brush.svg'; +import bitLineIcon from '../bit-line-mode/line.svg'; +import brushIcon from '../brush-mode/brush.svg'; import curvedPointIcon from './icons/curved-point.svg'; import eraserIcon from '../eraser-mode/eraser.svg'; import flipHorizontalIcon from './icons/flip-horizontal.svg'; @@ -39,6 +40,11 @@ const ModeToolsComponent = props => { description: 'Label for the brush size input', id: 'paint.modeTools.brushSize' }, + lineSize: { + defaultMessage: 'Line size', + description: 'Label for the line size input', + id: 'paint.modeTools.lineSize' + }, eraserSize: { defaultMessage: 'Eraser size', description: 'Label for the eraser size input', @@ -80,18 +86,22 @@ const ModeToolsComponent = props => { case Modes.BRUSH: /* falls through */ case Modes.BIT_BRUSH: + /* falls through */ + case Modes.BIT_LINE: { - const currentBrushIcon = isBitmap(props.format) ? bitBrushIcon : brushIcon; + const currentIcon = isVector(props.format) ? brushIcon : + props.mode === Modes.BIT_LINE ? bitLineIcon : bitBrushIcon; const currentBrushValue = isBitmap(props.format) ? props.bitBrushSize : props.brushValue; const changeFunction = isBitmap(props.format) ? props.onBitBrushSliderChange : props.onBrushSliderChange; + const currentMessage = props.mode === Modes.BIT_LINE ? messages.lineSize : messages.brushSize; return ( <div className={classNames(props.className, styles.modeTools)}> <div> <img - alt={props.intl.formatMessage(messages.brushSize)} + alt={props.intl.formatMessage(currentMessage)} className={styles.modeToolsIcon} draggable={false} - src={currentBrushIcon} + src={currentIcon} /> </div> <LiveInput diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index eb715312..3fff3bd7 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -11,7 +11,7 @@ import {shouldShowGroup, shouldShowUngroup} from '../../helper/group'; import {shouldShowBringForward, shouldShowSendBackward} from '../../helper/order'; import BitBrushMode from '../../containers/bit-brush-mode.jsx'; -import BitLineMode from '../../components/bit-line-mode/bit-line-mode.jsx'; +import BitLineMode from '../../containers/bit-line-mode.jsx'; import BitOvalMode from '../../components/bit-oval-mode/bit-oval-mode.jsx'; import BitRectMode from '../../components/bit-rect-mode/bit-rect-mode.jsx'; import BitTextMode from '../../components/bit-text-mode/bit-text-mode.jsx'; @@ -410,7 +410,9 @@ const PaintEditorComponent = props => { <BitBrushMode onUpdateSvg={props.onUpdateSvg} /> - <BitLineMode /> + <BitLineMode + onUpdateSvg={props.onUpdateSvg} + /> <BitOvalMode /> <BitRectMode /> <BitTextMode /> diff --git a/src/containers/bit-line-mode.jsx b/src/containers/bit-line-mode.jsx new file mode 100644 index 00000000..dc2bfd54 --- /dev/null +++ b/src/containers/bit-line-mode.jsx @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import bindAll from 'lodash.bindall'; +import Modes from '../lib/modes'; +import {MIXED} from '../helper/style-path'; + +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; +import {changeMode} from '../reducers/modes'; +import {clearSelectedItems} from '../reducers/selected-items'; +import {clearSelection} from '../helper/selection'; + +import BitLineModeComponent from '../components/bit-line-mode/bit-line-mode.jsx'; +import BitLineTool from '../helper/bit-tools/line-tool'; + +class BitLineMode extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'activateTool', + 'deactivateTool' + ]); + } + componentDidMount () { + if (this.props.isBitLineModeActive) { + this.activateTool(this.props); + } + } + componentWillReceiveProps (nextProps) { + if (this.tool && nextProps.color !== this.props.color) { + this.tool.setColor(nextProps.color); + } + if (this.tool && nextProps.bitBrushSize !== this.props.bitBrushSize) { + this.tool.setLineSize(nextProps.bitBrushSize); + } + + if (nextProps.isBitLineModeActive && !this.props.isBitLineModeActive) { + this.activateTool(); + } else if (!nextProps.isBitLineModeActive && this.props.isBitLineModeActive) { + this.deactivateTool(); + } + } + shouldComponentUpdate (nextProps) { + return nextProps.isBitLineModeActive !== this.props.isBitLineModeActive; + } + activateTool () { + clearSelection(this.props.clearSelectedItems); + // Force the default line color if fill is MIXED or transparent + let color = this.props.color; + if (!color || color === MIXED) { + this.props.onChangeFillColor(DEFAULT_COLOR); + color = DEFAULT_COLOR; + } + this.tool = new BitLineTool( + this.props.onUpdateSvg + ); + this.tool.setColor(color); + this.tool.setLineSize(this.props.bitBrushSize); + + this.tool.activate(); + } + deactivateTool () { + this.tool.deactivateTool(); + this.tool.remove(); + this.tool = null; + } + render () { + return ( + <BitLineModeComponent + isSelected={this.props.isBitLineModeActive} + onMouseDown={this.props.handleMouseDown} + /> + ); + } +} + +BitLineMode.propTypes = { + bitBrushSize: PropTypes.number.isRequired, + clearSelectedItems: PropTypes.func.isRequired, + color: PropTypes.string, + handleMouseDown: PropTypes.func.isRequired, + isBitLineModeActive: PropTypes.bool.isRequired, + onChangeFillColor: PropTypes.func.isRequired, + onUpdateSvg: PropTypes.func.isRequired +}; + +const mapStateToProps = state => ({ + bitBrushSize: state.scratchPaint.bitBrushSize, + color: state.scratchPaint.color.fillColor, + isBitLineModeActive: state.scratchPaint.mode === Modes.BIT_LINE +}); +const mapDispatchToProps = dispatch => ({ + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + handleMouseDown: () => { + dispatch(changeMode(Modes.BIT_LINE)); + }, + onChangeFillColor: fillColor => { + dispatch(changeFillColor(fillColor)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(BitLineMode); diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 07354966..659b8336 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -49,6 +49,7 @@ class PaintEditor extends React.Component { 'handleZoomReset', 'canRedo', 'canUndo', + 'switchMode', 'onMouseDown', 'setCanvas', 'setTextArea', @@ -81,11 +82,9 @@ class PaintEditor extends React.Component { this.stopEyeDroppingLoop(); } - // @todo move to correct corresponding tool - if (isVector(this.props.format) && isBitmap(prevProps.format)) { - this.props.changeMode(Modes.BRUSH); - } else if (isVector(prevProps.format) && isBitmap(this.props.format)) { - this.props.changeMode(Modes.BIT_BRUSH); + if ((isVector(this.props.format) && isBitmap(prevProps.format)) || + (isVector(prevProps.format) && isBitmap(this.props.format))) { + this.switchMode(this.props.format); } } componentWillUnmount () { @@ -94,6 +93,31 @@ class PaintEditor extends React.Component { document.removeEventListener('mousedown', this.onMouseDown); document.removeEventListener('touchstart', this.onMouseDown); } + switchMode (newFormat) { + 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; + default: + 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; + default: + this.props.changeMode(Modes.BIT_BRUSH); + } + } + } handleUpdateSvg (skipSnapshot) { // Store the zoom/pan and restore it after snapshotting // TODO Only doing this because snapshotting at zoom/pan makes export wrong @@ -300,6 +324,7 @@ PaintEditor.propTypes = { handleSwitchToBitmap: PropTypes.func.isRequired, handleSwitchToVector: PropTypes.func.isRequired, isEyeDropping: PropTypes.bool, + mode: PropTypes.oneOf(Object.keys(Modes)).isRequired, name: PropTypes.string, onDeactivateEyeDropper: PropTypes.func.isRequired, onKeyPress: PropTypes.func.isRequired, @@ -331,6 +356,7 @@ const mapStateToProps = state => ({ 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, diff --git a/src/helper/bit-tools/brush-tool.js b/src/helper/bit-tools/brush-tool.js index da173899..8802996e 100644 --- a/src/helper/bit-tools/brush-tool.js +++ b/src/helper/bit-tools/brush-tool.js @@ -96,10 +96,6 @@ class BrushTool extends paper.Tool { handleMouseDrag (event) { if (event.event.button > 0 || !this.active) return; // only first mouse button - if (this.isBoundingBoxMode) { - this.boundingBoxTool.onMouseDrag(event); - return; - } forEachLinePoint(this.lastPoint, event.point, this.draw.bind(this)); this.lastPoint = event.point; } diff --git a/src/helper/bit-tools/line-tool.js b/src/helper/bit-tools/line-tool.js new file mode 100644 index 00000000..b150e947 --- /dev/null +++ b/src/helper/bit-tools/line-tool.js @@ -0,0 +1,142 @@ +import paper from '@scratch/paper'; +import {getRaster} from '../layer'; +import {forEachLinePoint, fillEllipse} from '../bitmap'; +import {getGuideLayer} from '../layer'; +import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT} from '../view'; + +/** + * Tool for drawing lines with the bitmap brush. + */ +class LineTool extends paper.Tool { + /** + * @param {!function} onUpdateSvg A callback to call when the image visibly changes + */ + constructor (onUpdateSvg) { + super(); + this.onUpdateSvg = onUpdateSvg; + + // We have to set these functions instead of just declaring them because + // paper.js tools hook up the listeners in the setter functions. + this.onMouseMove = this.handleMouseMove; + this.onMouseDown = this.handleMouseDown; + this.onMouseDrag = this.handleMouseDrag; + this.onMouseUp = this.handleMouseUp; + + this.colorState = null; + this.active = false; + this.startPoint = null; + this.cursorPreview = null; + // Raster to which to draw + this.drawTarget = null; + } + setColor (color) { + this.color = color; + } + setLineSize (size) { + // For performance, make sure this is an integer + this.size = Math.max(1, ~~size); + } + // Draw a brush mark at the given point + draw (x, y) { + const roundedUpRadius = Math.ceil(this.size / 2); + this.drawTarget.drawImage(this.tmpCanvas, new paper.Point(~~x - roundedUpRadius, ~~y - roundedUpRadius)); + } + updateCursorIfNeeded () { + if (!this.size) { + return; + } + // The cursor preview was unattached from the view by an outside process, + // such as changing costumes or undo. + if (this.cursorPreview && !this.cursorPreview.parent) { + this.cursorPreview = null; + } + + if (!this.cursorPreview || !(this.lastSize === this.size && this.lastColor === this.color)) { + if (this.cursorPreview) { + this.cursorPreview.remove(); + } + + this.tmpCanvas = document.createElement('canvas'); + const roundedUpRadius = Math.ceil(this.size / 2); + this.tmpCanvas.width = roundedUpRadius * 2; + this.tmpCanvas.height = roundedUpRadius * 2; + const context = this.tmpCanvas.getContext('2d'); + context.imageSmoothingEnabled = false; + context.fillStyle = this.color; + // Small squares for pixel artists + if (this.size <= 5) { + if (this.size % 2) { + context.fillRect(1, 1, this.size, this.size); + } else { + context.fillRect(0, 0, this.size, this.size); + } + } else { + const roundedDownRadius = ~~(this.size / 2); + fillEllipse(roundedDownRadius, roundedDownRadius, roundedDownRadius, roundedDownRadius, context); + } + + this.cursorPreview = new paper.Raster(this.tmpCanvas); + this.cursorPreview.guide = true; + this.cursorPreview.parent = getGuideLayer(); + this.cursorPreview.data.isHelperItem = true; + } + this.lastSize = this.size; + this.lastColor = this.color; + } + handleMouseMove (event) { + this.updateCursorIfNeeded(); + this.cursorPreview.position = new paper.Point(~~event.point.x, ~~event.point.y); + } + handleMouseDown (event) { + if (event.event.button > 0) return; // only first mouse button + this.active = true; + + this.cursorPreview.remove(); + + const tmpCanvas = document.createElement('canvas'); + tmpCanvas.width = ART_BOARD_WIDTH; + tmpCanvas.height = ART_BOARD_HEIGHT; + this.drawTarget = new paper.Raster(tmpCanvas); + this.drawTarget.parent = getGuideLayer(); + this.drawTarget.guide = true; + this.drawTarget.locked = true; + this.drawTarget.position = getRaster().position; + + this.draw(event.point.x, event.point.y); + this.startPoint = event.point; + } + handleMouseDrag (event) { + if (event.event.button > 0 || !this.active) return; // only first mouse button + + // Clear + const context = this.drawTarget.canvas.getContext('2d'); + context.clearRect(0, 0, ART_BOARD_WIDTH, ART_BOARD_HEIGHT); + + forEachLinePoint(this.startPoint, event.point, this.draw.bind(this)); + } + handleMouseUp (event) { + if (event.event.button > 0 || !this.active) return; // only first mouse button + + this.drawTarget.remove(); + this.drawTarget = getRaster(); + forEachLinePoint(this.startPoint, event.point, this.draw.bind(this)); + this.drawTarget = null; + this.onUpdateSvg(); + + this.lastPoint = null; + this.active = false; + + this.updateCursorIfNeeded(); + this.cursorPreview.position = new paper.Point(~~event.point.x, ~~event.point.y); + } + deactivateTool () { + this.active = false; + this.tmpCanvas = null; + if (this.cursorPreview) { + this.cursorPreview.remove(); + this.cursorPreview = null; + } + } +} + +export default LineTool; diff --git a/src/lib/modes.js b/src/lib/modes.js index 4eafc6b5..332558cd 100644 --- a/src/lib/modes.js +++ b/src/lib/modes.js @@ -2,6 +2,7 @@ import keyMirror from 'keymirror'; const Modes = keyMirror({ BIT_BRUSH: null, + BIT_LINE: null, BRUSH: null, ERASER: null, LINE: null,