Skip to content
Snippets Groups Projects
Main.scala 6.77 KiB
Newer Older
Martin Ring's avatar
Martin Ring committed
import akka.NotUsed

import akka.actor._
import akka.stream._
import akka.stream.scaladsl._
import org.scalajs.dom._
import org.scalajs.dom.ext.KeyCode
import org.scalajs.dom.EventListenerOptions

import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.{Future, Promise}

implicit class EventSource(val target: org.scalajs.dom.EventTarget):
  def on[E <: Event](event: String): Source[E, NotUsed] =
    Source.queue[E](2048, OverflowStrategy.fail).mapMaterializedValue { queue =>
      val handler: scalajs.js.Function1[E, Unit] = (e: E) => queue.offer(e)
      target.addEventListener(event, handler)
      handler
    }.watchTermination() {
      case (h, d) =>
        d.onComplete(_ => target.removeEventListener(event, h))
        NotUsed
    }

@main def main(): Unit =
  import PaintCommand._
  given system: ActorSystem = ActorSystem("paint")
  given Materializer = Materializer(system)

  val canvas = document.createElement("canvas").asInstanceOf[html.Canvas]
  document.body.appendChild(canvas)
  canvas.style.position = "fixed"
  canvas.style.left = "0"
  canvas.style.right = "0"
  canvas.style.top = "0"
  canvas.style.bottom = "0"

  canvas.width = window.innerWidth.toInt
  canvas.height = window.innerHeight.toInt

  val mouseDown =
    canvas.on[MouseEvent]("mousedown")
      .map(e => BeginDrag(e.clientX, e.clientY))

  val mouseUp =
    canvas.on[MouseEvent]("mouseup")
      .map(e => EndDrag(e.clientX, e.clientY))

  val mouseLeave =
    canvas.on[MouseEvent]("mouseleave")
      .map(e => EndDrag(e.clientX, e.clientY))

  val mouseMove =
    canvas.on[MouseEvent]("mousemove")
      .map(e => Drag(e.clientX, e.clientY))

  val mouseCommands = mouseDown.flatMapConcat { b =>
    Source.single(b).concat(
      mouseMove.merge(mouseUp merge mouseLeave).takeWhile(_.isInstanceOf[Drag], inclusive = true)
    )    
  }

  val keyboardCommands =
    window.on[KeyboardEvent]("keydown")
      .map(e => e.keyCode)
      .collect {
        case KeyCode.B => ChooseColor("blue")
        case KeyCode.G => ChooseColor("green")
        case KeyCode.R => ChooseColor("red")
        case KeyCode.Up => IncStrokeWidth
        case KeyCode.Down => DecStrokeWidth
        case KeyCode.Left => SelectPrev
        case KeyCode.Right => SelectNext
        case KeyCode.Backspace => Cut
        case KeyCode.Enter => Paste
        case KeyCode.C => Copy
      }

  val commands: Source[PaintCommand, NotUsed] =
    mouseCommands merge keyboardCommands

  val paint: Flow[PaintCommand, PaintState, NotUsed] =
    Flow[PaintCommand].scan(PaintState.initial) {
      case (state, ChooseColor(c)) =>
        if (state.isSelected) state.modifySelected(_.copy(color = c))
        else state.copy(color = c)
      case (state, IncStrokeWidth) =>
        if (state.isSelected) state.modifySelected(p => p.copy(width = p.width + 1))
        else state.copy(width = state.width + 1)
      case (state, DecStrokeWidth) =>
        if (state.isSelected) state.modifySelected(p => p.copy(width = Math.max(1,p.width - 1)))
        else state.copy(width = Math.max(1,state.width - 1))
      case (state, BeginDrag(x, y)) =>
        val s = state.paths.indexWhere(_.hits(x,y))
        if (s > -1) state.copy(
          selected = s,
          dragStart = (x,y)
        ) else state.copy(
          selected = -1,
          currentPath = Some(Path(state.color, state.width, Vector(Point(x, y)))))
      case (state, Drag(x, y)) =>
        if (state.isSelected) state.modifySelected(p => p.copy(points = p.points.map {
          case Point(px,py) => Point(px + x - state.dragStart._1,py + y - state.dragStart._2)
        })).copy(dragStart = (x,y)) else state.copy(
        currentPath = state.currentPath.map(_.append(x, y)))
      case (state, EndDrag(x, y)) =>
        if (state.isSelected) state.modifySelected(p => p.copy(points = p.points.map {
          case Point(px,py) => Point(px + x - state.dragStart._1,py + y - state.dragStart._2)
        })) else state.copy(
          paths = state.currentPath.filter(!_.isEmpty).map(_.append(x,y)).toList ++ state.paths,
          currentPath = None)
      case (state, SelectPrev) =>
        if (state.selected > 0 && state.selected < state.paths.length) {
          state.copy(selected = state.selected - 1)
        } else state.copy(selected = state.paths.length - 1)
      case (state, SelectNext) =>
        if (state.selected > -1 && state.selected < state.paths.length - 1) {
          state.copy(selected = state.selected + 1)
        } else state.copy(selected = 0)
      case (state, Copy) =>
        if (state.isSelected) state.copy(
          clipboard = Some(state.paths(state.selected)))
        else state
      case (state, Cut) =>
        if (state.isSelected) {
          val (before,deleted :: after) = state.paths.splitAt(state.selected)
          state.copy(
            paths = before ++ after,
            clipboard = Some(deleted),
            selected = -1
          )
        } else state
      case (state, Paste) =>
        if (state.clipboard.isDefined) state.copy(
          paths = state.clipboard.toList ++ state.paths,
          selected = 0
        ) else state
    }

  def visualize = Sink.foreach[PaintState] {
    case s@PaintState(color, width, paths, _, currentPath, selected, _) =>
      val context = canvas.getContext("2d").asInstanceOf[raw.CanvasRenderingContext2D]
      context.lineJoin = "round"
      context.lineCap = "round"
      context.clearRect(0,0,canvas.width,canvas.height)
      paths.zipWithIndex.reverse.foreach {
        case (p@Path(color, width, points),i) =>
          if (i == selected) {
            context.shadowColor = "blue"
            context.shadowBlur = width
          }
          context.strokeStyle = color
          context.lineWidth = width
          context.beginPath()
          p.controlPoints.headOption.foreach {
            case Point(x, y) => context.moveTo(x, y)
          }
          p.controlPoints.drop(1).grouped(3).foreach {
            case Seq(c1,c2,p) => context.bezierCurveTo(c1.x,c1.y,c2.x,c2.y,p.x,p.y)
          }
          context.stroke()
          context.shadowColor = null
          context.shadowBlur = 0
      }
      currentPath.foreach {
        case Path(color, width, points) =>
          context.strokeStyle = color
          context.lineWidth = width
          context.beginPath()
          points.headOption.foreach {
            case Point(x, y) => context.moveTo(x, y)
          }
          points.drop(1).foreach {
            case Point(x, y) => context.lineTo(x, y)
          }
          context.stroke()
      }
      context.shadowColor = null
      context.shadowBlur = 0
      // visualize state
      context.strokeStyle = color
      context.lineWidth = width
      context.beginPath()
      context.moveTo(10 + width / 2,10 + width)
      context.lineTo(10 + width / 2,50)
      context.stroke()
  }

  (commands via paint to visualize).run()