Files
lexgo/learner/src/composables/useReaderLookup.ts
T

129 lines
5.5 KiB
TypeScript

import { onScopeDispose, ref, watch, type Ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
import { useSessionStore } from '../stores/session'
export interface ReaderToken { text: string; start: number; end: number; startUtf16: number; endUtf16: number; kind: 'word' | 'space' | 'punctuation' }
export interface LookupResult {
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
query: string
matchedForm: string | null
candidates: string[]
entries: { lemma: string; pos: string; definition: string; examples: string[] }[]
resource?: { name: string; version: string }
}
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
// Validate every coordinate before enabling selection; original text is always the fallback.
function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
if (!data || data.textSha256 !== chapter.contentSha256 || !Array.isArray(data.tokens)) return false
const text = chapter.originalText ?? ''
let cp = 0, utf16 = 0
for (const token of data.tokens) {
if (typeof token.text !== 'string' || !token.text || !['word', 'space', 'punctuation'].includes(token.kind)) return false
if (token.start !== cp || token.startUtf16 !== utf16) return false
cp += [...token.text].length
utf16 += token.text.length
if (token.end !== cp || token.endUtf16 !== utf16 || text.slice(token.startUtf16, utf16) !== token.text) return false
}
return utf16 === text.length && data.tokens.map(token => token.text).join('') === text
}
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
const session = useSessionStore()
const tokens = ref<ReaderToken[]>([])
const tokensError = ref('')
const tokensLoading = ref(false)
const selected = ref<ReaderToken | null>(null)
const result = ref<LookupResult | null>(null)
const loading = ref(false)
const error = ref('')
const draft = ref('')
let tokenSequence = 0, lookupSequence = 0
let origin: HTMLElement | null = null
let scrollBeforeAdjustment: { top: number; left: number } | null = null
let lastAdjustment: { top: number; left: number } | null = null
function stillAtAdjustment() {
return lastAdjustment !== null && Math.abs(window.scrollY - lastAdjustment.top) <= 2
&& Math.abs(window.scrollX - lastAdjustment.left) <= 2
}
function keepSelectionVisible(panelTop: number) {
if (!origin) return
const coveredBy = origin.getBoundingClientRect().bottom - panelTop + 16
if (coveredBy <= 0) return
// A later manual scroll becomes the new reading position, including when
// another resize subsequently needs to reveal the selected word again.
if (!scrollBeforeAdjustment || !stillAtAdjustment()) scrollBeforeAdjustment = { top: window.scrollY, left: window.scrollX }
window.scrollBy({ top: coveredBy, behavior: 'instant' })
lastAdjustment = { top: window.scrollY, left: window.scrollX }
}
function close(restoreFocus = true) {
lookupSequence++
selected.value = null
result.value = null
loading.value = false
error.value = ''
draft.value = ''
if (restoreFocus && origin?.isConnected) origin.focus({ preventScroll: true })
if (restoreFocus && scrollBeforeAdjustment && stillAtAdjustment()) window.scrollTo({ ...scrollBeforeAdjustment, behavior: 'instant' })
scrollBeforeAdjustment = null
lastAdjustment = null
origin = null
}
function reset() {
tokenSequence++
tokens.value = []
tokensError.value = ''
tokensLoading.value = false
close(false)
}
async function loadTokens() {
const current = chapter.value
if (!session.user || current?.status !== 'ready') return
const seq = ++tokenSequence
tokensLoading.value = true
tokensError.value = ''
try {
const data = await session.request<TokenResponse>(`chapters/${current.id}/tokens`)
if (seq !== tokenSequence) return
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
tokens.value = data.tokens
} catch (reason) {
if (seq !== tokenSequence) return
tokens.value = []
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
} finally { if (seq === tokenSequence) tokensLoading.value = false }
}
async function lookup() {
const current = chapter.value
const token = selected.value
if (!session.user || !current || !token) return
const seq = ++lookupSequence
result.value = null
error.value = ''
loading.value = true
try {
const data = await session.request<LookupResult>('lookup', 'POST', { chapterId: current.id, start: token.start, end: token.end })
if (seq === lookupSequence) result.value = data
} catch (reason) {
if (seq === lookupSequence) error.value = reason instanceof Error ? reason.message : '暂时无法查询,请重试。'
} finally { if (seq === lookupSequence) loading.value = false }
}
function select(token: ReaderToken, element: HTMLElement) {
close(false)
origin = element
selected.value = token
void lookup()
}
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
reset()
void loadTokens()
}, { immediate: true, flush: 'sync' })
// Watch the identity object, including clear → login for the same account.
watch(() => session.user, reset, { flush: 'sync' })
onScopeDispose(reset)
return { tokens, tokensError, tokensLoading, selected, result, loading, error, draft, loadTokens, lookup, select, close, reset, keepSelectionVisible }
}