@remit/ui 0.0.120 → 0.0.121
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 +4 -1
- package/src/components/compose-body.spellcheck.test.ts +5 -2
- package/src/components/rich-text-editor.stories.tsx +190 -16
- package/src/components/rich-text-editor.tsx +72 -29
- package/src/components/rich-text-spellcheck-double.ts +65 -0
- package/src/components/rich-text-spellcheck-engine.ts +267 -0
- package/src/components/rich-text-spellcheck-languages.ts +62 -0
- package/src/components/rich-text-spellcheck-menu.test.ts +6 -7
- package/src/components/rich-text-spellcheck-notice.tsx +168 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +296 -197
- package/src/components/rich-text-spellcheck-provider.ts +64 -10
- package/src/components/rich-text-spellcheck-words.ts +16 -354
- package/src/components/rich-text-spellcheck-worker-provider.ts +17 -7
- package/src/components/rich-text-spellcheck-worker.test.ts +253 -0
- package/src/components/rich-text-spellcheck-worker.ts +63 -15
- package/src/components/rich-text-spellcheck.test.ts +123 -9
- package/src/components/rich-text-spellcheck.ts +31 -2
- package/src/rich-text.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.121",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src"
|
|
@@ -51,6 +51,9 @@
|
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@storybook/react": "^9",
|
|
53
53
|
"@types/jsdom": "^28.0.3",
|
|
54
|
+
"dictionary-en": "4.0.0",
|
|
55
|
+
"dictionary-en-gb": "3.0.0",
|
|
56
|
+
"dictionary-nl": "2.0.0",
|
|
54
57
|
"jsdom": "^29.1.1",
|
|
55
58
|
"react": "^19",
|
|
56
59
|
"react-dom": "^19",
|
|
@@ -348,7 +348,10 @@ describe("the composer's spellchecker", () => {
|
|
|
348
348
|
|
|
349
349
|
assert.deepEqual(
|
|
350
350
|
statuses,
|
|
351
|
-
[
|
|
351
|
+
[
|
|
352
|
+
{ state: "opening", language: "en", bytesLoaded: 0, bytesTotal: 0 },
|
|
353
|
+
{ state: "failed", language: "en", reason: "worker", detail: "boom" },
|
|
354
|
+
],
|
|
352
355
|
"the composer hears why checking is not happening, in the shape it reports",
|
|
353
356
|
);
|
|
354
357
|
assert.equal(
|
|
@@ -376,7 +379,7 @@ describe("the composer's spellchecker", () => {
|
|
|
376
379
|
|
|
377
380
|
assert.deepEqual(
|
|
378
381
|
statuses.map((status) => status.state),
|
|
379
|
-
["ready", "failed"],
|
|
382
|
+
["opening", "ready", "failed"],
|
|
380
383
|
"the stop is reported, not swallowed",
|
|
381
384
|
);
|
|
382
385
|
assert.deepEqual(marked(), [], "no mark outlives the checker that drew it");
|
|
@@ -13,10 +13,8 @@ import type {
|
|
|
13
13
|
Finding,
|
|
14
14
|
SpellcheckOptions,
|
|
15
15
|
} from "./rich-text-spellcheck.js";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
findMisspellings,
|
|
19
|
-
} from "./rich-text-spellcheck-words.js";
|
|
16
|
+
import { stubKnows } from "./rich-text-spellcheck-double.js";
|
|
17
|
+
import { findMisspellings } from "./rich-text-spellcheck-words.js";
|
|
20
18
|
import { openSpellcheckWorker } from "./rich-text-spellcheck-worker-provider.js";
|
|
21
19
|
|
|
22
20
|
/**
|
|
@@ -255,14 +253,78 @@ export const NarrowToolbar: Story = {
|
|
|
255
253
|
|
|
256
254
|
const MISSPELT = "Ths report is redy today, and the notes are attachd.";
|
|
257
255
|
|
|
256
|
+
const DUTCH = "De vergaderingg gaat over de begrooting.";
|
|
257
|
+
|
|
258
258
|
/**
|
|
259
|
-
* A real module worker does the checking, over
|
|
260
|
-
*
|
|
261
|
-
* words came from.
|
|
262
|
-
* list of English words.
|
|
259
|
+
* A real module worker does the checking, over Hunspell and the dictionary this
|
|
260
|
+
* build staged: the component is handed a provider and never learns where the
|
|
261
|
+
* words came from.
|
|
263
262
|
*/
|
|
264
263
|
const workerSpellcheck: SpellcheckOptions = { provider: openSpellcheckWorker };
|
|
265
264
|
|
|
265
|
+
/** A download that never finishes, which on a bad link is most of a minute. */
|
|
266
|
+
const stalledSpellcheck = (bytesTotal: number): SpellcheckOptions => ({
|
|
267
|
+
provider: async (language) => ({
|
|
268
|
+
language,
|
|
269
|
+
onStatus: (listener) => {
|
|
270
|
+
listener({ state: "opening", language, bytesLoaded: 0, bytesTotal });
|
|
271
|
+
const timer = setInterval(
|
|
272
|
+
() =>
|
|
273
|
+
listener({
|
|
274
|
+
state: "opening",
|
|
275
|
+
language,
|
|
276
|
+
bytesLoaded: Math.round(bytesTotal / 4),
|
|
277
|
+
bytesTotal,
|
|
278
|
+
}),
|
|
279
|
+
500,
|
|
280
|
+
);
|
|
281
|
+
return () => clearInterval(timer);
|
|
282
|
+
},
|
|
283
|
+
check: (request) =>
|
|
284
|
+
Promise.resolve({
|
|
285
|
+
requestId: request.requestId,
|
|
286
|
+
revision: request.revision,
|
|
287
|
+
findings: [],
|
|
288
|
+
}),
|
|
289
|
+
suggest: (request) =>
|
|
290
|
+
Promise.resolve({
|
|
291
|
+
requestId: request.requestId,
|
|
292
|
+
word: request.word,
|
|
293
|
+
suggestions: [],
|
|
294
|
+
}),
|
|
295
|
+
close: () => {},
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
/** The dictionary answered, and what it answered was 503. */
|
|
300
|
+
const refusedSpellcheck: SpellcheckOptions = {
|
|
301
|
+
provider: async (language) => ({
|
|
302
|
+
language,
|
|
303
|
+
onStatus: (listener) => {
|
|
304
|
+
listener({
|
|
305
|
+
state: "failed",
|
|
306
|
+
language,
|
|
307
|
+
reason: "download",
|
|
308
|
+
detail: `/spellcheck/dictionaries/${language}/index.dic answered 503`,
|
|
309
|
+
});
|
|
310
|
+
return () => {};
|
|
311
|
+
},
|
|
312
|
+
check: (request) =>
|
|
313
|
+
Promise.resolve({
|
|
314
|
+
requestId: request.requestId,
|
|
315
|
+
revision: request.revision,
|
|
316
|
+
findings: [],
|
|
317
|
+
}),
|
|
318
|
+
suggest: (request) =>
|
|
319
|
+
Promise.resolve({
|
|
320
|
+
requestId: request.requestId,
|
|
321
|
+
word: request.word,
|
|
322
|
+
suggestions: [],
|
|
323
|
+
}),
|
|
324
|
+
close: () => {},
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
|
|
266
328
|
/**
|
|
267
329
|
* The same findings, answered against the revision before the one asked for —
|
|
268
330
|
* what a slow engine looks like when the text has already moved on.
|
|
@@ -278,12 +340,11 @@ const staleSpellcheck: SpellcheckOptions = {
|
|
|
278
340
|
},
|
|
279
341
|
check: (request: CheckRequest) => {
|
|
280
342
|
staleAnswers.push(request.requestId);
|
|
281
|
-
const words = dictionaryFor(request.language) ?? new Set<string>();
|
|
282
343
|
return Promise.resolve({
|
|
283
344
|
requestId: request.requestId,
|
|
284
345
|
revision: request.revision - 1,
|
|
285
346
|
findings: request.spans.flatMap((span) =>
|
|
286
|
-
findMisspellings(span.text,
|
|
347
|
+
findMisspellings(span.text, stubKnows).map(
|
|
287
348
|
(range): Finding => ({
|
|
288
349
|
spanId: span.spanId,
|
|
289
350
|
start: range.start,
|
|
@@ -377,6 +438,115 @@ export const SpellcheckMarks: Story = {
|
|
|
377
438
|
},
|
|
378
439
|
};
|
|
379
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The same worker on a Dutch message, against OpenTaal's 180,745 entries. Two
|
|
443
|
+
* Dutch misspellings carry a squiggle and every other Dutch word does not —
|
|
444
|
+
* which is the whole point, since a Dutch message in an English-configured
|
|
445
|
+
* Chrome gets a squiggle under every word.
|
|
446
|
+
*/
|
|
447
|
+
export const SpellcheckDutch: Story = {
|
|
448
|
+
name: "Spellcheck in Dutch (real dictionary)",
|
|
449
|
+
args: {
|
|
450
|
+
initialHtml: `<p>${DUTCH}</p>`,
|
|
451
|
+
lang: "nl",
|
|
452
|
+
spellcheck: workerSpellcheck,
|
|
453
|
+
},
|
|
454
|
+
play: async ({ canvasElement }) => {
|
|
455
|
+
const editable = writingSurface(canvasElement);
|
|
456
|
+
await waitFor(
|
|
457
|
+
() =>
|
|
458
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
459
|
+
[3, 15],
|
|
460
|
+
[29, 39],
|
|
461
|
+
]),
|
|
462
|
+
{ timeout: 15000 },
|
|
463
|
+
);
|
|
464
|
+
await expect(editable.getAttribute("spellcheck")).toBe("false");
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Fourteen seconds of a composer that looks like it has no opinion about
|
|
470
|
+
* spelling is the failure this must not have. The browser keeps checking, the
|
|
471
|
+
* banner names the language and its size once the wait is long enough to be
|
|
472
|
+
* worth mentioning, and cancelling leaves the writer with a way back.
|
|
473
|
+
*/
|
|
474
|
+
export const SpellcheckSlowDownload: Story = {
|
|
475
|
+
name: "Spellcheck while the dictionary downloads",
|
|
476
|
+
args: {
|
|
477
|
+
initialHtml: `<p>${DUTCH}</p>`,
|
|
478
|
+
lang: "nl",
|
|
479
|
+
spellcheck: stalledSpellcheck(702_464),
|
|
480
|
+
},
|
|
481
|
+
play: async ({ canvasElement }) => {
|
|
482
|
+
const editable = writingSurface(canvasElement);
|
|
483
|
+
await expect(editable.getAttribute("spellcheck")).toBe("true");
|
|
484
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
485
|
+
|
|
486
|
+
const notice = await waitFor(
|
|
487
|
+
() => {
|
|
488
|
+
const row = canvasElement.querySelector<HTMLElement>(
|
|
489
|
+
"[data-testid=spellcheck-notice]",
|
|
490
|
+
);
|
|
491
|
+
expect(row).not.toBeNull();
|
|
492
|
+
return row as HTMLElement;
|
|
493
|
+
},
|
|
494
|
+
{ timeout: 12000 },
|
|
495
|
+
);
|
|
496
|
+
await expect(notice.textContent).toContain("Nederlands");
|
|
497
|
+
await expect(notice.textContent).toContain("686 KB");
|
|
498
|
+
|
|
499
|
+
const cancel = notice.querySelector<HTMLElement>(
|
|
500
|
+
"[data-testid=spellcheck-cancel]",
|
|
501
|
+
);
|
|
502
|
+
if (!cancel)
|
|
503
|
+
throw new Error("a download nobody can stop is not a download");
|
|
504
|
+
await userEvent.click(cancel);
|
|
505
|
+
|
|
506
|
+
await waitFor(() =>
|
|
507
|
+
expect(
|
|
508
|
+
canvasElement.querySelector("[data-testid=spellcheck-retry]"),
|
|
509
|
+
).not.toBeNull(),
|
|
510
|
+
);
|
|
511
|
+
await expect(editable.getAttribute("spellcheck")).toBe("true");
|
|
512
|
+
},
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The dead squiggle-free editor is the worst outcome, so a failure says which
|
|
517
|
+
* file, what it answered, and where to report it — and hands the spelling back
|
|
518
|
+
* to the browser rather than leaving nobody checking.
|
|
519
|
+
*/
|
|
520
|
+
export const SpellcheckDownloadFailed: Story = {
|
|
521
|
+
name: "Spellcheck when the dictionary fails to load",
|
|
522
|
+
args: {
|
|
523
|
+
initialHtml: `<p>${DUTCH}</p>`,
|
|
524
|
+
lang: "nl",
|
|
525
|
+
spellcheck: refusedSpellcheck,
|
|
526
|
+
},
|
|
527
|
+
play: async ({ canvasElement }) => {
|
|
528
|
+
const editable = writingSurface(canvasElement);
|
|
529
|
+
const detail = await waitFor(() => {
|
|
530
|
+
const row = canvasElement.querySelector<HTMLElement>(
|
|
531
|
+
"[data-testid=spellcheck-detail]",
|
|
532
|
+
);
|
|
533
|
+
expect(row).not.toBeNull();
|
|
534
|
+
return row as HTMLElement;
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await expect(detail.textContent).toContain("answered 503");
|
|
538
|
+
await expect(
|
|
539
|
+
canvasElement.querySelector("[data-testid=spellcheck-retry]"),
|
|
540
|
+
).not.toBeNull();
|
|
541
|
+
const report = canvasElement.querySelector<HTMLAnchorElement>(
|
|
542
|
+
"[data-testid=spellcheck-report]",
|
|
543
|
+
);
|
|
544
|
+
await expect(report?.href).toContain("issues/new?title=");
|
|
545
|
+
await expect(editable.getAttribute("spellcheck")).toBe("true");
|
|
546
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
|
|
380
550
|
/** A language the build carries no dictionary for: the browser keeps checking. */
|
|
381
551
|
export const SpellcheckWithoutDictionary: Story = {
|
|
382
552
|
name: "Spellcheck with no dictionary for the language",
|
|
@@ -562,7 +732,7 @@ export const SpellcheckSuggestions: Story = {
|
|
|
562
732
|
timeout: 5000,
|
|
563
733
|
});
|
|
564
734
|
|
|
565
|
-
clickOn(editable, "
|
|
735
|
+
clickOn(editable, "attachd");
|
|
566
736
|
await waitFor(() =>
|
|
567
737
|
expect(spellNode(canvasElement, "[data-testid=spell-menu]")).toBeTruthy(),
|
|
568
738
|
);
|
|
@@ -570,22 +740,26 @@ export const SpellcheckSuggestions: Story = {
|
|
|
570
740
|
menuRows(canvasElement, "spell-suggestion-skeleton"),
|
|
571
741
|
).toHaveLength(3);
|
|
572
742
|
|
|
743
|
+
// What Hunspell offers over SCOWL, in its own order. The word the writer
|
|
744
|
+
// meant is in it, which is the whole reason for a real dictionary.
|
|
573
745
|
await waitFor(
|
|
574
746
|
() =>
|
|
575
747
|
expect(
|
|
576
748
|
menuRows(canvasElement, "spell-suggestion").map(
|
|
577
749
|
(row) => row.textContent,
|
|
578
750
|
),
|
|
579
|
-
).toEqual(["
|
|
751
|
+
).toEqual(["attach", "attached", "attache", "attach d"]),
|
|
580
752
|
{ timeout: 5000 },
|
|
581
753
|
);
|
|
582
754
|
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
755
|
+
const meant = menuRows(canvasElement, "spell-suggestion").find(
|
|
756
|
+
(row) => row.textContent === "attached",
|
|
757
|
+
);
|
|
758
|
+
if (!meant) throw new Error("no suggestion to pick");
|
|
759
|
+
await userEvent.click(meant);
|
|
586
760
|
|
|
587
761
|
await expect(editable.textContent).toBe(
|
|
588
|
-
MISSPELT.replace("
|
|
762
|
+
MISSPELT.replace("attachd", "attached"),
|
|
589
763
|
);
|
|
590
764
|
await waitFor(() => expect(spellMarks(editable).length).toBe(2), {
|
|
591
765
|
timeout: 5000,
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
SpellcheckOptions,
|
|
41
41
|
SpellProvider,
|
|
42
42
|
} from "./rich-text-spellcheck.js";
|
|
43
|
+
import { RichTextSpellcheckNotice } from "./rich-text-spellcheck-notice.js";
|
|
43
44
|
import {
|
|
44
45
|
normaliseWord,
|
|
45
46
|
SUGGESTION_LIMIT,
|
|
@@ -397,6 +398,19 @@ const SpellcheckPlugin = ({
|
|
|
397
398
|
null,
|
|
398
399
|
);
|
|
399
400
|
const [failure, setFailure] = useState<string | null>(null);
|
|
401
|
+
// A cancelled download and a retry are the same effect run again, so the
|
|
402
|
+
// attempt is what the effect keys off; standing down withholds the run.
|
|
403
|
+
const [attempt, setAttempt] = useState(0);
|
|
404
|
+
const [standDown, setStandDown] = useState(false);
|
|
405
|
+
const [status, setStatus] = useState<ProviderStatus>({
|
|
406
|
+
state: "opening",
|
|
407
|
+
language,
|
|
408
|
+
bytesLoaded: 0,
|
|
409
|
+
bytesTotal: 0,
|
|
410
|
+
});
|
|
411
|
+
// Nothing here can draw a mark without the highlight registry, so there is
|
|
412
|
+
// nothing to narrate either: the browser has the text to itself.
|
|
413
|
+
const [drawable] = useState(marksSupported);
|
|
400
414
|
|
|
401
415
|
useEffect(() => {
|
|
402
416
|
settings.current = options;
|
|
@@ -404,7 +418,7 @@ const SpellcheckPlugin = ({
|
|
|
404
418
|
}, [options, onReady]);
|
|
405
419
|
|
|
406
420
|
useEffect(() => {
|
|
407
|
-
if (!marksSupported()) return;
|
|
421
|
+
if (!marksSupported() || standDown) return;
|
|
408
422
|
const painter = Symbol(SPELLCHECK_HIGHLIGHT);
|
|
409
423
|
const findings = found.current;
|
|
410
424
|
const touched = new Set<string>();
|
|
@@ -473,7 +487,14 @@ const SpellcheckPlugin = ({
|
|
|
473
487
|
passes += 1;
|
|
474
488
|
const sent = revision;
|
|
475
489
|
provider
|
|
476
|
-
|
|
490
|
+
// The attempt is part of the id, so a retry's answers are legible
|
|
491
|
+
// against the ones the download it replaced never delivered.
|
|
492
|
+
.check({
|
|
493
|
+
requestId: `${attempt}:${passes}`,
|
|
494
|
+
language,
|
|
495
|
+
revision: sent,
|
|
496
|
+
spans,
|
|
497
|
+
})
|
|
477
498
|
.then((response) => {
|
|
478
499
|
if (!live) return;
|
|
479
500
|
// The text moved while this was in flight, so the offsets are
|
|
@@ -656,16 +677,18 @@ const SpellcheckPlugin = ({
|
|
|
656
677
|
schedule();
|
|
657
678
|
});
|
|
658
679
|
|
|
680
|
+
const announce = (next: ProviderStatus): void => {
|
|
681
|
+
setStatus(next);
|
|
682
|
+
settings.current.onStatus?.(next);
|
|
683
|
+
};
|
|
684
|
+
|
|
659
685
|
const stopped = (detail: string): void => {
|
|
660
|
-
|
|
661
|
-
state: "failed",
|
|
662
|
-
language,
|
|
663
|
-
reason: "worker",
|
|
664
|
-
detail,
|
|
665
|
-
});
|
|
686
|
+
announce({ state: "failed", language, reason: "worker", detail });
|
|
666
687
|
report.current(false);
|
|
667
688
|
};
|
|
668
689
|
|
|
690
|
+
announce({ state: "opening", language, bytesLoaded: 0, bytesTotal: 0 });
|
|
691
|
+
|
|
669
692
|
settings.current
|
|
670
693
|
.provider(language)
|
|
671
694
|
.then((opened) => {
|
|
@@ -675,19 +698,24 @@ const SpellcheckPlugin = ({
|
|
|
675
698
|
}
|
|
676
699
|
checker.current = opened;
|
|
677
700
|
if (!opened) {
|
|
678
|
-
|
|
701
|
+
announce({ state: "unavailable", language });
|
|
679
702
|
report.current(false);
|
|
680
703
|
return;
|
|
681
704
|
}
|
|
682
705
|
unsubscribe = opened.onStatus((status) => {
|
|
683
706
|
state = status.state;
|
|
684
|
-
|
|
707
|
+
announce(status);
|
|
685
708
|
const ready = status.state === "ready";
|
|
686
709
|
report.current(ready);
|
|
687
710
|
if (ready) {
|
|
688
711
|
schedule();
|
|
689
712
|
return;
|
|
690
713
|
}
|
|
714
|
+
// A dictionary on its way is not checking stopped, and it arrives in
|
|
715
|
+
// twenty progress reports: treating each as a stop would empty the
|
|
716
|
+
// queue the first pass is supposed to drain, and nothing would ever
|
|
717
|
+
// be checked.
|
|
718
|
+
if (status.state === "opening") return;
|
|
691
719
|
// Checking stopped, so the browser's own is back. Its squiggles are
|
|
692
720
|
// the only ones on screen from here.
|
|
693
721
|
findings.clear();
|
|
@@ -723,7 +751,7 @@ const SpellcheckPlugin = ({
|
|
|
723
751
|
report.current(false);
|
|
724
752
|
paintMarks(painter, []);
|
|
725
753
|
};
|
|
726
|
-
}, [editor, language, hostRef]);
|
|
754
|
+
}, [editor, language, hostRef, attempt, standDown]);
|
|
727
755
|
|
|
728
756
|
useEffect(() => {
|
|
729
757
|
if (!target) return;
|
|
@@ -780,9 +808,8 @@ const SpellcheckPlugin = ({
|
|
|
780
808
|
[editor, dismiss],
|
|
781
809
|
);
|
|
782
810
|
|
|
783
|
-
if (!target) return null;
|
|
784
|
-
|
|
785
811
|
const replace = (suggestion: string) => {
|
|
812
|
+
if (!target) return;
|
|
786
813
|
// One entry, so one undo puts the misspelling back (#707, decision 9).
|
|
787
814
|
editor.update(
|
|
788
815
|
() => {
|
|
@@ -809,27 +836,43 @@ const SpellcheckPlugin = ({
|
|
|
809
836
|
};
|
|
810
837
|
|
|
811
838
|
const addWord = () => {
|
|
839
|
+
if (!target) return;
|
|
812
840
|
settings.current.onAddWord?.(target.word);
|
|
813
841
|
forget(target.word);
|
|
814
842
|
};
|
|
815
843
|
|
|
816
844
|
return (
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
845
|
+
<>
|
|
846
|
+
{drawable ? (
|
|
847
|
+
<RichTextSpellcheckNotice
|
|
848
|
+
status={status}
|
|
849
|
+
standDown={standDown}
|
|
850
|
+
onCancel={() => setStandDown(true)}
|
|
851
|
+
onRetry={() => {
|
|
852
|
+
setStandDown(false);
|
|
853
|
+
setAttempt((run) => run + 1);
|
|
854
|
+
}}
|
|
855
|
+
/>
|
|
856
|
+
) : null}
|
|
857
|
+
{target ? (
|
|
858
|
+
<RichTextCorrectionMenu
|
|
859
|
+
word={target.word}
|
|
860
|
+
suggestions={suggestions}
|
|
861
|
+
failure={failure}
|
|
862
|
+
anchor={{
|
|
863
|
+
left: target.left,
|
|
864
|
+
right: target.right,
|
|
865
|
+
top: target.top,
|
|
866
|
+
bottom: target.bottom,
|
|
867
|
+
}}
|
|
868
|
+
language={language}
|
|
869
|
+
onReplace={replace}
|
|
870
|
+
onIgnore={() => forget(target.word)}
|
|
871
|
+
onAddWord={options.onAddWord ? addWord : undefined}
|
|
872
|
+
onDismiss={dismiss}
|
|
873
|
+
/>
|
|
874
|
+
) : null}
|
|
875
|
+
</>
|
|
833
876
|
);
|
|
834
877
|
};
|
|
835
878
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stand-in the marks, the menu and the stale-answer stories drive the
|
|
3
|
+
* editor with. It is not a spellchecker and never was: those tests are about
|
|
4
|
+
* what the editor does with an answer, so the answer has to be a fixed one.
|
|
5
|
+
* What a real dictionary says is proved against a real dictionary, in
|
|
6
|
+
* `rich-text-spellcheck-worker.test.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { normaliseWord } from "./rich-text-spellcheck-words.js";
|
|
9
|
+
|
|
10
|
+
const KNOWN = new Set([
|
|
11
|
+
"a",
|
|
12
|
+
"again",
|
|
13
|
+
"agenda",
|
|
14
|
+
"and",
|
|
15
|
+
"are",
|
|
16
|
+
"attached",
|
|
17
|
+
"budget",
|
|
18
|
+
"confirm",
|
|
19
|
+
"figures",
|
|
20
|
+
"for",
|
|
21
|
+
"is",
|
|
22
|
+
"i",
|
|
23
|
+
"meeting",
|
|
24
|
+
"notes",
|
|
25
|
+
"report",
|
|
26
|
+
"schedule",
|
|
27
|
+
"separately",
|
|
28
|
+
"the",
|
|
29
|
+
"this",
|
|
30
|
+
"today",
|
|
31
|
+
"tomorrow",
|
|
32
|
+
"well",
|
|
33
|
+
"will",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const CORRECTIONS = new Map<string, readonly string[]>([
|
|
37
|
+
["ths", ["the", "this", "than", "that", "them"]],
|
|
38
|
+
["redy", ["ready", "read", "very"]],
|
|
39
|
+
["attachd", ["attached"]],
|
|
40
|
+
["tomorow", ["tomorrow"]],
|
|
41
|
+
["budgt", ["budget"]],
|
|
42
|
+
["meetign", ["meeting"]],
|
|
43
|
+
["confrm", ["confirm"]],
|
|
44
|
+
["schedual", ["schedule"]],
|
|
45
|
+
["seperately", ["separately"]],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** A suggestion arrives dressed the way the word it replaces was written. */
|
|
49
|
+
const wearingTheCaseOf = (word: string, suggestion: string): string => {
|
|
50
|
+
const upper = word.toUpperCase();
|
|
51
|
+
if (word === upper && word !== word.toLowerCase())
|
|
52
|
+
return suggestion.toUpperCase();
|
|
53
|
+
if (word[0] === upper[0] && word[0] !== word.toLowerCase()[0]) {
|
|
54
|
+
return suggestion[0].toUpperCase() + suggestion.slice(1);
|
|
55
|
+
}
|
|
56
|
+
return suggestion;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const stubKnows = (word: string): boolean =>
|
|
60
|
+
KNOWN.has(normaliseWord(word));
|
|
61
|
+
|
|
62
|
+
export const stubSuggestionsFor = (word: string): readonly string[] =>
|
|
63
|
+
(CORRECTIONS.get(normaliseWord(word)) ?? []).map((suggestion) =>
|
|
64
|
+
wearingTheCaseOf(word, suggestion),
|
|
65
|
+
);
|