Implement MVP editor, still some bugs

This commit is contained in:
Eric Zhang 2021-06-02 22:57:00 -05:00
parent a851029de0
commit e97e19c1e3
8 changed files with 225 additions and 20 deletions

2
Cargo.lock generated
View file

@ -1290,8 +1290,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd"
dependencies = [
"cfg-if 1.0.0",
"serde",
"serde_json",
"wasm-bindgen-macro",
]

7
package-lock.json generated
View file

@ -21,6 +21,7 @@
"devDependencies": {
"@types/react": "^17.0.8",
"@types/react-dom": "^17.0.5",
"monaco-editor": "^0.23.0",
"prettier": "2.3.0",
"react-app-rewired": "^2.1.8",
"typescript": "^4.3.2",
@ -14130,8 +14131,7 @@
"node_modules/monaco-editor": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.23.0.tgz",
"integrity": "sha512-q+CP5zMR/aFiMTE9QlIavGyGicKnG2v/H8qVvybLzeFsARM8f6G9fL0sMST2tyVYCwDKkGamZUI6647A0jR/Lg==",
"peer": true
"integrity": "sha512-q+CP5zMR/aFiMTE9QlIavGyGicKnG2v/H8qVvybLzeFsARM8f6G9fL0sMST2tyVYCwDKkGamZUI6647A0jR/Lg=="
},
"node_modules/move-concurrently": {
"version": "1.0.1",
@ -33559,8 +33559,7 @@
"monaco-editor": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.23.0.tgz",
"integrity": "sha512-q+CP5zMR/aFiMTE9QlIavGyGicKnG2v/H8qVvybLzeFsARM8f6G9fL0sMST2tyVYCwDKkGamZUI6647A0jR/Lg==",
"peer": true
"integrity": "sha512-q+CP5zMR/aFiMTE9QlIavGyGicKnG2v/H8qVvybLzeFsARM8f6G9fL0sMST2tyVYCwDKkGamZUI6647A0jR/Lg=="
},
"move-concurrently": {
"version": "1.0.1",

View file

@ -24,6 +24,7 @@
"devDependencies": {
"@types/react": "^17.0.8",
"@types/react-dom": "^17.0.5",
"monaco-editor": "^0.23.0",
"prettier": "2.3.0",
"react-app-rewired": "^2.1.8",
"typescript": "^4.3.2",

View file

@ -156,6 +156,13 @@ impl Rustpad {
}
fn apply_edit(&self, id: u64, revision: usize, mut operation: OperationSeq) -> Result<()> {
info!(
"edit: id = {}, revision = {}, base_len = {}, target_len = {}",
id,
revision,
operation.base_len(),
operation.target_len()
);
let state = self.state.upgradable_read();
let len = state.operations.len();
if revision > len {

View file

@ -15,7 +15,7 @@ console_error_panic_hook = { version = "0.1", optional = true }
operational-transform = { version = "0.6.0", features = ["serde"] }
serde = { version = "1.0.126", features = ["derive"] }
serde_json = "1.0.64"
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
wasm-bindgen = "0.2"
js-sys = "0.3.51"
[dev-dependencies]

View file

@ -67,8 +67,8 @@ impl OpSeq {
}
/// Deletes `n` characters at the current cursor position.
pub fn delete(&mut self, n: u64) {
self.0.delete(n)
pub fn delete(&mut self, n: u32) {
self.0.delete(n as u64)
}
/// Inserts a `s` at the current cursor position.
@ -77,8 +77,8 @@ impl OpSeq {
}
/// Moves the cursor `n` characters forwards.
pub fn retain(&mut self, n: u64) {
self.0.retain(n)
pub fn retain(&mut self, n: u32) {
self.0.retain(n as u64)
}
/// Transforms two operations A and B that happened concurrently and produces

View file

@ -19,6 +19,7 @@ import {
} from "@chakra-ui/react";
import { VscAccount, VscCircleFilled, VscRemote } from "react-icons/vsc";
import Editor from "@monaco-editor/react";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
import Rustpad from "./rustpad";
import languages from "./languages.json";
@ -33,15 +34,22 @@ function App() {
const toast = useToast();
const [language, setLanguage] = useState("plaintext");
const [connected, setConnected] = useState(false);
const [editor, setEditor] = useState<editor.IStandaloneCodeEditor>();
useEffect(() => {
const rustpad = new Rustpad({
uri: WS_URI,
onConnected: () => setConnected(true),
onDisconnected: () => setConnected(false),
});
return () => rustpad.dispose();
}, []);
if (editor) {
const model = editor.getModel()!;
model.setValue("");
model.setEOL(0); // LF
const rustpad = new Rustpad({
uri: WS_URI,
editor,
onConnected: () => setConnected(true),
onDisconnected: () => setConnected(false),
});
return () => rustpad.dispose();
}
}, [editor]);
async function handleCopy() {
await navigator.clipboard.writeText(`${window.location.origin}/`);
@ -158,6 +166,7 @@ function App() {
automaticLayout: true,
fontSize: 14,
}}
onMount={(editor) => setEditor(editor)}
/>
</Box>
</Flex>

View file

@ -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;