Skip to content
Snippets Groups Projects
room.js 6.37 KiB
import uuidv4 from "uuid/v4"
import yArray from "y-array"
import yMap from "y-map"
import yUnion, { Union } from "./y-union.js"
import yMemory from "y-memory"
import Y from "yjs"

import yP2PMesh from "./y-p2p-mesh.js"

yMemory(Y)
Y.Struct.Union = Union
yUnion(Y)
yMap(Y)
yArray(Y)
yP2PMesh(Y)

import { spreadErasureIntervals, flattenErasureIntervals } from "./erasure.js"

/* webpack should NOT import the yaeti NodeJS polyfill */
// #!if false
import { EventTarget } from "yaeti"
// #!endif

class Room extends EventTarget {
  constructor(name) {
    super()
    this.name = name
    this._y = null
    this.ownID = null
    this.undoStack = []
  }

  disconnect() {
    this._y.destroy()
  }

  addPath([x, y, w, colour]) {
    const id = uuidv4()

    this.shared.strokePoints.set(id, Y.Array).push([[x, y, w, colour]])
    this.shared.eraseIntervals.set(id, Y.Union)

    this.undoStack.push([id, 0, 0])

    this.dispatchEvent(new CustomEvent("undoEnabled"))

    return id
  }

  extendPath(id, [x, y, w, colour]) {
    const path = this.shared.strokePoints.get(id)

    path.push([[x, y, w, colour]])

    if (path.length == 2) {
      this.undoStack[this.undoStack.length - 1] = [id, 0, 1]
    } else {
      this.undoStack.push([id, path.length - 2, path.length - 1])
    }

    this.dispatchEvent(new CustomEvent("undoEnabled"))
  }

  extendErasureIntervals(pathID, pointID, newIntervals) {
    this.shared.eraseIntervals
      .get(pathID)
      .merge(flattenErasureIntervals({ [pointID]: newIntervals }))
  }

  replacePath(pathID, newPoints) {
    this.fastUndo(true)
    newPoints.forEach((point) => this.extendPath(pathID, point))
    this.undoStack.splice(this.undoStack.length - newPoints.length, 1)
  }

  undo() {
    const operation = this.undoStack.pop()

    if (!operation) return

    const [id, ...interval] = operation

    this.shared.eraseIntervals.get(id).merge([interval])
  }

  fastUndo(forReplacing = false) {
    let from = this.undoStack.length - 1

    if (from < 0) return

    // eslint-disable-next-line no-unused-vars
    const [id, _, end] = this.undoStack[from]
    const endErasing = forReplacing ? end + 1 : end

    for (; from >= 0; from--) {
      if (this.undoStack[from][0] != id) {
        from++
        break
      }
    }

    this.undoStack = this.undoStack.slice(0, Math.max(0, from))
    this.shared.eraseIntervals.get(id).merge([[0, endErasing]])
  }

  canUndo() {
    return this.undoStack.length > 0
  }

  getPaths() {
    const paths = new Map()

    for (const id of this.shared.strokePoints.keys()) {
      paths.set(id, this._generatePath(id))
    }

    return paths
  }

  getErasureIntervals(pathID) {
    return this._generateRemovedIntervals(pathID)
  }

  getPathPoints(pathID) {
    return this._generatePath(pathID)
  }

  get shared() {
    return this._y.share
  }

  _generatePath(id) {
    const points = this.shared.strokePoints.get(id)

    if (!points) return []

    return points.toArray()
  }

  _generateRemovedIntervals(id) {
    const intervals = this.shared.eraseIntervals.get(id)

    if (!intervals) return []

    return spreadErasureIntervals(intervals.get())
  }

  inviteUser(id) {
    this._y.connector.connectToPeer(id)
  }

  async _initialise(connection) {
    this._y = await Y({
      db: {
        name: "memory",
      },
      connector: {
        name: "p2p-mesh",
        connection,
        url: "/",
        room: this.name,
        mesh: {
          minPeers: 4,
          maxPeers: 8,
        },
        handshake: {
          initial: 100,
          interval: 500,
        },
        heartbeat: {
          interval: 500,
          minimum: 1000,
          timeout: 10000,
        },
        onUserEvent: (event) => {
          if (event.action == "userConnection") {
            const { id, quality } = event
            this.dispatchEvent(
              new CustomEvent("userConnection", { detail: { id, quality } }),
            )
          } else if (event.action == "userID") {
            const { user: id } = event
            this.ownID = id
            this.dispatchEvent(new CustomEvent("allocateOwnID", { detail: id }))
          } else if (event.action == "userJoined") {
            const { user: id } = event
            this.dispatchEvent(new CustomEvent("userJoin", { detail: id }))
          } else if (event.action == "userLeft") {
            const { user: id } = event
            this.dispatchEvent(new CustomEvent("userLeave", { detail: id }))
          } else if (event.action === "peerSyncedWithUs") {
            const { user: id } = event
            this.dispatchEvent(
              new CustomEvent("peerSyncedWithUs", { detail: id }),
            )
          } else if (event.action === "waitingForSyncStep") {
            const { user: id } = event
            this.dispatchEvent(
              new CustomEvent("waitingForSyncStep", { detail: id }),
            )
          } else if (event.action === "weSyncedWithPeer") {
            const { user: id } = event
            this.dispatchEvent(
              new CustomEvent("weSyncedWithPeer", { detail: id }),
            )
          }
        },
      },
      share: {
        strokePoints: "Map",
        eraseIntervals: "Map",
      },
    })

    const dispatchRemovedIntervalsEvent = (lineEvent) => {
      const id = lineEvent.name
      const intervals = this._generateRemovedIntervals(id)
      const detail = { id, intervals }
      this.dispatchEvent(
        new CustomEvent("removedIntervalsChange", {
          detail,
        }),
      )
    }

    const dispatchPathUpdateEvent = (lineEvent) => {
      const id = lineEvent.name
      const points = this._generatePath(id)
      const detail = { id, points }
      this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
    }

    this.shared.strokePoints.observe((lineEvent) => {
      if (lineEvent.type == "add") {
        dispatchPathUpdateEvent(lineEvent)

        lineEvent.value.observe((pointEvent) => {
          if (pointEvent.type == "insert") {
            dispatchPathUpdateEvent(lineEvent)
          }
        })
      }
    })
    this.shared.eraseIntervals.observe((lineEvent) => {
      if (lineEvent.type == "add") {
        dispatchRemovedIntervalsEvent(lineEvent)

        lineEvent.value.observe(() => {
          dispatchRemovedIntervalsEvent(lineEvent)
        })
      }
    })
  }
}

export const connect = async (roomName, connection) => {
  const room = new Room(roomName)
  await room._initialise(connection)
  return room
}