投稿の内容
本投稿では、Scala.js
のUIフレームワークであるLaminar
を使って電卓アプリを作ってみます。
プロジェクトの作成
プロジェクトのディレクトリを作成します。
$ mkdir laminar-dentaku
$ cd laminar-dentaku
Scala.js
のプラグインの設定をします。
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0")
sbt
のバージョンを設定します。
sbt.version=1.7.1
sbt
のビルドファイルを作成します。ここでLaminar
を追加します。
lazy val commonSettings = Seq(
organization := "com.example.dentaku",
scalaVersion := "3.2.2",
version := "1.0.0"
)
lazy val root = (project in file("."))
.enablePlugins(ScalaJSPlugin)
.settings(
inThisBuild(commonSettings),
name := "dentaku",
scalaJSUseMainModuleInitializer := true,
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.4.0",
libraryDependencies += "com.raquo" %%% "laminar" % "0.14.2",
)
Main.scalaの作成
まずは、Hello World
。
@main def main = println("Hello World")
JSファイル生成
ビルド。
$ sbt
sbt> fastLinkJS
JSファイルは以下に出力されます。
target/scala-3.2.2/dentaku-fastopt/main.js
JSファイルの出力先を変更
デプロイするディレクトリpublic
にJSファイルを出力するように変更します。以下のようにbuild.sbt
を修正するとfastLinkJS
タスクでpublic
ディレクトリにindex.js
を作成してくれます。
...
.settings(
...
fastLinkJS := fastLinkJSWrapper("./public/index.js").value
...
)
...
def fastLinkJSWrapper(newOutputPath: String) = Def.taskDyn {
Def.task {
val oldOutputDir = (ThisProject / Compile / fastLinkJSOutput).value
val report = (ThisProject / Compile / fastLinkJS).value
val outputFileName = report.data.publicModules.head.jsFileName
val outputFilePath =
java.nio.file.Path.of(oldOutputDir.toString, outputFileName).toFile
IO.copyFile(
outputFilePath,
file(newOutputPath)
)
val sourceMapName = report.data.publicModules.head.sourceMapName
sourceMapName.foreach { fname =>
val sourceMapPath =
java.nio.file.Path.of(oldOutputDir.toString, fname).toFile
IO.copyFile(
sourceMapPath,
file(s"${newOutputPath}.map")
)
}
report
}
}
HTMLファイルの作成
ビルドされたJSファイルを以下のHTMLで使用します。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Dentaku</title>
</head>
<body>
<script type="text/javascript" src="index.js"></script>
</body>
</html>
LaminarでHello World
Laminar
を使ってブラウザにHello World
を表示してみましょう。
package com.example.dentaku
import org.scalajs.dom
import com.raquo.laminar.api.L.{*, given}
@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body
val appElement: Div = div(
"Hello World"
)
render(appContainer, appElement)
}(unsafeWindowOwner)
ビルドから表示まで。
$ stb fastLinkJS
$ npm i -D serve
$ npx serve -p 3333 public
ブラウザでhttp://localhost:3333
を表示してHello World
と表示されているのを確認します。
Laminarで電卓の画面を作る
Laminar
のHTMLタグのDSLを使用して電卓の画面を作ります。Laminar
のHTMLの記述は見れば分かるほど簡単です。スタイルも属性として書くことができます。
package com.example.dentaku
import org.scalajs.dom
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}
@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body
val appElement: Div = div(
width := "300px",
div(
padding := "4px",
display := "flex",
flexDirection := "row",
input(
margin := "4px",
flexGrow := 1,
border := "1px solid silver",
readOnly := true,
typ := "text"
),
button(
margin := "4px",
"AC"
)
),
div(
padding := "4px",
display := "flex",
flexDirection := "column",
js.Array(
js.Array("1", "2", "3", "/"),
js.Array("4", "5", "6", "*"),
js.Array("7", "8", "9", "-"),
js.Array("0", ".", "=", "+")
).map { row =>
div(
display := "flex",
flexDirection := "row",
row.map { cell => button(flexGrow := 1, margin := "4px", cell) }
)
}
)
)
render(appContainer, appElement)
}(unsafeWindowOwner)
出来た画面はこちら!
Laminarでイベント処理
電卓のそれぞれのボタンを押した時の挙動を実装する前に、Laminar
でのイベント処理の仕組みを簡単に見ていきましょう。
Laminar
でのイベントの処理はObserver
とObservable
を使います。
Observer
は、イベントを検知する役割を担っています。
Observable
は、Observer
が検知した値を受け取って、それをDOM要素のプロパティに設定する役割を担っています。
以下はボタンのクリックを検知する例です。
button(
onClick --> observer
)
以下は検知した値をTextNode
に設定する例です。
div(
child.text <-- observable
)
EventBus
を使ってObsever
とObservable
を関連づけることができます。
val bus = EventBus[dom.MouseEvent]
val observer = bus.writer
val observable = bus.events
上記の例は、observer
で受け取ったMouseEvent
をobservable
に渡しています。このままだとMouseEvent
を要素のプロパティに渡してしまうことになるので、observable
の値をmap
を使って、例えばクリックした時刻に変更する時は以下のようにします。
val observable = bus.events.map(evt => (new js.Date).toISOString)
MouseEvent
のevt
は使わないのでmapTo
を使って省略することもできます。
val observable = bus.events.mapTo((new js.Date).toISOString)
イベントで受け取った値を変換するだけではなくて、状態を変更することもできます。例えば、ボタンをクリックする度に1づつ増えるカウンターを作ってみましょう。
val bus = EventBus[dom.MouseEvent]
val observer = bus.writer
val observable = bus.events.foldLeft(0)((cnt, _) => cnt + 1).map(_.toString)
button(
onClick --> observer
)
div(
child.text <-- observable
)
電卓のイベント処理
ボタンをクリックしたらボタンの文字をObserver
に流します。Observable
で文字によって適用する処理を変えます。
例えばAC
が入力されたらクリアします。
計算式の評価はjs.eval
を使います。計算式の評価がエラーになったらLeft
にエラーメッセージを入れます。Observable
がLeft
の時はAC
以外入力できないようにしています。
val bus = EventBus[String]
val observer = bus.writer
val observable = bus.events
.foldLeft[Either[String, String]](Right("")) {
case (Left(msg), "AC") => Right("")
case (Left(msg), _) => Left(msg)
case (Right(exp), cmd) =>
cmd match
case "AC" => Right("")
case "=" =>
Try(js.eval(exp)) match
case Success(r) => Right(r.toString)
case Failure(ex) => Left(ex.getMessage)
case "+" | "-" | "*" | "/" =>
Right(if exp == "" then cmd else s"${exp} ${cmd} ")
case _ => Right(s"${exp}${cmd}")
}
.map {
case Left(msg) => msg
case Right(exp) => exp
}
以下全コードです。
package com.example.dentaku
import org.scalajs.dom
import scala.scalajs.js
import com.raquo.laminar.api.L.{*, given}
import scala.util.Try
import scala.util.Success
import scala.util.Failure
@main def main =
documentEvents.onDomContentLoaded.foreach { _ =>
val appContainer: dom.Element = dom.document.body
val bus = EventBus[String]
val observer = bus.writer
val observable = bus.events
.foldLeft[Either[String, String]](Right("")) {
case (Left(msg), "AC") => Right("")
case (Left(msg), _) => Left(msg)
case (Right(exp), cmd) =>
cmd match
case "AC" => Right("")
case "=" =>
Try(js.eval(exp)) match
case Success(r) => Right(r.toString)
case Failure(ex) => Left(ex.getMessage)
case "+" | "-" | "*" | "/" =>
Right(if exp == "" then cmd else s"${exp} ${cmd} ")
case _ => Right(s"${exp}${cmd}")
}
.map {
case Left(msg) => msg
case Right(exp) => exp
}
val appElement: Div = div(
width := "300px",
div(
padding := "4px",
display := "flex",
flexDirection := "row",
input(
margin := "4px",
flexGrow := 1,
border := "1px solid silver",
readOnly := true,
typ := "text",
value <-- observable
),
button(
margin := "4px",
"AC",
onClick.mapTo("AC") --> observer
)
),
div(
padding := "4px",
display := "flex",
flexDirection := "column",
js.Array(
js.Array("1", "2", "3", "/"),
js.Array("4", "5", "6", "*"),
js.Array("7", "8", "9", "-"),
js.Array("0", ".", "=", "+")
).map { row =>
div(
display := "flex",
flexDirection := "row",
row.map { cell =>
button(
flexGrow := 1,
margin := "4px",
cell,
onClick.mapTo(cell) --> observer
)
}
)
}
)
)
render(appContainer, appElement)
}(unsafeWindowOwner)
出来上がった電卓はこちらで確認できます。
以上、Laminar
で電卓を作ってみました。