@sveltia/ui 0.63.1 → 0.65.0

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.
Files changed (48) hide show
  1. package/dist/components/drawer/drawer.svelte.d.ts +2 -2
  2. package/dist/components/emoji/cache.d.ts +3 -0
  3. package/dist/components/emoji/cache.js +96 -0
  4. package/dist/components/emoji/caret.d.ts +2 -0
  5. package/dist/components/emoji/caret.js +106 -0
  6. package/dist/components/emoji/emoji-suggestions.svelte +424 -0
  7. package/dist/components/emoji/emoji-suggestions.svelte.d.ts +66 -0
  8. package/dist/components/emoji/emoji.d.ts +31 -0
  9. package/dist/components/emoji/emoji.js +208 -0
  10. package/dist/components/emoji/loader.d.ts +6 -0
  11. package/dist/components/emoji/loader.js +74 -0
  12. package/dist/components/text-editor/emoji-autocomplete.svelte +225 -0
  13. package/dist/components/text-editor/emoji-autocomplete.svelte.d.ts +12 -0
  14. package/dist/components/text-editor/shiki/generated.d.ts +1 -1
  15. package/dist/components/text-editor/shiki/generated.js +1 -1
  16. package/dist/components/text-editor/store.svelte.js +1 -0
  17. package/dist/components/text-editor/text-editor.svelte +9 -0
  18. package/dist/components/text-editor/text-editor.svelte.d.ts +10 -0
  19. package/dist/components/text-field/emoji-autocomplete.svelte +165 -0
  20. package/dist/components/text-field/emoji-autocomplete.svelte.d.ts +25 -0
  21. package/dist/components/text-field/text-area.svelte +11 -0
  22. package/dist/components/text-field/text-area.svelte.d.ts +19 -1
  23. package/dist/components/text-field/text-input.svelte +7 -0
  24. package/dist/components/text-field/text-input.svelte.d.ts +10 -0
  25. package/dist/locales/ar.yaml +3 -0
  26. package/dist/locales/bg.yaml +3 -0
  27. package/dist/locales/ca.yaml +3 -0
  28. package/dist/locales/cs.yaml +3 -0
  29. package/dist/locales/el.yaml +3 -0
  30. package/dist/locales/en-CA.yaml +3 -0
  31. package/dist/locales/en-GB.yaml +3 -0
  32. package/dist/locales/en-US.yaml +3 -0
  33. package/dist/locales/es-CO.yaml +116 -0
  34. package/dist/locales/fi.yaml +3 -0
  35. package/dist/locales/fr.yaml +3 -0
  36. package/dist/locales/ja.yaml +3 -0
  37. package/dist/locales/ko.yaml +3 -0
  38. package/dist/locales/nl.yaml +3 -0
  39. package/dist/locales/pl.yaml +3 -0
  40. package/dist/locales/pt-BR.yaml +3 -0
  41. package/dist/locales/pt-PT.yaml +3 -0
  42. package/dist/locales/ru.yaml +3 -0
  43. package/dist/locales/tr.yaml +3 -0
  44. package/dist/locales/uk.yaml +3 -0
  45. package/dist/locales/zh-CN.yaml +3 -0
  46. package/dist/typedefs.d.ts +61 -0
  47. package/dist/typedefs.js +34 -0
  48. package/package.json +5 -4
