diff --git a/package-lock.json b/package-lock.json
index 4c3121bb14..16d222f5af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13072,6 +13072,12 @@
"node": ">=6"
}
},
+ "node_modules/driver.js": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.3.6.tgz",
+ "integrity": "sha512-g2nNuu+tWmPpuoyk3ffpT9vKhjPz4NrJzq6mkRDZIwXCrFhrKdDJ9TX5tJOBpvCTBrBYjgRQ17XlcQB15q4gMg==",
+ "license": "MIT"
+ },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -32689,9 +32695,9 @@
"license": "BSD-3-Clause"
},
"node_modules/scratch-webpack-configuration": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/scratch-webpack-configuration/-/scratch-webpack-configuration-3.0.0.tgz",
- "integrity": "sha512-UIp4Jd5YdJTrEEpNWfz+otFRbkAAgmFCJdILlEGyyDZlr16/auoDCk69Y26qbYXcVg11k+gJqCfEp8MtO1rANA==",
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/scratch-webpack-configuration/-/scratch-webpack-configuration-3.1.0.tgz",
+ "integrity": "sha512-7hdjePBCaoFMmsWeRYqmb237OfFTHNEDgsf4q7a4g4cy9A1yL4QxQbJegUJj330ZnymOX1MRZbFNZzy1EUYJSQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -39726,6 +39732,7 @@
"core-js": "2.6.12",
"css-loader": "5.2.7",
"dapjs": "2.3.0",
+ "driver.js": "1.3.6",
"es6-object-assign": "1.1.0",
"fastestsmallesttextencoderdecoder": "1.0.22",
"get-float-time-domain-data": "0.1.0",
@@ -39810,7 +39817,7 @@
"redux-mock-store": "1.5.5",
"rimraf": "2.7.1",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"selenium-webdriver": "3.6.0",
"semantic-release": "19.0.5",
"stream-browserify": "3.0.0",
@@ -40339,7 +40346,7 @@
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
"scratch-storage": "4.0.201",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"semantic-release": "19.0.5",
"tap": "16.3.10",
"terser-webpack-plugin": "5.3.14",
@@ -40506,7 +40513,7 @@
"rimraf": "3.0.2",
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"semantic-release": "19.0.5",
"tap": "16.3.10",
"webpack": "5.101.0",
@@ -40602,7 +40609,7 @@
"scratch-l10n": "6.0.13",
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"script-loader": "0.7.2",
"semantic-release": "19.0.5",
"stats.js": "0.17.0",
diff --git a/packages/scratch-gui/package.json b/packages/scratch-gui/package.json
index 65da82a5e3..6f583043a2 100644
--- a/packages/scratch-gui/package.json
+++ b/packages/scratch-gui/package.json
@@ -77,6 +77,7 @@
"core-js": "2.6.12",
"css-loader": "5.2.7",
"dapjs": "2.3.0",
+ "driver.js": "1.3.6",
"es6-object-assign": "1.1.0",
"fastestsmallesttextencoderdecoder": "1.0.22",
"get-float-time-domain-data": "0.1.0",
@@ -167,7 +168,7 @@
"redux-mock-store": "1.5.5",
"rimraf": "2.7.1",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"selenium-webdriver": "3.6.0",
"semantic-release": "19.0.5",
"stream-browserify": "3.0.0",
diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.css b/packages/scratch-gui/src/components/extension-button/extension-button.css
new file mode 100644
index 0000000000..7c8f5b2d41
--- /dev/null
+++ b/packages/scratch-gui/src/components/extension-button/extension-button.css
@@ -0,0 +1,82 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+@import "../../css/z-index.css";
+
+.extension-button-container {
+ width: 3.75rem;
+ height: 3.25rem;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: $z-index-extension-button;
+ background: $looks-secondary;
+
+ border: 1px solid $looks-secondary;
+ box-sizing: content-box; /* To match scratch-block vertical toolbox borders */
+}
+
+$fade-out-distance: 15px;
+
+.extension-button-container:before {
+ content: "";
+ position: absolute;
+ top: calc(calc(-1 * $fade-out-distance) - 1px);
+ left: -1px;
+ background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15));
+ height: $fade-out-distance;
+ width: calc(100% + 0.5px);
+}
+
+
+.extension-button {
+ background: none;
+ border: none;
+ outline: none;
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+ --radiate-color: 133, 92, 214; /* $looks-secondary */
+}
+
+.extension-button-icon {
+ width: 1.75rem;
+ height: 1.75rem;
+}
+
+[dir="rtl"] .extension-button-icon {
+ transform: scaleX(-1);
+}
+
+.extension-button > div {
+ margin-top: 0;
+}
+
+$radiate-distance: 20px;
+
+.radiate:before,
+.radiate:after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ z-index: -1;
+ animation: radiate 2.5s infinite;
+ clip-path: inset(-$radiate-distance -$radiate-distance 0 0);
+}
+
+.radiate:after {
+ animation-delay: 0.7s;
+}
+
+@keyframes radiate {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(var(--radiate-color), 0.7);
+ }
+ 100% {
+ box-shadow: 0 0 0 $radiate-distance rgba(var(--radiate-color), 0);
+ }
+}
diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx
new file mode 100644
index 0000000000..e1ca42bf78
--- /dev/null
+++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx
@@ -0,0 +1,170 @@
+import React, {useEffect, useCallback, useState, useRef} from 'react';
+import classNames from 'classnames';
+// eslint-disable-next-line import/no-unresolved
+import {driver} from 'driver.js';
+import 'driver.js/dist/driver.css';
+import {defineMessages, injectIntl, intlShape} from 'react-intl';
+import PropTypes from 'prop-types';
+
+import Box from '../box/box.jsx';
+import {BLOCKS_TAB_INDEX} from '../../reducers/editor-tab';
+import {getLocalStorageValue, setLocalStorageValue} from '../../lib/local-storage.js';
+import addExtensionIcon from '../gui/icon--extensions.svg';
+import styles from './extension-button.css';
+import './extension-button.raw.css';
+
+const messages = defineMessages({
+ addExtension: {
+ id: 'gui.gui.addExtension',
+ description: 'Button to add an extension in the target pane',
+ defaultMessage: 'Add Extension'
+ },
+ faceSensingCalloutTitle: {
+ id: 'gui.gui.faceSensingCalloutTitle',
+ description: 'Hey there! \u{1F44B}',
+ defaultMessage: 'Hey there! \u{1F44B}'
+ },
+ faceSensingCalloutDescription: {
+ id: 'gui.gui.faceSensingCalloutDescription',
+ description: 'There is a new extension!',
+ defaultMessage: 'There is a new extension!'
+ }
+});
+
+const localStorageAvailable =
+ 'localStorage' in window && window.localStorage !== null;
+
+// Default to true to make sure we don't end up showing the feature
+// callouts multiple times if localStorage isn't available.
+const hasIntroducedFaceSensing = (username = 'guest') => {
+ if (!localStorageAvailable) return true;
+ return getLocalStorageValue('hasIntroducedFaceSensing', username) === true;
+};
+
+const setHasIntroducedFaceSensing = (username = 'guest') => {
+ if (!localStorageAvailable) return;
+ setLocalStorageValue('hasIntroducedFaceSensing', username, true);
+};
+
+const hasUsedFaceSensing = (username = 'guest') => {
+ if (!localStorageAvailable) return true;
+ return getLocalStorageValue('hasUsedFaceSensing', username) === true;
+};
+
+const ExtensionButton = props => {
+ const {
+ activeTabIndex,
+ intl,
+ showNewFeatureCallouts,
+ onExtensionButtonClick,
+ username
+ } = props;
+
+ const driverRef = useRef(null);
+ // Keep in a state to avoid reads from localStorage on every render.
+ const [shouldShowFaceSensingCallouts, setShouldShowFaceSensingCallouts] =
+ useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username) && !hasUsedFaceSensing(username));
+ const [clicked, setClicked] = useState(false);
+
+ useEffect(() => {
+ if (!shouldShowFaceSensingCallouts) return;
+
+ const onFirstClick = () => {
+ const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]');
+ if (!isExtensionButtonVisible) return;
+
+ const tooltip = driver({
+ allowClose: false,
+ allowInteraction: true,
+ overlayColor: 'transparent',
+ popoverOffset: -3,
+ steps: [{
+ element: 'div[class*="extension-button-container"]',
+ popover: {
+ title: intl.formatMessage(messages.faceSensingCalloutTitle),
+ description: intl.formatMessage(messages.faceSensingCalloutDescription),
+ side: 'right',
+ align: 'center',
+ popoverClass: 'tooltip-face-sensing',
+ showButtons: []
+ }
+ }]
+ });
+ setClicked(true);
+ driverRef.current = tooltip;
+ tooltip.drive();
+ };
+ window.addEventListener('click', onFirstClick, {once: true});
+
+ return () => {
+ if (driverRef.current) {
+ driverRef.current.destroy();
+ driverRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!driverRef.current) return;
+
+ if (!shouldShowFaceSensingCallouts && driverRef.current) {
+ driverRef.current.destroy();
+ }
+
+ if (!shouldShowFaceSensingCallouts || !clicked) return;
+
+ const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]');
+
+ if (!isExtensionButtonVisible || activeTabIndex !== BLOCKS_TAB_INDEX) {
+ driverRef.current.destroy();
+ }
+
+ if (isExtensionButtonVisible && activeTabIndex === BLOCKS_TAB_INDEX) {
+ driverRef.current.drive();
+ }
+ }, [shouldShowFaceSensingCallouts, activeTabIndex, clicked]);
+
+ const handleExtensionButtonClick = useCallback(() => {
+ if (driverRef.current) {
+ driverRef.current.destroy();
+ driverRef.current = null;
+ }
+
+ if (shouldShowFaceSensingCallouts) {
+ setHasIntroducedFaceSensing(username);
+ setShouldShowFaceSensingCallouts(false);
+ }
+ onExtensionButtonClick?.();
+ }, [shouldShowFaceSensingCallouts]);
+
+ return (
+
+
+
+ );
+};
+
+ExtensionButton.propTypes = {
+ activeTabIndex: PropTypes.number,
+ intl: intlShape.isRequired,
+ onExtensionButtonClick: PropTypes.func,
+ showNewFeatureCallouts: PropTypes.bool,
+ username: PropTypes.string
+};
+
+const ExtensionButtonIntl = injectIntl(ExtensionButton);
+
+export default ExtensionButtonIntl;
diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.raw.css b/packages/scratch-gui/src/components/extension-button/extension-button.raw.css
new file mode 100644
index 0000000000..ae8b49916c
--- /dev/null
+++ b/packages/scratch-gui/src/components/extension-button/extension-button.raw.css
@@ -0,0 +1,50 @@
+@import "../../css/units.css";
+@import "../../css/colors.css";
+@import "../../css/z-index.css";
+
+/* Make sure driver.js doesn't block interactions with page elements */
+.driver-active * {
+ pointer-events: revert;
+}
+
+.driver-active .driver-overlay {
+ pointer-events: none !important;
+}
+
+.driver-active:has(.tooltip-face-sensing) > .driver-overlay {
+ visibility: hidden;
+}
+
+/* Fallback, if :has is not supported */
+.tooltip-face-sensing ~ .driver-overlay {
+ visibility: hidden;
+}
+
+.driver-popover.tooltip-face-sensing {
+ padding: 1rem;
+ background-color: $looks-secondary;
+ color: $ui-white;
+ z-index: 100;
+ min-width: 12rem;
+ height: 5rem;
+ border-radius: 0.5rem;
+ border: 1px solid $looks-secondary;
+ transform: translate(0, -0.8rem);
+}
+
+.driver-popover.tooltip-face-sensing .driver-popover-title {
+ font-weight: 700;
+ line-height: 1.25rem;
+ font-size: 0.875rem;
+}
+
+.driver-popover.tooltip-face-sensing .driver-popover-description {
+ font-weight: 400;
+ line-height: 1.25rem;
+ font-size: 0.875rem;
+}
+
+.driver-popover.tooltip-face-sensing .driver-popover-arrow-side-right {
+ border-right-color: $looks-secondary;
+ border-width: 0.5rem;
+}
diff --git a/packages/scratch-gui/src/components/gui/gui.css b/packages/scratch-gui/src/components/gui/gui.css
index 2002e5bb2a..d502bfcd46 100644
--- a/packages/scratch-gui/src/components/gui/gui.css
+++ b/packages/scratch-gui/src/components/gui/gui.css
@@ -228,55 +228,6 @@
/* overflow: hidden; */
}
-.extension-button-container {
- width: 3.75rem;
- height: 3.25rem;
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: $z-index-extension-button;
- background: $looks-secondary;
-
- border: 1px solid $looks-secondary;
- box-sizing: content-box; /* To match scratch-block vertical toolbox borders */
-}
-
-$fade-out-distance: 15px;
-
-.extension-button-container:before {
- content: "";
- position: absolute;
- top: calc(calc(-1 * $fade-out-distance) - 1px);
- left: -1px;
- background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15));
- height: $fade-out-distance;
- width: calc(100% + 0.5px);
-}
-
-
-.extension-button {
- background: none;
- border: none;
- outline: none;
- width: 100%;
- height: 100%;
- cursor: pointer;
-}
-
-.extension-button-icon {
- width: 1.75rem;
- height: 1.75rem;
-}
-
-[dir="rtl"] .extension-button-icon {
- transform: scaleX(-1);
-}
-
-.extension-button > div {
- margin-top: 0;
-}
-
/* Sprite Selection Watermark */
.watermark {
position: absolute;
diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx
index 171ef06cca..14a1e76979 100644
--- a/packages/scratch-gui/src/components/gui/gui.jsx
+++ b/packages/scratch-gui/src/components/gui/gui.jsx
@@ -2,7 +2,7 @@ import classNames from 'classnames';
import omit from 'lodash.omit';
import PropTypes from 'prop-types';
import React, {useEffect, useCallback} from 'react';
-import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl';
+import {FormattedMessage, injectIntl, intlShape} from 'react-intl';
import {connect} from 'react-redux';
import MediaQuery from 'react-responsive';
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
@@ -23,6 +23,7 @@ import BackdropLibrary from '../../containers/backdrop-library.jsx';
import Watermark from '../../containers/watermark.jsx';
import Backpack from '../../containers/backpack.jsx';
+import ExtensionsButton from '../extension-button/extension-button.jsx';
import WebGlModal from '../../containers/webgl-modal.jsx';
import TipsLibrary from '../../containers/tips-library.jsx';
import Cards from '../../containers/cards.jsx';
@@ -37,7 +38,6 @@ import {themeMap} from '../../lib/themes';
import {AccountMenuOptionsPropTypes} from '../../lib/account-menu-options';
import styles from './gui.css';
-import addExtensionIcon from './icon--extensions.svg';
import codeIcon from './icon--code.svg';
import costumesIcon from './icon--costumes.svg';
import soundsIcon from './icon--sounds.svg';
@@ -45,14 +45,6 @@ import DebugModal from '../debug-modal/debug-modal.jsx';
import {setPlatform} from '../../reducers/platform.js';
import {PLATFORM} from '../../lib/platform.js';
-const messages = defineMessages({
- addExtension: {
- id: 'gui.gui.addExtension',
- description: 'Button to add an extension in the target pane',
- defaultMessage: 'Add Extension'
- }
-});
-
// Cache this value to only retrieve it once the first time.
// Assume that it doesn't change for a session.
let isRendererSupported = null;
@@ -131,6 +123,7 @@ const GUIComponent = props => {
onTelemetryModalOptOut,
onUpdateProjectThumbnail,
showComingSoon,
+ showNewFeatureCallouts,
soundsTabVisible,
stageSizeMode,
targetIsStage,
@@ -364,21 +357,17 @@ const GUIComponent = props => {
stageSize={stageSize}
theme={theme}
vm={vm}
+ showNewFeatureCallouts={showNewFeatureCallouts}
+ username={username}
/>
-
-
-
+
@@ -493,6 +482,7 @@ GUIComponent.propTypes = {
platform: PropTypes.oneOf(Object.keys(PLATFORM)),
renderLogin: PropTypes.func,
showComingSoon: PropTypes.bool,
+ showNewFeatureCallouts: PropTypes.bool,
soundsTabVisible: PropTypes.bool,
stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)),
setPlatform: PropTypes.func,
@@ -528,6 +518,7 @@ GUIComponent.defaultProps = {
isTotallyNormal: false,
loading: false,
showComingSoon: false,
+ showNewFeatureCallouts: false,
stageSizeMode: STAGE_SIZE_MODES.large,
useExternalPeripheralList: false
};
diff --git a/packages/scratch-gui/src/components/library-item/library-item.css b/packages/scratch-gui/src/components/library-item/library-item.css
index 1a7d15933a..3ebac24ee6 100644
--- a/packages/scratch-gui/src/components/library-item/library-item.css
+++ b/packages/scratch-gui/src/components/library-item/library-item.css
@@ -25,6 +25,7 @@
.library-item-extension {
align-self: stretch;
+ --radiate-color: 133, 92, 214; /* $looks-secondary */
}
.library-item:hover {
@@ -45,6 +46,13 @@
border-color: $ui-black-transparent;
}
+.content-wrapper {
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ border-radius: $space;
+}
+
.library-item-image-container-wrapper {
height: 100px;
width: 100%;
@@ -107,7 +115,6 @@
flex-basis: 300px;
max-width: 300px;
height: auto;
- overflow: hidden;
padding: 0;
}
@@ -203,3 +210,32 @@
[dir="rtl"] .coming-soon-text {
transform: translate(calc(-2 * $space), calc(2 * $space));
}
+
+$radiate-distance: 20px;
+
+.radiate:before,
+.radiate:after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ z-index: 0;
+ animation: radiate 2.5s infinite;
+ border-radius: $space;
+}
+
+.radiate:after {
+ animation-delay: 0.7s;
+}
+
+@keyframes radiate {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(var(--radiate-color), 0.7);
+ }
+ 100% {
+ box-shadow: 0 0 0 $radiate-distance rgba(var(--radiate-color), 0);
+ }
+}
\ No newline at end of file
diff --git a/packages/scratch-gui/src/components/library-item/library-item.jsx b/packages/scratch-gui/src/components/library-item/library-item.jsx
index bd61410aca..f094228977 100644
--- a/packages/scratch-gui/src/components/library-item/library-item.jsx
+++ b/packages/scratch-gui/src/components/library-item/library-item.jsx
@@ -7,6 +7,7 @@ import Box from '../box/box.jsx';
import ScratchImage from '../scratch-image/scratch-image.jsx';
import PlayButton from '../../containers/play-button.jsx';
import styles from './library-item.css';
+import './library-item.raw.css';
import classNames from 'classnames';
import bluetoothIconURL from './bluetooth.svg';
@@ -46,6 +47,7 @@ class LibraryItemComponent extends React.PureComponent {
render () {
return this.props.featured ? (
-
- {this.props.disabled ? (
-
-
+
+ {this.props.disabled ? (
+
+
+
+ ) : null}
+ {this.props.iconSource ? (
+ this.renderImage(styles.featuredImage, this.props.iconSource)
+ ) : null}
+
+ {this.props.insetIconURL ? (
+
+
) : null}
- {this.props.iconSource ? (
- this.renderImage(styles.featuredImage, this.props.iconSource)
- ) : null}
-
- {this.props.insetIconURL ? (
-
-

+
+ {this.props.name}
+
+ {this.props.description}
- ) : null}
-
- {this.props.name}
-
- {this.props.description}
-
- {this.props.bluetoothRequired || this.props.internetConnectionRequired || this.props.collaborator ? (
-
-
- {this.props.bluetoothRequired || this.props.internetConnectionRequired ? (
-
-
-
-
-
- {this.props.bluetoothRequired ? (
-

- ) : null}
- {this.props.internetConnectionRequired ? (
-

- ) : null}
-
+ {this.props.bluetoothRequired ||
+ this.props.internetConnectionRequired || this.props.collaborator ? (
+
+
+ {this.props.bluetoothRequired || this.props.internetConnectionRequired ? (
+
+
+
+
+
+ {this.props.bluetoothRequired ? (
+

+ ) : null}
+ {this.props.internetConnectionRequired ? (
+

+ ) : null}
+
+
+ ) : null}
- ) : null}
-
-
- {this.props.collaborator ? (
-
-
-
-
-
- {this.props.collaborator}
-
+
+ {this.props.collaborator ? (
+
+
+
+
+
+ {this.props.collaborator}
+
+
+ ) : null}
- ) : null}
-
-
- ) : null}
+
+ ) : null}
+
) : (
.driver-overlay {
+ visibility: hidden;
+}
+
+/* Fallback, if :has is not supported */
+.tooltip-face-sensing-modal ~ .driver-overlay {
+ visibility: hidden;
+}
+
+.driver-popover.tooltip-face-sensing-modal {
+ padding: 1rem;
+ background-color: $looks-secondary;
+ color: $ui-white;
+ z-index: 1000;
+ min-width: 12rem;
+ max-width: 13rem;
+ border-radius: 0.5rem;
+ border: 1px solid $looks-secondary;
+ transform: translate(0, 1.2rem);
+}
+
+.driver-popover.tooltip-face-sensing-modal .driver-popover-description {
+ font-weight: 400;
+ line-height: 1.25rem;
+ font-size: 0.875rem;
+}
+
+.driver-popover.tooltip-face-sensing-modal .driver-popover-arrow-side-right {
+ border-right-color: $looks-secondary;
+ border-width: 0.5rem;
+}
+
+.driver-popover.tooltip-face-sensing-modal .driver-popover-arrow-side-left {
+ border-left-color: $looks-secondary;
+ border-width: 0.5rem;
+}
+
+.driver-popover.tooltip-face-sensing-modal .driver-popover-arrow-side-bottom {
+ border-bottom-color: $looks-secondary;
+ border-width: 0.5rem;
+}
diff --git a/packages/scratch-gui/src/components/library/library.css b/packages/scratch-gui/src/components/library/library.css
index 1419380c10..2020a26289 100644
--- a/packages/scratch-gui/src/components/library/library.css
+++ b/packages/scratch-gui/src/components/library/library.css
@@ -15,6 +15,11 @@
height: calc(100% - $library-header-height);
}
+/* The selector needs to more specific and marked with !important to override driverjs styles */
+html body .library-scroll-grid {
+ overflow-y: auto !important;
+}
+
.library-scroll-grid.withFilterBar {
height: calc(100% - $library-header-height - $library-filter-bar-height - 2rem);
}
diff --git a/packages/scratch-gui/src/components/library/library.jsx b/packages/scratch-gui/src/components/library/library.jsx
index 2e53a44ceb..4e4d0f4322 100644
--- a/packages/scratch-gui/src/components/library/library.jsx
+++ b/packages/scratch-gui/src/components/library/library.jsx
@@ -3,6 +3,9 @@ import bindAll from 'lodash.bindall';
import PropTypes from 'prop-types';
import React from 'react';
import {defineMessages, injectIntl, intlShape} from 'react-intl';
+// eslint-disable-next-line import/no-unresolved
+import {driver} from 'driver.js';
+import 'driver.js/dist/driver.css';
import LibraryItem from '../../containers/library-item.jsx';
import Modal from '../../containers/modal.jsx';
@@ -12,9 +15,13 @@ import TagButton from '../../containers/tag-button.jsx';
import {legacyConfig} from '../../legacy-config';
import Spinner from '../spinner/spinner.jsx';
import {CATEGORIES} from '../../../src/lib/libraries/decks/index.jsx';
+import {getLocalStorageValue, setLocalStorageValue} from '../../lib/local-storage.js';
import styles from './library.css';
+const localStorageAvailable =
+ 'localStorage' in window && window.localStorage !== null;
+
const messages = defineMessages({
filterPlaceholder: {
id: 'gui.library.filterPlaceholder',
@@ -26,6 +33,12 @@ const messages = defineMessages({
defaultMessage: 'All',
description: 'Label for library tag to revert to all items after filtering by tag.'
},
+ faceSensingModalCallout: {
+ id: 'gui.library.faceSensingCallout',
+ description: 'Description for Face Sensing callout',
+ // eslint-disable-next-line max-len
+ defaultMessage: 'You can now use your face to control your projects, like making a sprite follow wherever your nose goes!'
+ },
// Strings here need to be defined statically
// https://formatjs.io/docs/getting-started/message-declaration/#pre-declaring-using-definemessage-for-later-consumption-less-recommended
[CATEGORIES.gettingStarted]: {
@@ -111,6 +124,18 @@ const getItemIcons = function (item) {
}
};
+// Default to true to make sure we don't end up showing the feature
+// callouts multiple times if localStorage isn't available.
+const hasUsedFaceSensing = (username = 'guest') => {
+ if (!localStorageAvailable) return true;
+ return getLocalStorageValue('hasUsedFaceSensing', username) === true;
+};
+
+const setHasUsedFaceSensing = (username = 'guest') => {
+ if (!localStorageAvailable) return;
+ setLocalStorageValue('hasUsedFaceSensing', username, true);
+};
+
class LibraryComponent extends React.Component {
constructor (props) {
super(props);
@@ -123,14 +148,18 @@ class LibraryComponent extends React.Component {
'handlePlayingEnd',
'handleSelect',
'handleTagClick',
+ 'handleScroll',
'setFilteredDataRef'
]);
this.state = {
playingItem: null,
filterQuery: '',
selectedTag: ALL_TAG.tag,
- loaded: false
+ loaded: false,
+ shouldShowFaceSensingCallout: props.showNewFeatureCallouts && !hasUsedFaceSensing(props.username)
};
+
+ this.driver = null;
}
componentDidMount () {
// Allow the spinner to display before loading the content
@@ -144,11 +173,76 @@ class LibraryComponent extends React.Component {
prevState.selectedTag !== this.state.selectedTag) {
this.scrollToTop();
}
+
+ // We need to create the driver when the content is loaded for the target element to exist
+ if (!prevState.loaded && this.state.loaded && this.state.shouldShowFaceSensingCallout) {
+ const onFirstClick = () => {
+ const isExtensionItemVisible = document.getElementById('faceSensing');
+ if (!isExtensionItemVisible) return;
+
+ const tooltip = driver({
+ allowClose: false,
+ allowInteraction: true,
+ overlayColor: 'transparent',
+ popoverOffset: -2,
+ steps: [{
+ element: 'div[id="faceSensing"]',
+ popover: {
+ description: this.props.intl.formatMessage(messages.faceSensingModalCallout),
+ side: 'left',
+ align: 'start',
+ popoverClass: 'tooltip-face-sensing-modal',
+ showButtons: []
+ }
+ }]
+ });
+
+ this.driver = tooltip;
+ tooltip.drive();
+ };
+
+ window.addEventListener('click', onFirstClick, {once: true});
+ this.filteredDataRef.addEventListener('scroll', this.handleScroll);
+ }
+ }
+ componentWillUnmount () {
+ if (this.driver) {
+ this.driver.destroy();
+ this.driver = null;
+ }
+
+ if (this.animationFrameId) {
+ window.cancelAnimationFrame(this.animationFrameId);
+ }
+
+ this.filteredDataRef.removeEventListener('scroll', this.handleScroll);
+ }
+ handleScroll () {
+ if (this.animationFrameId) return;
+
+ this.animationFrameId = window.requestAnimationFrame(() => {
+ if (this.driver) {
+ this.driver.refresh();
+ }
+
+ this.animationFrameId = null;
+ });
}
handleSelect (id) {
+ const selectedItem = this.getFilteredData().find(item => this.constructKey(item) === id);
+
+ if (this.state.shouldShowFaceSensingCallout && !this.driver && selectedItem.extensionId !== 'faceSensing') {
+ return;
+ }
+ if (this.state.shouldShowFaceSensingCallout && selectedItem.extensionId === 'faceSensing') {
+ setHasUsedFaceSensing(this.props.username);
+ this.setState({
+ shouldShowFaceSensingCallout: false
+ });
+ }
+
this.handleClose();
- this.props.onItemSelected(this.getFilteredData()
- .find(item => this.constructKey(item) === id));
+ this.props.onItemSelected(selectedItem);
}
handleClose () {
this.props.onRequestClose();
@@ -269,6 +363,7 @@ class LibraryComponent extends React.Component {
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
onSelect={this.handleSelect}
+ showItemCallout={this.state.shouldShowFaceSensingCallout && data.extensionId === 'faceSensing'}
/>);
}
renderData (data) {
@@ -395,7 +490,9 @@ LibraryComponent.propTypes = {
setStopHandler: PropTypes.func,
showPlayButton: PropTypes.bool,
tags: PropTypes.arrayOf(PropTypes.shape(TagButton.propTypes)),
- title: PropTypes.string.isRequired
+ title: PropTypes.string.isRequired,
+ username: PropTypes.string,
+ showNewFeatureCallouts: PropTypes.bool
};
LibraryComponent.defaultProps = {
diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx
index 714f306ea5..efd6b0572e 100644
--- a/packages/scratch-gui/src/containers/blocks.jsx
+++ b/packages/scratch-gui/src/containers/blocks.jsx
@@ -602,6 +602,8 @@ class Blocks extends React.Component {
vm={vm}
onCategorySelected={this.handleCategorySelected}
onRequestClose={onRequestCloseExtensionLibrary}
+ showNewFeatureCallouts={this.props.showNewFeatureCallouts}
+ username={this.props.username}
/>
) : null}
{customProceduresVisible ? (
@@ -651,7 +653,9 @@ Blocks.propTypes = {
vm: PropTypes.instanceOf(VM).isRequired,
workspaceMetrics: PropTypes.shape({
targets: PropTypes.objectOf(PropTypes.object)
- })
+ }),
+ showNewFeatureCallouts: PropTypes.bool,
+ username: PropTypes.string
};
Blocks.defaultOptions = {
diff --git a/packages/scratch-gui/src/containers/extension-library.jsx b/packages/scratch-gui/src/containers/extension-library.jsx
index f76a7de7b7..cb542001b9 100644
--- a/packages/scratch-gui/src/containers/extension-library.jsx
+++ b/packages/scratch-gui/src/containers/extension-library.jsx
@@ -60,6 +60,8 @@ class ExtensionLibrary extends React.PureComponent {
visible={this.props.visible}
onItemSelected={this.handleItemSelect}
onRequestClose={this.props.onRequestClose}
+ showNewFeatureCallouts={this.props.showNewFeatureCallouts}
+ username={this.props.username}
/>
);
}
@@ -70,7 +72,9 @@ ExtensionLibrary.propTypes = {
onCategorySelected: PropTypes.func,
onRequestClose: PropTypes.func,
visible: PropTypes.bool,
- vm: PropTypes.instanceOf(VM).isRequired // eslint-disable-line react/no-unused-prop-types
+ vm: PropTypes.instanceOf(VM).isRequired, // eslint-disable-line react/no-unused-prop-types
+ username: PropTypes.string,
+ showNewFeatureCallouts: PropTypes.bool
};
export default injectIntl(ExtensionLibrary);
diff --git a/packages/scratch-gui/src/containers/gui.jsx b/packages/scratch-gui/src/containers/gui.jsx
index c78fafc640..9eb9ac011d 100644
--- a/packages/scratch-gui/src/containers/gui.jsx
+++ b/packages/scratch-gui/src/containers/gui.jsx
@@ -123,6 +123,7 @@ GUI.propTypes = {
isShowingProject: PropTypes.bool,
isTotallyNormal: PropTypes.bool,
loadingStateVisible: PropTypes.bool,
+ manuallySaveThumbnails: PropTypes.bool,
onProjectLoaded: PropTypes.func,
onSeeCommunity: PropTypes.func,
onStorageInit: PropTypes.func,
@@ -130,6 +131,10 @@ GUI.propTypes = {
onVmInit: PropTypes.func,
platform: PropTypes.oneOf(Object.keys(PLATFORM)),
setPlatform: PropTypes.func.isRequired,
+ /**
+ * Whether to highlight new editor features in the UI.
+ */
+ showNewFeatureCallouts: PropTypes.bool,
projectHost: PropTypes.string,
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
shouldStopProject: PropTypes.bool,
diff --git a/packages/scratch-gui/src/containers/library-item.jsx b/packages/scratch-gui/src/containers/library-item.jsx
index c07e101490..ed0439f081 100644
--- a/packages/scratch-gui/src/containers/library-item.jsx
+++ b/packages/scratch-gui/src/containers/library-item.jsx
@@ -135,6 +135,7 @@ class LibraryItem extends React.PureComponent {
onMouseLeave={this.handleMouseLeave}
onPlay={this.handlePlay}
onStop={this.handleStop}
+ showItemCallout={this.props.showItemCallout}
/>
);
}
@@ -171,7 +172,8 @@ LibraryItem.propTypes = {
onMouseLeave: PropTypes.func.isRequired,
onSelect: PropTypes.func.isRequired,
platform: PropTypes.oneOf(Object.keys(PLATFORM)),
- showPlayButton: PropTypes.bool
+ showPlayButton: PropTypes.bool,
+ showItemCallout: PropTypes.bool
};
export default compose(
diff --git a/packages/scratch-gui/src/lib/local-storage.js b/packages/scratch-gui/src/lib/local-storage.js
new file mode 100644
index 0000000000..0ab82e0a1a
--- /dev/null
+++ b/packages/scratch-gui/src/lib/local-storage.js
@@ -0,0 +1,28 @@
+/**
+ * Copied from scratch-www:
+ * Util functions for managing local storage entries as key-value pairs.
+ */
+
+const getMap = key => {
+ try {
+ const raw = localStorage.getItem(key);
+ return raw ? JSON.parse(raw) : {};
+ } catch (e) {
+ return {};
+ }
+};
+
+const setMap = (key, map) => {
+ localStorage.setItem(key, JSON.stringify(map));
+};
+
+export const getLocalStorageValue = (key, id) => {
+ const map = getMap(key);
+ return map[id];
+};
+
+export const setLocalStorageValue = (key, id, value) => {
+ const map = getMap(key);
+ map[id] = value;
+ setMap(key, map);
+};
diff --git a/packages/scratch-gui/webpack.config.js b/packages/scratch-gui/webpack.config.js
index d0310a96d7..34e2eda2e3 100644
--- a/packages/scratch-gui/webpack.config.js
+++ b/packages/scratch-gui/webpack.config.js
@@ -21,12 +21,18 @@ const commonHtmlWebpackPluginOptions = {
gtm_env_auth: process.env.GTM_ENV_AUTH || ''
};
+const cssModuleExceptions = [
+ /\.raw\.css$/, // Allow for overriding CSS classes from libraries
+ /[\\/]driver\.js[\\/].*\.css$/ // driver.js CSS
+];
+
const baseConfig = new ScratchWebpackConfigBuilder(
{
rootPath: path.resolve(__dirname),
enableReact: true,
enableTs: true,
- shouldSplitChunks: false
+ shouldSplitChunks: false,
+ cssModuleExceptions
})
.setTarget('browserslist')
.merge({
diff --git a/packages/scratch-render/package.json b/packages/scratch-render/package.json
index 93a1417f66..15129814db 100644
--- a/packages/scratch-render/package.json
+++ b/packages/scratch-render/package.json
@@ -78,7 +78,7 @@
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
"scratch-storage": "4.0.201",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"semantic-release": "19.0.5",
"tap": "16.3.10",
"terser-webpack-plugin": "5.3.14",
diff --git a/packages/scratch-svg-renderer/package.json b/packages/scratch-svg-renderer/package.json
index aa3cfd4905..7d82aa690d 100644
--- a/packages/scratch-svg-renderer/package.json
+++ b/packages/scratch-svg-renderer/package.json
@@ -63,7 +63,7 @@
"rimraf": "3.0.2",
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"semantic-release": "19.0.5",
"tap": "16.3.10",
"webpack": "5.101.0",
diff --git a/packages/scratch-vm/package.json b/packages/scratch-vm/package.json
index b1184881d5..0456b5998c 100644
--- a/packages/scratch-vm/package.json
+++ b/packages/scratch-vm/package.json
@@ -96,7 +96,7 @@
"scratch-l10n": "6.0.13",
"scratch-render-fonts": "1.0.218",
"scratch-semantic-release-config": "3.0.0",
- "scratch-webpack-configuration": "3.0.0",
+ "scratch-webpack-configuration": "3.1.0",
"script-loader": "0.7.2",
"semantic-release": "19.0.5",
"stats.js": "0.17.0",