Skip to content

Commit 7f483ae

Browse files
authored
Merge pull request #330 from scratchfoundation/feature/uepr-331-implement-face-sensing-callouts
[UEPR-331] Implement face sensing callouts
2 parents 65628f9 + 378bdf1 commit 7f483ae

File tree

21 files changed

+664
-164
lines changed

21 files changed

+664
-164
lines changed

package-lock.json

Lines changed: 14 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/scratch-gui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"core-js": "2.6.12",
7878
"css-loader": "5.2.7",
7979
"dapjs": "2.3.0",
80+
"driver.js": "1.3.6",
8081
"es6-object-assign": "1.1.0",
8182
"fastestsmallesttextencoderdecoder": "1.0.22",
8283
"get-float-time-domain-data": "0.1.0",
@@ -167,7 +168,7 @@
167168
"redux-mock-store": "1.5.5",
168169
"rimraf": "2.7.1",
169170
"scratch-semantic-release-config": "3.0.0",
170-
"scratch-webpack-configuration": "3.0.0",
171+
"scratch-webpack-configuration": "3.1.0",
171172
"selenium-webdriver": "3.6.0",
172173
"semantic-release": "19.0.5",
173174
"stream-browserify": "3.0.0",
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
@import "../../css/units.css";
2+
@import "../../css/colors.css";
3+
@import "../../css/z-index.css";
4+
5+
.extension-button-container {
6+
width: 3.75rem;
7+
height: 3.25rem;
8+
position: absolute;
9+
bottom: 0;
10+
left: 0;
11+
right: 0;
12+
z-index: $z-index-extension-button;
13+
background: $looks-secondary;
14+
15+
border: 1px solid $looks-secondary;
16+
box-sizing: content-box; /* To match scratch-block vertical toolbox borders */
17+
}
18+
19+
$fade-out-distance: 15px;
20+
21+
.extension-button-container:before {
22+
content: "";
23+
position: absolute;
24+
top: calc(calc(-1 * $fade-out-distance) - 1px);
25+
left: -1px;
26+
background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15));
27+
height: $fade-out-distance;
28+
width: calc(100% + 0.5px);
29+
}
30+
31+
32+
.extension-button {
33+
background: none;
34+
border: none;
35+
outline: none;
36+
width: 100%;
37+
height: 100%;
38+
cursor: pointer;
39+
--radiate-color: 133, 92, 214; /* $looks-secondary */
40+
}
41+
42+
.extension-button-icon {
43+
width: 1.75rem;
44+
height: 1.75rem;
45+
}
46+
47+
[dir="rtl"] .extension-button-icon {
48+
transform: scaleX(-1);
49+
}
50+
51+
.extension-button > div {
52+
margin-top: 0;
53+
}
54+
55+
$radiate-distance: 20px;
56+
57+
.radiate:before,
58+
.radiate:after {
59+
content: '';
60+
position: absolute;
61+
top: 0;
62+
left: 0;
63+
width: 100%;
64+
height: 100%;
65+
66+
z-index: -1;
67+
animation: radiate 2.5s infinite;
68+
clip-path: inset(-$radiate-distance -$radiate-distance 0 0);
69+
}
70+
71+
.radiate:after {
72+
animation-delay: 0.7s;
73+
}
74+
75+
@keyframes radiate {
76+
0% {
77+
box-shadow: 0 0 0 0 rgba(var(--radiate-color), 0.7);
78+
}
79+
100% {
80+
box-shadow: 0 0 0 $radiate-distance rgba(var(--radiate-color), 0);
81+
}
82+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, {useEffect, useCallback, useState, useRef} from 'react';
2+
import classNames from 'classnames';
3+
// eslint-disable-next-line import/no-unresolved
4+
import {driver} from 'driver.js';
5+
import 'driver.js/dist/driver.css';
6+
import {defineMessages, injectIntl, intlShape} from 'react-intl';
7+
import PropTypes from 'prop-types';
8+
9+
import Box from '../box/box.jsx';
10+
import {BLOCKS_TAB_INDEX} from '../../reducers/editor-tab';
11+
import {getLocalStorageValue, setLocalStorageValue} from '../../lib/local-storage.js';
12+
import addExtensionIcon from '../gui/icon--extensions.svg';
13+
import styles from './extension-button.css';
14+
import './extension-button.raw.css';
15+
16+
const messages = defineMessages({
17+
addExtension: {
18+
id: 'gui.gui.addExtension',
19+
description: 'Button to add an extension in the target pane',
20+
defaultMessage: 'Add Extension'
21+
},
22+
faceSensingCalloutTitle: {
23+
id: 'gui.gui.faceSensingCalloutTitle',
24+
description: 'Hey there! \u{1F44B}',
25+
defaultMessage: 'Hey there! \u{1F44B}'
26+
},
27+
faceSensingCalloutDescription: {
28+
id: 'gui.gui.faceSensingCalloutDescription',
29+
description: 'There is a new extension!',
30+
defaultMessage: 'There is a new extension!'
31+
}
32+
});
33+
34+
const localStorageAvailable =
35+
'localStorage' in window && window.localStorage !== null;
36+
37+
// Default to true to make sure we don't end up showing the feature
38+
// callouts multiple times if localStorage isn't available.
39+
const hasIntroducedFaceSensing = (username = 'guest') => {
40+
if (!localStorageAvailable) return true;
41+
return getLocalStorageValue('hasIntroducedFaceSensing', username) === true;
42+
};
43+
44+
const setHasIntroducedFaceSensing = (username = 'guest') => {
45+
if (!localStorageAvailable) return;
46+
setLocalStorageValue('hasIntroducedFaceSensing', username, true);
47+
};
48+
49+
const hasUsedFaceSensing = (username = 'guest') => {
50+
if (!localStorageAvailable) return true;
51+
return getLocalStorageValue('hasUsedFaceSensing', username) === true;
52+
};
53+
54+
const ExtensionButton = props => {
55+
const {
56+
activeTabIndex,
57+
intl,
58+
showNewFeatureCallouts,
59+
onExtensionButtonClick,
60+
username
61+
} = props;
62+
63+
const driverRef = useRef(null);
64+
// Keep in a state to avoid reads from localStorage on every render.
65+
const [shouldShowFaceSensingCallouts, setShouldShowFaceSensingCallouts] =
66+
useState(showNewFeatureCallouts && !hasIntroducedFaceSensing(username) && !hasUsedFaceSensing(username));
67+
const [clicked, setClicked] = useState(false);
68+
69+
useEffect(() => {
70+
if (!shouldShowFaceSensingCallouts) return;
71+
72+
const onFirstClick = () => {
73+
const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]');
74+
if (!isExtensionButtonVisible) return;
75+
76+
const tooltip = driver({
77+
allowClose: false,
78+
allowInteraction: true,
79+
overlayColor: 'transparent',
80+
popoverOffset: -3,
81+
steps: [{
82+
element: 'div[class*="extension-button-container"]',
83+
popover: {
84+
title: intl.formatMessage(messages.faceSensingCalloutTitle),
85+
description: intl.formatMessage(messages.faceSensingCalloutDescription),
86+
side: 'right',
87+
align: 'center',
88+
popoverClass: 'tooltip-face-sensing',
89+
showButtons: []
90+
}
91+
}]
92+
});
93+
setClicked(true);
94+
driverRef.current = tooltip;
95+
tooltip.drive();
96+
};
97+
window.addEventListener('click', onFirstClick, {once: true});
98+
99+
return () => {
100+
if (driverRef.current) {
101+
driverRef.current.destroy();
102+
driverRef.current = null;
103+
}
104+
};
105+
}, []);
106+
107+
useEffect(() => {
108+
if (!driverRef.current) return;
109+
110+
if (!shouldShowFaceSensingCallouts && driverRef.current) {
111+
driverRef.current.destroy();
112+
}
113+
114+
if (!shouldShowFaceSensingCallouts || !clicked) return;
115+
116+
const isExtensionButtonVisible = document.querySelector('div[class*="extension-button-container"]');
117+
118+
if (!isExtensionButtonVisible || activeTabIndex !== BLOCKS_TAB_INDEX) {
119+
driverRef.current.destroy();
120+
}
121+
122+
if (isExtensionButtonVisible && activeTabIndex === BLOCKS_TAB_INDEX) {
123+
driverRef.current.drive();
124+
}
125+
}, [shouldShowFaceSensingCallouts, activeTabIndex, clicked]);
126+
127+
const handleExtensionButtonClick = useCallback(() => {
128+
if (driverRef.current) {
129+
driverRef.current.destroy();
130+
driverRef.current = null;
131+
}
132+
133+
if (shouldShowFaceSensingCallouts) {
134+
setHasIntroducedFaceSensing(username);
135+
setShouldShowFaceSensingCallouts(false);
136+
}
137+
onExtensionButtonClick?.();
138+
}, [shouldShowFaceSensingCallouts]);
139+
140+
return (
141+
<Box className={styles.extensionButtonContainer}>
142+
<button
143+
className={
144+
classNames(styles.extensionButton,
145+
shouldShowFaceSensingCallouts && styles.radiate
146+
)}
147+
title={intl.formatMessage(messages.addExtension)}
148+
onClick={handleExtensionButtonClick}
149+
>
150+
<img
151+
className={styles.extensionButtonIcon}
152+
draggable={false}
153+
src={addExtensionIcon}
154+
/>
155+
</button>
156+
</Box>
157+
);
158+
};
159+
160+
ExtensionButton.propTypes = {
161+
activeTabIndex: PropTypes.number,
162+
intl: intlShape.isRequired,
163+
onExtensionButtonClick: PropTypes.func,
164+
showNewFeatureCallouts: PropTypes.bool,
165+
username: PropTypes.string
166+
};
167+
168+
const ExtensionButtonIntl = injectIntl(ExtensionButton);
169+
170+
export default ExtensionButtonIntl;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@import "../../css/units.css";
2+
@import "../../css/colors.css";
3+
@import "../../css/z-index.css";
4+
5+
/* Make sure driver.js doesn't block interactions with page elements */
6+
.driver-active * {
7+
pointer-events: revert;
8+
}
9+
10+
.driver-active .driver-overlay {
11+
pointer-events: none !important;
12+
}
13+
14+
.driver-active:has(.tooltip-face-sensing) > .driver-overlay {
15+
visibility: hidden;
16+
}
17+
18+
/* Fallback, if :has is not supported */
19+
.tooltip-face-sensing ~ .driver-overlay {
20+
visibility: hidden;
21+
}
22+
23+
.driver-popover.tooltip-face-sensing {
24+
padding: 1rem;
25+
background-color: $looks-secondary;
26+
color: $ui-white;
27+
z-index: 100;
28+
min-width: 12rem;
29+
height: 5rem;
30+
border-radius: 0.5rem;
31+
border: 1px solid $looks-secondary;
32+
transform: translate(0, -0.8rem);
33+
}
34+
35+
.driver-popover.tooltip-face-sensing .driver-popover-title {
36+
font-weight: 700;
37+
line-height: 1.25rem;
38+
font-size: 0.875rem;
39+
}
40+
41+
.driver-popover.tooltip-face-sensing .driver-popover-description {
42+
font-weight: 400;
43+
line-height: 1.25rem;
44+
font-size: 0.875rem;
45+
}
46+
47+
.driver-popover.tooltip-face-sensing .driver-popover-arrow-side-right {
48+
border-right-color: $looks-secondary;
49+
border-width: 0.5rem;
50+
}

0 commit comments

Comments
 (0)