@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.
@@ -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
- }: RichTextEditorProps) => (
175
- <LexicalComposer
176
- initialConfig={{
177
- namespace: "compose",
178
- nodes: RICH_TEXT_NODES,
179
- theme: richTextTheme,
180
- editorState: initialHtml ? seedDocument(initialHtml) : undefined,
181
- onError: (error) => {
182
- throw error;
183
- },
184
- }}
185
- >
186
- {/* The editable claims the height its container offers rather than only the
187
- height of its own text. What is under the last line is the document, so
188
- a click there reaches it instead of an unfocusable parent. */}
189
- <div className="flex shrink-0 grow flex-col">
190
- <RichTextToolbar trailing={trailing} />
191
- <div className="relative flex shrink-0 grow flex-col">
192
- <RichTextPlugin
193
- contentEditable={
194
- <ContentEditable
195
- lang={lang}
196
- aria-label={ariaLabel}
197
- aria-placeholder={placeholder}
198
- data-testid="compose-body"
199
- className="min-h-[120px] w-full shrink-0 grow bg-canvas px-3 py-2 text-sm text-fg outline-none"
200
- placeholder={
201
- <div className="pointer-events-none absolute inset-x-0 top-0 px-3 py-2 text-sm text-fg-subtle">
202
- {placeholder}
203
- </div>
204
- }
205
- onKeyDown={(event) => {
206
- if (!onSubmit) return;
207
- if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter")
208
- return;
209
- event.preventDefault();
210
- onSubmit();
211
- }}
212
- />
213
- }
214
- ErrorBoundary={LexicalErrorBoundary}
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
- </div>
218
- <HistoryPlugin />
219
- <ListPlugin />
220
- <LinkPlugin />
221
- <TablePlugin />
222
- <PastePlugin />
223
- <AutoFocus enabled={autoFocus} />
224
- {onChange && <ChangePlugin onChange={onChange} />}
225
- </LexicalComposer>
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
+ });