Skip to content

Commit 23b5daf

Browse files
authored
Merge pull request #264 from scratchfoundation/UEPR-252
[feature] UEPR-252 Implement manual update of project thumbnails
2 parents e4c3cc3 + d18113e commit 23b5daf

File tree

6 files changed

+130
-39
lines changed

6 files changed

+130
-39
lines changed

packages/scratch-gui/src/components/gui/gui.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const GUIComponent = props => {
101101
isTotallyNormal,
102102
loading,
103103
logo,
104+
manuallySaveThumbnails,
104105
renderLogin,
105106
onClickAbout,
106107
onClickAccountNav,
@@ -128,6 +129,7 @@ const GUIComponent = props => {
128129
onTelemetryModalCancel,
129130
onTelemetryModalOptIn,
130131
onTelemetryModalOptOut,
132+
onUpdateProjectThumbnail,
131133
showComingSoon,
132134
soundsTabVisible,
133135
stageSizeMode,
@@ -181,6 +183,11 @@ const GUIComponent = props => {
181183
isRendererSupported={isRendererSupported}
182184
isRtl={isRtl}
183185
loading={loading}
186+
manuallySaveThumbnails={
187+
manuallySaveThumbnails &&
188+
userOwnsProject
189+
}
190+
onUpdateProjectThumbnail={onUpdateProjectThumbnail}
184191
stageSize={STAGE_SIZE_MODES.large}
185192
vm={vm}
186193
>
@@ -457,6 +464,7 @@ GUIComponent.propTypes = {
457464
isTotallyNormal: PropTypes.bool,
458465
loading: PropTypes.bool,
459466
logo: PropTypes.string,
467+
manuallySaveThumbnails: PropTypes.bool,
460468
onActivateCostumesTab: PropTypes.func,
461469
onActivateSoundsTab: PropTypes.func,
462470
onActivateTab: PropTypes.func,
@@ -481,6 +489,7 @@ GUIComponent.propTypes = {
481489
onTelemetryModalOptIn: PropTypes.func,
482490
onTelemetryModalOptOut: PropTypes.func,
483491
onToggleLoginOpen: PropTypes.func,
492+
onUpdateProjectThumbnail: PropTypes.func,
484493
platform: PropTypes.oneOf(Object.keys(PLATFORM)),
485494
renderLogin: PropTypes.func,
486495
showComingSoon: PropTypes.bool,

packages/scratch-gui/src/components/stage-header/stage-header.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,23 @@
7878
[dir="rtl"] .stage-button-icon {
7979
transform: scaleX(-1);
8080
}
81+
82+
.rightSection {
83+
display: flex;
84+
flex-direction: row;
85+
align-items: center;
86+
gap: 0.5rem;
87+
background-color: transparent;
88+
89+
.setThumbnailButton {
90+
padding: 0.625rem 0.75rem;
91+
font-size: 0.75rem;
92+
line-height: 0.875rem;
93+
color: $ui-white;
94+
background-color: $motion-primary;
95+
}
96+
97+
.setThumbnailButton:active {
98+
filter: brightness(90%);
99+
}
100+
}

packages/scratch-gui/src/components/stage-header/stage-header.jsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import {defineMessages, injectIntl, intlShape} from 'react-intl';
1+
import {FormattedMessage, defineMessages, injectIntl, intlShape} from 'react-intl';
22
import PropTypes from 'prop-types';
3-
import React from 'react';
3+
import React, {useCallback} from 'react';
44
import {connect} from 'react-redux';
55
import VM from '@scratch/scratch-vm';
66

@@ -18,6 +18,9 @@ import unFullScreenIcon from './icon--unfullscreen.svg';
1818

1919
import scratchLogo from '../menu-bar/scratch-logo.svg';
2020
import styles from './stage-header.css';
21+
import {storeProjectThumbnail} from '../../lib/store-project-thumbnail.js';
22+
import dataURItoBlob from '../../lib/data-uri-to-blob.js';
23+
import throttle from 'lodash.throttle';
2124

2225
const messages = defineMessages({
2326
largeStageSizeMessage: {
@@ -40,6 +43,11 @@ const messages = defineMessages({
4043
description: 'Button to get out of full screen mode',
4144
id: 'gui.stageHeader.stageSizeUnFull'
4245
},
46+
setThumbnail: {
47+
defaultMessage: 'Set Thumbnail',
48+
description: 'Manually save project thumbnail',
49+
id: 'gui.stageHeader.saveThumbnail'
50+
},
4351
fullscreenControl: {
4452
defaultMessage: 'Full Screen Control',
4553
description: 'Button to enter/exit full screen mode',
@@ -51,18 +59,37 @@ const StageHeaderComponent = function (props) {
5159
const {
5260
isFullScreen,
5361
isPlayerOnly,
62+
manuallySaveThumbnails,
5463
onKeyPress,
5564
onSetStageLarge,
5665
onSetStageSmall,
5766
onSetStageFull,
5867
onSetStageUnFull,
68+
onUpdateProjectThumbnail,
69+
projectId,
5970
showBranding,
6071
stageSizeMode,
6172
vm
6273
} = props;
6374

6475
let header = null;
6576

77+
const onUpdateThumbnail = useCallback(
78+
throttle(
79+
() => {
80+
if (!onUpdateProjectThumbnail) {
81+
return;
82+
}
83+
84+
storeProjectThumbnail(vm, dataURI => {
85+
onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI));
86+
});
87+
},
88+
3000
89+
),
90+
[projectId, onUpdateProjectThumbnail]
91+
);
92+
6693
if (isFullScreen) {
6794
const stageDimensions = getStageDimensions(null, true);
6895
const stageButton = showBranding ? (
@@ -138,7 +165,16 @@ const StageHeaderComponent = function (props) {
138165
<Controls vm={vm} />
139166
<div className={styles.stageSizeRow}>
140167
{stageControls}
141-
<div>
168+
<div className={styles.rightSection}>
169+
{manuallySaveThumbnails && (
170+
<Button
171+
aria-label={props.intl.formatMessage(messages.setThumbnail)}
172+
className={styles.setThumbnailButton}
173+
onClick={onUpdateThumbnail}
174+
>
175+
<FormattedMessage {...messages.setThumbnail} />
176+
</Button>
177+
)}
142178
<Button
143179
className={styles.stageButton}
144180
onClick={onSetStageFull}
@@ -162,6 +198,7 @@ const StageHeaderComponent = function (props) {
162198
};
163199

164200
const mapStateToProps = state => ({
201+
projectId: state.scratchGui.projectState.projectId,
165202
// This is the button's mode, as opposed to the actual current state
166203
stageSizeMode: state.scratchGui.stageSize.stageSize
167204
});
@@ -170,11 +207,14 @@ StageHeaderComponent.propTypes = {
170207
intl: intlShape,
171208
isFullScreen: PropTypes.bool.isRequired,
172209
isPlayerOnly: PropTypes.bool.isRequired,
210+
manuallySaveThumbnails: PropTypes.bool,
173211
onKeyPress: PropTypes.func.isRequired,
174212
onSetStageFull: PropTypes.func.isRequired,
175213
onSetStageLarge: PropTypes.func.isRequired,
176214
onSetStageSmall: PropTypes.func.isRequired,
177215
onSetStageUnFull: PropTypes.func.isRequired,
216+
onUpdateProjectThumbnail: PropTypes.func,
217+
projectId: PropTypes.number.isRequired,
178218
showBranding: PropTypes.bool.isRequired,
179219
stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)),
180220
vm: PropTypes.instanceOf(VM).isRequired

packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const StageWrapperComponent = function (props) {
1717
isRtl,
1818
isRendererSupported,
1919
loading,
20+
manuallySaveThumbnails,
21+
onUpdateProjectThumbnail,
2022
stageSize,
2123
vm
2224
} = props;
@@ -31,6 +33,8 @@ const StageWrapperComponent = function (props) {
3133
>
3234
<Box className={styles.stageMenuWrapper}>
3335
<StageHeader
36+
manuallySaveThumbnails={manuallySaveThumbnails}
37+
onUpdateProjectThumbnail={onUpdateProjectThumbnail}
3438
stageSize={stageSize}
3539
vm={vm}
3640
/>
@@ -57,6 +61,8 @@ StageWrapperComponent.propTypes = {
5761
isRendererSupported: PropTypes.bool.isRequired,
5862
isRtl: PropTypes.bool.isRequired,
5963
loading: PropTypes.bool,
64+
manuallySaveThumbnails: PropTypes.bool,
65+
onUpdateProjectThumbnail: PropTypes.func,
6066
stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
6167
vm: PropTypes.instanceOf(VM).isRequired
6268
};

packages/scratch-gui/src/lib/project-saver-hoc.jsx

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
projectError
3333
} from '../reducers/project-state';
3434
import {GUIStoragePropType} from '../gui-config';
35+
import {getProjectThumbnail, storeProjectThumbnail} from './store-project-thumbnail';
3536

3637
/**
3738
* Higher Order Component to provide behavior for saving projects.
@@ -47,7 +48,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
4748
constructor (props) {
4849
super(props);
4950
bindAll(this, [
50-
'getProjectThumbnail',
5151
'leavePageConfirm',
5252
'tryToAutoSave'
5353
]);
@@ -61,7 +61,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
6161
// Allow the GUI consumer to pass in a function to receive a trigger
6262
// for triggering thumbnail or whole project saves.
6363
// These functions are called with null on unmount to prevent stale references.
64-
this.props.onSetProjectThumbnailer(this.getProjectThumbnail);
64+
this.props.onSetProjectThumbnailer(callback => getProjectThumbnail(this.props.vm, callback));
6565
this.props.onSetProjectSaver(this.tryToAutoSave);
6666
}
6767
componentDidUpdate (prevProps) {
@@ -72,9 +72,21 @@ const ProjectSaverHOC = function (WrappedComponent) {
7272
this.reportTelemetryEvent('projectDidLoad');
7373
}
7474

75-
if (this.props.saveThumbnailOnLoad && this.props.isShowingWithId &&
76-
!prevProps.isShowingWithId) {
77-
setTimeout(() => this.storeProjectThumbnail(this.props.reduxProjectId));
75+
if (
76+
!this.props.manuallySaveThumbnails &&
77+
this.props.onUpdateProjectThumbnail &&
78+
this.props.saveThumbnailOnLoad &&
79+
this.props.isShowingWithId &&
80+
!prevProps.isShowingWithId
81+
) {
82+
setTimeout(() =>
83+
storeProjectThumbnail(this.props.vm, dataURI => {
84+
this.props.onUpdateProjectThumbnail(
85+
this.props.reduxProjectId,
86+
dataURItoBlob(dataURI)
87+
);
88+
})
89+
);
7890
}
7991

8092
if (this.props.projectChanged && !prevProps.projectChanged) {
@@ -172,7 +184,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
172184
});
173185
}
174186
createNewProjectToStorage () {
175-
return this.storeProject(null)
187+
return this.storeProject(null, {}, {isCreatingProject: true})
176188
.then(response => {
177189
this.props.onCreatedProject(response.id.toString(), this.props.loadingState);
178190
})
@@ -218,8 +230,9 @@ const ProjectSaverHOC = function (WrappedComponent) {
218230
* @param {number|string|undefined} projectId - defined value will PUT/update; undefined/null will POST/create
219231
* @return {Promise} - resolves with json object containing project's existing or new id
220232
* @param {?object} requestParams - object of params to add to request body
233+
* @param {?object} options - additional options for the store operation
221234
*/
222-
storeProject (projectId, requestParams) {
235+
storeProject (projectId, requestParams, options) {
223236
requestParams = requestParams || {};
224237
this.clearAutoSaveTimeout();
225238
// Serialize VM state now before embarking on
@@ -256,8 +269,16 @@ const ProjectSaverHOC = function (WrappedComponent) {
256269
.then(response => {
257270
this.props.onSetProjectUnchanged();
258271
const id = response.id.toString();
259-
if (id && this.props.onUpdateProjectThumbnail) {
260-
this.storeProjectThumbnail(id);
272+
if (this.props.onUpdateProjectThumbnail && id && (
273+
!this.props.manuallySaveThumbnails ||
274+
// Always save thumbnail on project creation
275+
options?.isCreatingProject)) {
276+
storeProjectThumbnail(this.props.vm, dataURI => {
277+
this.props.onUpdateProjectThumbnail(
278+
id,
279+
dataURItoBlob(dataURI)
280+
);
281+
});
261282
}
262283
this.reportTelemetryEvent('projectDidSave');
263284
return response;
@@ -268,32 +289,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
268289
});
269290
}
270291

271-
/**
272-
* Store a snapshot of the project once it has been saved/created.
273-
* Needs to happen _after_ save because the project must have an ID.
274-
* @param {!string} projectId - id of the project, must be defined.
275-
*/
276-
storeProjectThumbnail (projectId) {
277-
try {
278-
this.getProjectThumbnail(dataURI => {
279-
this.props.onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI));
280-
});
281-
} catch (e) {
282-
log.error('Project thumbnail save error', e);
283-
// This is intentionally fire/forget because a failure
284-
// to save the thumbnail is not vitally important to the user.
285-
}
286-
}
287-
288-
getProjectThumbnail (callback) {
289-
this.props.vm.postIOData('video', {forceTransparentPreview: true});
290-
this.props.vm.renderer.requestSnapshot(dataURI => {
291-
this.props.vm.postIOData('video', {forceTransparentPreview: false});
292-
callback(dataURI);
293-
});
294-
this.props.vm.renderer.draw();
295-
}
296-
297292
/**
298293
* Report a telemetry event.
299294
* @param {string} event - one of `projectWasCreated`, `projectDidLoad`, `projectDidSave`, `projectWasUploaded`
@@ -346,7 +341,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
346341
onShowSavingAlert,
347342
onUpdatedProject,
348343
onUpdateProjectData,
349-
onUpdateProjectThumbnail,
350344
noBeforeUnloadHandler,
351345
reduxProjectId,
352346
reduxProjectTitle,
@@ -408,6 +402,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
408402
saveThumbnailOnLoad: PropTypes.bool,
409403
storage: GUIStoragePropType,
410404
setAutoSaveTimeoutId: PropTypes.func.isRequired,
405+
manuallySaveThumbnails: PropTypes.bool,
411406
vm: PropTypes.instanceOf(VM).isRequired
412407
};
413408
ProjectSaverComponent.defaultProps = {
@@ -437,6 +432,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
437432
isManualUpdating: getIsManualUpdating(loadingState),
438433
loadingState: loadingState,
439434
locale: state.locales.locale,
435+
manuallySaveThumbnails: ownProps.manuallySaveThumbnails ?? false,
440436
onUpdateProjectThumbnail:
441437
ownProps.onUpdateProjectThumbnail ??
442438
storage.saveProjectThumbnail?.bind(storage),
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import log from './log';
2+
3+
export const storeProjectThumbnail = (vm, callback) => {
4+
try {
5+
getProjectThumbnail(vm, callback);
6+
} catch (e) {
7+
log.error('Project thumbnail save error', e);
8+
// This is intentionally fire/forget because a failure
9+
// to save the thumbnail is not vitally important to the user.
10+
}
11+
};
12+
13+
export const getProjectThumbnail = (vm, callback) => {
14+
vm.postIOData('video', {forceTransparentPreview: true});
15+
vm.renderer.requestSnapshot(dataURI => {
16+
vm.postIOData('video', {forceTransparentPreview: false});
17+
callback(dataURI);
18+
});
19+
vm.renderer.draw();
20+
};

0 commit comments

Comments
 (0)