9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactで右クリックした場所にメニューを表示してみたい。

Posted at

Reactで右クリックメニュー

先日書いたReact+Railsで付箋っぽいアプリを作る中で、右クリックメニューを作ってみたいなぁ、と、調べた内容と実装方法を書いていきたいと思います。

oncontextmenuのイベントハンドラを作れば良い

JavaScriptのドキュメントによれば、oncontextmenuイベントをハンドルすることで、独自のメニューが作れそうです。

実装方針

ここでは、以下の方針で実装してみることにしました。

  1. 右クリックした場所にメニューを表示する。
  2. メニューの要素は、Reactのコンポーネントとして作ってみる。
  3. 環境は、React+Railsで付箋っぽいアプリを作るの環境をそのまま使いましたが、多分Reactの動く環境なら、どこでも大丈夫。

実装だー

作るのは、以下の3つです。

  1. メニューを表示するコンポーネント(menu.js)
  2. 右クリックを受け付ける親コンポーネント(parent.js)
  3. スタイルシート(parent.css)

メニューを表示するコンポーネント

適当なメニューと"close"が選べるポップアップを表示させます。

menu.js
import React from 'react';
import PropTypes from 'prop-types';

class Menu extends React.Component {
  // コンストラクタ
  constructor(props){
    super(props);        // おまじないですね。

    // メニューを表示するdiv要素を参照するための変数です。
    this.menuElm = null;

    // イベントハンドラのバインド
    this.onCloseButtonClick = this.onCloseButtonClick.bind(this);
    this.onMenuItemClick = this.onMenuItemClick.bind(this);
    this.onKeyUp = this.onKeyUp.bind(this);

    // メニューに表示するアイテムの配列です。(適当すぎてやばい。)
    this.messages = ["You", "are", "incredible"];

  }

  // メニュー要素を表示(visibility="visible")します。
  // 親要素から、呼び出されるメソッドです。
  show(clientX, clientY){
    // 以下のようにstyle.top,style.leftを指定することで、好きな場所にメニューを表示できます。
    this.menuElm.style.top = clientY + "px";
    this.menuElm.style.left = clientX + "px";
    this.menuElm.style.visibility = "visible";

    // 表示したらフォーカスを割り当てます。
    // (これで、keyイベントを受け付けてくれるようになります。)
    this.menuElm.focus();
  }

  // メニューを閉じ(visibility="hidden"に変更し)ます。
  close(){
    this.menuElm.style.visibility = "hidden";
  }

  // "close"クリック時のイベントハンドラ
  onCloseButtonClick() {
    // close()を呼び出します。
    this.close();
  }

  // メニューアイテムクリック時のイベントハンドラ
  onMenuItemClick(event) {
    // 自分自身を閉じて
    this.close();

    // 親要素から渡されたコールバック関数を呼び出します。
    // 引数には、アイテムに表示されているテキストを渡します。
    this.props.onMenuItemClick(event.target.innerHTML);
  }

  // エスケープキーで閉じるためのイベントハンドラです。
  onKeyUp(event) {
    event.preventDefault();
    // 文字列で比較できるとは思いませんでした。。
    if ("Escape" == event.key) {
      this.close();
    }
  }

  // レンダラー
  render(){
    return (
      <React.Fragment>
        { /* refで要素を参照することで、styleの変更ができるようになります。 */ }
        <div className="MenuBox" ref={(node) => this.menuElm = node} onKeyUp={this.onKeyUp} tabIndex="0" >
          {
            this.messages.map((message) => 
              <div className="MenuItem" onClick={this.onMenuItemClick} key={ message }>{ message }</div> 
            )
          }
          <div className="MenuItem" onClick={this.onCloseButtonClick}>Close</div>
        </div>
      </React.Fragment>
    );
  }
}

Menu.propTypes = {
  onMenuItemClick: PropTypes.func
};

export default Menu;

右クリックを受け付ける親コンポーネント

自身の領域が右クリックされたら、メニューを表示させます。
さらに、メニューで選択された内容に応じて、自身の表示内容を変化させてみました。

parent.js
import React from 'react';
import Menu from './menu';

class ParentPage extends React.Component {
  // コンストラクタ
  constructor(props){
    super(props);

    // stateを初期化
    this.state = {message: "Please Click Anywhere you like."}

    // メニュー要素への参照を初期化(後ほどレンダラーの中でrefを割り当てます。)
    this.menu = null;

    // イベントハンドラのバインド
    this.onContextMenu = this.onContextMenu.bind(this);
    this.onMenuItemClick = this.onMenuItemClick.bind(this);
  }

  // 右クリックイベントハンドラ
  onContextMenu(event) {
    // preventDefault()を忘れると、普通の右クリックメニューが表示されますよ。
    event.preventDefault();

    // メニュー要素の"show()"メソッドを呼び出します。
    // 引数にはマウスポインタの位置情報を渡してあげます。
    this.menu.show(event.clientX, event.clientY);
  }

