diff --git a/src/components/paint-editor/paint-editor.css b/src/components/paint-editor/paint-editor.css index 9b7a508f..f10ef7b4 100644 --- a/src/components/paint-editor/paint-editor.css +++ b/src/components/paint-editor/paint-editor.css @@ -19,7 +19,7 @@ } .top-align-row { - display: flex; + display: flex; padding-top: calc(5 * $grid-unit); flex-direction: row; } @@ -29,7 +29,7 @@ } .mod-dashed-border { - border-right: 1px dashed $ui-pane-border; + border-right: 1px dashed $ui-pane-border; padding-right: calc(3 * $grid-unit); } @@ -92,3 +92,8 @@ $border-radius: 0.25rem; align-content: flex-start; justify-content: space-between; } + +.zoom-controls { + display: flex; + flex-direction: row-reverse; +} diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index 0f3c115b..cb262c80 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -39,6 +39,9 @@ import sendForwardIcon from './send-forward.svg'; import sendFrontIcon from './send-front.svg'; import undoIcon from './undo.svg'; import ungroupIcon from './ungroup.svg'; +import zoomInIcon from './zoom-in.svg'; +import zoomOutIcon from './zoom-out.svg'; +import zoomResetIcon from './zoom-reset.svg'; const BufferedInput = BufferedInputHOC(Input); const messages = defineMessages({ @@ -97,7 +100,7 @@ class PaintEditorComponent extends React.Component { onClick={this.props.onUndo} > <img - alt="Undo Icon" + alt="Undo" className={styles.buttonGroupButtonIcon} src={undoIcon} /> @@ -115,7 +118,7 @@ class PaintEditorComponent extends React.Component { onClick={this.props.onRedo} > <img - alt="Redo Icon" + alt="Redo" className={styles.buttonGroupButtonIcon} src={redoIcon} /> @@ -127,14 +130,14 @@ class PaintEditorComponent extends React.Component { <InputGroup className={styles.modDashedBorder}> <LabeledIconButton disabled={!shouldShowGroup()} - imgAlt="Group Icon" + imgAlt="Group" imgSrc={groupIcon} title="Group" onClick={this.props.onGroup} /> <LabeledIconButton disabled={!shouldShowUngroup()} - imgAlt="Ungroup Icon" + imgAlt="Ungroup" imgSrc={ungroupIcon} title="Ungroup" onClick={this.props.onUngroup} @@ -145,14 +148,14 @@ class PaintEditorComponent extends React.Component { <InputGroup className={styles.modDashedBorder}> <LabeledIconButton disabled={!shouldShowBringForward()} - imgAlt="Send Forward Icon" + imgAlt="Send Forward" imgSrc={sendForwardIcon} title="Forward" onClick={this.props.onSendForward} /> <LabeledIconButton disabled={!shouldShowSendBackward()} - imgAlt="Send Backward Icon" + imgAlt="Send Backward" imgSrc={sendBackwardIcon} title="Backward" onClick={this.props.onSendBackward} @@ -163,14 +166,14 @@ class PaintEditorComponent extends React.Component { <InputGroup> <LabeledIconButton disabled={!shouldShowBringForward()} - imgAlt="Send to Front Icon" + imgAlt="Send to Front" imgSrc={sendFrontIcon} title="Front" onClick={this.props.onSendToFront} /> <LabeledIconButton disabled={!shouldShowSendBackward()} - imgAlt="Send to Back Icon" + imgAlt="Send to Back" imgSrc={sendBackIcon} title="Back" onClick={this.props.onSendToBack} @@ -180,7 +183,7 @@ class PaintEditorComponent extends React.Component { {/* To be rotation point */} {/* <InputGroup> <LabeledIconButton - imgAlt="Rotation Point Icon" + imgAlt="Rotation Point" imgSrc={rotationPointIcon} title="Rotation Point" onClick={function () {}} @@ -222,20 +225,16 @@ class PaintEditorComponent extends React.Component { onUpdateSvg={this.props.onUpdateSvg} /> <BrushMode - canvas={this.state.canvas} onUpdateSvg={this.props.onUpdateSvg} /> <EraserMode - canvas={this.state.canvas} onUpdateSvg={this.props.onUpdateSvg} /> <PenMode - canvas={this.state.canvas} onUpdateSvg={this.props.onUpdateSvg} /> {/* Text mode will go here */} <LineMode - canvas={this.state.canvas} onUpdateSvg={this.props.onUpdateSvg} /> <OvalMode @@ -257,6 +256,41 @@ class PaintEditorComponent extends React.Component { svgId={this.props.svgId} onUpdateSvg={this.props.onUpdateSvg} /> + {/* Zoom controls */} + <InputGroup className={styles.zoomControls}> + <ButtonGroup> + <Button + className={styles.buttonGroupButton} + onClick={this.props.onZoomIn} + > + <img + alt="Zoom In" + className={styles.buttonGroupButtonIcon} + src={zoomInIcon} + /> + </Button> + <Button + className={styles.buttonGroupButton} + onClick={this.props.onZoomReset} + > + <img + alt="Zoom Reset" + className={styles.buttonGroupButtonIcon} + src={zoomResetIcon} + /> + </Button> + <Button + className={styles.buttonGroupButton} + onClick={this.props.onZoomOut} + > + <img + alt="Zoom Out" + className={styles.buttonGroupButtonIcon} + src={zoomOutIcon} + /> + </Button> + </ButtonGroup> + </InputGroup> </div> </div> </div> @@ -279,6 +313,9 @@ PaintEditorComponent.propTypes = { onUngroup: PropTypes.func.isRequired, onUpdateName: PropTypes.func.isRequired, onUpdateSvg: PropTypes.func.isRequired, + onZoomIn: PropTypes.func.isRequired, + onZoomOut: PropTypes.func.isRequired, + onZoomReset: PropTypes.func.isRequired, rotationCenterX: PropTypes.number, rotationCenterY: PropTypes.number, svg: PropTypes.string, diff --git a/src/components/paint-editor/zoom-in.svg b/src/components/paint-editor/zoom-in.svg new file mode 100644 index 00000000..4b804c1f --- /dev/null +++ b/src/components/paint-editor/zoom-in.svg @@ -0,0 +1,15 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="6 6 24 24"> +<defs> + <style> + .cls-4{fill:none;stroke:#575e75;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;} + </style> +</defs> +<title>zoom-in</title> +<g class="cls-3"> + <circle class="cls-4" cx="18" cy="18" r="7"/> + <line class="cls-4" x1="23" y1="23" x2="26" y2="26"/> + <line class="cls-4" x1="16" y1="18" x2="20" y2="18"/> + <line class="cls-4" x1="18" y1="16" x2="18" y2="20"/> +</g> +</svg> diff --git a/src/components/paint-editor/zoom-out.svg b/src/components/paint-editor/zoom-out.svg new file mode 100644 index 00000000..16846628 --- /dev/null +++ b/src/components/paint-editor/zoom-out.svg @@ -0,0 +1,14 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="6 6 24 24"> +<defs> + <style> + .cls-4{fill:none;stroke:#575e75;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.5px;} + </style> +</defs> +<title>zoom-out</title> +<g class="cls-3"> + <circle class="cls-4" cx="18" cy="18" r="7"/> + <line class="cls-4" x1="23" y1="23" x2="26" y2="26"/> + <line class="cls-4" x1="16" y1="18" x2="20" y2="18"/> +</g> +</svg> diff --git a/src/components/paint-editor/zoom-reset.svg b/src/components/paint-editor/zoom-reset.svg new file mode 100644 index 00000000..66d8040c --- /dev/null +++ b/src/components/paint-editor/zoom-reset.svg @@ -0,0 +1,13 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="6 6 24 24"> +<defs> + <style> + .cls-4{fill:#575e75;} + </style> +</defs> +<title>zoom-reset</title> +<g class="cls-3"> + <rect class="cls-4" x="13" y="14" width="10" height="2" rx="1" ry="1"/> + <rect class="cls-4" x="13" y="20" width="10" height="2" rx="1" ry="1"/> +</g> +</svg> diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx index a9401f38..adf0f603 100644 --- a/src/containers/brush-mode.jsx +++ b/src/containers/brush-mode.jsx @@ -22,8 +22,7 @@ class BrushMode extends React.Component { super(props); bindAll(this, [ 'activateTool', - 'deactivateTool', - 'onScroll' + 'deactivateTool' ]); this.blob = new Blobbiness( this.props.onUpdateSvg, this.props.clearSelectedItems); @@ -53,15 +52,11 @@ class BrushMode extends React.Component { // TODO: Instead of clearing selection, consider a kind of "draw inside" // analogous to how selection works with eraser clearSelection(this.props.clearSelectedItems); - // Force the default brush color if fill is MIXED or transparent const {fillColor} = this.props.colorState; if (fillColor === MIXED || fillColor === null) { this.props.onChangeFillColor(BrushMode.DEFAULT_COLOR); } - - // TODO: This is temporary until a component that provides the brush size is hooked up - this.props.canvas.addEventListener('mousewheel', this.onScroll); this.blob.activateTool({ isEraser: false, ...this.props.colorState, @@ -69,17 +64,8 @@ class BrushMode extends React.Component { }); } deactivateTool () { - this.props.canvas.removeEventListener('mousewheel', this.onScroll); this.blob.deactivateTool(); } - onScroll (event) { - if (event.deltaY < 0) { - this.props.changeBrushSize(this.props.brushModeState.brushSize + 1); - } else if (event.deltaY > 0 && this.props.brushModeState.brushSize > 1) { - this.props.changeBrushSize(this.props.brushModeState.brushSize - 1); - } - return true; - } render () { return ( <BrushModeComponent @@ -94,8 +80,6 @@ BrushMode.propTypes = { brushModeState: PropTypes.shape({ brushSize: PropTypes.number.isRequired }), - canvas: PropTypes.instanceOf(Element).isRequired, - changeBrushSize: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ fillColor: PropTypes.string, diff --git a/src/containers/eraser-mode.jsx b/src/containers/eraser-mode.jsx index cd7149df..45412f63 100644 --- a/src/containers/eraser-mode.jsx +++ b/src/containers/eraser-mode.jsx @@ -14,8 +14,7 @@ class EraserMode extends React.Component { super(props); bindAll(this, [ 'activateTool', - 'deactivateTool', - 'onScroll' + 'deactivateTool' ]); this.blob = new Blobbiness( this.props.onUpdateSvg, this.props.clearSelectedItems); @@ -41,22 +40,11 @@ class EraserMode extends React.Component { return nextProps.isEraserModeActive !== this.props.isEraserModeActive; } activateTool () { - this.props.canvas.addEventListener('mousewheel', this.onScroll); - this.blob.activateTool({isEraser: true, ...this.props.eraserModeState}); } deactivateTool () { - this.props.canvas.removeEventListener('mousewheel', this.onScroll); this.blob.deactivateTool(); } - onScroll (event) { - event.preventDefault(); - if (event.deltaY < 0) { - this.props.changeBrushSize(this.props.eraserModeState.brushSize + 1); - } else if (event.deltaY > 0 && this.props.eraserModeState.brushSize > 1) { - this.props.changeBrushSize(this.props.eraserModeState.brushSize - 1); - } - } render () { return ( <EraserModeComponent @@ -68,8 +56,6 @@ class EraserMode extends React.Component { } EraserMode.propTypes = { - canvas: PropTypes.instanceOf(Element).isRequired, - changeBrushSize: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, eraserModeState: PropTypes.shape({ brushSize: PropTypes.number.isRequired diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx index 476f329d..5c72c089 100644 --- a/src/containers/line-mode.jsx +++ b/src/containers/line-mode.jsx @@ -205,7 +205,6 @@ class LineMode extends React.Component { } } deactivateTool () { - this.props.canvas.removeEventListener('mousewheel', this.onScroll); this.tool.remove(); this.tool = null; if (this.hitResult) { @@ -227,7 +226,6 @@ class LineMode extends React.Component { } LineMode.propTypes = { - canvas: PropTypes.instanceOf(Element).isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ fillColor: PropTypes.string, diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 07a214b2..9156b6e6 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -11,6 +11,7 @@ import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRed import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order'; import {groupSelection, ungroupSelection} from '../helper/group'; import {getSelectedLeafItems} from '../helper/selection'; +import {resetZoom, zoomOnSelection} from '../helper/view'; import Modes from '../modes/modes'; import {connect} from 'react-redux'; @@ -18,6 +19,9 @@ import bindAll from 'lodash.bindall'; import paper from '@scratch/paper'; class PaintEditor extends React.Component { + static get ZOOM_INCREMENT () { + return 0.5; + } constructor (props) { super(props); bindAll(this, [ @@ -41,6 +45,11 @@ class PaintEditor extends React.Component { document.removeEventListener('keydown', this.props.onKeyPress); } handleUpdateSvg (skipSnapshot) { + // Store the zoom/pan and restore it after snapshotting + // TODO Only doing this because snapshotting at zoom/pan makes export wrong + const oldZoom = paper.project.view.zoom; + const oldCenter = paper.project.view.center.clone(); + resetZoom(); // Hide guide layer const guideLayer = getGuideLayer(); const backgroundGuideLayer = getBackgroundGuideLayer(); @@ -60,6 +69,10 @@ class PaintEditor extends React.Component { paper.project.addLayer(backgroundGuideLayer); backgroundGuideLayer.sendToBack(); paper.project.addLayer(guideLayer); + // Restore old zoom + paper.project.view.zoom = oldZoom; + paper.project.view.center = oldCenter; + paper.project.view.update(); } handleUndo () { performUndo(this.props.undoState, this.props.onUndo, this.props.setSelectedItems, this.handleUpdateSvg); @@ -91,6 +104,15 @@ class PaintEditor extends React.Component { canRedo () { return shouldShowRedo(this.props.undoState); } + handleZoomIn () { + zoomOnSelection(PaintEditor.ZOOM_INCREMENT); + } + handleZoomOut () { + zoomOnSelection(-PaintEditor.ZOOM_INCREMENT); + } + handleZoomReset () { + resetZoom(); + } render () { return ( <PaintEditorComponent @@ -111,6 +133,9 @@ class PaintEditor extends React.Component { onUngroup={this.handleUngroup} onUpdateName={this.props.onUpdateName} onUpdateSvg={this.handleUpdateSvg} + onZoomIn={this.handleZoomIn} + onZoomOut={this.handleZoomOut} + onZoomReset={this.handleZoomReset} /> ); } diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index 2d84220f..9534fedf 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -10,6 +10,8 @@ import {undoSnapshot, clearUndoState} from '../reducers/undo'; import {isGroup, ungroupItems} from '../helper/group'; import {setupLayers} from '../helper/layer'; import {deleteSelection, getSelectedLeafItems} from '../helper/selection'; +import {pan, resetZoom, zoomOnFixedPoint} from '../helper/view'; + import {setSelectedItems} from '../reducers/selected-items'; import styles from './paper-canvas.css'; @@ -20,7 +22,8 @@ class PaperCanvas extends React.Component { bindAll(this, [ 'setCanvas', 'importSvg', - 'handleKeyDown' + 'handleKeyDown', + 'handleWheel' ]); } componentDidMount () { @@ -45,7 +48,14 @@ class PaperCanvas extends React.Component { } this.props.clearUndo(); if (newProps.svg) { + // Store the zoom/pan and restore it after importing a new SVG + const oldZoom = paper.project.view.zoom; + const oldCenter = paper.project.view.center.clone(); + resetZoom(); this.importSvg(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY); + paper.project.view.zoom = oldZoom; + paper.project.view.center = oldCenter; + paper.project.view.update(); } } componentWillUnmount () { @@ -84,7 +94,7 @@ class PaperCanvas extends React.Component { item.clipped = false; mask.remove(); } - + // Reduce single item nested in groups if (item.children && item.children.length === 1) { item = item.reduce(); @@ -114,6 +124,23 @@ class PaperCanvas extends React.Component { this.props.canvasRef(canvas); } } + handleWheel (event) { + if (event.metaKey) { + // Zoom keeping mouse location fixed + const canvasRect = this.canvas.getBoundingClientRect(); + const offsetX = event.clientX - canvasRect.left; + const offsetY = event.clientY - canvasRect.top; + const fixedPoint = paper.project.view.viewToProject( + new paper.Point(offsetX, offsetY) + ); + zoomOnFixedPoint(-event.deltaY / 100, fixedPoint); + } else { + const dx = event.deltaX / paper.project.view.zoom; + const dy = event.deltaY / paper.project.view.zoom; + pan(dx, dy); + } + event.preventDefault(); + } render () { return ( <canvas @@ -121,6 +148,7 @@ class PaperCanvas extends React.Component { height="400px" ref={this.setCanvas} width="500px" + onWheel={this.handleWheel} /> ); } diff --git a/src/helper/view.js b/src/helper/view.js new file mode 100644 index 00000000..750bbea1 --- /dev/null +++ b/src/helper/view.js @@ -0,0 +1,70 @@ +import paper from '@scratch/paper'; +import {getSelectedRootItems} from './selection'; + +const clampViewBounds = () => { + const {left, right, top, bottom} = paper.project.view.bounds; + if (left < 0) { + paper.project.view.scrollBy(new paper.Point(-left, 0)); + } + if (top < 0) { + paper.project.view.scrollBy(new paper.Point(0, -top)); + } + if (bottom > 400) { + paper.project.view.scrollBy(new paper.Point(0, 400 - bottom)); + } + if (right > 500) { + paper.project.view.scrollBy(new paper.Point(500 - right, 0)); + } +}; + +// Zoom keeping a project-space point fixed. +// This article was helpful http://matthiasberth.com/tech/stable-zoom-and-pan-in-paperjs +const zoomOnFixedPoint = (deltaZoom, fixedPoint) => { + const {view} = paper.project; + const preZoomCenter = view.center; + const newZoom = Math.max(1, view.zoom + deltaZoom); + const scaling = view.zoom / newZoom; + const preZoomOffset = fixedPoint.subtract(preZoomCenter); + const postZoomOffset = fixedPoint.subtract(preZoomOffset.multiply(scaling)) + .subtract(preZoomCenter); + view.zoom = newZoom; + view.translate(postZoomOffset.multiply(-1)); + clampViewBounds(); +}; + +// Zoom keeping the selection center (if any) fixed. +const zoomOnSelection = deltaZoom => { + let fixedPoint; + const items = getSelectedRootItems(); + if (items.length > 0) { + let rect = null; + for (const item of items) { + if (rect) { + rect = rect.unite(item.bounds); + } else { + rect = item.bounds; + } + } + fixedPoint = rect.center; + } else { + fixedPoint = paper.project.view.center; + } + zoomOnFixedPoint(deltaZoom, fixedPoint); +}; + +const resetZoom = () => { + paper.project.view.zoom = 1; + clampViewBounds(); +}; + +const pan = (dx, dy) => { + paper.project.view.scrollBy(new paper.Point(dx, dy)); + clampViewBounds(); +}; + +export { + pan, + resetZoom, + zoomOnSelection, + zoomOnFixedPoint +};