Implement MVP editor, still some bugs
This commit is contained in:
parent
a851029de0
commit
e97e19c1e3
8 changed files with 225 additions and 20 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -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
7
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
23
src/App.tsx
23
src/App.tsx
|
@ -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>
|
||||
|
|
195
src/rustpad.ts
195
src/rustpad.ts
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue