More than 1 year has passed since last update.

知人がesaのデスクトップクライアントを使わなくなった理由が、複数タブが開けなくて不便だったと聞いた。
確かに「Electronのアプリでも複数タブが使えるといいな」と思ったのでさくっと作ってみました。

作ったもの

qiita2.gif

タブ周りの処理を中心に書いた。ブラウザというよりただのQiitaリーダ。

環境

MacOS X 10.10.4
Node.js v.4.1.1
electron v.0.34.3

準備

Electron環境の導入が必須。
30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまでがとても参考になります。

今回はReactをES6で書くのでbabel+webpack+gulpの設定を書いておきます。

npmで必要なパッケージをインストールし、gulpでビルド用のタスクを起動すると、ファイルを更新するごとにビルドしてくれます。

npm install --save-dev webpack babel-loader babel-core babel-preset-react babel-preset-es2015 gulp gulp-webpack
gulp build

やったこと

ReactでWebViewを扱う

アプリのエントリーポイントのJSはElectronのテンプレ通りに書きました。

main.js
'use strict';

var app = require('app');
var BrowserWindow = require('browser-window');

require('crash-reporter').start();

var mainWindow = null;

app.on('window-all-closed', function() {
  if (process.platform != 'darwin') {
    app.quit();
  }
});

app.on('ready', function() {
  mainWindow = new BrowserWindow({width: 1200, height: 800});
  mainWindow.loadUrl('file://' + __dirname + '/index.html');
  mainWindow.openDevTools(true);

  mainWindow.on('closed', function() {
    mainWindow = null;
  });
});
index.html
<html>
  ...
  <body>
    <div id="app"></div>
    <script src="dist/build.js"></script>
  </body>
</html>
app.js
import React, { Component } from 'react'
import { render } from 'react-dom'

class App extends Component {
  render() {
    <webview src="http://qiita.com" autosize="on"></webview>
  }
}

render(
  <App />,
  document.getElementById('app')
)

ここまででWebviewが表示出来る最小構成のコードになりました。

ここから、Reactのstateでtabを管理していきます。tabはtitleとurlを持つ形で管理していき、Appコンポーネントで配列で持つ形にします。
現在のタブを配列のインデックスで持つ形にするのでAppのstateは以下の様になります。

App.state
tabs : [
  {
    title: "Qiita",
    url: "http://qiita.com"
  },
  {
    title: "Qiita",
    url: "http://qiita.com"
  },
],
current: 0  // 現在開いているタブの位置を示す

Webviewを扱うコンポーネントを作る

webviewをコンポーネント化するとコードは次のようになりました。

...

class Tab extends Component {
  constructor(props) {
    super(props)
  }

  render() {
    return <webview src={this.props.url} autosize="on" style={{'display': this.props.visible ? 'block' : 'none'}}></webview>
  }
}

class App extends Component {
  constructor() {
    super();
    this.state = {
      // デバッグ用途に最初に開くタブを指定しました。
      tabs: [
        { url: "http://qiita.com", title: "" },
        { url: "http://google.com", title: "" }
      ],
      current: 0
    }
  }

  render() {
    return (
      <div>
     {this.state.tabs.map((tab, index) => {
          return (
            <div key={index}
                 onClick={() => {this.setState({current: index})}}>
              {index}
            <div/>
          )
        })}
        {this.state.tabs.map((tab, index) => {
          return <Tab key={index}
                      index={index}
                      url={tab.url}
                      visible={this.state.current == index} />
        })}
      </div>
    )
  }
}

Tabでwebviewの表示を行う形に変更しています。
Tabのprops.visibleでcurrentとindexが一致したら前面にwebviewを表示する仕様にしました。

タブの数字を押すと、state.currentが書き換わって表示するページを変更します。

Tabに表示するタイトルを取得する

webviewを通してタブに表示するタイトルを取得します。
ここで注意したいのがWebviewの準備が終わってないとタイトルが取れません。
dom-readyイベントの後に取ればいいらしいです。

AppのstateをいじるイベントなのでAppに書いてイベントをpropsで渡す形にします。

