@inshapardaz/likhari-react 0.1.25-dev.226 → 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;AAmB1D;;;;;;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,sCA6KA"}
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;
@@ -67,26 +91,37 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
67
91
  const { prefix, rtl } = target;
68
92
  const language = languageRef.current === 'auto' ? (rtl ? 'ur' : 'en') : languageRef.current;
69
93
  void indexFor(language).then((loaded) => {
94
+ // Same word as the open list: keep the user's highlight rather than resetting it.
95
+ const open = popupRef.current;
96
+ if (open && open.prefix === prefix && open.dir === (rtl ? 'rtl' : 'ltr'))
97
+ return;
70
98
  const items = loaded.complete(prefix);
71
99
  if (items.length === 0)
72
100
  return hide();
73
101
  const rect = caretRect(editor);
74
- show({ 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' });
75
103
  });
76
104
  };
77
- const move = (delta) => {
105
+ // Handles an arrow key for the list, and cancels the browser's own caret movement.
106
+ const move = (delta, event) => {
78
107
  const current = popupRef.current;
79
- if (!current)
108
+ // Still loading the list for the word being typed: the arrow is for the list, not the caret.
109
+ if (!current && prefixAtCaret(editor) === null)
80
110
  return false;
81
- const count = current.items.length;
82
- show({ ...current, index: (current.index + delta + count) % count, selected: true });
111
+ event?.preventDefault();
112
+ if (current) {
113
+ const count = current.items.length;
114
+ show({ ...current, index: (current.index + delta + count) % count, selected: true });
115
+ }
83
116
  return true;
84
117
  };
85
118
  const onKeyDown = (event) => {
86
119
  // Up, Down, Tab and Enter act on the list through their editor commands, which
87
120
  // run after this listener, so the list must stay open for them.
88
121
  const current = popupRef.current;
89
- if (current && (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key === 'Tab' || (event.key === 'Enter' && current.selected)))
122
+ if (current && (event.key === 'Tab' || (event.key === 'Enter' && current.selected)))
123
+ return;
124
+ if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && (current || prefixAtCaret(editor) !== null))
90
125
  return;
91
126
  if (event.key === 'Escape' && popupRef.current) {
92
127
  event.preventDefault();
@@ -129,8 +164,8 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
129
164
  setTimeout(refresh, 0);
130
165
  });
131
166
  const unregisterCommands = [
132
- editor.registerCommand(KEY_ARROW_DOWN_COMMAND, () => move(1), COMMAND_PRIORITY_HIGH),
133
- editor.registerCommand(KEY_ARROW_UP_COMMAND, () => move(-1), COMMAND_PRIORITY_HIGH),
167
+ editor.registerCommand(KEY_ARROW_DOWN_COMMAND, (event) => move(1, event), COMMAND_PRIORITY_HIGH),
168
+ editor.registerCommand(KEY_ARROW_UP_COMMAND, (event) => move(-1, event), COMMAND_PRIORITY_HIGH),
134
169
  editor.registerCommand(KEY_TAB_COMMAND, (event) => acceptHighlighted(event), COMMAND_PRIORITY_HIGH),
135
170
  editor.registerCommand(KEY_ENTER_COMMAND, (event) => (popupRef.current?.selected ? acceptHighlighted(event) : false), COMMAND_PRIORITY_HIGH),
136
171
  ];
@@ -139,7 +174,9 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
139
174
  if (!current)
140
175
  return false;
141
176
  event?.preventDefault();
142
- acceptItem(editor, current.items[current.index]);
177
+ const word = current.items[current.index];
178
+ acceptItem(editor, word);
179
+ recordAcceptance(current.language, word);
143
180
  show(null);
144
181
  return true;
145
182
  }
@@ -161,8 +198,29 @@ export function AutocompletePlugin({ label, stores, language: chosenLanguage, })
161
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) => {
162
199
  event.preventDefault();
163
200
  acceptItem(editor, item);
201
+ recordAcceptance(popup.language, item);
202
+ show(null);
164
203
  }, children: item }, item))) }));
165
204
  }
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. */
206
+ function prefixAtCaret(editor) {
207
+ let prefix = null;
208
+ editor.getEditorState().read(() => {
209
+ const selection = $getSelection();
210
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
211
+ return;
212
+ const node = selection.anchor.getNode();
213
+ if (!$isTextNode(node))
214
+ return;
215
+ const text = node.getTextContent();
216
+ const before = text.slice(0, selection.anchor.offset);
217
+ const word = WORD_END.exec(before)?.[0];
218
+ const after = text.charAt(selection.anchor.offset);
219
+ if (word && word.length >= MIN_PREFIX && !(after !== '' && LETTER.test(after)))
220
+ prefix = word;
221
+ });
222
+ return prefix;
223
+ }
166
224
  /** Where to put the popup: under the caret, or the editor's top-left if the caret has no position yet. */
167
225
  function caretRect(editor) {
168
226
  const selection = window.getSelection();
@@ -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.226",
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": {