Ver código fonte

Implement MVP editor, still some bugs

Eric Zhang 4 anos atrás
pai
commit
e97e19c1e3
8 arquivos alterados com 225 adições e 20 exclusões
  1. 0 2
      Cargo.lock
  2. 3 4
      package-lock.json
  3. 1 0
      package.json
  4. 7 0
      rustpad-server/src/rustpad.rs
  5. 1 1
      rustpad-wasm/Cargo.toml
  6. 4 4
      rustpad-wasm/src/lib.rs
  7. 16 7
      src/App.tsx
  8. 193 2
      src/rustpad.ts

+ 0 - 2
Cargo.lock

@@ -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",
 ]
 

+ 3 - 4
package-lock.json

@@ -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",

+ 1 - 0
package.json

@@ -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",

+ 7 - 0
rustpad-server/src/rustpad.rs

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

+ 1 - 1
rustpad-wasm/Cargo.toml

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

+ 4 - 4
rustpad-wasm/src/lib.rs

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

+ 16 - 7
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>

+ 193 - 2
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;