@@ -15,38 +15,57 @@ import {
1515 ruleIsDisabled ,
1616} from "../utils/rules.js" ;
1717
18+ interface GraphemeSegment {
19+ segment : string ;
20+ index : number ;
21+ input : string ;
22+ isWordLike ?: boolean ;
23+ }
24+
1825const segmenter = new Intl . Segmenter ( "en" , { granularity : "grapheme" } ) ;
1926const isPresentation = / ^ \p{ Emoji_Presentation} / u;
2027const isEmojiBase = / ^ \p{ Emoji} / u;
2128
2229/**
23- * Appends Unicode Variation Selector 16 (U+FE0F) to emojis missing it,
24- * forcing emoji-width (2 col, like ✨) presentation in terminals. Without VS16,
25- * emojis like 🛠 (U+1F6E0) and 🗑 (U+1F5D1) render at text-width (1 col),
26- * breaking column alignment in interactive menus.
30+ * Normalizes emojis to ensure a consistent 2-column width in terminals by appending
31+ * Variation Selector 16 (U+FE0F).
32+ * * Emojis like ✨ (U+2728) are "Emoji_Presentation" by default and render at width 2.
33+ * However, "text-style" emojis like 🛠 (U+1F6E0) or 🗑 (U+1F5D1) default to width 1
34+ * in many terminals, breaking column alignment.
35+ * * This function identifies single-grapheme emojis lacking presentation properties
36+ * and inserts the VS16 immediately after the base character (before modifiers
37+ * like skin tones) to force graphical rendering without breaking ZWJ sequences.
38+ * @param emoji The emoji string to normalize.
39+ * @returns The normalized emoji string with VS16 inserted where necessary.
2740 */
2841function normalizeEmoji ( emoji : string ) : string {
2942 const trimmed = emoji . replace ( / \s + $ / , "" ) ;
3043 const trailing = emoji . slice ( trimmed . length ) ;
3144
3245 if ( trimmed . length === 0 ) return emoji ;
3346
34- const segments = Array . from ( segmenter . segment ( trimmed ) ) ;
47+ const segments = Array . from ( segmenter . segment ( trimmed ) ) as GraphemeSegment [ ] ;
3548
3649 if ( segments . length === 1 ) {
37- const char = segments [ 0 ] . segment ;
50+ const cluster = segments [ 0 ] . segment ;
51+ const codePoints = Array . from ( cluster ) ;
52+ const baseChar = codePoints [ 0 ] ;
3853
3954 switch ( true ) {
40- case char . includes ( "\uFE0F" ) :
41- case char . includes ( "\uFE0E" ) :
55+ case cluster . includes ( "\uFE0F" ) :
56+ case cluster . includes ( "\uFE0E" ) :
4257 return emoji ;
4358
44- case isPresentation . test ( char ) :
59+ case isPresentation . test ( baseChar ) :
4560 return emoji ;
4661
47- // Is it a "Text-style" emoji base and not a number? Add VS16!
48- case isEmojiBase . test ( char ) && ! / ^ [ 0 - 9 # * ] $ / . test ( char ) :
49- return trimmed + "\uFE0F" + trailing ;
62+ // 3. If the base char is an Emoji base but not presentation:
63+ case isEmojiBase . test ( baseChar ) && ! / ^ [ 0 - 9 # * ] $ / . test ( baseChar ) : {
64+ // Reconstruct: Base + VS16 + the rest of the cluster (skin tones, ZWJs, etc.)
65+ const normalizedCluster =
66+ baseChar + "\uFE0F" + codePoints . slice ( 1 ) . join ( "" ) ;
67+ return normalizedCluster + trailing ;
68+ }
5069 }
5170 }
5271
0 commit comments