export type TextSegment = { text: string; highlight: boolean }; export type MatchOffset = { start: number; length: number }; /** * Converts a flat string and a list of character-level highlight offsets into * an array of text segments that can be rendered without {@html}. * * Offsets are sorted and merged (overlapping spans become the longest enclosing * span) before processing. Out-of-bounds offsets are clamped or dropped. * * @param text The display text (no delimiter characters). * @param offsets Character offsets produced by the backend (Java char positions, * compatible with JavaScript String indexing). */ export function applyOffsets(text: string, offsets: MatchOffset[]): TextSegment[] { if (!offsets.length) return [{ text, highlight: false }]; // Sort by start position and merge overlapping / adjacent spans const sorted = [...offsets].sort((a, b) => a.start - b.start); const merged: { start: number; end: number }[] = []; for (const { start, length } of sorted) { const end = start + length; if (end <= 0 || start >= text.length) continue; // completely out of bounds const clampedStart = Math.max(0, start); const clampedEnd = Math.min(text.length, end); const last = merged[merged.length - 1]; if (!last || clampedStart > last.end) { merged.push({ start: clampedStart, end: clampedEnd }); } else { last.end = Math.max(last.end, clampedEnd); } } if (!merged.length) return [{ text, highlight: false }]; const segments: TextSegment[] = []; let pos = 0; for (const { start, end } of merged) { if (pos < start) segments.push({ text: text.slice(pos, start), highlight: false }); segments.push({ text: text.slice(start, end), highlight: true }); pos = end; } if (pos < text.length) segments.push({ text: text.slice(pos), highlight: false }); return segments; }