/* * 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 { type MacroDef } from './macros'; import { ToolbarManager } from './toolbar'; import type { RibbitTheme, ToolbarSlot } 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; on?: Partial; } /** * Read-only markdown viewer. Renders markdown content into an HTML element. */ export class Ribbit { api: unknown; element: HTMLElement; states: Record; cachedHTML: string | null; cachedMarkdown: string | null; state: string | null; changed: boolean; theme: RibbitTheme; themes: ThemeManager; converter: HopDown; themesPath: string; toolbar: ToolbarManager; protected autoToolbar: boolean; private emitter: RibbitEmitter; private macros: MacroDef[]; 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.cachedHTML = null; this.cachedMarkdown = null; this.state = null; this.changed = false; 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.cachedHTML = null; 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; } on(event: K, callback: RibbitEventMap[K]): void { this.emitter.on(event, callback); } off(event: K, callback: RibbitEventMap[K]): void { this.emitter.off(event, callback); } run(): void { this.element.classList.add('loaded'); if (this.autoToolbar) { this.element.parentNode?.insertBefore(this.toolbar.render(), this.element); } this.view(); this.emitter.emit('ready', { markdown: this.getMarkdown(), html: this.getHTML(), mode: this.state || 'view', theme: this.theme, }); } getState(): string | null { return this.state; } 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, }); } markdownToHTML(md: string): string { return this.converter.toHTML(md); } getHTML(): string { if (this.cachedHTML === null) { this.cachedHTML = this.markdownToHTML(this.getMarkdown()); } return this.cachedHTML; } getMarkdown(): string { if (this.cachedMarkdown === null) { this.cachedMarkdown = this.element.textContent || ''; } return this.cachedMarkdown; } save(): void { this.emitter.emit('save', { markdown: this.getMarkdown(), html: this.getHTML(), }); } view(): void { if (this.getState() === this.states.VIEW) return; this.element.innerHTML = this.getHTML(); this.setState(this.states.VIEW); this.element.contentEditable = 'false'; } invalidateCache(): void { this.changed = true; this.cachedMarkdown = null; this.cachedHTML = null; } notifyChange(): void { this.emitter.emit('change', { markdown: this.getMarkdown(), html: this.getHTML(), }); } } 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); }); } export function decodeHtmlEntities(html: string): string { const txt = document.createElement('textarea'); txt.innerHTML = html; return txt.value; } export function encodeHtmlEntities(str: string): string { return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';'); }