ribbit/src/ts/ribbit.ts

205 lines
5.9 KiB
TypeScript
Raw Normal View History

/*
* 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';
2026-04-28 23:08:20 -07:00
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[];
2026-04-28 23:08:20 -07:00
toolbar?: ToolbarSlot[];
/** Set to false to prevent auto-rendering the toolbar. Default true. */
autoToolbar?: boolean;
on?: Partial<RibbitEventMap>;
}
/**
* Read-only markdown viewer. Renders markdown content into an HTML element.
*/
export class Ribbit {
api: unknown;
element: HTMLElement;
states: Record<string, string>;
cachedHTML: string | null;
cachedMarkdown: string | null;
state: string | null;
changed: boolean;
theme: RibbitTheme;
themes: ThemeManager;
converter: HopDown;
themesPath: string;
2026-04-28 23:08:20 -07:00
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);
}
}
}
2026-04-28 23:08:20 -07:00
this.toolbar = new ToolbarManager(
this,
this.theme.tags || {},
this.macros,
settings.toolbar,
);
this.autoToolbar = settings.autoToolbar !== false;
}
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.on(event, callback);
}
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
this.emitter.off(event, callback);
}
run(): void {
this.element.classList.add('loaded');
2026-04-28 23:08:20 -07:00
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) + ';');
}