feat: Add typed event system with on/off/emit

New events with structured payloads:

  change({ markdown, html })
    Fires on every content edit.

  save({ markdown, html })
    Fires when editor.save() is called. Consumer handles persistence.

  modeChange({ current, previous })
    Fires on VIEW/EDIT/WYSIWYG transitions.

  themeChange({ current, previous })
    Fires when themes.set() switches the active theme.

  ready({ markdown, html, mode, theme })
    Fires after editor.run() completes first render.

Events can be registered in the constructor via the 'on' setting
or at any time via editor.on(event, callback) / editor.off().

202/202 tests passing.
This commit is contained in:
gsb 2026-04-29 01:35:06 +00:00
parent ac7a698c4f
commit f76ebbf2e5
4 changed files with 195 additions and 6 deletions

111
src/ts/events.ts Normal file
View File

@ -0,0 +1,111 @@
/*
* events.ts typed event emitter for the ribbit editor.
*/
import type { RibbitTheme } from './types';
export interface ContentPayload {
markdown: string;
html: string;
}
export interface ModeChangePayload {
current: string;
previous: string | null;
}
export interface ThemeChangePayload {
current: RibbitTheme;
previous: RibbitTheme;
}
export interface ReadyPayload {
markdown: string;
html: string;
mode: string;
theme: RibbitTheme;
}
export interface RibbitEventMap {
/*
* Content was modified. Fires on every edit.
*
* editor.on('change', ({ markdown }) => {
* localStorage.setItem('draft', markdown);
* });
*/
change: (payload: ContentPayload) => void;
/*
* Save requested via editor.save(), toolbar button, or Ctrl+S.
*
* editor.on('save', ({ markdown, html }) => {
* fetch('/api/save', { method: 'POST', body: markdown });
* });
*/
save: (payload: ContentPayload) => void;
/*
* Editor mode switched between view, edit, and wysiwyg.
*
* editor.on('modeChange', ({ current, previous }) => {
* toolbar.toggle(current !== 'view');
* main.classList.toggle('editing', current !== 'view');
* });
*/
modeChange: (payload: ModeChangePayload) => void;
/*
* Theme switched via editor.themes.set().
*
* editor.on('themeChange', ({ current, previous }) => {
* analytics.track('theme_switch', { from: previous.name, to: current.name });
* });
*/
themeChange: (payload: ThemeChangePayload) => void;
/*
* Editor initialized and first render complete.
*
* editor.on('ready', ({ mode, theme }) => {
* console.log(`Editor ready in ${mode} mode with ${theme.name} theme`);
* });
*/
ready: (payload: ReadyPayload) => void;
}
type EventName = keyof RibbitEventMap;
export class RibbitEmitter {
private listeners: Map<string, Set<Function>>;
constructor() {
this.listeners = new Map();
}
/**
* Register a callback for an event.
*/
on<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
/**
* Remove a previously registered callback.
*/
off<K extends EventName>(event: K, callback: RibbitEventMap[K]): void {
this.listeners.get(event)?.delete(callback);
}
/**
* Emit an event, calling all registered callbacks with the payload.
*/
emit<K extends EventName>(event: K, ...args: Parameters<RibbitEventMap[K]>): void {
for (const callback of this.listeners.get(event) || []) {
callback(...args);
}
}
}

View File

@ -40,7 +40,7 @@ export class RibbitEditor extends Ribbit {
#bindEvents(): void { #bindEvents(): void {
this.element.addEventListener('input', () => { this.element.addEventListener('input', () => {
if (this.state !== this.states.VIEW) { if (this.state !== this.states.VIEW) {
this.changed = true; this.notifyChange();
} }
}); });
} }

View File

@ -5,6 +5,7 @@
import { HopDown } from './hopdown'; import { HopDown } from './hopdown';
import { defaultTheme } from './default-theme'; import { defaultTheme } from './default-theme';
import { ThemeManager } from './theme-manager'; import { ThemeManager } from './theme-manager';
import { RibbitEmitter, type RibbitEventMap } from './events';
import type { RibbitTheme } from './types'; import type { RibbitTheme } from './types';
export interface RibbitSettings { export interface RibbitSettings {
@ -14,6 +15,7 @@ export interface RibbitSettings {
currentTheme?: string; currentTheme?: string;
themes?: RibbitTheme[]; themes?: RibbitTheme[];
themesPath?: string; themesPath?: string;
on?: Partial<RibbitEventMap>;
} }
/** /**
@ -47,7 +49,12 @@ export class RibbitPlugin {
* Read-only markdown viewer. Renders markdown content into an HTML element. * Read-only markdown viewer. Renders markdown content into an HTML element.
* *
* Usage: * Usage:
* const viewer = new Ribbit({ editorId: 'my-element' }); * const viewer = new Ribbit({
* editorId: 'my-element',
* on: {
* ready: ({ mode, theme }) => console.log(`Ready in ${mode}`),
* },
* });
* viewer.run(); * viewer.run();
*/ */
export class Ribbit { export class Ribbit {
@ -63,11 +70,13 @@ export class Ribbit {
themes: ThemeManager; themes: ThemeManager;
converter: HopDown; converter: HopDown;
themesPath: string; themesPath: string;
private emitter: RibbitEmitter;
constructor(settings: RibbitSettings) { constructor(settings: RibbitSettings) {
this.api = settings.api || null; this.api = settings.api || null;
this.element = document.getElementById(settings.editorId || 'ribbit')!; this.element = document.getElementById(settings.editorId || 'ribbit')!;
this.themesPath = settings.themesPath || './themes'; this.themesPath = settings.themesPath || './themes';
this.emitter = new RibbitEmitter();
this.states = { this.states = {
VIEW: 'view', VIEW: 'view',
}; };
@ -77,12 +86,16 @@ export class Ribbit {
this.changed = false; this.changed = false;
this.enabledPlugins = {}; this.enabledPlugins = {};
this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => { this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => {
this.theme = theme; this.theme = theme;
this.converter = theme.tags this.converter = theme.tags
? new HopDown({ tags: theme.tags }) ? new HopDown({ tags: theme.tags })
: new HopDown(); : new HopDown();
this.cachedHTML = null; this.cachedHTML = null;
this.emitter.emit('themeChange', {
current: theme,
previous,
});
if (this.getState() === this.states.VIEW) { if (this.getState() === this.states.VIEW) {
this.state = null; this.state = null;
this.view(); this.view();
@ -106,11 +119,45 @@ export class Ribbit {
wiki: this, wiki: this,
}); });
}); });
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);
} }
run(): void { run(): void {
this.element.classList.add('loaded'); this.element.classList.add('loaded');
this.view(); this.view();
this.emitter.emit('ready', {
markdown: this.getMarkdown(),
html: this.getHTML(),
mode: this.state || 'view',
theme: this.theme,
});
} }
plugins(): RibbitPlugin[] { plugins(): RibbitPlugin[] {
@ -122,6 +169,7 @@ export class Ribbit {
} }
setState(newState: string): void { setState(newState: string): void {
const previous = this.state;
this.state = newState; this.state = newState;
Object.values(this.states).forEach(state => { Object.values(this.states).forEach(state => {
if (state === newState) { if (state === newState) {
@ -130,6 +178,10 @@ export class Ribbit {
this.element.classList.remove(state); this.element.classList.remove(state);
} }
}); });
this.emitter.emit('modeChange', {
current: newState,
previous,
});
} }
markdownToHTML(md: string): string { markdownToHTML(md: string): string {
@ -150,12 +202,37 @@ export class Ribbit {
return this.cachedMarkdown; return this.cachedMarkdown;
} }
/**
* 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(),
});
}
view(): void { view(): void {
if (this.getState() === this.states.VIEW) return; if (this.getState() === this.states.VIEW) return;
this.element.innerHTML = this.getHTML(); this.element.innerHTML = this.getHTML();
this.setState(this.states.VIEW); this.setState(this.states.VIEW);
this.element.contentEditable = 'false'; this.element.contentEditable = 'false';
} }
/**
* 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(),
});
}
} }
/** /**

View File

@ -11,9 +11,9 @@ export class ThemeManager {
private active: RibbitTheme; private active: RibbitTheme;
private themeLink: HTMLLinkElement | null; private themeLink: HTMLLinkElement | null;
private themesPath: string; private themesPath: string;
private onSwitch: (theme: RibbitTheme) => void; private onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void;
constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme) => void) { constructor(initial: RibbitTheme, themesPath: string, onSwitch: (theme: RibbitTheme, previous: RibbitTheme) => void) {
this.registered = new Map(); this.registered = new Map();
this.disabled = new Set(); this.disabled = new Set();
this.themeLink = null; this.themeLink = null;
@ -74,9 +74,10 @@ export class ThemeManager {
if (this.disabled.has(name)) { if (this.disabled.has(name)) {
throw new Error(`Theme "${name}" is disabled. Call enable() first.`); throw new Error(`Theme "${name}" is disabled. Call enable() first.`);
} }
const previous = this.active;
this.active = theme; this.active = theme;
this.loadCSS(name); this.loadCSS(name);
this.onSwitch(theme); this.onSwitch(theme, previous);
} }
/** /**