Skip to content

Commit 4f433df

Browse files
committed
Added real-time collaborative editing
1 parent 7a45f99 commit 4f433df

File tree

10 files changed

+1384
-420
lines changed

10 files changed

+1384
-420
lines changed

docker-compose.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ services:
2929
timeout: 5s
3030
retries: 5
3131

32+
y-websocket:
33+
image: node:18-alpine
34+
container_name: collabpad-ywebsocket
35+
working_dir: /app
36+
command: sh -c "npm install -g y-websocket && HOST=0.0.0.0 PORT=1234 npx y-websocket-server"
37+
ports:
38+
- '1234:1234'
39+
healthcheck:
40+
test:
41+
[
42+
'CMD-SHELL',
43+
'wget --no-verbose --tries=1 --spider http://localhost:1234 || exit 1',
44+
]
45+
interval: 10s
46+
timeout: 5s
47+
retries: 5
48+
3249
volumes:
3350
postgres_data:
3451
redis_data:

package.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,21 @@
1919
"db:push": "prisma db push",
2020
"db:migrate": "prisma migrate dev",
2121
"db:studio": "prisma studio",
22-
"db:seed": "tsx prisma/seed.ts"
22+
"db:seed": "tsx prisma/seed.ts",
23+
"ws:server": "node scripts/websocket-server.js"
2324
},
2425
"dependencies": {
2526
"@auth/prisma-adapter": "^2.10.0",
2627
"@prisma/client": "^5.7.1",
2728
"@tanstack/react-query": "^4.40.1",
29+
"@tiptap/core": "^3.0.7",
2830
"@tiptap/extension-code-block-lowlight": "^3.0.7",
31+
"@tiptap/extension-collaboration": "2.26.1",
32+
"@tiptap/extension-collaboration-cursor": "2.26.1",
2933
"@tiptap/extension-link": "^3.0.7",
3034
"@tiptap/extension-placeholder": "^3.0.7",
3135
"@tiptap/extension-typography": "^3.0.7",
32-
"@tiptap/react": "^3.0.7",
36+
"@tiptap/react": "2.26.1",
3337
"@tiptap/starter-kit": "^3.0.7",
3438
"@trpc/client": "^10.45.2",
3539
"@trpc/next": "^10.45.2",
@@ -39,16 +43,26 @@
3943
"lowlight": "^3.3.0",
4044
"next": "^15.1.0",
4145
"next-auth": "^4.24.5",
46+
"prosemirror-state": "^1.4.3",
47+
"prosemirror-view": "^1.40.1",
4248
"react": "^19.0.0",
4349
"react-dom": "^19.0.0",
4450
"superjson": "^2.2.1",
51+
"tinycolor2": "^1.6.0",
52+
"ws": "^8.18.3",
53+
"y-prosemirror": "1.3.7",
54+
"y-protocols": "^1.0.6",
55+
"y-websocket": "1.4.5",
56+
"y-websocket-server": "^1.0.2",
57+
"yjs": "13.6.27",
4558
"zod": "^3.22.4"
4659
},
4760
"devDependencies": {
4861
"@types/jest": "^29.5.8",
4962
"@types/node": "^20.10.4",
5063
"@types/react": "^19.0.0",
5164
"@types/react-dom": "^19.0.0",
65+
"@types/tinycolor2": "^1.4.6",
5266
"autoprefixer": "^10.4.16",
5367
"eslint": "^8.56.0",
5468
"eslint-config-next": "14.0.4",

pnpm-lock.yaml

Lines changed: 703 additions & 277 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/websocket-server.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env node
2+
3+
const { setupWSConnection } = require('y-websocket/bin/utils');
4+
const http = require('http');
5+
const WebSocket = require('ws');
6+
7+
const PORT = process.env.PORT || 1234;
8+
const HOST = process.env.HOST || 'localhost';
9+
10+
const server = http.createServer();
11+
const wss = new WebSocket.Server({ server });
12+
13+
wss.on('connection', (conn, req) => {
14+
setupWSConnection(conn, req);
15+
});
16+
17+
server.listen(PORT, HOST, () => {
18+
console.log(`✅ Official y-websocket server running on ws://${HOST}:${PORT}`);
19+
console.log('Ready for collaborative editing with full awareness support!');
20+
});
21+
22+
// Graceful shutdown
23+
process.on('SIGINT', () => {
24+
console.log('\n🛑 Shutting down WebSocket server...');
25+
wss.close(() => {
26+
server.close(() => {
27+
console.log('✅ Server closed successfully');
28+
process.exit(0);
29+
});
30+
});
31+
});
32+
33+
module.exports = { server, wss };

src/app/globals.css

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ body {
3232
}
3333
}
3434

