import type { Range } from "./types.js"; // All ranges refer to the original UTF-16 source. Never trim paragraph text: // Fluxtapose's trimmed paragraphs broke selection and annotations after spaces. const graphemeSegmenter = new Intl.Segmenter("zh", { granularity: "grapheme" }); const wordSegmenter = new Intl.Segmenter("zh", { granularity: "word" }); let lastWords: { text: string; ranges: Range[]; wordStarts: Set } | null = null; const segmented = new Map(); let cachedUnits = 0; export function graphemes(text: string): (Range & { text: string })[] { const cached = segmented.get(text); if (cached) return cached; const units = Array.from(graphemeSegmenter.segment(text), ({ segment, index }) => ({ text: segment, start: index, end: index + segment.length, })); // Retain documents across layout changes, without filling the cache with // every small annotation label. Bound both object count and retained units. if (text.length >= 256 && units.length <= 120000) { while (segmented.size >= 4 || cachedUnits + units.length > 120000) { const oldest = segmented.keys().next().value!; cachedUnits -= segmented.get(oldest)!.length; segmented.delete(oldest); } segmented.set(text, units); cachedUnits += units.length; } return units; } export function wordAt(text: string, offset: number): Range { if (lastWords?.text !== text) { const words = Array.from(wordSegmenter.segment(text)); lastWords = { text, ranges: words.map(({ index, segment }) => ({ start: index, end: index + segment.length, })), wordStarts: new Set(words.filter((word) => word.isWordLike).map((word) => word.index)), }; } const ranges = lastWords.ranges; let low = 0, high = ranges.length; while (low < high) { const mid = (low + high) >>> 1; if (ranges[mid].end <= offset) low = mid + 1; else high = mid; } if (ranges[low] && offset >= ranges[low].start) return { start: ranges[low].start, end: ranges[low].end }; return { start: text.length, end: text.length }; } /** Segmenter classifies word-like spans using Unicode data, including CJK. * Selection still uses wordAt, where selecting punctuation is legitimate. */ export function hoverWordAt(text: string, offset: number): Range | null { const range = wordAt(text, offset); return lastWords?.wordStarts.has(range.start) ? range : null; } export function snapOffset(text: string, offset: number, direction = 1): number { const bounded = Math.max(0, Math.min(text.length, offset)); for (const unit of graphemes(text)) { if (bounded > unit.start && bounded < unit.end) return direction < 0 ? unit.start : unit.end; } return bounded; }