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} + + + + + {cancelLabel ?? } + + + + {confirmLabel ?? } + + + + + ) + ); +}; + +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 && ( - - - - )} ({ - projectId: state.scratchGui.projectState.projectId, - // This is the button's mode, as opposed to the actual current state - stageSizeMode: state.scratchGui.stageSize.stageSize -}); - StageHeaderComponent.propTypes = { isFullScreen: PropTypes.bool.isRequired, isPlayerOnly: PropTypes.bool.isRequired, @@ -217,11 +330,17 @@ StageHeaderComponent.propTypes = { projectId: PropTypes.number.isRequired, showBranding: PropTypes.bool.isRequired, stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)), - vm: PropTypes.instanceOf(VM).isRequired + vm: PropTypes.instanceOf(VM).isRequired, + isProjectLoaded: PropTypes.bool, + userOwnsProject: PropTypes.bool, + onShowSettingThumbnail: PropTypes.func, + onShowThumbnailError: PropTypes.func, + onShowThumbnailSuccess: PropTypes.func }; StageHeaderComponent.defaultProps = { - stageSizeMode: STAGE_SIZE_MODES.large + stageSizeMode: STAGE_SIZE_MODES.large, + userOwnsProject: false }; -export default connect(mapStateToProps)(StageHeaderComponent); +export default StageHeaderComponent; diff --git a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx index 6aa93eff203..80572c92d0b 100644 --- a/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx +++ b/packages/scratch-gui/src/components/stage-wrapper/stage-wrapper.jsx @@ -21,6 +21,7 @@ const StageWrapperComponent = function (props) { loading, manuallySaveThumbnails, onUpdateProjectThumbnail, + userOwnsProject, stageSize, vm } = props; @@ -39,6 +40,7 @@ const StageWrapperComponent = function (props) { + + + + + + \ No newline at end of file diff --git a/packages/scratch-gui/src/components/tooltip/icon--arrow-left.svg b/packages/scratch-gui/src/components/tooltip/icon--arrow-left.svg new file mode 100644 index 00000000000..f0a9ff12ae5 --- /dev/null +++ b/packages/scratch-gui/src/components/tooltip/icon--arrow-left.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/packages/scratch-gui/src/components/tooltip/icon--arrow-right.svg b/packages/scratch-gui/src/components/tooltip/icon--arrow-right.svg new file mode 100644 index 00000000000..fc8f745e2d7 --- /dev/null +++ b/packages/scratch-gui/src/components/tooltip/icon--arrow-right.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/packages/scratch-gui/src/components/tooltip/icon--arrow-up.svg b/packages/scratch-gui/src/components/tooltip/icon--arrow-up.svg new file mode 100644 index 00000000000..b11be368003 --- /dev/null +++ b/packages/scratch-gui/src/components/tooltip/icon--arrow-up.svg @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/packages/scratch-gui/src/components/tooltip/tooltip.css b/packages/scratch-gui/src/components/tooltip/tooltip.css new file mode 100644 index 00000000000..024d9d444a2 --- /dev/null +++ b/packages/scratch-gui/src/components/tooltip/tooltip.css @@ -0,0 +1,42 @@ +@import "../../css/colors.css"; + +.tooltip { + display: flex; + flex-direction: column; + width: 21rem; + padding: 1rem; + align-items: flex-start; + gap: -0.0625rem; + + border-radius: 0.5rem; + border: 0.0625rem solid $motion-primary; + background-color: $motion-primary; + opacity: 1 !important; + + /* Basic Shadows/2dp */ + box-shadow: 0 0.25rem 0.375rem 0 $ui-black-transparent-10, 0 0.25rem 1rem 0 $ui-black-transparent; +} + +.tooltip-title { + align-self: stretch; + color: $ui-white; + font-family: "Helvetica Neue"; + font-size: 1rem; + font-style: normal; + font-weight: 700; + line-height: 1.5rem; +} + +.tooltip-body { + align-self: stretch; + color: $ui-white; + font-family: "Helvetica Neue"; + font-size: 0.875rem; + font-style: normal; + font-weight: 400; + line-height: 1.25rem; +} + +.tooltip-arrow { + background-color: transparent; +} \ No newline at end of file diff --git a/packages/scratch-gui/src/components/tooltip/tooltip.jsx b/packages/scratch-gui/src/components/tooltip/tooltip.jsx new file mode 100644 index 00000000000..d203bfdd8af --- /dev/null +++ b/packages/scratch-gui/src/components/tooltip/tooltip.jsx @@ -0,0 +1,233 @@ +import React, {useRef, useEffect, useState, useCallback} from 'react'; +import PropTypes from 'prop-types'; +import styles from './tooltip.css'; +import calculatePopupPosition, {PopupAlign, PopupSide} from '../../lib/calculatePopupPosition'; + +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 Box from '../box/box'; + +const defaultConfig = { + width: 336, + spaceForArrow: 12, + arrowOffsetFromBottom: 2, + counterOffset: 2, + arrowLongSide: 28, + arrowShortSide: 8 +}; + +const SIDE_TO_ARROW_ICON = { + [PopupSide.UP]: arrowDownIcon, + [PopupSide.DOWN]: arrowUpIcon, + [PopupSide.LEFT]: arrowRightIcon, + [PopupSide.RIGHT]: arrowLeftIcon +}; + +const Tooltip = ({ + isOpen, + onRequestClose, + onRequestOpen, + isManualOnly = true, + targetRef, + side, + align, + title, + body, + config +}) => { + const tooltipRef = useRef(null); + const [pos, setPos] = useState({top: 0, left: 0, arrowTop: 0, arrowLeft: 0}); + + const arrowIcon = SIDE_TO_ARROW_ICON[side]; + const { + width, + spaceForArrow, + counterOffset, + arrowOffsetFromBottom, + arrowLongSide, + arrowShortSide + } = {...defaultConfig, ...config}; + const [arrowHeight, arrowWidth] = (side === PopupSide.LEFT || side === PopupSide.RIGHT) ? + [arrowLongSide, arrowShortSide] : [arrowShortSide, arrowLongSide]; + + const updatePosition = useCallback(() => { + if (!targetRef?.current || !tooltipRef.current) return; + const newPos = calculatePopupPosition({ + relativeElementRef: targetRef, + popupRef: tooltipRef, + side, + align, + popupWidth: width, + arrowLeftIcon, + arrowRightIcon, + arrowUpIcon, + arrowDownIcon, + spaceForArrow, + counterOffset, + arrowOffsetFromBottom, + arrowShortSide, + arrowLongSide + }); + setPos(newPos); + }, [ + targetRef, + side, + align, + width, + spaceForArrow, + counterOffset, + arrowOffsetFromBottom, + arrowLongSide, + arrowShortSide + ]); + + // Resize/scroll listeners + useEffect(() => { + if (!isOpen) return; + + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen, updatePosition]); + + // Click outside to close + useEffect(() => { + if (!isOpen || !onRequestClose) return; + + const handleClickOutside = event => { + const isOutsideTooltip = tooltipRef.current && + !tooltipRef.current.contains(event.target); + + if (isOutsideTooltip) { + onRequestClose(); + } + }; + + // The Blockly workspace suppresses compat events like `mouseup`. + // Listen for `pointerup` instead. + document.addEventListener('pointerup', handleClickOutside); + return () => { + document.removeEventListener('pointerup', handleClickOutside); + }; + }, [isOpen, onRequestClose, targetRef]); + + // Simulate hover and focus (normal) tooltip behavior + useEffect(() => { + if (isManualOnly) return; + + const target = targetRef?.current; + if (!target) return; + + const handleMouseEnter = () => { + if (onRequestOpen) onRequestOpen(); + }; + + const handleMouseLeave = () => { + if (onRequestClose) onRequestClose(); + }; + + const handleFocus = () => { + if (onRequestOpen) onRequestOpen(); + }; + + const handleBlur = () => { + if (onRequestClose) onRequestClose(); + }; + + target.addEventListener('mouseenter', handleMouseEnter); + target.addEventListener('mouseleave', handleMouseLeave); + target.addEventListener('focus', handleFocus); + target.addEventListener('blur', handleBlur); + + return () => { + target.removeEventListener('mouseenter', handleMouseEnter); + target.removeEventListener('mouseleave', handleMouseLeave); + target.removeEventListener('focus', handleFocus); + target.removeEventListener('blur', handleBlur); + }; + }, [isManualOnly, onRequestOpen, onRequestClose, targetRef, targetRef?.current]); + + // Update position when isOpen changes + useEffect(() => { + if (isOpen && tooltipRef.current && targetRef?.current) { + updatePosition(); + } + }, [isOpen, targetRef, updatePosition]); + + const onTooltipMount = useCallback(el => { + if (!el || !isOpen) return; + tooltipRef.current = el; + + updatePosition(); + }, [isOpen, updatePosition]); + + if (!isOpen) return null; + + return ( + <> + + + {title} + + + {body} + + + {arrowIcon && ( + + )} + > + ); +}; + +Tooltip.propTypes = { + isOpen: PropTypes.bool.isRequired, + onRequestClose: PropTypes.func, + onRequestOpen: PropTypes.func, + isManualOnly: PropTypes.bool, + targetRef: PropTypes.shape({current: PropTypes.instanceOf(Element)}).isRequired, + side: PropTypes.oneOf(Object.values(PopupSide)).isRequired, + align: PropTypes.oneOf(Object.values(PopupAlign)), + title: PropTypes.node, + body: PropTypes.node.isRequired, + config: PropTypes.shape({ + width: PropTypes.number, + spaceForArrow: PropTypes.number, + arrowOffsetFromBottom: PropTypes.number, + counterOffset: PropTypes.number, + arrowShortSide: PropTypes.number, + arrowLongSide: PropTypes.number + }) +}; + +export default Tooltip; diff --git a/packages/scratch-gui/src/containers/stage-header.jsx b/packages/scratch-gui/src/containers/stage-header.jsx index e5d017b4dbe..3b1df86b122 100644 --- a/packages/scratch-gui/src/containers/stage-header.jsx +++ b/packages/scratch-gui/src/containers/stage-header.jsx @@ -9,7 +9,14 @@ import {setFullScreen} from '../reducers/mode'; import {connect} from 'react-redux'; import StageHeaderComponent from '../components/stage-header/stage-header.jsx'; +import {getIsProjectLoadedWithId} from '../reducers/project-state.js'; +import {showAlertWithTimeout, showStandardAlert} from '../reducers/alerts.js'; +const ALERT_ID = { + settingThumbnail: 'settingThumbnail', + thumbnailSuccess: 'thumbnailSuccess', + thumbnailError: 'thumbnailError' +}; class StageHeader extends React.Component { constructor (props) { @@ -51,18 +58,30 @@ StageHeader.propTypes = { vm: PropTypes.instanceOf(VM).isRequired }; -const mapStateToProps = state => ({ - stageSizeMode: state.scratchGui.stageSize.stageSize, - showBranding: state.scratchGui.mode.showBranding, - isFullScreen: state.scratchGui.mode.isFullScreen, - isPlayerOnly: state.scratchGui.mode.isPlayerOnly -}); +const mapStateToProps = state => { + const projectState = state.scratchGui.projectState; + const loadingState = projectState.loadingState; + + return { + stageSizeMode: state.scratchGui.stageSize.stageSize, + showBranding: state.scratchGui.mode.showBranding, + isFullScreen: state.scratchGui.mode.isFullScreen, + isPlayerOnly: state.scratchGui.mode.isPlayerOnly, + + projectId: projectState.projectId, + isProjectLoaded: getIsProjectLoadedWithId(loadingState) + }; + +}; const mapDispatchToProps = dispatch => ({ onSetStageLarge: () => dispatch(setStageSize(STAGE_SIZE_MODES.large)), onSetStageSmall: () => dispatch(setStageSize(STAGE_SIZE_MODES.small)), onSetStageFull: () => dispatch(setFullScreen(true)), - onSetStageUnFull: () => dispatch(setFullScreen(false)) + onSetStageUnFull: () => dispatch(setFullScreen(false)), + onShowSettingThumbnail: () => dispatch(showStandardAlert(ALERT_ID.settingThumbnail)), + onShowThumbnailSuccess: () => showAlertWithTimeout(dispatch, ALERT_ID.thumbnailSuccess), + onShowThumbnailError: () => showAlertWithTimeout(dispatch, ALERT_ID.thumbnailError) }); export default connect( diff --git a/packages/scratch-gui/src/css/colors.css b/packages/scratch-gui/src/css/colors.css index 73d32a0080b..2dbed3ee72b 100644 --- a/packages/scratch-gui/src/css/colors.css +++ b/packages/scratch-gui/src/css/colors.css @@ -3,6 +3,7 @@ $ui-secondary: hsla(215, 75%, 95%, 1); /* #E9F1FC */ $ui-tertiary: hsla(215, 50%, 90%, 1); /* #D9E3F2 */ $ui-modal-overlay: hsla(215, 100%, 65%, 0.9); /* 90% transparent version of motion-primary */ +$ui-alert-overlay: hsla(215, 100%, 65%, 0.4); /* 40% transparent version of motion-primary */ $ui-white: hsla(0, 100%, 100%, 1); /* #FFFFFF */ $ui-white-dim: hsla(0, 100%, 100%, 0.75); /* 25% transparent version of ui-white */ @@ -16,6 +17,7 @@ $ui-green: hsla(163, 85%, 35%, 1); /* #0DA57A */ $ui-green-2: hsla(163, 85%, 40%, 1); /* #0FBD8C */ $ui-orange: hsla(37, 96%, 55%, 1); /* #FAA51D */ +$ui-alert-orange: hsla(32, 100%, 94%, 1); /* #FFF0DF */ $text-primary: hsla(225, 15%, 40%, 1); /* #575E75 */ $text-primary-transparent: hsla(225, 15%, 40%, 0.75); diff --git a/packages/scratch-gui/src/lib/alerts/index.jsx b/packages/scratch-gui/src/lib/alerts/index.jsx index 06c8a4d8cd9..15d2a7bd9e5 100644 --- a/packages/scratch-gui/src/lib/alerts/index.jsx +++ b/packages/scratch-gui/src/lib/alerts/index.jsx @@ -3,6 +3,7 @@ import {FormattedMessage} from 'react-intl'; import keyMirror from 'keymirror'; import successImage from '../assets/icon--success.svg'; +import failImage from '../assets/icon--error.svg'; const AlertTypes = keyMirror({ STANDARD: null, @@ -12,6 +13,7 @@ const AlertTypes = keyMirror({ const AlertLevels = { SUCCESS: 'success', + INFO_BLUE: 'info-blue', INFO: 'info', WARN: 'warn' }; @@ -229,6 +231,50 @@ const alerts = [ ), iconSpinner: true, level: AlertLevels.SUCCESS + }, + { + alertId: 'settingThumbnail', + alertType: AlertTypes.STANDARD, + clearList: ['settingThumbnail', 'thumbnailSuccess', 'thumbnailError'], + content: ( + + ), + iconSpinner: true, + level: AlertLevels.INFO_BLUE + }, + { + alertId: 'thumbnailSuccess', + alertType: AlertTypes.STANDARD, + clearList: ['settingThumbnail', 'thumbnailSuccess', 'thumbnailError'], + content: ( + + ), + iconURL: successImage, + level: AlertLevels.SUCCESS, + maxDisplaySecs: 5 + }, + { + alertId: 'thumbnailError', + alertType: AlertTypes.STANDARD, + clearList: ['settingThumbnail', 'thumbnailError', 'thumbnailSuccess'], + content: ( + + ), + iconURL: failImage, + level: AlertLevels.WARN, + maxDisplaySecs: 5 } ]; diff --git a/packages/scratch-gui/src/lib/assets/icon--error.svg b/packages/scratch-gui/src/lib/assets/icon--error.svg new file mode 100644 index 00000000000..03bc844b3bc --- /dev/null +++ b/packages/scratch-gui/src/lib/assets/icon--error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scratch-gui/src/lib/calculatePopupPosition.js b/packages/scratch-gui/src/lib/calculatePopupPosition.js new file mode 100644 index 00000000000..6a6ed8ac87d --- /dev/null +++ b/packages/scratch-gui/src/lib/calculatePopupPosition.js @@ -0,0 +1,104 @@ +export const PopupSide = { + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right' +}; + +export const PopupAlign = { + UP: 'up', + DOWN: 'down', + LEFT: 'left', + RIGHT: 'right', + CENTER: 'center' +}; + +const calculatePopupPosition = ({ + relativeElementRef, + popupRef, + side, + align = PopupAlign.CENTER, + popupWidth, + spaceForArrow, + arrowShortSide, + arrowLongSide, + counterOffset = 5, + arrowOffsetFromBottom = 0 +}) => { + const el = relativeElementRef?.current; + const modalEl = popupRef?.current; + if (!el || !modalEl) return {}; + + const modalHeight = popupRef.current.getBoundingClientRect().height; + const [arrowHeight, arrowWidth] = (side === PopupSide.LEFT || side === PopupSide.RIGHT) ? + [arrowLongSide, arrowShortSide] : [arrowShortSide, arrowLongSide]; + const buttonRect = el.getBoundingClientRect(); + + let top = 0; + let left = 0; + let arrowTop = 0; + let arrowLeft = 0; + + switch (side) { + case PopupSide.UP: + top = buttonRect.top - modalHeight - spaceForArrow; + break; + case PopupSide.DOWN: + top = buttonRect.bottom + spaceForArrow; + break; + case PopupSide.LEFT: + left = buttonRect.left - popupWidth - spaceForArrow; + break; + case PopupSide.RIGHT: + left = buttonRect.right + spaceForArrow; + break; + } + + switch (side) { + case PopupSide.UP: + case PopupSide.DOWN: + if (align === PopupAlign.LEFT) { + left = (buttonRect.left + buttonRect.width) - popupWidth + counterOffset; + } else if (align === PopupAlign.RIGHT) { + left = buttonRect.left - counterOffset; + } else { + left = buttonRect.left + ((buttonRect.width - popupWidth) / 2); + } + break; + + case PopupSide.LEFT: + case PopupSide.RIGHT: + if (align === PopupAlign.UP) { + top = (buttonRect.top + buttonRect.height) - modalHeight - counterOffset; + } else if (align === PopupAlign.DOWN) { + top = buttonRect.top - counterOffset; + } else { + top = buttonRect.top + ((buttonRect.height - modalHeight) / 2); + } + break; + } + + // Arrow positioning + switch (side) { + case PopupSide.UP: + arrowTop = buttonRect.top - spaceForArrow - arrowOffsetFromBottom; + arrowLeft = buttonRect.left + ((buttonRect.width - arrowWidth) / 2); + break; + case PopupSide.DOWN: + arrowTop = buttonRect.top + buttonRect.height + spaceForArrow - arrowHeight + arrowOffsetFromBottom; + arrowLeft = buttonRect.left + ((buttonRect.width - arrowWidth) / 2); + break; + case PopupSide.LEFT: + arrowTop = buttonRect.top + ((buttonRect.height - arrowHeight) / 2); + arrowLeft = buttonRect.left - spaceForArrow - arrowOffsetFromBottom; + break; + case PopupSide.RIGHT: + arrowTop = buttonRect.top + ((buttonRect.height - arrowHeight) / 2); + arrowLeft = buttonRect.left + buttonRect.width + spaceForArrow - arrowWidth + arrowOffsetFromBottom; + break; + } + + return {top, left, arrowTop, arrowLeft}; +}; + +export default calculatePopupPosition; diff --git a/packages/scratch-gui/src/lib/store-project-thumbnail.js b/packages/scratch-gui/src/lib/store-project-thumbnail.js index 26efc81a5ce..0d11e28312c 100644 --- a/packages/scratch-gui/src/lib/store-project-thumbnail.js +++ b/packages/scratch-gui/src/lib/store-project-thumbnail.js @@ -1,20 +1,24 @@ import log from './log'; -export const storeProjectThumbnail = (vm, callback) => { +export const storeProjectThumbnail = async (vm, callback) => { try { - getProjectThumbnail(vm, callback); + await getProjectThumbnail(vm, callback); } catch (e) { log.error('Project thumbnail save error', e); - // This is intentionally fire/forget because a failure - // to save the thumbnail is not vitally important to the user. + throw e; // re-throw so it is handled by alert } }; -export const getProjectThumbnail = (vm, callback) => { +export const getProjectThumbnail = (vm, callback) => new Promise((resolve, reject) => { vm.postIOData('video', {forceTransparentPreview: true}); - vm.renderer.requestSnapshot(dataURI => { + vm.renderer.requestSnapshot(async dataURI => { vm.postIOData('video', {forceTransparentPreview: false}); - callback(dataURI); + try { + await callback(dataURI); + resolve(); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } }); vm.renderer.draw(); -}; +}); diff --git a/packages/scratch-gui/src/reducers/project-state.js b/packages/scratch-gui/src/reducers/project-state.js index 76955d589d5..3e2981929c7 100644 --- a/packages/scratch-gui/src/reducers/project-state.js +++ b/packages/scratch-gui/src/reducers/project-state.js @@ -100,6 +100,10 @@ const getIsShowingWithId = loadingState => ( const getIsShowingWithoutId = loadingState => ( loadingState === LoadingState.SHOWING_WITHOUT_ID ); +const getIsProjectLoadedWithId = loadingState => ( + getIsShowingWithId(loadingState) || + getIsUpdating(loadingState) +); const getIsError = loadingState => ( loadingState === LoadingState.ERROR ); @@ -528,6 +532,7 @@ export { getIsShowingProject, getIsShowingWithId, getIsShowingWithoutId, + getIsProjectLoadedWithId, getIsUpdating, manualUpdateProject, onFetchedProjectData,