Merge pull request #116 from paulkaplan/wheel-pan-zoom

Add basic zooming and panning
This commit is contained in:
Paul Kaplan 2017-10-27 11:12:21 -04:00 committed by GitHub
commit 5bae62e7d1
11 changed files with 226 additions and 51 deletions

View file

@ -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;
}

View file

@ -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,

View file

@ -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>

After

Width:  |  Height:  |  Size: 537 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 480 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 394 B

View file

@ -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,

View file

@ -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

View file

@ -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,

View file

@ -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}
/>
);
}

View file

@ -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}
/>
);
}

70
src/helper/view.js Normal file
View file

@ -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
};