  // 右クリックメニューでメニューが選択された際にコールバックしてもらうメソッドです。
  // 選択されたメニューの内容(innnerHTML)をstateに設定しています。
  // (これにより、画面左上のメッセージが切り替わるはず。)
  onMenuItemClick(message) {
    this.setState({message: message});
  }

  // レンダラー
  render(){
    return (
      <React.Fragment>
        { /* 自身の右クリックイベントハンドラをonContextMenu=で指定 */ }
        <div className="ParentBox" onContextMenu={this.onContextMenu} >
         { this.state.message }
         { /* コンポーネントもrefで参照できるので、子要素のメソッドを呼び出すことが可能になります。 */ }
         <Menu onMenuItemClick={this.onMenuItemClick} ref={(node) => this.menu = node} />
        </div>
      </React.Fragment>
    );
  }
}

スタイルシート

ブラウザのUIは、結局のところスタイルシートで「それっぽく見せている」だけなんですね。
何かの本にこんなことが書いてありました。
「コンピュータ使って実現しているものっていうのは、『そのように見える』だけのハリボテに過ぎない」
まさしくその通りだなぁ、と、改めて思うのでした。

parent.scss
// 親要素のスタイル
div.ParentBox {
  font-weight: bold;
  position: relative;
  width: 100vw;
  height: 100vh;
  background-color: #FFFFFF;
  border: 1px solid #000000;
}

// メニュー要素のスタイル
// position: absoluteにしないと、指定した場所に表示できないのでご注意を。
div.MenuBox {
  position: absolute;
  margin: 0px;
  padding: 5px;
  font-size: 10px;
  font-weight: thin;
  width: 100px;
  background-color: #888888;
  color: #000000;
  visibility: hidden;
  border-radius: 5px;
}

// focusをあてたときに周りが光らないようにしました。(気分の問題)
div.MenuBox:focus {
  outline: none;
}

// 各メニューにマウスポインタが乗っかった時のスタイルです。
div.MenuItem:hover {
  cursor: pointer;
  background-color: #E0E0FF;
}

実験

殺風景ですが、クリックしたところにメニューが表示される感じが実現できたはずです。
メニューを選択すると、メニューに表示されていた文字列が、そのまま親ページの右肩に表示されると思います。
Escapeボタンでも、"Close"のリンクでもメニューを消すことができます。
スクリーンショット 2020-05-26 20.44.32.png

補足

今回の内容については、DBが関係ないので、Railsの環境は正直不要で、Reactの環境だけあれば実験できます。
npmとwebpackだけで動作させた際のwebpack.config.jsの内容と、ちょっとしたソースの修正内容を記載しておきます。

ディレクトリ構成

特段特別なものはありません。
publicにindex.htmlを置いて、src配下にスクリプトとスタイルシートを置きました。

shell
app
├── node_modules(配下は割愛)
├── package-lock.json
├── package.json
├── public
│   └── index.html
├── src
│   ├── menu.js
│   ├── parent.js
│   └── parent.scss
└── webpack.config.js

webpack.config.js

app/webpack.config.js
module.exports = {
  mode: "development",
  entry: {
    app: "./src/parent.js"
  },
  output: {
    path: __dirname + '/public/js',
    filename: "[name].js"
  },
  devServer: {
    contentBase: __dirname + '/public',
    port: 8080,
    publicPath: '/js/'
  },
  devtool: "#inline-source-map",
  module: {
    rules: [{
      test: /\.js$/,
      enforce: "pre",
      exclude: /node_modules/,
      loader: "eslint-loader"
    },{
      test: /\.css$/,
      loader: ["style-loader","css-loader"]
    },{
      test: /\.scss$/,
      loader: ["style-loader","css-loader","sass-loader"]
    },{
      test: /\.js$/,
      exclude: /node_modules/,
      loader: 'babel-loader'
    }]
  }
};

public/index.html

ブラウザからアクセスするためのトップページです。

public/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1,shrink-to-fit=no" />
    <meta http-equiv="X-UA-Compatible" content="IE=Edge, chrome=1" />
    <title>React App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="text/javascript" src="js/app.js" charset="utf-8"></script>
  </body>
</html>

src/parent.js

最後に、メインエントリポイントになるparent.jsの修正部分を書いておきます。
明示的にimportしないとスタイルが読み込まれなかったので、その対処と、
ReactDom.renderの呼び出しを追加しました。

src/parent.js
// 1,2はファイルの先頭(import文の並び)に追加
// 1. ReactDomの追加
import ReactDom from 'react-dom';

// 2. スタイルの参照を追加
import './parent.scss';

// : (中略)

// 3はファイルの最後に追加
// 3. レンダラーの呼び出し
ReactDom.render(
  <ParentPage />,
  document.getElementById('root')
);

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?