+ {!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 %>