Elm でマークダウンプレゼンテーションエディタを作ってみた (その2)
Electron Advent Calendar 2017 の22日目の記事です.
Elm2 アドカレで 「Elm でマークダウンプレゼンテーションエディタを作るハナシ」を書いたのですが,長くなったので分けました. 前半はコチラ(前半は Electron 関係ないけどね).
今回はローカルファイルの読み書きをするために Electron を導入します(Elm もといブラウザでいい感じにする方法が分からなかった). 今回のコードは以下のリポジトリにあります.
Elm と Electron
結構いろんな人が挑戦してて,資料は豊富にある. ぼくは以下のリポジトリを参考にした.
アナログ時計を表示する Electron プログラムだったはず.
つくる
少しずつ拡張していく.
Electron 化
まずは Electron 化する. もともとは次のような構成だった(main.js
は Elm ファイル群から生成).
/
|-- elm-package.json
|-- index.html
|-- src/
| |-- Main.elm
| \-- ..
\-- js/
|-- main.js \-- highlight.js
これ,elmtrn を参考に次のような構成に変更した.
/
|-- elm-package.json
|-- gulpfile.js
|-- package.json
\-- app
|-- index.html
|-- src/
| |-- Main.elm
| \-- ..
\-- js/
|-- app.js
|-- main.js \-- ..
package.json は elmtrn をほぼそのまんま(main
の場所だけ違う). gulp を使って,Elm のコードを監視・コンパイルし,生成した JS コードを Electron から呼び出す. elmtrn の gulpfile.js の設定では,各 Elm ファイルに対しひとつの JS ファイルを生成していたが,自分はひとまとめにした JS を生成したかったので,次のように gulpfile.js を書き換えた.
const g = require('gulp');
const electron = require('electron-connect').server.create();
const packager = require('electron-packager');
const $ = require('gulp-load-plugins')();
const packageJson = require('./package.json');
const extend = require('util')._extend;
.task('watch', () => {
g.watch(['app/src/**/*.elm'],['elm']);
g.start();
electron.watch(['app/js/*.js', 'app/index.html'], electron.restart);
g.watch([], electron.reload);
g
})
.task('elm', () =>{
g.src(['app/src/**/*.elm'])
g.pipe($.logger())
.pipe($.plumber())
.pipe($.elm.bundle('main.js', debug=true))
.pipe(g.dest("app/js"));
})
.task('default', ['watch']) g
philopon/gulp-elm の README が参考になった.
あとは,次のように elmtrn の app.js を適当に書き直した.
const {app, BrowserWindow} = require('electron');
var mainWindow = null;
.on('window-all-closed', function() {
app.quit();
app;
})
.on('ready', function() {
app= new BrowserWindow({
mainWindow "frame": true,
"always-on-top": true,
"resizable": true
;
}).maximize();
mainWindow.loadURL('file://' + __dirname + '/../index.html');
mainWindow.on('closed', function() {
mainWindow= null;
mainWindow ;
}); })
これで gulp
を実行すればブラウザ版 elmdeck がそのまんま electron で実行できる. やったぁ.
ファイルの読み込み
ココからが本番.
設計として,デスクトップでよくある感じに,左上の File
から Open
とかしたい. こんな感じ(これは Atom だけど).
Electron でファイルの呼び出しをする方法は以下の記事を参考にした.
Node の fs ライブラリを使えばよいようだ(Electron に限らないハナシかな). fs の公式ドキュメントとにらめっこして fs.readFile
を呼び出せば良いみたいなのは分かった. 取りあえず,次のような files.js
ファイルを書いた.
'use strict';
const {remote} = require('electron');
const {dialog, BrowserWindow} = remote;
const fs = require('fs');
.exports = {
modulereadFile: function (app) {
.showOpenDialog(null, {
dialogproperties: ['openFile'],
title: 'File',
defaultPath: '.',
filters: [
name: 'マークダウン', extensions: ['md', 'markdown']},
{
], (fileNames) => {
}.readFile(fileNames[0], 'utf8', (err, data) => {
fsif (err) console.log(err);
console.log(data);
});
})
} }
次にこれをメニューバーから呼べるようにする. Electron のメニューバーを拡張するには Menu
クラスを使えば良いらしい. サンプルやらを参考にしながらイロイロ試行錯誤してみた結果,次のような menuItems.js
ファイルを書き,
const {app, Menu, dialog} = require('electron');
const template = [
{label: 'Edit',
submenu: [
role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
{role: 'pasteandmatchstyle'},
{role: 'delete'},
{role: 'selectall'}
{
],
}
{label: 'View',
submenu: [
role: 'reload'},
{role: 'forcereload'},
{role: 'toggledevtools'},
{type: 'separator'},
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'},
{role: 'toggledevtools'}
{
],
}
{role: 'window',
submenu: [
role: 'minimize'},
{role: 'close'}
{
],
}
{role: 'help',
submenu: [
{label: 'Learn More',
click () { require('electron').shell.openExternal('https://electron.atom.io') }
}
]
}
]
const items = template.map( option => { return new MenuItem(option) });
.exports = {
moduleget: () => { return items; }
}
これ(module exports した get
関数のコト)を index.html
で次のように呼び出した.
<script>
const {remote} = require('electron');
const {Menu, MenuItem} = remote;
const files = require('./js/files');
const menuItems = require('./js/menuItems')
var node = document.getElementById('main');
while (node.firstChild) {
.removeChild(node.firstChild);
node
}var app = module.exports.Main.embed(node);
var menuvar = new Menu();
.append(new MenuItem(
menuvar
{label: 'File',
submenu: [
{label: 'Open',
click() { files.readFile(app) }
}
]
};
)).get().forEach( item => { menuvar.append(item) } );
menuItems.setApplicationMenu(menuvar)
Menu</script>
var menuvar = new Menu();
以下からがキモです. どーしても,動的に処理を定義しない部分(Edit
とか View
とか)を別ファイル(menuItems.js
)にまとめたうえで,File
を先頭に突っ込みたかったのでこうなった. JS は全然詳しくないのでアンチパターンかもしれないけどね.
Elm に繋げる
ここまでで
- 上部にあるメニューバーの
File
->Open
を押して - ファイルをダイアログで選択し
- コンソールに内容を吐き出す
までは書けた. ここからは (3) が「Elm に渡して input エリアに書き出す」になるようにする.
Elm と JS を繋ぐには方法がいくつかあるが,今回は Port
を使ってみる(前回はお行儀の悪い Native
モジュールを使ったけど). 次の記事が本当に参考になった.
マークダウンファイルの中身を JS から Elm に投げるので Elm で次のような ports
関数を定義した.
-- src/Port/FS.elm
port module Port.FS exposing (..)
port readFile : (String -> msg) -> Sub msg
これを Main.elm
で次のように呼び出す.
type alias Model =
textarea : String
{ , window : Window.Size
}
type Msg
= TextAreaInput String
| SizeUpdated Window.Size
main : Program Never Model Msg
main =
Html.program
init = init model
{ , view = view
, update = update
, subscriptions = subscriptions
}
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
Window.resizes SizeUpdated
[ , FS.readFile TextAreaInput
]
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
TextAreaInput str ->
model | textarea = str }, Cmd.none )
( { SizeUpdated size ->
model | window = size }, Cmd.none ) ( {
Window.Size
とか SizeUpdated
はブラウザやアプリのウィンドウサイズに合わせて,スライドのサイズを変更するためのサブスクリプションなので気にしないで. TextAreaInput
は input エリアにテキストを書き込んだ時にも使っている. 同じ型なので使いまわした.
あとは files.js
の console.log(data);
としていた部分を app.ports.readFile.send(data);
と書き換えるだけ.
うまくいった.
ファイルの書き込み
さて次はファイルの保存を実装する.
ファイルパスも投げておく
ファイルを保存するには開いてるファイルのファイルパスがあった方が良いだろう(上書き保存とかするなら). なのでまずは,読み込み時の処理をファイルパスも投げるように書き換える.
-- src/Port/FS.elm
port module Port.FS exposing (..)
type alias File =
path : String
{ , body : String
}
port readFile : (File -> msg) -> Sub msg
type alias Model =
textarea : String
{ , window : Window.Size
, filepath : String
}
type Msg
= TextAreaInput String
| SizeUpdated Window.Size
| ReadFile FS.File
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
TextAreaInput str ->
model | textarea = str }, Cmd.none )
( { SizeUpdated size ->
model | window = size }, Cmd.none )
( { ReadFile file ->
model | textarea = file.body, filepath = file.path }, Cmd.none )
( {
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
Window.resizes SizeUpdated
[ , FS.readFile ReadFile
]
レコード型を JS から Elm に投げるには普通のオブジェクトを使えばよいらしい(最初はタプルを使おうとして良くわからなくなり諦めた...).
// js/files.js
.exports = {
modulereadFile: function (app) {
.showOpenDialog(null, {
dialogproperties: ['openFile'],
title: 'File',
defaultPath: '.',
filters: [
name: 'マークダウン', extensions: ['md', 'markdown']},
{
], (fileNames) => {
}.readFile(fileNames[0], 'utf8', (err, data) => {
fsif (err) console.log(err);
.ports.readFile.send({ path: fileNames[0], body: data });
app
});
})
} }
いよいよ書き出し
保存するとき,データは Elm 側から投げられるが保存ボタンは Electron 側(JS側)から始めたい. なので
- 保存ボタンを押したら何らかの値を JS から Elm に送信
- それを受けたら Elm から JS にマークダウンのデータを送信
というお手製同期通信を行うことにした(これもアンチパターンかも...). 上書き保存のときは null
(Elm 側では Nothing
) を JS から送り,新規保存ならファイル名を送ることにする.
まずは Elm 側で,以上の戦略から次のような port
を書いた.
port writeFileHook : (Maybe String -> msg) -> Sub msg
port writeFile : File -> Cmd msg
次は JS 側に移る. ファイルの書き出しには fs.writeFile
関数を用いた. 前述した port
も使って,次のような関数を files.js
に追加した.
function writeFileTo(fileName, data) {
if (fileName) {
.writeFile(fileName, data, (err) => {
fsif (err) {
console.log(err);
.showErrorBox('Can not save fiel: ' + fileName, err);
dialog
}
})
}
}
.exports = {
modulereadFile: function (app) {
...
,
}writeFile: function (app) {
.ports.writeFileHook.send(null);
app.ports.writeFile.subscribe(args => { writeFileTo(args['path'], args['body']) });
app,
}writeFileAs: function (app) {
.showSaveDialog(null, {
dialogproperties: ['openFile'],
title: 'File',
defaultPath: '.',
filters: [
name: 'Markdown', extensions: ['md', 'markdown']},
{
], (fileName) => {
}if (fileName == undefined) {
console.log(fileName);
.showErrorBox('Can not save fiel: ', 'Please select file.');
dialogreturn
}.ports.writeFileHook.send(fileName);
app.ports.writeFile.subscribe(args => { writeFileTo(args['path'], args['body']) });
app;
})
} }
上書き保存 writeFile
と新しく保存 writeFileAs
を用意し,共通部分は writeFileTo
関数として書き出した.
これをメニューバーに追加する.
var menuvar = new Menu();
.append(new MenuItem(
menuvar
{label: 'File',
submenu: [
{label: 'Open',
click() { files.readFile(app) }
,
}
{label: 'Save',
click() { files.writeFile(app) }
,
}
{label: 'Save As',
click() { files.writeFileAs(app) }
}
]
}; ))
最後に Elm 側に処理を追加した.
-- app/src/Main.elm
type Msg
= TextAreaInput String
| SizeUpdated Window.Size
| ReadFile FS.File
| WriteFileHook (Maybe String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
TextAreaInput str ->
model | textarea = str }, Cmd.none )
( { SizeUpdated size ->
model | window = size }, Cmd.none )
( { ReadFile file ->
model | textarea = file.body, filepath = file.path }, Cmd.none )
( { WriteFileHook (Just filepath) ->
model | filepath = filepath }, FS.writeFile { path = filepath, body = model.textarea } )
( { WriteFileHook Nothing ->
model, FS.writeFile { path = model.filepath, body = model.textarea } )
(
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
Window.resizes SizeUpdated
[ , FS.readFile ReadFile
, FS.writeFileHook WriteFileHook
]
これでうまく動作するはずだ.
ショートカット
最後にショートカットだ. 次の記事が参考になった.
Accelerator
というのを使えばよいらしい.
var menuvar = new Menu();
.append(new MenuItem(
menuvar
{label: 'File',
submenu: [
{label: 'Open',
accelerator: 'Ctrl+O',
click() { files.readFile(app) }
,
}
{label: 'Save',
accelerator: 'Ctrl+S',
click() { files.writeFile(app) }
,
}
{label: 'Save As',
accelerator: 'Ctrl+Shift+S',
click() { files.writeFileAs(app) }
}
]
}; ))
これで目的のモノはできた!
懸念
なんか Electron のファイル IO にはセキュリティ的に甘いところがあるらしい...
個人で使う分にはいいんだけど...対策しなきゃかなぁ... Elm を介してレンダラしたマークダウンを貼り付けてるので問題ないのだろうか... 良く分からない.
思うところ
結局 JS は結構書いてるなーと思った(笑) JS 絶対書きたくないマンは Elm でできることは,まだ制限される印象だ. JS の知識も多少ないとキツソウだし.
まぁ綺麗に分離できるのがうれしいんだけどね.
おしまい
頑張って作っていくぞ.