diff --git a/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.css b/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.css
index 37e6acec36d..ddc23fe8bfb 100644
--- a/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.css
+++ b/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.css
@@ -1,12 +1,15 @@
@import "../../css/z-index.css";
-/* Position above the Scratch Blocks zoom controls.
- bottom = scrollbar(11) + MARGIN_BOTTOM(12) + HEIGHT(124) + gap(8) - 1 = 154px
- right = MARGIN_SIDE(12) + scrollbar(11) - 1 = 22px */
+/* Position is set at runtime by `blocks-screenshot-button.jsx` so the camera
+ button stays anchored to the actual scratch-blocks v2 zoom controls
+ regardless of scrollbar width or window resize. The CSS values below are
+ only used until the first measurement settles in (typically the next
+ animation frame), so they are sized to match the v2 zoom-controls layout
+ (MARGIN_VERTICAL=20, MARGIN_HORIZONTAL=20, total height 124, gap 8). */
.screenshotButton {
position: absolute;
- bottom: 154px;
- right: 22px;
+ bottom: 152px;
+ right: 20px;
width: 36px;
height: 36px;
padding: 0;
diff --git a/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.jsx b/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.jsx
index f6d031e1895..e945686d9e8 100644
--- a/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.jsx
+++ b/packages/scratch-gui/src/components/blocks-screenshot-button/blocks-screenshot-button.jsx
@@ -1,25 +1,100 @@
import PropTypes from 'prop-types';
-import React from 'react';
+import React, { useLayoutEffect, useRef } from 'react';
import cameraIcon from './icon--camera.svg';
import styles from './blocks-screenshot-button.css';
-const BlocksScreenshotButton = ({onClick}) => (
-
-);
+/** Visual gap between the camera button and the topmost zoom button. */
+const ZOOM_GAP_PX = 8;
+
+/**
+ * Camera button that exports the workspace blocks as a PNG image. Positioned
+ * directly above the scratch-blocks zoom controls. The position is measured
+ * at runtime (rather than fixed via CSS) so it survives:
+ * - scrollbar width differences across platforms (macOS overlay vs Windows
+ * classic scrollbars),
+ * - mobile and window-resize layout changes that move the zoom controls,
+ * - scratch-blocks v2's reflow that re-runs the zoom controls auto-layout.
+ * @param {object} props - Component props.
+ * @param {Function} props.onClick - Click handler for the export action.
+ * @returns {React.ReactElement} The screenshot button element.
+ */
+const BlocksScreenshotButton = ({ onClick }) => {
+ const buttonRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const button = buttonRef.current;
+ if (!button) return;
+
+ let raf = 0;
+ const updatePosition = () => {
+ const wrapper = button.offsetParent;
+ if (!wrapper) return;
+ // ScratchZoomControls (scratch-blocks v2) renders a `` with
+ // class `blocklyZoom blocklyZoomIn` that ends up at the top of
+ // the zoom column when the workspace is in the bottom corner.
+ const zoomIn = wrapper.querySelector('.blocklyZoomIn');
+ if (!zoomIn) return;
+ const zoomRect = zoomIn.getBoundingClientRect();
+ const wrapperRect = wrapper.getBoundingClientRect();
+ const right = Math.max(0, wrapperRect.right - zoomRect.right);
+ const bottom = Math.max(
+ 0,
+ wrapperRect.bottom - (zoomRect.top - ZOOM_GAP_PX),
+ );
+ button.style.right = `${right}px`;
+ button.style.bottom = `${bottom}px`;
+ };
+
+ const schedule = () => {
+ cancelAnimationFrame(raf);
+ raf = requestAnimationFrame(updatePosition);
+ };
+
+ // Initial measurement; retry a few frames in case the zoom controls
+ // haven't been mounted yet at this point in the workspace lifecycle.
+ let retries = 10;
+ const tryUpdate = () => {
+ updatePosition();
+ const wrapper = button.offsetParent;
+ const zoomIn = wrapper && wrapper.querySelector('.blocklyZoomIn');
+ if (!zoomIn && retries > 0) {
+ retries -= 1;
+ requestAnimationFrame(tryUpdate);
+ }
+ };
+ tryUpdate();
+
+ const wrapper = button.offsetParent;
+ const observer = new ResizeObserver(schedule);
+ if (wrapper) observer.observe(wrapper);
+ window.addEventListener('resize', schedule);
+
+ return () => {
+ cancelAnimationFrame(raf);
+ observer.disconnect();
+ window.removeEventListener('resize', schedule);
+ };
+ }, []);
+
+ return (
+
+ );
+};
BlocksScreenshotButton.propTypes = {
- onClick: PropTypes.func.isRequired
+ onClick: PropTypes.func.isRequired,
};
export default BlocksScreenshotButton;
diff --git a/packages/scratch-gui/src/containers/ruby-tab/ruby-tab.css b/packages/scratch-gui/src/containers/ruby-tab/ruby-tab.css
index 1723c604dc2..b78cbc6ad50 100644
--- a/packages/scratch-gui/src/containers/ruby-tab/ruby-tab.css
+++ b/packages/scratch-gui/src/containers/ruby-tab/ruby-tab.css
@@ -2,39 +2,15 @@
@import "../../css/units.css";
@import "../../css/z-index.css";
-.downloadButton {
- z-index: $z-index-add-button;
- width: 2.75rem;
- height: 2.75rem;
- border: none;
- border-radius: 100%;
- background-color: $looks-secondary;
- box-shadow: 0 0 0 4px $looks-transparent;
- display: flex;
- justify-content: center;
- align-items: center;
- cursor: pointer;
- padding: 0;
-}
-
-.downloadIcon {
- width: 28px;
- height: 28px;
-}
-
-.downloadWrapper {
- position: absolute;
- bottom: 22px;
- right: 70px; /* 22px (zoom margin) + 36px (zoom width) + 12px (gap) */
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
.zoomControlsWrapper {
position: absolute;
- bottom: 22px;
- right: 22px; /* Match Blockly zoom controls position */
+ /* Match the on-screen position of the Code tab's scratch-blocks v2 zoom
+ controls. ScratchZoomControls uses MARGIN_VERTICAL = MARGIN_HORIZONTAL = 20
+ inside the workspace's view metrics, which exclude the scratch-blocks
+ scrollbar (11px on desktop). Add that scrollbar offset here so the icons
+ sit at the same screen X/Y when switching tabs. */
+ bottom: 31px;
+ right: 31px;
display: flex;
flex-direction: column;
align-items: center;