Zoom to fit (#629)

Note do not merge without GUI change
This commit is contained in:
DD Liu 2018-08-31 12:07:17 -04:00 committed by GitHub
parent 20a98db397
commit 516f6eb714
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 206 additions and 19 deletions

View file

@ -69,11 +69,6 @@ npm install --save scratch-paint
```
For an example of how to use scratch-paint as a library, check out the `scratch-paint/src/playground` directory.
In `playground.jsx`, you can change the image that is passed in (which may either be nothing, an SVG string or a base64 data URI) and edit the handler `onUpdateImage`, which is called with the new image (either an SVG string or an ImageData) each time the vector drawing is edited.
If the `imageId` parameter changes, then the paint editor will be cleared, the undo stack reset, and the image re-imported.
SVGs of up to size 480 x 360 will fit into the view window of the paint editor, while bitmaps of size up to 960 x 720 will fit into the paint editor. One unit of an SVG will appear twice as tall and wide as one unit of a bitmap. This quirky import behavior comes from needing to support legacy projects in Scratch.
In your parent component:
```
@ -82,13 +77,33 @@ import PaintEditor from 'scratch-paint';
<PaintEditor
image={optionalImage}
imageId={optionalId}
imageFormat='svg' // 'svg', 'png', or 'jpg'
rotationCenterX={optionalCenterPointXRelativeToTopLeft}
rotationCenterY={optionalCenterPointYRelativeToTopLeft}
imageFormat='svg'
rotationCenterX={optionalCenterPointX}
rotationCenterY={optionalCenterPointY}
rtl={true|false}
onUpdateImage={handleUpdateImageFunction}
zoomLevelId={optionalZoomLevelId}
/>
```
`image`: may either be nothing, an SVG string or a base64 data URI)
SVGs of up to size 480 x 360 will fit into the view window of the paint editor, while bitmaps of size up to 960 x 720 will fit into the paint editor. One unit of an SVG will appear twice as tall and wide as one unit of a bitmap. This quirky import behavior comes from needing to support legacy projects in Scratch.
`imageId`: If this parameter changes, then the paint editor will be cleared, the undo stack reset, and the image re-imported.
`imageFormat`: 'svg', 'png', or 'jpg'. Other formats are currently not supported.
`rotationCenterX`: x coordinate relative to the top left corner of the sprite of the point that should be centered.
`rotationCenterY`: y coordinate relative to the top left corner of the sprite of the point that should be centered.
`rtl`: True if the paint editor should be laid out right to left (meant for right to left languages)
`onUpdateImage`: A handler called with the new image (either an SVG string or an ImageData) each time the drawing is edited.
`zoomLevelId`: All costumes with the same zoom level ID will share the same saved zoom level. When a new zoom level ID is encountered, the paint editor will zoom to fit the current costume comfortably. Leave undefined to perform no zoom to fit.
In the top-level combineReducers function:
```
import {ScratchPaintReducer} from 'scratch-paint';

View file

@ -210,6 +210,7 @@ const PaintEditorComponent = props => (
imageId={props.imageId}
rotationCenterX={props.rotationCenterX}
rotationCenterY={props.rotationCenterY}
zoomLevelId={props.zoomLevelId}
onUpdateImage={props.onUpdateImage}
/>
<textarea
@ -332,7 +333,8 @@ PaintEditorComponent.propTypes = {
rtl: PropTypes.bool,
setCanvas: PropTypes.func.isRequired,
setTextArea: PropTypes.func.isRequired,
textArea: PropTypes.instanceOf(Element)
textArea: PropTypes.instanceOf(Element),
zoomLevelId: PropTypes.string
};
export default injectIntl(PaintEditorComponent);

View file

@ -28,6 +28,47 @@ import Formats from '../lib/format';
import {isBitmap, isVector} from '../lib/format';
import bindAll from 'lodash.bindall';
/**
* The top-level paint editor component. See README for more details on usage.
*
* <PaintEditor
* image={optionalImage}
* imageId={optionalId}
* imageFormat='svg'
* rotationCenterX={optionalCenterPointX}
* rotationCenterY={optionalCenterPointY}
* rtl={true|false}
* onUpdateImage={handleUpdateImageFunction}
* zoomLevelId={optionalZoomLevelId}
* />
*
* `image`: may either be nothing, an SVG string or a base64 data URI)
* SVGs of up to size 480 x 360 will fit into the view window of the paint editor,
* while bitmaps of size up to 960 x 720 will fit into the paint editor. One unit
* of an SVG will appear twice as tall and wide as one unit of a bitmap. This quirky
* import behavior comes from needing to support legacy projects in Scratch.
*
* `imageId`: If this parameter changes, then the paint editor will be cleared, the
* undo stack reset, and the image re-imported.
*
* `imageFormat`: 'svg', 'png', or 'jpg'. Other formats are currently not supported.
*
* `rotationCenterX`: x coordinate relative to the top left corner of the sprite of
* the point that should be centered.
*
* `rotationCenterY`: y coordinate relative to the top left corner of the sprite of
* the point that should be centered.
*
* `rtl`: True if the paint editor should be laid out right to left (meant for right
* to left languages)
*
* `onUpdateImage`: A handler called with the new image (either an SVG string or an
* ImageData) each time the drawing is edited.
*
* `zoomLevelId`: All costumes with the same zoom level ID will share the same saved
* zoom level. When a new zoom level ID is encountered, the paint editor will zoom to
* fit the current costume comfortably. Leave undefined to perform no zoom to fit.
*/
class PaintEditor extends React.Component {
static get ZOOM_INCREMENT () {
return 0.5;
@ -265,6 +306,7 @@ class PaintEditor extends React.Component {
setCanvas={this.setCanvas}
setTextArea={this.setTextArea}
textArea={this.state.textArea}
zoomLevelId={this.props.zoomLevelId}
onRedo={this.props.onRedo}
onSwitchToBitmap={this.props.handleSwitchToBitmap}
onSwitchToVector={this.props.handleSwitchToVector}
@ -314,7 +356,8 @@ PaintEditor.propTypes = {
shouldShowRedo: PropTypes.func.isRequired,
shouldShowUndo: PropTypes.func.isRequired,
updateViewBounds: PropTypes.func.isRequired,
viewBounds: PropTypes.instanceOf(paper.Matrix).isRequired
viewBounds: PropTypes.instanceOf(paper.Matrix).isRequired,
zoomLevelId: PropTypes.string
};
const mapStateToProps = state => ({

View file

@ -11,12 +11,13 @@ import {undoSnapshot, clearUndoState} from '../reducers/undo';
import {isGroup, ungroupItems} from '../helper/group';
import {clearRaster, getRaster, setupLayers} from '../helper/layer';
import {clearSelectedItems} from '../reducers/selected-items';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, resetZoom} from '../helper/view';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, resetZoom, zoomToFit} from '../helper/view';
import {ensureClockwise, scaleWithStrokes} from '../helper/math';
import {clearHoveredItem} from '../reducers/hover';
import {clearPasteOffset} from '../reducers/clipboard';
import {changeFormat} from '../reducers/format';
import {updateViewBounds} from '../reducers/view-bounds';
import {saveZoomLevel, setZoomLevelId} from '../reducers/zoom-levels';
import styles from './paper-canvas.css';
class PaperCanvas extends React.Component {
@ -25,6 +26,7 @@ class PaperCanvas extends React.Component {
bindAll(this, [
'setCanvas',
'importSvg',
'maybeZoomToFit',
'switchCostume'
]);
}
@ -32,6 +34,16 @@ class PaperCanvas extends React.Component {
paper.setup(this.canvas);
resetZoom();
this.props.updateViewBounds(paper.view.matrix);
if (this.props.zoomLevelId) {
this.props.setZoomLevelId(this.props.zoomLevelId);
if (this.props.zoomLevels[this.props.zoomLevelId]) {
// This is the matrix that the view should be zoomed to after image import
this.shouldZoomToFit = this.props.zoomLevels[this.props.zoomLevelId];
} else {
// Zoom to fit true means find a comfortable zoom level for viewing the costume
this.shouldZoomToFit = true;
}
}
const context = this.canvas.getContext('2d');
context.webkitImageSmoothingEnabled = false;
@ -46,14 +58,27 @@ class PaperCanvas extends React.Component {
}
componentWillReceiveProps (newProps) {
if (this.props.imageId !== newProps.imageId) {
this.switchCostume(
newProps.imageFormat, newProps.image, newProps.rotationCenterX, newProps.rotationCenterY);
this.switchCostume(newProps.imageFormat, newProps.image,
newProps.rotationCenterX, newProps.rotationCenterY,
this.props.zoomLevelId, newProps.zoomLevelId);
}
}
componentWillUnmount () {
this.props.saveZoomLevel();
paper.remove();
}
switchCostume (format, image, rotationCenterX, rotationCenterY) {
switchCostume (format, image, rotationCenterX, rotationCenterY, oldZoomLevelId, newZoomLevelId) {
if (oldZoomLevelId && oldZoomLevelId !== newZoomLevelId) {
this.props.saveZoomLevel();
}
if (newZoomLevelId && oldZoomLevelId !== newZoomLevelId) {
if (this.props.zoomLevels[newZoomLevelId]) {
this.shouldZoomToFit = this.props.zoomLevels[newZoomLevelId];
} else {
this.shouldZoomToFit = true;
}
this.props.setZoomLevelId(newZoomLevelId);
}
for (const layer of paper.project.layers) {
if (layer.data.isRasterLayer) {
clearRaster();
@ -87,18 +112,28 @@ class PaperCanvas extends React.Component {
imgElement,
(ART_BOARD_WIDTH / 2) - rotationCenterX,
(ART_BOARD_HEIGHT / 2) - rotationCenterY);
this.maybeZoomToFit(true /* isBitmap */);
performSnapshot(this.props.undoSnapshot, Formats.BITMAP_SKIP_CONVERT);
};
imgElement.src = image;
} else if (format === 'svg') {
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
this.importSvg(image, rotationCenterX, rotationCenterY);
this.maybeZoomToFit();
} else {
log.error(`Didn't recognize format: ${format}. Use 'jpg', 'png' or 'svg'.`);
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
performSnapshot(this.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT);
}
}
maybeZoomToFit (isBitmapMode) {
if (this.shouldZoomToFit instanceof paper.Matrix) {
paper.view.matrix = this.shouldZoomToFit;
} else if (this.shouldZoomToFit === true) {
zoomToFit(isBitmapMode);
}
this.shouldZoomToFit = false;
}
importSvg (svg, rotationCenterX, rotationCenterY) {
const paperCanvas = this;
// Pre-process SVG to prevent parsing errors (discussion from #213)
@ -221,12 +256,19 @@ PaperCanvas.propTypes = {
imageId: PropTypes.string,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
saveZoomLevel: PropTypes.func.isRequired,
setZoomLevelId: PropTypes.func.isRequired,
undoSnapshot: PropTypes.func.isRequired,
updateViewBounds: PropTypes.func.isRequired
updateViewBounds: PropTypes.func.isRequired,
zoomLevelId: PropTypes.string,
zoomLevels: PropTypes.shape({
currentZoomLevelId: PropTypes.string
})
};
const mapStateToProps = state => ({
mode: state.scratchPaint.mode,
format: state.scratchPaint.format
format: state.scratchPaint.format,
zoomLevels: state.scratchPaint.zoomLevels
});
const mapDispatchToProps = dispatch => ({
undoSnapshot: snapshot => {
@ -247,6 +289,12 @@ const mapDispatchToProps = dispatch => ({
changeFormat: format => {
dispatch(changeFormat(format));
},
saveZoomLevel: () => {
dispatch(saveZoomLevel(paper.view.matrix));
},
setZoomLevelId: zoomLevelId => {
dispatch(setZoomLevelId(zoomLevelId));
},
updateViewBounds: matrix => {
dispatch(updateViewBounds(matrix));
}

View file

@ -1,5 +1,7 @@
import paper from '@scratch/paper';
import {getSelectedRootItems} from './selection';
import {getRaster} from './layer';
import {getHitBounds} from './bitmap';
// Vectors are imported and exported at SVG_ART_BOARD size.
// Once they are imported however, both SVGs and bitmaps are on
@ -8,6 +10,9 @@ const SVG_ART_BOARD_WIDTH = 480;
const SVG_ART_BOARD_HEIGHT = 360;
const ART_BOARD_WIDTH = 480 * 2;
const ART_BOARD_HEIGHT = 360 * 2;
const PADDING_PERCENT = 25; // Padding as a percent of the max of width/height of the sprite
const MIN_RATIO = .125; // Zoom in to at least 1/8 of the screen. This way you don't end up incredibly
// zoomed in for tiny costumes.
const clampViewBounds = () => {
const {left, right, top, bottom} = paper.project.view.bounds;
@ -28,7 +33,7 @@ const clampViewBounds = () => {
// 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 view = paper.view;
const preZoomCenter = view.center;
const newZoom = Math.max(0.5, view.zoom + deltaZoom);
const scaling = view.zoom / newZoom;
@ -70,6 +75,28 @@ const pan = (dx, dy) => {
clampViewBounds();
};
const zoomToFit = isBitmap => {
resetZoom();
let bounds;
if (isBitmap) {
bounds = getHitBounds(getRaster());
} else {
bounds = paper.project.activeLayer.bounds;
}
if (bounds && bounds.width && bounds.height) {
// Ratio of (sprite length plus padding on all sides) to art board length.
let ratio = Math.max(bounds.width * (1 + (2 * PADDING_PERCENT / 100)) / ART_BOARD_WIDTH,
bounds.height * (1 + (2 * PADDING_PERCENT / 100)) / ART_BOARD_HEIGHT);
// Clamp ratio
ratio = Math.max(Math.min(1, ratio), MIN_RATIO);
if (ratio < 1) {
paper.view.center = bounds.center;
paper.view.zoom = paper.view.zoom / ratio;
clampViewBounds();
}
}
};
export {
ART_BOARD_HEIGHT,
ART_BOARD_WIDTH,
@ -79,5 +106,6 @@ export {
pan,
resetZoom,
zoomOnSelection,
zoomOnFixedPoint
zoomOnFixedPoint,
zoomToFit
};

View file

@ -17,6 +17,7 @@ import selectedItemReducer from './selected-items';
import textEditTargetReducer from './text-edit-target';
import viewBoundsReducer from './view-bounds';
import undoReducer from './undo';
import zoomLevelsReducer from './zoom-levels';
export default combineReducers({
mode: modeReducer,
@ -36,5 +37,6 @@ export default combineReducers({
selectedItems: selectedItemReducer,
textEditTarget: textEditTargetReducer,
undo: undoReducer,
viewBounds: viewBoundsReducer
viewBounds: viewBoundsReducer,
zoomLevels: zoomLevelsReducer
});

View file

@ -0,0 +1,49 @@
import paper from '@scratch/paper';
import log from '../log/log';
const SAVE_ZOOM_LEVEL = 'scratch-paint/zoom-levels/SAVE_ZOOM_LEVEL';
const SET_ZOOM_LEVEL_ID = 'scratch-paint/zoom-levels/SET_ZOOM_LEVEL_ID';
const initialState = {};
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case SET_ZOOM_LEVEL_ID:
if (action.zoomLevelId === 'currentZoomLevelId') {
log.warn(`currentZoomLevelId is an invalid string for zoomLevel`);
return state;
}
return Object.assign({}, state, {
currentZoomLevelId: action.zoomLevelId
});
case SAVE_ZOOM_LEVEL:
return Object.assign({}, state, {
[state.currentZoomLevelId]: action.zoomLevel
});
default:
return state;
}
};
// Action creators ==================================
const saveZoomLevel = function (zoomLevel) {
if (!(zoomLevel instanceof paper.Matrix)) {
log.warn(`Not a matrix: ${zoomLevel}`);
}
return {
type: SAVE_ZOOM_LEVEL,
zoomLevel: new paper.Matrix(zoomLevel)
};
};
const setZoomLevelId = function (zoomLevelId) {
return {
type: SET_ZOOM_LEVEL_ID,
zoomLevelId: zoomLevelId
};
};
export {
reducer as default,
saveZoomLevel,
setZoomLevelId
};