@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.106",
3
+ "version": "0.0.107",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
- import { expect, userEvent } from "storybook/test";
3
+ import { expect, userEvent, waitFor } from "storybook/test";
4
4
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
5
5
  import { ComposeLanguageChip } from "./compose-language-chip.js";
6
6
  import {
@@ -8,6 +8,16 @@ import {
8
8
  ComposeModeToggle,
9
9
  } from "./compose-mode-toggle.js";
10
10
  import { RichTextEditor } from "./rich-text-editor.js";
11
+ import type {
12
+ CheckRequest,
13
+ Finding,
14
+ SpellcheckOptions,
15
+ } from "./rich-text-spellcheck.js";
16
+ import {
17
+ dictionaryFor,
18
+ findMisspellings,
19
+ } from "./rich-text-spellcheck-words.js";
20
+ import { openSpellcheckWorker } from "./rich-text-spellcheck-worker-provider.js";
11
21
 
12
22
  /**
13
23
  * The frame is the compose body region at its real geometry — a column with a
@@ -189,6 +199,159 @@ export const NarrowToolbar: Story = {
189
199
  },
190
200
  };
191
201
 
202
+ const MISSPELT = "Ths report is redy today, and the notes are attachd.";
203
+
204
+ /**
205
+ * A real module worker does the checking, over the same messages an engine
206
+ * would answer: the component is handed a provider and never learns where the
207
+ * words came from. What the worker holds instead of a dictionary is a short
208
+ * list of English words.
209
+ */
210
+ const workerSpellcheck: SpellcheckOptions = { provider: openSpellcheckWorker };
211
+
212
+ /**
213
+ * The same findings, answered against the revision before the one asked for —
214
+ * what a slow engine looks like when the text has already moved on.
215
+ */
216
+ const staleAnswers: string[] = [];
217
+
218
+ const staleSpellcheck: SpellcheckOptions = {
219
+ provider: async () => ({
220
+ language: "en",
221
+ onStatus: (listener) => {
222
+ listener({ state: "ready", language: "en" });
223
+ return () => {};
224
+ },
225
+ check: (request: CheckRequest) => {
226
+ staleAnswers.push(request.requestId);
227
+ const words = dictionaryFor(request.language) ?? new Set<string>();
228
+ return Promise.resolve({
229
+ requestId: request.requestId,
230
+ revision: request.revision - 1,
231
+ findings: request.spans.flatMap((span) =>
232
+ findMisspellings(span.text, words).map(
233
+ (range): Finding => ({
234
+ spanId: span.spanId,
235
+ start: range.start,
236
+ end: range.end,
237
+ kind: "spelling",
238
+ suggestions: [],
239
+ }),
240
+ ),
241
+ ),
242
+ });
243
+ },
244
+ close: () => {},
245
+ }),
246
+ };
247
+
248
+ /**
249
+ * Every editor on the page shares the one registry entry, so a story reads back
250
+ * the marks that fall inside its own writing surface — on a docs page the
251
+ * neighbouring stories are drawing into it at the same time.
252
+ */
253
+ const spellMarks = (editable: HTMLElement): AbstractRange[] => {
254
+ const ranges: AbstractRange[] = [];
255
+ CSS.highlights.forEach((highlight, name) => {
256
+ if (name !== "spell-error") return;
257
+ highlight.forEach((range) => {
258
+ if (editable.contains(range.startContainer)) ranges.push(range);
259
+ });
260
+ });
261
+ return ranges;
262
+ };
263
+
264
+ const spellMarkOffsets = (editable: HTMLElement): [number, number][] =>
265
+ spellMarks(editable).map((range) => [range.startOffset, range.endOffset]);
266
+
267
+ const writingSurface = (canvasElement: HTMLElement): HTMLElement => {
268
+ const editable = canvasElement.querySelector<HTMLElement>(
269
+ "[data-testid=compose-body]",
270
+ );
271
+ if (!editable) throw new Error("the editor is not mounted");
272
+ return editable;
273
+ };
274
+
275
+ /**
276
+ * The marks a provider produced, drawn through the CSS Custom Highlight
277
+ * registry: two misspelt words carry a squiggle, the browser's own checking is
278
+ * off while ours is on, and the document holds nothing that was not typed.
279
+ */
280
+ export const SpellcheckMarks: Story = {
281
+ name: "Spellcheck marks (worker provider)",
282
+ args: {
283
+ initialHtml: `<p>${MISSPELT}</p>`,
284
+ lang: "en",
285
+ spellcheck: workerSpellcheck,
286
+ },
287
+ play: async ({ canvasElement }) => {
288
+ const editable = writingSurface(canvasElement);
289
+
290
+ await waitFor(
291
+ () =>
292
+ expect(spellMarkOffsets(editable)).toEqual([
293
+ [0, 3],
294
+ [14, 18],
295
+ [44, 51],
296
+ ]),
297
+ { timeout: 5000 },
298
+ );
299
+ await expect(editable.getAttribute("spellcheck")).toBe("false");
300
+ await expect(editable.textContent).toBe(MISSPELT);
301
+ await expect(editable.querySelectorAll("[data-lexical-text]")).toHaveLength(
302
+ 1,
303
+ );
304
+
305
+ // The word the caret sits in is left alone until the writer moves on.
306
+ await userEvent.click(editable);
307
+ const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
308
+ const characters = line?.firstChild ?? null;
309
+ canvasElement.ownerDocument.getSelection()?.setPosition(characters, 17);
310
+
311
+ await waitFor(
312
+ () =>
313
+ expect(spellMarkOffsets(editable)).toEqual([
314
+ [0, 3],
315
+ [44, 51],
316
+ ]),
317
+ { timeout: 5000 },
318
+ );
319
+ },
320
+ };
321
+
322
+ /** A language the build carries no dictionary for: the browser keeps checking. */
323
+ export const SpellcheckWithoutDictionary: Story = {
324
+ name: "Spellcheck with no dictionary for the language",
325
+ args: {
326
+ initialHtml: `<p>Vielen Dank für den Bericht.</p>`,
327
+ lang: "de",
328
+ spellcheck: workerSpellcheck,
329
+ },
330
+ play: async ({ canvasElement }) => {
331
+ const editable = writingSurface(canvasElement);
332
+ await expect(editable.getAttribute("spellcheck")).toBe("true");
333
+ await expect(spellMarks(editable)).toHaveLength(0);
334
+ },
335
+ };
336
+
337
+ /** An answer against a revision the document has moved past paints nothing. */
338
+ export const SpellcheckStaleAnswer: Story = {
339
+ name: "Spellcheck drops a stale answer",
340
+ args: {
341
+ initialHtml: `<p>${MISSPELT}</p>`,
342
+ lang: "en",
343
+ spellcheck: staleSpellcheck,
344
+ },
345
+ play: async ({ canvasElement }) => {
346
+ const editable = writingSurface(canvasElement);
347
+ await waitFor(() => expect(staleAnswers.length).toBeGreaterThan(0), {
348
+ timeout: 5000,
349
+ });
350
+ await expect(spellMarks(editable)).toHaveLength(0);
351
+ await expect(editable.textContent).toBe(MISSPELT);
352
+ },
353
+ };
354
+
192
355
  /**
193
356
  * The toolbar and the body share one scroller, so twenty lines of typing would
194
357
  * carry the toolbar off the top with them. It stays at the top of the body
@@ -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
+ };