- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
99 lines
4.5 KiB
TypeScript
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)
|
|
}
|