From e62a6ab83a4f3edb4bdd0deaee1df59d409663f5 Mon Sep 17 00:00:00 2001 From: Dan Fessler Date: Tue, 26 Nov 2024 04:28:20 -0800 Subject: [PATCH 1/3] resizable stage no code changes --- src/components/box/resizeableBox.jsx | 107 ++++++++++++++ src/components/gui/gui.css | 3 - src/components/gui/gui.jsx | 49 ++++++- src/components/monitor-list/monitor-list.jsx | 25 +++- src/components/monitor/monitor.jsx | 4 +- src/components/sprite-info/sprite-info.css | 5 + src/components/sprite-info/sprite-info.jsx | 145 ++++++++++--------- src/components/stage-header/stage-header.jsx | 4 +- src/components/stage/stage.css | 31 ++++ src/components/stage/stage.jsx | 64 ++++---- src/containers/monitor.jsx | 4 +- src/containers/stage.jsx | 3 + src/lib/screen-utils.js | 90 ++++++------ 13 files changed, 372 insertions(+), 162 deletions(-) create mode 100644 src/components/box/resizeableBox.jsx diff --git a/src/components/box/resizeableBox.jsx b/src/components/box/resizeableBox.jsx new file mode 100644 index 00000000000..31eccf7c58d --- /dev/null +++ b/src/components/box/resizeableBox.jsx @@ -0,0 +1,107 @@ +import PropTypes from 'prop-types'; +import React, {useEffect, useRef, useState, useCallback} from 'react'; + +import Box from './box.jsx'; + +const useMouseDrag = (ref, onDrag, onDragEnd) => { + useEffect(() => { + if (!onDrag) return; + let isDragging = false; + const onMouseMove = e => { + e.preventDefault(); + if (!isDragging) return; + if (onDrag) onDrag(e); + }; + const onMouseUp = e => { + isDragging = false; + if (onDragEnd) onDragEnd(e); + }; + const onMouseDown = e => { + e.preventDefault(); + isDragging = true; + }; + ref.current.addEventListener('mousedown', onMouseDown); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + return () => { + ref.current.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [onDrag]); +}; + + +const ResizableBox = props => { + const { + children, + minWidth = 420, + maxWidth = 480, + defaultWidth = 480, + onResize, + initialSize, + ...rest + } = props; + + const boxRef = useRef(null); + const handleRef = useRef(null); + const [width, setWidth] = useState(defaultWidth); + + // whenever the initial size changes, lets override whatever the current width is + useEffect(() => { + setWidth(initialSize); + }, [initialSize]); + + // This is a workaround to force other elements to resize that + // are listening to the window resize event (such as the editor). + useEffect(() => { + window.dispatchEvent(new Event('resize')); + }, [width]); + + const onDrag = useCallback(e => { + if (!boxRef.current) return; + const rect = boxRef.current.getBoundingClientRect(); + const newWidth = Math.max(Math.min(rect.width - e.movementX, maxWidth), minWidth); + onResize(newWidth); + setWidth(newWidth); + }, [minWidth, maxWidth]); + + + useMouseDrag(handleRef, onDrag); + + return ( + +
+ {children} + + ); +}; + +ResizableBox.propTypes = { + children: PropTypes.node, + minWidth: PropTypes.number, + maxWidth: PropTypes.number, + defaultWidth: PropTypes.number, + onResize: PropTypes.func, + initialSize: PropTypes.number +}; + +export default ResizableBox; diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 2002e5bb2a7..0987b8349e7 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -207,9 +207,6 @@ /* pad entire wrapper to the left and right; allow children to fill width */ padding-left: $space; padding-right: $space; - - /* this will only ever be as wide as the stage */ - flex-basis: 0; } .target-wrapper { diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index f48e5a6ec33..939dceb97bd 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -1,7 +1,7 @@ import classNames from 'classnames'; import omit from 'lodash.omit'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, {useState, useEffect, useRef, useCallback} from 'react'; import {defineMessages, FormattedMessage, injectIntl, intlShape} from 'react-intl'; import {connect} from 'react-redux'; import MediaQuery from 'react-responsive'; @@ -17,6 +17,7 @@ import SoundTab from '../../containers/sound-tab.jsx'; import StageWrapper from '../../containers/stage-wrapper.jsx'; import Loader from '../loader/loader.jsx'; import Box from '../box/box.jsx'; +import ResizableBox from '../box/resizeableBox.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; import CostumeLibrary from '../../containers/costume-library.jsx'; import BackdropLibrary from '../../containers/backdrop-library.jsx'; @@ -41,6 +42,8 @@ import codeIcon from './icon--code.svg'; import costumesIcon from './icon--costumes.svg'; import soundsIcon from './icon--sounds.svg'; +import {setStageSize} from '../../reducers/stage-size'; + const messages = defineMessages({ addExtension: { id: 'gui.gui.addExtension', @@ -130,6 +133,28 @@ const GUIComponent = props => { return {children}; } + // This is the width of the stage as set by the user. used as the "large" stage size + const [stageUserWidth, setStageUserWidth] = useState(480); + + // callback to set the stage size mode based on the user's defined width + const {onSetStageSmall, onSetStageLarge} = props; + const onResize = useCallback(size => { + const stageSize = resolveStageSize(stageSizeMode, false); + if (size < 480 && stageSize !== STAGE_SIZE_MODES.small) { + onSetStageSmall(); + } + else if (stageSize !== STAGE_SIZE_MODES.large) { + onSetStageLarge(); + } + setStageUserWidth(size); + }, [onSetStageSmall, onSetStageLarge, stageSizeMode]); + + + const getStageSize = () => { + const size = stageSizeMode === STAGE_SIZE_MODES.large ? Math.max(stageUserWidth, 480 + 16 + 2) : 240 + 16 + 2; + return size; + }; + const tabClassNames = { tabs: styles.tabs, tab: classNames(tabStyles.reactTabsTab, styles.tab), @@ -348,7 +373,13 @@ const GUIComponent = props => { ) : null} - + { vm={vm} /> - + @@ -436,7 +467,9 @@ GUIComponent.propTypes = { telemetryModalVisible: PropTypes.bool, theme: PropTypes.string, tipsLibraryVisible: PropTypes.bool, - vm: PropTypes.instanceOf(VM).isRequired + vm: PropTypes.instanceOf(VM).isRequired, + onSetStageLarge: PropTypes.func.isRequired, + onSetStageSmall: PropTypes.func.isRequired, }; GUIComponent.defaultProps = { backpackHost: null, @@ -469,6 +502,12 @@ const mapStateToProps = state => ({ theme: state.scratchGui.theme.theme }); +const mapDispatchToProps = dispatch => ({ + onSetStageLarge: () => dispatch(setStageSize(STAGE_SIZE_MODES.large)), + onSetStageSmall: () => dispatch(setStageSize(STAGE_SIZE_MODES.small)) +}); + export default injectIntl(connect( - mapStateToProps + mapStateToProps, + mapDispatchToProps )(GUIComponent)); diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index e4da715b0b4..c432eb09dbb 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -8,15 +8,32 @@ import {stageSizeToTransform} from '../../lib/screen-utils'; import styles from './monitor-list.css'; +// Use static `monitor-overlay` class for bounds of draggables +// This is just a dummy div which doesn't scale to make the +// bounds tracking play nicer with react-draggable +const MonitorOverlay = () => ( +
+); + const MonitorList = props => ( + ( x={monitorData.x} y={monitorData.y} onDragEnd={props.onMonitorChange} + scale={props.stageSize.scale} /> ))} @@ -55,7 +73,8 @@ MonitorList.propTypes = { width: PropTypes.number, height: PropTypes.number, widthDefault: PropTypes.number, - heightDefault: PropTypes.number + heightDefault: PropTypes.number, + scale: PropTypes.number }).isRequired }; diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index d9ceb926957..bce68721b5a 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -52,6 +52,7 @@ const MonitorComponent = props => ( defaultClassNameDragging={styles.dragging} disabled={!props.draggable} onStop={props.onDragEnd} + scale={props.scale} > -
-
- {spriteNameInput} +
+
+
+ {spriteNameInput} +
+
+
+ {xPosition} + {yPosition}
-
-
- {xPosition} - {yPosition}
); @@ -174,72 +176,79 @@ class SpriteInfo extends React.Component { return ( -
-
-
diff --git a/src/components/stage-header/stage-header.jsx b/src/components/stage-header/stage-header.jsx index 291acccae57..b6e0c603ef8 100644 --- a/src/components/stage-header/stage-header.jsx +++ b/src/components/stage-header/stage-header.jsx @@ -8,7 +8,6 @@ import Box from '../box/box.jsx'; import Button from '../button/button.jsx'; import ToggleButtons from '../toggle-buttons/toggle-buttons.jsx'; import Controls from '../../containers/controls.jsx'; -import {getStageDimensions} from '../../lib/screen-utils'; import {STAGE_SIZE_MODES} from '../../lib/layout-constants'; import fullScreenIcon from './icon--fullscreen.svg'; @@ -64,7 +63,6 @@ const StageHeaderComponent = function (props) { let header = null; if (isFullScreen) { - const stageDimensions = getStageDimensions(null, true); const stageButton = showBranding ? (
{stageButton} diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index a799e05ce34..2ffb4c10ed2 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -24,10 +24,18 @@ /* enforce overflow + reset position of absolutely-positioned children */ position: relative; + + display: block; + width: 100%; + height: auto; + max-height: 100%; + aspect-ratio: 4/3; + box-sizing: border-box; } .stage.full-screen { border: $stage-full-screen-border-width solid rgb(126, 133, 151); + left: -3px; } .with-color-picker { @@ -49,6 +57,29 @@ .stage-wrapper { position: relative; } +.stage-wrapper.full-screen { + height: calc(100vh - 56px); + width: calc(100vw - 2px); + display: flex; + justify-content: center; + align-items: center; +} + +.stage-canvas { + display: block; + width: 100%; + height: auto; + aspect-ratio: 4/3; +} + +.stage-max-width { + height: 100%; + width: auto; + max-width: calc(100% - 2px); + aspect-ratio: 4/3; + display: grid; + place-items: center stretch; +} /* we want stage overlays to all be positioned in the same spot as the stage, but can't put them inside the border because we want their overflow to be visible, and the bordered element must have overflow: hidden set so that the diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index 24bf4cadd09..d2eb8c78a4e 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -11,8 +11,9 @@ import GreenFlagOverlay from '../../containers/green-flag-overlay.jsx'; import Question from '../../containers/question.jsx'; import MicIndicator from '../mic-indicator/mic-indicator.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; -import {getStageDimensions} from '../../lib/screen-utils.js'; import styles from './stage.css'; +import {useCanvasSize} from '../../lib/screen-utils'; + const StageComponent = props => { const { @@ -24,7 +25,6 @@ const StageComponent = props => { colorInfo, micIndicator, question, - stageSize, useEditorDragStyle, onDeactivateColorPicker, onDoubleClick, @@ -32,50 +32,48 @@ const StageComponent = props => { ...boxProps } = props; - const stageDimensions = getStageDimensions(stageSize, isFullScreen); + const stageDimensions = useCanvasSize(canvas); return ( - - - - - - + + + + + + + + {isColorPicking && colorInfo ? ( + + ) : null} - {isColorPicking && colorInfo ? ( - - ) : null} {/* `stageOverlays` is for items that should *not* have their overflow contained within the stage */} diff --git a/src/containers/monitor.jsx b/src/containers/monitor.jsx index f45c2d1ef18..bba91648fa2 100644 --- a/src/containers/monitor.jsx +++ b/src/containers/monitor.jsx @@ -225,6 +225,7 @@ class Monitor extends React.Component { onSetModeToLarge={isList ? null : this.handleSetModeToLarge} onSetModeToSlider={showSliderOption ? this.handleSetModeToSlider : null} onSliderPromptOpen={this.handleSliderPromptOpen} + scale={this.props.scale} /> ); @@ -265,7 +266,8 @@ Monitor.propTypes = { vm: PropTypes.instanceOf(VM), width: PropTypes.number, x: PropTypes.number, - y: PropTypes.number + y: PropTypes.number, + scale: PropTypes.number }; Monitor.defaultProps = { theme: DEFAULT_THEME diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index caa3de483dd..118ba1c0a65 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -146,6 +146,9 @@ class Stage extends React.Component { } updateRect () { this.rect = this.canvas.getBoundingClientRect(); + this.renderer.resize(this.rect.width, this.rect.height); + this.canvas.width = this.rect.width; + this.canvas.height = this.rect.height; } getScratchCoords (x, y) { const nativeSize = this.renderer.getNativeSize(); diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js index a94a67d5b75..430d1f65666 100644 --- a/src/lib/screen-utils.js +++ b/src/lib/screen-utils.js @@ -1,4 +1,5 @@ import layout, {STAGE_DISPLAY_SCALES, STAGE_SIZE_MODES, STAGE_DISPLAY_SIZES} from '../lib/layout-constants'; +import {useEffect, useState} from 'react'; /** * @typedef {object} StageDimensions @@ -35,47 +36,6 @@ const resolveStageSize = (stageSizeMode, isFullSize) => { return STAGE_DISPLAY_SIZES.largeConstrained; }; -/** - * Retrieve info used to determine the actual stage size based on the current GUI and browser state. - * @param {STAGE_DISPLAY_SIZES} stageSize - the current fully-resolved stage size. - * @param {boolean} isFullScreen - true if full-screen mode is enabled. - * @return {StageDimensions} - an object describing the dimensions of the stage. - */ -const getStageDimensions = (stageSize, isFullScreen) => { - const stageDimensions = { - heightDefault: layout.standardStageHeight, - widthDefault: layout.standardStageWidth, - height: 0, - width: 0, - scale: 0 - }; - - if (isFullScreen) { - stageDimensions.height = window.innerHeight - - STAGE_DIMENSION_DEFAULTS.menuHeightAdjustment - - STAGE_DIMENSION_DEFAULTS.fullScreenSpacingBorderAdjustment; - - stageDimensions.width = stageDimensions.height + (stageDimensions.height / 3); - - if (stageDimensions.width > window.innerWidth) { - stageDimensions.width = window.innerWidth; - stageDimensions.height = stageDimensions.width * .75; - } - - stageDimensions.scale = stageDimensions.width / stageDimensions.widthDefault; - } else { - stageDimensions.scale = STAGE_DISPLAY_SCALES[stageSize]; - stageDimensions.height = stageDimensions.scale * stageDimensions.heightDefault; - stageDimensions.width = stageDimensions.scale * stageDimensions.widthDefault; - } - - // Round off dimensions to prevent resampling/blurriness - stageDimensions.height = Math.round(stageDimensions.height); - stageDimensions.width = Math.round(stageDimensions.width); - - return stageDimensions; -}; - /** * Take a pair of sizes for the stage (a target height and width and a default height and width), * calculate the ratio between them, and return a CSS transform to scale to that ratio. @@ -97,8 +57,48 @@ const stageSizeToTransform = ({width, height, widthDefault, heightDefault}) => { return {transform: `scale(${scaleX},${scaleY})`}; }; -export { - getStageDimensions, - resolveStageSize, - stageSizeToTransform +/** + * React hook that monitors a canvas element's dimensions and returns stage sizing information. + * @param {HTMLCanvasElement} canvas - The canvas element to monitor + * @returns {object} Stage dimension information + * @property {number} heightDefault - The default stage height (standardStageHeight) + * @property {number} widthDefault - The default stage width (standardStageWidth) + * @property {number} height - The current height of the canvas in pixels + * @property {number} width - The current width of the canvas in pixels + * @property {number} scale - Scale factor between current width and default width + */ +const useCanvasSize = canvas => { + const [stageDimensions, setStageDimensions] = useState({ + heightDefault: layout.standardStageHeight, + widthDefault: layout.standardStageWidth, + height: canvas?.clientHeight || layout.standardStageHeight, + width: canvas?.clientWidth || layout.standardStageWidth, + scale: (canvas?.clientWidth || layout.standardStageWidth) / layout.standardStageWidth + }); + + useEffect(() => { + const resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + setStageDimensions({ + heightDefault: layout.standardStageHeight, + widthDefault: layout.standardStageWidth, + height: entry.contentRect.height, + width: entry.contentRect.width, + scale: entry.contentRect.width / layout.standardStageWidth + }); + } + }); + + if (canvas) { + resizeObserver.observe(canvas); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [canvas]); + + return stageDimensions; }; + +export {resolveStageSize, stageSizeToTransform, useCanvasSize}; From a6813143119bb627999e7323d5dc16cefe5fd35a Mon Sep 17 00:00:00 2001 From: Dan Fessler Date: Tue, 26 Nov 2024 11:07:43 -0800 Subject: [PATCH 2/3] fixing some minor warnings in the console --- src/components/box/box.jsx | 7 +++++-- src/components/gui/gui.jsx | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/box/box.jsx b/src/components/box/box.jsx index 4e9c8cdb8e0..48e4d5efe0e 100644 --- a/src/components/box/box.jsx +++ b/src/components/box/box.jsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, {Component} from 'react'; import stylePropType from 'react-style-proptype'; import styles from './box.css'; @@ -100,7 +100,10 @@ Box.propTypes = { * A callback function whose first parameter is the underlying dom elements. * This call back will be executed immediately after the component is mounted or unmounted */ - componentRef: PropTypes.func, + componentRef: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({current: PropTypes.object}) + ]), /** https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction */ direction: PropTypes.oneOf([ 'row', 'row-reverse', 'column', 'column-reverse' diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 939dceb97bd..888f3f3b2bd 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -127,6 +127,8 @@ const GUIComponent = props => { theme, tipsLibraryVisible, vm, + onSetStageLarge, + onSetStageSmall, ...componentProps } = omit(props, 'dispatch'); if (children) { @@ -137,7 +139,6 @@ const GUIComponent = props => { const [stageUserWidth, setStageUserWidth] = useState(480); // callback to set the stage size mode based on the user's defined width - const {onSetStageSmall, onSetStageLarge} = props; const onResize = useCallback(size => { const stageSize = resolveStageSize(stageSizeMode, false); if (size < 480 && stageSize !== STAGE_SIZE_MODES.small) { From 816d91794362d689f350faad09eddb8665729491 Mon Sep 17 00:00:00 2001 From: Dan Fessler Date: Tue, 26 Nov 2024 11:10:55 -0800 Subject: [PATCH 3/3] updating the comment description of componentRef prop --- src/components/box/box.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/box/box.jsx b/src/components/box/box.jsx index 48e4d5efe0e..bcc65cf0507 100644 --- a/src/components/box/box.jsx +++ b/src/components/box/box.jsx @@ -98,7 +98,8 @@ Box.propTypes = { className: PropTypes.string, /** * A callback function whose first parameter is the underlying dom elements. - * This call back will be executed immediately after the component is mounted or unmounted + * This call back will be executed immediately after the component is mounted or unmounted. + * Also accepts component refs created with createRef() and useRef() */ componentRef: PropTypes.oneOfType([ PropTypes.func,