Skip to content

Commit 965e89d

Browse files
committed
very basic collaborative editing
1 parent 4288098 commit 965e89d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+5394
-412
lines changed

assets/.babelrc

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"presets": [
3-
"@babel/preset-env"
3+
"@babel/preset-env",
4+
"@babel/preset-react"
45
]
56
}

assets/js/app.js

+24
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,27 @@ import "../css/app.scss"
1313
// import socket from "./socket"
1414
//
1515
import "phoenix_html"
16+
17+
import renderCollaborator from "./collaborator"
18+
19+
window.onerror = function (msg, url, lineNo, columnNo, error) {
20+
fetch("/api/reporterror", {
21+
method: "POST",
22+
headers: {
23+
'Accept': 'application/json',
24+
'Content-Type': 'application/json',
25+
},
26+
body: JSON.stringify({
27+
name: error.name,
28+
message: error.message,
29+
stack: error.stack,
30+
}),
31+
})
32+
33+
return false;
34+
}
35+
36+
const collaborator = document.getElementById("collaborator");
37+
if (collaborator) {
38+
renderCollaborator(collaborator);
39+
}

assets/js/char.ts

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import * as Decimal from "./decimal";
2+
import * as Identifier from "./identifier";
3+
4+
import { cons, head, rest } from "./utils";
5+
6+
export interface t {
7+
position: Identifier.t[];
8+
lamport: number;
9+
value: string;
10+
}
11+
12+
export type Serial = [Array<[number, number]>, number, string];
13+
14+
export function create(position: Identifier.t[], lamport: number, value: string) {
15+
const obj = { position, lamport, value };
16+
Object.freeze(obj);
17+
return obj;
18+
}
19+
20+
export function startOfFile(): t {
21+
// Note that the digit is 1, not 0. We don't want the min to be 0
22+
// because we don't want the last digit to be 0, since fractions
23+
// would have multiple representations (e.g. 0.1 === 0.10) which
24+
// would be bad.
25+
return create([Identifier.create(1, 0)], 0, "^");
26+
}
27+
28+
export function endOfFile(): t {
29+
return create([Identifier.create(255, 0)], 0, "$");
30+
}
31+
32+
export function ofArray(array: Serial): t {
33+
return create(array[0].map(Identifier.ofArray), array[1], array[2]);
34+
}
35+
36+
export function toArray(obj: t): Serial {
37+
const position = obj.position.map(Identifier.toArray);
38+
return [position, obj.lamport, obj.value];
39+
}
40+
41+
export function comparePosition(p1: Identifier.t[], p2: Identifier.t[]): number {
42+
for (let i = 0; i < Math.min(p1.length, p2.length); i++) {
43+
const comp = Identifier.compare(p1[i], p2[i]);
44+
if (comp !== 0) {
45+
return comp;
46+
}
47+
}
48+
if (p1.length < p2.length) {
49+
return - 1;
50+
} else if (p1.length > p2.length) {
51+
return 1;
52+
} else {
53+
return 0;
54+
}
55+
}
56+
57+
export function compare(c1: t, c2: t): number {
58+
return comparePosition(c1.position, c2.position);
59+
}
60+
61+
// Generate a position between p1 and p2. The generated position will be heavily
62+
// biased to lean towards the left since character insertions tend to happen on
63+
// the right side.
64+
export function generatePositionBetween(position1: Identifier.t[],
65+
position2: Identifier.t[],
66+
site: number): Identifier.t[] {
67+
// Get either the head of the position, or fallback to default value
68+
const head1 = head(position1) || Identifier.create(0, site);
69+
console.log("created head1")
70+
const head2 = head(position2) || Identifier.create(Decimal.BASE, site);
71+
console.log("created head2")
72+
73+
if (head1.digit !== head2.digit) {
74+
// Case 1: Head digits are different
75+
// It's easy to create a position to insert in-between by doing regular arithmetics.
76+
const n1 = Decimal.fromIdentifierList(position1);
77+
console.log("created n1")
78+
const n2 = Decimal.fromIdentifierList(position2);
79+
console.log("created n2")
80+
const delta = Decimal.subtractGreaterThan(n2, n1);
81+
console.log("created delta")
82+
83+
// Increment n1 by some amount less than delta
84+
const next = Decimal.increment(n1, delta);
85+
console.log('incremented')
86+
return Decimal.toIdentifierList(next, position1, position2, site);
87+
} else {
88+
if (head1.site < head2.site) {
89+
// Case 2: Head digits are the same, sites are different
90+
// Since the site acts as a tie breaker, it will always be the case that
91+
// cons(head1, anything) < position2
92+
return cons(head1, generatePositionBetween(rest(position1), [], site));
93+
} else if (head1.site === head2.site) {
94+
// Case 3: Head digits and sites are the same
95+
// Need to recurse on the next digits
96+
return cons(head1, generatePositionBetween(rest(position1), rest(position2), site));
97+
} else {
98+
throw new Error("invalid site ordering");
99+
}
100+
}
101+
}
102+
103+
export function equals(c1: t, c2: t): boolean {
104+
if (c1.position.length !== c2.position.length) return false;
105+
if (c1.lamport !== c2.lamport) return false;
106+
if (c1.value !== c2.value) return false;
107+
for (let i = 0; i < c1.position.length; i++) {
108+
if (!Identifier.equals(c1.position[i], c2.position[i])) return false;
109+
}
110+
return true;
111+
}

