@remit/ui 0.0.119 → 0.0.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/components/compose-body.spellcheck.test.ts +5 -2
- package/src/components/rich-text-editor.stories.tsx +190 -16
- package/src/components/rich-text-editor.tsx +72 -29
- package/src/components/rich-text-spellcheck-double.ts +65 -0
- package/src/components/rich-text-spellcheck-engine.ts +267 -0
- package/src/components/rich-text-spellcheck-languages.ts +62 -0
- package/src/components/rich-text-spellcheck-menu.test.ts +23 -8
- package/src/components/rich-text-spellcheck-notice.tsx +168 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +296 -197
- package/src/components/rich-text-spellcheck-provider.ts +64 -10
- package/src/components/rich-text-spellcheck-words.ts +16 -354
- package/src/components/rich-text-spellcheck-worker-provider.ts +17 -7
- package/src/components/rich-text-spellcheck-worker.test.ts +253 -0
- package/src/components/rich-text-spellcheck-worker.ts +63 -15
- package/src/components/rich-text-spellcheck.test.ts +123 -9
- package/src/components/rich-text-spellcheck.ts +31 -2
- package/src/rich-text.ts +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hunspell, over the WebAssembly build in `docker/hunspell/`. The engine
|
|
3
|
+
* and the two dictionary files are fetched, never bundled: a language costs
|
|
4
|
+
* nothing until someone writes in it, and the bytes are reported as they arrive
|
|
5
|
+
* because the first Dutch download is fourteen seconds on a bad link and a
|
|
6
|
+
* composer that says nothing for fourteen seconds looks broken.
|
|
7
|
+
*
|
|
8
|
+
* Dictionaries are handed to Hunspell as paths, so the two files go into the
|
|
9
|
+
* module's own in-memory filesystem exactly as downloaded. Nothing rewrites
|
|
10
|
+
* them — the served bytes are the upstream source, which is what keeps every
|
|
11
|
+
* dictionary licence at its verbatim-redistribution minimum.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SpellEngine {
|
|
15
|
+
spell(word: string): boolean;
|
|
16
|
+
suggest(word: string): readonly string[];
|
|
17
|
+
add(word: string): void;
|
|
18
|
+
close(): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DownloadProgress {
|
|
22
|
+
readonly bytesLoaded: number;
|
|
23
|
+
readonly bytesTotal: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EngineFailure {
|
|
27
|
+
readonly ok: false;
|
|
28
|
+
/** Never arrived, or arrived and was rejected — different remedies. */
|
|
29
|
+
readonly reason: "download" | "engine";
|
|
30
|
+
readonly detail: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Opening is two designed failures and a success, so it answers with which one
|
|
35
|
+
* rather than throwing: what the composer puts in front of the writer, and what
|
|
36
|
+
* the report link carries, is this string.
|
|
37
|
+
*/
|
|
38
|
+
export type EngineResult =
|
|
39
|
+
| { readonly ok: true; readonly engine: SpellEngine }
|
|
40
|
+
| EngineFailure;
|
|
41
|
+
|
|
42
|
+
type Attempt<T> = { readonly ok: true; readonly value: T } | EngineFailure;
|
|
43
|
+
|
|
44
|
+
interface HunspellModule {
|
|
45
|
+
readonly FS: { writeFile(path: string, data: Uint8Array): void };
|
|
46
|
+
_free(pointer: number): void;
|
|
47
|
+
_remit_open(affPath: number, dicPath: number): number;
|
|
48
|
+
_remit_close(handle: number): void;
|
|
49
|
+
_remit_spell(handle: number, word: number): number;
|
|
50
|
+
_remit_add(handle: number, word: number): number;
|
|
51
|
+
_remit_suggest(handle: number, word: number): number;
|
|
52
|
+
stringToNewUTF8(text: string): number;
|
|
53
|
+
UTF8ToString(pointer: number): string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface HunspellOptions {
|
|
57
|
+
instantiateWasm(
|
|
58
|
+
imports: WebAssembly.Imports,
|
|
59
|
+
ready: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
|
|
60
|
+
): Record<string, never>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type HunspellFactory = (options: HunspellOptions) => Promise<HunspellModule>;
|
|
64
|
+
|
|
65
|
+
const isFactory = (value: unknown): value is { default: HunspellFactory } =>
|
|
66
|
+
typeof value === "object" &&
|
|
67
|
+
value !== null &&
|
|
68
|
+
typeof (value as { default?: unknown }).default === "function";
|
|
69
|
+
|
|
70
|
+
export interface EngineAssets {
|
|
71
|
+
/** The Emscripten loader, as an ES module with the factory as its default. */
|
|
72
|
+
loadFactory(url: string): Promise<unknown>;
|
|
73
|
+
fetch(url: string): Promise<Response>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const browserAssets: EngineAssets = {
|
|
77
|
+
loadFactory: (url) => import(/* @vite-ignore */ url),
|
|
78
|
+
fetch: (url) => fetch(url),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const failure = (
|
|
82
|
+
reason: "download" | "engine",
|
|
83
|
+
detail: string,
|
|
84
|
+
): EngineFailure => ({ ok: false, reason, detail });
|
|
85
|
+
|
|
86
|
+
const said = (error: unknown): string =>
|
|
87
|
+
error instanceof Error ? error.message : String(error);
|
|
88
|
+
|
|
89
|
+
const attempt = <T>(
|
|
90
|
+
work: Promise<T>,
|
|
91
|
+
reason: "download" | "engine",
|
|
92
|
+
describe: (message: string) => string,
|
|
93
|
+
): Promise<Attempt<T>> =>
|
|
94
|
+
work.then(
|
|
95
|
+
(value) => ({ ok: true as const, value }),
|
|
96
|
+
(error: unknown) => failure(reason, describe(said(error))),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A failure a person can act on: the file, the status, and nothing about
|
|
101
|
+
* promises.
|
|
102
|
+
*/
|
|
103
|
+
const download = async (
|
|
104
|
+
assets: EngineAssets,
|
|
105
|
+
url: string,
|
|
106
|
+
): Promise<Attempt<Response>> => {
|
|
107
|
+
const fetched = await attempt(
|
|
108
|
+
assets.fetch(url),
|
|
109
|
+
"download",
|
|
110
|
+
(message) => `${url} could not be reached: ${message}`,
|
|
111
|
+
);
|
|
112
|
+
if (!fetched.ok) return fetched;
|
|
113
|
+
if (!fetched.value.ok) {
|
|
114
|
+
return failure("download", `${url} answered ${fetched.value.status}`);
|
|
115
|
+
}
|
|
116
|
+
return fetched;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const readBody = async (
|
|
120
|
+
response: Response,
|
|
121
|
+
counted: (added: number) => void,
|
|
122
|
+
): Promise<Uint8Array<ArrayBuffer>> => {
|
|
123
|
+
const body = response.body;
|
|
124
|
+
if (!body) {
|
|
125
|
+
const whole = new Uint8Array(await response.arrayBuffer());
|
|
126
|
+
counted(whole.byteLength);
|
|
127
|
+
return whole;
|
|
128
|
+
}
|
|
129
|
+
const reader = body.getReader();
|
|
130
|
+
const chunks: Uint8Array[] = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const step = await reader.read();
|
|
134
|
+
if (step.done) break;
|
|
135
|
+
chunks.push(step.value);
|
|
136
|
+
size += step.value.byteLength;
|
|
137
|
+
counted(step.value.byteLength);
|
|
138
|
+
}
|
|
139
|
+
const whole = new Uint8Array(size);
|
|
140
|
+
let at = 0;
|
|
141
|
+
for (const chunk of chunks) {
|
|
142
|
+
whole.set(chunk, at);
|
|
143
|
+
at += chunk.byteLength;
|
|
144
|
+
}
|
|
145
|
+
return whole;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export interface EngineRequest {
|
|
149
|
+
/** Where `hunspell.mjs`, `hunspell.wasm` and `dictionaries/` are served. */
|
|
150
|
+
readonly base: string;
|
|
151
|
+
/** The tag the build staged, which is not always the tag the writer picked. */
|
|
152
|
+
readonly tag: string;
|
|
153
|
+
/**
|
|
154
|
+
* What the build says these files weigh. `content-length` is the wrong
|
|
155
|
+
* number: the files are served brotli-compressed and counted here
|
|
156
|
+
* decompressed, so believing the header names a size a quarter of the truth
|
|
157
|
+
* and then walks past it while the writer watches.
|
|
158
|
+
*/
|
|
159
|
+
readonly bytesExpected: number;
|
|
160
|
+
onProgress(progress: DownloadProgress): void;
|
|
161
|
+
assets?: EngineAssets;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const openEngine = async ({
|
|
165
|
+
base,
|
|
166
|
+
tag,
|
|
167
|
+
bytesExpected,
|
|
168
|
+
onProgress,
|
|
169
|
+
assets = browserAssets,
|
|
170
|
+
}: EngineRequest): Promise<EngineResult> => {
|
|
171
|
+
const dictionary = `${base}dictionaries/${tag}`;
|
|
172
|
+
const answers = await Promise.all([
|
|
173
|
+
download(assets, `${base}hunspell.wasm`),
|
|
174
|
+
download(assets, `${dictionary}/index.aff`),
|
|
175
|
+
download(assets, `${dictionary}/index.dic`),
|
|
176
|
+
]);
|
|
177
|
+
for (const answer of answers) if (!answer.ok) return answer;
|
|
178
|
+
const responses = answers
|
|
179
|
+
.filter((answer) => answer.ok)
|
|
180
|
+
.map((answer) => answer.value);
|
|
181
|
+
|
|
182
|
+
let bytesLoaded = 0;
|
|
183
|
+
const report = (added: number): void => {
|
|
184
|
+
bytesLoaded += added;
|
|
185
|
+
onProgress({
|
|
186
|
+
bytesLoaded,
|
|
187
|
+
bytesTotal: Math.max(bytesExpected, bytesLoaded),
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
report(0);
|
|
191
|
+
|
|
192
|
+
const [wasm, aff, dic] = await Promise.all(
|
|
193
|
+
responses.map((response) => readBody(response, report)),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const loaded = await attempt(
|
|
197
|
+
assets.loadFactory(`${base}hunspell.mjs`),
|
|
198
|
+
"download",
|
|
199
|
+
(message) => `${base}hunspell.mjs could not be loaded: ${message}`,
|
|
200
|
+
);
|
|
201
|
+
if (!loaded.ok) return loaded;
|
|
202
|
+
if (!isFactory(loaded.value)) {
|
|
203
|
+
return failure("engine", `${base}hunspell.mjs exports no engine`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const started = await attempt(
|
|
207
|
+
loaded.value.default({
|
|
208
|
+
instantiateWasm: (imports, ready) => {
|
|
209
|
+
WebAssembly.instantiate(wasm, imports).then((built) => {
|
|
210
|
+
ready(built.instance, built.module);
|
|
211
|
+
});
|
|
212
|
+
return {};
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
"engine",
|
|
216
|
+
(message) => `the engine did not start: ${message}`,
|
|
217
|
+
);
|
|
218
|
+
if (!started.ok) return started;
|
|
219
|
+
const engine = started.value;
|
|
220
|
+
|
|
221
|
+
engine.FS.writeFile("/index.aff", aff);
|
|
222
|
+
engine.FS.writeFile("/index.dic", dic);
|
|
223
|
+
const affPath = engine.stringToNewUTF8("/index.aff");
|
|
224
|
+
const dicPath = engine.stringToNewUTF8("/index.dic");
|
|
225
|
+
const handle = engine._remit_open(affPath, dicPath);
|
|
226
|
+
engine._free(affPath);
|
|
227
|
+
engine._free(dicPath);
|
|
228
|
+
if (handle === 0) {
|
|
229
|
+
return failure("engine", `the ${tag} dictionary did not load`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const withWord = <T>(word: string, use: (pointer: number) => T): T => {
|
|
233
|
+
const pointer = engine.stringToNewUTF8(word);
|
|
234
|
+
try {
|
|
235
|
+
return use(pointer);
|
|
236
|
+
} finally {
|
|
237
|
+
engine._free(pointer);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
let open = true;
|
|
242
|
+
return {
|
|
243
|
+
ok: true,
|
|
244
|
+
engine: {
|
|
245
|
+
spell: (word) =>
|
|
246
|
+
open && withWord(word, (at) => engine._remit_spell(handle, at) !== 0),
|
|
247
|
+
suggest: (word) => {
|
|
248
|
+
if (!open) return [];
|
|
249
|
+
const answer = withWord(word, (at) =>
|
|
250
|
+
engine._remit_suggest(handle, at),
|
|
251
|
+
);
|
|
252
|
+
if (answer === 0) return [];
|
|
253
|
+
const joined = engine.UTF8ToString(answer);
|
|
254
|
+
engine._free(answer);
|
|
255
|
+
return joined === "" ? [] : joined.split("\n");
|
|
256
|
+
},
|
|
257
|
+
add: (word) => {
|
|
258
|
+
if (open) withWord(word, (at) => engine._remit_add(handle, at));
|
|
259
|
+
},
|
|
260
|
+
close: () => {
|
|
261
|
+
if (!open) return;
|
|
262
|
+
open = false;
|
|
263
|
+
engine._remit_close(handle);
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this build carries, and where it serves it from. Both are decided by
|
|
3
|
+
* `REMIT_SPELLCHECK_LANGUAGES` at build time and written in by the web client's
|
|
4
|
+
* spellcheck plugin, which stages exactly the same set — so there is no second
|
|
5
|
+
* list to fall out of step with the files on disk.
|
|
6
|
+
*
|
|
7
|
+
* A build that stages nothing leaves both undefined, and every language is
|
|
8
|
+
* `unavailable`: no worker starts, and the browser keeps checking.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
declare const __REMIT_SPELLCHECK_LANGUAGES__: readonly string[];
|
|
12
|
+
declare const __REMIT_SPELLCHECK_BASE__: string;
|
|
13
|
+
declare const __REMIT_SPELLCHECK_BYTES__: Readonly<Record<string, number>>;
|
|
14
|
+
|
|
15
|
+
export const spellcheckLanguages = (): readonly string[] =>
|
|
16
|
+
typeof __REMIT_SPELLCHECK_LANGUAGES__ === "undefined"
|
|
17
|
+
? []
|
|
18
|
+
: __REMIT_SPELLCHECK_LANGUAGES__;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Absolute, and resolved here rather than baked in: a Storybook build is
|
|
22
|
+
* published under a path it cannot know at build time, so what the plugin
|
|
23
|
+
* writes in is relative and the document says where it lands. The app's own
|
|
24
|
+
* base is already absolute and survives the same resolution unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export const spellcheckBase = (): string => {
|
|
27
|
+
const staged =
|
|
28
|
+
typeof __REMIT_SPELLCHECK_BASE__ === "undefined" ||
|
|
29
|
+
__REMIT_SPELLCHECK_BASE__ === ""
|
|
30
|
+
? "/spellcheck/"
|
|
31
|
+
: __REMIT_SPELLCHECK_BASE__;
|
|
32
|
+
if (typeof document === "undefined") return staged;
|
|
33
|
+
return new URL(staged, document.baseURI).href;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What opening a language costs, counted by the build over the bytes it staged.
|
|
38
|
+
* `content-length` cannot answer this: the engine and the dictionaries are
|
|
39
|
+
* served brotli-compressed, so the header is the compressed length while what
|
|
40
|
+
* arrives — and what the composer counts — is the decompressed file.
|
|
41
|
+
*/
|
|
42
|
+
export const spellcheckBytes = (tag: string): number =>
|
|
43
|
+
typeof __REMIT_SPELLCHECK_BYTES__ === "undefined"
|
|
44
|
+
? 0
|
|
45
|
+
: (__REMIT_SPELLCHECK_BYTES__[tag] ?? 0);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The staged dictionary that answers for a tag. `en-GB` takes the British one
|
|
49
|
+
* where the build carries it and the American one where it does not, because
|
|
50
|
+
* being checked against the wrong English is a long way better than not being
|
|
51
|
+
* checked at all.
|
|
52
|
+
*/
|
|
53
|
+
export const dictionaryTagFor = (
|
|
54
|
+
language: string,
|
|
55
|
+
built: readonly string[] = spellcheckLanguages(),
|
|
56
|
+
): string | null => {
|
|
57
|
+
const wanted = language.toLowerCase();
|
|
58
|
+
const exact = built.find((tag) => tag.toLowerCase() === wanted);
|
|
59
|
+
if (exact) return exact;
|
|
60
|
+
const base = wanted.split("-")[0];
|
|
61
|
+
return built.find((tag) => tag.toLowerCase().split("-")[0] === base) ?? null;
|
|
62
|
+
};
|
|
@@ -27,10 +27,10 @@ import type {
|
|
|
27
27
|
SuggestRequest,
|
|
28
28
|
} from "./rich-text-spellcheck.js";
|
|
29
29
|
import {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
} from "./rich-text-spellcheck-words.js";
|
|
30
|
+
stubKnows,
|
|
31
|
+
stubSuggestionsFor,
|
|
32
|
+
} from "./rich-text-spellcheck-double.js";
|
|
33
|
+
import { findMisspellings } from "./rich-text-spellcheck-words.js";
|
|
34
34
|
|
|
35
35
|
const SENTENCE = "Ths report is redy today";
|
|
36
36
|
const IDLE_MS = 400;
|
|
@@ -77,13 +77,12 @@ const stubSpellcheck = (
|
|
|
77
77
|
): Stub => {
|
|
78
78
|
const wordsAsked: SuggestRequest[] = [];
|
|
79
79
|
const held: (() => void)[] = [];
|
|
80
|
-
const words = dictionaryFor("en") ?? new Set<string>();
|
|
81
80
|
|
|
82
81
|
const answer = (request: CheckRequest): CheckResponse => ({
|
|
83
82
|
requestId: request.requestId,
|
|
84
83
|
revision: request.revision,
|
|
85
84
|
findings: request.spans.flatMap((span) =>
|
|
86
|
-
findMisspellings(span.text,
|
|
85
|
+
findMisspellings(span.text, stubKnows).map(
|
|
87
86
|
(range): Finding => ({
|
|
88
87
|
spanId: span.spanId,
|
|
89
88
|
start: range.start,
|
|
@@ -109,7 +108,7 @@ const stubSpellcheck = (
|
|
|
109
108
|
const settled = {
|
|
110
109
|
requestId: request.requestId,
|
|
111
110
|
word: request.word,
|
|
112
|
-
suggestions:
|
|
111
|
+
suggestions: stubSuggestionsFor(request.word),
|
|
113
112
|
};
|
|
114
113
|
if (!tune.holdSuggestions) return Promise.resolve(settled);
|
|
115
114
|
return new Promise((resolve) => {
|
|
@@ -177,6 +176,20 @@ const characters = (): Node => {
|
|
|
177
176
|
return leaf;
|
|
178
177
|
};
|
|
179
178
|
|
|
179
|
+
/**
|
|
180
|
+
* jsdom lays out nothing and moves no caret, so a selection Lexical set on its
|
|
181
|
+
* own — `select()`, or an edit that carries the caret with it — lives only in
|
|
182
|
+
* Lexical's own state. The next commit reconciles against the document's
|
|
183
|
+
* selection, finds none, and puts the anchor back at the start. Everything
|
|
184
|
+
* about a withheld mark is read off the caret at paint time, so a test that
|
|
185
|
+
* left the browser's selection behind is asserting on whichever landed first:
|
|
186
|
+
* the answer to the check, or the reconciliation that undoes the caret. The
|
|
187
|
+
* browser is the one that does this; here the test has to.
|
|
188
|
+
*/
|
|
189
|
+
const putCaret = (offset: number): void => {
|
|
190
|
+
dom.window.document.getSelection()?.setPosition(characters(), offset);
|
|
191
|
+
};
|
|
192
|
+
|
|
180
193
|
/**
|
|
181
194
|
* The desktop popover portals to the document body, clear of whatever
|
|
182
195
|
* ancestor would otherwise clip it, so the menu and its rows are found
|
|
@@ -217,7 +230,7 @@ const pressDownAt = async (
|
|
|
217
230
|
pointerType: string,
|
|
218
231
|
button = 0,
|
|
219
232
|
): Promise<void> => {
|
|
220
|
-
|
|
233
|
+
putCaret(offset);
|
|
221
234
|
await act(async () => {
|
|
222
235
|
editable().dispatchEvent(
|
|
223
236
|
pointerEvent("pointerdown", pointerType, {
|
|
@@ -268,6 +281,7 @@ const caretAt = async (
|
|
|
268
281
|
text?.select(offset, offset);
|
|
269
282
|
});
|
|
270
283
|
});
|
|
284
|
+
putCaret(offset);
|
|
271
285
|
};
|
|
272
286
|
|
|
273
287
|
/** A key on the writing surface, whatever it turns out to do. */
|
|
@@ -745,6 +759,7 @@ describe("the correction menu", () => {
|
|
|
745
759
|
text?.spliceText(18, 0, "y", true);
|
|
746
760
|
});
|
|
747
761
|
});
|
|
762
|
+
putCaret(19);
|
|
748
763
|
await settle();
|
|
749
764
|
|
|
750
765
|
assert.equal(editable().textContent, "Ths report is redyy today");
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { AlertCircle, Loader2 } from "lucide-react";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { formatByteSize } from "../lib/attachment-file.js";
|
|
4
|
+
import { languageLabel } from "../lib/compose-language.js";
|
|
5
|
+
import type { ProviderStatus } from "./rich-text-spellcheck.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* How long a dictionary may download before the composer says so. A first Dutch
|
|
9
|
+
* download is 435 ms on a fast link and fourteen seconds on a bad one (#692),
|
|
10
|
+
* and a composer that says nothing for fourteen seconds reads as a composer
|
|
11
|
+
* with no opinion about spelling.
|
|
12
|
+
*/
|
|
13
|
+
export const SPELLCHECK_SLOW_MS = 5000;
|
|
14
|
+
|
|
15
|
+
export const SPELLCHECK_ISSUE_URL =
|
|
16
|
+
"https://github.com/remit-mail/reader/issues/new";
|
|
17
|
+
|
|
18
|
+
export const spellcheckReportUrl = (
|
|
19
|
+
language: string,
|
|
20
|
+
detail: string,
|
|
21
|
+
): string => {
|
|
22
|
+
const title = `Spellcheck dictionary failed to load (${language})`;
|
|
23
|
+
const body = [
|
|
24
|
+
`Language: ${language}`,
|
|
25
|
+
`What happened: ${detail}`,
|
|
26
|
+
"",
|
|
27
|
+
"What I was doing:",
|
|
28
|
+
].join("\n");
|
|
29
|
+
return `${SPELLCHECK_ISSUE_URL}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface RichTextSpellcheckNoticeProps {
|
|
33
|
+
status: ProviderStatus;
|
|
34
|
+
/** The writer cancelled the download, so nothing is coming unless they ask. */
|
|
35
|
+
standDown: boolean;
|
|
36
|
+
onCancel: () => void;
|
|
37
|
+
onRetry: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const Row = ({
|
|
41
|
+
tone,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
tone: "quiet" | "danger";
|
|
45
|
+
children: React.ReactNode;
|
|
46
|
+
}) => (
|
|
47
|
+
<div
|
|
48
|
+
role="status"
|
|
49
|
+
data-testid="spellcheck-notice"
|
|
50
|
+
className={`flex items-start gap-2 border-t px-3 py-1.5 text-xs ${
|
|
51
|
+
tone === "danger"
|
|
52
|
+
? "border-danger/30 bg-danger-soft"
|
|
53
|
+
: "border-border bg-surface-sunken"
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What the composer says while a dictionary is on its way, and when it never
|
|
62
|
+
* arrives. The browser's own checker is on the whole time either way, so this
|
|
63
|
+
* never claims spelling has stopped — only that ours has not started.
|
|
64
|
+
*
|
|
65
|
+
* Quiet for the first five seconds, because a fast link finishes inside half of
|
|
66
|
+
* one and a banner that flashes on every composer open is worse than no banner.
|
|
67
|
+
*/
|
|
68
|
+
export function RichTextSpellcheckNotice({
|
|
69
|
+
status,
|
|
70
|
+
standDown,
|
|
71
|
+
onCancel,
|
|
72
|
+
onRetry,
|
|
73
|
+
}: RichTextSpellcheckNoticeProps) {
|
|
74
|
+
const [waited, setWaited] = useState(false);
|
|
75
|
+
const opening = status.state === "opening";
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!opening) {
|
|
79
|
+
setWaited(false);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const timer = setTimeout(() => setWaited(true), SPELLCHECK_SLOW_MS);
|
|
83
|
+
return () => clearTimeout(timer);
|
|
84
|
+
}, [opening]);
|
|
85
|
+
|
|
86
|
+
if (standDown) {
|
|
87
|
+
return (
|
|
88
|
+
<Row tone="quiet">
|
|
89
|
+
<p className="min-w-0 flex-1 text-fg-muted">
|
|
90
|
+
{languageLabel(status.language)} spelling is left to the browser.
|
|
91
|
+
</p>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={onRetry}
|
|
95
|
+
data-testid="spellcheck-retry"
|
|
96
|
+
className="shrink-0 font-medium text-accent hover:underline"
|
|
97
|
+
>
|
|
98
|
+
Download again
|
|
99
|
+
</button>
|
|
100
|
+
</Row>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (status.state === "failed") {
|
|
105
|
+
return (
|
|
106
|
+
<Row tone="danger">
|
|
107
|
+
<AlertCircle
|
|
108
|
+
className="mt-0.5 size-3.5 shrink-0 text-danger"
|
|
109
|
+
aria-hidden
|
|
110
|
+
/>
|
|
111
|
+
<div className="min-w-0 flex-1">
|
|
112
|
+
<p className="font-medium text-danger">
|
|
113
|
+
The {languageLabel(status.language)} dictionary did not load.
|
|
114
|
+
</p>
|
|
115
|
+
<p className="mt-0.5 text-fg-muted" data-testid="spellcheck-detail">
|
|
116
|
+
{status.detail}. The browser is still checking your spelling.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex shrink-0 items-center gap-3">
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={onRetry}
|
|
123
|
+
data-testid="spellcheck-retry"
|
|
124
|
+
className="font-medium text-accent hover:underline"
|
|
125
|
+
>
|
|
126
|
+
Try again
|
|
127
|
+
</button>
|
|
128
|
+
<a
|
|
129
|
+
href={spellcheckReportUrl(status.language, status.detail)}
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noreferrer"
|
|
132
|
+
data-testid="spellcheck-report"
|
|
133
|
+
className="font-medium text-accent hover:underline"
|
|
134
|
+
>
|
|
135
|
+
Report this
|
|
136
|
+
</a>
|
|
137
|
+
</div>
|
|
138
|
+
</Row>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!opening || !waited) return null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<Row tone="quiet">
|
|
146
|
+
<Loader2
|
|
147
|
+
className="mt-0.5 size-3.5 shrink-0 animate-spin text-fg-subtle"
|
|
148
|
+
aria-hidden
|
|
149
|
+
/>
|
|
150
|
+
<p className="min-w-0 flex-1 text-fg-muted">
|
|
151
|
+
Downloading the {languageLabel(status.language)} dictionary
|
|
152
|
+
{status.bytesTotal > 0 ? ` (${formatByteSize(status.bytesTotal)})` : ""}
|
|
153
|
+
{"… "}
|
|
154
|
+
<span data-testid="spellcheck-progress">
|
|
155
|
+
{formatByteSize(status.bytesLoaded)} so far
|
|
156
|
+
</span>
|
|
157
|
+
</p>
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={onCancel}
|
|
161
|
+
data-testid="spellcheck-cancel"
|
|
162
|
+
className="shrink-0 font-medium text-accent hover:underline"
|
|
163
|
+
>
|
|
164
|
+
Cancel
|
|
165
|
+
</button>
|
|
166
|
+
</Row>
|
|
167
|
+
);
|
|
168
|
+
}
|