Add collaboration support

Real-time collaboration through consumer-provided transport and
presence interfaces. Also includes a sample backend app.
This commit is contained in:
gsb
2026-04-29 22:15:02 +00:00
parent 2e28598243
commit 1198791505
10 changed files with 1097 additions and 4 deletions
+92 -3
View File
@@ -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(),
});
}