Browse Source

Implement MVP of remote selections

Eric Zhang 4 years ago
parent
commit
211e567275
4 changed files with 207 additions and 4 deletions
  1. 23 0
      rustpad-server/src/rustpad.rs
  2. 51 0
      rustpad-server/tests/users.rs
  3. 1 1
      src/User.tsx
  4. 132 3
      src/rustpad.ts

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

@@ -31,6 +31,7 @@ struct State {
     text: String,
     language: Option<String>,
     users: HashMap<u64, UserInfo>,
+    cursors: HashMap<u64, CursorData>,
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -45,6 +46,12 @@ struct UserInfo {
     hue: u32,
 }
 
+#[derive(Clone, Debug, Serialize, Deserialize)]
+struct CursorData {
+    cursors: Vec<u32>,
+    selections: Vec<(u32, u32)>,
+}
+
 /// A message received from the client over WebSocket.
 #[derive(Clone, Debug, Serialize, Deserialize)]
 enum ClientMsg {
@@ -57,6 +64,8 @@ enum ClientMsg {
     SetLanguage(String),
     /// Sets the user's current information.
     ClientInfo(UserInfo),
+    /// Sets the user's cursor and selection positions.
+    CursorData(CursorData),
 }
 
 /// A message sent to the client over WebSocket.
@@ -73,6 +82,8 @@ enum ServerMsg {
     Language(String),
     /// Broadcasts a user's information, or `None` on disconnect.
     UserInfo { id: u64, info: Option<UserInfo> },
+    /// Broadcasts a user's cursor position.
+    UserCursor { id: u64, data: CursorData },
 }
 
 impl From<ServerMsg> for Message {
@@ -104,6 +115,7 @@ impl Rustpad {
         }
         info!("disconnection, id = {}", id);
         self.state.write().users.remove(&id);
+        self.state.write().cursors.remove(&id);
         self.update
             .send(ServerMsg::UserInfo { id, info: None })
             .ok();
@@ -169,6 +181,12 @@ impl Rustpad {
                     info: Some(info.clone()),
                 });
             }
+            for (&id, data) in &state.cursors {
+                messages.push(ServerMsg::UserCursor {
+                    id,
+                    data: data.clone(),
+                });
+            }
         };
         for msg in messages {
             socket.send(msg.into()).await?;
@@ -220,6 +238,11 @@ impl Rustpad {
                 };
                 self.update.send(msg).ok();
             }
+            ClientMsg::CursorData(data) => {
+                self.state.write().cursors.insert(id, data.clone());
+                let msg = ServerMsg::UserCursor { id, data };
+                self.update.send(msg).ok();
+            }
         }
         Ok(())
     }

+ 51 - 0
rustpad-server/tests/users.rs

@@ -110,3 +110,54 @@ async fn test_leave_rejoin() -> Result<()> {
 
     Ok(())
 }
+
+#[tokio::test]
+async fn test_cursors() -> Result<()> {
+    pretty_env_logger::try_init().ok();
+    let filter = server();
+
+    let mut client = connect(&filter, "foobar").await?;
+    assert_eq!(client.recv().await?, json!({ "Identity": 0 }));
+
+    let cursors = json!({
+        "cursors": [4, 6, 7],
+        "selections": [[5, 10], [3, 4]]
+    });
+    client.send(&json!({ "CursorData": cursors })).await;
+
+    let cursors_resp = json!({
+        "UserCursor": {
+            "id": 0,
+            "data": cursors
+        }
+    });
+    assert_eq!(client.recv().await?, cursors_resp);
+
+    let mut client2 = connect(&filter, "foobar").await?;
+    assert_eq!(client2.recv().await?, json!({ "Identity": 1 }));
+    assert_eq!(client2.recv().await?, cursors_resp);
+
+    let cursors2 = json!({
+        "cursors": [10],
+        "selections": []
+    });
+    client2.send(&json!({ "CursorData": cursors2 })).await;
+
+    let cursors2_resp = json!({
+        "UserCursor": {
+            "id": 1,
+            "data": cursors2
+        }
+    });
+    assert_eq!(client2.recv().await?, cursors2_resp);
+    assert_eq!(client.recv().await?, cursors2_resp);
+
+    client.send(&json!({ "Invalid": "please close" })).await;
+    client.recv_closed().await?;
+
+    let mut client3 = connect(&filter, "foobar").await?;
+    assert_eq!(client3.recv().await?, json!({ "Identity": 2 }));
+    assert_eq!(client3.recv().await?, cursors2_resp);
+
+    Ok(())
+}

