Add collaboration support
Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
This commit is contained in:
parent
2e28598243
commit
1198791505
46
examples/flask-collab/README.md
Normal file
46
examples/flask-collab/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Flask Collaboration Example
|
||||||
|
|
||||||
|
A minimal Flask server demonstrating ribbit's collaboration features:
|
||||||
|
real-time sync, presence, locking, and revisions.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install flask flask-sock
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy (or symlink) the ribbit dist into the static directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ln -s /path/to/ribbit/dist/ribbit static/ribbit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:5000 in multiple browser tabs. Edits in one tab
|
||||||
|
appear in the others in real time.
|
||||||
|
|
||||||
|
## What it demonstrates
|
||||||
|
|
||||||
|
- **Real-time sync**: WebSocket relays document updates between clients
|
||||||
|
- **Presence**: colored badges show connected users and their status
|
||||||
|
- **Revisions**: save button creates named revisions, click to restore
|
||||||
|
- **Locking**: (available via console: `editor.lockForEditing()`)
|
||||||
|
- **Source mode**: entering markdown mode pauses sync, shows remote change count
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser A ──┐
|
||||||
|
├── WebSocket ──→ Flask server ──→ WebSocket ──→ Browser B
|
||||||
|
Browser C ──┘ │
|
||||||
|
├── /api/revisions (REST)
|
||||||
|
└── /api/lock (REST)
|
||||||
|
```
|
||||||
|
|
||||||
|
The server is ~160 lines. In production you'd replace the in-memory
|
||||||
|
stores with a database and add authentication.
|
||||||
160
examples/flask-collab/server.py
Normal file
160
examples/flask-collab/server.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Flask collaboration server example for ribbit.
|
||||||
|
|
||||||
|
Demonstrates: WebSocket relay, presence, revisions, and locking.
|
||||||
|
Requires: flask, flask-sock
|
||||||
|
|
||||||
|
pip install flask flask-sock
|
||||||
|
python server.py
|
||||||
|
|
||||||
|
Then open http://localhost:5000 in multiple browser tabs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template, request
|
||||||
|
from flask_sock import Sock
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
sock = Sock(app)
|
||||||
|
|
||||||
|
# In-memory state (replace with a database in production)
|
||||||
|
document = {"content": "# Hello\n\nEdit this page collaboratively.\n\n- Try opening multiple tabs\n- Watch edits appear in real time\n"}
|
||||||
|
revisions = []
|
||||||
|
lock_holder = None
|
||||||
|
lock_mutex = Lock()
|
||||||
|
clients = {} # ws -> user info
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pages ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return render_template("index.html", content=document["content"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Revisions API ────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/revisions", methods=["GET"])
|
||||||
|
def list_revisions():
|
||||||
|
return jsonify([{k: v for k, v in r.items() if k != "content"} for r in revisions])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/revisions/<revision_id>", methods=["GET"])
|
||||||
|
def get_revision(revision_id):
|
||||||
|
for r in revisions:
|
||||||
|
if r["id"] == revision_id:
|
||||||
|
return jsonify(r)
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/revisions", methods=["POST"])
|
||||||
|
def create_revision():
|
||||||
|
data = request.json
|
||||||
|
rev = {
|
||||||
|
"id": str(uuid.uuid4())[:8],
|
||||||
|
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"author": data.get("author", "anonymous"),
|
||||||
|
"summary": data.get("summary", ""),
|
||||||
|
"content": data.get("content", document["content"]),
|
||||||
|
}
|
||||||
|
revisions.append(rev)
|
||||||
|
broadcast_json({"type": "revision", "revision": {k: v for k, v in rev.items() if k != "content"}})
|
||||||
|
return jsonify(rev), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ── Locking API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route("/api/lock", methods=["POST"])
|
||||||
|
def acquire_lock():
|
||||||
|
global lock_holder
|
||||||
|
with lock_mutex:
|
||||||
|
if lock_holder is None:
|
||||||
|
lock_holder = request.json
|
||||||
|
broadcast_json({"type": "lock", "holder": lock_holder})
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
return jsonify({"ok": False, "holder": lock_holder}), 409
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/lock", methods=["DELETE"])
|
||||||
|
def release_lock():
|
||||||
|
global lock_holder
|
||||||
|
with lock_mutex:
|
||||||
|
lock_holder = None
|
||||||
|
broadcast_json({"type": "lock", "holder": None})
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/lock/force", methods=["POST"])
|
||||||
|
def force_lock():
|
||||||
|
global lock_holder
|
||||||
|
with lock_mutex:
|
||||||
|
lock_holder = request.json
|
||||||
|
broadcast_json({"type": "lock", "holder": lock_holder})
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# ── WebSocket relay ──────────────────────────────────────
|
||||||
|
|
||||||
|
@sock.route("/ws")
|
||||||
|
def websocket(ws):
|
||||||
|
client_id = str(uuid.uuid4())[:8]
|
||||||
|
clients[client_id] = {"ws": ws, "user": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = ws.receive()
|
||||||
|
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
# Binary = document update, relay to all other clients
|
||||||
|
document["content"] = data.decode("utf-8")
|
||||||
|
for cid, client in clients.items():
|
||||||
|
if cid != client_id:
|
||||||
|
try:
|
||||||
|
client["ws"].send(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif isinstance(data, str):
|
||||||
|
msg = json.loads(data)
|
||||||
|
|
||||||
|
if msg.get("type") == "join":
|
||||||
|
clients[client_id]["user"] = msg.get("user")
|
||||||
|
# Send current document state
|
||||||
|
ws.send(document["content"].encode("utf-8"))
|
||||||
|
# Send current lock state
|
||||||
|
ws.send(json.dumps({"type": "lock", "holder": lock_holder}))
|
||||||
|
# Broadcast updated peer list
|
||||||
|
broadcast_peers()
|
||||||
|
|
||||||
|
elif msg.get("type") == "presence":
|
||||||
|
clients[client_id]["user"] = msg
|
||||||
|
broadcast_peers()
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
del clients[client_id]
|
||||||
|
broadcast_peers()
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_json(msg):
|
||||||
|
data = json.dumps(msg)
|
||||||
|
for client in clients.values():
|
||||||
|
try:
|
||||||
|
client["ws"].send(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def broadcast_peers():
|
||||||
|
peers = [c["user"] for c in clients.values() if c["user"]]
|
||||||
|
broadcast_json({"type": "peers", "peers": peers})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, port=5000)
|
||||||
164
examples/flask-collab/templates/index.html
Normal file
164
examples/flask-collab/templates/index.html
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Ribbit Collaboration Example</title>
|
||||||
|
<link rel="stylesheet" href="/static/ribbit/themes/ribbit-default/theme.css">
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; }
|
||||||
|
#peers { padding: 8px; background: #f0f0f0; border-radius: 4px; margin-bottom: 10px; font-size: 14px; }
|
||||||
|
#peers .peer { display: inline-block; padding: 2px 8px; border-radius: 3px; margin-right: 4px; color: white; }
|
||||||
|
#status { font-size: 12px; color: #666; margin-bottom: 10px; }
|
||||||
|
#revisions { margin-top: 20px; }
|
||||||
|
#revisions button { margin: 2px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Ribbit Collaboration Example</h1>
|
||||||
|
<div id="peers">No peers connected</div>
|
||||||
|
<div id="status"></div>
|
||||||
|
<article id="ribbit">{{ content }}</article>
|
||||||
|
<div id="revisions">
|
||||||
|
<h3>Revisions</h3>
|
||||||
|
<div id="revision-list">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/ribbit/ribbit.js"></script>
|
||||||
|
<script>
|
||||||
|
const userId = 'user-' + Math.random().toString(36).slice(2, 6);
|
||||||
|
const colors = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#f39c12', '#1abc9c'];
|
||||||
|
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
|
||||||
|
const ws = new WebSocket(`ws://${location.host}/ws`);
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
connect() {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'join',
|
||||||
|
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
disconnect() {},
|
||||||
|
send(update) { if (ws.readyState === 1) ws.send(update); },
|
||||||
|
onReceive(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (e.data instanceof Blob) {
|
||||||
|
e.data.arrayBuffer().then(buf => callback(new Uint8Array(buf)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async lock() {
|
||||||
|
const res = await fetch('/api/lock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, displayName: userId }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
},
|
||||||
|
unlock() { fetch('/api/lock', { method: 'DELETE' }); },
|
||||||
|
async forceLock() {
|
||||||
|
const res = await fetch('/api/lock/force', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId, displayName: userId }),
|
||||||
|
});
|
||||||
|
return res.ok;
|
||||||
|
},
|
||||||
|
onLockChange(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (typeof e.data === 'string') {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'lock') callback(msg.holder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const presence = {
|
||||||
|
send(info) {
|
||||||
|
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'presence', ...info }));
|
||||||
|
},
|
||||||
|
onUpdate(callback) {
|
||||||
|
ws.addEventListener('message', (e) => {
|
||||||
|
if (typeof e.data === 'string') {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.type === 'peers') callback(msg.peers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const revisions = {
|
||||||
|
async list() {
|
||||||
|
return (await fetch('/api/revisions')).json();
|
||||||
|
},
|
||||||
|
async get(id) {
|
||||||
|
return (await fetch(`/api/revisions/${id}`)).json();
|
||||||
|
},
|
||||||
|
async create(content, metadata) {
|
||||||
|
const res = await fetch('/api/revisions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, ...metadata }),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = new ribbit.Editor({
|
||||||
|
collaboration: {
|
||||||
|
transport,
|
||||||
|
presence,
|
||||||
|
revisions,
|
||||||
|
user: { userId, displayName: userId, color, status: 'active', lastActive: Date.now() },
|
||||||
|
},
|
||||||
|
on: {
|
||||||
|
peerChange({ peers }) {
|
||||||
|
const el = document.getElementById('peers');
|
||||||
|
if (peers.length === 0) {
|
||||||
|
el.innerHTML = 'No peers connected';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = peers.map(p =>
|
||||||
|
`<span class="peer" style="background:${p.color || '#999'}">${p.displayName} (${p.status})</span>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lockChange({ holder }) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = holder ? `🔒 Locked by ${holder.displayName}` : '';
|
||||||
|
},
|
||||||
|
remoteActivity({ count }) {
|
||||||
|
const el = document.getElementById('status');
|
||||||
|
el.textContent = `⚡ ${count} remote change${count > 1 ? 's' : ''} while in source mode`;
|
||||||
|
},
|
||||||
|
save({ markdown }) {
|
||||||
|
revisions.create(markdown, { author: userId, summary: 'Manual save' }).then(refreshRevisions);
|
||||||
|
},
|
||||||
|
revisionCreated() {
|
||||||
|
refreshRevisions();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.run();
|
||||||
|
|
||||||
|
async function refreshRevisions() {
|
||||||
|
const list = await editor.listRevisions();
|
||||||
|
const el = document.getElementById('revision-list');
|
||||||
|
if (list.length === 0) {
|
||||||
|
el.innerHTML = '<em>No revisions yet. Click Save to create one.</em>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = list.map(r =>
|
||||||
|
`<button onclick="restore('${r.id}')">${r.timestamp} by ${r.author}${r.summary ? ': ' + r.summary : ''}</button>`
|
||||||
|
).join('<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.restore = async function(id) {
|
||||||
|
await editor.restoreRevision(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshRevisions();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
225
src/ts/collaboration.ts
Normal file
225
src/ts/collaboration.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
/*
|
||||||
|
* collaboration.ts — real-time collaboration manager for ribbit.
|
||||||
|
*
|
||||||
|
* Manages document sync, presence, locking, and revision creation
|
||||||
|
* through consumer-provided interfaces. Ribbit never makes network
|
||||||
|
* calls — the consumer owns the network layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DocumentTransport, PresenceChannel, PeerInfo,
|
||||||
|
CollaborationSettings, RevisionProvider, Revision, RevisionMetadata,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export class CollaborationManager {
|
||||||
|
private transport: DocumentTransport;
|
||||||
|
private presence?: PresenceChannel;
|
||||||
|
private revisions?: RevisionProvider;
|
||||||
|
private user: PeerInfo;
|
||||||
|
private peers: PeerInfo[];
|
||||||
|
private connected: boolean;
|
||||||
|
private paused: boolean;
|
||||||
|
private remoteChangeCount: number;
|
||||||
|
private latestRemoteContent: string | null;
|
||||||
|
private baseContent: string | null;
|
||||||
|
private idleTimeout: number;
|
||||||
|
private idleTimer?: number;
|
||||||
|
private lockHolder: PeerInfo | null;
|
||||||
|
private onRemoteUpdate: (content: string) => void;
|
||||||
|
private onPeersChange: (peers: PeerInfo[]) => void;
|
||||||
|
private onLockChange: (holder: PeerInfo | null) => void;
|
||||||
|
private onRemoteActivity: (count: number) => void;
|
||||||
|
private receiveBuffer: Uint8Array[];
|
||||||
|
private throttleTimer?: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
settings: CollaborationSettings,
|
||||||
|
callbacks: {
|
||||||
|
onRemoteUpdate: (content: string) => void;
|
||||||
|
onPeersChange: (peers: PeerInfo[]) => void;
|
||||||
|
onLockChange: (holder: PeerInfo | null) => void;
|
||||||
|
onRemoteActivity: (count: number) => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
this.transport = settings.transport;
|
||||||
|
this.presence = settings.presence;
|
||||||
|
this.revisions = settings.revisions;
|
||||||
|
this.user = settings.user;
|
||||||
|
this.peers = [];
|
||||||
|
this.connected = false;
|
||||||
|
this.paused = false;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
this.baseContent = null;
|
||||||
|
this.idleTimeout = settings.idleTimeout ?? 30000;
|
||||||
|
this.lockHolder = null;
|
||||||
|
this.onRemoteUpdate = callbacks.onRemoteUpdate;
|
||||||
|
this.onPeersChange = callbacks.onPeersChange;
|
||||||
|
this.onLockChange = callbacks.onLockChange;
|
||||||
|
this.onRemoteActivity = callbacks.onRemoteActivity;
|
||||||
|
this.receiveBuffer = [];
|
||||||
|
|
||||||
|
this.transport.onReceive((update) => {
|
||||||
|
this.handleRemoteUpdate(update);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.presence) {
|
||||||
|
this.presence.onUpdate((peers) => {
|
||||||
|
this.peers = this.applyIdleStatus(peers);
|
||||||
|
this.onPeersChange(this.peers);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.transport.onLockChange) {
|
||||||
|
this.transport.onLockChange((holder) => {
|
||||||
|
this.lockHolder = holder;
|
||||||
|
this.onLockChange(holder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.connected) return;
|
||||||
|
this.transport.connect();
|
||||||
|
this.connected = true;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (!this.connected) return;
|
||||||
|
this.transport.disconnect();
|
||||||
|
this.connected = false;
|
||||||
|
this.peers = [];
|
||||||
|
this.paused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause applying remote updates (entering source mode).
|
||||||
|
* Updates are still received and counted.
|
||||||
|
*/
|
||||||
|
pause(currentContent: string): void {
|
||||||
|
this.paused = true;
|
||||||
|
this.baseContent = currentContent;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume applying remote updates (leaving source mode).
|
||||||
|
* If there were remote changes, creates a revision of the remote
|
||||||
|
* version before applying the local version (last-write-wins).
|
||||||
|
*/
|
||||||
|
async resume(localContent: string): Promise<void> {
|
||||||
|
if (this.paused && this.latestRemoteContent && this.revisions) {
|
||||||
|
await this.revisions.create(this.latestRemoteContent, {
|
||||||
|
author: 'auto',
|
||||||
|
summary: 'Auto-saved before source mode merge',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.paused = false;
|
||||||
|
this.baseContent = null;
|
||||||
|
this.remoteChangeCount = 0;
|
||||||
|
this.latestRemoteContent = null;
|
||||||
|
this.sendUpdate(localContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendUpdate(markdown: string): void {
|
||||||
|
if (!this.connected || this.paused) return;
|
||||||
|
const encoded = new TextEncoder().encode(markdown);
|
||||||
|
this.transport.send(encoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCursor(position: number): void {
|
||||||
|
if (!this.connected || !this.presence) return;
|
||||||
|
this.presence.send({
|
||||||
|
...this.user,
|
||||||
|
status: this.paused ? 'editing' : 'active',
|
||||||
|
lastActive: Date.now(),
|
||||||
|
cursor: position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async lock(): Promise<boolean> {
|
||||||
|
if (!this.transport.lock) return false;
|
||||||
|
return this.transport.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(): void {
|
||||||
|
this.transport.unlock?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceLock(): Promise<boolean> {
|
||||||
|
if (!this.transport.forceLock) return false;
|
||||||
|
return this.transport.forceLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
getLockHolder(): PeerInfo | null {
|
||||||
|
return this.lockHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeers(): PeerInfo[] {
|
||||||
|
return this.peers;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteChangeCount(): number {
|
||||||
|
return this.remoteChangeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPaused(): boolean {
|
||||||
|
return this.paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revision access — delegates to the consumer's RevisionProvider.
|
||||||
|
*/
|
||||||
|
async listRevisions(): Promise<Revision[]> {
|
||||||
|
if (!this.revisions) return [];
|
||||||
|
return this.revisions.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
|
if (!this.revisions) return null;
|
||||||
|
return this.revisions.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRevision(content: string, metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
|
if (!this.revisions) return null;
|
||||||
|
return this.revisions.create(content, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRemoteUpdate(update: Uint8Array): void {
|
||||||
|
const content = new TextDecoder().decode(update);
|
||||||
|
|
||||||
|
if (this.paused) {
|
||||||
|
this.remoteChangeCount++;
|
||||||
|
this.latestRemoteContent = content;
|
||||||
|
this.onRemoteActivity(this.remoteChangeCount);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.receiveBuffer.push(update);
|
||||||
|
if (this.throttleTimer !== undefined) return;
|
||||||
|
|
||||||
|
this.throttleTimer = window.setTimeout(() => {
|
||||||
|
this.throttleTimer = undefined;
|
||||||
|
if (this.receiveBuffer.length === 0) return;
|
||||||
|
const latest = this.receiveBuffer[this.receiveBuffer.length - 1];
|
||||||
|
this.receiveBuffer = [];
|
||||||
|
this.onRemoteUpdate(new TextDecoder().decode(latest));
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyIdleStatus(peers: PeerInfo[]): PeerInfo[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return peers.map(peer => ({
|
||||||
|
...peer,
|
||||||
|
status: peer.status === 'editing' ? 'editing'
|
||||||
|
: (now - peer.lastActive > this.idleTimeout ? 'idle' : 'active'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* events.ts — typed event emitter for the ribbit editor.
|
* events.ts — typed event emitter for the ribbit editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { RibbitTheme } from './types';
|
import type { RibbitTheme, PeerInfo, Revision } from './types';
|
||||||
|
|
||||||
export interface ContentPayload {
|
export interface ContentPayload {
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
|
@ -72,6 +72,43 @@ export interface RibbitEventMap {
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
ready: (payload: ReadyPayload) => void;
|
ready: (payload: ReadyPayload) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remote users connected, disconnected, or moved their cursors.
|
||||||
|
*
|
||||||
|
* editor.on('peerChange', ({ peers }) => {
|
||||||
|
* updateUserList(peers);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
peerChange: (payload: { peers: PeerInfo[] }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Document lock acquired or released.
|
||||||
|
*
|
||||||
|
* editor.on('lockChange', ({ holder }) => {
|
||||||
|
* if (holder) showBanner(`Locked by ${holder.displayName}`);
|
||||||
|
* else hideBanner();
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
lockChange: (payload: { holder: PeerInfo | null }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remote changes received while in source mode.
|
||||||
|
*
|
||||||
|
* editor.on('remoteActivity', ({ count }) => {
|
||||||
|
* statusBar.textContent = `${count} remote changes`;
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
remoteActivity: (payload: { count: number }) => void;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A revision was created.
|
||||||
|
*
|
||||||
|
* editor.on('revisionCreated', ({ revision }) => {
|
||||||
|
* console.log(`Revision ${revision.id} saved`);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
revisionCreated: (payload: { revision: Revision }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventName = keyof RibbitEventMap;
|
type EventName = keyof RibbitEventMap;
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,12 @@ export class RibbitEditor extends Ribbit {
|
||||||
|
|
||||||
wysiwyg(): void {
|
wysiwyg(): void {
|
||||||
if (this.getState() === this.states.WYSIWYG) return;
|
if (this.getState() === this.states.WYSIWYG) return;
|
||||||
|
const wasEditing = this.getState() === this.states.EDIT;
|
||||||
this.vim?.detach();
|
this.vim?.detach();
|
||||||
|
this.collaboration?.connect();
|
||||||
|
if (wasEditing && this.collaboration?.isPaused()) {
|
||||||
|
this.collaboration.resume(this.getMarkdown());
|
||||||
|
}
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||||
|
|
@ -241,6 +246,8 @@ export class RibbitEditor extends Ribbit {
|
||||||
this.element.contentEditable = 'true';
|
this.element.contentEditable = 'true';
|
||||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||||
this.vim?.attach(this.element);
|
this.vim?.attach(this.element);
|
||||||
|
this.collaboration?.connect();
|
||||||
|
this.collaboration?.pause(this.getMarkdown());
|
||||||
this.setState(this.states.EDIT);
|
this.setState(this.states.EDIT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,4 +273,5 @@ export { defaultTheme };
|
||||||
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
|
||||||
export { ToolbarManager } from './toolbar';
|
export { ToolbarManager } from './toolbar';
|
||||||
export { VimHandler } from './vim';
|
export { VimHandler } from './vim';
|
||||||
|
export { CollaborationManager } from './collaboration';
|
||||||
export type { MacroDef };
|
export type { MacroDef };
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import { HopDown } from './hopdown';
|
||||||
import { defaultTheme } from './default-theme';
|
import { defaultTheme } from './default-theme';
|
||||||
import { ThemeManager } from './theme-manager';
|
import { ThemeManager } from './theme-manager';
|
||||||
import { RibbitEmitter, type RibbitEventMap } from './events';
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
||||||
|
import { CollaborationManager } from './collaboration';
|
||||||
import { type MacroDef } from './macros';
|
import { type MacroDef } from './macros';
|
||||||
import { ToolbarManager } from './toolbar';
|
import { ToolbarManager } from './toolbar';
|
||||||
import type { RibbitTheme, ToolbarSlot } from './types';
|
import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types';
|
||||||
|
|
||||||
export interface RibbitSettings {
|
export interface RibbitSettings {
|
||||||
api?: unknown;
|
api?: unknown;
|
||||||
|
|
@ -20,6 +21,8 @@ export interface RibbitSettings {
|
||||||
toolbar?: ToolbarSlot[];
|
toolbar?: ToolbarSlot[];
|
||||||
/** Set to false to prevent auto-rendering the toolbar. Default true. */
|
/** Set to false to prevent auto-rendering the toolbar. Default true. */
|
||||||
autoToolbar?: boolean;
|
autoToolbar?: boolean;
|
||||||
|
/** Collaboration settings. Omit to disable. */
|
||||||
|
collaboration?: CollaborationSettings;
|
||||||
on?: Partial<RibbitEventMap>;
|
on?: Partial<RibbitEventMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,6 +42,7 @@ export class Ribbit {
|
||||||
converter: HopDown;
|
converter: HopDown;
|
||||||
themesPath: string;
|
themesPath: string;
|
||||||
toolbar: ToolbarManager;
|
toolbar: ToolbarManager;
|
||||||
|
collaboration?: CollaborationManager;
|
||||||
protected autoToolbar: boolean;
|
protected autoToolbar: boolean;
|
||||||
private emitter: RibbitEmitter;
|
private emitter: RibbitEmitter;
|
||||||
private macros: MacroDef[];
|
private macros: MacroDef[];
|
||||||
|
|
@ -99,6 +103,39 @@ export class Ribbit {
|
||||||
settings.toolbar,
|
settings.toolbar,
|
||||||
);
|
);
|
||||||
this.autoToolbar = settings.autoToolbar !== false;
|
this.autoToolbar = settings.autoToolbar !== false;
|
||||||
|
|
||||||
|
if (settings.collaboration) {
|
||||||
|
this.collaboration = new CollaborationManager(
|
||||||
|
settings.collaboration,
|
||||||
|
{
|
||||||
|
onRemoteUpdate: (content) => {
|
||||||
|
this.cachedMarkdown = content;
|
||||||
|
this.cachedHTML = null;
|
||||||
|
if (this.getState() !== this.states.VIEW) {
|
||||||
|
this.element.innerHTML = this.getHTML();
|
||||||
|
}
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
markdown: content,
|
||||||
|
html: this.getHTML(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPeersChange: (peers) => {
|
||||||
|
this.emitter.emit('peerChange', { peers });
|
||||||
|
},
|
||||||
|
onLockChange: (holder) => {
|
||||||
|
this.emitter.emit('lockChange', { holder });
|
||||||
|
if (holder && holder.userId !== settings.collaboration!.user.userId) {
|
||||||
|
this.toolbar.disable();
|
||||||
|
} else {
|
||||||
|
this.toolbar.enable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRemoteActivity: (count) => {
|
||||||
|
this.emitter.emit('remoteActivity', { count });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
||||||
|
|
@ -167,6 +204,7 @@ export class Ribbit {
|
||||||
|
|
||||||
view(): void {
|
view(): void {
|
||||||
if (this.getState() === this.states.VIEW) return;
|
if (this.getState() === this.states.VIEW) return;
|
||||||
|
this.collaboration?.disconnect();
|
||||||
this.element.innerHTML = this.getHTML();
|
this.element.innerHTML = this.getHTML();
|
||||||
this.setState(this.states.VIEW);
|
this.setState(this.states.VIEW);
|
||||||
this.element.contentEditable = 'false';
|
this.element.contentEditable = 'false';
|
||||||
|
|
@ -178,9 +216,60 @@ export class Ribbit {
|
||||||
this.cachedHTML = null;
|
this.cachedHTML = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyChange(): void {
|
async lockForEditing(): Promise<boolean> {
|
||||||
|
if (!this.collaboration) return false;
|
||||||
|
return this.collaboration.lock();
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockEditing(): void {
|
||||||
|
this.collaboration?.unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async forceLockEditing(): Promise<boolean> {
|
||||||
|
if (!this.collaboration) return false;
|
||||||
|
return this.collaboration.forceLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listRevisions(): Promise<Revision[]> {
|
||||||
|
if (!this.collaboration) return [];
|
||||||
|
return this.collaboration.listRevisions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
||||||
|
if (!this.collaboration) return null;
|
||||||
|
return this.collaboration.getRevision(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreRevision(id: string): Promise<void> {
|
||||||
|
if (!this.collaboration) return;
|
||||||
|
const revision = await this.collaboration.getRevision(id);
|
||||||
|
if (!revision) return;
|
||||||
|
this.cachedMarkdown = revision.content;
|
||||||
|
this.cachedHTML = null;
|
||||||
|
this.collaboration.sendUpdate(revision.content);
|
||||||
|
if (this.getState() !== this.states.VIEW) {
|
||||||
|
this.element.innerHTML = this.getHTML();
|
||||||
|
}
|
||||||
this.emitter.emit('change', {
|
this.emitter.emit('change', {
|
||||||
markdown: this.getMarkdown(),
|
markdown: revision.content,
|
||||||
|
html: this.getHTML(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
|
||||||
|
if (!this.collaboration) return null;
|
||||||
|
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
|
||||||
|
if (revision) {
|
||||||
|
this.emitter.emit('revisionCreated', { revision });
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyChange(): void {
|
||||||
|
const markdown = this.getMarkdown();
|
||||||
|
this.collaboration?.sendUpdate(markdown);
|
||||||
|
this.emitter.emit('change', {
|
||||||
|
markdown,
|
||||||
html: this.getHTML(),
|
html: this.getHTML(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,91 @@ export interface InlineTagDef {
|
||||||
export interface RibbitThemeFeatures {
|
export interface RibbitThemeFeatures {
|
||||||
sourceMode?: boolean;
|
sourceMode?: boolean;
|
||||||
vim?: boolean;
|
vim?: boolean;
|
||||||
|
collaboration?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport for syncing document changes between clients.
|
||||||
|
* The consumer implements this with their choice of network layer.
|
||||||
|
*
|
||||||
|
* { connect() { ws.open(); },
|
||||||
|
* disconnect() { ws.close(); },
|
||||||
|
* send(update) { ws.send(update); },
|
||||||
|
* onReceive(cb) { ws.onmessage = (e) => cb(e.data); } }
|
||||||
|
*/
|
||||||
|
export interface DocumentTransport {
|
||||||
|
connect(): void;
|
||||||
|
disconnect(): void;
|
||||||
|
send(update: Uint8Array): void;
|
||||||
|
onReceive(callback: (update: Uint8Array) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel for broadcasting cursor position and user presence.
|
||||||
|
* Optional — collaboration works without it.
|
||||||
|
*
|
||||||
|
* { send(info) { ws.send(JSON.stringify(info)); },
|
||||||
|
* onUpdate(cb) { ws.onmessage = (e) => cb(JSON.parse(e.data)); } }
|
||||||
|
*/
|
||||||
|
export interface PresenceChannel {
|
||||||
|
send(info: PeerInfo): void;
|
||||||
|
onUpdate(callback: (peers: PeerInfo[]) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerInfo {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
cursor?: number;
|
||||||
|
color?: string;
|
||||||
|
status: 'active' | 'editing' | 'idle';
|
||||||
|
lastActive: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollaborationSettings {
|
||||||
|
transport: DocumentTransport;
|
||||||
|
presence?: PresenceChannel;
|
||||||
|
user: PeerInfo;
|
||||||
|
/** Milliseconds before a peer is considered idle. Default 30000. */
|
||||||
|
idleTimeout?: number;
|
||||||
|
/** Provider for revision storage. Required for auto-revision on source mode exit. */
|
||||||
|
revisions?: RevisionProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentTransport {
|
||||||
|
connect(): void;
|
||||||
|
disconnect(): void;
|
||||||
|
send(update: Uint8Array): void;
|
||||||
|
onReceive(callback: (update: Uint8Array) => void): void;
|
||||||
|
lock?(): Promise<boolean>;
|
||||||
|
unlock?(): void;
|
||||||
|
forceLock?(): Promise<boolean>;
|
||||||
|
onLockChange?(callback: (holder: PeerInfo | null) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceChannel {
|
||||||
|
send(info: PeerInfo): void;
|
||||||
|
onUpdate(callback: (peers: PeerInfo[]) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionProvider {
|
||||||
|
/** List all revisions for the current document. */
|
||||||
|
list(): Promise<Revision[]>;
|
||||||
|
/** Get a specific revision's content. */
|
||||||
|
get(id: string): Promise<Revision & { content: string }>;
|
||||||
|
/** Create a new revision from the given content. */
|
||||||
|
create(content: string, metadata?: RevisionMetadata): Promise<Revision>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revision {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
author: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionMetadata {
|
||||||
|
summary?: string;
|
||||||
|
author: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
273
test/collaboration.test.ts
Normal file
273
test/collaboration.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import { ribbit, resetDOM } from './setup';
|
||||||
|
|
||||||
|
const r = ribbit();
|
||||||
|
|
||||||
|
function mockTransport() {
|
||||||
|
const receiveListeners: Array<(update: Uint8Array) => void> = [];
|
||||||
|
const lockListeners: Array<(holder: any) => void> = [];
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
sent: [] as Uint8Array[],
|
||||||
|
locked: false,
|
||||||
|
connect() { this.connected = true; },
|
||||||
|
disconnect() { this.connected = false; },
|
||||||
|
send(update: Uint8Array) { this.sent.push(update); },
|
||||||
|
onReceive(cb: (update: Uint8Array) => void) { receiveListeners.push(cb); },
|
||||||
|
simulateRemote(content: string) {
|
||||||
|
const encoded = new TextEncoder().encode(content);
|
||||||
|
receiveListeners.forEach(cb => cb(encoded));
|
||||||
|
},
|
||||||
|
lock: async function() { this.locked = true; return true; },
|
||||||
|
unlock() { this.locked = false; },
|
||||||
|
forceLock: async function() { this.locked = true; return true; },
|
||||||
|
onLockChange(cb: (holder: any) => void) { lockListeners.push(cb); },
|
||||||
|
simulateLock(holder: any) { lockListeners.forEach(cb => cb(holder)); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPresence() {
|
||||||
|
const listeners: Array<(peers: any[]) => void> = [];
|
||||||
|
return {
|
||||||
|
lastSent: null as any,
|
||||||
|
send(info: any) { this.lastSent = info; },
|
||||||
|
onUpdate(cb: (peers: any[]) => void) { listeners.push(cb); },
|
||||||
|
simulatePeers(peers: any[]) { listeners.forEach(cb => cb(peers)); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRevisions() {
|
||||||
|
const store: any[] = [];
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
list: async () => store,
|
||||||
|
get: async (id: string) => store.find((r: any) => r.id === id),
|
||||||
|
create: async (content: string, meta?: any) => {
|
||||||
|
const rev = { id: String(store.length + 1), timestamp: new Date().toISOString(), content, ...meta };
|
||||||
|
store.push(rev);
|
||||||
|
return rev;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationManager', () => {
|
||||||
|
beforeEach(() => resetDOM('initial'));
|
||||||
|
|
||||||
|
it('does not create manager without settings', () => {
|
||||||
|
const editor = new r.Editor({});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.collaboration).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates manager with settings', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(editor.collaboration).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connection lifecycle', () => {
|
||||||
|
it('connects on wysiwyg', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(transport.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connects on edit', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
expect(transport.connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects on view', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.view();
|
||||||
|
expect(transport.connected).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('source mode pausing', () => {
|
||||||
|
it('pauses on entering source mode', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
expect(editor.collaboration!.isPaused()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts remote changes while paused', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
transport.simulateRemote('change 1');
|
||||||
|
transport.simulateRemote('change 2');
|
||||||
|
expect(editor.collaboration!.getRemoteChangeCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires remoteActivity event while paused', (done) => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
on: { remoteActivity: ({ count }: any) => { if (count === 1) done(); } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
transport.simulateRemote('change');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes on switching to wysiwyg', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
editor.wysiwyg();
|
||||||
|
expect(editor.collaboration!.isPaused()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('locking', () => {
|
||||||
|
it('lock returns true', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(await editor.lockForEditing()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forceLock returns true', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
expect(await editor.forceLockEditing()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires lockChange event', (done) => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
on: { lockChange: ({ holder }: any) => { if (holder?.userId === 'alice') done(); } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
transport.simulateLock({ userId: 'alice', displayName: 'Alice', status: 'active', lastActive: Date.now() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('presence', () => {
|
||||||
|
it('sends cursor with status', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const presence = mockPresence();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now(), color: '#f00' } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
editor.collaboration!.sendCursor(42);
|
||||||
|
expect(presence.lastSent.status).toBe('active');
|
||||||
|
expect(presence.lastSent.cursor).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends editing status when paused', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const presence = mockPresence();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, presence, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.edit();
|
||||||
|
editor.collaboration!.sendCursor(10);
|
||||||
|
expect(presence.lastSent.status).toBe('editing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies idle status to peers', () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const presence = mockPresence();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, presence, idleTimeout: 100, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
presence.simulatePeers([
|
||||||
|
{ userId: 'a', displayName: 'A', status: 'active', lastActive: Date.now() - 200 },
|
||||||
|
{ userId: 'b', displayName: 'B', status: 'active', lastActive: Date.now() },
|
||||||
|
]);
|
||||||
|
const peers = editor.collaboration!.getPeers();
|
||||||
|
expect(peers[0].status).toBe('idle');
|
||||||
|
expect(peers[1].status).toBe('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revisions', () => {
|
||||||
|
it('lists revisions', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const revisions = mockRevisions();
|
||||||
|
await revisions.create('v1', { author: 'test' });
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const list = await editor.listRevisions();
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates revision', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const revisions = mockRevisions();
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
const rev = await editor.createRevision({ author: 'test', summary: 'test rev' });
|
||||||
|
expect(rev).toBeDefined();
|
||||||
|
expect(revisions.store).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores revision', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const revisions = mockRevisions();
|
||||||
|
await revisions.create('old content', { author: 'test' });
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
editor.wysiwyg();
|
||||||
|
await editor.restoreRevision('1');
|
||||||
|
expect(editor.getMarkdown()).toBe('old content');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires revisionCreated event', async () => {
|
||||||
|
const transport = mockTransport();
|
||||||
|
const revisions = mockRevisions();
|
||||||
|
let fired = false;
|
||||||
|
const editor = new r.Editor({
|
||||||
|
collaboration: { transport, revisions, user: { userId: 'test', displayName: 'Test', status: 'active', lastActive: Date.now() } },
|
||||||
|
on: { revisionCreated: () => { fired = true; } },
|
||||||
|
});
|
||||||
|
editor.run();
|
||||||
|
await editor.createRevision({ author: 'test' });
|
||||||
|
expect(fired).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -12,6 +12,12 @@ export function getWindow(): any {
|
||||||
(global as any).HTMLElement = _window.HTMLElement;
|
(global as any).HTMLElement = _window.HTMLElement;
|
||||||
(global as any).Node = _window.Node;
|
(global as any).Node = _window.Node;
|
||||||
(global as any).NodeFilter = _window.NodeFilter;
|
(global as any).NodeFilter = _window.NodeFilter;
|
||||||
|
(global as any).TextEncoder = _window.TextEncoder || require('util').TextEncoder;
|
||||||
|
(global as any).TextDecoder = _window.TextDecoder || require('util').TextDecoder;
|
||||||
|
|
||||||
|
const { TextEncoder, TextDecoder } = require('util');
|
||||||
|
_window.TextEncoder = TextEncoder;
|
||||||
|
_window.TextDecoder = TextDecoder;
|
||||||
|
|
||||||
const bundle = fs.readFileSync(
|
const bundle = fs.readFileSync(
|
||||||
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
|
path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user