@remit/ui 0.0.113 → 0.0.115

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.
Files changed (63) hide show
  1. package/package.json +3 -3
  2. package/src/components/app-shell-slotted.render.test.ts +83 -0
  3. package/src/components/app-shell-slotted.tsx +31 -3
  4. package/src/components/attendee-row.render.test.ts +73 -0
  5. package/src/components/attendee-row.stories.tsx +68 -0
  6. package/src/components/attendee-row.tsx +96 -0
  7. package/src/components/calendar-event-chip.render.test.ts +67 -0
  8. package/src/components/calendar-event-chip.stories.tsx +128 -0
  9. package/src/components/calendar-event-chip.tsx +116 -0
  10. package/src/components/calendar-list.render.test.ts +77 -0
  11. package/src/components/calendar-list.stories.tsx +130 -0
  12. package/src/components/calendar-list.tsx +225 -0
  13. package/src/components/calendar-toolbar.render.test.ts +69 -0
  14. package/src/components/calendar-toolbar.stories.tsx +55 -0
  15. package/src/components/calendar-toolbar.tsx +178 -0
  16. package/src/components/calendar-types.ts +135 -0
  17. package/src/components/custom-recurrence.stories.tsx +106 -0
  18. package/src/components/custom-recurrence.tsx +411 -0
  19. package/src/components/event-detail.render.test.ts +104 -0
  20. package/src/components/event-detail.stories.tsx +178 -0
  21. package/src/components/event-detail.tsx +188 -0
  22. package/src/components/event-editor-pane.tsx +54 -0
  23. package/src/components/event-editor.render.test.ts +83 -0
  24. package/src/components/event-editor.stories.tsx +99 -0
  25. package/src/components/event-editor.tsx +480 -0
  26. package/src/components/event-quick-entry.render.test.ts +40 -0
  27. package/src/components/event-quick-entry.stories.tsx +56 -0
  28. package/src/components/event-quick-entry.tsx +159 -0
  29. package/src/components/event-suggestion-card.render.test.ts +65 -0
  30. package/src/components/event-suggestion-card.stories.tsx +93 -0
  31. package/src/components/event-suggestion-card.tsx +116 -0
  32. package/src/components/flow-screen.tsx +169 -0
  33. package/src/components/intelligence-panel.stories.tsx +5 -1
  34. package/src/components/intelligence-panel.tsx +4 -0
  35. package/src/components/isolated-email-frame.tsx +1 -18
  36. package/src/components/message-list-pane.render.test.ts +2 -2
  37. package/src/components/message-list-pane.stories.tsx +1 -1
  38. package/src/components/nav-sidebar.tsx +20 -0
  39. package/src/components/popover-menu.tsx +230 -27
  40. package/src/components/pull-to-refresh.tsx +1 -19
  41. package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
  42. package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
  43. package/src/components/recurrence-scope-prompt.tsx +84 -0
  44. package/src/components/rich-text-correction-menu.tsx +220 -0
  45. package/src/components/rich-text-editor.stories.tsx +507 -5
  46. package/src/components/rich-text-editor.tsx +482 -83
  47. package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
  48. package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
  49. package/src/components/rich-text-spellcheck-provider.ts +68 -3
  50. package/src/components/rich-text-spellcheck-words.ts +168 -4
  51. package/src/components/rich-text-spellcheck-worker.ts +11 -0
  52. package/src/components/rich-text-spellcheck.test.ts +7 -0
  53. package/src/components/rich-text-spellcheck.ts +28 -2
  54. package/src/components/selection-wizard.tsx +19 -69
  55. package/src/index.ts +116 -0
  56. package/src/lib/calendar-color.ts +71 -0
  57. package/src/lib/event-phrase.test.ts +89 -0
  58. package/src/lib/event-phrase.ts +228 -0
  59. package/src/lib/recurrence.test.ts +141 -0
  60. package/src/lib/recurrence.ts +266 -0
  61. package/src/lib/use-match-media.ts +25 -0
  62. package/src/rich-text.ts +12 -0
  63. package/src/tokens.css +63 -0
