From c569c85b844051013051d84cbd78ce91e0bd9e96 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Tue, 5 May 2026 19:50:02 +0900 Subject: [PATCH] fix(zoom-ui): align screenshot button + Ruby zoom column with scratch-blocks v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scratch-blocks v2's `ScratchZoomControls` uses MARGIN_VERTICAL = MARGIN_HORIZONTAL = 20 (vs v1's 12) inside the workspace's view metrics (which exclude the 11px scratch-blocks scrollbar). The Code tab camera button was hard-coded at right=22/bottom=154 — values tuned for v1 — so it drifted off the v2 zoom column on desktop and shifted further on mobile / window resize because scrollbar width is platform dependent. The Ruby tab's zoom column was at right=22/bottom=22, which sat 11px inboard of the Code tab's actual on-screen position, producing a visible jump when switching tabs. Fixes: - `BlocksScreenshotButton` now measures the live `.blocklyZoomIn` position via `useLayoutEffect` + `ResizeObserver` and pins the camera button 8px above its top edge, with right edges flush. Survives scrollbar variance, mobile orientation, and any v2 zoom-controls reflow. CSS keeps right=20/bottom=152 as a one-frame fallback that matches the v2 layout (MARGIN(20) + ZOOM_HEIGHT(124) + GAP(8)). - `ruby-tab.css` `.zoomControlsWrapper` moves to right=31/bottom=31 (= MARGIN(20) + scratch-blocks scrollbar(11)) so the Ruby tab column lands at the same screen X/Y as the Code tab's zoom controls. Verified with Playwright at desktop and 800/812-wide viewports — camera buttons match within 1px. Removes orphan `.downloadButton` / `.downloadIcon` / `.downloadWrapper` rules from ruby-tab.css; ruby-tab.jsx hasn't used them since the camera moved into `zoomControlsWrapper`. --- .../blocks-screenshot-button.css | 13 ++- .../blocks-screenshot-button.jsx | 107 +++++++++++++++--- .../src/containers/ruby-tab/ruby-tab.css | 38 ++----- 3 files changed, 106 insertions(+), 52 deletions(-) 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;