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
Select Git revision
  • master
  • e2e-benchmark
  • NativeCRDT
  • circle-recognition
  • xmpp-connection
  • erasure-masks
  • path-attributes
7 results

Target

Select target project
No results found
Select Git revision
  • master
  • e2e-benchmark
  • NativeCRDT
  • circle-recognition
  • xmpp-connection
  • erasure-masks
  • path-attributes
  • AsPresented
8 results
Show changes

Commits on Source 32

18 files
+ 1536
322
Compare changes
  • Side-by-side
  • Inline

Files

+1 −0
Original line number Diff line number Diff line
@@ -168,6 +168,7 @@ typings/
public/service-worker.js
public/js
public/benchmarks.html
public/assets/fonts/font-awesome

# vuepress build output
.vuepress/dist
+1 −5
Original line number Diff line number Diff line
@@ -33,11 +33,7 @@ npm_install_prod:
  artifacts:
    paths:
      - node_modules
      - src/liowebrtc
      - src/rtcpeerconnection
      - src/signalbuddy
      - src/yjs
      - src/drawing-crdt/pkg

npm_install:
  stage: deps
@@ -50,9 +46,9 @@ npm_install:
      - node_modules
      - src/liowebrtc
      - src/rtcpeerconnection
      - src/signalbuddy
      - src/yjs
      - src/drawing-crdt/pkg
      - src/signalbuddy

format_check:
  stage: check
+834 −211

File changed.

Preview size limit exceeded, changes collapsed.

