Files
lexgo/learner/src/components/ReaderTokens.vue
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

49 lines
2.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>