121 lines
6.8 KiB
JavaScript
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()
|