riot
Electron
Riot.jsDay 12

Riot+ElectronでMarkdownエディタを作る

riotが最近自分の中でキてるのでなんか作りたいなと思って、markdownエディタを作ってみました。
riot中心なのでelectronの説明はしません(そもそも起動でしか使ってない)

自分の環境

  • Windows 10 home
  • node.js v8.9.1
  • npm 5.5.1

どうやる

  • markdown-it >> markdownパーサ
  • highlight.js >> コードをハイライト表示してくれる
  • ace >> web上に埋め込めるエディター

リアルタイムにプレビューさせたいので,
aceエディタの中身が変更されたら、エディタの値をmarkdown-itでパースして表示させます。そして表示されたやつにコードがあったらhighlight.jsでハイライトを付けます。

その他ファイル読み込みと上書き保存機能も付けます

やっていきましょう

ディレクトリ構成

-public
   -index.html
   -main.js
-src
   -app
     -editor
        -editor.tag
        -editor.css
     -header
        -header.tag
        -header.css
     -preview
        -preview.tag
        -preview.css
     -app.tag
     -app.css
   -main.js

パッケージと設定ファイル

package.json
{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "electron": "^1.7.9"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015-riot": "^1.1.0",
    "bootstrap": "^3.3.7",
    "brace": "^0.11.0",
    "css-loader": "^0.28.7",
    "github-markdown-css": "^2.9.0",
    "highlight.js": "^9.12.0",
    "jquery": "^3.2.1",
    "markdown-it": "^8.4.0",
    "riot": "^3.7.4",
    "riot-tag-loader": "^1.0.0",
    "style-loader": "^0.19.0",
    "webpack": "^3.10.0"
  },
  "scripts": {
    "start": "electron ./public/main.js",
    "watch": "webpack --watch --progress --color",
    "build": "webpack --progress --color"
  }
}

webpack.config.js
const path = require('path')
const webpack = require('webpack')

module.exports = {
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.join(__dirname, 'public/javascripts'),
  },
  module: {
    rules: [{
        enforce: 'pre',
        test: /\.tag$/,
        exclude: /node_modules/,
        loader: 'riot-tag-loader',
        query: {
          type: 'es6',
          debug: true
        }
      },
      {
        test: /\.css/,
        loaders: [
          'style-loader',
          {
            loader: 'css-loader',
            options: {
              url: false
            }
          }
        ]
      },
      {
        test: /\.(tag|js)$/,
        exclude: /node_modules/,
        loader: 'babel-loader',
        options: {
          presets: ['es2015-riot'],
        },
      },
    ],
  },
  devtool: 'source-map',
  resolve: {
    extensions: ['*', '.js'],
  },
  plugins: [
    new webpack.optimize.UglifyJsPlugin({
      sourceMap: true,
    }),
    new webpack.optimize.ModuleConcatenationPlugin(),
    new webpack.optimize.OccurrenceOrderPlugin(),
    new webpack.optimize.AggressiveMergingPlugin(),
    new webpack.ProvidePlugin({
      riot: 'riot',
      jQuery: 'jquery',
      $: 'jquery'
    }),
  ]
}

コード

まずはhtmlと起動スクリプトを書きます。

index.html
<html>
  <head>
    <meta charset="UTF-8">
    <title>Markdown Editor</title>
  </head>
  <body>
    <app-root></app-root>
    <script>
        //webpackでうまく`fs`が使えなかったのでグローバルにしました。
        window.fs = require('fs') 
    </script>
    <script src="javascripts/bundle.js"></script> <!--webpackで出力されたファイル-->
  </body>
</html>
public/main.js
// Electron起動スクリプト。
const { app, BrowserWindow } = require('electron')

const path = require('path')
const url = require('url')

let mainWindow

function createWindow() {
  mainWindow = new BrowserWindow({ width: 1200, height: 1000 })
  mainWindow.loadURL(url.format({
    pathname: path.join(__dirname, 'index.html'),
    protocol: 'file:',
    slashes: true
  }))
  mainWindow.on('closed', () => {
    mainWindow = null
  })
}

app.on('ready', createWindow)

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

app.on('activate', () => {
  if (win === null) {
    createWindow()
  }
})

npm startで動作確認
アプリが立ち上がれば大丈夫です:smile:

riot側のコード

コンポーネント指向なので

  • ヘッダーコンポーネント (app-header)
  • エディタコンポーネント (app-editor)
  • プレビューコンポーネント (app-preview)

の三つのコンポーネントに分けます。
後は、ヘッダー用とエディター用のオブザーブルを作って、コンポーネント間の値渡しやイベント検知などもしやすくします。

