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 = [];
}
}