diff --git a/README.md b/README.md
index 7c405315..8aeea4f8 100644
--- a/README.md
+++ b/README.md
@@ -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';
```
+`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';
diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx
index d8857e38..de1b433a 100644
--- a/src/components/paint-editor/paint-editor.jsx
+++ b/src/components/paint-editor/paint-editor.jsx
@@ -210,6 +210,7 @@ const PaintEditorComponent = props => (
imageId={props.imageId}
rotationCenterX={props.rotationCenterX}
rotationCenterY={props.rotationCenterY}
+ zoomLevelId={props.zoomLevelId}
onUpdateImage={props.onUpdateImage}
/>
+ *
+ * `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 => ({
diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx
index 8aa65ff8..91ed8e23 100644
--- a/src/containers/paper-canvas.jsx
+++ b/src/containers/paper-canvas.jsx
@@ -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));
}
diff --git a/src/helper/view.js b/src/helper/view.js
index 4163bc71..aa1fa0e4 100644
--- a/src/helper/view.js
+++ b/src/helper/view.js
@@ -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
};
diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js
index 8b6f0bec..a8575706 100644
--- a/src/reducers/scratch-paint-reducer.js
+++ b/src/reducers/scratch-paint-reducer.js
@@ -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
});
diff --git a/src/reducers/zoom-levels.js b/src/reducers/zoom-levels.js
new file mode 100644
index 00000000..190ca412
--- /dev/null
+++ b/src/reducers/zoom-levels.js
@@ -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
+};