Posted at

Elmでディレクトリ構造エディタを作る


はじめに

動いているサイト



こんな感じの静的サイトを作ったという話です。(demoは少し古いですが)


全体的にMonocleやOptionalと言った概念を理解していないところが出ていると思うので、ご容赦ください。


使ったもの


実装

src/Main.elmに全てのロジックが書いてあります。なので上から解説していこうと思います。


model定義~init


Main.elm

-- MODEL

-- 階層構造的なID
type alias Id =
List Int

-- 木構造のNodeに当たる部分, 今回valueはディレクトリ・ファイル名に相当
type alias NodeData =
{ id : Id, value : String }

-- Undo/Redo機能のためにUndoListで包んでいる
type alias Model =
UndoList (Tree NodeData)

-- 最初は空
nodeTree : Tree NodeData
nodeTree =
Tree { id = [], value = "" }
[]

-- 初期化
init : Model
init =
UndoList.fresh nodeTree


model定義はelm-monocle Lens/Optionalで多分木操作を隠蔽を参考にしています。(いつもありがとうございます。)


更新処理(update)


Main.elm

-- UPDATE

type Msg
= Change Id String
| Add Id
| Delete Id
| Reset
| Undo
| Redo
| Save
| CancelSave

update : Msg -> Model -> Model
update msg model =
case msg of
-- idで指定したnodeの値を変更する。1文字ずつUndoしたくないのでここではUndoListを更新しない。
Change id value ->
{ model | present = model.present |> Optional.modify (setNodeById id) (\nd -> { nd | value = value }) }

-- idで指定したnodeを削除する(子供も含めて)
Delete id ->
case
List.reverse id
|> List.head
of
Just index ->
UndoList.new
(model.present
-- 指定されたnodeを削除するためには、親の子供を変更する必要がある
|> Optional.modify (parentId id |> setChildrenById) (\children -> removeElement index children)
|> resetRootId
)
model

Nothing ->
model

-- idで指定したnodeの下に子を追加する
Add id ->
UndoList.new
(model.present
|> Optional.modify (addTreeById id) (\_ -> Tree { id = [], value = "" } [])
|> resetRootId
)
model

-- model(木)を初期化する
Reset ->
UndoList.new (Tree { id = [], value = "" } []) model

Undo ->
UndoList.undo model

Redo ->
UndoList.redo model

-- focus時に状態を保存しておく
Save ->
UndoList.new model.present model

-- blur時に何も変更がなければSaveをcancelする
CancelSave ->
if model.present == (UndoList.undo model).present then
if UndoList.lengthPast model == 1 then
UndoList.fresh model.present

else
UndoList.new (UndoList.undo model).present (UndoList.undo model |> UndoList.undo)

else
model


ポイントはOptional.modifyで、変更する対象+変更を与えると階層構造に対してよしなに変更してくれます。


Optionalをゴリゴリするところ


Main.elm

-- getterとsetterとIdを与えるとそれを変更できるものが返ってくる。

-- 自分でも何言ってるかわからないので、いい感じの解釈があれば教えて欲しいです。
getOptionalByGetterAndSetter : (Zipper a -> b) -> (b -> Zipper a -> Maybe (Zipper a)) -> Id -> Optional (Tree a) b
getOptionalByGetterAndSetter getFunc setFunc id =
let
goToZipper : Tree a -> Maybe (Zipper a)
goToZipper tree =
let
goToNode zipper =
List.foldl (\idx mz -> mz |> Maybe.andThen (Zipper.goToChild idx)) (Just zipper) id
in
( tree, [] ) |> goToNode

-- 指定されたidのZipperを、dataで書き換える関数を用いて書き換え、Root Zipperに戻したもの
replacedZipper tree data =
goToZipper tree |> Maybe.andThen (setFunc data) |> Maybe.andThen Zipper.goToRoot

-- 指定されたidのZipperからdataを得るgetter
get tree =
goToZipper tree |> Maybe.map getFunc

-- 指定されたidのZipperのdataを書き換えるsetter, もし該当箇所が無ければ元のtreeを返す
set data tree =
Maybe.withDefault tree (Maybe.map treeOfZipper <| replacedZipper tree data)
in
Optional get set

-- Idを指定してTreeに子供を加えられるOptionalを返す
addTreeById : Id -> Optional (Tree a) (Tree a)
addTreeById =
getOptionalByGetterAndSetter treeOfZipper Zipper.appendChild

-- Idを指定して子供を変更できるOptionalを返す
setChildrenById : Id -> Optional (Tree a) (Forest a)
setChildrenById =
getOptionalByGetterAndSetter (treeOfZipper >> MultiwayTree.children) Zipper.updateChildren

-- Idを指定してNodeの値を変更できるOptionalを返す
setNodeById : Id -> Optional (Tree a) a
setNodeById =
getOptionalByGetterAndSetter Zipper.datum Zipper.replaceDatum


getOptionalByGetterAndSetterを作ることで、子供を変更したり、値を変更したりするOptionalを簡単に作れるようになりました。


IdをResetする


Main.elm

resetRootId : Tree NodeData -> Tree NodeData

resetRootId tree =
let
-- 最初のIdを決めないと再帰できないので決めたのち、再帰関数(resetId)に渡す。
newTree =
Tree { id = [], value = (MultiwayTree.datum tree).value } (MultiwayTree.children tree)
in
resetId newTree

resetId : Tree NodeData -> Tree NodeData
resetId tree =
let
id =
(MultiwayTree.datum tree).id

newChildren =
List.foldl
(\child acc ->
let
-- 自分の直下の子供のIdは、自分のIdから決まる。
newChild =
Tree { id = id ++ [ List.length acc ], value = (MultiwayTree.datum child).value } (MultiwayTree.children child)
in
-- 子供のIdが決まったので孫のIdを再帰的に決めてもらう
acc ++ [ resetId newChild ]
)
[]
(MultiwayTree.children tree)
in
Tree (MultiwayTree.datum tree) newChildren


子供を加えたり、削除したりするとIdがめちゃくちゃになるので、そういう操作が入った時には毎回Idを初期化しています。(無駄な処理も多いので改善したい・ここでもOptionalを使えそう・・・?)


PlaneTextへの変換


Main.elm

tree2Plane : Tree NodeData -> String

tree2Plane tree =
let
-- 得られたディレクトリ構造のブロックの頭に、線(┣━ ・ ┃)を足す処理
addHeader : String -> String
addHeader s =
case String.split "\n" s of
first :: list ->
"\n├── " ++ first ++ List.foldl (\x acc -> acc ++ "\n│ " ++ x) "" list

_ ->
""

-- 同じく線を足すが、最後尾は特別扱いしなければならない(┗━ を足す)
addHeader4Last : String -> String
addHeader4Last s =
case String.split "\n" s of
first :: list ->
"\n└── " ++ first ++ List.foldl (\x acc -> acc ++ "\n " ++ x) "" list

_ ->
""
in
-- 最後尾を取りたいが、直接は取れないのでreverseしている
case MultiwayTree.children tree |> List.reverse of
last :: rest ->
(MultiwayTree.datum tree).value
++ List.foldl
(\x acc ->
-- 子供のブロック(文字の塊)にHeaderを足して親に返していくという発想
acc ++ addHeader (tree2Plane x)
)
""
(List.reverse rest)
++ addHeader4Last (tree2Plane last)

_ ->
(MultiwayTree.datum tree).value


acc ++ addHeader (tree2Plane x)の部分など(tree2Plane x)で完成したものが返ってくるという前提で組んでいくという、再帰関数的な思考に慣れるまで時間がかかりました。(小並感)


まとめ

Optionalの概念をまだ深く理解してはいないですが、不変なものの一部を変更するという観点に置いて非常に便利なものだとは理解できました。周辺ライブラリも色々とあるので、こういったものはElmでシュッと作れて非常に良いですね。