129 lines
5.5 KiB
TypeScript
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 }
|
|
}
|