UNPKG

@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
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); }