|
@@ -1,6 +1,10 @@
|
|
|
+import { OpSeq } from "rustpad-wasm";
|
|
|
+import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
|
|
|
+
|
|
|
/** Options passed in to the Rustpad constructor. */
|
|
|
-type RustpadOptions = {
|
|
|
+export type RustpadOptions = {
|
|
|
readonly uri: string;
|
|
|
+ readonly editor: editor.IStandaloneCodeEditor;
|
|
|
readonly onConnected?: () => unknown;
|
|
|
readonly onDisconnected?: () => unknown;
|
|
|
readonly reconnectInterval?: number;
|
|
@@ -10,9 +14,25 @@ type RustpadOptions = {
|
|
|
class Rustpad {
|
|
|
private ws?: WebSocket;
|
|
|
private connecting?: boolean;
|
|
|
+ private readonly model: editor.ITextModel;
|
|
|
+ private readonly onChangeHandle: any;
|
|
|
private readonly intervalId: number;
|
|
|
|
|
|
+ // Client-server state
|
|
|
+ private me: number = -1;
|
|
|
+ private revision: number = 0;
|
|
|
+ private outstanding?: OpSeq;
|
|
|
+ private buffer?: OpSeq;
|
|
|
+
|
|
|
+ // Intermittent local editor state
|
|
|
+ private lastValue: string = "";
|
|
|
+ private ignoreChanges: boolean = false;
|
|
|
+
|
|
|
constructor(readonly options: RustpadOptions) {
|
|
|
+ this.model = options.editor.getModel()!;
|
|
|
+ this.onChangeHandle = options.editor.onDidChangeModelContent((e) =>
|
|
|
+ this.onChange(e)
|
|
|
+ );
|
|
|
this.tryConnect();
|
|
|
this.intervalId = window.setInterval(
|
|
|
() => this.tryConnect(),
|
|
@@ -23,7 +43,8 @@ class Rustpad {
|
|
|
/** Destroy this Rustpad instance and close any sockets. */
|
|
|
dispose() {
|
|
|
window.clearInterval(this.intervalId);
|
|
|
- if (this.ws) this.ws.close();
|
|
|
+ this.onChangeHandle.dispose();
|
|
|
+ this.ws?.close();
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -45,6 +66,9 @@ class Rustpad {
|
|
|
this.connecting = false;
|
|
|
this.ws = ws;
|
|
|
this.options.onConnected?.();
|
|
|
+ if (this.outstanding) {
|
|
|
+ this.sendOperation(this.outstanding);
|
|
|
+ }
|
|
|
};
|
|
|
ws.onclose = () => {
|
|
|
if (this.ws) {
|
|
@@ -54,7 +78,174 @@ class Rustpad {
|
|
|
this.connecting = false;
|
|
|
}
|
|
|
};
|
|
|
+ ws.onmessage = ({ data }) => {
|
|
|
+ if (typeof data === "string") {
|
|
|
+ this.handleMessage(JSON.parse(data));
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ private handleMessage(msg: ServerMsg) {
|
|
|
+ if (msg.Identity !== undefined) {
|
|
|
+ this.me = msg.Identity;
|
|
|
+ } else if (msg.History !== undefined) {
|
|
|
+ const { start, operations } = msg.History;
|
|
|
+ if (start > this.revision) {
|
|
|
+ console.warn("History message has start greater than last operation.");
|
|
|
+ this.ws?.close();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ for (let i = this.revision - start; i < operations.length; i++) {
|
|
|
+ let { id, operation } = operations[i];
|
|
|
+ if (id === this.me) {
|
|
|
+ this.serverAck();
|
|
|
+ } else {
|
|
|
+ operation = OpSeq.from_str(JSON.stringify(operation));
|
|
|
+ this.applyServer(operation);
|
|
|
+ }
|
|
|
+ this.revision++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private serverAck() {
|
|
|
+ if (!this.outstanding) {
|
|
|
+ console.warn("Received serverAck with no outstanding operation.");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.outstanding = this.buffer;
|
|
|
+ this.buffer = undefined;
|
|
|
+ if (this.outstanding) {
|
|
|
+ this.sendOperation(this.outstanding);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private applyServer(operation: OpSeq) {
|
|
|
+ if (this.outstanding) {
|
|
|
+ const pair = this.outstanding.transform(operation)!;
|
|
|
+ this.outstanding = pair.first();
|
|
|
+ operation = pair.second();
|
|
|
+ if (this.buffer) {
|
|
|
+ const pair = this.buffer.transform(operation)!;
|
|
|
+ this.buffer = pair.first();
|
|
|
+ operation = pair.second();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.applyOperation(operation);
|
|
|
+ }
|
|
|
+
|
|
|
+ private applyClient(operation: OpSeq) {
|
|
|
+ if (!this.outstanding) {
|
|
|
+ this.sendOperation(operation);
|
|
|
+ this.outstanding = operation;
|
|
|
+ } else if (!this.buffer) {
|
|
|
+ this.buffer = operation;
|
|
|
+ } else {
|
|
|
+ this.buffer = this.buffer.compose(operation);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private sendOperation(operation: OpSeq) {
|
|
|
+ const op = operation.to_string();
|
|
|
+ this.ws?.send(`{"Edit":{"revision":${this.revision},"operation":${op}}}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ // The following functions are based on Firepad's monaco-adapter.js
|
|
|
+
|
|
|
+ private applyOperation(operation: OpSeq) {
|
|
|
+ if (operation.is_noop()) return;
|
|
|
+
|
|
|
+ this.ignoreChanges = true;
|
|
|
+ const ops: (string | number)[] = JSON.parse(operation.to_string());
|
|
|
+ let index = 0;
|
|
|
+
|
|
|
+ for (const op of ops) {
|
|
|
+ if (typeof op === "string") {
|
|
|
+ // Insert
|
|
|
+ const pos = this.model.getPositionAt(index);
|
|
|
+ this.model.pushEditOperations(
|
|
|
+ this.options.editor.getSelections(),
|
|
|
+ [
|
|
|
+ {
|
|
|
+ range: {
|
|
|
+ startLineNumber: pos.lineNumber,
|
|
|
+ startColumn: pos.column,
|
|
|
+ endLineNumber: pos.lineNumber,
|
|
|
+ endColumn: pos.column,
|
|
|
+ },
|
|
|
+ text: op,
|
|
|
+ forceMoveMarkers: true,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ () => null
|
|
|
+ );
|
|
|
+ } else if (op >= 0) {
|
|
|
+ // Retain
|
|
|
+ index += op;
|
|
|
+ } else {
|
|
|
+ // Delete
|
|
|
+ const chars = -op;
|
|
|
+ var from = this.model.getPositionAt(index);
|
|
|
+ var to = this.model.getPositionAt(index + chars);
|
|
|
+ this.model.pushEditOperations(
|
|
|
+ this.options.editor.getSelections(),
|
|
|
+ [
|
|
|
+ {
|
|
|
+ range: {
|
|
|
+ startLineNumber: from.lineNumber,
|
|
|
+ startColumn: from.column,
|
|
|
+ endLineNumber: to.lineNumber,
|
|
|
+ endColumn: to.column,
|
|
|
+ },
|
|
|
+ text: "",
|
|
|
+ forceMoveMarkers: true,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ () => null
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ this.lastValue = this.model.getValue();
|
|
|
+ this.ignoreChanges = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ private onChange(event: editor.IModelContentChangedEvent) {
|
|
|
+ if (!this.ignoreChanges) {
|
|
|
+ const content = this.lastValue;
|
|
|
+ let offset = 0;
|
|
|
+
|
|
|
+ let operation = OpSeq.new();
|
|
|
+ operation.retain(content.length);
|
|
|
+ event.changes.sort((a, b) => b.rangeOffset - a.rangeOffset);
|
|
|
+ for (const change of event.changes) {
|
|
|
+ const { text, rangeOffset, rangeLength } = change;
|
|
|
+ const restLength = content.length + offset - rangeOffset - rangeLength;
|
|
|
+ const changeOp = OpSeq.new();
|
|
|
+ changeOp.retain(rangeOffset);
|
|
|
+ changeOp.delete(rangeLength);
|
|
|
+ changeOp.insert(text);
|
|
|
+ changeOp.retain(restLength);
|
|
|
+ operation = operation.compose(changeOp)!;
|
|
|
+ offset += changeOp.target_len() - changeOp.base_len();
|
|
|
+ }
|
|
|
+ this.applyClient(operation);
|
|
|
+ this.lastValue = this.model.getValue();
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+type UserOperation = {
|
|
|
+ id: number;
|
|
|
+ operation: any;
|
|
|
+};
|
|
|
+
|
|
|
+type ServerMsg = {
|
|
|
+ Identity?: number;
|
|
|
+ History?: {
|
|
|
+ start: number;
|
|
|
+ operations: UserOperation[];
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
export default Rustpad;
|