@@ -9,6 +9,7 @@ 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
+ $getNearestNodeFromDOMNode,
12
13
  $getNodeByKey,
13
14
  $getRoot,
14
15
  $getSelection,
@@ -17,11 +18,19 @@ import {
17
18
  $isTextNode,
18
19
  COMMAND_PRIORITY_CRITICAL,
19
20
  COMMAND_PRIORITY_LOW,
21
+ HISTORY_PUSH_TAG,
20
22
  KEY_DOWN_COMMAND,
21
23
  type LexicalEditor,
22
24
  PASTE_COMMAND,
23
25
  } from "lexical";
24
- import { useEffect, useRef, useState } from "react";
26
+ import {
27
+ type RefObject,
28
+ useCallback,
29
+ useEffect,
30
+ useRef,
31
+ useState,
32
+ } from "react";
33
+ import { RichTextCorrectionMenu } from "./rich-text-correction-menu.js";
25
34
  import { $adoptHtml, $readRichText } from "./rich-text-document.js";
26
35
  import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
27
36
  import type {
@@ -31,6 +40,10 @@ import type {
31
40
  SpellcheckOptions,
32
41
  SpellProvider,
33
42
  } from "./rich-text-spellcheck.js";
43
+ import {
44
+ normaliseWord,
45
+ SUGGESTION_LIMIT,
46
+ } from "./rich-text-spellcheck-words.js";
34
47
  import { RichTextToolbar } from "./rich-text-toolbar.js";
35
48
  import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
36
49
 
@@ -237,28 +250,153 @@ const leafCharacters = (node: Node): Node | null => {
237
250
  return null;
238
251
  };
239
252
 
253
+ /** The characters a finding covers, as the browser draws them. */
254
+ const leafRange = (
255
+ editor: LexicalEditor,
256
+ key: string,
257
+ start: number,
258
+ end: number,
259
+ ): Range | null => {
260
+ const element = editor.getElementByKey(key);
261
+ const characters = element ? leafCharacters(element) : null;
262
+ if (!characters) return null;
263
+ if (end > (characters.textContent?.length ?? 0)) return null;
264
+ const range = characters.ownerDocument?.createRange();
265
+ if (!range) return null;
266
+ range.setStart(characters, start);
267
+ range.setEnd(characters, end);
268
+ return range;
269
+ };
270
+
271
+ /**
272
+ * Where a pointer landed, in characters. `caretPositionFromPoint` is the
273
+ * standard spelling and `caretRangeFromPoint` the one WebKit shipped; without
274
+ * either, the browser has already moved the caret to the point and the
275
+ * selection says where.
276
+ */
277
+ interface CaretDocument {
278
+ caretPositionFromPoint?(
279
+ x: number,
280
+ y: number,
281
+ ): { offsetNode: Node; offset: number } | null;
282
+ caretRangeFromPoint?(x: number, y: number): Range | null;
283
+ }
284
+
285
+ const characterAt = (
286
+ owner: Document,
287
+ x: number,
288
+ y: number,
289
+ ): { node: Node; offset: number } | null => {
290
+ const caret = owner as unknown as CaretDocument;
291
+ const position = caret.caretPositionFromPoint?.(x, y);
292
+ if (position) return { node: position.offsetNode, offset: position.offset };
293
+ const range = caret.caretRangeFromPoint?.(x, y);
294
+ if (range) return { node: range.startContainer, offset: range.startOffset };
295
+ const selection = owner.getSelection();
296
+ if (!selection?.focusNode) return null;
297
+ return { node: selection.focusNode, offset: selection.focusOffset };
298
+ };
299
+
300
+ interface CorrectionTarget {
301
+ readonly key: string;
302
+ readonly start: number;
303
+ readonly end: number;
304
+ readonly word: string;
305
+ /** Viewport-relative, straight off the word's own range — the menu portals
306
+ * to the document body, so nothing here is relative to the editor. */
307
+ readonly left: number;
308
+ readonly right: number;
309
+ readonly top: number;
310
+ readonly bottom: number;
311
+ }
312
+
313
+ /**
314
+ * A caret sits between characters and a pointer lands on one, so the two ask
315
+ * different questions of the same range. The caret is inside the word from the
316
+ * first character to just after the last — which is also what goes unmarked
317
+ * while it is being written, so the chord opens exactly the word the squiggle
318
+ * is being withheld from. A point belongs to a character, and the position
319
+ * after the last one is already the space that follows.
320
+ */
321
+ const coversCaret = (finding: Finding, offset: number): boolean =>
322
+ offset > finding.start && offset <= finding.end;
323
+
324
+ const coversCharacter = (finding: Finding, offset: number): boolean =>
325
+ offset >= finding.start && offset < finding.end;
326
+
327
+ /** How far a pointer may travel and still have been put down, not dragged. */
328
+ const TAP_SLOP_PX = 6;
329
+
330
+ /**
331
+ * Drops what the session has been told to leave alone. Read against the text as
332
+ * it stands now, so a word ignored at one place in the document loses its marks
333
+ * everywhere it appears.
334
+ */
335
+ const forgetIgnored = (
336
+ editor: LexicalEditor,
337
+ found: Map<string, readonly Finding[]>,
338
+ ignored: ReadonlySet<string>,
339
+ ): void => {
340
+ editor.read(() => {
341
+ for (const [key, findings] of found) {
342
+ const node = $getNodeByKey(key);
343
+ if (!$isTextNode(node)) continue;
344
+ const text = node.getTextContent();
345
+ const kept = findings.filter(
346
+ (finding) =>
347
+ !ignored.has(normaliseWord(text.slice(finding.start, finding.end))),
348
+ );
349
+ if (kept.length === findings.length) continue;
350
+ if (kept.length === 0) {
351
+ found.delete(key);
352
+ continue;
353
+ }
354
+ found.set(key, kept);
355
+ }
356
+ });
357
+ };
358
+
240
359
  /**
241
- * Marks, and nothing else. Only the leaves an edit touched are sent, a quarter
242
- * of a second after the typing stops, so a word is not called wrong while it is
243
- * still being written — and the word the caret sits in is never marked at all.
244
- * An answer carrying a revision the document has moved past is dropped.
360
+ * Marks and their corrections. Only the leaves an edit touched are sent, a
361
+ * quarter of a second after the typing stops, so a word is not called wrong
362
+ * while it is still being written — and the word the caret sits in is never
363
+ * marked at all. An answer carrying a revision the document has moved past is
364
+ * dropped.
245
365
  *
246
366
  * The marks live in the highlight registry, keyed by node key and rebuilt after
247
367
  * every reconciliation. Nothing enters the document, so history, the outgoing
248
368
  * HTML and the Markdown never see one.
369
+ *
370
+ * A click, a tap or the chord on a marked word opens the correction menu over
371
+ * the findings already held here; the suggestions themselves are asked for one
372
+ * word at a time, when the menu opens. The right button is left to the browser.
249
373
  */
250
374
  const SpellcheckPlugin = ({
251
375
  language,
252
376
  options,
377
+ hostRef,
253
378
  onReady,
254
379
  }: {
255
380
  language: string;
256
381
  options: SpellcheckOptions;
382
+ hostRef: RefObject<HTMLDivElement | null>;
257
383
  onReady: (ready: boolean) => void;
258
384
  }) => {
259
385
  const [editor] = useLexicalComposerContext();
260
386
  const settings = useRef(options);
261
387
  const report = useRef(onReady);
388
+ const found = useRef(new Map<string, readonly Finding[]>());
389
+ // Session-scoped by construction: the set belongs to this mount and goes
390
+ // with it, and nothing writes it anywhere (#707, decision 10).
391
+ const ignored = useRef(new Set<string>());
392
+ const checker = useRef<SpellProvider | null>(null);
393
+ const repaint = useRef<() => void>(() => {});
394
+ const asked = useRef(0);
395
+ const [target, setTarget] = useState<CorrectionTarget | null>(null);
396
+ const [suggestions, setSuggestions] = useState<readonly string[] | null>(
397
+ null,
398
+ );
399
+ const [failure, setFailure] = useState<string | null>(null);
262
400
 
263
401
  useEffect(() => {
264
402
  settings.current = options;
@@ -268,56 +406,52 @@ const SpellcheckPlugin = ({
268
406
  useEffect(() => {
269
407
  if (!marksSupported()) return;
270
408
  const painter = Symbol(SPELLCHECK_HIGHLIGHT);
271
- const found = new Map<string, readonly Finding[]>();
409
+ const findings = found.current;
272
410
  const touched = new Set<string>();
273
- let provider: SpellProvider | null = null;
274
411
  let unsubscribe: (() => void) | undefined;
275
412
  let idle: ReturnType<typeof setTimeout> | undefined;
276
413
  let live = true;
277
414
  let state: ProviderStatus["state"] = "opening";
278
415
  let revision = 0;
279
- let asked = 0;
280
-
281
- const repaint = (): void => {
416
+ let passes = 0;
417
+ let pressed: { x: number; y: number } | null = null;
418
+ // The caret withholds the mark of the word it sits in, so a word is not
419
+ // called wrong while it is still being written. A caret a pointer put
420
+ // down is reading rather than writing: a click on a squiggle is how the
421
+ // corrections open, and it must not take the squiggle with it. The next
422
+ // key is the writer back at the text, and the withholding with them.
423
+ let pointed = false;
424
+
425
+ const paint = (): void => {
282
426
  const ranges: Range[] = [];
283
427
  editor.read(() => {
284
428
  const selection = $getSelection();
285
429
  const caret =
286
- $isRangeSelection(selection) && selection.isCollapsed()
430
+ !pointed && $isRangeSelection(selection) && selection.isCollapsed()
287
431
  ? selection.anchor
288
432
  : null;
289
- for (const [key, findings] of found) {
433
+ for (const [key, spanFindings] of findings) {
290
434
  const node = $getNodeByKey(key);
291
435
  if (!$isTextNode(node)) {
292
- found.delete(key);
436
+ findings.delete(key);
293
437
  continue;
294
438
  }
295
- const element = editor.getElementByKey(key);
296
- const text = element ? leafCharacters(element) : null;
297
- if (!text) continue;
298
- const length = text.textContent?.length ?? 0;
299
- for (const finding of findings) {
300
- if (finding.end > length) continue;
301
- if (
302
- caret?.key === key &&
303
- caret.offset > finding.start &&
304
- caret.offset <= finding.end
305
- )
439
+ for (const finding of spanFindings) {
440
+ if (caret?.key === key && coversCaret(finding, caret.offset))
306
441
  continue;
307
- const range = text.ownerDocument?.createRange();
308
- if (!range) continue;
309
- range.setStart(text, finding.start);
310
- range.setEnd(text, finding.end);
311
- ranges.push(range);
442
+ const range = leafRange(editor, key, finding.start, finding.end);
443
+ if (range) ranges.push(range);
312
444
  }
313
445
  }
314
446
  });
315
447
  paintMarks(painter, ranges);
316
448
  };
449
+ repaint.current = paint;
317
450
 
318
451
  const check = (): void => {
319
452
  // Anything queued while the provider is not answering stays queued: the
320
453
  // next status that says ready sends it.
454
+ const provider = checker.current;
321
455
  if (!provider || state !== "ready") return;
322
456
  const keys = [...touched];
323
457
  touched.clear();
@@ -326,20 +460,20 @@ const SpellcheckPlugin = ({
326
460
  for (const key of keys) {
327
461
  const node = $getNodeByKey(key);
328
462
  if (!$isTextNode(node)) {
329
- found.delete(key);
463
+ findings.delete(key);
330
464
  continue;
331
465
  }
332
466
  spans.push({ spanId: key, text: node.getTextContent() });
333
467
  }
334
468
  });
335
469
  if (spans.length === 0) {
336
- repaint();
470
+ paint();
337
471
  return;
338
472
  }
339
- asked += 1;
473
+ passes += 1;
340
474
  const sent = revision;
341
475
  provider
342
- .check({ requestId: `${asked}`, language, revision: sent, spans })
476
+ .check({ requestId: `${passes}`, language, revision: sent, spans })
343
477
  .then((response) => {
344
478
  if (!live) return;
345
479
  // The text moved while this was in flight, so the offsets are
@@ -351,8 +485,13 @@ const SpellcheckPlugin = ({
351
485
  schedule();
352
486
  return;
353
487
  }
488
+ const texts = new Map(spans.map((span) => [span.spanId, span.text]));
354
489
  const grouped = new Map<string, Finding[]>();
355
490
  for (const finding of response.findings) {
491
+ const text = texts.get(finding.spanId);
492
+ if (!text) continue;
493
+ const word = text.slice(finding.start, finding.end);
494
+ if (ignored.current.has(normaliseWord(word))) continue;
356
495
  const list = grouped.get(finding.spanId);
357
496
  if (list) {
358
497
  list.push(finding);
@@ -360,9 +499,10 @@ const SpellcheckPlugin = ({
360
499
  }
361
500
  grouped.set(finding.spanId, [finding]);
362
501
  }
363
- for (const span of spans) found.delete(span.spanId);
364
- for (const [key, findings] of grouped) found.set(key, findings);
365
- repaint();
502
+ for (const span of spans) findings.delete(span.spanId);
503
+ for (const [key, spanFindings] of grouped)
504
+ findings.set(key, spanFindings);
505
+ paint();
366
506
  });
367
507
  };
368
508
 
@@ -371,72 +511,326 @@ const SpellcheckPlugin = ({
371
511
  idle = setTimeout(check, SPELLCHECK_IDLE_MS);
372
512
  };
373
513
 
514
+ const openAt = (
515
+ key: string,
516
+ offset: number,
517
+ covers: (finding: Finding, offset: number) => boolean,
518
+ ): boolean => {
519
+ const hit = (findings.get(key) ?? []).find((finding) =>
520
+ covers(finding, offset),
521
+ );
522
+ // The editor still has to be mounted for a click on its own text to mean
523
+ // anything, even though the menu itself no longer anchors to it.
524
+ if (!hit || !hostRef.current) return false;
525
+ const range = leafRange(editor, key, hit.start, hit.end);
526
+ const word = editor.read(() => {
527
+ const node = $getNodeByKey(key);
528
+ if (!$isTextNode(node)) return "";
529
+ return node.getTextContent().slice(hit.start, hit.end);
530
+ });
531
+ if (!range || !word) return false;
532
+ const box = range.getBoundingClientRect();
533
+ setTarget({
534
+ key,
535
+ start: hit.start,
536
+ end: hit.end,
537
+ word,
538
+ left: box.left,
539
+ right: box.right,
540
+ top: box.top,
541
+ bottom: box.bottom,
542
+ });
543
+ return true;
544
+ };
545
+
546
+ const openAtPoint = (x: number, y: number): boolean => {
547
+ const owner = editor.getRootElement()?.ownerDocument;
548
+ if (!owner) return false;
549
+ const character = characterAt(owner, x, y);
550
+ if (!character) return false;
551
+ const key = editor.read(() => {
552
+ const node = $getNearestNodeFromDOMNode(character.node);
553
+ return $isTextNode(node) ? node.getKey() : null;
554
+ });
555
+ if (!key) return false;
556
+ return openAt(key, character.offset, coversCharacter);
557
+ };
558
+
559
+ const onPointerDown = (event: PointerEvent) => {
560
+ pointed = true;
561
+ // The right button belongs to the browser's own menu, and a middle
562
+ // click is a paste on the platforms that have one.
563
+ pressed =
564
+ event.button === 0 ? { x: event.clientX, y: event.clientY } : null;
565
+ };
566
+
567
+ /**
568
+ * A pointer put down on a marked word and lifted where it landed, whether
569
+ * it was a finger or a mouse. A press that travelled was dragging out a
570
+ * selection, and a menu over the top of what it just selected is the one
571
+ * thing it must not get.
572
+ */
573
+ const onPointerUp = (event: PointerEvent) => {
574
+ const from = pressed;
575
+ pressed = null;
576
+ if (!from) return;
577
+ if (
578
+ Math.abs(event.clientX - from.x) > TAP_SLOP_PX ||
579
+ Math.abs(event.clientY - from.y) > TAP_SLOP_PX
580
+ )
581
+ return;
582
+ const selection = editor.getRootElement()?.ownerDocument.getSelection();
583
+ if (selection && !selection.isCollapsed) return;
584
+ openAtPoint(event.clientX, event.clientY);
585
+ };
586
+
587
+ const onPointerCancel = () => {
588
+ pressed = null;
589
+ };
590
+
591
+ /**
592
+ * Whatever the key turns out to do — walk the caret, or write a character
593
+ * — it is the writer taking the text back, so the word under the caret
594
+ * goes quiet again. The repaint is here rather than left to the edit: a
595
+ * caret walked across a marked word makes no edit at all.
596
+ */
597
+ const onKeyDown = () => {
598
+ if (!pointed) return;
599
+ pointed = false;
600
+ paint();
601
+ };
602
+
603
+ const unbindRoot = editor.registerRootListener((next, previous) => {
604
+ previous?.removeEventListener("pointerdown", onPointerDown);
605
+ previous?.removeEventListener("pointerup", onPointerUp);
606
+ previous?.removeEventListener("pointercancel", onPointerCancel);
607
+ previous?.removeEventListener("keydown", onKeyDown);
608
+ next?.addEventListener("pointerdown", onPointerDown);
609
+ next?.addEventListener("pointerup", onPointerUp);
610
+ next?.addEventListener("pointercancel", onPointerCancel);
611
+ next?.addEventListener("keydown", onKeyDown);
612
+ });
613
+
614
+ // The keyboard's way in. The word under the caret is deliberately
615
+ // unmarked, so this is the one path that has to read it anyway.
616
+ const unbindChord = editor.registerCommand(
617
+ KEY_DOWN_COMMAND,
618
+ (event) => {
619
+ if (!(event.metaKey || event.ctrlKey) || event.key !== ".")
620
+ return false;
621
+ const at = editor.read(() => {
622
+ const selection = $getSelection();
623
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
624
+ return null;
625
+ return {
626
+ key: selection.anchor.key,
627
+ offset: selection.anchor.offset,
628
+ };
629
+ });
630
+ if (!at || !openAt(at.key, at.offset, coversCaret)) return false;
631
+ event.preventDefault();
632
+ return true;
633
+ },
634
+ COMMAND_PRIORITY_LOW,
635
+ );
636
+
374
637
  const unregister = editor.registerUpdateListener(({ dirtyLeaves }) => {
375
638
  if (dirtyLeaves.size === 0) {
376
- repaint();
639
+ paint();
377
640
  return;
378
641
  }
379
642
  revision += 1;
643
+ // An edit is writing whatever put the caret there, including a paste
644
+ // or a correction, neither of which arrives on a key.
645
+ pointed = false;
646
+ // The menu is about a word at an offset, and both have just moved.
647
+ setTarget(null);
380
648
  // Characters moved under the findings this leaf carries, so they are
381
649
  // answers about text that is no longer there. They go now rather than
382
650
  // sitting over the wrong letters until the next answer arrives.
383
651
  for (const key of dirtyLeaves) {
384
- found.delete(key);
652
+ findings.delete(key);
385
653
  touched.add(key);
386
654
  }
387
- repaint();
655
+ paint();
388
656
  schedule();
389
657
  });
390
658
 
391
- settings.current.provider(language).then((opened) => {
392
- if (!live) {
393
- opened?.close();
394
- return;
395
- }
396
- provider = opened;
397
- if (!opened) {
398
- settings.current.onStatus?.({ state: "unavailable", language });
399
- report.current(false);
400
- return;
401
- }
402
- unsubscribe = opened.onStatus((status) => {
403
- state = status.state;
404
- settings.current.onStatus?.(status);
405
- const ready = status.state === "ready";
406
- report.current(ready);
407
- if (ready) {
408
- schedule();
659
+ const stopped = (detail: string): void => {
660
+ settings.current.onStatus?.({
661
+ state: "failed",
662
+ language,
663
+ reason: "worker",
664
+ detail,
665
+ });
666
+ report.current(false);
667
+ };
668
+
669
+ settings.current
670
+ .provider(language)
671
+ .then((opened) => {
672
+ if (!live) {
673
+ opened?.close();
409
674
  return;
410
675
  }
411
- // Checking stopped, so the browser's own is back. Its squiggles are
412
- // the only ones on screen from here.
413
- found.clear();
414
- touched.clear();
415
- repaint();
416
- });
417
- // The document the editor opened on was reconciled before this
418
- // listener existed, so its leaves are checked here rather than waiting
419
- // for an edit that may never come.
420
- editor.read(() => {
421
- for (const node of $getRoot().getAllTextNodes())
422
- touched.add(node.getKey());
676
+ checker.current = opened;
677
+ if (!opened) {
678
+ settings.current.onStatus?.({ state: "unavailable", language });
679
+ report.current(false);
680
+ return;
681
+ }
682
+ unsubscribe = opened.onStatus((status) => {
683
+ state = status.state;
684
+ settings.current.onStatus?.(status);
685
+ const ready = status.state === "ready";
686
+ report.current(ready);
687
+ if (ready) {
688
+ schedule();
689
+ return;
690
+ }
691
+ // Checking stopped, so the browser's own is back. Its squiggles are
692
+ // the only ones on screen from here.
693
+ findings.clear();
694
+ touched.clear();
695
+ setTarget(null);
696
+ paint();
697
+ });
698
+ // The document the editor opened on was reconciled before this
699
+ // listener existed, so its leaves are checked here rather than waiting
700
+ // for an edit that may never come.
701
+ editor.read(() => {
702
+ for (const node of $getRoot().getAllTextNodes())
703
+ touched.add(node.getKey());
704
+ });
705
+ schedule();
706
+ })
707
+ .catch((error: unknown) => {
708
+ if (!live) return;
709
+ stopped(error instanceof Error ? error.message : String(error));
423
710
  });
424
- schedule();
425
- });
426
711
 
427
712
  return () => {
428
713
  live = false;
429
714
  clearTimeout(idle);
430
715
  unregister();
716
+ unbindRoot();
717
+ unbindChord();
431
718
  unsubscribe?.();
432
- provider?.close();
433
- provider = null;
719
+ checker.current?.close();
720
+ checker.current = null;
721
+ findings.clear();
722
+ setTarget(null);
434
723
  report.current(false);
435
724
  paintMarks(painter, []);
436
725
  };
437
- }, [editor, language]);
726
+ }, [editor, language, hostRef]);
438
727
 
439
- return null;
728
+ useEffect(() => {
729
+ if (!target) return;
730
+ setSuggestions(null);
731
+ setFailure(null);
732
+ const provider = checker.current;
733
+ if (!provider) {
734
+ setFailure("the checker is not running");
735
+ return;
736
+ }
737
+ let live = true;
738
+ asked.current += 1;
739
+ provider
740
+ .suggest({
741
+ requestId: `suggest-${asked.current}`,
742
+ language,
743
+ word: target.word,
744
+ })
745
+ .then((answer) => {
746
+ if (live) setSuggestions(answer.suggestions.slice(0, SUGGESTION_LIMIT));
747
+ })
748
+ .catch((error: unknown) => {
749
+ if (!live) return;
750
+ setFailure(error instanceof Error ? error.message : String(error));
751
+ });
752
+ return () => {
753
+ live = false;
754
+ };
755
+ }, [target, language]);
756
+
757
+ const dismiss = useCallback(
758
+ (returnFocus: boolean) => {
759
+ setTarget(null);
760
+ // `editor.focus()` alone only moves the DOM caret when Lexical's own
761
+ // selection model already matches the live DOM selection; the caret
762
+ // the click left behind rarely does, and setting a Selection range
763
+ // inside a contenteditable is not itself what moves focus there. The
764
+ // explicit call is what actually lands the caret back in the message.
765
+ if (returnFocus) {
766
+ editor.focus();
767
+ editor.getRootElement()?.focus();
768
+ }
769
+ },
770
+ [editor],
771
+ );
772
+
773
+ const forget = useCallback(
774
+ (word: string) => {
775
+ ignored.current.add(normaliseWord(word));
776
+ forgetIgnored(editor, found.current, ignored.current);
777
+ repaint.current();
778
+ dismiss(true);
779
+ },
780
+ [editor, dismiss],
781
+ );
782
+
783
+ if (!target) return null;
784
+
785
+ const replace = (suggestion: string) => {
786
+ // One entry, so one undo puts the misspelling back (#707, decision 9).
787
+ editor.update(
788
+ () => {
789
+ const node = $getNodeByKey(target.key);
790
+ if (!$isTextNode(node)) return;
791
+ // The offsets were read when the menu opened. An edit that landed
792
+ // between then and the click has moved them, and writing there would
793
+ // put the correction through the middle of a different word.
794
+ if (
795
+ node.getTextContent().slice(target.start, target.end) !== target.word
796
+ )
797
+ return;
798
+ const written = node.spliceText(
799
+ target.start,
800
+ target.end - target.start,
801
+ suggestion,
802
+ );
803
+ const caret = target.start + suggestion.length;
804
+ written.select(caret, caret);
805
+ },
806
+ { tag: HISTORY_PUSH_TAG },
807
+ );
808
+ dismiss(true);
809
+ };
810
+
811
+ const addWord = () => {
812
+ settings.current.onAddWord?.(target.word);
813
+ forget(target.word);
814
+ };
815
+
816
+ return (
817
+ <RichTextCorrectionMenu
818
+ word={target.word}
819
+ suggestions={suggestions}
820
+ failure={failure}
821
+ anchor={{
822
+ left: target.left,
823
+ right: target.right,
824
+ top: target.top,
825
+ bottom: target.bottom,
826
+ }}
827
+ language={language}
828
+ onReplace={replace}
829
+ onIgnore={() => forget(target.word)}
830
+ onAddWord={options.onAddWord ? addWord : undefined}
831
+ onDismiss={dismiss}
832
+ />
833
+ );
440
834
  };
441
835
 
442
836
  const AutoFocus = ({ caret }: { caret?: ComposeCaret }) => {
@@ -488,6 +882,7 @@ export const RichTextEditor = ({
488
882
  spellcheck,
489
883
  }: RichTextEditorProps) => {
490
884
  const [checkedHere, setCheckedHere] = useState(false);
885
+ const bodyRef = useRef<HTMLDivElement>(null);
491
886
 
492
887
  return (
493
888
  <LexicalComposer
@@ -506,7 +901,7 @@ export const RichTextEditor = ({
506
901
  a click there reaches it instead of an unfocusable parent. */}
507
902
  <div className="flex shrink-0 grow flex-col">
508
903
  <RichTextToolbar trailing={trailing} />
509
- <div className="relative flex shrink-0 grow flex-col">
904
+ <div ref={bodyRef} className="relative flex shrink-0 grow flex-col">
510
905
  <RichTextPlugin
511
906
  contentEditable={
512
907
  <ContentEditable
@@ -538,6 +933,17 @@ export const RichTextEditor = ({
538
933
  }
539
934
  ErrorBoundary={LexicalErrorBoundary}
540
935
  />
936
+ {/* Inside the body's own box: the popover is placed against the
937
+ word's rect, and the sheet rises from the bottom of the writing
938
+ surface rather than the bottom of whatever page holds it. */}
939
+ {spellcheck && lang ? (
940
+ <SpellcheckPlugin
941
+ language={lang}
942
+ options={spellcheck}
943
+ hostRef={bodyRef}
944
+ onReady={setCheckedHere}
945
+ />
946
+ ) : null}
541
947
  </div>
542
948
  </div>
543
949
  <HistoryPlugin />
@@ -547,13 +953,6 @@ export const RichTextEditor = ({
547
953
  <PastePlugin />
548
954
  <AutoFocus caret={initialCaret} />
549
955
  {onChange && <ChangePlugin onChange={onChange} />}
550
- {spellcheck && lang ? (
551
- <SpellcheckPlugin
552
- language={lang}
553
- options={spellcheck}
554
- onReady={setCheckedHere}
555
- />
556
- ) : null}
557
956
  </LexicalComposer>
558
957
  );
559
958
  };