@inshapardaz/likhari-react 0.1.25-dev.228 → 0.1.25-dev.230
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/autocomplete/AutocompletePlugin.d.ts.map +1 -1
- package/dist/autocomplete/AutocompletePlugin.js +31 -3
- package/dist/autocomplete/completionStores.d.ts +16 -7
- package/dist/autocomplete/completionStores.d.ts.map +1 -1
- package/dist/autocomplete/completionStores.js +51 -6
- package/dist/autocomplete/wordIndex.d.ts +11 -4
- package/dist/autocomplete/wordIndex.d.ts.map +1 -1
- package/dist/autocomplete/wordIndex.js +24 -10
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AutocompletePlugin.d.ts","sourceRoot":"","sources":["../../src/autocomplete/AutocompletePlugin.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"AutocompletePlugin.d.ts","sourceRoot":"","sources":["../../src/autocomplete/AutocompletePlugin.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAsB1D;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,MAAM,EACN,QAAQ,EAAE,cAAc,GACzB,EAAE;IACD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,eAAe,EAAE,CAAC;IAC1B,8EAA8E;IAC9E,QAAQ,EAAE,aAAa,GAAG,MAAM,CAAC;CAClC,sCAgNA"}
|
|
@@ -33,11 +33,35 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
|
|
|
33
33
|
popupRef.current = next;
|
|
34
34
|
setPopup(next);
|
|
35
35
|
};
|
|
36
|
+
/** Counts an accepted completion in every store that keeps counts, then drops the cached lists. */
|
|
37
|
+
const recordAcceptance = (language, word) => {
|
|
38
|
+
for (const store of storesRef.current) {
|
|
39
|
+
store.recordAccept?.(language, word).catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
indexes.current.clear();
|
|
42
|
+
};
|
|
36
43
|
useEffect(() => {
|
|
37
44
|
const indexFor = (language) => {
|
|
38
45
|
let index = indexes.current.get(language);
|
|
39
46
|
if (!index) {
|
|
40
|
-
index = Promise.all(storesRef.current.map((store) =>
|
|
47
|
+
index = Promise.all(storesRef.current.map(async (store) => {
|
|
48
|
+
try {
|
|
49
|
+
const [words, counts] = await Promise.all([
|
|
50
|
+
store.load(language),
|
|
51
|
+
store.loadCounts ? store.loadCounts(language) : Promise.resolve({}),
|
|
52
|
+
]);
|
|
53
|
+
return { words, counts };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { words: [], counts: {} };
|
|
57
|
+
}
|
|
58
|
+
})).then((parts) => {
|
|
59
|
+
const counts = {};
|
|
60
|
+
for (const part of parts)
|
|
61
|
+
for (const [word, count] of Object.entries(part.counts))
|
|
62
|
+
counts[word] = (counts[word] ?? 0) + count;
|
|
63
|
+
return new WordIndex(parts.flatMap((part) => part.words), counts);
|
|
64
|
+
});
|
|
41
65
|
indexes.current.set(language, index);
|
|
42
66
|
}
|
|
43
67
|
return index;
|
|
@@ -75,7 +99,7 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
|
|
|
75
99
|
if (items.length === 0)
|
|
76
100
|
return hide();
|
|
77
101
|
const rect = caretRect(editor);
|
|
78
|
-
show({ prefix, items, index: 0, selected: false, x: rect.left, y: rect.bottom, dir: rtl ? 'rtl' : 'ltr' });
|
|
102
|
+
show({ prefix, language, items, index: 0, selected: false, x: rect.left, y: rect.bottom, dir: rtl ? 'rtl' : 'ltr' });
|
|
79
103
|
});
|
|
80
104
|
};
|
|
81
105
|
// Handles an arrow key for the list, and cancels the browser's own caret movement.
|
|
@@ -150,7 +174,9 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
|
|
|
150
174
|
if (!current)
|
|
151
175
|
return false;
|
|
152
176
|
event?.preventDefault();
|
|
153
|
-
|
|
177
|
+
const word = current.items[current.index];
|
|
178
|
+
acceptItem(editor, word);
|
|
179
|
+
recordAcceptance(current.language, word);
|
|
154
180
|
show(null);
|
|
155
181
|
return true;
|
|
156
182
|
}
|
|
@@ -172,6 +198,8 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
|
|
|
172
198
|
return (_jsx("div", { className: "likhari-autocomplete", role: "listbox", "aria-label": label, dir: popup.dir, style: { position: 'fixed', left: popup.x, top: popup.y + 4 }, children: popup.items.map((item, i) => (_jsx("div", { role: "option", "aria-selected": i === popup.index, className: i === popup.index ? 'likhari-autocomplete-item likhari-autocomplete-item--active' : 'likhari-autocomplete-item', onMouseDown: (event) => {
|
|
173
199
|
event.preventDefault();
|
|
174
200
|
acceptItem(editor, item);
|
|
201
|
+
recordAcceptance(popup.language, item);
|
|
202
|
+
show(null);
|
|
175
203
|
}, children: item }, item))) }));
|
|
176
204
|
}
|
|
177
205
|
/** The typed part of the word at the caret, when the caret is at its end and the word is long enough to complete. */
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import { type SpellLanguage } from '../spellcheck/spellDictionaries.js';
|
|
2
2
|
/**
|
|
3
|
-
* Where autocomplete words come from
|
|
4
|
-
* are combined per language
|
|
3
|
+
* Where autocomplete words come from, and how often each was accepted. The host
|
|
4
|
+
* can pass several; words are combined per language and their counts summed.
|
|
5
|
+
* A failing store is skipped.
|
|
5
6
|
*/
|
|
6
7
|
export interface CompletionStore {
|
|
7
8
|
readonly id: string;
|
|
8
9
|
load(language: SpellLanguage): Promise<string[]>;
|
|
10
|
+
/** How many times each word was accepted as a completion. */
|
|
11
|
+
loadCounts?(language: SpellLanguage): Promise<Record<string, number>>;
|
|
12
|
+
/** Records one accepted completion, so the word is suggested sooner next time. */
|
|
13
|
+
recordAccept?(language: SpellLanguage, word: string): Promise<void>;
|
|
9
14
|
}
|
|
10
15
|
/**
|
|
11
|
-
* The default: the dictionary's words plus the words the user has added.
|
|
12
|
-
*
|
|
16
|
+
* The default: the dictionary's words plus the words the user has added. Accepted
|
|
17
|
+
* completions are counted in this browser's localStorage, and the most accepted
|
|
18
|
+
* words are suggested first.
|
|
13
19
|
*/
|
|
14
|
-
export declare function dictionaryCompletionStore(
|
|
20
|
+
export declare function dictionaryCompletionStore(options?: {
|
|
21
|
+
countsKey?: string;
|
|
22
|
+
}): CompletionStore;
|
|
15
23
|
/**
|
|
16
|
-
* Words served by an API. `load` calls `GET {url}?language=xx`
|
|
17
|
-
* array of strings
|
|
24
|
+
* Words and counts served by an API. `load` calls `GET {url}?language=xx` for a JSON
|
|
25
|
+
* array of strings; `loadCounts` calls `GET {url}/counts?language=xx` for a JSON
|
|
26
|
+
* object of counts; `recordAccept` calls `POST {url}/accept` with `{ language, word }`.
|
|
18
27
|
*/
|
|
19
28
|
export declare function apiCompletionStore(options: {
|
|
20
29
|
id?: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"completionStores.d.ts","sourceRoot":"","sources":["../../src/autocomplete/completionStores.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGzF
|
|
1
|
+
{"version":3,"file":"completionStores.d.ts","sourceRoot":"","sources":["../../src/autocomplete/completionStores.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,aAAa,EAAE,MAAM,iCAAiC,CAAC;AAGzF;;;;GAIG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IACjD,6DAA6D;IAC7D,UAAU,CAAC,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACtE,kFAAkF;IAClF,YAAY,CAAC,CAAC,QAAQ,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACrE;AAiBD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,GAAE;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,eAAe,CAwB/F;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE;IAC1C,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB,GAAG,eAAe,CA0BlB"}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { getDictionaryWords } from '../spellcheck/spellDictionaries.js';
|
|
2
2
|
import { acceptedWords } from '../spellcheck/userWords.js';
|
|
3
|
+
const COUNTS_KEY = 'likhari-completion-counts';
|
|
4
|
+
function readCounts(key) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = JSON.parse(localStorage.getItem(key) ?? '{}');
|
|
7
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
3
13
|
/**
|
|
4
|
-
* The default: the dictionary's words plus the words the user has added.
|
|
5
|
-
*
|
|
14
|
+
* The default: the dictionary's words plus the words the user has added. Accepted
|
|
15
|
+
* completions are counted in this browser's localStorage, and the most accepted
|
|
16
|
+
* words are suggested first.
|
|
6
17
|
*/
|
|
7
|
-
export function dictionaryCompletionStore() {
|
|
18
|
+
export function dictionaryCompletionStore(options = {}) {
|
|
19
|
+
const key = options.countsKey ?? COUNTS_KEY;
|
|
8
20
|
return {
|
|
9
21
|
id: 'dictionary',
|
|
10
22
|
async load(language) {
|
|
@@ -12,22 +24,55 @@ export function dictionaryCompletionStore() {
|
|
|
12
24
|
const dictionary = words ? await words : [];
|
|
13
25
|
return [...dictionary, ...acceptedWords(language)];
|
|
14
26
|
},
|
|
27
|
+
async loadCounts(language) {
|
|
28
|
+
return readCounts(key)[language] ?? {};
|
|
29
|
+
},
|
|
30
|
+
async recordAccept(language, word) {
|
|
31
|
+
const all = readCounts(key);
|
|
32
|
+
const counts = all[language] ?? {};
|
|
33
|
+
counts[word] = (counts[word] ?? 0) + 1;
|
|
34
|
+
all[language] = counts;
|
|
35
|
+
try {
|
|
36
|
+
localStorage.setItem(key, JSON.stringify(all));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Storage full or disabled: the count is kept for this session only.
|
|
40
|
+
}
|
|
41
|
+
},
|
|
15
42
|
};
|
|
16
43
|
}
|
|
17
44
|
/**
|
|
18
|
-
* Words served by an API. `load` calls `GET {url}?language=xx`
|
|
19
|
-
* array of strings
|
|
45
|
+
* Words and counts served by an API. `load` calls `GET {url}?language=xx` for a JSON
|
|
46
|
+
* array of strings; `loadCounts` calls `GET {url}/counts?language=xx` for a JSON
|
|
47
|
+
* object of counts; `recordAccept` calls `POST {url}/accept` with `{ language, word }`.
|
|
20
48
|
*/
|
|
21
49
|
export function apiCompletionStore(options) {
|
|
22
50
|
const doFetch = options.fetch ?? fetch;
|
|
51
|
+
const query = (language) => `?language=${encodeURIComponent(language)}`;
|
|
23
52
|
return {
|
|
24
53
|
id: options.id ?? 'api',
|
|
25
54
|
async load(language) {
|
|
26
|
-
const response = await doFetch(`${options.url}
|
|
55
|
+
const response = await doFetch(`${options.url}${query(language)}`, { headers: options.headers });
|
|
27
56
|
if (!response.ok)
|
|
28
57
|
throw new Error(`Completion API failed: ${response.status}`);
|
|
29
58
|
const data = await response.json();
|
|
30
59
|
return Array.isArray(data) ? data.filter((w) => typeof w === 'string') : [];
|
|
31
60
|
},
|
|
61
|
+
async loadCounts(language) {
|
|
62
|
+
const response = await doFetch(`${options.url}/counts${query(language)}`, { headers: options.headers });
|
|
63
|
+
if (!response.ok)
|
|
64
|
+
throw new Error(`Completion counts failed: ${response.status}`);
|
|
65
|
+
const data = await response.json();
|
|
66
|
+
return data && typeof data === 'object' ? data : {};
|
|
67
|
+
},
|
|
68
|
+
async recordAccept(language, word) {
|
|
69
|
+
const response = await doFetch(`${options.url}/accept`, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json', ...options.headers },
|
|
72
|
+
body: JSON.stringify({ language, word }),
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok)
|
|
75
|
+
throw new Error(`Completion API rejected the count: ${response.status}`);
|
|
76
|
+
},
|
|
32
77
|
};
|
|
33
78
|
}
|
|
@@ -1,13 +1,20 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Completion words, sorted once so every prefix lookup is a binary search. Each
|
|
3
|
+
* word has a count of how often it was accepted as a completion; more used words
|
|
4
|
+
* are suggested first.
|
|
5
|
+
*/
|
|
2
6
|
export declare class WordIndex {
|
|
3
7
|
private readonly words;
|
|
4
|
-
|
|
8
|
+
private readonly counts;
|
|
9
|
+
constructor(words: Iterable<string>, counts?: Record<string, number>);
|
|
5
10
|
/**
|
|
6
11
|
* Completions for a prefix: words that start with it (other than the prefix
|
|
7
|
-
* itself),
|
|
8
|
-
* matches are
|
|
12
|
+
* itself), most accepted first, then shortest, then alphabetical. Only the first
|
|
13
|
+
* `scanLimit` matches of the sorted words are scanned, so a one-letter prefix stays
|
|
14
|
+
* quick; words with a count are always considered.
|
|
9
15
|
*/
|
|
10
16
|
complete(prefix: string, limit?: number, scanLimit?: number): string[];
|
|
17
|
+
private countOf;
|
|
11
18
|
private lowerBound;
|
|
12
19
|
}
|
|
13
20
|
//# sourceMappingURL=wordIndex.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wordIndex.d.ts","sourceRoot":"","sources":["../../src/autocomplete/wordIndex.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"wordIndex.d.ts","sourceRoot":"","sources":["../../src/autocomplete/wordIndex.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAW;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;gBAEjC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM;IAKxE;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,SAAI,EAAE,SAAS,SAAM,GAAG,MAAM,EAAE;IAgB9D,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,UAAU;CAUnB"}
|
|
@@ -1,26 +1,40 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Completion words, sorted once so every prefix lookup is a binary search. Each
|
|
3
|
+
* word has a count of how often it was accepted as a completion; more used words
|
|
4
|
+
* are suggested first.
|
|
5
|
+
*/
|
|
2
6
|
export class WordIndex {
|
|
3
|
-
constructor(words) {
|
|
4
|
-
this.
|
|
7
|
+
constructor(words, counts = {}) {
|
|
8
|
+
this.counts = new Map(Object.entries(counts));
|
|
9
|
+
this.words = [...new Set([...words, ...this.counts.keys()])].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
5
10
|
}
|
|
6
11
|
/**
|
|
7
12
|
* Completions for a prefix: words that start with it (other than the prefix
|
|
8
|
-
* itself),
|
|
9
|
-
* matches are
|
|
13
|
+
* itself), most accepted first, then shortest, then alphabetical. Only the first
|
|
14
|
+
* `scanLimit` matches of the sorted words are scanned, so a one-letter prefix stays
|
|
15
|
+
* quick; words with a count are always considered.
|
|
10
16
|
*/
|
|
11
17
|
complete(prefix, limit = 5, scanLimit = 500) {
|
|
12
18
|
if (prefix === '')
|
|
13
19
|
return [];
|
|
14
|
-
const
|
|
15
|
-
for (let i = this.lowerBound(prefix); i < this.words.length &&
|
|
20
|
+
const candidates = new Set();
|
|
21
|
+
for (let i = this.lowerBound(prefix), scanned = 0; i < this.words.length && scanned < scanLimit; i++) {
|
|
16
22
|
const word = this.words[i];
|
|
17
23
|
if (!word.startsWith(prefix))
|
|
18
24
|
break;
|
|
19
25
|
if (word !== prefix)
|
|
20
|
-
|
|
26
|
+
candidates.add(word);
|
|
27
|
+
scanned += 1;
|
|
21
28
|
}
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
for (const word of this.counts.keys())
|
|
30
|
+
if (word !== prefix && word.startsWith(prefix))
|
|
31
|
+
candidates.add(word);
|
|
32
|
+
return [...candidates]
|
|
33
|
+
.sort((a, b) => (this.countOf(b) - this.countOf(a)) || a.length - b.length || (a < b ? -1 : a > b ? 1 : 0))
|
|
34
|
+
.slice(0, limit);
|
|
35
|
+
}
|
|
36
|
+
countOf(word) {
|
|
37
|
+
return this.counts.get(word) ?? 0;
|
|
24
38
|
}
|
|
25
39
|
lowerBound(prefix) {
|
|
26
40
|
let low = 0;
|