mirror of https://github.com/ekzhang/rustpad
508 lines
15 KiB
TypeScript
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;
|