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:
parent
b55362032d
commit
e7e921665d
|
|
@ -16,6 +16,8 @@ services:
|
||||||
- IPFS_ENABLED=true
|
- IPFS_ENABLED=true
|
||||||
- IPFS_API_URL=https://ipfs-api.rspace.online
|
- IPFS_API_URL=https://ipfs-api.rspace.online
|
||||||
- IPFS_GATEWAY_URL=https://ipfs.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:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
labels:
|
labels:
|
||||||
|
|
@ -44,6 +46,28 @@ services:
|
||||||
- /tmp
|
- /tmp
|
||||||
- /home/nextjs/.npm
|
- /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:
|
rnotes-postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: rnotes-postgres
|
container_name: rnotes-postgres
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^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-image": "^3.19.0",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
|
|
@ -28,6 +30,8 @@
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"y-websocket": "^3.0.0",
|
||||||
|
"yjs": "^13.6.30",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -44,7 +48,7 @@
|
||||||
},
|
},
|
||||||
"../encryptid-sdk": {
|
"../encryptid-sdk": {
|
||||||
"name": "@encryptid/sdk",
|
"name": "@encryptid/sdk",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/curves": "^2.0.1",
|
"@noble/curves": "^2.0.1",
|
||||||
|
|
@ -375,14 +379,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/core": {
|
"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",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@tiptap/pm": "^3.19.0"
|
"@tiptap/pm": "^3.22.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/extension-blockquote": {
|
"node_modules/@tiptap/extension-blockquote": {
|
||||||
|
|
@ -472,6 +478,37 @@
|
||||||
"lowlight": "^2 || ^3"
|
"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": {
|
"node_modules/@tiptap/extension-document": {
|
||||||
"version": "3.19.0",
|
"version": "3.19.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -725,7 +762,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tiptap/pm": {
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
|
|
@ -811,6 +850,27 @@
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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": {
|
"node_modules/@types/archiver": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz",
|
||||||
|
|
@ -1872,6 +1932,16 @@
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/jackspeak": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
|
||||||
|
|
@ -1941,6 +2011,27 @@
|
||||||
"safe-buffer": "~5.1.0"
|
"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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|
@ -3478,6 +3569,64 @@
|
||||||
"node": ">=8"
|
"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": {
|
"node_modules/zip-stream": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-code-block-lowlight": "^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-image": "^3.19.0",
|
||||||
"@tiptap/extension-link": "^3.19.0",
|
"@tiptap/extension-link": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
|
|
@ -32,6 +34,8 @@
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
|
"y-websocket": "^3.0.0",
|
||||||
|
"yjs": "^13.6.30",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
|
|
@ -8,6 +8,12 @@ import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import TaskList from '@tiptap/extension-task-list';
|
import TaskList from '@tiptap/extension-task-list';
|
||||||
import TaskItem from '@tiptap/extension-task-item';
|
import TaskItem from '@tiptap/extension-task-item';
|
||||||
import Image from '@tiptap/extension-image';
|
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 {
|
interface NoteEditorProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -15,6 +21,12 @@ interface NoteEditorProps {
|
||||||
valueJson?: object;
|
valueJson?: object;
|
||||||
type?: string;
|
type?: string;
|
||||||
placeholder?: 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({
|
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [collabState, setCollabState] = useState<CollabState>({
|
||||||
|
connected: false, synced: false, peerCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const editor = useEditor({
|
// Create Y.js doc + provider (stable across re-renders)
|
||||||
extensions: [
|
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({
|
StarterKit.configure({
|
||||||
heading: { levels: [1, 2, 3] },
|
heading: { levels: [1, 2, 3] },
|
||||||
codeBlock: { HTMLAttributes: { class: 'bg-slate-800 rounded-lg p-3 font-mono text-sm' } },
|
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' } },
|
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' } },
|
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({
|
Link.configure({
|
||||||
openOnClick: false,
|
openOnClick: false,
|
||||||
|
|
@ -64,8 +137,39 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }
|
||||||
TaskList,
|
TaskList,
|
||||||
TaskItem.configure({ nested: true }),
|
TaskItem.configure({ nested: true }),
|
||||||
Image.configure({ inline: 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 }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onChange(editor.getHTML(), editor.getJSON());
|
onChange(editor.getHTML(), editor.getJSON());
|
||||||
},
|
},
|
||||||
|
|
@ -261,13 +365,23 @@ function RichEditor({ value, onChange, valueJson, placeholder: placeholderText }
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</div>
|
</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 */}
|
{/* Editor */}
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</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';
|
const isCode = type === 'CODE';
|
||||||
|
|
||||||
if (isCode) {
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue