@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.
@@ -27,17 +27,39 @@ export interface SpellWorkerPort {
27
27
  */
28
28
  export const SUGGEST_DEADLINE_MS = 5000;
29
29
 
30
+ /**
31
+ * How long a pass waits before the checker is called stopped. Hunspell answers
32
+ * a paragraph in single-digit milliseconds, so anything past this is a wedged
33
+ * engine rather than a slow one — and a wedged engine is invisible: the marks
34
+ * simply stop moving while `spellcheck` stays off, which on screen is text with
35
+ * nothing wrong in it. The deadline is what turns that into a failure the
36
+ * writer is told about and the browser's own checker takes back.
37
+ */
38
+ export const CHECK_DEADLINE_MS = 5000;
39
+
40
+ interface Waiting {
41
+ settle(response: CheckResponse): void;
42
+ drop(): void;
43
+ }
44
+
30
45
  export const openSpellProvider = (
31
46
  language: LanguageTag,
47
+ base: string,
32
48
  port: SpellWorkerPort,
49
+ bytesExpected = 0,
33
50
  ): SpellProvider => {
34
- const pending = new Map<string, (response: CheckResponse) => void>();
51
+ const pending = new Map<string, Waiting>();
35
52
  const asking = new Map<
36
53
  string,
37
54
  { settle(response: SuggestResponse): void; abandon(reason: Error): void }
38
55
  >();
39
56
  const listeners = new Set<(status: ProviderStatus) => void>();
40
- let status: ProviderStatus = { state: "opening", language };
57
+ let status: ProviderStatus = {
58
+ state: "opening",
59
+ language,
60
+ bytesLoaded: 0,
61
+ bytesTotal: 0,
62
+ };
41
63
 
42
64
  /**
43
65
  * A check that never comes back costs a pass; a suggestion that never comes
@@ -62,6 +84,15 @@ export const openSpellProvider = (
62
84
  };
63
85
 
64
86
  port.listen((message) => {
87
+ if (message.type === "opening") {
88
+ publish({
89
+ state: "opening",
90
+ language,
91
+ bytesLoaded: message.bytesLoaded,
92
+ bytesTotal: message.bytesTotal,
93
+ });
94
+ return;
95
+ }
65
96
  if (message.type === "ready") {
66
97
  publish({ state: "ready", language });
67
98
  return;
@@ -87,20 +118,22 @@ export const openSpellProvider = (
87
118
  });
88
119
  return;
89
120
  }
90
- const settle = pending.get(message.requestId);
91
- if (!settle) return;
121
+ const waiting = pending.get(message.requestId);
122
+ if (!waiting) return;
92
123
  pending.delete(message.requestId);
93
- settle({
124
+ waiting.settle({
94
125
  requestId: message.requestId,
95
126
  revision: message.revision,
96
127
  findings: message.findings,
97
128
  });
98
129
  });
99
- port.fail((detail) => {
100
- publish({ state: "failed", language, reason: "worker", detail });
130
+ const stopped = (detail: string): void => {
131
+ if (status.state !== "failed")
132
+ publish({ state: "failed", language, reason: "worker", detail });
101
133
  abandonSuggestions(detail);
102
- });
103
- port.post({ type: "open", language });
134
+ };
135
+ port.fail(stopped);
136
+ port.post({ type: "open", language, base, bytesExpected });
104
137
 
105
138
  return {
106
139
  language,
@@ -113,7 +146,27 @@ export const openSpellProvider = (
113
146
  },
114
147
  check: (request) =>
115
148
  new Promise((resolve) => {
116
- pending.set(request.requestId, resolve);
149
+ const deadline = setTimeout(() => {
150
+ pending.delete(request.requestId);
151
+ stopped(
152
+ `the ${language} checker did not answer within ${CHECK_DEADLINE_MS}ms`,
153
+ );
154
+ // An empty answer rather than a promise nobody settles: the pass is
155
+ // over, and the marks it would have painted are the ones the failed
156
+ // status has just cleared.
157
+ resolve({
158
+ requestId: request.requestId,
159
+ revision: request.revision,
160
+ findings: [],
161
+ });
162
+ }, CHECK_DEADLINE_MS);
163
+ pending.set(request.requestId, {
164
+ settle: (response) => {
165
+ clearTimeout(deadline);
166
+ resolve(response);
167
+ },
168
+ drop: () => clearTimeout(deadline),
169
+ });
117
170
  port.post({ type: "check", ...request });
118
171
  }),
119
172
  suggest: (request) =>
@@ -139,6 +192,7 @@ export const openSpellProvider = (
139
192
  port.post({ type: "suggest", ...request });
140
193
  }),
141
194
  close: () => {
195
+ for (const waiting of pending.values()) waiting.drop();
142
196
  pending.clear();
143
197
  abandonSuggestions("the checker closed");
144
198
  listeners.clear();
@@ -1,10 +1,9 @@
1
1
  /**
2
- * The placeholder a real dictionary replaces (#707): a word list small enough
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.
2
+ * The tokeniser. A provider is handed paragraph text and decides for itself
3
+ * where the words are, so this is the one place that answers what a word is
4
+ * and the session ignore list keys off the same normalisation, so ignoring a
5
+ * word at the start of a sentence ignores it in the middle of the next one.
6
6
  */
7
- import type { LanguageTag } from "./rich-text-spellcheck.js";
8
7
 
9
8
  export interface WordRange {
10
9
  readonly start: number;
@@ -14,365 +13,28 @@ export interface WordRange {
14
13
  /** What a correction menu shows, and what the engine is asked for. */
15
14
  export const SUGGESTION_LIMIT = 5;
16
15
 
17
- const ENGLISH: ReadonlySet<string> = new Set([
18
- "a",
19
- "about",
20
- "address",
21
- "after",
22
- "afternoon",
23
- "again",
24
- "agenda",
25
- "all",
26
- "already",
27
- "also",
28
- "always",
29
- "an",
30
- "and",
31
- "answer",
32
- "any",
33
- "apologies",
34
- "approved",
35
- "are",
36
- "as",
37
- "at",
38
- "attached",
39
- "available",
40
- "be",
41
- "because",
42
- "been",
43
- "before",
44
- "both",
45
- "budget",
46
- "but",
47
- "by",
48
- "calendar",
49
- "call",
50
- "can",
51
- "change",
52
- "client",
53
- "colleague",
54
- "come",
55
- "confirm",
56
- "could",
57
- "customer",
58
- "day",
59
- "deadline",
60
- "decision",
61
- "definitely",
62
- "delivery",
63
- "did",
64
- "do",
65
- "document",
66
- "does",
67
- "done",
68
- "down",
69
- "draft",
70
- "each",
71
- "email",
72
- "end",
73
- "environment",
74
- "even",
75
- "every",
76
- "few",
77
- "figures",
78
- "find",
79
- "first",
80
- "follow",
81
- "for",
82
- "forward",
83
- "friday",
84
- "from",
85
- "get",
86
- "give",
87
- "go",
88
- "good",
89
- "had",
90
- "has",
91
- "have",
92
- "he",
93
- "help",
94
- "her",
95
- "here",
96
- "him",
97
- "his",
98
- "how",
99
- "i",
100
- "if",
101
- "in",
102
- "into",
103
- "invoice",
104
- "is",
105
- "it",
106
- "its",
107
- "just",
108
- "keep",
109
- "know",
110
- "last",
111
- "let",
112
- "like",
113
- "long",
114
- "look",
115
- "made",
116
- "make",
117
- "many",
118
- "may",
119
- "me",
120
- "meeting",
121
- "message",
122
- "month",
123
- "more",
124
- "morning",
125
- "most",
126
- "much",
127
- "must",
128
- "my",
129
- "necessary",
130
- "need",
131
- "new",
132
- "next",
133
- "no",
134
- "not",
135
- "note",
136
- "notes",
137
- "now",
138
- "number",
139
- "numbers",
140
- "occurred",
141
- "of",
142
- "off",
143
- "office",
144
- "on",
145
- "once",
146
- "one",
147
- "only",
148
- "or",
149
- "other",
150
- "our",
151
- "out",
152
- "over",
153
- "own",
154
- "page",
155
- "payment",
156
- "people",
157
- "please",
158
- "presentation",
159
- "price",
160
- "process",
161
- "product",
162
- "project",
163
- "put",
164
- "quarter",
165
- "question",
166
- "read",
167
- "ready",
168
- "receipt",
169
- "receive",
170
- "received",
171
- "recommend",
172
- "regards",
173
- "report",
174
- "review",
175
- "right",
176
- "same",
177
- "say",
178
- "schedule",
179
- "see",
180
- "send",
181
- "sent",
182
- "separate",
183
- "service",
184
- "she",
185
- "should",
186
- "show",
187
- "since",
188
- "so",
189
- "some",
190
- "sorry",
191
- "still",
192
- "such",
193
- "summary",
194
- "support",
195
- "take",
196
- "team",
197
- "tell",
198
- "than",
199
- "thanks",
200
- "that",
201
- "the",
202
- "their",
203
- "them",
204
- "then",
205
- "there",
206
- "these",
207
- "they",
208
- "thing",
209
- "think",
210
- "this",
211
- "those",
212
- "through",
213
- "time",
214
- "to",
215
- "today",
216
- "together",
217
- "tomorrow",
218
- "too",
219
- "two",
220
- "up",
221
- "update",
222
- "us",
223
- "use",
224
- "version",
225
- "very",
226
- "want",
227
- "was",
228
- "way",
229
- "we",
230
- "week",
231
- "welcome",
232
- "well",
233
- "were",
234
- "what",
235
- "when",
236
- "where",
237
- "which",
238
- "while",
239
- "who",
240
- "why",
241
- "will",
242
- "with",
243
- "word",
244
- "work",
245
- "would",
246
- "year",
247
- "yes",
248
- "you",
249
- "your",
250
- ]);
251
-
252
- const DICTIONARIES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
253
- ["en", ENGLISH],
254
- ]);
255
-
256
- export const dictionaryFor = (
257
- language: LanguageTag,
258
- ): ReadonlySet<string> | null =>
259
- DICTIONARIES.get(language.toLowerCase().split("-")[0]) ?? null;
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
16
  export const normaliseWord = (word: string): string =>
267
17
  word.toLowerCase().replace(/’/g, "'");
268
18
 
269
- export const findMisspellings = (
270
- text: string,
271
- words: ReadonlySet<string>,
272
- ): readonly WordRange[] => {
19
+ /**
20
+ * Every word in the text, in order. Single letters are not offered: they are
21
+ * initials and list markers far more often than they are misspellings.
22
+ */
23
+ export const wordsIn = (text: string): readonly WordRange[] => {
273
24
  const pattern = /\p{L}[\p{L}\p{M}'’]*/gu;
274
25
  const found: WordRange[] = [];
275
26
  let match = pattern.exec(text);
276
27
  while (match) {
277
- const word = match[0];
278
- if (word.length > 1 && !words.has(normaliseWord(word))) {
279
- found.push({ start: match.index, end: match.index + word.length });
28
+ if (match[0].length > 1) {
29
+ found.push({ start: match.index, end: match.index + match[0].length });
280
30
  }
281
31
  match = pattern.exec(text);
282
32
  }
283
33
  return found;
284
34
  };
285
35
 
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
- };
36
+ export const findMisspellings = (
37
+ text: string,
38
+ known: (word: string) => boolean,
39
+ ): readonly WordRange[] =>
40
+ wordsIn(text).filter((range) => !known(text.slice(range.start, range.end)));
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The `spellcheck.provider` a composer hands the editor, over a real worker.
3
- * A language with no dictionary answers null and no worker ever starts.
3
+ * A language this build carries no dictionary for answers null and no worker
4
+ * ever starts, which is what the editor turns into `unavailable`.
4
5
  *
5
6
  * Its own module, and deliberately not on the package barrel: a bundler emits
6
- * the worker chunk for whatever graph reaches this `new URL`, and until the
7
- * build carries an engine (#692, decision 11) that chunk has no business in an
8
- * app build.
7
+ * the worker chunk for whatever graph reaches this `new URL`, and a build that
8
+ * stages no dictionaries has no business carrying that chunk.
9
9
  */
10
10
 
11
11
  import type {
@@ -13,9 +13,13 @@ import type {
13
13
  SpellProvider,
14
14
  SpellWorkerResponse,
15
15
  } from "./rich-text-spellcheck.js";
16
+ import {
17
+ dictionaryTagFor,
18
+ spellcheckBase,
19
+ spellcheckBytes,
20
+ } from "./rich-text-spellcheck-languages.js";
16
21
  import type { SpellWorkerPort } from "./rich-text-spellcheck-provider.js";
17
22
  import { openSpellProvider } from "./rich-text-spellcheck-provider.js";
18
- import { dictionaryFor } from "./rich-text-spellcheck-words.js";
19
23
 
20
24
  const workerPort = (worker: Worker): SpellWorkerPort => ({
21
25
  post: (message) => worker.postMessage(message),
@@ -33,10 +37,16 @@ const workerPort = (worker: Worker): SpellWorkerPort => ({
33
37
  export const openSpellcheckWorker = async (
34
38
  language: LanguageTag,
35
39
  ): Promise<SpellProvider | null> => {
36
- if (!dictionaryFor(language)) return null;
40
+ const tag = dictionaryTagFor(language);
41
+ if (!tag) return null;
37
42
  const worker = new Worker(
38
43
  new URL("./rich-text-spellcheck-worker.ts", import.meta.url),
39
44
  { type: "module" },
40
45
  );
41
- return openSpellProvider(language, workerPort(worker));
46
+ return openSpellProvider(
47
+ tag,
48
+ spellcheckBase(),
49
+ workerPort(worker),
50
+ spellcheckBytes(tag),
51
+ );
42
52
  };