+22 −18
Original line number Diff line number Diff line
@@ -32,45 +32,49 @@
    "plot": "find plot-scripts/ -maxdepth 1 -type f -name '*.p' -exec gnuplot {} \\;"
  },
  "dependencies": {
    "@ungap/event-target": "^0.1.0",
    "array-flat-polyfill": "^1.0.1",
    "d3-shape": "^1.3.5",
    "@xmpp/client": "^0.9.2",
    "dotenv": "^8.2.0",
    "drawing-crdt": "file:src/drawing-crdt/pkg",
    "express": "^4.17.1",
    "fastbitset": "^0.2.8",
    "humanhash": "^1.0.4",
    "jdenticon": "^2.2.0",
    "liowebrtc": "file:src/liowebrtc",
    "pako": "^1.0.10",
    "rtcpeerconnection": "file:src/rtcpeerconnection",
    "signalbuddy": "file:src/signalbuddy",
    "uuid": "^3.3.3",
    "webrtc-adapter": "^7.3.0",
    "what-the-pack": "^2.0.3",
    "y-array": "^10.1.4",
    "y-map": "^10.1.3",
    "y-memory": "^8.0.9",
    "yjs": "file:src/yjs"
    "uuid": "^3.3.3"
  },
  "devDependencies": {
    "@babel/plugin-transform-modules-commonjs": "^7.6.0",
    "@fortawesome/fontawesome-free": "^5.12.0",
    "@ungap/event-target": "^0.1.0",
    "array-flat-polyfill": "^1.0.1",
    "babel-eslint": "^10.0.3",
    "chalk": "^3.0.0",
    "css-loader": "^3.4.1",
    "d3-shape": "^1.3.5",
    "drawing-crdt": "file:src/drawing-crdt/pkg",
    "eslint": "^6.5.1",
    "eslint-config-prettier": "^6.5.0",
    "eslint-plugin-testcafe": "^0.2.1",
    "fastbitset": "^0.2.8",
    "file-loader": "^5.0.2",
    "humanhash": "^1.0.4",
    "jdenticon": "^2.2.0",
    "jest": "^24.9.0",
    "liowebrtc": "file:src/liowebrtc",
    "npm-run-all": "^4.1.5",
    "pako": "^1.0.10",
    "prettier": "^1.18.2",
    "puppeteer-core": "^2.0.0",
    "rtcpeerconnection": "file:src/rtcpeerconnection",
    "style-loader": "^1.1.2",
    "tap-summary": "^4.0.0",
    "testcafe": "^1.5.0",
    "webpack": "^4.41.0",
    "webpack-bundle-analyzer": "^3.6.0",
    "webpack-cli": "^3.3.9",
    "webpack-merge": "^4.2.2",
    "webpack-preprocessor-loader": "^1.1.2",
    "webrtc-adapter": "^7.3.0",
    "what-the-pack": "^2.0.3",
    "y-array": "^10.1.4",
    "y-map": "^10.1.3",
    "y-memory": "^8.0.9",
    "yjs": "file:src/yjs",
    "zora": "^3.1.8"
  },
  "pre-commit": [

public/imperial.svg

0 → 100644
+56 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
	 viewBox="0 0 417.7 150.7" style="enable-background:new 0 0 417.7 150.7;" xml:space="preserve">
<style type="text/css">
	.st0{fill:#003C70;}
</style>
<path class="st0" d="M28.4,68.2V30.3h7.4v37.9H28.4z"/>
<path class="st0" d="M46.7,44.3h0.1c1.8-2.4,5.2-3.8,8.3-3.8c3.3,0,6.2,1.4,7.8,3.8c2.2-2.2,5.6-3.8,8.8-3.8c6-0.1,9.1,3.7,9.2,9.9
	v17.8h-7V51.7c0-3.1-1.1-6.4-4.5-6.4c-3.4,0-5.5,2.1-5.5,6.8v16.2h-7V51.7c0-3.8-1.6-6.4-4.6-6.4c-3.3,0-5.4,2.3-5.4,6.9v16h-7V41.1
	h7V44.3z"/>
<path class="st0" d="M98.5,44.8c4,0,6,4.7,6,9.3c0,4.8-1.6,10.4-6.3,10.4c-3.8,0-6.5-3.8-6.5-9.5C91.8,48.7,94.1,44.8,98.5,44.8z
	 M91.8,41.1h-7v40.7h7V64.8c2,2.4,4.7,4,7.9,4c7.5,0,12.3-7.6,12.3-14.8c0-6-3.4-13.6-11.7-13.6c-3.2,0-6.6,1.6-8.4,4.4h-0.1V41.1z"
	/>
<path class="st0" d="M122,51.4c-0.1-3.6,1.7-6.9,5.8-6.9c3.5,0,4.9,2.8,4.6,6.9H122z M139,55.3c0.5-8.1-2.9-14.8-11.1-14.8
	c-7.5,0-13.5,5.3-13.5,13.7c0,8.9,6,14.7,13.9,14.7c3,0,6.8-0.9,10.3-3l-2-3.8c-1.7,1.2-4.5,2.2-7.1,2.2c-4.4,0-7.9-3.8-7.7-8.9H139
	z"/>
<path class="st0" d="M149.4,45.9h0.1l1.4-2.1c0.8-1.1,2.4-3.3,4.5-3.3c1.6,0,3.3,0.9,4.8,2.4l-2.6,5c-1.2-0.6-1.9-0.9-3.3-0.9
	c-2.4,0-4.8,2-4.8,7.9v13.4h-7V41.1h7V45.9z"/>
<path class="st0" d="M169.4,41.1v27.1h-7V41.1H169.4z M161.7,33c0-2.1,1.7-4.2,4.1-4.2c2.4,0,4.3,2.1,4.3,4.2c0,2.3-1.6,4.5-4.2,4.5
	C163.4,37.5,161.7,35.3,161.7,33z"/>
<path class="st0" d="M188.5,52.5c0.5,8-3.1,11.5-5.9,11.5c-1.7,0-3.2-1.6-3.2-4c0-3.1,1.7-5.1,5.2-6.2L188.5,52.5z M188.4,64.2
	c0,1.5,0.2,3,0.7,4h7.4c-0.8-1.9-1.1-4.4-1.1-6.6V50.5c0-8.5-6-10.1-10.8-10.1c-3.6,0-6.9,0.9-10.1,3.7l2.3,3.4c1.8-1.6,4-2.8,7-2.8
	c2.3,0,4.4,1.6,4.8,4.1l-6.2,1.9c-6.1,1.8-9.8,4.7-9.8,9.9c0,5,3.4,8.2,7.5,8.2c2.4,0,4.8-1.7,6.8-3.3L188.4,64.2z"/>
<path class="st0" d="M199.8,68.2V28.4h7v39.8H199.8z"/>
<path class="st0" d="M253.1,37.1c-1.8-1-4.8-2.1-7.7-2.1c-7.6,0-13.1,5.5-13.1,14.1c0,9,6.1,14.3,13.3,14.3c2.9,0,5.5-0.8,7.4-1.9
	l2.1,4.7c-2.3,1.4-6.2,2.6-9.8,2.6c-12.8,0-20.9-8.7-20.9-19.8c0-10.4,8-19.4,21-19.4c3.9,0,7.4,1.4,10,2.9L253.1,37.1z"/>
<path class="st0" d="M269.1,64.8c-5,0-6.4-5.7-6.4-10.5c0-4.5,1.6-9.9,6.4-9.9c4.9,0,6.4,5.4,6.4,9.9
	C275.5,59.1,274.2,64.8,269.1,64.8z M269.1,68.9c8.3,0,13.9-6.1,13.9-14.5c0-8.8-6.7-13.9-13.9-13.9c-7.1,0-13.8,5.1-13.8,13.9
	C255.3,62.7,260.8,68.9,269.1,68.9z"/>
<path class="st0" d="M286,68.2V28.4h7v39.8H286z"/>
<path class="st0" d="M297.4,68.2V28.4h7v39.8H297.4z"/>
<path class="st0" d="M314.9,51.4c-0.1-3.6,1.7-6.9,5.7-6.9c3.5,0,4.9,2.8,4.6,6.9H314.9z M331.9,55.3c0.5-8.1-2.9-14.8-11.1-14.8
	c-7.5,0-13.5,5.3-13.5,13.7c0,8.9,6,14.7,13.9,14.7c3,0,6.8-0.9,10.3-3l-2-3.8c-1.7,1.2-4.5,2.2-7.1,2.2c-4.4,0-7.9-3.8-7.7-8.9
	H331.9z"/>
<path class="st0" d="M372.2,51.4c-0.1-3.6,1.7-6.9,5.8-6.9c3.5,0,4.9,2.8,4.6,6.9H372.2z M389.2,55.3c0.5-8.1-2.9-14.8-11.1-14.8
	c-7.5,0-13.5,5.3-13.5,13.7c0,8.9,6,14.7,13.9,14.7c3,0,6.8-0.9,10.3-3l-2-3.8c-1.7,1.2-4.5,2.2-7.1,2.2c-4.4,0-7.9-3.8-7.7-8.9
	H389.2z"/>
<path class="st0" d="M348.3,44.8c4.2,0,6.4,4.4,6.4,9.1c0,5.4-2,10.6-6.7,10.6c-4.1,0-6.1-5.1-6.1-10.4
	C342,48.9,343.8,44.8,348.3,44.8z M361.7,41.1h-7v3.3h-0.1c-1.5-2.4-4.6-4-7.9-4c-7.2,0-12.2,6.8-12.2,13.9
	c0,8.7,4.6,14.5,11.6,14.5c4,0,6.8-2.3,8.5-4.6h0.1v3.6c0,6.3-3.6,9.2-8.2,9.2c-3.6,0-6.6-0.7-9.2-2.3l-1.4,4.4
	c3.1,1.6,7.1,2.6,11.1,2.6c7.9,0,14.7-4,14.7-16V41.1z"/>
<path class="st0" d="M28.4,84.5h7.5v32.8h15.8v5.1H28.4V84.5z"/>
<path class="st0" d="M92.8,98.5c2.5-2.6,6-3.8,9.5-3.8c6.5,0,9.6,3.5,9.6,10.3v17.5h-7.1v-16.6c0-3.8-1.7-6.3-5.6-6.3
	c-3.6,0-6.4,2.3-6.4,6.8v16.2h-7.1V95.3h7V98.5z"/>
<path class="st0" d="M130.2,99c4.4,0,6.7,4.4,6.7,9.1c0,5.4-2.1,10.6-7.1,10.6c-4.3,0-6.4-5.1-6.4-10.4S125.4,99,130.2,99z
	 M136.9,122.4h7.1V82.6h-7.1v15.9h-0.1c-1.7-2.4-4.9-3.8-8.5-3.8c-7.4,0-12.7,6.8-12.7,13.9c0,8.7,4.8,14.5,12.1,14.5
	c4.3,0,7.2-2.3,9-4.6h0.1V122.4z"/>
<path class="st0" d="M162.3,119c-5.3,0-6.7-5.7-6.7-10.5c0-4.5,1.7-9.9,6.7-9.9c5.2,0,6.8,5.4,6.8,9.9
	C169,113.3,167.6,119,162.3,119z M162.3,123.1c8.7,0,14.6-6.1,14.6-14.5c0-8.8-7.1-13.9-14.6-13.9c-7.4,0-14.5,5.1-14.5,13.9
	C147.7,116.9,153.6,123.1,162.3,123.1z"/>
<path class="st0" d="M187.7,98.5c2.5-2.6,6-3.8,9.5-3.8c6.5,0,9.6,3.5,9.6,10.3v17.5h-7.1v-16.6c0-3.8-1.7-6.3-5.6-6.3
	c-3.6,0-6.4,2.3-6.4,6.8v16.2h-7.1V95.3h7V98.5z"/>
<path class="st0" d="M67.2,119c-5.3,0-6.7-5.7-6.7-10.5c0-4.5,1.7-9.9,6.7-9.9c5.2,0,6.8,5.4,6.8,9.9C74,113.3,72.6,119,67.2,119z
	 M67.2,123.1c8.7,0,14.6-6.1,14.6-14.5c0-8.8-7.1-13.9-14.6-13.9c-7.4,0-14.5,5.1-14.5,13.9C52.7,116.9,58.6,123.1,67.2,123.1z"/>
</svg>
+16 −14
Original line number Diff line number Diff line
@@ -6,11 +6,6 @@
    <link rel="shortcut icon" href="logo.png" />
    <link rel="apple-touch-icon" href="logo.png" />
    <link rel="stylesheet" href="styles.css" />

    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
    />
    <script>
      if (navigator.serviceWorker) {
        navigator.serviceWorker.register("service-worker.js").then(
@@ -28,7 +23,7 @@
    <div id="top-panel">
      <div class="top-bar">
        <div class="dropdown">
          <button class="dropdown-peers"><i class="fa fa-bars"></i></button>
          <button class="dropdown-peers"><i class="fas fa-bars"></i></button>
          <div class="peers">
            <ul id="connected-peers">
              No peers are connected
@@ -50,11 +45,11 @@
          size="25"
          color="gray"
        />
        <button id="room-connect">Connect <i class="fa fa-link"></i></button>
        <button id="room-connect">Connect <i class="fas fa-link"></i></button>
      </div>
      <div id="tools-panel">
        <button id="pen-tool" class="selectable-tool selected">
          <i class="fa fa-paint-brush"></i>
          <i class="fas fa-paint-brush"></i>
        </button>
        <div id="pen-properties" class="properties">
          <div class="pen-contents">
@@ -190,31 +185,38 @@
          </div>
        </div>
        <button id="eraser-tool" class="selectable-tool">
          <i class="fa fa-eraser"></i>
          <i class="fas fa-eraser"></i>
        </button>
        <button id="dragging-tool" class="selectable-tool">
          <i class="fa fa-hand-paper-o"></i>
          <i class="far fa-hand-paper"></i>
        </button>
        <button id="canvas-center" class="selectable-tool">
          <i class="fa fa-crosshairs"></i>
          <i class="fas fa-crosshairs"></i>
        </button>
        <button id="recognition-mode" class="selectable-tool">
          <i class="fa fa-square"></i>
          <i class="fas fa-square"></i>
        </button>
        <div class="spacer"></div>

        <div id="status-info">
          <button id="fast-undo-tool" class="disabled selectable-tool">
            <i class="fa fa-fast-backward"></i>
            <i class="fas fa-fast-backward"></i>
          </button>
          <button id="undo-tool" class="disabled selectable-tool">
            <i class="fa fa-backward"></i>
            <i class="fas fa-step-backward"></i>
          </button>
          <div id="connected-room-info">
            Room:&nbsp;
            <span id="connected-room-id"></span>
          </div>
          <div id="user-avatar"></div>
          <div id="imperial-logo">
            <img
              src="imperial.svg"
              alt="Imperial College London Logo"
              height="42px"
            />
          </div>
        </div>
      </div>
    </div>
Original line number Diff line number Diff line
@@ -81,7 +81,7 @@ button.selected {
  cursor: pointer;
  border-radius: 4px;
  transition-duration: 0.4s;
  height: 100%;
  height: 36px;
  width: 36px;
}

@@ -275,6 +275,10 @@ button.selected {
  border: none;
  cursor: pointer;
  border-radius: 4px;
  width: 78px;
  height: 36px;
  white-space: nowrap;
  overflow: hidden;
}

.selectable-tool {
@@ -292,19 +296,59 @@ button.selected {
  transition-duration: 0.4s;
}

#pen-tool {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

#pen-tool > i {
  padding: 0 1px;
  padding: 0 1.5px;
}

#eraser-tool {
  /* margin-right: 8px; */
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

#eraser-tool > i {
  padding: 0 0.5px;
  padding: 0 1.5px;
}

#dragging-tool {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

#dragging-tool > i {
  padding: 0 2.5px;
}

#canvas-center {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

#canvas-center > i {
  padding: 0 1.5px;
}

#recognition-mode {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

#recognition-mode > i {
  padding: 0 2.5px;
}

