import { CometChatTextFormatter } from '@cometchat/chat-uikit-react';
/** Class on every colored span, in the composer and in rendered messages. */
const COLOR_CLASS = 'cc-color';
/** Accepted color values: 3- to 6-digit hex. */
const HEX = '#[0-9a-fA-F]{3,6}';
/** Stored token: `{color:#e5484d}text{/color}`. */
const TOKEN_REGEX = new RegExp(`\\{color:(${HEX})\\}([\\s\\S]*?)\\{/color\\}`, 'g');
/**
* Upper bound on how many characters one keystroke may color. A fast typist can insert a few
* characters before a `keyup` arrives; a larger jump means the caret moved, not that text was typed.
*/
const MAX_TYPED_RUN = 16;
/** Content the formatter never colors: mentions and other atomic nodes, code, and links. */
const DEFAULT_PROTECTED_SELECTORS = ['[contenteditable="false"]', 'code', 'pre', 'a'];
export interface ColorFormatterOptions {
/** Extra CSS selectors whose content must not be colored (e.g. another formatter's spans). */
protectedSelectors?: string[];
}
/**
* Text color formatter for the CometChat React UI Kit.
*
* Works like a highlighter pen: pick a color and whatever you type next is colored, or select text
* and apply a color to it. In the composer, colored text is a `<span class="cc-color">`. On send it
* is stored as `{color:#hex}text{/color}`, and every surface this formatter is registered on renders
* that token back to colored text.
*
* Requires `enableRichTextEditor` on the composer. Share ONE instance between the composer and its
* toolbar button, and register the formatter on every surface that displays messages.
*/
export class ColorFormatter extends CometChatTextFormatter {
readonly id = 'color';
override priority = 60;
private readonly protectedSelectors: string[];
/** Pen color applied to newly typed text; `null` when the pen is off. */
private activeColor: string | null = null;
/** Set when the pen is turned off inside a colored run: the next typed char is moved out of it. */
private breakoutPending = false;
private readonly listeners = new Set<() => void>();
private selectionHandler: (() => void) | null = null;
private domObserver: MutationObserver | null = null;
private wasNonEmpty = false;
/**
* Caret offset after the last keystroke or click. `formatText` colors only the text typed since,
* so text that was already there is never recolored.
*/
private lastCaret = 0;
private trackedRoot: HTMLElement | null = null;
private readonly caretSync = (): void => {
this.lastCaret = this.getCaretPosition();
};
constructor(options: ColorFormatterOptions = {}) {
super();
this.protectedSelectors = [...DEFAULT_PROTECTED_SELECTORS, ...(options.protectedSelectors ?? [])];
}
// ── Composer lifecycle ─────────────────────────────────────────────────────
/** Called by the composer once `inputElementReference` is assigned. */
override initializeComposerTracking(): void {
const root = this.inputElementReference;
if (!root) return;
this.domObserver?.disconnect();
// Clicks move the caret without a keyup, so sync `lastCaret` on mouseup too.
if (this.trackedRoot) this.trackedRoot.removeEventListener('mouseup', this.caretSync);
this.trackedRoot = root;
root.addEventListener('mouseup', this.caretSync);
// Turn the pen off when the input goes from non-empty to empty (message sent or cleared).
this.domObserver = new MutationObserver(() => {
const empty = (root.textContent ?? '').trim() === '';
if (!empty) {
this.wasNonEmpty = true;
return;
}
if (this.wasNonEmpty) {
this.wasNonEmpty = false;
this.lastCaret = 0;
if (this.activeColor !== null || this.breakoutPending) {
this.activeColor = null;
this.breakoutPending = false;
this.notify();
}
}
});
this.domObserver.observe(root, { childList: true, subtree: true, characterData: true });
}
// ── Pen state ──────────────────────────────────────────────────────────────
/** Set the pen color for newly typed text, or `null` to turn the pen off. */
setActiveColor(color: string | null): void {
this.activeColor = color;
if (color === null && this.caretColorSpan()) this.breakoutPending = true;
this.notify();
}
getActiveColor(): string | null {
return this.activeColor;
}
/**
* Subscribe to changes of the pen or of the caret position. Shaped for React's
* `useSyncExternalStore(formatter.onColorChange, formatter.getCaretColor)`.
*/
onColorChange = (listener: () => void): (() => void) => {
if (this.listeners.size === 0) {
this.selectionHandler = () => this.notify();
document.addEventListener('selectionchange', this.selectionHandler);
}
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0 && this.selectionHandler) {
document.removeEventListener('selectionchange', this.selectionHandler);
this.selectionHandler = null;
}
};
};
/** Color of the text at the caret, or `null` when the caret is not in colored text. */
getCaretColor = (): string | null => {
const span = this.caretColorSpan();
return span ? this.colorOf(span) : null;
};
private notify(): void {
for (const listener of this.listeners) listener();
}
// ── Live typing ────────────────────────────────────────────────────────────
override onKeyUp(event: KeyboardEvent): void {
// Skip IME composition; the committed text arrives with a later keyup.
if (event.isComposing || event.keyCode === 229) return;
this.formatText();
this.lastCaret = this.getCaretPosition();
}
/** Color the text typed since the last keystroke according to the pen. */
override formatText(): void {
const root = this.inputElementReference;
if (!root) return;
const sel = this.selection();
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
const offset = range.startOffset;
if (!root.contains(node) || node.nodeType !== Node.TEXT_NODE || offset === 0) {
if (this.activeColor === null) this.breakoutPending = false;
return;
}
if (this.isProtected(node, root)) return;
const text = node as Text;
const span = this.closestColorSpan(text);
const caret = this.getCaretPosition();
// Pen was turned off inside a colored run: move the typed char out, uncolored.
if (this.breakoutPending) {
this.breakoutPending = false;
if (span) {
this.breakOutCharBefore(text, offset, span, null);
this.setCaretPosition(caret);
this.reRender();
}
return;
}
if (this.activeColor === null) return;
// The browser already typed into a run of the pen's color.
if (span && this.colorOf(span) === this.activeColor) return;
if (span) {
// Typed inside a run of a different color: move the char out and wrap it in the pen color.
this.breakOutCharBefore(text, offset, span, this.activeColor);
} else {
// Color everything typed since the last keystroke. A delta outside [1, min(offset, cap)]
// means the caret jumped, so color just the last char.
const delta = caret - this.lastCaret;
const runLen = delta >= 1 && delta <= MAX_TYPED_RUN && delta <= offset ? delta : 1;
this.wrapRunBefore(text, offset, runLen, this.activeColor);
}
this.setCaretPosition(caret);
this.reRender();
}
// ── Selection actions ──────────────────────────────────────────────────────
/** Apply `color` to the current selection, replacing any color already there. */
applyColorToSelection(color: string): void {
const root = this.inputElementReference;
if (!root) return;
const sel = this.selection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
const spans: HTMLElement[] = [];
for (const { node, start, end } of this.collectSlices(sel.getRangeAt(0), root)) {
const existing = this.closestColorSpan(node);
spans.push(
existing
? this.recolorSlice(existing, node, start, end, color)
: this.wrapSlice(node, start, end, color)
);
}
if (spans.length === 0) return;
this.mergeRun(spans);
this.reselect(spans);
this.reRender();
}
/** Remove color from the current selection. Colored text outside the selection keeps its color. */
clearColorInSelection(): void {
const root = this.inputElementReference;
if (!root) return;
const sel = this.selection();
if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return;
const bare: Text[] = [];
for (const { node, start, end } of this.collectSlices(sel.getRangeAt(0), root)) {
const existing = this.closestColorSpan(node);
bare.push(existing ? this.unwrapSlice(existing, node, start, end) : node);
}
const first = bare[0];
const last = bare[bare.length - 1];
if (first && last) {
const range = this.doc().createRange();
range.setStart(first, 0);
range.setEnd(last, last.length);
sel.removeAllRanges();
sel.addRange(range);
}
this.reRender();
}
// ── Token round-trip ───────────────────────────────────────────────────────
/**
* Token → HTML. Used by every display surface (the base `format()` delegates here) and to restore
* colors after a paste.
*/
override customLogicToFormatText(text: string): string {
return text.replace(TOKEN_REGEX, `<span class="${COLOR_CLASS}" style="color:$1">$2</span>`);
}
/** Composer HTML → token. Used on send and before a paste is sanitized. */
override getOriginalText(inputText?: string): string {
if (inputText === undefined) return super.getOriginalText();
if (!inputText.includes(COLOR_CLASS)) return inputText;
// Walk the DOM rather than using a regex: a colored run can contain another element (e.g. a
// mention), and a non-greedy `…</span>` regex would stop at that element's closing tag.
const doc = new DOMParser().parseFromString(inputText, 'text/html');
doc.querySelectorAll(`span.${COLOR_CLASS}`).forEach(el => {
const parent = el.parentNode;
if (!parent) return;
const color = this.colorOf(el as HTMLElement);
// Replace the span with `{color:#hex}` + its children + `{/color}`.
if (color) parent.insertBefore(doc.createTextNode(`{color:${color}}`), el);
while (el.firstChild) parent.insertBefore(el.firstChild, el);
if (color) parent.insertBefore(doc.createTextNode('{/color}'), el);
parent.removeChild(el);
});
return doc.body.innerHTML;
}
// ── DOM helpers ────────────────────────────────────────────────────────────
private doc(): Document {
return this.inputElementReference?.ownerDocument ?? document;
}
private selection(): Selection | null {
return this.doc().defaultView?.getSelection() ?? null;
}
private colorOf(el: HTMLElement): string {
return new RegExp(`color:\\s*(${HEX})`).exec(el.getAttribute('style') ?? '')?.[1] ?? '';
}
private closestColorSpan(node: Node): HTMLElement | null {
let el: Node | null = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
const root = this.inputElementReference;
while (el && el !== root) {
if (el.nodeType === Node.ELEMENT_NODE && (el as HTMLElement).classList.contains(COLOR_CLASS)) {
return el as HTMLElement;
}
el = el.parentNode;
}
return null;
}
private caretColorSpan(): HTMLElement | null {
const sel = this.selection();
if (!sel || sel.rangeCount === 0) return null;
const node = sel.getRangeAt(0).startContainer;
return this.inputElementReference?.contains(node) ? this.closestColorSpan(node) : null;
}
private isProtected(node: Node, root: HTMLElement): boolean {
let el = node.parentElement;
while (el && el !== root) {
const current = el;
if (this.protectedSelectors.some(selector => current.matches(selector))) return true;
el = el.parentElement;
}
return false;
}
private makeSpan(color: string): HTMLElement {
const span = this.doc().createElement('span');
span.className = COLOR_CLASS;
span.setAttribute('style', `color:${color}`);
return span;
}
/** Wrap `[start, end)` of a text node in a new colored span. */
private wrapSlice(node: Text, start: number, end: number, color: string): HTMLElement {
const mid = start > 0 ? node.splitText(start) : node;
if (end - start < mid.length) mid.splitText(end - start);
const span = this.makeSpan(color);
mid.parentNode?.insertBefore(span, mid);
span.appendChild(mid);
return span;
}
/** Recolor `[start, end)` of a run; the text before and after keeps the old color. */
private recolorSlice(
mark: HTMLElement,
node: Text,
start: number,
end: number,
color: string
): HTMLElement {
const parent = mark.parentNode;
if (!parent) return this.makeSpan(color);
const mid = start > 0 ? node.splitText(start) : node;
if (end - start < mid.length) mid.splitText(end - start);
const trailing = this.detachTrailing(mark, mid);
const span = this.makeSpan(color);
span.appendChild(mid);
const anchor = mark.nextSibling;
parent.insertBefore(span, anchor);
if (trailing) parent.insertBefore(trailing, anchor);
if (!mark.firstChild) parent.removeChild(mark);
return span;
}
/** Uncolor `[start, end)` of a run; the text before and after keeps its color. */
private unwrapSlice(mark: HTMLElement, node: Text, start: number, end: number): Text {
const parent = mark.parentNode;
if (!parent) return node;
const mid = start > 0 ? node.splitText(start) : node;
if (end - start < mid.length) mid.splitText(end - start);
const trailing = this.detachTrailing(mark, mid);
const anchor = mark.nextSibling;
parent.insertBefore(mid, anchor);
if (trailing) parent.insertBefore(trailing, anchor);
if (!mark.firstChild) parent.removeChild(mark);
return mid;
}
/** Move everything after `from` inside `mark` into a clone of `mark`, which keeps the color. */
private detachTrailing(mark: HTMLElement, from: Node): HTMLElement | null {
let sib: ChildNode | null = from.nextSibling;
if (!sib) return null;
const trailing = mark.cloneNode(false) as HTMLElement;
while (sib) {
const next: ChildNode | null = sib.nextSibling;
trailing.appendChild(sib);
sib = next;
}
return trailing;
}
/** Color the `count` characters before `offset` and merge them into an adjacent same-color run. */
private wrapRunBefore(text: Text, offset: number, count: number, color: string): void {
const span = this.wrapSlice(text, Math.max(0, offset - count), offset, color);
this.mergeRun([span]);
}
/**
* Move the char before `offset` out of `span`, re-wrapped in `color` (or left uncolored when
* `color` is null). Text after the char stays in the original color.
*/
private breakOutCharBefore(
text: Text,
offset: number,
span: HTMLElement,
color: string | null
): void {
const parent = span.parentNode;
if (!parent) return;
const charNode = offset - 1 > 0 ? text.splitText(offset - 1) : text;
if (charNode.length > 1) charNode.splitText(1);
const trailing = this.detachTrailing(span, charNode);
const anchor = span.nextSibling;
if (color) {
const newSpan = this.makeSpan(color);
newSpan.appendChild(charNode);
parent.insertBefore(newSpan, anchor);
this.mergeRun([newSpan]);
} else {
parent.insertBefore(charNode, anchor);
}
if (trailing) parent.insertBefore(trailing, anchor);
if (!span.firstChild) parent.removeChild(span);
}
/** Merge each span with an adjacent sibling of the same color. */
private mergeRun(spans: HTMLElement[]): void {
const isSameColor = (a: HTMLElement, b: Node | null): b is HTMLElement =>
!!b &&
b.nodeType === Node.ELEMENT_NODE &&
(b as HTMLElement).classList.contains(COLOR_CLASS) &&
this.colorOf(b as HTMLElement) === this.colorOf(a);
for (const span of spans) {
if (!span.isConnected) continue;
const prev = span.previousSibling;
if (isSameColor(span, prev)) {
while (span.firstChild) prev.appendChild(span.firstChild);
span.remove();
continue;
}
const next = span.nextSibling;
if (isSameColor(span, next)) {
while (next.firstChild) span.appendChild(next.firstChild);
next.remove();
}
}
}
/** Select from the first to the last of `spans`. */
private reselect(spans: HTMLElement[]): void {
const alive = spans.filter(s => s.isConnected);
const first = alive[0];
const last = alive[alive.length - 1];
if (!first || !last) return;
const range = this.doc().createRange();
range.setStart(first, 0);
range.setEnd(last, last.childNodes.length);
const sel = this.selection();
sel?.removeAllRanges();
sel?.addRange(range);
}
/** The selected part of each unprotected text node in `range`. */
private collectSlices(
range: Range,
root: HTMLElement
): { node: Text; start: number; end: number }[] {
const startNode = range.startContainer;
const endNode = range.endContainer;
const walker = this.doc().createTreeWalker(range.commonAncestorContainer, NodeFilter.SHOW_TEXT, {
acceptNode: n =>
range.intersectsNode(n) && (n.textContent ?? '') !== ''
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT,
});
const nodes: Text[] = [];
let n = walker.nextNode();
while (n) {
nodes.push(n as Text);
n = walker.nextNode();
}
if (nodes.length === 0 && startNode === endNode && startNode.nodeType === Node.TEXT_NODE) {
nodes.push(startNode as Text);
}
return nodes
.map(node => ({
node,
start: node === startNode ? range.startOffset : 0,
end: node === endNode ? range.endOffset : node.length,
}))
.filter(slice => slice.end > slice.start && !this.isProtected(slice.node, root));
}
}