diff --git a/examples/flask-collab/README.md b/examples/flask-collab/README.md new file mode 100644 index 0000000..938e817 --- /dev/null +++ b/examples/flask-collab/README.md @@ -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. diff --git a/examples/flask-collab/server.py b/examples/flask-collab/server.py new file mode 100644 index 0000000..cda9536 --- /dev/null +++ b/examples/flask-collab/server.py @@ -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/", 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) diff --git a/examples/flask-collab/templates/index.html b/examples/flask-collab/templates/index.html new file mode 100644 index 0000000..eb7ac23 --- /dev/null +++ b/examples/flask-collab/templates/index.html @@ -0,0 +1,164 @@ + + + + + Ribbit Collaboration Example + + + + +

Ribbit Collaboration Example

+
No peers connected
+
+
{{ content }}
+
+

Revisions

+
Loading...
+
+ + + + + diff --git a/src/ts/collaboration.ts b/src/ts/collaboration.ts new file mode 100644 index 0000000..e05095b --- /dev/null +++ b/src/ts/collaboration.ts @@ -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 { + 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 { + if (!this.transport.lock) return false; + return this.transport.lock(); + } + + unlock(): void { + this.transport.unlock?.(); + } + + async forceLock(): Promise { + 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 { + 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 { + 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'), + })); + } +} diff --git a/src/ts/events.ts b/src/ts/events.ts index d04a518..e234fbb 100644 --- a/src/ts/events.ts +++ b/src/ts/events.ts @@ -2,7 +2,7 @@ * events.ts — typed event emitter for the ribbit editor. */ -import type { RibbitTheme } from './types'; +import type { RibbitTheme, PeerInfo, Revision } from './types'; export interface ContentPayload { markdown: string; @@ -72,6 +72,43 @@ export interface RibbitEventMap { * }); */ 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; diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index b9ac646..83fbbfa 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -220,7 +220,12 @@ export class RibbitEditor extends Ribbit { wysiwyg(): void { if (this.getState() === this.states.WYSIWYG) return; + const wasEditing = this.getState() === this.states.EDIT; this.vim?.detach(); + this.collaboration?.connect(); + if (wasEditing && this.collaboration?.isPaused()) { + this.collaboration.resume(this.getMarkdown()); + } this.element.contentEditable = 'true'; this.element.innerHTML = this.getHTML(); Array.from(this.element.querySelectorAll('.macro')).forEach(el => { @@ -241,6 +246,8 @@ export class RibbitEditor extends Ribbit { this.element.contentEditable = 'true'; this.element.innerHTML = encodeHtmlEntities(this.getMarkdown()); this.vim?.attach(this.element); + this.collaboration?.connect(); + this.collaboration?.pause(this.getMarkdown()); this.setState(this.states.EDIT); } @@ -266,4 +273,5 @@ export { defaultTheme }; export { camelCase, decodeHtmlEntities, encodeHtmlEntities }; export { ToolbarManager } from './toolbar'; export { VimHandler } from './vim'; +export { CollaborationManager } from './collaboration'; export type { MacroDef }; diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index 6dc551c..db29114 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -6,9 +6,10 @@ import { HopDown } from './hopdown'; import { defaultTheme } from './default-theme'; import { ThemeManager } from './theme-manager'; import { RibbitEmitter, type RibbitEventMap } from './events'; +import { CollaborationManager } from './collaboration'; import { type MacroDef } from './macros'; import { ToolbarManager } from './toolbar'; -import type { RibbitTheme, ToolbarSlot } from './types'; +import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types'; export interface RibbitSettings { api?: unknown; @@ -20,6 +21,8 @@ export interface RibbitSettings { toolbar?: ToolbarSlot[]; /** Set to false to prevent auto-rendering the toolbar. Default true. */ autoToolbar?: boolean; + /** Collaboration settings. Omit to disable. */ + collaboration?: CollaborationSettings; on?: Partial; } @@ -39,6 +42,7 @@ export class Ribbit { converter: HopDown; themesPath: string; toolbar: ToolbarManager; + collaboration?: CollaborationManager; protected autoToolbar: boolean; private emitter: RibbitEmitter; private macros: MacroDef[]; @@ -99,6 +103,39 @@ export class Ribbit { settings.toolbar, ); 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(event: K, callback: RibbitEventMap[K]): void { @@ -167,6 +204,7 @@ export class Ribbit { view(): void { if (this.getState() === this.states.VIEW) return; + this.collaboration?.disconnect(); this.element.innerHTML = this.getHTML(); this.setState(this.states.VIEW); this.element.contentEditable = 'false'; @@ -178,9 +216,60 @@ export class Ribbit { this.cachedHTML = null; } - notifyChange(): void { + async lockForEditing(): Promise { + if (!this.collaboration) return false; + return this.collaboration.lock(); + } + + unlockEditing(): void { + this.collaboration?.unlock(); + } + + async forceLockEditing(): Promise { + if (!this.collaboration) return false; + return this.collaboration.forceLock(); + } + + async listRevisions(): Promise { + 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 { + 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', { - markdown: this.getMarkdown(), + markdown: revision.content, + html: this.getHTML(), + }); + } + + async createRevision(metadata?: RevisionMetadata): Promise { + 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(), }); } diff --git a/src/ts/types.ts b/src/ts/types.ts index 2066155..2745f4e 100644 --- a/src/ts/types.ts +++ b/src/ts/types.ts @@ -69,6 +69,91 @@ export interface InlineTagDef { export interface RibbitThemeFeatures { sourceMode?: 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; + unlock?(): void; + forceLock?(): Promise; + 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; + /** Get a specific revision's content. */ + get(id: string): Promise; + /** Create a new revision from the given content. */ + create(content: string, metadata?: RevisionMetadata): Promise; +} + +export interface Revision { + id: string; + timestamp: string; + author: string; + summary?: string; +} + +export interface RevisionMetadata { + summary?: string; + author: string; } /** diff --git a/test/collaboration.test.ts b/test/collaboration.test.ts new file mode 100644 index 0000000..71a0efd --- /dev/null +++ b/test/collaboration.test.ts @@ -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); + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index eac3024..e43e2e4 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -12,6 +12,12 @@ export function getWindow(): any { (global as any).HTMLElement = _window.HTMLElement; (global as any).Node = _window.Node; (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( path.join(__dirname, '..', 'dist', 'ribbit', 'ribbit.js'), 'utf8'