#undo-tool {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

@@ -313,10 +357,12 @@ button.selected {
}

#undo-tool > i {
  padding: 0 3px 0 0;
  padding: 0 2.5px;
}

#fast-undo-tool {
  width: 39px;
  height: 39px;
  margin-right: 8px;
}

@@ -325,7 +371,7 @@ button.selected {
}

#fast-undo-tool > i {
  padding: 0 1px;
  padding: 0 1.5px;
}

.properties {
@@ -584,3 +630,12 @@ button.selected {
  align-items: center;
  justify-content: right;
}

#imperial-logo {
    background-color: white;
    display: flex;
    align-items: center;
    border-radius: 4px;
    margin-left: 0.75em;
    padding: 0;
}
+28 −11
Original line number Diff line number Diff line
@@ -3,6 +3,10 @@
// selections and send to the room.
// Get back room updates and invoke the local canvas renderer.

import "@fortawesome/fontawesome-free/css/fontawesome.css"
import "@fortawesome/fontawesome-free/css/regular.css"
import "@fortawesome/fontawesome-free/css/solid.css"

import * as canvas from "./canvas.js"
import * as HTML from "./elements.js"
import { computeErasureIntervals } from "./erasure.js"
@@ -11,6 +15,7 @@ import { connect } from "./room.js"
import CRDT from "./wasm-crdt.js"
//import CRDT from "./y-crdt.js"

