Server refactor, cursor update broadcast, history compaction

This commit is contained in:
Zef Hemel
2022-03-10 14:09:59 +01:00
parent 7ae3496749
commit 5c5e232034
14 changed files with 577 additions and 423 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ import { HttpRemoteSpace } from "./space";
import { safeRun } from "./util";
import { io } from "socket.io-client";
let socket = io("http://localhost:3000");
let socket = io(`http://${location.hostname}:3000`);
let editor = new Editor(
new HttpRemoteSpace(`http://${location.hostname}:3000/fs`, socket),
+118 -72
View File
@@ -10,6 +10,7 @@ import {
receiveUpdates,
sendableUpdates,
} from "@codemirror/collab";
import { RangeSetBuilder, Range } from "@codemirror/rangeset";
import { EditorState, StateEffect, StateField, Text } from "@codemirror/state";
import {
Decoration,
@@ -19,81 +20,46 @@ import {
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { cursorEffect } from "./cursorEffect";
import { Cursor, cursorEffect } from "./cursorEffect";
import { HttpRemoteSpace } from "./space";
const throttleInterval = 250;
const throttle = (func: () => void, limit: number) => {
let timer: any = null;
return function () {
if (!timer) {
timer = setTimeout(() => {
func();
timer = null;
}, limit);
}
};
};
//@ts-ignore
window.throttle = throttle;
export class Document {
text: Text;
version: number;
cursors: Map<string, Cursor>;
constructor(text: Text, version: number) {
constructor(text: Text, version: number, cursors: Map<string, Cursor>) {
this.text = text;
this.version = version;
this.cursors = cursors;
}
}
let meId = "";
const cursorField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(cursors, tr) {
cursors = cursors.map(tr.changes);
for (let e of tr.effects) {
if (e.is(cursorEffect)) {
const newCursorDecoration = Decoration.widget({
widget: new CursorWidget(e.value.userId, e.value.color, e.value.pos),
side: 1,
});
cursors = cursors.update({
filter: (from, to, d) => !d.eq(newCursorDecoration),
// add: [newCursorDecoration.range(e.value.pos)],
sort: true,
});
}
}
// console.log("New cursors", cursors.size);
return cursors;
},
provide: (f) => EditorView.decorations.from(f),
fromJSON(cursorJSONs) {
let cursors = [];
for (let cursorJSON of cursorJSONs) {
cursors.push(
Decoration.widget({
widget: new CursorWidget(
cursorJSON.userId,
cursorJSON.color,
cursorJSON.pos
),
side: 1,
}).range(cursorJSON.pos)
);
}
return Decoration.set(cursors);
},
toJSON(cursors) {
let cursor = cursors.iter();
let results = [];
while (cursor.value) {
results.push({ ...cursor.value.spec.widget });
cursor.next();
}
return results;
},
});
class CursorWidget extends WidgetType {
userId: string;
color: string;
pos: number;
constructor(userId: string, color: string, pos: number) {
constructor(userId: string, color: string) {
super();
this.userId = userId;
this.color = color;
this.pos = pos;
}
eq(other: CursorWidget) {
@@ -104,9 +70,13 @@ class CursorWidget extends WidgetType {
let el = document.createElement("span");
el.className = "other-cursor";
el.style.backgroundColor = this.color;
if (this.userId == meId) {
el.style.display = "none";
}
// let nameSpanContainer = document.createElement("span");
// nameSpanContainer.className = "cursor-label-container";
// let nameSpanLabel = document.createElement("label");
// nameSpanLabel.className = "cursor-label";
// nameSpanLabel.textContent = this.userId;
// nameSpanContainer.appendChild(nameSpanLabel);
// el.appendChild(nameSpanContainer);
return el;
}
}
@@ -114,28 +84,71 @@ class CursorWidget extends WidgetType {
export function collabExtension(
pageName: string,
clientID: string,
startVersion: number,
doc: Document,
space: HttpRemoteSpace,
reloadCallback: () => void
) {
meId = clientID;
let plugin = ViewPlugin.fromClass(
class {
private pushing = false;
private done = false;
private failedPushes = 0;
decorations: DecorationSet;
private cursorPositions: Map<string, Cursor> = doc.cursors;
throttledPush: () => void;
buildDecorations(view: EditorView) {
let builder = new RangeSetBuilder<Decoration>();
let list = [];
for (let [userId, def] of this.cursorPositions) {
if (userId == clientID) {
continue;
}
list.push({
pos: def.pos,
widget: Decoration.widget({
widget: new CursorWidget(userId, def.color),
side: 1,
}),
});
}
list
.sort((a, b) => a.pos - b.pos)
.forEach((r) => {
builder.add(r.pos, r.pos, r.widget);
});
return builder.finish();
}
constructor(private view: EditorView) {
if (pageName) {
this.pull();
}
this.decorations = this.buildDecorations(view);
this.throttledPush = throttle(() => this.push(), throttleInterval);
console.log("Created collabo plug");
space.addEventListener("cursors", this.updateCursors);
}
updateCursors(cursorEvent: any) {
this.cursorPositions = new Map();
console.log("Received new cursor snapshot", cursorEvent.detail, this);
for (let userId in cursorEvent.detail) {
this.cursorPositions.set(userId, cursorEvent.detail[userId]);
}
}
update(update: ViewUpdate) {
if (update.selectionSet) {
let pos = update.state.selection.main.head;
console.log("New pos", pos);
// return;
// if (pos === 0) {
// console.error("Warning: position reset? at 0");
// console.trace();
// }
setTimeout(() => {
update.view.dispatch({
effects: [
@@ -144,17 +157,32 @@ export function collabExtension(
});
});
}
let foundEffect = false;
let foundCursorMoves = new Set<string>();
for (let tx of update.transactions) {
if (tx.effects.some((e) => e.is(cursorEffect))) {
foundEffect = true;
let cursorMove = tx.effects.find((e) => e.is(cursorEffect));
if (cursorMove) {
foundCursorMoves.add(cursorMove.value.userId);
}
}
if (update.docChanged || foundEffect) this.push();
// Update cursors
for (let cursor of this.cursorPositions.values()) {
if (foundCursorMoves.has(cursor.userId)) {
// Already got a cursor update for this one, no need to manually map
continue;
}
update.transactions.forEach((tx) => {
cursor.pos = tx.changes.mapPos(cursor.pos);
});
}
this.decorations = this.buildDecorations(update.view);
if (update.docChanged || foundCursorMoves.size > 0) {
this.throttledPush();
}
}
async push() {
let updates = sendableUpdates(this.view.state);
// TODO: compose multiple updates into one
if (this.pushing || !updates.length) return;
console.log("Updates", updates);
this.pushing = true;
@@ -178,7 +206,8 @@ export function collabExtension(
// Regardless of whether the push failed or new updates came in
// while it was running, try again if there's updates remaining
if (sendableUpdates(this.view.state).length) {
setTimeout(() => this.push(), 100);
// setTimeout(() => this.push(), 100);
this.throttledPush();
}
}
@@ -187,26 +216,43 @@ export function collabExtension(
let version = getSyncedVersion(this.view.state);
let updates = await space.pullUpdates(pageName, version);
let d = receiveUpdates(this.view.state, updates);
console.log("Received", d);
// Pull out cursor updates and update local state
for (let update of updates) {
if (update.effects) {
for (let effect of update.effects) {
if (effect.is(cursorEffect)) {
this.cursorPositions.set(effect.value.userId, {
userId: effect.value.userId,
pos: effect.value.pos,
color: effect.value.color,
});
}
}
}
}
this.view.dispatch(d);
}
}
destroy() {
this.done = true;
space.removeEventListener("cursors", this.updateCursors);
}
},
{
decorations: (v) => v.decorations,
}
);
return [
collab({
startVersion,
startVersion: doc.version,
clientID,
sharedEffects: (tr) => {
return tr.effects.filter((e) => e.is(cursorEffect));
},
}),
cursorField,
// cursorField,
plugin,
];
}
+4 -3
View File
@@ -1,10 +1,11 @@
import { StateEffect } from "@codemirror/state";
export const cursorEffect = StateEffect.define<{
export type Cursor = {
pos: number;
userId: string;
color: string;
}>({
};
export const cursorEffect = StateEffect.define<Cursor>({
map({ pos, userId, color }, changes) {
return { pos: changes.mapPos(pos), userId, color };
},
+9 -2
View File
@@ -65,6 +65,7 @@ import { collabExtension } from "./collab";
import { Document } from "./collab";
import { EditorSelection } from "@codemirror/state";
import { Cursor } from "./cursorEffect";
class PageState {
scrollTop: number;
@@ -100,7 +101,10 @@ export class Editor implements AppEventDispatcher {
this.viewDispatch = () => {};
this.render(parent);
this.editorView = new EditorView({
state: this.createEditorState("", new Document(Text.of([""]), 0)),
state: this.createEditorState(
"",
new Document(Text.of([""]), 0, new Map<string, Cursor>())
),
parent: document.getElementById("editor")!,
});
this.pageNavigator = new PathPageNavigator();
@@ -238,7 +242,7 @@ export class Editor implements AppEventDispatcher {
collabExtension(
pageName,
this.space.socket.id,
doc.version,
doc,
this.space,
this.reloadPage.bind(this)
),
@@ -435,6 +439,9 @@ export class Editor implements AppEventDispatcher {
if (!pageState) {
pageState = new PageState(0, editorState.selection);
this.openPages.set(pageName, pageState!);
editorView.dispatch({
selection: { anchor: 0 },
});
} else {
// Restore state
console.log("Restoring selection state");
+1 -1
View File
@@ -134,7 +134,7 @@ const TagLink: MarkdownConfig = {
const WikiMarkdown = commonmark.configure([
WikiLink,
AtMention,
TagLink,
// TagLink,
TaskList,
UnmarkedUrl,
Comment,
+11 -4
View File
@@ -4,7 +4,7 @@ import { Update } from "@codemirror/collab";
import { Transaction, Text, ChangeSet } from "@codemirror/state";
import { Document } from "./collab";
import { cursorEffect } from "./cursorEffect";
import { Cursor, cursorEffect } from "./cursorEffect";
export interface Space {
listPages(): Promise<PageMeta[]>;
@@ -32,6 +32,10 @@ export class HttpRemoteSpace extends EventTarget implements Space {
socket.on("reload", (pageName: string) => {
this.dispatchEvent(new CustomEvent("reload", { detail: pageName }));
});
socket.on("cursors", (cursors) => {
this.dispatchEvent(new CustomEvent("cursors", { detail: cursors }));
});
}
private wsCall(eventName: string, ...args: any[]): Promise<any> {
@@ -68,7 +72,6 @@ export class HttpRemoteSpace extends EventTarget implements Space {
effects: u.effects?.map((e) => cursorEffect.of(e.value)),
clientID: u.clientID,
}));
console.log("Got updates", ups);
return ups;
}
@@ -85,8 +88,12 @@ export class HttpRemoteSpace extends EventTarget implements Space {
async openPage(name: string): Promise<Document> {
this.reqId++;
let [version, text] = await this.wsCall("openPage", name);
return new Document(Text.of(text), version);
let pageJSON = await this.wsCall("openPage", name);
let cursors = new Map<string, Cursor>();
for (let p in pageJSON.cursors) {
cursors.set(p, pageJSON.cursors[p]);
}
return new Document(Text.of(pageJSON.text), pageJSON.version, cursors);
}
async closePage(name: string): Promise<void> {
+25 -2
View File
@@ -11,11 +11,34 @@
.other-cursor {
display: inline-block;
width: 1px;
margin-right: -1px;
width: 2px;
margin-right: -2px;
height: 1em;
}
.cursor-label-container {
// display: none;
position: relative;
top: 2ch;
float: left;
width: 120px;
height: 2.2ch;
margin: 0;
padding: 0;
overflow: hidden;
font-family: Arial, Helvetica, sans-serif;
color: #fff;
border: gray 1px solid;
background-color: purple;
// font-size: 0.5em;
}
.cursor-label-container label {
margin: 0;
padding: 0;
font-size: 0.7em;
}
.cm-selectionBackground {
background-color: #d7e1f6 !important;
}