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:
2026-10-04 23:51:31 +02:00
co-authored by Claude Opus 5.5
parent b157c5c9bb
commit 1f2e539eb2
16 changed files with 1000 additions and 9 deletions
+67 -1
View File
@@ -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.
@@ -1,6 +1,26 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import {
canHighlight,
clearPaint,
collectText,
ignoreKey,
paint,
place,
stillValid,
type PlacedIssue,
} from "./spellcheck";
// Imperative handle for the composer to manage the signature block.
export interface RichTextEditorHandle {
@@ -170,9 +190,12 @@ export const RichTextEditor = forwardRef<
onReady?: (el: HTMLDivElement) => void;
showToolbar?: boolean;
minHeight?: string; // Tailwind min-h-* class for the editable area
/** This message's spell-check language from the compose window's More menu:
* "off", "auto" or a language code. Unset follows Settings. */
spellLanguage?: string | null;
}
>(function RichTextEditor(
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]" },
{ initialHtml, onInput, onReady, showToolbar = true, minHeight = "min-h-[250px]", spellLanguage: spellOverride },
handleRef,
) {
const t = useT();
@@ -193,6 +216,27 @@ export const RichTextEditor = forwardRef<
const [newLinkRange, setNewLinkRange] = useState<Range | null>(null);
const linkPopupRef = useRef<HTMLDivElement>(null);
// Spelling and grammar check (LanguageTool via /api/spellcheck), when the user
// has it on and the server has an engine. Off -> the browser's own checker.
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const [spellUnavailable, setSpellUnavailable] = useState(false);
const spellWanted = spellOverride
? spellOverride !== "off"
: Boolean(settings.data?.spellcheck_enabled);
const spellOn = spellWanted && !spellUnavailable && canHighlight();
const spellLanguage =
spellOverride && spellOverride !== "off"
? spellOverride
: (settings.data?.spellcheck_language ?? "auto");
const spellOwner = useRef({}); // this editor's key in the shared highlight set
const issuesRef = useRef<PlacedIssue[]>([]);
const ignoredRef = useRef(new Set<string>());
const spellSeq = useRef(0);
const spellTimer = useRef<number | null>(null);
const [spellIssue, setSpellIssue] = useState<PlacedIssue | null>(null);
const [spellPos, setSpellPos] = useState<{ left: number; top: number } | null>(null);
const spellPopupRef = useRef<HTMLDivElement>(null);
// A reply's quoted original starts folded away behind the "•••" pill.
const [quoteOpen, setQuoteOpen] = useState(false);
@@ -306,8 +350,63 @@ export const RichTextEditor = forwardRef<
return () => document.removeEventListener("mousedown", onDown);
}, [colorMenu]);
const runSpellcheck = useCallback(async () => {
const ed = ref.current;
if (!ed || !spellOn) return;
const map = collectText(ed);
const seq = ++spellSeq.current;
if (!map.text.trim() || map.text.length > 20_000) {
issuesRef.current = [];
paint(spellOwner.current, []);
return;
}
try {
const res = await api.spellcheck(map.text, spellLanguage);
if (seq !== spellSeq.current) return; // a newer check is on its way
issuesRef.current = place(map, res.issues).filter(
(i) => !ignoredRef.current.has(ignoreKey(i)),
);
paint(spellOwner.current, issuesRef.current);
} catch (e) {
// No engine on this server (or it is down): fall back to the browser's.
if (e instanceof ApiError && e.status === 503) setSpellUnavailable(true);
}
}, [spellOn, spellLanguage]);
// Check again once typing pauses. Marks on words just edited go at once.
const scheduleSpellcheck = useCallback(() => {
if (!spellOn) return;
issuesRef.current = issuesRef.current.filter(stillValid);
paint(spellOwner.current, issuesRef.current);
if (spellTimer.current) window.clearTimeout(spellTimer.current);
spellTimer.current = window.setTimeout(() => void runSpellcheck(), 800);
}, [spellOn, runSpellcheck]);
// On open, when turned on, or when the language changes; off clears the marks.
useEffect(() => {
if (spellOn) scheduleSpellcheck();
else {
// A check still on its way must not paint marks once checking is off.
spellSeq.current++;
if (spellTimer.current) window.clearTimeout(spellTimer.current);
issuesRef.current = [];
clearPaint(spellOwner.current);
}
}, [spellOn, scheduleSpellcheck]);
useEffect(() => {
const owner = spellOwner.current;
const seq = spellSeq; // the counter itself, not a value read at cleanup
return () => {
seq.current++; // nor after the editor is gone
if (spellTimer.current) window.clearTimeout(spellTimer.current);
clearPaint(owner);
};
}, []);
const emit = () => {
if (ref.current) onInput(contentHtml(ref.current));
scheduleSpellcheck();
};
const exec = (command: string, value?: string) => {
@@ -441,6 +540,88 @@ export const RichTextEditor = forwardRef<
const a = el.closest("a");
setSelLink(a && ref.current?.contains(a) ? a : null);
setLinkEdit(null);
setSpellIssue(null);
if (a || !spellOn) return;
const sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.anchorNode) return;
const node = sel.anchorNode;
const offset = sel.anchorOffset;
const hit = issuesRef.current.find((i) => {
if (!stillValid(i)) return false;
try {
return i.range.comparePoint(node, offset) === 0;
} catch {
return false;
}
});
if (hit) setSpellIssue(hit);
};
// The spell popup sits just under the marked words, like the link popup.
const computeSpellPos = useCallback(() => {
const ed = ref.current;
if (!ed || !spellIssue || !stillValid(spellIssue)) return null;
const r = spellIssue.range.getBoundingClientRect();
const er = ed.getBoundingClientRect();
return {
left: Math.max(8, Math.min(r.left - er.left, er.width - 300)),
top: popupTop(r, spellPopupRef.current?.offsetHeight || 160),
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [spellIssue]);
useEffect(() => setSpellPos(computeSpellPos()), [spellIssue, computeSpellPos]);
useEffect(() => {
if (!spellIssue) return;
const onWin = () => setSpellPos(computeSpellPos());
const onDown = (e: MouseEvent) => {
if (spellPopupRef.current?.contains(e.target as Node)) return;
setSpellIssue(null);
};
window.addEventListener("resize", onWin);
document.addEventListener("mousedown", onDown);
return () => {
window.removeEventListener("resize", onWin);
document.removeEventListener("mousedown", onDown);
};
}, [spellIssue, computeSpellPos]);
// Take a suggestion: replace the marked words as if typed (so Ctrl+Z undoes it).
const applySuggestion = (issue: PlacedIssue, replacement: string) => {
const ed = ref.current;
setSpellIssue(null);
if (!ed || !stillValid(issue)) return;
ed.focus();
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(issue.range);
document.execCommand("insertText", false, replacement);
issuesRef.current = issuesRef.current.filter((i) => i !== issue);
emit();
};
// "Ignore": this word (or this grammar point) is fine, for the rest of the message.
const ignoreIssue = (issue: PlacedIssue) => {
const key = ignoreKey(issue);
ignoredRef.current.add(key);
issuesRef.current = issuesRef.current.filter((i) => ignoreKey(i) !== key);
paint(spellOwner.current, issuesRef.current);
setSpellIssue(null);
};
// A popup's top edge, relative to the editor: just under its anchor, or just
// above it when there is no room left below — inside the editor's visible
// box and the window alike — so it is never cut off at the bottom.
const popupTop = (anchor: DOMRect, popupH: number) => {
const er = ref.current!.getBoundingClientRect();
const bottom = Math.min(er.bottom, window.innerHeight) - 4;
const top = Math.max(er.top, 0) + 4;
const below = anchor.bottom + 4;
const above = anchor.top - 4 - popupH;
const fitsBelow = below + popupH <= bottom;
return (fitsBelow || above < top ? below : above) - er.top;
};
// Where the link popup goes: just under the link (or the text becoming one),
@@ -458,11 +639,27 @@ export const RichTextEditor = forwardRef<
const er = ed.getBoundingClientRect();
if (!ar || (!ar.width && !ar.height && !ar.top)) return newLinkRange ? { left: 12, top: 12 } : null;
const left = Math.max(8, Math.min(ar.left - er.left, er.width - 320));
return { left, top: ar.bottom - er.top + 4 };
return { left, top: popupTop(ar, linkPopupRef.current?.offsetHeight || 44) };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selLink, newLinkRange]);
useEffect(() => setLinkPos(computeLinkPos()), [selLink, newLinkRange, computeLinkPos]);
// The first placement guesses a popup's height; once it is on screen, place it
// again with the real one (it may need to go above after all). Only a change
// in position re-renders, so this settles at once.
useLayoutEffect(() => {
if (spellIssue && spellPopupRef.current) {
const p = computeSpellPos();
if (p && (p.top !== spellPos?.top || p.left !== spellPos?.left)) setSpellPos(p);
}
if ((selLink || newLinkRange) && linkPopupRef.current) {
const p = computeLinkPos();
if (p && (p.top !== linkPos?.top || p.left !== linkPos?.left)) setLinkPos(p);
}
// linkEdit: switching the link popup to its URL field changes its height.
}, [spellIssue, spellPos, computeSpellPos, selLink, newLinkRange, linkEdit, linkPos, computeLinkPos]);
// While the URL field has focus the browser hides the text selection, so the
// words about to become a link would look unselected. Keep them marked with a
// CSS highlight (styled in index.css; nothing is written into the message).
@@ -556,6 +753,13 @@ export const RichTextEditor = forwardRef<
const onEditorKeyDown = (e: React.KeyboardEvent) => {
// Typing (or Escape) in the message puts the link popup away. Escape stops
// here: the mail view's own Escape would otherwise minimise the whole window.
if (spellIssue) {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation(); // the popup, not the compose window
}
setSpellIssue(null);
}
if (selLink || newLinkRange) {
if (e.key === "Escape") {
e.preventDefault();
@@ -737,7 +941,12 @@ export const RichTextEditor = forwardRef<
role="textbox"
aria-multiline="true"
data-testid="rich-editor"
onInput={(e) => onInput(contentHtml(e.currentTarget))}
onInput={(e) => {
onInput(contentHtml(e.currentTarget));
scheduleSpellcheck();
}}
// Our own checker replaces the browser's, rather than doubling it up.
spellCheck={!spellOn}
onKeyUp={refreshActive}
onMouseUp={refreshActive}
onClick={onEditorClick}
@@ -745,9 +954,51 @@ export const RichTextEditor = forwardRef<
onScroll={() => {
setBox(computeBox());
setLinkPos(computeLinkPos());
setSpellPos(computeSpellPos());
}}
className={`wmc-editor ${quoteOpen ? "" : "wmc-quote-collapsed"} ${minHeight} flex-1 overflow-y-auto px-3 py-3 text-sm text-app-fg outline-none`}
/>
{spellIssue && spellPos && (
<div
ref={spellPopupRef}
role="dialog"
aria-label={t("compose.spellTitle")}
className="absolute z-20 w-[300px] max-w-[calc(100%-16px)] rounded-lg border border-app-border bg-app-elevated p-3 text-[13px] text-app-fg shadow-xl"
style={{ left: spellPos.left, top: spellPos.top }}
>
<p className="mb-2 flex items-start gap-2">
<span
aria-hidden
className={`mt-1 h-2 w-2 shrink-0 rounded-full ${
spellIssue.kind === "spelling" ? "bg-[#e5484d]" : "bg-app-accent"
}`}
/>
<span>{spellIssue.message}</span>
</p>
{spellIssue.replacements.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{spellIssue.replacements.slice(0, 5).map((r) => (
<button
key={r}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySuggestion(spellIssue, r)}
className="rounded-md bg-app-accent/10 px-2 py-0.5 font-medium text-app-accent hover:bg-app-accent/20"
>
{r || t("compose.spellRemove")}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => ignoreIssue(spellIssue)}
className="text-app-muted hover:text-app-fg hover:underline"
>
{t("compose.spellIgnore")}
</button>
</div>
)}
{(selLink || newLinkRange) && linkPos && (
<div
ref={linkPopupRef}
@@ -0,0 +1,143 @@
// Spelling and grammar marks for the compose editor (LanguageTool, through
// POST /api/spellcheck).
//
// Nothing is ever written into the message for this: the underlines are CSS
// highlights (`::highlight(wmc-spell)` / `wmc-grammar` in index.css) painted
// over live DOM Ranges, so the HTML that gets sent is exactly what was typed.
// Ranges also follow later edits on their own; an issue whose words changed is
// dropped rather than shown in the wrong place.
import type { SpellIssue } from "../../lib/types";
/** Where the checked text came from: each text node and its start offset in it. */
export interface TextMap {
text: string;
pieces: { node: Text; start: number }[];
}
export interface PlacedIssue extends SpellIssue {
range: Range;
/** The words as they were when checked: a mismatch means they were edited. */
original: string;
}
// The signature and the quoted original are not the user's writing to check.
const SKIP = ".wmc-signature, .wmc-quoted, .wmc-quote-toggle";
const BLOCK = /^(DIV|P|LI|UL|OL|BLOCKQUOTE|H[1-6]|TABLE|TR|PRE|SECTION)$/;
/**
* The editor's own text as one string, with a line break wherever the editor
* starts a new line (so "end." and "Next" on two lines never read as one word),
* plus the map back to the text nodes.
*/
export function collectText(root: HTMLElement): TextMap {
const pieces: TextMap["pieces"] = [];
let text = "";
const newline = () => {
if (text && !text.endsWith("\n")) text += "\n";
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
const t = node as Text;
if (t.data) {
pieces.push({ node: t, start: text.length });
text += t.data;
}
return;
}
if (!(node instanceof HTMLElement)) return;
if (node.matches(SKIP)) return;
if (node.tagName === "BR") {
text += "\n";
return;
}
const block = BLOCK.test(node.tagName);
if (block) newline();
node.childNodes.forEach(walk);
if (block) newline();
};
root.childNodes.forEach(walk);
return { text, pieces };
}
/** The DOM point for a character offset into the collected text. */
function pointAt(map: TextMap, offset: number, isEnd: boolean): [Text, number] | null {
for (const { node, start } of map.pieces) {
const end = start + node.data.length;
if (isEnd ? offset > start && offset <= end : offset >= start && offset < end) {
return [node, offset - start];
}
}
return null;
}
/** Issues pinned to live Ranges in the editor; ones that no longer fit are left out. */
export function place(map: TextMap, issues: SpellIssue[]): PlacedIssue[] {
const out: PlacedIssue[] = [];
for (const issue of issues) {
const original = map.text.slice(issue.offset, issue.offset + issue.length);
// Spanning a line break means spanning elements: not something to underline.
if (!original || original.includes("\n")) continue;
const a = pointAt(map, issue.offset, false);
const b = pointAt(map, issue.offset + issue.length, true);
if (!a || !b || !a[0].isConnected || !b[0].isConnected) continue;
const range = document.createRange();
try {
range.setStart(a[0], a[1]);
range.setEnd(b[0], b[1]);
} catch {
continue; // the text changed under us
}
if (range.toString() !== original) continue;
out.push({ ...issue, range, original });
}
return out;
}
/** Still pointing at the words it was found on (not edited since). */
export function stillValid(issue: PlacedIssue): boolean {
return issue.range.startContainer.isConnected && issue.range.toString() === issue.original;
}
/** The key "Ignore" remembers an issue by, for the rest of this message. */
export function ignoreKey(issue: { rule_id: string; original: string }): string {
return `${issue.rule_id}\u0000${issue.original}`;
}
type HighlightRegistry = Map<string, unknown>;
function registry(): HighlightRegistry | null {
return (globalThis.CSS as unknown as { highlights?: HighlightRegistry }).highlights ?? null;
}
/** Whether this browser can draw the underlines at all. */
export function canHighlight(): boolean {
return registry() !== null && "Highlight" in globalThis;
}
// Several compose windows can be open at once, but a highlight name is global
// to the page. Each editor keeps its own marks here, and every repaint draws
// all of them, so one window's check never wipes another's underlines.
const owners = new Map<object, PlacedIssue[]>();
function repaint(): void {
const reg = registry();
const HighlightCtor = (globalThis as unknown as { Highlight?: new (...r: Range[]) => unknown })
.Highlight;
if (!reg || !HighlightCtor) return;
const all = [...owners.values()].flat();
reg.set("wmc-spell", new HighlightCtor(...all.filter((i) => i.kind === "spelling").map((i) => i.range)));
reg.set("wmc-grammar", new HighlightCtor(...all.filter((i) => i.kind !== "spelling").map((i) => i.range)));
}
/** Paint one editor's underlines: red for spelling, blue for grammar and style. */
export function paint(owner: object, issues: PlacedIssue[]): void {
owners.set(owner, issues);
repaint();
}
/** Remove one editor's underlines (it closed, or checking was turned off). */
export function clearPaint(owner: object): void {
owners.delete(owner);
repaint();
}