Four ways the two scripts weren't the same app, and a smaller cat
A review of the pair work found the seams — every one of them a place where
the Chinese half was written and the older Latin half was left standing.
The right-click menu still asked the Latin tokenizer whether there was a word
under the pointer, so right-clicking a hanzi opened the browser's own menu
instead of the card. Hover, long-press and Ctrl+D had all moved to the shared
resolver; this one hadn't, and it is the surface the segmenter's own header
names first.
isHan is a property escape precisely so the extension blocks are covered, and
then every call site handed it one UTF-16 code unit — half a surrogate pair
for anything above the BMP, which \p{Script=Han} rightly says is not Han. The
run split in two around the character and the words either side stopped being
looked up. The walk, the scan and wordAt now step by code point, the regex is
anchored, and the test that passed by accident (unanchored, so it searched a
two-unit string rather than testing one character) is joined by one that
would have failed.
The pair picker sent the pair alone. The server validates pair and direction
as one decision and refuses a learner direction for a pair it has no word
list for — so an English speaker learning Chinese could not move to French at
all: every button failed with the generic message. It now names both, keeps
her direction where the target pack has a learner side, and returns her to
learning_en where it does not. Routed through useSession rather than the
picker's own api call, so me.direction — which decides whether the word list
stays loaded — moves with it.
UpdateMe answered every error from Get with 401. A SQLite fault on a PATCH
would have tripped the client's session interceptor and thrown a writer into
the signed-out overlay while her session was fine. Only a missing row means
not signed in, which is the distinction SetPair already made below it.
emitCommittedRef was assigned during render and called later from
compositionend; a render React discards must not leave its closure behind for
a DOM event.
And the kitten is 10% smaller — one clamp, three terms, everything else
calc()s off it.
vitest 297/297, tsc, go build/vet/test clean.
This commit is contained in:
@@ -25,6 +25,7 @@ interface Props {
|
||||
// and the drawer is the only chrome always one tap away on a phone.
|
||||
direction?: string
|
||||
onDirection?: (direction: string) => Promise<void>
|
||||
onPair?: (lang: string, direction: string) => Promise<void>
|
||||
}
|
||||
|
||||
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
|
||||
@@ -50,6 +51,7 @@ export function DocList({
|
||||
account,
|
||||
direction,
|
||||
onDirection,
|
||||
onPair,
|
||||
}: Props) {
|
||||
const t = usePack()
|
||||
// Active tag filter (null = show all). Cleared automatically if the tag
|
||||
@@ -168,7 +170,7 @@ export function DocList({
|
||||
{/* The pair Petal speaks. Unlike the rows above it this is not about any
|
||||
document, and unlike sign-out it is offered whether or not there is an
|
||||
account behind the session — a local-dev build still has a langpack. */}
|
||||
<LanguagePicker direction={direction} onDirection={onDirection} />
|
||||
<LanguagePicker direction={direction} onDirection={onDirection} onPair={onPair} />
|
||||
|
||||
{/* Who's writing, and the way out. Shown only when there's a real account
|
||||
behind the session — a local-dev build has nobody to sign out as. */}
|
||||
|
||||
@@ -22,9 +22,12 @@ interface Props {
|
||||
// and this control is only where the writer says so.
|
||||
direction?: string
|
||||
onDirection?: (direction: string) => Promise<void>
|
||||
// Move the pair itself. Owned by App for the same reason: the answer carries
|
||||
// the direction too, and the account's direction is what loads the word list.
|
||||
onPair?: (lang: string, direction: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function LanguagePicker({ direction, onDirection }: Props = {}) {
|
||||
export function LanguagePicker({ direction, onDirection, onPair }: Props = {}) {
|
||||
const t = usePack()
|
||||
const packs = shippedPacks()
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
@@ -61,11 +64,24 @@ export function LanguagePicker({ direction, onDirection }: Props = {}) {
|
||||
setSaving(code)
|
||||
setFailed(false)
|
||||
try {
|
||||
const me = await api.setPairLang(code)
|
||||
// The server's answer, not the code we asked for. Everything downstream —
|
||||
// her dictionary, the read-aloud voice, the word lookups — follows the
|
||||
// pack, so it must follow what was actually stored.
|
||||
setPackLang(me.pair_lang)
|
||||
// Name the direction alongside the pair. The server validates the two as
|
||||
// one decision and refuses a learner direction for a pair it has no word
|
||||
// list for, so an account that is learning Chinese cannot move to French
|
||||
// by naming only the pair — that request is rejected outright, and the
|
||||
// writer is left on a picker whose buttons all fail. A pair with no
|
||||
// learner side can only be travelled toward English; saying so is how the
|
||||
// move is actually made.
|
||||
const target = packs.find((p) => p.code === code)
|
||||
const next = target?.learner ? (direction ?? 'learning_en') : 'learning_en'
|
||||
if (onPair) {
|
||||
await onPair(code, next)
|
||||
} else {
|
||||
const me = await api.setPairLang(code)
|
||||
// The server's answer, not the code we asked for. Everything downstream
|
||||
// — her dictionary, the read-aloud voice, the word lookups — follows the
|
||||
// pack, so it must follow what was actually stored.
|
||||
setPackLang(me.pair_lang)
|
||||
}
|
||||
} catch {
|
||||
// A 401 has already surfaced as the sign-in overlay through the client's
|
||||
// interceptor; anything else leaves her on the pair she was already on,
|
||||
|
||||
@@ -445,16 +445,22 @@ export function EditorCore({
|
||||
// The composition-end nudge. Same payload as onUpdate's, with `composing`
|
||||
// false by construction — this runs after the composition has ended and its
|
||||
// final changes have been flushed, so the text here is the committed one.
|
||||
emitCommittedRef.current = () => {
|
||||
if (!editor) return
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
composing: false,
|
||||
})
|
||||
recomputeRailRef.current()
|
||||
}
|
||||
//
|
||||
// Written in an effect rather than during render, like recomputeRailRef
|
||||
// below: a render React throws away must not be the one that leaves its
|
||||
// closure behind for a DOM event to call later.
|
||||
useEffect(() => {
|
||||
emitCommittedRef.current = () => {
|
||||
if (!editor) return
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
word_count: editor.storage.characterCount.words(),
|
||||
composing: false,
|
||||
})
|
||||
recomputeRailRef.current()
|
||||
}
|
||||
}, [editor, onChange])
|
||||
|
||||
// When the selected document changes, swap in its content without emitting an
|
||||
// update (false) so loading a doc doesn't trigger a spurious save.
|
||||
@@ -1025,13 +1031,16 @@ export function EditorCore({
|
||||
if (!editor) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
if (!wordAt(editor.state.doc, coords.pos, wordAlphabet)) return
|
||||
// Whichever script is under the pointer — the same resolver openWordLookup
|
||||
// uses, so a Chinese word gets the card here too rather than falling
|
||||
// through to the native menu.
|
||||
if (!resolveWord(coords.pos)) return
|
||||
e.preventDefault()
|
||||
// A misspelled word offers corrections first; otherwise look it up.
|
||||
if (openMisspellAt(coords.pos)) return
|
||||
openWordLookup(coords.pos)
|
||||
},
|
||||
[editor, wordAlphabet, openMisspellAt, openWordLookup],
|
||||
[editor, resolveWord, openMisspellAt, openWordLookup],
|
||||
)
|
||||
|
||||
// Touch has no hover or right-click, so a long-press (~500ms without moving)
|
||||
|
||||
Reference in New Issue
Block a user