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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -255,6 +255,13 @@ export const en = {
|
||||
"compose.linkEdit": "Edit",
|
||||
"compose.linkDelete": "Delete",
|
||||
"compose.linkSave": "Save",
|
||||
"compose.spellTitle": "Spelling and grammar",
|
||||
"compose.spellIgnore": "Ignore",
|
||||
"compose.spellRemove": "(remove)",
|
||||
"compose.spellMenu": "Spelling",
|
||||
"compose.spellAuto": "Auto-detect",
|
||||
"compose.spellOff": "Off",
|
||||
"compose.spellOffMessage": "Off for this message",
|
||||
"compose.imageTooLarge": "Image is too large (max 5 MB).",
|
||||
"compose.attachTooLarge": "File exceeds the 25 MB limit.",
|
||||
"compose.attachments": "Attachments",
|
||||
@@ -352,6 +359,12 @@ export const en = {
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.handle": "Handle",
|
||||
"settings.recoveryEmail": "Recovery email",
|
||||
"settings.spellcheck": "Spelling and grammar check",
|
||||
"settings.on": "On",
|
||||
"settings.off": "Off",
|
||||
"settings.spellcheckLanguage": "Language",
|
||||
"settings.spellcheckAuto": "Auto-detect (each paragraph)",
|
||||
"settings.spellcheckUnavailable": "Not available: this server has no spell-checking engine set up.",
|
||||
"settings.recoveryEmailHint":
|
||||
"If you forget your password, a link to choose a new one is sent here.",
|
||||
"settings.recoveryEmailNew": "Recovery email address",
|
||||
|
||||
@@ -192,6 +192,20 @@ body {
|
||||
background-color: rgb(var(--app-accent) / 0.25);
|
||||
}
|
||||
|
||||
/* Spelling (red) and grammar/style (blue) marks from the compose editor's
|
||||
LanguageTool check, drawn as CSS highlights over the text — never written
|
||||
into the message (components/compose/spellcheck.ts). */
|
||||
::highlight(wmc-spell) {
|
||||
text-decoration: underline wavy #e5484d;
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
::highlight(wmc-grammar) {
|
||||
text-decoration: underline wavy rgb(var(--app-accent));
|
||||
text-decoration-thickness: 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Thin, unobtrusive scrollbars that match the theme. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
SendResponse,
|
||||
ServerConfig,
|
||||
SessionInfo,
|
||||
SpellCheckOut,
|
||||
SyncWindow,
|
||||
ThreadDetail,
|
||||
ThreadListOut,
|
||||
@@ -230,11 +231,19 @@ export const api = {
|
||||
| "invitation_from"
|
||||
| "invitation_calendar"
|
||||
| "invitation_file"
|
||||
| "spellcheck_enabled"
|
||||
| "spellcheck_language"
|
||||
>
|
||||
>,
|
||||
) =>
|
||||
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
quota: () => apiFetch<QuotaOut>("/settings/quota"),
|
||||
spellcheck: (text: string, language: string) =>
|
||||
apiFetch<SpellCheckOut>("/spellcheck", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text, language }),
|
||||
}),
|
||||
spellcheckLanguages: () => apiFetch<{ code: string; name: string }[]>("/spellcheck/languages"),
|
||||
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
|
||||
|
||||
// --- calendars (builtin calendar plugin) ---
|
||||
|
||||
@@ -230,6 +230,27 @@ export interface UserSettings {
|
||||
invitation_calendar: string;
|
||||
invitation_file: boolean;
|
||||
allowed_image_senders: string[];
|
||||
/** Spelling and grammar check in the compose window (LanguageTool). */
|
||||
spellcheck_enabled: boolean;
|
||||
/** "auto", or a LanguageTool code such as "pl-PL". */
|
||||
spellcheck_language: string;
|
||||
}
|
||||
|
||||
/** One problem the spell checker found, by character offset in the checked text. */
|
||||
export interface SpellIssue {
|
||||
offset: number;
|
||||
length: number;
|
||||
message: string;
|
||||
short_message: string;
|
||||
replacements: string[];
|
||||
kind: "spelling" | "grammar" | "style";
|
||||
rule_id: string;
|
||||
}
|
||||
|
||||
export interface SpellCheckOut {
|
||||
language: string;
|
||||
language_name: string;
|
||||
issues: SpellIssue[];
|
||||
}
|
||||
|
||||
export interface QuotaOut {
|
||||
|
||||
@@ -720,6 +720,70 @@ function ServerRow({
|
||||
);
|
||||
}
|
||||
|
||||
// Spelling and grammar check in the compose window (LanguageTool on the server).
|
||||
function SpellcheckSetting() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
|
||||
// Also tells us whether this server has a checker at all (503 when not).
|
||||
const languages = useQuery({
|
||||
queryKey: ["spellcheck-languages"],
|
||||
queryFn: api.spellcheckLanguages,
|
||||
retry: false,
|
||||
staleTime: 3_600_000,
|
||||
});
|
||||
const patch = useMutation({
|
||||
mutationFn: api.patchSettings,
|
||||
onSuccess: (data) => queryClient.setQueryData(["settings"], data),
|
||||
});
|
||||
const on = settings.data?.spellcheck_enabled ?? true;
|
||||
const unavailable = languages.isError;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-b border-app-border py-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.spellcheck")}</span>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={unavailable || patch.isPending}
|
||||
onClick={() => patch.mutate({ spellcheck_enabled: value })}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
on === value ? "bg-app-active font-medium text-app-fg" : "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "settings.on" : "settings.off")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{unavailable ? (
|
||||
<p className="text-xs text-app-faint">{t("settings.spellcheckUnavailable")}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-app-muted">{t("settings.spellcheckLanguage")}</span>
|
||||
<select
|
||||
className="max-w-[60%] rounded border border-app-border bg-app-surface px-2 py-1 disabled:opacity-50"
|
||||
disabled={!on || patch.isPending}
|
||||
value={settings.data?.spellcheck_language ?? "auto"}
|
||||
onChange={(e) => patch.mutate({ spellcheck_language: e.target.value })}
|
||||
>
|
||||
<option value="auto">{t("settings.spellcheckAuto")}</option>
|
||||
{(languages.data ?? []).map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection() {
|
||||
const t = useT();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -757,6 +821,7 @@ function AppearanceSection() {
|
||||
{/* Saved per device, like the theme: the right choice depends on the screen. */}
|
||||
<p className="text-xs text-app-faint">{t("display.phoneNote")}</p>
|
||||
</div>
|
||||
<SpellcheckSetting />
|
||||
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
|
||||
<span className="text-app-muted">{t("settings.threadOrder")}</span>
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user