Skip to content

feat: 拡張機能ライブラリにデフォルト非表示機能を追加 #18

Description

@takaokouji

概要

scratch.mit.edu で実装されている拡張機能のうち、スモウルビーでの対応が十分ではないものや、現在は入手が難しいデバイスの拡張機能をデフォルト状態で非表示にする機能を追加します。

拡張機能モーダルのタイトルバーに「すべての拡張機能を表示」チェックボックスを追加し、ユーザーがチェックを入れると非表示の拡張機能も表示されるようにします。この設定は Redux で状態管理するとともに、ローカルストレージに保存して次回起動時にも設定を引き継げるようにします。

背景

現在、以下の拡張機能が表示されていますが、一部はスモウルビーでの対応が不十分、またはデバイスの入手が困難です:

  • Makey Makey
  • micro:bit (標準版)
  • LEGO MINDSTORMS EV3
  • LEGO BOOST
  • LEGO Education WeDo 2.0
  • Go Direct Force & Acceleration

これらをデフォルトで非表示にすることで、ユーザーが利用可能な拡張機能に集中できるようにします。

仕様

デフォルトで表示する拡張機能

以下の拡張機能のみをデフォルトで表示:

  • Music (music)
  • Pen (pen)
  • Video Sensing (videoSensing)
  • Text to Speech (text2speech)
  • Translate (translate)
  • Mesh (mesh)
  • Smalrubot S1 (smalrubotS1)
  • Microbit More (microbitMore) - v2-0.2.5
  • Smalruby Koshien (koshien)

デフォルトで非表示にする拡張機能

以下の拡張機能をデフォルトで非表示:

  • Makey Makey (makeymakey)
  • micro:bit (microbit)
  • LEGO MINDSTORMS EV3 (ev3)
  • LEGO BOOST (boost)
  • LEGO Education WeDo 2.0 (wedo2)
  • Go Direct Force & Acceleration (gdxfor)

UI 仕様

拡張機能モーダルのタイトルバーに以下のチェックボックスを追加:

[ ] すべての拡張機能を表示

配置位置:

  • タイトルとCloseボタンの間

動作:

  • チェックを入れると、非表示の拡張機能も表示される
  • チェックを外すと、デフォルト表示の拡張機能のみ表示される
  • チェック状態はローカルストレージに保存され、次回起動時にも引き継がれる

多言語対応

react-intl の FormattedMessage を使用して多言語対応:

<FormattedMessage
    defaultMessage="Show all extensions"
    description="Checkbox label to show all extensions including hidden ones"
    id="gui.extensionLibrary.showAllExtensions"
/>

アーキテクチャ改善:headerActions パターン

現在の問題点

既存の実装(および最初の提案)では、Modal コンポーネントに拡張機能固有のロジック(チェックボックス、リロードボタンなど)を直接追加しています:

// 問題のあるアプローチ
const ModalComponent = props => (
    <div className={styles.header}>
        {/* Modal が拡張機能固有のロジックを知っている */}
        {props.showAllExtensionsCheckbox ? <checkbox> : null}
        {props.onReload ? <reload button> : null}
    </div>
);

問題点:

  • ❌ Modal が特定のアクションの知識を持つ
  • ❌ 新しいアクションを追加するたびに Modal を修正が必要
  • ❌ 使用しないモーダルでも不要な props を受け取る
  • ❌ メンテナンス性が低い

改善案:headerActions Props パターン

Modal をジェネリックなコンポーネントとして保ち、各モーダルが自分のカスタムアクションを ReactNode として渡す方法を採用します。

メリット:

  • ✅ Modal はジェネリックで、特定のアクションの知識を持たない
  • ✅ 各モーダルが自分のアクションを完全にコントロール
  • ✅ 新しいアクションを追加しても Modal を変更する必要がない
  • ✅ 既存の Reload/Stop ボタンも同じパターンで統一できる
  • ✅ テスト性が向上(各モーダルが独立してテスト可能)

実装概要

