Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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}) => (
<button
className={styles.screenshotButton}
title="命令ブロックを画像として保存"
onClick={onClick}
>
<img
alt="命令ブロックを画像として保存"
className={styles.screenshotIcon}
draggable={false}
src={cameraIcon}
/>
</button>
);
/** 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 `<g>` 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 (
<button
ref={buttonRef}
className={styles.screenshotButton}
title="命令ブロックを画像として保存"
onClick={onClick}
>
<img
alt="命令ブロックを画像として保存"
className={styles.screenshotIcon}
draggable={false}
src={cameraIcon}
/>
</button>
);
};

BlocksScreenshotButton.propTypes = {
onClick: PropTypes.func.isRequired
onClick: PropTypes.func.isRequired,
};

export default BlocksScreenshotButton;
38 changes: 7 additions & 31 deletions packages/scratch-gui/src/containers/ruby-tab/ruby-tab.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading