JavaScript
Node.js
vue.js
MaterialDesign
Electron

Electron/Node.js初心者がマテリアルデザインなMarkdownエディタを作ってみた

More than 1 year has passed since last update.

はじめに

VB6が全盛の時代、Webは何もできないなんて言われた時代もありましたが、今やひと通りのことができる時代にGoogle?がChromeやV8など、JavaScriptを爆速にして連れて来てくれました。

そんなこんなで、今年、みんな大好きGithubがAtomエディタを作るために開発した実行環境が Electron (旧Atom-Shell)です。

Web開発スキル(HTML5、CSS、JavaScript)とNode.jsの知識があれば、Mac、Windows、Linuxのデスクトップアプリを作れるのが特徴のクロスプラットフォーム実行環境ですね。

動機とコンセプト

Githubをはじめとして、最近はちょっとしたドキュメントは全てMarkdownで書くようになりました。仕事・プライベートともに、Atomや専用Markdownエディタをはじめとして、色々と試してはいますが、帯に短し襷に長しといった感じで常々自分でも作りたいと思っていたので、今回のAdvent Calendar企画に巻き込まれたのをよい機会に、自分でとりあえずプロトタイプレベルのものを作ってみることにしました。

JavaScriptは長らくやってますが、Electon/Node.jsはこれまで触れてこなかったうえに、
あまり時間が取れないので、コンセプトは 「ソーシャルパワーを活かして、ゴリゴリ書かない」 です :sweat_smile:

仕様

  • ウィンドウ二分割のリアルタイムプレビュー
  • 無駄にマテリアルデザイン
  • 使うほどでもないけどJSフレームワークを使う
  • やっぱり絵文字には対応する
  • エディタ部もただのテキストエリアじゃなくていい感じに

Materialized_Design_Markdown_Editor.png

まぁ絵文字補完とか、OneNoteライクにファイルを意識させないとか、やりたいことはたくさんありますが、今回はここまでということで。

実装手順

Electronのインストール

Node.jsはインストール前提で。

$ npm -g install electron-prebuilt

オラに元気を・・・

というわけで、仕様実現のために元気を分けてもらいます。。。

やはりWeb系はいくらでもありますね:laughing:

そして、組み合わせる

今回はあえてnpmで入れずに利用してみました。

色々と端折ってますが、ファイル構成は下記のような感じです。

.
├── css
│   ├── codemirror.css
│   ├── emojify.min.css
│   ├── github-markdown.css
│   ├── main.css
│   ├── materialize.css
│   ├── materialize.min.css
│   └── split-pane.css
├── font
├── images
├── index.html
├── js
│   ├── codemirror
│   │   ├── addon
│   │   └── mode
│   ├── codemirror.js
│   ├── editor.js
│   ├── emojify.min.js
│   ├── hammer.min.js
│   ├── highlight
│   ├── jquery-2.1.4.min.js
│   ├── markdown-it.min.js
│   ├── materialize.js
│   ├── materialize.min.js
│   ├── script.js
│   ├── split-pane.js
│   └── vue.min.js
├── main.js
└── package.json

で、自分で書いたコードは下記ぐらい。

まずは、VIEWです。SPAだし、大した機能もないので、このファイルのみ。

index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <!--Import Google Icon Font-->
        <link href="http://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
        <!--Import materialize.css-->
        <link type="text/css" rel="stylesheet" href="css/materialize.min.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="css/split-pane.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="css/codemirror.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="css/github-markdown.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="css/emojify.min.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="js/highlight/styles/github.css" media="screen,projection"/>
        <link type="text/css" rel="stylesheet" href="css/main.css" media="screen,projection"/>
        <!--Let browser know website is optimized for mobile-->
        <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
        <title>Materialized Design Markdown Editor</title>
    </head>
    <body>
        <nav>
            <div id="nav_toolbar" class="nav-wrapper">
                <span class="brand-logo">
                    <img src="images/app.png" width="64" align="top" />
                    <span class="hide-on-small-and-down description">Materialized Design Markdown Editor</span>
                </span>
                <ul id="nav-mobile" class="right">
                    <li><a v-on:click.stop.prevent="load">Load</a></li>
                    <li><a v-on:click.stop.prevent="save">Save</a></li>
                </ul>
            </div>
        </nav>
        <div class="split-container">
            <div id="editor" class="split-pane fixed-left">
                <div id="left-component" class="split-pane-component">
                    <textarea id="editor_textarea" class="materialize-textarea" v-model="input"></textarea>
                </div>
                <div class="split-pane-divider"></div>
                <div id="right-component" class="split-pane-component markdown-body" v-html="input | marked"></div>
            </div>
        </div>
        <footer id="footer" class="page-footer">
            <div class="footer-copyright">
                <div v-html="currentPath"></div>
            </div>
        </footer>
        <script type="text/javascript" src="js/jquery-2.1.4.min.js"></script>
        <script type="text/javascript" src="js/hammer.min.js"></script>
        <script type="text/javascript" src="js/codemirror.js"></script>
        <script type="text/javascript" src="js/codemirror/addon/edit/continuelist.js"></script>
        <script type="text/javascript" src="js/codemirror/mode/markdown/markdown.js"></script>
        <script type="text/javascript" src="js/codemirror/mode/gfm/gfm.js"></script>
        <script type="text/javascript" src="js/markdown-it.min.js"></script>
        <script type="text/javascript" src="js/emojify.min.js"></script>
        <script type="text/javascript" src="js/highlight/highlight.pack.js"></script>
        <script type="text/javascript" src="js/script.js"></script>
        <script type="text/javascript" src="js/split-pane.js"></script>
        <script type="text/javascript" src="js/materialize.min.js"></script>
        <script type="text/javascript" src="js/vue.min.js"></script>
        <script type="text/javascript" src="js/editor.js"></script>
    </body>
</html>

次に起動スクリプト。ほぼデフォルトですね。

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() {

  // ブラウザ(Chromium)の起動, 初期画面のロード
  mainWindow = new BrowserWindow({width: 1024, height: 768});
  mainWindow.loadUrl('file://' + __dirname + '/index.html');

  mainWindow.on('closed', function() {
    mainWindow = null;
  });
});

ここが今回一番自分で実装した部分。

script.js
var $ = jQuery = require("./js/jquery-2.1.4.min.js");
var Hammer = require('./js/hammer.min.js');
hljs.initHighlightingOnLoad();

var md = require('./js/markdown-it.min.js')({
    html: true,
    linkify: true,
    typographer: true,
    highlight: function(str, lang) {
        lang = lang.split(":")[0];
        if (lang && hljs.getLanguage(lang)) {
            try {
                return hljs.highlight(lang, str).value;
            } catch (__) {}
        }

        try {
            return hljs.highlightAuto(str).value;
        } catch (__) {}

        return ''; // use external default escaping
    }
});
var mdEditor = CodeMirror.fromTextArea(document.getElementById('editor_textarea'), {
    mode: "markdown",
    autofocus: true,
    lineNumbers: true,
    indentUnit: 4,
    extraKeys: {
        "Enter": "newlineAndIndentContinueMarkdownList"
    },
});

function adjustWindow() {
    var h = $(window).height();
    var hHeader = $("#nav_toolbar").height();
    var hFooter = $("#footer").height();
    $('html').css('height', h - hHeader - hFooter - 5); //可変部分の高さを適用
}

var editorVm, toolbarVm, footerVm;

$(function() {
    $('div.split-pane').splitPane();
    $(window).on('resize', function() {
        setTimeout(function(){adjustWindow()}, 50);
    });
    editorVm = new Vue({
        el: '#editor',
        data: {
            input: ""
        },
        filters: {
            marked: function(input) {
                var result = md.render(input);
                result = result.replace(/(:[0-9a-zA-Z_\+\-]+?:)/g, " $1 ");
                return emojify.replace(result);
            }
        }
    });
    mdEditor.on(
        'change',
        function() {
            mdEditor.save();
            editorVm.input = $('#editor_textarea').val();
        }
    );

    toolbarVm = new Vue({
        el: '#nav_toolbar',
        data: {},
        methods: {
            load: function(e) {
                loadFile();
            },
            save: function(e) {
                saveFile();
            }
        }
    });

    footerVm = new Vue({
        el: '#footer',
        data: {
            currentPath: ""
        }
    });
});