// 改善されたアプローチ
const ModalComponent = props => (
    <div className={styles.header}>
        {props.onHelp ? <help button> : null}
        <div className={styles.headerItemTitle}>{props.contentLabel}</div>

        {/* ジェネリックな headerActions - 各モーダルが定義 */}
        {props.headerActions}

        <div className={styles.headerItemClose}>
            <close button>
        </div>
    </div>
);

各モーダルが自分のアクションを定義:

// LibraryComponent の例
const headerActions = (
    <label className={styles.showAllExtensionsLabel}>
        <input type="checkbox" ... />
        <FormattedMessage ... />
    </label>
);

<Modal headerActions={headerActions}>

// KoshienTestModal の例
const headerActions = (
    <Button onClick={handleReload}>
        <FormattedMessage id="gui.modal.reload" />
    </Button>
);

<Modal headerActions={headerActions}>

実装詳細

1. Redux Reducer/Actions の追加

ファイル: gui/smalruby3-gui/src/reducers/extension-filter.js (新規作成)

const TOGGLE_SHOW_ALL_EXTENSIONS = 'scratch-gui/extension-filter/TOGGLE_SHOW_ALL_EXTENSIONS';

const SHOW_ALL_EXTENSIONS_KEY = 'smalruby:showAllExtensions';
const savedShowAllExtensions = typeof window !== 'undefined' && window.localStorage ?
    window.localStorage.getItem(SHOW_ALL_EXTENSIONS_KEY) === 'true' : false;

const initialState = {
    showAllExtensions: savedShowAllExtensions
};

const reducer = function (state, action) {
    if (typeof state === 'undefined') state = initialState;
    switch (action.type) {
    case TOGGLE_SHOW_ALL_EXTENSIONS:
        if (typeof window !== 'undefined' && window.localStorage) {
            window.localStorage.setItem(SHOW_ALL_EXTENSIONS_KEY, action.showAllExtensions);
        }
        return Object.assign({}, state, {
            showAllExtensions: action.showAllExtensions
        });
    default:
        return state;
    }
};

const toggleShowAllExtensions = function (showAllExtensions) {
    return {
        type: TOGGLE_SHOW_ALL_EXTENSIONS,
        showAllExtensions: showAllExtensions
    };
};

export {
    reducer as default,
    initialState as extensionFilterInitialState,
    toggleShowAllExtensions
};

参考: gui/smalruby3-gui/src/reducers/ruby-code.js のローカルストレージパターン

Redux store への統合:
gui/smalruby3-gui/src/reducers/gui.js に追加

import extensionFilterReducer, {extensionFilterInitialState} from './extension-filter';

const guiInitialState = {
    // ... 既存の state
    extensionFilter: extensionFilterInitialState
};

const guiReducer = combineReducers({
    // ... 既存の reducers
    extensionFilter: extensionFilterReducer
});

2. ExtensionLibrary の修正

ファイル: gui/smalruby3-gui/src/containers/extension-library.jsx

フィルタリングロジックと headerActions を追加:

import React from 'react';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import {toggleShowAllExtensions} from '../reducers/extension-filter';

// 非表示にする拡張機能のリスト
const DEFAULT_HIDDEN_EXTENSIONS = [
    'makeymakey',
    'microbit',
    'ev3',
    'boost',
    'wedo2',
    'gdxfor'
];

class ExtensionLibrary extends React.PureComponent {
    constructor (props) {
        super(props);
        // ... 既存の constructor
        bindAll(this, [
            'handleItemSelect',
            'handleToggleShowAllExtensions'
        ]);
    }

    handleToggleShowAllExtensions (event) {
        this.props.onToggleShowAllExtensions(event.target.checked);
    }

