diff --git a/package-lock.json b/package-lock.json index 786c133ce..ded0b0546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "es6-object-assign": "1.1.0", "get-float-time-domain-data": "0.1.0", "get-user-media-promise": "1.1.4", + "hammerjs": "2.0.8", "immutable": "3.8.2", "intl": "1.2.5", "intl-messageformat": "^2.2.0", @@ -10306,6 +10307,11 @@ "node": ">=4.x" } }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==" + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -12086,7 +12092,7 @@ "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", "funding": { - "type": "GitHub Sponsors ❤", + "type": "GitHub Sponsors \u2764", "url": "https://github.com/sponsors/dmonad" } }, @@ -13357,7 +13363,7 @@ "node": ">=16" }, "funding": { - "type": "GitHub Sponsors ❤", + "type": "GitHub Sponsors \u2764", "url": "https://github.com/sponsors/dmonad" } }, @@ -19015,15 +19021,6 @@ "node": ">=0.10.0" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -19147,6 +19144,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -22273,7 +22279,7 @@ "npm": ">=8.0.0" }, "funding": { - "type": "GitHub Sponsors ❤", + "type": "GitHub Sponsors \u2764", "url": "https://github.com/sponsors/dmonad" } }, diff --git a/package.json b/package.json index d57631c33..81327fd9b 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "es6-object-assign": "1.1.0", "get-float-time-domain-data": "0.1.0", "get-user-media-promise": "1.1.4", + "hammerjs": "2.0.8", "immutable": "3.8.2", "intl": "1.2.5", "intl-messageformat": "^2.2.0", diff --git a/src/components/action-menu/action-menu.css b/src/components/action-menu/action-menu.css index 03be98cac..54ae632ef 100644 --- a/src/components/action-menu/action-menu.css +++ b/src/components/action-menu/action-menu.css @@ -198,3 +198,20 @@ $arrow-rounding: 0.125rem; top: $arrow-inset !important; transform: rotate(-45deg) !important; } + +/* Larger add-buttons and their menus on touch screens */ +@media only screen and (pointer: coarse) { + .main-button { + min-height: 3.25rem; + min-width: 3.25rem; + } + + .more-button { + min-height: 2.75rem; + min-width: 2.75rem; + } + + .tooltip { + font-size: 0.75rem; + } +} diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css index 05fcdee5e..f13dc18c6 100644 --- a/src/components/asset-panel/selector.css +++ b/src/components/asset-panel/selector.css @@ -82,3 +82,15 @@ $fade-out-distance: 100px; background: white; filter: opacity(15%) brightness(0%); } + +@media only screen and (pointer: coarse) { + .list-item { + min-height: 4.5rem; + min-width: 4.5rem; + } + + .new-buttons > * { + min-height: 3.5rem; + min-width: 3.5rem; + } +} diff --git a/src/components/backpack/backpack.css b/src/components/backpack/backpack.css index 4a597b070..cf3ac9926 100644 --- a/src/components/backpack/backpack.css +++ b/src/components/backpack/backpack.css @@ -99,3 +99,14 @@ margin: 0.5rem; cursor: pointer; } + +@media only screen and (pointer: coarse) { + .backpack-header { + min-height: 2.75rem; + } + + .backpack-item { + min-height: 3.25rem; + min-width: 5.5rem; + } +} diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css index 2a023e290..b807c050b 100644 --- a/src/components/close-button/close-button.css +++ b/src/components/close-button/close-button.css @@ -79,3 +79,10 @@ width: 2rem; height: 2rem; } + +@media only screen and (pointer: coarse) { + .close-button { + min-height: 2.75rem; + min-width: 2.75rem; + } +} diff --git a/src/components/context-menu/context-menu.css b/src/components/context-menu/context-menu.css index f939f6d7c..9520b3957 100644 --- a/src/components/context-menu/context-menu.css +++ b/src/components/context-menu/context-menu.css @@ -36,3 +36,14 @@ .menu-item-danger:hover { background: $error-primary; } + +/* Larger context menu items on touch screens */ +@media only screen and (pointer: coarse) { + .menu-item, + .menu-item-bordered, + .menu-item-danger { + min-height: 2.75rem; + padding: 0.75rem 1.25rem; + font-size: 0.95rem; + } +} diff --git a/src/components/dash-mobile-settings/dash-mobile-settings.css b/src/components/dash-mobile-settings/dash-mobile-settings.css new file mode 100644 index 000000000..36aee4389 --- /dev/null +++ b/src/components/dash-mobile-settings/dash-mobile-settings.css @@ -0,0 +1,82 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; +@import "../../css/z-index.css"; + +.wrapper { + position: relative; + display: flex; + align-items: center; +} + +.toggle { + padding: 0.25rem 0.5rem; + border: 1px solid $ui-black-transparent; + border-radius: 0.35rem; + background: $ui-primary; + font-size: 1.1rem; + line-height: 1.25; + cursor: pointer; + touch-action: manipulation; +} + +.panel { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + z-index: $z-index-menu-bar; + display: flex; + flex-direction: column; + gap: 0.15rem; + width: 15rem; + padding: 0.5rem; + border: 1px solid $ui-black-transparent; + border-radius: $space; + background: $ui-white; + color: $text-primary; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 0.8125rem; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2); +} + +.header { + padding: 0.25rem 0.35rem 0.45rem; + font-weight: bold; + border-bottom: 1px solid $ui-black-transparent; +} + +.row { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.35rem; + border-radius: 0.35rem; + cursor: pointer; +} + +.row:hover { + background: $ui-tertiary; +} + +.row input { + flex: none; + width: 1.15rem; + height: 1.15rem; +} + +.install-button { + margin-top: 0.35rem; + padding: 0.65rem 0.75rem; + border: 1px solid $looks-secondary; + border-radius: 0.45rem; + background: $looks-secondary; + color: #fff; + font-weight: bold; + font-size: 0.85rem; + cursor: pointer; + touch-action: manipulation; +} + +.install-button:hover { + filter: brightness(1.08); +} diff --git a/src/components/dash-mobile-settings/dash-mobile-settings.jsx b/src/components/dash-mobile-settings/dash-mobile-settings.jsx new file mode 100644 index 000000000..31a905452 --- /dev/null +++ b/src/components/dash-mobile-settings/dash-mobile-settings.jsx @@ -0,0 +1,157 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {FormattedMessage, injectIntl, intlShape} from 'react-intl'; + +import dashSettings from '../../lib/dash-settings.js'; +import {buzz, onInstallPrompt, promptInstall, releaseWakeLock} from '../../lib/dash-platform.js'; +import * as styles from './dash-mobile-settings.css'; + +class DashMobileSettings extends React.Component { + constructor (props) { + super(props); + this.state = { + open: false, + settings: dashSettings.all(), + canInstall: false + }; + bindAll(this, [ + 'handleToggleOpen', + 'handleClose', + 'handleDocumentClick', + 'handleChangeSetting', + 'handleInstall', + 'handleSettingsChange', + 'handleInstallPrompt' + ]); + } + componentDidMount () { + this.unsubscribeSettings = dashSettings.subscribe(this.handleSettingsChange); + this.unsubscribeInstall = onInstallPrompt(this.handleInstallPrompt); + document.addEventListener('mousedown', this.handleDocumentClick, true); + } + componentWillUnmount () { + if (this.unsubscribeSettings) this.unsubscribeSettings(); + if (this.unsubscribeInstall) this.unsubscribeInstall(); + document.removeEventListener('mousedown', this.handleDocumentClick, true); + } + handleSettingsChange (settings) { + this.setState({settings}); + } + handleInstallPrompt (prompt) { + this.setState({canInstall: Boolean(prompt)}); + } + handleDocumentClick (event) { + if (!this.state.open || !this.wrapperRef) return; + if (!this.wrapperRef.contains(event.target)) this.handleClose(); + } + handleToggleOpen () { + buzz(5); + this.setState({open: !this.state.open}); + } + handleClose () { + this.setState({open: false}); + } + handleChangeSetting (key) { + return event => { + dashSettings.set(key, event.target.checked); + if (key === 'haptics' && event.target.checked) buzz(10); + if (key === 'keepAwake' && !event.target.checked) releaseWakeLock(); + }; + } + async handleInstall () { + buzz(5); + const installed = await promptInstall(); + if (installed) this.handleClose(); + } + render () { + const {settings, canInstall} = this.state; + return ( +
{ + this.wrapperRef = ref; + }} + > + + {this.state.open ? ( +
+
+ +
+ + + + {canInstall ? ( + + ) : null} +
+ ) : null} +
+ ); + } +} + +DashMobileSettings.propTypes = { + intl: intlShape +}; + +export default injectIntl(DashMobileSettings); diff --git a/src/components/green-flag/green-flag.css b/src/components/green-flag/green-flag.css index 3ca52f61e..85f16e78c 100644 --- a/src/components/green-flag/green-flag.css +++ b/src/components/green-flag/green-flag.css @@ -17,3 +17,10 @@ .green-flag.is-active { background-color: $looks-transparent; } + +@media only screen and (pointer: coarse) { + .green-flag { + min-height: 2.5rem; + min-width: 2.5rem; + } +} diff --git a/src/components/green-flag/green-flag.jsx b/src/components/green-flag/green-flag.jsx index dc092b67e..729fa4c5f 100644 --- a/src/components/green-flag/green-flag.jsx +++ b/src/components/green-flag/green-flag.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import TWRenderRecoloredImage from '../../lib/tw-recolor/render.jsx'; +import {buzz, requestWakeLock} from '../../lib/dash-platform.js'; import greenFlagIcon from '!../../lib/tw-recolor/build!./icon--green-flag.svg'; import * as styles from './green-flag.css'; @@ -27,7 +28,11 @@ const GreenFlagComponent = function (props) { draggable={false} src={greenFlagIcon} title={title} - onClick={onClick} + onClick={event => { + requestWakeLock(); + if (onClick) onClick(event); + }} + onTouchStart={() => buzz(5)} // tw: also fire click when opening context menu (right click on all systems and alt+click on chromebooks) onContextMenu={onClick} {...componentProps} diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 8b0a8dbee..730c41c9c 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -305,3 +305,202 @@ $fade-out-distance: 15px; /* overridden by JS; this will be used if the browser can't understand that color */ background-color: white; } + +/* + Phone / narrow-window layout: stack the stage above the editor instead of + the desktop two-column layout, and let the page scroll vertically. +*/ +@media only screen and (max-width: 1023px) { + .body-wrapper { + overflow-x: hidden; + overflow-y: auto; + } + + .flex-wrapper { + flex-direction: column; + height: auto; + min-height: 100%; + overflow: visible; + } + + .stage-and-target-wrapper { + order: -1; /* stage on top, editor below */ + flex: none; + width: 100%; + align-items: center; + } + + .target-wrapper { + flex: none; + min-height: 12rem; + } + + .editor-wrapper { + flex: 1 1 auto; + width: 100%; + min-height: 24rem; + } +} + +/* Larger tap targets on touch screens */ +@media only screen and (pointer: coarse) { + .tab { + min-width: 2.75rem; + min-height: 2.25rem; + padding: 0.125rem 1.5rem 0; + font-size: 0.85rem; + } + + .tab img { + width: 1.5rem; + } +} + +/* No double-tap zoom delay on buttons */ +.page-wrapper button, +.page-wrapper [role="button"] { + touch-action: manipulation; +} + +/* ------------------------------------------------------------------ */ +/* Phone layout: compact stage controls and more room for the editor */ +/* ------------------------------------------------------------------ */ +@media only screen and (max-width: 599px) { + .stage-and-target-wrapper { + padding-left: 0; + padding-right: 0; + } + + .editor-wrapper { + min-height: 20rem; + } + + .tab-list { + gap: 0.15rem; + padding: 0.15rem; + } + + .tab { + flex: 1; + padding: 0.125rem 0.5rem 0; + } +} + +/* + Phone landscape and other short windows: keep the side-by-side layout so + the editor is not squeezed under the stage. +*/ +@media only screen and (max-width: 1023px) and (max-height: 520px) { + .flex-wrapper { + flex-direction: row; + height: 100%; + overflow: hidden; + } + + .stage-and-target-wrapper { + order: 0; + flex: 0 1 auto; + width: auto; + } + + .target-wrapper { + min-height: 0; + } + + .editor-wrapper { + min-height: 0; + } +} + +/* Safe areas for notched phones */ +@media only screen and (max-width: 1023px) { + .body-wrapper { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} + +/* Keep the focused input above the on-screen keyboard */ +:global(.dash-keyboard-open) .body-wrapper { + padding-bottom: var(--dash-keyboard-height, 0px); +} + +/* iOS: do not let Safari boost text sizes on its own */ +.page-wrapper { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +/* On touch screens, form fields must be 16px or iOS zooms the page on focus */ +@media only screen and (pointer: coarse) { + .page-wrapper input, + .page-wrapper textarea, + .page-wrapper select { + font-size: 1rem; + } +} + +/* Honor the system "reduce motion" preference */ +@media (prefers-reduced-motion: reduce) { + .page-wrapper *, + .page-wrapper *:before, + .page-wrapper *:after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + } +} + +/* Phone: tabs become a sticky bottom navigation bar */ +@media only screen and (max-width: 599px) { + .tabs { + justify-content: flex-end; + } + + .tab-list { + order: 2; + position: sticky; + bottom: 0; + z-index: $z-index-menu-bar; + background-color: $ui-primary; + padding-bottom: calc(0.15rem + env(safe-area-inset-bottom)); + } +} + +/* Touch: no browser pull-to-refresh while dragging */ +@media only screen and (pointer: coarse) { + .page-wrapper { + overscroll-behavior-y: none; + } +} + +/* --------------------------------- */ +/* Compact mode (mobile settings) */ +/* --------------------------------- */ +:global(.dash-compact) [class*="menu-bar_menu-bar"], +:global(.dash-compact) [class*="menu-bar_menu-bar"] * { + font-size: 0.78rem; +} + +:global(.dash-compact) [class*="sprite-selector_sprite-selector"] { + max-height: 145px; +} + +:global(.dash-compact) [class*="sprite-selector-item_sprite-wrapper"], +:global(.dash-compact) [class*="sprite-selector-item_sprite-wrapper"] img { + width: 3.25rem; + height: 3.25rem; +} + +:global(.dash-compact) [class*="asset-panel_wrapper"], +:global(.dash-compact) [class*="asset-panel_wrapper"] * { + font-size: 0.75rem; +} + +:global(.dash-compact) [class*="action-menu_button"] { + width: 36px; + height: 36px; +} + +:global(.dash-compact) [class*="stage-selector_item-wrapper"] { + min-height: 72px; +} diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index ecca002df..3e8b7ada4 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -9,6 +9,8 @@ import {Tab, Tabs, TabList, TabPanel} from 'react-tabs'; import * as tabStyles from 'react-tabs/style/react-tabs.css'; import VM from 'scratch-vm'; +import '../../lib/dash-platform.js'; +import tabSwipeHandlers from '../../lib/tab-swipe.js'; import Blocks from '../../containers/blocks.jsx'; import CostumeTab from '../../containers/costume-tab.jsx'; import TargetPane from '../../containers/target-pane.jsx'; @@ -354,7 +356,13 @@ const GUIComponent = props => { selectedTabPanelClassName={tabClassNames.tabPanelSelected} onSelect={onActivateTab} > - + onActivateTab((activeTabIndex + 1) % 3), + onSwipeRight: () => onActivateTab((activeTabIndex + 2) % 3) + })} + > * { + min-width: 9.5rem; + } + + .filter-bar input, + .filter { + min-height: 2.75rem; + } +} + +/* Phone: full-width search and taller tile grid */ +@media only screen and (max-width: 599px) { + .filter-bar-content { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + } +} diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css index b06181f28..138d5ecb0 100644 --- a/src/components/menu-bar/menu-bar.css +++ b/src/components/menu-bar/menu-bar.css @@ -361,3 +361,60 @@ .editor-button { padding: 1rem; } + +/* Scrollable menu bar on narrow screens instead of squishing or overflowing */ +@media only screen and (max-width: 1023px) { + .menu-bar { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + } +} + +/* Larger tap targets on touch screens */ +@media only screen and (pointer: coarse) { + .file-group .menu-bar-item, + .settings-group .menu-bar-item { + padding: 0 0.75rem; + } + + .menu-bar-search { + max-width: 12rem; + } +} + +/* Phone: show only the essentials, safe-area aware */ +@media only screen and (max-width: 599px) { + .menu-bar { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + gap: 0.25rem; + } + + .menu-bar-search, + .feedback-link, + .title-field-growable { + display: none; + } + + .dash-logo { + margin-right: 0.25rem; + } + + .file-group .menu-bar-item, + .settings-group .menu-bar-item { + padding: 0 0.4rem; + } +} + +@media only screen and (pointer: coarse) { + .menu-bar-button { + min-height: 2.5rem; + min-width: 2.5rem; + } + + .menu-bar-search-input, + .menu-bar-search-button { + min-height: 2.5rem; + } +} diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 9fd1a9ffd..d3fc76e21 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -29,6 +29,7 @@ import SettingsMenu from './settings-menu.jsx'; import AccountNav from '../../containers/account-nav.jsx'; import Spinner from '../spinner/spinner.jsx'; import CollaboratorsModal from '../dash-collaborators/collaborators-modal.jsx'; +import DashMobileSettings from '../dash-mobile-settings/dash-mobile-settings.jsx'; import FramerateChanger from '../../containers/tw-framerate-changer.jsx'; import ChangeUsername from '../../containers/tw-change-username.jsx'; @@ -1351,6 +1352,9 @@ class MenuBar extends React.Component { }
+ {!this.props.isPlayerOnly &&
+ +
} {!this.props.isPlayerOnly &&
( onTouchStart: props.onMouseDown }} disable={props.preventContextMenu} + holdToDisplay={400} id={`${props.name}-${contextMenuId}`} ref={props.componentRef} > diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index bfb3598c9..87a652f1a 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -125,3 +125,29 @@ background: black; filter: opacity(15%) brightness(0%); } + +/* Touch: easier to grab and tap */ +@media only screen and (pointer: coarse) { + .sprite { + min-width: 5.5rem; + min-height: 5.5rem; + } + + .add-button { + min-width: 4.5rem; + min-height: 4.5rem; + } +} + +/* Phone: sprite list gets a comfortable fixed height and scrolls */ +@media only screen and (max-width: 599px) { + .sprite-selector { + min-height: 11rem; + } + + .scroll-wrapper { + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + } +} diff --git a/src/components/stage-header/stage-header.css b/src/components/stage-header/stage-header.css index 3a3882493..88645bc0e 100644 --- a/src/components/stage-header/stage-header.css +++ b/src/components/stage-header/stage-header.css @@ -91,3 +91,30 @@ /* relied on by addons */ display: flex; } + +/* Larger stage controls on touch screens */ +@media only screen and (pointer: coarse) { + .stage-button { + min-height: 2.75rem; + min-width: 2.75rem; + padding: 0.5rem; + } + + .stage-button-icon { + width: 1.25rem; + height: 1.25rem; + } + + .stage-size-toggle-group button { + min-height: 2.5rem; + min-width: 2.5rem; + } +} + +/* Phone: keep the stage menu on one scrollable row */ +@media only screen and (max-width: 599px) { + .stage-menu-wrapper { + overflow-x: auto; + overflow-y: hidden; + } +} diff --git a/src/components/stage-selector/stage-selector.css b/src/components/stage-selector/stage-selector.css index 12ebe6a20..e88f18d50 100644 --- a/src/components/stage-selector/stage-selector.css +++ b/src/components/stage-selector/stage-selector.css @@ -117,3 +117,9 @@ $header-height: calc($stage-menu-height - 2px); 90% { box-shadow: 0 0 10px #7fff1e; } 100% { box-shadow: none; } } + +@media only screen and (pointer: coarse) { + .stage-selector { + min-height: 5.5rem; + } +} diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index e1718752e..18b028bbe 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -157,3 +157,9 @@ stage doesn't "spill" out from under its rounded corners. instead, shift these o 0% { opacity: 1; } 100% { opacity: 0; } } + +/* Respect the notch/home indicator in fullscreen */ +.stage.full-screen { + margin-left: env(safe-area-inset-left); + margin-right: env(safe-area-inset-right); +} diff --git a/src/components/stop-all/stop-all.css b/src/components/stop-all/stop-all.css index ab89a5c70..f462c0b6a 100644 --- a/src/components/stop-all/stop-all.css +++ b/src/components/stop-all/stop-all.css @@ -20,3 +20,10 @@ .stop-all.is-active { opacity: 1; } + +@media only screen and (pointer: coarse) { + .stop-all { + min-height: 2.5rem; + min-width: 2.5rem; + } +} diff --git a/src/components/stop-all/stop-all.jsx b/src/components/stop-all/stop-all.jsx index 24835b9ca..aa99d39f4 100644 --- a/src/components/stop-all/stop-all.jsx +++ b/src/components/stop-all/stop-all.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import TWRenderRecoloredImage from '../../lib/tw-recolor/render.jsx'; +import {buzz} from '../../lib/dash-platform.js'; import stopAllIcon from '!../../lib/tw-recolor/build!./icon--stop-all.svg'; import * as styles from './stop-all.css'; @@ -28,6 +29,7 @@ const StopAllComponent = function (props) { src={stopAllIcon} title={title} onClick={onClick} + onTouchStart={() => buzz(5)} {...componentProps} /> ); diff --git a/src/components/target-pane/target-pane.css b/src/components/target-pane/target-pane.css index 07b07cb16..319d00242 100644 --- a/src/components/target-pane/target-pane.css +++ b/src/components/target-pane/target-pane.css @@ -20,3 +20,15 @@ [dir="rtl"] .stage-selector-wrapper { margin-right: calc($space / 2); } + +/* Phone: the sprite pane sits under the stage and keeps a usable height */ +@media only screen and (max-width: 599px) { + .target-pane { + width: 100%; + min-height: 12rem; + } + + .stage-selector-wrapper { + flex: none; + } +} diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index c41a62abe..760602ca9 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -10,6 +10,8 @@ import VM from 'scratch-vm'; import Prompt from './prompt.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; +import enableWorkspaceGestures from '../lib/workspace-gestures.js'; +import {isNarrow} from '../lib/dash-platform.js'; import ExtensionLibrary from './extension-library.jsx'; import extensionData from '../lib/libraries/extensions/index.jsx'; import CustomProcedures from './custom-procedures.jsx'; @@ -231,10 +233,17 @@ class Blocks extends React.Component { }, Blocks.defaultOptions ); + if (isNarrow() && workspaceConfig.zoom) { + // Readable default zoom on phones. + workspaceConfig.zoom.startScale = Math.max(workspaceConfig.zoom.startScale || 0, 0.9); + } this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig); AddonHooks.blocklyWorkspace = this.workspace; this.updateCollaborationReadOnly(); + // Touch gestures: pinch-to-zoom, two-finger pan, double-tap zoom. + this.workspaceGestures = enableWorkspaceGestures(this.workspace, this.blocks); + // Register buttons under new callback keys for creating variables, // lists, and procedures from extensions. @@ -360,6 +369,7 @@ class Blocks extends React.Component { componentWillUnmount () { this.detachVM(); this.unmounted = true; + if (this.workspaceGestures) this.workspaceGestures.destroy(); this.workspace.dispose(); clearTimeout(this.toolboxUpdateTimeout); diff --git a/src/lib/dash-platform.js b/src/lib/dash-platform.js new file mode 100644 index 000000000..303033be0 --- /dev/null +++ b/src/lib/dash-platform.js @@ -0,0 +1,205 @@ +/** + * @fileoverview Cross-platform touch/mobile helpers for Dash. + * + * Small, dependency-free utilities used across the GUI: + * - Device detection (touch, narrow screens) + * - Haptic feedback via the Vibration API + * - Wake Lock so the screen stays on while a project runs + * - PWA install prompt plumbing (beforeinstallprompt) + * - Service worker registration for the installed/offline shell + */ + +import dashSettings from './dash-settings.js'; + +let dashPlatformInitialized = false; + +const isTouch = () => + typeof window !== 'undefined' && + ('ontouchstart' in window || (navigator.maxTouchPoints || 0) > 0); + +const isNarrow = () => + typeof window !== 'undefined' && window.matchMedia('(max-width: 599px)').matches; + +/* ------------------------------------------------------------------ */ +/* Haptics */ +/* ------------------------------------------------------------------ */ + +/** + * Buzz the device (if it supports vibration) and the setting allows it. + * Safe to call everywhere: it silently does nothing on desktop. + * @param {number} ms vibration duration in milliseconds + */ +const buzz = ms => { + if (!dashSettings.get('haptics')) return; + if (typeof navigator !== 'undefined' && navigator.vibrate) { + try { + navigator.vibrate(ms); + } catch (e) { + // Some browsers require a user gesture — ignore failures. + } + } +}; + +/* ------------------------------------------------------------------ */ +/* Wake Lock */ +/* ------------------------------------------------------------------ */ + +let wakeLock = null; +let wakeLockRequested = false; +let wakeLockListenersBound = false; + +const requestWakeLock = async () => { + if (!dashSettings.get('keepAwake')) return null; + wakeLockRequested = true; + if (typeof navigator === 'undefined' || !('wakeLock' in navigator)) return null; + try { + if (wakeLock) { + // Drop the previous sentinel so they do not stack. + try { + await wakeLock.release(); + } catch (e) { + // Already released. + } + wakeLock = null; + } + wakeLock = await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release', () => { + wakeLock = null; + }); + return wakeLock; + } catch (e) { + // Denied or unsupported — the screen may sleep normally. + return null; + } +}; + +const releaseWakeLock = async () => { + wakeLockRequested = false; + if (wakeLock) { + try { + await wakeLock.release(); + } catch (e) { + // Already released. + } + wakeLock = null; + } +}; + +/* ------------------------------------------------------------------ */ +/* Install prompt (PWA) */ +/* ------------------------------------------------------------------ */ + +let installPrompt = null; +const installPromptListeners = new Set(); + +const notifyInstallPrompt = () => { + for (const listener of installPromptListeners) { + try { + listener(installPrompt); + } catch (e) { + // Listener errors must not break other listeners. + } + } +}; + +/** + * Subscribe to PWA install-availability changes. + * The listener is called immediately with the current state. + * @param {function(?Event): void} listener receives the prompt event or null + * @returns {function(): void} unsubscribe function + */ +const onInstallPrompt = listener => { + installPromptListeners.add(listener); + listener(installPrompt); + return () => installPromptListeners.delete(listener); +}; + +/** + * Trigger the PWA installation flow if a prompt is available. + * @returns {Promise} true if the install prompt was shown and accepted + */ +const promptInstall = async () => { + if (!installPrompt) return false; + try { + installPrompt.prompt(); + const choice = await installPrompt.userChoice; + installPrompt = null; + notifyInstallPrompt(); + return Boolean(choice && choice.outcome === 'accepted'); + } catch (e) { + installPrompt = null; + notifyInstallPrompt(); + return false; + } +}; + +/* ------------------------------------------------------------------ */ +/* Service worker */ +/* ------------------------------------------------------------------ */ + +/** + * Registers the app service worker so the editor shell can be installed + * and works offline. Failures are silent (unsupported browsers / dev servers). + * The worker lives next to the manifest, so sub-path deploys work too. + */ +const registerServiceWorker = () => { + if (!('serviceWorker' in navigator)) return; + if (typeof window === 'undefined') return; + window.addEventListener('load', () => { + const manifestLink = document.querySelector('link[rel="manifest"]'); + const swUrl = manifestLink + ? String(manifestLink.href).replace(/manifest\.webmanifest(\?.*)?$/, 'sw.js$1') + : 'sw.js'; + navigator.serviceWorker.register(swUrl).catch(() => { + // Registration can fail on dev servers or unsupported origins — fine. + }); + }); +}; + +/* ------------------------------------------------------------------ */ +/* Init */ +/* ------------------------------------------------------------------ */ + +const initDashPlatform = () => { + if (dashPlatformInitialized) return; + if (typeof window === 'undefined') return; + dashPlatformInitialized = true; + + // PWA install prompt. + window.addEventListener('beforeinstallprompt', event => { + event.preventDefault(); + installPrompt = event; + notifyInstallPrompt(); + }); + window.addEventListener('appinstalled', () => { + installPrompt = null; + notifyInstallPrompt(); + }); + registerServiceWorker(); + + // Re-acquire the wake lock when the tab becomes visible again. + if (!wakeLockListenersBound) { + wakeLockListenersBound = true; + document.addEventListener('visibilitychange', () => { + if (wakeLockRequested && document.visibilityState === 'visible') { + requestWakeLock(); + } + }); + } +}; + +// Install eagerly so early user interactions benefit. +initDashPlatform(); + +export { + initDashPlatform, + isTouch, + isNarrow, + buzz, + requestWakeLock, + releaseWakeLock, + onInstallPrompt, + promptInstall, + registerServiceWorker +}; + diff --git a/src/lib/dash-settings.js b/src/lib/dash-settings.js new file mode 100644 index 000000000..ccad2d47e --- /dev/null +++ b/src/lib/dash-settings.js @@ -0,0 +1,69 @@ +/** + * Small persistent settings for the touch/mobile experience. + * Stored in localStorage; subscribers are notified on every change. + */ + +const STORAGE_KEY = 'dash-mobile-settings'; + +const DEFAULTS = { + haptics: true, + keepAwake: true, + compact: false +}; + +const listeners = new Set(); +let values = {...DEFAULTS}; +let loaded = false; + +const load = () => { + if (loaded || typeof localStorage === 'undefined') return; + loaded = true; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) values = {...DEFAULTS, ...JSON.parse(raw)}; + } catch (error) { + values = {...DEFAULTS}; + } +}; + +const notify = () => { + for (const listener of [...listeners]) listener(values); +}; + +const dashSettings = { + /** + * @param {string} key setting name + * @returns {*} current value + */ + get (key) { + load(); + return values[key]; + }, + /** + * @param {string} key setting name + * @param {*} value new value + */ + set (key, value) { + load(); + if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) return; + values = {...values, [key]: value}; + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(values)); + } catch (error) { + // Settings are optional; ignore quota/private-mode errors. + } + } + notify(); + }, + all () { + load(); + return {...values}; + }, + subscribe (listener) { + listeners.add(listener); + return () => listeners.delete(listener); + } +}; + +export default dashSettings; diff --git a/src/lib/drag-recognizer.js b/src/lib/drag-recognizer.js index ca2f4d4e6..30858b27b 100644 --- a/src/lib/drag-recognizer.js +++ b/src/lib/drag-recognizer.js @@ -1,5 +1,6 @@ import bindAll from 'lodash.bindall'; import {getEventXY} from '../lib/touch-utils'; +import {buzz} from './dash-platform.js'; class DragRecognizer { /* Gesture states */ @@ -98,6 +99,7 @@ class DragRecognizer { this._gestureState = DragRecognizer.STATE_SCROLL; } else { this._gestureState = DragRecognizer.STATE_DRAG; + buzz(8); } } else { // Mouse moves are always considered drags diff --git a/src/lib/screen-utils.js b/src/lib/screen-utils.js index 5bcd41589..7374c97a2 100644 --- a/src/lib/screen-utils.js +++ b/src/lib/screen-utils.js @@ -23,7 +23,11 @@ const STAGE_DIMENSION_DEFAULTS = { fullScreenSpacingBorderAdjustment: 8, // referencing css/units.css, // menuHeightAdjustment = $stage-menu-height - menuHeightAdjustment: 44 + menuHeightAdjustment: 44, + // referencing css/units.css, + // windowFitAdjustment = 2 * $space + 2 * $stage-standard-border-width + // (stage column padding + stage border; keeps the stage from overflowing narrow windows) + windowFitAdjustment: 18 }; /** @@ -84,6 +88,16 @@ const getStageDimensions = (stageSize, customStageSize, isFullScreen) => { stageDimensions.height = stageDimensions.scale * stageDimensions.heightDefault; stageDimensions.width = stageDimensions.scale * stageDimensions.widthDefault; } + + // Shrink to fit narrow windows (phones, split screen) instead of overflowing. + const maxWidth = (maxScaleParam ? + Math.min(window.innerWidth, maxScaleParam * customStageSize.width) : + window.innerWidth) - STAGE_DIMENSION_DEFAULTS.windowFitAdjustment; + if (stageDimensions.width > maxWidth) { + stageDimensions.scale *= maxWidth / stageDimensions.width; + stageDimensions.width = maxWidth; + stageDimensions.height = stageDimensions.scale * stageDimensions.heightDefault; + } } // Round off dimensions to prevent resampling/blurriness @@ -99,10 +113,8 @@ const getStageDimensions = (stageSize, customStageSize, isFullScreen) => { */ const getMinWidth = stageSize => { const metadata = STAGE_DISPLAY_SCALE_METADATA[stageSize]; - if (metadata.width) { - return metadata.width; - } - return FIXED_WIDTH * metadata.scale; + const minWidth = metadata.width ? metadata.width : FIXED_WIDTH * metadata.scale; + return Math.min(minWidth, window.innerWidth - STAGE_DIMENSION_DEFAULTS.windowFitAdjustment); }; /** diff --git a/src/lib/tab-swipe.js b/src/lib/tab-swipe.js new file mode 100644 index 000000000..616d6bf19 --- /dev/null +++ b/src/lib/tab-swipe.js @@ -0,0 +1,45 @@ +/** + * Swipe left/right handlers for switching editor tabs on touch screens. + * Horizontal swipes switch tabs; near-vertical swipes are left alone so the + * tab bar can still be scrolled past by page scrolling. + */ + +const SWIPE_MIN_DX = 40; +const SWIPE_MAX_DY = 30; + +/** + * @param {object} callbacks - {onSwipeLeft, onSwipeRight} called for a completed swipe + * @returns {object} touch handler props to spread onto a component + */ +const tabSwipeHandlers = callbacks => { + let startX = 0; + let startY = 0; + let tracking = false; + return { + onTouchStart: event => { + if (event.touches.length !== 1) { + tracking = false; + return; + } + const touch = event.touches[0]; + startX = touch.clientX; + startY = touch.clientY; + tracking = true; + }, + onTouchEnd: event => { + if (!tracking || !event.changedTouches.length) return; + tracking = false; + const touch = event.changedTouches[0]; + const dx = touch.clientX - startX; + const dy = touch.clientY - startY; + if (Math.abs(dx) < SWIPE_MIN_DX || Math.abs(dy) > SWIPE_MAX_DY) return; + if (dx < 0) { + callbacks.onSwipeLeft(); + } else { + callbacks.onSwipeRight(); + } + } + }; +}; + +export default tabSwipeHandlers; diff --git a/src/lib/workspace-gestures.js b/src/lib/workspace-gestures.js new file mode 100644 index 000000000..8faa5d0b8 --- /dev/null +++ b/src/lib/workspace-gestures.js @@ -0,0 +1,130 @@ +import Hammer from 'hammerjs'; +import {BLOCKS_DEFAULT_SCALE} from './layout-constants'; + +const SCALE_MIN = 0.25; +const SCALE_MAX = 4; + +const clampScale = scale => Math.min(SCALE_MAX, Math.max(SCALE_MIN, scale)); + +/** + * Pinch-to-zoom, two-finger pan and double-tap zoom for a scratch-blocks + * workspace on touch screens. One-finger gestures are left to scratch-blocks + * (block dragging and view panning) and to page scrolling. + */ +class WorkspaceGestures { + constructor (workspace, element) { + this.workspace = workspace; + this.element = element; + this.pinchStartScale = null; + this.panLast = null; + + this.manager = new Hammer.Manager(element, { + // Blocks are handled entirely in JS (Blockly), so the browser must + // not steal multi-touch for native page pinch-zoom. + touchAction: 'none', + inputClass: Hammer.TouchInput + }); + const pinch = new Hammer.Pinch(); + const pan = new Hammer.Pan({event: 'pan2', pointers: 2, threshold: 5}); + const doubleTap = new Hammer.Tap({event: 'doubletap', taps: 2}); + pinch.recognizeWith(pan); + this.manager.add([pinch, pan, doubleTap]); + + this.handlePinchStart = this.handlePinchStart.bind(this); + this.handlePinchMove = this.handlePinchMove.bind(this); + this.handlePinchEnd = this.handlePinchEnd.bind(this); + this.handlePanStart = this.handlePanStart.bind(this); + this.handlePanMove = this.handlePanMove.bind(this); + this.handlePanEnd = this.handlePanEnd.bind(this); + this.handleDoubleTap = this.handleDoubleTap.bind(this); + + this.manager.on('pinchstart', this.handlePinchStart); + this.manager.on('pinchmove', this.handlePinchMove); + this.manager.on('pinchend pinchcancel', this.handlePinchEnd); + this.manager.on('pan2start', this.handlePanStart); + this.manager.on('pan2move', this.handlePanMove); + this.manager.on('pan2end pan2cancel', this.handlePanEnd); + this.manager.on('doubletap', this.handleDoubleTap); + } + destroy () { + if (this.manager) this.manager.destroy(); + this.manager = null; + } + contentOrigin () { + // The workspace content origin on screen, matching the formula used + // by handleDrop/`presenceTransform` in containers/blocks.jsx. + const ws = this.workspace; + const scrollbar = ws && ws.scrollbar; + if (!scrollbar || !scrollbar.hScroll || !scrollbar.vScroll) return null; + return { + left: scrollbar.hScroll.outerSvg_.getBoundingClientRect().left, + top: scrollbar.vScroll.outerSvg_.getBoundingClientRect().top + }; + } + applyView () { + const ws = this.workspace; + ws.resize(); + } + /** + * Zoom to a scale while keeping the content point under (clientX, clientY) + * fixed on screen. + */ + zoomAround (clientX, clientY, nextScale) { + const ws = this.workspace; + const origin = this.contentOrigin(); + if (!ws || !origin) return; + const scale = ws.scale || 1; + const contentX = (clientX - origin.left - ws.scrollX) / scale; + const contentY = (clientY - origin.top - ws.scrollY) / scale; + const clamped = clampScale(nextScale); + ws.scale = clamped; + ws.scrollX = clientX - origin.left - contentX * clamped; + ws.scrollY = clientY - origin.top - contentY * clamped; + this.applyView(); + } + handlePinchStart () { + this.pinchStartScale = this.workspace.scale || 1; + } + handlePinchMove (event) { + if (!this.pinchStartScale) return; + if (event.srcEvent && event.srcEvent.preventDefault) event.srcEvent.preventDefault(); + this.zoomAround(event.center.x, event.center.y, this.pinchStartScale * event.scale); + } + handlePinchEnd () { + this.pinchStartScale = null; + } + handlePanStart (event) { + this.panLast = {x: event.center.x, y: event.center.y}; + } + handlePanMove (event) { + if (!this.panLast) return; + if (event.srcEvent && event.srcEvent.preventDefault) event.srcEvent.preventDefault(); + const ws = this.workspace; + ws.scrollX += event.center.x - this.panLast.x; + ws.scrollY += event.center.y - this.panLast.y; + this.panLast = {x: event.center.x, y: event.center.y}; + this.applyView(); + } + handlePanEnd () { + this.panLast = null; + } + handleDoubleTap (event) { + // Only zoom when tapping empty workspace, not a block (blocks use + // double-tap/click for their own interactions). + const target = event.srcEvent && event.srcEvent.target; + if (target && target.closest && target.closest('[data-id]')) return; + const scale = this.workspace.scale || 1; + const targetScale = scale < 0.95 ? 1 : BLOCKS_DEFAULT_SCALE; + this.zoomAround(event.center.x, event.center.y, targetScale); + } +} + +/** + * Attach touch gestures to a workspace. + * @param {Workspace} workspace scratch-blocks workspace + * @param {HTMLElement} element element that receives touch events + * @returns {WorkspaceGestures} call .destroy() to detach + */ +const enableWorkspaceGestures = (workspace, element) => new WorkspaceGestures(workspace, element); + +export default enableWorkspaceGestures; diff --git a/src/playground/embed.ejs b/src/playground/embed.ejs index 7c17f595a..d9fb838ea 100644 --- a/src/playground/embed.ejs +++ b/src/playground/embed.ejs @@ -18,7 +18,13 @@ - + + + + + + + <%= htmlWebpackPlugin.options.title %>