Export template source from e33714a5ca38429d1bdb77a961a14461697944f5:examples/local-first/packages/language-reader-presentation
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user