@sveltia/ui 0.65.1 → 0.65.2
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/components/emoji/emoji-suggestions.svelte +82 -9
- package/dist/components/emoji/emoji.d.ts +5 -2
- package/dist/components/emoji/emoji.js +75 -17
- package/dist/components/text-editor/shiki/generated.d.ts +1 -1
- package/dist/components/text-editor/shiki/generated.js +1 -1
- package/package.json +1 -1
|
@@ -40,11 +40,17 @@
|
|
|
40
40
|
} = $props();
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
* also enforced in the stylesheet below.
|
|
43
|
+
* Width of the dropdown, also enforced in the stylesheet below.
|
|
45
44
|
*/
|
|
46
45
|
const LIST_WIDTH = 280;
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* How many suggestions are visible at once. The rest are reached by scrolling.
|
|
48
|
+
*/
|
|
49
|
+
const VISIBLE_ROWS = 5;
|
|
50
|
+
/**
|
|
51
|
+
* Height to assume until a row has been measured, so the first open is positioned sensibly.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_MAX_HEIGHT = 180;
|
|
48
54
|
/**
|
|
49
55
|
* Gap between the dropdown and the caret, and the minimum margin to the viewport edges.
|
|
50
56
|
*/
|
|
@@ -93,6 +99,23 @@
|
|
|
93
99
|
|
|
94
100
|
const open = $derived(!!trigger && !!candidates.length);
|
|
95
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Height of one suggestion, measured rather than assumed so the dropdown still shows exactly
|
|
104
|
+
* {@link VISIBLE_ROWS} of them whatever the theme makes a row.
|
|
105
|
+
* @type {number}
|
|
106
|
+
*/
|
|
107
|
+
let rowHeight = $state(0);
|
|
108
|
+
/**
|
|
109
|
+
* The dropdown’s own vertical padding and border, which sit outside the rows. `box-sizing` is
|
|
110
|
+
* `border-box`, so `max-height` has to cover them for the rows to get their full share.
|
|
111
|
+
* @type {number}
|
|
112
|
+
*/
|
|
113
|
+
let listChrome = $state(0);
|
|
114
|
+
|
|
115
|
+
const listMaxHeight = $derived(
|
|
116
|
+
rowHeight ? rowHeight * VISIBLE_ROWS + listChrome : FALLBACK_MAX_HEIGHT,
|
|
117
|
+
);
|
|
118
|
+
|
|
96
119
|
/**
|
|
97
120
|
* Position of the dropdown, flipped above the caret and clamped to the viewport as needed.
|
|
98
121
|
*
|
|
@@ -108,7 +131,7 @@
|
|
|
108
131
|
const { innerWidth, innerHeight } = window;
|
|
109
132
|
const spaceBelow = innerHeight - anchorRect.bottom;
|
|
110
133
|
const spaceAbove = anchorRect.top;
|
|
111
|
-
const flipped = spaceBelow <
|
|
134
|
+
const flipped = spaceBelow < listMaxHeight + VIEWPORT_MARGIN && spaceAbove > spaceBelow;
|
|
112
135
|
const rtl = document.dir === 'rtl';
|
|
113
136
|
const anchorLeft = rtl ? anchorRect.right - LIST_WIDTH : anchorRect.left;
|
|
114
137
|
|
|
@@ -120,7 +143,7 @@
|
|
|
120
143
|
)}px`,
|
|
121
144
|
maxHeight: `${Math.round(
|
|
122
145
|
Math.min(
|
|
123
|
-
|
|
146
|
+
listMaxHeight,
|
|
124
147
|
(flipped ? spaceAbove : spaceBelow) - LIST_OFFSET - VIEWPORT_MARGIN,
|
|
125
148
|
),
|
|
126
149
|
)}px`,
|
|
@@ -247,11 +270,34 @@
|
|
|
247
270
|
return true;
|
|
248
271
|
};
|
|
249
272
|
|
|
250
|
-
// Move the dropdown to the top layer, so it’s not clipped by anything around the field
|
|
273
|
+
// Move the dropdown to the top layer, so it’s not clipped by anything around the field, then
|
|
274
|
+
// measure a row. The measurement has to happen after the popover is shown, because until then the
|
|
275
|
+
// element isn’t rendered at all and everything measures zero. A row’s height comes from the
|
|
276
|
+
// theme’s control height plus its padding, so it’s read back rather than assumed.
|
|
251
277
|
$effect(() => {
|
|
252
|
-
|
|
278
|
+
void candidates;
|
|
279
|
+
|
|
280
|
+
if (!listElement) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!listElement.matches(':popover-open')) {
|
|
253
285
|
listElement.showPopover?.();
|
|
254
286
|
}
|
|
287
|
+
|
|
288
|
+
const row = listElement.querySelector('.option');
|
|
289
|
+
|
|
290
|
+
if (row) {
|
|
291
|
+
const { paddingTop, paddingBottom, borderTopWidth, borderBottomWidth } =
|
|
292
|
+
getComputedStyle(listElement);
|
|
293
|
+
|
|
294
|
+
rowHeight = row.getBoundingClientRect().height;
|
|
295
|
+
listChrome =
|
|
296
|
+
Number.parseFloat(paddingTop) +
|
|
297
|
+
Number.parseFloat(paddingBottom) +
|
|
298
|
+
Number.parseFloat(borderTopWidth) +
|
|
299
|
+
Number.parseFloat(borderBottomWidth);
|
|
300
|
+
}
|
|
255
301
|
});
|
|
256
302
|
|
|
257
303
|
// Advertise that typing here can bring up predictions, for as long as the autocomplete is
|
|
@@ -301,11 +347,15 @@
|
|
|
301
347
|
};
|
|
302
348
|
});
|
|
303
349
|
|
|
304
|
-
// Keep the highlighted suggestion visible while the user arrows through a long list
|
|
350
|
+
// Keep the highlighted suggestion visible while the user arrows through a long list. The scroll
|
|
351
|
+
// has to be instant: an inherited `scroll-behavior: smooth` otherwise animates it, and the
|
|
352
|
+
// animation never lands while the list is in the top layer, leaving the highlight off screen.
|
|
305
353
|
$effect(() => {
|
|
306
354
|
void selectedIndex;
|
|
307
355
|
|
|
308
|
-
listElement
|
|
356
|
+
listElement
|
|
357
|
+
?.querySelector('[aria-selected="true"]')
|
|
358
|
+
?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
|
309
359
|
});
|
|
310
360
|
|
|
311
361
|
onMount(() => {
|
|
@@ -323,12 +373,35 @@
|
|
|
323
373
|
}
|
|
324
374
|
};
|
|
325
375
|
|
|
376
|
+
/**
|
|
377
|
+
* Dismiss the list when the user presses somewhere else on the page.
|
|
378
|
+
*
|
|
379
|
+
* The popover is `manual` rather than `auto`, so the platform’s own light dismiss is off: an
|
|
380
|
+
* `auto` popover also closes itself on Escape, which would fight the Escape handling here, and
|
|
381
|
+
* it would close on a press inside the field the list belongs to, where the caret moving is
|
|
382
|
+
* what should decide. This covers the one behavior worth borrowing.
|
|
383
|
+
* @param {PointerEvent} event `pointerdown` event.
|
|
384
|
+
*/
|
|
385
|
+
const onPointerDown = ({ target }) => {
|
|
386
|
+
const node = /** @type {Node} */ (target);
|
|
387
|
+
|
|
388
|
+
// A press on the list is a choice, not a dismissal, and this runs before the option’s own
|
|
389
|
+
// handler; a press in the field is left to the caret to sort out
|
|
390
|
+
if (!open || listElement?.contains(node) || ariaOwner?.contains(node)) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
close();
|
|
395
|
+
};
|
|
396
|
+
|
|
326
397
|
window.addEventListener('scroll', reposition, { capture: true, passive: true });
|
|
327
398
|
window.addEventListener('resize', reposition, { passive: true });
|
|
399
|
+
document.addEventListener('pointerdown', onPointerDown, { capture: true });
|
|
328
400
|
|
|
329
401
|
return () => {
|
|
330
402
|
window.removeEventListener('scroll', reposition, { capture: true });
|
|
331
403
|
window.removeEventListener('resize', reposition);
|
|
404
|
+
document.removeEventListener('pointerdown', onPointerDown, { capture: true });
|
|
332
405
|
};
|
|
333
406
|
});
|
|
334
407
|
</script>
|
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export const EMOJI_TRIGGER_REGEX: RegExp;
|
|
10
10
|
/**
|
|
11
|
-
* Maximum number of emoji suggestions
|
|
11
|
+
* Maximum number of emoji suggestions offered, matching what Discord shows. The dropdown displays
|
|
12
|
+
* five at a time and scrolls through the rest, so this only bounds how far a query can be explored
|
|
13
|
+
* — a single letter otherwise matches over a thousand emojis, all rendered on every keystroke.
|
|
12
14
|
*/
|
|
13
|
-
export const MAX_EMOJI_SUGGESTIONS:
|
|
15
|
+
export const MAX_EMOJI_SUGGESTIONS: 50;
|
|
16
|
+
export function normalizeEmojiName(name: string): string;
|
|
14
17
|
export function parseEmojiData(data: EmojiData): EmojiEntry[];
|
|
15
18
|
export function loadEmojiList(): Promise<EmojiEntry[]>;
|
|
16
19
|
/**
|
|
@@ -13,9 +13,11 @@ import { getEmojiDataLoader } from './loader.js';
|
|
|
13
13
|
export const EMOJI_TRIGGER_REGEX = /(?<=^|[\s([{"'«])(?::)(?<query>[a-zA-Z0-9_+-]{1,32})$/;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Maximum number of emoji suggestions
|
|
16
|
+
* Maximum number of emoji suggestions offered, matching what Discord shows. The dropdown displays
|
|
17
|
+
* five at a time and scrolls through the rest, so this only bounds how far a query can be explored
|
|
18
|
+
* — a single letter otherwise matches over a thousand emojis, all rendered on every keystroke.
|
|
17
19
|
*/
|
|
18
|
-
export const MAX_EMOJI_SUGGESTIONS =
|
|
20
|
+
export const MAX_EMOJI_SUGGESTIONS = 50;
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Cached emoji list. This is `undefined` until {@link loadEmojiList} resolves for the first time.
|
|
@@ -28,6 +30,24 @@ let emojiList;
|
|
|
28
30
|
*/
|
|
29
31
|
let loader;
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Rewrite an emoji name as a shortcode the user can actually type.
|
|
35
|
+
*
|
|
36
|
+
* Most names are already lower case words joined with underscores, but a hundred or so of the newer
|
|
37
|
+
* ones are written with spaces or commas instead, like `heart hands`. A query can contain neither,
|
|
38
|
+
* so those names would be unreachable by their own shortcode and would be shown as something the
|
|
39
|
+
* user can’t type back.
|
|
40
|
+
* @internal
|
|
41
|
+
* @param {string} name Name as published.
|
|
42
|
+
* @returns {string} Name made up of the characters a query can contain.
|
|
43
|
+
*/
|
|
44
|
+
export const normalizeEmojiName = (name) =>
|
|
45
|
+
name
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
// Runs of anything a query can’t contain become a single separator
|
|
48
|
+
.replace(/[^a-z0-9_+-]+/g, '_')
|
|
49
|
+
.replace(/^_+|_+$/g, '');
|
|
50
|
+
|
|
31
51
|
/**
|
|
32
52
|
* Convert the raw emoji data into a searchable list.
|
|
33
53
|
* @internal
|
|
@@ -38,7 +58,7 @@ let loader;
|
|
|
38
58
|
export const parseEmojiData = (data) =>
|
|
39
59
|
Object.entries(data).map(([emoji, [name, ...aliases]]) => ({
|
|
40
60
|
emoji,
|
|
41
|
-
name,
|
|
61
|
+
name: normalizeEmojiName(name),
|
|
42
62
|
// Some of the keywords are capitalized, e.g. `NASA` and `XD`
|
|
43
63
|
aliases: aliases.map((alias) => alias.toLowerCase()),
|
|
44
64
|
}));
|
|
@@ -86,9 +106,11 @@ export const NO_EMOJI_MATCH = 9;
|
|
|
86
106
|
/**
|
|
87
107
|
* Get how well an emoji’s name matches the given query. A lower rank means a better match.
|
|
88
108
|
*
|
|
89
|
-
* The name is matched word by word rather than only as a
|
|
90
|
-
* `flag_canada`’s second word just as `:canada` does
|
|
91
|
-
* spanning a word boundary
|
|
109
|
+
* A match has to start at a word boundary. The name is matched word by word rather than only as a
|
|
110
|
+
* whole, so a partly typed `:cana` reaches `flag_canada`’s second word just as `:canada` does, and
|
|
111
|
+
* the whole name is tested as well, so a query spanning a word boundary like `:flag_can` still
|
|
112
|
+
* matches. What this rules out is a match starting mid-word, which is nearly always coincidental:
|
|
113
|
+
* `:age` would otherwise turn up `mage`, `bagel`, `baggage`, `pager` and `package`.
|
|
92
114
|
* @internal
|
|
93
115
|
* @param {string} name Canonical emoji name.
|
|
94
116
|
* @param {string} query Lower-cased search query without the leading colon.
|
|
@@ -101,16 +123,20 @@ export const getEmojiNameMatchRank = (name, query) => {
|
|
|
101
123
|
return 0;
|
|
102
124
|
}
|
|
103
125
|
|
|
104
|
-
|
|
126
|
+
// What the name leads with is what the emoji mostly is, so `heart_hands` is a better `:heart`
|
|
127
|
+
// match than `sparkling_heart`, where the word merely turns up along the way. The whole leading
|
|
128
|
+
// word has to match: `japanese_castle` is not what `:japan` is after, nor `crystal_ball` `:cry`.
|
|
129
|
+
if (name.startsWith(`${query}_`)) {
|
|
105
130
|
return 1;
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
if (
|
|
109
|
-
return
|
|
133
|
+
if (words.includes(query)) {
|
|
134
|
+
return 2;
|
|
110
135
|
}
|
|
111
136
|
|
|
112
|
-
|
|
113
|
-
|
|
137
|
+
// The whole name is tested too, so a query spanning a word boundary like `:heart_h` still matches
|
|
138
|
+
if (name.startsWith(query) || words.some((word) => word.startsWith(query))) {
|
|
139
|
+
return 4;
|
|
114
140
|
}
|
|
115
141
|
|
|
116
142
|
return NO_EMOJI_MATCH;
|
|
@@ -127,11 +153,11 @@ export const getEmojiNameMatchRank = (name, query) => {
|
|
|
127
153
|
*/
|
|
128
154
|
export const getEmojiAliasMatchRank = (aliases, query) => {
|
|
129
155
|
if (aliases.includes(query)) {
|
|
130
|
-
return
|
|
156
|
+
return 3;
|
|
131
157
|
}
|
|
132
158
|
|
|
133
159
|
if (aliases.some((alias) => alias.startsWith(query))) {
|
|
134
|
-
return
|
|
160
|
+
return 5;
|
|
135
161
|
}
|
|
136
162
|
|
|
137
163
|
return NO_EMOJI_MATCH;
|
|
@@ -158,6 +184,30 @@ export const getEmojiMatchRank = ({ name, aliases }, query) => {
|
|
|
158
184
|
return { rank: Math.min(nameRank, aliasRank), nameRank };
|
|
159
185
|
};
|
|
160
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Get how central a match is to the emoji, to separate emojis that match equally well. A lower
|
|
189
|
+
* number means the query is more of what the emoji is about.
|
|
190
|
+
*
|
|
191
|
+
* For a name match, that’s how much of the name the query accounts for: `red_heart` is more of a
|
|
192
|
+
* `:heart` than `smiling_face_with_heart_eyes` is. For a keyword match, it’s how prominent the
|
|
193
|
+
* keyword is — `emojilib` lists them roughly in order of relevance, so a keyword listed first, in a
|
|
194
|
+
* short list, is what the emoji is really for. `love` is the first of 🫶’s three keywords, while
|
|
195
|
+
* 💏 `kiss` buries it third among nineteen.
|
|
196
|
+
* @internal
|
|
197
|
+
* @param {EmojiEntry} entry Emoji entry.
|
|
198
|
+
* @param {string} query Lower-cased search query without the leading colon.
|
|
199
|
+
* @returns {number} Centrality, comparable only between equally ranked emojis.
|
|
200
|
+
*/
|
|
201
|
+
const getMatchCentrality = ({ name, aliases }, query) => {
|
|
202
|
+
if (getEmojiNameMatchRank(name, query) < NO_EMOJI_MATCH) {
|
|
203
|
+
return name.split('_').length;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const index = aliases.findIndex((alias) => alias === query || alias.startsWith(query));
|
|
207
|
+
|
|
208
|
+
return (index > -1 ? index : aliases.length) * 100 + aliases.length;
|
|
209
|
+
};
|
|
210
|
+
|
|
161
211
|
/**
|
|
162
212
|
* Search the loaded emoji list for the given query. This returns an empty list unless
|
|
163
213
|
* {@link loadEmojiList} has been resolved beforehand.
|
|
@@ -174,11 +224,19 @@ export const searchEmojis = (query) => {
|
|
|
174
224
|
|
|
175
225
|
return (
|
|
176
226
|
emojiList
|
|
177
|
-
.map((entry) =>
|
|
227
|
+
.map((entry) => {
|
|
228
|
+
const { rank, nameRank } = getEmojiMatchRank(entry, normalizedQuery);
|
|
229
|
+
|
|
230
|
+
return { entry, rank, nameRank, centrality: getMatchCentrality(entry, normalizedQuery) };
|
|
231
|
+
})
|
|
178
232
|
.filter(({ rank }) => rank < NO_EMOJI_MATCH)
|
|
179
|
-
// Equally ranked emojis are settled by the name, then by the
|
|
180
|
-
//
|
|
181
|
-
|
|
233
|
+
// Equally ranked emojis are settled by the name, then by how central the match is to the
|
|
234
|
+
// emoji, then by the published order — `Array.prototype.sort()` is stable.
|
|
235
|
+
//
|
|
236
|
+
// That last resort is weak: the published order follows the Unicode categories, not how often
|
|
237
|
+
// an emoji is used, and newer emojis are simply appended. 🫶 `heart_hands` sits at 1826, so
|
|
238
|
+
// without the two keys before it, it loses every tie to whatever happens to be older.
|
|
239
|
+
.sort((a, b) => a.rank - b.rank || a.nameRank - b.nameRank || a.centrality - b.centrality)
|
|
182
240
|
.slice(0, MAX_EMOJI_SUGGESTIONS)
|
|
183
241
|
.map(({ entry }) => entry)
|
|
184
242
|
);
|
|
@@ -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.65.
|
|
10
|
+
export const UI_VERSION: "0.65.2";
|
|
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.65.
|
|
13
|
+
export const UI_VERSION = "0.65.2";
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Available syntax highlighting languages, sorted by display name.
|