- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
81 lines
3.3 KiB
TypeScript
81 lines
3.3 KiB
TypeScript
import { onScopeDispose, type Ref } from 'vue'
|
|
|
|
/**
|
|
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
|
|
* pointer movement: a mouse drag and the phone's system selection handles both produce a
|
|
* selectionchange, which is the interaction #4 verified.
|
|
*/
|
|
export function tokenIndexOf(node: Node | null): number {
|
|
if (!node) return -1
|
|
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
|
|
const holder = element?.closest?.('[data-token-index]')
|
|
if (!holder) return -1
|
|
const value = Number((holder as HTMLElement).dataset.tokenIndex)
|
|
return Number.isInteger(value) ? value : -1
|
|
}
|
|
|
|
export interface TextSelectionOptions {
|
|
/** The element the selection has to start inside. */
|
|
container: Ref<HTMLElement | null>
|
|
/** Called with the first and last token index of a non-empty selection inside the reader. */
|
|
onSelect: (anchor: number, focus: number) => void
|
|
}
|
|
|
|
/**
|
|
* Watches the native selection and reports token indices. A collapsed selection (a plain
|
|
* click) reports nothing, so clicking a word keeps opening the word panel.
|
|
*/
|
|
export function useTextSelection(options: TextSelectionOptions) {
|
|
let timer: number | undefined
|
|
let disposed = false
|
|
|
|
function read(): void {
|
|
if (disposed) return
|
|
const root = options.container.value
|
|
const selection = window.getSelection?.()
|
|
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
|
|
const range = selection.getRangeAt(0)
|
|
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
|
|
const anchor = tokenIndexOf(range.startContainer)
|
|
const focus = tokenIndexOf(range.endContainer)
|
|
if (anchor < 0 || focus < 0) return
|
|
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
|
|
}
|
|
|
|
// The debounce matches the verified spike: it lets the browser finish a drag before the
|
|
// range is read, and it never blocks scrolling on a touch device.
|
|
function schedule(): void {
|
|
if (timer !== undefined) window.clearTimeout(timer)
|
|
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
|
|
}
|
|
|
|
function pointerUp(event: PointerEvent): void {
|
|
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
|
|
// re-read a range the learner already adjusted there.
|
|
const root = options.container.value
|
|
const target = event.target as Node | null
|
|
if (!root || !target || !root.contains(target)) return
|
|
if (timer !== undefined) window.clearTimeout(timer)
|
|
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
|
|
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
|
|
}
|
|
|
|
// Both listeners live on the document: the reader body only exists once a chapter is ready,
|
|
// so a listener bound to the element at setup time would miss every later selection.
|
|
document.addEventListener('selectionchange', schedule)
|
|
document.addEventListener('pointerup', pointerUp)
|
|
|
|
onScopeDispose(() => {
|
|
disposed = true
|
|
if (timer !== undefined) window.clearTimeout(timer)
|
|
document.removeEventListener('selectionchange', schedule)
|
|
document.removeEventListener('pointerup', pointerUp)
|
|
})
|
|
|
|
return {
|
|
/** Drops the native highlight, e.g. when the panel closes. */
|
|
clear() { window.getSelection?.()?.removeAllRanges() },
|
|
read,
|
|
}
|
|
}
|