@remit/ui 0.0.106 → 0.0.108
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/nav-link-surface.interaction.test.ts +117 -0
- package/src/components/nav-link-surface.render.test.ts +126 -0
- package/src/components/nav-link-surface.tsx +97 -0
- 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/index.ts +1 -0
- package/src/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
|
@@ -9,19 +9,28 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
|
9
9
|
import { TablePlugin } from "@lexical/react/LexicalTablePlugin";
|
|
10
10
|
import { mergeRegister } from "@lexical/utils";
|
|
11
11
|
import {
|
|
12
|
+
$getNodeByKey,
|
|
12
13
|
$getRoot,
|
|
13
14
|
$getSelection,
|
|
14
15
|
$insertNodes,
|
|
15
16
|
$isRangeSelection,
|
|
17
|
+
$isTextNode,
|
|
16
18
|
COMMAND_PRIORITY_CRITICAL,
|
|
17
19
|
COMMAND_PRIORITY_LOW,
|
|
18
20
|
KEY_DOWN_COMMAND,
|
|
19
21
|
type LexicalEditor,
|
|
20
22
|
PASTE_COMMAND,
|
|
21
23
|
} from "lexical";
|
|
22
|
-
import { useEffect, useRef } from "react";
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
23
25
|
import { $adoptHtml, $readRichText } from "./rich-text-document.js";
|
|
24
26
|
import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
|
|
27
|
+
import type {
|
|
28
|
+
CheckSpan,
|
|
29
|
+
Finding,
|
|
30
|
+
ProviderStatus,
|
|
31
|
+
SpellcheckOptions,
|
|
32
|
+
SpellProvider,
|
|
33
|
+
} from "./rich-text-spellcheck.js";
|
|
25
34
|
import { RichTextToolbar } from "./rich-text-toolbar.js";
|
|
26
35
|
import type { RichTextValue } from "./rich-text-value.js";
|
|
27
36
|
|
|
@@ -44,6 +53,13 @@ export interface RichTextEditorProps {
|
|
|
44
53
|
* ignore it. Every screen reader picks a voice from it.
|
|
45
54
|
*/
|
|
46
55
|
lang?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Where checking comes from. Left out, the browser does its own and the
|
|
58
|
+
* editor behaves as it does today. The engine is injected: this component
|
|
59
|
+
* never imports one, and a `provider` resolving null means no dictionary for
|
|
60
|
+
* `lang`, which is also when the browser keeps checking.
|
|
61
|
+
*/
|
|
62
|
+
spellcheck?: SpellcheckOptions;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
/**
|
|
@@ -141,6 +157,282 @@ const ChangePlugin = ({
|
|
|
141
157
|
return null;
|
|
142
158
|
};
|
|
143
159
|
|
|
160
|
+
const SPELLCHECK_IDLE_MS = 250;
|
|
161
|
+
const SPELLCHECK_HIGHLIGHT = "spell-error";
|
|
162
|
+
|
|
163
|
+
interface SpellMarks {
|
|
164
|
+
add(range: Range): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface MarkRegistry {
|
|
168
|
+
set(name: string, marks: SpellMarks): void;
|
|
169
|
+
delete(name: string): void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The registry is read off the global rather than through the DOM typings,
|
|
174
|
+
* which describe `Highlight` without the members that put a range in it. Where
|
|
175
|
+
* a browser has neither, nothing is drawn and the provider is never opened.
|
|
176
|
+
*/
|
|
177
|
+
interface MarkHost {
|
|
178
|
+
CSS?: { highlights?: MarkRegistry };
|
|
179
|
+
Highlight?: new () => SpellMarks;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const markHost = (): MarkHost => globalThis as unknown as MarkHost;
|
|
183
|
+
|
|
184
|
+
const marksSupported = (): boolean => {
|
|
185
|
+
const host = markHost();
|
|
186
|
+
return Boolean(host.CSS?.highlights) && typeof host.Highlight === "function";
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One registry entry carries the marks of every editor on the page, because a
|
|
191
|
+
* highlight name is a page-wide thing and `::highlight(spell-error)` names it
|
|
192
|
+
* statically. Each editor owns its own ranges here and hands over the whole set
|
|
193
|
+
* each pass, so a second composer neither overwrites the first nor takes its
|
|
194
|
+
* marks down when it closes.
|
|
195
|
+
*/
|
|
196
|
+
const painters = new Map<symbol, readonly Range[]>();
|
|
197
|
+
|
|
198
|
+
const paintMarks = (painter: symbol, ranges: readonly Range[]): void => {
|
|
199
|
+
const host = markHost();
|
|
200
|
+
const registry = host.CSS?.highlights;
|
|
201
|
+
const Marks = host.Highlight;
|
|
202
|
+
if (!registry || !Marks) return;
|
|
203
|
+
if (ranges.length === 0) painters.delete(painter);
|
|
204
|
+
else painters.set(painter, ranges);
|
|
205
|
+
|
|
206
|
+
const marks = new Marks();
|
|
207
|
+
let drawn = 0;
|
|
208
|
+
for (const owned of painters.values())
|
|
209
|
+
for (const range of owned) {
|
|
210
|
+
marks.add(range);
|
|
211
|
+
drawn += 1;
|
|
212
|
+
}
|
|
213
|
+
if (drawn === 0) {
|
|
214
|
+
registry.delete(SPELLCHECK_HIGHLIGHT);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
registry.set(SPELLCHECK_HIGHLIGHT, marks);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The characters of a leaf. A format Lexical renders with a tag of its own —
|
|
222
|
+
* code, subscript, superscript — puts the text one level further down, so the
|
|
223
|
+
* element the node key resolves to is not always the text itself.
|
|
224
|
+
*/
|
|
225
|
+
const leafCharacters = (node: Node): Node | null => {
|
|
226
|
+
if (node.nodeType === Node.TEXT_NODE) return node;
|
|
227
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
228
|
+
const characters = leafCharacters(child);
|
|
229
|
+
if (characters) return characters;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Marks, and nothing else. Only the leaves an edit touched are sent, a quarter
|
|
236
|
+
* of a second after the typing stops, so a word is not called wrong while it is
|
|
237
|
+
* still being written — and the word the caret sits in is never marked at all.
|
|
238
|
+
* An answer carrying a revision the document has moved past is dropped.
|
|
239
|
+
*
|
|
240
|
+
* The marks live in the highlight registry, keyed by node key and rebuilt after
|
|
241
|
+
* every reconciliation. Nothing enters the document, so history, the outgoing
|
|
242
|
+
* HTML and the Markdown never see one.
|
|
243
|
+
*/
|
|
244
|
+
const SpellcheckPlugin = ({
|
|
245
|
+
language,
|
|
246
|
+
options,
|
|
247
|
+
onReady,
|
|
248
|
+
}: {
|
|
249
|
+
language: string;
|
|
250
|
+
options: SpellcheckOptions;
|
|
251
|
+
onReady: (ready: boolean) => void;
|
|
252
|
+
}) => {
|
|
253
|
+
const [editor] = useLexicalComposerContext();
|
|
254
|
+
const settings = useRef(options);
|
|
255
|
+
const report = useRef(onReady);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
settings.current = options;
|
|
259
|
+
report.current = onReady;
|
|
260
|
+
}, [options, onReady]);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!marksSupported()) return;
|
|
264
|
+
const painter = Symbol(SPELLCHECK_HIGHLIGHT);
|
|
265
|
+
const found = new Map<string, readonly Finding[]>();
|
|
266
|
+
const touched = new Set<string>();
|
|
267
|
+
let provider: SpellProvider | null = null;
|
|
268
|
+
let unsubscribe: (() => void) | undefined;
|
|
269
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
270
|
+
let live = true;
|
|
271
|
+
let state: ProviderStatus["state"] = "opening";
|
|
272
|
+
let revision = 0;
|
|
273
|
+
let asked = 0;
|
|
274
|
+
|
|
275
|
+
const repaint = (): void => {
|
|
276
|
+
const ranges: Range[] = [];
|
|
277
|
+
editor.read(() => {
|
|
278
|
+
const selection = $getSelection();
|
|
279
|
+
const caret =
|
|
280
|
+
$isRangeSelection(selection) && selection.isCollapsed()
|
|
281
|
+
? selection.anchor
|
|
282
|
+
: null;
|
|
283
|
+
for (const [key, findings] of found) {
|
|
284
|
+
const node = $getNodeByKey(key);
|
|
285
|
+
if (!$isTextNode(node)) {
|
|
286
|
+
found.delete(key);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const element = editor.getElementByKey(key);
|
|
290
|
+
const text = element ? leafCharacters(element) : null;
|
|
291
|
+
if (!text) continue;
|
|
292
|
+
const length = text.textContent?.length ?? 0;
|
|
293
|
+
for (const finding of findings) {
|
|
294
|
+
if (finding.end > length) continue;
|
|
295
|
+
if (
|
|
296
|
+
caret?.key === key &&
|
|
297
|
+
caret.offset > finding.start &&
|
|
298
|
+
caret.offset <= finding.end
|
|
299
|
+
)
|
|
300
|
+
continue;
|
|
301
|
+
const range = text.ownerDocument?.createRange();
|
|
302
|
+
if (!range) continue;
|
|
303
|
+
range.setStart(text, finding.start);
|
|
304
|
+
range.setEnd(text, finding.end);
|
|
305
|
+
ranges.push(range);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
paintMarks(painter, ranges);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const check = (): void => {
|
|
313
|
+
// Anything queued while the provider is not answering stays queued: the
|
|
314
|
+
// next status that says ready sends it.
|
|
315
|
+
if (!provider || state !== "ready") return;
|
|
316
|
+
const keys = [...touched];
|
|
317
|
+
touched.clear();
|
|
318
|
+
const spans: CheckSpan[] = [];
|
|
319
|
+
editor.read(() => {
|
|
320
|
+
for (const key of keys) {
|
|
321
|
+
const node = $getNodeByKey(key);
|
|
322
|
+
if (!$isTextNode(node)) {
|
|
323
|
+
found.delete(key);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
spans.push({ spanId: key, text: node.getTextContent() });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
if (spans.length === 0) {
|
|
330
|
+
repaint();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
asked += 1;
|
|
334
|
+
const sent = revision;
|
|
335
|
+
provider
|
|
336
|
+
.check({ requestId: `${asked}`, language, revision: sent, spans })
|
|
337
|
+
.then((response) => {
|
|
338
|
+
if (!live) return;
|
|
339
|
+
// The text moved while this was in flight, so the offsets are
|
|
340
|
+
// answers to a document that no longer exists. The leaves go back
|
|
341
|
+
// on the queue: dropping them here would leave them unchecked
|
|
342
|
+
// until the writer happened to touch them again.
|
|
343
|
+
if (response.revision !== revision) {
|
|
344
|
+
for (const span of spans) touched.add(span.spanId);
|
|
345
|
+
schedule();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const grouped = new Map<string, Finding[]>();
|
|
349
|
+
for (const finding of response.findings) {
|
|
350
|
+
const list = grouped.get(finding.spanId);
|
|
351
|
+
if (list) {
|
|
352
|
+
list.push(finding);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
grouped.set(finding.spanId, [finding]);
|
|
356
|
+
}
|
|
357
|
+
for (const span of spans) found.delete(span.spanId);
|
|
358
|
+
for (const [key, findings] of grouped) found.set(key, findings);
|
|
359
|
+
repaint();
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const schedule = (): void => {
|
|
364
|
+
clearTimeout(idle);
|
|
365
|
+
idle = setTimeout(check, SPELLCHECK_IDLE_MS);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const unregister = editor.registerUpdateListener(({ dirtyLeaves }) => {
|
|
369
|
+
if (dirtyLeaves.size === 0) {
|
|
370
|
+
repaint();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
revision += 1;
|
|
374
|
+
// Characters moved under the findings this leaf carries, so they are
|
|
375
|
+
// answers about text that is no longer there. They go now rather than
|
|
376
|
+
// sitting over the wrong letters until the next answer arrives.
|
|
377
|
+
for (const key of dirtyLeaves) {
|
|
378
|
+
found.delete(key);
|
|
379
|
+
touched.add(key);
|
|
380
|
+
}
|
|
381
|
+
repaint();
|
|
382
|
+
schedule();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
settings.current.provider(language).then((opened) => {
|
|
386
|
+
if (!live) {
|
|
387
|
+
opened?.close();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
provider = opened;
|
|
391
|
+
if (!opened) {
|
|
392
|
+
settings.current.onStatus?.({ state: "unavailable", language });
|
|
393
|
+
report.current(false);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
unsubscribe = opened.onStatus((status) => {
|
|
397
|
+
state = status.state;
|
|
398
|
+
settings.current.onStatus?.(status);
|
|
399
|
+
const ready = status.state === "ready";
|
|
400
|
+
report.current(ready);
|
|
401
|
+
if (ready) {
|
|
402
|
+
schedule();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Checking stopped, so the browser's own is back. Its squiggles are
|
|
406
|
+
// the only ones on screen from here.
|
|
407
|
+
found.clear();
|
|
408
|
+
touched.clear();
|
|
409
|
+
repaint();
|
|
410
|
+
});
|
|
411
|
+
// The document the editor opened on was reconciled before this
|
|
412
|
+
// listener existed, so its leaves are checked here rather than waiting
|
|
413
|
+
// for an edit that may never come.
|
|
414
|
+
editor.read(() => {
|
|
415
|
+
for (const node of $getRoot().getAllTextNodes())
|
|
416
|
+
touched.add(node.getKey());
|
|
417
|
+
});
|
|
418
|
+
schedule();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return () => {
|
|
422
|
+
live = false;
|
|
423
|
+
clearTimeout(idle);
|
|
424
|
+
unregister();
|
|
425
|
+
unsubscribe?.();
|
|
426
|
+
provider?.close();
|
|
427
|
+
provider = null;
|
|
428
|
+
report.current(false);
|
|
429
|
+
paintMarks(painter, []);
|
|
430
|
+
};
|
|
431
|
+
}, [editor, language]);
|
|
432
|
+
|
|
433
|
+
return null;
|
|
434
|
+
};
|
|
435
|
+
|
|
144
436
|
const AutoFocus = ({ enabled }: { enabled: boolean }) => {
|
|
145
437
|
const [editor] = useLexicalComposerContext();
|
|
146
438
|
|
|
@@ -171,56 +463,75 @@ export const RichTextEditor = ({
|
|
|
171
463
|
ariaLabel = "Message body",
|
|
172
464
|
trailing,
|
|
173
465
|
lang,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
466
|
+
spellcheck,
|
|
467
|
+
}: RichTextEditorProps) => {
|
|
468
|
+
const [checkedHere, setCheckedHere] = useState(false);
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<LexicalComposer
|
|
472
|
+
initialConfig={{
|
|
473
|
+
namespace: "compose",
|
|
474
|
+
nodes: RICH_TEXT_NODES,
|
|
475
|
+
theme: richTextTheme,
|
|
476
|
+
editorState: initialHtml ? seedDocument(initialHtml) : undefined,
|
|
477
|
+
onError: (error) => {
|
|
478
|
+
throw error;
|
|
479
|
+
},
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
{/* The editable claims the height its container offers rather than only the
|
|
483
|
+
height of its own text. What is under the last line is the document, so
|
|
484
|
+
a click there reaches it instead of an unfocusable parent. */}
|
|
485
|
+
<div className="flex shrink-0 grow flex-col">
|
|
486
|
+
<RichTextToolbar trailing={trailing} />
|
|
487
|
+
<div className="relative flex shrink-0 grow flex-col">
|
|
488
|
+
<RichTextPlugin
|
|
489
|
+
contentEditable={
|
|
490
|
+
<ContentEditable
|
|
491
|
+
lang={lang}
|
|
492
|
+
/* Two sets of squiggles never coexist: the browser stops
|
|
493
|
+
checking exactly while a provider of ours is ready, and
|
|
494
|
+
checks again the moment one is not. */
|
|
495
|
+
spellCheck={spellcheck ? !checkedHere : undefined}
|
|
496
|
+
aria-label={ariaLabel}
|
|
497
|
+
aria-placeholder={placeholder}
|
|
498
|
+
data-testid="compose-body"
|
|
499
|
+
className="min-h-[120px] w-full shrink-0 grow bg-canvas px-3 py-2 text-sm text-fg outline-none"
|
|
500
|
+
placeholder={
|
|
501
|
+
<div className="pointer-events-none absolute inset-x-0 top-0 px-3 py-2 text-sm text-fg-subtle">
|
|
502
|
+
{placeholder}
|
|
503
|
+
</div>
|
|
504
|
+
}
|
|
505
|
+
onKeyDown={(event) => {
|
|
506
|
+
if (!onSubmit) return;
|
|
507
|
+
if (
|
|
508
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
509
|
+
event.key !== "Enter"
|
|
510
|
+
)
|
|
511
|
+
return;
|
|
512
|
+
event.preventDefault();
|
|
513
|
+
onSubmit();
|
|
514
|
+
}}
|
|
515
|
+
/>
|
|
516
|
+
}
|
|
517
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
518
|
+
/>
|
|
519
|
+
</div>
|
|
216
520
|
</div>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
521
|
+
<HistoryPlugin />
|
|
522
|
+
<ListPlugin />
|
|
523
|
+
<LinkPlugin />
|
|
524
|
+
<TablePlugin />
|
|
525
|
+
<PastePlugin />
|
|
526
|
+
<AutoFocus enabled={autoFocus} />
|
|
527
|
+
{onChange && <ChangePlugin onChange={onChange} />}
|
|
528
|
+
{spellcheck && lang ? (
|
|
529
|
+
<SpellcheckPlugin
|
|
530
|
+
language={lang}
|
|
531
|
+
options={spellcheck}
|
|
532
|
+
onReady={setCheckedHere}
|
|
533
|
+
/>
|
|
534
|
+
) : null}
|
|
535
|
+
</LexicalComposer>
|
|
536
|
+
);
|
|
537
|
+
};
|
|
@@ -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
|
+
});
|