Implement MVP of remote selections
This commit is contained in:
parent
130564bec6
commit
211e567275
4 changed files with 207 additions and 4 deletions
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
135
src/rustpad.ts
135
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;
|
||||
|
|
Loading…
Add table
Reference in a new issue