diff --git a/package-lock.json b/package-lock.json index e88266b839c919cc811be65632c52d7a667af8ac..6796e5ac07630fa91a9c03e636cb4a8b617aa03e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -517,6 +517,11 @@ "@types/istanbul-lib-report": "*" } }, + "@types/node": { + "version": "12.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.7.tgz", + "integrity": "sha512-E6Zn0rffhgd130zbCbAr/JdXfXkoOUFAKNs/rF8qnafSJ8KYaA/j3oz7dcwal+lYjLA7xvdd5J4wdYpCTlP8+w==" + }, "@types/stack-utils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz", @@ -1458,6 +1463,11 @@ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true }, + "canvas-renderer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/canvas-renderer/-/canvas-renderer-2.1.1.tgz", + "integrity": "sha512-/V0XetN7s1Mk3NO7x2wxPZYv0pLMQtGAhecuOuKR88beiYCUle1AbCcFZNLu+4NVzi9RVHS0rXtIgzPEaKidLw==" + }, "capture-exit": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", @@ -3883,6 +3893,14 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "humanhash": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/humanhash/-/humanhash-1.0.4.tgz", + "integrity": "sha512-fxOhEl/Ezv7PobYOTomDmQKWaSC0hk0mzl5et5McPtr+6LRBP7LYoeFLPjKW6xOSGmMNLj50BufrrgX+M5EvEA==", + "requires": { + "uuid": "^3.3.2" + } + }, "iana-hashes": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/iana-hashes/-/iana-hashes-1.1.0.tgz", @@ -4339,6 +4357,15 @@ "handlebars": "^4.1.2" } }, + "jdenticon": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/jdenticon/-/jdenticon-2.2.0.tgz", + "integrity": "sha512-WGqwpjN9pab/Sah9pGnFH5tQc3HF3WbLV/tPVbykvk5nuAkxG/zhzQYWC2owvpnS+/A0HmlSx35rtY8kyN+x7Q==", + "requires": { + "@types/node": "*", + "canvas-renderer": "~2.1.1" + } + }, "jest": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest/-/jest-24.9.0.tgz", @@ -6134,9 +6161,9 @@ } }, "react-is": { - "version": "16.11.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", - "integrity": "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw==", + "version": "16.12.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.12.0.tgz", + "integrity": "sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==", "dev": true }, "read-pkg": { diff --git a/package.json b/package.json index 7767796697e115d85aa50bda71bc22f26bbb6ba2..52c352f7ee82f185f75055355355ae4d5ae3fa6c 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "d3-shape": "^1.3.5", "dotenv": "^8.2.0", "express": "^4.17.1", + "humanhash": "^1.0.4", + "jdenticon": "^2.2.0", "liowebrtc": "file:src/liowebrtc", "pako": "^1.0.10", "signalbuddy": "file:src/signalbuddy", diff --git a/public/styles.css b/public/styles.css index 04842b1391d4b38b4a2f47628dbab4ee2335405e..3eb118cac54c599e8a414432ff1738adf6f8e3d0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -79,7 +79,7 @@ button.selected { min-width: 160px; box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); z-index: 999; - padding: 16px; + padding: 8px; } .peers a { @@ -515,3 +515,7 @@ button.selected { url("./assets/fonts/martel-v4-latin/martel-v4-latin-regular.svg#Martel") format("svg"); } + +.avatar { + margin-right: 5px; +} diff --git a/src/app.js b/src/app.js index ae6d6305e2b39f7d13fffac0da058795fd199c32..0665da2af040d7d58733e1a9650c84dd4884bb3a 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,8 @@ import { computeErasureIntervals, combineErasureIntervals } from "./erasure.js" import { connect } from "./room.js" import * as toolSelection from "./tool-selection.js" import recognizeFromPoints, { Shapes } from "./shapes.js" +import * as humanhash from "humanhash" +import jdenticon from "jdenticon" const DEFAULT_ROOM = "imperial" @@ -33,6 +35,8 @@ const getPressureFactor = (pressure) => { let room = null +const humanHasher = new humanhash() + function eraseEverythingAtPosition(x, y, radius, room) { const mousePos = [x, y] room.getPaths().forEach((points, pathID) => { @@ -95,7 +99,7 @@ const onRoomConnect = (room_) => { const medium = "/quality-medium.svg" const low = "/quality-low.svg" - const peer = getOrInsertPeerById(id).children[0] + const peer = getOrInsertPeerById(id).children[1] if (quality < 0.33) { if (!peer.src.includes(high)) { peer.src = high @@ -112,18 +116,18 @@ const onRoomConnect = (room_) => { }) room.addEventListener("weSyncedWithPeer", ({ detail: id }) => { - getOrInsertPeerById(id).children[1].className = "peer-status upload synced" + getOrInsertPeerById(id).children[2].className = "peer-status upload synced" updateOverallStatusIcon() }) room.addEventListener("waitingForSyncStep", ({ detail: id }) => { - getOrInsertPeerById(id).children[2].className = + getOrInsertPeerById(id).children[3].className = "peer-status download negotiating" updateOverallStatusIcon() }) room.addEventListener("peerSyncedWithUs", ({ detail: id }) => { - getOrInsertPeerById(id).children[2].className = + getOrInsertPeerById(id).children[3].className = "peer-status download synced" updateOverallStatusIcon() }) @@ -259,13 +263,18 @@ HTML.roomIDElem.addEventListener("keydown", (event) => { const getOrInsertPeerById = (id) => { for (const peerElem of HTML.connectedPeers.children) { - const peerId = peerElem.children[3].innerHTML + 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) + avatarImage.className = "avatar" + const quality = document.createElement("img") quality.src = "/quality-low.svg" quality.alt = "Peer quality icon" @@ -279,8 +288,12 @@ const getOrInsertPeerById = (id) => { const peerId = document.createElement("div") peerId.style.marginLeft = "5px" - peerId.innerHTML = id + peerId.id = id + + peerId.innerHTML = humanHasher.humanize(id, 2) + + peerElem.appendChild(avatarImage) peerElem.appendChild(quality) peerElem.appendChild(ourStatus) peerElem.appendChild(theirStatus) @@ -294,8 +307,8 @@ const getOrInsertPeerById = (id) => { const updateOverallStatusIcon = () => { for (const peerElem of HTML.connectedPeers.children) { if ( - !peerElem.children[1].classList.contains("synced") || - !peerElem.children[2].classList.contains("synced") + !peerElem.children[2].classList.contains("synced") || + !peerElem.children[3].classList.contains("synced") ) { HTML.overallStatusIcon.className = "synchronising" HTML.overallStatusIconImage.src = "synchronising.svg" diff --git a/src/room.js b/src/room.js index af3bfbf9697cfc899b7bb1b7ae31583e12606bd0..d1ac2ff4d8f227a186cdbd3e4360c8f147d20f48 100644 --- a/src/room.js +++ b/src/room.js @@ -1,6 +1,7 @@ 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" @@ -8,15 +9,13 @@ import yP2PMesh from "./y-p2p-mesh.js" import WebRTCConnection from "./connection/WebRTC.js" yMemory(Y) +Y.Struct.Union = Union +yUnion(Y) yMap(Y) yArray(Y) yP2PMesh(Y) -import { - combineErasureIntervals, - spreadErasureIntervals, - flattenErasureIntervals, -} from "./erasure.js" +import { spreadErasureIntervals, flattenErasureIntervals } from "./erasure.js" class Room extends EventTarget { constructor(name) { @@ -33,7 +32,10 @@ class Room extends EventTarget { 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) + return id } @@ -42,25 +44,9 @@ class Room extends EventTarget { } extendErasureIntervals(pathID, pointID, newIntervals) { - const self = this - - // eslint-disable-next-line require-yield - this._y.db.requestTransaction(function* requestTransaction() { - const prevJSON = self.shared.eraseIntervals.get(pathID) || "[]" - const pathIntervals = JSON.parse(prevJSON) - - const combinedIntervals = combineErasureIntervals( - [pathIntervals], - [flattenErasureIntervals({ [pointID]: newIntervals })], - )[0] - const postJSON = JSON.stringify(combinedIntervals) - - if (prevJSON == postJSON) { - return - } - - self.shared.eraseIntervals.set(pathID, postJSON) - }) + this.shared.eraseIntervals + .get(pathID) + .merge(flattenErasureIntervals({ [pointID]: newIntervals })) } // TODO: Refactor duplication @@ -114,7 +100,7 @@ class Room extends EventTarget { if (!intervals) return [] - return spreadErasureIntervals(JSON.parse(intervals)) + return spreadErasureIntervals(intervals.get()) } inviteUser(id) { @@ -215,7 +201,13 @@ class Room extends EventTarget { } }) this.shared.eraseIntervals.observe((lineEvent) => { - dispatchRemovedIntervalsEvent(lineEvent) + if (lineEvent.type == "add") { + dispatchRemovedIntervalsEvent(lineEvent) + + lineEvent.value.observe(() => { + dispatchRemovedIntervalsEvent(lineEvent) + }) + } }) } } diff --git a/src/tool-selection.js b/src/tool-selection.js index 16c8e687aaeb060a30deb47b712b7673195de69c..a6c2d0563bc9e59d55bc5e79998ed1c3f90f76a2 100644 --- a/src/tool-selection.js +++ b/src/tool-selection.js @@ -60,11 +60,18 @@ HTML.picker.addEventListener("change", () => { HTML.slider.oninput = function() { HTML.output.innerHTML = this.value - strokeRadius = this.value / 10 + strokeRadius = this.value / 2 } HTML.output.innerHTML = HTML.slider.value +// If the page has been refreshed +if (performance.navigation.type == 1) { + const sliderValue = parseInt(HTML.output.innerHTML) + HTML.slider.setAttribute("value", sliderValue) + strokeRadius = sliderValue / 2 +} + const x = window.matchMedia( "only screen and (orientation: landscape) and (max-width: 600px)", ) diff --git a/src/y-union.js b/src/y-union.js new file mode 100644 index 0000000000000000000000000000000000000000..aabe816396061e64ec907505ecd051d8206dd6ae --- /dev/null +++ b/src/y-union.js @@ -0,0 +1,155 @@ +/* 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) +}