From f76ebbf2e5f0f45624e3ab0fdbb65fc08db20925 Mon Sep 17 00:00:00 2001 From: gsb Date: Wed, 29 Apr 2026 01:35:06 +0000 Subject: [PATCH] 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. --- src/ts/events.ts | 111 ++++++++++++++++++++++++++++++++++++++++ src/ts/ribbit-editor.ts | 2 +- src/ts/ribbit.ts | 81 ++++++++++++++++++++++++++++- src/ts/theme-manager.ts | 7 +-- 4 files changed, 195 insertions(+), 6 deletions(-) create mode 100644 src/ts/events.ts diff --git a/src/ts/events.ts b/src/ts/events.ts new file mode 100644 index 0000000..d04a518 --- /dev/null +++ b/src/ts/events.ts @@ -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>; + + constructor() { + this.listeners = new Map(); + } + + /** + * Register a callback for an event. + */ + on(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(event: K, callback: RibbitEventMap[K]): void { + this.listeners.get(event)?.delete(callback); + } + + /** + * Emit an event, calling all registered callbacks with the payload. + */ + emit(event: K, ...args: Parameters): void { + for (const callback of this.listeners.get(event) || []) { + callback(...args); + } + } +} diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 95ff926..19d4574 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -40,7 +40,7 @@ export class RibbitEditor extends Ribbit { #bindEvents(): void { this.element.addEventListener('input', () => { if (this.state !== this.states.VIEW) { - this.changed = true; + this.notifyChange(); } }); } diff --git a/src/ts/ribbit.ts b/src/ts/ribbit.ts index 8b0b90c..f56b23b 100644 --- a/src/ts/ribbit.ts +++ b/src/ts/ribbit.ts @@ -5,6 +5,7 @@ import { HopDown } from './hopdown'; import { defaultTheme } from './default-theme'; import { ThemeManager } from './theme-manager'; +import { RibbitEmitter, type RibbitEventMap } from './events'; import type { RibbitTheme } from './types'; export interface RibbitSettings { @@ -14,6 +15,7 @@ export interface RibbitSettings { currentTheme?: string; themes?: RibbitTheme[]; themesPath?: string; + on?: Partial; } /** @@ -47,7 +49,12 @@ export class RibbitPlugin { * Read-only markdown viewer. Renders markdown content into an HTML element. * * 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(); */ export class Ribbit { @@ -63,11 +70,13 @@ export class Ribbit { themes: ThemeManager; converter: HopDown; themesPath: string; + private emitter: RibbitEmitter; 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.states = { VIEW: 'view', }; @@ -77,12 +86,16 @@ export class Ribbit { this.changed = false; this.enabledPlugins = {}; - this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme) => { + this.themes = new ThemeManager(defaultTheme, this.themesPath, (theme, previous) => { this.theme = theme; this.converter = theme.tags ? new HopDown({ tags: theme.tags }) : new HopDown(); this.cachedHTML = null; + this.emitter.emit('themeChange', { + current: theme, + previous, + }); if (this.getState() === this.states.VIEW) { this.state = null; this.view(); @@ -106,11 +119,45 @@ export class Ribbit { 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(event: K, callback: RibbitEventMap[K]): void { + this.emitter.on(event, callback); + } + + /** + * Remove a previously registered callback. + * + * editor.off('change', myHandler); + */ + off(event: K, callback: RibbitEventMap[K]): void { + this.emitter.off(event, callback); } run(): void { this.element.classList.add('loaded'); this.view(); + this.emitter.emit('ready', { + markdown: this.getMarkdown(), + html: this.getHTML(), + mode: this.state || 'view', + theme: this.theme, + }); } plugins(): RibbitPlugin[] { @@ -122,6 +169,7 @@ export class Ribbit { } setState(newState: string): void { + const previous = this.state; this.state = newState; Object.values(this.states).forEach(state => { if (state === newState) { @@ -130,6 +178,10 @@ export class Ribbit { this.element.classList.remove(state); } }); + this.emitter.emit('modeChange', { + current: newState, + previous, + }); } markdownToHTML(md: string): string { @@ -150,12 +202,37 @@ export class Ribbit { 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 { if (this.getState() === this.states.VIEW) return; this.element.innerHTML = this.getHTML(); this.setState(this.states.VIEW); 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(), + }); + } } /** diff --git a/src/ts/theme-manager.ts b/src/ts/theme-manager.ts index 68c8771..acdcd76 100644 --- a/src/ts/theme-manager.ts +++ b/src/ts/theme-manager.ts @@ -11,9 +11,9 @@ export class ThemeManager { private active: RibbitTheme; private themeLink: HTMLLinkElement | null; 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.disabled = new Set(); this.themeLink = null; @@ -74,9 +74,10 @@ export class ThemeManager { if (this.disabled.has(name)) { throw new Error(`Theme "${name}" is disabled. Call enable() first.`); } + const previous = this.active; this.active = theme; this.loadCSS(name); - this.onSwitch(theme); + this.onSwitch(theme, previous); } /**