class Tab extends Component {
  ...
  componentDidMount() {
    let webview = this.refs.webview
    webview.addEventListener("dom-ready", () => {
      this.updateTab(webview.getTitle())
    });
  }

  render() {
    return <webview ref="webview" src={this.props.url} autosize="on" style={{'display': this.props.visible ? 'block' : 'none'}}></webview>
  }
}

class App extends Component {
  ...
  updateTab(tab, index) {
    var newTabs = this.state.tabs.concat();
    newTabs[index] = tab
    this.setState({tabs: newTabs})
  }
  ...
  render() {
    ...
        {this.state.tabs.map((tab, index) => {
          return <Tab key={index}
                      index={index}
                      url={tab.url}
                      visible={this.state.current == index}
                      updateTab={this.updateTab.bind(this)} />
        })}
    ...
  } 
}

Photonを使ってMacアプリっぽくする

タブ型ブラウザっぽくなってきましたが、いまいちパッとしません。
きっと見た目のせいだと思ったので、Mac OS X風UIを提供してくれるPhotonを使用します。
簡単なHTMLでそれっぽい見た目にしてくれます。

PhotonをダウンロードしてHTMLで読み込み、Photonのコンポーネントのページから今回使用するTabを探して組み込みます。

class App extends Component {
  ...

  render() {
    return {
      <div>
        <div className="tab-group">
          {this.state.tabs.map((tab, index) => {
            return (
              <div key={index}
                   className={index == this.state.current ? "tab-item active" : "tab-item"}
                   onClick={() => {this.setState({current: index})}}>
                <span className="icon icon-cancel icon-close-tab"></span>
                {this.state.tabs[index].title}
              </div>
            )
          })}
        </div>
        ...
      </div>
    }
  }
}

スクリーンショット 2016-01-24 1.30.27.png

いい感じの見た目になりました。

タブの管理を出来るようにする

後は、新しいタブを作ったり、いらないタブを消すことが出来ないのでざっくり実装していきます。

新しいタブを作る

新しいタブを作るってことは、stateで管理しているtabの配列に新しい要素を付け加えることで実現出来ます。

class App extends Component {
  ...
  createTab(url="http://qiita.com") {
    let newTab = {url: url, title: ""}
    let newTabs = this.state.tabs.concat()
    newTabs.push(newTab)
    this.setState({
      tabs: newTabs,
      current: this.state.tabs.length
    });
  }
}

新しいtabページを作るのが面倒なのでデフォルトパラメータでqiitaのURLを突っ込みます。ES2015だと簡潔に書けていいですね。

タブを閉じる

タブを閉じるのはtabの配列から該当する要素を削除すれば大丈夫です。
タブの追加と違って、現在のタブの位置に気を付けないといけません。

class App extends Component {
  ...
  closeTab(index) {
    let newTabs = this.state.tabs
    newTabs.splice(index, 1);
    let nextCurrent = this.state.current;
    if (newTabs.length < 1) {
      // タブがなくなったらアプリを終了!
    } else if (index <= this.state.current) {
      nextCurrent = --nextCurrent;
    }
    this.setState({
      tabs: newTabs,
      current: nextCurrent
    })
  }
}

イベントを登録する

後はこれらのイベントが適切なタイミングで呼ばれるようにします。

使用感を改善する

ここまでタブの管理を中心に実装しましたが、実際に使い物にするにはまだまだ足りないものがたくさんあります。

  • ページの戻る、進む
  • キーボードからの操作に対応する
  • アプリ終了時にタブの保持

これらがブラウザとして基本的にあるべきと期待されるであろうものですがこの記事では実装しませんでした。

しかし、これらの実装をやっただけでは、ブラウザの完全劣化版です。わざわざデスクトップアプリにしているので付加価値をちゃんと付けていきたいですね。
Twitterクライアントの競争とかは参考になりますね。

まとめ

  • せっかく作るならElectronアプリでブラウザより良い体験を提供しましょう
  • ReactとElectronでの可能性を感じた
  • 命名がいろいろよろしくない

実際の使用には耐えませんが、今回のコードはGithubに上げておきました。

リポジトリ

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.