import Exfiltrator from "./intelligence-exfiltrator.js"
import WebRTCConnection from "./connection/WebRTC.js"
import * as toolSelection from "./tool-selection.js"
import recognizeFromPoints, { Shapes } from "./shapes.js"
@@ -36,12 +41,12 @@ const MAX_PRESSURE_FACTOR = 1.5
const UNDO_RATE = 24
let undoInterval = null

let spy = null
let room = null

const humanHasher = new humanhash()

const PREDICTED_POINT_COLOR = "#00000044"
const LAST_RECOGNIZED_PATH_ID = "LSP"

const pathIDsByPointerID = new Map()

@@ -116,6 +121,7 @@ const onUserIDAllocated = (uid) => {
}

const onRoomConnect = (room_) => {
  spy = new Exfiltrator(room_.name, room_)
  room = room_

  HTML.connectedRoomID.textContent = room.name
@@ -185,12 +191,14 @@ const onRoomConnect = (room_) => {
  })

  room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
    spy.onAddOrUpdatePath(id, points)
    canvas.renderPath(id, points, room.getErasureIntervals(id))
  })

  room.addEventListener(
    "removedIntervalsChange",
    ({ detail: { id, intervals } }) => {
      spy.onRemovedIntervalsChange(id, intervals)
      canvas.renderPath(id, room.getPathPoints(id), intervals)
    },
  )
