@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
|
@@ -9,6 +9,7 @@ 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
|
+
$getNearestNodeFromDOMNode,
|
|
12
13
|
$getNodeByKey,
|
|
13
14
|
$getRoot,
|
|
14
15
|
$getSelection,
|
|
@@ -17,11 +18,19 @@ import {
|
|
|
17
18
|
$isTextNode,
|
|
18
19
|
COMMAND_PRIORITY_CRITICAL,
|
|
19
20
|
COMMAND_PRIORITY_LOW,
|
|
21
|
+
HISTORY_PUSH_TAG,
|
|
20
22
|
KEY_DOWN_COMMAND,
|
|
21
23
|
type LexicalEditor,
|
|
22
24
|
PASTE_COMMAND,
|
|
23
25
|
} from "lexical";
|
|
24
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type RefObject,
|
|
28
|
+
useCallback,
|
|
29
|
+
useEffect,
|
|
30
|
+
useRef,
|
|
31
|
+
useState,
|
|
32
|
+
} from "react";
|
|
33
|
+
import { RichTextCorrectionMenu } from "./rich-text-correction-menu.js";
|
|
25
34
|
import { $adoptHtml, $readRichText } from "./rich-text-document.js";
|
|
26
35
|
import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
|
|
27
36
|
import type {
|
|
@@ -31,6 +40,10 @@ import type {
|
|
|
31
40
|
SpellcheckOptions,
|
|
32
41
|
SpellProvider,
|
|
33
42
|
} from "./rich-text-spellcheck.js";
|
|
43
|
+
import {
|
|
44
|
+
normaliseWord,
|
|
45
|
+
SUGGESTION_LIMIT,
|
|
46
|
+
} from "./rich-text-spellcheck-words.js";
|
|
34
47
|
import { RichTextToolbar } from "./rich-text-toolbar.js";
|
|
35
48
|
import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
|
|
36
49
|
|
|
@@ -237,28 +250,153 @@ const leafCharacters = (node: Node): Node | null => {
|
|
|
237
250
|
return null;
|
|
238
251
|
};
|
|
239
252
|
|
|
253
|
+
/** The characters a finding covers, as the browser draws them. */
|
|
254
|
+
const leafRange = (
|
|
255
|
+
editor: LexicalEditor,
|
|
256
|
+
key: string,
|
|
257
|
+
start: number,
|
|
258
|
+
end: number,
|
|
259
|
+
): Range | null => {
|
|
260
|
+
const element = editor.getElementByKey(key);
|
|
261
|
+
const characters = element ? leafCharacters(element) : null;
|
|
262
|
+
if (!characters) return null;
|
|
263
|
+
if (end > (characters.textContent?.length ?? 0)) return null;
|
|
264
|
+
const range = characters.ownerDocument?.createRange();
|
|
265
|
+
if (!range) return null;
|
|
266
|
+
range.setStart(characters, start);
|
|
267
|
+
range.setEnd(characters, end);
|
|
268
|
+
return range;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Where a pointer landed, in characters. `caretPositionFromPoint` is the
|
|
273
|
+
* standard spelling and `caretRangeFromPoint` the one WebKit shipped; without
|
|
274
|
+
* either, the browser has already moved the caret to the point and the
|
|
275
|
+
* selection says where.
|
|
276
|
+
*/
|
|
277
|
+
interface CaretDocument {
|
|
278
|
+
caretPositionFromPoint?(
|
|
279
|
+
x: number,
|
|
280
|
+
y: number,
|
|
281
|
+
): { offsetNode: Node; offset: number } | null;
|
|
282
|
+
caretRangeFromPoint?(x: number, y: number): Range | null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const characterAt = (
|
|
286
|
+
owner: Document,
|
|
287
|
+
x: number,
|
|
288
|
+
y: number,
|
|
289
|
+
): { node: Node; offset: number } | null => {
|
|
290
|
+
const caret = owner as unknown as CaretDocument;
|
|
291
|
+
const position = caret.caretPositionFromPoint?.(x, y);
|
|
292
|
+
if (position) return { node: position.offsetNode, offset: position.offset };
|
|
293
|
+
const range = caret.caretRangeFromPoint?.(x, y);
|
|
294
|
+
if (range) return { node: range.startContainer, offset: range.startOffset };
|
|
295
|
+
const selection = owner.getSelection();
|
|
296
|
+
if (!selection?.focusNode) return null;
|
|
297
|
+
return { node: selection.focusNode, offset: selection.focusOffset };
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
interface CorrectionTarget {
|
|
301
|
+
readonly key: string;
|
|
302
|
+
readonly start: number;
|
|
303
|
+
readonly end: number;
|
|
304
|
+
readonly word: string;
|
|
305
|
+
/** Viewport-relative, straight off the word's own range — the menu portals
|
|
306
|
+
* to the document body, so nothing here is relative to the editor. */
|
|
307
|
+
readonly left: number;
|
|
308
|
+
readonly right: number;
|
|
309
|
+
readonly top: number;
|
|
310
|
+
readonly bottom: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A caret sits between characters and a pointer lands on one, so the two ask
|
|
315
|
+
* different questions of the same range. The caret is inside the word from the
|
|
316
|
+
* first character to just after the last — which is also what goes unmarked
|
|
317
|
+
* while it is being written, so the chord opens exactly the word the squiggle
|
|
318
|
+
* is being withheld from. A point belongs to a character, and the position
|
|
319
|
+
* after the last one is already the space that follows.
|
|
320
|
+
*/
|
|
321
|
+
const coversCaret = (finding: Finding, offset: number): boolean =>
|
|
322
|
+
offset > finding.start && offset <= finding.end;
|
|
323
|
+
|
|
324
|
+
const coversCharacter = (finding: Finding, offset: number): boolean =>
|
|
325
|
+
offset >= finding.start && offset < finding.end;
|
|
326
|
+
|
|
327
|
+
/** How far a pointer may travel and still have been put down, not dragged. */
|
|
328
|
+
const TAP_SLOP_PX = 6;
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Drops what the session has been told to leave alone. Read against the text as
|
|
332
|
+
* it stands now, so a word ignored at one place in the document loses its marks
|
|
333
|
+
* everywhere it appears.
|
|
334
|
+
*/
|
|
335
|
+
const forgetIgnored = (
|
|
336
|
+
editor: LexicalEditor,
|
|
337
|
+
found: Map<string, readonly Finding[]>,
|
|
338
|
+
ignored: ReadonlySet<string>,
|
|
339
|
+
): void => {
|
|
340
|
+
editor.read(() => {
|
|
341
|
+
for (const [key, findings] of found) {
|
|
342
|
+
const node = $getNodeByKey(key);
|
|
343
|
+
if (!$isTextNode(node)) continue;
|
|
344
|
+
const text = node.getTextContent();
|
|
345
|
+
const kept = findings.filter(
|
|
346
|
+
(finding) =>
|
|
347
|
+
!ignored.has(normaliseWord(text.slice(finding.start, finding.end))),
|
|
348
|
+
);
|
|
349
|
+
if (kept.length === findings.length) continue;
|
|
350
|
+
if (kept.length === 0) {
|
|
351
|
+
found.delete(key);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
found.set(key, kept);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
|
|
240
359
|
/**
|
|
241
|
-
* Marks
|
|
242
|
-
* of a second after the typing stops, so a word is not called wrong
|
|
243
|
-
* still being written — and the word the caret sits in is never
|
|
244
|
-
* An answer carrying a revision the document has moved past is
|
|
360
|
+
* Marks and their corrections. Only the leaves an edit touched are sent, a
|
|
361
|
+
* quarter of a second after the typing stops, so a word is not called wrong
|
|
362
|
+
* while it is still being written — and the word the caret sits in is never
|
|
363
|
+
* marked at all. An answer carrying a revision the document has moved past is
|
|
364
|
+
* dropped.
|
|
245
365
|
*
|
|
246
366
|
* The marks live in the highlight registry, keyed by node key and rebuilt after
|
|
247
367
|
* every reconciliation. Nothing enters the document, so history, the outgoing
|
|
248
368
|
* HTML and the Markdown never see one.
|
|
369
|
+
*
|
|
370
|
+
* A click, a tap or the chord on a marked word opens the correction menu over
|
|
371
|
+
* the findings already held here; the suggestions themselves are asked for one
|
|
372
|
+
* word at a time, when the menu opens. The right button is left to the browser.
|
|
249
373
|
*/
|
|
250
374
|
const SpellcheckPlugin = ({
|
|
251
375
|
language,
|
|
252
376
|
options,
|
|
377
|
+
hostRef,
|
|
253
378
|
onReady,
|
|
254
379
|
}: {
|
|
255
380
|
language: string;
|
|
256
381
|
options: SpellcheckOptions;
|
|
382
|
+
hostRef: RefObject<HTMLDivElement | null>;
|
|
257
383
|
onReady: (ready: boolean) => void;
|
|
258
384
|
}) => {
|
|
259
385
|
const [editor] = useLexicalComposerContext();
|
|
260
386
|
const settings = useRef(options);
|
|
261
387
|
const report = useRef(onReady);
|
|
388
|
+
const found = useRef(new Map<string, readonly Finding[]>());
|
|
389
|
+
// Session-scoped by construction: the set belongs to this mount and goes
|
|
390
|
+
// with it, and nothing writes it anywhere (#707, decision 10).
|
|
391
|
+
const ignored = useRef(new Set<string>());
|
|
392
|
+
const checker = useRef<SpellProvider | null>(null);
|
|
393
|
+
const repaint = useRef<() => void>(() => {});
|
|
394
|
+
const asked = useRef(0);
|
|
395
|
+
const [target, setTarget] = useState<CorrectionTarget | null>(null);
|
|
396
|
+
const [suggestions, setSuggestions] = useState<readonly string[] | null>(
|
|
397
|
+
null,
|
|
398
|
+
);
|
|
399
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
262
400
|
|
|
263
401
|
useEffect(() => {
|
|
264
402
|
settings.current = options;
|
|
@@ -268,56 +406,52 @@ const SpellcheckPlugin = ({
|
|
|
268
406
|
useEffect(() => {
|
|
269
407
|
if (!marksSupported()) return;
|
|
270
408
|
const painter = Symbol(SPELLCHECK_HIGHLIGHT);
|
|
271
|
-
const
|
|
409
|
+
const findings = found.current;
|
|
272
410
|
const touched = new Set<string>();
|
|
273
|
-
let provider: SpellProvider | null = null;
|
|
274
411
|
let unsubscribe: (() => void) | undefined;
|
|
275
412
|
let idle: ReturnType<typeof setTimeout> | undefined;
|
|
276
413
|
let live = true;
|
|
277
414
|
let state: ProviderStatus["state"] = "opening";
|
|
278
415
|
let revision = 0;
|
|
279
|
-
let
|
|
280
|
-
|
|
281
|
-
|
|
416
|
+
let passes = 0;
|
|
417
|
+
let pressed: { x: number; y: number } | null = null;
|
|
418
|
+
// The caret withholds the mark of the word it sits in, so a word is not
|
|
419
|
+
// called wrong while it is still being written. A caret a pointer put
|
|
420
|
+
// down is reading rather than writing: a click on a squiggle is how the
|
|
421
|
+
// corrections open, and it must not take the squiggle with it. The next
|
|
422
|
+
// key is the writer back at the text, and the withholding with them.
|
|
423
|
+
let pointed = false;
|
|
424
|
+
|
|
425
|
+
const paint = (): void => {
|
|
282
426
|
const ranges: Range[] = [];
|
|
283
427
|
editor.read(() => {
|
|
284
428
|
const selection = $getSelection();
|
|
285
429
|
const caret =
|
|
286
|
-
$isRangeSelection(selection) && selection.isCollapsed()
|
|
430
|
+
!pointed && $isRangeSelection(selection) && selection.isCollapsed()
|
|
287
431
|
? selection.anchor
|
|
288
432
|
: null;
|
|
289
|
-
for (const [key,
|
|
433
|
+
for (const [key, spanFindings] of findings) {
|
|
290
434
|
const node = $getNodeByKey(key);
|
|
291
435
|
if (!$isTextNode(node)) {
|
|
292
|
-
|
|
436
|
+
findings.delete(key);
|
|
293
437
|
continue;
|
|
294
438
|
}
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
if (!text) continue;
|
|
298
|
-
const length = text.textContent?.length ?? 0;
|
|
299
|
-
for (const finding of findings) {
|
|
300
|
-
if (finding.end > length) continue;
|
|
301
|
-
if (
|
|
302
|
-
caret?.key === key &&
|
|
303
|
-
caret.offset > finding.start &&
|
|
304
|
-
caret.offset <= finding.end
|
|
305
|
-
)
|
|
439
|
+
for (const finding of spanFindings) {
|
|
440
|
+
if (caret?.key === key && coversCaret(finding, caret.offset))
|
|
306
441
|
continue;
|
|
307
|
-
const range =
|
|
308
|
-
if (
|
|
309
|
-
range.setStart(text, finding.start);
|
|
310
|
-
range.setEnd(text, finding.end);
|
|
311
|
-
ranges.push(range);
|
|
442
|
+
const range = leafRange(editor, key, finding.start, finding.end);
|
|
443
|
+
if (range) ranges.push(range);
|
|
312
444
|
}
|
|
313
445
|
}
|
|
314
446
|
});
|
|
315
447
|
paintMarks(painter, ranges);
|
|
316
448
|
};
|
|
449
|
+
repaint.current = paint;
|
|
317
450
|
|
|
318
451
|
const check = (): void => {
|
|
319
452
|
// Anything queued while the provider is not answering stays queued: the
|
|
320
453
|
// next status that says ready sends it.
|
|
454
|
+
const provider = checker.current;
|
|
321
455
|
if (!provider || state !== "ready") return;
|
|
322
456
|
const keys = [...touched];
|
|
323
457
|
touched.clear();
|
|
@@ -326,20 +460,20 @@ const SpellcheckPlugin = ({
|
|
|
326
460
|
for (const key of keys) {
|
|
327
461
|
const node = $getNodeByKey(key);
|
|
328
462
|
if (!$isTextNode(node)) {
|
|
329
|
-
|
|
463
|
+
findings.delete(key);
|
|
330
464
|
continue;
|
|
331
465
|
}
|
|
332
466
|
spans.push({ spanId: key, text: node.getTextContent() });
|
|
333
467
|
}
|
|
334
468
|
});
|
|
335
469
|
if (spans.length === 0) {
|
|
336
|
-
|
|
470
|
+
paint();
|
|
337
471
|
return;
|
|
338
472
|
}
|
|
339
|
-
|
|
473
|
+
passes += 1;
|
|
340
474
|
const sent = revision;
|
|
341
475
|
provider
|
|
342
|
-
.check({ requestId: `${
|
|
476
|
+
.check({ requestId: `${passes}`, language, revision: sent, spans })
|
|
343
477
|
.then((response) => {
|
|
344
478
|
if (!live) return;
|
|
345
479
|
// The text moved while this was in flight, so the offsets are
|
|
@@ -351,8 +485,13 @@ const SpellcheckPlugin = ({
|
|
|
351
485
|
schedule();
|
|
352
486
|
return;
|
|
353
487
|
}
|
|
488
|
+
const texts = new Map(spans.map((span) => [span.spanId, span.text]));
|
|
354
489
|
const grouped = new Map<string, Finding[]>();
|
|
355
490
|
for (const finding of response.findings) {
|
|
491
|
+
const text = texts.get(finding.spanId);
|
|
492
|
+
if (!text) continue;
|
|
493
|
+
const word = text.slice(finding.start, finding.end);
|
|
494
|
+
if (ignored.current.has(normaliseWord(word))) continue;
|
|
356
495
|
const list = grouped.get(finding.spanId);
|
|
357
496
|
if (list) {
|
|
358
497
|
list.push(finding);
|
|
@@ -360,9 +499,10 @@ const SpellcheckPlugin = ({
|
|
|
360
499
|
}
|
|
361
500
|
grouped.set(finding.spanId, [finding]);
|
|
362
501
|
}
|
|
363
|
-
for (const span of spans)
|
|
364
|
-
for (const [key,
|
|
365
|
-
|
|
502
|
+
for (const span of spans) findings.delete(span.spanId);
|
|
503
|
+
for (const [key, spanFindings] of grouped)
|
|
504
|
+
findings.set(key, spanFindings);
|
|
505
|
+
paint();
|
|
366
506
|
});
|
|
367
507
|
};
|
|
368
508
|
|
|
@@ -371,72 +511,326 @@ const SpellcheckPlugin = ({
|
|
|
371
511
|
idle = setTimeout(check, SPELLCHECK_IDLE_MS);
|
|
372
512
|
};
|
|
373
513
|
|
|
514
|
+
const openAt = (
|
|
515
|
+
key: string,
|
|
516
|
+
offset: number,
|
|
517
|
+
covers: (finding: Finding, offset: number) => boolean,
|
|
518
|
+
): boolean => {
|
|
519
|
+
const hit = (findings.get(key) ?? []).find((finding) =>
|
|
520
|
+
covers(finding, offset),
|
|
521
|
+
);
|
|
522
|
+
// The editor still has to be mounted for a click on its own text to mean
|
|
523
|
+
// anything, even though the menu itself no longer anchors to it.
|
|
524
|
+
if (!hit || !hostRef.current) return false;
|
|
525
|
+
const range = leafRange(editor, key, hit.start, hit.end);
|
|
526
|
+
const word = editor.read(() => {
|
|
527
|
+
const node = $getNodeByKey(key);
|
|
528
|
+
if (!$isTextNode(node)) return "";
|
|
529
|
+
return node.getTextContent().slice(hit.start, hit.end);
|
|
530
|
+
});
|
|
531
|
+
if (!range || !word) return false;
|
|
532
|
+
const box = range.getBoundingClientRect();
|
|
533
|
+
setTarget({
|
|
534
|
+
key,
|
|
535
|
+
start: hit.start,
|
|
536
|
+
end: hit.end,
|
|
537
|
+
word,
|
|
538
|
+
left: box.left,
|
|
539
|
+
right: box.right,
|
|
540
|
+
top: box.top,
|
|
541
|
+
bottom: box.bottom,
|
|
542
|
+
});
|
|
543
|
+
return true;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const openAtPoint = (x: number, y: number): boolean => {
|
|
547
|
+
const owner = editor.getRootElement()?.ownerDocument;
|
|
548
|
+
if (!owner) return false;
|
|
549
|
+
const character = characterAt(owner, x, y);
|
|
550
|
+
if (!character) return false;
|
|
551
|
+
const key = editor.read(() => {
|
|
552
|
+
const node = $getNearestNodeFromDOMNode(character.node);
|
|
553
|
+
return $isTextNode(node) ? node.getKey() : null;
|
|
554
|
+
});
|
|
555
|
+
if (!key) return false;
|
|
556
|
+
return openAt(key, character.offset, coversCharacter);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
560
|
+
pointed = true;
|
|
561
|
+
// The right button belongs to the browser's own menu, and a middle
|
|
562
|
+
// click is a paste on the platforms that have one.
|
|
563
|
+
pressed =
|
|
564
|
+
event.button === 0 ? { x: event.clientX, y: event.clientY } : null;
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* A pointer put down on a marked word and lifted where it landed, whether
|
|
569
|
+
* it was a finger or a mouse. A press that travelled was dragging out a
|
|
570
|
+
* selection, and a menu over the top of what it just selected is the one
|
|
571
|
+
* thing it must not get.
|
|
572
|
+
*/
|
|
573
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
574
|
+
const from = pressed;
|
|
575
|
+
pressed = null;
|
|
576
|
+
if (!from) return;
|
|
577
|
+
if (
|
|
578
|
+
Math.abs(event.clientX - from.x) > TAP_SLOP_PX ||
|
|
579
|
+
Math.abs(event.clientY - from.y) > TAP_SLOP_PX
|
|
580
|
+
)
|
|
581
|
+
return;
|
|
582
|
+
const selection = editor.getRootElement()?.ownerDocument.getSelection();
|
|
583
|
+
if (selection && !selection.isCollapsed) return;
|
|
584
|
+
openAtPoint(event.clientX, event.clientY);
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
const onPointerCancel = () => {
|
|
588
|
+
pressed = null;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Whatever the key turns out to do — walk the caret, or write a character
|
|
593
|
+
* — it is the writer taking the text back, so the word under the caret
|
|
594
|
+
* goes quiet again. The repaint is here rather than left to the edit: a
|
|
595
|
+
* caret walked across a marked word makes no edit at all.
|
|
596
|
+
*/
|
|
597
|
+
const onKeyDown = () => {
|
|
598
|
+
if (!pointed) return;
|
|
599
|
+
pointed = false;
|
|
600
|
+
paint();
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const unbindRoot = editor.registerRootListener((next, previous) => {
|
|
604
|
+
previous?.removeEventListener("pointerdown", onPointerDown);
|
|
605
|
+
previous?.removeEventListener("pointerup", onPointerUp);
|
|
606
|
+
previous?.removeEventListener("pointercancel", onPointerCancel);
|
|
607
|
+
previous?.removeEventListener("keydown", onKeyDown);
|
|
608
|
+
next?.addEventListener("pointerdown", onPointerDown);
|
|
609
|
+
next?.addEventListener("pointerup", onPointerUp);
|
|
610
|
+
next?.addEventListener("pointercancel", onPointerCancel);
|
|
611
|
+
next?.addEventListener("keydown", onKeyDown);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// The keyboard's way in. The word under the caret is deliberately
|
|
615
|
+
// unmarked, so this is the one path that has to read it anyway.
|
|
616
|
+
const unbindChord = editor.registerCommand(
|
|
617
|
+
KEY_DOWN_COMMAND,
|
|
618
|
+
(event) => {
|
|
619
|
+
if (!(event.metaKey || event.ctrlKey) || event.key !== ".")
|
|
620
|
+
return false;
|
|
621
|
+
const at = editor.read(() => {
|
|
622
|
+
const selection = $getSelection();
|
|
623
|
+
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
|
624
|
+
return null;
|
|
625
|
+
return {
|
|
626
|
+
key: selection.anchor.key,
|
|
627
|
+
offset: selection.anchor.offset,
|
|
628
|
+
};
|
|
629
|
+
});
|
|
630
|
+
if (!at || !openAt(at.key, at.offset, coversCaret)) return false;
|
|
631
|
+
event.preventDefault();
|
|
632
|
+
return true;
|
|
633
|
+
},
|
|
634
|
+
COMMAND_PRIORITY_LOW,
|
|
635
|
+
);
|
|
636
|
+
|
|
374
637
|
const unregister = editor.registerUpdateListener(({ dirtyLeaves }) => {
|
|
375
638
|
if (dirtyLeaves.size === 0) {
|
|
376
|
-
|
|
639
|
+
paint();
|
|
377
640
|
return;
|
|
378
641
|
}
|
|
379
642
|
revision += 1;
|
|
643
|
+
// An edit is writing whatever put the caret there, including a paste
|
|
644
|
+
// or a correction, neither of which arrives on a key.
|
|
645
|
+
pointed = false;
|
|
646
|
+
// The menu is about a word at an offset, and both have just moved.
|
|
647
|
+
setTarget(null);
|
|
380
648
|
// Characters moved under the findings this leaf carries, so they are
|
|
381
649
|
// answers about text that is no longer there. They go now rather than
|
|
382
650
|
// sitting over the wrong letters until the next answer arrives.
|
|
383
651
|
for (const key of dirtyLeaves) {
|
|
384
|
-
|
|
652
|
+
findings.delete(key);
|
|
385
653
|
touched.add(key);
|
|
386
654
|
}
|
|
387
|
-
|
|
655
|
+
paint();
|
|
388
656
|
schedule();
|
|
389
657
|
});
|
|
390
658
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
report.current(ready);
|
|
407
|
-
if (ready) {
|
|
408
|
-
schedule();
|
|
659
|
+
const stopped = (detail: string): void => {
|
|
660
|
+
settings.current.onStatus?.({
|
|
661
|
+
state: "failed",
|
|
662
|
+
language,
|
|
663
|
+
reason: "worker",
|
|
664
|
+
detail,
|
|
665
|
+
});
|
|
666
|
+
report.current(false);
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
settings.current
|
|
670
|
+
.provider(language)
|
|
671
|
+
.then((opened) => {
|
|
672
|
+
if (!live) {
|
|
673
|
+
opened?.close();
|
|
409
674
|
return;
|
|
410
675
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
676
|
+
checker.current = opened;
|
|
677
|
+
if (!opened) {
|
|
678
|
+
settings.current.onStatus?.({ state: "unavailable", language });
|
|
679
|
+
report.current(false);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
unsubscribe = opened.onStatus((status) => {
|
|
683
|
+
state = status.state;
|
|
684
|
+
settings.current.onStatus?.(status);
|
|
685
|
+
const ready = status.state === "ready";
|
|
686
|
+
report.current(ready);
|
|
687
|
+
if (ready) {
|
|
688
|
+
schedule();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// Checking stopped, so the browser's own is back. Its squiggles are
|
|
692
|
+
// the only ones on screen from here.
|
|
693
|
+
findings.clear();
|
|
694
|
+
touched.clear();
|
|
695
|
+
setTarget(null);
|
|
696
|
+
paint();
|
|
697
|
+
});
|
|
698
|
+
// The document the editor opened on was reconciled before this
|
|
699
|
+
// listener existed, so its leaves are checked here rather than waiting
|
|
700
|
+
// for an edit that may never come.
|
|
701
|
+
editor.read(() => {
|
|
702
|
+
for (const node of $getRoot().getAllTextNodes())
|
|
703
|
+
touched.add(node.getKey());
|
|
704
|
+
});
|
|
705
|
+
schedule();
|
|
706
|
+
})
|
|
707
|
+
.catch((error: unknown) => {
|
|
708
|
+
if (!live) return;
|
|
709
|
+
stopped(error instanceof Error ? error.message : String(error));
|
|
423
710
|
});
|
|
424
|
-
schedule();
|
|
425
|
-
});
|
|
426
711
|
|
|
427
712
|
return () => {
|
|
428
713
|
live = false;
|
|
429
714
|
clearTimeout(idle);
|
|
430
715
|
unregister();
|
|
716
|
+
unbindRoot();
|
|
717
|
+
unbindChord();
|
|
431
718
|
unsubscribe?.();
|
|
432
|
-
|
|
433
|
-
|
|
719
|
+
checker.current?.close();
|
|
720
|
+
checker.current = null;
|
|
721
|
+
findings.clear();
|
|
722
|
+
setTarget(null);
|
|
434
723
|
report.current(false);
|
|
435
724
|
paintMarks(painter, []);
|
|
436
725
|
};
|
|
437
|
-
}, [editor, language]);
|
|
726
|
+
}, [editor, language, hostRef]);
|
|
438
727
|
|
|
439
|
-
|
|
728
|
+
useEffect(() => {
|
|
729
|
+
if (!target) return;
|
|
730
|
+
setSuggestions(null);
|
|
731
|
+
setFailure(null);
|
|
732
|
+
const provider = checker.current;
|
|
733
|
+
if (!provider) {
|
|
734
|
+
setFailure("the checker is not running");
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
let live = true;
|
|
738
|
+
asked.current += 1;
|
|
739
|
+
provider
|
|
740
|
+
.suggest({
|
|
741
|
+
requestId: `suggest-${asked.current}`,
|
|
742
|
+
language,
|
|
743
|
+
word: target.word,
|
|
744
|
+
})
|
|
745
|
+
.then((answer) => {
|
|
746
|
+
if (live) setSuggestions(answer.suggestions.slice(0, SUGGESTION_LIMIT));
|
|
747
|
+
})
|
|
748
|
+
.catch((error: unknown) => {
|
|
749
|
+
if (!live) return;
|
|
750
|
+
setFailure(error instanceof Error ? error.message : String(error));
|
|
751
|
+
});
|
|
752
|
+
return () => {
|
|
753
|
+
live = false;
|
|
754
|
+
};
|
|
755
|
+
}, [target, language]);
|
|
756
|
+
|
|
757
|
+
const dismiss = useCallback(
|
|
758
|
+
(returnFocus: boolean) => {
|
|
759
|
+
setTarget(null);
|
|
760
|
+
// `editor.focus()` alone only moves the DOM caret when Lexical's own
|
|
761
|
+
// selection model already matches the live DOM selection; the caret
|
|
762
|
+
// the click left behind rarely does, and setting a Selection range
|
|
763
|
+
// inside a contenteditable is not itself what moves focus there. The
|
|
764
|
+
// explicit call is what actually lands the caret back in the message.
|
|
765
|
+
if (returnFocus) {
|
|
766
|
+
editor.focus();
|
|
767
|
+
editor.getRootElement()?.focus();
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
[editor],
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const forget = useCallback(
|
|
774
|
+
(word: string) => {
|
|
775
|
+
ignored.current.add(normaliseWord(word));
|
|
776
|
+
forgetIgnored(editor, found.current, ignored.current);
|
|
777
|
+
repaint.current();
|
|
778
|
+
dismiss(true);
|
|
779
|
+
},
|
|
780
|
+
[editor, dismiss],
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
if (!target) return null;
|
|
784
|
+
|
|
785
|
+
const replace = (suggestion: string) => {
|
|
786
|
+
// One entry, so one undo puts the misspelling back (#707, decision 9).
|
|
787
|
+
editor.update(
|
|
788
|
+
() => {
|
|
789
|
+
const node = $getNodeByKey(target.key);
|
|
790
|
+
if (!$isTextNode(node)) return;
|
|
791
|
+
// The offsets were read when the menu opened. An edit that landed
|
|
792
|
+
// between then and the click has moved them, and writing there would
|
|
793
|
+
// put the correction through the middle of a different word.
|
|
794
|
+
if (
|
|
795
|
+
node.getTextContent().slice(target.start, target.end) !== target.word
|
|
796
|
+
)
|
|
797
|
+
return;
|
|
798
|
+
const written = node.spliceText(
|
|
799
|
+
target.start,
|
|
800
|
+
target.end - target.start,
|
|
801
|
+
suggestion,
|
|
802
|
+
);
|
|
803
|
+
const caret = target.start + suggestion.length;
|
|
804
|
+
written.select(caret, caret);
|
|
805
|
+
},
|
|
806
|
+
{ tag: HISTORY_PUSH_TAG },
|
|
807
|
+
);
|
|
808
|
+
dismiss(true);
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const addWord = () => {
|
|
812
|
+
settings.current.onAddWord?.(target.word);
|
|
813
|
+
forget(target.word);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
return (
|
|
817
|
+
<RichTextCorrectionMenu
|
|
818
|
+
word={target.word}
|
|
819
|
+
suggestions={suggestions}
|
|
820
|
+
failure={failure}
|
|
821
|
+
anchor={{
|
|
822
|
+
left: target.left,
|
|
823
|
+
right: target.right,
|
|
824
|
+
top: target.top,
|
|
825
|
+
bottom: target.bottom,
|
|
826
|
+
}}
|
|
827
|
+
language={language}
|
|
828
|
+
onReplace={replace}
|
|
829
|
+
onIgnore={() => forget(target.word)}
|
|
830
|
+
onAddWord={options.onAddWord ? addWord : undefined}
|
|
831
|
+
onDismiss={dismiss}
|
|
832
|
+
/>
|
|
833
|
+
);
|
|
440
834
|
};
|
|
441
835
|
|
|
442
836
|
const AutoFocus = ({ caret }: { caret?: ComposeCaret }) => {
|
|
@@ -488,6 +882,7 @@ export const RichTextEditor = ({
|
|
|
488
882
|
spellcheck,
|
|
489
883
|
}: RichTextEditorProps) => {
|
|
490
884
|
const [checkedHere, setCheckedHere] = useState(false);
|
|
885
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
491
886
|
|
|
492
887
|
return (
|
|
493
888
|
<LexicalComposer
|
|
@@ -506,7 +901,7 @@ export const RichTextEditor = ({
|
|
|
506
901
|
a click there reaches it instead of an unfocusable parent. */}
|
|
507
902
|
<div className="flex shrink-0 grow flex-col">
|
|
508
903
|
<RichTextToolbar trailing={trailing} />
|
|
509
|
-
<div className="relative flex shrink-0 grow flex-col">
|
|
904
|
+
<div ref={bodyRef} className="relative flex shrink-0 grow flex-col">
|
|
510
905
|
<RichTextPlugin
|
|
511
906
|
contentEditable={
|
|
512
907
|
<ContentEditable
|
|
@@ -538,6 +933,17 @@ export const RichTextEditor = ({
|
|
|
538
933
|
}
|
|
539
934
|
ErrorBoundary={LexicalErrorBoundary}
|
|
540
935
|
/>
|
|
936
|
+
{/* Inside the body's own box: the popover is placed against the
|
|
937
|
+
word's rect, and the sheet rises from the bottom of the writing
|
|
938
|
+
surface rather than the bottom of whatever page holds it. */}
|
|
939
|
+
{spellcheck && lang ? (
|
|
940
|
+
<SpellcheckPlugin
|
|
941
|
+
language={lang}
|
|
942
|
+
options={spellcheck}
|
|
943
|
+
hostRef={bodyRef}
|
|
944
|
+
onReady={setCheckedHere}
|
|
945
|
+
/>
|
|
946
|
+
) : null}
|
|
541
947
|
</div>
|
|
542
948
|
</div>
|
|
543
949
|
<HistoryPlugin />
|
|
@@ -547,13 +953,6 @@ export const RichTextEditor = ({
|
|
|
547
953
|
<PastePlugin />
|
|
548
954
|
<AutoFocus caret={initialCaret} />
|
|
549
955
|
{onChange && <ChangePlugin onChange={onChange} />}
|
|
550
|
-
{spellcheck && lang ? (
|
|
551
|
-
<SpellcheckPlugin
|
|
552
|
-
language={lang}
|
|
553
|
-
options={spellcheck}
|
|
554
|
-
onReady={setCheckedHere}
|
|
555
|
-
/>
|
|
556
|
-
) : null}
|
|
557
956
|
</LexicalComposer>
|
|
558
957
|
);
|
|
559
958
|
};
|