assets/js/collaborator.tsx

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as React from "react"
2+
import * as ReactDOM from "react-dom"
3+
4+
import Editor from "./editor"
5+
import { EditorSocket, UserPresence } from "./editor_socket"
6+
7+
// Yes, React is overkill right now
8+
class Collaborator extends React.Component<any, any> {
9+
editor: Editor
10+
11+
state = {
12+
presences: [],
13+
exceededLimit: false,
14+
disconnected: false
15+
};
16+
17+
presenceCallback = (presences: UserPresence[]) => {
18+
this.setState({ presences: [...presences] });
19+
this.editor.updateCursors(presences);
20+
}
21+
22+
disconnectCallback = () => {
23+
this.setState({ disconnected: true })
24+
}
25+
26+
limitCallback = (exceeded: boolean) => {
27+
this.setState({ exceededLimit: exceeded });
28+
}
29+
30+
componentDidMount() {
31+
const url = window.location.pathname;
32+
const documentId = url.substring(url.lastIndexOf('/') + 1);
33+
const editorSocket = new EditorSocket(
34+
documentId, this.presenceCallback, this.disconnectCallback
35+
);
36+
const textarea = ReactDOM.findDOMNode(this).getElementsByTagName("textarea").item(0);
37+
this.editor = new Editor(textarea, editorSocket, this.limitCallback);
38+
}
39+
40+
render() {
41+
const users: Map<number, UserPresence> = new Map();
42+
this.state.presences.forEach((presence: UserPresence) => {
43+
users.set(presence.userId, presence);
44+
});
45+
const indicators = Array.from(users).map(([userId, presence]: [number, UserPresence]) =>
46+
<div key={userId} className="user">
47+
<div className="circle" style={{background: presence.color}}></div>
48+
<div className="username">{ presence.username }</div>
49+
</div>
50+
);
51+
return (
52+
<div className="page">
53+
<header className="header">
54+
<div className="nav-left indicators">
55+
{ indicators }
56+
</div>
57+
<div className="nav-right">
58+
<a href="/">back to main</a>
59+
</div>
60+
</header>
61+
<div className="container">
62+
{ this.state.disconnected &&
63+
<div className="warning">
64+
<p>Disconnected due to connection error - please refresh</p>
65+
</div>
66+
}
67+
{ this.state.exceededLimit &&
68+
<div className="warning">
69+
<p>Operation cancelled: Exceeded the 2500 character limit of this document</p>
70+
<p>I know you'd like to stress test this application but my server is pretty small! Please run tests on your own machine instead and let me know what you find!</p>
71+
</div>
72+
}
73+
<div className="code-container">
74+
<textarea />
75+
</div>
76+
</div>
77+
</div>
78+
);
79+
}
80+
}
81+
82+
export default function renderCollaborator(domNode) {
83+
ReactDOM.render(
84+
<Collaborator/>,
85+
domNode
86+
);
87+
}