electronでMaterializeを使う場合、jQueryやHammerといったライブラリのグローバル変数を定義しておかないとundefinedになるんですよね。

下記Qiita投稿が詳しいです。

また、Vue.jsのfilterとemojify.jsの連携は色々と試しましたが、下記のような実装に落ち着きました。正規表現で置換しているのは、あああ:smaile:いいい のような入力時でも絵文字とemojify.jsに認識させるために前後に半角スペース入れて調整かけてます。

editorVm = new Vue({
        el: '#editor',
        data: {
            input: ""
        },
        filters: {
            marked: function(input) {
                var result = md.render(input);
                result = result.replace(/(:[0-9a-zA-Z_\+\-]+?:)/g, " $1 ");
                return emojify.replace(result);
            }
        }
    });

なお、ウィンドウサイズ変更時にCodeMirrorのエディタ部分の高さ調節など、細かい調整もしています。


そして、最後にファイルオープンと保存です。
Rendererプロセスからダイアログを開く場合には、require('remote') でBrowserProcessから取得するのがポイントです。

editor.js
var remote = require('remote');
var fs = require('fs');
var dialog = remote.require('dialog');
var browserWindow = remote.require('browser-window');

/**
 * ファイルを開く
 */
function loadFile() {
    var win = browserWindow.getFocusedWindow();
    dialog.showOpenDialog(
        win,
        {
            properties: ['openFile'],
            filters: [
                {
                    name: 'Markdown',
                    extensions: ['md', 'txt']
                }
            ]
        },
        function (filenames) {
            if (filenames) {
                fs.readFile(filenames[0], function (error, text) {
                    if (error != null) {
                        alert('error : ' + error);
                        return;
                    }
                    mdEditor.setValue(text.toString());
                    Materialize.toast('Load complete.', 1000);
                });
                footerVm.currentPath = filenames[0];
            }
        });
}

/**
 * ファイルを保存する
 */
function saveFile() {
    if (footerVm.currentPath == "") {
        saveNewFile();
        return;
    }
    var win = browserWindow.getFocusedWindow();
    dialog.showMessageBox(win,
        {
            title: 'ファイルの上書き保存を行います。',
            type: 'info',
            buttons: ['OK', 'Cancel'],
            detail: '本当に保存しますか?'
        },
        function (res) {
            if (res == 0) {
                var data = mdEditor.getValue();
                writeFile(footerVm.currentPath, data);
            }
        }
    );
}

/**
 * ファイルを新規に保存する
 */
function saveNewFile() {
    var win = browserWindow.getFocusedWindow();
    dialog.showSaveDialog(
        win,
        {
            properties: ['openFile'],
            filters: [
                {
                    name: 'Documents',
                    extensions: ['md', 'txt']
                }
            ]
        },
        function (fileName) {
            if (fileName) {
                var data = mdEditor.getValue();
                footerVm.currentPath = fileName;
                writeFile(fileName, data);
            }
        }
    );
}

/**
 * ファイルを書き込む
 */
function writeFile(path, data) {
    fs.writeFile(path, data, function (error) {
        if (error != null) {
            alert('error : ' + error);
        }
        Materialize.toast('Save complete.', 1000);
    });
}

ソース & リリース

ソースはGithubで公開してます。

electron-packagerでWindows版とMac版の実行ファイルを作ってみました。

所感

初のElectron/Node.jsでしたが、ソース修正しても、Reloadするだけで動作確認できるし、Chrome Developer Toolsが使えるので、デバッグもほぼいつものノリで可能で、目新しさのない反面、ほぼキャッチアップする必要もなく、自然と使いこなすことができました。

またelectron-packagerで、クロスプラットフォームのビルドも作れて、本当に便利ですね。

来年はここで得たノウハウを元に、本気でMarkdownアプリ作ろうかと思いました。

参考