mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
387 lines
16 KiB
TypeScript
387 lines
16 KiB
TypeScript
"use client";
|
|
|
|
// Proofreading in one composer: runs the checker plugins (see
|
|
// lib/proofreading/composer-proofreader.ts) on the editor or the plain-text
|
|
// textarea, draws their underlines and applies the fixes the user picks.
|
|
|
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore, type RefObject } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import { announce } from "@/lib/announce";
|
|
import type { Editor } from "@tiptap/core";
|
|
import { TextSelection } from "@tiptap/pm/state";
|
|
import { ComposerProofreader, type DismissAction, type PlacedIssue, type ProofreaderStatus } from "@/lib/proofreading/composer-proofreader";
|
|
import { checkerAccess, type PolicyView } from "@/lib/proofreading/gate";
|
|
import { draftProtection, OPTIONS_SURFACE } from "@/lib/proofreading/protection";
|
|
import { plainSegments, richSegments } from "@/lib/proofreading/segments";
|
|
import { allCheckers, dispatchCheck, dispatchCheckerAction, registerCheckTarget, subscribeCheckers } from "@/lib/plugin-sandbox/checkers";
|
|
import { actionsFor, subscribeActions } from "@/lib/plugin-sandbox/actions";
|
|
import { all as allActivePlugins, subscribe as subscribeActivePlugins } from "@/lib/plugin-sandbox/registry";
|
|
import { replaceTextareaRange, type PlainOwnOptions } from "@/lib/plugin-sandbox/composer-edit";
|
|
import { usePolicyStore } from "@/stores/policy-store";
|
|
import { knownPlugins, subscribeKnownPlugins } from "@/lib/plugin-sandbox/known-plugins";
|
|
import type { InstalledPlugin } from "@/lib/plugin-types";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import {
|
|
applyIssueReplacement, drawnIssues, issueNavigationKey, issueRect, selectNextIssue, setActiveIssue, setTextIssues,
|
|
type IssueOpenVia, type TextCheckOptions,
|
|
} from "./text-check-extension";
|
|
|
|
/**
|
|
* The policy as the gate needs it, read now. Settled only with a policy from
|
|
* the server (or the copy this browser kept of one): when the fetch failed
|
|
* and nothing was ever fetched, the built-in defaults could undo an admin's
|
|
* lock, so nothing is checked.
|
|
*/
|
|
export function currentPolicyView(): PolicyView {
|
|
const s = usePolicyStore.getState();
|
|
return {
|
|
restrictions: s.policy.restrictions ?? {},
|
|
defaults: (s.policy.defaults ?? {}) as Record<string, unknown>,
|
|
settled: policySettled(s),
|
|
};
|
|
}
|
|
|
|
export function policySettled(s: Pick<ReturnType<typeof usePolicyStore.getState>, 'loaded' | 'partial' | 'subjectPending' | 'source'>): boolean {
|
|
return s.loaded && !s.partial && !s.subjectPending && s.source !== 'fallback' && s.source !== 'none';
|
|
}
|
|
|
|
/** Plugins turned on that aren't running yet (still loading). */
|
|
function startingPlugins(running: InstalledPlugin[]): InstalledPlugin[] {
|
|
const up = new Set(running.map((p) => p.id));
|
|
return knownPlugins().filter((p) => p.enabled && p.status !== 'error' && p.status !== 'disabled' && !up.has(p.id));
|
|
}
|
|
|
|
// The per-composer switch outlives a remount (pop-out, layout switch).
|
|
const composerSwitches = new Map<string, boolean>();
|
|
|
|
export interface UseProofreadingArgs {
|
|
composerId: string | null;
|
|
sessionKey: string;
|
|
editor: Editor | null;
|
|
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
plainTextMode: boolean;
|
|
ownOptions: RefObject<PlainOwnOptions>;
|
|
}
|
|
|
|
export interface Proofreading {
|
|
issues: PlacedIssue[];
|
|
status: ProofreaderStatus;
|
|
/** The per-composer switch (ignored when the admin requires checking). */
|
|
composerOn: boolean;
|
|
setComposerOn: (on: boolean) => void;
|
|
/** Put a suggestion in; false when the text changed meanwhile. */
|
|
apply: (issue: PlacedIssue, replacement: string) => boolean;
|
|
dismiss: (issue: PlacedIssue, action: DismissAction) => void;
|
|
/** Whether the browser's own spell check should be on. */
|
|
browserSpellcheck: boolean;
|
|
/** Issues of checkers that asked for a warning before sending. */
|
|
sendWarningIssues: () => PlacedIssue[];
|
|
/** The popup on one issue. */
|
|
popup: IssuePopupState | null;
|
|
openPopup: (key: string, anchor: DOMRect | null, via: IssueOpenVia) => void;
|
|
closePopup: (refocus: boolean) => void;
|
|
/** The status control's panel, and the issue to focus in its list. */
|
|
panel: { focusKey: string | null } | null;
|
|
setPanel: (panel: { focusKey: string | null } | null) => void;
|
|
navigate: (dir: 1 | -1) => boolean;
|
|
/** From the list: select the issue in the text and open its popup. */
|
|
jumpTo: (issue: PlacedIssue) => void;
|
|
/** For the rich editor (RichTextEditor's textCheck prop). */
|
|
editorHandlers: TextCheckOptions;
|
|
}
|
|
|
|
export interface IssuePopupState {
|
|
key: string;
|
|
anchor: DOMRect | null;
|
|
via: IssueOpenVia;
|
|
}
|
|
|
|
const EMPTY_STATUS: ProofreaderStatus = {
|
|
available: false, on: false, userOn: false, forced: false, lockedOff: false, paused: null,
|
|
checking: false, trouble: null, count: 0, activeLabels: [], activePlugins: [], availablePlugins: [],
|
|
};
|
|
|
|
export function useProofreading({ composerId, sessionKey, editor, textareaRef, plainTextMode, ownOptions }: UseProofreadingArgs): Proofreading {
|
|
const checkers = useSyncExternalStore(subscribeCheckers, allCheckers, allCheckers);
|
|
const [version, bump] = useReducer((n: number) => n + 1, 0);
|
|
const [proofreader, setProofreader] = useState<ComposerProofreader | null>(null);
|
|
const [composerOn, setComposerOnState] = useState(() => composerSwitches.get(sessionKey) ?? true);
|
|
const composerOnRef = useRef(composerOn);
|
|
composerOnRef.current = composerOn;
|
|
const editorRef = useRef(editor);
|
|
editorRef.current = editor;
|
|
const t = useTranslations("proofreading");
|
|
const tRef = useRef(t);
|
|
tRef.current = t;
|
|
const plainRef = useRef(plainTextMode);
|
|
plainRef.current = plainTextMode;
|
|
const hasCheckers = checkers.length > 0;
|
|
|
|
useEffect(() => {
|
|
if (!composerId || !hasCheckers) return;
|
|
const p = new ComposerProofreader({
|
|
composerId,
|
|
checkers: allCheckers,
|
|
readSegments: () => {
|
|
const opts = ownOptions.current ?? {};
|
|
if (plainRef.current) return plainSegments(textareaRef.current?.value ?? "", opts);
|
|
const ed = editorRef.current;
|
|
return ed && !ed.isDestroyed ? richSegments(ed.state.doc, opts) : [];
|
|
},
|
|
language: () => useSettingsStore.getState().writingLanguage,
|
|
access: (pluginId) => checkerAccess(currentPolicyView(), useSettingsStore.getState(), pluginId),
|
|
protection: () => {
|
|
const running = allActivePlugins().map((e) => e.plugin);
|
|
return draftProtection(actionsFor(OPTIONS_SURFACE), running, startingPlugins(running));
|
|
},
|
|
composerOn: () => composerOnRef.current,
|
|
send: (spec, request) => dispatchCheck(spec, request),
|
|
action: (spec, payload) => dispatchCheckerAction(spec, payload),
|
|
onChange: bump,
|
|
});
|
|
setProofreader(p);
|
|
const unsubscribe = [
|
|
registerCheckTarget(composerId, {
|
|
getCheckText: (spec) => p.getCheckText(spec),
|
|
setAnnotations: (spec, revision, raw) => p.setAnnotations(spec, revision, raw),
|
|
}),
|
|
subscribeCheckers(() => p.sync()),
|
|
subscribeActions(() => p.gateChanged()),
|
|
subscribeActivePlugins(() => p.gateChanged()),
|
|
subscribeKnownPlugins(() => p.gateChanged()),
|
|
usePolicyStore.subscribe(() => p.gateChanged()),
|
|
useSettingsStore.subscribe((s, prev) => {
|
|
if (s.writingLanguage !== prev.writingLanguage) p.edited("now");
|
|
if (s.proofreading !== prev.proofreading || s.proofreadingPlugins !== prev.proofreadingPlugins) p.gateChanged();
|
|
}),
|
|
];
|
|
return () => {
|
|
for (const u of unsubscribe) u();
|
|
p.dispose();
|
|
setProofreader(null);
|
|
};
|
|
}, [composerId, hasCheckers, ownOptions, textareaRef]);
|
|
|
|
// Typing, pasting and leaving the text.
|
|
useEffect(() => {
|
|
if (!proofreader) return;
|
|
if (plainTextMode) {
|
|
const ta = textareaRef.current;
|
|
if (!ta) return;
|
|
const typing = () => proofreader.edited("typing");
|
|
const now = () => setTimeout(() => proofreader.edited("now"), 0);
|
|
ta.addEventListener("input", typing);
|
|
ta.addEventListener("paste", now);
|
|
ta.addEventListener("blur", now);
|
|
proofreader.edited("now");
|
|
// The text is the same, so no new answer will say so: read the issues
|
|
// again now that the textarea is there.
|
|
bump();
|
|
return () => {
|
|
ta.removeEventListener("input", typing);
|
|
ta.removeEventListener("paste", now);
|
|
ta.removeEventListener("blur", now);
|
|
};
|
|
}
|
|
if (!editor || editor.isDestroyed) return;
|
|
const typing = () => proofreader.edited("typing");
|
|
const now = () => setTimeout(() => proofreader.edited("now"), 0);
|
|
const dom = editor.view.dom;
|
|
editor.on("update", typing);
|
|
dom.addEventListener("paste", now);
|
|
editor.on("blur", now);
|
|
proofreader.edited("now");
|
|
bump();
|
|
return () => {
|
|
editor.off("update", typing);
|
|
dom.removeEventListener("paste", now);
|
|
editor.off("blur", now);
|
|
};
|
|
}, [proofreader, editor, plainTextMode, textareaRef]);
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is the change signal
|
|
const issues = useMemo(() => proofreader?.issues() ?? [], [proofreader, version, plainTextMode]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is the change signal
|
|
const status = useMemo(() => proofreader?.status() ?? EMPTY_STATUS, [proofreader, version, composerOn]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- version is the change signal
|
|
const browserSpellcheck = useMemo(() => proofreader?.browserSpellcheck() ?? true, [proofreader, version, composerOn]);
|
|
|
|
// Underlines in the rich editor.
|
|
const drawnRef = useRef("");
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed || !editor.state || plainTextMode) return;
|
|
const ranges = issues.map((i) => ({ key: i.key, from: i.from, to: i.to, kind: i.kind, text: i.annotation.text }));
|
|
const signature = ranges.map((r) => `${r.key}@${r.from}`).join(",");
|
|
if (signature === drawnRef.current) return;
|
|
drawnRef.current = signature;
|
|
setTextIssues(editor, ranges);
|
|
}, [editor, issues, plainTextMode]);
|
|
|
|
// Which underlines are still there: typing into one removes it at once,
|
|
// and the list and the count follow.
|
|
const [drawnKeys, setDrawnKeys] = useState<string[] | null>(null);
|
|
useEffect(() => {
|
|
if (!editor || editor.isDestroyed || !editor.state || plainTextMode) { setDrawnKeys(null); return; }
|
|
const read = () => {
|
|
const keys = drawnIssues(editor.state).map((r) => r.key);
|
|
setDrawnKeys((prev) => (prev && prev.length === keys.length && prev.every((k, i) => k === keys[i]) ? prev : keys));
|
|
};
|
|
read();
|
|
editor.on("transaction", read);
|
|
return () => { editor.off("transaction", read); };
|
|
}, [editor, plainTextMode]);
|
|
const visibleIssues = useMemo(() => {
|
|
if (drawnKeys === null) return issues;
|
|
const keep = new Set(drawnKeys);
|
|
return issues.filter((i) => keep.has(i.key));
|
|
}, [issues, drawnKeys]);
|
|
const visibleStatus = useMemo(
|
|
() => (status.count === visibleIssues.length ? status : { ...status, count: visibleIssues.length }),
|
|
[status, visibleIssues.length],
|
|
);
|
|
|
|
const setComposerOn = useCallback((on: boolean) => {
|
|
composerSwitches.set(sessionKey, on);
|
|
composerOnRef.current = on;
|
|
setComposerOnState(on);
|
|
proofreader?.gateChanged();
|
|
}, [sessionKey, proofreader]);
|
|
|
|
// ─── The popup on an issue, and the list in the status control ───
|
|
const [popup, setPopup] = useState<IssuePopupState | null>(null);
|
|
const [panel, setPanel] = useState<{ focusKey: string | null } | null>(null);
|
|
const popupRef = useRef(popup);
|
|
popupRef.current = popup;
|
|
const visibleRef = useRef(visibleIssues);
|
|
visibleRef.current = visibleIssues;
|
|
|
|
const openPopup = useCallback((key: string, anchor: DOMRect | null, via: IssueOpenVia) => {
|
|
setPanel(null);
|
|
setPopup({ key, anchor, via });
|
|
const ed = editorRef.current;
|
|
if (ed && !plainRef.current) setActiveIssue(ed, key);
|
|
}, []);
|
|
|
|
const closePopup = useCallback((refocus: boolean) => {
|
|
setPopup(null);
|
|
const ed = editorRef.current;
|
|
if (ed && !ed.isDestroyed && !plainRef.current) {
|
|
setActiveIssue(ed, null);
|
|
if (refocus) ed.commands.focus();
|
|
} else if (refocus) {
|
|
textareaRef.current?.focus();
|
|
}
|
|
}, [textareaRef]);
|
|
|
|
/** Go to the next or previous issue: in the text (rich) or in the list (plain). */
|
|
const navigate = useCallback((dir: 1 | -1) => {
|
|
const list = visibleRef.current;
|
|
if (!list.length) {
|
|
if (composerOnRef.current) announce(tRef.current("panel.no_issues"), { key: "proofreading-count" });
|
|
return false;
|
|
}
|
|
const ed = editorRef.current;
|
|
if (ed && !plainRef.current) {
|
|
const key = selectNextIssue(ed, dir);
|
|
if (!key) return false;
|
|
openPopup(key, issueRect(ed, key), "keyboard");
|
|
return true;
|
|
}
|
|
const ta = textareaRef.current;
|
|
const start = ta?.selectionStart ?? 0;
|
|
const end = ta?.selectionEnd ?? start;
|
|
const next = dir === 1
|
|
? list.find((i) => i.from >= end && !(i.from === start && i.to === end)) ?? list[0]
|
|
: [...list].reverse().find((i) => i.to <= start && !(i.from === start && i.to === end)) ?? list[list.length - 1];
|
|
ta?.setSelectionRange(next.from, next.to);
|
|
setPopup(null);
|
|
setPanel({ focusKey: next.key });
|
|
return true;
|
|
}, [openPopup, textareaRef]);
|
|
|
|
/** Show an issue from the list: select it in the text and open its popup. */
|
|
const jumpTo = useCallback((issue: PlacedIssue) => {
|
|
const ed = editorRef.current;
|
|
if (!ed || plainRef.current) return;
|
|
const range = drawnIssues(ed.state).find((r) => r.key === issue.key);
|
|
if (!range) return;
|
|
// Focus now, not later as TipTap's focus command does: a late focus
|
|
// would pull the keyboard out of the popup again.
|
|
ed.view.focus();
|
|
ed.view.dispatch(ed.state.tr.setSelection(TextSelection.create(ed.state.doc, range.from, range.to)).scrollIntoView());
|
|
openPopup(issue.key, issueRect(ed, issue.key), "keyboard");
|
|
}, [openPopup]);
|
|
|
|
// Keys in the rich text (see text-check-extension.ts).
|
|
const editorHandlers = useMemo<TextCheckOptions>(() => ({
|
|
onOpen: openPopup,
|
|
onKey: (event) => {
|
|
if (event.key === "Escape" && popupRef.current) {
|
|
closePopup(true);
|
|
return true;
|
|
}
|
|
const dir = issueNavigationKey(event);
|
|
if (dir && navigate(dir)) {
|
|
event.preventDefault();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
}), [openPopup, closePopup, navigate]);
|
|
|
|
// The same keys in the plain-text composer.
|
|
useEffect(() => {
|
|
const ta = textareaRef.current;
|
|
if (!plainTextMode || !ta || !proofreader) return;
|
|
const onKey = (event: KeyboardEvent) => {
|
|
const dir = issueNavigationKey(event);
|
|
if (dir && navigate(dir)) event.preventDefault();
|
|
};
|
|
ta.addEventListener("keydown", onKey);
|
|
return () => ta.removeEventListener("keydown", onKey);
|
|
}, [plainTextMode, textareaRef, proofreader, navigate]);
|
|
|
|
// An issue that went away (fixed, typed over) closes its popup.
|
|
// With the focus in it, the focus goes back to the text.
|
|
useEffect(() => {
|
|
if (!popup || visibleIssues.some((i) => i.key === popup.key)) return;
|
|
const focusInside = !!(document.activeElement as Element | null)?.closest?.('[data-testid="proofreading-popup"]');
|
|
closePopup(focusInside);
|
|
}, [popup, visibleIssues, closePopup]);
|
|
|
|
const apply = useCallback((issue: PlacedIssue, replacement: string): boolean => {
|
|
if (!proofreader) return false;
|
|
let done = false;
|
|
if (plainRef.current) {
|
|
const ta = textareaRef.current;
|
|
// Where the issue is now, and only if it still says the same.
|
|
const now = ta ? proofreader.issues().find((i) => i.key === issue.key) : undefined;
|
|
if (ta && now && ta.value.slice(now.from, now.to) === issue.annotation.text) {
|
|
done = replaceTextareaRange(ta, now.from, now.to, replacement);
|
|
}
|
|
} else if (editorRef.current) {
|
|
done = applyIssueReplacement(editorRef.current, issue.key, replacement);
|
|
}
|
|
if (done) {
|
|
proofreader.edited("now");
|
|
setPopup(null);
|
|
const ed = editorRef.current;
|
|
if (ed && !plainRef.current) { setActiveIssue(ed, null); ed.commands.focus(); }
|
|
}
|
|
bump();
|
|
return done;
|
|
}, [proofreader, textareaRef]);
|
|
|
|
const dismiss = useCallback((issue: PlacedIssue, action: DismissAction) => {
|
|
proofreader?.dismiss(issue, action);
|
|
if (popupRef.current?.key === issue.key) closePopup(true);
|
|
}, [proofreader, closePopup]);
|
|
|
|
const sendWarningIssues = useCallback(() => proofreader?.issuesForSendWarning() ?? [], [proofreader]);
|
|
|
|
return {
|
|
issues: visibleIssues, status: visibleStatus, composerOn, setComposerOn, apply, dismiss, browserSpellcheck, sendWarningIssues,
|
|
popup, openPopup, closePopup, panel, setPanel, navigate, jumpTo, editorHandlers,
|
|
};
|
|
}
|