@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.
@@ -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;AAqB1D;;;;;;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,sCAsLA"}
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) => store.load(language).catch(() => []))).then((lists) => new WordIndex(lists.flat()));
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
- acceptItem(editor, current.items[current.index]);
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. The host can pass several; their words
4
- * are combined per language. Like the other stores, a failing store is skipped.
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. Nothing
12
- * to configure; it follows whichever spelling dictionaries are registered.
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(): CompletionStore;
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` and expects a JSON
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;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;CAClD;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,IAAI,eAAe,CAS3D;AAED;;;GAGG;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,CAWlB"}
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. Nothing
5
- * to configure; it follows whichever spelling dictionaries are registered.
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` and expects a JSON
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}?language=${encodeURIComponent(language)}`, { headers: options.headers });
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
- /** Words sorted once, so every prefix lookup is a binary search rather than a scan. */
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
- constructor(words: Iterable<string>);
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), shortest first, then alphabetical. Only the first `scanLimit`
8
- * matches are considered, so a one-letter prefix stays quick.
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,uFAAuF;AACvF,qBAAa,SAAS;IACpB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAW;gBAErB,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC;IAInC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,SAAI,EAAE,SAAS,SAAM,GAAG,MAAM,EAAE;IAY9D,OAAO,CAAC,UAAU;CAUnB"}
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
- /** Words sorted once, so every prefix lookup is a binary search rather than a scan. */
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.words = [...new Set(words)].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
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), shortest first, then alphabetical. Only the first `scanLimit`
9
- * matches are considered, so a one-letter prefix stays quick.
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 matches = [];
15
- for (let i = this.lowerBound(prefix); i < this.words.length && matches.length < scanLimit; i++) {
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
- matches.push(word);
26
+ candidates.add(word);
27
+ scanned += 1;
21
28
  }
22
- matches.sort((a, b) => a.length - b.length || (a < b ? -1 : a > b ? 1 : 0));
23
- return matches.slice(0, limit);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.25-dev.228",
3
+ "version": "0.1.25-dev.230",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {