Initial Commit

main
Oliver Kennedy 2023-01-04 11:07:04 -05:00
commit 74afa40620
Signed by: okennedy
GPG Key ID: 3E5F9B3ABD3FDB60
24 changed files with 888 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/out
/.bloop
/.metals

133
build.sc Normal file
View File

@ -0,0 +1,133 @@
import $ivy.`com.lihaoyi::mill-contrib-bloop:$MILL_VERSION`
import $ivy.`io.bit3:jsass:5.10.4`
import mill._
import mill.scalalib._
import mill.scalalib.publish._
import mill.scalajslib._
import coursier.maven.{ MavenRepository }
import mill.util.Ctx
import mill.api.{ Result, PathRef }
import io.bit3.jsass.{ Compiler => SassCompiler, Options => SassOptions, OutputStyle => SassOutputStyle }
/*************************************************
*** Overarching Target
*************************************************/
object cells extends Module {
val scalaVersion = "3.2.1"
def run() = server.run()
object shared extends Module
object server extends ScalaModule
{
def scalaVersion = cells.scalaVersion
def mainClass = Some("net.okennedy.cells.CellsServer")
def ivyDeps = Agg(
ivy"com.lihaoyi::cask:0.8.3",
ivy"com.typesafe.play::play-json::2.10.0-RC7",
)
def sources = T.sources(
millSourcePath,
shared.millSourcePath
)
def resources =
T.sources(
millSourcePath / "shared" / "resources",
cells.resources()
)
}
object ui extends ScalaJSModule
{
def scalaVersion = cells.scalaVersion
def scalaJSVersion = "1.12.0"
def ivyDeps = Agg(
ivy"org.scala-js::scalajs-dom::2.3.0",
ivy"com.raquo::laminar::0.14.5",
ivy"com.typesafe.play::play-json::2.10.0-RC7",
)
def sources = T.sources(
millSourcePath,
shared.millSourcePath
)
def sass =
T.sources {
os.walk(millSourcePath / "css")
.filter { _.ext == "scss" }
.map { PathRef(_) }
}
def compiledSass =
T {
val compiler = new SassCompiler()
val options = new SassOptions()
val target = T.dest
options.setOutputStyle(SassOutputStyle.COMPRESSED)
val src = sass().filter { _.path.last == "cells.scss" }.head
val out = target / "cells.css"
println(s"IGNORE THE FOLLOWING DEPRECATION WARNING: https://gitlab.com/jsass/jsass/-/issues/95")
val output = compiler.compileFile(
new java.net.URI((src.path.toString).toString),
new java.net.URI(out.toString),
options
)
output.getCss
}
def html =
T.sources {
os.walk(millSourcePath / "html")
.map { PathRef(_) }
}
}
def resources =
T {
val target = T.dest
// Cells UI binary
os.copy.over(
ui.fastOpt().path,
target / "app" / "cells.js",
createFolders = true
)
os.copy.over(
ui.fastOpt().path / os.up / (ui.fastOpt().path.last+".map"),
target / "app" / (ui.fastOpt().path.last+".map"),
createFolders = true
)
os.write(
target / "app" / "cells.css",
ui.compiledSass(),
createFolders = true
)
val assets =
ui.html().map { x => (x.path -> os.rel / x.path.last) }
for((asset, assetTarget) <- assets){
os.copy.over(
asset,
target / "app" / assetTarget,
createFolders = true
)
}
println(s"Generated UI resource dir: $target")
target
}
}

View File

@ -0,0 +1,72 @@
package net.okennedy.cells
import scala.io.Source
import play.api.libs.json._
object CellsServer extends cask.MainRoutes
{
var layout = new state.Canvas()
layout.addTable()
def serveResource(path: String, headers: (String,String)*): cask.Response[String] =
{
val resource =
Source.fromInputStream(
this.getClass().getClassLoader().getResourceAsStream(path)
).getLines.mkString
cask.Response.apply(
resource,
headers = headers
)
}
@cask.get("/")
def index(request: cask.Request) =
serveResource("app/index.html", "Content-Type" -> "text/html")
@cask.get("/cells.js")
def app(request: cask.Request) =
serveResource("app/cells.js", "Content-Type" -> "application/javascript")
@cask.get("/cells.css")
def css(request: cask.Request) =
serveResource("app/cells.css", "Content-Type" -> "text/css")
@cask.get("/out.js.map")
def appMap(request: cask.Request) =
serveResource("app/out.js.map", "Content-Type" -> "application/javascript")
@cask.websocket("/ws")
def websocket(): cask.WebsocketResult =
{
cask.WsHandler { channel =>
def send(msg: WebsocketResponse): Unit =
{
channel.send(
cask.Ws.Text(
Json.toJson(msg).toString
)
)
}
cask.WsActor {
case cask.Ws.Text(data) =>
Json.parse(data).as[WebsocketRequest] match {
case WebsocketHello(_) =>
for(table <- layout.tables.values)
{
send(AddTable(table.serialize))
}
}
}
}
}
@cask.staticResources("/app")
def staticResourceRoutes() = "app"
initialize()
}

View File

@ -0,0 +1,27 @@
package net.okennedy.cells.state
import scala.collection.mutable
import net.okennedy.cells.Identifier
import net.okennedy.cells.serialized
class Canvas()
{
val tables = mutable.Map[Identifier, Table]()
def genSafeTableId(depth: Int = 0): Identifier =
{
val id = java.util.UUID.randomUUID()
if(tables contains id){
if(depth > 100) { throw new Exception("Too Many Tables!!!") }
return genSafeTableId(depth + 1)
}
return id
}
def addTable(): Unit =
{
val table = new Table(genSafeTableId())
tables.put(table.id, table)
}
}

View File

@ -0,0 +1,14 @@
package net.okennedy.cells.state
import net.okennedy.cells.Identifier
import net.okennedy.cells.serialized
class Table(var id: Identifier)
{
def serialize =
serialized.Table(
id
)
}

View File

@ -0,0 +1,13 @@
package net.okennedy.cells
import play.api.libs.json._
sealed trait WebsocketRequest
case class WebsocketHello(client: String) extends WebsocketRequest
object WebsocketRequest
{
implicit val WebsocketHelloFormat: Format[WebsocketHello] = Json.format
implicit val WebsocketRequestFormat: Format[WebsocketRequest] = Json.format
}

View File

@ -0,0 +1,17 @@
package net.okennedy.cells
import play.api.libs.json._
sealed trait WebsocketResponse
sealed trait CanvasOp extends WebsocketResponse
case class AddTable(table: serialized.Table) extends CanvasOp
sealed trait TableOp extends CanvasOp
object WebsocketResponse
{
implicit val AddTableFormat: Format[AddTable] = Json.format
implicit val WebsocketResponseFormat: Format[WebsocketResponse] = Json.format
}

View File

@ -0,0 +1,5 @@
package net.okennedy.cells.serialized
import play.api.libs.json._
case class Cell(spec: String, value: JsValue)

View File

@ -0,0 +1,8 @@
package net.okennedy.cells.serialized
import net.okennedy.cells._
case class ColSpec(
id: Identifier,
width: Int
)

View File

@ -0,0 +1,8 @@
package net.okennedy.cells.serialized
import net.okennedy.cells._
case class RowSpec(
id: Identifier,
height: Int
)

View File

@ -0,0 +1,18 @@
package net.okennedy.cells.serialized
import play.api.libs.json._
import net.okennedy.cells._
case class Table(
id: Identifier,
// x: Int,
// y: Int,
// rows: Seq[RowSpec],
// cols: Seq[ColSpec],
// data: Seq[Seq[Cell]]
)
object Table
{
implicit val tableFormat: Format[Table] = Json.format
}

View File

@ -0,0 +1,3 @@
package net.okennedy.cells
type Identifier = java.util.UUID

110
cells/ui/css/cells.scss Normal file
View File

@ -0,0 +1,110 @@
.debug-red
{
background-color: red;
}
body
{
width: 100vw;
height: 100vh;
margin: 0px;
padding: 0px;
}
.spreadsheetCanvas
{
width: 100%;
height: 100%;
margin: 0px;
padding: 0px;
background-color: #eeeeee;
}
.dataTable
{
position: absolute;
background-color: #cccccc;
border-top-left-radius: 4px;
user-select: none;
.columnGutters
{
position: absolute;
.columnGutter
{
position: absolute;
border: 1px solid darkgrey;
text-align: center;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
}
.rowGutters
{
position: absolute;
.rowGutter
{
position: absolute;
border: 1px solid darkgrey;
text-align: center;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
.valignHack
{
position: absolute;
width: calc(100% - 4px);
text-align: right;
margin-top: -0.5em;
height: 1em;
top: 50%;
padding-right: 4px;
}
}
}
.content
{
background-color: #fff;
position: absolute;
.row
{
position: absolute;
width: 100%;
.cell
{
position: absolute;
height: 100%;
border-right: solid 1px lightgrey;
border-bottom: solid 1px lightgrey;
.cellBody
{
position: absolute;
height: 1em;
top: 50%;
margin-top: -0.5em;
min-width: 100%;
text-align: center;
}
}
}
}
}
.widgets
{
.dragFrame
{
position: absolute;
background-color: rgba(127, 127, 127, .3);
margin-left: 2px;
margin-top: 2px;
border: dashed lightgrey 2px;
}
}

15
cells/ui/html/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cells</title>
<link rel="stylesheet" type="text/css" href="cells.css">
</head>
<body>
<script type="text/javascript" src="cells.js"></script>
<script type="text/javascript">
Cells.run()
</script>
</body>
</html>

View File

@ -0,0 +1,37 @@
package net.okennedy.cells
import org.scalajs.dom
import scala.scalajs.js.annotation._
import com.raquo.laminar.api.L._
@JSExportTopLevel("Cells")
object CellsUI
{
// val dataTables = Var[Map[String, sheet.DataTable]](initial = Map.empty)
@JSExport("run")
def run(): Unit =
{
val conn = new network.Connection("ws://localhost:8080/ws")
dom.window.onload = { (evt: dom.Event) =>
val container: dom.Element =
dom.document.querySelector("body")
// val tableStream =
// dataTables.signal
// .map { _.values.toSeq }
// .split { _.id }
render(
container,
div(
conn.canvas.root,
widgets.Widgets.root
)
)
}
}
}

View File

@ -0,0 +1,57 @@
package net.okennedy.cells.network
import org.scalajs.dom
import play.api.libs.json._
import net.okennedy.cells.WebsocketRequest
import net.okennedy.cells.WebsocketResponse
import net.okennedy.cells.WebsocketHello
import net.okennedy.cells.CanvasOp
import net.okennedy.cells.sheet
class Connection(url: String)
{
val canvas = new sheet.Canvas()
var socket = getSocket()
protected[network] def getSocket(): dom.WebSocket =
{
val s = new dom.WebSocket(url)
s.onopen = onConnected(_)
s.onclose = onClosed(_)
s.onerror = onError(_)
s.onmessage = onMessage(_)
// keepaliveTimer = setInterval(20000) { keepalive(s) }
s
}
def send(msg: WebsocketRequest): Unit =
{
socket.send(Json.toJson(msg).toString)
}
def onConnected(event: dom.Event): Unit =
{
println("Connected")
send(WebsocketHello("itsame"))
}
def onClosed(event: dom.Event): Unit =
{
println("Closed")
}
def onError(event: dom.Event): Unit =
{
println("Error!")
}
def onMessage(message: dom.MessageEvent): Unit =
{
// println(s"Received: ${message.data.toString.take(200)}")
val request = Json.parse(message.data.toString).as[WebsocketResponse]
request match {
case x:CanvasOp => canvas.process(x)
}
}
}

View File

@ -0,0 +1,28 @@
package net.okennedy.cells.sheet
import scala.collection.mutable
import com.raquo.airstream.state.Var
import net.okennedy.cells._
import com.raquo.laminar.api.L._
class Canvas()
{
val tables = Var[Map[Identifier, Table]](initial = Map.empty)
def process(op: CanvasOp): Unit =
{
op match {
case AddTable(table) =>
tables.set(tables.now() ++ Map(table.id -> new Table(table)))
}
}
val tableNodes =
tables.signal.map { _.values.toSeq }
.split( key = _.id )( (k, tabl, sig) => tabl.root )
val root = div(
className("spreadsheetCanvas"),
children <-- tableNodes
)
}

View File

@ -0,0 +1,12 @@
package net.okennedy.cells.sheet
import com.raquo.laminar.api.L._
class Cell(val data: String)
{
val tag =
div(
className("cellBody text"),
data
)
}

View File

@ -0,0 +1,19 @@
package net.okennedy.cells.sheet
import net.okennedy.cells.Identifier
import net.okennedy.cells.serialized
class ColumnGutter(val spec: serialized.ColSpec, val position: Int, val idx: Int)
{
def label: String =
{
var i = idx
var l = ""
while(true) {
l += ('A'.toByte + (i % 26)).toChar.toString
if(i < 26) { return l }
i /= 26
}
return "???"
}
}

View File

@ -0,0 +1,8 @@
package net.okennedy.cells.sheet
object Constants
{
val GUTTER_HEIGHT = 20
val GUTTER_WIDTH = 30
val BORDER = 1
}

View File

@ -0,0 +1,9 @@
package net.okennedy.cells.sheet
import net.okennedy.cells.Identifier
import net.okennedy.cells.serialized
class RowGutter(val spec: serialized.RowSpec, val position: Int, val idx: Int)
{
def label = (idx+1).toString
}

View File

@ -0,0 +1,198 @@
package net.okennedy.cells.sheet
import com.raquo.laminar.api.L._
import net.okennedy.cells.Identifier
import net.okennedy.cells.serialized
import com.raquo.airstream.core.Signal
import com.raquo.airstream.combine.MergeEventStream
import play.api.libs.json._
import com.raquo.airstream.core.EventStream
import com.raquo.airstream.flatten.ConcurrentEventStream
import com.raquo.airstream.flatten.FlattenStrategy.ConcurrentStreamStrategy
import com.raquo.airstream.flatten.FlattenStrategy
import net.okennedy.cells.widgets.DragFrame
import com.raquo.airstream.ownership.OneTimeOwner
class Table(val id: Identifier)
{
implicit val tableOwner: Owner =
new OneTimeOwner( () => println(s"Accessing owner of table $id after killed") )
def this(ser: serialized.Table) =
{
this(
ser.id
)
}
val columns =
Var[Seq[serialized.ColSpec]](initial = Seq(
serialized.ColSpec(java.util.UUID.randomUUID(), width = 100),
serialized.ColSpec(java.util.UUID.randomUUID(), width = 100),
serialized.ColSpec(java.util.UUID.randomUUID(), width = 100),
))
val columnGutters: Signal[Seq[(Identifier, Signal[ColumnGutter])]] =
columns.signal
.map {
_.foldLeft( (0, 0, Seq[ColumnGutter]()) ) {
case ((pos, idx, accum), col) =>
(pos+col.width, idx+1, accum :+ new ColumnGutter(col, pos, idx))
}._3
}
.split(_.spec.id)( project = (key, _, signal) => key -> signal)
val rows =
Var[Seq[serialized.RowSpec]](initial = Seq(
serialized.RowSpec(java.util.UUID.randomUUID(), height = 30),
serialized.RowSpec(java.util.UUID.randomUUID(), height = 30),
serialized.RowSpec(java.util.UUID.randomUUID(), height = 30),
serialized.RowSpec(java.util.UUID.randomUUID(), height = 30),
serialized.RowSpec(java.util.UUID.randomUUID(), height = 30),
))
val rowGutters: Signal[Seq[(Identifier, Signal[RowGutter])]] =
rows.signal
.map {
_.foldLeft( (0, 0, Seq[RowGutter]()) ) {
case ((pos, idx, accum), col) =>
(pos+col.height, idx+1, accum :+ new RowGutter(col, pos, idx))
}._3
}
.split(_.spec.id)( project = (key, _, signal) => key -> signal)
val cells = Var[Seq[(Identifier, Seq[(Identifier, Cell)])]](initial =
rows.now().zipWithIndex.map { case (r, ridx) =>
r.id ->
columns.now().zipWithIndex.map { case (c, cidx) =>
c.id -> new Cell("["+cidx+", "+ridx+"]")
}
}
)
val x = Var(initial = 20)
val y = Var(initial = 20)
val width =
columns.signal.map { _.foldLeft(0)( (w, col) => w + col.width) }
.observe
val height =
rows.signal.map { _.foldLeft(0)( (h, row) => h + row.height) }
.observe
def root = div(
className("dataTable"),
// Table position and size information (reactive)
styleAttr <--
Signal.combine(x, y, width, height)
.map { case (x, y, w, h) =>
Seq(
s"left: ${x}px;",
s"top: ${y}px;",
s"width: ${w+Constants.GUTTER_WIDTH}px;",
s"height: ${h+Constants.GUTTER_HEIGHT}px;"
).mkString(" ")
},
// Initiate a Frame Drag session on click
onMouseDown --> {
(evt) =>
DragFrame.start(
x.now(), y.now(),
width.now()+Constants.GUTTER_WIDTH,
height.now()+Constants.GUTTER_HEIGHT,
evt
) { (x, y) => println(s"Move to : $x, $y")}
evt.stopPropagation()
},
// Display column gutters
div(
className("columnGutters"),
styleAttr(s"left: ${Constants.GUTTER_WIDTH}px"),
children <--
columnGutters.map { _.map { case (_, signal) =>
div(
className("columnGutter"),
styleAttr <-- signal.map { case col =>
s"width: ${col.spec.width-Constants.BORDER}px; height: ${Constants.GUTTER_HEIGHT-2*Constants.BORDER}px; left: ${col.position}px"
},
child <-- signal.map { _.label }
)
}},
),
// Display row gutters
div(
className("rowGutters"),
styleAttr(s"top: ${Constants.GUTTER_HEIGHT}px"),
children <--
rowGutters.map { _.map { case (_, signal) =>
div(
className("rowGutter"),
styleAttr <-- signal.map { case row =>
s"width: ${Constants.GUTTER_WIDTH-2*Constants.BORDER}px; height: ${row.spec.height-Constants.BORDER}px; top: ${row.position}px"
},
div(
className("valignHack"),
child <-- signal.map { _.label }
)
)
}},
),
// Display the actual content
div(
className("content"),
styleAttr <--
Signal.combine(width, height)
.map { case (w, h) =>
Seq(
s"width: ${w}px;",
s"height: ${h}px;",
s"left: ${Constants.GUTTER_WIDTH}px;",
s"top: ${Constants.GUTTER_HEIGHT}px",
).mkString(" ")
},
children <--
cells.signal.split( key = _._1 ){
(rowKey, _, rowCellStream) =>
val rowGutterStream: Signal[RowGutter] =
rowGutters.flatMap {
_.find(_._1 == rowKey).get._2:Signal[RowGutter]
}
div(
className("row"),
styleAttr <--
rowGutterStream.map { row =>
s"height: ${row.spec.height}px; top: ${row.position}px"
},
children <--
rowCellStream.map { _._2 }.split( key = _._1 ) {
(colKey, _, cellStream) =>
val cellGutterStream: Signal[ColumnGutter] =
columnGutters.flatMap {
_.find(_._1 == colKey).get._2:Signal[ColumnGutter]
}
div(
className("cell"),
styleAttr <--
cellGutterStream.map { col =>
s"width: ${col.spec.width}px; left: ${col.position}px"
},
child <--
cellStream.map { _._2.tag }
)
}
)
},
)
)
}

View File

@ -0,0 +1,44 @@
package net.okennedy.cells.widgets
import com.raquo.laminar.api.L._
import com.raquo.airstream.ownership.OneTimeOwner
class DragFrame(initX: Int, initY: Int, width: Int, height: Int, cursorX: Double, cursorY: Double, callback: (Int, Int) => Unit)
extends Widget
{
implicit val tableOwner: Owner =
new OneTimeOwner( () => println(s"Accessing owner of DragFrame after killed") )
val curr = Var[(Int, Int)](initial = (initX, initY))
val root = div(
className("dragFrame"),
styleAttr <--
curr.signal.map { case (x, y) =>
s"left: ${x-2}px; top: ${y-2}px; height: ${height-4}px; width: ${width-4}px"
},
documentEvents.onMouseUp --> {
(evt) =>
val (x, y) = curr.now()
callback( x, y )
Widgets.remove(this)
},
documentEvents.onMouseMove --> {
(evt) =>
curr.set( (
initX + (evt.pageX - cursorX).toInt,
initY + (evt.pageY - cursorY).toInt
) )
}
)
}
object DragFrame
{
def start(x: Int, y: Int, width: Int, height: Int, evt: org.scalajs.dom.MouseEvent)(callback: (Int, Int) => Unit): Unit =
{
val frame = new DragFrame(x, y, width, height, evt.pageX, evt.pageY, callback)
Widgets.register(frame)
}
}

View File

@ -0,0 +1,30 @@
package net.okennedy.cells.widgets
import com.raquo.laminar.api.L._
trait Widget
{
val root: com.raquo.laminar.nodes.ReactiveHtmlElement[org.scalajs.dom.html.Element]
}
object Widgets
{
val widgets = Var[List[Widget]](initial = Nil)
val root = div(
className("widgets"),
children <-- widgets.signal.map { _.map { _.root } }
)
def register(widget: Widget): Unit =
{
widgets.set(widget :: widgets.now())
}
def remove(widget: Widget): Unit =
{
println(s"Removing: $widget from ${widgets.now()}")
widgets.set(widgets.now().filter { _ ne widget })
}
}