- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
49 lines
2.7 KiB
Vue
49 lines
2.7 KiB
Vue
<script setup lang="ts">
|
||
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
|
||
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
|
||
const emit = defineEmits<{
|
||
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
|
||
selectPhrase: [span: PhraseSpan, element: HTMLElement]
|
||
}>()
|
||
|
||
// A token belongs to the phrase that covers it; the spans come from the server match, so the
|
||
// reader never re-derives which words form a phrase.
|
||
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
|
||
let best: PhraseSpan | null = null
|
||
for (const phrase of phrases) {
|
||
if (index < phrase.startToken || index > phrase.endToken) continue
|
||
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
|
||
}
|
||
return best
|
||
}
|
||
|
||
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
|
||
const element = event.currentTarget as HTMLElement
|
||
if (phrase && !(event as MouseEvent).shiftKey) {
|
||
emit('selectPhrase', phrase, element)
|
||
return
|
||
}
|
||
emit('select', token, element, (event as MouseEvent).shiftKey === true)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
|
||
class="reader-token"
|
||
:data-token-index="index"
|
||
:class="[
|
||
token.kind === 'word' ? 'reader-word' : 'reader-separator',
|
||
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
|
||
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
|
||
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
|
||
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
|
||
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
|
||
]"
|
||
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
|
||
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
|
||
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
|
||
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
|
||
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
|
||
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
|
||
</template>
|