    render () {
        const query = new URLSearchParams(window.location.search);
        const extensionsParam = query.get('extensions') || '';
        const showMeshV2 = extensionsParam.split(',').includes('meshV2');

        const extensionLibraryThumbnailData = extensionLibraryContent
            .filter(extension => {
                // meshV2 の既存フィルタリング
                if (extension.extensionId === 'meshV2' && !showMeshV2) {
                    return false;
                }
                // 新しいフィルタリング:showAllExtensions が false の場合、非表示リストをフィルタ
                if (!this.props.showAllExtensions &&
                    DEFAULT_HIDDEN_EXTENSIONS.includes(extension.extensionId)) {
                    return false;
                }
                return true;
            })
            .map(extension => ({
                rawURL: extension.iconURL || extensionIcon,
                ...extension
            }));

        // headerActions を定義(LibraryComponent が完全にコントロール)
        const headerActions = (
            <label className={styles.showAllExtensionsLabel}>
                <input
                    checked={this.props.showAllExtensions}
                    className={styles.showAllExtensionsCheckbox}
                    type="checkbox"
                    onChange={this.handleToggleShowAllExtensions}
                />
                <FormattedMessage
                    defaultMessage="Show all extensions"
                    description="Checkbox label to show all extensions including hidden ones"
                    id="gui.extensionLibrary.showAllExtensions"
                />
            </label>
        );

        return (
            <LibraryComponent
                data={extensionLibraryThumbnailData}
                filterable={false}
                headerActions={headerActions}
                id="extensionLibrary"
                title={this.props.intl.formatMessage(messages.extensionTitle)}
                visible={this.props.visible}
                onItemSelected={this.handleItemSelect}
                onRequestClose={this.props.onRequestClose}
            />
        );
    }
}

ExtensionLibrary.propTypes = {
    intl: intlShape.isRequired,
    onCategorySelected: PropTypes.func,
    onRequestClose: PropTypes.func,
    onToggleShowAllExtensions: PropTypes.func,
    showAllExtensions: PropTypes.bool,
    visible: PropTypes.bool,
    vm: PropTypes.instanceOf(VM).isRequired
};

const mapStateToProps = state => ({
    showAllExtensions: state.scratchGui.extensionFilter.showAllExtensions
});

const mapDispatchToProps = dispatch => ({
    onToggleShowAllExtensions: showAll => dispatch(toggleShowAllExtensions(showAll))
});

export default connect(
    mapStateToProps,
    mapDispatchToProps
)(injectIntl(ExtensionLibrary));

CSS の追加:
gui/smalruby3-gui/src/containers/extension-library.css (新規作成または既存ファイルに追加)

@import "../components/modal/modal.css";

.show-all-extensions-label {
    display: flex;
    align-items: center;
    font-size: 0.75rem;
    font-weight: normal;
    cursor: pointer;
    user-select: none;
    color: $ui-white;
    white-space: nowrap;
    padding: 1rem;
}

.show-all-extensions-checkbox {
    cursor: pointer;
}

[dir="ltr"] .show-all-extensions-checkbox {
    margin-right: 0.5rem;
}

[dir="rtl"] .show-all-extensions-checkbox {
    margin-left: 0.5rem;
}

3. LibraryComponent の拡張

ファイル: gui/smalruby3-gui/src/components/library/library.jsx

headerActions を Modal に渡す:

LibraryComponent.propTypes = {
    data: PropTypes.arrayOf(/* ... */),
    filterable: PropTypes.bool,
    headerActions: PropTypes.node, // 追加
    id: PropTypes.string.isRequired,
    intl: intlShape.isRequired,
    onItemMouseEnter: PropTypes.func,
    onItemMouseLeave: PropTypes.func,
    onItemSelected: PropTypes.func,
    onRequestClose: PropTypes.func,
    setStopHandler: PropTypes.func,
    showPlayButton: PropTypes.bool,
    tags: PropTypes.arrayOf(PropTypes.shape(TagButton.propTypes)),
    title: PropTypes.string.isRequired
};

// render メソッド内の Modal
return (
    <Modal
        fullScreen
        contentLabel={this.props.title}
        headerActions={this.props.headerActions}
        id={this.props.id}
        onRequestClose={this.handleClose}
    >
        {/* 既存の内容 */}
    </Modal>
);

4. Modal Component の拡張

ファイル: gui/smalruby3-gui/src/components/modal/modal.jsx

headerActions を受け取ってレンダリング:

