From 9df2a36668168ac9e722674505803c8776d1ad5d Mon Sep 17 00:00:00 2001 From: Kaloyan Manolov Date: Fri, 26 Sep 2025 12:14:37 +0300 Subject: [PATCH 1/8] feat: add face sensing callouts to main editor page --- package-lock.json | 7 + packages/scratch-gui/package.json | 1 + .../extension-button/extension-button.css | 82 +++++++++ .../extension-button/extension-button.jsx | 160 ++++++++++++++++++ .../extension-button/extension-button.raw.css | 50 ++++++ .../scratch-gui/src/components/gui/gui.css | 49 ------ .../scratch-gui/src/components/gui/gui.jsx | 35 ++-- packages/scratch-gui/src/containers/gui.jsx | 5 + packages/scratch-gui/src/lib/local-storage.js | 28 +++ packages/scratch-gui/webpack.config.js | 8 +- 10 files changed, 352 insertions(+), 73 deletions(-) create mode 100644 packages/scratch-gui/src/components/extension-button/extension-button.css create mode 100644 packages/scratch-gui/src/components/extension-button/extension-button.jsx create mode 100644 packages/scratch-gui/src/components/extension-button/extension-button.raw.css create mode 100644 packages/scratch-gui/src/lib/local-storage.js diff --git a/package-lock.json b/package-lock.json index 4c3121bb14..832c1d9291 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", @@ -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", diff --git a/packages/scratch-gui/package.json b/packages/scratch-gui/package.json index 65da82a5e3..f25bf2003f 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", 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..f0614ee9ea --- /dev/null +++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx @@ -0,0 +1,160 @@ +import React, {useEffect, useCallback, useState} 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 ExtensionButton = props => { + const { + activeTabIndex, + intl, + showNewFeatureCallouts, + onExtensionButtonClick, + username + } = props; + + const [tooltipDriver, setTooltipDriver] = useState(null); + // Keep in a state to avoid reads from localStorage on every render. + const [shouldShowFaceSensingCallouts, setShouldShowFaceSensingCallouts] = + useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username)); + + 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: [] + } + }] + }); + setTooltipDriver(tooltip); + tooltip.drive(); + }; + window.addEventListener('click', onFirstClick, {once: true}); + }, []); + + useEffect(() => { + if (!tooltipDriver) return; + + if (!shouldShowFaceSensingCallouts && tooltipDriver) { + tooltipDriver.destroy(); + } + + if (!shouldShowFaceSensingCallouts && !tooltipDriver) return; + + const destroyTooltipIfHidden = () => { + const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]'); + if (tooltipDriver && ( + !isExtensionButtonVisible || + activeTabIndex !== BLOCKS_TAB_INDEX + )) { + tooltipDriver.destroy(); + setTooltipDriver(null); + } + }; + window.addEventListener('click', destroyTooltipIfHidden); + + return () => window.removeEventListener('click', destroyTooltipIfHidden); + }, [tooltipDriver, activeTabIndex]); + + const handleExtensionButtonClick = useCallback(() => { + if (tooltipDriver) { + tooltipDriver.destroy(); + setTooltipDriver(null); + } + + if (shouldShowFaceSensingCallouts) { + setHasIntroducedFaceSensing(username); + setShouldShowFaceSensingCallouts(false); + } + onExtensionButtonClick?.(); + }, [tooltipDriver]); + + 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..73e0f187e2 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, @@ -366,19 +359,13 @@ const GUIComponent = props => { vm={vm} /> - - - + @@ -493,6 +480,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 +516,7 @@ GUIComponent.defaultProps = { isTotallyNormal: false, loading: false, showComingSoon: false, + showNewFeatureCallouts: true /* TODO: change to false */, stageSizeMode: STAGE_SIZE_MODES.large, useExternalPeripheralList: false }; 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/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({ From aff7ccf0602a5b2decfeb01bf4307ac4161444f6 Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Tue, 30 Sep 2025 11:38:20 +0300 Subject: [PATCH 2/8] fix: ui issues with extension button callout --- .../extension-button/extension-button.jsx | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx index f0614ee9ea..70660bf34f 100644 --- a/packages/scratch-gui/src/components/extension-button/extension-button.jsx +++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx @@ -1,4 +1,4 @@ -import React, {useEffect, useCallback, useState} from 'react'; +import React, {useEffect, useCallback, useState, useRef} from 'react'; import classNames from 'classnames'; // eslint-disable-next-line import/no-unresolved import {driver} from 'driver.js'; @@ -55,10 +55,11 @@ const ExtensionButton = props => { username } = props; - const [tooltipDriver, setTooltipDriver] = useState(null); + const driverRef = useRef(null); // Keep in a state to avoid reads from localStorage on every render. const [shouldShowFaceSensingCallouts, setShouldShowFaceSensingCallouts] = useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username)); + const [clicked, setClicked] = useState(false); useEffect(() => { if (!shouldShowFaceSensingCallouts) return; @@ -84,40 +85,48 @@ const ExtensionButton = props => { } }] }); - setTooltipDriver(tooltip); + 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 (!tooltipDriver) return; + if (!driverRef.current) return; - if (!shouldShowFaceSensingCallouts && tooltipDriver) { - tooltipDriver.destroy(); + if (!shouldShowFaceSensingCallouts && driverRef.current) { + driverRef.current.destroy(); } - if (!shouldShowFaceSensingCallouts && !tooltipDriver) return; + if (!shouldShowFaceSensingCallouts || !clicked) return; - const destroyTooltipIfHidden = () => { - const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]'); - if (tooltipDriver && ( - !isExtensionButtonVisible || - activeTabIndex !== BLOCKS_TAB_INDEX - )) { - tooltipDriver.destroy(); - setTooltipDriver(null); - } - }; - window.addEventListener('click', destroyTooltipIfHidden); + const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]'); - return () => window.removeEventListener('click', destroyTooltipIfHidden); - }, [tooltipDriver, activeTabIndex]); + 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 (tooltipDriver) { - tooltipDriver.destroy(); - setTooltipDriver(null); + if (shouldShowFaceSensingCallouts && !driverRef.current) { + return; + } + + if (driverRef.current) { + driverRef.current.destroy(); + driverRef.current = null; } if (shouldShowFaceSensingCallouts) { @@ -125,7 +134,7 @@ const ExtensionButton = props => { setShouldShowFaceSensingCallouts(false); } onExtensionButtonClick?.(); - }, [tooltipDriver]); + }, [shouldShowFaceSensingCallouts]); return ( From 8c96e4b355cc5996be72c02f3df12473e1ab5101 Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Tue, 30 Sep 2025 11:39:46 +0300 Subject: [PATCH 3/8] feat: add callout for the face sensing extension in the extensions modal --- .../scratch-gui/src/components/gui/gui.jsx | 2 + .../components/library-item/library-item.css | 38 ++++- .../components/library-item/library-item.jsx | 151 +++++++++--------- .../library-item/library-item.raw.css | 53 ++++++ .../src/components/library/library.jsx | 85 +++++++++- .../scratch-gui/src/containers/blocks.jsx | 6 +- .../src/containers/extension-library.jsx | 6 +- .../src/containers/library-item.jsx | 4 +- 8 files changed, 265 insertions(+), 80 deletions(-) create mode 100644 packages/scratch-gui/src/components/library-item/library-item.raw.css diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 73e0f187e2..940d05eb9c 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -357,6 +357,8 @@ const GUIComponent = props => { stageSize={stageSize} theme={theme} vm={vm} + showNewFeatureCallouts={showNewFeatureCallouts} + username={username} /> -
- {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; +} + +:not(body):has(>.driver-active-element) { + overflow-y: auto; +} + +.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; +} diff --git a/packages/scratch-gui/src/components/library/library.jsx b/packages/scratch-gui/src/components/library/library.jsx index 2e53a44ceb..d2d4d2df5c 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); @@ -129,8 +154,11 @@ class LibraryComponent extends React.Component { 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 +172,57 @@ 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 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}); + } + } + componentWillUnmount () { + if (this.driver) { + this.driver.destroy(); + this.driver = null; + } } handleSelect (id) { + if (this.state.shouldShowFaceSensingCallout && !this.driver) { + return; + } + + const selectedItem = this.getFilteredData() + .find(item => this.constructKey(item) === id); + + if (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 +343,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 +470,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/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( From 32b1bcd7737cd755109173c959b4f8bf41736bdb Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Tue, 30 Sep 2025 12:13:18 +0300 Subject: [PATCH 4/8] fix: set default value for feature callouts in the editor to false --- packages/scratch-gui/src/components/gui/gui.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 940d05eb9c..14a1e76979 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -518,7 +518,7 @@ GUIComponent.defaultProps = { isTotallyNormal: false, loading: false, showComingSoon: false, - showNewFeatureCallouts: true /* TODO: change to false */, + showNewFeatureCallouts: false, stageSizeMode: STAGE_SIZE_MODES.large, useExternalPeripheralList: false }; From 9c064f983f46ad2e6149aa2a4a529be34439679e Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Tue, 30 Sep 2025 12:26:26 +0300 Subject: [PATCH 5/8] fix: store whether user has used face sensing only when callouts are enabled --- packages/scratch-gui/src/components/library/library.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/components/library/library.jsx b/packages/scratch-gui/src/components/library/library.jsx index d2d4d2df5c..d2b77af126 100644 --- a/packages/scratch-gui/src/components/library/library.jsx +++ b/packages/scratch-gui/src/components/library/library.jsx @@ -214,7 +214,7 @@ class LibraryComponent extends React.Component { const selectedItem = this.getFilteredData() .find(item => this.constructKey(item) === id); - if (selectedItem.extensionId === 'faceSensing') { + if (this.state.shouldShowFaceSensingCallout && selectedItem.extensionId === 'faceSensing') { setHasUsedFaceSensing(this.props.username); this.setState({ shouldShowFaceSensingCallout: false From 4037650fb65041bd2511cfc15928c8ed46089617 Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Tue, 30 Sep 2025 17:54:07 +0300 Subject: [PATCH 6/8] fix: address review points and fix small ui issues --- .../extension-button/extension-button.jsx | 11 ++++--- .../library-item/library-item.raw.css | 4 --- .../src/components/library/library.css | 5 +++ .../src/components/library/library.jsx | 32 +++++++++++++++---- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/scratch-gui/src/components/extension-button/extension-button.jsx b/packages/scratch-gui/src/components/extension-button/extension-button.jsx index 70660bf34f..e1ca42bf78 100644 --- a/packages/scratch-gui/src/components/extension-button/extension-button.jsx +++ b/packages/scratch-gui/src/components/extension-button/extension-button.jsx @@ -46,6 +46,11 @@ const setHasIntroducedFaceSensing = (username = 'guest') => { setLocalStorageValue('hasIntroducedFaceSensing', username, true); }; +const hasUsedFaceSensing = (username = 'guest') => { + if (!localStorageAvailable) return true; + return getLocalStorageValue('hasUsedFaceSensing', username) === true; +}; + const ExtensionButton = props => { const { activeTabIndex, @@ -58,7 +63,7 @@ const ExtensionButton = props => { const driverRef = useRef(null); // Keep in a state to avoid reads from localStorage on every render. const [shouldShowFaceSensingCallouts, setShouldShowFaceSensingCallouts] = - useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username)); + useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username) && !hasUsedFaceSensing(username)); const [clicked, setClicked] = useState(false); useEffect(() => { @@ -120,10 +125,6 @@ const ExtensionButton = props => { }, [shouldShowFaceSensingCallouts, activeTabIndex, clicked]); const handleExtensionButtonClick = useCallback(() => { - if (shouldShowFaceSensingCallouts && !driverRef.current) { - return; - } - if (driverRef.current) { driverRef.current.destroy(); driverRef.current = null; diff --git a/packages/scratch-gui/src/components/library-item/library-item.raw.css b/packages/scratch-gui/src/components/library-item/library-item.raw.css index 2e198d1fb9..e9c6db791a 100644 --- a/packages/scratch-gui/src/components/library-item/library-item.raw.css +++ b/packages/scratch-gui/src/components/library-item/library-item.raw.css @@ -20,10 +20,6 @@ visibility: hidden; } -:not(body):has(>.driver-active-element) { - overflow-y: auto; -} - .driver-popover.tooltip-face-sensing-modal { padding: 1rem; background-color: $looks-secondary; 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 d2b77af126..4e4d0f4322 100644 --- a/packages/scratch-gui/src/components/library/library.jsx +++ b/packages/scratch-gui/src/components/library/library.jsx @@ -148,6 +148,7 @@ class LibraryComponent extends React.Component { 'handlePlayingEnd', 'handleSelect', 'handleTagClick', + 'handleScroll', 'setFilteredDataRef' ]); this.state = { @@ -176,6 +177,9 @@ class LibraryComponent extends React.Component { // 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, @@ -196,8 +200,9 @@ class LibraryComponent extends React.Component { this.driver = tooltip; tooltip.drive(); }; - + window.addEventListener('click', onFirstClick, {once: true}); + this.filteredDataRef.addEventListener('scroll', this.handleScroll); } } componentWillUnmount () { @@ -205,15 +210,30 @@ class LibraryComponent extends React.Component { 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) { - if (this.state.shouldShowFaceSensingCallout && !this.driver) { + const selectedItem = this.getFilteredData().find(item => this.constructKey(item) === id); + + if (this.state.shouldShowFaceSensingCallout && !this.driver && selectedItem.extensionId !== 'faceSensing') { return; } - - const selectedItem = this.getFilteredData() - .find(item => this.constructKey(item) === id); - if (this.state.shouldShowFaceSensingCallout && selectedItem.extensionId === 'faceSensing') { setHasUsedFaceSensing(this.props.username); this.setState({ From c07eb573ecc4a5b53f2975b483b481fb3fc612b6 Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Wed, 1 Oct 2025 13:27:40 +0300 Subject: [PATCH 7/8] chore: update scratch-webpack-configuration version to 3.1.0 --- package-lock.json | 14 +++++++------- packages/scratch-gui/package.json | 2 +- packages/scratch-render/package.json | 2 +- packages/scratch-svg-renderer/package.json | 2 +- packages/scratch-vm/package.json | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 832c1d9291..16d222f5af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32695,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": { @@ -39817,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", @@ -40346,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", @@ -40513,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", @@ -40609,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 f25bf2003f..6f583043a2 100644 --- a/packages/scratch-gui/package.json +++ b/packages/scratch-gui/package.json @@ -168,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-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", From 378bdf107efc09c2352011921a6a29a1208433ca Mon Sep 17 00:00:00 2001 From: Ayshe Dzhindzhi Date: Wed, 1 Oct 2025 15:16:42 +0300 Subject: [PATCH 8/8] fix: driverjs arrow styling --- .../src/components/library-item/library-item.raw.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/scratch-gui/src/components/library-item/library-item.raw.css b/packages/scratch-gui/src/components/library-item/library-item.raw.css index e9c6db791a..7a33c5a46c 100644 --- a/packages/scratch-gui/src/components/library-item/library-item.raw.css +++ b/packages/scratch-gui/src/components/library-item/library-item.raw.css @@ -47,3 +47,8 @@ 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; +}