Files
package-language-reader-pre…/src/canvas/text.ts
T

67 lines
2.7 KiB
TypeScript
Raw Normal View History

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