Build a Collaborative Text Editor
Learn how to build a real-time collaborative text editor like Google Docs using GUN. This guide covers real-time synchronization, conflict resolution, and handling concurrent edits.What You’ll Build
- Real-time text synchronization
- Multi-user editing
- Conflict-free updates
- Auto-save functionality
- Document persistence
- Offline editing with sync
Minimal Note Editor
Here’s the simplest collaborative editor in just 9 lines:<!DOCTYPE html>
<style>html, body, textarea {
width: 100%; height: 100%; padding: 0; margin: 0;
}</style>
<textarea id="view" placeholder="write here..."></textarea>
<script src="https://cdn.jsdelivr.net/npm/gun/gun.js"></script>
<script>
gun = GUN();
note = gun.get('note').get(location.hash.replace('#','') || 1);
view.oninput = () => { note.put(view.value) };
note.on((data) => { view.value = data });
</script>
Try It Out
- Save as
editor.htmland open in browser - Open the same file in another window
- Type in one window, see it appear in the other!
- Add
#room1to URL to create different documents
Full-Featured Editor
Here’s a complete editor with better UX:<!DOCTYPE html>
<html>
<head>
<title>Collaborative Editor</title>
<meta charset="UTF-8">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f5f5f5;
}
.header {
background: white;
padding: 15px 20px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 20px;
color: #333;
}
.status {
font-size: 14px;
color: #666;
display: flex;
align-items: center;
gap: 10px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4CAF50;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.editor-container {
max-width: 900px;
margin: 40px auto;
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
.toolbar {
background: #f9f9f9;
border-bottom: 1px solid #ddd;
padding: 10px 20px;
display: flex;
gap: 10px;
}
.toolbar button {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.toolbar button:hover {
background: #f0f0f0;
}
#editor {
width: 100%;
min-height: 500px;
padding: 40px;
border: none;
font-size: 16px;
line-height: 1.6;
font-family: Georgia, serif;
resize: vertical;
outline: none;
}
.footer {
padding: 10px 20px;
background: #f9f9f9;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
text-align: right;
}
</style>
</head>
<body>
<div class="header">
<h1 id="doc-title">Untitled Document</h1>
<div class="status">
<span class="status-dot"></span>
<span>Synced</span>
</div>
</div>
<div class="editor-container">
<div class="toolbar">
<button onclick="document.execCommand('bold')"><b>B</b></button>
<button onclick="document.execCommand('italic')"><i>I</i></button>
<button onclick="document.execCommand('underline')"><u>U</u></button>
<button onclick="clearDocument()">Clear</button>
<button onclick="exportDocument()">Export</button>
</div>
<textarea id="editor" placeholder="Start typing..."></textarea>
<div class="footer">
<span id="word-count">0 words</span> ·
<span id="char-count">0 characters</span>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gun/gun.js"></script>
<script>
// Initialize GUN
const gun = GUN([
'http://localhost:8765/gun',
'https://gun-manhattan.herokuapp.com/gun'
]);
// Get document reference from URL hash
const docId = location.hash.slice(1) || 'default';
const doc = gun.get('docs').get(docId);
// Get editor element
const editor = document.getElementById('editor');
const docTitle = document.getElementById('doc-title');
const wordCount = document.getElementById('word-count');
const charCount = document.getElementById('char-count');
// Update document title
docTitle.textContent = `Document: ${docId}`;
// Debounce function to avoid too many updates
let saveTimeout;
function debouncedSave(value) {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
doc.get('content').put(value);
doc.get('lastModified').put(Date.now());
}, 300);
}
// Handle local edits
let isRemoteUpdate = false;
editor.addEventListener('input', () => {
if (isRemoteUpdate) return;
const content = editor.value;
debouncedSave(content);
updateStats(content);
});
// Listen for remote updates
doc.get('content').on((content) => {
if (content === undefined || content === null) return;
// Only update if content changed and not from this editor
if (editor.value !== content) {
// Preserve cursor position
const start = editor.selectionStart;
const end = editor.selectionEnd;
isRemoteUpdate = true;
editor.value = content;
// Restore cursor position
editor.setSelectionRange(start, end);
isRemoteUpdate = false;
updateStats(content);
}
});
// Update word and character count
function updateStats(text) {
const words = text.trim().split(/\s+/).filter(w => w.length > 0).length;
const chars = text.length;
wordCount.textContent = `${words} word${words !== 1 ? 's' : ''}`;
charCount.textContent = `${chars} character${chars !== 1 ? 's' : ''}`;
}
// Clear document
function clearDocument() {
if (confirm('Clear all content?')) {
editor.value = '';
doc.get('content').put('');
}
}
// Export document
function exportDocument() {
const content = editor.value;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${docId}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// Auto-focus editor
editor.focus();
</script>
</body>
</html>
Rich Text Editor
Here’s an example using ContentEditable for rich text:<!DOCTYPE html>
<html>
<head>
<title>Rich Text Editor</title>
<style>
#editor {
border: 1px solid #ccc;
min-height: 400px;
padding: 20px;
font-size: 16px;
line-height: 1.6;
}
.toolbar button {
margin: 5px;
padding: 8px 12px;
cursor: pointer;
}
</style>
</head>
<body>
<div class="toolbar">
<button onclick="format('bold')"><b>Bold</b></button>
<button onclick="format('italic')"><i>Italic</i></button>
<button onclick="format('underline')"><u>Underline</u></button>
<button onclick="format('insertUnorderedList')">• List</button>
<button onclick="format('insertOrderedList')">1. List</button>
<select onchange="format('formatBlock', this.value); this.value=''">
<option value="">Format</option>
<option value="h1">Heading 1</option>
<option value="h2">Heading 2</option>
<option value="p">Paragraph</option>
</select>
</div>
<div id="editor" contenteditable="true"></div>
<script src="https://cdn.jsdelivr.net/npm/gun/gun.js"></script>
<script>
const gun = GUN();
const docId = location.hash.slice(1) || 'default';
const doc = gun.get('rich-docs').get(docId);
const editor = document.getElementById('editor');
let saveTimeout;
let isRemoteUpdate = false;
// Format text
function format(command, value = null) {
document.execCommand(command, false, value);
editor.focus();
}
// Save content
function saveContent() {
if (isRemoteUpdate) return;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
doc.get('html').put(editor.innerHTML);
}, 500);
}
// Listen for input
editor.addEventListener('input', saveContent);
editor.addEventListener('paste', (e) => {
// Clean pasted content
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
});
// Load content
doc.get('html').on((html) => {
if (html && editor.innerHTML !== html) {
isRemoteUpdate = true;
editor.innerHTML = html;
isRemoteUpdate = false;
}
});
</script>
</body>
</html>
Conflict Resolution
Operational Transformation (OT)
For more sophisticated conflict resolution:// Track document version
let localVersion = 0;
let remoteVersion = 0;
// Store pending operations
const pendingOps = [];
// Handle local change
editor.addEventListener('input', (e) => {
const op = {
type: 'insert',
position: editor.selectionStart,
text: e.data,
version: localVersion++
};
pendingOps.push(op);
doc.get('ops').set(op);
});
// Handle remote operations
doc.get('ops').map().on((op, id) => {
if (op.version <= remoteVersion) return;
// Transform against pending operations
const transformed = transformOp(op, pendingOps);
applyOp(transformed);
remoteVersion = op.version;
});
function transformOp(op, pending) {
// Simplified transformation
let newOp = { ...op };
pending.forEach(localOp => {
if (localOp.position <= newOp.position) {
newOp.position += localOp.text.length;
}
});
return newOp;
}
function applyOp(op) {
const content = editor.value;
const newContent =
content.slice(0, op.position) +
op.text +
content.slice(op.position);
editor.value = newContent;
}
Character-by-Character Sync
For fine-grained synchronization:// Split document into characters
function syncDocument() {
const content = editor.value;
const docChars = gun.get('doc-' + docId).get('chars');
content.split('').forEach((char, index) => {
docChars.get(index).put(char);
});
// Store length
docChars.get('length').put(content.length);
}
// Reconstruct document
function loadDocument() {
const docChars = gun.get('doc-' + docId).get('chars');
docChars.get('length').once((length) => {
if (!length) return;
let content = '';
let loaded = 0;
for (let i = 0; i < length; i++) {
docChars.get(i).once((char) => {
content += char || '';
loaded++;
if (loaded === length) {
editor.value = content;
}
});
}
});
}
Advanced Features
Cursor Position Sync
// Share cursor positions
const cursors = gun.get('cursors-' + docId);
const userId = 'user-' + Math.random().toString(36).substr(2, 9);
editor.addEventListener('click', updateCursor);
editor.addEventListener('keyup', updateCursor);
function updateCursor() {
cursors.get(userId).put({
position: editor.selectionStart,
color: '#' + Math.random().toString(16).substr(2, 6),
timestamp: Date.now()
});
}
// Show other cursors
cursors.map().on((cursor, id) => {
if (id === userId) return;
// Show cursor indicator in UI
showCursorIndicator(cursor);
});
Version History
// Save versions
let versionNum = 0;
function saveVersion() {
const version = {
content: editor.value,
timestamp: Date.now(),
author: currentUser,
number: versionNum++
};
doc.get('versions').get(version.number).put(version);
}
// Load version
function loadVersion(versionNum) {
doc.get('versions').get(versionNum).once((version) => {
if (version) {
editor.value = version.content;
}
});
}
// Auto-save every 30 seconds
setInterval(saveVersion, 30000);
Presence Indicators
// Track active users
const presence = gun.get('presence-' + docId);
const userId = sessionStorage.getItem('userId') ||
Math.random().toString(36).substr(2, 9);
sessionStorage.setItem('userId', userId);
// Update presence
function updatePresence() {
presence.get(userId).put({
name: userName,
color: userColor,
lastSeen: Date.now(),
active: true
});
}
// Send heartbeat
setInterval(updatePresence, 5000);
updatePresence();
// Show active users
presence.map().on((user, id) => {
if (!user || !user.active) return;
// Remove if inactive for 15 seconds
if (Date.now() - user.lastSeen > 15000) {
presence.get(id).get('active').put(false);
return;
}
// Show in UI
showActiveUser(user);
});
// Clean up on close
window.addEventListener('beforeunload', () => {
presence.get(userId).get('active').put(false);
});
Best Practices
- Debounce updates - Don’t save on every keystroke
- Preserve cursor position - Maintain UX during remote updates
- Handle conflicts - Use timestamps or version numbers
- Show sync status - Let users know when changes are saved
- Implement auto-save - Don’t rely on manual saves
- Add presence - Show who’s currently editing
Performance Tips
- Use debouncing to reduce network traffic
- Consider delta compression for large documents
- Implement lazy loading for very long documents
- Cache content locally for instant load
- Use worker threads for complex transformations
Next Steps
Chat App
Build a real-time chat application
User Authentication
Add user accounts and permissions
Conflict Resolution
Deep dive into conflict handling
P2P Networking
Understand mesh networking