const ModalComponent = props => (
    <ReactModal
        isOpen
        className={classNames(styles.modalContent, props.className, {
            [styles.fullScreen]: props.fullScreen
        })}
        contentLabel={props.contentLabel.toString()}
        overlayClassName={styles.modalOverlay}
        onRequestClose={props.onRequestClose}
    >
        <Box
            className={styles.box}
            dir={props.isRtl ? 'rtl' : 'ltr'}
            direction="column"
            grow={1}
        >
            <div className={classNames(styles.header, props.headerClassName)}>
                {/* 既存の Help ボタン */}
                {props.onHelp ? (
                    <div className={classNames(styles.headerItem, styles.headerItemHelp)}>
                        <Button
                            className={styles.helpButton}
                            iconSrc={helpIcon}
                            onClick={props.onHelp}
                        >
                            <FormattedMessage
                                defaultMessage="Help"
                                description="Help button in modal"
                                id="gui.modal.help"
                            />
                        </Button>
                    </div>
                ) : null}

                {/* タイトル */}
                <div className={classNames(styles.headerItem, styles.headerItemTitle)}>
                    {props.headerImage ? (
                        <img
                            className={styles.headerImage}
                            src={props.headerImage}
                        />
                    ) : null}
                    {props.contentLabel}
                </div>

                {/* 新規: ジェネリックな headerActions */}
                {props.headerActions ? (
                    <div className={classNames(styles.headerItem, styles.headerItemActions)}>
                        {props.headerActions}
                    </div>
                ) : null}

                {/* 既存の Reload/Stop ボタン(後方互換性のため残す) */}
                {props.loading && props.onStop ? (
                    <div className={classNames(styles.headerItem, styles.headerItemReload)}>
                        <Button
                            className={styles.reloadButton}
                            iconClassName={styles.stopIcon}
                            iconSrc={stopIcon}
                            onClick={props.onStop}
                        >
                            <FormattedMessage
                                defaultMessage="Stop"
                                description="Stop button in modal"
                                id="gui.modal.stop"
                            />
                        </Button>
                    </div>
                ) : (props.onReload ? (
                    <div className={classNames(styles.headerItem, styles.headerItemReload)}>
                        <Button
                            className={styles.reloadButton}
                            iconSrc={reloadIcon}
                            onClick={props.onReload}
                        >
                            <FormattedMessage
                                defaultMessage="Reload"
                                description="Reload button in modal"
                                id="gui.modal.reload"
                            />
                        </Button>
                    </div>
                ) : null)}

                {/* 既存の Close ボタン */}
                <div className={classNames(styles.headerItem, styles.headerItemClose)}>
                    {props.fullScreen ? (
                        <Button
                            className={styles.backButton}
                            iconSrc={backIcon}
                            onClick={props.onRequestClose}
                        >
                            <FormattedMessage
                                defaultMessage="Back"
                                description="Back button in modal"
                                id="gui.modal.back"
                            />
                        </Button>
                    ) : (
                        <CloseButton
                            size={CloseButton.SIZE_LARGE}
                            onClick={props.onRequestClose}
                        />
                    )}
                </div>
            </div>
            {props.loading ? (
                <div className={styles.progressBar}>
                    <div className={styles.progressBarValue} />
                </div>
            ) : null}
            {props.children}
        </Box>
    </ReactModal>
);

ModalComponent.propTypes = {
    children: PropTypes.node,
    className: PropTypes.string,
    contentLabel: PropTypes.oneOfType([
        PropTypes.string,
        PropTypes.object
    ]).isRequired,
    fullScreen: PropTypes.bool,
    headerActions: PropTypes.node, // 追加
    headerClassName: PropTypes.string,
    headerImage: PropTypes.string,
    isRtl: PropTypes.bool,
    loading: PropTypes.bool,
    onHelp: PropTypes.func,
    onReload: PropTypes.func, // 後方互換性のため残す
    onRequestClose: PropTypes.func,
    onStop: PropTypes.func // 後方互換性のため残す
};

