diff --git a/packages/scratch-gui/src/components/alerts/alert.css b/packages/scratch-gui/src/components/alerts/alert.css index 3350fbf906f..e869873075b 100644 --- a/packages/scratch-gui/src/components/alerts/alert.css +++ b/packages/scratch-gui/src/components/alerts/alert.css @@ -5,6 +5,7 @@ body .alert { width: 100%; display: flex; + box-sizing: border-box; flex-direction: row; overflow: hidden; justify-content: flex-start; @@ -19,8 +20,8 @@ body .alert { } .alert.warn { - background: #FFF0DF; - border: 1px solid #FF8C1A; + background: $ui-alert-orange; + border: 1px solid $data-primary; box-shadow: 0px 0px 0px 2px rgba(255, 140, 26, 0.25); } @@ -30,6 +31,11 @@ body .alert { box-shadow: 0px 0px 0px 2px $extensions-light; } +.alert.info-blue { + border: 1px solid $motion-primary; + background: $ui-primary; +} + .alert-spinner { self-align: center; } @@ -76,7 +82,7 @@ body .alert { width: 6.5rem; padding: 0.55rem 0.9rem; border-radius: 0.35rem; - background: #FF8C1A; + background: $data-primary; color: white; font-weight: 700; font-size: 0.77rem; diff --git a/packages/scratch-gui/src/components/button/button.jsx b/packages/scratch-gui/src/components/button/button.jsx index eb4a5bdd4e1..e101bddb304 100644 --- a/packages/scratch-gui/src/components/button/button.jsx +++ b/packages/scratch-gui/src/components/button/button.jsx @@ -11,6 +11,7 @@ const ButtonComponent = ({ iconSrc, onClick, children, + componentRef, ...props }) => { @@ -33,6 +34,7 @@ const ButtonComponent = ({ className )} onClick={onClick} + ref={componentRef} {...props} > {icon} @@ -47,7 +49,11 @@ ButtonComponent.propTypes = { disabled: PropTypes.bool, iconClassName: PropTypes.string, iconSrc: PropTypes.string, - onClick: PropTypes.func + onClick: PropTypes.func, + componentRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({current: PropTypes.instanceOf(Element)}) + ]) }; export default ButtonComponent; diff --git a/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.css b/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.css new file mode 100644 index 00000000000..8efd6d15a63 --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.css @@ -0,0 +1,70 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.modal-container { + display: flex; + width: 12.5rem; + padding: 0.75rem; + flex-direction: column; + align-items: flex-start; + border-radius: 0.5rem; + background: $looks-secondary; + gap: 0.9375rem; + box-sizing: border-box; +} + +.label { + align-self: stretch; + color: $ui-white; + text-align: center; + font-family: "Helvetica Neue"; + font-size: 1rem; + font-style: normal; + font-weight: 700; + line-height: 1.5rem; +} + +.button-row { + font-weight: bolder; + display: flex; + gap: 0.5rem; + width: 100%; +} + +.button-row button { + all: unset; + display: flex; + padding: 0.5rem 1rem; + justify-content: center; + align-items: center; + gap: 0.5rem; + flex: 1; + border-radius: 2.5rem; + background: inherit; + cursor: pointer; +} + +.button-row button:focus { + outline: auto; +} + +.button-row button span { + color: $ui-white; + font-family: "Helvetica Neue"; + font-size: 0.75rem; + font-style: normal; + font-weight: 700; + line-height: 1rem; +} + +.button-row button.confirm-button { + background: $ui-white; +} + +.button-row button.confirm-button span { + color: $looks-secondary; +} + +.button-row button.cancel-button { + border: 0.0625rem solid $ui-white; +} \ No newline at end of file diff --git a/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.jsx b/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.jsx new file mode 100644 index 00000000000..7c840f796bb --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/confirmation-prompt.jsx @@ -0,0 +1,218 @@ +import React, {useRef, useCallback, useEffect} from 'react'; +import debounce from 'lodash.debounce'; +import PropTypes from 'prop-types'; +import ReactModal from 'react-modal'; +import {defineMessages, FormattedMessage} from 'react-intl'; + +import Box from '../box/box.jsx'; + +import arrowLeftIcon from './icon--arrow-left.svg'; +import arrowRightIcon from './icon--arrow-right.svg'; +import arrowDownIcon from './icon--arrow-down.svg'; +import arrowUpIcon from './icon--arrow-up.svg'; + +import styles from './confirmation-prompt.css'; +import calculatePopupPosition, {PopupAlign, PopupSide} from '../../lib/calculatePopupPosition.js'; + +const messages = defineMessages({ + defaultConfirmLabel: { + defaultMessage: 'yes', + description: 'Label for confirm button in confirmation prompt', + id: 'gui.confirmationPrompt.confirm' + }, + defaultCancelLabel: { + defaultMessage: 'no', + description: 'Label for cancel button in confirmation prompt', + id: 'gui.confirmationPrompt.cancel' + } +}); + +const defaultConfig = { + modalWidth: 200, + spaceForArrow: 16, + counterOffset: 7, + arrowLongSide: 29, + arrowShortSide: 13 +}; + +const SIDE_TO_ARROW_ICON = { + [PopupSide.UP]: arrowDownIcon, + [PopupSide.DOWN]: arrowUpIcon, + [PopupSide.LEFT]: arrowRightIcon, + [PopupSide.RIGHT]: arrowLeftIcon +}; + +const ConfirmationPrompt = ({ + title, + message, + confirmLabel, + cancelLabel, + onConfirm, + onCancel, + isOpen, + relativeElementRef, + side, + align, + config +}) => { + const { + modalWidth, + spaceForArrow, + counterOffset, + arrowLongSide, + arrowShortSide + } = {...defaultConfig, ...config}; + const arrowIcon = SIDE_TO_ARROW_ICON[side]; + + const modalRef = useRef(null); + const [modalPositionValues, setModalPositionValues] = React.useState({}); + const [arrowHeight, arrowWidth] = (side === PopupSide.LEFT || side === PopupSide.RIGHT) ? + [arrowLongSide, arrowShortSide] : [arrowShortSide, arrowLongSide]; + + const updatePosition = useCallback(() => { + if (relativeElementRef.current && modalRef.current) { + const pos = calculatePopupPosition({ + relativeElementRef, + popupRef: modalRef, + side, + align, + popupWidth: modalWidth, + arrowLeftIcon, + arrowRightIcon, + arrowUpIcon, + arrowDownIcon, + spaceForArrow, + counterOffset, + arrowShortSide, + arrowLongSide + }); + setModalPositionValues(pos); + } + }, [ + relativeElementRef, + side, + align, + modalWidth, + spaceForArrow, + counterOffset, + arrowShortSide, + arrowLongSide + ]); + + useEffect(() => { + if (!isOpen) return; + + const debouncedUpdate = debounce(updatePosition, 50, {leading: true}); + + debouncedUpdate(); + + window.addEventListener('resize', debouncedUpdate); + return () => { + window.removeEventListener('resize', debouncedUpdate); + debouncedUpdate.cancel(); + }; + }, [isOpen, updatePosition]); + + const onModalMount = useCallback(el => { + if (!el || !isOpen) return; + modalRef.current = el; + + updatePosition(); + }, [isOpen, updatePosition]); + + return ( + isOpen && ( + + {arrowIcon && ( + + )} + + + {message} + + + + + + + + + + ) + ); +}; + +ConfirmationPrompt.propTypes = { + isOpen: PropTypes.bool.isRequired, + title: PropTypes.string, + message: PropTypes.node.isRequired, + confirmLabel: PropTypes.node, + cancelLabel: PropTypes.node, + onConfirm: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + relativeElementRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}), + side: PropTypes.oneOf(Object.values(PopupSide)).isRequired, + align: PropTypes.oneOf(Object.values(PopupAlign)), + config: PropTypes.shape({ + modalWidth: PropTypes.number, + spaceForArrow: PropTypes.number, + counterOffset: PropTypes.number, + arrowLongSide: PropTypes.number, + arrowShortSide: PropTypes.number + }) +}; + +export default ConfirmationPrompt; diff --git a/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-down.svg b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-down.svg new file mode 100644 index 00000000000..5c932999e6e --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-left.svg b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-left.svg new file mode 100644 index 00000000000..0712a6f88b8 --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-right.svg b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-right.svg new file mode 100644 index 00000000000..cc59340dc3f --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-up.svg b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-up.svg new file mode 100644 index 00000000000..110e25bc789 --- /dev/null +++ b/packages/scratch-gui/src/components/confirmation-prompt/icon--arrow-up.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/scratch-gui/src/components/gui/gui.jsx b/packages/scratch-gui/src/components/gui/gui.jsx index 5bc5f583753..e37e9ca3332 100644 --- a/packages/scratch-gui/src/components/gui/gui.jsx +++ b/packages/scratch-gui/src/components/gui/gui.jsx @@ -259,11 +259,6 @@ const GUIComponent = props => { isRendererSupported={isRendererSupported} isRtl={isRtl} loading={loading} - manuallySaveThumbnails={ - manuallySaveThumbnails && - userOwnsProject - } - onUpdateProjectThumbnail={onUpdateProjectThumbnail} stageSize={STAGE_SIZE_MODES.large} vm={vm} > @@ -545,6 +540,9 @@ const GUIComponent = props => { vm={vm} ariaRole="region" ariaLabel={intl.formatMessage(ariaMessages.stage)} + manuallySaveThumbnails={manuallySaveThumbnails} + userOwnsProject={userOwnsProject} + onUpdateProjectThumbnail={onUpdateProjectThumbnail} /> + + + diff --git a/packages/scratch-gui/src/components/stage-header/stage-header.css b/packages/scratch-gui/src/components/stage-header/stage-header.css index 3b82aae0d7c..65a03140a0d 100644 --- a/packages/scratch-gui/src/components/stage-header/stage-header.css +++ b/packages/scratch-gui/src/components/stage-header/stage-header.css @@ -34,6 +34,7 @@ .stage-size-row { display: flex; + gap: 0.25rem; } .stage-size-toggle-group { @@ -46,14 +47,6 @@ border-radius: $form-radius; } -[dir="ltr"] .stage-size-toggle-group { - margin-right: .2rem; -} - -[dir="rtl"] .stage-size-toggle-group { - margin-left: .2rem; -} - .stage-button { display: block; border: 1px solid $ui-black-transparent; @@ -75,26 +68,19 @@ height: 100%; } +.stage-button-highlighted { + border-radius: 4px; + box-shadow: 0 0 0 2px #4C97FF; +} + [dir="rtl"] .stage-button-icon { transform: scaleX(-1); } -.rightSection { +.right-section { display: flex; flex-direction: row; align-items: center; gap: 0.5rem; background-color: transparent; - - .setThumbnailButton { - padding: 0.625rem 0.75rem; - font-size: 0.75rem; - line-height: 0.875rem; - color: $ui-white; - background-color: $motion-primary; - } - - .setThumbnailButton:active { - filter: brightness(90%); - } } diff --git a/packages/scratch-gui/src/components/stage-header/stage-header.jsx b/packages/scratch-gui/src/components/stage-header/stage-header.jsx index 5c69607fd97..bfd40a3fcd6 100644 --- a/packages/scratch-gui/src/components/stage-header/stage-header.jsx +++ b/packages/scratch-gui/src/components/stage-header/stage-header.jsx @@ -1,7 +1,6 @@ -import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; +import {defineMessages, FormattedMessage, useIntl} from 'react-intl'; import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; -import {connect} from 'react-redux'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import VM from '@scratch/scratch-vm'; import Box from '../box/box.jsx'; @@ -21,6 +20,10 @@ import styles from './stage-header.css'; import {storeProjectThumbnail} from '../../lib/store-project-thumbnail.js'; import dataURItoBlob from '../../lib/data-uri-to-blob.js'; import throttle from 'lodash.throttle'; +import thumbnailIcon from './icon--thumbnail.svg'; +import ConfirmationPrompt from '../confirmation-prompt/confirmation-prompt.jsx'; +import Tooltip from '../tooltip/tooltip.jsx'; +import classNames from 'classnames'; const messages = defineMessages({ largeStageSizeMessage: { @@ -48,6 +51,22 @@ const messages = defineMessages({ description: 'Manually save project thumbnail', id: 'gui.stageHeader.saveThumbnail' }, + setThumbnailMessage: { + defaultMessage: 'Are you sure you want to set your thumbnail?', + description: 'Confirmation message for manually saving project thumbnail', + id: 'gui.stageHeader.saveThumbnailMessage' + }, + thumbnailTooltipTitle: { + defaultMessage: 'Hey there! 👋', + description: 'Title for the thumbnail tooltip', + id: 'gui.stageHeader.thumbnailTooltipTitle' + }, + thumbnailTooltipBody: { + defaultMessage: 'The “{boldText}” has a new spot. The way it works is by ' + + 'taking a snapshot of your canvas and setting it as your project thumbnail.', + description: 'Body text for the thumbnail tooltip', + id: 'gui.stageHeader.thumbnailTooltipBody' + }, fullscreenControl: { defaultMessage: 'Full Screen Control', description: 'Button to enter/exit full screen mode', @@ -69,28 +88,88 @@ const StageHeaderComponent = function (props) { projectId, showBranding, stageSizeMode, - vm + vm, + isProjectLoaded, + userOwnsProject, + onShowSettingThumbnail, + onShowThumbnailSuccess, + onShowThumbnailError } = props; const intl = useIntl(); let header = null; + const thumbnailTooltipId = 'thumbnail-tooltip'; + const thumbnailButtonRef = useRef(null); + + const [isThumbnailPromptOpen, setIsThumbnailPromptOpen] = useState(false); + const [isThumbnailTooltipOpen, setIsThumbnailTooltipOpen] = useState(false); + const [isUpdatingThumbnail, setIsUpdatingThumbnail] = useState(false); + + // To remove - new feature awareness tooltip + useEffect(() => { + if (manuallySaveThumbnails && isProjectLoaded && + userOwnsProject && thumbnailButtonRef.current) { + setIsThumbnailTooltipOpen(true); + } + }, [manuallySaveThumbnails, isProjectLoaded, userOwnsProject]); + const onUpdateThumbnail = useCallback( throttle( - () => { - if (!onUpdateProjectThumbnail) { - return; - } + async () => { + if (!onUpdateProjectThumbnail) return; + + setIsUpdatingThumbnail(true); + onShowSettingThumbnail(); - storeProjectThumbnail(vm, dataURI => { - onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI)); - }); + try { + await storeProjectThumbnail(vm, dataURI => new Promise((resolve, reject) => { + onUpdateProjectThumbnail( + projectId, + dataURItoBlob(dataURI), + resolve, + reject + ); + })); + onShowThumbnailSuccess(); + } catch (e) { + onShowThumbnailError(); + } finally { + setIsUpdatingThumbnail(false); + } }, 3000 ), - [projectId, onUpdateProjectThumbnail] + [ + onUpdateProjectThumbnail, + projectId, + onShowSettingThumbnail, + onShowThumbnailSuccess, + onShowThumbnailError + ] ); + const onThumbnailPromptOpen = useCallback(() => { + setIsThumbnailPromptOpen(true); + }, []); + + const onThumbnailPromptClose = useCallback(() => { + setIsThumbnailPromptOpen(false); + }, []); + + const onUpdateThumbnailAndClose = useCallback(() => { + onThumbnailPromptClose(); + onUpdateThumbnail(); + }, [onUpdateThumbnail]); + + const onOpenTooltip = useCallback(() => { + setIsThumbnailTooltipOpen(true); + }, []); + + const onCloseTooltip = useCallback(() => { + setIsThumbnailTooltipOpen(false); + }, []); + if (isFullScreen) { const stageDimensions = getStageDimensions(null, true); const stageButton = showBranding ? ( @@ -165,17 +244,57 @@ const StageHeaderComponent = function (props) {
+ {/* To remove - new feature awareness tooltip */} + {intl.formatMessage(messages.setThumbnail)} + }} + /> + } + /> + {manuallySaveThumbnails && isProjectLoaded && userOwnsProject && ( + + )} + {stageControls}
- {manuallySaveThumbnails && ( - - )}