Add collaboration support
Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
This commit is contained in:
@@ -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'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
+38
-1
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+92
-3
@@ -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<RibbitEventMap>;
|
||||
}
|
||||
|
||||
@@ -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<K extends keyof RibbitEventMap>(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<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', {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user