注意点:

  • onReloadonStop は後方互換性のため残す
  • 将来的には KoshienTestModal も headerActions パターンに移行可能

5. CSS の追加

ファイル: gui/smalruby3-gui/src/components/modal/modal.css

headerActions のスタイル:

/* Header actions(ジェネリック) */
.header-item-actions {
    padding: 0;
    z-index: 1;
}

[dir="ltr"] .header-item-actions {
    margin-left: -4.75rem;
}

[dir="rtl"] .header-item-actions {
    margin-right: -4.75rem;
}

注意点:

  • header-item-reload と同じスタイルパターン
  • ジェネリックなため、内部のスタイルは各モーダルが定義

6. 翻訳ファイルの更新

各言語の翻訳ファイルに以下を追加:

英語 (en.json):

{
    "gui.extensionLibrary.showAllExtensions": "Show all extensions"
}

日本語 (ja.json / ja-Hira.json):

{
    "gui.extensionLibrary.showAllExtensions": "すべての拡張機能を表示"
}

KoshienTestModal の移行(オプション)

headerActions パターンを採用することで、KoshienTestModal も同様に改善できます。

Before(現在の実装)

<Modal
    loading={loading}
    onReload={handleReload}
    onStop={handleStop}
    onRequestClose={onRequestClose}
>

After(headerActions パターン)

const headerActions = loading ? (
    <Button
        className={styles.stopButton}
        iconClassName={styles.stopIcon}
        iconSrc={stopIcon}
        onClick={handleStop}
    >
        <FormattedMessage
            defaultMessage="Stop"
            description="Stop button in modal"
            id="gui.modal.stop"
        />
    </Button>
) : (
    <Button
        className={styles.reloadButton}
        iconSrc={reloadIcon}
        onClick={handleReload}
    >
        <FormattedMessage
            defaultMessage="Reload"
            description="Reload button in modal"
            id="gui.modal.reload"
        />
    </Button>
);

<Modal
    headerActions={headerActions}
    loading={loading}
    onRequestClose={onRequestClose}
>

メリット:

  • KoshienTestModal が自分のボタンのスタイルを完全にコントロール
  • Modal は loading state の知識を持つ必要がない
  • より一貫性のあるアーキテクチャ

改修案

案1: 拡張機能メタデータに defaultHidden フラグを追加

メリット:

  • よりスケーラブルで保守性が高い
  • 拡張機能の定義と表示制御が同じ場所にある
  • ハードコードされたリストを避けられる

実装例:

// gui/smalruby3-gui/src/lib/libraries/extensions/index.jsx
const extensions = [
    {
        name: 'Music',
        extensionId: 'music',
        // defaultHidden を追加しない(デフォルト表示)
        ...
    },
    {
        name: 'Makey Makey',
        extensionId: 'makeymakey',
        defaultHidden: true, // デフォルトで非表示
        ...
    }
];

フィルタリング:

.filter(extension => {
    if (extension.extensionId === 'meshV2' && !showMeshV2) return false;
    if (!this.props.showAllExtensions && extension.defaultHidden) return false;
    return true;
})

案2: URL パラメータでの制御

メリット:

  • デバッグやデモ時に便利
  • ローカルストレージを上書きできる
  • 既存の meshV2 URL パラメータパターンと一貫性がある

実装例:

const query = new URLSearchParams(window.location.search);
const showAllExtensionsParam = query.get('showAllExtensions');
const showAllExtensions = showAllExtensionsParam === 'true' ? true :
                          showAllExtensionsParam === 'false' ? false :
                          this.props.showAllExtensions;

テスト計画