35-
/* TipTap Editor Styles */
3635
.ProseMirror {
3736
outline: none;
3837
padding: 1rem;
@@ -137,10 +136,10 @@ body {
137136
margin-bottom: 0;
138137
}
139138

140-
/* Code highlighting styles */
141139
.hljs-comment,
142140
.hljs-quote {
143141
color: #6a737d;
142+
font-style: italic;
144143
}
145144

146145
.hljs-variable,
@@ -173,16 +172,63 @@ body {
173172
.hljs-symbol,
174173
.hljs-bullet,
175174
.hljs-addition {
176-
color: #032f62;
175+
color: #22863a;
177176
}
178177

179178
.hljs-title,
180179
.hljs-section {
181-
color: #6f42c1;
180+
color: #24292e;
182181
font-weight: bold;
183182
}
184183

185184
.hljs-keyword,
186185
.hljs-selector-tag {
187186
color: #d73a49;
187+
font-weight: bold;
188+
}
189+
190+
.collab-cursor-caret {
191+
display: inline-block;
192+
width: 0;
193+
border-left-width: 2px;
194+
border-left-style: solid;
195+
position: relative;
196+
z-index: 10;
197+
}
198+
.collab-cursor-label {
199+
position: absolute;
200+
top: -1.5em;
201+
left: 0;
202+
padding: 2px 6px;
203+
border-radius: 4px;
204+
font-size: 0.75em;
205+
color: #fff;
206+
white-space: nowrap;
207+
pointer-events: none;
208+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
209+
}
210+
.collab-selection {
211+
border-radius: 2px;
212+
pointer-events: none;
213+
}
214+
215+
.ProseMirror .collaboration-cursor__selection {
216+
pointer-events: none;
217+
user-select: none;
218+
opacity: 0.3;
219+
}
220+
221+
.collaboration-cursor__caret {
222+
animation: cursor-blink 1.5s infinite;
223+
}
224+
225+
@keyframes cursor-blink {
226+
0%,
227+
50% {
228+
opacity: 1;
229+
}
230+
51%,
231+
100% {
232+
opacity: 0.3;
233+
}
188234
}

src/app/page.tsx

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,16 @@ export default async function HomePage() {
4444
</span>
4545
</div>
4646
</div>
47-
<div className="space-y-4">
48-
{session?.user ? (
49-
<div className="flex flex-col sm:flex-row gap-4 justify-center">
50-
<Link
51-
href="/documents"
52-
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
53-
>
54-
View Documents
55-
</Link>
56-
</div>
57-
) : (
58-
<div className="text-gray-500">
59-
Sign in above to start creating documents
60-
</div>
61-
)}
62-
</div>
47+
{session?.user && (
48+
<div className="mt-8">
49+
<Link
50+
href="/documents"
51+
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
52+
>
53+
My Documents
54+
</Link>
55+
</div>
56+
)}
6357
</div>
6458
</div>
6559
);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Extension } from '@tiptap/core';
2+
import { Decoration, DecorationSet } from 'prosemirror-view';
3+
import { Plugin } from 'prosemirror-state';
4+
import { EditorState } from 'prosemirror-state';
5+
import tinycolor from 'tinycolor2';
6+
7+
export const CollaborativeCursorExtension = Extension.create({
8+
name: 'collaborativeCursor',
9+
addOptions() {
10+
return { provider: null };
11+
},
12+
addProseMirrorPlugins() {
13+
let deco = DecorationSet.empty;
14+
return [
15+
new Plugin({
16+
props: {
17+
decorations: (state: EditorState) => deco,
18+
},
19+
view: view => {
20+
const provider = this.options.provider;
21+
const localClientId = provider?.awareness?.clientID;
22+
if (!provider) return { props: {} };
23+
24+
const updateDecorations = () => {
25+
const states = Array.from(
26+
provider.awareness.getStates().entries()
27+
) as [any, any][];
28+
const decorations = [];
29+
for (const [clientId, state] of states) {
30+
if (!state || !state.user || !state.cursor) continue;
31+
if (clientId === localClientId) {
32+
continue;
33+
}
34+
const { name, color } = state.user;
35+
if (!state.cursor) continue;
36+
const { anchor, head } = state.cursor;
37+
const from = Math.min(anchor, head);
38+
const to = Math.max(anchor, head);
39+
if (from !== to) {
40+
// Use a slightly darker border for contrast
41+
const borderColor = tinycolor(color).darken(15).toString();
42+
decorations.push(
43+
Decoration.inline(from, to, {
44+
class: 'collab-selection',
45+
style: `background: ${color}; opacity: 0.6; border-radius: 2px; border: 2px solid ${borderColor};`,
46+
})
47+
);
48+
}
49+
decorations.push(
50+
Decoration.widget(
51+
head,
52+
() => {
53+
const caret = document.createElement('span');
54+
caret.className = 'collab-cursor-caret';
55+
caret.style.borderLeft = `2px solid ${color}`;
56+
caret.style.marginLeft = '-1px';
57+
caret.style.height = '1em';
58+
caret.style.position = 'relative';
59+
caret.style.verticalAlign = 'text-bottom';
60+
caret.style.zIndex = '10';
61+
caret.style.pointerEvents = 'none';
62+
const label = document.createElement('div');
63+
label.className = 'collab-cursor-label';
64+
label.textContent = name || 'Anonymous';
65+
label.style.background = color;
66+
label.style.color = '#fff';
67+
label.style.fontSize = '0.75em';
68+
label.style.position = 'absolute';
69+
label.style.top = '-1.5em';
70+
label.style.left = '0';
71+
label.style.padding = '2px 6px';
72+
label.style.borderRadius = '4px';
73+
label.style.whiteSpace = 'nowrap';
74+
label.style.userSelect = 'none';
75+
label.style.pointerEvents = 'none';
76+
caret.appendChild(label);
77+
return caret;
78+
},
79+
{ side: 1 }
80+
)
81+
);
82+
}
83+
deco = DecorationSet.create(view.state.doc, decorations);
84+
view.dispatch(view.state.tr.setMeta('addToHistory', false));
85+
};
86+
87+
provider.awareness.on('change', updateDecorations);
88+
setTimeout(updateDecorations, 0);
89+
90+
return {
91+
destroy() {
92+
provider.awareness.off('change', updateDecorations);
93+
},
94+
};
95+
},
96+
}),
97+
];
98+
},
99+
});

0 commit comments

Comments
 (0)