@remit/ui 0.0.120 → 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 +6 -7
- 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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker against real dictionaries. What runs here is the code that ships
|
|
3
|
+
* inside the worker, over the Hunspell build in `build/hunspell/` and the
|
|
4
|
+
* upstream `.aff`/`.dic` pairs the image stages — so a language that passes
|
|
5
|
+
* here is a language a writer is actually checked in.
|
|
6
|
+
*
|
|
7
|
+
* The engine and the dictionaries are fetched at runtime, so the fetch is where
|
|
8
|
+
* this stands in: file URLs into `build/` and `node_modules/`, byte for byte
|
|
9
|
+
* what the server would hand a browser.
|
|
10
|
+
*/
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { before, describe, it } from "node:test";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import type {
|
|
18
|
+
SpellWorkerRequest,
|
|
19
|
+
SpellWorkerResponse,
|
|
20
|
+
} from "./rich-text-spellcheck.js";
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const engineDir = fileURLToPath(
|
|
24
|
+
new URL("../../../../build/hunspell/", import.meta.url),
|
|
25
|
+
);
|
|
26
|
+
const BASE = pathToFileURL(engineDir).href;
|
|
27
|
+
|
|
28
|
+
const dictionaryDir = (tag: string): string =>
|
|
29
|
+
dirname(require.resolve(`dictionary-${tag.toLowerCase()}`));
|
|
30
|
+
|
|
31
|
+
/** The one path that answers 503, so the download failure has a producer. */
|
|
32
|
+
let refuse: string | null = null;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The image serves these files brotli-compressed, so `content-length` is the
|
|
36
|
+
* compressed length while what arrives is the decompressed file. A quarter
|
|
37
|
+
* stands in for that ratio: anything reading the header instead of the build's
|
|
38
|
+
* own figure opens the notice at a size it then walks straight past.
|
|
39
|
+
*/
|
|
40
|
+
const asOnTheWire = (bytes: Buffer): string =>
|
|
41
|
+
String(Math.max(1, Math.round(bytes.byteLength / 4)));
|
|
42
|
+
|
|
43
|
+
const served = async (url: string): Promise<Response> => {
|
|
44
|
+
if (url === refuse) return new Response("no", { status: 503 });
|
|
45
|
+
const dictionary = /dictionaries\/([^/]+)\/(index\.(?:aff|dic))$/.exec(url);
|
|
46
|
+
const path = dictionary
|
|
47
|
+
? join(dictionaryDir(dictionary[1]), dictionary[2])
|
|
48
|
+
: fileURLToPath(url);
|
|
49
|
+
const bytes = await readFile(path);
|
|
50
|
+
return new Response(bytes, {
|
|
51
|
+
headers: { "content-length": asOnTheWire(bytes) },
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What the build weighs a language at: the engine and its two files. */
|
|
56
|
+
const weightOf = async (tag: string): Promise<number> => {
|
|
57
|
+
const directory = dictionaryDir(tag);
|
|
58
|
+
const files = await Promise.all([
|
|
59
|
+
readFile(join(engineDir, "hunspell.wasm")),
|
|
60
|
+
readFile(join(directory, "index.aff")),
|
|
61
|
+
readFile(join(directory, "index.dic")),
|
|
62
|
+
]);
|
|
63
|
+
return files.reduce((total, bytes) => total + bytes.byteLength, 0);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
let post: (message: SpellWorkerRequest) => void;
|
|
67
|
+
const heard: SpellWorkerResponse[] = [];
|
|
68
|
+
const waiting = new Map<string, (message: SpellWorkerResponse) => void>();
|
|
69
|
+
|
|
70
|
+
const nextMessage = <T extends SpellWorkerResponse["type"]>(
|
|
71
|
+
type: T,
|
|
72
|
+
): Promise<Extract<SpellWorkerResponse, { type: T }>> =>
|
|
73
|
+
new Promise((resolve) => {
|
|
74
|
+
waiting.set(type, (message) =>
|
|
75
|
+
resolve(message as Extract<SpellWorkerResponse, { type: T }>),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const openOn = async (
|
|
80
|
+
tag: string,
|
|
81
|
+
): Promise<Extract<SpellWorkerResponse, { type: "ready" | "failed" }>> => {
|
|
82
|
+
const ready = nextMessage("ready");
|
|
83
|
+
const failed = nextMessage("failed");
|
|
84
|
+
post({
|
|
85
|
+
type: "open",
|
|
86
|
+
language: tag,
|
|
87
|
+
base: BASE,
|
|
88
|
+
bytesExpected: await weightOf(tag),
|
|
89
|
+
});
|
|
90
|
+
return Promise.race([ready, failed]);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const checked = async (
|
|
94
|
+
tag: string,
|
|
95
|
+
text: string,
|
|
96
|
+
): Promise<readonly string[]> => {
|
|
97
|
+
const answer = nextMessage("checked");
|
|
98
|
+
post({
|
|
99
|
+
type: "check",
|
|
100
|
+
requestId: "1",
|
|
101
|
+
language: tag,
|
|
102
|
+
revision: 1,
|
|
103
|
+
spans: [{ spanId: "a", text }],
|
|
104
|
+
});
|
|
105
|
+
const { findings } = await answer;
|
|
106
|
+
return findings.map((finding) => text.slice(finding.start, finding.end));
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const suggested = async (tag: string, word: string): Promise<string[]> => {
|
|
110
|
+
const answer = nextMessage("suggested");
|
|
111
|
+
post({ type: "suggest", requestId: "1", language: tag, word });
|
|
112
|
+
return [...(await answer).suggestions];
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
before(async () => {
|
|
116
|
+
Object.defineProperty(globalThis, "fetch", {
|
|
117
|
+
value: (input: RequestInfo | URL) => served(String(input)),
|
|
118
|
+
configurable: true,
|
|
119
|
+
});
|
|
120
|
+
Object.defineProperty(globalThis, "postMessage", {
|
|
121
|
+
value: (message: SpellWorkerResponse) => {
|
|
122
|
+
heard.push(message);
|
|
123
|
+
const settle = waiting.get(message.type);
|
|
124
|
+
if (!settle) return;
|
|
125
|
+
waiting.delete(message.type);
|
|
126
|
+
settle(message);
|
|
127
|
+
},
|
|
128
|
+
configurable: true,
|
|
129
|
+
});
|
|
130
|
+
Object.defineProperty(globalThis, "addEventListener", {
|
|
131
|
+
value: (
|
|
132
|
+
_type: string,
|
|
133
|
+
listener: (event: { data: SpellWorkerRequest }) => void,
|
|
134
|
+
) => {
|
|
135
|
+
post = (message) => listener({ data: message });
|
|
136
|
+
},
|
|
137
|
+
configurable: true,
|
|
138
|
+
});
|
|
139
|
+
await import("./rich-text-spellcheck-worker.js");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("English, against SCOWL", () => {
|
|
143
|
+
it("marks what is misspelt and leaves the rest alone", async () => {
|
|
144
|
+
assert.equal((await openOn("en")).type, "ready");
|
|
145
|
+
assert.deepEqual(await checked("en", "Ths report is redy today"), [
|
|
146
|
+
"Ths",
|
|
147
|
+
"redy",
|
|
148
|
+
]);
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
await checked("en", "The meeting notes are attached."),
|
|
151
|
+
[],
|
|
152
|
+
"a sentence of real words carries no marks",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("offers the correction a writer meant", async () => {
|
|
157
|
+
assert.ok(
|
|
158
|
+
(await suggested("en", "recieve")).includes("receive"),
|
|
159
|
+
"the engine suggests, and it knows the word",
|
|
160
|
+
);
|
|
161
|
+
assert.ok((await suggested("en", "meetign")).includes("meeting"));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("reports the bytes as they arrive", () => {
|
|
165
|
+
const progress = heard.filter((message) => message.type === "opening");
|
|
166
|
+
assert.ok(progress.length > 0, "a download that says nothing looks broken");
|
|
167
|
+
const last = progress.at(-1);
|
|
168
|
+
assert.ok(last && last.bytesLoaded > 0 && last.bytesTotal > 0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("British English, against the same source", () => {
|
|
173
|
+
it("takes the spellings the other English does not", async () => {
|
|
174
|
+
assert.equal((await openOn("en-GB")).type, "ready");
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
await checked("en-GB", "I will organise the colour of the centre."),
|
|
177
|
+
[],
|
|
178
|
+
);
|
|
179
|
+
assert.deepEqual(
|
|
180
|
+
await checked("en-GB", "The kolour of the sentre."),
|
|
181
|
+
["kolour", "sentre"],
|
|
182
|
+
"a real British misspelling is still a misspelling",
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("Dutch, against OpenTaal", () => {
|
|
188
|
+
it("knows the words the stub never did", async () => {
|
|
189
|
+
assert.equal((await openOn("nl")).type, "ready");
|
|
190
|
+
assert.deepEqual(
|
|
191
|
+
await checked(
|
|
192
|
+
"nl",
|
|
193
|
+
"De vergadering van vanmiddag gaat over de begroting.",
|
|
194
|
+
),
|
|
195
|
+
[],
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("marks a Dutch misspelling and corrects it in Dutch", async () => {
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
await checked("nl", "De vergaderingg gaat over de begrooting."),
|
|
202
|
+
["vergaderingg", "begrooting"],
|
|
203
|
+
);
|
|
204
|
+
assert.ok((await suggested("nl", "vergaderingg")).includes("vergadering"));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("what the composer is told the download costs", () => {
|
|
209
|
+
it("names the size the build weighed, not the length on the wire", async () => {
|
|
210
|
+
const from = heard.length;
|
|
211
|
+
assert.equal((await openOn("nl")).type, "ready");
|
|
212
|
+
const progress = heard
|
|
213
|
+
.slice(from)
|
|
214
|
+
.filter((message) => message.type === "opening");
|
|
215
|
+
const weight = await weightOf("nl");
|
|
216
|
+
|
|
217
|
+
assert.ok(progress.length > 1, "a download reported once is not reported");
|
|
218
|
+
assert.deepEqual(
|
|
219
|
+
[...new Set(progress.map((message) => message.bytesTotal))],
|
|
220
|
+
[weight],
|
|
221
|
+
"a figure that is rewritten mid-download was never the size of anything",
|
|
222
|
+
);
|
|
223
|
+
assert.equal(
|
|
224
|
+
progress.at(-1)?.bytesLoaded,
|
|
225
|
+
weight,
|
|
226
|
+
"and what arrives ends on it rather than past it",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("when the dictionary does not arrive", () => {
|
|
232
|
+
it("says which file and what it answered", async () => {
|
|
233
|
+
refuse = `${BASE}dictionaries/nl/index.dic`;
|
|
234
|
+
const answer = await openOn("nl");
|
|
235
|
+
refuse = null;
|
|
236
|
+
|
|
237
|
+
assert.equal(answer.type, "failed");
|
|
238
|
+
assert.equal(answer.type === "failed" && answer.reason, "download");
|
|
239
|
+
assert.match(
|
|
240
|
+
answer.type === "failed" ? answer.detail : "",
|
|
241
|
+
/index\.dic answered 503/,
|
|
242
|
+
"the status is what a report link has to carry",
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("marks nothing rather than everything once it has failed", async () => {
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
await checked("nl", "De vergaderingg gaat over de begrooting."),
|
|
249
|
+
[],
|
|
250
|
+
"the browser's own checker is the one on screen now",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The checking side, off the main thread. It answers with the revision it was
|
|
3
3
|
* asked at and never touches the document, which is what lets the editor throw
|
|
4
|
-
* a late answer away.
|
|
5
|
-
*
|
|
4
|
+
* a late answer away.
|
|
5
|
+
*
|
|
6
|
+
* One worker holds one language's engine. Everything before `ready` is a
|
|
7
|
+
* download, reported as it goes, and every way it can end badly is a message
|
|
8
|
+
* the composer can put words to — never silence, because the browser's own
|
|
9
|
+
* checker is still on and the writer must not be left with neither.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import type {
|
|
@@ -11,10 +15,11 @@ import type {
|
|
|
11
15
|
SpellWorkerRequest,
|
|
12
16
|
SpellWorkerResponse,
|
|
13
17
|
} from "./rich-text-spellcheck.js";
|
|
18
|
+
import type { SpellEngine } from "./rich-text-spellcheck-engine.js";
|
|
19
|
+
import { openEngine } from "./rich-text-spellcheck-engine.js";
|
|
14
20
|
import {
|
|
15
|
-
dictionaryFor,
|
|
16
21
|
findMisspellings,
|
|
17
|
-
|
|
22
|
+
SUGGESTION_LIMIT,
|
|
18
23
|
} from "./rich-text-spellcheck-words.js";
|
|
19
24
|
|
|
20
25
|
interface WorkerScope {
|
|
@@ -25,11 +30,13 @@ interface WorkerScope {
|
|
|
25
30
|
): void;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
const scope = globalThis as unknown as WorkerScope;
|
|
34
|
+
|
|
35
|
+
let engine: SpellEngine | null = null;
|
|
36
|
+
|
|
37
|
+
const spell = (request: CheckRequest, checker: SpellEngine): Finding[] =>
|
|
38
|
+
request.spans.flatMap((span) =>
|
|
39
|
+
findMisspellings(span.text, (word) => checker.spell(word)).map((range) => ({
|
|
33
40
|
spanId: span.spanId,
|
|
34
41
|
start: range.start,
|
|
35
42
|
end: range.end,
|
|
@@ -37,22 +44,63 @@ const spell = (request: CheckRequest): Finding[] => {
|
|
|
37
44
|
suggestions: [],
|
|
38
45
|
})),
|
|
39
46
|
);
|
|
40
|
-
};
|
|
41
47
|
|
|
42
|
-
const
|
|
48
|
+
const open = async (
|
|
49
|
+
language: string,
|
|
50
|
+
base: string,
|
|
51
|
+
bytesExpected: number,
|
|
52
|
+
): Promise<void> => {
|
|
53
|
+
// Whatever was answering before is not answering for this language, and a
|
|
54
|
+
// failed open must not leave the previous dictionary marking the new text.
|
|
55
|
+
engine?.close();
|
|
56
|
+
engine = null;
|
|
57
|
+
const result = await openEngine({
|
|
58
|
+
base,
|
|
59
|
+
tag: language,
|
|
60
|
+
bytesExpected,
|
|
61
|
+
onProgress: ({ bytesLoaded, bytesTotal }) =>
|
|
62
|
+
scope.postMessage({
|
|
63
|
+
type: "opening",
|
|
64
|
+
language,
|
|
65
|
+
bytesLoaded,
|
|
66
|
+
bytesTotal,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
scope.postMessage({
|
|
71
|
+
type: "failed",
|
|
72
|
+
language,
|
|
73
|
+
reason: result.reason,
|
|
74
|
+
detail: result.detail,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
engine = result.engine;
|
|
79
|
+
scope.postMessage({ type: "ready", language });
|
|
80
|
+
};
|
|
43
81
|
|
|
44
82
|
scope.addEventListener("message", ({ data }) => {
|
|
45
83
|
if (data.type === "open") {
|
|
46
|
-
|
|
84
|
+
open(data.language, data.base, data.bytesExpected).catch(
|
|
85
|
+
(error: unknown) => {
|
|
86
|
+
scope.postMessage({
|
|
87
|
+
type: "failed",
|
|
88
|
+
language: data.language,
|
|
89
|
+
reason: "engine",
|
|
90
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
);
|
|
47
94
|
return;
|
|
48
95
|
}
|
|
49
96
|
if (data.type === "suggest") {
|
|
50
|
-
const words = dictionaryFor(data.language);
|
|
51
97
|
scope.postMessage({
|
|
52
98
|
type: "suggested",
|
|
53
99
|
requestId: data.requestId,
|
|
54
100
|
word: data.word,
|
|
55
|
-
suggestions:
|
|
101
|
+
suggestions: engine
|
|
102
|
+
? engine.suggest(data.word).slice(0, SUGGESTION_LIMIT)
|
|
103
|
+
: [],
|
|
56
104
|
});
|
|
57
105
|
return;
|
|
58
106
|
}
|
|
@@ -60,6 +108,6 @@ scope.addEventListener("message", ({ data }) => {
|
|
|
60
108
|
type: "checked",
|
|
61
109
|
requestId: data.requestId,
|
|
62
110
|
revision: data.revision,
|
|
63
|
-
findings: spell(data),
|
|
111
|
+
findings: engine ? spell(data, engine) : [],
|
|
64
112
|
});
|
|
65
113
|
});
|
|
@@ -22,12 +22,19 @@ import type {
|
|
|
22
22
|
ProviderStatus,
|
|
23
23
|
SpellcheckOptions,
|
|
24
24
|
SpellProvider,
|
|
25
|
+
SpellWorkerRequest,
|
|
26
|
+
SpellWorkerResponse,
|
|
25
27
|
} from "./rich-text-spellcheck.js";
|
|
26
28
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
stubKnows,
|
|
30
|
+
stubSuggestionsFor,
|
|
31
|
+
} from "./rich-text-spellcheck-double.js";
|
|
32
|
+
import {
|
|
33
|
+
CHECK_DEADLINE_MS,
|
|
34
|
+
openSpellProvider,
|
|
35
|
+
type SpellWorkerPort,
|
|
36
|
+
} from "./rich-text-spellcheck-provider.js";
|
|
37
|
+
import { findMisspellings } from "./rich-text-spellcheck-words.js";
|
|
31
38
|
|
|
32
39
|
const SENTENCE = "Ths report is redy today";
|
|
33
40
|
const IDLE_MS = 400;
|
|
@@ -77,19 +84,20 @@ const stubSpellcheck = (
|
|
|
77
84
|
tune: {
|
|
78
85
|
revisionOf?: (request: CheckRequest, nth: number) => number;
|
|
79
86
|
hold?: boolean;
|
|
87
|
+
/** Opens on a dictionary still arriving, the way a real one does. */
|
|
88
|
+
downloading?: boolean;
|
|
80
89
|
} = {},
|
|
81
90
|
): Stub => {
|
|
82
91
|
const asked: CheckRequest[] = [];
|
|
83
92
|
const held: (() => void)[] = [];
|
|
84
93
|
const listeners = new Set<(status: ProviderStatus) => void>();
|
|
85
94
|
let closed = 0;
|
|
86
|
-
const words = dictionaryFor("en") ?? new Set<string>();
|
|
87
95
|
|
|
88
96
|
const answer = (request: CheckRequest, nth: number): CheckResponse => ({
|
|
89
97
|
requestId: request.requestId,
|
|
90
98
|
revision: tune.revisionOf?.(request, nth) ?? request.revision,
|
|
91
99
|
findings: request.spans.flatMap((span) =>
|
|
92
|
-
findMisspellings(span.text,
|
|
100
|
+
findMisspellings(span.text, stubKnows).map(
|
|
93
101
|
(range): Finding => ({
|
|
94
102
|
spanId: span.spanId,
|
|
95
103
|
start: range.start,
|
|
@@ -105,7 +113,16 @@ const stubSpellcheck = (
|
|
|
105
113
|
language: "en",
|
|
106
114
|
onStatus: (listener) => {
|
|
107
115
|
listeners.add(listener);
|
|
108
|
-
listener(
|
|
116
|
+
listener(
|
|
117
|
+
tune.downloading
|
|
118
|
+
? {
|
|
119
|
+
state: "opening",
|
|
120
|
+
language: "en",
|
|
121
|
+
bytesLoaded: 0,
|
|
122
|
+
bytesTotal: 167_936,
|
|
123
|
+
}
|
|
124
|
+
: { state: "ready", language: "en" },
|
|
125
|
+
);
|
|
109
126
|
return () => {
|
|
110
127
|
listeners.delete(listener);
|
|
111
128
|
};
|
|
@@ -122,7 +139,7 @@ const stubSpellcheck = (
|
|
|
122
139
|
Promise.resolve({
|
|
123
140
|
requestId: request.requestId,
|
|
124
141
|
word: request.word,
|
|
125
|
-
suggestions:
|
|
142
|
+
suggestions: stubSuggestionsFor(request.word),
|
|
126
143
|
}),
|
|
127
144
|
close: () => {
|
|
128
145
|
closed += 1;
|
|
@@ -532,6 +549,100 @@ describe("spellcheck marks", () => {
|
|
|
532
549
|
assert.equal(seen.at(-1)?.state, "failed", "the caller is told why");
|
|
533
550
|
});
|
|
534
551
|
|
|
552
|
+
it("checks the document the download was holding up", async () => {
|
|
553
|
+
const spellcheck = stubSpellcheck({ downloading: true });
|
|
554
|
+
await mount({
|
|
555
|
+
initialHtml: `<p>${SENTENCE}</p>`,
|
|
556
|
+
lang: "en",
|
|
557
|
+
spellcheck,
|
|
558
|
+
});
|
|
559
|
+
await settle();
|
|
560
|
+
assert.equal(
|
|
561
|
+
marks(),
|
|
562
|
+
undefined,
|
|
563
|
+
"nothing of ours is on screen while the dictionary is on its way",
|
|
564
|
+
);
|
|
565
|
+
assert.equal(editable().getAttribute("spellcheck"), "true");
|
|
566
|
+
|
|
567
|
+
// A real dictionary arrives in twenty of these before it is ready.
|
|
568
|
+
await act(async () => {
|
|
569
|
+
for (const bytesLoaded of [65_536, 131_072, 167_936]) {
|
|
570
|
+
spellcheck.push({
|
|
571
|
+
state: "opening",
|
|
572
|
+
language: "en",
|
|
573
|
+
bytesLoaded,
|
|
574
|
+
bytesTotal: 167_936,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
await act(async () => {
|
|
579
|
+
spellcheck.push({ state: "ready", language: "en" });
|
|
580
|
+
});
|
|
581
|
+
await settle();
|
|
582
|
+
|
|
583
|
+
assert.deepEqual(
|
|
584
|
+
offsets(),
|
|
585
|
+
[
|
|
586
|
+
[0, 3],
|
|
587
|
+
[14, 18],
|
|
588
|
+
],
|
|
589
|
+
"the leaves that were waiting on the dictionary are the first pass",
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("hands checking back when the engine takes a pass and freezes", async () => {
|
|
594
|
+
const seen: ProviderStatus[] = [];
|
|
595
|
+
const posted: SpellWorkerRequest[] = [];
|
|
596
|
+
let deliver: ((message: SpellWorkerResponse) => void) | undefined;
|
|
597
|
+
// A worker that opens, says it is ready, and then answers nothing at all —
|
|
598
|
+
// a wedged WebAssembly instance, which no `error` event ever announces.
|
|
599
|
+
const dead: SpellWorkerPort = {
|
|
600
|
+
post: (message) => posted.push(message),
|
|
601
|
+
listen: (listener) => {
|
|
602
|
+
deliver = listener;
|
|
603
|
+
},
|
|
604
|
+
fail: () => {},
|
|
605
|
+
terminate: () => {},
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
await mount({
|
|
609
|
+
initialHtml: `<p>${SENTENCE}</p>`,
|
|
610
|
+
lang: "en",
|
|
611
|
+
spellcheck: {
|
|
612
|
+
provider: async (language: string) =>
|
|
613
|
+
openSpellProvider(language, "/spellcheck/", dead),
|
|
614
|
+
onStatus: (status: ProviderStatus) => seen.push(status),
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
await act(async () => {
|
|
618
|
+
deliver?.({ type: "ready", language: "en" });
|
|
619
|
+
});
|
|
620
|
+
await settle();
|
|
621
|
+
assert.ok(
|
|
622
|
+
posted.some((message) => message.type === "check"),
|
|
623
|
+
"the pass went out",
|
|
624
|
+
);
|
|
625
|
+
assert.equal(
|
|
626
|
+
editable().getAttribute("spellcheck"),
|
|
627
|
+
"false",
|
|
628
|
+
"ours is the checker on screen while it is answering",
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
await act(async () => {
|
|
632
|
+
await new Promise((resolve) =>
|
|
633
|
+
setTimeout(resolve, CHECK_DEADLINE_MS + IDLE_MS),
|
|
634
|
+
);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
assert.equal(seen.at(-1)?.state, "failed", "silence is named, not endured");
|
|
638
|
+
assert.equal(
|
|
639
|
+
editable().getAttribute("spellcheck"),
|
|
640
|
+
"true",
|
|
641
|
+
"the browser checks again rather than nobody checking",
|
|
642
|
+
);
|
|
643
|
+
assert.equal(marks(), undefined);
|
|
644
|
+
});
|
|
645
|
+
|
|
535
646
|
it("says when a language has no dictionary", async () => {
|
|
536
647
|
const seen: ProviderStatus[] = [];
|
|
537
648
|
await mount({
|
|
@@ -544,7 +655,10 @@ describe("spellcheck marks", () => {
|
|
|
544
655
|
});
|
|
545
656
|
await settle();
|
|
546
657
|
|
|
547
|
-
assert.deepEqual(seen, [
|
|
658
|
+
assert.deepEqual(seen, [
|
|
659
|
+
{ state: "opening", language: "de", bytesLoaded: 0, bytesTotal: 0 },
|
|
660
|
+
{ state: "unavailable", language: "de" },
|
|
661
|
+
]);
|
|
548
662
|
assert.equal(editable().getAttribute("spellcheck"), "true");
|
|
549
663
|
});
|
|
550
664
|
|
|
@@ -52,7 +52,19 @@ export interface SuggestResponse {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export type ProviderStatus =
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Carries the download rather than a verdict about it: a composer that has to
|
|
57
|
+
* say "still fetching Dutch (2.4 MB)" needs the two numbers, and where the
|
|
58
|
+
* line between quick and slow falls belongs to whatever renders it, not here.
|
|
59
|
+
* The total is what the build weighed the files at, so it is right from the
|
|
60
|
+
* first report and never moves under the writer.
|
|
61
|
+
*/
|
|
62
|
+
| {
|
|
63
|
+
readonly state: "opening";
|
|
64
|
+
readonly language: LanguageTag;
|
|
65
|
+
readonly bytesLoaded: number;
|
|
66
|
+
readonly bytesTotal: number;
|
|
67
|
+
}
|
|
56
68
|
| { readonly state: "ready"; readonly language: LanguageTag }
|
|
57
69
|
/** Nothing here checks this language, and the browser is welcome to. */
|
|
58
70
|
| { readonly state: "unavailable"; readonly language: LanguageTag }
|
|
@@ -85,11 +97,28 @@ export interface SpellcheckOptions {
|
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
export type SpellWorkerRequest =
|
|
88
|
-
|
|
100
|
+
/**
|
|
101
|
+
* `base` is where the engine and the dictionaries are served from, decided by
|
|
102
|
+
* the build and passed in rather than read inside the worker — so what the
|
|
103
|
+
* worker fetches is visible at the seam and can be pointed elsewhere.
|
|
104
|
+
*/
|
|
105
|
+
| {
|
|
106
|
+
readonly type: "open";
|
|
107
|
+
readonly language: LanguageTag;
|
|
108
|
+
readonly base: string;
|
|
109
|
+
/** What the build weighed these files at, for the download notice. */
|
|
110
|
+
readonly bytesExpected: number;
|
|
111
|
+
}
|
|
89
112
|
| ({ readonly type: "check" } & CheckRequest)
|
|
90
113
|
| ({ readonly type: "suggest" } & SuggestRequest);
|
|
91
114
|
|
|
92
115
|
export type SpellWorkerResponse =
|
|
116
|
+
| {
|
|
117
|
+
readonly type: "opening";
|
|
118
|
+
readonly language: LanguageTag;
|
|
119
|
+
readonly bytesLoaded: number;
|
|
120
|
+
readonly bytesTotal: number;
|
|
121
|
+
}
|
|
93
122
|
| { readonly type: "ready"; readonly language: LanguageTag }
|
|
94
123
|
| {
|
|
95
124
|
readonly type: "failed";
|
package/src/rich-text.ts
CHANGED
|
@@ -55,9 +55,9 @@ export {
|
|
|
55
55
|
type SpellWorkerPort,
|
|
56
56
|
} from "./components/rich-text-spellcheck-provider.js";
|
|
57
57
|
export {
|
|
58
|
+
findMisspellings,
|
|
58
59
|
normaliseWord,
|
|
59
60
|
SUGGESTION_LIMIT,
|
|
60
|
-
suggestionsFor,
|
|
61
61
|
} from "./components/rich-text-spellcheck-words.js";
|
|
62
62
|
export {
|
|
63
63
|
type ComposeCaret,
|