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. 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: In your parent component:
``` ```
@ -82,13 +77,33 @@ import PaintEditor from 'scratch-paint';
<PaintEditor <PaintEditor
image={optionalImage} image={optionalImage}
imageId={optionalId} imageId={optionalId}
imageFormat='svg' // 'svg', 'png', or 'jpg' imageFormat='svg'
rotationCenterX={optionalCenterPointXRelativeToTopLeft} rotationCenterX={optionalCenterPointX}
rotationCenterY={optionalCenterPointYRelativeToTopLeft} rotationCenterY={optionalCenterPointY}
rtl={true|false}
onUpdateImage={handleUpdateImageFunction} 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: In the top-level combineReducers function:
``` ```
import {ScratchPaintReducer} from 'scratch-paint'; import {ScratchPaintReducer} from 'scratch-paint';

View file

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

View file

@ -28,6 +28,47 @@ import Formats from '../lib/format';
import {isBitmap, isVector} from '../lib/format'; import {isBitmap, isVector} from '../lib/format';
import bindAll from 'lodash.bindall'; 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 { class PaintEditor extends React.Component {
static get ZOOM_INCREMENT () { static get ZOOM_INCREMENT () {
return 0.5; return 0.5;
@ -265,6 +306,7 @@ class PaintEditor extends React.Component {
setCanvas={this.setCanvas} setCanvas={this.setCanvas}
setTextArea={this.setTextArea} setTextArea={this.setTextArea}
textArea={this.state.textArea} textArea={this.state.textArea}
zoomLevelId={this.props.zoomLevelId}
onRedo={this.props.onRedo} onRedo={this.props.onRedo}
onSwitchToBitmap={this.props.handleSwitchToBitmap} onSwitchToBitmap={this.props.handleSwitchToBitmap}
onSwitchToVector={this.props.handleSwitchToVector} onSwitchToVector={this.props.handleSwitchToVector}
@ -314,7 +356,8 @@ PaintEditor.propTypes = {
shouldShowRedo: PropTypes.func.isRequired, shouldShowRedo: PropTypes.func.isRequired,
shouldShowUndo: PropTypes.func.isRequired, shouldShowUndo: PropTypes.func.isRequired,
updateViewBounds: 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 => ({ const mapStateToProps = state => ({

View file

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

View file

@ -1,5 +1,7 @@
import paper from '@scratch/paper'; import paper from '@scratch/paper';
import {getSelectedRootItems} from './selection'; import {getSelectedRootItems} from './selection';
import {getRaster} from './layer';
import {getHitBounds} from './bitmap';
// Vectors are imported and exported at SVG_ART_BOARD size. // Vectors are imported and exported at SVG_ART_BOARD size.
// Once they are imported however, both SVGs and bitmaps are on // 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 SVG_ART_BOARD_HEIGHT = 360;
const ART_BOARD_WIDTH = 480 * 2; const ART_BOARD_WIDTH = 480 * 2;
const ART_BOARD_HEIGHT = 360 * 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 clampViewBounds = () => {
const {left, right, top, bottom} = paper.project.view.bounds; const {left, right, top, bottom} = paper.project.view.bounds;
@ -28,7 +33,7 @@ const clampViewBounds = () => {
// Zoom keeping a project-space point fixed. // Zoom keeping a project-space point fixed.
// This article was helpful http://matthiasberth.com/tech/stable-zoom-and-pan-in-paperjs // This article was helpful http://matthiasberth.com/tech/stable-zoom-and-pan-in-paperjs
const zoomOnFixedPoint = (deltaZoom, fixedPoint) => { const zoomOnFixedPoint = (deltaZoom, fixedPoint) => {
const {view} = paper.project; const view = paper.view;
const preZoomCenter = view.center; const preZoomCenter = view.center;
const newZoom = Math.max(0.5, view.zoom + deltaZoom); const newZoom = Math.max(0.5, view.zoom + deltaZoom);
const scaling = view.zoom / newZoom; const scaling = view.zoom / newZoom;
@ -70,6 +75,28 @@ const pan = (dx, dy) => {
clampViewBounds(); 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 { export {
ART_BOARD_HEIGHT, ART_BOARD_HEIGHT,
ART_BOARD_WIDTH, ART_BOARD_WIDTH,
@ -79,5 +106,6 @@ export {
pan, pan,
resetZoom, resetZoom,
zoomOnSelection, zoomOnSelection,
zoomOnFixedPoint zoomOnFixedPoint,
zoomToFit
}; };

View file

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