@remit/ui 0.0.106 → 0.0.107
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 +1 -1
- package/src/components/rich-text-editor.stories.tsx +164 -1
- package/src/components/rich-text-editor.tsx +364 -53
- package/src/components/rich-text-spellcheck-provider.test.ts +217 -0
- package/src/components/rich-text-spellcheck-provider.ts +83 -0
- package/src/components/rich-text-spellcheck-words.ts +214 -0
- package/src/components/rich-text-spellcheck-worker-provider.ts +42 -0
- package/src/components/rich-text-spellcheck-worker.ts +54 -0
- package/src/components/rich-text-spellcheck.test.ts +638 -0
- package/src/components/rich-text-spellcheck.ts +75 -0
- package/src/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider and the worker against each other. The worker module is loaded
|
|
3
|
+
* with a stand-in for the worker global, so what runs here is the code that
|
|
4
|
+
* ships inside the worker, over the same messages a real one exchanges.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { before, describe, it } from "node:test";
|
|
8
|
+
import type {
|
|
9
|
+
ProviderStatus,
|
|
10
|
+
SpellWorkerRequest,
|
|
11
|
+
SpellWorkerResponse,
|
|
12
|
+
} from "./rich-text-spellcheck.js";
|
|
13
|
+
import {
|
|
14
|
+
openSpellProvider,
|
|
15
|
+
type SpellWorkerPort,
|
|
16
|
+
} from "./rich-text-spellcheck-provider.js";
|
|
17
|
+
import {
|
|
18
|
+
dictionaryFor,
|
|
19
|
+
findMisspellings,
|
|
20
|
+
} from "./rich-text-spellcheck-words.js";
|
|
21
|
+
|
|
22
|
+
interface WorkerScope {
|
|
23
|
+
postMessage(message: SpellWorkerResponse): void;
|
|
24
|
+
addEventListener(
|
|
25
|
+
type: "message",
|
|
26
|
+
listener: (event: { data: SpellWorkerRequest }) => void,
|
|
27
|
+
): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let receive: (event: { data: SpellWorkerRequest }) => void;
|
|
31
|
+
let deliver: ((message: SpellWorkerResponse) => void) | undefined;
|
|
32
|
+
let terminated = 0;
|
|
33
|
+
|
|
34
|
+
/** Wires the loaded worker module to a provider, the way a real port does. */
|
|
35
|
+
const port: SpellWorkerPort = {
|
|
36
|
+
post: (message) => receive({ data: message }),
|
|
37
|
+
listen: (listener) => {
|
|
38
|
+
deliver = listener;
|
|
39
|
+
},
|
|
40
|
+
fail: () => {},
|
|
41
|
+
terminate: () => {
|
|
42
|
+
terminated += 1;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
before(async () => {
|
|
47
|
+
const scope: WorkerScope = {
|
|
48
|
+
postMessage: (message) => deliver?.(message),
|
|
49
|
+
addEventListener: (_type, listener) => {
|
|
50
|
+
receive = listener;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(globalThis, "postMessage", {
|
|
54
|
+
value: scope.postMessage,
|
|
55
|
+
configurable: true,
|
|
56
|
+
});
|
|
57
|
+
Object.defineProperty(globalThis, "addEventListener", {
|
|
58
|
+
value: scope.addEventListener,
|
|
59
|
+
configurable: true,
|
|
60
|
+
});
|
|
61
|
+
await import("./rich-text-spellcheck-worker.js");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("the stub dictionary", () => {
|
|
65
|
+
it("takes a region tag and answers for the language", () => {
|
|
66
|
+
assert.ok(dictionaryFor("en-GB"), "a region variant reads the same words");
|
|
67
|
+
assert.equal(
|
|
68
|
+
dictionaryFor("de"),
|
|
69
|
+
null,
|
|
70
|
+
"no dictionary is not an empty one",
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("ranges the words it does not hold", () => {
|
|
75
|
+
const words = dictionaryFor("en");
|
|
76
|
+
assert.ok(words);
|
|
77
|
+
assert.deepEqual(findMisspellings("Ths report is redy today", words), [
|
|
78
|
+
{ start: 0, end: 3 },
|
|
79
|
+
{ start: 14, end: 18 },
|
|
80
|
+
]);
|
|
81
|
+
assert.deepEqual(
|
|
82
|
+
findMisspellings("A report", words),
|
|
83
|
+
[],
|
|
84
|
+
"a single letter is not a word to check",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("a provider over a worker", () => {
|
|
90
|
+
it("opens, checks and closes", async () => {
|
|
91
|
+
const seen: ProviderStatus[] = [];
|
|
92
|
+
const provider = openSpellProvider("en", port);
|
|
93
|
+
const unsubscribe = provider.onStatus((status) => seen.push(status));
|
|
94
|
+
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
seen.at(-1),
|
|
97
|
+
{ state: "ready", language: "en" },
|
|
98
|
+
"a listener that subscribes late still learns the worker is up",
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const response = await provider.check({
|
|
102
|
+
requestId: "7",
|
|
103
|
+
language: "en",
|
|
104
|
+
revision: 3,
|
|
105
|
+
spans: [{ spanId: "a", text: "Ths report" }],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
assert.equal(response.requestId, "7");
|
|
109
|
+
assert.equal(response.revision, 3, "the answer carries the revision asked");
|
|
110
|
+
assert.deepEqual(response.findings, [
|
|
111
|
+
{
|
|
112
|
+
spanId: "a",
|
|
113
|
+
start: 0,
|
|
114
|
+
end: 3,
|
|
115
|
+
kind: "spelling",
|
|
116
|
+
suggestions: [],
|
|
117
|
+
},
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
unsubscribe();
|
|
121
|
+
provider.close();
|
|
122
|
+
assert.equal(terminated, 1, "closing the provider takes the worker down");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("answers each request with its own findings", async () => {
|
|
126
|
+
const provider = openSpellProvider("en", port);
|
|
127
|
+
const [first, second] = await Promise.all([
|
|
128
|
+
provider.check({
|
|
129
|
+
requestId: "1",
|
|
130
|
+
language: "en",
|
|
131
|
+
revision: 1,
|
|
132
|
+
spans: [{ spanId: "a", text: "redy" }],
|
|
133
|
+
}),
|
|
134
|
+
provider.check({
|
|
135
|
+
requestId: "2",
|
|
136
|
+
language: "en",
|
|
137
|
+
revision: 2,
|
|
138
|
+
spans: [{ spanId: "b", text: "the report" }],
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
assert.equal(first?.findings.length, 1);
|
|
143
|
+
assert.equal(first?.findings[0]?.spanId, "a");
|
|
144
|
+
assert.deepEqual(second?.findings, []);
|
|
145
|
+
provider.close();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("says which language it has no words for", async () => {
|
|
149
|
+
const provider = openSpellProvider("de", port);
|
|
150
|
+
const response = await provider.check({
|
|
151
|
+
requestId: "1",
|
|
152
|
+
language: "de",
|
|
153
|
+
revision: 1,
|
|
154
|
+
spans: [{ spanId: "a", text: "Vielen Dank" }],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(
|
|
158
|
+
response.findings,
|
|
159
|
+
[],
|
|
160
|
+
"a language with no dictionary marks nothing rather than everything",
|
|
161
|
+
);
|
|
162
|
+
provider.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("passes on a failure the worker names itself", () => {
|
|
166
|
+
const seen: ProviderStatus[] = [];
|
|
167
|
+
let answer: ((message: SpellWorkerResponse) => void) | undefined;
|
|
168
|
+
const provider = openSpellProvider("nl", {
|
|
169
|
+
...port,
|
|
170
|
+
post: () => {},
|
|
171
|
+
listen: (listener) => {
|
|
172
|
+
answer = listener;
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
provider.onStatus((status) => seen.push(status));
|
|
176
|
+
answer?.({
|
|
177
|
+
type: "failed",
|
|
178
|
+
language: "nl",
|
|
179
|
+
reason: "download",
|
|
180
|
+
detail: "503 fetching the dictionary",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
assert.deepEqual(seen.at(-1), {
|
|
184
|
+
state: "failed",
|
|
185
|
+
language: "nl",
|
|
186
|
+
reason: "download",
|
|
187
|
+
detail: "503 fetching the dictionary",
|
|
188
|
+
});
|
|
189
|
+
provider.close();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reports a worker that fell over", () => {
|
|
193
|
+
const seen: ProviderStatus[] = [];
|
|
194
|
+
let raise: ((detail: string) => void) | undefined;
|
|
195
|
+
const provider = openSpellProvider("en", {
|
|
196
|
+
...port,
|
|
197
|
+
post: () => {},
|
|
198
|
+
listen: () => {},
|
|
199
|
+
fail: (listener) => {
|
|
200
|
+
raise = listener;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
provider.onStatus((status) => seen.push(status));
|
|
204
|
+
raise?.("worker exited");
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(seen, [
|
|
207
|
+
{ state: "opening", language: "en" },
|
|
208
|
+
{
|
|
209
|
+
state: "failed",
|
|
210
|
+
language: "en",
|
|
211
|
+
reason: "worker",
|
|
212
|
+
detail: "worker exited",
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
provider.close();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider over a port. The editor holds it through the interface only, so
|
|
3
|
+
* where the checking runs — a worker here, a backend later — is not something
|
|
4
|
+
* the plugin can observe. Closing it takes the port down with it.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
CheckResponse,
|
|
8
|
+
LanguageTag,
|
|
9
|
+
ProviderStatus,
|
|
10
|
+
SpellProvider,
|
|
11
|
+
SpellWorkerRequest,
|
|
12
|
+
SpellWorkerResponse,
|
|
13
|
+
} from "./rich-text-spellcheck.js";
|
|
14
|
+
|
|
15
|
+
export interface SpellWorkerPort {
|
|
16
|
+
post(message: SpellWorkerRequest): void;
|
|
17
|
+
listen(listener: (message: SpellWorkerResponse) => void): void;
|
|
18
|
+
fail(listener: (detail: string) => void): void;
|
|
19
|
+
terminate(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const openSpellProvider = (
|
|
23
|
+
language: LanguageTag,
|
|
24
|
+
port: SpellWorkerPort,
|
|
25
|
+
): SpellProvider => {
|
|
26
|
+
const pending = new Map<string, (response: CheckResponse) => void>();
|
|
27
|
+
const listeners = new Set<(status: ProviderStatus) => void>();
|
|
28
|
+
let status: ProviderStatus = { state: "opening", language };
|
|
29
|
+
|
|
30
|
+
const publish = (next: ProviderStatus): void => {
|
|
31
|
+
status = next;
|
|
32
|
+
for (const listener of listeners) listener(next);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
port.listen((message) => {
|
|
36
|
+
if (message.type === "ready") {
|
|
37
|
+
publish({ state: "ready", language });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (message.type === "failed") {
|
|
41
|
+
publish({
|
|
42
|
+
state: "failed",
|
|
43
|
+
language,
|
|
44
|
+
reason: message.reason,
|
|
45
|
+
detail: message.detail,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const settle = pending.get(message.requestId);
|
|
50
|
+
if (!settle) return;
|
|
51
|
+
pending.delete(message.requestId);
|
|
52
|
+
settle({
|
|
53
|
+
requestId: message.requestId,
|
|
54
|
+
revision: message.revision,
|
|
55
|
+
findings: message.findings,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
port.fail((detail) =>
|
|
59
|
+
publish({ state: "failed", language, reason: "worker", detail }),
|
|
60
|
+
);
|
|
61
|
+
port.post({ type: "open", language });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
language,
|
|
65
|
+
onStatus: (listener) => {
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
listener(status);
|
|
68
|
+
return () => {
|
|
69
|
+
listeners.delete(listener);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
check: (request) =>
|
|
73
|
+
new Promise((resolve) => {
|
|
74
|
+
pending.set(request.requestId, resolve);
|
|
75
|
+
port.post({ type: "check", ...request });
|
|
76
|
+
}),
|
|
77
|
+
close: () => {
|
|
78
|
+
pending.clear();
|
|
79
|
+
listeners.clear();
|
|
80
|
+
port.terminate();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The placeholder a real dictionary replaces (#707): a word list small enough
|
|
3
|
+
* to read, so the marks can be driven end to end before an engine exists. The
|
|
4
|
+
* tokeniser is the part that stays — a provider is handed paragraph text and
|
|
5
|
+
* decides for itself where the words are.
|
|
6
|
+
*/
|
|
7
|
+
import type { LanguageTag } from "./rich-text-spellcheck.js";
|
|
8
|
+
|
|
9
|
+
export interface WordRange {
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ENGLISH: ReadonlySet<string> = new Set([
|
|
15
|
+
"a",
|
|
16
|
+
"about",
|
|
17
|
+
"after",
|
|
18
|
+
"again",
|
|
19
|
+
"all",
|
|
20
|
+
"also",
|
|
21
|
+
"an",
|
|
22
|
+
"and",
|
|
23
|
+
"any",
|
|
24
|
+
"are",
|
|
25
|
+
"as",
|
|
26
|
+
"at",
|
|
27
|
+
"be",
|
|
28
|
+
"because",
|
|
29
|
+
"been",
|
|
30
|
+
"before",
|
|
31
|
+
"both",
|
|
32
|
+
"but",
|
|
33
|
+
"by",
|
|
34
|
+
"can",
|
|
35
|
+
"come",
|
|
36
|
+
"could",
|
|
37
|
+
"day",
|
|
38
|
+
"did",
|
|
39
|
+
"do",
|
|
40
|
+
"does",
|
|
41
|
+
"done",
|
|
42
|
+
"down",
|
|
43
|
+
"draft",
|
|
44
|
+
"each",
|
|
45
|
+
"end",
|
|
46
|
+
"even",
|
|
47
|
+
"every",
|
|
48
|
+
"few",
|
|
49
|
+
"find",
|
|
50
|
+
"first",
|
|
51
|
+
"for",
|
|
52
|
+
"friday",
|
|
53
|
+
"from",
|
|
54
|
+
"get",
|
|
55
|
+
"give",
|
|
56
|
+
"go",
|
|
57
|
+
"good",
|
|
58
|
+
"had",
|
|
59
|
+
"has",
|
|
60
|
+
"have",
|
|
61
|
+
"he",
|
|
62
|
+
"help",
|
|
63
|
+
"her",
|
|
64
|
+
"here",
|
|
65
|
+
"him",
|
|
66
|
+
"his",
|
|
67
|
+
"how",
|
|
68
|
+
"i",
|
|
69
|
+
"if",
|
|
70
|
+
"in",
|
|
71
|
+
"into",
|
|
72
|
+
"is",
|
|
73
|
+
"it",
|
|
74
|
+
"its",
|
|
75
|
+
"just",
|
|
76
|
+
"keep",
|
|
77
|
+
"know",
|
|
78
|
+
"last",
|
|
79
|
+
"let",
|
|
80
|
+
"like",
|
|
81
|
+
"long",
|
|
82
|
+
"look",
|
|
83
|
+
"made",
|
|
84
|
+
"make",
|
|
85
|
+
"many",
|
|
86
|
+
"may",
|
|
87
|
+
"me",
|
|
88
|
+
"meeting",
|
|
89
|
+
"month",
|
|
90
|
+
"more",
|
|
91
|
+
"most",
|
|
92
|
+
"much",
|
|
93
|
+
"must",
|
|
94
|
+
"my",
|
|
95
|
+
"need",
|
|
96
|
+
"new",
|
|
97
|
+
"next",
|
|
98
|
+
"no",
|
|
99
|
+
"not",
|
|
100
|
+
"note",
|
|
101
|
+
"notes",
|
|
102
|
+
"now",
|
|
103
|
+
"number",
|
|
104
|
+
"numbers",
|
|
105
|
+
"of",
|
|
106
|
+
"off",
|
|
107
|
+
"on",
|
|
108
|
+
"once",
|
|
109
|
+
"one",
|
|
110
|
+
"only",
|
|
111
|
+
"or",
|
|
112
|
+
"other",
|
|
113
|
+
"our",
|
|
114
|
+
"out",
|
|
115
|
+
"over",
|
|
116
|
+
"own",
|
|
117
|
+
"people",
|
|
118
|
+
"please",
|
|
119
|
+
"put",
|
|
120
|
+
"quarter",
|
|
121
|
+
"read",
|
|
122
|
+
"ready",
|
|
123
|
+
"receive",
|
|
124
|
+
"report",
|
|
125
|
+
"right",
|
|
126
|
+
"same",
|
|
127
|
+
"say",
|
|
128
|
+
"see",
|
|
129
|
+
"send",
|
|
130
|
+
"sent",
|
|
131
|
+
"she",
|
|
132
|
+
"should",
|
|
133
|
+
"show",
|
|
134
|
+
"since",
|
|
135
|
+
"so",
|
|
136
|
+
"some",
|
|
137
|
+
"still",
|
|
138
|
+
"such",
|
|
139
|
+
"take",
|
|
140
|
+
"team",
|
|
141
|
+
"tell",
|
|
142
|
+
"than",
|
|
143
|
+
"that",
|
|
144
|
+
"the",
|
|
145
|
+
"their",
|
|
146
|
+
"them",
|
|
147
|
+
"then",
|
|
148
|
+
"there",
|
|
149
|
+
"these",
|
|
150
|
+
"they",
|
|
151
|
+
"thing",
|
|
152
|
+
"think",
|
|
153
|
+
"this",
|
|
154
|
+
"those",
|
|
155
|
+
"through",
|
|
156
|
+
"time",
|
|
157
|
+
"to",
|
|
158
|
+
"today",
|
|
159
|
+
"too",
|
|
160
|
+
"two",
|
|
161
|
+
"up",
|
|
162
|
+
"us",
|
|
163
|
+
"use",
|
|
164
|
+
"very",
|
|
165
|
+
"want",
|
|
166
|
+
"was",
|
|
167
|
+
"way",
|
|
168
|
+
"we",
|
|
169
|
+
"week",
|
|
170
|
+
"well",
|
|
171
|
+
"were",
|
|
172
|
+
"what",
|
|
173
|
+
"when",
|
|
174
|
+
"where",
|
|
175
|
+
"which",
|
|
176
|
+
"while",
|
|
177
|
+
"who",
|
|
178
|
+
"why",
|
|
179
|
+
"will",
|
|
180
|
+
"with",
|
|
181
|
+
"word",
|
|
182
|
+
"work",
|
|
183
|
+
"would",
|
|
184
|
+
"year",
|
|
185
|
+
"yes",
|
|
186
|
+
"you",
|
|
187
|
+
"your",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const DICTIONARIES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
191
|
+
["en", ENGLISH],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
export const dictionaryFor = (
|
|
195
|
+
language: LanguageTag,
|
|
196
|
+
): ReadonlySet<string> | null =>
|
|
197
|
+
DICTIONARIES.get(language.toLowerCase().split("-")[0]) ?? null;
|
|
198
|
+
|
|
199
|
+
export const findMisspellings = (
|
|
200
|
+
text: string,
|
|
201
|
+
words: ReadonlySet<string>,
|
|
202
|
+
): readonly WordRange[] => {
|
|
203
|
+
const pattern = /\p{L}[\p{L}\p{M}'’]*/gu;
|
|
204
|
+
const found: WordRange[] = [];
|
|
205
|
+
let match = pattern.exec(text);
|
|
206
|
+
while (match) {
|
|
207
|
+
const word = match[0];
|
|
208
|
+
if (word.length > 1 && !words.has(word.toLowerCase().replace(/’/g, "'"))) {
|
|
209
|
+
found.push({ start: match.index, end: match.index + word.length });
|
|
210
|
+
}
|
|
211
|
+
match = pattern.exec(text);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `spellcheck.provider` a composer hands the editor, over a real worker.
|
|
3
|
+
* A language with no dictionary answers null and no worker ever starts.
|
|
4
|
+
*
|
|
5
|
+
* Its own module, and deliberately not on the package barrel: a bundler emits
|
|
6
|
+
* the worker chunk for whatever graph reaches this `new URL`, and until the
|
|
7
|
+
* build carries an engine (#692, decision 11) that chunk has no business in an
|
|
8
|
+
* app build.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
LanguageTag,
|
|
13
|
+
SpellProvider,
|
|
14
|
+
SpellWorkerResponse,
|
|
15
|
+
} from "./rich-text-spellcheck.js";
|
|
16
|
+
import type { SpellWorkerPort } from "./rich-text-spellcheck-provider.js";
|
|
17
|
+
import { openSpellProvider } from "./rich-text-spellcheck-provider.js";
|
|
18
|
+
import { dictionaryFor } from "./rich-text-spellcheck-words.js";
|
|
19
|
+
|
|
20
|
+
const workerPort = (worker: Worker): SpellWorkerPort => ({
|
|
21
|
+
post: (message) => worker.postMessage(message),
|
|
22
|
+
listen: (listener) =>
|
|
23
|
+
worker.addEventListener("message", (event: MessageEvent) =>
|
|
24
|
+
listener(event.data as SpellWorkerResponse),
|
|
25
|
+
),
|
|
26
|
+
fail: (listener) =>
|
|
27
|
+
worker.addEventListener("error", (event: ErrorEvent) =>
|
|
28
|
+
listener(event.message),
|
|
29
|
+
),
|
|
30
|
+
terminate: () => worker.terminate(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const openSpellcheckWorker = async (
|
|
34
|
+
language: LanguageTag,
|
|
35
|
+
): Promise<SpellProvider | null> => {
|
|
36
|
+
if (!dictionaryFor(language)) return null;
|
|
37
|
+
const worker = new Worker(
|
|
38
|
+
new URL("./rich-text-spellcheck-worker.ts", import.meta.url),
|
|
39
|
+
{ type: "module" },
|
|
40
|
+
);
|
|
41
|
+
return openSpellProvider(language, workerPort(worker));
|
|
42
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checking side, off the main thread. It answers with the revision it was
|
|
3
|
+
* asked at and never touches the document, which is what lets the editor throw
|
|
4
|
+
* a late answer away. The word list it consults is a stub; the engine that
|
|
5
|
+
* replaces it changes nothing above this file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CheckRequest,
|
|
10
|
+
Finding,
|
|
11
|
+
SpellWorkerRequest,
|
|
12
|
+
SpellWorkerResponse,
|
|
13
|
+
} from "./rich-text-spellcheck.js";
|
|
14
|
+
import {
|
|
15
|
+
dictionaryFor,
|
|
16
|
+
findMisspellings,
|
|
17
|
+
} from "./rich-text-spellcheck-words.js";
|
|
18
|
+
|
|
19
|
+
interface WorkerScope {
|
|
20
|
+
postMessage(message: SpellWorkerResponse): void;
|
|
21
|
+
addEventListener(
|
|
22
|
+
type: "message",
|
|
23
|
+
listener: (event: MessageEvent<SpellWorkerRequest>) => void,
|
|
24
|
+
): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const spell = (request: CheckRequest): Finding[] => {
|
|
28
|
+
const words = dictionaryFor(request.language);
|
|
29
|
+
if (!words) return [];
|
|
30
|
+
return request.spans.flatMap((span) =>
|
|
31
|
+
findMisspellings(span.text, words).map((range) => ({
|
|
32
|
+
spanId: span.spanId,
|
|
33
|
+
start: range.start,
|
|
34
|
+
end: range.end,
|
|
35
|
+
kind: "spelling" as const,
|
|
36
|
+
suggestions: [],
|
|
37
|
+
})),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const scope = globalThis as unknown as WorkerScope;
|
|
42
|
+
|
|
43
|
+
scope.addEventListener("message", ({ data }) => {
|
|
44
|
+
if (data.type === "open") {
|
|
45
|
+
scope.postMessage({ type: "ready", language: data.language });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
scope.postMessage({
|
|
49
|
+
type: "checked",
|
|
50
|
+
requestId: data.requestId,
|
|
51
|
+
revision: data.revision,
|
|
52
|
+
findings: spell(data),
|
|
53
|
+
});
|
|
54
|
+
});
|