import { uid } from '../shared/dom'; import { getCaretCoordinates } from '../shared/textarea-caret'; import { toPreviewHTML, toggleLinePrefix, toggleMarker, stripFormatting, isMarkerActive, WHATSAPP_MARKERS, type WhatsappBlock, type WhatsappMarker, } from './whatsapp-markup'; import { WHATSAPP_EDITOR_CHANGE_EVENT, WHATSAPP_EDITOR_FORMAT_EVENT, type WhatsappEditorChangeDetail, type WhatsappEditorFormatDetail, type WhatsappEditorOptions, } from './whatsapp-editor.types'; const SELECTORS = { input: '[data-c42-wa-input]', toolbar: '[data-c42-wa-toolbar]', command: '[data-c42-wa-command]', floating: '[data-c42-wa-floating]', trigger: '[data-c42-wa-trigger]', picker: '[data-c42-wa-picker]', counter: '[data-c42-wa-counter]', preview: '[data-c42-wa-preview]', } as const; /** Keyboard shortcuts → marker (Cmd/Ctrl + key). */ const SHORTCUTS: Record = { b: 'bold', i: 'italic' }; function isMarker(value: string | undefined): value is WhatsappMarker { return value === 'bold' || value === 'italic' || value === 'strikethrough' || value === 'monospace'; } function isBlock(value: string | undefined): value is WhatsappBlock { return value === 'blockquote' || value === 'bullet' || value === 'ordered'; } /** Inline markers (with their glyphs) used to build the default floating menu. */ const DEFAULT_FLOATING_ITEMS: ReadonlyArray = [ ['bold', 'Bold', 'B'], ['italic', 'Italic', 'I'], ['strikethrough', 'Strikethrough', 'S'], ['monospace', 'Monospace', '</>'], ]; /** * Build the controller's default themed floating menu (used when `floating: true` * and the consumer did not supply their own `[data-c42-wa-floating]` element). * Buttons carry `data-c42-wa-command`, so the normal toolbar wiring picks them up. */ function createDefaultFloatingMenu(doc: Document): HTMLElement { const menu = doc.createElement('div'); menu.setAttribute('data-c42-wa-floating', ''); menu.className = 'c42-whatsapp-editor-floating'; menu.hidden = true; DEFAULT_FLOATING_ITEMS.forEach(([marker, label, glyph], i) => { // Group the inline-emphasis trio from the monospace action with a divider. if (i === DEFAULT_FLOATING_ITEMS.length - 1) { const sep = doc.createElement('span'); sep.className = 'c42-whatsapp-editor-separator'; sep.setAttribute('aria-hidden', 'true'); menu.appendChild(sep); } const button = doc.createElement('button'); button.type = 'button'; button.setAttribute('data-c42-wa-command', marker); button.className = 'c42-whatsapp-editor-command'; button.setAttribute('aria-label', label); button.innerHTML = glyph; // static, library-controlled glyph (no user input) menu.appendChild(button); }); return menu; } /** * Headless WhatsApp message editor. Enhances a ` * *
* * ``` */ export class WhatsappEditor { private readonly root: HTMLElement; private readonly input: HTMLTextAreaElement; private readonly toolbar: HTMLElement | null; private readonly buttons: HTMLElement[]; private readonly floating: HTMLElement | null; private readonly trigger: HTMLElement | null; private readonly picker: HTMLElement | null; private readonly counter: HTMLElement | null; private readonly preview: HTMLElement | null; private readonly maxLength: number; private injectedFloating: HTMLElement | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: WhatsappEditorOptions = {}) { const input = root.querySelector(SELECTORS.input); if (!input) { throw new Error('[42/whatsapp-editor] Needs a [data-c42-wa-input] textarea.'); } this.root = root; this.input = input; // Inject our default themed bubble menu when enabled and the consumer did // not supply their own. Must happen before the command buttons are collected // so the injected buttons get the same wiring. if (options.floating && !root.querySelector(SELECTORS.floating)) { this.injectedFloating = createDefaultFloatingMenu(root.ownerDocument); root.appendChild(this.injectedFloating); } this.toolbar = root.querySelector(SELECTORS.toolbar); this.buttons = Array.from(root.querySelectorAll(SELECTORS.command)); this.floating = root.querySelector(SELECTORS.floating); this.trigger = root.querySelector(SELECTORS.trigger); this.picker = root.querySelector(SELECTORS.picker); this.counter = root.querySelector(SELECTORS.counter); this.preview = root.querySelector(SELECTORS.preview); this.maxLength = options.maxLength ?? 4096; this.init(options.value); const emojisEnabled = options.emojis !== false; if (!emojisEnabled) { this.trigger?.setAttribute('hidden', ''); this.picker?.setAttribute('hidden', ''); } else if (options.renderPicker && this.picker) { options.renderPicker(this.picker, (emoji) => this.insertText(emoji)); } } private init(value?: string): void { this.input.setAttribute('maxlength', String(this.maxLength)); this.input.setAttribute('role', 'textbox'); this.input.setAttribute('aria-multiline', 'true'); if (!this.input.id) { this.input.id = uid('wa-input'); } if (value !== undefined) { this.input.value = value.slice(0, this.maxLength); } this.root.setAttribute('data-picker-open', 'false'); this.picker?.setAttribute('hidden', ''); if (this.toolbar) { this.toolbar.setAttribute('role', 'toolbar'); this.toolbar.setAttribute('aria-controls', this.input.id); } if (this.floating) { this.floating.setAttribute('role', 'toolbar'); this.floating.setAttribute('aria-controls', this.input.id); this.floating.setAttribute('aria-label', 'Selection formatting'); this.floating.setAttribute('data-state', 'closed'); this.floating.setAttribute('hidden', ''); } this.buttons.forEach((button) => { if (button instanceof HTMLButtonElement && !button.hasAttribute('type')) { button.type = 'button'; } const onMouseDown = (e: Event): void => e.preventDefault(); // keep selection const onClick = (): void => { const command = button.dataset.c42WaCommand; if (isMarker(command)) { this.format(command); } else if (isBlock(command)) { this.applyBlock(command); } else if (command === 'clear') { this.clearFormatting(); } }; this.listen(button, 'mousedown', onMouseDown); this.listen(button, 'click', onClick); }); this.listen(this.input, 'input', () => this.sync()); this.listen(this.input, 'keydown', (e) => { const ke = e as KeyboardEvent; if (!(ke.metaKey || ke.ctrlKey)) { return; } const marker = SHORTCUTS[ke.key.toLowerCase()]; if (marker) { ke.preventDefault(); this.format(marker); } }); // Floating selection menu + active-state reflection follow the selection. this.listen(document, 'selectionchange', () => this.updateSelectionUI()); this.listen(this.input, 'blur', () => this.hideFloating()); this.listen(this.input, 'scroll', () => { if (this.isFloatingOpen()) { this.positionFloating(); } }); this.listen(window, 'resize', () => this.hideFloating()); if (this.trigger) { if (this.trigger instanceof HTMLButtonElement && !this.trigger.hasAttribute('type')) { this.trigger.type = 'button'; } this.listen(this.trigger, 'click', () => this.togglePicker()); } this.listen(document, 'click', (e) => { if (this.isPickerOpen() && !this.root.contains(e.target as Node)) { this.closePicker(); } }); this.listen(document, 'keydown', (e) => { if ((e as KeyboardEvent).key !== 'Escape') { return; } if (this.isPickerOpen()) { this.closePicker(); } this.hideFloating(); }); this.sync(); } private listen(el: EventTarget, event: string, handler: (e: Event) => void): void { el.addEventListener(event, handler); this.cleanups.push(() => el.removeEventListener(event, handler)); } /** Recompute preview + counter and emit a change event. */ private sync(): void { const text = this.input.value; if (this.preview) { this.preview.innerHTML = toPreviewHTML(text); this.preview.toggleAttribute('data-empty', text.length === 0); } if (this.counter) { this.counter.textContent = `${text.length}/${this.maxLength}`; } const detail: WhatsappEditorChangeDetail = { text, html: this.preview ? this.preview.innerHTML : toPreviewHTML(text), length: text.length, }; this.root.dispatchEvent(new CustomEvent(WHATSAPP_EDITOR_CHANGE_EVENT, { detail, bubbles: true })); this.refreshActiveStates(); } // ─── Selection menu ──────────────────────────────────────────────────── /** Whether the floating selection toolbar is currently shown. */ isFloatingOpen(): boolean { return this.floating?.getAttribute('data-state') === 'open'; } /** * Driven by `selectionchange`: show + position the floating toolbar while a * non-empty selection lives in the textarea, hide it otherwise, and keep the * toolbar buttons' active states in sync. */ private updateSelectionUI(): void { this.refreshActiveStates(); if (!this.floating) { return; } const focused = this.input.ownerDocument.activeElement === this.input; const hasSelection = this.input.selectionEnd > this.input.selectionStart; if (focused && hasSelection) { this.showFloating(); } else { this.hideFloating(); } } private showFloating(): void { if (!this.floating) { return; } this.floating.removeAttribute('hidden'); this.floating.setAttribute('data-state', 'open'); this.positionFloating(); } private hideFloating(): void { if (!this.floating || !this.isFloatingOpen()) { return; } this.floating.setAttribute('data-state', 'closed'); this.floating.setAttribute('hidden', ''); } /** * Position the floating toolbar centered above the selection. Coordinates come * from the mirror-div caret helper, offset by the textarea's position within * the (relatively positioned) root and its scroll. CSS owns the final upward * shift via `transform`, so this only sets `left`/`top` in pixels. */ private positionFloating(): void { if (!this.floating) { return; } const { selectionStart, selectionEnd } = this.input; const startC = getCaretCoordinates(this.input, selectionStart); const endC = getCaretCoordinates(this.input, selectionEnd); const sameLine = Math.abs(startC.top - endC.top) < 1; const caretLeft = sameLine ? (startC.left + endC.left) / 2 : startC.left; const rawLeft = this.input.offsetLeft + caretLeft - this.input.scrollLeft; const top = this.input.offsetTop + Math.min(startC.top, endC.top) - this.input.scrollTop; // The menu is centered on `left` via a translate(-50%); clamp so its edges // stay inside the editor even when it is wide or the selection is near a side. const halfWidth = this.floating.offsetWidth / 2; const margin = 4; const minLeft = halfWidth + margin; const maxLeft = this.root.clientWidth - halfWidth - margin; const left = Math.min(Math.max(rawLeft, minLeft), Math.max(minLeft, maxLeft)); this.floating.style.left = `${left}px`; this.floating.style.top = `${top}px`; } /** * Reflect whether the current selection is wrapped by each inline marker on * every command button (toolbar + floating) via `aria-pressed` + `data-active` * so styles can highlight the active state. */ private refreshActiveStates(): void { const { value, selectionStart, selectionEnd } = this.input; for (const button of this.buttons) { const command = button.dataset.c42WaCommand; if (!isMarker(command)) { continue; } const active = isMarkerActive(value, selectionStart, selectionEnd, WHATSAPP_MARKERS[command]); button.setAttribute('aria-pressed', String(active)); button.toggleAttribute('data-active', active); } } // ─── Picker ──────────────────────────────────────────────────────────── isPickerOpen(): boolean { return this.root.getAttribute('data-picker-open') === 'true'; } togglePicker(): void { if (this.isPickerOpen()) { this.closePicker(); } else { this.openPicker(); } } openPicker(): void { this.root.setAttribute('data-picker-open', 'true'); this.picker?.removeAttribute('hidden'); } closePicker(): void { this.root.setAttribute('data-picker-open', 'false'); this.picker?.setAttribute('hidden', ''); } // ─── Public API ────────────────────────────────────────────────────────── /** Insert text (e.g. an emoji) at the caret, respecting maxLength. */ insertText(text: string): void { const start = this.input.selectionStart; const end = this.input.selectionEnd; const current = this.input.value; if (current.length - (end - start) + text.length > this.maxLength) { return; } this.input.value = current.slice(0, start) + text + current.slice(end); const pos = start + text.length; this.input.setSelectionRange(pos, pos); this.input.focus(); this.sync(); } /** Toggle an inline marker around the current selection. */ format(marker: WhatsappMarker): void { const char = WHATSAPP_MARKERS[marker]; const { value, selectionStart, selectionEnd } = toggleMarker( this.input.value, this.input.selectionStart, this.input.selectionEnd, char, ); if (value.length > this.maxLength) { return; } this.input.value = value; this.input.focus(); this.input.setSelectionRange(selectionStart, selectionEnd); const detail: WhatsappEditorFormatDetail = { marker, text: value }; this.root.dispatchEvent(new CustomEvent(WHATSAPP_EDITOR_FORMAT_EVENT, { detail, bubbles: true })); this.sync(); } /** * Toggle a line-prefix block (blockquote / bullet / numbered list) across the * line(s) spanned by the current selection. */ applyBlock(kind: WhatsappBlock): void { const { value, selectionStart, selectionEnd } = toggleLinePrefix( this.input.value, this.input.selectionStart, this.input.selectionEnd, kind, ); this.input.value = value; this.input.focus(); this.input.setSelectionRange(selectionStart, selectionEnd); const detail: WhatsappEditorFormatDetail = { marker: kind, text: value }; this.root.dispatchEvent(new CustomEvent(WHATSAPP_EDITOR_FORMAT_EVENT, { detail, bubbles: true })); this.sync(); } /** * Remove all WhatsApp formatting (inline markers + line-prefix blocks) from * the current selection, or from the whole message when nothing is selected. */ clearFormatting(): void { const value = this.input.value; const { selectionStart, selectionEnd } = this.input; const hasSelection = selectionEnd > selectionStart; const from = hasSelection ? selectionStart : 0; const to = hasSelection ? selectionEnd : value.length; const cleaned = stripFormatting(value.slice(from, to)); const newValue = value.slice(0, from) + cleaned + value.slice(to); if (newValue === value) { return; } this.input.value = newValue; this.input.focus(); this.input.setSelectionRange(from, from + cleaned.length); const detail: WhatsappEditorFormatDetail = { marker: 'clear', text: newValue }; this.root.dispatchEvent(new CustomEvent(WHATSAPP_EDITOR_FORMAT_EVENT, { detail, bubbles: true })); this.sync(); } /** Current WhatsApp markup (what you send). */ getText(): string { return this.input.value; } /** Current preview HTML (safe, escaped). */ getHTML(): string { return toPreviewHTML(this.input.value); } get value(): string { return this.input.value; } set value(v: string) { this.input.value = v.slice(0, this.maxLength); this.sync(); } /** Focus the textarea. */ focus(): void { this.input.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 = []; // Remove DOM we injected so destroy() leaves no side effects behind. this.injectedFloating?.remove(); this.injectedFloating = null; } }