Skip to content

Commit dfe5464

Browse files
committed
feat: collaborative document snippet example
1 parent b14834e commit dfe5464

File tree

9 files changed

+391
-0
lines changed

9 files changed

+391
-0
lines changed

examples/collab-document/.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.actorcore
2+
node_modules
3+
# React
4+
build/
+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { actor, setup } from "actor-core";
2+
3+
export type Cursor = { x: number, y: number, userId: string };
4+
5+
export type CursorUpdateEvent = { userId: string, x: number, y: number };
6+
7+
export type TextUpdatedEvent = { text: string, userId: string };
8+
9+
export type UserDisconnectedEvent = { userId: string };
10+
11+
const document = actor({
12+
state: {
13+
text: "",
14+
cursors: {} as Record<string, Cursor>,
15+
},
16+
17+
onDisconnect: (c, conn) => {
18+
console.log("onDisconnect(): " + conn.id);
19+
delete c.state.cursors[conn.id];
20+
21+
// Broadcast removal
22+
c.broadcastWithOptions(
23+
{ exclude: [conn.id] },
24+
"userDisconnected",
25+
{
26+
userId: conn.id
27+
} as UserDisconnectedEvent
28+
);
29+
},
30+
31+
actions: {
32+
getText: (c) => c.state.text,
33+
34+
// Update the document (real use case has better conflict resolution)
35+
setText: (c, text: string) => {
36+
// Save document state
37+
c.state.text = text;
38+
39+
// Broadcast update
40+
c.broadcastWithOptions(
41+
{ excludeSelf: true },
42+
"textUpdated",
43+
{
44+
text,
45+
userId: c.conn.id
46+
} as TextUpdatedEvent
47+
);
48+
},
49+
50+
getCursors: (c) => c.state.cursors,
51+
52+
updateCursor: (c, x: number, y: number) => {
53+
console.log("updateCursor(): " + c.conn.id);
54+
// Update user location
55+
const userId = c.conn.id;
56+
c.state.cursors[userId] = { x, y, userId };
57+
58+
// Broadcast location
59+
c.broadcastWithOptions(
60+
{ excludeSelf: true },
61+
"cursorUpdated",
62+
{
63+
userId,
64+
x,
65+
y
66+
} as CursorUpdateEvent
67+
);
68+
},
69+
}
70+
});
71+
72+
// Create and export the app
73+
export const app = setup({
74+
actors: { document }
75+
});
76+
77+
// Export type for client type checking
78+
export type App = typeof app;

examples/collab-document/package.json

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "collab-document",
3+
"version": "0.8.0",
4+
"private": true,
5+
"type": "module",
6+
"main": "src/index.ts",
7+
"scripts": {
8+
"dev:actors": "npx @actor-core/cli@latest dev actors/app.ts",
9+
"dev:frontend": "react-scripts start",
10+
"check-types": "tsc --noEmit",
11+
"test": "vitest run"
12+
},
13+
"dependencies": {
14+
"@actor-core/react": "workspace:*",
15+
"@types/react": "^19",
16+
"@types/react-dom": "^19",
17+
"actor-core": "workspace:*",
18+
"react": "^19",
19+
"react-dom": "^19",
20+
"react-scripts": "^5.0.1"
21+
},
22+
"devDependencies": {
23+
"@actor-core/cli": "workspace:*",
24+
"actor-core": "workspace:*",
25+
"typescript": "^5.5.2"
26+
},
27+
"example": {
28+
"platforms": [
29+
"*"
30+
]
31+
},
32+
"browserslist": {
33+
"production": [
34+
">0.2%",
35+
"not dead",
36+
"not op_mini all"
37+
],
38+
"development": [
39+
"last 1 chrome version",
40+
"last 1 firefox version",
41+
"last 1 safari version"
42+
]
43+
},
44+
"resolutions": {
45+
"react@^19": "^19.0.0",
46+
"react-dom@^19": "^19.0.0",
47+
"react@^18": "^18.3"
48+
}
49+
}
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
body, html {
2+
margin: 0;
3+
padding: 0;
4+
padding-top: 24px;
5+
width: 100%;
6+
height: 100%;
7+
}
8+
#example--repo-ref {
9+
position: fixed;
10+
top: 0px;
11+
left: 0px;
12+
cursor: pointer;
13+
background-color: rgb(243, 243, 243);
14+
height: 24px;
15+
width: 100%;
16+
padding: 8px 8px;
17+
}
18+
#example--github-icon {
19+
height: 24px;
20+
float: left;
21+
}
22+
#example--repo-link {
23+
height: 24px;
24+
margin-left: 8px;
25+
color: rgb(45, 50, 55);
26+
font-weight: bold;
27+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
28+
font-size: 15px;
29+
vertical-align: middle;
30+
}
31+
32+
#example--repo-ref:hover #example--repo-link {
33+
color: black;
34+
}
35+
#example--repo-ref:hover svg {
36+
fill: black !important;
37+
}
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6+
<meta name="theme-color" content="#000000">
7+
<title>Chat Room</title>
8+
<link rel="stylesheet" href="./github.css">
9+
<link rel="stylesheet" href="./main.css">
10+
</head>
11+
<body>
12+
<!-- Github Notch -->
13+
<div id="example--repo-ref">
14+
<a id="example--github-icon" href="https://github.com/rivet-gg/actor-core/tree/main/examples/collab-document" target="_blank">
15+
<svg height="24" width="24" viewBox="0 0 16 16" style="fill: #24292e;">
16+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
17+
</svg>
18+
</a>
19+
<a id="example--repo-link" href="https://github.com/rivet-gg/actor-core/tree/main/examples/collab-document">@rivet-gg/actor-core</a>
20+
</div>
21+
<noscript>
22+
You need to enable JavaScript to run this app.
23+
</noscript>
24+
<div id="root"></div>
25+
</body>
26+
</html>
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
body {
2+
font-family: sans-serif;
3+
background-color: #f4f4f4;
4+
}
5+
6+
h1 {
7+
color: #333;
8+
}
9+
10+
#root {
11+
background-color: white;
12+
padding: 20px;
13+
margin: 10px 20px;
14+
border-radius: 8px;
15+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
16+
}
17+
18+
textarea {
19+
width: 100%;
20+
min-height: 300px;
21+
padding: 10px;
22+
box-sizing: border-box;
23+
border: 1px solid #ccc;
24+
border-radius: 4px;
25+
font-size: 1rem;
26+
}

