Skip to content
Snippets Groups Projects
app.js 9.89 KiB
import { line, curveLinear } from "d3-shape"

import * as HTML from "./elements.js"
import { connect } from "./room.js"

const TEST_ROOM = "imperial"

// TODO: switch to curve interpolation that respects mouse points based on velocity
const lineFn = line()
  .x((d) => d[0])
  .y((d) => d[1])
  .curve(curveLinear)

const tools = {
  PEN: "pen",
  ERASER: "eraser",
}

const STROKECOLOUR = "blue"
const STROKERADIUS = 2
const ERASERRADIUS = STROKERADIUS * 5

function interpolatedCoordinate(start, end, length) {
  const dx = end[0] - start[0]
  const dy = end[1] - start[1]
  const dist = getDistance(start, end)
  const ratio = length / dist
  return [start[0] + dx * ratio, start[1] + dy * ratio]
}

function eraseAt(x, y, room) {
  let mousePos = [x, y]
  room.getPaths().forEach((points, pathID) => {
    points.forEach((point, i) => {
      const distanceToPoint = getDistance(mousePos, point)
      if (distanceToPoint <= ERASERRADIUS) {
        room.erasePoint(pathID, i)

        let prev, next
        if (i > 0) {
          prev = i - 1
        }
        if (i < points.length - 1) {
          next = i + 1
        }

        if (prev !== undefined) {
          const interpolatedPoint = interpolatedCoordinate(
            point,
            points[prev],
            ERASERRADIUS,
          )
          room.insertIntoPath(pathID, prev, interpolatedPoint)
        }

        if (next !== undefined) {
          const interpolatedPoint = interpolatedCoordinate(
            point,
            points[next],
            ERASERRADIUS,
          )
          room.insertIntoPath(pathID, next, interpolatedPoint)
        }
      }
    })
  })
}

const SVG_URL = "http://www.w3.org/2000/svg"
const SVG = {
  create: {
    circle: () => SVG.create.elem("circle"),
    group: () => SVG.create.elem("g"),
    path: () => SVG.create.elem("path"),
    elem: (elemName) => document.createElementNS(SVG_URL, elemName),
  },
}

const TEST_ERASE_INTERVAL = {
  0: [0.1, 0.9],
  1: [0.01, 0.99999],
}

function erasurePoints(pointA, pointB, start, fin) {
  if (start >= fin) return
  if (start <= 0) start = 0
  if (fin >= 1) fin = 1

  const [xa, ya] = pointA
  const [xb, yb] = pointB

  const dx = xb - xa
  const dy = yb - ya

  const pointOnLine = (percent) => [xa + percent * dx, ya + percent * dy]
  return [start, fin].map(pointOnLine)
}

const addOrUpdatePathElem = (pathElems, id, points) => {
  let pathElem = pathElems.get(id)

  if (pathElem == null) {
    pathElem = SVG.create.group()
    console.log(pathElem)

    pathElem.setAttribute("stroke", STROKECOLOUR)
    pathElem.setAttribute("stroke-width", STROKERADIUS * 2)
    pathElem.setAttribute("fill", "none")
    pathElem.setAttribute("pointer-events", "none")
    pathElem.setAttribute("marker-start", "url(#dot)")
    pathElem.setAttribute("marker-end", "url(#dot)")

    HTML.canvas.appendChild(pathElem)
    pathElems.set(id, pathElem)
  }

  pathElem.innerHTML = ""

  if (points.length == 0) {
    return pathElem
  }

  // Push a fake path split to generate the last path
  points.push([-1, -1, false])
  const originalID = (i) => i - 1

  let subPath = []
  let toAdd = undefined

  for (let i = 0; i < points.length; i++) {
    const point = points[i]
    console.log(subPath)
    if (toAdd) {
      subPath.push(toAdd)
      toAdd = undefined
    }

    if (point[0] === undefined) {
      continue
    }

    if (point[2] === false) {
      // End of subpath
      if (subPath.length == 1) {
        const subpathElem = SVG.create.circle()

        subpathElem.setAttribute("stroke", "none")
        subpathElem.setAttribute("fill", STROKECOLOUR)
        subpathElem.setAttribute("cx", subPath[0][0])
        subpathElem.setAttribute("cy", subPath[0][1])
        subpathElem.setAttribute("r", STROKERADIUS)

        pathElem.appendChild(subpathElem)
      } else if (subPath.length > 0) {
        const subpathElem = SVG.create.path()

        subpathElem.setAttribute("d", lineFn(subPath))

        pathElem.appendChild(subpathElem)
      }

      subPath = []

      continue
    }

    // Valid point inside a subpath
    subPath.push(point)

    const eraseInterval = TEST_ERASE_INTERVAL[originalID(i)]

    if (eraseInterval) {
      if (i === points.length - 1) {
        continue
      }

      const nextPoint = points[i + 1]

      const [start, fin] = erasurePoints(
        point,
        nextPoint,
        eraseInterval[0],
        eraseInterval[1],
      )

      subPath.push(start)

      const subpathElem = SVG.create.path()
      subpathElem.setAttribute("d", lineFn(subPath))
      pathElem.appendChild(subpathElem)
      subPath = []

      toAdd = fin
    }
  }

  return pathElem
}

const getDistance = (a, b) => {
  return Math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
}

function setElemVisible(elem, visible = true) {
  if (!(elem && elem.style)) return
  elem.style.display = visible ? "block" : "none"
}

function showConnectedRoom(roomID) {
  HTML.connectedRoomID.textContent = roomID
  setElemVisible(HTML.connectedRoomInfoContainer)
}

