Files
lexgo/learner/src/composables/readerRange.ts
T
ila bd77aa0a46 feat: 选择连续短语,保存并加入到期复习 (#11)
- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 22:21:51 +08:00

99 lines
4.5 KiB
TypeScript

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