import type { ReaderToken } from './useReaderLookup' /** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */ export interface TokenRange { /** Token indices of the first and last word of the phrase. */ firstWord: number lastWord: number /** Code point range of the whole phrase inside the original text. */ start: number end: number /** The original text exactly as the chapter holds it. */ text: string wordCount: number } export const MAX_PHRASE_WORDS = 12 /** Indices of the word tokens, in reading order. */ export function wordIndices(tokens: ReaderToken[]): number[] { const indices: number[] = [] for (let index = 0; index < tokens.length; index++) { if (tokens[index]!.kind === 'word') indices.push(index) } return indices } /** * Builds a phrase range from two token indices, aligning both ends to whole words and * keeping interior punctuation and line breaks in the text. It mirrors the rules verified in * #4: separators at the edges are skipped, interior ones survive, and a single word is not a * phrase. */ export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null { if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null const from = Math.min(anchor, focus) const to = Math.max(anchor, focus) if (from < 0 || to >= tokens.length) return null let firstWord = -1 let lastWord = -1 for (let index = from; index <= to; index++) { if (tokens[index]!.kind !== 'word') continue if (firstWord < 0) firstWord = index lastWord = index } if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null return rangeOfWords(tokens, original, firstWord, lastWord) } /** The phrase range for a known first and last word token. */ export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null { const first = tokens[firstWord] const last = tokens[lastWord] if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null const text = [...original].slice(first.start, last.end).join('') if (!text) return null return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount } } /** * Moves one end of a phrase by whole words, skipping separators and never inverting the range. * Returns the adjusted range, or null when the move is impossible. */ export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null { const words = wordIndices(tokens) const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex) const firstPosition = positionOf(range.firstWord) const lastPosition = positionOf(range.lastWord) if (firstPosition < 0 || lastPosition < 0) return null if (edge === 'start') { const next = firstPosition + direction if (next < 0 || next > lastPosition - 1) return null return rangeOfWords(tokens, original, words[next]!, range.lastWord) } const next = lastPosition + direction if (next >= words.length || next < firstPosition + 1) return null return rangeOfWords(tokens, original, range.firstWord, words[next]!) } /** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */ export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null { let best: { id: number; startToken: number; endToken: number } | null = null for (const phrase of phrases) { if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase } return best } /** The phrase range a saved span covers, used when a highlighted phrase is clicked. */ export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null { const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1)) if (words.length < 2) return null const firstWord = span.startToken + words[0]! const lastWord = span.startToken + words[words.length - 1]! return rangeOfWords(tokens, original, firstWord, lastWord) }