diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index a10b08b60a4d34e30ae137cfa4f274ab559fa6ff..6118b799753ce6d2bd3ef6033c596a37e244a8c8 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -279,6 +279,20 @@ $fade-out-distance: 15px; margin-top: 0; } +/* Sprite Selection Watermark */ +.watermark { + position: absolute; + top: 1.25rem; +} + +[dir="ltr"] .watermark { + right: 1.25rem; +} + +[dir="rtl"] .watermark { + left: 1.25rem; +} + /* Menu */ .menu-bar-position { diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index e898f8f48f4b076da63095cfb40a9cdaa26bb3a2..08a74dfca382cd6e3c963d257b075734ed20cd72 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -20,6 +20,7 @@ import Box from '../box/box.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; import CostumeLibrary from '../../containers/costume-library.jsx'; import BackdropLibrary from '../../containers/backdrop-library.jsx'; +import Watermark from '../../containers/watermark.jsx'; import Backpack from '../../containers/backpack.jsx'; import PreviewModal from '../../containers/preview-modal.jsx'; @@ -271,6 +272,9 @@ const GUIComponent = props => { /> </button> </Box> + <Box className={styles.watermark}> + <Watermark /> + </Box> </TabPanel> <TabPanel className={tabClassNames.tabPanel}> {costumesTabVisible ? <CostumeTab vm={vm} /> : null} diff --git a/src/components/watermark/watermark.css b/src/components/watermark/watermark.css new file mode 100644 index 0000000000000000000000000000000000000000..f02e3242c9be4adabb3d421754c56c1692cff2ea --- /dev/null +++ b/src/components/watermark/watermark.css @@ -0,0 +1,9 @@ + +.sprite-image { + margin: auto; + user-select: none; + max-width: 48px; + max-height: 48px; + opacity: 0.35; + pointer-events: none; +} diff --git a/src/components/watermark/watermark.jsx b/src/components/watermark/watermark.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d3f5b36e24aaaeca8100b71adf833871525964be --- /dev/null +++ b/src/components/watermark/watermark.jsx @@ -0,0 +1,17 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import styles from './watermark.css'; + +const Watermark = props => ( + <img + className={styles.spriteImage} + src={props.costumeURL} + /> +); + +Watermark.propTypes = { + costumeURL: PropTypes.string +}; + +export default Watermark; diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index 9b154c59b0ae97a85522865db8cec792fd1ad65b..aceffc30e585bc0efddcf01951df1cf043dbaffa 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -7,19 +7,17 @@ import {setHoveredSprite} from '../reducers/hovered-target'; import {updateAssetDrag} from '../reducers/asset-drag'; import {getEventXY} from '../lib/touch-utils'; import VM from 'scratch-vm'; -import {SVGRenderer} from 'scratch-svg-renderer'; +import getCostumeUrl from '../lib/get-costume-url'; import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx'; const dragThreshold = 3; // Same as the block drag threshold -// Contains 'font-family', but doesn't only contain 'font-family="none"' -const HAS_FONT_REGEXP = 'font-family(?!="none")'; class SpriteSelectorItem extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'getCostumeUrl', + 'getCostumeData', 'handleClick', 'handleDelete', 'handleDuplicate', @@ -30,8 +28,8 @@ class SpriteSelectorItem extends React.Component { 'handleMouseMove', 'handleMouseUp' ]); - this.svgRenderer = new SVGRenderer(); - // Asset ID of the SVG currently in SVGRenderer + + // Asset ID of the current decoded costume this.decodedAssetId = null; } shouldComponentUpdate (nextProps) { @@ -44,32 +42,11 @@ class SpriteSelectorItem extends React.Component { } return false; } - getCostumeUrl () { + getCostumeData () { if (this.props.costumeURL) return this.props.costumeURL; if (!this.props.assetId) return null; - const storage = this.props.vm.runtime.storage; - const asset = storage.get(this.props.assetId); - // If the SVG refers to fonts, they must be inlined in order to display correctly in the img tag. - // Avoid parsing the SVG when possible, since it's expensive. - if (asset.assetType === storage.AssetType.ImageVector) { - // If the asset ID has not changed, no need to re-parse - if (this.decodedAssetId === this.props.assetId) { - // @todo consider caching more than one URL. - return this.cachedUrl; - } - this.decodedAssetId = this.props.assetId; - const svgString = this.props.vm.runtime.storage.get(this.props.assetId).decodeText(); - if (svgString.match(HAS_FONT_REGEXP)) { - this.svgRenderer.loadString(svgString); - const svgText = this.svgRenderer.toString(true /* shouldInjectFonts */); - this.cachedUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; - } else { - this.cachedUrl = this.props.vm.runtime.storage.get(this.props.assetId).encodeDataURI(); - } - return this.cachedUrl; - } - return this.props.vm.runtime.storage.get(this.props.assetId).encodeDataURI(); + return getCostumeUrl(this.props.assetId, this.props.vm); } handleMouseUp () { this.initialOffset = null; @@ -155,7 +132,7 @@ class SpriteSelectorItem extends React.Component { } = this.props; return ( <SpriteSelectorItemComponent - costumeURL={this.getCostumeUrl()} + costumeURL={this.getCostumeData()} onClick={this.handleClick} onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null} onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null} @@ -209,7 +186,4 @@ const ConnectedComponent = connect( mapDispatchToProps )(SpriteSelectorItem); -export { - ConnectedComponent as default, - HAS_FONT_REGEXP // Exposed for testing -}; +export default ConnectedComponent; diff --git a/src/containers/watermark.jsx b/src/containers/watermark.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c14b70ad41504c5e41d48766e0aebb94d2594bb7 --- /dev/null +++ b/src/containers/watermark.jsx @@ -0,0 +1,68 @@ +import bindAll from 'lodash.bindall'; +import omit from 'lodash.omit'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import VM from 'scratch-vm'; +import getCostumeUrl from '../lib/get-costume-url'; + +import WatermarkComponent from '../components/watermark/watermark.jsx'; + +class Watermark extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'getCostumeData' + ]); + // Asset ID of the current sprite's current costume + this.decodedAssetId = null; + } + + getCostumeData () { + if (!this.props.assetId) return null; + + return getCostumeUrl(this.props.assetId, this.props.vm); + } + + render () { + const componentProps = omit(this.props, ['assetId', 'vm']); + return ( + <WatermarkComponent + costumeURL={this.getCostumeData()} + {...componentProps} + /> + ); + } +} + +Watermark.propTypes = { + assetId: PropTypes.string, + vm: PropTypes.instanceOf(VM).isRequired +}; + +const mapStateToProps = state => { + const targets = state.scratchGui.targets; + const currentTargetId = targets.editingTarget; + + let assetId; + if (currentTargetId) { + if (targets.stage.id === currentTargetId) { + assetId = targets.stage.costume.assetId; + } else if (targets.sprites.hasOwnProperty(currentTargetId)) { + const currentSprite = targets.sprites[currentTargetId]; + assetId = currentSprite.costume.assetId; + } + } + + return { + vm: state.scratchGui.vm, + assetId: assetId + }; +}; + +const ConnectedComponent = connect( + mapStateToProps +)(Watermark); + +export default ConnectedComponent; diff --git a/src/lib/get-costume-url.js b/src/lib/get-costume-url.js new file mode 100644 index 0000000000000000000000000000000000000000..41602b9f91ce87891e5a79ec81e00535c672afd1 --- /dev/null +++ b/src/lib/get-costume-url.js @@ -0,0 +1,41 @@ +import {SVGRenderer} from 'scratch-svg-renderer'; + +// Contains 'font-family', but doesn't only contain 'font-family="none"' +const HAS_FONT_REGEXP = 'font-family(?!="none")'; + +const getCostumeUrl = (function () { + let cachedAssetId; + let cachedUrl; + + return function (assetId, vm) { + + if (cachedAssetId === assetId) { + return cachedUrl; + } + + cachedAssetId = assetId; + + const storage = vm.runtime.storage; + const asset = storage.get(assetId); + // If the SVG refers to fonts, they must be inlined in order to display correctly in the img tag. + // Avoid parsing the SVG when possible, since it's expensive. + if (asset.assetType === storage.AssetType.ImageVector) { + const svgString = vm.runtime.storage.get(assetId).decodeText(); + if (svgString.match(HAS_FONT_REGEXP)) { + const svgRenderer = new SVGRenderer(); + svgRenderer.loadString(svgString); + const svgText = svgRenderer.toString(true /* shouldInjectFonts */); + cachedUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; + } + cachedUrl = vm.runtime.storage.get(assetId).encodeDataURI(); + } + cachedUrl = vm.runtime.storage.get(assetId).encodeDataURI(); + + return cachedUrl; + }; +}()); + +export { + getCostumeUrl as default, + HAS_FONT_REGEXP +}; diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index 25200a1fb5f8376bd7ade0d9b13ff98eaf990885..f133c02a25789791f754e275edcfb58eb813c777 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -4,7 +4,6 @@ import configureStore from 'redux-mock-store'; import {Provider} from 'react-redux'; import SpriteSelectorItem from '../../../src/containers/sprite-selector-item'; -import {HAS_FONT_REGEXP} from '../../../src/containers/sprite-selector-item'; import CloseButton from '../../../src/components/close-button/close-button'; describe('SpriteSelectorItem Container', () => { @@ -56,12 +55,4 @@ describe('SpriteSelectorItem Container', () => { wrapper.find(CloseButton).simulate('click'); expect(onDeleteButtonClick).toHaveBeenCalledWith(1337); }); - - test('Has font regexp works', () => { - expect('font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); - expect('font-family="none" font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); - expect('font-family = "Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); - - expect('font-family="none"'.match(HAS_FONT_REGEXP)).toBeFalsy(); - }); }); diff --git a/test/unit/util/get-costume-url.test.js b/test/unit/util/get-costume-url.test.js new file mode 100644 index 0000000000000000000000000000000000000000..93154acca26452f3cb06c96b0773c1479eb8ad68 --- /dev/null +++ b/test/unit/util/get-costume-url.test.js @@ -0,0 +1,11 @@ +import {HAS_FONT_REGEXP} from '../../../src/lib/get-costume-url'; + +describe('SVG Font Parsing', () => { + test('Has font regexp works', () => { + expect('font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); + expect('font-family="none" font-family="Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); + expect('font-family = "Sans Serif"'.match(HAS_FONT_REGEXP)).toBeTruthy(); + + expect('font-family="none"'.match(HAS_FONT_REGEXP)).toBeFalsy(); + }); +});