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:
parent
ac7a698c4f
commit
f76ebbf2e5
111
src/ts/events.ts
Normal file
111
src/ts/events.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user