Electron + React.js でちょっとした Markdown Viewer を作成して少し知見が溜まったので宣伝とハマりどころなどまとめた (Markdown 表示 編)


ネタが複数あるので分けて作成してきたのですが、今回が最後になります。  
前回までに gulpfile でのポイントおよび Electron でのポイントを以下で紹介しています。
* [Electron + React.js でちょっとした Markdown Viewer を作成して少し知見が溜まったので宣伝とハマりどころなどまとめた (gulpfile編)](http://qiita.com/ma-tu/items/9daf81c0b34817a675bc)
* [Electron + React.js でちょっとした Markdown Viewer を作成して少し知見が溜まったので宣伝とハマりどころなどまとめた (Electron編)](http://qiita.com/ma-tu/items/7fea2ca64a0a92012ed1)

こちらも見ていただけると幸いです。再び宣伝するのは、くどい気もしたのですが 前回の記事を見ていない人のほうが圧倒的に多いと思いますし、最後なので少し宣伝。

# 作成したもの

**markcat**  
Electron製の Markdown Viewer

## 機能的な特徴

* **Intellij IDEA の Darcula 風の表示テーマも用意**
* 表示テーマの変更も可能
* Github Flavored Markdown
* コードハイライト表示
* 編集時の自動更新
* ドラッグ＆ドロップからの Markdown 表示
* (Windows) SendTo に配置することにより、エクスプローラーの「送る」からの Markdown 表示
* (Windows)ファイル関連付けを行うと、ダブルクリックからの Markdown 表示
* (Mac)このアプリケーションで開く からの起動。
* (Mac)ファイル拡張子で関連付けを行うと、ダブルクリックからの Markdown 表示

## 開発としての特徴

* Typescript + gulp + react + electron と今風の技術を利用しています。
* 実装はそれなりにシンプルなので学習にも最適
* とはいえ Markdown としての基本機能に追加して上記のこだわり機能を実装しています。

## 開発動機

Markdown は普段は Atom などのエディタを利用して作成しています。  
Markdown を見る時も同じく Atom を利用したり、Chrome の拡張機能を利用して表示したりしていたのですが、md ファイルをダブルクリックしたり、エクスプローラーのコンテキストメニューからサクッと見ることができないかと考えていました。  
そんな時 WEB+DB Press の React の記事の中で marked という markdown parser が紹介されていたのをきっかけに、また [rhysd/Shiba](https://github.com/rhysd/Shiba) を拝見して、自分用の Markdown Viewer を作ってみたいと思いました。

## ソースファイル

以下で公開しています。  
https://github.com/ma-tu/markcat

## 利用方法

GitHub の [Release](https://github.com/ma-tu/markcat/releases) にコンパイル済みのファイルを配置しているので、環境に合わせた zip ファイルをダウンロードして、適当なフォルダに解凍後 markcat を実行するだけです。
詳細は [こちらの README](https://github.com/ma-tu/markcat) を参照ください。

## 知見

#### Markdown Parser と コードハイライトの対応

Markdown Viewer の一番のコアである .md の Markdown File を HTML 形式に変更する部分は以下のライブラリを利用させていただきました。

* HTML に変更する部分については [marked](https://www.npmjs.com/package/marked)
* Markdown のコード部分のシンタックスハイライト表示については [highlight.js](https://github.com/isagalaev/highlight.js)
* GitHub風に表示するために [github-markdown-css](https://www.npmjs.com/package/github-markdown-css)

Darcula 風テーマは上記の highlight.js の css および github-markdown-css の色について調整して作成しました。

以下が Markdown File を HTML 形式に変換している箇所になります。  
少し補足説明します。  

* convertMarkedHtml() 関数の引数 content には、Markdown File の 内容を文字列で渡しています。
* marked の setOptions で highlight を指定することで、コードブロックのハイライト処理を制御することができます。
* 今回はコードブロックのハイライト処理に、 highlight.js を利用していますが、他のマークアップ言語を利用することもできます。
* lang には コードブロックに [ js ] と指定した場合の [ js ] という文字列が渡ってきます。
* 基本的にはこの lang を渡すだけでもよいのですが、Qiita の Markdown File では コードブロックで [ js:src/renderer/marked.ts ] と記述できるように拡張されています。このケースに対応するために [ : ] で区切って処理するようにしています。

```js:src/renderer/marked.ts
import * as marked from 'marked'
import {highlight} from 'highlight.js';

export function convertMarkedHtml(content: string): string {
  marked.setOptions({
    highlight: function (code: string, lang: string): string {
      if (lang === undefined) {
          return code
      }
      
      const langSplit = lang.split(':')
      try {
          return highlight(langSplit[0], code).value
      } catch (e) {
          console.log(e.message)
          return code
      }
    }
  })

  return marked(content)
}
```

上記の convertMarkedHtml() 関数で変換した HTML 形式の文字列を React の dangerouslySetInnerHTML というプロパティに渡すことにより表示を行います。このプロパティは HTML をエスケープせずに表示するためのプロパティなのでクロスサイトスクリプティングの危険性があるため、このような名前になっているようです。

少し古い Version ですが、以下の説明がわかりやすいと思います。
* [危険なinnerHTMLの使用 | React 0.13 日本語リファレンス | js Studio](http://js.studio-kingdom.com/react/tips/dangerously_set_inner_html)

```js:src/renderer/components/comntentHolderComponent.tsx
render() {
  return (
    <div id="holder" onDragOver={this.handleDragOver.bind(this)} onDrop={this.handleDrop.bind(this)}>
      <div id="content" dangerouslySetInnerHTML={{__html: this.props.html}} />
    </div>
  )
}
```

#### Markdown のリンクをクリックした時に外部ブラウザを利用して表示する方法

以下のような記述を Markdown File で指定すると、以下のように a タグに変換されます。  
このリンクをクリックすると、Electron 上でリンク先のファイルを読み込みます。結果として Markdown Viewer としてのページからリンク先のページに遷移してしまい、Markdown Viewer として利用できなくなります。そのため今回は MarkCat で a タグをクリックしたときには、外部ブラウザを利用してリンク先のファイルを表示するようにしています。

``` 
[Google](http://www.google.co.jp) 
``` 

``` 
<a href="http://www.google.co.jp">Google</a>
```

以下補足します。

* setOpenLinkWithBrowser() 関数で document 内の a タグの一覧を取得し、その OnClick イベントに対して event.preventDefault() でキャンセルしたうえで、electron.shell.openExternal() 関数を利用して外部ブラウザを呼び出しています。
* electron の shell.openExternal() については [atom/electron の shell api リファレンス](https://github.com/atom/electron/blob/master/docs/api/shell.md) を参照ください。
* React.js では　DOM に対しての操作は DOMツリーに追加されたタイミング、更新されたタイミングで行う必要があります。  
  componentDidMount はコンポーネントが DOMツリーに追加された時に呼ばれます。  
  componentDidUpdate はコンポーネントが更新された後に呼ばれます。  

```js:src/renderer/components/markdownComponent.tsx
componentDidMount() {
  this.setOpenLinkWithBrowser()
}

componentDidUpdate() {
  this.setOpenLinkWithBrowser()
}

private setOpenLinkWithBrowser(): void {
  const anchors = document.querySelectorAll('a')
  for(let i = 0; i < anchors.length; i++) {
    const anker = anchors.item(i) as HTMLLinkElement
    anker.onclick = (e) => {
      event.preventDefault()

      const target = event.target as HTMLLinkElement
      electron.shell.openExternal(target.href)
    }
  }
}
```

#### Thema を動的に入れ替える方法

Thema を Dark <-> Normal に変更したときには、以下の changeCssThemaFile() を実行します。  
document の id="css-thema" の HTMLLinkElement を取得して href を変更することにより Thema の更新を行っています。

```js:src/renderer/services/thema.ts
function changeCssThemaFile(cssFilePath: string) {
  var linkEl: HTMLLinkElement = document.getElementById('css-thema') as HTMLLinkElement
  linkEl.href = cssFilePath;
}
```

```html:src/renderer/index.html
<link rel="stylesheet" id="css-thema" href="./css/thema-normal.css">
```

#### 編集時の自動更新の方法

Markdown の編集時の自動更新は [chokidar](https://github.com/paulmillr/chokidar) を利用しています。  
以下補足します。

* chokidar.watch(<監視対象パス>) で監視対象のパスを指定して watcher オブジェクトを取得します。
* watchker オブジェクトに対して on(event, callback) で Event の Listner を登録します。
* on('add', callback) はファイルの追加を監視します。
* on('change', callback) はファイルの変更を監視します。
* ファイルの追加・変更が発生すると、対象のファイルを読込み、そのファイルのコンテンツを引数にして handleUpdate() 関数を呼び出します。

```js:src/renderer/services/watcher.ts
import * as chokidar from 'chokidar'
import * as fs from 'fs'

export class Watcher {
  private watcher: any
  private currentPath: string
  private handleUpdate: any

  constructor(path: string, handleUpdate: any) {
    this.currentPath = path
    this.handleUpdate = handleUpdate

    this.watcher = chokidar.watch(this.currentPath)
    this.watcher.on('add', this.onUpdate.bind(this))
    this.watcher.on('change', this.onUpdate.bind(this))
  }

  public changeWatchPath(path: string): void {
    this.watcher.unwatch(this.currentPath)
    this.watcher.add(path)
    this.currentPath = path
  }

  private onUpdate(path: string, stats: any) {
    fs.readFile(path, 'utf8', (err: any, content: any) => {
      if (err) throw err
      this.handleUpdate(content)
    });
  }
}
```

## 終わりに

いかがだったでしょうか？  
Markdown viewer を作成するにあたってのポイントはすべて記述したつもりです。  
少しでも誰かの開発のヒントになることができれば幸いです。  
またできましたら MarkCat 使ってみてください。  
最後にこの記事を見た人が好きな言語を利用して、もっとすごい Markdown Viewer を作成していただけたりしたら最高です。