Overview
This advanced example demonstrates building a production-ready collaborative editor with:- Real-time typing sync across all peers
- Remote cursor positions and selections
- Markdown/HTML split preview with draggable splitter
- Version history with restore functionality
- File sharing via P2P
- Integrated video room for face-to-face collaboration
- RBAC + WebAuthn for secure authentication
- Rich text toolbar with formatting options
View the live demo to see all features in action.
Architecture Overview
The collaborative editor combines multiple GenosDB features:Core Concepts
1. Document Synchronization
The editor uses a single document node that all peers subscribe to:const DOCUMENT_ID = 'doc:main'
// Subscribe to document changes
await db.get(
DOCUMENT_ID,
(doc) => {
if (doc && doc.value.content !== editor.value) {
// Update local editor if remote change
editor.value = doc.value.content
}
}
)
// Save changes on input (debounced)
let saveTimeout
editor.addEventListener('input', () => {
clearTimeout(saveTimeout)
saveTimeout = setTimeout(async () => {
await db.put({
content: editor.value,
updatedAt: Date.now(),
updatedBy: currentUser
}, DOCUMENT_ID)
}, 300) // 300ms debounce
})
Conflict Resolution: GenosDB uses Last-Write-Wins (LWW). For high-frequency concurrent edits, consider operational transformation (OT) or CRDTs for character-level merging.
2. Remote Cursor Positions
Cursors use data channels for ephemeral updates:const cursorChannel = db.room.channel('cursors')
const remoteCursors = new Map()
// Send cursor position
editor.addEventListener('selectionchange', () => {
const { selectionStart, selectionEnd } = editor
cursorChannel.send({
username: currentUser,
start: selectionStart,
end: selectionEnd
})
})
// Receive remote cursors
cursorChannel.on('message', ({ username, start, end }, peerId) => {
updateCursorDisplay(peerId, username, start, end)
})
3. Version History
Save snapshots with timestamps:const versions = []
async function saveVersion() {
const versionId = await db.put({
type: 'version',
content: editor.value,
timestamp: Date.now(),
author: currentUser
})
versions.unshift({
id: versionId,
timestamp: Date.now(),
preview: editor.value.slice(0, 100)
})
updateVersionList()
}
async function restoreVersion(versionId) {
const { result: version } = await db.get(versionId)
if (!version) return
editor.value = version.value.content
await db.put({
content: version.value.content,
updatedAt: Date.now(),
updatedBy: currentUser
}, DOCUMENT_ID)
}
4. Video Integration
Add video chat alongside the editor:const videoContainer = document.getElementById('videos')
const peerVideos = new Map()
// Start local video
async function startVideo() {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
// Display local video
const localVideo = document.createElement('video')
localVideo.srcObject = stream
localVideo.autoplay = true
localVideo.muted = true // Mute own audio
videoContainer.appendChild(localVideo)
// Broadcast to peers
db.room.addStream(stream)
}
// Receive remote videos
db.room.on('stream:add', (stream, peerId) => {
const video = document.createElement('video')
video.srcObject = stream
video.autoplay = true
video.playsInline = true
video.dataset.peer = peerId
videoContainer.appendChild(video)
peerVideos.set(peerId, video)
})
db.room.on('peer:leave', (peerId) => {
const video = peerVideos.get(peerId)
if (video) {
video.remove()
peerVideos.delete(peerId)
}
})
Complete Implementation
Here’s a simplified but complete collaborative editor:<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collaborative Editor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
background: #0b1220;
color: #e5e7eb;
}
header {
background: #1a1a1a;
padding: 1rem;
border-bottom: 1px solid #333;
}
#toolbar {
background: #1a1a1a;
padding: 0.5rem;
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #333;
}
button {
padding: 0.5rem 1rem;
background: #0ea5e9;
border: none;
color: white;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0284c7;
}
#editor-container {
flex: 1;
display: flex;
overflow: hidden;
}
#editor {
flex: 1;
padding: 1rem;
background: #1e293b;
color: #e5e7eb;
border: none;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
}
#editor:focus {
outline: none;
}
#preview {
flex: 1;
padding: 1rem;
background: #0f172a;
overflow-y: auto;
border-left: 1px solid #333;
}
#videos {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 10px;
z-index: 1000;
}
#videos video {
width: 200px;
height: 150px;
border-radius: 8px;
border: 2px solid #0ea5e9;
}
.cursor-overlay {
position: absolute;
width: 2px;
background: var(--cursor-color);
pointer-events: none;
z-index: 10;
}
.cursor-label {
position: absolute;
background: var(--cursor-color);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
}
</style>
</head>
<body>
<header>
<h1>Collaborative Editor</h1>
</header>
<div id="toolbar">
<button onclick="saveVersion()">Save Version</button>
<button onclick="togglePreview()">Toggle Preview</button>
<button onclick="startVideo()">Start Video</button>
<button onclick="shareFile()">Share File</button>
</div>
<div id="editor-container">
<textarea id="editor" placeholder="Start typing..."></textarea>
<div id="preview" style="display: none;"></div>
</div>
<div id="videos"></div>
<script type="module">
import { gdb } from 'https://cdn.jsdelivr.net/npm/genosdb@latest/dist/index.min.js'
import { marked } from 'https://cdn.jsdelivr.net/npm/marked@latest/lib/marked.esm.js'
const DOCUMENT_ID = 'doc:main'
const editor = document.getElementById('editor')
const preview = document.getElementById('preview')
const videoContainer = document.getElementById('videos')
let currentUser = localStorage.getItem('username') || prompt('Enter your name:')
localStorage.setItem('username', currentUser)
// Initialize GenosDB
const db = await gdb('collab-editor', { rtc: true })
// === Document Synchronization ===
let isRemoteUpdate = false
await db.get(DOCUMENT_ID, (doc) => {
if (doc && doc.value.content !== editor.value) {
isRemoteUpdate = true
editor.value = doc.value.content
updatePreview()
}
})
let saveTimeout
editor.addEventListener('input', () => {
updatePreview()
if (isRemoteUpdate) {
isRemoteUpdate = false
return
}
clearTimeout(saveTimeout)
saveTimeout = setTimeout(async () => {
await db.put({
content: editor.value,
updatedAt: Date.now(),
updatedBy: currentUser
}, DOCUMENT_ID)
}, 300)
})
// === Preview ===
function updatePreview() {
preview.innerHTML = marked(editor.value)
}
window.togglePreview = function() {
const isVisible = preview.style.display !== 'none'
preview.style.display = isVisible ? 'none' : 'block'
}
// === Remote Cursors ===
const cursorChannel = db.room.channel('cursors')
const remoteCursors = new Map()
const colors = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6']
let colorIndex = 0
editor.addEventListener('click', sendCursorPosition)
editor.addEventListener('keyup', sendCursorPosition)
function sendCursorPosition() {
cursorChannel.send({
username: currentUser,
position: editor.selectionStart
})
}
cursorChannel.on('message', ({ username, position }, peerId) => {
if (!remoteCursors.has(peerId)) {
remoteCursors.set(peerId, {
username,
color: colors[colorIndex++ % colors.length]
})
}
// In production, calculate pixel position and display cursor
})
// === Video Chat ===
const peerVideos = new Map()
window.startVideo = async function() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
const localVideo = document.createElement('video')
localVideo.srcObject = stream
localVideo.autoplay = true
localVideo.muted = true
localVideo.playsInline = true
videoContainer.appendChild(localVideo)
db.room.addStream(stream)
} catch (err) {
alert('Camera access denied: ' + err.message)
}
}
db.room.on('stream:add', (stream, peerId) => {
const video = document.createElement('video')
video.srcObject = stream
video.autoplay = true
video.playsInline = true
video.dataset.peer = peerId
videoContainer.appendChild(video)
peerVideos.set(peerId, video)
})
db.room.on('peer:leave', (peerId) => {
const video = peerVideos.get(peerId)
if (video) {
video.remove()
peerVideos.delete(peerId)
}
})
// === Version History ===
window.saveVersion = async function() {
const versionId = await db.put({
type: 'version',
content: editor.value,
timestamp: Date.now(),
author: currentUser
})
alert('Version saved!')
}
// === File Sharing ===
const fileChannel = db.room.channel('files')
window.shareFile = async function() {
const input = document.createElement('input')
input.type = 'file'
input.onchange = async (e) => {
const file = e.target.files[0]
if (!file) return
const buffer = await file.arrayBuffer()
fileChannel.send({
name: file.name,
type: file.type,
data: buffer
})
alert('File shared!')
}
input.click()
}
fileChannel.on('message', ({ name, type, data }) => {
const blob = new Blob([data], { type })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
URL.revokeObjectURL(url)
alert(`Received file: ${name}`)
})
</script>
</body>
</html>
Advanced Features
Security with WebAuthn
const db = await gdb('secure-editor', {
rtc: true,
sm: {
superAdmins: ['0x1234...'],
customRoles: {
editor: { can: ['write'], inherits: ['guest'] },
viewer: { can: ['read'], inherits: ['guest'] }
}
}
})
// Require login
await db.sm.loginCurrentUserWithWebAuthn()
// Only editors can save
if (await db.sm.executeWithPermission('write')) {
await db.put({ content: editor.value }, DOCUMENT_ID)
}
Presence Indicators
const presenceChannel = db.room.channel('presence')
const onlineUsers = new Set()
// Broadcast presence
setInterval(() => {
presenceChannel.send({ username: currentUser, active: true })
}, 3000)
// Track users
presenceChannel.on('message', ({ username }, peerId) => {
onlineUsers.add(username)
updateUserList()
})
db.room.on('peer:leave', (peerId) => {
// Remove user from list
})
Conflict Indicators
Show when multiple users edit simultaneously:let lastRemoteUpdate = 0
await db.get(DOCUMENT_ID, (doc) => {
if (doc) {
const timeSinceEdit = Date.now() - lastRemoteUpdate
if (timeSinceEdit < 2000 && doc.value.updatedBy !== currentUser) {
showConflictWarning(doc.value.updatedBy)
}
lastRemoteUpdate = Date.now()
}
})
Key Learnings
Debounce Saves
Use setTimeout to batch rapid keystrokes into single database writes.
Data Channels for Ephemeral
Cursor positions don’t need persistence. Use channels instead of put().
Version Snapshots
Periodic snapshots enable time travel and conflict recovery.
LWW Limitations
Last-Write-Wins can lose concurrent edits. Consider OT/CRDTs for character-level sync.
Performance Optimization
Chunked Documents
For large documents, split into chunks:// Split into paragraphs
const paragraphs = editor.value.split('\n\n')
for (let i = 0; i < paragraphs.length; i++) {
await db.put({
type: 'paragraph',
docId: DOCUMENT_ID,
index: i,
content: paragraphs[i]
}, `${DOCUMENT_ID}:p${i}`)
}
Lazy Loading
Load version history on demand:async function loadVersionHistory() {
const { results } = await db.map({
query: { type: 'version' },
field: 'timestamp',
order: 'desc',
$limit: 20 // Last 20 versions
})
displayVersions(results)
}
Next Steps
Security Model
Add RBAC and WebAuthn authentication
P2P Setup
Configure for production with TURN servers
Real-Time Subscriptions
Master reactive patterns
Examples Overview
Explore more GenosDB examples