/* * 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 type { RibbitTheme } from './types'; export interface RibbitSettings { api?: unknown; editorId?: string; plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>; currentTheme?: string; themes?: RibbitTheme[]; themesPath?: string; } /** * Base class for editor plugins. Subclass and override toHTML/toMarkdown * to add custom processing hooks. */ export class RibbitPlugin { name: string; wiki: Ribbit; precedence: number; constructor(settings: { name: string; wiki: Ribbit }) { this.name = settings.name; this.wiki = settings.wiki; this.precedence = 50; } setEditable(): void { } toMarkdown(html: string): string { return html; } toHTML(md: string): string { return md; } } /** * Read-only markdown viewer. Renders markdown content into an HTML element. * * Usage: * const viewer = new Ribbit({ editorId: 'my-element' }); * viewer.run(); */ export class Ribbit { api: unknown; element: HTMLElement; states: Record; cachedHTML: string | null; cachedMarkdown: string | null; state: string | null; changed: boolean; enabledPlugins: Record; theme: RibbitTheme; themes: ThemeManager; converter: HopDown; themesPath: string; constructor(settings: RibbitSettings) { this.api = settings.api || null; this.element = document.getElementById(settings.editorId || 'ribbit')!; this.themesPath = settings.themesPath || './themes'; this.states = { VIEW: 'view', }; this.cachedHTML = null; this.cachedMarkdown = null; this.state = null; this.changed = false; this.enabledPlugins = {}; this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => { this.theme = theme; this.converter = theme.tags ? new HopDown({ tags: theme.tags }) : new HopDown(); this.cachedHTML = null; 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 }) : new HopDown(); (settings.plugins || []).forEach(plugin => { this.enabledPlugins[plugin.name] = new plugin({ name: plugin.name, wiki: this, }); }); } run(): void { this.element.classList.add('loaded'); this.view(); } plugins(): RibbitPlugin[] { return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence); } getState(): string | null { return this.state; } setState(newState: string): void { this.state = newState; Object.values(this.states).forEach(state => { if (state === newState) { this.element.classList.add(state); } else { this.element.classList.remove(state); } }); } markdownToHTML(md: string): string { return this.converter.toHTML(md); } getHTML(): string { if (this.changed || !this.cachedHTML) { this.cachedHTML = this.markdownToHTML(this.getMarkdown()); } return this.cachedHTML; } getMarkdown(): string { if (!this.cachedMarkdown) { this.cachedMarkdown = this.element.textContent || ''; } return this.cachedMarkdown; } view(): void { if (this.getState() === this.states.VIEW) return; this.element.innerHTML = this.getHTML(); this.setState(this.states.VIEW); this.element.contentEditable = 'false'; } } /** * Convert a string to title case, splitting on whitespace. * Returns an array of capitalized words. */ 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 in a string using a textarea element. */ export function decodeHtmlEntities(html: string): string { const txt = document.createElement('textarea'); txt.innerHTML = html; return txt.value; } /** * Encode HTML-significant characters as numeric entities. */ export function encodeHtmlEntities(str: string): string { return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';'); }