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-29 09:02:38 -07:00
|
|
|
import { CollaborationManager } from './collaboration';
|
2026-04-28 20:18:19 -07:00
|
|
|
import { type MacroDef } from './macros';
|
2026-04-28 23:08:20 -07:00
|
|
|
import { ToolbarManager } from './toolbar';
|
2026-04-29 09:02:38 -07:00
|
|
|
import type { RibbitTheme, ToolbarSlot, CollaborationSettings, PeerInfo, Revision, RevisionMetadata } from './types';
|
2026-04-28 09:59:30 -07:00
|
|
|
|
|
|
|
|
export interface RibbitSettings {
|
|
|
|
|
api?: unknown;
|
|
|
|
|
editorId?: string;
|
2026-04-28 18:17:32 -07:00
|
|
|
currentTheme?: string;
|
|
|
|
|
themes?: RibbitTheme[];
|
|
|
|
|
themesPath?: string;
|
2026-04-28 20:03:58 -07:00
|
|
|
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;
|
2026-04-29 09:02:38 -07:00
|
|
|
/** Collaboration settings. Omit to disable. */
|
|
|
|
|
collaboration?: CollaborationSettings;
|
2026-04-28 18:35:06 -07:00
|
|
|
on?: Partial<RibbitEventMap>;
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-29 15:48:36 -07:00
|
|
|
* Base class providing read-only markdown rendering. RibbitEditor extends
|
|
|
|
|
* this with editing capabilities, so consumers who only need to display
|
|
|
|
|
* rendered markdown can use Ribbit directly and avoid loading editor code.
|
|
|
|
|
*
|
|
|
|
|
* const viewer = new Ribbit({ editorId: 'my-element' });
|
|
|
|
|
* viewer.run();
|
2026-04-28 09:59:30 -07:00
|
|
|
*/
|
|
|
|
|
export class Ribbit {
|
|
|
|
|
api: unknown;
|
|
|
|
|
element: HTMLElement;
|
|
|
|
|
states: Record<string, string>;
|
|
|
|
|
cachedHTML: string | null;
|
|
|
|
|
cachedMarkdown: string | null;
|
|
|
|
|
state: string | null;
|
2026-04-28 18:17:32 -07:00
|
|
|
theme: RibbitTheme;
|
|
|
|
|
themes: ThemeManager;
|
|
|
|
|
converter: HopDown;
|
|
|
|
|
themesPath: string;
|
2026-04-28 23:08:20 -07:00
|
|
|
toolbar: ToolbarManager;
|
2026-04-29 09:02:38 -07:00
|
|
|
collaboration?: CollaborationManager;
|
2026-04-28 23:08:20 -07:00
|
|
|
protected autoToolbar: boolean;
|
2026-04-28 18:35:06 -07:00
|
|
|
private emitter: RibbitEmitter;
|
2026-04-28 20:03:58 -07:00
|
|
|
private macros: MacroDef[];
|
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 20:03:58 -07:00
|
|
|
this.macros = settings.macros || [];
|
2026-04-28 09:59:30 -07:00
|
|
|
this.states = {
|
|
|
|
|
VIEW: 'view',
|
|
|
|
|
};
|
|
|
|
|
this.cachedHTML = null;
|
|
|
|
|
this.cachedMarkdown = null;
|
|
|
|
|
this.state = null;
|
|
|
|
|
|
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
|
2026-04-28 20:03:58 -07:00
|
|
|
? new HopDown({ tags: theme.tags, macros: this.macros })
|
|
|
|
|
: new HopDown({ macros: this.macros });
|
2026-04-28 18:17:32 -07:00
|
|
|
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
|
2026-04-28 20:03:58 -07:00
|
|
|
? new HopDown({ tags: this.theme.tags, macros: this.macros })
|
|
|
|
|
: new HopDown({ macros: this.macros });
|
2026-04-28 18:17:32 -07:00
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
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;
|
2026-04-29 09:02:38 -07:00
|
|
|
|
|
|
|
|
if (settings.collaboration) {
|
|
|
|
|
this.collaboration = new CollaborationManager(
|
|
|
|
|
settings.collaboration,
|
|
|
|
|
{
|
|
|
|
|
onRemoteUpdate: (content) => {
|
|
|
|
|
this.cachedMarkdown = content;
|
|
|
|
|
this.cachedHTML = null;
|
|
|
|
|
if (this.getState() !== this.states.VIEW) {
|
|
|
|
|
this.element.innerHTML = this.getHTML();
|
|
|
|
|
}
|
|
|
|
|
this.emitter.emit('change', {
|
|
|
|
|
markdown: content,
|
|
|
|
|
html: this.getHTML(),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
onPeersChange: (peers) => {
|
|
|
|
|
this.emitter.emit('peerChange', { peers });
|
|
|
|
|
},
|
|
|
|
|
onLockChange: (holder) => {
|
|
|
|
|
this.emitter.emit('lockChange', { holder });
|
|
|
|
|
if (holder && holder.userId !== settings.collaboration!.user.userId) {
|
|
|
|
|
this.toolbar.disable();
|
|
|
|
|
} else {
|
|
|
|
|
this.toolbar.enable();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onRemoteActivity: (count) => {
|
|
|
|
|
this.emitter.emit('remoteActivity', { count });
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-28 18:35:06 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Subscribe to editor events. Callbacks persist across mode switches.
|
|
|
|
|
*
|
|
|
|
|
* editor.on('change', ({ markdown, html }) => console.log(markdown));
|
|
|
|
|
* editor.on('save', ({ markdown }) => fetch('/api', { body: markdown }));
|
|
|
|
|
*/
|
2026-04-28 18:35:06 -07:00
|
|
|
on<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
|
|
|
this.emitter.on(event, callback);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Unsubscribe a previously registered event callback.
|
|
|
|
|
*
|
|
|
|
|
* const handler = (e) => console.log(e);
|
|
|
|
|
* editor.on('change', handler);
|
|
|
|
|
* editor.off('change', handler);
|
|
|
|
|
*/
|
2026-04-28 18:35:06 -07:00
|
|
|
off<K extends keyof RibbitEventMap>(event: K, callback: RibbitEventMap[K]): void {
|
|
|
|
|
this.emitter.off(event, callback);
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:04:20 -07:00
|
|
|
protected emitReady(): void {
|
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
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Initialize the viewer: render toolbar, switch to view mode, and
|
|
|
|
|
* fire the ready event. Call once after construction.
|
|
|
|
|
*
|
|
|
|
|
* const viewer = new Ribbit({ editorId: 'content' });
|
|
|
|
|
* viewer.run();
|
|
|
|
|
*/
|
2026-04-29 11:04:20 -07:00
|
|
|
run(): void {
|
|
|
|
|
this.element.classList.add('loaded');
|
|
|
|
|
if (this.autoToolbar) {
|
|
|
|
|
this.element.parentNode?.insertBefore(this.toolbar.render(), this.element);
|
|
|
|
|
}
|
|
|
|
|
this.view();
|
|
|
|
|
this.emitReady();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Current mode name ('view', 'edit', or 'wysiwyg').
|
|
|
|
|
*
|
|
|
|
|
* if (editor.getState() === 'wysiwyg') { ... }
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
getState(): string | null {
|
|
|
|
|
return this.state;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Transition to a new mode. Updates CSS classes on the editor element
|
|
|
|
|
* so themes can style each mode differently, and fires modeChange.
|
|
|
|
|
*
|
|
|
|
|
* editor.setState('edit');
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
setState(newState: string): void {
|
2026-04-28 18:35:06 -07:00
|
|
|
const previous = this.state;
|
2026-04-28 20:18:19 -07:00
|
|
|
if (previous) {
|
|
|
|
|
this.element.classList.remove(previous);
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
this.state = newState;
|
2026-04-28 20:18:19 -07:00
|
|
|
this.element.classList.add(newState);
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter.emit('modeChange', {
|
|
|
|
|
current: newState,
|
|
|
|
|
previous,
|
|
|
|
|
});
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* One-shot markdown→HTML conversion using the current theme's tags.
|
|
|
|
|
*
|
|
|
|
|
* const html = viewer.markdownToHTML('**hello**');
|
|
|
|
|
*/
|
|
|
|
|
markdownToHTML(markdown: string): string {
|
|
|
|
|
return this.converter.toHTML(markdown);
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Rendered HTML of the current content, cached until invalidated.
|
|
|
|
|
*
|
|
|
|
|
* document.getElementById('preview').innerHTML = viewer.getHTML();
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
getHTML(): string {
|
2026-04-28 20:18:19 -07:00
|
|
|
if (this.cachedHTML === null) {
|
2026-04-28 09:59:30 -07:00
|
|
|
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
|
|
|
|
}
|
|
|
|
|
return this.cachedHTML;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Raw markdown of the current content. In view mode this is the
|
|
|
|
|
* original text; in edit/wysiwyg mode it's derived from the DOM.
|
|
|
|
|
*
|
|
|
|
|
* fetch('/save', { body: editor.getMarkdown() });
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
getMarkdown(): string {
|
2026-04-28 20:18:19 -07:00
|
|
|
if (this.cachedMarkdown === null) {
|
2026-04-28 09:59:30 -07:00
|
|
|
this.cachedMarkdown = this.element.textContent || '';
|
|
|
|
|
}
|
|
|
|
|
return this.cachedMarkdown;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Emit a save event with the current content. Ribbit never persists
|
|
|
|
|
* data itself — the consumer handles storage in the callback.
|
|
|
|
|
*
|
|
|
|
|
* editor.on('save', ({ markdown }) => localStorage.setItem('doc', markdown));
|
|
|
|
|
* editor.save();
|
|
|
|
|
*/
|
2026-04-28 18:35:06 -07:00
|
|
|
save(): void {
|
|
|
|
|
this.emitter.emit('save', {
|
|
|
|
|
markdown: this.getMarkdown(),
|
|
|
|
|
html: this.getHTML(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Switch to read-only view mode. Renders markdown to HTML and
|
|
|
|
|
* disables contentEditable. Disconnects collaboration if active.
|
|
|
|
|
*
|
|
|
|
|
* editor.view();
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
view(): void {
|
|
|
|
|
if (this.getState() === this.states.VIEW) return;
|
2026-04-29 09:02:38 -07:00
|
|
|
this.collaboration?.disconnect();
|
2026-04-28 09:59:30 -07:00
|
|
|
this.element.innerHTML = this.getHTML();
|
|
|
|
|
this.setState(this.states.VIEW);
|
|
|
|
|
this.element.contentEditable = 'false';
|
|
|
|
|
}
|
2026-04-28 18:35:06 -07:00
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Force re-conversion on next getHTML()/getMarkdown() call.
|
|
|
|
|
* Call after programmatically changing element content.
|
|
|
|
|
*
|
|
|
|
|
* editor.element.innerHTML = newContent;
|
|
|
|
|
* editor.invalidateCache();
|
|
|
|
|
*/
|
2026-04-28 20:18:19 -07:00
|
|
|
invalidateCache(): void {
|
|
|
|
|
this.cachedMarkdown = null;
|
|
|
|
|
this.cachedHTML = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Request an advisory editing lock. Returns false if another user
|
|
|
|
|
* holds the lock. Requires a collaboration transport.
|
|
|
|
|
*
|
|
|
|
|
* if (await editor.lockForEditing()) { editor.wysiwyg(); }
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async lockForEditing(): Promise<boolean> {
|
|
|
|
|
if (!this.collaboration) return false;
|
|
|
|
|
return this.collaboration.lock();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Release the advisory editing lock.
|
|
|
|
|
*
|
|
|
|
|
* editor.unlockEditing();
|
|
|
|
|
* editor.view();
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
unlockEditing(): void {
|
|
|
|
|
this.collaboration?.unlock();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Steal the lock from another user. Use when an admin needs to
|
|
|
|
|
* override a stale lock.
|
|
|
|
|
*
|
|
|
|
|
* await editor.forceLockEditing();
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async forceLockEditing(): Promise<boolean> {
|
|
|
|
|
if (!this.collaboration) return false;
|
|
|
|
|
return this.collaboration.forceLock();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Fetch all saved revisions from the revision provider.
|
|
|
|
|
*
|
|
|
|
|
* const revisions = await editor.listRevisions();
|
|
|
|
|
* revisions.forEach(r => console.log(r.id, r.timestamp));
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async listRevisions(): Promise<Revision[]> {
|
|
|
|
|
if (!this.collaboration) return [];
|
|
|
|
|
return this.collaboration.listRevisions();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Fetch a single revision's content by ID.
|
|
|
|
|
*
|
|
|
|
|
* const rev = await editor.getRevision('abc-123');
|
|
|
|
|
* if (rev) { console.log(rev.content); }
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async getRevision(id: string): Promise<(Revision & { content: string }) | null> {
|
|
|
|
|
if (!this.collaboration) return null;
|
|
|
|
|
return this.collaboration.getRevision(id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Replace the editor content with a previous revision and broadcast
|
|
|
|
|
* the change to collaborators.
|
|
|
|
|
*
|
|
|
|
|
* await editor.restoreRevision('abc-123');
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async restoreRevision(id: string): Promise<void> {
|
|
|
|
|
if (!this.collaboration) return;
|
|
|
|
|
const revision = await this.collaboration.getRevision(id);
|
|
|
|
|
if (!revision) return;
|
|
|
|
|
this.cachedMarkdown = revision.content;
|
2026-04-29 11:12:45 -07:00
|
|
|
this.cachedHTML = this.markdownToHTML(revision.content);
|
2026-04-29 09:02:38 -07:00
|
|
|
this.collaboration.sendUpdate(revision.content);
|
|
|
|
|
if (this.getState() !== this.states.VIEW) {
|
2026-04-29 11:12:45 -07:00
|
|
|
this.element.innerHTML = this.cachedHTML;
|
2026-04-29 09:02:38 -07:00
|
|
|
}
|
|
|
|
|
this.emitter.emit('change', {
|
|
|
|
|
markdown: revision.content,
|
2026-04-29 11:12:45 -07:00
|
|
|
html: this.cachedHTML,
|
2026-04-29 09:02:38 -07:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Snapshot the current content as a named revision. The revision
|
|
|
|
|
* provider stores it; ribbit never persists data itself.
|
|
|
|
|
*
|
|
|
|
|
* const rev = await editor.createRevision({ label: 'v1.0' });
|
|
|
|
|
*/
|
2026-04-29 09:02:38 -07:00
|
|
|
async createRevision(metadata?: RevisionMetadata): Promise<Revision | null> {
|
|
|
|
|
if (!this.collaboration) return null;
|
|
|
|
|
const revision = await this.collaboration.createRevision(this.getMarkdown(), metadata);
|
|
|
|
|
if (revision) {
|
|
|
|
|
this.emitter.emit('revisionCreated', { revision });
|
|
|
|
|
}
|
|
|
|
|
return revision;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Broadcast the current content to collaborators and fire the
|
|
|
|
|
* change event. Called automatically on input; call manually
|
|
|
|
|
* after programmatic content changes.
|
|
|
|
|
*
|
|
|
|
|
* editor.element.innerHTML = '<p>new content</p>';
|
|
|
|
|
* editor.notifyChange();
|
|
|
|
|
*/
|
2026-04-28 18:35:06 -07:00
|
|
|
notifyChange(): void {
|
2026-04-29 09:02:38 -07:00
|
|
|
const markdown = this.getMarkdown();
|
|
|
|
|
this.collaboration?.sendUpdate(markdown);
|
2026-04-28 18:35:06 -07:00
|
|
|
this.emitter.emit('change', {
|
2026-04-29 09:02:38 -07:00
|
|
|
markdown,
|
2026-04-28 18:35:06 -07:00
|
|
|
html: this.getHTML(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-28 09:59:30 -07:00
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Split a string into words and capitalize each one.
|
|
|
|
|
* Used to generate camelCase IDs for heading anchors.
|
|
|
|
|
*
|
|
|
|
|
* camelCase('hello world') // ['Hello', 'World']
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Decode HTML entities back to characters. Uses a textarea element
|
|
|
|
|
* because the browser's HTML parser handles all entity forms.
|
|
|
|
|
*
|
|
|
|
|
* decodeHtmlEntities('<b>') // '<b>'
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
export function decodeHtmlEntities(html: string): string {
|
|
|
|
|
const txt = document.createElement('textarea');
|
|
|
|
|
txt.innerHTML = html;
|
|
|
|
|
return txt.value;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 15:48:36 -07:00
|
|
|
/**
|
|
|
|
|
* Encode characters that would be interpreted as HTML into numeric
|
|
|
|
|
* entities. Used when displaying raw markdown in contentEditable
|
|
|
|
|
* (edit mode) so the browser doesn't parse it as markup.
|
|
|
|
|
*
|
|
|
|
|
* encodeHtmlEntities('<b>hi</b>') // '<b>hi</b>'
|
|
|
|
|
*/
|
2026-04-28 09:59:30 -07:00
|
|
|
export function encodeHtmlEntities(str: string): string {
|
|
|
|
|
return str.replace(/[\u00A0-\u9999<>&]/g, i => '&#' + i.charCodeAt(0) + ';');
|
|
|
|
|
}
|