mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2024-12-22 13:32:28 -05:00
parent
20a98db397
commit
516f6eb714
7 changed files with 206 additions and 19 deletions
31
README.md
31
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';
|
|||
<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';
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 => ({
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
49
src/reducers/zoom-levels.js
Normal file
49
src/reducers/zoom-levels.js
Normal 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
|
||||
};
|
Loading…
Reference in a new issue