手動テスト

  1. デフォルト表示の確認

    • 拡張機能モーダルを開く
    • デフォルトで9個の拡張機能のみが表示されることを確認
    • 非表示の6個の拡張機能が表示されないことを確認
  2. チェックボックス機能の確認

    • チェックボックスをオンにする
    • すべての拡張機能(15個)が表示されることを確認
    • チェックボックスをオフにする
    • デフォルトの9個の拡張機能のみに戻ることを確認
  3. ローカルストレージの永続性確認

    • チェックボックスをオンにする
    • ページをリロード
    • チェックボックスがオンのままで、すべての拡張機能が表示されることを確認
    • チェックボックスをオフにする
    • ページをリロード
    • チェックボックスがオフのままで、デフォルト表示になることを確認
  4. 多言語対応の確認

    • 言語を英語に変更し、チェックボックスのラベルを確認
    • 言語を日本語に変更し、チェックボックスのラベルを確認
  5. 既存機能への影響確認

    • Mesh V2 の URL パラメータ制御が引き続き動作することを確認
    • 拡張機能の選択・読み込みが正常に動作することを確認
    • KoshienTestModal のリロードボタンが引き続き動作することを確認(後方互換性)
  6. レイアウト確認

    • チェックボックスがタイトルとCloseボタンの間に表示されることを確認
    • RTL(右から左)レイアウトでも正しく表示されることを確認

ESLint チェック

docker compose run --rm gui bash -c "cd /app/gui/smalruby3-gui && npm run test:lint"

ビルド確認

docker compose run --rm gui bash -c "cd /app/gui/smalruby3-gui && npm run build"

セキュリティ考慮事項

  • ローカルストレージの値は boolean のみ(XSS リスクなし)
  • ユーザー入力は checkbox のクリックイベントのみ(インジェクションリスクなし)
  • headerActions は ReactNode のため、React の自動エスケープが適用される
  • 既存のセキュリティパターンに従う

アクセシビリティ

  • checkbox に FormattedMessage でラベルを提供
  • label 要素で checkbox とテキストを関連付け
  • キーボード操作対応(React の標準 checkbox で対応済み)
  • RTL(右から左)レイアウト対応

完了条件

  • Redux reducer extension-filter.js を作成
  • Redux store に統合
  • ExtensionLibrary にフィルタリングロジックを追加
  • ExtensionLibrary に headerActions を実装
  • ExtensionLibrary を Redux に connect
  • ExtensionLibrary の CSS を追加
  • LibraryComponent に headerActions props を追加
  • Modal コンポーネントに headerActions を追加
  • Modal の CSS を追加
  • 翻訳ファイルを更新(en, ja, ja-Hira)
  • ESLint エラーがないことを確認
  • ビルドが成功することを確認
  • 手動テストをすべて完了
  • デフォルトで9個の拡張機能のみが表示される
  • チェックボックスで表示/非表示が切り替わる
  • ローカルストレージに設定が保存される
  • 多言語対応が動作する
  • RTL レイアウトで正しく表示される
  • KoshienTestModal が引き続き動作する(後方互換性)

関連ファイル

新規作成

  • gui/smalruby3-gui/src/reducers/extension-filter.js
  • gui/smalruby3-gui/src/containers/extension-library.css

修正

  • gui/smalruby3-gui/src/reducers/gui.js (Redux store に統合)
  • gui/smalruby3-gui/src/containers/extension-library.jsx (フィルタリング + headerActions)
  • gui/smalruby3-gui/src/components/library/library.jsx (headerActions props 追加)
  • gui/smalruby3-gui/src/components/modal/modal.jsx (headerActions サポート)
  • gui/smalruby3-gui/src/components/modal/modal.css (headerActions スタイル)
  • 翻訳ファイル (en.json, ja.json, ja-Hira.json)

参照のみ

  • gui/smalruby3-gui/src/lib/libraries/extensions/index.jsx
  • gui/smalruby3-gui/src/components/koshien-test-modal/koshien-test-modal.jsx (移行可能な例)
  • gui/smalruby3-gui/src/reducers/ruby-code.js (ローカルストレージパターンの参考)

将来の改善

KoshienTestModal の移行

headerActions パターンの有効性が確認できたら、KoshienTestModal も同様に移行することを推奨します:

  1. KoshienTestModal で headerActions を定義
  2. Modal から onReloadonStop props を削除(破壊的変更)
  3. より一貫性のあるアーキテクチャを実現

この移行は別の Issue として扱うことを推奨します。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions