Add collaboration support
Real-time collaboration through consumer-provided transport and presence interfaces. Also includes a sample backend app.
This commit is contained in:
+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(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user