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
Showing with 2368 additions and 527 deletions
<?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>
......@@ -4,39 +4,26 @@
<meta charset="UTF-8" />
<link rel="manifest" href="manifest.json" />
<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(
(registration) =>
console.log(
`Service worker registered on scope ${registration.scope}`,
),
(reason) =>
console.log(`Service worker failed to register ~ ${reason}`),
)
navigator.serviceWorker.register("service-worker.js").then(
(registration) =>
console.log(
`Service worker registered on scope ${registration.scope}`,
),
(reason) =>
console.log(`Service worker failed to register ~ ${reason}`),
)
}
</script>
</head>
<body>
<div style="display:none">
Your client ID: <input id="user-id" type="text" value="" readonly="" />
</div>
<div style="display:none">
Enter peer ID: <input id="peer-id" type="text" value="" />
<button id="peer-connect">Connect</button>
</div>
<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
......@@ -58,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="selected">
<i class="fa fa-paint-brush"></i>
<button id="pen-tool" class="selectable-tool selected">
<i class="fas fa-paint-brush"></i>
</button>
<div id="pen-properties" class="properties">
<div class="pen-contents">
......@@ -87,8 +74,7 @@
<input
type="range"
min="1"
max="100"
value="10"
max="10"
class="slider"
id="range"
/>
......@@ -193,19 +179,51 @@
<b>Other colours</b>
</div>
<label id="colours">
<input id="other-colours" type="color" value="#0000ff" />
<input id="other-colours" type="color" />
</label>
</div>
</div>
</div>
<button id="eraser-tool"><i class="fa fa-eraser"></i></button>
<div id="connected-room-info">
You are connected to a room: <span id="connected-room-id"></span>
<button id="eraser-tool" class="selectable-tool">
<i class="fas fa-eraser"></i>
</button>
<button id="dragging-tool" class="selectable-tool">
<i class="far fa-hand-paper"></i>
</button>
<button id="canvas-center" class="selectable-tool">
<i class="fas fa-crosshairs"></i>
</button>
<button id="recognition-mode" class="selectable-tool">
<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="fas fa-fast-backward"></i>
</button>
<button id="undo-tool" class="disabled selectable-tool">
<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>
<svg id="canvas"></svg>
<div id="canvas-container">
<svg id="canvas"></svg>
</div>
<script src="js/app.js"></script>
</body>
......
......@@ -3,6 +3,10 @@ body {
height: 100%;
}
#console {
color: white;
}
body {
background-color: black;
margin: 0;
......@@ -10,11 +14,21 @@ body {
flex-direction: column;
}
#canvas {
#canvas-container {
width: calc(100vw - 8px);
background-color: white;
border: 4px solid red;
flex-grow: 1;
position: relative;
overflow: hidden;
}
#canvas {
position: absolute;
left: -5000px;
top: -5000px;
width: 10000px;
height: 10000px;
}
#tools-panel {
......@@ -23,11 +37,18 @@ body {
align-items: center;
}
.spacer {
flex: 1;
}
#connected-room-info {
display: none;
color: white;
flex: 1;
text-align: right;
display: flex;
align-items: center;
background-color: #4f4f4fb7;
border-radius: 4px;
justify-content: center;
padding: 0.75em;
}
button.selected {
......@@ -60,7 +81,7 @@ button.selected {
cursor: pointer;
border-radius: 4px;
transition-duration: 0.4s;
height: 100%;
height: 36px;
width: 36px;
}
......@@ -71,7 +92,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 {
......@@ -254,9 +275,13 @@ button.selected {
border: none;
cursor: pointer;
border-radius: 4px;
width: 78px;
height: 36px;
white-space: nowrap;
overflow: hidden;
}
#pen-tool {
.selectable-tool {
background-color: #2f2f2f;
color: white;
padding: 10px;
......@@ -264,27 +289,89 @@ button.selected {
border: none;
cursor: pointer;
border-radius: 50%;
margin-right: 8px;
}
#pen-tool:hover {
.selectable-tool:hover {
background-color: #4f4f4f !important;
transition-duration: 0.4s;
}
#pen-tool {
width: 39px;
height: 39px;
margin-right: 8px;
}
#pen-tool > i {
padding: 0 1.5px;
}
#eraser-tool {
background-color: #2f2f2f;
color: white;
padding: 10px;
font-size: 16px;
border: none;
cursor: pointer;
border-radius: 50%;
width: 39px;
height: 39px;
margin-right: 8px;
}
#eraser-tool:hover {
background-color: #4f4f4f !important;
transition-duration: 0.4s;
#eraser-tool > i {
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;
}
#undo-tool.disabled {
display: none;
}
#undo-tool > i {
padding: 0 2.5px;
}
#fast-undo-tool {
width: 39px;
height: 39px;
margin-right: 8px;
}
#fast-undo-tool.disabled {
display: none;
}
#fast-undo-tool > i {
padding: 0 1.5px;
}
.properties {
......@@ -523,3 +610,32 @@ button.selected {
url("./assets/fonts/martel-v4-latin/martel-v4-latin-regular.svg#Martel")
format("svg");
}
.avatar {
margin-right: 5px;
}
#user-avatar {
color: white;
display: flex;
align-items: center;
background-color: #4f4f4fb7;
border-radius: 4px;
margin-left: 0.75em;
padding: 0 0.75em 0 0;
}
#status-info {
display: flex;
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;
}
File added
// Room connection and synchronisation.
// Translate local canvas input events to draw messages and send to the room.
// Translate local canvas input events to draw messages in light of current tool
// 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"
import { connect } from "./room.js"
const TEST_ROOM = "imperial"
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"
import * as humanhash from "humanhash"
import jdenticon from "jdenticon"
const DEFAULT_ROOM = "imperial"
const navigateToRoom = (roomID) => {
const url = new URL(location.href)
url.searchParams.set("room", roomID)
location.href = url
}
const initialRoom = new URLSearchParams(location.search).get("room")
if (initialRoom == null) {
navigateToRoom(DEFAULT_ROOM)
}
const MIN_PRESSURE_FACTOR = 0.1
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 pathIDsByPointerID = new Map()
// Safari reports mouse touches as events with 0 pressure, but the standard requires
// it to be 0.5
function getNormalizedPressure(e) {
if (e.pressure === 0 && e.buttons) {
return 0.5
}
return e.pressure
}
// This is a quadratic such that:
// - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
// - getPressureFactor(0.5) = 1.0
// - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR
// For sensible results, maintain that:
// - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0
// - 1.0 <= MAX_PRESSURE_FACTOR
// For intuitive results, maintain that:
// - MAX_PRESSURE_FACTOR <= ~2.0
const getPressureFactor = (pressure) => {
const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4
const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4
const c = MIN_PRESSURE_FACTOR
return a * pressure ** 2 + b * pressure + c
}
function selectedRadiusPoint(x, y, color, pressure = 0) {
return [
x,
y,
toolSelection.getStrokeRadius() * getPressureFactor(pressure),
color,
]
}
function faintPredictionPoint(x, y, pressure = 0.5) {
return selectedRadiusPoint(x, y, PREDICTED_POINT_COLOR, pressure)
}
function selectedColorAndRadiusPoint(x, y, pressure = 0.5) {
return selectedRadiusPoint(x, y, toolSelection.getStrokeColour(), pressure)
}
function eraseEverythingAtPosition(x, y, radius, room) {
const mousePos = [x, y]
room.getPaths().forEach((points, pathID) => {
const newPathIntervals = computeErasureIntervals(points, mousePos, radius)
Object.keys(newPathIntervals).forEach((pointID) =>
room.extendErasureIntervals(pathID, pointID, newPathIntervals[pointID]),
)
})
}
const onUserIDAllocated = (uid) => {
const userID = uid.replace(/-/g, "")
// Create user account
const avatarImage = document.createElement("svg")
avatarImage.innerHTML = jdenticon.toSvg(userID, 40)
avatarImage.className = "avatar"
const userAccount = document.createElement("div")
userAccount.innerHTML = humanHasher.humanize(userID, 2)
HTML.userInfo.innerHTML = ""
HTML.userInfo.appendChild(avatarImage)
HTML.userInfo.appendChild(userAccount)
}
const onRoomConnect = (room_) => {
spy = new Exfiltrator(room_.name, room_)
room = room_
HTML.connectedRoomID.textContent = room.name
HTML.connectedRoomInfoContainer.style.display = "block"
HTML.userIDElem.value = room.ownID || ""
room.addEventListener("allocateOwnID", ({ detail: id }) => {
HTML.userIDElem.value = id
})
const uid = room.getUserID()
room.addEventListener("userJoin", ({ detail: id }) => {
if (HTML.connectedPeers.children.length == 0) {
HTML.connectedPeers.innerHTML = ""
}
if (uid) {
onUserIDAllocated(uid)
}
room.addEventListener("allocateOwnID", ({ detail: id }) =>
onUserIDAllocated(id),
)
room.addEventListener("userJoin", ({ detail: id }) => {
getOrInsertPeerById(id)
updateOverallStatusIcon()
})
......@@ -45,7 +157,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
......@@ -62,156 +174,249 @@ 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()
})
room.addEventListener("addOrUpdatePath", ({ detail: { id, points } }) => {
canvas.renderPath(id, points)
spy.onAddOrUpdatePath(id, points)
canvas.renderPath(id, points, room.getErasureIntervals(id))
})
}
const tryRoomConnect = async (roomID) => {
return await connect(roomID)
.then(onRoomConnect)
.catch((err) => alert(`Error connecting to a room:\n${err}`))
}
room.addEventListener(
"removedIntervalsChange",
({ detail: { id, intervals } }) => {
spy.onRemovedIntervalsChange(id, intervals)
canvas.renderPath(id, room.getPathPoints(id), intervals)
},
)
const ERASER_RADIUS = 10
const tools = {
PEN: Symbol("pen"),
ERASER: Symbol("eraser"),
}
let currentTool = tools.PEN
const pathIDsByPointerID = new Map()
room.addEventListener("undoEnabled", () => {
HTML.fastUndoButton.classList.remove("disabled")
HTML.undoButton.classList.remove("disabled")
})
HTML.penButton.addEventListener("click", () => {
if (currentTool == tools.PEN) {
showElement(HTML.penProperties)
} else {
currentTool = tools.PEN
HTML.penButton.classList.add("selected")
HTML.eraserButton.classList.remove("selected")
}
})
// TODO: Move this iPhone specific code to the general user interaction to avoid double events
/*HTML.canvas.addEventListener("touchstart", (e) => {
e.preventDefault()
let pressure = 0
let x, y
const topPanelHeight = HTML.topPanel.offsetHeight
if (
e.touches &&
e.touches[0] &&
typeof e.touches[0]["force"] !== "undefined"
) {
if (e.touches[0]["force"] > 0) {
pressure = e.touches[0]["force"]
}
x = e.touches[0].pageX
y = e.touches[0].pageY - topPanelHeight
} else {
pressure = 1.0
x = e.pageX
y = e.pageY - topPanelHeight
}
HTML.closeButton.forEach((element) => {
element.addEventListener("click", () => {
hideElement(element.parentNode.parentNode.parentNode)
clearRecognizedUpcoming()
if (room == null) {
return
}
const currentTool = toolSelection.getTool()
const mousePos = [x, y]
if (currentTool == toolSelection.Tools.PEN) {
pathIDsByPointerID.set(
e.pointerId,
room.addPath(selectedColorAndRadiusPoint(...mousePos, pressure)),
)
} else if (currentTool == toolSelection.Tools.ERASER) {
eraseEverythingAtPosition(
...mousePos,
toolSelection.getEraseRadius(),
room,
)
}
})
})
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.canvas.addEventListener("touchmove", (e) => {
let pressure = 0
let x, y
const topPanelHeight = HTML.topPanel.offsetHeight
if (
e.touches &&
e.touches[0] &&
typeof e.touches[0]["force"] !== "undefined"
) {
if (e.touches[0]["force"] > 0) {
pressure = e.touches[0]["force"]
}
x = e.touches[0].pageX
y = e.touches[0].pageY - topPanelHeight
} else {
pressure = 1.0
x = e.pageX
y = e.pageY - topPanelHeight
}
HTML.rectangle.addEventListener("click", () => {
showElement(HTML.palette)
})
if (room == null) {
return
}
const currentTool = toolSelection.getTool()
const mousePos = [x, y]
if (currentTool == toolSelection.Tools.PEN) {
const pathID = pathIDsByPointerID.get(e.pointerId)
room.extendPath(
pathID,
selectedColorAndRadiusPoint(...mousePos, pressure),
)
if (toolSelection.isRecognitionModeSet()) {
drawRecognizedUpcoming(room.getPoints(pathID), pressure)
}
} else if (currentTool == toolSelection.Tools.ERASER) {
eraseEverythingAtPosition(
...mousePos,
toolSelection.getEraseRadius(),
room,
)
}
})
const svg = HTML.wheel.children
for (let i = 1; i < svg.length; i++) {
svg[i].addEventListener("click", (event) => {
const paletteColour = event.target.getAttribute("fill")
HTML.rectangle.style.backgroundColor = paletteColour
HTML.picker.value = paletteColour
HTML.labelColours.style.backgroundColor = paletteColour
canvas.setStrokeColour(paletteColour)
hideElement(HTML.palette)
HTML.canvas.addEventListener("touchend", (e) => {
const { pointerId } = e
const pressure = getNormalizedPressure(e)
const pathID = pathIDsByPointerID.get(pointerId)
if (toolSelection.isRecognitionModeSet()) {
drawRecognized(pathID, room.getPoints(pathID), pressure)
}
pathIDsByPointerID.delete(pointerId)
clearRecognizedUpcoming()
})
HTML.canvas.addEventListener("touchleave", (e) => {
const { pointerId } = e
const pressure = getNormalizedPressure(e)
const pathID = pathIDsByPointerID.get(pointerId)
if (toolSelection.isRecognitionModeSet()) {
drawRecognized(pathID, room.getPoints(pathID), pressure)
}
pathIDsByPointerID.delete(pointerId)
clearRecognizedUpcoming()
})*/
}
function showElement(element) {
element.style.display = "block"
function getRecognizedShapePoints(points) {
const recognizedShape = recognizeFromPoints(points)
if (!recognizedShape.shape) return undefined
switch (recognizedShape.shape) {
case Shapes.line: {
const p1 = recognizedShape.firstPoint
const p2 = recognizedShape.lastPoint
return [p1, p2]
}
case Shapes.rectangle: {
return recognizedShape.boundingPoints
}
}
return undefined
}
function hideElement(element) {
element.style.display = "none"
function drawIfRecognized(points, callback, notRecCallback) {
const recognizedPoints = getRecognizedShapePoints(points)
if (recognizedPoints) {
callback && callback(recognizedPoints)
} else {
notRecCallback && notRecCallback()
}
}
HTML.picker.addEventListener("change", () => {
const paletteColour = event.target.value
HTML.rectangle.style.backgroundColor = paletteColour
HTML.labelColours.style.backgroundColor = paletteColour
canvas.setStrokeColour(paletteColour)
})
function clearRecognizedUpcoming() {
canvas.renderPath(canvas.LAST_RECOGNIZED_PATH_ID, [], [])
}
HTML.output.innerHTML = HTML.slider.value
function drawRecognizedUpcoming(points) {
drawIfRecognized(
points,
(recognizedPoints) =>
canvas.renderPath(
canvas.LAST_RECOGNIZED_PATH_ID,
recognizedPoints.map((point) =>
faintPredictionPoint(point[0], point[1]),
),
[],
),
clearRecognizedUpcoming,
)
}
HTML.slider.oninput = function() {
HTML.output.innerHTML = this.value
canvas.setStrokeRadius(this.value / 10)
function drawRecognized(pathID, points) {
drawIfRecognized(points, (newPoints) =>
room.replacePath(
pathID,
newPoints.map((point) => selectedColorAndRadiusPoint(point[0], point[1])),
),
)
clearRecognizedUpcoming()
}
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")
}
})
const tryRoomConnect = async (roomID) => {
return await connect(roomID, CRDT, WebRTCConnection, {
wasm: { interval: 16 },
})
.then(onRoomConnect)
.catch((err) => alert(`Error connecting to a room:\n${err}`))
}
HTML.picker.addEventListener("change", () => {
const paletteColour = event.target.value
HTML.rectangle.style.backgroundColor = paletteColour
HTML.labelColours.style.backgroundColor = paletteColour
canvas.setStrokeColour(paletteColour)
})
const onRoomJoinEnter = () => {
navigateToRoom(HTML.roomIDElem.value)
}
HTML.eraserButton.addEventListener("click", () => {
currentTool = tools.ERASER
HTML.penButton.classList.remove("selected")
HTML.eraserButton.classList.add("selected")
})
HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)
HTML.peerButton.addEventListener("click", () => {
const peerID = HTML.peerIDElem.value
if (room == null || peerID == "") {
return
HTML.fastUndoButton.addEventListener("click", () => {
if (room == null) return
room.fastUndo()
if (!room.canUndo()) {
HTML.fastUndoButton.classList.add("disabled")
HTML.undoButton.classList.add("disabled")
}
room.inviteUser(peerID)
HTML.peerIDElem.value = ""
})
const onRoomJoinEnter = () => {
const selectedRoomID = HTML.roomIDElem.value
if (!selectedRoomID || selectedRoomID == room.name) {
return
}
const undoButtonEnd = () => clearInterval(undoInterval)
if (room != null) {
room.disconnect()
room = null
}
const undoButtonStart = () => {
undoInterval = setInterval(function() {
if (room == null) return
canvas.clear()
HTML.connectedPeers.innerHTML = "No peers are connected"
room.undo()
tryRoomConnect(selectedRoomID)
if (!room.canUndo()) {
HTML.fastUndoButton.classList.add("disabled")
HTML.undoButton.classList.add("disabled")
}
}, 1000 / UNDO_RATE)
}
HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter)
HTML.undoButton.addEventListener("pointerup", undoButtonEnd)
HTML.undoButton.addEventListener("pointerleave", undoButtonEnd)
HTML.undoButton.addEventListener("mouseup", undoButtonEnd)
HTML.undoButton.addEventListener("pointerdown", undoButtonStart)
HTML.roomIDElem.addEventListener("keydown", (event) => {
if (event.key == "Enter") {
......@@ -222,16 +427,23 @@ HTML.roomIDElem.addEventListener("keydown", (event) => {
})
const getOrInsertPeerById = (id) => {
id = id.replace(/-/g, "")
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.src = "/quality-high.svg"
quality.alt = "Peer quality icon"
quality.className = "peer-quality"
......@@ -243,13 +455,21 @@ 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)
peerElem.appendChild(peerId)
if (HTML.connectedPeers.children.length == 0) {
HTML.connectedPeers.innerHTML = ""
}
HTML.connectedPeers.appendChild(peerElem)
return peerElem
......@@ -258,8 +478,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"
......@@ -270,66 +490,134 @@ const updateOverallStatusIcon = () => {
HTML.overallStatusIconImage.src = "synchronised.svg"
}
const getDistance = (a, b) => {
return Math.sqrt(
(a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]),
)
let canvasDraggingStart = null
function startCanvasDragging(mousePos) {
if (canvasDraggingStart == null) {
canvasDraggingStart = mousePos
}
}
const erasePoint = ([x, y]) => {
if (room == null) {
return
function stopCanvasDragging() {
canvasDraggingStart = null
}
function dragCanvas(mousePos) {
if (canvasDraggingStart) {
const [x0, y0] = canvasDraggingStart
const [x1, y1] = mousePos
const offset = [x1 - x0, y1 - y0]
canvasDraggingStart = mousePos
toolSelection.applyCanvasOffset(offset)
}
room.getPaths().forEach((points, pathID) => {
points.forEach((point, i) => {
if (getDistance([x, y], point) <= ERASER_RADIUS) {
room.erasePoint(pathID, i)
}
})
})
}
function getOffsets(e) {
return [e.offsetX, e.offsetY]
}
function getScreenOffsets(e) {
return [e.screenX, e.screenY]
}
canvas.input.addEventListener("strokestart", ({ detail: e }) => {
e.preventDefault()
const pressure = getNormalizedPressure(e)
clearRecognizedUpcoming()
if (room == null) {
return
}
const mousePos = [e.offsetX, e.offsetY]
if (currentTool == tools.PEN) {
pathIDsByPointerID.set(
e.pointerId,
room.addPath([
const currentTool = toolSelection.getTool()
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(
...mousePos,
canvas.getStrokeRadius(e.pressure),
canvas.getStrokeColour(),
]),
)
} else if (currentTool == tools.ERASER) {
erasePoint(mousePos)
toolSelection.getEraseRadius(),
room,
)
break
case toolSelection.Tools.DRAGGER:
startCanvasDragging(getScreenOffsets(e))
break
}
})
canvas.input.addEventListener("strokeend", ({ detail: e }) => {
pathIDsByPointerID.delete(e.pointerId)
const { pointerId } = e
const pressure = getNormalizedPressure(e)
const pathID = pathIDsByPointerID.get(pointerId)
if (room != null && pathID) {
const currentTool = toolSelection.getTool()
if (
currentTool === toolSelection.Tools.PEN &&
toolSelection.isRecognitionModeSet()
) {
drawRecognized(pathID, room.getPathPoints(pathID), pressure)
}
room.endPath(pathID)
}
pathIDsByPointerID.delete(pointerId)
clearRecognizedUpcoming()
stopCanvasDragging()
})
canvas.input.addEventListener("strokemove", ({ detail: e }) => {
if (room == null) {
return
}
const pressure = getNormalizedPressure(e)
const currentTool = toolSelection.getTool()
const mousePos = getOffsets(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)
}
break
}
case toolSelection.Tools.ERASER:
eraseEverythingAtPosition(
...mousePos,
toolSelection.getEraseRadius(),
room,
)
break
case toolSelection.Tools.DRAGGER:
dragCanvas(getScreenOffsets(e))
break
}
})
const mousePos = [e.offsetX, e.offsetY]
if (currentTool == tools.PEN) {
room.extendPath(pathIDsByPointerID.get(e.pointerId), [
...mousePos,
canvas.getStrokeRadius(e.pressure),
canvas.getStrokeColour(),
])
} else if (currentTool == tools.ERASER) {
erasePoint(mousePos)
window.addEventListener("unload", () => {
if (room) {
room.disconnect()
}
})
tryRoomConnect(TEST_ROOM)
// Add the recognition hint element in advance for consistency during tests.
clearRecognizedUpcoming()
tryRoomConnect(initialRoom)
import "array-flat-polyfill"
// Local canvas rendering.
// Emit input events and receive draw calls seperately - these must be piped
// together externally if desired.
import { line, curveBasis } from "d3-shape"
import { line, curveCatmullRom, curveLinear } from "d3-shape"
import { canvas } from "./elements.js"
const SVG_URL = "http://www.w3.org/2000/svg"
// TODO: switch to curve interpolation that respects mouse points based on velocity
const lineFn = line()
import * as HTML from "./elements.js"
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(curveBasis)
.curve(curve)
const straightLine = line()
.x((d) => d[0])
.y((d) => d[1])
.curve(curveLinear)
const pathGroupElems = new Map()
let strokeColour = "#0000ff"
let strokeRadius = 5
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[i - 1][4],
])
}
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]
......@@ -62,7 +46,6 @@ const smoothPath = ([...path]) => {
path[i - 1][1] + (dy / segmentsToSplit) * j,
path[i - 1][2] + (dw / segmentsToSplit) * j,
path[i - 1][3],
path[i - 1][4],
])
}
path.splice(i, 0, ...newPoints)
......@@ -73,76 +56,197 @@ const smoothPath = ([...path]) => {
export const input = new EventTarget()
const createPathElem = (d, width) => {
const pathGroupElem = document.createElementNS(SVG_URL, "path")
pathGroupElem.setAttribute("stroke-width", width)
pathGroupElem.setAttribute("d", d)
return pathGroupElem
const createSvgElem = (tagName) => document.createElementNS(SVG_URL, tagName)
const interpolate = (point0, point1, fraction) => [
point0[0] + fraction * (point1[0] - point0[0]),
point0[1] + fraction * (point1[1] - point0[1]),
point0[2] + fraction * (point1[2] - point0[2]),
point0[3],
]
const ensurePathGroupElem = (id) => {
let groupElem = pathGroupElems.get(id)
if (groupElem == null) {
groupElem = createSvgElem("g")
groupElem.setAttribute("fill", "none")
groupElem.setAttribute("stroke-linecap", "round")
groupElem.setAttribute("pointer-events", "none")
HTML.canvas.appendChild(groupElem)
pathGroupElems.set(id, groupElem)
}
groupElem.innerHTML = ""
return groupElem
}
export const renderPath = (id, points) => {
points = points.filter(([x]) => x != null)
let colour = ""
// Split up points into completely non-erased segments.
let segments = [[]]
for (const point of points) {
colour = point[3]
if (point[4] != false) {
segments[segments.length - 1].push(point)
} else {
segments.push([])
}
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",
pathSmooth ? smoothLine(subpath) : straightLine(subpath),
)
return pathElem
}
const isValidPoint = (point) => {
return point != null && point[0] != null
}
const POINT_ERASE_LIMIT = 0.0001
const pointWasErased = (pointEraseIntervals) => {
return (
pointEraseIntervals.length > 0 &&
pointEraseIntervals[0][0] <= POINT_ERASE_LIMIT
)
}
const needToDrawLastPoint = (points, pathEraseIntervals) => {
if (points.length < 2) return true
const penultimatePointIndex = points.length - 2
const pointEraseIntervals = pathEraseIntervals[penultimatePointIndex] || null
if (
pointEraseIntervals != null &&
pointEraseIntervals.length > 0 &&
pointEraseIntervals.some((interval) => interval[1] >= 1 - POINT_ERASE_LIMIT)
) {
return false
}
segments = segments.filter((a) => a.length > 0)
return true
}
let pathGroupElem = pathGroupElems.get(id)
const applyErasureIntervals = (points, pathEraseIntervals) => {
if (points.length == 0) {
return []
}
if (segments.length == 0) {
if (pathGroupElem != null) {
canvas.removeChild(pathGroupElem)
pathGroupElems.delete(id)
const subpaths = []
let subpath = []
for (let i = 0; i < points.length; i++) {
const point = points[i]
if (!isValidPoint(point)) {
continue
}
const nextPoint = points[i + 1]
const pointEraseIntervals = pathEraseIntervals[i] || null
if (pointEraseIntervals == null) {
subpath.push(point)
continue
} else if (nextPoint == null) {
if (JSON.stringify(pointEraseIntervals) != "[[0,0]]") subpath.push(point)
continue
}
if (!pointWasErased(pointEraseIntervals) || subpath.length) {
subpath.push(point)
}
for (const pointEraseInterval of pointEraseIntervals) {
const eraseIntervalBounds = pointEraseInterval.map((f) =>
interpolate(point, nextPoint, f),
)
const [endOfDrawnSegment, startOfNewSegment] = eraseIntervalBounds
if (pointEraseInterval[0] > POINT_ERASE_LIMIT) {
subpath.push(endOfDrawnSegment)
}
subpaths.push(subpath.slice())
if (pointEraseInterval[1] < 1 - POINT_ERASE_LIMIT) {
subpath = [startOfNewSegment]
} else {
subpath = []
}
}
return
}
if (pathGroupElem == null) {
pathGroupElem = document.createElementNS(SVG_URL, "g")
pathGroupElem.setAttribute("stroke", colour)
pathGroupElem.setAttribute("fill", "none")
pathGroupElem.setAttribute("stroke-linecap", "round")
pathGroupElem.setAttribute("pointer-events", "none")
canvas.appendChild(pathGroupElem)
pathGroupElems.set(id, pathGroupElem)
if (needToDrawLastPoint(points, pathEraseIntervals)) {
subpaths.push(subpath.slice())
}
pathGroupElem.innerHTML = ""
for (const subpath of segments) {
if (subpath.length == 1) {
const circleElem = document.createElementNS(SVG_URL, "circle")
circleElem.setAttribute("stroke", "none")
circleElem.setAttribute("fill", colour)
circleElem.setAttribute("cx", subpath[0][0])
circleElem.setAttribute("cy", subpath[0][1])
circleElem.setAttribute("r", subpath[0][2])
pathGroupElem.appendChild(circleElem)
} else {
// Further split up segments based on thickness.
const subpath_ = smoothPath(subpath)
let w = subpath_[0][2]
for (let i = 1; i < subpath_.length; i++) {
if (subpath_[i][2] != w) {
const d = lineFn([...subpath_.splice(0, i), subpath_[0]])
pathGroupElem.appendChild(createPathElem(d, w * 2))
w = subpath_[0][2]
i = 1
}
return subpaths.filter((subpath) => subpath.length > 0)
}
export const splitOnPressures = ([...path]) => {
const subpaths = []
let w = path[0][2]
for (let i = 1; i < path.length; i++) {
if (path[i][2] != w) {
subpaths.push([...path.splice(0, i), path[0]])
w = path[0][2]
i = 1
}
}
subpaths.push(path)
return subpaths
}
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
}
const d = lineFn(subpath_)
pathGroupElem.appendChild(createPathElem(d, w * 2))
}
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, pathSmooth),
)
const pathGroupElem = ensurePathGroupElem(id)
subpathElems.forEach((subpathElem) => pathGroupElem.appendChild(subpathElem))
}
export const clear = () => {
......@@ -151,12 +255,13 @@ export const clear = () => {
}
// Necessary since buttons property is non standard on iOS versions < 13.2
function checkValidPointerEvent(e) {
const isValidPointerEvent = (e, name) => {
if (name === "strokeend") return true
return e.buttons & 1 || e.pointerType === "touch"
}
const dispatchPointerEvent = (name) => (e) => {
if (checkValidPointerEvent(e)) {
if (isValidPointerEvent(e, name)) {
input.dispatchEvent(new CustomEvent(name, { detail: e }))
}
}
......@@ -165,41 +270,8 @@ const dispatchPointerEvent = (name) => (e) => {
canvas.addEventListener("pointerdown", dispatchPointerEvent("strokestart"))
canvas.addEventListener("pointerenter", dispatchPointerEvent("strokestart"))
canvas.addEventListener("pointerup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("onmouseup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("mouseup", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend"))
canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove"))
canvas.addEventListener("touchmove", (e) => e.preventDefault())
export function setStrokeColour(colour) {
strokeColour = colour
}
export function getStrokeColour() {
return strokeColour
}
const MIN_PRESSURE_FACTOR = 0.1
const MAX_PRESSURE_FACTOR = 1.5
// This is a quadratic such that:
// - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR
// - getPressureFactor(0.5) = 1.0
// - getPressureFactor(1.0) = MAX_PRESSURE_FACTOR
// For sensible results, maintain that:
// - 0.0 <= MIN_PRESSURE_FACTOR <= 1.0
// - 1.0 <= MAX_PRESSURE_FACTOR
// For intuitive results, maintain that:
// - MAX_PRESSURE_FACTOR <= ~2.0
const getPressureFactor = (pressure) => {
const a = 2 * (MAX_PRESSURE_FACTOR + MIN_PRESSURE_FACTOR) - 4
const b = -MAX_PRESSURE_FACTOR - 3 * MIN_PRESSURE_FACTOR + 4
const c = MIN_PRESSURE_FACTOR
return a * pressure ** 2 + b * pressure + c
}
export function getStrokeRadius(pressure) {
return strokeRadius * getPressureFactor(pressure)
}
export function setStrokeRadius(radius) {
strokeRadius = radius
}
export default class AbstractConnection extends EventTarget {
constructor(options) {
super()
this.options = options
}
/*
Supported events:
- roomJoined => ()
- roomLeft => ()
- channelOpened => ({detail: uid})
- channelError => ({detail: uid})
- channelClosed => ({detail: uid})
- messageReceived => ({detail: {uid, channel, message}})
*/
getUserID() {
// => int
}
getPeerHandle(/*uid*/) {
// => opaque
}
getPeerFootprint(/*uid*/) {
// => Promise => int
}
send(/*uid, channel, message*/) {
// => void
}
broadcast(/*channel, message*/) {
// => void
}
terminatePeer(/*uid*/) {
// => void
}
destructor() {
// => void
}
}
export const userID = { uuid: null }
export const sendListener = { callback: null }
export const broadcastListener = { callback: null }
const eventListeners = new Map()
export const getEventListener = (room, event) =>
eventListeners.get(`${room}:${event}`)
class MockConnection {
constructor({ room }) {
this.room = room
setTimeout(
() =>
getEventListener(room, "roomJoined") &&
getEventListener(room, "roomJoined")(),
0,
)
}
getUserID() {
return userID.uuid
}
getPeerHandle(/*uid*/) {
return undefined
}
getPeerFootprint(/*uid*/) {
return Promise.resolve(Date.now())
}
send(uid, channel, message) {
if (sendListener.callback) {
sendListener.callback(uid, channel, message)
}
}
broadcast(channel, message) {
if (broadcastListener.callback) {
broadcastListener.callback(channel, message)
}
}
terminatePeer() {
// Twiddle thumbs
}
destructor() {
sendListener.callback = null
broadcastListener.callback = null
eventListeners.clear()
}
addEventListener(event, callback) {
eventListeners.set(`${this.room}:${event}`, callback)
}
}
export default MockConnection
import AbstractConnection from "./Connection.js"
import LioWebRTC from "liowebrtc"
export default class WebRTCConnection extends AbstractConnection {
constructor(options) {
super(options)
this.webrtc = new LioWebRTC({
url: this.options.url,
dataOnly: true,
constraints: {
minPeers: this.options.mesh.minPeers,
maxPeers: this.options.mesh.maxPeers,
},
})
this.webrtc.on("ready", () => {
this.webrtc.joinRoom(this.options.room)
})
this.webrtc.on("joinedRoom", () => {
this.dispatchEvent(new CustomEvent("roomJoined"))
})
this.webrtc.on("leftRoom", () => {
this.dispatchEvent(new CustomEvent("roomLeft"))
})
this.webrtc.on("channelOpen", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelOpened", { detail: peer.id }))
})
this.webrtc.on("channelError", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelError", { detail: peer.id }))
})
this.webrtc.on("channelClose", (dataChannel, peer) => {
this.dispatchEvent(new CustomEvent("channelClosed", { detail: peer.id }))
})
this.webrtc.on("receivedPeerData", (channel, message, peer) => {
// Message could have been forwarded but interface only needs to know about directly connected peers
this.dispatchEvent(
new CustomEvent("messageReceived", {
detail: {
uid: peer.forwardedBy ? peer.forwardedBy.id : peer.id,
channel,
message,
},
}),
)
})
}
getUserID() {
return this.webrtc.getMyId()
}
getPeerHandle(uid) {
return this.webrtc.getPeerById(uid)
}
getPeerFootprint(uid) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return Promise.reject()
return new Promise(function(resolve, reject) {
peer.getStats(null).then((stats) => {
let footprint = -1
stats.forEach((report) => {
if (
report.type == "candidate-pair" &&
report.bytesSent > 0 &&
report.bytesReceived > 0 &&
report.writable
) {
footprint = Math.max(footprint, report.bytesReceived)
}
})
if (footprint != -1) {
resolve(footprint)
} else {
reject()
}
})
})
}
send(uid, channel, message) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return
this.webrtc.whisper(peer, channel, message)
}
broadcast(channel, message) {
this.webrtc.shout(channel, message)
}
terminatePeer(uid) {
const peer = this.webrtc.getPeerById(uid)
if (!peer) return
peer.end()
}
destructor() {
this.webrtc.quit()
}
}
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)
}
}
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")
......@@ -12,6 +8,12 @@ export const overallStatusIconImage = document.getElementById(
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")
......@@ -25,7 +27,9 @@ 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 picker = document.getElementById("other-colours")
export const slider = document.getElementById("range")
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")
}
"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,
})
}
}
......@@ -3,91 +3,117 @@
import MessagePack from "what-the-pack"
import pako from "pako"
import uuidv4 from "uuid/v4"
import FastBitSet from "fastbitset"
const MESSAGE_BUFFER_SIZE = 2 ** 24 // 16MB
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 = {}
self.onmessage = (event) => {
onmessage = (event) => {
if (!event || !event.data) {
return
}
if (event.data.method == "send" || event.data.method == "broadcast") {
let message = event.data.message
const compressed = typeof message == "object"
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)
}
for (
let offset = 0;
offset < message.length;
offset += MESSAGE_SLICE_SIZE
) {
event.data.message = {
const sender = (slice) => {
let offset = slice * MESSAGE_SLICE_SIZE
event.data.message = encode({
uuid,
message: message.subarray(offset, offset + MESSAGE_SLICE_SIZE),
slice: offset / MESSAGE_SLICE_SIZE,
length: Math.ceil(message.length / 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") {
let message = event.data.message.message
const packet = decode(MessagePack.Buffer.from(event.data.message))
let message = packet.message
if (event.data.message.length > 1) {
let messages = buffer[event.data.peer.id]
//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.peer.id] = messages
buffer[event.data.uid] = messages
}
let slices = messages[event.data.message.uuid]
let slices = messages[packet.uuid]
if (!slices) {
slices = []
messages[event.data.message.uuid] = slices
slices = {
message: new Uint8Array(packet.length),
received: new FastBitSet(),
length: 0,
}
messages[packet.uuid] = slices
}
slices.push(event.data.message)
if (slices.length < slices[slices.length - 1].length) {
return
// 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)
}
message = new Uint8Array(
slices.reduce((acc, s) => acc + s.message.length, 0),
)
if (slices.length < slices.message.length) {
delete packet.uuid
delete packet.message
slices.sort((a, b) => a.slice - b.slice)
let offset = 0
for (const slice of slices) {
message.set(slice.message, offset)
offset += slice.message.length
return
}
delete messages[event.data.message.uuid]
message = slices.message
delete messages[packet.uuid]
}
if (event.data.message.compressed) {
message = pako.inflate(message)
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
}
getUserID() {
return this.crdt.getUserID()
}
addPath([x, y, w, colour]) {
const id = uuidv4()
this._y.share.strokeAdd.set(id, Y.Array).push([[x, y, w, colour]])
return id
const pathID = this.crdt.addPath([x, y, w, colour])
this.undoStack.push([pathID, 0, 0])
this.dispatchEvent(new CustomEvent("undoEnabled"))
return pathID
}
extendPath(id, [x, y, w, colour]) {
this._y.share.strokeAdd.get(id).push([[x, y, w, colour]])
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])
if (pathLength == 2) {
this.undoStack[this.undoStack.length - 1] = [pathID, 0, 1]
} else {
this.undoStack.push([pathID, pathLength - 2, pathLength - 1])
}
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)
}
extendErasureIntervals(pathID, pointID, newIntervals) {
this.crdt.extendErasureIntervals(
pathID,
flattenErasureIntervals({ [pointID]: newIntervals }),
)
}
replacePath(pathID, newPoints) {
this.fastUndo(true)
newPoints.forEach((point) => this.extendPath(pathID, point))
this.undoStack.splice(this.undoStack.length - newPoints.length, 1)
}
getPaths() {
const paths = new Map()
for (const id of this._y.share.strokeAdd.keys()) {
paths.set(id, this._generatePath(id))
for (const pathID of this.crdt.getPathIDs()) {
paths.set(pathID, this.crdt.getPathPoints(pathID))
}
return paths
}
erasePoint(id, idx) {
let eraseSet = this._y.share.strokeErase.get(id)
getPathPoints(pathID) {
return this.crdt.getPathPoints(pathID)
}
if (!eraseSet) {
eraseSet = this._y.share.strokeErase.set(id, Y.Map)
}
getErasureIntervals(pathID) {
return spreadErasureIntervals(this.crdt.getErasureIntervals(pathID))
}
eraseSet.set(idx.toString(), true)
canUndo() {
return this.undoStack.length > 0
}
// Generate an array of points [x, y, exist] by merging the path's add and erase sets
_generatePath(id) {
const addSet = this._y.share.strokeAdd.get(id)
undo() {
const operation = this.undoStack.pop()
if (addSet === undefined) {
return []
}
if (!operation) return
const eraseSet = this._y.share.strokeErase.get(id) || { get: () => false }
const [pathID, ...interval] = operation
return addSet
.toArray()
.map((p = [], i) => [p[0], p[1], p[2], p[3], !eraseSet.get(i.toString())])
this.crdt.extendErasureIntervals(pathID, [interval])
}
inviteUser(id) {
this._y.connector.connectToPeer(id)
}
fastUndo(forReplacing = false) {
let from = this.undoStack.length - 1
async _initialise() {
this._y = await Y({
db: {
name: "memory",
},
connector: {
name: "webrtc",
url: "/",
room: this.name,
handshake: {
initial: 100,
interval: 500,
},
heartbeat: {
interval: 500,
minimum: 1000,
timeout: 10000,
},
onUserEvent: (event) => {
if (event.action == "userConnection") {
const { id, quality } = event
this.dispatchEvent(
new CustomEvent("userConnection", { detail: { id, quality } }),
)
} else if (event.action == "userID") {
const { user: id } = event
this.ownID = id
this.dispatchEvent(new CustomEvent("allocateOwnID", { detail: id }))
} else if (event.action == "userJoined") {
const { user: id } = event
this.dispatchEvent(new CustomEvent("userJoin", { detail: id }))
} else if (event.action == "userLeft") {
const { user: id } = event
this.dispatchEvent(new CustomEvent("userLeave", { detail: id }))
} else if (event.action === "peerSyncedWithUs") {
const { user: id } = event
this.dispatchEvent(
new CustomEvent("peerSyncedWithUs", { detail: id }),
)
} else if (event.action === "waitingForSyncStep") {
const { user: id } = event
this.dispatchEvent(
new CustomEvent("waitingForSyncStep", { detail: id }),
)
} else if (event.action === "weSyncedWithPeer") {
const { user: id } = event
this.dispatchEvent(
new CustomEvent("weSyncedWithPeer", { detail: id }),
)
}
},
},
share: {
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 }))
}
})
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,24 +7,7 @@ self.addEventListener("activate", (event) => {
})
const CACHE_NAME = "APP-V0"
const FILES_TO_CACHE = [
"/index.html",
"/styles.css",
"/js/app.js",
"/js/queue.js",
"/logo.png",
"/manifest.json",
"/assets/fonts/martel-v4-latin/martel-v4-latin-regular.eot",
"/assets/fonts/martel-v4-latin/martel-v4-latin-regular.svg",
"/assets/fonts/martel-v4-latin/martel-v4-latin-regular.ttf",
"/assets/fonts/martel-v4-latin/martel-v4-latin-regular.woff",
"/assets/fonts/martel-v4-latin/martel-v4-latin-regular.woff2",
"/synchronising.svg",
"/synchronised.svg",
"/quality-high.svg",
"/quality-medium.svg",
"/quality-low.svg",
]
const FILES_TO_CACHE = [FILES_TO_CACHE_LIST] // eslint-disable-line no-undef
const FILE_ALIASES = new Map([["/", "/index.html"]])
self.addEventListener("install", async () => {
......@@ -40,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