Skip to content
Snippets Groups Projects
app.js 9.25 KiB
Newer Older
  • Learn to ignore specific revisions
  • // Room connection and synchronisation.
    
    // Translate local canvas input events to draw messages in light of current tool
    // selections and send to the room.
    
    // Get back room updates and invoke the local canvas renderer.
    
    import * as canvas from "./canvas.js"
    
    import * as HTML from "./elements.js"
    
    Nayeem Rahman's avatar
    Nayeem Rahman committed
    import { computeErasureIntervals, combineErasureIntervals } from "./erasure.js"
    
    import { connect } from "./room.js"
    
    import * as toolSelection from "./tool-selection.js"
    
    import * as humanhash from "humanhash"
    
    import jdenticon from "jdenticon"
    
    const MIN_PRESSURE_FACTOR = 0.1
    const MAX_PRESSURE_FACTOR = 1.5
    
    // This is a quadratic such that:
    // - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
    // - getPressureFactor(0.5) = 1.0
    // - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR
    // For sensible results, maintain that:
    // - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0
    // - 1.0 <= MAX_PRESSURE_FACTOR
    // For intuitive results, maintain that:
    // - MAX_PRESSURE_FACTOR <= ~2.0
    const getPressureFactor = (pressure) => {
      const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4
      const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4
      const c = MIN_PRESSURE_FACTOR
      return a * pressure ** 2 + b * pressure + c
    }
    
    
    let room = null
    
    const humanHasher = new humanhash()
    
    
    function eraseEverythingAtPosition(x, y, radius, room) {
    
      const mousePos = [x, y]
    
      room.getPaths().forEach((points, pathID) => {
        const prevPathIntervals =
          (room.erasureIntervals || { [pathID]: {} })[pathID] || {}
        const newPathIntervals = computeErasureIntervals(
          points,
          mousePos,
          radius,
          prevPathIntervals,
        )
    
        const erasureIntervalsForPath = combineErasureIntervals(
          prevPathIntervals,
          newPathIntervals,
        )
    
        Object.keys(erasureIntervalsForPath).forEach((pointID) =>
          room.extendErasureIntervals(
            pathID,
            pointID,
            erasureIntervalsForPath[pointID],
          ),
        )
      })
    }
    
    
    const onRoomConnect = (room_) => {
      room = room_
    
      HTML.connectedRoomID.textContent = room.name
    
      HTML.userIDElem.value = room.ownID || ""
    
      room.addEventListener("allocateOwnID", ({ detail: id }) => {
    
        HTML.userIDElem.value = id
    
      // Create user account
      const userID = HTML.userIDElem.value
    
      const avatarImage = document.createElement("svg")
    
      avatarImage.innerHTML = jdenticon.toSvg(userID, 40)
    
      avatarImage.className = "avatar"
    
      const userAccount = document.createElement("div")
      userAccount.innerHTML = humanHasher.humanize(userID, 2)
    
      HTML.userInfo.innerHTML = ""
      HTML.userInfo.appendChild(avatarImage)
      HTML.userInfo.appendChild(userAccount)
    
    
      room.addEventListener("userJoin", ({ detail: id }) => {
    
        if (HTML.connectedPeers.children.length == 0) {
    
        getOrInsertPeerById(id)
    
        updateOverallStatusIcon()
    
      })
    
      room.addEventListener("userLeave", ({ detail: id }) => {
    
        HTML.connectedPeers.removeChild(getOrInsertPeerById(id))
    
        if (HTML.connectedPeers.children.length == 0) {
    
          HTML.connectedPeers.innerHTML = "No peers are connected"
        }
    
    
        updateOverallStatusIcon()
    
      room.addEventListener("userConnection", ({ detail: { id, quality } }) => {
        const high = "/quality-high.svg"
        const medium = "/quality-medium.svg"
        const low = "/quality-low.svg"
    
    
        const peer = getOrInsertPeerById(id).children[1]
    
        if (quality < 0.33) {
          if (!peer.src.includes(high)) {
            peer.src = high
          }
        } else if (quality < 0.66) {
          if (!peer.src.includes(medium)) {
            peer.src = medium
          }
        } else {
          if (!peer.src.includes(low)) {
            peer.src = low
          }
        }
      })
    
    
      room.addEventListener("weSyncedWithPeer", ({ detail: id }) => {
    
        getOrInsertPeerById(id).children[2].className = "peer-status upload synced"
    
        updateOverallStatusIcon()
    
      })
    
      room.addEventListener("waitingForSyncStep", ({ detail: id }) => {
    
        getOrInsertPeerById(id).children[3].className =
    
          "peer-status download negotiating"
    
        updateOverallStatusIcon()
    
      })
    
      room.addEventListener("peerSyncedWithUs", ({ detail: id }) => {
    
        getOrInsertPeerById(id).children[3].className =
    
          "peer-status download synced"
    
        updateOverallStatusIcon()
    
      room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
    
        canvas.renderPath(id, points, room.erasureIntervals[id] || [])
    
      room.addEventListener(
        "removedIntervalsChange",
        ({ detail: { id, intervals, points } }) => {
    
          room.erasureIntervals[id] = combineErasureIntervals(
            room.erasureIntervals[id] || {},
    
            intervals,
          )
    
          canvas.renderPath(id, points, room.erasureIntervals[id] || [])
    
    
      room.addEventListener("undoEnabled", () => {
        HTML.fastUndoButton.classList.remove("disabled")
        HTML.undoButton.classList.remove("disabled")
      })
    
    const tryRoomConnect = async (roomID) => {
      return await connect(roomID)
        .then(onRoomConnect)
        .catch((err) => alert(`Error connecting to a room:\n${err}`))
    }
    
    const pathIDsByPointerID = new Map()
    
    HTML.peerButton.addEventListener("click", () => {
      const peerID = HTML.peerIDElem.value
      if (room == null || peerID == "") {
        return
    
      room.inviteUser(peerID)
      HTML.peerIDElem.value = ""
    })
    
    const onRoomJoinEnter = () => {
    
      const selectedRoomID = HTML.roomIDElem.value
      if (!selectedRoomID || selectedRoomID == room.name) {
        return
    
      if (room != null) {
        room.disconnect()
        room = null
    
      canvas.clear()
      HTML.connectedPeers.innerHTML = "No peers are connected"
    
      HTML.fastUndoButton.classList.add("disabled")
      HTML.undoButton.classList.add("disabled")
    
      tryRoomConnect(selectedRoomID)
    
    }
    
    HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)
    
    
    HTML.fastUndoButton.addEventListener("click", () => {
      if (room == null) return
    
      room.fastUndo()
    
      if (!room.canUndo) {
        HTML.fastUndoButton.classList.add("disabled")
        HTML.undoButton.classList.add("disabled")
      }
    })
    HTML.undoButton.addEventListener("click", () => {
      if (room == null) return
    
      room.undo()
    
      if (!room.canUndo) {
        HTML.fastUndoButton.classList.add("disabled")
        HTML.undoButton.classList.add("disabled")
      }
    })
    
    HTML.roomIDElem.addEventListener("keydown", (event) => {
      if (event.key == "Enter") {
        event.target.blur()
    
        onRoomJoinEnter()
      }
    
    const getOrInsertPeerById = (id) => {
      for (const peerElem of HTML.connectedPeers.children) {
    
        const peerId = peerElem.children[4].id
    
        if (peerId == id) {
          return peerElem
        }
      }
    
    
      const peerElem = document.createElement("li")
    
      const avatarImage = document.createElement("svg")
      avatarImage.innerHTML = jdenticon.toSvg(id, 50)
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
      avatarImage.className = "avatar"
    
    
      const quality = document.createElement("img")
      quality.src = "/quality-low.svg"
      quality.alt = "Peer quality icon"
      quality.className = "peer-quality"
    
    
      const ourStatus = document.createElement("div")
    
      ourStatus.className = "peer-status upload unsynced"
    
    
      const theirStatus = document.createElement("div")
    
      theirStatus.className = "peer-status download unsynced"
    
      const peerId = document.createElement("div")
      peerId.style.marginLeft = "5px"
    
      peerId.innerHTML = humanHasher.humanize(id, 2)
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
      peerElem.appendChild(avatarImage)
    
      peerElem.appendChild(quality)
    
      peerElem.appendChild(ourStatus)
      peerElem.appendChild(theirStatus)
    
      peerElem.appendChild(peerId)
    
    
      HTML.connectedPeers.appendChild(peerElem)
    
    
      return peerElem
    
    const updateOverallStatusIcon = () => {
      for (const peerElem of HTML.connectedPeers.children) {
        if (
    
          !peerElem.children[2].classList.contains("synced") ||
          !peerElem.children[3].classList.contains("synced")
    
        ) {
          HTML.overallStatusIcon.className = "synchronising"
          HTML.overallStatusIconImage.src = "synchronising.svg"
          return
        }
      }
      HTML.overallStatusIcon.className = "synchronised"
      HTML.overallStatusIconImage.src = "synchronised.svg"
    }
    
    
    canvas.input.addEventListener("strokestart", ({ detail: e }) => {
      if (room == null) {
        return
      }
    
      const currentTool = toolSelection.getTool()
    
      const mousePos = [e.offsetX, e.offsetY]
    
      if (currentTool == toolSelection.Tools.PEN) {
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
        pathIDsByPointerID.set(
          e.pointerId,
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
          room.addPath([
            ...mousePos,
    
            toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
            toolSelection.getStrokeColour(),
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
          ]),
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
        )
    
      } else if (currentTool == toolSelection.Tools.ERASER) {
    
        eraseEverythingAtPosition(
          mousePos[0],
          mousePos[1],
    
          toolSelection.getEraseRadius(),
    
    canvas.input.addEventListener("strokeend", ({ detail: e }) => {
      pathIDsByPointerID.delete(e.pointerId)
    })
    
    canvas.input.addEventListener("strokemove", ({ detail: e }) => {
      if (room == null) {
        return
      }
    
      const currentTool = toolSelection.getTool()
    
      const mousePos = [e.offsetX, e.offsetY]
    
      if (currentTool == toolSelection.Tools.PEN) {
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
        room.extendPath(pathIDsByPointerID.get(e.pointerId), [
    
          toolSelection.getStrokeRadius() * getPressureFactor(e.pressure),
          toolSelection.getStrokeColour(),
    
    Giovanni Caruso's avatar
    Giovanni Caruso committed
        ])
    
      } else if (currentTool == toolSelection.Tools.ERASER) {
    
        eraseEverythingAtPosition(
          mousePos[0],
          mousePos[1],
          toolSelection.getEraseRadius(),
          room,
        )
    
    window.addEventListener("unload", () => {
      if (room) {
        room.disconnect()