examples/collab-document/src/App.tsx

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { createClient } from "actor-core/client";
2+
import { createReactActorCore } from "@actor-core/react";
3+
import { useState, useEffect } from "react";
4+
import type { App, Cursor, CursorUpdateEvent, TextUpdatedEvent, UserDisconnectedEvent } from "../actors/app";
5+
6+
const client = createClient<App>("http://localhost:6420");
7+
const { useActor, useActorEvent } = createReactActorCore(client);
8+
9+
function DocumentEditor() {
10+
// Connect to actor for this document ID from URL
11+
const documentId = new URLSearchParams(window.location.search).get('id') || 'default-doc';
12+
const [{ actor, state }] = useActor("document", { tags: { id: documentId } });
13+
14+
// Local state
15+
const [text, setText] = useState("");
16+
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
17+
const [otherCursors, setOtherCursors] = useState<Record<string, Cursor>>({});
18+
19+
// Load initial document state
20+
useEffect(() => {
21+
if (actor && state === "created") {
22+
actor.getText().then(setText);
23+
actor.getCursors().then(setOtherCursors);
24+
}
25+
}, [actor, state]);
26+
27+
// Listen for updates from other users
28+
useActorEvent({ actor, event: "textUpdated" }, (event) => {
29+
const { text: newText, userId: _senderId } = event as TextUpdatedEvent;
30+
31+
setText(newText);
32+
});
33+
34+
useActorEvent({ actor, event: "cursorUpdated" }, (event) => {
35+
const { userId: cursorUserId, x, y } = event as CursorUpdateEvent;
36+
37+
setOtherCursors(prev => ({
38+
...prev,
39+
[cursorUserId]: { x, y, userId: cursorUserId }
40+
}));
41+
});
42+
43+
useActorEvent({ actor, event: "userDisconnected" }, (event) => {
44+
const { userId } = event as UserDisconnectedEvent;
45+
46+
setOtherCursors(prev => {
47+
const newCursors = { ...prev };
48+
delete newCursors[userId];
49+
return newCursors;
50+
});
51+
});
52+
53+
54+
useEffect(() => {
55+
if (!actor || state !== "created") return;
56+
57+
const updateCursor = ({ x, y }: { x: number, y: number }) => {
58+
59+
if (x !== cursorPos.x || y !== cursorPos.y) {
60+
setCursorPos({ x, y });
61+
actor.updateCursor(x, y);
62+
}
63+
};
64+
65+
window.addEventListener("mousemove", (e) => {
66+
const x = e.clientX;
67+
const y = e.clientY;
68+
69+
updateCursor({ x, y });
70+
});
71+
}, [actor, state]);
72+
73+
return (
74+
<div className="document-editor">
75+
<h2>Document: {documentId}</h2>
76+
77+
<div>
78+
<textarea
79+
value={text}
80+
onChange={(e) => {
81+
const newText = e.target.value;
82+
setText(newText);
83+
if (actor && state === "created") {
84+
actor.setText(newText);
85+
}
86+
}}
87+
placeholder="Start typing..."
88+
/>
89+
90+
{/* Other users' cursors */}
91+
{Object.values(otherCursors).map((cursor) => (
92+
<div
93+
key={cursor.userId}
94+
style={{
95+
position: 'absolute',
96+
left: `${cursor.x}px`,
97+
top: `${cursor.y}px`,
98+
width: '10px',
99+
height: '10px',
100+
backgroundColor: 'red',
101+
borderRadius: '50%'
102+
}}
103+
/>
104+
))}
105+
</div>
106+
107+
<div>
108+
<p>Connected users: You and {Object.keys(otherCursors).length} others</p>
109+
</div>
110+
</div>
111+
);
112+
}
113+
114+
export default function ReactApp() {
115+
return (
116+
<div className="app">
117+
<h1>Collaborative Document Editor</h1>
118+
<DocumentEditor />
119+
</div>
120+
);
121+
}
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import ReactDOM from "react-dom/client";
2+
import ReactApp from "./App";
3+
4+
const container = document.getElementById('root')!;
5+
const root = ReactDOM.createRoot(container);
6+
7+
root.render(<ReactApp />);

0 commit comments

Comments
 (0)