@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.
- package/package.json +3 -3
- package/src/components/app-shell-slotted.render.test.ts +83 -0
- package/src/components/app-shell-slotted.tsx +31 -3
- package/src/components/attendee-row.render.test.ts +73 -0
- package/src/components/attendee-row.stories.tsx +68 -0
- package/src/components/attendee-row.tsx +96 -0
- package/src/components/calendar-event-chip.render.test.ts +67 -0
- package/src/components/calendar-event-chip.stories.tsx +128 -0
- package/src/components/calendar-event-chip.tsx +116 -0
- package/src/components/calendar-list.render.test.ts +77 -0
- package/src/components/calendar-list.stories.tsx +130 -0
- package/src/components/calendar-list.tsx +225 -0
- package/src/components/calendar-toolbar.render.test.ts +69 -0
- package/src/components/calendar-toolbar.stories.tsx +55 -0
- package/src/components/calendar-toolbar.tsx +178 -0
- package/src/components/calendar-types.ts +135 -0
- package/src/components/custom-recurrence.stories.tsx +106 -0
- package/src/components/custom-recurrence.tsx +411 -0
- package/src/components/event-detail.render.test.ts +104 -0
- package/src/components/event-detail.stories.tsx +178 -0
- package/src/components/event-detail.tsx +188 -0
- package/src/components/event-editor-pane.tsx +54 -0
- package/src/components/event-editor.render.test.ts +83 -0
- package/src/components/event-editor.stories.tsx +99 -0
- package/src/components/event-editor.tsx +480 -0
- package/src/components/event-quick-entry.render.test.ts +40 -0
- package/src/components/event-quick-entry.stories.tsx +56 -0
- package/src/components/event-quick-entry.tsx +159 -0
- package/src/components/event-suggestion-card.render.test.ts +65 -0
- package/src/components/event-suggestion-card.stories.tsx +93 -0
- package/src/components/event-suggestion-card.tsx +116 -0
- package/src/components/flow-screen.tsx +169 -0
- package/src/components/intelligence-panel.stories.tsx +5 -1
- package/src/components/intelligence-panel.tsx +4 -0
- package/src/components/isolated-email-frame.tsx +1 -18
- package/src/components/message-list-pane.render.test.ts +2 -2
- package/src/components/message-list-pane.stories.tsx +1 -1
- package/src/components/nav-sidebar.tsx +20 -0
- package/src/components/popover-menu.tsx +230 -27
- package/src/components/pull-to-refresh.tsx +1 -19
- package/src/components/recurrence-scope-prompt.render.test.ts +36 -0
- package/src/components/recurrence-scope-prompt.stories.tsx +46 -0
- package/src/components/recurrence-scope-prompt.tsx +84 -0
- package/src/components/rich-text-correction-menu.tsx +220 -0
- package/src/components/rich-text-editor.stories.tsx +507 -5
- package/src/components/rich-text-editor.tsx +482 -83
- package/src/components/rich-text-spellcheck-menu.test.ts +865 -0
- package/src/components/rich-text-spellcheck-provider.test.ts +114 -1
- package/src/components/rich-text-spellcheck-provider.ts +68 -3
- package/src/components/rich-text-spellcheck-words.ts +168 -4
- package/src/components/rich-text-spellcheck-worker.ts +11 -0
- package/src/components/rich-text-spellcheck.test.ts +7 -0
- package/src/components/rich-text-spellcheck.ts +28 -2
- package/src/components/selection-wizard.tsx +19 -69
- package/src/index.ts +116 -0
- package/src/lib/calendar-color.ts +71 -0
- package/src/lib/event-phrase.test.ts +89 -0
- package/src/lib/event-phrase.ts +228 -0
- package/src/lib/recurrence.test.ts +141 -0
- package/src/lib/recurrence.ts +266 -0
- package/src/lib/use-match-media.ts +25 -0
- package/src/rich-text.ts +12 -0
- 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
|
|
4
|
-
* tokeniser is the part that stays — a provider is handed
|
|
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
|
|
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);
|