import { uid } from '../shared/dom'; import { RICH_TEXT_EDITOR_CHANGE_EVENT, RICH_TEXT_EDITOR_COMMAND_EVENT, type RichTextEditorChangeDetail, type RichTextEditorCommandDetail, type RichTextEditorOptions, } from './rich-text-editor.types'; const SELECTORS = { toolbar: '[data-c42-rte-toolbar]', content: '[data-c42-rte-content]', command: '[data-c42-rte-command]', } as const; /** Commands that need a value and so prompt/read one before executing. */ const VALUE_COMMANDS = new Set(['createLink', 'formatBlock', 'fontName', 'fontSize']); /** * Headless rich-text editor. Wires a toolbar of `document.execCommand` actions * to a `contenteditable` region, reflects each toggle command's state on the * toolbar buttons (`aria-pressed` + `data-active`) and emits change/command * events. It applies no visual styles. * * > `document.execCommand` is deprecated but remains the most broadly supported * > way to do inline formatting with native undo. Swap the engine by listening * > to `richtexteditor:command` if you need a custom model. * * Markup: * ```html *
*
* * * *
*
*
* ``` */ export class RichTextEditor { private readonly root: HTMLElement; private readonly toolbar: HTMLElement | null; private readonly content: HTMLElement; private readonly buttons: HTMLElement[]; private readonly getLinkUrl: () => string | null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: RichTextEditorOptions = {}) { const content = root.querySelector(SELECTORS.content); if (!content) { throw new Error('[42/rich-text-editor] Needs a [data-c42-rte-content] element.'); } this.root = root; this.content = content; this.toolbar = root.querySelector(SELECTORS.toolbar); this.buttons = Array.from(root.querySelectorAll(SELECTORS.command)); this.getLinkUrl = options.getLinkUrl ?? (() => (typeof window !== 'undefined' ? window.prompt('Enter URL') : null)); this.init(options.value); } private init(value?: string): void { this.content.setAttribute('contenteditable', 'true'); this.content.setAttribute('role', 'textbox'); this.content.setAttribute('aria-multiline', 'true'); if (!this.content.id) { this.content.id = uid('rte-content'); } if (this.toolbar) { this.toolbar.setAttribute('role', 'toolbar'); this.toolbar.setAttribute('aria-controls', this.content.id); } if (value !== undefined) { this.content.innerHTML = value; } this.buttons.forEach((button) => { if (button instanceof HTMLButtonElement && !button.hasAttribute('type')) { button.type = 'button'; } // Prevent the button from stealing the selection from the content area. const onMouseDown = (event: Event): void => event.preventDefault(); const onClick = (): void => this.runButton(button); button.addEventListener('mousedown', onMouseDown); button.addEventListener('click', onClick); this.cleanups.push( () => button.removeEventListener('mousedown', onMouseDown), () => button.removeEventListener('click', onClick), ); }); const onInput = (): void => { this.updateEmpty(); this.emitChange(); }; this.content.addEventListener('input', onInput); this.cleanups.push(() => this.content.removeEventListener('input', onInput)); const onSelectionChange = (): void => { if (this.selectionInside()) { this.updateToolbar(); } }; document.addEventListener('selectionchange', onSelectionChange); this.cleanups.push(() => document.removeEventListener('selectionchange', onSelectionChange)); this.updateEmpty(); this.updateToolbar(); } private selectionInside(): boolean { const selection = document.getSelection?.(); const node = selection?.anchorNode; return node ? this.content.contains(node) : false; } private runButton(button: HTMLElement): void { const command = button.dataset.c42RteCommand; if (!command) { return; } let value = button.dataset.value; if (VALUE_COMMANDS.has(command) && value === undefined) { if (command === 'createLink') { const url = this.getLinkUrl(); if (!url) { return; } value = url; } } this.format(command, value); } private exec(command: string, value?: string): boolean { if (typeof document.execCommand !== 'function') { return false; } this.content.focus(); let ok = false; try { ok = document.execCommand(command, false, value); } catch { ok = false; } return ok; } private updateToolbar(): void { this.buttons.forEach((button) => { const command = button.dataset.c42RteCommand; if (!command || typeof document.queryCommandState !== 'function') { return; } let active = false; try { active = document.queryCommandState(command); } catch { return; } button.setAttribute('aria-pressed', String(active)); button.dataset.active = String(active); }); } private updateEmpty(): void { const empty = !this.content.textContent?.trim(); this.content.toggleAttribute('data-empty', empty); } private emitChange(): void { const detail: RichTextEditorChangeDetail = { html: this.content.innerHTML }; this.root.dispatchEvent( new CustomEvent(RICH_TEXT_EDITOR_CHANGE_EVENT, { detail, bubbles: true }), ); } // ─── Public API ────────────────────────────────────────────────────────── /** Run a formatting command (optionally with a value), then sync UI + emit. */ format(command: string, value?: string): void { this.exec(command, value); const detail: RichTextEditorCommandDetail = { command, value }; this.root.dispatchEvent( new CustomEvent(RICH_TEXT_EDITOR_COMMAND_EVENT, { detail, bubbles: true }), ); this.updateToolbar(); this.updateEmpty(); this.emitChange(); } /** Current HTML of the editable region. */ getHTML(): string { return this.content.innerHTML; } /** Replace the editable region's HTML and emit a change. */ setHTML(html: string): void { this.content.innerHTML = html; this.updateEmpty(); this.emitChange(); } /** Focus the editable region. */ focus(): void { this.content.focus(); } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }