@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
243 lines (215 loc) • 7.87 kB
text/typescript
import type { HighlightRule } from './composer';
import { createTextWalker } from './composer-dom';
export interface HighlightMatch {
start: number;
end: number;
class: string | undefined;
}
/** Escape special regex metacharacters in a literal string. */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Find all non-overlapping highlight ranges in `text` for the given rules.
*
* - A string rule is matched as a case-insensitive literal (regex metachars are escaped).
* - An object rule uses `new RegExp(pattern, flags ?? 'gi')`.
* - Overlapping ranges are resolved in earliest-start order; later ranges that
* overlap an already-claimed span are dropped.
* - Zero-width matches are skipped to avoid infinite loops.
* - Returns `[]` when nothing matches.
*/
export function findHighlightMatches(text: string, rules: HighlightRule[]): HighlightMatch[] {
const candidates: HighlightMatch[] = [];
for (const rule of rules) {
let regex: RegExp;
let cls: string | undefined;
if (typeof rule === 'string') {
regex = new RegExp(escapeRegex(rule), 'gi');
cls = undefined;
} else {
regex = new RegExp(rule.pattern, rule.flags ?? 'gi');
cls = rule.class;
}
// Reset lastIndex so we scan from the beginning regardless of flag state.
regex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Guard against zero-width matches to avoid infinite loops.
if (end === start) {
regex.lastIndex = start + 1;
continue;
}
candidates.push({ start, end, class: cls });
}
}
// Sort by start position (tie-break: earlier rule wins, which preserves insertion order).
candidates.sort((a, b) => a.start - b.start);
// Remove overlapping ranges (greedy earliest-start wins).
const result: HighlightMatch[] = [];
let frontier = -1; // end of the last accepted range
for (const c of candidates) {
if (c.start >= frontier) {
result.push(c);
frontier = c.end;
}
}
return result;
}
// ---------------------------------------------------------------------------
// CSS Custom Highlight API — ambient type declarations
// (jsdom does not ship these; they are defined here rather than in a lib to
// keep the footprint minimal and avoid tsconfig changes.)
// ---------------------------------------------------------------------------
declare global {
// CSS.highlights is defined on the CSS namespace object.
interface CSSNamespace {
highlights?: HighlightRegistry;
}
interface HighlightRegistry {
set(name: string, highlight: CSSHighlight): void;
delete(name: string): boolean;
has(name: string): boolean;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class CSSHighlight {
constructor(...ranges: Range[]);
}
}
/**
* Feature-detect whether the CSS Custom Highlight API is available in the
* current environment. Returns false in jsdom (decoration no-op).
*/
export function supportsHighlightAPI(): boolean {
try {
return (
typeof CSS !== 'undefined' &&
typeof CSS.highlights !== 'undefined' &&
typeof Highlight !== 'undefined'
);
} catch {
return false;
}
}
const HIGHLIGHT_NAME = 'kai-composer-highlight';
/**
* Apply highlight matches to a contenteditable element using the CSS Custom
* Highlight API. Maps text offsets (ZWSP-stripped concatenated text) to DOM
* Ranges spanning across text nodes, builds a `Highlight`, and registers it.
*
* If the API is unavailable (jsdom, older browsers), this is a strict no-op.
*
* @param root The contenteditable element.
* @param matches Pre-computed non-overlapping `{start, end}` ranges.
* @param zwsp The ZWSP character used in the editable (passed to avoid
* a circular import; callers pass ZWSP from composer-dom).
*/
export function applyHighlights(
root: HTMLElement,
matches: HighlightMatch[],
zwsp: string,
name = HIGHLIGHT_NAME,
): void {
if (!supportsHighlightAPI()) return;
const registry = CSS.highlights as HighlightRegistry;
if (matches.length === 0) {
registry.delete(name);
return;
}
// Build a flat list of {node, rawStart, rawEnd} segments mirroring
// how getFullText works, but retaining the raw (pre-stripped) offsets so
// we can set Range.setStart/setEnd correctly.
//
// Each entry records:
// textNode - the DOM Text node
// stripped - the node's textContent with ZWSP removed
// rawOffset - raw index in textContent where the stripped content starts
// at the node level (always 0 — we need per-char mapping when
// ZWSP chars are interspersed, so we build a per-node map)
// strippedStart - cumulative stripped-text offset at the start of this node
//
interface NodeEntry {
node: Text;
strippedStart: number; // cumulative stripped offset before this node
// Maps stripped-relative index i → raw textContent index
strippedToRaw: Int32Array;
}
const entries: NodeEntry[] = [];
let cursor = 0; // cumulative stripped offset
const ownerDoc = root.ownerDocument;
const walker = createTextWalker(root); // skip text inside entity pills
let node = walker.nextNode() as Text | null;
while (node) {
const raw = node.textContent ?? '';
// Build stripped→raw index map for this node.
const strippedIndices: number[] = [];
for (let ri = 0; ri < raw.length; ri++) {
if (raw[ri] !== zwsp) {
strippedIndices.push(ri);
}
}
if (strippedIndices.length > 0) {
const map = new Int32Array(strippedIndices.length);
for (let i = 0; i < strippedIndices.length; i++) map[i] = strippedIndices[i];
entries.push({ node, strippedStart: cursor, strippedToRaw: map });
cursor += strippedIndices.length;
}
node = walker.nextNode() as Text | null;
}
/**
* Convert a stripped-text offset to a {node, rawOffset} pair for Range API.
* Returns null if out of range.
*/
function resolve(
strippedOffset: number,
): { node: Text; rawOffset: number } | null {
// Binary-search for the entry that contains strippedOffset.
let lo = 0;
let hi = entries.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const e = entries[mid];
const eEnd = e.strippedStart + e.strippedToRaw.length;
if (strippedOffset < e.strippedStart) {
hi = mid - 1;
} else if (strippedOffset >= eEnd) {
lo = mid + 1;
} else {
const localIdx = strippedOffset - e.strippedStart;
return { node: e.node, rawOffset: e.strippedToRaw[localIdx] };
}
}
// strippedOffset is exactly at the end of the last entry — point after
// the last raw character of the last node.
if (entries.length > 0) {
const last = entries[entries.length - 1];
const localIdx = strippedOffset - last.strippedStart;
if (localIdx === last.strippedToRaw.length) {
// End position: raw offset is one past the last stripped char's raw index.
return {
node: last.node,
rawOffset: last.strippedToRaw[last.strippedToRaw.length - 1] + 1,
};
}
}
return null;
}
const ranges: Range[] = [];
for (const m of matches) {
const startPos = resolve(m.start);
const endPos = resolve(m.end);
if (!startPos || !endPos) continue;
const r = ownerDoc.createRange();
r.setStart(startPos.node, startPos.rawOffset);
r.setEnd(endPos.node, endPos.rawOffset);
ranges.push(r);
}
if (ranges.length === 0) {
registry.delete(name);
return;
}
const hl = new Highlight(...ranges);
registry.set(name, hl);
}