Code-review fixes for collocation coach + vocab garden
Correctness: - useCheckpoint: clear the busy flag unconditionally so overlapping explicit passes don't strand each other's spinner; explicit actions now also supersede a queued auto-check and clear the stranded "checking" dot. Deduped runVoice/runCollocation into runExplicitPass. - EditorCore: token-guard the auto-capture so a late capture can't resurrect a removed word; move toggleSaveWord side effects out of the setWordInfo updater (StrictMode double-fire); fix sentenceAround offset desync via shared exampleAt (textBetween + parentOffset, single resolve); optimistic saved state so the heart doesn't flash unsaved. - vocab capture: normalize word to lower+trim (matches lexicon) so "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList. - GardenPanel: Promise.allSettled so a /due failure doesn't blank the whole garden; scrim click during review ends the review (mirrors Esc); gate footer on !error; O(1) due lookup via a Set. Features requested in review: - Definition-only review card: add vocab_words.definition (migration 0007) as an English fallback meaning, threaded through capture and used by review/garden when there's no Chinese gloss. - Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't push a word out of rotation for years. Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback, TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -640,6 +640,20 @@ export function EditorCore({
|
||||
setMisspell(null)
|
||||
}, [misspell, onAddWord])
|
||||
|
||||
// exampleAt pulls the sentence containing the position out of its block, for
|
||||
// review context in the garden. textBetween with a single-char leaf/break
|
||||
// placeholder keeps the string indices aligned with ProseMirror's parentOffset
|
||||
// (so a hard break or inline atom before the word doesn't desync the slice).
|
||||
const exampleAt = useCallback(
|
||||
(pos: number): string => {
|
||||
if (!editor) return ''
|
||||
const $pos = editor.state.doc.resolve(pos)
|
||||
const text = $pos.parent.textBetween(0, $pos.parent.content.size, '\n', ' ')
|
||||
return sentenceAround(text, Math.max(0, $pos.parentOffset))
|
||||
},
|
||||
[editor],
|
||||
)
|
||||
|
||||
// openWordLookup resolves the exact word span at a document position, anchors a
|
||||
// popover beneath it, and kicks off the offline lookup. The card opens
|
||||
// immediately in a loading state and fills in when the (local) lookup returns.
|
||||
@@ -665,28 +679,35 @@ export function EditorCore({
|
||||
const token = ++wordReqRef.current
|
||||
setWordInfo({ word: range.word, from: range.from, to: range.to, top, left, loading: true, info: null, vocabId: null, saved: false })
|
||||
// The sentence the word sits in, for review context in the garden.
|
||||
const block = editor.state.doc.resolve(range.from).parent.textContent
|
||||
const example = sentenceAround(block, Math.max(0, range.from - editor.state.doc.resolve(range.from).start()))
|
||||
const example = exampleAt(range.from)
|
||||
api
|
||||
.lookupWord(range.word)
|
||||
.then((info) => {
|
||||
if (token !== wordReqRef.current) return
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info } : null))
|
||||
// Auto-capture into the vocabulary garden — only words the dictionary
|
||||
// actually knows (a real gloss or definition), so accidental lookups of
|
||||
// typos or proper nouns don't clutter the garden. Looking words up IS
|
||||
// the data source; this costs the writer nothing.
|
||||
const known = !!info.gloss || info.definitions.length > 0
|
||||
// Reflect the saved state optimistically so the heart shows 💚 the
|
||||
// moment a known word loads, rather than flashing 🤍 until the capture
|
||||
// round-trips. vocabId is filled in when recordVocab returns.
|
||||
setWordInfo((w) => (w ? { ...w, loading: false, info, saved: known } : null))
|
||||
if (!known) return
|
||||
api
|
||||
.recordVocab({
|
||||
word: range.word,
|
||||
gloss: info.gloss,
|
||||
definition: info.definitions[0]?.definition ?? '',
|
||||
phonetic: info.phonetic,
|
||||
example,
|
||||
doc_id: docId,
|
||||
})
|
||||
.then((row) => {
|
||||
// Discard a late capture if the card has since been superseded (a
|
||||
// new lookup, navigation, or an explicit remove all bump the token),
|
||||
// so it can't resurrect a word the writer just removed.
|
||||
if (token !== wordReqRef.current) return
|
||||
setWordInfo((w) => (w && w.word === range.word ? { ...w, vocabId: row.id, saved: true } : w))
|
||||
})
|
||||
.catch((err) => console.error('vocab capture failed', err))
|
||||
@@ -705,23 +726,36 @@ export function EditorCore({
|
||||
// heart. Auto-capture saves it on lookup; this lets the writer remove a word
|
||||
// she already knows (or re-add one she removed by mistake).
|
||||
const toggleSaveWord = useCallback(() => {
|
||||
setWordInfo((w) => {
|
||||
if (!w || !w.info) return w
|
||||
if (w.saved && w.vocabId) {
|
||||
const id = w.vocabId
|
||||
api.deleteVocab(id).catch((err) => console.error('vocab remove failed', err))
|
||||
return { ...w, saved: false, vocabId: null }
|
||||
}
|
||||
const word = w.word
|
||||
const block = editor?.state.doc.resolve(w.from)
|
||||
const example = block ? sentenceAround(block.parent.textContent, Math.max(0, w.from - block.start())) : ''
|
||||
api
|
||||
.recordVocab({ word, gloss: w.info.gloss, phonetic: w.info.phonetic, example, doc_id: docId })
|
||||
.then((row) => setWordInfo((cur) => (cur && cur.word === word ? { ...cur, vocabId: row.id, saved: true } : cur)))
|
||||
.catch((err) => console.error('vocab save failed', err))
|
||||
return { ...w, saved: true }
|
||||
})
|
||||
}, [editor, docId])
|
||||
// Read the current card and do the network side effects OUTSIDE the state
|
||||
// updater — an updater must be pure (React StrictMode double-invokes it,
|
||||
// which would otherwise fire each request twice).
|
||||
const w = wordInfo
|
||||
if (!w || !w.info) return
|
||||
if (w.vocabId) {
|
||||
// Already in the garden — remove it, and invalidate any in-flight capture
|
||||
// for this card so a late auto-capture can't resurrect the removed word.
|
||||
const id = w.vocabId
|
||||
wordReqRef.current++
|
||||
setWordInfo((cur) => (cur ? { ...cur, saved: false, vocabId: null } : cur))
|
||||
api.deleteVocab(id).catch((err) => console.error('vocab remove failed', err))
|
||||
return
|
||||
}
|
||||
// Not in the garden yet — save it (idempotent upsert keyed on the word).
|
||||
const word = w.word
|
||||
const example = exampleAt(w.from)
|
||||
setWordInfo((cur) => (cur ? { ...cur, saved: true } : cur))
|
||||
api
|
||||
.recordVocab({
|
||||
word,
|
||||
gloss: w.info.gloss,
|
||||
definition: w.info.definitions[0]?.definition ?? '',
|
||||
phonetic: w.info.phonetic,
|
||||
example,
|
||||
doc_id: docId,
|
||||
})
|
||||
.then((row) => setWordInfo((cur) => (cur && cur.word === word ? { ...cur, vocabId: row.id, saved: true } : cur)))
|
||||
.catch((err) => console.error('vocab save failed', err))
|
||||
}, [wordInfo, exampleAt, docId])
|
||||
|
||||
// Right-click a word to look it up. Right-clicking off any word falls through
|
||||
// to the native menu (so copy/paste-by-menu still works — see the Selection fix).
|
||||
|
||||
Reference in New Issue
Block a user