Server refactor, cursor update broadcast, history compaction
This commit is contained in:
+1
-1
@@ -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
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -134,7 +134,7 @@ const TagLink: MarkdownConfig = {
|
||||
const WikiMarkdown = commonmark.configure([
|
||||
WikiLink,
|
||||
AtMention,
|
||||
TagLink,
|
||||
// TagLink,
|
||||
TaskList,
|
||||
UnmarkedUrl,
|
||||
Comment,
|
||||
|
||||
+11
-4
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user