Implement MVP of remote selections

This commit is contained in:
Eric Zhang 2021-06-04 23:40:52 -05:00
parent 130564bec6
commit 211e567275
4 changed files with 207 additions and 4 deletions

View file

@ -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(())
}

View file

@ -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(())
}

View file

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

View file

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