Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hlgr/drawing-app
  • sweng-group-15/drawing-app
2 results
Show changes
Subproject commit 139ab6e2cc9d6f0501fa958f3c813df0fcc81310
export const userIDElem = document.getElementById("user-id")
export const peerIDElem = document.getElementById("peer-id")
export const peerButton = document.getElementById("peer-connect")
export const connectedPeers = document.getElementById("connected-peers")
export const overallStatusIcon = document.getElementById("overall-status-icon")
export const overallStatusIconImage = document.getElementById(
"overall-status-icon-img",
)
export const canvas = document.getElementById("canvas")
export const penButton = document.getElementById("pen-tool")
export const eraserButton = document.getElementById("eraser-tool")
export const recognitionModeButton = document.getElementById("recognition-mode")
export const draggingToolButton = document.getElementById("dragging-tool")
export const canvasCenterToolButton = document.getElementById("canvas-center")
export const fastUndoButton = document.getElementById("fast-undo-tool")
export const undoButton = document.getElementById("undo-tool")
export const roomIDElem = document.getElementById("room-id")
export const roomConnectButton = document.getElementById("room-connect")
......@@ -15,8 +22,14 @@ export const connectedRoomInfoContainer = document.getElementById(
"connected-room-info",
)
export const properties = document.getElementById("pen-properties")
export const span = document.getElementsByClassName("close")[0]
export const penProperties = document.getElementById("pen-properties")
export const closeButton = document.querySelectorAll(".close")
export const palette = document.getElementById("palette")
export const rectangle = document.getElementById("rectangle")
export const wheel = document.getElementById("wheel")
export const strokeColorPicker = document.getElementById("other-colours")
export const strokeRadiusSlider = document.getElementById("range")
export const output = document.getElementById("value")
export const labelColours = document.getElementById("colours")
export const userInfo = document.getElementById("user-avatar")
export const topPanel = document.getElementById("top-panel")
function sqr(x) {
return x ** 2
}
function hypotenuseSquared(a, b) {
return sqr(a) + sqr(b)
}
function distanceSquared([x0, y0], [x1, y1]) {
return hypotenuseSquared(x0 - x1, y0 - y1)
}
function distance(point0, point1) {
return Math.sqrt(distanceSquared(point0, point1))
}
function cap01(x) {
return Math.max(0, Math.min(1, x))
}
function distToSegmentSquared(lineStart, lineEnd, point) {
const l2 = distanceSquared(lineStart, lineEnd)
if (l2 === 0) return distanceSquared(point, lineStart)
let t =
((point[0] - lineStart[0]) * (lineEnd[0] - lineStart[0]) +
(point[1] - lineStart[1]) * (lineEnd[1] - lineStart[1])) /
l2
t = cap01(t)
return distanceSquared(point, [
lineStart[0] + t * (lineEnd[0] - lineStart[0]),
lineStart[1] + t * (lineEnd[1] - lineStart[1]),
])
}
function interpolate([x0, y0], [x1, y1], t) {
return [x0 + (x1 - x0) * t, y0 + (y1 - y0) * t]
}
function project([x1, y1], [x2, y2], [x3, y3]) {
const x21 = x2 - x1,
y21 = y2 - y1
const x31 = x3 - x1,
y31 = y3 - y1
return (x31 * x21 + y31 * y21) / (x21 * x21 + y21 * y21)
}
function erasureInterval(lineStart, lineEnd, erasureCenter, erasureRadius) {
if (!(lineStart && erasureCenter)) return undefined
if (!lineEnd) {
const dist2ToSingularity = distanceSquared(erasureCenter, lineStart)
return dist2ToSingularity <= erasureRadius ** 2 ? [0, 0] : undefined
}
const distToSegment2 = distToSegmentSquared(lineStart, lineEnd, erasureCenter)
if (erasureRadius ** 2 < distToSegment2) return undefined
const lineLength = distance(lineStart, lineEnd)
if (lineLength === 0) {
return distToSegment2 <= erasureRadius ** 2 ? [0, 1] : undefined
}
const projT = project(lineStart, lineEnd, erasureCenter)
const projectionPoint = interpolate(lineStart, lineEnd, projT)
const d2 = distance(erasureCenter, projectionPoint)
const halfLength = Math.sqrt(Math.abs(sqr(erasureRadius) - sqr(d2)))
if (halfLength === 0) return undefined
let touchFromStartDist = distance(lineStart, projectionPoint)
if (projT < 0) touchFromStartDist = -touchFromStartDist
const touchBeginFromStarDist = touchFromStartDist - halfLength
const touchEndFromStarDist = touchFromStartDist + halfLength
return [
cap01(touchBeginFromStarDist / lineLength),
cap01(touchEndFromStarDist / lineLength),
]
}
export function computeErasureIntervals(points, erasureCenter, erasureRadius) {
return points
.map((point, i) => ({ point, i }))
.reduce((acc, { point, i }) => {
const interval = erasureInterval(
point,
points[i + 1],
erasureCenter,
erasureRadius,
)
if (!interval) return acc
return {
...acc,
[i]: [interval],
}
}, {})
}
function overlaps([s1, e1], [, e2]) {
return s1 <= e2 && s1 <= e1
}
function mergeIntervals(...intervals) {
if (!intervals.length) return []
const sorted = intervals.sort(([a], [b]) => a - b)
const stack = [sorted[0]]
sorted.forEach((x) => {
const top = stack[stack.length - 1]
if (overlaps(x, top)) {
if (x[1] > top[1]) top[1] = x[1]
} else {
stack.push(x)
}
})
return stack
}
export function combineErasureIntervals(i1, i2) {
const _i1 = { ...i1 }
Object.keys(i1).forEach((key) => {
if (i2[key]) {
_i1[key] = mergeIntervals(...i1[key], ...i2[key])
}
})
return { ...i2, ..._i1 }
}
export function spreadErasureIntervals(intervals) {
const spread = {}
intervals.forEach(([l, u]) => {
if (u > l) {
for (let li = Math.floor(l); li < u; li++) {
const list = spread[li] || []
const [il, iu] = [Math.max(li, l), Math.min(li + 1, u)]
list.push([il % 1, iu == li + 1 ? 1 : iu % 1])
spread[li] = list
}
} else {
spread[Math.floor(l)] = [[l % 1, u % 1]]
}
})
return spread
}
export function flattenErasureIntervals(intervals) {
const flatten = []
for (const idx in intervals) {
if (intervals[idx])
flatten.push(
intervals[idx].map(([l, u]) => [parseInt(idx) + l, parseInt(idx) + u]),
)
}
return flatten.flat()
}
import XMPP from "./connection/XMPP.js"
const CORRECTION_OFFSET = 5000
const HOST = "xmpp.lets-draw.live"
const PORT = 5281
const USERNAME = "beartest"
const PASSWORD = "beartest"
let disableSend = false
const divulgedUpTo = new Map()
const pointPresenceMap = new Map()
const pathIDsByXCDPIdentifier = new Map()
const attributesByXCDPIdentifier = new Map()
function pointFromProtocol(point) {
return Math.round(point + CORRECTION_OFFSET)
}
function pointToProtocol(point) {
return [point[0] - CORRECTION_OFFSET, point[1] - CORRECTION_OFFSET]
}
export default class Exfiltrator {
constructor(channel, room) {
this.room = room
this.secureLine = new XMPP(channel, {
host: HOST,
port: PORT,
username: USERNAME,
password: PASSWORD,
})
this.secureLine.addEventListener("stanza", ({ detail: content }) =>
this.onStanza(content),
)
}
onAddOrUpdatePath(id, points) {
if (points.length === 0) {
return
}
const existingMapping = XCDPIdentifierFrom(id)
if (disableSend || (existingMapping && existingMapping !== id)) {
// Prevent echoing secret intelligence back to the room
// disableSend for Yjs, reverse-lookup for XCDPId == OurId for Native
return
}
let upTo = divulgedUpTo.get(id)
if (upTo === undefined) {
pathIDsByXCDPIdentifier.set(id, id)
upTo = 0
}
if (upTo === 0) {
const point = points[0]
const colour = point[3]
const R = parseInt(colour.substring(1, 3), 16)
const G = parseInt(colour.substring(3, 5), 16)
const B = parseInt(colour.substring(5, 7), 16)
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "ADD",
identifier: id,
weight: point[2],
colour: [R, G, B],
start: pointToProtocol(point),
}),
)
upTo++
}
const batch = []
for (; upTo !== points.length; upTo++) {
const point = points[upTo]
batch.push(pointToProtocol(point))
}
if (batch.length !== 0) {
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "APPEND",
identifier: id,
points: batch,
}),
)
}
divulgedUpTo.set(id, upTo)
}
onRemovedIntervalsChange(id, intervals) {
const points = this.room.getPathPoints(id)
extendPointPresenceMapFor(id, points.length)
for (const offset in intervals) {
this.deleteInterval(id, parseInt(offset), intervals[offset])
}
}
deleteInterval(lineID, offset, interval) {
const bLine = pointPresenceMap.get(lineID)
if (!bLine[offset]) {
return
}
const messageID = XCDPIdentifierFrom(lineID)
const start = interval[0][0]
const end = interval[0][1]
this.secureLine.sneakilySendTheOtherTeamOur(
JSON.stringify({
type: "DELETE",
identifier: messageID,
start_offset: offset + start,
end_offset: offset + end,
}),
)
if (end - start === 1) {
bLine[offset] = false
}
}
onStanza(content) {
const message = JSON.parse(content.children[0])
const ourID = pathIDsByXCDPIdentifier.get(message.identifier)
if (message.type === "ADD") {
if (ourID !== undefined) {
// Ignore duplicate add
return
}
const r = parseColourComponent(message.colour[0])
const g = parseColourComponent(message.colour[1])
const b = parseColourComponent(message.colour[2])
const attributes = { weight: message.weight, colour: "#" + r + g + b }
const initialPoint = [
pointFromProtocol(message.start[0]),
pointFromProtocol(message.start[1]),
attributes.weight,
attributes.colour,
]
attributesByXCDPIdentifier.set(message.identifier, attributes)
disableSend = true
pathIDsByXCDPIdentifier.set(
message.identifier,
this.room.addPathRemote(initialPoint),
)
disableSend = false
} else if (message.type === "APPEND") {
if (ourID === undefined) {
// They're trying to hack us with an ID that wasn't added
// Plan of action: initiate DDOS against them in retaliation
return
}
const attributes = attributesByXCDPIdentifier.get(message.identifier)
disableSend = true
for (let index = 0; index != message.points.length; index++) {
const point = message.points[index]
const toAdd = [
pointFromProtocol(point[0]),
pointFromProtocol(point[1]),
attributes.weight,
attributes.colour,
]
this.room.extendPathRemote(ourID, toAdd)
}
disableSend = false
} else if (message.type === "DELETE") {
if (ourID === undefined) {
// Ditto above. Alternative explanation: exfiltrator was started late and missed the add
return
}
const offset = parseInt(message.start_offset)
extendPointPresenceMapFor(ourID, offset)
this.room.extendErasureIntervals(ourID, offset, [
[message.start_offset - offset, message.end_offset - offset],
])
}
}
}
function extendPointPresenceMapFor(pathID, minLength) {
const mapping = pointPresenceMap.get(pathID)
if (mapping === undefined) {
pointPresenceMap.set(pathID, Array(minLength).fill(true))
} else if (mapping.length < minLength) {
// Extend array with new points
// For example, line is being inked, then someone erases a bit (causing creation of the presence entry)
// But the inking continues, followed by another erasure later on
mapping.push.apply(mapping, Array(minLength - mapping.length).fill(true))
}
}
const XCDPIdentifierFrom = (lineID) => {
for (const [XCDPID, ourID] of pathIDsByXCDPIdentifier.entries()) {
if (ourID === lineID) {
return XCDPID
}
}
}
function parseColourComponent(component) {
return component.toString(16).padStart(2, "0")
}
Subproject commit 545380d3c27797799f3013ad112f9f17c64bc8c7
Subproject commit ce4a2ebe160804ed84f7b6fc3bd10c91e766bdcd
"use strict"
export default class P2PMesh {
constructor(crdt, options) {
if (options === undefined) {
throw new Error("Options must not be undefined!")
}
this.crdt = crdt
this.options = options
this.options.mesh = this.options.mesh || {}
this.options.mesh.minPeers = this.options.mesh.minPeers || 4
this.options.mesh.maxPeers = this.options.mesh.maxPeers || 8
this.options.handshake = this.options.handshake || {}
this.options.handshake.initial = this.options.handshake.initial || 100
this.options.handshake.interval = this.options.handshake.interval || 500
this.options.heartbeat = this.options.heartbeat || {}
this.options.heartbeat.interval = this.options.heartbeat.interval || 500
this.options.heartbeat.minimum = this.options.heartbeat.minimum || 1000
this.options.heartbeat.timeout = this.options.heartbeat.timeout || 10000
this.queue = new Worker("js/queue.js")
this.queue.onmessage = (event) => {
if (!this.crdt) {
return
}
const method = event.data.method
if (method == "send") {
const { uid, channel, message } = event.data
// CRDT (e.g. y-js db transactions) can send messages after a peer has disconnected
if (channel == "crdt" && !this.peers.has(uid)) {
return
}
this.connection.send(uid, channel, message)
} else if (method == "broadcast") {
const { channel, message } = event.data
return this.connection.broadcast(channel, message)
} else if (method == "received") {
const { uid, channel, message } = event.data
if (channel === "tw-ml") {
// Handshakes can only be sent and received directly
if (message === "tw") {
// Response message in the handshake
this.queue.postMessage({
method: "send",
uid,
channel: "tw-ml",
message: "ml",
compressed: false,
})
} else if (message == "ml") {
// Handshake completed
this.checkAndInsertPeer(uid)
}
} else {
this.checkAndInsertPeer(uid)
if (channel === "crdt") {
this.checkAndInsertPeer(uid)
this.crdt.receiveMessage(uid, message)
}
}
}
}
this.initialiseConnection()
}
initialiseConnection() {
this.peers = new Map()
this.connection = new this.options.connection(this.options)
this.connection.addEventListener("roomJoined", () => {
this.checkAndEnsureUser()
})
this.connection.addEventListener("roomLeft", () => {
console.log("TODO: LEFT ROOM")
})
this.connection.addEventListener("channelOpened", ({ detail: uid }) => {
this.checkAndEnsureUser()
// Start a handshake to ensure both sides are able to use the channel
function handshake(peer) {
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer) {
return
}
if (this.peers.has(uid)) {
return
}
// Initial message in the handshake
this.queue.postMessage({
method: "send",
uid,
channel: "tw-ml",
message: "tw",
compressed: false,
})
setTimeout(handshake.bind(this, peer), this.options.handshake.interval)
}
setTimeout(
handshake.bind(this, this.connection.getPeerHandle(uid)),
this.options.handshake.initial,
)
})
this.connection.addEventListener("channelError", ({ detail: uid }) =>
console.log("TODO: CHANNEL ERROR", uid),
)
this.connection.addEventListener("channelClosed", ({ detail: uid }) => {
this.checkAndEnsureUser()
this.checkAndRemovePeer(uid)
})
this.connection.addEventListener(
"messageReceived",
({ detail: { uid, channel, message } }) => {
this.checkAndEnsureUser()
this.queue.postMessage({
method: "received",
uid,
channel,
message,
})
},
)
}
// Ensure that the crdt is up to date on the user's id
checkAndEnsureUser() {
if (!this.crdt) {
return
}
const uid = this.connection.getUserID()
if (this.crdt.getUserID() == uid) {
return
}
this.crdt.setUserID(uid)
}
// Ensure that the crdt knows that the peer has joined
checkAndInsertPeer(uid) {
if (!this.crdt) {
return
}
if (this.peers.has(uid)) {
return
}
const health = {
lastFootprintResolved: true,
lastFootprint: 0,
lastFootprintTimestamp: Date.now(),
}
health.cb = setInterval(
this.heartbeat.bind(
this,
uid,
this.connection.getPeerHandle(uid),
health,
),
this.options.heartbeat.interval,
)
this.peers.set(uid, health)
this.crdt.userJoined(uid)
}
heartbeat(uid, peer, health) {
const _peer = this.connection.getPeerHandle(uid)
if (!_peer || _peer !== peer || !this.peers.has(uid)) {
clearInterval(health.cb)
return
}
if (!health.lastFootprintResolved) {
return this.connection.terminatePeer(uid)
}
health.lastFootprintResolved = false
const self = this
this.connection
.getPeerFootprint(uid)
.then((footprint) => {
health.lastFootprintResolved = true
const timeSinceLastFootprint =
Date.now() - health.lastFootprintTimestamp
if (footprint != health.lastFootprint) {
health.lastFootprint = footprint
health.lastFootprintTimestamp = Date.now()
} else if (timeSinceLastFootprint > self.options.heartbeat.timeout) {
return this.connection.terminatePeer(uid)
} else if (timeSinceLastFootprint > self.options.heartbeat.interval) {
self.queue.postMessage({
method: "send",
uid,
channel: "heartbeat",
})
}
this.crdt.reportConnectionQuality(
uid,
1.0 -
(self.options.heartbeat.timeout -
Math.max(
timeSinceLastFootprint,
self.options.heartbeat.minimum,
)) /
(self.options.heartbeat.timeout - self.options.heartbeat.minimum),
)
})
.catch(() => {
return this.connection.terminatePeer(uid)
})
}
// Ensure that the crdt knows that the peer has left
checkAndRemovePeer(uid) {
if (!this.crdt) {
return
}
if (!this.peers.has(uid)) {
return
}
this.peers.delete(uid)
this.crdt.userLeft(uid)
}
disconnect() {
this.queue.terminate()
this.connection.destructor()
this.crdt = null
}
send(uid, message, compressed = true) {
this.queue.postMessage({
method: "send",
uid,
channel: "crdt",
message,
compressed,
})
}
broadcast(message, compressed = true) {
this.queue.postMessage({
method: "broadcast",
channel: "crdt",
message,
compressed,
})
}
}
"use strict"
import MessagePack from "what-the-pack"
import pako from "pako"
import uuidv4 from "uuid/v4"
import FastBitSet from "fastbitset"
const MESSAGE_BUFFER_SIZE = 2 ** 25 // 32MB
const MESSAGE_SLICE_SIZE = 2 ** 10 // 1KB
const { encode, decode } = MessagePack.initialize(MESSAGE_BUFFER_SIZE)
const queue = []
const buffer = {}
onmessage = (event) => {
if (!event || !event.data) {
return
}
if (event.data.method == "send" || event.data.method == "broadcast") {
let message = event.data.message
const compressed =
event.data.compressed != null ? event.data.compressed : true
const uuid = uuidv4()
//console.log("send in", JSON.stringify(message))
message = encode(message)
if (compressed) {
message = pako.deflate(message)
}
const sender = (slice) => {
let offset = slice * MESSAGE_SLICE_SIZE
event.data.message = encode({
uuid,
message: message.subarray(offset, offset + MESSAGE_SLICE_SIZE),
slice,
length: message.length,
compressed,
})
//console.log(JSON.stringify([...event.data.message]), event.data.message.length, "send out")
self.postMessage(event.data)
offset += MESSAGE_SLICE_SIZE
if (offset < message.length) {
setTimeout(() => sender(slice + 1), 5)
} else {
queue.shift()
if (queue.length > 0) queue[0](0)
}
}
queue.push(sender)
if (queue.length == 1) queue[0](0)
} else if (event.data.method == "received") {
const packet = decode(MessagePack.Buffer.from(event.data.message))
let message = packet.message
//console.log("receive in", JSON.stringify(packet))
if (packet.length > MESSAGE_SLICE_SIZE) {
let messages = buffer[event.data.uid]
if (!messages) {
messages = {}
buffer[event.data.uid] = messages
}
let slices = messages[packet.uuid]
if (!slices) {
slices = {
message: new Uint8Array(packet.length),
received: new FastBitSet(),
length: 0,
}
messages[packet.uuid] = slices
}
// Packets may arrive out-of-order and multiple times
if (slices.received.checkedAdd(packet.slice) === 1) {
slices.length += packet.message.length
slices.message.set(packet.message, packet.slice * MESSAGE_SLICE_SIZE)
}
if (slices.length < slices.message.length) {
delete packet.uuid
delete packet.message
return
}
message = slices.message
delete messages[packet.uuid]
}
if (packet.compressed) {
message = pako.inflate(Uint8Array.from(message))
}
message = decode(MessagePack.Buffer.from(message))
event.data.message = message
//console.log("receive out", JSON.stringify(event.data))
self.postMessage(event.data)
}
}
import uuidv4 from "uuid/v4"
import yArray from "y-array"
import yMap from "y-map"
import yMemory from "y-memory"
import Y from "yjs"
import yWebrtc from "./y-webrtc/index.js"
yMemory(Y)
yMap(Y)
yArray(Y)
yWebrtc(Y)
import { spreadErasureIntervals, flattenErasureIntervals } from "./erasure.js"
class Room extends EventTarget {
constructor(name) {
super()
this.name = name
this._y = null
this.ownID = null
this.crdt = null
this.undoStack = []
}
disconnect() {
this._y.destroy()
this.crdt.destroy()
this.crdt = null
}
addPath([x, y]) {
const id = uuidv4()
this._y.share.strokeAdd.set(id, Y.Array).push([[x, y]])
return id
getUserID() {
return this.crdt.getUserID()
}
extendPath(id, [x, y]) {
this._y.share.strokeAdd.get(id).push([[x, y]])
addPath([x, y, w, colour]) {
const pathID = this.crdt.addPath([x, y, w, colour])
this.undoStack.push([pathID, 0, 0])
this.dispatchEvent(new CustomEvent("undoEnabled"))
return pathID
}
getPaths() {
let paths = new Map()
addPathRemote([x, y, w, colour]) {
return this.crdt.addPathRemote([x, y, w, colour])
}
extendPath(pathID, [x, y, w, colour]) {
const pathLength = this.crdt.extendPath(pathID, [x, y, w, colour])
for (let id of this._y.share.strokeAdd.keys()) {
paths.set(id, this._generatePath(id))
if (pathLength == 2) {
this.undoStack[this.undoStack.length - 1] = [pathID, 0, 1]
} else {
this.undoStack.push([pathID, pathLength - 2, pathLength - 1])
}
return paths
this.dispatchEvent(new CustomEvent("undoEnabled"))
}
erasePoint(id, idx) {
let eraseSet = this._y.share.strokeErase.get(id)
extendPathRemote(pathID, [x, y, w, colour]) {
return this.crdt.extendPathRemote(pathID, [x, y, w, colour])
}
if (!eraseSet) {
eraseSet = this._y.share.strokeErase.set(id, Y.Map)
}
endPath(pathID) {
this.crdt.endPath(pathID)
}
extendErasureIntervals(pathID, pointID, newIntervals) {
this.crdt.extendErasureIntervals(
pathID,
flattenErasureIntervals({ [pointID]: newIntervals }),
)
}
replacePath(pathID, newPoints) {
this.fastUndo(true)
newPoints.forEach((point) => this.extendPath(pathID, point))
eraseSet.set(idx.toString(), true)
this.undoStack.splice(this.undoStack.length - newPoints.length, 1)
}
// Generate an array of points [x, y, exist] by merging the path's add and erase sets
_generatePath(id) {
let addSet = this._y.share.strokeAdd.get(id)
getPaths() {
const paths = new Map()
if (addSet === undefined) {
return []
for (const pathID of this.crdt.getPathIDs()) {
paths.set(pathID, this.crdt.getPathPoints(pathID))
}
let eraseSet = this._y.share.strokeErase.get(id) || { get: () => false }
return paths
}
return addSet
.toArray()
.map((p = [], i) => [p[0], p[1], !eraseSet.get(i.toString())])
getPathPoints(pathID) {
return this.crdt.getPathPoints(pathID)
}
inviteUser(id) {
this._y.connector.connectToPeer(id)
getErasureIntervals(pathID) {
return spreadErasureIntervals(this.crdt.getErasureIntervals(pathID))
}
async _initialise() {
this._y = await Y({
db: {
name: "memory",
},
connector: {
name: "webrtc",
url: "/",
room: this.name,
onUserEvent: (event) => {
if (event.action == "userID") {
const { 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 }))
}
},
},
share: {
strokeAdd: "Map",
strokeErase: "Map",
},
})
this._y.share.strokeAdd.observe((lineEvent) => {
if (lineEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
lineEvent.value.observe((pointEvent) => {
if (pointEvent.type == "insert") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
}
})
}
})
this._y.share.strokeErase.observe((lineEvent) => {
if (lineEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
lineEvent.value.observe((pointEvent) => {
if (pointEvent.type == "add") {
const points = this._generatePath(lineEvent.name)
const detail = { id: lineEvent.name, points }
this.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
}
})
canUndo() {
return this.undoStack.length > 0
}
undo() {
const operation = this.undoStack.pop()
if (!operation) return
const [pathID, ...interval] = operation
this.crdt.extendErasureIntervals(pathID, [interval])
}
fastUndo(forReplacing = false) {
let from = this.undoStack.length - 1
if (from < 0) return
// eslint-disable-next-line no-unused-vars
const [pathID, _, end] = this.undoStack[from]
const endErasing = forReplacing ? end + 1 : end
for (; from >= 0; from--) {
if (this.undoStack[from][0] != pathID) {
from++
break
}
})
}
this.undoStack = this.undoStack.slice(0, Math.max(0, from))
this.crdt.extendErasureIntervals(pathID, [[0, endErasing]])
}
}
export const connect = async (roomName) => {
export const connect = async (
roomName,
CRDT,
connection,
options = undefined,
) => {
const room = new Room(roomName)
await room._initialise()
await CRDT.initialise(
room,
Object.assign({}, options, {
connection,
url: "/",
room: room.name,
mesh: {
minPeers: 4,
maxPeers: 8,
},
handshake: {
initial: 100,
interval: 500,
},
heartbeat: {
interval: 500,
minimum: 1000,
timeout: 10000,
},
}),
)
return room
}
......@@ -45,6 +45,17 @@ app.use((request, response, next) => {
next()
})
app.get("/", (request, response, next) => {
if (request.query.room == null) {
const url = new URL(
`${request.protocol}://${request.get("host")}${request.originalUrl}`,
)
url.searchParams.set("room", "imperial")
return response.redirect(url)
}
next()
})
app.use("/", express.static("public"))
server.listen(port, host, () => {
......
......@@ -7,13 +7,7 @@ self.addEventListener("activate", (event) => {
})
const CACHE_NAME = "APP-V0"
const FILES_TO_CACHE = [
"/index.html",
"/styles.css",
"/js/app.js",
"/logo.png",
"/manifest.json",
]
const FILES_TO_CACHE = [FILES_TO_CACHE_LIST] // eslint-disable-line no-undef
const FILE_ALIASES = new Map([["/", "/index.html"]])
self.addEventListener("install", async () => {
......@@ -29,7 +23,11 @@ self.addEventListener("activate", async () => {
})
const normalizePath = (path) => {
let normalizedPath = path.replace(/\/+/g, "/").replace(/$(?<!^)\/$/, "")
let normalizedPath = path.replace(/\/+/g, "/")
// TODO: Is this a correct replacement for replace(/$(?<!^)\/$/, "") (compatible with Safari)?
if (normalizedPath != "/" && normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.slice(0, -1)
}
normalizedPath = FILE_ALIASES.get(normalizedPath) || normalizedPath
return normalizedPath
}
......
const LINE_ANGLE_THRESHOLD = Math.PI / 6
const VECTOR_LEN_THRESHOLD_FRACTION = 0.3
const RECT_MATRIX_SIZE = 3
const RECT_MATRIX_CENTER_RATIO = 0.65
const RECT_THRESHOLD_CENTER = 0
const RECT_THRESHOLD_SIDE_VARIANCE = 0.25
const MIN_RECT_POINTS = 4
const MIN_LINE_POINTS = 2
function getDistance(p1, p2) {
if (!(p1 && p2)) return 0
const [[x0, y0], [x1, y1]] = [p1, p2]
return Math.hypot(x1 - x0, y1 - y0)
}
function vectorLen(v) {
const [x, y] = v
return Math.hypot(x, y)
}
function diffVector([x0, y0], [x1, y1]) {
return [x0 - x1, y0 - y1]
}
function angleBetweenVectors(p1, p2) {
const [[x0, y0], [x1, y1]] = [p1, p2]
return Math.acos((x0 * x1 + y0 * y1) / (vectorLen(p1) * vectorLen(p2)))
}
function boundingCoords(points) {
const xs = points.map((p) => p[0])
const ys = points.map((p) => p[1])
return {
maxX: Math.max(...xs),
minX: Math.min(...xs),
maxY: Math.max(...ys),
minY: Math.min(...ys),
}
}
function matrixBoundsArray(min, max) {
const d = max - min
const centerSegmentSize = d * RECT_MATRIX_CENTER_RATIO
const smallStep = (d - centerSegmentSize) / 2
const p = [min + smallStep, min + smallStep + centerSegmentSize, max]
return p
}
function getCluster([x, y], xBounds, yBounds) {
return {
x: xBounds.findIndex((bound) => x <= bound),
y: yBounds.findIndex((bound) => y <= bound),
}
}
function computeClusters(points, xBounds, yBounds) {
const clusters = Array(RECT_MATRIX_SIZE)
.fill(0)
.map(() =>
Array(RECT_MATRIX_SIZE)
.fill()
.map(() => ({ arr: [], sum: 0 })),
)
const intervals = points.map((point, i) => ({
point,
dist: getDistance(point, points[i + 1]),
}))
let totalSum = 0
intervals.forEach((interval) => {
const { x, y } = getCluster(interval.point, xBounds, yBounds)
clusters[x][y].arr.push(interval)
clusters[x][y].sum += interval.dist
totalSum += interval.dist
})
return { arr: clusters, totalSum }
}
function clusterCoefficients(clusters) {
return clusters.arr.map((rowCluster) =>
rowCluster.map((cluster) => cluster.sum / clusters.totalSum),
)
}
export function computeMatrixCoefficients(points, boundingRect) {
const { maxX, minX, maxY, minY } = boundingRect
const xBounds = matrixBoundsArray(minX, maxX)
const yBounds = matrixBoundsArray(minY, maxY)
const clusters = computeClusters(points, xBounds, yBounds)
const coefficients = clusterCoefficients(clusters, points)
return coefficients
}
function couldBeRect(points) {
if (points.length < MIN_RECT_POINTS) return false
const boundingRect = boundingCoords(points)
const matrixCoefficients = computeMatrixCoefficients(points, boundingRect)
let [maxC, minC] = [0, 1]
for (let i = 0; i < RECT_MATRIX_SIZE; i++) {
for (let j = 0; j < RECT_MATRIX_SIZE; j++) {
if (!(i === j && j === 1)) {
maxC = Math.max(maxC, matrixCoefficients[i][j])
minC = Math.min(minC, matrixCoefficients[i][j])
}
}
}
if (
matrixCoefficients[1][1] <= RECT_THRESHOLD_CENTER &&
maxC - minC < RECT_THRESHOLD_SIDE_VARIANCE
) {
return { coefficients: matrixCoefficients, boundingRect }
}
return undefined
}
function couldBeLine(points) {
if (points.length < MIN_LINE_POINTS) return false
const vectorThreshold = Math.floor(
points.length * VECTOR_LEN_THRESHOLD_FRACTION,
)
const pivot = points[0]
let cumulativeThreshold = 0
for (let i = 2; i < points.length; i++) {
const prev = points[i - 1]
const curr = points[i]
const d1 = diffVector(pivot, prev)
const d2 = diffVector(prev, curr)
const angle = angleBetweenVectors(d1, d2)
if (Math.abs(angle) > LINE_ANGLE_THRESHOLD) {
const d2Len = vectorLen(d2)
if (cumulativeThreshold < vectorThreshold && d2Len < vectorThreshold) {
cumulativeThreshold += d2Len
continue
}
return false
}
}
return true
}
function recognizedRect(_, rectDetectionData) {
const { minX, minY, maxX, maxY } = rectDetectionData.boundingRect
return {
boundingPoints: [
[minX, minY],
[minX, maxY],
[maxX, maxY],
[maxX, minY],
[minX, minY],
],
shape: Shapes.rectangle,
}
}
function recognizedLine(points) {
const [p1, p2] = [points[0], points[points.length - 1]]
return {
shape: Shapes.line,
// Take only [x, y] from the whole point tuple
lastPoint: p2.slice(0, 2),
firstPoint: p1.slice(0, 2),
}
}
function recognizeFromPoints(points) {
const rectDetectData = couldBeRect(points)
if (rectDetectData) {
return recognizedRect(points, rectDetectData)
} else if (couldBeLine(points)) {
return recognizedLine(points)
}
return {}
}
export const Shapes = {
rectangle: "rect",
line: "line",
}
export default recognizeFromPoints
Subproject commit 04ea4e5081059faffdebae65c92da25fa98f996d
Subproject commit 216be42b2a6cc5632d427cf222c789d34026a274
import * as HTML from "./elements.js"
export const Tools = Object.freeze({
PEN: Symbol("pen"),
ERASER: Symbol("eraser"),
DRAGGER: Symbol("dragger"),
})
const STANDARD_CANVAS_OFFSET = [-5000, -5000]
let canvasOffset = [...STANDARD_CANVAS_OFFSET]
let selectedTool = Tools.PEN
let strokeColour = "#000000"
let strokeRadius = 2
let recognitionEnabled = false
// TODO: The erase radius should also be selectable.
const ERASE_RADIUS = 20
export const getTool = () => selectedTool
export const getStrokeColour = () => strokeColour
export const getStrokeRadius = () => strokeRadius
export const applyCanvasOffset = ([x, y]) => {
canvasOffset[0] += x
canvasOffset[1] += y
updateCanvasOffset()
}
export const getEraseRadius = () => ERASE_RADIUS
export const isRecognitionModeSet = () => recognitionEnabled
const showElement = (element) => {
element.style.display = "block"
}
const hideElement = (element) => {
element.style.display = "none"
}
function setStrokeColour(colour) {
HTML.rectangle.style.backgroundColor = colour
HTML.strokeColorPicker.value = colour
HTML.labelColours.style.backgroundColor = colour
strokeColour = colour
}
function setStrokeRadius(value) {
HTML.strokeRadiusSlider.setAttribute("value", value)
strokeRadius = value / 2
}
setStrokeColour(strokeColour)
setStrokeRadius(strokeRadius)
HTML.recognitionModeButton.addEventListener("click", () => {
recognitionEnabled = !recognitionEnabled
if (recognitionEnabled) {
setSelectedTool(Tools.PEN)
HTML.recognitionModeButton.classList.add("selected")
} else {
HTML.recognitionModeButton.classList.remove("selected")
}
})
const toolElements = {
[Tools.PEN]: HTML.penButton,
[Tools.ERASER]: HTML.eraserButton,
[Tools.DRAGGER]: HTML.draggingToolButton,
}
function setSelectedTool(newSelectedTool) {
selectedTool = newSelectedTool
Object.getOwnPropertySymbols(toolElements).forEach((e) =>
toolElements[e].classList.remove("selected"),
)
if (newSelectedTool != Tools.PEN) {
recognitionEnabled = false
HTML.recognitionModeButton.classList.remove("selected")
}
toolElements[newSelectedTool].classList.add("selected")
}
function withPx(str) {
return `${str}px`
}
function updateCanvasOffset() {
HTML.canvas.style.left = withPx(canvasOffset[0])
HTML.canvas.style.top = withPx(canvasOffset[1])
}
function centerCanvas() {
canvasOffset = [...STANDARD_CANVAS_OFFSET]
updateCanvasOffset()
}
HTML.penButton.addEventListener("click", () => {
if (selectedTool == Tools.PEN) {
showElement(HTML.penProperties)
} else {
setSelectedTool(Tools.PEN)
}
})
HTML.eraserButton.addEventListener("click", () => {
setSelectedTool(Tools.ERASER)
})
HTML.draggingToolButton.addEventListener("click", () => {
setSelectedTool(Tools.DRAGGER)
})
HTML.canvasCenterToolButton.addEventListener("click", () => {
centerCanvas()
})
HTML.strokeColorPicker.addEventListener("change", () => {
const paletteColour = event.target.value
setStrokeColour(paletteColour)
})
HTML.strokeRadiusSlider.oninput = function() {
HTML.output.innerHTML = this.value
strokeRadius = this.value / 2
}
HTML.output.innerHTML = HTML.strokeRadiusSlider.value
// If the page has been refreshed
if (performance.navigation.type == 1) {
const sliderValue = parseInt(HTML.output.innerHTML)
setStrokeRadius(sliderValue)
}
const x = window.matchMedia(
"only screen and (orientation: landscape) and (max-width: 600px)",
)
x.addListener(() => {
if (x.matches) {
HTML.wheel.setAttribute("viewBox", "-50 10 200 100")
HTML.palette.setAttribute("style", "padding-top: 50px")
} else {
HTML.wheel.setAttribute("viewBox", "0 10 100 100")
}
})
HTML.closeButton.forEach((element) => {
element.addEventListener("click", () => {
hideElement(element.parentNode.parentNode.parentNode)
})
})
window.addEventListener("click", (event) => {
if (event.target == HTML.penProperties) {
hideElement(HTML.penProperties)
} else if (event.target == HTML.palette) {
hideElement(HTML.palette)
hideElement(HTML.penProperties)
}
})
HTML.rectangle.addEventListener("click", () => {
showElement(HTML.palette)
})
const svg = HTML.wheel.children
for (let i = 1; i < svg.length; i++) {
svg[i].addEventListener("click", (event) => {
const paletteColour = event.target.getAttribute("fill")
setStrokeColour(paletteColour)
hideElement(HTML.palette)
})
}
const WasmCRDTAsync = import("drawing-crdt")
import { spreadErasureIntervals } from "./erasure.js"
import P2PMesh from "./p2p-mesh.js"
Array.prototype.remove = function(elem) {
const index = this.indexOf(elem)
if (index > -1) {
return this.splice(index, 1)[0]
}
return undefined
}
export default class WasmCRDTWrapper {
constructor(WasmCRDT, room, interval, resolve) {
this.room = room
this.mesh = null
this.resolve = resolve
this.users = {
synced: [],
syncing: null,
check: false,
waiting: [],
}
this.crdt = new WasmCRDT({
on_stroke: (stroke_id, points) => {
stroke_id = (" " + stroke_id).slice(1)
const detail = { id: stroke_id, points }
this.room.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
},
on_interval: (stroke_id, intervals) => {
const detail = {
id: stroke_id,
intervals: spreadErasureIntervals(intervals),
}
this.room.dispatchEvent(
new CustomEvent("removedIntervalsChange", {
detail,
}),
)
},
on_deltas: (deltas) => this.mesh.broadcast(deltas, true),
on_deltas_from_state: (uid, deltas) => {
this.mesh.send(
uid,
{
type: "sync step 2",
message: deltas,
},
true,
)
this.room.dispatchEvent(
new CustomEvent("weSyncedWithPeer", { detail: uid }),
)
},
})
this.interval = setInterval(() => {
this.crdt.fetch_events()
this.crdt.fetch_deltas()
}, interval)
}
destroy() {
clearInterval(this.interval)
this.interval = null
this.mesh.disconnect()
this.mesh = null
this.crdt.free()
this.crdt = null
this.room = null
}
static async initialise(room, options) {
const { WasmCRDT } = await WasmCRDTAsync
await new Promise((resolve) => {
room.crdt = new WasmCRDTWrapper(
WasmCRDT,
room,
(options.wasm && options.wasm.interval) || 0,
resolve,
)
room.crdt.mesh = new P2PMesh(room.crdt, options)
})
}
getUserID() {
return this.crdt.get_user()
}
setUserID(uid) {
const success = this.crdt.set_user(uid)
if (success) {
this.resolve()
this.room.dispatchEvent(new CustomEvent("allocateOwnID", { detail: uid }))
}
return success
}
fetchDrawingEvents() {
return this.crdt.fetch_events()
}
addPath([x, y, w, colour]) {
return this.crdt.add_stroke(x, y, w, colour)
}
addPathRemote([x, y, w, colour]) {
return this.crdt.add_stroke_unique(x, y, w, colour)
}
extendPath(pathID, [x, y, w, colour]) {
return this.crdt.add_point(pathID, x, y, w, colour)
}
extendPathRemote(pathID, [x, y, w, colour]) {
return this.crdt.add_point_unique(pathID, x, y, w, colour)
}
endPath(pathID) {
this.crdt.end_stroke(pathID)
}
extendErasureIntervals(pathID, newIntervals) {
newIntervals.forEach(([from, to]) =>
this.crdt.erase_stroke(pathID, from, to),
)
}
getPathIDs() {
return this.crdt.get_stroke_ids()
}
getPathPoints(pathID) {
return this.crdt.get_stroke_points(pathID)
}
getErasureIntervals(pathID) {
return this.crdt.get_stroke_intervals(pathID)
}
userJoined(uid) {
if (
this.users.syncing == uid ||
this.users.synced.includes(uid) ||
this.users.waiting.includes(uid)
) {
return
}
this.users.waiting.push(uid)
this._continueSync()
this.room.dispatchEvent(new CustomEvent("userJoin", { detail: uid }))
}
userLeft(uid) {
if (this.users.syncing == uid) {
this.users.syncing = null
this.users.check = false
this._continueSync()
} else if (
!this.users.synced.remove(uid) &&
!this.users.waiting.remove(uid)
) {
return
}
this.room.dispatchEvent(new CustomEvent("userLeave", { detail: uid }))
}
receiveMessage(uid, message) {
if (message instanceof Uint8Array) {
return this.crdt.apply_deltas(message)
}
const { type, message: _message } = message
if (type == "sync step 1" && _message instanceof Uint8Array) {
this.crdt.fetch_deltas_from_state_vector(uid, _message)
} else if (type == "sync step 2" && _message instanceof Uint8Array) {
this.users.check = this.crdt.apply_deltas(_message)
if (this.users.syncing == uid) {
this.users.syncing = null
if (this.users.check) {
this.users.synced.forEach((user) =>
this.mesh.send(user, { type: "sync check" }, false),
)
this.users.waiting.forEach((user) =>
this.mesh.send(user, { type: "sync check" }, false),
)
}
this.users.check = false
this.users.synced.push(uid)
this._continueSync()
this.room.dispatchEvent(
new CustomEvent("peerSyncedWithUs", { detail: uid }),
)
}
} else if (type == "sync check") {
this.users.synced.remove(uid)
this.users.waiting.remove(uid)
this.users.waiting.unshift(uid)
if (this.users.syncing == uid) {
this.users.syncing = null
this.users.check = false
this._continueSync()
}
}
}
_continueSync() {
if (this.users.syncing != null || this.users.waiting.length <= 0) {
return
}
this.users.syncing = this.users.waiting.shift()
this.users.check = false
this.mesh.send(
this.users.syncing,
{
type: "sync step 1",
message: this.crdt.get_state_vector(),
},
false,
)
this.room.dispatchEvent(
new CustomEvent("waitingForSyncStep", { detail: this.users.syncing }),
)
}
reportConnectionQuality(uid, quality) {
this.room.dispatchEvent(
new CustomEvent("userConnection", { detail: { id: uid, quality } }),
)
}
}
export const benchmark = {
blocksize: Number.MAX_SAFE_INTEGER,
eventsGC: Number.MAX_SAFE_INTEGER,
syncStep1: Uint8Array.of(
133,
164,
117,
117,
105,
100,
217,
36,
55,
101,
98,
98,
55,
53,
54,
51,
45,
50,
54,
100,
55,
45,
52,
97,
100,
101,
45,
56,
56,
54,
57,
45,
52,
52,
102,
102,
101,
100,
57,
49,
102,
99,
55,
54,
167,
109,
101,
115,
115,
97,
103,
101,
196,
31,
130,
164,
116,
121,
112,
101,
171,
115,
121,
110,
99,
32,
115,
116,
101,
112,
32,
49,
167,
109,
101,
115,
115,
97,
103,
101,
196,
3,
0,
0,
0,
165,
115,
108,
105,
99,
101,
0,
166,
108,
101,
110,
103,
116,
104,
31,
170,
99,
111,
109,
112,
114,
101,
115,
115,
101,
100,
194,
),
}
import P2PMesh from "./p2p-mesh.js"
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"
yMemory(Y)
Y.Struct.Union = Union
yUnion(Y)
yMap(Y)
yArray(Y)
function gc(obj) {
const objs = new Set()
const free = (obj) => {
if (obj == null) return
for (const key of Object.keys(obj)) {
if (typeof obj[key] == "object") {
if (!objs.has(obj[key])) {
objs.add(obj[key])
free(obj[key])
}
}
delete obj[key]
}
}
free(obj)
objs.clear()
}
export default class YjsCRDTWrapper extends Y.AbstractConnector {
constructor(y, options) {
if (options === undefined) {
throw new Error("Options must not be undefined!")
}
options.role = "slave"
super(y, options)
this.y = y
this.room = null
this.mesh = new P2PMesh(this, options)
}
_initialise(room) {
this.room = room
super.onUserEvent((event) => {
if (event.action == "userJoined") {
const { user: id } = event
this.room.dispatchEvent(new CustomEvent("userJoin", { detail: id }))
} else if (event.action == "userLeft") {
const { user: id } = event
this.room.dispatchEvent(new CustomEvent("userLeave", { detail: id }))
}
})
const dispatchPathUpdateEvent = (lineEvent) => {
const pathID = lineEvent.name
const points = this.room.getPathPoints(pathID)
const detail = { id: pathID, points }
this.room.dispatchEvent(new CustomEvent("addOrUpdatePath", { detail }))
}
const dispatchRemovedIntervalsEvent = (lineEvent) => {
const pathID = lineEvent.name
const intervals = this.room.getErasureIntervals(pathID)
const detail = { id: pathID, intervals }
this.room.dispatchEvent(
new CustomEvent("removedIntervalsChange", {
detail,
}),
)
}
this.y.share.strokePoints.observe((lineEvent) => {
if (lineEvent.type == "add") {
dispatchPathUpdateEvent(lineEvent)
lineEvent.value.observe((pointEvent) => {
if (pointEvent.type == "insert") {
dispatchPathUpdateEvent(lineEvent)
}
})
}
})
this.y.share.eraseIntervals.observe((lineEvent) => {
if (lineEvent.type == "add") {
dispatchRemovedIntervalsEvent(lineEvent)
lineEvent.value.observe(() => {
dispatchRemovedIntervalsEvent(lineEvent)
})
}
})
}
destroy() {
// yjs connectors have an optional destroy() method that is called on y.destroy()
if (this.mesh == null) return
this.mesh.disconnect()
this.mesh = null
delete this.room
gc(this)
}
static async initialise(room, options) {
const y = await Y({
db: {
name: "memory",
},
connector: Object.assign({}, options, { name: "y-crdt" }),
share: {
strokePoints: "Map",
eraseIntervals: "Map",
},
})
y.connector._initialise(room)
room.crdt = y.connector
}
getUserID() {
return this.y.db.userId
}
setUserID(uid) {
return super.setUserId(uid)
}
fetchDrawingEvents() {
// NOOP: twiddle thumbs
}
addPath([x, y, w, colour]) {
const id = uuidv4()
this.y.share.strokePoints.set(id, Y.Array).push([[x, y, w, colour]])
this.y.share.eraseIntervals.set(id, Y.Union)
return id
}
addPathRemote([x, y, w, colour]) {
return this.addPath([x, y, w, colour])
}
extendPath(pathID, [x, y, w, colour]) {
const path = this.y.share.strokePoints.get(pathID)
path.push([[x, y, w, colour]])
return path.length
}
extendPathRemote(pathID, [x, y, w, colour]) {
return this.extendPath(pathID, [x, y, w, colour])
}
endPath(/*pathID*/) {
// NOOP: twiddle thumbs
}
extendErasureIntervals(pathID, newIntervals) {
this.y.share.eraseIntervals.get(pathID).merge(newIntervals)
}
getPathIDs() {
return this.y.share.strokePoints.keys()
}
getPathPoints(pathID) {
const points = this.y.share.strokePoints.get(pathID)
if (!points) return []
return points.toArray()
}
getErasureIntervals(pathID) {
const intervals = this.y.share.eraseIntervals.get(pathID)
if (!intervals) return []
return intervals.get()
}
userJoined(uid) {
super.userJoined(uid, "master")
}
userLeft(uid) {
super.userLeft(uid)
}
receiveMessage(uid, message) {
super.receiveMessage(uid, message)
if (message && message.type === "sync step 2") {
// We emulate the sync done message as it is not sent
super.receiveMessage(uid, { type: "sync done" })
this.room.dispatchEvent(
new CustomEvent("peerSyncedWithUs", { detail: uid }),
)
}
}
reportConnectionQuality(uid, quality) {
this.room.dispatchEvent(
new CustomEvent("userConnection", { detail: { id: uid, quality } }),
)
}
disconnect() {
super.disconnect()
}
reconnect() {
throw "Unsupported operation reconnect()"
}
send(uid, message) {
let compressed = true
if (!message) {
compressed = false
} else if (message.type === "sync step 1") {
compressed = false
this.room.dispatchEvent(
new CustomEvent("waitingForSyncStep", { detail: uid }),
)
} else if (message.type === "sync done") {
this.room.dispatchEvent(
new CustomEvent("weSyncedWithPeer", { detail: uid }),
)
// We supress the sync done message as it is emulated on receival of sync step 2
return
} else if (message.type === "sync check") {
compressed = false
}
this.mesh.send(uid, message, compressed)
}
broadcast(message) {
this.mesh.broadcast(message, true)
}
isDisconnected() {
return false
}
}
Y.extend("y-crdt", YjsCRDTWrapper)
export const benchmark = {
blocksize: 10,
eventsGC: 5000,
syncStep1: Uint8Array.of(
133,
164,
117,
117,
105,
100,
217,
36,
51,
98,
53,
98,
100,
52,
53,
53,
45,
49,
100,
57,
102,
45,
52,
55,
51,
55,
45,
97,
52,
99,
97,
45,
53,
57,
53,
49,
57,
50,
54,
49,
51,
99,
97,
51,
167,
109,
101,
115,
115,
97,
103,
101,
196,
62,
133,
164,
116,
121,
112,
101,
171,
115,
121,
110,
99,
32,
115,
116,
101,
112,
32,
49,
168,
115,
116,
97,
116,
101,
83,
101,
116,
128,
169,
100,
101,
108,
101,
116,
101,
83,
101,
116,
128,
175,
112,
114,
111,
116,
111,
99,
111,
108,
86,
101,
114,
115,
105,
111,
110,
11,
164,
97,
117,
116,
104,
192,
165,
115,
108,
105,
99,
101,
0,
166,
108,
101,
110,
103,
116,
104,
62,
170,
99,
111,
109,
112,
114,
101,
115,
115,
101,
100,
194,
),
}
/* global Y */
import { combineErasureIntervals } from "./erasure.js"
export const Union = {
create: function(id) {
return {
id: id,
union: null,
struct: "Union",
}
},
encode: function(op) {
const e = {
struct: "Union",
type: op.type,
id: op.id,
union: null,
}
if (op.requires != null) {
e.requires = op.requires
}
if (op.info != null) {
e.info = op.info
}
return e
},
requiredOps: function() {
return []
},
execute: function*() {},
}
export default function extendYUnion(Y) {
class YUnion extends Y.utils.CustomType {
constructor(os, model, contents) {
super()
this._model = model.id
this._parent = null
this._deepEventHandler = new Y.utils.EventListenerHandler()
this.os = os
this.union = model.union ? Y.utils.copyObject(model.union) : null
this.contents = contents
this.eventHandler = new Y.utils.EventHandler((op) => {
// compute op event
if (op.struct === "Insert") {
if (!Y.utils.compareIds(op.id, this.union)) {
const mergedContents = this._merge(JSON.parse(op.content[0]))
this.union = op.id
if (this.contents == mergedContents) {
return
}
this.contents = mergedContents
Y.utils.bubbleEvent(this, {
object: this,
type: "merge",
})
}
} else {
throw new Error("Unexpected Operation!")
}
})
}
_getPathToChild(/*childId*/) {
return undefined
}
_destroy() {
this.eventHandler.destroy()
this.eventHandler = null
this.contents = null
this._model = null
this._parent = null
this.os = null
this.union = null
}
get() {
return JSON.parse(this.contents)
}
_merge(newIntervals) {
const prevIntervals = this.get()
const mergedIntervals = combineErasureIntervals(
[prevIntervals],
[newIntervals],
)[0]
return JSON.stringify(mergedIntervals)
}
merge(newIntervals) {
const mergedContents = this._merge(newIntervals)
if (this.contents == mergedContents) {
return
}
const insert = {
id: this.os.getNextOpId(1),
left: null,
right: this.union,
origin: null,
parent: this._model,
content: [mergedContents],
struct: "Insert",
}
const eventHandler = this.eventHandler
this.os.requestTransaction(function*() {
yield* eventHandler.awaitOps(this, this.applyCreatedOperations, [
[insert],
])
})
// always remember to do that after this.os.requestTransaction
// (otherwise values might contain a undefined reference to type)
eventHandler.awaitAndPrematurelyCall([insert])
}
observe(f) {
this.eventHandler.addEventListener(f)
}
observeDeep(f) {
this._deepEventHandler.addEventListener(f)
}
unobserve(f) {
this.eventHandler.removeEventListener(f)
}
unobserveDeep(f) {
this._deepEventHandler.removeEventListener(f)
}
// eslint-disable-next-line require-yield
*_changed(transaction, op) {
this.eventHandler.receivedOp(op)
}
}
Y.extend(
"Union",
new Y.utils.CustomTypeDefinition({
name: "Union",
class: YUnion,
struct: "Union",
initType: function* YUnionInitializer(os, model) {
const union = model.union
const contents =
union !== null ? (yield* this.getOperation(union)).content[0] : "[]"
return new YUnion(os, model, contents)
},
createType: function YUnionCreator(os, model) {
const union = new YUnion(os, model, "[]")
return union
},
}),
)
}
if (typeof Y !== "undefined") {
extendYUnion(Y)
}
The MIT License (MIT)
Copyright (c) 2014 Kevin Jahns <kevin.jahns@rwth-aachen.de>.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
/* global Y */
"use strict"
import LioWebRTC from "liowebrtc"
function extend(Y) {
class WebRTC extends Y.AbstractConnector {
constructor(y, options) {
if (options === undefined) {
throw new Error("Options must not be undefined!")
}
options.role = "slave"
super(y, options)
this.webrtcOptions = options
if (options.onUserEvent) {
this.onUserEvent(options.onUserEvent)
}
this.initialiseConnection()
window.addEventListener("unload", () => {
this.y.destroy()
})
}
initialiseConnection() {
this.webrtc = new LioWebRTC({
url: this.webrtcOptions.url,
dataOnly: true,
/*network: {
minPeers: 4,
maxPeers: 8,
},*/
})
this.peers = new Set()
this.webrtc.on("ready", () => {
this.webrtc.joinRoom(this.webrtcOptions.room)
})
this.webrtc.on("joinedRoom", () => {
this.checkAndEnsureUser()
})
this.webrtc.on("leftRoom", () => {
console.log("LEFT ROOM")
})
this.webrtc.on("channelOpen", (dataChannel, peer) => {
this.checkAndEnsureUser()
// Start a handshake to ensure both sides are able to use the channel
function handshake(peer) {
const _peer = this.webrtc.getPeerById(peer.id)
if (!_peer || _peer !== peer) {
return
}
if (this.peers.has(peer.id)) {
return
}
console.log("ping", peer.id)
// Initial message in the handshake
this.webrtc.whisper(peer, "tw-ml", "tw")
setTimeout(handshake.bind(this, peer), 500)
}
setTimeout(handshake.bind(this, peer), 100)
})
this.webrtc.on("receivedPeerData", (type, message, peer) => {
this.checkAndEnsureUser()
if (message.type !== "update") {
console.log("receivedData", peer.id, message)
}
if (type === "y-js") {
this.checkAndInsertPeer(peer.id)
this.receiveMessage(peer.id, message)
} else if (type === "tw-ml") {
if (message === "tw") {
// Response message in the handshake
this.webrtc.whisper(peer, "tw-ml", "ml")
} else if (message == "ml") {
// Handshake completed
this.checkAndInsertPeer(peer.id)
}
}
})
this.webrtc.on("channelClose", (dataChannel, peer) => {
this.checkAndEnsureUser()
this.checkAndRemovePeer(peer.id)
})
}
// Ensure that y-js is up to date on the user's id
checkAndEnsureUser() {
const id = this.webrtc.getMyId()
if (this.y.db.userId === id) {
return
}
for (let f of this.userEventListeners) {
f({ action: "userID", id: id })
}
this.setUserId(id)
}
// Ensure that y-js knows that the peer has joined
checkAndInsertPeer(uid) {
if (this.peers.has(uid)) {
return
}
this.peers.add(uid)
console.log("createdPeer", uid)
this.userJoined(uid, "master")
}
// Ensure that y-js knows that the peer has left
checkAndRemovePeer(uid) {
if (!this.peers.has(uid)) {
return
}
this.peers.delete(uid)
console.log("removedPeer", uid)
this.userLeft(uid)
}
connectToPeer(/*uid*/) {
// currently deprecated
}
disconnect() {
this.webrtc.quit()
super.disconnect()
}
reconnect() {
this.initialiseConnection()
super.reconnect()
}
send(uid, message) {
// y-js db transactions can send messages after a peer has disconnected
if (!this.peers.has(uid) || !this.webrtc.getPeerById(uid)) {
return
}
console.log("send", uid, message)
this.webrtc.whisper(this.webrtc.getPeerById(uid), "y-js", message)
}
broadcast(message) {
if (message.type !== "update") console.log("broadcast", message)
this.webrtc.shout("y-js", message)
}
isDisconnected() {
return false
}
}
Y.extend("webrtc", WebRTC)
}
export default extend
if (typeof Y !== "undefined") {
extend(Y)
}
Subproject commit ba068ea6a62d6a124047fd3dd0075adecd5d149a
Subproject commit 44aa194d19217cbc1d8e0828cf8fdf39fc4dcdd3
const merge = require("webpack-merge")
const common = require("./webpack.common.js")
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
.BundleAnalyzerPlugin
module.exports = (env) => {
const config = merge(common, {
mode: "production",
entry: {
benchmarks: "./__benchmarks__/benchmarks.js",
},
plugins: env && env.analyze ? [new BundleAnalyzerPlugin()] : [],
})
delete config.entry.app
return config
}