function setBlankUIState() {
  while (HTML.canvas.children[1]) {
    HTML.canvas.removeChild(HTML.canvas.children[1])
  }

  HTML.connectedPeers.innerHTML = "No peers are connected"
}

function handleRoomConnectClick() {
  const selectedRoomID = HTML.roomIDElem.value
  if (!selectedRoomID) return

  setBlankUIState()

  connectToARoom(selectedRoomID)
}

function handleRoomConnectionEstablished(room) {
  showConnectedRoom(room.name)

  HTML.userIDElem.value = room.ownID || ""
  room.addEventListener("allocateOwnID", ({ detail: id }) => {
    HTML.userIDElem.value = id
  })

  room.addEventListener("userJoin", ({ detail: id }) => {
    if (HTML.connectedPeers.children.length == 0) {
      HTML.connectedPeers.innerHTML = ""
    }

    const peerElem = document.createElement("li")
    peerElem.innerHTML = id
    HTML.connectedPeers.appendChild(peerElem)
  })

  room.addEventListener("userLeave", ({ detail: id }) => {
    for (const peerElem of HTML.connectedPeers.children) {
      if (peerElem.innerHTML == id) {
        HTML.connectedPeers.removeChild(peerElem)
      }
    }

    if (HTML.connectedPeers.children.length == 0) {
      HTML.connectedPeers.innerHTML = "No peers are connected"
    }
  })

  const pathElems = new Map()

  room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
    addOrUpdatePathElem(pathElems, id, points)
  })

  let currentTool = tools.ERASER
  const pathIDsByPointerID = new Map()

  const canvasOnPointerEnter = (e) => {
    if (~e.buttons & 1) {
      return
    }

    const mousePos = [e.offsetX, e.offsetY]

    if (currentTool == tools.PEN) {
      pathIDsByPointerID.set(e.pointerId, room.addPath(mousePos))
    } else if (currentTool == tools.ERASER) {
      eraseAt(mousePos[0], mousePos[1], room)
    }
  }

  const canvasOnPointerLeave = (e) => {
    pathIDsByPointerID.delete(e.pointerId)
  }

  const canvasOnPointerMove = (e) => {
    if (~e.buttons & 1) {
      return
    }

    const mousePos = [e.offsetX, e.offsetY]

    if (currentTool == tools.PEN) {
      room.extendPath(pathIDsByPointerID.get(e.pointerId), mousePos)
    } else if (currentTool == tools.ERASER) {
      room.getPaths().forEach((points, pathID) => {
        points.forEach((point, i) => {
          if (getDistance(mousePos, point) <= ERASERRADIUS) {
            room.erasePoint(pathID, i)
          }
        })
      })
    }
  }

  const peerButtonOnClick = () => {
    const peerID = HTML.peerIDElem.value
    if (peerID == "") {
      return
    }
    room.inviteUser(peerID)
    HTML.peerIDElem.value = ""
  }

  const penButtonOnClick = () => {
    currentTool = tools.PEN

    HTML.penButton.classList.add("selected")
    HTML.eraserButton.classList.remove("selected")
  }

  const eraserButtonOnClick = () => {
    currentTool = tools.ERASER

    HTML.penButton.classList.remove("selected")
    HTML.eraserButton.classList.add("selected")
  }

  HTML.canvas.addEventListener("pointerdown", canvasOnPointerEnter)
  HTML.canvas.addEventListener("pointerenter", canvasOnPointerEnter)
  HTML.canvas.addEventListener("pointerup", canvasOnPointerLeave)
  HTML.canvas.addEventListener("pointerleave", canvasOnPointerLeave)
  HTML.canvas.addEventListener("pointermove", canvasOnPointerMove)
  HTML.penButton.addEventListener("click", penButtonOnClick)
  HTML.eraserButton.addEventListener("click", eraserButtonOnClick)
  HTML.peerButton.addEventListener("click", peerButtonOnClick)
  HTML.roomConnectButton.removeEventListener("click", handleRoomConnectClick)

  const roomConnectButtonOnClick = () => {
    const selectedRoomID = HTML.roomIDElem.value
    if (!selectedRoomID || selectedRoomID == room.name) {
      return
    }

    HTML.canvas.removeEventListener("pointerdown", canvasOnPointerEnter)
    HTML.canvas.removeEventListener("pointerenter", canvasOnPointerEnter)
    HTML.canvas.removeEventListener("pointerup", canvasOnPointerLeave)
    HTML.canvas.removeEventListener("pointerleave", canvasOnPointerLeave)
    HTML.canvas.removeEventListener("pointermove", canvasOnPointerMove)
    HTML.peerButton.removeEventListener("click", peerButtonOnClick)
    HTML.penButton.removeEventListener("click", penButtonOnClick)
    HTML.eraserButton.removeEventListener("click", eraserButtonOnClick)
    HTML.roomConnectButton.removeEventListener(
      "click",
      roomConnectButtonOnClick,
    )

    room.disconnect()
    room = null

    handleRoomConnectClick()
  }
  HTML.roomConnectButton.addEventListener("click", roomConnectButtonOnClick)

  let pid = room.addPath([100, 100])
  room.extendPath(pid, [100, 100])
  room.extendPath(pid, [150, 150])
  room.extendPath(pid, [100, 200])
}

function handleRoomConnectionError(err) {
  alert(`Error connecting to a room:\n${err}`)
}

function connectToARoom(roomID) {
  connect(roomID)
    .then(handleRoomConnectionEstablished)
    .catch(handleRoomConnectionError)
}

HTML.canvas.addEventListener("touchmove", (e) => e.preventDefault())

HTML.roomConnectButton.addEventListener("click", handleRoomConnectClick, {
  once: true,
})

connectToARoom(TEST_ROOM)