@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
@@ -4,7 +4,7 @@
4
4
  * ships inside the worker, over the same messages a real one exchanges.
5
5
  */
6
6
  import assert from "node:assert/strict";
7
- import { before, describe, it } from "node:test";
7
+ import { before, describe, it, mock } from "node:test";
8
8
  import type {
9
9
  ProviderStatus,
10
10
  SpellWorkerRequest,
@@ -13,10 +13,12 @@ import type {
13
13
  import {
14
14
  openSpellProvider,
15
15
  type SpellWorkerPort,
16
+ SUGGEST_DEADLINE_MS,
16
17
  } from "./rich-text-spellcheck-provider.js";
17
18
  import {
18
19
  dictionaryFor,
19
20
  findMisspellings,
21
+ suggestionsFor,
20
22
  } from "./rich-text-spellcheck-words.js";
21
23
 
22
24
  interface WorkerScope {
@@ -84,6 +86,37 @@ describe("the stub dictionary", () => {
84
86
  "a single letter is not a word to check",
85
87
  );
86
88
  });
89
+
90
+ it("offers the words a keystroke or two away, nearest first", () => {
91
+ const words = dictionaryFor("en");
92
+ assert.ok(words);
93
+ assert.deepEqual(suggestionsFor("redy", words), ["ready", "read", "very"]);
94
+ assert.deepEqual(
95
+ suggestionsFor("recieve", words),
96
+ ["receive", "received"],
97
+ "a swapped pair of letters is one keystroke, not two",
98
+ );
99
+ assert.deepEqual(
100
+ suggestionsFor("Attachd", words),
101
+ ["Attached"],
102
+ "a suggestion arrives dressed the way the word was written",
103
+ );
104
+ assert.equal(
105
+ suggestionsFor("Ths", words).length,
106
+ 5,
107
+ "a short word has many neighbours, and the menu takes five",
108
+ );
109
+ assert.deepEqual(
110
+ suggestionsFor("qwertyuiop", words),
111
+ [],
112
+ "nothing near it is not the same as anything at all",
113
+ );
114
+ assert.deepEqual(
115
+ suggestionsFor("report", words),
116
+ [],
117
+ "a word it holds needs no correcting",
118
+ );
119
+ });
87
120
  });
88
121
 
