67 lines
2.7 KiB
TypeScript
67 lines
2.7 KiB
TypeScript
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<number> } | null = null;
|
||
|
|
const segmented = new Map<string, (Range & { text: string })[]>();
|
||
|
|
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;
|
||
|
|
}
|