@@ -0,0 +1,66 @@
1
+ export default EmojiSuggestions;
2
+ type EmojiSuggestions = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<Props>): void;
5
+ } & {
6
+ isOpen: () => boolean;
7
+ close: (dismissed?: boolean | undefined) => void;
8
+ update: (newTrigger?: EmojiTrigger | undefined) => Promise<void>;
9
+ moveSelection: (delta: number) => void;
10
+ selectHighlighted: () => void;
11
+ handleKeyDown: (event: KeyboardEvent) => boolean;
12
+ };
13
+ /**
14
+ * The dropdown shown while an emoji shortcode is being typed, along with the state behind it. This
15
+ * knows nothing about where the text is being typed; a host component detects the shortcode, feeds
16
+ * it in with {@link update} and applies the chosen emoji through the `onSelect` callback.
17
+ *
18
+ * The list is rendered in the top layer with the Popover API, so it’s never clipped by whatever
19
+ * contains the field, and it never takes the focus, so the user can keep typing to narrow down the
20
+ * suggestions.
21
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
22
+ */
23
+ declare const EmojiSuggestions: import("svelte").Component<{
24
+ /**
25
+ * Get the
26
+ * viewport-relative bounds of the shortcode being typed, which the dropdown is anchored to.
27
+ */
28
+ getAnchorRect: (trigger: EmojiTrigger) => EmojiAnchorRect | undefined;
29
+ /**
30
+ * Called with the emoji
31
+ * the user picked and the shortcode it should replace.
32
+ */
33
+ onSelect: (entry: EmojiEntry, trigger: EmojiTrigger) => void;
34
+ /**
35
+ * The element holding the caret, which is labelled with the
36
+ * highlighted suggestion because the focus never moves to the dropdown.
37
+ */
38
+ ariaOwner?: HTMLElement | undefined;
39
+ }, {
40
+ isOpen: () => boolean;
41
+ close: (dismissed?: boolean | undefined) => void;
42
+ update: (newTrigger?: EmojiTrigger | undefined) => Promise<void>;
43
+ moveSelection: (delta: number) => void;
44
+ selectHighlighted: () => void;
45
+ handleKeyDown: (event: KeyboardEvent) => boolean;
46
+ }, "">;
47
+ type Props = {
48
+ /**
49
+ * Get the
50
+ * viewport-relative bounds of the shortcode being typed, which the dropdown is anchored to.
51
+ */
52
+ getAnchorRect: (trigger: EmojiTrigger) => EmojiAnchorRect | undefined;
53
+ /**
54
+ * Called with the emoji
55
+ * the user picked and the shortcode it should replace.
56
+ */
57
+ onSelect: (entry: EmojiEntry, trigger: EmojiTrigger) => void;
58
+ /**
59
+ * The element holding the caret, which is labelled with the
60
+ * highlighted suggestion because the focus never moves to the dropdown.
61
+ */
62
+ ariaOwner?: HTMLElement | undefined;
63
+ };
64
+ import type { EmojiTrigger } from '../../typedefs';
65
+ import type { EmojiAnchorRect } from '../../typedefs';
66
+ import type { EmojiEntry } from '../../typedefs';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @import { EmojiData, EmojiEntry } from '../../typedefs';
3
+ */
4
+ /**
5
+ * Regular expression that matches an emoji shortcode being typed at the end of a line, like `:smi`.
6
+ * The colon must be at the beginning of the text or preceded by a whitespace or an opening bracket,
7
+ * so a colon in the middle of a word, as in `https://` or `12:34`, doesn’t trigger the suggestions.
8
+ */
9
+ export const EMOJI_TRIGGER_REGEX: RegExp;
10
+ /**
11
+ * Maximum number of emoji suggestions shown at a time.
12
+ */
13
+ export const MAX_EMOJI_SUGGESTIONS: 10;
14
+ export function parseEmojiData(data: EmojiData): EmojiEntry[];
15
+ export function loadEmojiList(): Promise<EmojiEntry[]>;
16
+ /**
17
+ * Rank given when there is no match at all. Higher than any real rank, so an unmatched emoji sorts
18
+ * last and the ranks can still be compared arithmetically.
19
+ */
20
+ export const NO_EMOJI_MATCH: 9;
21
+ export function getEmojiNameMatchRank(name: string, query: string): number;
22
+ export function getEmojiAliasMatchRank(aliases: string[], query: string): number;
23
+ export function getEmojiMatchRank({ name, aliases }: EmojiEntry, query: string): {
24
+ rank: number;
25
+ nameRank: number;
26
+ };
27
+ export function searchEmojis(query: string): EmojiEntry[];
28
+ export function getEmojiInsertText(emoji: string, textAfterCaret: string): string;
29
+ export function detectEmojiTrigger(textBeforeCaret: string): string | undefined;
30
+ import type { EmojiData } from '../../typedefs';
31
+ import type { EmojiEntry } from '../../typedefs';
@@ -0,0 +1,208 @@
1
+ import { cacheEmojiData, getCachedEmojiData } from './cache.js';
2
+ import { getEmojiDataLoader } from './loader.js';
3
+
4
+ /**
5
+ * @import { EmojiData, EmojiEntry } from '../../typedefs';
6
+ */
7
+
8
+ /**
9
+ * Regular expression that matches an emoji shortcode being typed at the end of a line, like `:smi`.
10
+ * The colon must be at the beginning of the text or preceded by a whitespace or an opening bracket,
11
+ * so a colon in the middle of a word, as in `https://` or `12:34`, doesn’t trigger the suggestions.
12
+ */
13
+ export const EMOJI_TRIGGER_REGEX = /(?<=^|[\s([{"'«])(?::)(?<query>[a-zA-Z0-9_+-]{1,32})$/;
14
+
15
+ /**
16
+ * Maximum number of emoji suggestions shown at a time.
17
+ */
18
+ export const MAX_EMOJI_SUGGESTIONS = 10;
19
+
20
+ /**
21
+ * Cached emoji list. This is `undefined` until {@link loadEmojiList} resolves for the first time.
22
+ * @type {EmojiEntry[] | undefined}
23
+ */
24
+ let emojiList;
25
+ /**
26
+ * In-flight or completed loader for {@link emojiList}, so the data is only fetched once.
27
+ * @type {Promise<EmojiEntry[]> | undefined}
28
+ */
29
+ let loader;
30
+
31
+ /**
32
+ * Convert the raw emoji data into a searchable list.
33
+ * @internal
34
+ * @param {EmojiData} data Emoji data, keyed by emoji character, with each value listing the name
35
+ * followed by any keywords.
36
+ * @returns {EmojiEntry[]} Emoji list.
37
+ */
38
+ export const parseEmojiData = (data) =>
39
+ Object.entries(data).map(([emoji, [name, ...aliases]]) => ({
40
+ emoji,
41
+ name,
42
+ // Some of the keywords are capitalized, e.g. `NASA` and `XD`
43
+ aliases: aliases.map((alias) => alias.toLowerCase()),
44
+ }));
45
+
46
+ /**
47
+ * Load the emoji list, from the local cache if it’s there and from the CDN otherwise.
48
+ *
49
+ * The data is a few hundred kilobytes that most sessions never need, so it’s deliberately kept out
50
+ * of the bundle. A failure here is not worth surfacing: no suggestions are ever shown, and the
51
+ * shortcode the user typed stays as plain text.
52
+ * @returns {Promise<EmojiEntry[]>} Emoji list, or an empty list if the data can’t be obtained.
53
+ */
54
+ export const loadEmojiList = async () => {
55
+ loader ??= (async () => {
56
+ try {
57
+ let data = await getCachedEmojiData();
58
+
59
+ if (!data) {
60
+ data = await getEmojiDataLoader()();
61
+ // Don’t make the caller wait on the write
62
+ cacheEmojiData(data);
63
+ }
64
+
65
+ emojiList = parseEmojiData(data);
66
+ } catch (ex) {
67
+ // Allow a later attempt to retry, so a transient network failure isn’t permanent
68
+ loader = undefined;
69
+ emojiList = [];
70
+ // eslint-disable-next-line no-console
71
+ console.error(ex);
72
+ }
73
+
74
+ return /** @type {EmojiEntry[]} */ (emojiList);
75
+ })();
76
+
77
+ return loader;
78
+ };
79
+
80
+ /**
81
+ * Rank given when there is no match at all. Higher than any real rank, so an unmatched emoji sorts
82
+ * last and the ranks can still be compared arithmetically.
83
+ */
84
+ export const NO_EMOJI_MATCH = 9;
85
+
86
+ /**
87
+ * Get how well an emoji’s name matches the given query. A lower rank means a better match.
88
+ *
89
+ * The name is matched word by word rather than only as a whole, so a partly typed `:cana` reaches
90
+ * `flag_canada`’s second word just as `:canada` does. The whole name is tested as well, so a query
91
+ * spanning a word boundary, like `:flag_can`, still matches.
92
+ * @internal
93
+ * @param {string} name Canonical emoji name.
94
+ * @param {string} query Lower-cased search query without the leading colon.
95
+ * @returns {number} Rank, or {@link NO_EMOJI_MATCH}.
96
+ */
97
+ export const getEmojiNameMatchRank = (name, query) => {
98
+ const words = name.split('_');
99
+
100
+ if (name === query) {
101
+ return 0;
102
+ }
103
+
104
+ if (words.includes(query)) {
105
+ return 1;
106
+ }
107
+
108
+ if (name.startsWith(query) || words.some((word) => word.startsWith(query))) {
109
+ return 3;
110
+ }
111
+
112
+ if (name.includes(query)) {
113
+ return 5;
114
+ }
115
+
116
+ return NO_EMOJI_MATCH;
117
+ };
118
+
119
+ /**
120
+ * Get how well an emoji’s keywords match the given query. A lower rank means a better match. The
121
+ * ranks interleave with {@link getEmojiNameMatchRank}’s: an exact keyword sits between a whole word
122
+ * of the name and a partial one.
123
+ * @internal
124
+ * @param {string[]} aliases Lower-cased alternative keywords.
125
+ * @param {string} query Lower-cased search query without the leading colon.
126
+ * @returns {number} Rank, or {@link NO_EMOJI_MATCH}.
127
+ */
128
+ export const getEmojiAliasMatchRank = (aliases, query) => {
129
+ if (aliases.includes(query)) {
130
+ return 2;
131
+ }
132
+
133
+ if (aliases.some((alias) => alias.startsWith(query))) {
134
+ return 4;
135
+ }
136
+
137
+ return NO_EMOJI_MATCH;
138
+ };
139
+
140
+ /**
141
+ * Get how well an emoji matches the given query, as the best of its name and keyword ranks plus the
142
+ * name rank on its own.
143
+ *
144
+ * The name rank is kept so it can break ties, because the name is what the emoji actually depicts
145
+ * while the keywords are merely associated with it. Many emojis share a keyword — `ca` and `canada`
146
+ * belong to 🍁, 🇨🇦 and 🫎 alike — and the one that also carries the query in its name,
147
+ * `flag_canada`, is the one the user is after.
148
+ * @internal
149
+ * @param {EmojiEntry} entry Emoji entry.
150
+ * @param {string} query Lower-cased search query without the leading colon.
151
+ * @returns {{ rank: number, nameRank: number }} Best rank and name rank, either of which is
152
+ * {@link NO_EMOJI_MATCH} when there is nothing to match.
153
+ */
154
+ export const getEmojiMatchRank = ({ name, aliases }, query) => {
155
+ const nameRank = getEmojiNameMatchRank(name, query);
156
+ const aliasRank = getEmojiAliasMatchRank(aliases, query);
157
+
158
+ return { rank: Math.min(nameRank, aliasRank), nameRank };
159
+ };
160
+
161
+ /**
162
+ * Search the loaded emoji list for the given query. This returns an empty list unless
163
+ * {@link loadEmojiList} has been resolved beforehand.
164
+ * @param {string} query Search query without the leading colon, e.g. `smi`.
165
+ * @returns {EmojiEntry[]} Matching emojis, best match first, capped at
166
+ * {@link MAX_EMOJI_SUGGESTIONS}.
167
+ */
168
+ export const searchEmojis = (query) => {
169
+ const normalizedQuery = query.toLowerCase();
170
+
171
+ if (!emojiList || !normalizedQuery) {
172
+ return [];
173
+ }
174
+
175
+ return (
176
+ emojiList
177
+ .map((entry) => ({ entry, ...getEmojiMatchRank(entry, normalizedQuery) }))
178
+ .filter(({ rank }) => rank < NO_EMOJI_MATCH)
179
+ // Equally ranked emojis are settled by the name, then by the original order, which roughly
180
+ // goes from the most to the least commonly used — `Array.prototype.sort()` is stable
181
+ .sort((a, b) => a.rank - b.rank || a.nameRank - b.nameRank)
182
+ .slice(0, MAX_EMOJI_SUGGESTIONS)
183
+ .map(({ entry }) => entry)
184
+ );
185
+ };
186
+
187
+ /**
188
+ * Get the text to insert in place of a shortcode.
189
+ *
190
+ * A space follows the emoji, so the user can carry straight on typing the next word, the way it
191
+ * works on GitHub, Slack and Discord. It’s left out when the caret is already followed by
192
+ * whitespace, which would otherwise leave a double space behind.
193
+ * @param {string} emoji Emoji character.
194
+ * @param {string} textAfterCaret Text between the caret and the end of the line.
195
+ * @returns {string} Text to insert.
196
+ */
197
+ export const getEmojiInsertText = (emoji, textAfterCaret) =>
198
+ /^\s/.test(textAfterCaret) ? emoji : `${emoji} `;
199
+
200
+ /**
201
+ * Detect an emoji shortcode being typed right before the caret.
202
+ * @param {string} textBeforeCaret Text between the beginning of the current text node and the
203
+ * caret.
204
+ * @returns {string | undefined} Query without the leading colon, or `undefined` if there is no
205
+ * shortcode.
206
+ */
207
+ export const detectEmojiTrigger = (textBeforeCaret) =>
208
+ textBeforeCaret.match(EMOJI_TRIGGER_REGEX)?.groups?.query;
@@ -0,0 +1,6 @@
1
+ export function getEmojiDataURL(): string;
2
+ export { EMOJILIB_VERSION };
3
+ export function setEmojiDataLoader(newLoader: () => Promise<EmojiData>): void;
4
+ export function getEmojiDataLoader(): () => Promise<EmojiData>;
5
+ import { version as EMOJILIB_VERSION } from 'emojilib/package.json';
6
+ import type { EmojiData } from '../../typedefs';
@@ -0,0 +1,74 @@
1
+ import { version as EMOJILIB_VERSION } from 'emojilib/package.json';
2
+
3
+ /**
4
+ * @import { EmojiData } from '../../typedefs';
5
+ */
6
+
7
+ const CDN_BASE_URL = 'https://unpkg.com/emojilib';
8
+ /**
9
+ * How long to wait for the emoji data, in milliseconds. The suggestions are a convenience, so a
10
+ * slow or unreachable CDN should never leave a request hanging around.
11
+ */
12
+ const FETCH_TIMEOUT = 5000;
13
+
14
+ /**
15
+ * Get the URL of the emoji data.
16
+ *
17
+ * The data is fetched rather than bundled because it’s a few hundred kilobytes that most sessions
18
+ * never need, and a single-file bundle — our main consumer, Sveltia CMS — would otherwise inline it
19
+ * wholesale. The version comes from the installed `emojilib`, so the URL always matches the
20
+ * package this was developed against.
21
+ * @returns {string} URL.
22
+ */
23
+ export const getEmojiDataURL = () => `${CDN_BASE_URL}@${EMOJILIB_VERSION}/dist/emoji-en-US.json`;
24
+
25
+ /**
26
+ * Version of the emoji data, used to scope the cache so a bump invalidates it.
27
+ */
28
+ export { EMOJILIB_VERSION };
29
+
30
+ /**
31
+ * Fetch the emoji data from the CDN.
32
+ * @returns {Promise<EmojiData>} Emoji data, keyed by emoji character.
33
+ * @throws {Error} When the request fails, times out or returns a non-OK response.
34
+ */
35
+ const loadEmojiData = async () => {
36
+ const response = await fetch(getEmojiDataURL(), {
37
+ signal: AbortSignal.timeout(FETCH_TIMEOUT),
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`Failed to load emoji data: ${response.status}`);
42
+ }
43
+
44
+ return response.json();
45
+ };
46
+
47
+ /**
48
+ * Loader in effect.
49
+ * @type {() => Promise<EmojiData>}
50
+ */
51
+ let loader = loadEmojiData;
52
+
53
+ /**
54
+ * Override how the emoji suggestions obtain their data.
55
+ *
56
+ * By default the data is fetched from a CDN and cached locally, which keeps it out of the
57
+ * consumer’s bundle. Consumers who would rather bundle it, self-host it, or run without any
58
+ * outbound requests at all can replace the loader.
59
+ *
60
+ * ```js
61
+ * setEmojiDataLoader(async () => (await import('emojilib')).default);
62
+ * ```
63
+ * @param {() => Promise<EmojiData>} newLoader Loader returning the emoji data, keyed by emoji
64
+ * character, with each value listing the name followed by any keywords.
65
+ */
66
+ export const setEmojiDataLoader = (newLoader) => {
67
+ loader = newLoader;
68
+ };
69
+
70
+ /**
71
+ * Get the loader currently in effect.
72
+ * @returns {() => Promise<EmojiData>} Active loader.
73
+ */
74
+ export const getEmojiDataLoader = () => loader;
@@ -0,0 +1,225 @@
1
+ <!--
2
+ @component
3
+ Emoji autocomplete for the rich text editor. Typing a colon followed by one or more characters,
4
+ like `:smi`, brings up a list of matching emojis that can be inserted with a click or the Enter
5
+ key, the same way it works on GitHub, Slack and other apps. This wires the Lexical editor up to
6
+ `<EmojiSuggestions>`, which owns the dropdown itself.
7
+ -->
8
+ <script>
9
+ import { $isCodeNode as isCodeNode } from '@lexical/code-core';
10
+ import {
11
+ COMMAND_PRIORITY_CRITICAL,
12
+ $getNodeByKey as getNodeByKey,
13
+ $getSelection as getSelection,
14
+ $isRangeSelection as isRangeSelection,
15
+ $isTextNode as isTextNode,
16
+ KEY_ARROW_DOWN_COMMAND,
17
+ KEY_ARROW_UP_COMMAND,
18
+ KEY_ENTER_COMMAND,
19
+ KEY_ESCAPE_COMMAND,
20
+ KEY_TAB_COMMAND,
21
+ } from 'lexical';
22
+ import { getContext } from 'svelte';
23
+ import { detectEmojiTrigger, getEmojiInsertText } from '../emoji/emoji.js';
24
+ import EmojiSuggestions from '../emoji/emoji-suggestions.svelte';
25
+
26
+ /**
27
+ * @import { EmojiAnchorRect, EmojiEntry, EmojiTrigger, TextEditorStore } from '../../typedefs';
28
+ */
29
+
30
+ /**
31
+ * A shortcode within the Lexical editor, identified by the text node it sits in and where its
32
+ * colon is, which doesn’t change as the query grows.
33
+ * @typedef {EmojiTrigger & { nodeKey: string, offset: number }} LexicalEmojiTrigger
34
+ */
35
+
36
+ /** @type {TextEditorStore} */
37
+ const editorStore = getContext('editorStore');
38
+
39
+ /**
40
+ * A reference to the dropdown.
41
+ * @type {ReturnType<typeof EmojiSuggestions> | undefined}
42
+ */
43
+ let list = $state();
44
+
45
+ /**
46
+ * Look for a shortcode being typed right before the caret. This must be called within an editor
47
+ * state read.
48
+ * @returns {LexicalEmojiTrigger | undefined} Shortcode state, if any.
49
+ */
50
+ const findTrigger = () => {
51
+ const selection = getSelection();
52
+
53
+ // Never suggest emojis within code, where a colon is much more likely to be code than a
54
+ // shortcode
55
+ if (!isRangeSelection(selection) || !selection.isCollapsed() || selection.hasFormat('code')) {
56
+ return undefined;
57
+ }
58
+
59
+ const node = selection.anchor.getNode();
60
+
61
+ if (!isTextNode(node) || !node.isSimpleText() || isCodeNode(node.getParent())) {
62
+ return undefined;
63
+ }
64
+
65
+ const { offset } = selection.anchor;
66
+ const query = detectEmojiTrigger(node.getTextContent().slice(0, offset));
67
+
68
+ if (query === undefined) {
69
+ return undefined;
70
+ }
71
+
72
+ const nodeKey = node.getKey();
73
+
74
+ return { id: `${nodeKey}:${offset - query.length - 1}`, query, nodeKey, offset };
75
+ };
76
+
77
+ /**
78
+ * Get the viewport-relative bounds of the shortcode being typed. The DOM selection is used rather
79
+ * than the Lexical node, because the caret is exactly where the shortcode ends, and the browser
80
+ * has already laid it out by the time an update listener runs.
81
+ * @param {EmojiTrigger} trigger Shortcode state.
82
+ * @returns {EmojiAnchorRect | undefined} Bounds, or `undefined` if they can’t be determined.
83
+ */
84
+ const getAnchorRect = ({ query }) => {
85
+ const domSelection = window.getSelection();
86
+
87
+ if (!domSelection?.rangeCount) {
88
+ return undefined;
89
+ }
90
+
91
+ const range = domSelection.getRangeAt(0).cloneRange();
92
+
93
+ try {
94
+ range.setStart(range.startContainer, Math.max(0, range.startOffset - query.length - 1));
95
+ } catch {
96
+ // The container turned out to be shorter than expected; fall back to the collapsed caret
97
+ }
98
+
99
+ const { top, bottom, left, right, width, height } = range.getBoundingClientRect();
100
+
101
+ // A range with no client rects yields an all-zero rectangle
102
+ return top || bottom || left || width || height ? { top, bottom, left, right } : undefined;
103
+ };
104
+
105
+ /**
106
+ * Replace the shortcode being typed with the given emoji.
107
+ * @param {EmojiEntry} entry Emoji to insert.
108
+ * @param {EmojiTrigger} trigger Shortcode to replace.
109
+ */
110
+ const insertEmoji = (entry, trigger) => {
111
+ const { editor } = editorStore;
112
+ const { query, nodeKey, offset } = /** @type {LexicalEmojiTrigger} */ (trigger);
113
+ const start = offset - query.length - 1;
114
+
115
+ editor?.update(() => {
116
+ const node = getNodeByKey(nodeKey);
117
+
118
+ // Make sure the shortcode is still where it was when the suggestions appeared
119
+ if (!isTextNode(node) || node.getTextContent().slice(start, offset) !== `:${query}`) {
120
+ return;
121
+ }
122
+
123
+ const text = getEmojiInsertText(entry.emoji, node.getTextContent().slice(offset));
124
+
125
+ node.spliceText(start, query.length + 1, text, true);
126
+ });
127
+ };
128
+
129
+ /**
130
+ * Register the keyboard shortcuts that drive the list, as well as the listener that watches for
131
+ * shortcodes. The shortcuts are registered with the highest priority, so they take precedence
132
+ * over the editor’s own handling while the list is open, and return `false` otherwise to leave
133
+ * the editor alone.
134
+ * @returns {Array<() => void>} Cleanup handlers.
135
+ */
136
+ const registerCommands = () => {
137
+ const { editor } = editorStore;
138
+
139
+ if (!editor) {
140
+ return [];
141
+ }
142
+
143
+ /**
144
+ * Create a command listener that only acts while the list is open.
145
+ * @param {() => void} handler Handler to be called.
146
+ * @returns {(event: KeyboardEvent | null) => boolean} Command listener.
147
+ */
148
+ const whileOpen = (handler) => (event) => {
149
+ if (!list?.isOpen()) {
150
+ return false;
151
+ }
152
+
153
+ event?.preventDefault();
154
+ handler();
155
+
156
+ return true;
157
+ };
158
+
159
+ return [
160
+ editor.registerCommand(
161
+ KEY_ARROW_DOWN_COMMAND,
162
+ whileOpen(() => list?.moveSelection(1)),
163
+ COMMAND_PRIORITY_CRITICAL,
164
+ ),
165
+ editor.registerCommand(
166
+ KEY_ARROW_UP_COMMAND,
167
+ whileOpen(() => list?.moveSelection(-1)),
168
+ COMMAND_PRIORITY_CRITICAL,
169
+ ),
170
+ editor.registerCommand(
171
+ KEY_ENTER_COMMAND,
172
+ whileOpen(() => list?.selectHighlighted()),
173
+ COMMAND_PRIORITY_CRITICAL,
174
+ ),
175
+ editor.registerCommand(
176
+ KEY_TAB_COMMAND,
177
+ whileOpen(() => list?.selectHighlighted()),
178
+ COMMAND_PRIORITY_CRITICAL,
179
+ ),
180
+ editor.registerCommand(
181
+ KEY_ESCAPE_COMMAND,
182
+ whileOpen(() => list?.close(true)),
183
+ COMMAND_PRIORITY_CRITICAL,
184
+ ),
185
+ editor.registerUpdateListener(({ editorState }) => {
186
+ /** @type {LexicalEmojiTrigger | undefined} */
187
+ let trigger;
188
+
189
+ editorState.read(() => {
190
+ trigger = findTrigger();
191
+ });
192
+
193
+ list?.update(trigger);
194
+ }),
195
+ ];
196
+ };
197
+
198
+ $effect(() => {
199
+ if (!editorStore.editor || !list) {
200
+ return undefined;
201
+ }
202
+
203
+ const unregisters = registerCommands();
204
+
205
+ return () => {
206
+ unregisters.forEach((unregister) => unregister());
207
+ list?.close();
208
+ };
209
+ });
210
+
211
+ // The rich text editor can be swapped for the plain text one at any time, in which case there is
212
+ // no caret to anchor the dropdown to anymore
213
+ $effect(() => {
214
+ if (!editorStore.useRichText) {
215
+ list?.close();
216
+ }
217
+ });
218
+ </script>
219
+
220
+ <EmojiSuggestions
221
+ bind:this={list}
222
+ {getAnchorRect}
223
+ onSelect={insertEmoji}
224
+ ariaOwner={editorStore.editor?.getRootElement() ?? undefined}
225
+ />
@@ -0,0 +1,12 @@
1
+ export default EmojiAutocomplete;
2
+ type EmojiAutocomplete = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<Record<string, never>>): void;
5
+ };
6
+ /**
7
+ * Emoji autocomplete for the rich text editor. Typing a colon followed by one or more characters,
8
+ * like `:smi`, brings up a list of matching emojis that can be inserted with a click or the Enter
9
+ * key, the same way it works on GitHub, Slack and other apps. This wires the Lexical editor up to
10
+ * `<EmojiSuggestions>`, which owns the dropdown itself.
11
+ */
12
+ declare const EmojiAutocomplete: import("svelte").Component<Record<string, never>, {}, "">;
@@ -7,7 +7,7 @@ export const SHIKI_VERSION: "4.4.3";
7
7
  /**
8
8
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
9
9
  */
10
- export const UI_VERSION: "0.63.1";
10
+ export const UI_VERSION: "0.65.0";
11
11
  /**
12
12
  * Available syntax highlighting languages, sorted by display name.
13
13
  * @type {{ id: string, name: string, aliases?: string[] }[]}
@@ -10,7 +10,7 @@ export const SHIKI_VERSION = "4.4.3";
10
10
  /**
11
11
  * Version of this package, used to resolve the prebuilt Shiki engine chunk from a CDN.
12
12
  */
13
- export const UI_VERSION = "0.63.1";
13
+ export const UI_VERSION = "0.65.0";
14
14
 
15
15
  /**
16
16
  * Available syntax highlighting languages, sorted by display name.
@@ -28,6 +28,7 @@ export const createEditorStore = () => {
28
28
  components: [],
29
29
  useMarkdownShortcuts: true,
30
30
  isCodeEditor: false,
31
+ useEmojiAutocomplete: false,
31
32
  });
32
33
 
33
34
  /** @type {string} */