89
122
  describe("a provider over a worker", () => {
@@ -145,6 +178,86 @@ describe("a provider over a worker", () => {
145
178
  provider.close();
146
179
  });
147
180
 
181
+ it("answers a correction menu one word at a time", async () => {
182
+ const provider = openSpellProvider("en", port);
183
+ const response = await provider.suggest({
184
+ requestId: "9",
185
+ language: "en",
186
+ word: "redy",
187
+ });
188
+
189
+ assert.equal(response.requestId, "9");
190
+ assert.equal(response.word, "redy");
191
+ assert.deepEqual(response.suggestions, ["ready", "read", "very"]);
192
+ provider.close();
193
+ });
194
+
195
+ it("tells a waiting menu when the worker stops answering", async () => {
196
+ let raise: ((detail: string) => void) | undefined;
197
+ const provider = openSpellProvider("en", {
198
+ ...port,
199
+ post: () => {},
200
+ listen: () => {},
201
+ fail: (listener) => {
202
+ raise = listener;
203
+ },
204
+ });
205
+ const asking = provider.suggest({
206
+ requestId: "1",
207
+ language: "en",
208
+ word: "redy",
209
+ });
210
+ raise?.("worker exited");
211
+
212
+ await assert.rejects(asking, /worker exited/);
213
+ provider.close();
214
+ });
215
+
216
+ it("tells a waiting menu when the checker closes under it", async () => {
217
+ const provider = openSpellProvider("en", {
218
+ ...port,
219
+ post: () => {},
220
+ listen: () => {},
221
+ });
222
+ const asking = provider.suggest({
223
+ requestId: "1",
224
+ language: "en",
225
+ word: "redy",
226
+ });
227
+
228
+ provider.close();
229
+
230
+ await assert.rejects(
231
+ asking,
232
+ /the checker closed/,
233
+ "a menu waiting on a provider that went away hears about it",
234
+ );
235
+ });
236
+
237
+ it("gives up on a worker that took the word and went quiet", async () => {
238
+ mock.timers.enable({ apis: ["setTimeout"] });
239
+ const provider = openSpellProvider("en", {
240
+ ...port,
241
+ post: () => {},
242
+ listen: () => {},
243
+ });
244
+ const asking = provider.suggest({
245
+ requestId: "1",
246
+ language: "en",
247
+ word: "redy",
248
+ });
249
+
250
+ mock.timers.tick(SUGGEST_DEADLINE_MS);
251
+
252
+ await assert.rejects(
253
+ asking,
254
+ /no suggestions for "redy"/,
255
+ "skeleton rows that will never fill become the failure row instead",
256
+ );
257
+ mock.timers.reset();
258
+ provider.close();
259
+ });
260
+
148
261
  it("says which language it has no words for", async () => {
149
262
  const provider = openSpellProvider("de", port);
150
263
  const response = await provider.check({
@@ -10,6 +10,7 @@ import type {
10
10
  SpellProvider,
11
11
  SpellWorkerRequest,
12
12
  SpellWorkerResponse,
13
+ SuggestResponse,
13
14
  } from "./rich-text-spellcheck.js";
14
15
 
15
16
  export interface SpellWorkerPort {
@@ -19,14 +20,42 @@ export interface SpellWorkerPort {
19
20
  terminate(): void;
20
21
  }
21
22
 
23
+ /**
24
+ * How long a menu waits for its suggestions before it is told there are none
25
+ * coming. A worker that took the request and went quiet is indistinguishable
26
+ * from a slow one, and skeleton rows that never fill are the worst of both.
27
+ */
28
+ export const SUGGEST_DEADLINE_MS = 5000;
29
+
22
30
  export const openSpellProvider = (
23
31
  language: LanguageTag,
24
32
  port: SpellWorkerPort,
25
33
  ): SpellProvider => {
26
34
  const pending = new Map<string, (response: CheckResponse) => void>();
35
+ const asking = new Map<
36
+ string,
37
+ { settle(response: SuggestResponse): void; abandon(reason: Error): void }
38
+ >();
27
39
  const listeners = new Set<(status: ProviderStatus) => void>();
28
40
  let status: ProviderStatus = { state: "opening", language };
29
41
 
42
+ /**
43
+ * A check that never comes back costs a pass; a suggestion that never comes
44
+ * back leaves a menu open in front of someone. The one is dropped, the other
45
+ * is told.
46
+ */
47
+ const abandonSuggestion = (requestId: string, detail: string): void => {
48
+ const request = asking.get(requestId);
49
+ if (!request) return;
50
+ asking.delete(requestId);
51
+ request.abandon(new Error(detail));
52
+ };
53
+
54
+ const abandonSuggestions = (detail: string): void => {
55
+ for (const requestId of [...asking.keys()])
56
+ abandonSuggestion(requestId, detail);
57
+ };
58
+
30
59
  const publish = (next: ProviderStatus): void => {
31
60
  status = next;
32
61
  for (const listener of listeners) listener(next);
@@ -44,6 +73,18 @@ export const openSpellProvider = (
44
73
  reason: message.reason,
45
74
  detail: message.detail,
46
75
  });
76
+ abandonSuggestions(message.detail);
77
+ return;
78
+ }
79
+ if (message.type === "suggested") {
80
+ const request = asking.get(message.requestId);
81
+ if (!request) return;
82
+ asking.delete(message.requestId);
83
+ request.settle({
84
+ requestId: message.requestId,
85
+ word: message.word,
86
+ suggestions: message.suggestions,
87
+ });
47
88
  return;
48
89
  }
49
90
  const settle = pending.get(message.requestId);
@@ -55,9 +96,10 @@ export const openSpellProvider = (
55
96
  findings: message.findings,
56
97
  });
57
98
  });
58
- port.fail((detail) =>
59
- publish({ state: "failed", language, reason: "worker", detail }),
60
- );
99
+ port.fail((detail) => {
100
+ publish({ state: "failed", language, reason: "worker", detail });
101
+ abandonSuggestions(detail);
102
+ });
61
103
  port.post({ type: "open", language });
62
104
 
63
105
  return {
@@ -74,8 +116,31 @@ export const openSpellProvider = (
74
116
  pending.set(request.requestId, resolve);
75
117
  port.post({ type: "check", ...request });
76
118
  }),
119
+ suggest: (request) =>
120
+ new Promise((resolve, reject) => {
121
+ const deadline = setTimeout(
122
+ () =>
123
+ abandonSuggestion(
124
+ request.requestId,
125
+ `no suggestions for "${request.word}" within ${SUGGEST_DEADLINE_MS}ms`,
126
+ ),
127
+ SUGGEST_DEADLINE_MS,
128
+ );
129
+ asking.set(request.requestId, {
130
+ settle: (response) => {
131
+ clearTimeout(deadline);
132
+ resolve(response);
133
+ },
134
+ abandon: (reason) => {
135
+ clearTimeout(deadline);
136
+ reject(reason);
137
+ },
138
+ });
139
+ port.post({ type: "suggest", ...request });
140
+ }),
77
141
  close: () => {
78
142
  pending.clear();
143
+ abandonSuggestions("the checker closed");
79
144
  listeners.clear();
80
145
  port.terminate();
81
146
  },
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The placeholder a real dictionary replaces (#707): a word list small enough
3
- * to read, so the marks can be driven end to end before an engine exists. The
4
- * tokeniser is the part that stays — a provider is handed paragraph text and
5
- * decides for itself where the words are.
3
+ * to read, so the marks and the corrections can be driven end to end before an
4
+ * engine exists. The tokeniser is the part that stays — a provider is handed
5
+ * paragraph text and decides for itself where the words are.
6
6
  */
7
7
  import type { LanguageTag } from "./rich-text-spellcheck.js";
8
8
 
@@ -11,44 +11,75 @@ export interface WordRange {
11
11
  readonly end: number;
12
12
  }
13
13
 
14
+ /** What a correction menu shows, and what the engine is asked for. */
15
+ export const SUGGESTION_LIMIT = 5;
16
+
14
17
  const ENGLISH: ReadonlySet<string> = new Set([
15
18
  "a",
16
19
  "about",
20
+ "address",
17
21
  "after",
22
+ "afternoon",
18
23
  "again",
24
+ "agenda",
19
25
  "all",
26
+ "already",
20
27
  "also",
28
+ "always",
21
29
  "an",
22
30
  "and",
31
+ "answer",
23
32
  "any",
33
+ "apologies",
34
+ "approved",
24
35
  "are",
25
36
  "as",
26
37
  "at",
38
+ "attached",
39
+ "available",
27
40
  "be",
28
41
  "because",
29
42
  "been",
30
43
  "before",
31
44
  "both",
45
+ "budget",
32
46
  "but",
33
47
  "by",
48
+ "calendar",
49
+ "call",
34
50
  "can",
51
+ "change",
52
+ "client",
53
+ "colleague",
35
54
  "come",
55
+ "confirm",
36
56
  "could",
57
+ "customer",
37
58
  "day",
59
+ "deadline",
60
+ "decision",
61
+ "definitely",
62
+ "delivery",
38
63
  "did",
39
64
  "do",
65
+ "document",
40
66
  "does",
41
67
  "done",
42
68
  "down",
43
69
  "draft",
44
70
  "each",
71
+ "email",
45
72
  "end",
73
+ "environment",
46
74
  "even",
47
75
  "every",
48
76
  "few",
77
+ "figures",
49
78
  "find",
50
79
  "first",
80
+ "follow",
51
81
  "for",
82
+ "forward",
52
83
  "friday",
53
84
  "from",
54
85
  "get",
@@ -69,6 +100,7 @@ const ENGLISH: ReadonlySet<string> = new Set([
69
100
  "if",
70
101
  "in",
71
102
  "into",
103
+ "invoice",
72
104
  "is",
73
105
  "it",
74
106
  "its",
@@ -86,12 +118,15 @@ const ENGLISH: ReadonlySet<string> = new Set([
86
118
  "may",
87
119
  "me",
88
120
  "meeting",
121
+ "message",
89
122
  "month",
90
123
  "more",
124
+ "morning",
91
125
  "most",
92
126
  "much",
93
127
  "must",
94
128
  "my",
129
+ "necessary",
95
130
  "need",
96
131
  "new",
97
132
  "next",
@@ -102,8 +137,10 @@ const ENGLISH: ReadonlySet<string> = new Set([
102
137
  "now",
103
138
  "number",
104
139
  "numbers",
140
+ "occurred",
105
141
  "of",
106
142
  "off",
143
+ "office",
107
144
  "on",
108
145
  "once",
109
146
  "one",
@@ -114,32 +151,52 @@ const ENGLISH: ReadonlySet<string> = new Set([
114
151
  "out",
115
152
  "over",
116
153
  "own",
154
+ "page",
155
+ "payment",
117
156
  "people",
118
157
  "please",
158
+ "presentation",
159
+ "price",
160
+ "process",
161
+ "product",
162
+ "project",
119
163
  "put",
120
164
  "quarter",
165
+ "question",
121
166
  "read",
122
167
  "ready",
168
+ "receipt",
123
169
  "receive",
170
+ "received",
171
+ "recommend",
172
+ "regards",
124
173
  "report",
174
+ "review",
125
175
  "right",
126
176
  "same",
127
177
  "say",
178
+ "schedule",
128
179
  "see",
129
180
  "send",
130
181
  "sent",
182
+ "separate",
183
+ "service",
131
184
  "she",
132
185
  "should",
133
186
  "show",
134
187
  "since",
135
188
  "so",
136
189
  "some",
190
+ "sorry",
137
191
  "still",
138
192
  "such",
193
+ "summary",
194
+ "support",
139
195
  "take",
140
196
  "team",
141
197
  "tell",
142
198
  "than",
199
+ "thanks",
143
200
  "that",
144
201
  "the",
145
202
  "their",
@@ -156,17 +213,22 @@ const ENGLISH: ReadonlySet<string> = new Set([
156
213
  "time",
157
214
  "to",
158
215
  "today",
216
+ "together",
217
+ "tomorrow",
159
218
  "too",
160
219
  "two",
161
220
  "up",
221
+ "update",
162
222
  "us",
163
223
  "use",
224
+ "version",
164
225
  "very",
165
226
  "want",
166
227
  "was",
167
228
  "way",
168
229
  "we",
169
230
  "week",
231
+ "welcome",
170
232
  "well",
171
233
  "were",
172
234
  "what",
@@ -196,6 +258,14 @@ export const dictionaryFor = (
196
258
  ): ReadonlySet<string> | null =>
197
259
  DICTIONARIES.get(language.toLowerCase().split("-")[0]) ?? null;
198
260
 
261
+ /**
262
+ * The form a word is looked up and remembered under. The session's ignore list
263
+ * uses it too, so ignoring a word at the start of a sentence ignores it in the
264
+ * middle of the next one.
265
+ */
266
+ export const normaliseWord = (word: string): string =>
267
+ word.toLowerCase().replace(/’/g, "'");
268
+
199
269
  export const findMisspellings = (
200
270
  text: string,
201
271
  words: ReadonlySet<string>,
@@ -205,10 +275,104 @@ export const findMisspellings = (
205
275
  let match = pattern.exec(text);
206
276
  while (match) {
207
277
  const word = match[0];
208
- if (word.length > 1 && !words.has(word.toLowerCase().replace(/’/g, "'"))) {
278
+ if (word.length > 1 && !words.has(normaliseWord(word))) {
209
279
  found.push({ start: match.index, end: match.index + word.length });
210
280
  }
211
281
  match = pattern.exec(text);
212
282
  }
213
283
  return found;
214
284
  };
285
+
286
+ const NEAR_ENOUGH = 2;
287
+
288
+ /**
289
+ * Optimal string alignment: insertions, deletions, substitutions and the
290
+ * swapped pair of letters that is most of what a keyboard produces. Rows are
291
+ * abandoned once every cell in one is further away than a suggestion may be.
292
+ */
293
+ const editDistance = (word: string, candidate: string): number => {
294
+ let twoBack: number[] = [];
295
+ let previous = Array.from({ length: candidate.length + 1 }, (_, at) => at);
296
+ for (let row = 1; row <= word.length; row += 1) {
297
+ const current = [row];
298
+ let best = row;
299
+ for (let column = 1; column <= candidate.length; column += 1) {
300
+ const cost = word[row - 1] === candidate[column - 1] ? 0 : 1;
301
+ let step = Math.min(
302
+ current[column - 1] + 1,
303
+ previous[column] + 1,
304
+ previous[column - 1] + cost,
305
+ );
306
+ if (
307
+ row > 1 &&
308
+ column > 1 &&
309
+ word[row - 1] === candidate[column - 2] &&
310
+ word[row - 2] === candidate[column - 1]
311
+ ) {
312
+ step = Math.min(step, twoBack[column - 2] + 1);
313
+ }
314
+ current[column] = step;
315
+ if (step < best) best = step;
316
+ }
317
+ if (best > NEAR_ENOUGH) return NEAR_ENOUGH + 1;
318
+ twoBack = previous;
319
+ previous = current;
320
+ }
321
+ return previous[candidate.length];
322
+ };
323
+
324
+ const sharedPrefix = (word: string, candidate: string): number => {
325
+ let shared = 0;
326
+ while (
327
+ shared < word.length &&
328
+ shared < candidate.length &&
329
+ word[shared] === candidate[shared]
330
+ ) {
331
+ shared += 1;
332
+ }
333
+ return shared;
334
+ };
335
+
336
+ /** A suggestion arrives dressed the way the word it replaces was written. */
337
+ const wearingTheCaseOf = (word: string, suggestion: string): string => {
338
+ const upper = word.toUpperCase();
339
+ if (word === upper && word !== word.toLowerCase())
340
+ return suggestion.toUpperCase();
341
+ if (word[0] === upper[0] && word[0] !== word.toLowerCase()[0]) {
342
+ return suggestion[0].toUpperCase() + suggestion.slice(1);
343
+ }
344
+ return suggestion;
345
+ };
346
+
347
+ /**
348
+ * The corrections a word list can offer without an engine: everything within a
349
+ * couple of keystrokes, nearest first, and the one that starts the same way
350
+ * ahead of the one that does not.
351
+ */
352
+ export const suggestionsFor = (
353
+ word: string,
354
+ words: ReadonlySet<string>,
355
+ ): readonly string[] => {
356
+ const target = normaliseWord(word);
357
+ if (target.length < 2 || words.has(target)) return [];
358
+ const ranked: { candidate: string; distance: number; prefix: number }[] = [];
359
+ for (const candidate of words) {
360
+ if (Math.abs(candidate.length - target.length) > NEAR_ENOUGH) continue;
361
+ const distance = editDistance(target, candidate);
362
+ if (distance > NEAR_ENOUGH) continue;
363
+ ranked.push({
364
+ candidate,
365
+ distance,
366
+ prefix: sharedPrefix(target, candidate),
367
+ });
368
+ }
369
+ ranked.sort(
370
+ (left, right) =>
371
+ left.distance - right.distance ||
372
+ right.prefix - left.prefix ||
373
+ left.candidate.localeCompare(right.candidate),
374
+ );
375
+ return ranked
376
+ .slice(0, SUGGESTION_LIMIT)
377
+ .map(({ candidate }) => wearingTheCaseOf(word, candidate));
378
+ };
@@ -14,6 +14,7 @@ import type {
14
14
  import {
15
15
  dictionaryFor,
16
16
  findMisspellings,
17
+ suggestionsFor,
17
18
  } from "./rich-text-spellcheck-words.js";
18
19
 
19
20
  interface WorkerScope {
@@ -45,6 +46,16 @@ scope.addEventListener("message", ({ data }) => {
45
46
  scope.postMessage({ type: "ready", language: data.language });
46
47
  return;
47
48
  }
49
+ if (data.type === "suggest") {
50
+ const words = dictionaryFor(data.language);
51
+ scope.postMessage({
52
+ type: "suggested",
53
+ requestId: data.requestId,
54
+ word: data.word,
55
+ suggestions: words ? suggestionsFor(data.word, words) : [],
56
+ });
57
+ return;
58
+ }
48
59
  scope.postMessage({
49
60
  type: "checked",
50
61
  requestId: data.requestId,
@@ -26,6 +26,7 @@ import type {
26
26
  import {
27
27
  dictionaryFor,
28
28
  findMisspellings,
29
+ suggestionsFor,
29
30
  } from "./rich-text-spellcheck-words.js";
30
31
 
31
32
  const SENTENCE = "Ths report is redy today";
@@ -117,6 +118,12 @@ const stubSpellcheck = (
117
118
  held.push(() => resolve(response));
118
119
  });
119
120
  },
121
+ suggest: (request) =>
122
+ Promise.resolve({
123
+ requestId: request.requestId,
124
+ word: request.word,
125
+ suggestions: suggestionsFor(request.word, words),
126
+ }),
120
127
  close: () => {
121
128
  closed += 1;
122
129
  },
@@ -34,6 +34,23 @@ export interface CheckResponse {
34
34
  readonly findings: readonly Finding[];
35
35
  }
36
36
 
37
+ /**
38
+ * One word, asked when a correction menu opens. Never a paragraph: a menu is
39
+ * about the word under the pointer, and asking wider would spend the engine on
40
+ * words nobody is looking at.
41
+ */
42
+ export interface SuggestRequest {
43
+ readonly requestId: string;
44
+ readonly language: LanguageTag;
45
+ readonly word: string;
46
+ }
47
+
48
+ export interface SuggestResponse {
49
+ readonly requestId: string;
50
+ readonly word: string;
51
+ readonly suggestions: readonly string[];
52
+ }
53
+
37
54
  export type ProviderStatus =
38
55
  | { readonly state: "opening"; readonly language: LanguageTag }
39
56
  | { readonly state: "ready"; readonly language: LanguageTag }
@@ -51,6 +68,8 @@ export interface SpellProvider {
51
68
  /** Emits the current status to the listener before any later one. */
52
69
  onStatus(listener: (status: ProviderStatus) => void): () => void;
53
70
  check(request: CheckRequest): Promise<CheckResponse>;
71
+ /** Rejects when the checker cannot answer, so the menu can say so. */
72
+ suggest(request: SuggestRequest): Promise<SuggestResponse>;
54
73
  close(): void;
55
74
  }
56
75
 
@@ -58,11 +77,17 @@ export interface SpellcheckOptions {
58
77
  /** Resolving null means no dictionary for that language. */
59
78
  provider(language: LanguageTag): Promise<SpellProvider | null>;
60
79
  onStatus?(status: ProviderStatus): void;
80
+ /**
81
+ * Adds the word to whatever holds the writer's own words. The correction
82
+ * menu offers the row only when this is passed.
83
+ */
84
+ onAddWord?(word: string): void;
61
85
  }
62
86
 
63
87
  export type SpellWorkerRequest =
64
88
  | { readonly type: "open"; readonly language: LanguageTag }
65
- | ({ readonly type: "check" } & CheckRequest);
89
+ | ({ readonly type: "check" } & CheckRequest)
90
+ | ({ readonly type: "suggest" } & SuggestRequest);
66
91
 
67
92
  export type SpellWorkerResponse =
68
93
  | { readonly type: "ready"; readonly language: LanguageTag }
@@ -72,4 +97,5 @@ export type SpellWorkerResponse =
72
97
  readonly reason: "download" | "engine";
73
98
  readonly detail: string;
74
99
  }
75
- | ({ readonly type: "checked" } & CheckResponse);
100
+ | ({ readonly type: "checked" } & CheckResponse)
101
+ | ({ readonly type: "suggested" } & SuggestResponse);