Files
lexgo/spikes/selection/app.mjs

121 lines
6.8 KiB
JavaScript

import {tokenize,normalizeRange,pointSelection,adjustRange,occurrences,resolveOverlaps} from './range.mjs'
import {chapters} from './fixtures.mjs'
const $=id=>document.getElementById(id)
const reader=$('reader'), panel=$('panel'), saved=new Map(), positions=new Map()
const meanings={'curiosity':'好奇心;求知欲','a small step':'一小步','small step':'小小的一步','learning':'学习','café':'咖啡馆'}
let chapterIndex=0,tokens=[],nodes=[],selected=null,anchor=null,focus=null,changingChapter=false,timer
function chapter(){return chapters[chapterIndex]}
function clearNative(){window.getSelection()?.removeAllRanges()}
function paint(){
const candidates=[]
for(const [text,item] of saved) for(const range of occurrences(chapter().text,text)) candidates.push({...range,state:item.state})
const visible=resolveOverlaps(candidates)
nodes.forEach((node,index)=>{
const token=tokens[index]
const match=visible.find(range=>token.start>=range.start && token.end<=range.end)
node.className=(token.word?'word ':'')+(match?.state || '')
node.classList.toggle('selected',!!selected && token.start>=selected.start && token.end<=selected.end)
})
}
function dictionary(){
if(!selected)return
const mode=$('dictionary-mode').value
$('dictionary').textContent=mode==='missing'?'该语言词典暂不可用,仍可填写释义。':mode==='empty'?'未找到释义,可手动填写。':meanings[selected.text.toLocaleLowerCase('en')] || '未找到释义,可手动填写。'
}
function choose(range,{resetAnchor=true}={}){
if(!range)return
if(selected?.start===range.start && selected?.end===range.end)return
selected=range
if(resetAnchor){anchor=range.first;focus=range.last}
panel.hidden=false;$('empty').hidden=true
$('term').textContent=range.text
$('kind').textContent=range.first===range.last?'单词':'短语'
const item=saved.get(range.text)
$('meaning').value=item?.meaning || ''
$('word-state').value=item?.state || 'learning'
$('feedback').textContent=''
$('range-output').textContent=JSON.stringify({chapter:chapter().id,text:range.text,utf16:[range.start,range.end],code_point:[range.start_cp,range.end_cp],utf8:[range.start_utf8,range.end_utf8]},null,2)
dictionary();paint()
document.querySelectorAll('[data-edge]').forEach(button=>{
const next=adjustRange(chapter().text,tokens,range,button.dataset.edge,Number(button.dataset.direction))
button.disabled=next.start===range.start && next.end===range.end
})
}
function close(){
const scroll=window.scrollY
selected=null;anchor=focus
clearTimeout(timer);clearNative();panel.hidden=true;$('empty').hidden=false
paint();reader.focus({preventScroll:true});window.scrollTo({top:scroll,behavior:'instant'})
$('event-status').textContent=`关闭前 ${scroll},关闭后 ${window.scrollY}`
}
function readNativeSelection(){
const selection=window.getSelection()
if(changingChapter || !selection || selection.isCollapsed || !selection.rangeCount)return false
const range=selection.getRangeAt(0)
if(!reader.contains(range.startContainer)||!reader.contains(range.endContainer))return false
const prefix=document.createRange();prefix.selectNodeContents(reader);prefix.setEnd(range.startContainer,range.startOffset)
const start=prefix.toString().length
prefix.setEnd(range.endContainer,range.endOffset)
const end=prefix.toString().length
const normalized=normalizeRange(chapter().text,tokens,start,end)
if(!normalized){
selected=null;panel.hidden=true;$('empty').hidden=false;paint()
return false
}
choose(normalized)
return true
}
function renderChapter(){
changingChapter=true;clearTimeout(timer);clearNative();selected=null;anchor=null;focus=null
$('title').textContent=chapter().title;$('chapter-number').textContent=`${chapterIndex+1} / ${chapters.length}`
$('previous').disabled=chapterIndex===0;$('next').disabled=chapterIndex===chapters.length-1
tokens=tokenize(chapter().text)
nodes=tokens.map((token,index)=>{const span=document.createElement('span');span.textContent=token.text;span.dataset.index=String(index);return span})
reader.replaceChildren(...nodes)
if(reader.textContent!==chapter().text)throw new Error('Original text reconstruction failed')
panel.hidden=true;$('empty').hidden=false;paint();changingChapter=false
}
reader.addEventListener('click',event=>{
if(readNativeSelection())return
const node=event.target.closest('[data-index]')
if(node)choose(pointSelection(chapter().text,tokens,Number(node.dataset.index)))
})
// Native selection remains in charge of mouse dragging and mobile long-press handles.
// No touchmove preventDefault, pointer capture or text-node replacement on selection.
document.addEventListener('selectionchange',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,100)})
reader.addEventListener('pointerup',()=>{clearTimeout(timer);timer=setTimeout(readNativeSelection,0)})
reader.addEventListener('keydown',event=>{
if(event.key==='Escape'){event.preventDefault();close();return}
if(!['ArrowLeft','ArrowRight'].includes(event.key)||event.ctrlKey||event.metaKey||event.altKey)return
event.preventDefault();clearNative()
const direction=event.key==='ArrowLeft'?-1:1
let next=focus===null?(direction===1?0:tokens.length-1):focus+direction
while(next>=0 && next<tokens.length && !tokens[next].word)next+=direction
if(next<0||next>=tokens.length)return
if(!event.shiftKey||anchor===null){anchor=next;focus=next;choose(pointSelection(chapter().text,tokens,next),{resetAnchor:false})}
else {focus=next;choose(normalizeRange(chapter().text,tokens,Math.min(tokens[anchor].start,tokens[focus].start),Math.max(tokens[anchor].end,tokens[focus].end)),{resetAnchor:false})}
// Keyboard users can navigate a long chapter without losing the active word.
nodes[next].scrollIntoView({block:'nearest',inline:'nearest',behavior:'instant'})
})
document.addEventListener('keydown',event=>{if(event.key==='Escape' && !panel.hidden && event.target!==reader){event.preventDefault();close()}})
document.querySelectorAll('[data-edge]').forEach(button=>button.addEventListener('click',()=>{
if(!selected)return
clearNative();choose(adjustRange(chapter().text,tokens,selected,button.dataset.edge,Number(button.dataset.direction)))
}))
$('close').addEventListener('click',close)
$('save').addEventListener('click',()=>{
if(!selected)return
const meaning=$('meaning').value.trim()
if(!meaning){$('feedback').textContent='请填写释义。';return}
saved.set(selected.text,{meaning,state:$('word-state').value})
$('feedback').textContent='已保存 · 本次页面内有效';paint()
})
$('dictionary-mode').addEventListener('change',dictionary)
for(const [id,delta] of [['previous',-1],['next',1]])$(id).addEventListener('click',()=>{
positions.set(chapter().id,window.scrollY);chapterIndex+=delta;renderChapter()
reader.focus({preventScroll:true});window.scrollTo({top:positions.get(chapter().id)||0,behavior:'instant'})
})
renderChapter()