rustpad/src/rustpad.ts

508 lines
15 KiB
TypeScript

import { OpSeq } from "rustpad-wasm";
import type {
editor,
IDisposable,
IPosition,
} from "monaco-editor/esm/vs/editor/editor.api";
import debounce from "lodash.debounce";
/** Options passed in to the Rustpad constructor. */
export type RustpadOptions = {
readonly uri: string;
readonly editor: editor.IStandaloneCodeEditor;
readonly onConnected?: () => unknown;
readonly onDisconnected?: () => unknown;
readonly onDesynchronized?: () => unknown;
readonly onChangeLanguage?: (language: string) => unknown;
readonly onChangeUsers?: (users: Record<number, UserInfo>) => unknown;
readonly reconnectInterval?: number;
};
/** A user currently editing the document. */
export type UserInfo = {
readonly name: string;
readonly hue: number;
};
/** Browser client for Rustpad. */
class Rustpad {
private ws?: WebSocket;
private connecting?: boolean;
private recentFailures: number = 0;
private readonly model: editor.ITextModel;
private readonly onChangeHandle: IDisposable;
private readonly onCursorHandle: IDisposable;
private readonly onSelectionHandle: IDisposable;
private readonly beforeUnload: (event: BeforeUnloadEvent) => void;
private readonly tryConnectId: number;
private readonly resetFailuresId: number;
// Client-server state
private me: number = -1;
private revision: number = 0;
private outstanding?: OpSeq;
private buffer?: OpSeq;
private users: Record<number, UserInfo> = {};
private userCursors: Record<number, CursorData> = {};
private myInfo?: UserInfo;
private cursorData: CursorData = { cursors: [], selections: [] };
// Intermittent local editor state
private lastValue: string = "";
private ignoreChanges: boolean = false;
private oldDecorations: string[] = [];
constructor(readonly options: RustpadOptions) {
this.model = options.editor.getModel()!;
this.onChangeHandle = options.editor.onDidChangeModelContent((e) =>
this.onChange(e)
);
const cursorUpdate = debounce(() => this.sendCursorData(), 20);
this.onCursorHandle = options.editor.onDidChangeCursorPosition((e) => {
this.onCursor(e);
cursorUpdate();
});
this.onSelectionHandle = options.editor.onDidChangeCursorSelection((e) => {
this.onSelection(e);
cursorUpdate();
});
this.beforeUnload = (event: BeforeUnloadEvent) => {
if (this.outstanding) {
event.preventDefault();
event.returnValue = "";
} else {
delete event.returnValue;
}
};
window.addEventListener("beforeunload", this.beforeUnload);
const interval = options.reconnectInterval ?? 1000;
this.tryConnect();
this.tryConnectId = window.setInterval(() => this.tryConnect(), interval);
this.resetFailuresId = window.setInterval(
() => (this.recentFailures = 0),
15 * interval
);
}
/** Destroy this Rustpad instance and close any sockets. */
dispose() {
window.clearInterval(this.tryConnectId);
window.clearInterval(this.resetFailuresId);
this.onSelectionHandle.dispose();
this.onCursorHandle.dispose();
this.onChangeHandle.dispose();
window.removeEventListener("beforeunload", this.beforeUnload);
this.ws?.close();
}
/** Try to set the language of the editor, if connected. */
setLanguage(language: string): boolean {
this.ws?.send(`{"SetLanguage":${JSON.stringify(language)}}`);
return this.ws !== undefined;
}
/** Set the user's information. */
setInfo(info: UserInfo) {
this.myInfo = info;
this.sendInfo();
}
/**
* Attempts a WebSocket connection.
*
* Safety Invariant: Until this WebSocket connection is closed, no other
* connections will be attempted because either `this.ws` or
* `this.connecting` will be set to a truthy value.
*
* Liveness Invariant: After this WebSocket connection closes, either through
* error or successful end, both `this.connecting` and `this.ws` will be set
* to falsy values.
*/
private tryConnect() {
if (this.connecting || this.ws) return;
this.connecting = true;
const ws = new WebSocket(this.options.uri);
ws.onopen = () => {
this.connecting = false;
this.ws = ws;
this.options.onConnected?.();
this.users = {};
this.options.onChangeUsers?.(this.users);
this.sendInfo();
this.sendCursorData();
if (this.outstanding) {
this.sendOperation(this.outstanding);
}
};
ws.onclose = () => {
if (this.ws) {
this.ws = undefined;
this.options.onDisconnected?.();
if (++this.recentFailures >= 5) {
// If we disconnect 5 times within 15 reconnection intervals, then the
// client is likely desynchronized and needs to refresh.
this.dispose();
this.options.onDesynchronized?.();
}
} else {
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];
this.revision++;
if (id === this.me) {
this.serverAck();
} else {
operation = OpSeq.from_str(JSON.stringify(operation));
this.applyServer(operation);
}
}
} else if (msg.Language !== undefined) {
this.options.onChangeLanguage?.(msg.Language);
} else if (msg.UserInfo !== undefined) {
const { id, info } = msg.UserInfo;
if (id !== this.me) {
this.users = { ...this.users };
if (info) {
this.users[id] = info;
} else {
delete this.users[id];
delete this.userCursors[id];
}
this.updateCursors();
this.options.onChangeUsers?.(this.users);
}
} else if (msg.UserCursor !== undefined) {
const { id, data } = msg.UserCursor;
if (id !== this.me) {
this.userCursors[id] = data;
this.updateCursors();
}
}
}
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);
}
this.transformCursors(operation);
}
private sendOperation(operation: OpSeq) {
const op = operation.to_string();
this.ws?.send(`{"Edit":{"revision":${this.revision},"operation":${op}}}`);
}
private sendInfo() {
if (this.myInfo) {
this.ws?.send(`{"ClientInfo":${JSON.stringify(this.myInfo)}}`);
}
}
private sendCursorData() {
if (!this.buffer) {
this.ws?.send(`{"CursorData":${JSON.stringify(this.cursorData)}}`);
}
}
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 = unicodePosition(this.model, index);
index += unicodeLength(op);
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 = unicodePosition(this.model, index);
var to = unicodePosition(this.model, 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;
this.transformCursors(operation);
}
private transformCursors(operation: OpSeq) {
for (const data of Object.values(this.userCursors)) {
data.cursors = data.cursors.map((c) => operation.transform_index(c));
data.selections = data.selections.map(([s, e]) => [
operation.transform_index(s),
operation.transform_index(e),
]);
}
this.updateCursors();
}
private updateCursors() {
const decorations: editor.IModelDeltaDecoration[] = [];
for (const [id, data] of Object.entries(this.userCursors)) {
if (id in this.users) {
const { hue, name } = this.users[id as any];
generateCssStyles(hue);
for (const cursor of data.cursors) {
const position = unicodePosition(this.model, cursor);
decorations.push({
options: {
className: `remote-cursor-${hue}`,
stickiness: 1,
zIndex: 2,
},
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: position.lineNumber,
endColumn: position.column,
},
});
}
for (const selection of data.selections) {
const position = unicodePosition(this.model, selection[0]);
const positionEnd = unicodePosition(this.model, selection[1]);
decorations.push({
options: {
className: `remote-selection-${hue}`,
hoverMessage: {
value: name,
},
stickiness: 1,
zIndex: 1,
},
range: {
startLineNumber: position.lineNumber,
startColumn: position.column,
endLineNumber: positionEnd.lineNumber,
endColumn: positionEnd.column,
},
});
}
}
}
this.oldDecorations = this.model.deltaDecorations(
this.oldDecorations,
decorations
);
}
private onChange(event: editor.IModelContentChangedEvent) {
if (!this.ignoreChanges) {
const content = this.lastValue;
const contentLength = unicodeLength(content);
let offset = 0;
let operation = OpSeq.new();
operation.retain(contentLength);
event.changes.sort((a, b) => b.rangeOffset - a.rangeOffset);
for (const change of event.changes) {
// The following dance is necessary to convert from UTF-16 indices (evil
// encoding-dependent JavaScript representation) to portable Unicode
// codepoint indices.
const { text, rangeOffset, rangeLength } = change;
const initialLength = unicodeLength(content.slice(0, rangeOffset));
const deletedLength = unicodeLength(
content.slice(rangeOffset, rangeOffset + rangeLength)
);
const restLength =
contentLength + offset - initialLength - deletedLength;
const changeOp = OpSeq.new();
changeOp.retain(initialLength);
changeOp.delete(deletedLength);
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();
}
}
private onCursor(event: editor.ICursorPositionChangedEvent) {
const cursors = [event.position, ...event.secondaryPositions];
this.cursorData.cursors = cursors.map((p) => unicodeOffset(this.model, p));
}
private onSelection(event: editor.ICursorSelectionChangedEvent) {
const selections = [event.selection, ...event.secondarySelections];
this.cursorData.selections = selections.map((s) => [
unicodeOffset(this.model, s.getStartPosition()),
unicodeOffset(this.model, s.getEndPosition()),
]);
}
}
type UserOperation = {
id: number;
operation: any;
};
type CursorData = {
cursors: number[];
selections: [number, number][];
};
type ServerMsg = {
Identity?: number;
History?: {
start: number;
operations: UserOperation[];
};
Language?: string;
UserInfo?: {
id: number;
info: UserInfo | null;
};
UserCursor?: {
id: number;
data: CursorData;
};
};
/** Returns the number of Unicode codepoints in a string. */
function unicodeLength(str: string): number {
let length = 0;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const c of str) ++length;
return length;
}
/** Returns the number of Unicode codepoints before a position in the model. */
function unicodeOffset(model: editor.ITextModel, pos: IPosition): number {
const value = model.getValue();
const offsetUTF16 = model.getOffsetAt(pos);
return unicodeLength(value.slice(0, offsetUTF16));
}
/** Returns the position after a certain number of Unicode codepoints. */
function unicodePosition(model: editor.ITextModel, offset: number): IPosition {
const value = model.getValue();
let offsetUTF16 = 0;
for (const c of value) {
// Iterate over Unicode codepoints
if (offset <= 0) break;
offsetUTF16 += c.length;
offset -= 1;
}
return model.getPositionAt(offsetUTF16);
}
/** Cache for private use by `generateCssStyles()`. */
const generatedStyles = new Set<number>();
/** Add CSS styles for a remote user's cursor and selection. */
function generateCssStyles(hue: number) {
if (!generatedStyles.has(hue)) {
generatedStyles.add(hue);
const css = `
.monaco-editor .remote-selection-${hue} {
background-color: hsla(${hue}, 90%, 80%, 0.5);
}
.monaco-editor .remote-cursor-${hue} {
border-left: 2px solid hsl(${hue}, 90%, 25%);
}
`;
const element = document.createElement("style");
const text = document.createTextNode(css);
element.appendChild(text);
document.head.appendChild(element);
}
}
export default Rustpad;