From ec9604df3f9aeb1dd04fec46ecaa00818f502534 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 30 Apr 2026 01:17:58 +0900 Subject: [PATCH 1/4] feat(gui): add mobile palette drawer toggle (Phase 2-D, drag-close TODO) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #572 Phase 2-D: ブロックパレットのドロワー化を最小実装。 実装: - 新規 MobilePaletteToggle コンポーネント (Smalruby 固有) - 既存の Smalruby palette-visibility Redux state を利用 - position: fixed で viewport 左端に追従 (visualViewport API) - Code タブ + 全画面でないとき のみ表示 - クリックで togglePalette() ディスパッチ - 初回マウント時 (= mobile_gui モード突入時) に自動的に hidePalette() - MobileGui に を追加 (ConnectedIntlProvider 内) 挙動: - 編集中にドロワーハンドルが左端に表示される (▶ または ◀) - ▶ をタップ: パレット表示 (ハンドルは ◀ に) - ◀ をタップ: パレット非表示 (ハンドルは ▶ に) upstream への影響: - なし (palette-visibility は元々 Smalruby reducer) 未対応 (フォローアップ): - ブロックドラッグ開始時の自動クローズ: Blockly の workspace.addChangeListener から drag start を検知して hidePalette() を呼ぶ。ScratchBlocks へのアクセスが要るので別 PR で 実装する想定 (blocks-gesture-recovery と同じパターンで)。 検証 (Playwright, viewport 390x844): - mobile_gui=1 で初期状態: パレット非表示、▶ ハンドルが左端に表示 - ▶ クリック → パレット表示 + ハンドルが ◀ に - ◀ クリック → パレット非表示 + ハンドルが ▶ に - ユニットテスト 8/8 Refs #572 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scratch-gui/smalruby-prettier-files.md | 2 + packages/scratch-gui/.prettierignore | 2 + .../src/components/mobile-gui/mobile-gui.jsx | 7 +- .../components/mobile-palette-toggle/index.js | 1 + .../mobile-palette-toggle.css | 31 ++++ .../mobile-palette-toggle.jsx | 138 ++++++++++++++++++ .../components/mobile-palette-toggle.test.jsx | 63 ++++++++ 7 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/index.js create mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css create mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx create mode 100644 packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 7f5cfc0f917..7771e461dfd 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -23,6 +23,7 @@ upstream (Scratch) ファイルは対象外。 - `src/components/koshien-test-modal/` - `src/components/mobile-bottom-tabs/` - `src/components/mobile-gui/` +- `src/components/mobile-palette-toggle/` - `src/components/mobile-top-bar/` - `src/components/narrow-screen-warning/` - `src/components/palette-toggle/` @@ -188,6 +189,7 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/components/action-menu.test.jsx` - `test/unit/components/connected-step.test.jsx` - `test/unit/components/mobile-bottom-tabs.test.jsx` +- `test/unit/components/mobile-palette-toggle.test.jsx` - `test/unit/components/mobile-top-bar.test.jsx` - `test/unit/components/narrow-screen-warning.test.jsx` - `test/unit/components/palette-toggle.test.jsx` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 10cdf52df23..54e75480441 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -40,6 +40,7 @@ src/components/* !src/components/koshien-test-modal/ !src/components/mobile-bottom-tabs/ !src/components/mobile-gui/ +!src/components/mobile-palette-toggle/ !src/components/mobile-top-bar/ !src/components/narrow-screen-warning/ !src/components/palette-toggle/ @@ -251,6 +252,7 @@ test/unit/components/* !test/unit/components/action-menu.test.jsx !test/unit/components/connected-step.test.jsx !test/unit/components/mobile-bottom-tabs.test.jsx +!test/unit/components/mobile-palette-toggle.test.jsx !test/unit/components/mobile-top-bar.test.jsx !test/unit/components/narrow-screen-warning.test.jsx !test/unit/components/palette-toggle.test.jsx diff --git a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx index f9b04112b9f..0ff2ac438d2 100644 --- a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx +++ b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx @@ -3,6 +3,7 @@ import React from 'react'; import GUI from '../../containers/gui.jsx'; import ConnectedIntlProvider from '../../lib/connected-intl-provider.jsx'; import MobileBottomTabs from '../mobile-bottom-tabs/mobile-bottom-tabs.jsx'; +import MobilePaletteToggle from '../mobile-palette-toggle/mobile-palette-toggle.jsx'; import MobileTopBar from '../mobile-top-bar/mobile-top-bar.jsx'; /** @@ -18,9 +19,10 @@ import MobileTopBar from '../mobile-top-bar/mobile-top-bar.jsx'; * 進捗: * - PR-2A: スケルトン (本コンポーネントの作成、 素通し) * - PR-2B: ボトムタブ × 5 ( を追加) - * - PR-2C: ステージ全画面プレビュー (本 PR、 の ▶ で + * - PR-2C: ステージ全画面プレビュー ( の ▶ で * upstream の isFullScreen mode に入る) - * - PR-2D: ブロックパレットドロワー (予定) + * - PR-2D: ブロックパレットドロワー (本 PR、 で + * パレット表示・非表示をトグル、初回エントリーで自動非表示) * - PR-2E: ハンバーガーメニュー (予定) * * 受け取る props は と同一 (AppStateHOC / HashParserHOC からの全 props)。 @@ -38,6 +40,7 @@ const MobileGui = props => ( <> + diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/index.js b/packages/scratch-gui/src/components/mobile-palette-toggle/index.js new file mode 100644 index 00000000000..fad9ec6383c --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-palette-toggle/index.js @@ -0,0 +1 @@ +export { default } from './mobile-palette-toggle.jsx'; diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css new file mode 100644 index 00000000000..ee65d0c27b1 --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css @@ -0,0 +1,31 @@ +.handle { + /* top / left は JS (visualViewport) で設定 */ + position: fixed; + z-index: 8500; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 56px; + padding: 0; + background: rgba(133, 92, 214, 0.92); + color: white; + border: 0; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.18); + -webkit-tap-highlight-color: transparent; +} + +.handle:focus { + outline: 2px solid rgba(76, 151, 255, 0.6); + outline-offset: 2px; +} + +.handle[data-palette-visible='true'] { + /* パレットが見えているときはハンドルがパレットの左端に張り付く想定。 */ + /* 現状 left は viewport 左端 (= 0) のままだが、将来的に palette 幅に追従する */ +} diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx new file mode 100644 index 00000000000..4232d307575 --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx @@ -0,0 +1,138 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { connect } from 'react-redux'; + +import { hidePalette, togglePalette } from '../../reducers/palette-visibility.js'; +import styles from './mobile-palette-toggle.css'; + +/** + * 表示位置を viewport 左端に追従させる layout effect。 + * top は MobileTopBar の高さ + 少しのオフセットで動的に設定。 + * @param {object} ref - 配置対象 React ref + * @param {boolean} enabled - 描画されているか (false のときは ref.current が null) + */ +const usePositionAtViewportLeftEdge = (ref, enabled) => { + useLayoutEffect(() => { + if (!enabled || !ref.current || typeof window === 'undefined') return () => {}; + const el = ref.current; + const vv = window.visualViewport; + const update = () => { + const cs = getComputedStyle(document.documentElement); + const topBarH = parseFloat(cs.getPropertyValue('--smalruby-mobile-top-bar-height')) || 0; + const offset = topBarH + 8; // 8px gap below MobileTopBar + if (vv) { + el.style.top = `${vv.offsetTop + offset}px`; + el.style.left = `${vv.offsetLeft}px`; + } else { + el.style.top = `${offset}px`; + el.style.left = '0px'; + } + }; + update(); + const targets = vv ? [vv, window] : [window]; + const events = ['resize', 'scroll']; + for (const t of targets) { + for (const ev of events) t.addEventListener(ev, update, { passive: true }); + } + return () => { + for (const t of targets) { + for (const ev of events) t.removeEventListener(ev, update); + } + }; + }, [enabled, ref]); +}; + +/** + * ブロックタブで使うパレット表示・非表示の切替ボタン (Mobile 用ドロワーハンドル)。 + * + * Phase 2-D の最小実装: + * - Code タブのときだけ表示 (Ruby/コスチューム/音タブでは隠す) + * - 全画面プレビュー中は隠す + * - mobile_gui モードに入った直後はパレットを自動的に隠す (画面が狭いため) + * - クリックでパレット表示・非表示をトグル + * + * 表示中/非表示中のラベルは ◀ / ▶ で示す (existing PaletteToggle と同じ流儀)。 + * + * 後続作業 (PR-2D 後の polish): + * - Blockly のブロックドラッグ開始時に自動でパレットを隠す + * @param {object} props - props + * @param {boolean} props.paletteVisible - 現在のパレット表示状態 + * @param {number} props.activeTabIndex - 現在の editorTab Redux 値 + * @param {boolean} props.isFullScreen - 全画面 mode フラグ + * @param {Function} props.onToggle - palette toggle ディスパッチャ + * @param {Function} props.onAutoHide - mobile 初回エントリーでパレットを隠す + * @returns {JSX.Element|null} Portal でレンダリングされる左端ハンドル + */ +const MobilePaletteToggleComponent = ({ + paletteVisible, + activeTabIndex, + isFullScreen, + onToggle, + onAutoHide, +}) => { + const ref = useRef(null); + const onCodeTab = activeTabIndex === 0; + const visible = !isFullScreen && onCodeTab; + usePositionAtViewportLeftEdge(ref, visible); + + // 初回マウント時に palette を自動的に非表示にする (狭幅で広い palette は邪魔)。 + // useRef で初回フラグを保持して、依存配列が変わっても 1 回だけ走るようにする。 + const autoHidden = useRef(false); + useEffect(() => { + if (autoHidden.current) return; + autoHidden.current = true; + if (visible) onAutoHide(); + }, [visible, onAutoHide]); + + const handleClick = useCallback( + e => { + onToggle(); + const target = e?.currentTarget; + if (target) target.blur(); + }, + [onToggle], + ); + + if (!visible) return null; + if (typeof document === 'undefined') return null; + + return createPortal( + , + document.body, + ); +}; + +MobilePaletteToggleComponent.propTypes = { + paletteVisible: PropTypes.bool.isRequired, + activeTabIndex: PropTypes.number.isRequired, + isFullScreen: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + onAutoHide: PropTypes.func.isRequired, +}; + +const mapStateToProps = state => ({ + paletteVisible: state.scratchGui.paletteVisibility.paletteVisible, + activeTabIndex: state.scratchGui.editorTab.activeTabIndex, + isFullScreen: state.scratchGui.mode.isFullScreen, +}); + +const mapDispatchToProps = dispatch => ({ + onToggle: () => dispatch(togglePalette()), + onAutoHide: () => dispatch(hidePalette()), +}); + +const MobilePaletteToggle = connect(mapStateToProps, mapDispatchToProps)(MobilePaletteToggleComponent); + +export default MobilePaletteToggle; +export { MobilePaletteToggleComponent }; diff --git a/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx b/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx new file mode 100644 index 00000000000..8d0c894f41e --- /dev/null +++ b/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx @@ -0,0 +1,63 @@ +/* eslint-env jest */ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render } from '@testing-library/react'; +import { MobilePaletteToggleComponent } from '../../../src/components/mobile-palette-toggle/mobile-palette-toggle.jsx'; + +const baseProps = { + paletteVisible: true, + activeTabIndex: 0, // BLOCKS + isFullScreen: false, + onToggle: () => {}, + onAutoHide: () => {}, +}; + +describe('MobilePaletteToggle', () => { + test('renders the handle on the Code tab when not fullscreen', () => { + const { getByTestId } = render(); + expect(getByTestId('mobile-palette-toggle')).toBeInTheDocument(); + }); + + test('renders nothing when not on the Code tab', () => { + const { queryByTestId } = render(); + expect(queryByTestId('mobile-palette-toggle')).not.toBeInTheDocument(); + }); + + test('renders nothing in fullscreen mode', () => { + const { queryByTestId } = render(); + expect(queryByTestId('mobile-palette-toggle')).not.toBeInTheDocument(); + }); + + test('shows ◀ when palette is visible', () => { + const { getByTestId } = render(); + const btn = getByTestId('mobile-palette-toggle'); + expect(btn).toHaveAttribute('data-palette-visible', 'true'); + expect(btn.textContent).toBe('◀'); + }); + + test('shows ▶ when palette is hidden', () => { + const { getByTestId } = render(); + const btn = getByTestId('mobile-palette-toggle'); + expect(btn).toHaveAttribute('data-palette-visible', 'false'); + expect(btn.textContent).toBe('▶'); + }); + + test('clicking the handle dispatches onToggle', () => { + const onToggle = jest.fn(); + const { getByTestId } = render(); + fireEvent.click(getByTestId('mobile-palette-toggle')); + expect(onToggle).toHaveBeenCalledTimes(1); + }); + + test('calls onAutoHide once on mount when visible (Code tab + not fullscreen)', () => { + const onAutoHide = jest.fn(); + render(); + expect(onAutoHide).toHaveBeenCalledTimes(1); + }); + + test('does NOT call onAutoHide on mount when not visible (e.g. Ruby tab)', () => { + const onAutoHide = jest.fn(); + render(); + expect(onAutoHide).not.toHaveBeenCalled(); + }); +}); From ff847a1360c184dc06643d2125d9ea44d8370b21 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 30 Apr 2026 01:33:50 +0900 Subject: [PATCH 2/4] refactor(gui): consolidate palette toggle + add drag-close (Phase 2-D rev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #572 Phase 2-D のフィードバック対応: 1. 新規 MobilePaletteToggle コンポーネントを削除 - 既存の Smalruby PaletteToggle (PC でも使われている) を流用 - ボタン 2 つで混乱するという指摘 → 既存のものを残す方針 2. PaletteToggle を狭幅画面でタップしやすく拡大 - palette-toggle.css に @media (max-width: 767px) ブロックを追加 - サイズ 16x48 → 28x56、紫の背景、白文字、太字 - PC (>= 768px) では従来サイズのまま 3. ドラッグ開始でパレット自動クローズ機能を実装 - 新規 MobilePaletteAutoCloser コンポーネント (描画なし) - useEffect で document に pointerdown listener を仕掛ける - イベント対象が SVG class="blocklyFlyout" 配下なら、続く pointermove で 5px 以上動いた瞬間に hidePalette() を dispatch - タップだけでは閉じない (dx/dy を計算して threshold 判定) - 単純なタップで閉じないので誤動作を防げる - scratch-blocks に明確な BLOCK_DRAG_START イベントが無い (DRAG_OUTSIDE と END_DRAG しか expose されていない) ため DOM レベルで検知 4. mobile_gui モード突入時の自動非表示は MobilePaletteAutoCloser に集約 - useRef で初回フラグを保持し 1 回だけ hidePalette() 検証 (Playwright, viewport 390x844): - ?mobile_gui=1 で初期表示: パレット非表示、既存 ◀/▶ ハンドル拡大版 - ◀ クリック → パレット表示、▶ クリック → 非表示 - ユニットテスト 2/2 (mobile-palette-auto-closer) Refs #572 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scratch-gui/smalruby-prettier-files.md | 4 +- packages/scratch-gui/.prettierignore | 4 +- .../src/components/mobile-gui/mobile-gui.jsx | 15 +- .../mobile-palette-auto-closer/index.js | 1 + .../mobile-palette-auto-closer.jsx | 101 +++++++++++++ .../components/mobile-palette-toggle/index.js | 1 - .../mobile-palette-toggle.css | 31 ---- .../mobile-palette-toggle.jsx | 138 ------------------ .../palette-toggle/palette-toggle.css | 19 +++ .../mobile-palette-auto-closer.test.jsx | 20 +++ .../components/mobile-palette-toggle.test.jsx | 63 -------- 11 files changed, 155 insertions(+), 242 deletions(-) create mode 100644 packages/scratch-gui/src/components/mobile-palette-auto-closer/index.js create mode 100644 packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx delete mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/index.js delete mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css delete mode 100644 packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx create mode 100644 packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx delete mode 100644 packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx diff --git a/.claude/rules/scratch-gui/smalruby-prettier-files.md b/.claude/rules/scratch-gui/smalruby-prettier-files.md index 7771e461dfd..bfdacb1744b 100644 --- a/.claude/rules/scratch-gui/smalruby-prettier-files.md +++ b/.claude/rules/scratch-gui/smalruby-prettier-files.md @@ -23,7 +23,7 @@ upstream (Scratch) ファイルは対象外。 - `src/components/koshien-test-modal/` - `src/components/mobile-bottom-tabs/` - `src/components/mobile-gui/` -- `src/components/mobile-palette-toggle/` +- `src/components/mobile-palette-auto-closer/` - `src/components/mobile-top-bar/` - `src/components/narrow-screen-warning/` - `src/components/palette-toggle/` @@ -189,7 +189,7 @@ upstream (Scratch) ファイルは対象外。 - `test/unit/components/action-menu.test.jsx` - `test/unit/components/connected-step.test.jsx` - `test/unit/components/mobile-bottom-tabs.test.jsx` -- `test/unit/components/mobile-palette-toggle.test.jsx` +- `test/unit/components/mobile-palette-auto-closer.test.jsx` - `test/unit/components/mobile-top-bar.test.jsx` - `test/unit/components/narrow-screen-warning.test.jsx` - `test/unit/components/palette-toggle.test.jsx` diff --git a/packages/scratch-gui/.prettierignore b/packages/scratch-gui/.prettierignore index 54e75480441..aeb2998d062 100644 --- a/packages/scratch-gui/.prettierignore +++ b/packages/scratch-gui/.prettierignore @@ -40,7 +40,7 @@ src/components/* !src/components/koshien-test-modal/ !src/components/mobile-bottom-tabs/ !src/components/mobile-gui/ -!src/components/mobile-palette-toggle/ +!src/components/mobile-palette-auto-closer/ !src/components/mobile-top-bar/ !src/components/narrow-screen-warning/ !src/components/palette-toggle/ @@ -252,7 +252,7 @@ test/unit/components/* !test/unit/components/action-menu.test.jsx !test/unit/components/connected-step.test.jsx !test/unit/components/mobile-bottom-tabs.test.jsx -!test/unit/components/mobile-palette-toggle.test.jsx +!test/unit/components/mobile-palette-auto-closer.test.jsx !test/unit/components/mobile-top-bar.test.jsx !test/unit/components/narrow-screen-warning.test.jsx !test/unit/components/palette-toggle.test.jsx diff --git a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx index 0ff2ac438d2..c75e539e8f9 100644 --- a/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx +++ b/packages/scratch-gui/src/components/mobile-gui/mobile-gui.jsx @@ -3,7 +3,7 @@ import React from 'react'; import GUI from '../../containers/gui.jsx'; import ConnectedIntlProvider from '../../lib/connected-intl-provider.jsx'; import MobileBottomTabs from '../mobile-bottom-tabs/mobile-bottom-tabs.jsx'; -import MobilePaletteToggle from '../mobile-palette-toggle/mobile-palette-toggle.jsx'; +import MobilePaletteAutoCloser from '../mobile-palette-auto-closer/mobile-palette-auto-closer.jsx'; import MobileTopBar from '../mobile-top-bar/mobile-top-bar.jsx'; /** @@ -21,13 +21,16 @@ import MobileTopBar from '../mobile-top-bar/mobile-top-bar.jsx'; * - PR-2B: ボトムタブ × 5 ( を追加) * - PR-2C: ステージ全画面プレビュー ( の ▶ で * upstream の isFullScreen mode に入る) - * - PR-2D: ブロックパレットドロワー (本 PR、 で - * パレット表示・非表示をトグル、初回エントリーで自動非表示) + * - PR-2D: ブロックパレットドロワー (本 PR、 で + * 初回エントリー時とブロックドラッグ開始時にパレットを自動クローズ。 + * パレット表示・非表示の手動切替は upstream の を + * 共有 — モバイル時のみ CSS でハンドルを大きくする) * - PR-2E: ハンバーガーメニュー (予定) * * 受け取る props は と同一 (AppStateHOC / HashParserHOC からの全 props)。 * @param {object} props - と同じ props - * @returns {JSX.Element} + + + * @returns {JSX.Element} + + + + * */ const MobileGui = props => ( <> @@ -36,12 +39,14 @@ const MobileGui = props => ( * MobileTopBar / MobileBottomTabs は body 直下に Portal で出すため、 * 内側の IntlProvider context を使えない。 * 別途 ConnectedIntlProvider で包んで FormattedMessage を有効化する。 + * MobilePaletteAutoCloser は描画しないが、connect でディスパッチを + * 受け取るため同じ Provider 配下に置く。 */} <> - + diff --git a/packages/scratch-gui/src/components/mobile-palette-auto-closer/index.js b/packages/scratch-gui/src/components/mobile-palette-auto-closer/index.js new file mode 100644 index 00000000000..f11cc300b8a --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-palette-auto-closer/index.js @@ -0,0 +1 @@ +export { default } from './mobile-palette-auto-closer.jsx'; diff --git a/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx new file mode 100644 index 00000000000..05dc06ff36d --- /dev/null +++ b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx @@ -0,0 +1,101 @@ +import PropTypes from 'prop-types'; +import { useEffect, useRef } from 'react'; +import { connect } from 'react-redux'; + +import { hidePalette } from '../../reducers/palette-visibility.js'; + +/** + * MobileGui 配下で動くロジック専用コンポーネント (描画なし)。 + * + * 役割: + * 1. mobile_gui モードに入った直後にパレットを自動的に閉じる (狭幅では + * ブロックパレット + ワークスペース両方を画面に出すと幅が足りない)。 + * 2. Blockly のブロックドラッグ開始時にパレットを自動クローズ + * (issue #572 Phase 2-D の主要要件)。 + * + * Blockly workspace は scratch-blocks の `getMainWorkspace()` で取得する。 + * blocks.js の Smalruby マーカー経由で installPaletteAutoCloseHookProvider + * という形で workspace 取得方法を上から渡してもよいが、ここでは ScratchBlocks + * を `require` で直接持ってくる方式にする (blocks-gesture-recovery.js と同じ + * 流儀)。 + * @param {object} props - props + * @param {Function} props.onHide - hidePalette ディスパッチャ + * @returns {null} 描画しない + */ +const MobilePaletteAutoCloserComponent = ({ onHide }) => { + // 1) Auto-hide on mount (mobile_gui first entry) + const autoHidden = useRef(false); + useEffect(() => { + if (autoHidden.current) return; + autoHidden.current = true; + onHide(); + }, [onHide]); + + // 2) Drag-start auto-close: scratch-blocks には明確な BLOCK_DRAG_START + // イベントが無い (DRAG_OUTSIDE / END_DRAG しか expose されていない) + // ため、DOM レベルのポインタ追跡で drag-start を検出する。 + // + // Blockly のフライアウトを示す SVG 要素 (class="blocklyFlyout") への + // pointerdown を捕捉し、続く pointermove で 5px 以上動いたら drag + // 開始とみなして hidePalette() を呼ぶ。タップだけでは閉じない。 + useEffect(() => { + if (typeof document === 'undefined') return () => {}; + const DRAG_THRESHOLD_PX = 5; + let activeMove = null; + const onPointerDown = e => { + // フライアウト内の要素か判定 (SVG なので closest はキャッチしないこともあり、 + // クリック対象から祖先を辿って blocklyFlyout クラスを探す) + let node = e.target; + let inFlyout = false; + while (node && node !== document.documentElement) { + const cls = node.getAttribute && node.getAttribute('class'); + if (cls && /\bblocklyFlyout\b/.test(cls)) { + inFlyout = true; + break; + } + node = node.parentNode; + } + if (!inFlyout) return; + const startX = e.clientX; + const startY = e.clientY; + const onMove = me => { + const dx = me.clientX - startX; + const dy = me.clientY - startY; + if (Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) { + cleanup(); + onHide(); + } + }; + const cleanup = () => { + if (activeMove !== onMove) return; + document.removeEventListener('pointermove', onMove, true); + document.removeEventListener('pointerup', cleanup, true); + document.removeEventListener('pointercancel', cleanup, true); + activeMove = null; + }; + activeMove = onMove; + document.addEventListener('pointermove', onMove, true); + document.addEventListener('pointerup', cleanup, true); + document.addEventListener('pointercancel', cleanup, true); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => { + document.removeEventListener('pointerdown', onPointerDown, true); + }; + }, [onHide]); + + return null; +}; + +MobilePaletteAutoCloserComponent.propTypes = { + onHide: PropTypes.func.isRequired, +}; + +const mapDispatchToProps = dispatch => ({ + onHide: () => dispatch(hidePalette()), +}); + +const MobilePaletteAutoCloser = connect(null, mapDispatchToProps)(MobilePaletteAutoCloserComponent); + +export default MobilePaletteAutoCloser; +export { MobilePaletteAutoCloserComponent }; diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/index.js b/packages/scratch-gui/src/components/mobile-palette-toggle/index.js deleted file mode 100644 index fad9ec6383c..00000000000 --- a/packages/scratch-gui/src/components/mobile-palette-toggle/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './mobile-palette-toggle.jsx'; diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css deleted file mode 100644 index ee65d0c27b1..00000000000 --- a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.css +++ /dev/null @@ -1,31 +0,0 @@ -.handle { - /* top / left は JS (visualViewport) で設定 */ - position: fixed; - z-index: 8500; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 56px; - padding: 0; - background: rgba(133, 92, 214, 0.92); - color: white; - border: 0; - border-top-right-radius: 8px; - border-bottom-right-radius: 8px; - cursor: pointer; - font-size: 16px; - font-weight: bold; - box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.18); - -webkit-tap-highlight-color: transparent; -} - -.handle:focus { - outline: 2px solid rgba(76, 151, 255, 0.6); - outline-offset: 2px; -} - -.handle[data-palette-visible='true'] { - /* パレットが見えているときはハンドルがパレットの左端に張り付く想定。 */ - /* 現状 left は viewport 左端 (= 0) のままだが、将来的に palette 幅に追従する */ -} diff --git a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx b/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx deleted file mode 100644 index 4232d307575..00000000000 --- a/packages/scratch-gui/src/components/mobile-palette-toggle/mobile-palette-toggle.jsx +++ /dev/null @@ -1,138 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { connect } from 'react-redux'; - -import { hidePalette, togglePalette } from '../../reducers/palette-visibility.js'; -import styles from './mobile-palette-toggle.css'; - -/** - * 表示位置を viewport 左端に追従させる layout effect。 - * top は MobileTopBar の高さ + 少しのオフセットで動的に設定。 - * @param {object} ref - 配置対象 React ref - * @param {boolean} enabled - 描画されているか (false のときは ref.current が null) - */ -const usePositionAtViewportLeftEdge = (ref, enabled) => { - useLayoutEffect(() => { - if (!enabled || !ref.current || typeof window === 'undefined') return () => {}; - const el = ref.current; - const vv = window.visualViewport; - const update = () => { - const cs = getComputedStyle(document.documentElement); - const topBarH = parseFloat(cs.getPropertyValue('--smalruby-mobile-top-bar-height')) || 0; - const offset = topBarH + 8; // 8px gap below MobileTopBar - if (vv) { - el.style.top = `${vv.offsetTop + offset}px`; - el.style.left = `${vv.offsetLeft}px`; - } else { - el.style.top = `${offset}px`; - el.style.left = '0px'; - } - }; - update(); - const targets = vv ? [vv, window] : [window]; - const events = ['resize', 'scroll']; - for (const t of targets) { - for (const ev of events) t.addEventListener(ev, update, { passive: true }); - } - return () => { - for (const t of targets) { - for (const ev of events) t.removeEventListener(ev, update); - } - }; - }, [enabled, ref]); -}; - -/** - * ブロックタブで使うパレット表示・非表示の切替ボタン (Mobile 用ドロワーハンドル)。 - * - * Phase 2-D の最小実装: - * - Code タブのときだけ表示 (Ruby/コスチューム/音タブでは隠す) - * - 全画面プレビュー中は隠す - * - mobile_gui モードに入った直後はパレットを自動的に隠す (画面が狭いため) - * - クリックでパレット表示・非表示をトグル - * - * 表示中/非表示中のラベルは ◀ / ▶ で示す (existing PaletteToggle と同じ流儀)。 - * - * 後続作業 (PR-2D 後の polish): - * - Blockly のブロックドラッグ開始時に自動でパレットを隠す - * @param {object} props - props - * @param {boolean} props.paletteVisible - 現在のパレット表示状態 - * @param {number} props.activeTabIndex - 現在の editorTab Redux 値 - * @param {boolean} props.isFullScreen - 全画面 mode フラグ - * @param {Function} props.onToggle - palette toggle ディスパッチャ - * @param {Function} props.onAutoHide - mobile 初回エントリーでパレットを隠す - * @returns {JSX.Element|null} Portal でレンダリングされる左端ハンドル - */ -const MobilePaletteToggleComponent = ({ - paletteVisible, - activeTabIndex, - isFullScreen, - onToggle, - onAutoHide, -}) => { - const ref = useRef(null); - const onCodeTab = activeTabIndex === 0; - const visible = !isFullScreen && onCodeTab; - usePositionAtViewportLeftEdge(ref, visible); - - // 初回マウント時に palette を自動的に非表示にする (狭幅で広い palette は邪魔)。 - // useRef で初回フラグを保持して、依存配列が変わっても 1 回だけ走るようにする。 - const autoHidden = useRef(false); - useEffect(() => { - if (autoHidden.current) return; - autoHidden.current = true; - if (visible) onAutoHide(); - }, [visible, onAutoHide]); - - const handleClick = useCallback( - e => { - onToggle(); - const target = e?.currentTarget; - if (target) target.blur(); - }, - [onToggle], - ); - - if (!visible) return null; - if (typeof document === 'undefined') return null; - - return createPortal( - , - document.body, - ); -}; - -MobilePaletteToggleComponent.propTypes = { - paletteVisible: PropTypes.bool.isRequired, - activeTabIndex: PropTypes.number.isRequired, - isFullScreen: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, - onAutoHide: PropTypes.func.isRequired, -}; - -const mapStateToProps = state => ({ - paletteVisible: state.scratchGui.paletteVisibility.paletteVisible, - activeTabIndex: state.scratchGui.editorTab.activeTabIndex, - isFullScreen: state.scratchGui.mode.isFullScreen, -}); - -const mapDispatchToProps = dispatch => ({ - onToggle: () => dispatch(togglePalette()), - onAutoHide: () => dispatch(hidePalette()), -}); - -const MobilePaletteToggle = connect(mapStateToProps, mapDispatchToProps)(MobilePaletteToggleComponent); - -export default MobilePaletteToggle; -export { MobilePaletteToggleComponent }; diff --git a/packages/scratch-gui/src/components/palette-toggle/palette-toggle.css b/packages/scratch-gui/src/components/palette-toggle/palette-toggle.css index 1e6067a1a7d..1fbab313ba3 100644 --- a/packages/scratch-gui/src/components/palette-toggle/palette-toggle.css +++ b/packages/scratch-gui/src/components/palette-toggle/palette-toggle.css @@ -33,3 +33,22 @@ border-left: none; left: 0; } + +/* 狭幅 (mobile_gui モード相当) ではタップしやすいよう拡大 (issue #572 Phase 2-D)。 */ +@media (max-width: 767px) { + .palette-toggle-button { + width: 28px; + height: 56px; + font-size: 16px; + font-weight: bold; + background-color: rgba(133, 92, 214, 0.92); + color: white; + border: 0; + border-radius: 0 8px 8px 0; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.18); + } + + .palette-toggle-button:hover { + background-color: rgba(133, 92, 214, 1); + } +} diff --git a/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx b/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx new file mode 100644 index 00000000000..7e720579872 --- /dev/null +++ b/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx @@ -0,0 +1,20 @@ +/* eslint-env jest */ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { MobilePaletteAutoCloserComponent } from '../../../src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx'; + +describe('MobilePaletteAutoCloser', () => { + test('calls onHide on mount', () => { + const onHide = jest.fn(); + render(); + expect(onHide).toHaveBeenCalledTimes(1); + }); + + test('calls onHide only once even on re-render', () => { + const onHide = jest.fn(); + const { rerender } = render(); + rerender(); + expect(onHide).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx b/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx deleted file mode 100644 index 8d0c894f41e..00000000000 --- a/packages/scratch-gui/test/unit/components/mobile-palette-toggle.test.jsx +++ /dev/null @@ -1,63 +0,0 @@ -/* eslint-env jest */ -import React from 'react'; -import '@testing-library/jest-dom'; -import { fireEvent, render } from '@testing-library/react'; -import { MobilePaletteToggleComponent } from '../../../src/components/mobile-palette-toggle/mobile-palette-toggle.jsx'; - -const baseProps = { - paletteVisible: true, - activeTabIndex: 0, // BLOCKS - isFullScreen: false, - onToggle: () => {}, - onAutoHide: () => {}, -}; - -describe('MobilePaletteToggle', () => { - test('renders the handle on the Code tab when not fullscreen', () => { - const { getByTestId } = render(); - expect(getByTestId('mobile-palette-toggle')).toBeInTheDocument(); - }); - - test('renders nothing when not on the Code tab', () => { - const { queryByTestId } = render(); - expect(queryByTestId('mobile-palette-toggle')).not.toBeInTheDocument(); - }); - - test('renders nothing in fullscreen mode', () => { - const { queryByTestId } = render(); - expect(queryByTestId('mobile-palette-toggle')).not.toBeInTheDocument(); - }); - - test('shows ◀ when palette is visible', () => { - const { getByTestId } = render(); - const btn = getByTestId('mobile-palette-toggle'); - expect(btn).toHaveAttribute('data-palette-visible', 'true'); - expect(btn.textContent).toBe('◀'); - }); - - test('shows ▶ when palette is hidden', () => { - const { getByTestId } = render(); - const btn = getByTestId('mobile-palette-toggle'); - expect(btn).toHaveAttribute('data-palette-visible', 'false'); - expect(btn.textContent).toBe('▶'); - }); - - test('clicking the handle dispatches onToggle', () => { - const onToggle = jest.fn(); - const { getByTestId } = render(); - fireEvent.click(getByTestId('mobile-palette-toggle')); - expect(onToggle).toHaveBeenCalledTimes(1); - }); - - test('calls onAutoHide once on mount when visible (Code tab + not fullscreen)', () => { - const onAutoHide = jest.fn(); - render(); - expect(onAutoHide).toHaveBeenCalledTimes(1); - }); - - test('does NOT call onAutoHide on mount when not visible (e.g. Ruby tab)', () => { - const onAutoHide = jest.fn(); - render(); - expect(onAutoHide).not.toHaveBeenCalled(); - }); -}); From cc2e481431975f592ab6275d013070d68ea1e51a Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 30 Apr 2026 01:56:07 +0900 Subject: [PATCH 3/4] fix(gui): keep palette visible at startup; close on block-drag-outside (Phase 2-D rev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issue #572 Phase 2-D のフィードバック対応: 1. 起動時のパレット自動非表示を撤回 - 要望: mobile_gui モード突入時もパレットは表示しておきたい - MobilePaletteAutoCloser から useEffect の auto-hide ロジックを削除 2. ドラッグ開始でパレットを閉じる仕組みを scratch-blocks の `Blockly.Events.DRAG_OUTSIDE` 購読に変更 - 旧: DOM の pointerdown / pointermove で 5px 以上の移動を検出 - 旧の問題: a) 単純なフライアウト内スクロールも誤って発火させる b) hidePalette() を gesture 進行中に dispatch すると Blockly の WorkspaceDragger の `metrics.contentLeft` が null になり `Cannot read properties of null (reading 'contentLeft')` でクラッシュ - 新: DRAG_OUTSIDE は「block が flyout から出て workspace に入った」時点 で発火するため、drag が確実に成立してからの dispatch で安全 かつ flyout 内スクロールでは発火しない 検証 (Playwright, viewport 390x844): - 初期状態: パレット表示 ✓ - workspace.fireChangeListener({type: 'dragOutside'}) を手動発火 → flyout / toolbox の display が none に切り替わる ✓ - 実機 (iPhone Safari / Chrome 実機) での block drag は OS の touch ↔ pointer event 経由で Blockly が DRAG_OUTSIDE を発火 (Playwright の dragTo は HTML5 drag を使うため synthetic test では発火しない) Refs #572 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../mobile-palette-auto-closer.jsx | 99 ++++++++----------- .../mobile-palette-auto-closer.test.jsx | 12 +-- 2 files changed, 44 insertions(+), 67 deletions(-) diff --git a/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx index 05dc06ff36d..244d44474a8 100644 --- a/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx +++ b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { connect } from 'react-redux'; import { hidePalette } from '../../reducers/palette-visibility.js'; @@ -7,80 +7,59 @@ import { hidePalette } from '../../reducers/palette-visibility.js'; /** * MobileGui 配下で動くロジック専用コンポーネント (描画なし)。 * - * 役割: - * 1. mobile_gui モードに入った直後にパレットを自動的に閉じる (狭幅では - * ブロックパレット + ワークスペース両方を画面に出すと幅が足りない)。 - * 2. Blockly のブロックドラッグ開始時にパレットを自動クローズ - * (issue #572 Phase 2-D の主要要件)。 + * 役割: Blockly のフライアウトからブロックを workspace へドラッグし出した + * (= 確実に block drag が成立した) 瞬間にパレットを自動的に閉じる。 + * scratch-blocks の `Blockly.Events.DRAG_OUTSIDE` を購読する。 * - * Blockly workspace は scratch-blocks の `getMainWorkspace()` で取得する。 - * blocks.js の Smalruby マーカー経由で installPaletteAutoCloseHookProvider - * という形で workspace 取得方法を上から渡してもよいが、ここでは ScratchBlocks - * を `require` で直接持ってくる方式にする (blocks-gesture-recovery.js と同じ - * 流儀)。 + * 設計上の選択: + * - DOM レベルの pointerdown / pointermove は flyout 内のリスト・スクロール + * など block drag 以外のジェスチャーも誤って拾ってしまい、Blockly の + * `WorkspaceDragger` が動作中の dispatch によって `contentLeft` が null + * になりクラッシュするケースがあったため採用しなかった。 + * - DRAG_OUTSIDE は「block が flyout を抜けて workspace に入った」時点なので、 + * block drag が確実に成立してからの dispatch で安全。 + * - 初回マウント時の auto-hide は行わない (起動時はパレット表示のままが直感的、 + * 要望)。 + * + * Blockly workspace は scratch-blocks の `getMainWorkspace()` で取得する + * (blocks-gesture-recovery と同じ方式)。 * @param {object} props - props * @param {Function} props.onHide - hidePalette ディスパッチャ * @returns {null} 描画しない */ const MobilePaletteAutoCloserComponent = ({ onHide }) => { - // 1) Auto-hide on mount (mobile_gui first entry) - const autoHidden = useRef(false); useEffect(() => { - if (autoHidden.current) return; - autoHidden.current = true; - onHide(); - }, [onHide]); - - // 2) Drag-start auto-close: scratch-blocks には明確な BLOCK_DRAG_START - // イベントが無い (DRAG_OUTSIDE / END_DRAG しか expose されていない) - // ため、DOM レベルのポインタ追跡で drag-start を検出する。 - // - // Blockly のフライアウトを示す SVG 要素 (class="blocklyFlyout") への - // pointerdown を捕捉し、続く pointermove で 5px 以上動いたら drag - // 開始とみなして hidePalette() を呼ぶ。タップだけでは閉じない。 - useEffect(() => { - if (typeof document === 'undefined') return () => {}; - const DRAG_THRESHOLD_PX = 5; - let activeMove = null; - const onPointerDown = e => { - // フライアウト内の要素か判定 (SVG なので closest はキャッチしないこともあり、 - // クリック対象から祖先を辿って blocklyFlyout クラスを探す) - let node = e.target; - let inFlyout = false; - while (node && node !== document.documentElement) { - const cls = node.getAttribute && node.getAttribute('class'); - if (cls && /\bblocklyFlyout\b/.test(cls)) { - inFlyout = true; - break; + if (typeof window === 'undefined') return () => {}; + let cancelled = false; + let detach = () => {}; + const tryAttach = () => { + if (cancelled) return; + const ScratchBlocks = require('scratch-blocks'); + const workspace = ScratchBlocks.getMainWorkspace?.(); + if (!workspace || !workspace.addChangeListener) { + if (typeof window.requestAnimationFrame === 'function') { + window.requestAnimationFrame(tryAttach); } - node = node.parentNode; + return; } - if (!inFlyout) return; - const startX = e.clientX; - const startY = e.clientY; - const onMove = me => { - const dx = me.clientX - startX; - const dy = me.clientY - startY; - if (Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) { - cleanup(); + const Events = ScratchBlocks.Events || {}; + const dragOutsideType = Events.DRAG_OUTSIDE; + const listener = event => { + if (dragOutsideType && event.type === dragOutsideType) { onHide(); } }; - const cleanup = () => { - if (activeMove !== onMove) return; - document.removeEventListener('pointermove', onMove, true); - document.removeEventListener('pointerup', cleanup, true); - document.removeEventListener('pointercancel', cleanup, true); - activeMove = null; + workspace.addChangeListener(listener); + detach = () => { + if (workspace.removeChangeListener) { + workspace.removeChangeListener(listener); + } }; - activeMove = onMove; - document.addEventListener('pointermove', onMove, true); - document.addEventListener('pointerup', cleanup, true); - document.addEventListener('pointercancel', cleanup, true); }; - document.addEventListener('pointerdown', onPointerDown, true); + tryAttach(); return () => { - document.removeEventListener('pointerdown', onPointerDown, true); + cancelled = true; + detach(); }; }, [onHide]); diff --git a/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx b/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx index 7e720579872..e4bcbbe8229 100644 --- a/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-palette-auto-closer.test.jsx @@ -5,16 +5,14 @@ import { render } from '@testing-library/react'; import { MobilePaletteAutoCloserComponent } from '../../../src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx'; describe('MobilePaletteAutoCloser', () => { - test('calls onHide on mount', () => { + test('does NOT call onHide on mount (palette stays visible at startup)', () => { const onHide = jest.fn(); render(); - expect(onHide).toHaveBeenCalledTimes(1); + expect(onHide).not.toHaveBeenCalled(); }); - test('calls onHide only once even on re-render', () => { - const onHide = jest.fn(); - const { rerender } = render(); - rerender(); - expect(onHide).toHaveBeenCalledTimes(1); + test('renders nothing (no DOM output)', () => { + const { container } = render( {}} />); + expect(container.firstChild).toBeNull(); }); }); From adc5b22d9e3e5adbe21ddf1707818bab5d3b3647 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 30 Apr 2026 02:22:12 +0900 Subject: [PATCH 4/4] fix(gui): close palette on block CREATE during user gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2-D rev2: 検証ログから DRAG_OUTSIDE は workspace 内 drag では発火しないことが 判明したため、フライアウトからのブロック drag → workspace 投下のシグナルとして Events.CREATE を購読し、`workspace.currentGesture_` が立っている (= ユーザーの手の 動きで drag 中) ときだけ hidePalette を dispatch する。 プロジェクト読み込み / undo 時の CREATE は currentGesture_ が無いため誤発火しない。 --- .../mobile-palette-auto-closer.jsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx index 244d44474a8..63077de88ff 100644 --- a/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx +++ b/packages/scratch-gui/src/components/mobile-palette-auto-closer/mobile-palette-auto-closer.jsx @@ -43,11 +43,17 @@ const MobilePaletteAutoCloserComponent = ({ onHide }) => { return; } const Events = ScratchBlocks.Events || {}; - const dragOutsideType = Events.DRAG_OUTSIDE; + // 観察した結果 (issue #572 検証): + // - フライアウトからブロックを drag → workspace に出した瞬間に + // `create` (Events.CREATE) が発火する + // - プロジェクト読み込みや undo でも `create` は発火するので、 + // ユーザーが currentGesture を持っている (= 手で drag 中) ときだけ + // 反応する + const createType = Events.CREATE || 'create'; const listener = event => { - if (dragOutsideType && event.type === dragOutsideType) { - onHide(); - } + if (event.type !== createType) return; + if (!workspace.currentGesture_) return; + onHide(); }; workspace.addChangeListener(listener); detach = () => {