assets/js/crdt.ts

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import CodeMirror from "codemirror";
2+
3+
import { AATree } from "augmented-aa-tree";
4+
5+
import * as Char from "./char";
6+
7+
export type RemoteChange = ["add" | "remove", Char.t];
8+
9+
export namespace RemoteChange {
10+
export function add(char: Char.t): RemoteChange {
11+
return ["add", char];
12+
}
13+
export function remove(char: Char.t): RemoteChange {
14+
return ["remove", char];
15+
}
16+
}
17+
18+
export interface LocalChange {
19+
from: CodeMirror.Position;
20+
to: CodeMirror.Position;
21+
text: string;
22+
}
23+
24+
export namespace LocalChange {
25+
export function create(from: CodeMirror.Position, to: CodeMirror.Position, text: string): LocalChange {
26+
const obj = { from, to, text };
27+
Object.freeze(obj);
28+
return obj;
29+
}
30+
}
31+
32+
export interface Crdt {
33+
init(init: Char.Serial[]);
34+
toString(): string;
35+
remoteInsert(char: Char.t): LocalChange | null;
36+
remoteDelete(char: Char.t): LocalChange | null;
37+
38+
// Returns inserted characters
39+
localInsert(lamport: number, site: number, change: LocalChange): Char.t[];
40+
41+
// Returns deleted characters
42+
localDelete(change: LocalChange): Char.t[];
43+
}
44+
45+
export function updateAndConvertLocalToRemote(crdt: Crdt,
46+
lamport: number,
47+
site: number,
48+
change: CodeMirror.EditorChange): RemoteChange[] {
49+
if (change.from.line > change.to.line ||
50+
(change.from.line === change.to.line && change.from.ch > change.to.ch)) {
51+
throw new Error("got inverted inverted from/to");
52+
}
53+
54+
switch (change.origin) {
55+
case "+delete":
56+
const deleteChange = LocalChange.create(change.from, change.to, "");
57+
return crdt.localDelete(deleteChange).map(RemoteChange.remove);
58+
case "+input":
59+
case "paste":
60+
// Pure insertions have change.removed = [""]
61+
let removeChanges: RemoteChange[] = [];
62+
if (!(change.removed.length === 1 && change.removed[0] === "")) {
63+
const deletion = LocalChange.create(change.from, change.to, "");
64+
removeChanges = crdt.localDelete(deletion).map(RemoteChange.remove);
65+
}
66+
// All strings expect the last one represent the insertion of a new line
67+
const insert = LocalChange.create(change.from, change.to, change.text.join("\n"));
68+
const insertChanges = crdt.localInsert(lamport, site, insert).map(RemoteChange.add);
69+
return removeChanges.concat(insertChanges);
70+
default:
71+
throw new Error("Unknown change origin " + change.origin);
72+
}
73+
}
74+
75+
export function updateAndConvertRemoteToLocal(crdt: Crdt, change: RemoteChange): LocalChange | null {
76+
const char = change[1];
77+
switch (change[0]) {
78+
case "add":
79+
return crdt.remoteInsert(char);
80+
case "remove":
81+
return crdt.remoteDelete(char);
82+
default: throw new Error("unknown remote change");
83+
// default: const _exhaustiveCheck: never = "never";
84+
}
85+
}

0 commit comments

Comments
 (0)