@remit/ui 0.0.106 → 0.0.108
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 +1 -1
- package/src/components/nav-link-surface.interaction.test.ts +117 -0
- package/src/components/nav-link-surface.render.test.ts +126 -0
- package/src/components/nav-link-surface.tsx +97 -0
- package/src/components/rich-text-editor.stories.tsx +164 -1
- package/src/components/rich-text-editor.tsx +364 -53
- package/src/components/rich-text-spellcheck-provider.test.ts +217 -0
- package/src/components/rich-text-spellcheck-provider.ts +83 -0
- package/src/components/rich-text-spellcheck-words.ts +214 -0
- package/src/components/rich-text-spellcheck-worker-provider.ts +42 -0
- package/src/components/rich-text-spellcheck-worker.ts +54 -0
- package/src/components/rich-text-spellcheck.test.ts +638 -0
- package/src/components/rich-text-spellcheck.ts +75 -0
- package/src/index.ts +1 -0
- package/src/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider over a port. The editor holds it through the interface only, so
|
|
3
|
+
* where the checking runs — a worker here, a backend later — is not something
|
|
4
|
+
* the plugin can observe. Closing it takes the port down with it.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
CheckResponse,
|
|
8
|
+
LanguageTag,
|
|
9
|
+
ProviderStatus,
|
|
10
|
+
SpellProvider,
|
|
11
|
+
SpellWorkerRequest,
|
|
12
|
+
SpellWorkerResponse,
|
|
13
|
+
} from "./rich-text-spellcheck.js";
|
|
14
|
+
|
|
15
|
+
export interface SpellWorkerPort {
|
|
16
|
+
post(message: SpellWorkerRequest): void;
|
|
17
|
+
listen(listener: (message: SpellWorkerResponse) => void): void;
|
|
18
|
+
fail(listener: (detail: string) => void): void;
|
|
19
|
+
terminate(): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const openSpellProvider = (
|
|
23
|
+
language: LanguageTag,
|
|
24
|
+
port: SpellWorkerPort,
|
|
25
|
+
): SpellProvider => {
|
|
26
|
+
const pending = new Map<string, (response: CheckResponse) => void>();
|
|
27
|
+
const listeners = new Set<(status: ProviderStatus) => void>();
|
|
28
|
+
let status: ProviderStatus = { state: "opening", language };
|
|
29
|
+
|
|
30
|
+
const publish = (next: ProviderStatus): void => {
|
|
31
|
+
status = next;
|
|
32
|
+
for (const listener of listeners) listener(next);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
port.listen((message) => {
|
|
36
|
+
if (message.type === "ready") {
|
|
37
|
+
publish({ state: "ready", language });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (message.type === "failed") {
|
|
41
|
+
publish({
|
|
42
|
+
state: "failed",
|
|
43
|
+
language,
|
|
44
|
+
reason: message.reason,
|
|
45
|
+
detail: message.detail,
|
|
46
|
+
});
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const settle = pending.get(message.requestId);
|
|
50
|
+
if (!settle) return;
|
|
51
|
+
pending.delete(message.requestId);
|
|
52
|
+
settle({
|
|
53
|
+
requestId: message.requestId,
|
|
54
|
+
revision: message.revision,
|
|
55
|
+
findings: message.findings,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
port.fail((detail) =>
|
|
59
|
+
publish({ state: "failed", language, reason: "worker", detail }),
|
|
60
|
+
);
|
|
61
|
+
port.post({ type: "open", language });
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
language,
|
|
65
|
+
onStatus: (listener) => {
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
listener(status);
|
|
68
|
+
return () => {
|
|
69
|
+
listeners.delete(listener);
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
check: (request) =>
|
|
73
|
+
new Promise((resolve) => {
|
|
74
|
+
pending.set(request.requestId, resolve);
|
|
75
|
+
port.post({ type: "check", ...request });
|
|
76
|
+
}),
|
|
77
|
+
close: () => {
|
|
78
|
+
pending.clear();
|
|
79
|
+
listeners.clear();
|
|
80
|
+
port.terminate();
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
6
|
+
*/
|
|
7
|
+
import type { LanguageTag } from "./rich-text-spellcheck.js";
|
|
8
|
+
|
|
9
|
+
export interface WordRange {
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ENGLISH: ReadonlySet<string> = new Set([
|
|
15
|
+
"a",
|
|
16
|
+
"about",
|
|
17
|
+
"after",
|
|
18
|
+
"again",
|
|
19
|
+
"all",
|
|
20
|
+
"also",
|
|
21
|
+
"an",
|
|
22
|
+
"and",
|
|
23
|
+
"any",
|
|
24
|
+
"are",
|
|
25
|
+
"as",
|
|
26
|
+
"at",
|
|
27
|
+
"be",
|
|
28
|
+
"because",
|
|
29
|
+
"been",
|
|
30
|
+
"before",
|
|
31
|
+
"both",
|
|
32
|
+
"but",
|
|
33
|
+
"by",
|
|
34
|
+
"can",
|
|
35
|
+
"come",
|
|
36
|
+
"could",
|
|
37
|
+
"day",
|
|
38
|
+
"did",
|
|
39
|
+
"do",
|
|
40
|
+
"does",
|
|
41
|
+
"done",
|
|
42
|
+
"down",
|
|
43
|
+
"draft",
|
|
44
|
+
"each",
|
|
45
|
+
"end",
|
|
46
|
+
"even",
|
|
47
|
+
"every",
|
|
48
|
+
"few",
|
|
49
|
+
"find",
|
|
50
|
+
"first",
|
|
51
|
+
"for",
|
|
52
|
+
"friday",
|
|
53
|
+
"from",
|
|
54
|
+
"get",
|
|
55
|
+
"give",
|
|
56
|
+
"go",
|
|
57
|
+
"good",
|
|
58
|
+
"had",
|
|
59
|
+
"has",
|
|
60
|
+
"have",
|
|
61
|
+
"he",
|
|
62
|
+
"help",
|
|
63
|
+
"her",
|
|
64
|
+
"here",
|
|
65
|
+
"him",
|
|
66
|
+
"his",
|
|
67
|
+
"how",
|
|
68
|
+
"i",
|
|
69
|
+
"if",
|
|
70
|
+
"in",
|
|
71
|
+
"into",
|
|
72
|
+
"is",
|
|
73
|
+
"it",
|
|
74
|
+
"its",
|
|
75
|
+
"just",
|
|
76
|
+
"keep",
|
|
77
|
+
"know",
|
|
78
|
+
"last",
|
|
79
|
+
"let",
|
|
80
|
+
"like",
|
|
81
|
+
"long",
|
|
82
|
+
"look",
|
|
83
|
+
"made",
|
|
84
|
+
"make",
|
|
85
|
+
"many",
|
|
86
|
+
"may",
|
|
87
|
+
"me",
|
|
88
|
+
"meeting",
|
|
89
|
+
"month",
|
|
90
|
+
"more",
|
|
91
|
+
"most",
|
|
92
|
+
"much",
|
|
93
|
+
"must",
|
|
94
|
+
"my",
|
|
95
|
+
"need",
|
|
96
|
+
"new",
|
|
97
|
+
"next",
|
|
98
|
+
"no",
|
|
99
|
+
"not",
|
|
100
|
+
"note",
|
|
101
|
+
"notes",
|
|
102
|
+
"now",
|
|
103
|
+
"number",
|
|
104
|
+
"numbers",
|
|
105
|
+
"of",
|
|
106
|
+
"off",
|
|
107
|
+
"on",
|
|
108
|
+
"once",
|
|
109
|
+
"one",
|
|
110
|
+
"only",
|
|
111
|
+
"or",
|
|
112
|
+
"other",
|
|
113
|
+
"our",
|
|
114
|
+
"out",
|
|
115
|
+
"over",
|
|
116
|
+
"own",
|
|
117
|
+
"people",
|
|
118
|
+
"please",
|
|
119
|
+
"put",
|
|
120
|
+
"quarter",
|
|
121
|
+
"read",
|
|
122
|
+
"ready",
|
|
123
|
+
"receive",
|
|
124
|
+
"report",
|
|
125
|
+
"right",
|
|
126
|
+
"same",
|
|
127
|
+
"say",
|
|
128
|
+
"see",
|
|
129
|
+
"send",
|
|
130
|
+
"sent",
|
|
131
|
+
"she",
|
|
132
|
+
"should",
|
|
133
|
+
"show",
|
|
134
|
+
"since",
|
|
135
|
+
"so",
|
|
136
|
+
"some",
|
|
137
|
+
"still",
|
|
138
|
+
"such",
|
|
139
|
+
"take",
|
|
140
|
+
"team",
|
|
141
|
+
"tell",
|
|
142
|
+
"than",
|
|
143
|
+
"that",
|
|
144
|
+
"the",
|
|
145
|
+
"their",
|
|
146
|
+
"them",
|
|
147
|
+
"then",
|
|
148
|
+
"there",
|
|
149
|
+
"these",
|
|
150
|
+
"they",
|
|
151
|
+
"thing",
|
|
152
|
+
"think",
|
|
153
|
+
"this",
|
|
154
|
+
"those",
|
|
155
|
+
"through",
|
|
156
|
+
"time",
|
|
157
|
+
"to",
|
|
158
|
+
"today",
|
|
159
|
+
"too",
|
|
160
|
+
"two",
|
|
161
|
+
"up",
|
|
162
|
+
"us",
|
|
163
|
+
"use",
|
|
164
|
+
"very",
|
|
165
|
+
"want",
|
|
166
|
+
"was",
|
|
167
|
+
"way",
|
|
168
|
+
"we",
|
|
169
|
+
"week",
|
|
170
|
+
"well",
|
|
171
|
+
"were",
|
|
172
|
+
"what",
|
|
173
|
+
"when",
|
|
174
|
+
"where",
|
|
175
|
+
"which",
|
|
176
|
+
"while",
|
|
177
|
+
"who",
|
|
178
|
+
"why",
|
|
179
|
+
"will",
|
|
180
|
+
"with",
|
|
181
|
+
"word",
|
|
182
|
+
"work",
|
|
183
|
+
"would",
|
|
184
|
+
"year",
|
|
185
|
+
"yes",
|
|
186
|
+
"you",
|
|
187
|
+
"your",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const DICTIONARIES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
191
|
+
["en", ENGLISH],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
export const dictionaryFor = (
|
|
195
|
+
language: LanguageTag,
|
|
196
|
+
): ReadonlySet<string> | null =>
|
|
197
|
+
DICTIONARIES.get(language.toLowerCase().split("-")[0]) ?? null;
|
|
198
|
+
|
|
199
|
+
export const findMisspellings = (
|
|
200
|
+
text: string,
|
|
201
|
+
words: ReadonlySet<string>,
|
|
202
|
+
): readonly WordRange[] => {
|
|
203
|
+
const pattern = /\p{L}[\p{L}\p{M}'’]*/gu;
|
|
204
|
+
const found: WordRange[] = [];
|
|
205
|
+
let match = pattern.exec(text);
|
|
206
|
+
while (match) {
|
|
207
|
+
const word = match[0];
|
|
208
|
+
if (word.length > 1 && !words.has(word.toLowerCase().replace(/’/g, "'"))) {
|
|
209
|
+
found.push({ start: match.index, end: match.index + word.length });
|
|
210
|
+
}
|
|
211
|
+
match = pattern.exec(text);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* 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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
LanguageTag,
|
|
13
|
+
SpellProvider,
|
|
14
|
+
SpellWorkerResponse,
|
|
15
|
+
} from "./rich-text-spellcheck.js";
|
|
16
|
+
import type { SpellWorkerPort } from "./rich-text-spellcheck-provider.js";
|
|
17
|
+
import { openSpellProvider } from "./rich-text-spellcheck-provider.js";
|
|
18
|
+
import { dictionaryFor } from "./rich-text-spellcheck-words.js";
|
|
19
|
+
|
|
20
|
+
const workerPort = (worker: Worker): SpellWorkerPort => ({
|
|
21
|
+
post: (message) => worker.postMessage(message),
|
|
22
|
+
listen: (listener) =>
|
|
23
|
+
worker.addEventListener("message", (event: MessageEvent) =>
|
|
24
|
+
listener(event.data as SpellWorkerResponse),
|
|
25
|
+
),
|
|
26
|
+
fail: (listener) =>
|
|
27
|
+
worker.addEventListener("error", (event: ErrorEvent) =>
|
|
28
|
+
listener(event.message),
|
|
29
|
+
),
|
|
30
|
+
terminate: () => worker.terminate(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const openSpellcheckWorker = async (
|
|
34
|
+
language: LanguageTag,
|
|
35
|
+
): Promise<SpellProvider | null> => {
|
|
36
|
+
if (!dictionaryFor(language)) return null;
|
|
37
|
+
const worker = new Worker(
|
|
38
|
+
new URL("./rich-text-spellcheck-worker.ts", import.meta.url),
|
|
39
|
+
{ type: "module" },
|
|
40
|
+
);
|
|
41
|
+
return openSpellProvider(language, workerPort(worker));
|
|
42
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checking side, off the main thread. It answers with the revision it was
|
|
3
|
+
* asked at and never touches the document, which is what lets the editor throw
|
|
4
|
+
* a late answer away. The word list it consults is a stub; the engine that
|
|
5
|
+
* replaces it changes nothing above this file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
CheckRequest,
|
|
10
|
+
Finding,
|
|
11
|
+
SpellWorkerRequest,
|
|
12
|
+
SpellWorkerResponse,
|
|
13
|
+
} from "./rich-text-spellcheck.js";
|
|
14
|
+
import {
|
|
15
|
+
dictionaryFor,
|
|
16
|
+
findMisspellings,
|
|
17
|
+
} from "./rich-text-spellcheck-words.js";
|
|
18
|
+
|
|
19
|
+
interface WorkerScope {
|
|
20
|
+
postMessage(message: SpellWorkerResponse): void;
|
|
21
|
+
addEventListener(
|
|
22
|
+
type: "message",
|
|
23
|
+
listener: (event: MessageEvent<SpellWorkerRequest>) => void,
|
|
24
|
+
): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const spell = (request: CheckRequest): Finding[] => {
|
|
28
|
+
const words = dictionaryFor(request.language);
|
|
29
|
+
if (!words) return [];
|
|
30
|
+
return request.spans.flatMap((span) =>
|
|
31
|
+
findMisspellings(span.text, words).map((range) => ({
|
|
32
|
+
spanId: span.spanId,
|
|
33
|
+
start: range.start,
|
|
34
|
+
end: range.end,
|
|
35
|
+
kind: "spelling" as const,
|
|
36
|
+
suggestions: [],
|
|
37
|
+
})),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const scope = globalThis as unknown as WorkerScope;
|
|
42
|
+
|
|
43
|
+
scope.addEventListener("message", ({ data }) => {
|
|
44
|
+
if (data.type === "open") {
|
|
45
|
+
scope.postMessage({ type: "ready", language: data.language });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
scope.postMessage({
|
|
49
|
+
type: "checked",
|
|
50
|
+
requestId: data.requestId,
|
|
51
|
+
revision: data.revision,
|
|
52
|
+
findings: spell(data),
|
|
53
|
+
});
|
|
54
|
+
});
|