src/main.js
//bootstrapとmarkdownのcss等。デザイン部分なのであまり重要ではない
import '../node_modules/bootstrap/dist/css/bootstrap.min.css'
import '../node_modules/github-markdown-css/github-markdown.css'
import '../node_modules/highlight.js/styles/github.css'
import '../node_modules/bootstrap'

//app-rootタグを読み込む
import './app/app.css'
import './app/app.tag'

riot.mount('app-root')

app.tag

<!--app.tag-->
<app-root>
  <!--ヘッダー部分-->
  <app-header header="{ header }"></app-header>

  <div class="container-fluid">

    <!--エディタ部分-->
    <div class="col-sm-6">
      <app-editor editor="{ editor }" header="{ header }"></app-editor>
    </div>

    <!--プレビュー部分-->
    <div class="col-sm-6">
      <app-preview editor="{ editor }"></app-preview>
    </div>

  </div>

  <script>
    import './editor/editor.tag'
    import './editor/editor.css'

    import './preview/preview.tag'
    import './preview/preview.css'

    import './header/header.tag'
    import './header/header.css'

    //エディタ用のオブザーブル
    this.editor = riot.observable()
    //ヘッダー用のオブザーブル
    this.header = riot.observable()

  </script>
</app-root>

header.tag

<!--header.tag-->
<app-header>
  <div class="group-button">

    <label>
      <span class="btn btn-default">load
        <input type="file" id="file" onchange="{ load }">
      </span>
    </label>

    <button class="btn btn-default" onclick="{ save }">save</button>

  </div>

  <script>

    this.load = () => {
      const path = $('#file')[0].files[0].path
      this.opts.header.trigger('load', path)  //loadイベント発火
    }

    this.save = () => {
      this.opts.header.trigger('save')  //saveイベント発火
    }

  </script>
</app-header>

editor.tag

<!--editor.tag-->
<app-editor>
  <div id="editor"></div>
  <script>
    import ace from 'brace'
    import 'brace/mode/markdown'  //markdownのハイライト
    import 'brace/theme/monokai'  //エディタのテーマ(sublimeっぽくした)

    $(document).ready(() => {
   //エディタ設定
      this.editor = ace.edit('editor')
      this.editor.$blockScrolling = Infinity
      this.editor.setTheme('ace/theme/monokai')
      this.editor.getSession().setMode('ace/mode/markdown')
      this.editor.setFontSize(14)
      this.editor.getSession().setUseWrapMode(true)
      this.editor.getSession().setTabSize(2)

      // エディタに変更を検知したとき、editorオブザーブルにchangeイベントを発火
      // エディタの値も渡すよ
      this.editor.getSession().on('change', () => {
        this.opts.editor.trigger('change', this.editor.getValue())
      })

    })

    //headerオブザーブルのloadイベントを検知したら、ファイルの読み込み処理
    this.opts.header.on('load', path => {
      this.path = path
      fs.readFile(this.path, 'utf8', (err, data) => {
        this.editor.setValue(data)
      })
    })

    //headerオブザーブルのsaveイベントを検知したら、上書き保存処理
    this.opts.header.on('save', () => {
      fs.writeFile(this.path, this.editor.getValue(), err => {
        if(!err)
          alert('せいこう!')
      });
    })
  </script>
</app-editor>

preview.tag

<!--preview.tag-->
<app-preview>
  <article class="markdown-body"></article>
  <script>
    import MarkDownIt from 'markdown-it'
    import hljs from 'highlight.js'
    hljs.initHighlightingOnLoad()

    //editorオブザーブルのchangeイベントを検知したら
    this.opts.editor.on('change', data => {
      const md = new MarkDownIt()
      const result = md.render(data) //エディタの値をパース

      $('article').html(result)
      $('pre code').each((i, block) => {
        hljs.highlightBlock(block)
      })
    })
  </script>
</app-preview>

cssコード

適当です。

app.css
.container-fluid {
  padding: 0px;
  width: 100%;
  height: 100%;
}

.col-sm-6 {
  padding: 0px;
}
preview.css
app-preview article {
  padding: 5px;
  margin: 10px;
  height: 100%;
  overflow: auto;
}
header.css
app-header #file {
  display: none;
}

editor.css
app-editor #editor {
  position: relative;
  height: 100%;
}

動作確認

npm run buildしてからnpm startを実行してください。

結果
image.png

:thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup::thumbsup:

最後に

  • riotのobservable機能がとても便利だということが分かった。
  • webpackでなぜかfsがうまく使えなかったし、electronをimportしようとするとエラーが出ちゃう。。。