Add Y.js real-time collaboration to rNotes editor

- Add yjs, y-websocket, @tiptap/extension-collaboration,
  @tiptap/extension-collaboration-caret dependencies
- Create collab-provider.ts: WebsocketProvider wrapper
- Create CollabStatus.tsx: connection/sync state indicator
- NoteEditor.tsx: add collaborative mode with Y.js document,
  awareness cursors, and auto-disable built-in history
- docker-compose.yml: add y-websocket sidecar (rnotes-yws)
  at collab-ws.rnotes.online, add NEXT_PUBLIC_COLLAB_WS_URL

Enable by passing collaborative={true} noteId="..." user={...}
to NoteEditor. Non-collaborative mode unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-31 19:22:18 -07:00
parent b55362032d
commit e7e921665d
6 changed files with 389 additions and 12 deletions

View File

@ -16,6 +16,8 @@ services:
- IPFS_ENABLED=true
- IPFS_API_URL=https://ipfs-api.rspace.online
- IPFS_GATEWAY_URL=https://ipfs.rspace.online
# Y.js collaboration (client-side env var baked at build time)
- NEXT_PUBLIC_COLLAB_WS_URL=wss://collab-ws.rnotes.online
volumes:
- uploads_data:/app/uploads
labels:
@ -44,6 +46,28 @@ services:
- /tmp
- /home/nextjs/.npm
# Y.js WebSocket server for real-time collaboration
rnotes-yws:
image: node:22-slim
container_name: rnotes-yws
restart: unless-stopped
command: ["npx", "y-websocket"]
environment:
- HOST=0.0.0.0
- PORT=1234
networks:
- traefik-public
- rnotes-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.rnotes-yws.rule=Host(`collab-ws.rnotes.online`)"
- "traefik.http.routers.rnotes-yws.entrypoints=web"
- "traefik.http.services.rnotes-yws.loadbalancer.server.port=1234"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
rnotes-postgres:
image: postgres:16-alpine
container_name: rnotes-postgres

157
package-lock.json generated
View File

