Skip to content

Commit d83771d

Browse files
committed
feat: collaborative document snippet example
1 parent 7bf1236 commit d83771d

File tree

16 files changed

+671
-96
lines changed

16 files changed

+671
-96
lines changed

docs/snippets/examples/document-js.mdx

+38-9
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,32 @@ import { actor } from "actor-core";
33

44
export type Cursor = { x: number, y: number, userId: string };
55

6+
export type CursorUpdateEvent = { userId: string, x: number, y: number };
7+
8+
export type TextUpdatedEvent = { text: string, userId: string };
9+
10+
export type UserDisconnectedEvent = { userId: string };
11+
612
const document = actor({
713
state: {
814
text: "",
915
cursors: {} as Record<string, Cursor>,
1016
},
1117

18+
onDisconnect: (c, conn) => {
19+
console.log("onDisconnect(): " + conn.id);
20+
delete c.state.cursors[conn.id];
21+
22+
// Broadcast removal
23+
c.broadcastWithOptions(
24+
{ exclude: [conn.id] },
25+
"userDisconnected",
26+
{
27+
userId: conn.id
28+
} as UserDisconnectedEvent
29+
);
30+
},
31+
1232
actions: {
1333
getText: (c) => c.state.text,
1434

@@ -18,25 +38,34 @@ const document = actor({
1838
c.state.text = text;
1939

2040
// Broadcast update
21-
c.broadcast("textUpdated", {
22-
text,
23-
userId: c.conn.id
24-
});
41+
c.broadcastWithOptions(
42+
{ excludeSelf: true },
43+
"textUpdated",
44+
{
45+
text,
46+
userId: c.conn.id
47+
} as TextUpdatedEvent
48+
);
2549
},
2650

2751
getCursors: (c) => c.state.cursors,
2852

2953
updateCursor: (c, x: number, y: number) => {
54+
console.log("updateCursor(): " + c.conn.id);
3055
// Update user location
3156
const userId = c.conn.id;
3257
c.state.cursors[userId] = { x, y, userId };
3358

3459
// Broadcast location
35-
c.broadcast("cursorUpdated", {
36-
userId,
37-
x,
38-
y
39-
});
60+
c.broadcastWithOptions(
61+
{ excludeSelf: true },
62+
"cursorUpdated",
63+
{
64+
userId,
65+
x,
66+
y
67+
} as CursorUpdateEvent
68+
);
4069
},
4170
}
4271
});

docs/snippets/examples/document-react.mdx

+54-30
Original file line numberDiff line numberDiff line change
@@ -2,73 +2,97 @@
22
import { createClient } from "actor-core/client";
33
import { createReactActorCore } from "@actor-core/react";
44
import { useState, useEffect } from "react";
5-
import type { App } from "../actors/app";
5+
import type {
6+
App, Cursor, TextUpdatedEvent,
7+
CursorUpdateEvent, UserDisconnectedEvent
8+
} from "../actors/app";
69

710
const client = createClient<App>("http://localhost:6420");
811
const { useActor, useActorEvent } = createReactActorCore(client);
912

1013
export function DocumentEditor() {
1114
// Connect to actor for this document ID from URL
1215
const documentId = new URLSearchParams(window.location.search).get('id') || 'default-doc';
13-
const [{ actor, connectionId }] = useActor("document", { tags: { id: documentId } });
14-
16+
const [{ actor, state }] = useActor("document", { tags: { id: documentId } });
17+
1518
// Local state
1619
const [text, setText] = useState("");
1720
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
18-
const [otherCursors, setOtherCursors] = useState({});
21+
const [otherCursors, setOtherCursors] = useState<Record<string, Cursor>>({});
1922

2023
// Load initial document state
2124
useEffect(() => {
22-
if (actor) {
25+
if (actor && state === "created") {
2326
actor.getText().then(setText);
2427
actor.getCursors().then(setOtherCursors);
2528
}
26-
}, [actor]);
29+
}, [actor, state]);
2730

2831
// Listen for updates from other users
29-
useActorEvent({ actor, event: "textUpdated" }, ({ text: newText, userId: senderId }) => {
30-
if (senderId !== connectionId) setText(newText);
32+
useActorEvent({ actor, event: "textUpdated" }, (event) => {
33+
const { text: newText, userId: _senderId } = event as TextUpdatedEvent;
34+
35+
setText(newText);
3136
});
3237

33-
useActorEvent({ actor, event: "cursorUpdated" }, ({ userId: cursorUserId, x, y }) => {
34-
if (cursorUserId !== connectionId) {
35-
setOtherCursors(prev => ({
36-
...prev,
37-
[cursorUserId]: { x, y, userId: cursorUserId }
38-
}));
39-
}
38+
useActorEvent({ actor, event: "cursorUpdated" }, (event) => {
39+
const { userId: cursorUserId, x, y } = event as CursorUpdateEvent;
40+
41+
setOtherCursors(prev => ({
42+
...prev,
43+
[cursorUserId]: { x, y, userId: cursorUserId }
44+
}));
4045
});
41-
42-
// Update cursor position
43-
const updateCursor = (e) => {
44-
if (!actor) return;
45-
const rect = e.currentTarget.getBoundingClientRect();
46-
const x = e.clientX - rect.left;
47-
const y = e.clientY - rect.top;
46+
47+
useActorEvent({ actor, event: "userDisconnected" }, (event) => {
48+
const { userId } = event as UserDisconnectedEvent;
4849

49-
if (x !== cursorPos.x || y !== cursorPos.y) {
50-
setCursorPos({ x, y });
51-
actor.updateCursor(x, y);
52-
}
53-
};
50+
setOtherCursors(prev => {
51+
const newCursors = { ...prev };
52+
delete newCursors[userId];
53+
return newCursors;
54+
});
55+
});
56+
57+
58+
useEffect(() => {
59+
if (!actor || state !== "created") return;
60+
61+
const updateCursor = ({ x, y }: { x: number, y: number }) => {
62+
63+
if (x !== cursorPos.x || y !== cursorPos.y) {
64+
setCursorPos({ x, y });
65+
actor.updateCursor(x, y);
66+
}
67+
};
68+
69+
window.addEventListener("mousemove", (e) => {
70+
const x = e.clientX;
71+
const y = e.clientY;
72+
73+
updateCursor({ x, y });
74+
});
75+
}, [actor, state]);
5476

5577
return (
5678
<div className="document-editor">
5779
<h2>Document: {documentId}</h2>
5880

59-
<div onMouseMove={updateCursor}>
81+
<div>
6082
<textarea
6183
value={text}
6284
onChange={(e) => {
6385
const newText = e.target.value;
6486
setText(newText);
65-
actor?.setText(newText);
87+
if (actor && state === "created") {
88+
actor.setText(newText);
89+
}
6690
}}
6791
placeholder="Start typing..."
6892
/>
6993

7094
{/* Other users' cursors */}
71-
{Object.values(otherCursors).map((cursor: any) => (
95+
{Object.values(otherCursors).map((cursor) => (
7296
<div
7397
key={cursor.userId}
7498
style={{

docs/snippets/examples/document-sqlite.mdx

+40-9
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,32 @@ import { documents, cursors } from "./schema";
55

66
export type Cursor = { x: number, y: number, userId: string };
77

8+
export type CursorUpdateEvent = { userId: string, x: number, y: number };
9+
10+
export type TextUpdatedEvent = { text: string, userId: string };
11+
12+
export type UserDisconnectedEvent = { userId: string };
13+
814
const document = actor({
915
sql: drizzle(),
1016

17+
onDisconnect: async (c, conn) => {
18+
console.log("onDisconnect(): " + conn.id);
19+
await c.db
20+
.delete(documents)
21+
.where({ userId: conn.id })
22+
.execute();
23+
24+
// Broadcast removal
25+
c.broadcastWithOptions(
26+
{ exclude: [conn.id] },
27+
"userDisconnected",
28+
{
29+
userId: conn.id
30+
} as UserDisconnectedEvent
31+
);
32+
},
33+
1134
actions: {
1235
getText: async (c) => {
1336
const doc = await c.db
@@ -34,10 +57,14 @@ const document = actor({
3457
});
3558

3659
// Broadcast update
37-
c.broadcast("textUpdated", {
38-
text,
39-
userId: c.conn.id
40-
});
60+
c.broadcastWithOptions(
61+
{ excludeSelf: true },
62+
"textUpdated",
63+
{
64+
text,
65+
userId: c.conn.id
66+
} as TextUpdatedEvent
67+
);
4168
},
4269

4370
getCursors: async (c) => {
@@ -76,11 +103,15 @@ const document = actor({
76103
});
77104

78105
// Broadcast location
79-
c.broadcast("cursorUpdated", {
80-
userId,
81-
x,
82-
y
83-
});
106+
c.broadcastWithOptions(
107+
{ excludeSelf: true },
108+
"cursorUpdated",
109+
{
110+
userId,
111+
x,
112+
y
113+
} as CursorUpdateEvent
114+
);
84115
},
85116
}
86117
});

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;

0 commit comments

Comments
 (0)