Compose: spelling and grammar check with LanguageTool
A self-hosted LanguageTool (dev: docker/docker-compose.yml, started by `make up`) checks what the user writes in the compose window, through POST /api/spellcheck — the browser never talks to the engine directly. - Marks are CSS highlights over the text, never written into the message: red wavy for spelling, blue for grammar/style. Clicking one shows the message, up to five suggestions (applied as typed, so Ctrl+Z undoes them) and Ignore. The popup, like the link popup, opens above its anchor when there is no room below. - Checked after a pause in typing; the signature and quoted original are skipped; marks on words just edited drop at once; a check overtaken by a newer one, by turning checking off, or by closing the window is discarded. - With "auto", text is checked in chunks of lines, each in its own detected language — LanguageTool settles on one language per request, so a mixed Polish/English message had every English word flagged as a Polish typo. - Settings > Appearance: on/off and language (Auto-detect or any of the engine's languages); the compose window's More menu overrides it for one message. The browser's own spellcheck steps aside while ours runs, and comes back when it is off or the server has no engine (503 from the endpoint). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -159,6 +159,21 @@ export function Composer({
|
||||
const [showToolbar, setShowToolbar] = useState(true);
|
||||
const [fromOpen, setFromOpen] = useState(false);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
// This message's spell-check language (More menu); null follows Settings.
|
||||
const [spellLang, setSpellLang] = useState<string | null>(null);
|
||||
const [spellMenuOpen, setSpellMenuOpen] = useState(false);
|
||||
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
// The language list folds away whenever the More menu closes.
|
||||
useEffect(() => {
|
||||
if (!moreOpen) setSpellMenuOpen(false);
|
||||
}, [moreOpen]);
|
||||
// What the engine can check — and whether this server has one at all.
|
||||
const spellLanguages = useQuery({
|
||||
queryKey: ["spellcheck-languages"],
|
||||
queryFn: api.spellcheckLanguages,
|
||||
retry: false,
|
||||
staleTime: 3_600_000,
|
||||
});
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const fromRef = useRef<HTMLDivElement>(null);
|
||||
const [attachments, setAttachments] = useState<AttachmentOut[]>([]);
|
||||
@@ -656,7 +671,7 @@ export function Composer({
|
||||
{t("compose.more")}
|
||||
</HeaderButton>
|
||||
{moreOpen && (
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-44 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<div className="absolute left-0 top-full z-30 mt-1 w-56 overflow-hidden rounded-lg border border-app-border bg-app-elevated py-1 text-sm shadow-xl">
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
@@ -676,6 +691,56 @@ export function Composer({
|
||||
>
|
||||
{t("compose.insertSignature")}
|
||||
</button>
|
||||
{spellLanguages.data && (() => {
|
||||
// The language in force: this message's choice, else Settings.
|
||||
const fallback = settingsQuery.data?.spellcheck_enabled
|
||||
? (settingsQuery.data?.spellcheck_language ?? "auto")
|
||||
: "off";
|
||||
const current = spellLang ?? fallback;
|
||||
const nameOf = (code: string) =>
|
||||
code === "off"
|
||||
? t("compose.spellOff")
|
||||
: code === "auto"
|
||||
? t("compose.spellAuto")
|
||||
: (spellLanguages.data.find((l) => l.code === code)?.name ?? code);
|
||||
const choices = [
|
||||
{ code: "off", name: t("compose.spellOffMessage") },
|
||||
{ code: "auto", name: t("compose.spellAuto") },
|
||||
...spellLanguages.data,
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-app-fg hover:bg-app-hover"
|
||||
aria-expanded={spellMenuOpen}
|
||||
onClick={() => setSpellMenuOpen((v) => !v)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t("compose.spellMenu")}: {nameOf(current)}
|
||||
</span>
|
||||
<span className="shrink-0 text-app-faint">{spellMenuOpen ? "▾" : "›"}</span>
|
||||
</button>
|
||||
{spellMenuOpen && (
|
||||
<div className="max-h-56 overflow-y-auto border-y border-app-border bg-app-surface py-1">
|
||||
{choices.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
className="flex w-full items-center gap-2 px-3 py-1 text-left text-[13px] text-app-fg hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
setSpellLang(c.code);
|
||||
setSpellMenuOpen(false);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="w-3 shrink-0 text-app-accent">{c.code === current ? "✓" : ""}</span>
|
||||
<span className="truncate">{c.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
className="block w-full px-3 py-1.5 text-left text-red-500 hover:bg-app-hover"
|
||||
onClick={() => {
|
||||
@@ -870,6 +935,7 @@ export function Composer({
|
||||
setBodyHtml(html);
|
||||
}}
|
||||
showToolbar={showToolbar}
|
||||
spellLanguage={spellLang}
|
||||
// With a height to divide, the body takes what is left and
|
||||
// scrolls. A floor here would instead push the window past that
|
||||
// height — and `overflow-hidden` would clip the difference.
|
||||
|
||||
Reference in New Issue
Block a user