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 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
this.webrtcOptions.handshake = this.webrtcOptions.handshake || {}
this.webrtcOptions.handshake.initial =
this.webrtcOptions.handshake.initial || 100
this.webrtcOptions.handshake.interval =
this.webrtcOptions.handshake.interval || 500
this.webrtcOptions.heartbeat = this.webrtcOptions.heartbeat || {}
this.webrtcOptions.heartbeat.interval =
this.webrtcOptions.heartbeat.interval || 500
this.webrtcOptions.heartbeat.minimum =
this.webrtcOptions.heartbeat.minimum || 1000
this.webrtcOptions.heartbeat.timeout =
this.webrtcOptions.heartbeat.timeout || 10000
this.queue = new Worker("js/queue.js")
this.queue.onmessage = (event) => {
const method = event.data.method
if (method == "send") {
const { uid, channel, message } = event.data
// y-js db transactions can send messages after a peer has disconnected
if (
(channel == "y-js" && !this.peers.has(uid)) ||
!this.webrtc.getPeerById(uid)
) {
return
}
this.webrtc.whisper(this.webrtc.getPeerById(uid), channel, message)
} else if (method == "broadcast") {
const { channel, message } = event.data
return this.webrtc.shout(channel, message)
} else if (method == "received") {
const { type, message, peer } = event.data
if (type === "tw-ml") {
// Handshakes can only be sent and received directly
if (message === "tw") {
// Response message in the handshake
this.queue.postMessage({
method: "send",
uid: peer.id,
channel: "tw-ml",
message: "ml",
})
} else if (message == "ml") {
// Handshake completed
this.checkAndInsertPeer(peer.id)
}
} else {
this.checkAndInsertPeer(peer.id)
if (type === "y-js") {
this.checkAndInsertPeer(peer.id)
if (message.type === "sync done") {
this.raiseUserEvent("peerSyncedWithUs", { user: peer.id })
}
this.receiveMessage(peer.id, message)
}
}
}
}
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,
constraints: {
minPeers: 4,
maxPeers: 8,
},
})
this.peers = new Map()
this.webrtc.on("ready", () => {
this.webrtc.joinRoom(this.webrtcOptions.room)
})
this.webrtc.on("joinedRoom", () => {
this.checkAndEnsureUser()
})
this.webrtc.on("leftRoom", () => {
console.log("TODO: LEFT ROOM")
})
this.webrtc.on("channelError", (a, b, c, d) =>
console.log("TODO: CHANNEL ERROR", a, b, c, d),
)
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
}
// Initial message in the handshake
this.queue.postMessage({
method: "send",
uid: peer.id,
channel: "tw-ml",
message: "tw",
})
setTimeout(
handshake.bind(this, peer),
this.webrtcOptions.handshake.interval,
)
}
setTimeout(
handshake.bind(this, peer),
this.webrtcOptions.handshake.initial,
)
})
this.webrtc.on("receivedPeerData", (type, message, peer) => {
this.checkAndEnsureUser()
// Message could have been forwarded but yjs only needs to know about directly connected peers
this.queue.postMessage({
method: "received",
type,
message,
peer: { id: peer.forwardedBy ? peer.forwardedBy.id : 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
}
this.raiseUserEvent("userID", { user: id })
this.setUserId(id)
}
// Ensure that y-js knows that the peer has joined
checkAndInsertPeer(uid) {
if (this.peers.has(uid)) {
return
}
const health = {
lastStatsResolved: true,
lastReceivedBytes: 0,
lastReceivedTimestamp: Date.now(),
}
health.cb = setInterval(
this.heartbeat.bind(this, this.webrtc.getPeerById(uid), health),
this.webrtcOptions.heartbeat.interval,
)
this.peers.set(uid, health)
this.userJoined(uid, "master")
}
heartbeat(peer, health) {
const _peer = this.webrtc.getPeerById(peer.id)
if (!_peer || _peer !== peer || !this.peers.has(peer.id)) {
clearInterval(health.cb)
return
}
if (!health.lastStatsResolved) {
return peer.end(true)
}
health.lastStatsResolved = false
const self = this
peer.getStats(null).then((stats) => {
health.lastStatsResolved = true
let disconnect = true
stats.forEach((report) => {
if (
report.type == "candidate-pair" &&
report.bytesSent > 0 &&
report.bytesReceived > 0 &&
report.writable
) {
const timeSinceLastReceived =
Date.now() - health.lastReceivedTimestamp
if (report.bytesReceived != health.lastReceivedBytes) {
health.lastReceivedBytes = report.bytesReceived
health.lastReceivedTimestamp = Date.now()
} else if (
timeSinceLastReceived > self.webrtcOptions.heartbeat.timeout
) {
return
} else if (
timeSinceLastReceived > self.webrtcOptions.heartbeat.interval
) {
self.queue.postMessage({
method: "send",
uid: peer.id,
channel: "heartbeat",
})
}
this.raiseUserEvent("userConnection", {
id: peer.id,
quality:
1.0 -
(self.webrtcOptions.heartbeat.timeout -
Math.max(
timeSinceLastReceived,
self.webrtcOptions.heartbeat.minimum,
)) /
(self.webrtcOptions.heartbeat.timeout -
self.webrtcOptions.heartbeat.minimum),
})
disconnect = false
}
})
if (disconnect) {
peer.end(true)
}
})
}
// Ensure that y-js knows that the peer has left
checkAndRemovePeer(uid) {
if (!this.peers.has(uid)) {
return
}
this.peers.delete(uid)
this.userLeft(uid)
}
connectToPeer(/*uid*/) {
// currently deprecated
}
disconnect() {
this.queue.terminate()
this.webrtc.quit()
super.disconnect()
}
reconnect() {
this.initialiseConnection()
super.reconnect()
}
raiseUserEvent(action, data) {
const event = Object.assign({ action }, data)
for (const f of this.userEventListeners) {
f(event)
}
}
send(uid, message) {
if (message.type === "sync step 1") {
this.raiseUserEvent("waitingForSyncStep", { user: uid })
} else if (message.type === "sync done") {
this.raiseUserEvent("weSyncedWithPeer", { user: uid })
}
this.queue.postMessage({ method: "send", channel: "y-js", uid, message })
}
broadcast(message) {
this.queue.postMessage({ method: "broadcast", channel: "y-js", message })
}
isDisconnected() {
return false
}
}
Y.extend("webrtc", WebRTC)
}
export default extend
if (typeof Y !== "undefined") {
extend(Y)
}
Subproject commit c2a07807dfaa1e5e6b667d83aa4a7d348c759c0f
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
}
......@@ -10,10 +10,31 @@ module.exports = {
output: {
filename: "[name].js",
path: path.resolve(__dirname, "public/js"),
publicPath: "js/",
},
plugins: [
new webpack.ProvidePlugin({
EventTarget: ["@ungap/event-target", "default"],
}),
],
module: {
rules: [
{
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
use: [
{
loader: "file-loader",
options: {
name: "../assets/fonts/font-awesome/[name].[ext]",
publicPath: "font-awesome/",
},
},
],
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
}