@inshapardaz/likhari-react 0.1.28-dev.245 → 0.1.28-dev.254
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/dist/spellcheck/SpellHighlightPlugin.d.ts +5 -3
- package/dist/spellcheck/SpellHighlightPlugin.d.ts.map +1 -1
- package/dist/spellcheck/SpellHighlightPlugin.js +164 -28
- package/dist/spellcheck/spellcheckActions.d.ts +13 -0
- package/dist/spellcheck/spellcheckActions.d.ts.map +1 -1
- package/dist/spellcheck/spellcheckActions.js +35 -0
- package/package.json +1 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Underlines misspelled words the way the browser does, without changing the
|
|
3
|
-
* document
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* document. Only blocks that are in view are checked, a block a few seconds after
|
|
4
|
+
* it scrolls into view, and an edit is rechecked after typing pauses. Results are
|
|
5
|
+
* kept per block, so a change re-checks only the block it is in. Underlines are
|
|
6
|
+
* ranges set through the CSS Custom Highlight API. In browsers without it, nothing
|
|
7
|
+
* is underlined.
|
|
6
8
|
*/
|
|
7
9
|
export declare function SpellHighlightPlugin(): null;
|
|
8
10
|
//# sourceMappingURL=SpellHighlightPlugin.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SpellHighlightPlugin.d.ts","sourceRoot":"","sources":["../../src/spellcheck/SpellHighlightPlugin.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SpellHighlightPlugin.d.ts","sourceRoot":"","sources":["../../src/spellcheck/SpellHighlightPlugin.tsx"],"names":[],"mappings":"AAsBA;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,SAoJnC"}
|
|
@@ -1,60 +1,196 @@
|
|
|
1
1
|
import { useEffect } from 'react';
|
|
2
2
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
3
|
-
import { $
|
|
3
|
+
import { $getNodeByKey, $getRoot, $isElementNode } from 'lexical';
|
|
4
|
+
import { $misspellingsInBlock } from './spellcheckActions.js';
|
|
4
5
|
import { getSpeller } from './spellDictionaries.js';
|
|
5
6
|
import { onSpellWordsChange } from './userWords.js';
|
|
6
7
|
/** The CSS Custom Highlight name; editor.css styles it as a red wavy underline. */
|
|
7
8
|
const HIGHLIGHT = 'likhari-spelling';
|
|
8
|
-
|
|
9
|
+
/** A block that has just come into view is checked after this long, so scrolling past it costs nothing. */
|
|
10
|
+
const VIEW_DELAY_MS = 2000;
|
|
11
|
+
/** An edit inside a visible block is checked after the typing pauses for this long. */
|
|
12
|
+
const TYPING_DELAY_MS = 300;
|
|
13
|
+
/** The top-level block (paragraph, table, list...) that holds a node, or null. */
|
|
14
|
+
function $topLevelKey(node) {
|
|
15
|
+
const root = $getRoot();
|
|
16
|
+
let current = node;
|
|
17
|
+
while (current.getParent() && !current.getParent().is(root))
|
|
18
|
+
current = current.getParent();
|
|
19
|
+
return current.getParent() ? current.getKey() : null;
|
|
20
|
+
}
|
|
9
21
|
/**
|
|
10
22
|
* Underlines misspelled words the way the browser does, without changing the
|
|
11
|
-
* document
|
|
12
|
-
*
|
|
13
|
-
*
|
|
23
|
+
* document. Only blocks that are in view are checked, a block a few seconds after
|
|
24
|
+
* it scrolls into view, and an edit is rechecked after typing pauses. Results are
|
|
25
|
+
* kept per block, so a change re-checks only the block it is in. Underlines are
|
|
26
|
+
* ranges set through the CSS Custom Highlight API. In browsers without it, nothing
|
|
27
|
+
* is underlined.
|
|
14
28
|
*/
|
|
15
29
|
export function SpellHighlightPlugin() {
|
|
16
30
|
const [editor] = useLexicalComposerContext();
|
|
17
31
|
useEffect(() => {
|
|
18
|
-
if (typeof CSS === 'undefined' || !('highlights' in CSS))
|
|
32
|
+
if (typeof CSS === 'undefined' || !('highlights' in CSS) || typeof IntersectionObserver === 'undefined')
|
|
19
33
|
return;
|
|
20
34
|
let checkers = null;
|
|
21
|
-
let timer;
|
|
22
35
|
let active = true;
|
|
36
|
+
const cache = new Map();
|
|
37
|
+
const visible = new Set();
|
|
38
|
+
const elements = new Map();
|
|
39
|
+
const keyOf = new Map();
|
|
40
|
+
const timers = new Map();
|
|
23
41
|
const paint = () => {
|
|
24
|
-
if (!active || !checkers)
|
|
25
|
-
return;
|
|
26
|
-
const found = editor.getEditorState().read(() => $collectMisspellings(checkers));
|
|
27
42
|
const ranges = [];
|
|
28
|
-
for (const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
for (const [key, words] of cache) {
|
|
44
|
+
const block = editor.getElementByKey(key);
|
|
45
|
+
if (!block || words.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
const pieces = textPieces(block);
|
|
48
|
+
for (const word of words) {
|
|
49
|
+
for (const piece of pieces) {
|
|
50
|
+
const start = Math.max(word.start, piece.start);
|
|
51
|
+
const end = Math.min(word.end, piece.end);
|
|
52
|
+
if (end <= start)
|
|
53
|
+
continue;
|
|
54
|
+
const range = document.createRange();
|
|
55
|
+
range.setStart(piece.node, start - piece.start);
|
|
56
|
+
range.setEnd(piece.node, end - piece.start);
|
|
57
|
+
ranges.push(range);
|
|
58
|
+
}
|
|
37
59
|
}
|
|
38
60
|
}
|
|
39
61
|
CSS.highlights.set(HIGHLIGHT, new Highlight(...ranges));
|
|
40
62
|
};
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
const checkBlock = (key) => {
|
|
64
|
+
if (!active || !checkers)
|
|
65
|
+
return;
|
|
66
|
+
const words = editor.getEditorState().read(() => {
|
|
67
|
+
const node = $getNodeByKey(key);
|
|
68
|
+
return $isElementNode(node) ? $misspellingsInBlock(node, checkers) : null;
|
|
69
|
+
});
|
|
70
|
+
if (words === null)
|
|
71
|
+
cache.delete(key);
|
|
72
|
+
else
|
|
73
|
+
cache.set(key, words);
|
|
74
|
+
paint();
|
|
75
|
+
};
|
|
76
|
+
const schedule = (key, delay) => {
|
|
77
|
+
clearTimeout(timers.get(key));
|
|
78
|
+
timers.set(key, setTimeout(() => {
|
|
79
|
+
timers.delete(key);
|
|
80
|
+
if (visible.has(key))
|
|
81
|
+
checkBlock(key);
|
|
82
|
+
}, delay));
|
|
83
|
+
};
|
|
84
|
+
const observer = new IntersectionObserver((entries) => {
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const key = keyOf.get(entry.target);
|
|
87
|
+
if (key === undefined)
|
|
88
|
+
continue;
|
|
89
|
+
if (entry.isIntersecting) {
|
|
90
|
+
visible.add(key);
|
|
91
|
+
if (!cache.has(key))
|
|
92
|
+
schedule(key, VIEW_DELAY_MS);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
visible.delete(key);
|
|
96
|
+
clearTimeout(timers.get(key));
|
|
97
|
+
timers.delete(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Keeps the observer on exactly the current top-level blocks.
|
|
102
|
+
const syncBlocks = (editorState = editor.getEditorState()) => {
|
|
103
|
+
const current = new Map();
|
|
104
|
+
editorState.read(() => {
|
|
105
|
+
for (const block of $getRoot().getChildren()) {
|
|
106
|
+
const element = editor.getElementByKey(block.getKey());
|
|
107
|
+
if (element)
|
|
108
|
+
current.set(block.getKey(), element);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
for (const [key, element] of elements) {
|
|
112
|
+
if (current.get(key) !== element) {
|
|
113
|
+
observer.unobserve(element);
|
|
114
|
+
keyOf.delete(element);
|
|
115
|
+
elements.delete(key);
|
|
116
|
+
visible.delete(key);
|
|
117
|
+
cache.delete(key);
|
|
118
|
+
clearTimeout(timers.get(key));
|
|
119
|
+
timers.delete(key);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [key, element] of current) {
|
|
123
|
+
if (!elements.has(key)) {
|
|
124
|
+
elements.set(key, element);
|
|
125
|
+
keyOf.set(element, key);
|
|
126
|
+
observer.observe(element);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
44
129
|
};
|
|
45
|
-
const
|
|
46
|
-
|
|
130
|
+
const unregisterUpdate = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => {
|
|
131
|
+
if (!checkers)
|
|
132
|
+
return;
|
|
133
|
+
const dirty = new Set();
|
|
134
|
+
editorState.read(() => {
|
|
135
|
+
for (const key of dirtyLeaves) {
|
|
136
|
+
const node = $getNodeByKey(key);
|
|
137
|
+
const top = node ? $topLevelKey(node) : null;
|
|
138
|
+
if (top)
|
|
139
|
+
dirty.add(top);
|
|
140
|
+
}
|
|
141
|
+
for (const key of dirtyElements.keys()) {
|
|
142
|
+
const node = $getNodeByKey(key);
|
|
143
|
+
const top = node ? $topLevelKey(node) : null;
|
|
144
|
+
if (top)
|
|
145
|
+
dirty.add(top);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
syncBlocks(editorState);
|
|
149
|
+
for (const key of dirty) {
|
|
150
|
+
if (visible.has(key))
|
|
151
|
+
schedule(key, TYPING_DELAY_MS);
|
|
152
|
+
else
|
|
153
|
+
cache.delete(key);
|
|
154
|
+
}
|
|
155
|
+
if (dirty.size > 0)
|
|
156
|
+
paint();
|
|
157
|
+
});
|
|
158
|
+
const unsubscribeWords = onSpellWordsChange(() => {
|
|
159
|
+
cache.clear();
|
|
160
|
+
for (const key of visible)
|
|
161
|
+
schedule(key, 0);
|
|
162
|
+
paint();
|
|
163
|
+
});
|
|
47
164
|
Promise.all([getSpeller('en'), getSpeller('ur')]).then(async ([en, ur]) => {
|
|
165
|
+
if (!active)
|
|
166
|
+
return;
|
|
48
167
|
checkers = { ltr: en ? await en : undefined, rtl: ur ? await ur : undefined };
|
|
49
|
-
|
|
168
|
+
syncBlocks();
|
|
169
|
+
for (const key of visible)
|
|
170
|
+
if (!cache.has(key))
|
|
171
|
+
schedule(key, 0);
|
|
50
172
|
});
|
|
51
173
|
return () => {
|
|
52
174
|
active = false;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
175
|
+
observer.disconnect();
|
|
176
|
+
unregisterUpdate();
|
|
177
|
+
unsubscribeWords();
|
|
178
|
+
for (const timer of timers.values())
|
|
179
|
+
clearTimeout(timer);
|
|
56
180
|
CSS.highlights.delete(HIGHLIGHT);
|
|
57
181
|
};
|
|
58
182
|
}, [editor]);
|
|
59
183
|
return null;
|
|
60
184
|
}
|
|
185
|
+
/** The text nodes of a rendered block, with where each one starts in the block's text. */
|
|
186
|
+
function textPieces(block) {
|
|
187
|
+
const pieces = [];
|
|
188
|
+
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
191
|
+
const text = node;
|
|
192
|
+
pieces.push({ node: text, start: offset, end: offset + text.length });
|
|
193
|
+
offset += text.length;
|
|
194
|
+
}
|
|
195
|
+
return pieces;
|
|
196
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ElementNode } from 'lexical';
|
|
1
2
|
import { type FindMatch } from '../find/findReplaceActions.js';
|
|
2
3
|
import { type Speller } from './spellDictionaries.js';
|
|
3
4
|
/** The checker for each text direction: English for LTR blocks, Urdu for RTL. */
|
|
@@ -18,4 +19,16 @@ export interface Misspelling {
|
|
|
18
19
|
}
|
|
19
20
|
/** Every word in the document its block's checker does not recognise. */
|
|
20
21
|
export declare function $collectMisspellings(checkers: Checkers): Misspelling[];
|
|
22
|
+
/** A misspelled word inside a block, as offsets into the block's text. */
|
|
23
|
+
export interface BlockMisspelling {
|
|
24
|
+
start: number;
|
|
25
|
+
end: number;
|
|
26
|
+
word: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The misspelled words inside one top-level block (a paragraph, a table, a list...),
|
|
30
|
+
* as offsets into the block's text. Words are found per parent (paragraph or cell),
|
|
31
|
+
* so text in different cells never forms one word. Must run inside an editor read.
|
|
32
|
+
*/
|
|
33
|
+
export declare function $misspellingsInBlock(block: ElementNode, checkers: Checkers): BlockMisspelling[];
|
|
21
34
|
//# sourceMappingURL=spellcheckActions.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"spellcheckActions.d.ts","sourceRoot":"","sources":["../../src/spellcheck/spellcheckActions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAClF,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAE5D,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,qEAAqE;AACrE,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED,yEAAyE;AACzE,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,EAAE,CAmBtE"}
|
|
1
|
+
{"version":3,"file":"spellcheckActions.d.ts","sourceRoot":"","sources":["../../src/spellcheck/spellcheckActions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAC3C,OAAO,EAAwB,KAAK,SAAS,EAAE,MAAM,4BAA4B,CAAC;AAClF,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAE5D,iFAAiF;AACjF,MAAM,WAAW,QAAQ;IACvB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;CACf;AAED,qEAAqE;AACrE,MAAM,WAAW,OAAO;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED,yEAAyE;AACzE,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,EAAE,CAmBtE;AAeD,0EAA0E;AAC1E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,QAAQ,GAAG,gBAAgB,EAAE,CA8B/F"}
|
|
@@ -36,3 +36,38 @@ function segmentsOf(group, start, end) {
|
|
|
36
36
|
}
|
|
37
37
|
return segments;
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* The misspelled words inside one top-level block (a paragraph, a table, a list...),
|
|
41
|
+
* as offsets into the block's text. Words are found per parent (paragraph or cell),
|
|
42
|
+
* so text in different cells never forms one word. Must run inside an editor read.
|
|
43
|
+
*/
|
|
44
|
+
export function $misspellingsInBlock(block, checkers) {
|
|
45
|
+
const nodes = block.getAllTextNodes();
|
|
46
|
+
const starts = [];
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
starts.push(offset);
|
|
50
|
+
offset += node.getTextContent().length;
|
|
51
|
+
}
|
|
52
|
+
const found = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < nodes.length) {
|
|
55
|
+
const parent = nodes[i].getParentOrThrow();
|
|
56
|
+
let j = i;
|
|
57
|
+
let text = '';
|
|
58
|
+
while (j < nodes.length && nodes[j].getParentOrThrow().getKey() === parent.getKey()) {
|
|
59
|
+
text += nodes[j].getTextContent();
|
|
60
|
+
j += 1;
|
|
61
|
+
}
|
|
62
|
+
const speller = parent.getDirection() === 'rtl' ? checkers.rtl : checkers.ltr;
|
|
63
|
+
if (speller) {
|
|
64
|
+
for (const span of wordsIn(text)) {
|
|
65
|
+
if (!speller.correct(span.word)) {
|
|
66
|
+
found.push({ start: starts[i] + span.start, end: starts[i] + span.end, word: span.word });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
i = j;
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|