2026-04-28 09:59:30 -07:00
|
|
|
/*
|
|
|
|
|
* ribbit.ts — core editor classes for the ribbit WYSIWYG markdown editor.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-28 18:17:32 -07:00
|
|
|
import { HopDown } from './hopdown';
|
|
|
|
|
import { defaultTheme } from './default-theme';
|
|
|
|
|
import { ThemeManager } from './theme-manager';
|
2026-04-28 18:35:06 -07:00
|
|
|
import { RibbitEmitter, type RibbitEventMap } from './events';
|
2026-04-28 18:17:32 -07:00
|
|
|
import type { RibbitTheme } from './types';
|
2026-04-28 09:59:30 -07:00
|
|
|
|
|
|
|
|
export interface RibbitSettings {
|
|
|
|
|
api?: unknown;
|
|
|
|
|
editorId?: string;
|
|
|
|
|
plugins?: Array<{ new(settings: { name: string; wiki: Ribbit }): RibbitPlugin; name: string }>;
|
2026-04-28 18:17:32 -07:00
|
|
|
currentTheme?: string;
|
|
|
|
|
themes?: RibbitTheme[];
|
|
|
|
|
themesPath?: string;
|
2026-04-28 18:35:06 -07:00
|
|
|
on?: Partial<RibbitEventMap>;
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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:
|
2026-04-28 18:35:06 -07:00
|
|
|
* const viewer = new Ribbit({
|
|
|
|
|
* editorId: 'my-element',
|
|
|
|
|
* on: {
|
|
|
|
|
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
|
|
|
|
|
* },
|
|
|
|
|
* });
|
2026-04-28 09:59:30 -07:00
|
|
|
* viewer.run();
|
|
|
|
|
*/
|
|
|
|
|
export class Ribbit {
|
|
|
|
|
api: unknown;
|
|
|
|
|
element: HTMLElement;
|
|
|
|
|
states: Record<string, string>;
|
|
|
|
|
cachedHTML: string | null;
|
|
|
|
|
cachedMarkdown: string | null;
|
|
|
|
|
state: string | null;
|
|
|
|
|
changed: boolean;
|
|
|
|
|
enabledPlugins: Record<string, RibbitPlugin>;
|
2026-04-28 18:17:32 -07:00
|
|
|
theme: RibbitTheme;
|
|
|
|
|
themes: ThemeManager;
|
|
|
|
|
converter: HopDown;
|
|
|
|
|
themesPath: string;
|
2026-04-28 18:35:06 -07:00
|
|
|
private emitter: RibbitEmitter;
|
2026-04-28 09:59:30 -07:00
|
|
|
|
|
|
|
|
constructor(settings: RibbitSettings) {
|
|
|
|
|
this.api = settings.api || null;
|
|
|
|
|
this.element = document.getElementById(settings.editorId || 'ribbit')!;
|
2026-04-28 18:17:32 -07:00
|
|
|
this.themesPath = settings.themesPath || './themes';
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter = new RibbitEmitter();
|
2026-04-28 09:59:30 -07:00
|
|
|
this.states = {
|
|
|
|
|
VIEW: 'view',
|
|
|
|
|
};
|
|
|
|
|
this.cachedHTML = null;
|
|
|
|
|
this.cachedMarkdown = null;
|
|
|
|
|
this.state = null;
|
|
|
|
|
this.changed = false;
|
|
|
|
|
this.enabledPlugins = {};
|
|
|
|
|
|
2026-04-28 18:35:06 -07:00
|
|
|
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
|
2026-04-28 18:17:32 -07:00
|
|
|
this.theme = theme;
|
|
|
|
|
this.converter = theme.tags
|
|
|
|
|
? new HopDown({ tags: theme.tags })
|
|
|
|
|
: new HopDown();
|
|
|
|
|
this.cachedHTML = null;
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter.emit('themeChange', {
|
|
|
|
|
current: theme,
|
|
|
|
|
previous,
|
|
|
|
|
});
|
2026-04-28 18:17:32 -07:00
|
|
|
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();
|
|
|
|
|
|
2026-04-28 09:59:30 -07:00
|
|
|
(settings.plugins || []).forEach(plugin => {
|
|
|
|
|
this.enabledPlugins[plugin.name] = new plugin({
|
|
|
|
|
name: plugin.name,
|
|
|
|
|
wiki: this,
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-04-28 18:35:06 -07:00
|
|
|
|
|
|
|
|
if (settings.on) {
|
|
|
|
|
for (const [event, handler] of Object.entries(settings.on)) {
|
|
|
|
|
if (handler) {
|
|
|
|
|
this.on(event as keyof RibbitEventMap, handler as any);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Register a callback for an event.
|
|
|
|
|
*
|
|
|
|
|
* editor.on('save', ({ markdown }) => {
|
|
|
|
|
* fetch('/api/save', { method: 'POST', body: markdown });
|
|
|
|
|
* });
|
|
|
|
|
*/
|
|
|
|
|
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
|
|
|
this.emitter.on(event, callback);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Remove a previously registered callback.
|
|
|
|
|
*
|
|
|
|
|
* editor.off('change', myHandler);
|
|
|
|
|
*/
|
|
|
|
|
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
|
|
|
this.emitter.off(event, callback);
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
run(): void {
|
|
|
|
|
this.element.classList.add('loaded');
|
|
|
|
|
this.view();
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter.emit('ready', {
|
|
|
|
|
markdown: this.getMarkdown(),
|
|
|
|
|
html: this.getHTML(),
|
|
|
|
|
mode: this.state || 'view',
|
|
|
|
|
theme: this.theme,
|
|
|
|
|
});
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
plugins(): RibbitPlugin[] {
|
|
|
|
|
return Object.values(this.enabledPlugins).sort((a, b) => a.precedence - b.precedence);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getState(): string | null {
|
|
|
|
|
return this.state;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setState(newState: string): void {
|
2026-04-28 18:35:06 -07:00
|
|
|
const previous = this.state;
|
2026-04-28 09:59:30 -07:00
|
|
|
this.state = newState;
|
|
|
|
|
Object.values(this.states).forEach(state => {
|
|
|
|
|
if (state === newState) {
|
|
|
|
|
this.element.classList.add(state);
|
|
|
|
|
} else {
|
|
|
|
|
this.element.classList.remove(state);
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter.emit('modeChange', {
|
|
|
|
|
current: newState,
|
|
|
|
|
previous,
|
|
|
|
|
});
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
markdownToHTML(md: string): string {
|
2026-04-28 18:17:32 -07:00
|
|
|
return this.converter.toHTML(md);
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 18:35:06 -07:00
|
|
|
/**
|
|
|
|
|
* Request a save. Fires the 'save' event with the current content.
|
|
|
|
|
* The consumer's callback handles persistence.
|
|
|
|
|
*
|
|
|
|
|
* editor.save(); // triggers on.save({ markdown, html })
|
|
|
|
|
*/
|
|
|
|
|
save(): void {
|
|
|
|
|
this.emitter.emit('save', {
|
|
|
|
|
markdown: this.getMarkdown(),
|
|
|
|
|
html: this.getHTML(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 09:59:30 -07:00
|
|
|
view(): void {
|
|
|
|
|
if (this.getState() === this.states.VIEW) return;
|
|
|
|
|
this.element.innerHTML = this.getHTML();
|
|
|
|
|
this.setState(this.states.VIEW);
|
|
|
|
|
this.element.contentEditable = 'false';
|
|
|
|
|
}
|
2026-04-28 18:35:06 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Notify that content has changed. Called internally by the editor
|
|
|
|
|
* on input events. Fires the 'change' event with current content.
|
|
|
|
|
*/
|
|
|
|
|
notifyChange(): void {
|
|
|
|
|
this.changed = true;
|
|
|
|
|
this.emitter.emit('change', {
|
|
|
|
|
markdown: this.getMarkdown(),
|
|
|
|
|
html: this.getHTML(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) + ';');
|
|
|
|
|
}
|