fix: the thesaurus kept throwing its own results away

The pass that reads the caret's word on open also writes it, so it was a
dependency of itself: it re-ran on every flush and cleared term and groups
just as a lookup landed. It runs untracked now, on `open` alone. The
dialog also shows the language it is asking and lets it be switched, as
both products do — a German word in an English document found nothing,
with nothing on screen saying why.
This commit is contained in:
Steffen Becker committed 2026-08-15 20:41:51 +02:00
1 parent 44b351e865
commit ef1d9abc14
4 files changed
+46 -17

No files matched your search

+5
View File
@@ -169,6 +169,11 @@ which is how both dialogs follow a chain.
worker from then on — like a dictionary, and missing data disables the feature, not the app.
- The groups come back ordered by where the word sits in each: a group leads with its main
form, which is the sense LibreOffice lists first as well.
- **The language is shown and switchable** (both dialogs have that box): a lookup asks one
language, and a German word in an English document otherwise just finds nothing with no
hint why. It opens on the document's language, or the app's where that has no data.
- The open pass runs `untrack`ed, on `open` alone: it writes the state it reads (the term,
the language), so tracked it re-runs on its own result and wipes each lookup as it lands.
## Navigator (`NavigatorPane.svelte`, `editor/extensions/outline.ts`)
+41 -15
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { Editor } from '@tiptap/core';
import { synonyms } from '../spell/thesaurus';
import { spellController } from '../spell/controller';
import { wordRangeAt } from '../editor/extensions/spellCheck';
import { NO_LANGUAGE } from '../storage/documentLanguage';
import { t } from '../i18n/i18n.svelte';
import { LANGUAGES, findLanguage, type DocumentLanguage } from '../storage/documentLanguage';
import { locale, t } from '../i18n/i18n.svelte';
// LibreOffice's Tools ▸ Thesaurus (Ctrl+F7) / Word's Review ▸ Thesaurus: the word at
// the caret, the groups it appears in, click one to replace it. The box above looks
@@ -18,8 +19,16 @@
// The range the dialog opened on. A modal freezes the selection, and the term above
// may wander off it while the replacement still belongs to this word.
let target = $state<{ from: number; to: number } | null>(null);
// Which thesaurus is asked, as both dialogs show and let you switch: a word is looked
// up in one language, and the document's own may not be the one being typed.
let lang = $state<DocumentLanguage>('en');
let token = 0;
const language = $derived(open ? spellController.getLanguage() : NO_LANGUAGE);
// The document's language where it has data, else the one the app is speaking.
function startLanguage(): DocumentLanguage {
const doc = spellController.getLanguage();
return findLanguage(doc)?.code ?? findLanguage(locale())?.code ?? LANGUAGES[0].code;
}
$effect(() => {
const el = dialogEl;
@@ -28,22 +37,28 @@
else if (!open && el.open) el.close();
});
// Re-read on each open, as the dialog stays mounted.
// Re-read on each open, as the dialog stays mounted. Opening is the only dependency:
// the pass writes the state it reads, so tracked it would re-run on its own result
// and wipe every lookup the moment it arrives.
$effect(() => {
if (!open) return;
const view = editor?.view;
const range = view ? wordRangeAt(view.state, view.state.selection.from) : null;
target = range ? { from: range.from, to: range.to } : null;
void lookUp(range?.word ?? '');
untrack(() => {
const view = editor?.view;
const range = view ? wordRangeAt(view.state, view.state.selection.from) : null;
target = range ? { from: range.from, to: range.to } : null;
lang = startLanguage();
void lookUp(range?.word ?? '');
});
});
async function lookUp(word: string) {
const mine = ++token;
term = word;
groups = [];
if (!word.trim()) return;
busy = true;
const found = await synonyms(spellController.getLanguage(), word);
if (term !== word) return; // a newer lookup superseded this one
busy = !!word.trim();
if (!busy) return;
const found = await synonyms(lang, word);
if (mine !== token) return; // a newer lookup owns the state now
groups = found;
busy = false;
}
@@ -79,12 +94,15 @@
spellcheck="false"
autocomplete="off"
/>
<select bind:value={lang} onchange={() => void lookUp(term)} aria-label={t().spellPicker.label}>
{#each LANGUAGES as l (l.code)}
<option value={l.code}>{l.label}</option>
{/each}
</select>
<button class="small" onclick={() => void lookUp(term)}>{t().thesaurus.lookUp}</button>
</div>
{#if language === NO_LANGUAGE}
<p class="hint">{t().thesaurus.noLanguage}</p>
{:else if busy}
{#if busy}
<p class="hint">{t().thesaurus.loading}</p>
{:else if groups.length}
<p class="hint">{t().thesaurus.replaceHint}</p>
@@ -139,6 +157,14 @@
.hint { color: var(--color-text-muted); }
.lookup { display: flex; gap: 6px; }
select {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
padding: 4px 6px;
font: inherit;
}
input {
flex: 1;
min-width: 0;
-1
View File
@@ -492,7 +492,6 @@ const de: Messages = {
loading: 'Wird geladen…',
replaceHint: 'Ein Wort anklicken, um es im Dokument zu ersetzen.',
noSynonyms: 'Keine Synonyme gefunden',
noLanguage: 'Für diese Sprache gibt es keinen Thesaurus',
},
contextMenu: {
cut: 'Ausschneiden',
-1
View File
@@ -496,7 +496,6 @@ const en = {
loading: 'Loading…',
replaceHint: 'Click a word to replace the one in the document.',
noSynonyms: 'No synonyms found',
noLanguage: 'No thesaurus for this language',
},
contextMenu: {
cut: 'Cut',