@ -12,6 +12,8 @@
"@prisma/client": "^6.19.2",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
"@tiptap/extension-collaboration": "^3.22.0",
"@tiptap/extension-collaboration-caret": "^3.22.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
@ -28,6 +30,8 @@
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"y-websocket": "^3.0.0",
"yjs": "^13.6.30",
"zustand": "^5.0.11"
},
"devDependencies": {
@ -44,7 +48,7 @@
},
"../encryptid-sdk": {
"name": "@encryptid/sdk",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@noble/curves": "^2.0.1",
@ -375,14 +379,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.19.0",
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.0.tgz",
"integrity": "sha512-EA/XFbvvz0yRyccqrgOwB9RQe6+uJ8NszjLKH9+3xPE2/+Sa2imax0IqWl7YOXkWihdQVrlpP+EpQF9APKx3jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.19.0"
"@tiptap/pm": "^3.22.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
@ -472,6 +478,37 @@
"lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-collaboration": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.22.0.tgz",
"integrity": "sha512-S/nPIth4/Dr5wmxROk4ELWRYhBXxWt7O6cIi8Fca1rYDBdXofjgt4VDO2iCjnOF0LkIekjQudSOFLWadbM2Z+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.22.0",
"@tiptap/pm": "^3.22.0",
"@tiptap/y-tiptap": "^3.0.2",
"yjs": "^13"
}
},
"node_modules/@tiptap/extension-collaboration-caret": {
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration-caret/-/extension-collaboration-caret-3.22.0.tgz",
"integrity": "sha512-M17V2Q3VQLg9VA/1kyyqoC2N55SbOSPDybMiAyBogwpy1JcE8YbLkfpvHMGRi29Dbw3H/+F5wkfCZ4JyS5ASxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.22.0",
"@tiptap/pm": "^3.22.0",
"@tiptap/y-tiptap": "^3.0.2"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.19.0",
"license": "MIT",
@ -725,7 +762,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "3.19.0",
"version": "3.22.0",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.0.tgz",
"integrity": "sha512-O9kpzNnFX5837kFevwAM8yr7ImLHu8noIwIpoci0AwfJjiBMzfZBejhbzxnKEfTpFWnkvZ8rWohlb6CQdJ6Crg==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
@ -811,6 +850,27 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/y-tiptap": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.2.tgz",
"integrity": "sha512-flMn/YW6zTbc6cvDaUPh/NfLRTXDIqgpBUkYzM74KA1snqQwhOMjnRcnpu4hDFrTnPO6QGzr99vRyXEA7M44WA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.100"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.7.1",
"prosemirror-state": "^1.2.3",
"prosemirror-view": "^1.9.10",
"y-protocols": "^1.0.1",
"yjs": "^13.5.38"
}
},
"node_modules/@types/archiver": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
@ -1872,6 +1932,16 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
@ -1941,6 +2011,27 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"dev": true,
@ -3478,6 +3569,64 @@
"node": ">=8"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y-websocket": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz",
"integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.102",
"y-protocols": "^1.0.5"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.5.6"
}
},
"node_modules/yjs": {
"version": "13.6.30",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz",
"integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zip-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",

View File

@ -16,6 +16,8 @@
"@prisma/client": "^6.19.2",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
"@tiptap/extension-collaboration": "^3.22.0",
"@tiptap/extension-collaboration-caret": "^3.22.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
@ -32,6 +34,8 @@
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"y-websocket": "^3.0.0",
"yjs": "^13.6.30",
"zustand": "^5.0.11"
},
"devDependencies": {

View File

@ -0,0 +1,32 @@
'use client';
import type { CollabState } from '@/lib/collab-provider';
interface CollabStatusProps {
state: CollabState;
}
export function CollabStatus({ state }: CollabStatusProps) {
const { connected, synced, peerCount } = state;
const color = !connected
? 'bg-red-500'
: !synced
? 'bg-amber-500 animate-pulse'
: 'bg-emerald-500';
const label = !connected
? 'Offline'
: !synced
? 'Syncing...'
: peerCount > 0
? `${peerCount} online`
: 'Connected';
return (
<div className="flex items-center gap-1.5 text-xs text-slate-400" title={label}>
<span className={`w-2 h-2 rounded-full ${color}`} />
<span>{label}</span>
</div>
);
}

View File

@ -1,6 +1,6 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
@ -8,6 +8,12 @@ import Placeholder from '@tiptap/extension-placeholder';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Image from '@tiptap/extension-image';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCaret from '@tiptap/extension-collaboration-caret';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { CollabStatus } from './CollabStatus';
import type { CollabState } from '@/lib/collab-provider';
interface NoteEditorProps {
value: string;
@ -15,6 +21,12 @@ interface NoteEditorProps {
valueJson?: object;
type?: string;
placeholder?: string;
/** Enable Y.js collaboration for this note */
collaborative?: boolean;
/** Note ID used as room name for collab */
noteId?: string;
/** Current user info for cursor display */
user?: { name: string; color: string };
}
function ToolbarButton({
@ -44,17 +56,78 @@ function ToolbarButton({
);
}
function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
const COLLAB_WS_URL = process.env.NEXT_PUBLIC_COLLAB_WS_URL || 'ws://localhost:1234';
const COLLAB_COLORS = [
'#f87171', '#fb923c', '#fbbf24', '#a3e635', '#34d399',
'#22d3ee', '#60a5fa', '#a78bfa', '#f472b6', '#e879f9',
];
function pickColor(name: string): string {
let hash = 0;
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) | 0;
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
}
function RichEditor({
value, onChange, valueJson, placeholder: placeholderText,
collaborative, noteId, user,
}: Omit<NoteEditorProps, 'type'>) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [collabState, setCollabState] = useState<CollabState>({
connected: false, synced: false, peerCount: 0,
});
const editor = useEditor({
extensions: [
// Create Y.js doc + provider (stable across re-renders)
const { ydoc, provider } = useMemo(() => {
if (!collaborative || !noteId) return { ydoc: null, provider: null };
const doc = new Y.Doc();
const prov = new WebsocketProvider(COLLAB_WS_URL, `note-${noteId}`, doc);
const userName = user?.name || 'Anonymous';
prov.awareness.setLocalStateField('user', {
name: userName,
color: user?.color || pickColor(userName),
});
return { ydoc: doc, provider: prov };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [collaborative, noteId]);
// Track collab connection state
useEffect(() => {
if (!provider) return;
const update = () => {
setCollabState({
connected: provider.wsconnected,
synced: provider.synced,
peerCount: Math.max(0, provider.awareness.getStates().size - 1),
});
};
provider.on('status', update);
provider.on('sync', update);
provider.awareness.on('update', update);
return () => {
provider.off('status', update);
provider.off('sync', update);
provider.awareness.off('update', update);
provider.destroy();
ydoc?.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider]);
// Build extensions — add collaboration when enabled
const extensions = useMemo(() => {
const base = [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: { HTMLAttributes: { class: 'bg-slate-800 rounded-lg p-3 font-mono text-sm' } },
code: { HTMLAttributes: { class: 'bg-slate-800 rounded px-1.5 py-0.5 font-mono text-sm text-amber-300' } },
blockquote: { HTMLAttributes: { class: 'border-l-2 border-amber-500/50 pl-4 text-slate-400' } },
// Disable built-in history when collab is active (Y.js handles undo/redo)
...(ydoc ? { history: false } : {}),
}),
Link.configure({
openOnClick: false,
@ -64,8 +137,39 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }
TaskList,
TaskItem.configure({ nested: true }),
Image.configure({ inline: true }),
],
content: valueJson || value || '',
];
if (ydoc && provider) {
base.push(
Collaboration.configure({ document: ydoc }) as any,
CollaborationCaret.configure({
provider,
user: {
name: user?.name || 'Anonymous',
color: user?.color || pickColor(user?.name || 'Anonymous'),
},
render: (u: Record<string, any>) => {
const cursor = document.createElement('span');
cursor.classList.add('collaboration-cursor');
cursor.style.borderColor = u.color;
const label = document.createElement('span');
label.classList.add('collaboration-cursor-label');
label.style.backgroundColor = u.color;
label.textContent = u.name;
cursor.appendChild(label);
return cursor;
},
}) as any,
);
}
return base;
}, [ydoc, provider, placeholderText, user]);
const editor = useEditor({
extensions,
// When collaborative, Y.js is the source of truth — don't set initial content
content: ydoc ? undefined : (valueJson || value || ''),
onUpdate: ({ editor }) => {
onChange(editor.getHTML(), editor.getJSON());
},
@ -261,13 +365,23 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }
</ToolbarButton>
</div>
{/* Collab status indicator */}
{collaborative && (
<div className="flex items-center justify-end px-2 py-1 border-b border-slate-700 bg-slate-800/30">
<CollabStatus state={collabState} />
</div>
)}
{/* Editor */}
<EditorContent editor={editor} />
</div>
);
}
export function NoteEditor({ value, onChange, valueJson, type, placeholder }: NoteEditorProps) {
export function NoteEditor({
value, onChange, valueJson, type, placeholder,
collaborative, noteId, user,
}: NoteEditorProps) {
const isCode = type === 'CODE';
if (isCode) {
@ -283,5 +397,15 @@ export function NoteEditor({ value, onChange, valueJson, type, placeholder }: No
);
}
return <RichEditor value={value} onChange={onChange} valueJson={valueJson} placeholder={placeholder} />;
return (
<RichEditor
value={value}
onChange={onChange}
valueJson={valueJson}
placeholder={placeholder}
collaborative={collaborative}
noteId={noteId}
user={user}
/>
);
}

View File

@ -0,0 +1,44 @@
/**
* Y.js collaboration provider for rNotes
*
* Wraps y-websocket's WebsocketProvider to connect to the y-websocket
* sidecar server for real-time collaborative editing.
*/
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const COLLAB_WS_URL = process.env.NEXT_PUBLIC_COLLAB_WS_URL || 'ws://localhost:1234'
export interface CollabState {
connected: boolean
synced: boolean
peerCount: number
}
export function createCollabProvider(
noteId: string,
ydoc: Y.Doc,
user: { name: string; color: string },
onStateChange?: (state: CollabState) => void,
): WebsocketProvider {
const roomName = `note-${noteId}`
const provider = new WebsocketProvider(COLLAB_WS_URL, roomName, ydoc)
// Set local awareness (cursor name/color)
provider.awareness.setLocalStateField('user', user)
const emitState = () => {
onStateChange?.({
connected: provider.wsconnected,
synced: provider.synced,
peerCount: provider.awareness.getStates().size - 1,
})
}
provider.on('status', emitState)
provider.on('sync', emitState)
provider.awareness.on('update', emitState)
return provider
}