@remit/ui 0.0.106 → 0.0.107
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/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/rich-text.ts +14 -0
- package/src/tokens.css +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import { expect, userEvent } from "storybook/test";
|
|
3
|
+
import { expect, userEvent, waitFor } from "storybook/test";
|
|
4
4
|
import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
|
|
5
5
|
import { ComposeLanguageChip } from "./compose-language-chip.js";
|
|
6
6
|
import {
|
|
@@ -8,6 +8,16 @@ import {
|
|
|
8
8
|
ComposeModeToggle,
|
|
9
9
|
} from "./compose-mode-toggle.js";
|
|
10
10
|
import { RichTextEditor } from "./rich-text-editor.js";
|
|
11
|
+
import type {
|
|
12
|
+
CheckRequest,
|
|
13
|
+
Finding,
|
|
14
|
+
SpellcheckOptions,
|
|
15
|
+
} from "./rich-text-spellcheck.js";
|
|
16
|
+
import {
|
|
17
|
+
dictionaryFor,
|
|
18
|
+
findMisspellings,
|
|
19
|
+
} from "./rich-text-spellcheck-words.js";
|
|
20
|
+
import { openSpellcheckWorker } from "./rich-text-spellcheck-worker-provider.js";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* The frame is the compose body region at its real geometry — a column with a
|
|
@@ -189,6 +199,159 @@ export const NarrowToolbar: Story = {
|
|
|
189
199
|
},
|
|
190
200
|
};
|
|
191
201
|
|
|
202
|
+
const MISSPELT = "Ths report is redy today, and the notes are attachd.";
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A real module worker does the checking, over the same messages an engine
|
|
206
|
+
* would answer: the component is handed a provider and never learns where the
|
|
207
|
+
* words came from. What the worker holds instead of a dictionary is a short
|
|
208
|
+
* list of English words.
|
|
209
|
+
*/
|
|
210
|
+
const workerSpellcheck: SpellcheckOptions = { provider: openSpellcheckWorker };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The same findings, answered against the revision before the one asked for —
|
|
214
|
+
* what a slow engine looks like when the text has already moved on.
|
|
215
|
+
*/
|
|
216
|
+
const staleAnswers: string[] = [];
|
|
217
|
+
|
|
218
|
+
const staleSpellcheck: SpellcheckOptions = {
|
|
219
|
+
provider: async () => ({
|
|
220
|
+
language: "en",
|
|
221
|
+
onStatus: (listener) => {
|
|
222
|
+
listener({ state: "ready", language: "en" });
|
|
223
|
+
return () => {};
|
|
224
|
+
},
|
|
225
|
+
check: (request: CheckRequest) => {
|
|
226
|
+
staleAnswers.push(request.requestId);
|
|
227
|
+
const words = dictionaryFor(request.language) ?? new Set<string>();
|
|
228
|
+
return Promise.resolve({
|
|
229
|
+
requestId: request.requestId,
|
|
230
|
+
revision: request.revision - 1,
|
|
231
|
+
findings: request.spans.flatMap((span) =>
|
|
232
|
+
findMisspellings(span.text, words).map(
|
|
233
|
+
(range): Finding => ({
|
|
234
|
+
spanId: span.spanId,
|
|
235
|
+
start: range.start,
|
|
236
|
+
end: range.end,
|
|
237
|
+
kind: "spelling",
|
|
238
|
+
suggestions: [],
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
),
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
close: () => {},
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every editor on the page shares the one registry entry, so a story reads back
|
|
250
|
+
* the marks that fall inside its own writing surface — on a docs page the
|
|
251
|
+
* neighbouring stories are drawing into it at the same time.
|
|
252
|
+
*/
|
|
253
|
+
const spellMarks = (editable: HTMLElement): AbstractRange[] => {
|
|
254
|
+
const ranges: AbstractRange[] = [];
|
|
255
|
+
CSS.highlights.forEach((highlight, name) => {
|
|
256
|
+
if (name !== "spell-error") return;
|
|
257
|
+
highlight.forEach((range) => {
|
|
258
|
+
if (editable.contains(range.startContainer)) ranges.push(range);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
return ranges;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const spellMarkOffsets = (editable: HTMLElement): [number, number][] =>
|
|
265
|
+
spellMarks(editable).map((range) => [range.startOffset, range.endOffset]);
|
|
266
|
+
|
|
267
|
+
const writingSurface = (canvasElement: HTMLElement): HTMLElement => {
|
|
268
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
269
|
+
"[data-testid=compose-body]",
|
|
270
|
+
);
|
|
271
|
+
if (!editable) throw new Error("the editor is not mounted");
|
|
272
|
+
return editable;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The marks a provider produced, drawn through the CSS Custom Highlight
|
|
277
|
+
* registry: two misspelt words carry a squiggle, the browser's own checking is
|
|
278
|
+
* off while ours is on, and the document holds nothing that was not typed.
|
|
279
|
+
*/
|
|
280
|
+
export const SpellcheckMarks: Story = {
|
|
281
|
+
name: "Spellcheck marks (worker provider)",
|
|
282
|
+
args: {
|
|
283
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
284
|
+
lang: "en",
|
|
285
|
+
spellcheck: workerSpellcheck,
|
|
286
|
+
},
|
|
287
|
+
play: async ({ canvasElement }) => {
|
|
288
|
+
const editable = writingSurface(canvasElement);
|
|
289
|
+
|
|
290
|
+
await waitFor(
|
|
291
|
+
() =>
|
|
292
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
293
|
+
[0, 3],
|
|
294
|
+
[14, 18],
|
|
295
|
+
[44, 51],
|
|
296
|
+
]),
|
|
297
|
+
{ timeout: 5000 },
|
|
298
|
+
);
|
|
299
|
+
await expect(editable.getAttribute("spellcheck")).toBe("false");
|
|
300
|
+
await expect(editable.textContent).toBe(MISSPELT);
|
|
301
|
+
await expect(editable.querySelectorAll("[data-lexical-text]")).toHaveLength(
|
|
302
|
+
1,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
// The word the caret sits in is left alone until the writer moves on.
|
|
306
|
+
await userEvent.click(editable);
|
|
307
|
+
const line = editable.querySelector<HTMLElement>("[data-lexical-text]");
|
|
308
|
+
const characters = line?.firstChild ?? null;
|
|
309
|
+
canvasElement.ownerDocument.getSelection()?.setPosition(characters, 17);
|
|
310
|
+
|
|
311
|
+
await waitFor(
|
|
312
|
+
() =>
|
|
313
|
+
expect(spellMarkOffsets(editable)).toEqual([
|
|
314
|
+
[0, 3],
|
|
315
|
+
[44, 51],
|
|
316
|
+
]),
|
|
317
|
+
{ timeout: 5000 },
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** A language the build carries no dictionary for: the browser keeps checking. */
|
|
323
|
+
export const SpellcheckWithoutDictionary: Story = {
|
|
324
|
+
name: "Spellcheck with no dictionary for the language",
|
|
325
|
+
args: {
|
|
326
|
+
initialHtml: `<p>Vielen Dank für den Bericht.</p>`,
|
|
327
|
+
lang: "de",
|
|
328
|
+
spellcheck: workerSpellcheck,
|
|
329
|
+
},
|
|
330
|
+
play: async ({ canvasElement }) => {
|
|
331
|
+
const editable = writingSurface(canvasElement);
|
|
332
|
+
await expect(editable.getAttribute("spellcheck")).toBe("true");
|
|
333
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** An answer against a revision the document has moved past paints nothing. */
|
|
338
|
+
export const SpellcheckStaleAnswer: Story = {
|
|
339
|
+
name: "Spellcheck drops a stale answer",
|
|
340
|
+
args: {
|
|
341
|
+
initialHtml: `<p>${MISSPELT}</p>`,
|
|
342
|
+
lang: "en",
|
|
343
|
+
spellcheck: staleSpellcheck,
|
|
344
|
+
},
|
|
345
|
+
play: async ({ canvasElement }) => {
|
|
346
|
+
const editable = writingSurface(canvasElement);
|
|
347
|
+
await waitFor(() => expect(staleAnswers.length).toBeGreaterThan(0), {
|
|
348
|
+
timeout: 5000,
|
|
349
|
+
});
|
|
350
|
+
await expect(spellMarks(editable)).toHaveLength(0);
|
|
351
|
+
await expect(editable.textContent).toBe(MISSPELT);
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
|
|
192
355
|
/**
|
|
193
356
|
* The toolbar and the body share one scroller, so twenty lines of typing would
|
|
194
357
|
* carry the toolbar off the top with them. It stays at the top of the body
|
|
@@ -9,19 +9,28 @@ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
|
9
9
|
import { TablePlugin } from "@lexical/react/LexicalTablePlugin";
|
|
10
10
|
import { mergeRegister } from "@lexical/utils";
|
|
11
11
|
import {
|
|
12
|
+
$getNodeByKey,
|
|
12
13
|
$getRoot,
|
|
13
14
|
$getSelection,
|
|
14
15
|
$insertNodes,
|
|
15
16
|
$isRangeSelection,
|
|
17
|
+
$isTextNode,
|
|
16
18
|
COMMAND_PRIORITY_CRITICAL,
|
|
17
19
|
COMMAND_PRIORITY_LOW,
|
|
18
20
|
KEY_DOWN_COMMAND,
|
|
19
21
|
type LexicalEditor,
|
|
20
22
|
PASTE_COMMAND,
|
|
21
23
|
} from "lexical";
|
|
22
|
-
import { useEffect, useRef } from "react";
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
23
25
|
import { $adoptHtml, $readRichText } from "./rich-text-document.js";
|
|
24
26
|
import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
|
|
27
|
+
import type {
|
|
28
|
+
CheckSpan,
|
|
29
|
+
Finding,
|
|
30
|
+
ProviderStatus,
|
|
31
|
+
SpellcheckOptions,
|
|
32
|
+
SpellProvider,
|
|
33
|
+
} from "./rich-text-spellcheck.js";
|
|
25
34
|
import { RichTextToolbar } from "./rich-text-toolbar.js";
|
|
26
35
|
import type { RichTextValue } from "./rich-text-value.js";
|
|
27
36
|
|
|
@@ -44,6 +53,13 @@ export interface RichTextEditorProps {
|
|
|
44
53
|
* ignore it. Every screen reader picks a voice from it.
|
|
45
54
|
*/
|
|
46
55
|
lang?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Where checking comes from. Left out, the browser does its own and the
|
|
58
|
+
* editor behaves as it does today. The engine is injected: this component
|
|
59
|
+
* never imports one, and a `provider` resolving null means no dictionary for
|
|
60
|
+
* `lang`, which is also when the browser keeps checking.
|
|
61
|
+
*/
|
|
62
|
+
spellcheck?: SpellcheckOptions;
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
/**
|
|
@@ -141,6 +157,282 @@ const ChangePlugin = ({
|
|
|
141
157
|
return null;
|
|
142
158
|
};
|
|
143
159
|
|
|
160
|
+
const SPELLCHECK_IDLE_MS = 250;
|
|
161
|
+
const SPELLCHECK_HIGHLIGHT = "spell-error";
|
|
162
|
+
|
|
163
|
+
interface SpellMarks {
|
|
164
|
+
add(range: Range): void;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface MarkRegistry {
|
|
168
|
+
set(name: string, marks: SpellMarks): void;
|
|
169
|
+
delete(name: string): void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The registry is read off the global rather than through the DOM typings,
|
|
174
|
+
* which describe `Highlight` without the members that put a range in it. Where
|
|
175
|
+
* a browser has neither, nothing is drawn and the provider is never opened.
|
|
176
|
+
*/
|
|
177
|
+
interface MarkHost {
|
|
178
|
+
CSS?: { highlights?: MarkRegistry };
|
|
179
|
+
Highlight?: new () => SpellMarks;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const markHost = (): MarkHost => globalThis as unknown as MarkHost;
|
|
183
|
+
|
|
184
|
+
const marksSupported = (): boolean => {
|
|
185
|
+
const host = markHost();
|
|
186
|
+
return Boolean(host.CSS?.highlights) && typeof host.Highlight === "function";
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One registry entry carries the marks of every editor on the page, because a
|
|
191
|
+
* highlight name is a page-wide thing and `::highlight(spell-error)` names it
|
|
192
|
+
* statically. Each editor owns its own ranges here and hands over the whole set
|
|
193
|
+
* each pass, so a second composer neither overwrites the first nor takes its
|
|
194
|
+
* marks down when it closes.
|
|
195
|
+
*/
|
|
196
|
+
const painters = new Map<symbol, readonly Range[]>();
|
|
197
|
+
|
|
198
|
+
const paintMarks = (painter: symbol, ranges: readonly Range[]): void => {
|
|
199
|
+
const host = markHost();
|
|
200
|
+
const registry = host.CSS?.highlights;
|
|
201
|
+
const Marks = host.Highlight;
|
|
202
|
+
if (!registry || !Marks) return;
|
|
203
|
+
if (ranges.length === 0) painters.delete(painter);
|
|
204
|
+
else painters.set(painter, ranges);
|
|
205
|
+
|
|
206
|
+
const marks = new Marks();
|
|
207
|
+
let drawn = 0;
|
|
208
|
+
for (const owned of painters.values())
|
|
209
|
+
for (const range of owned) {
|
|
210
|
+
marks.add(range);
|
|
211
|
+
drawn += 1;
|
|
212
|
+
}
|
|
213
|
+
if (drawn === 0) {
|
|
214
|
+
registry.delete(SPELLCHECK_HIGHLIGHT);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
registry.set(SPELLCHECK_HIGHLIGHT, marks);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The characters of a leaf. A format Lexical renders with a tag of its own —
|
|
222
|
+
* code, subscript, superscript — puts the text one level further down, so the
|
|
223
|
+
* element the node key resolves to is not always the text itself.
|
|
224
|
+
*/
|
|
225
|
+
const leafCharacters = (node: Node): Node | null => {
|
|
226
|
+
if (node.nodeType === Node.TEXT_NODE) return node;
|
|
227
|
+
for (let child = node.firstChild; child; child = child.nextSibling) {
|
|
228
|
+
const characters = leafCharacters(child);
|
|
229
|
+
if (characters) return characters;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Marks, and nothing else. Only the leaves an edit touched are sent, a quarter
|
|
236
|
+
* of a second after the typing stops, so a word is not called wrong while it is
|
|
237
|
+
* still being written — and the word the caret sits in is never marked at all.
|
|
238
|
+
* An answer carrying a revision the document has moved past is dropped.
|
|
239
|
+
*
|
|
240
|
+
* The marks live in the highlight registry, keyed by node key and rebuilt after
|
|
241
|
+
* every reconciliation. Nothing enters the document, so history, the outgoing
|
|
242
|
+
* HTML and the Markdown never see one.
|
|
243
|
+
*/
|
|
244
|
+
const SpellcheckPlugin = ({
|
|
245
|
+
language,
|
|
246
|
+
options,
|
|
247
|
+
onReady,
|
|
248
|
+
}: {
|
|
249
|
+
language: string;
|
|
250
|
+
options: SpellcheckOptions;
|
|
251
|
+
onReady: (ready: boolean) => void;
|
|
252
|
+
}) => {
|
|
253
|
+
const [editor] = useLexicalComposerContext();
|
|
254
|
+
const settings = useRef(options);
|
|
255
|
+
const report = useRef(onReady);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
settings.current = options;
|
|
259
|
+
report.current = onReady;
|
|
260
|
+
}, [options, onReady]);
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
if (!marksSupported()) return;
|
|
264
|
+
const painter = Symbol(SPELLCHECK_HIGHLIGHT);
|
|
265
|
+
const found = new Map<string, readonly Finding[]>();
|
|
266
|
+
const touched = new Set<string>();
|
|
267
|
+
let provider: SpellProvider | null = null;
|
|
268
|
+
let unsubscribe: (() => void) | undefined;
|
|
269
|
+
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
270
|
+
let live = true;
|
|
271
|
+
let state: ProviderStatus["state"] = "opening";
|
|
272
|
+
let revision = 0;
|
|
273
|
+
let asked = 0;
|
|
274
|
+
|
|
275
|
+
const repaint = (): void => {
|
|
276
|
+
const ranges: Range[] = [];
|
|
277
|
+
editor.read(() => {
|
|
278
|
+
const selection = $getSelection();
|
|
279
|
+
const caret =
|
|
280
|
+
$isRangeSelection(selection) && selection.isCollapsed()
|
|
281
|
+
? selection.anchor
|
|
282
|
+
: null;
|
|
283
|
+
for (const [key, findings] of found) {
|
|
284
|
+
const node = $getNodeByKey(key);
|
|
285
|
+
if (!$isTextNode(node)) {
|
|
286
|
+
found.delete(key);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const element = editor.getElementByKey(key);
|
|
290
|
+
const text = element ? leafCharacters(element) : null;
|
|
291
|
+
if (!text) continue;
|
|
292
|
+
const length = text.textContent?.length ?? 0;
|
|
293
|
+
for (const finding of findings) {
|
|
294
|
+
if (finding.end > length) continue;
|
|
295
|
+
if (
|
|
296
|
+
caret?.key === key &&
|
|
297
|
+
caret.offset > finding.start &&
|
|
298
|
+
caret.offset <= finding.end
|
|
299
|
+
)
|
|
300
|
+
continue;
|
|
301
|
+
const range = text.ownerDocument?.createRange();
|
|
302
|
+
if (!range) continue;
|
|
303
|
+
range.setStart(text, finding.start);
|
|
304
|
+
range.setEnd(text, finding.end);
|
|
305
|
+
ranges.push(range);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
paintMarks(painter, ranges);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const check = (): void => {
|
|
313
|
+
// Anything queued while the provider is not answering stays queued: the
|
|
314
|
+
// next status that says ready sends it.
|
|
315
|
+
if (!provider || state !== "ready") return;
|
|
316
|
+
const keys = [...touched];
|
|
317
|
+
touched.clear();
|
|
318
|
+
const spans: CheckSpan[] = [];
|
|
319
|
+
editor.read(() => {
|
|
320
|
+
for (const key of keys) {
|
|
321
|
+
const node = $getNodeByKey(key);
|
|
322
|
+
if (!$isTextNode(node)) {
|
|
323
|
+
found.delete(key);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
spans.push({ spanId: key, text: node.getTextContent() });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
if (spans.length === 0) {
|
|
330
|
+
repaint();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
asked += 1;
|
|
334
|
+
const sent = revision;
|
|
335
|
+
provider
|
|
336
|
+
.check({ requestId: `${asked}`, language, revision: sent, spans })
|
|
337
|
+
.then((response) => {
|
|
338
|
+
if (!live) return;
|
|
339
|
+
// The text moved while this was in flight, so the offsets are
|
|
340
|
+
// answers to a document that no longer exists. The leaves go back
|
|
341
|
+
// on the queue: dropping them here would leave them unchecked
|
|
342
|
+
// until the writer happened to touch them again.
|
|
343
|
+
if (response.revision !== revision) {
|
|
344
|
+
for (const span of spans) touched.add(span.spanId);
|
|
345
|
+
schedule();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const grouped = new Map<string, Finding[]>();
|
|
349
|
+
for (const finding of response.findings) {
|
|
350
|
+
const list = grouped.get(finding.spanId);
|
|
351
|
+
if (list) {
|
|
352
|
+
list.push(finding);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
grouped.set(finding.spanId, [finding]);
|
|
356
|
+
}
|
|
357
|
+
for (const span of spans) found.delete(span.spanId);
|
|
358
|
+
for (const [key, findings] of grouped) found.set(key, findings);
|
|
359
|
+
repaint();
|
|
360
|
+
});
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const schedule = (): void => {
|
|
364
|
+
clearTimeout(idle);
|
|
365
|
+
idle = setTimeout(check, SPELLCHECK_IDLE_MS);
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const unregister = editor.registerUpdateListener(({ dirtyLeaves }) => {
|
|
369
|
+
if (dirtyLeaves.size === 0) {
|
|
370
|
+
repaint();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
revision += 1;
|
|
374
|
+
// Characters moved under the findings this leaf carries, so they are
|
|
375
|
+
// answers about text that is no longer there. They go now rather than
|
|
376
|
+
// sitting over the wrong letters until the next answer arrives.
|
|
377
|
+
for (const key of dirtyLeaves) {
|
|
378
|
+
found.delete(key);
|
|
379
|
+
touched.add(key);
|
|
380
|
+
}
|
|
381
|
+
repaint();
|
|
382
|
+
schedule();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
settings.current.provider(language).then((opened) => {
|
|
386
|
+
if (!live) {
|
|
387
|
+
opened?.close();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
provider = opened;
|
|
391
|
+
if (!opened) {
|
|
392
|
+
settings.current.onStatus?.({ state: "unavailable", language });
|
|
393
|
+
report.current(false);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
unsubscribe = opened.onStatus((status) => {
|
|
397
|
+
state = status.state;
|
|
398
|
+
settings.current.onStatus?.(status);
|
|
399
|
+
const ready = status.state === "ready";
|
|
400
|
+
report.current(ready);
|
|
401
|
+
if (ready) {
|
|
402
|
+
schedule();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Checking stopped, so the browser's own is back. Its squiggles are
|
|
406
|
+
// the only ones on screen from here.
|
|
407
|
+
found.clear();
|
|
408
|
+
touched.clear();
|
|
409
|
+
repaint();
|
|
410
|
+
});
|
|
411
|
+
// The document the editor opened on was reconciled before this
|
|
412
|
+
// listener existed, so its leaves are checked here rather than waiting
|
|
413
|
+
// for an edit that may never come.
|
|
414
|
+
editor.read(() => {
|
|
415
|
+
for (const node of $getRoot().getAllTextNodes())
|
|
416
|
+
touched.add(node.getKey());
|
|
417
|
+
});
|
|
418
|
+
schedule();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
return () => {
|
|
422
|
+
live = false;
|
|
423
|
+
clearTimeout(idle);
|
|
424
|
+
unregister();
|
|
425
|
+
unsubscribe?.();
|
|
426
|
+
provider?.close();
|
|
427
|
+
provider = null;
|
|
428
|
+
report.current(false);
|
|
429
|
+
paintMarks(painter, []);
|
|
430
|
+
};
|
|
431
|
+
}, [editor, language]);
|
|
432
|
+
|
|
433
|
+
return null;
|
|
434
|
+
};
|
|
435
|
+
|
|
144
436
|
const AutoFocus = ({ enabled }: { enabled: boolean }) => {
|
|
145
437
|
const [editor] = useLexicalComposerContext();
|
|
146
438
|
|
|
@@ -171,56 +463,75 @@ export const RichTextEditor = ({
|
|
|
171
463
|
ariaLabel = "Message body",
|
|
172
464
|
trailing,
|
|
173
465
|
lang,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
466
|
+
spellcheck,
|
|
467
|
+
}: RichTextEditorProps) => {
|
|
468
|
+
const [checkedHere, setCheckedHere] = useState(false);
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
<LexicalComposer
|
|
472
|
+
initialConfig={{
|
|
473
|
+
namespace: "compose",
|
|
474
|
+
nodes: RICH_TEXT_NODES,
|
|
475
|
+
theme: richTextTheme,
|
|
476
|
+
editorState: initialHtml ? seedDocument(initialHtml) : undefined,
|
|
477
|
+
onError: (error) => {
|
|
478
|
+
throw error;
|
|
479
|
+
},
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
{/* The editable claims the height its container offers rather than only the
|
|
483
|
+
height of its own text. What is under the last line is the document, so
|
|
484
|
+
a click there reaches it instead of an unfocusable parent. */}
|
|
485
|
+
<div className="flex shrink-0 grow flex-col">
|
|
486
|
+
<RichTextToolbar trailing={trailing} />
|
|
487
|
+
<div className="relative flex shrink-0 grow flex-col">
|
|
488
|
+
<RichTextPlugin
|
|
489
|
+
contentEditable={
|
|
490
|
+
<ContentEditable
|
|
491
|
+
lang={lang}
|
|
492
|
+
/* Two sets of squiggles never coexist: the browser stops
|
|
493
|
+
checking exactly while a provider of ours is ready, and
|
|
494
|
+
checks again the moment one is not. */
|
|
495
|
+
spellCheck={spellcheck ? !checkedHere : undefined}
|
|
496
|
+
aria-label={ariaLabel}
|
|
497
|
+
aria-placeholder={placeholder}
|
|
498
|
+
data-testid="compose-body"
|
|
499
|
+
className="min-h-[120px] w-full shrink-0 grow bg-canvas px-3 py-2 text-sm text-fg outline-none"
|
|
500
|
+
placeholder={
|
|
501
|
+
<div className="pointer-events-none absolute inset-x-0 top-0 px-3 py-2 text-sm text-fg-subtle">
|
|
502
|
+
{placeholder}
|
|
503
|
+
</div>
|
|
504
|
+
}
|
|
505
|
+
onKeyDown={(event) => {
|
|
506
|
+
if (!onSubmit) return;
|
|
507
|
+
if (
|
|
508
|
+
!(event.metaKey || event.ctrlKey) ||
|
|
509
|
+
event.key !== "Enter"
|
|
510
|
+
)
|
|
511
|
+
return;
|
|
512
|
+
event.preventDefault();
|
|
513
|
+
onSubmit();
|
|
514
|
+
}}
|
|
515
|
+
/>
|
|
516
|
+
}
|
|
517
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
518
|
+
/>
|
|
519
|
+
</div>
|
|
216
520
|
</div>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
521
|
+
<HistoryPlugin />
|
|
522
|
+
<ListPlugin />
|
|
523
|
+
<LinkPlugin />
|
|
524
|
+
<TablePlugin />
|
|
525
|
+
<PastePlugin />
|
|
526
|
+
<AutoFocus enabled={autoFocus} />
|
|
527
|
+
{onChange && <ChangePlugin onChange={onChange} />}
|
|
528
|
+
{spellcheck && lang ? (
|
|
529
|
+
<SpellcheckPlugin
|
|
530
|
+
language={lang}
|
|
531
|
+
options={spellcheck}
|
|
532
|
+
onReady={setCheckedHere}
|
|
533
|
+
/>
|
|
534
|
+
) : null}
|
|
535
|
+
</LexicalComposer>
|
|
536
|
+
);
|
|
537
|
+
};
|