/* * ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor. */ 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, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types'; export interface RibbitSettings { api?: unknown; editorId?: string; currentTheme?: string; themes?: RibbitTheme[]; themesPath?: string; macros?: MacroDef[]; toolbar?: ToolbarSlot[]; /** Set to false to prevent auto-rendering the toolbar. Default true. */ autoToolbar?: boolean; /** Collaboration settings. Omit to disable. */ collaboration?: CollaborationSettings; on?: Partial; } /** * Base class providing read-only markdown rendering. RibbitEditor extends * this with editing capabilities, so consumers who only need to display * rendered markdown can use Ribbit directly and avoid loading editor code. * * const viewer = new Ribbit({ editorId: 'my-element' }); * viewer.run(); */ export class Ribbit { api: unknown; element: HTMLElement; states: Record; state: string | null; theme: RibbitTheme; themes: ThemeManager; converter: HopDown; themesPath: string; toolbar: ToolbarManager; collaboration?: CollaborationManager; protected autoToolbar: boolean; private emitter: RibbitEmitter; private macros: MacroDef[]; // The markdown source as it existed before view() rendered it to HTML. // Set by subclasses (RibbitEditor) before overwriting element.innerHTML. // Allows getMarkdown() in view state to return the original source rather // than textContent of the rendered HTML (which strips delimiters). protected sourceMarkdown: string | null = null; constructor(settings: RibbitSettings) { this.api = settings.api || null; this.element = document.getElementById(settings.editorId || 'ribbit')!; this.themesPath = settings.themesPath || './themes'; this.emitter = new RibbitEmitter(); this.macros = settings.macros || []; this.states = { VIEW: 'view', }; this.state = null; this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.theme = theme; this.converter = theme.tags ? new HopDown({ tags: theme.tags, macros: this.macros }) : new HopDown({ macros: this.macros }); this.emitter.emit('themeChange', { current: theme, previous, }); if (this.getState() === this.states.VIEW) { this.state = null; this.view(); } }); (settings.themes || []).forEach(theme => { this.themes.add(theme); }); const activeName = settings.currentTheme || defaultTheme.name; this.themes.set(activeName); this.theme = this.themes.current(); this.converter = this.theme.tags ? new HopDown({ tags: this.theme.tags, macros: this.macros }) : new HopDown({ macros: this.macros }); if (settings.on) { for (const [event, handler] of Object.entries(settings.on)) { if (handler) { this.on(event as keyof RibbitEventMap, handler as any); } } } this.toolbar = new ToolbarManager( this, this.theme.tags || {}, this.macros, settings.toolbar, ); this.autoToolbar = settings.autoToolbar !== false; if (settings.collaboration) { this.collaboration = new CollaborationManager( settings.collaboration, { onRemoteUpdate: (content) => { this.sourceMarkdown = content; if (this.getState() !== this.states.VIEW) { this.element.innerHTML = this.markdownToHTML(content); } this.emitter.emit('change', { markdown: content, html: this.markdownToHTML(content), }); }, 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 }); }, }, ); } } /** * Subscribe to editor events. Callbacks persist across mode switches. * * editor.on('change', ({ markdown, html }) => console.log(markdown)); * editor.on('save', ({ markdown }) => fetch('/api', { body: markdown })); */ on(event: K, callback: RibbitEventMap[K]): void { this.emitter.on(event, callback); } /** * Unsubscribe a previously registered event callback. * * const handler = (e) => console.log(e); * editor.on('change', handler); * editor.off('change', handler); */ off(event: K, callback: RibbitEventMap[K]): void { this.emitter.off(event, callback); } protected emitReady(): void { this.emitter.emit('ready', { markdown: this.getMarkdown(), html: this.getHTML(), mode: this.state || 'view', theme: this.theme, }); } /** * Initialize the viewer: render toolbar, switch to view mode, and * fire the ready event. Call once after construction. * * const viewer = new Ribbit({ editorId: 'content' }); * viewer.run(); */ run(): void { this.element.classList.add('loaded'); if (this.autoToolbar) { this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); } this.view(); this.emitReady(); } /** * Current mode name ('view' or 'wysiwyg'). * * if (editor.getState() === 'wysiwyg') { ... } */ getState(): string | null { return this.state; } /** * Transition to a new mode. Updates CSS classes on the editor element * so themes can style each mode differently, and fires modeChange. * * editor.setState('wysiwyg'); */ setState(newState: string): void { const previous = this.state; if (previous) { this.element.classList.remove(previous); } this.state = newState; this.element.classList.add(newState); this.emitter.emit('modeChange', { current: newState, previous, }); } /** * One-shot markdown→HTML conversion using the current theme's tags. * * const html = viewer.markdownToHTML('**hello**'); */ markdownToHTML(markdown: string): string { return this.converter.toHTML(markdown); } /** * Rendered HTML of the current content. * * document.getElementById('preview').innerHTML = viewer.getHTML(); */ getHTML(): string { return this.markdownToHTML(this.getMarkdown()); } /** * Raw markdown of the current content. In view state reads from * sourceMarkdown if set (preserved before rendering overwrote the * element), otherwise falls back to element.textContent. * * fetch('/save', { body: editor.getMarkdown() }); */ getMarkdown(): string { if (this.sourceMarkdown !== null) { return this.sourceMarkdown; } return this.element.textContent || ''; } /** * Emit a save event with the current content. Ribbit never persists * data itself — the consumer handles storage in the callback. * * editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown)); * editor.save(); */ save(): void { this.emitter.emit('save', { markdown: this.getMarkdown(), html: this.getHTML(), }); } /** * Switch to read-only view mode. Renders markdown to HTML and * disables contentEditable. Disconnects collaboration if active. * * editor.view(); */ view(): void { if (this.getState() === this.states.VIEW) { return; } // Capture markdown before overwriting the element with rendered HTML. // getMarkdown() on the base class reads element.textContent when // sourceMarkdown is null — correct for the initial load case where // the element contains raw markdown text. this.sourceMarkdown = this.getMarkdown(); this.collaboration?.disconnect(); this.element.innerHTML = this.markdownToHTML(this.sourceMarkdown); this.setState(this.states.VIEW); this.element.contentEditable = 'false'; } /** * Request an advisory editing lock. Returns false if another user * holds the lock. Requires a collaboration transport. * * if (await editor.lockForEditing()) { editor.wysiwyg(); } */ async lockForEditing(): Promise { if (!this.collaboration) { return false; } return this.collaboration.lock(); } /** * Release the advisory editing lock. * * editor.unlockEditing(); * editor.view(); */ unlockEditing(): void { this.collaboration?.unlock(); } /** * Steal the lock from another user. Use when an admin needs to * override a stale lock. * * await editor.forceLockEditing(); */ async forceLockEditing(): Promise { if (!this.collaboration) { return false; } return this.collaboration.forceLock(); } /** * Fetch all saved revisions from the revision provider. * * const revisions = await editor.listRevisions(); * revisions.forEach(r => console.log(r.id, r.timestamp)); */ async listRevisions(): Promise { if (!this.collaboration) { return []; } return this.collaboration.listRevisions(); } /** * Fetch a single revision's content by ID. * * const rev = await editor.getRevision('abc-123'); * if (rev) { console.log(rev.content); } */ async getRevision(id: string): Promise<(Revision & { content: string }) | null> { if (!this.collaboration) { return null; } return this.collaboration.getRevision(id); } /** * Replace the editor content with a previous revision and broadcast * the change to collaborators. * * await editor.restoreRevision('abc-123'); */ async restoreRevision(id: string): Promise { if (!this.collaboration) { return; } const revision = await this.collaboration.getRevision(id); if (!revision) { return; } this.sourceMarkdown = revision.content; const html = this.markdownToHTML(revision.content); this.collaboration.sendUpdate(revision.content); if (this.getState() !== this.states.VIEW) { this.element.innerHTML = html; } this.emitter.emit('change', { markdown: revision.content, html, }); } /** * Snapshot the current content as a named revision. The revision * provider stores it; ribbit never persists data itself. * * const rev = await editor.createRevision({ label: 'v1.0' }); */ 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; } /** * Broadcast the current content to collaborators and fire the * change event. Called automatically on input; call manually * after programmatic content changes. * * editor.element.innerHTML = '

new content

'; * editor.notifyChange(); */ notifyChange(): void { const markdown = this.getMarkdown(); this.collaboration?.sendUpdate(markdown); this.emitter.emit('change', { markdown, html: this.getHTML(), }); } } /** * Split a string into words and capitalize each one. * Used to generate camelCase IDs for heading anchors. * * camelCase('hello world') // ['Hello', 'World'] */ export function camelCase(words: string): string[] { return words.trim().split(/\s+/g).map(word => { const lc = word.toLowerCase(); return lc.charAt(0).toUpperCase() + lc.slice(1); }); } /** * Decode HTML entities back to characters. Uses a textarea element * because the browser's HTML parser handles all entity forms. * * decodeHtmlEntities('<b>') // '' */ export function decodeHtmlEntities(html: string): string { const txt = document.createElement('textarea'); txt.innerHTML = html; return txt.value; } /** * Encode characters that would be interpreted as HTML into numeric * entities. Used when displaying raw markdown in contentEditable * so the browser doesn't parse it as markup. * * encodeHtmlEntities('hi') // '<b>hi</b>' */ export function encodeHtmlEntities(str: string): string { return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';'); }