+ 1 - 1
src/User.tsx

@@ -31,7 +31,7 @@ function User({ info, isMe = false, onChangeName, onChangeColor }: UserProps) {
   const inputRef = useRef<HTMLInputElement>(null);
   const { isOpen, onOpen, onClose } = useDisclosure();
 
-  const nameColor = `hsl(${info.hue}, 90%, 15%)`;
+  const nameColor = `hsl(${info.hue}, 90%, 25%)`;
   return (
     <Popover
       placement="right"

+ 132 - 3
src/rustpad.ts

@@ -1,5 +1,8 @@
 import { OpSeq } from "rustpad-wasm";
-import type { editor } from "monaco-editor/esm/vs/editor/editor.api";
+import type {
+  editor,
+  IDisposable,
+} from "monaco-editor/esm/vs/editor/editor.api";
 
 /** Options passed in to the Rustpad constructor. */
 export type RustpadOptions = {
@@ -25,7 +28,9 @@ class Rustpad {
   private connecting?: boolean;
   private recentFailures: number = 0;
   private readonly model: editor.ITextModel;
-  private readonly onChangeHandle: any;
+  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;
@@ -36,17 +41,26 @@ class Rustpad {
   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)
     );
+    this.onCursorHandle = options.editor.onDidChangeCursorPosition((e) =>
+      this.onCursor(e)
+    );
+    this.onSelectionHandle = options.editor.onDidChangeCursorSelection((e) =>
+      this.onSelection(e)
+    );
     this.beforeUnload = (event: BeforeUnloadEvent) => {
       if (this.outstanding) {
         event.preventDefault();
@@ -70,6 +84,8 @@ class Rustpad {
   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();
@@ -108,6 +124,8 @@ class Rustpad {
       this.options.onConnected?.();
       this.users = {};
       this.options.onChangeUsers?.(this.users);
+      this.sendInfo();
+      this.sendCursorData();
       if (this.outstanding) {
         this.sendOperation(this.outstanding);
       }
@@ -163,9 +181,17 @@ class Rustpad {
           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();
+      }
     }
   }
 
@@ -217,7 +243,11 @@ class Rustpad {
     }
   }
 
-  // The following functions are based on Firepad's monaco-adapter.js
+  private sendCursorData() {
+    if (!this.buffer) {
+      this.ws?.send(`{"CursorData":${JSON.stringify(this.cursorData)}}`);
+    }
+  }
 
   private applyOperation(operation: OpSeq) {
     if (operation.is_noop()) return;
@@ -278,6 +308,59 @@ class Rustpad {
     this.ignoreChanges = false;
   }
 
+  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 = this.model.getPositionAt(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 = this.model.getPositionAt(selection[0]);
+          const positionEnd = this.model.getPositionAt(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;
@@ -301,6 +384,21 @@ class Rustpad {
       this.lastValue = this.model.getValue();
     }
   }
+
+  private onCursor(event: editor.ICursorPositionChangedEvent) {
+    const cursors = [event.position, ...event.secondaryPositions];
+    this.cursorData.cursors = cursors.map((p) => this.model.getOffsetAt(p));
+    this.sendCursorData();
+  }
+
+  private onSelection(event: editor.ICursorSelectionChangedEvent) {
+    const selections = [event.selection, ...event.secondarySelections];
+    this.cursorData.selections = selections.map((s) => [
+      this.model.getOffsetAt(s.getStartPosition()),
+      this.model.getOffsetAt(s.getEndPosition()),
+    ]);
+    this.sendCursorData();
+  }
 }
 
 type UserOperation = {
@@ -308,6 +406,11 @@ type UserOperation = {
   operation: any;
 };
 
+type CursorData = {
+  cursors: number[];
+  selections: [number, number][];
+};
+
 type ServerMsg = {
   Identity?: number;
   History?: {
@@ -319,6 +422,32 @@ type ServerMsg = {
     id: number;
     info: UserInfo | null;
   };
+  UserCursor?: {
+    id: number;
+    data: CursorData;
+  };
 };
 
+/** 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;