Files
lexgo/learner/src/composables/useTextSelection.ts
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

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,
}
}