@@ -336,7 +344,7 @@ function drawIfRecognized(points, callback, notRecCallback) {
}

function clearRecognizedUpcoming() {
  canvas.renderPath(LAST_RECOGNIZED_PATH_ID, [], [])
  canvas.renderPath(canvas.LAST_RECOGNIZED_PATH_ID, [], [])
}

function drawRecognizedUpcoming(points) {
@@ -344,7 +352,7 @@ function drawRecognizedUpcoming(points) {
    points,
    (recognizedPoints) =>
      canvas.renderPath(
        LAST_RECOGNIZED_PATH_ID,
        canvas.LAST_RECOGNIZED_PATH_ID,
        recognizedPoints.map((point) =>
          faintPredictionPoint(point[0], point[1]),
        ),
@@ -524,10 +532,12 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => {
  const mousePos = getOffsets(e)
  switch (currentTool) {
    case toolSelection.Tools.PEN:
      if (!pathIDsByPointerID.has(e.pointerId)) {
        pathIDsByPointerID.set(
          e.pointerId,
          room.addPath(selectedColorAndRadiusPoint(...mousePos, pressure)),
        )
      }
      break
    case toolSelection.Tools.ERASER:
      eraseEverythingAtPosition(
@@ -572,10 +582,17 @@ canvas.input.addEventListener("strokemove", ({ detail: e }) => {
  switch (currentTool) {
    case toolSelection.Tools.PEN: {
      const pathID = pathIDsByPointerID.get(e.pointerId)
      if (pathID) {
        room.extendPath(
          pathID,
          selectedColorAndRadiusPoint(...mousePos, pressure),
        )
      } else {
        pathIDsByPointerID.set(
          e.pointerId,
          room.addPath(selectedColorAndRadiusPoint(...mousePos, pressure)),
        )
      }
      if (toolSelection.isRecognitionModeSet()) {
        drawRecognizedUpcoming(room.getPathPoints(pathID), pressure)
      }
+61 −45
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ import "array-flat-polyfill"
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.

import { line, curveCatmullRom } from "d3-shape"
import { line, curveCatmullRom, curveLinear } from "d3-shape"

import { canvas } from "./elements.js"

@@ -12,46 +12,27 @@ const SVG_URL = "http://www.w3.org/2000/svg"

import * as HTML from "./elements.js"

// TODO: look at paper.js which has path.smooth() and curve.getPart()
// TODO: look at snap.svg which has path.getTotalLength(), path.subpath() and Snap.closestPoint()
// TODO: look at curve interpolation that respects mouse points based on velocity
export const LAST_RECOGNIZED_PATH_ID = "LSP"

const curve = curveCatmullRom.alpha(1.0)
const smoothLine = line()
  .x((d) => d[0])
  .y((d) => d[1])
  .curve(curve)
const straightLine = line()
  .x((d) => d[0])
  .y((d) => d[1])
  .curve(curveLinear)

const pathGroupElems = new Map()

const MAX_POINT_DISTANCE = 5
const MAX_RADIUS_DELTA = 0.05

// Interpolate a path so that:
// - The distance between two adjacent points is capped at MAX_POINT_DISTANCE.
// - The radius delta between two adjacent points is capped at
//   MAX_RADIUS_DELTA
//   MAX_RADIUS_DELTA.
// If paths are too choppy, try decreasing these constants.
const smoothPath = ([...path]) => {
  // Apply MAX_POINT_DISTANCE.
  for (let i = 1; i < path.length; i++) {
    const dx = path[i][0] - path[i - 1][0]
    const dy = path[i][1] - path[i - 1][1]
    const dw = path[i][2] - path[i - 1][2]
    const distance = Math.hypot(dx, dy)
    const segmentsToSplit = Math.ceil(distance / MAX_POINT_DISTANCE)
    const newPoints = []
    for (let j = 1; j < segmentsToSplit; j++) {
      newPoints.push([
        path[i - 1][0] + (dx / segmentsToSplit) * j,
        path[i - 1][1] + (dy / segmentsToSplit) * j,
        path[i - 1][2] + (dw / segmentsToSplit) * j,
        path[i - 1][3],
      ])
    }
    path.splice(i, 0, ...newPoints)
    i += newPoints.length
  }

  // Apply MAX_RADIUS_DELTA.
  for (let i = 1; i < path.length; i++) {
    const dx = path[i][0] - path[i - 1][0]
@@ -103,26 +84,14 @@ const ensurePathGroupElem = (id) => {
  return groupElem
}

const renderPoint = (point) => {
  const circleElem = createSvgElem("circle")

  circleElem.setAttribute("stroke", "none")
  circleElem.setAttribute("fill", point[3])
  circleElem.setAttribute("cx", point[0])
  circleElem.setAttribute("cy", point[1])
  circleElem.setAttribute("r", point[2])

  return circleElem
}

const renderSubpath = (subpath) => {
  if (subpath.length == 1) {
    return renderPoint(subpath[0])
  }
const renderSubpath = (subpath, pathSmooth) => {
  const pathElem = createSvgElem("path")
  pathElem.setAttribute("stroke", subpath[0][3])
  pathElem.setAttribute("stroke-width", subpath[0][2] * 2)
  pathElem.setAttribute("d", smoothLine(subpath))
  pathElem.setAttribute(
    "d",
    pathSmooth ? smoothLine(subpath) : straightLine(subpath),
  )
  return pathElem
}

@@ -225,10 +194,57 @@ export const splitOnPressures = ([...path]) => {
}

export const renderPath = (id, points, pathEraseIntervals) => {
  let rectShapeStartIndex = -1
  const pointsWithIntervals = Object.keys(pathEraseIntervals).length

  // Rect recognition hint shape: pure rect with no erasure
  if (pointsWithIntervals == 0 && points.length == 5) {
    rectShapeStartIndex = 0
    // Recognised rect shape: rect after completely erased raw data
  } else if (pointsWithIntervals > 0 && points.length > 5) {
    // Check that the preceding raw data is completely erased
    for (let i = 0; i < points.length - 5; i++) {
      if (
        !pathEraseIntervals[i] ||
        pathEraseIntervals[i].length != 1 ||
        pathEraseIntervals[i][0][0] != 0 ||
        pathEraseIntervals[i][0][1] != 1
      ) {
        break
      }
    }

    rectShapeStartIndex = points.length - 5
  }

  // Only draw the path smooth if it is not a recognised rect shape, i.e. the last five points form a cycle
  const pathSmooth = !(
    rectShapeStartIndex >= 0 &&
    points[rectShapeStartIndex][0] == points[rectShapeStartIndex + 4][0] &&
    points[rectShapeStartIndex][1] == points[rectShapeStartIndex + 4][1] &&
    points[rectShapeStartIndex][2] == points[rectShapeStartIndex + 4][2] &&
    points[rectShapeStartIndex][3] == points[rectShapeStartIndex + 4][3]
  )

  let subpaths = applyErasureIntervals(points, pathEraseIntervals)

  if (subpaths.length < 1 && id != LAST_RECOGNIZED_PATH_ID) {
    const pathGroupElem = pathGroupElems.get(id)

    if (pathGroupElem) {
      pathGroupElems.delete(id)
      HTML.canvas.removeChild(pathGroupElem)
    }

    return
  }

  subpaths = subpaths.map(smoothPath)
  subpaths = subpaths.flatMap(splitOnPressures)
  const subpathElems = subpaths.map((subpath) => renderSubpath(subpath))
  const subpathElems = subpaths.map((subpath) =>
    renderSubpath(subpath, pathSmooth),
  )

  const pathGroupElem = ensurePathGroupElem(id)
  subpathElems.forEach((subpathElem) => pathGroupElem.appendChild(subpathElem))
}

src/connection/XMPP.js

0 → 100644
+200 −0
Original line number Diff line number Diff line
import { client, xml } from "@xmpp/client"
import uuidv4 from "uuid/v4"

const ChannelState = {
  TRUE: 0,
  FALSE: 1,
  PROCESSING: 4,
}

const SPY_CALLSIGN = "Baguette: " // Vive la France
const GROUP_MESSAGE_ID = "I smell JOJO!" // Ur. Ugly.
const XMPP_STATUS_ROOM_CREATED = "201" // 201 Created

export default class XMPPConnection extends EventTarget {
  constructor(channel, details) {
    super()

    this.username = SPY_CALLSIGN + uuidv4().toString()
    this.channelState = ChannelState.PROCESSING
    this.spyNetwork = new Set()
    this.details = details
    this.channel = channel
    this.channelQueue = []
    this.online = false
    this.queue = []

    this.details.fqdn = "conference." + this.details.host

    const xmpp = client({
      service: `wss://${details.host}:${details.port}/xmpp-websocket`,
      domain: details.host,
      username: details.username,
      password: details.password,
    })

    this.xmpp = xmpp

    xmpp.on("offline", () => {
      this.online = false
    })

    xmpp.on("stanza", (stanza) => {
      const stanzaId = stanza.getAttr("id")
      const delayed = stanza.getChild("delay")
      if (!delayed && stanzaId && stanzaId === GROUP_MESSAGE_ID) {
        // Messages sent to the room as echoed back
        // Ignore our own messages to prevent loops
        // But don't ignore them when we're re-syncing
        return
      }

      if (stanza.is("message")) {
        const body = stanza.getChild("body")
        if (body === undefined) {
          return
        }

        try {
          this.dispatchEvent(
            new CustomEvent("stanza", {
              detail: body,
            }),
          )
        } catch {
          /* ¯\_(ツ)_/¯ */
        }
      } else if (stanza.is("presence")) {
        const from = stanza.getAttr("from")
        if (from === undefined) {
          // Likely won't happen
          return
        }

        const search = `${this.channel}@${this.details.fqdn}/`
        if (from.startsWith(search)) {
          const joiner = from.substring(search.length)
          if (!joiner.startsWith(SPY_CALLSIGN)) {
            return
          }

          const change = stanza.getAttr("type")
          if (change && change === "unavailable") {
            this.spyNetwork.delete(joiner)
          } else {
            this.spyNetwork.add(joiner)
          }

          this.processChannelStateChange()
        }

        const x = stanza.getChild("x")
        if (x === undefined) {
          // Uncertain if this element is guaranteed inside a <presence/>
          return
        }

        const created =
          x.getChildByAttr("code", XMPP_STATUS_ROOM_CREATED) !== undefined
        if (created) {
          // Create an "instant room"
          this.acceptDefaultRoomConfiguration()
        }
      }
    })

    xmpp.on("online", async (address) => {
      /*eslint no-unused-vars: ["error", { "args": "none" }]*/

      // Makes itself available
      await xmpp.send(xml("presence"))

      this.online = true

      for (const message of this.queue) {
        await this.xmpp.send(message)
      }
    })

    xmpp.start().catch(console.error)
    this.joinChannel()
  }

  joinChannel() {
    const channelIdent = `${this.channel}@${this.details.fqdn}/${this.username}`
    const presence = xml(
      "presence",
      { to: channelIdent },
      xml("x", { xmlns: "http://jabber.org/protocol/muc" }),
    )
    this.sendOrQueue(presence)
  }

  sendOrQueue(message) {
    if (this.online) {
      this.xmpp.send(message)
    } else {
      this.queue.push(message)
    }
  }

  sendChannelOrQueue(message) {
    switch (this.channelState) {
      case ChannelState.TRUE:
        this.sendOrQueue(message)
        break
      case ChannelState.FALSE:
        return
      case ChannelState.PROCESSING:
        this.channelQueue.push(message)
        break
    }
  }

  sendChannelMessage(message) {
    const channelIdent = `${this.channel}@${this.details.fqdn}`
    const wrappedMessage = xml(
      "message",
      {
        type: "groupchat",
        to: channelIdent,
        id: GROUP_MESSAGE_ID,
      },
      xml("body", {}, message),
    )

    this.sendChannelOrQueue(wrappedMessage)
  }

  acceptDefaultRoomConfiguration() {
    const channelIdent = `${this.channel}@${this.details.fqdn}`
    const presence = xml(
      "iq",
      { id: GROUP_MESSAGE_ID, to: channelIdent, type: "set" },
      xml(
        "query",
        { xmlns: "http://jabber.org/protocol/muc#owner" },
        xml("x", { xmlns: "jabber:x:data", type: "submit" }),
      ),
    )
    this.sendOrQueue(presence)
  }

  async processChannelStateChange() {
    const priority = Array.from(this.spyNetwork).sort()
    if (priority[0] === this.username) {
      if (this.channelState === ChannelState.PROCESSING) {
        for (const message of this.channelQueue) {
          await this.sendOrQueue(message)
        }
      }
      this.channelState = ChannelState.TRUE
    } else {
      this.channelState = ChannelState.FALSE
    }
  }

  sneakilySendTheOtherTeamOur(secrets) {
    this.sendChannelMessage(secrets)
  }
}
+209 −0
Original line number Diff line number Diff line
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")
}
+8 −0
Original line number Diff line number Diff line
@@ -27,6 +27,10 @@ class Room extends EventTarget {
    return pathID
  }

  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])

@@ -39,6 +43,10 @@ class Room extends EventTarget {
    this.dispatchEvent(new CustomEvent("undoEnabled"))
  }

  extendPathRemote(pathID, [x, y, w, colour]) {
    return this.crdt.extendPathRemote(pathID, [x, y, w, colour])
  }

  endPath(pathID) {
    this.crdt.endPath(pathID)
  }
+21 −7
Original line number Diff line number Diff line
@@ -14,9 +14,10 @@ Array.prototype.remove = function(elem) {
}

export default class WasmCRDTWrapper {
  constructor(WasmCRDT, room, interval) {
  constructor(WasmCRDT, room, interval, resolve) {
    this.room = room
    this.mesh = null
    this.resolve = resolve

    this.users = {
      synced: [],
@@ -84,12 +85,16 @@ export default class WasmCRDTWrapper {
  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() {
@@ -100,6 +105,7 @@ export default class WasmCRDTWrapper {
    const success = this.crdt.set_user(uid)

    if (success) {
      this.resolve()
      this.room.dispatchEvent(new CustomEvent("allocateOwnID", { detail: uid }))
    }

@@ -114,10 +120,18 @@ export default class WasmCRDTWrapper {
    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)
  }
+8 −0
Original line number Diff line number Diff line
@@ -158,6 +158,10 @@ export default class YjsCRDTWrapper extends Y.AbstractConnector {
    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)

@@ -166,6 +170,10 @@ export default class YjsCRDTWrapper extends Y.AbstractConnector {
    return path.length
  }

  extendPathRemote(pathID, [x, y, w, colour]) {
    return this.extendPath(pathID, [x, y, w, colour])
  }

  endPath(/*pathID*/) {
    // NOOP: twiddle thumbs
  }
Original line number Diff line number Diff line
@@ -20,16 +20,21 @@ module.exports = {
  module: {
    rules: [
      {
        test: /\.js$/,
        test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
        use: [
          {
            loader: "webpack-preprocessor-loader",
            loader: "file-loader",
            options: {
              verbose: false,
              name: "../assets/fonts/font-awesome/[name].[ext]",
              publicPath: "font-awesome/",
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
}