2231 lines
165 KiB
JavaScript
2231 lines
165 KiB
JavaScript
/**
|
|
* @name Emoji Picker
|
|
* @description A Discord-style emoji picker for X's text boxes. X's own emoji button (or Ctrl+Shift+E) opens it right under the composer toolbar: search with skin tones on top, a category rail on the left, one scrolling grid with Frequently used first and sticky category headers, and a preview bar with the :shortcode:. Full keyboard control (arrows, Enter, Tab, PageUp/PageDown, Esc). In text boxes without X's own emoji suggestions, `:fire` suggests emoji (arrows/Tab/Enter insert, Esc closes) and a closing `:` inserts an exact match. The gear in the picker chooses whether X's emoji button opens this picker or X's own.
|
|
* @version 3.2.0
|
|
* @author wayBetter
|
|
* @permissions storage, ui, network:none
|
|
*/
|
|
const EMOJI_DATA = {"categories":["Smileys & Emotion","People & Body","Animals & Nature","Food & Drink","Travel & Places","Activities","Objects","Symbols","Flags"],"rows":[["😀","grinning","smile happy",0,0],["😃","smiley","happy joy haha",0,0],["😄","smile","happy joy laugh pleased",0,0],["😁","grin","",0,0],["😆","laughing;satisfied","happy haha",0,0],["😅","sweat_smile","hot",0,0],["🤣","rofl","lol laughing",0,0],["😂","joy","tears",0,0],["🙂","slightly_smiling_face","",0,0],["🙃","upside_down_face","",0,0],["🫠","melting_face","sarcasm dread",0,0],["😉","wink","flirt",0,0],["😊","blush","proud",0,0],["😇","innocent","angel",0,0],["🥰","smiling_face_with_three_hearts","love",0,0],["😍","heart_eyes","love crush",0,0],["🤩","star_struck","eyes",0,0],["😘","kissing_heart","flirt",0,0],["😗","kissing","",0,0],["☺️","relaxed","blush pleased",0,0],["😚","kissing_closed_eyes","",0,0],["😙","kissing_smiling_eyes","",0,0],["🥲","smiling_face_with_tear","",0,0],["😋","yum","tongue lick",0,0],["😛","stuck_out_tongue","",0,0],["😜","stuck_out_tongue_winking_eye","prank silly",0,0],["🤪","zany_face","goofy wacky",0,0],["😝","stuck_out_tongue_closed_eyes","prank",0,0],["🤑","money_mouth_face","rich",0,0],["🤗","hugs","",0,0],["🤭","hand_over_mouth","quiet whoops",0,0],["🫢","face_with_open_eyes_and_hand_over_mouth","gasp shock",0,0],["🫣","face_with_peeking_eye","",0,0],["🤫","shushing_face","silence quiet",0,0],["🤔","thinking","",0,0],["🫡","saluting_face","respect",0,0],["🤐","zipper_mouth_face","silence hush",0,0],["🤨","raised_eyebrow","suspicious",0,0],["😐","neutral_face","meh",0,0],["😑","expressionless","",0,0],["😶","no_mouth","mute silence",0,0],["🫥","dotted_line_face","invisible",0,0],["😶🌫️","face_in_clouds","",0,0],["😏","smirk","smug",0,0],["😒","unamused","meh",0,0],["🙄","roll_eyes","",0,0],["😬","grimacing","",0,0],["😮💨","face_exhaling","",0,0],["🤥","lying_face","liar",0,0],["😌","relieved","whew",0,0],["😔","pensive","",0,0],["😪","sleepy","tired",0,0],["🤤","drooling_face","",0,0],["😴","sleeping","zzz",0,0],["😷","mask","sick ill",0,0],["🤒","face_with_thermometer","sick",0,0],["🤕","face_with_head_bandage","hurt",0,0],["🤢","nauseated_face","sick barf disgusted",0,0],["🤮","vomiting_face","barf sick",0,0],["🤧","sneezing_face","achoo sick",0,0],["🥵","hot_face","heat sweating",0,0],["🥶","cold_face","freezing ice",0,0],["🥴","woozy_face","groggy",0,0],["😵","dizzy_face","",0,0],["😵💫","face_with_spiral_eyes","",0,0],["🤯","exploding_head","mind blown",0,0],["🤠","cowboy_hat_face","",0,0],["🥳","partying_face","celebration birthday",0,0],["🥸","disguised_face","",0,0],["😎","sunglasses","cool",0,0],["🤓","nerd_face","geek glasses",0,0],["🧐","monocle_face","",0,0],["😕","confused","",0,0],["🫤","face_with_diagonal_mouth","confused",0,0],["😟","worried","nervous",0,0],["🙁","slightly_frowning_face","",0,0],["☹️","frowning_face","",0,0],["😮","open_mouth","surprise impressed wow",0,0],["😯","hushed","silence speechless",0,0],["😲","astonished","amazed gasp",0,0],["😳","flushed","",0,0],["🥺","pleading_face","puppy eyes",0,0],["🥹","face_holding_back_tears","tears gratitude",0,0],["😦","frowning","",0,0],["😧","anguished","stunned",0,0],["😨","fearful","scared shocked oops",0,0],["😰","cold_sweat","nervous",0,0],["😥","disappointed_relieved","phew sweat nervous",0,0],["😢","cry","sad tear",0,0],["😭","sob","sad cry bawling",0,0],["😱","scream","horror shocked",0,0],["😖","confounded","",0,0],["😣","persevere","struggling",0,0],["😞","disappointed","sad",0,0],["😓","sweat","",0,0],["😩","weary","tired",0,0],["😫","tired_face","upset whine",0,0],["🥱","yawning_face","",0,0],["😤","triumph","smug",0,0],["😡","rage;pout","angry",0,0],["😠","angry","mad annoyed",0,0],["🤬","cursing_face","foul",0,0],["😈","smiling_imp","devil evil horns",0,0],["👿","imp","angry devil evil horns",0,0],["💀","skull","dead danger poison",0,0],["☠️","skull_and_crossbones","danger pirate",0,0],["💩","hankey;poop;shit","crap",0,0],["🤡","clown_face","",0,0],["👹","japanese_ogre","monster",0,0],["👺","japanese_goblin","",0,0],["👻","ghost","halloween",0,0],["👽","alien","ufo",0,0],["👾","space_invader","game retro",0,0],["🤖","robot","",0,0],["😺","smiley_cat","",0,0],["😸","smile_cat","",0,0],["😹","joy_cat","",0,0],["😻","heart_eyes_cat","",0,0],["😼","smirk_cat","",0,0],["😽","kissing_cat","",0,0],["🙀","scream_cat","horror",0,0],["😿","crying_cat_face","sad tear",0,0],["😾","pouting_cat","",0,0],["🙈","see_no_evil","monkey blind ignore",0,0],["🙉","hear_no_evil","monkey deaf",0,0],["🙊","speak_no_evil","monkey mute hush",0,0],["💋","kiss","lipstick",0,0],["💌","love_letter","email envelope",0,0],["💘","cupid","love heart",0,0],["💝","gift_heart","chocolates",0,0],["💖","sparkling_heart","",0,0],["💗","heartpulse","",0,0],["💓","heartbeat","",0,0],["💞","revolving_hearts","",0,0],["💕","two_hearts","",0,0],["💟","heart_decoration","",0,0],["❣️","heavy_heart_exclamation","",0,0],["💔","broken_heart","",0,0],["❤️🔥","heart_on_fire","",0,0],["❤️🩹","mending_heart","",0,0],["❤️","heart","love",0,0],["🧡","orange_heart","",0,0],["💛","yellow_heart","",0,0],["💚","green_heart","",0,0],["💙","blue_heart","",0,0],["💜","purple_heart","",0,0],["🤎","brown_heart","",0,0],["🖤","black_heart","",0,0],["🤍","white_heart","",0,0],["💯","100","score perfect",0,0],["💢","anger","angry",0,0],["💥","boom;collision","explode",0,0],["💫","dizzy","star",0,0],["💦","sweat_drops","water workout",0,0],["💨","dash","wind blow fast",0,0],["🕳️","hole","",0,0],["💣","bomb","boom",0,0],["💬","speech_balloon","comment",0,0],["👁️🗨️","eye_speech_bubble","",0,0],["🗨️","left_speech_bubble","",0,0],["🗯️","right_anger_bubble","",0,0],["💭","thought_balloon","thinking",0,0],["💤","zzz","sleeping",0,0],["👋","wave","goodbye",1,1],["🤚","raised_back_of_hand","",1,1],["🖐️","raised_hand_with_fingers_splayed","",1,1],["✋","hand;raised_hand","highfive stop",1,1],["🖖","vulcan_salute","prosper spock",1,1],["🫱","rightwards_hand","",1,1],["🫲","leftwards_hand","",1,1],["🫳","palm_down_hand","",1,1],["🫴","palm_up_hand","",1,1],["👌","ok_hand","",1,1],["🤌","pinched_fingers","",1,1],["🤏","pinching_hand","",1,1],["✌️","v","victory peace",1,1],["🤞","crossed_fingers","luck hopeful",1,1],["🫰","hand_with_index_finger_and_thumb_crossed","",1,1],["🤟","love_you_gesture","",1,1],["🤘","metal","",1,1],["🤙","call_me_hand","",1,1],["👈","point_left","",1,1],["👉","point_right","",1,1],["👆","point_up_2","",1,1],["🖕","middle_finger;fu","",1,1],["👇","point_down","",1,1],["☝️","point_up","",1,1],["🫵","index_pointing_at_the_viewer","",1,1],["👍","+1;thumbsup","approve ok",1,1],["👎","-1;thumbsdown","disapprove bury",1,1],["✊","fist_raised;fist","power",1,1],["👊","fist_oncoming;facepunch;punch","attack",1,1],["🤛","fist_left","",1,1],["🤜","fist_right","",1,1],["👏","clap","praise applause",1,1],["🙌","raised_hands","hooray",1,1],["🫶","heart_hands","love",1,1],["👐","open_hands","",1,1],["🤲","palms_up_together","",1,1],["🤝","handshake","deal",1,1],["🙏","pray","please hope wish",1,1],["✍️","writing_hand","",1,1],["💅","nail_care","beauty manicure",1,1],["🤳","selfie","",1,1],["💪","muscle","flex bicep strong workout",1,1],["🦾","mechanical_arm","",1,0],["🦿","mechanical_leg","",1,0],["🦵","leg","",1,1],["🦶","foot","",1,1],["👂","ear","hear sound listen",1,1],["🦻","ear_with_hearing_aid","",1,1],["👃","nose","smell",1,1],["🧠","brain","",1,0],["🫀","anatomical_heart","",1,0],["🫁","lungs","",1,0],["🦷","tooth","",1,0],["🦴","bone","",1,0],["👀","eyes","look see watch",1,0],["👁️","eye","",1,0],["👅","tongue","taste",1,0],["👄","lips","kiss",1,0],["🫦","biting_lip","",1,0],["👶","baby","child newborn",1,1],["🧒","child","",1,1],["👦","boy","child",1,1],["👧","girl","child",1,1],["🧑","adult","",1,1],["👱","blond_haired_person","",1,1],["👨","man","mustache father dad",1,1],["🧔","bearded_person","",1,1],["🧔♂️","man_beard","",1,1],["🧔♀️","woman_beard","",1,1],["👨🦰","red_haired_man","",1,1],["👨🦱","curly_haired_man","",1,1],["👨🦳","white_haired_man","",1,1],["👨🦲","bald_man","",1,1],["👩","woman","girls",1,1],["👩🦰","red_haired_woman","",1,1],["🧑🦰","person_red_hair","",1,1],["👩🦱","curly_haired_woman","",1,1],["🧑🦱","person_curly_hair","",1,1],["👩🦳","white_haired_woman","",1,1],["🧑🦳","person_white_hair","",1,1],["👩🦲","bald_woman","",1,1],["🧑🦲","person_bald","",1,1],["👱♀️","blond_haired_woman;blonde_woman","",1,1],["👱♂️","blond_haired_man","",1,1],["🧓","older_adult","",1,1],["👴","older_man","",1,1],["👵","older_woman","",1,1],["🙍","frowning_person","",1,1],["🙍♂️","frowning_man","",1,1],["🙍♀️","frowning_woman","",1,1],["🙎","pouting_face","",1,1],["🙎♂️","pouting_man","",1,1],["🙎♀️","pouting_woman","",1,1],["🙅","no_good","stop halt denied",1,1],["🙅♂️","no_good_man;ng_man","stop halt denied",1,1],["🙅♀️","no_good_woman;ng_woman","stop halt denied",1,1],["🙆","ok_person","",1,1],["🙆♂️","ok_man","",1,1],["🙆♀️","ok_woman","",1,1],["💁","tipping_hand_person;information_desk_person","",1,1],["💁♂️","tipping_hand_man;sassy_man","information",1,1],["💁♀️","tipping_hand_woman;sassy_woman","information",1,1],["🙋","raising_hand","",1,1],["🙋♂️","raising_hand_man","",1,1],["🙋♀️","raising_hand_woman","",1,1],["🧏","deaf_person","",1,1],["🧏♂️","deaf_man","",1,1],["🧏♀️","deaf_woman","",1,1],["🙇","bow","respect thanks",1,1],["🙇♂️","bowing_man","respect thanks",1,1],["🙇♀️","bowing_woman","respect thanks",1,1],["🤦","facepalm","",1,1],["🤦♂️","man_facepalming","",1,1],["🤦♀️","woman_facepalming","",1,1],["🤷","shrug","",1,1],["🤷♂️","man_shrugging","",1,1],["🤷♀️","woman_shrugging","",1,1],["🧑⚕️","health_worker","",1,1],["👨⚕️","man_health_worker","doctor nurse",1,1],["👩⚕️","woman_health_worker","doctor nurse",1,1],["🧑🎓","student","",1,1],["👨🎓","man_student","graduation",1,1],["👩🎓","woman_student","graduation",1,1],["🧑🏫","teacher","",1,1],["👨🏫","man_teacher","school professor",1,1],["👩🏫","woman_teacher","school professor",1,1],["🧑⚖️","judge","",1,1],["👨⚖️","man_judge","justice",1,1],["👩⚖️","woman_judge","justice",1,1],["🧑🌾","farmer","",1,1],["👨🌾","man_farmer","",1,1],["👩🌾","woman_farmer","",1,1],["🧑🍳","cook","",1,1],["👨🍳","man_cook","chef",1,1],["👩🍳","woman_cook","chef",1,1],["🧑🔧","mechanic","",1,1],["👨🔧","man_mechanic","",1,1],["👩🔧","woman_mechanic","",1,1],["🧑🏭","factory_worker","",1,1],["👨🏭","man_factory_worker","",1,1],["👩🏭","woman_factory_worker","",1,1],["🧑💼","office_worker","",1,1],["👨💼","man_office_worker","business",1,1],["👩💼","woman_office_worker","business",1,1],["🧑🔬","scientist","",1,1],["👨🔬","man_scientist","research",1,1],["👩🔬","woman_scientist","research",1,1],["🧑💻","technologist","",1,1],["👨💻","man_technologist","coder",1,1],["👩💻","woman_technologist","coder",1,1],["🧑🎤","singer","",1,1],["👨🎤","man_singer","rockstar",1,1],["👩🎤","woman_singer","rockstar",1,1],["🧑🎨","artist","",1,1],["👨🎨","man_artist","painter",1,1],["👩🎨","woman_artist","painter",1,1],["🧑✈️","pilot","",1,1],["👨✈️","man_pilot","",1,1],["👩✈️","woman_pilot","",1,1],["🧑🚀","astronaut","",1,1],["👨🚀","man_astronaut","space",1,1],["👩🚀","woman_astronaut","space",1,1],["🧑🚒","firefighter","",1,1],["👨🚒","man_firefighter","",1,1],["👩🚒","woman_firefighter","",1,1],["👮","police_officer;cop","law",1,1],["👮♂️","policeman","law cop",1,1],["👮♀️","policewoman","law cop",1,1],["🕵️","detective","sleuth",1,1],["🕵️♂️","male_detective","sleuth",1,1],["🕵️♀️","female_detective","sleuth",1,1],["💂","guard","",1,1],["💂♂️","guardsman","",1,1],["💂♀️","guardswoman","",1,1],["🥷","ninja","",1,1],["👷","construction_worker","helmet",1,1],["👷♂️","construction_worker_man","helmet",1,1],["👷♀️","construction_worker_woman","helmet",1,1],["🫅","person_with_crown","",1,1],["🤴","prince","crown royal",1,1],["👸","princess","crown royal",1,1],["👳","person_with_turban","",1,1],["👳♂️","man_with_turban","",1,1],["👳♀️","woman_with_turban","",1,1],["👲","man_with_gua_pi_mao","",1,1],["🧕","woman_with_headscarf","hijab",1,1],["🤵","person_in_tuxedo","groom marriage wedding",1,1],["🤵♂️","man_in_tuxedo","",1,1],["🤵♀️","woman_in_tuxedo","",1,1],["👰","person_with_veil","marriage wedding",1,1],["👰♂️","man_with_veil","",1,1],["👰♀️","woman_with_veil;bride_with_veil","",1,1],["🤰","pregnant_woman","",1,1],["🫃","pregnant_man","",1,1],["🫄","pregnant_person","",1,1],["🤱","breast_feeding","nursing",1,1],["👩🍼","woman_feeding_baby","",1,1],["👨🍼","man_feeding_baby","",1,1],["🧑🍼","person_feeding_baby","",1,1],["👼","angel","",1,1],["🎅","santa","christmas",1,1],["🤶","mrs_claus","santa",1,1],["🧑🎄","mx_claus","",1,1],["🦸","superhero","",1,1],["🦸♂️","superhero_man","",1,1],["🦸♀️","superhero_woman","",1,1],["🦹","supervillain","",1,1],["🦹♂️","supervillain_man","",1,1],["🦹♀️","supervillain_woman","",1,1],["🧙","mage","wizard",1,1],["🧙♂️","mage_man","wizard",1,1],["🧙♀️","mage_woman","wizard",1,1],["🧚","fairy","",1,1],["🧚♂️","fairy_man","",1,1],["🧚♀️","fairy_woman","",1,1],["🧛","vampire","",1,1],["🧛♂️","vampire_man","",1,1],["🧛♀️","vampire_woman","",1,1],["🧜","merperson","",1,1],["🧜♂️","merman","",1,1],["🧜♀️","mermaid","",1,1],["🧝","elf","",1,1],["🧝♂️","elf_man","",1,1],["🧝♀️","elf_woman","",1,1],["🧞","genie","",1,0],["🧞♂️","genie_man","",1,0],["🧞♀️","genie_woman","",1,0],["🧟","zombie","",1,0],["🧟♂️","zombie_man","",1,0],["🧟♀️","zombie_woman","",1,0],["🧌","troll","",1,0],["💆","massage","spa",1,1],["💆♂️","massage_man","spa",1,1],["💆♀️","massage_woman","spa",1,1],["💇","haircut","beauty",1,1],["💇♂️","haircut_man","",1,1],["💇♀️","haircut_woman","",1,1],["🚶","walking","",1,1],["🚶♂️","walking_man","",1,1],["🚶♀️","walking_woman","",1,1],["🧍","standing_person","",1,1],["🧍♂️","standing_man","",1,1],["🧍♀️","standing_woman","",1,1],["🧎","kneeling_person","",1,1],["🧎♂️","kneeling_man","",1,1],["🧎♀️","kneeling_woman","",1,1],["🧑🦯","person_with_probing_cane","",1,1],["👨🦯","man_with_probing_cane","",1,1],["👩🦯","woman_with_probing_cane","",1,1],["🧑🦼","person_in_motorized_wheelchair","",1,1],["👨🦼","man_in_motorized_wheelchair","",1,1],["👩🦼","woman_in_motorized_wheelchair","",1,1],["🧑🦽","person_in_manual_wheelchair","",1,1],["👨🦽","man_in_manual_wheelchair","",1,1],["👩🦽","woman_in_manual_wheelchair","",1,1],["🏃","runner;running","exercise workout marathon",1,1],["🏃♂️","running_man","exercise workout marathon",1,1],["🏃♀️","running_woman","exercise workout marathon",1,1],["💃","woman_dancing;dancer","dress",1,1],["🕺","man_dancing","dancer",1,1],["🕴️","business_suit_levitating","",1,1],["👯","dancers","bunny",1,0],["👯♂️","dancing_men","bunny",1,0],["👯♀️","dancing_women","bunny",1,0],["🧖","sauna_person","steamy",1,1],["🧖♂️","sauna_man","steamy",1,1],["🧖♀️","sauna_woman","steamy",1,1],["🧗","climbing","bouldering",1,1],["🧗♂️","climbing_man","bouldering",1,1],["🧗♀️","climbing_woman","bouldering",1,1],["🤺","person_fencing","",1,0],["🏇","horse_racing","",1,1],["⛷️","skier","",1,0],["🏂","snowboarder","",1,1],["🏌️","golfing","",1,1],["🏌️♂️","golfing_man","",1,1],["🏌️♀️","golfing_woman","",1,1],["🏄","surfer","",1,1],["🏄♂️","surfing_man","",1,1],["🏄♀️","surfing_woman","",1,1],["🚣","rowboat","",1,1],["🚣♂️","rowing_man","",1,1],["🚣♀️","rowing_woman","",1,1],["🏊","swimmer","",1,1],["🏊♂️","swimming_man","",1,1],["🏊♀️","swimming_woman","",1,1],["⛹️","bouncing_ball_person","basketball",1,1],["⛹️♂️","bouncing_ball_man;basketball_man","",1,1],["⛹️♀️","bouncing_ball_woman;basketball_woman","",1,1],["🏋️","weight_lifting","gym workout",1,1],["🏋️♂️","weight_lifting_man","gym workout",1,1],["🏋️♀️","weight_lifting_woman","gym workout",1,1],["🚴","bicyclist","",1,1],["🚴♂️","biking_man","",1,1],["🚴♀️","biking_woman","",1,1],["🚵","mountain_bicyclist","",1,1],["🚵♂️","mountain_biking_man","",1,1],["🚵♀️","mountain_biking_woman","",1,1],["🤸","cartwheeling","",1,1],["🤸♂️","man_cartwheeling","",1,1],["🤸♀️","woman_cartwheeling","",1,1],["🤼","wrestling","",1,0],["🤼♂️","men_wrestling","",1,0],["🤼♀️","women_wrestling","",1,0],["🤽","water_polo","",1,1],["🤽♂️","man_playing_water_polo","",1,1],["🤽♀️","woman_playing_water_polo","",1,1],["🤾","handball_person","",1,1],["🤾♂️","man_playing_handball","",1,1],["🤾♀️","woman_playing_handball","",1,1],["🤹","juggling_person","",1,1],["🤹♂️","man_juggling","",1,1],["🤹♀️","woman_juggling","",1,1],["🧘","lotus_position","meditation",1,1],["🧘♂️","lotus_position_man","meditation",1,1],["🧘♀️","lotus_position_woman","meditation",1,1],["🛀","bath","shower",1,1],["🛌","sleeping_bed","",1,1],["🧑🤝🧑","people_holding_hands","couple date",1,1],["👭","two_women_holding_hands","couple date",1,1],["👫","couple","date",1,1],["👬","two_men_holding_hands","couple date",1,1],["💏","couplekiss","",1,1],["👩❤️💋👨","couplekiss_man_woman","",1,1],["👨❤️💋👨","couplekiss_man_man","",1,1],["👩❤️💋👩","couplekiss_woman_woman","",1,1],["💑","couple_with_heart","",1,1],["👩❤️👨","couple_with_heart_woman_man","",1,1],["👨❤️👨","couple_with_heart_man_man","",1,1],["👩❤️👩","couple_with_heart_woman_woman","",1,1],["👪","family","home parents child",1,0],["👨👩👦","family_man_woman_boy","",1,0],["👨👩👧","family_man_woman_girl","",1,0],["👨👩👧👦","family_man_woman_girl_boy","",1,0],["👨👩👦👦","family_man_woman_boy_boy","",1,0],["👨👩👧👧","family_man_woman_girl_girl","",1,0],["👨👨👦","family_man_man_boy","",1,0],["👨👨👧","family_man_man_girl","",1,0],["👨👨👧👦","family_man_man_girl_boy","",1,0],["👨👨👦👦","family_man_man_boy_boy","",1,0],["👨👨👧👧","family_man_man_girl_girl","",1,0],["👩👩👦","family_woman_woman_boy","",1,0],["👩👩👧","family_woman_woman_girl","",1,0],["👩👩👧👦","family_woman_woman_girl_boy","",1,0],["👩👩👦👦","family_woman_woman_boy_boy","",1,0],["👩👩👧👧","family_woman_woman_girl_girl","",1,0],["👨👦","family_man_boy","",1,0],["👨👦👦","family_man_boy_boy","",1,0],["👨👧","family_man_girl","",1,0],["👨👧👦","family_man_girl_boy","",1,0],["👨👧👧","family_man_girl_girl","",1,0],["👩👦","family_woman_boy","",1,0],["👩👦👦","family_woman_boy_boy","",1,0],["👩👧","family_woman_girl","",1,0],["👩👧👦","family_woman_girl_boy","",1,0],["👩👧👧","family_woman_girl_girl","",1,0],["🗣️","speaking_head","",1,0],["👤","bust_in_silhouette","user",1,0],["👥","busts_in_silhouette","users group team",1,0],["🫂","people_hugging","",1,0],["👣","footprints","feet tracks",1,0],["🐵","monkey_face","",2,0],["🐒","monkey","",2,0],["🦍","gorilla","",2,0],["🦧","orangutan","",2,0],["🐶","dog","pet",2,0],["🐕","dog2","",2,0],["🦮","guide_dog","",2,0],["🐕🦺","service_dog","",2,0],["🐩","poodle","dog",2,0],["🐺","wolf","",2,0],["🦊","fox_face","",2,0],["🦝","raccoon","",2,0],["🐱","cat","pet",2,0],["🐈","cat2","",2,0],["🐈⬛","black_cat","",2,0],["🦁","lion","",2,0],["🐯","tiger","",2,0],["🐅","tiger2","",2,0],["🐆","leopard","",2,0],["🐴","horse","",2,0],["🐎","racehorse","speed",2,0],["🦄","unicorn","",2,0],["🦓","zebra","",2,0],["🦌","deer","",2,0],["🦬","bison","",2,0],["🐮","cow","",2,0],["🐂","ox","",2,0],["🐃","water_buffalo","",2,0],["🐄","cow2","",2,0],["🐷","pig","",2,0],["🐖","pig2","",2,0],["🐗","boar","",2,0],["🐽","pig_nose","",2,0],["🐏","ram","",2,0],["🐑","sheep","",2,0],["🐐","goat","",2,0],["🐪","dromedary_camel","desert",2,0],["🐫","camel","",2,0],["🦙","llama","",2,0],["🦒","giraffe","",2,0],["🐘","elephant","",2,0],["🦣","mammoth","",2,0],["🦏","rhinoceros","",2,0],["🦛","hippopotamus","",2,0],["🐭","mouse","",2,0],["🐁","mouse2","",2,0],["🐀","rat","",2,0],["🐹","hamster","pet",2,0],["🐰","rabbit","bunny",2,0],["🐇","rabbit2","",2,0],["🐿️","chipmunk","",2,0],["🦫","beaver","",2,0],["🦔","hedgehog","",2,0],["🦇","bat","",2,0],["🐻","bear","",2,0],["🐻❄️","polar_bear","",2,0],["🐨","koala","",2,0],["🐼","panda_face","",2,0],["🦥","sloth","",2,0],["🦦","otter","",2,0],["🦨","skunk","",2,0],["🦘","kangaroo","",2,0],["🦡","badger","",2,0],["🐾","feet;paw_prints","",2,0],["🦃","turkey","thanksgiving",2,0],["🐔","chicken","",2,0],["🐓","rooster","",2,0],["🐣","hatching_chick","",2,0],["🐤","baby_chick","",2,0],["🐥","hatched_chick","",2,0],["🐦","bird","",2,0],["🐧","penguin","",2,0],["🕊️","dove","peace",2,0],["🦅","eagle","",2,0],["🦆","duck","",2,0],["🦢","swan","",2,0],["🦉","owl","",2,0],["🦤","dodo","",2,0],["🪶","feather","",2,0],["🦩","flamingo","",2,0],["🦚","peacock","",2,0],["🦜","parrot","",2,0],["🐸","frog","",2,0],["🐊","crocodile","",2,0],["🐢","turtle","slow",2,0],["🦎","lizard","",2,0],["🐍","snake","",2,0],["🐲","dragon_face","",2,0],["🐉","dragon","",2,0],["🦕","sauropod","dinosaur",2,0],["🦖","t-rex","dinosaur",2,0],["🐳","whale","sea",2,0],["🐋","whale2","",2,0],["🐬","dolphin;flipper","",2,0],["🦭","seal","",2,0],["🐟","fish","",2,0],["🐠","tropical_fish","",2,0],["🐡","blowfish","",2,0],["🦈","shark","",2,0],["🐙","octopus","",2,0],["🐚","shell","sea beach",2,0],["🪸","coral","",2,0],["🐌","snail","slow",2,0],["🦋","butterfly","",2,0],["🐛","bug","",2,0],["🐜","ant","",2,0],["🐝","bee;honeybee","",2,0],["🪲","beetle","",2,0],["🐞","lady_beetle","bug",2,0],["🦗","cricket","",2,0],["🪳","cockroach","",2,0],["🕷️","spider","",2,0],["🕸️","spider_web","",2,0],["🦂","scorpion","",2,0],["🦟","mosquito","",2,0],["🪰","fly","",2,0],["🪱","worm","",2,0],["🦠","microbe","germ",2,0],["💐","bouquet","flowers",2,0],["🌸","cherry_blossom","flower spring",2,0],["💮","white_flower","",2,0],["🪷","lotus","",2,0],["🏵️","rosette","",2,0],["🌹","rose","flower",2,0],["🥀","wilted_flower","",2,0],["🌺","hibiscus","",2,0],["🌻","sunflower","",2,0],["🌼","blossom","",2,0],["🌷","tulip","flower",2,0],["🌱","seedling","plant",2,0],["🪴","potted_plant","",2,0],["🌲","evergreen_tree","wood",2,0],["🌳","deciduous_tree","wood",2,0],["🌴","palm_tree","",2,0],["🌵","cactus","",2,0],["🌾","ear_of_rice","",2,0],["🌿","herb","",2,0],["☘️","shamrock","",2,0],["🍀","four_leaf_clover","luck",2,0],["🍁","maple_leaf","canada",2,0],["🍂","fallen_leaf","autumn",2,0],["🍃","leaves","leaf",2,0],["🪹","empty_nest","",2,0],["🪺","nest_with_eggs","",2,0],["🍇","grapes","",3,0],["🍈","melon","",3,0],["🍉","watermelon","",3,0],["🍊","tangerine;orange;mandarin","",3,0],["🍋","lemon","",3,0],["🍌","banana","fruit",3,0],["🍍","pineapple","",3,0],["🥭","mango","",3,0],["🍎","apple","",3,0],["🍏","green_apple","fruit",3,0],["🍐","pear","",3,0],["🍑","peach","",3,0],["🍒","cherries","fruit",3,0],["🍓","strawberry","fruit",3,0],["🫐","blueberries","",3,0],["🥝","kiwi_fruit","",3,0],["🍅","tomato","",3,0],["🫒","olive","",3,0],["🥥","coconut","",3,0],["🥑","avocado","",3,0],["🍆","eggplant","aubergine",3,0],["🥔","potato","",3,0],["🥕","carrot","",3,0],["🌽","corn","",3,0],["🌶️","hot_pepper","spicy",3,0],["🫑","bell_pepper","",3,0],["🥒","cucumber","",3,0],["🥬","leafy_green","",3,0],["🥦","broccoli","",3,0],["🧄","garlic","",3,0],["🧅","onion","",3,0],["🍄","mushroom","",3,0],["🥜","peanuts","",3,0],["🫘","beans","",3,0],["🌰","chestnut","",3,0],["🍞","bread","toast",3,0],["🥐","croissant","",3,0],["🥖","baguette_bread","",3,0],["🫓","flatbread","",3,0],["🥨","pretzel","",3,0],["🥯","bagel","",3,0],["🥞","pancakes","",3,0],["🧇","waffle","",3,0],["🧀","cheese","",3,0],["🍖","meat_on_bone","",3,0],["🍗","poultry_leg","meat chicken",3,0],["🥩","cut_of_meat","",3,0],["🥓","bacon","",3,0],["🍔","hamburger","burger",3,0],["🍟","fries","",3,0],["🍕","pizza","",3,0],["🌭","hotdog","",3,0],["🥪","sandwich","",3,0],["🌮","taco","",3,0],["🌯","burrito","",3,0],["🫔","tamale","",3,0],["🥙","stuffed_flatbread","",3,0],["🧆","falafel","",3,0],["🥚","egg","",3,0],["🍳","fried_egg","breakfast",3,0],["🥘","shallow_pan_of_food","paella curry",3,0],["🍲","stew","",3,0],["🫕","fondue","",3,0],["🥣","bowl_with_spoon","",3,0],["🥗","green_salad","",3,0],["🍿","popcorn","",3,0],["🧈","butter","",3,0],["🧂","salt","",3,0],["🥫","canned_food","",3,0],["🍱","bento","",3,0],["🍘","rice_cracker","",3,0],["🍙","rice_ball","",3,0],["🍚","rice","",3,0],["🍛","curry","",3,0],["🍜","ramen","noodle",3,0],["🍝","spaghetti","pasta",3,0],["🍠","sweet_potato","",3,0],["🍢","oden","",3,0],["🍣","sushi","",3,0],["🍤","fried_shrimp","tempura",3,0],["🍥","fish_cake","",3,0],["🥮","moon_cake","",3,0],["🍡","dango","",3,0],["🥟","dumpling","",3,0],["🥠","fortune_cookie","",3,0],["🥡","takeout_box","",3,0],["🦀","crab","",3,0],["🦞","lobster","",3,0],["🦐","shrimp","",3,0],["🦑","squid","",3,0],["🦪","oyster","",3,0],["🍦","icecream","",3,0],["🍧","shaved_ice","",3,0],["🍨","ice_cream","",3,0],["🍩","doughnut","",3,0],["🍪","cookie","",3,0],["🎂","birthday","party",3,0],["🍰","cake","dessert",3,0],["🧁","cupcake","",3,0],["🥧","pie","",3,0],["🍫","chocolate_bar","",3,0],["🍬","candy","sweet",3,0],["🍭","lollipop","",3,0],["🍮","custard","",3,0],["🍯","honey_pot","",3,0],["🍼","baby_bottle","milk",3,0],["🥛","milk_glass","",3,0],["☕","coffee","cafe espresso",3,0],["🫖","teapot","",3,0],["🍵","tea","green breakfast",3,0],["🍶","sake","",3,0],["🍾","champagne","bottle bubbly celebration",3,0],["🍷","wine_glass","",3,0],["🍸","cocktail","drink",3,0],["🍹","tropical_drink","summer vacation",3,0],["🍺","beer","drink",3,0],["🍻","beers","drinks",3,0],["🥂","clinking_glasses","cheers toast",3,0],["🥃","tumbler_glass","whisky",3,0],["🫗","pouring_liquid","",3,0],["🥤","cup_with_straw","",3,0],["🧋","bubble_tea","",3,0],["🧃","beverage_box","",3,0],["🧉","mate","",3,0],["🧊","ice_cube","",3,0],["🥢","chopsticks","",3,0],["🍽️","plate_with_cutlery","dining dinner",3,0],["🍴","fork_and_knife","cutlery",3,0],["🥄","spoon","",3,0],["🔪","hocho;knife","cut chop",3,0],["🫙","jar","",3,0],["🏺","amphora","",3,0],["🌍","earth_africa","globe world international",4,0],["🌎","earth_americas","globe world international",4,0],["🌏","earth_asia","globe world international",4,0],["🌐","globe_with_meridians","world global international",4,0],["🗺️","world_map","travel",4,0],["🗾","japan","",4,0],["🧭","compass","",4,0],["🏔️","mountain_snow","",4,0],["⛰️","mountain","",4,0],["🌋","volcano","",4,0],["🗻","mount_fuji","",4,0],["🏕️","camping","",4,0],["🏖️","beach_umbrella","",4,0],["🏜️","desert","",4,0],["🏝️","desert_island","",4,0],["🏞️","national_park","",4,0],["🏟️","stadium","",4,0],["🏛️","classical_building","",4,0],["🏗️","building_construction","",4,0],["🧱","bricks","",4,0],["🪨","rock","",4,0],["🪵","wood","",4,0],["🛖","hut","",4,0],["🏘️","houses","",4,0],["🏚️","derelict_house","",4,0],["🏠","house","",4,0],["🏡","house_with_garden","",4,0],["🏢","office","",4,0],["🏣","post_office","",4,0],["🏤","european_post_office","",4,0],["🏥","hospital","",4,0],["🏦","bank","",4,0],["🏨","hotel","",4,0],["🏩","love_hotel","",4,0],["🏪","convenience_store","",4,0],["🏫","school","",4,0],["🏬","department_store","",4,0],["🏭","factory","",4,0],["🏯","japanese_castle","",4,0],["🏰","european_castle","",4,0],["💒","wedding","marriage",4,0],["🗼","tokyo_tower","",4,0],["🗽","statue_of_liberty","",4,0],["⛪","church","",4,0],["🕌","mosque","",4,0],["🛕","hindu_temple","",4,0],["🕍","synagogue","",4,0],["⛩️","shinto_shrine","",4,0],["🕋","kaaba","",4,0],["⛲","fountain","",4,0],["⛺","tent","camping",4,0],["🌁","foggy","karl",4,0],["🌃","night_with_stars","",4,0],["🏙️","cityscape","skyline",4,0],["🌄","sunrise_over_mountains","",4,0],["🌅","sunrise","",4,0],["🌆","city_sunset","",4,0],["🌇","city_sunrise","",4,0],["🌉","bridge_at_night","",4,0],["♨️","hotsprings","",4,0],["🎠","carousel_horse","",4,0],["🛝","playground_slide","",4,0],["🎡","ferris_wheel","",4,0],["🎢","roller_coaster","",4,0],["💈","barber","",4,0],["🎪","circus_tent","",4,0],["🚂","steam_locomotive","train",4,0],["🚃","railway_car","",4,0],["🚄","bullettrain_side","train",4,0],["🚅","bullettrain_front","train",4,0],["🚆","train2","",4,0],["🚇","metro","",4,0],["🚈","light_rail","",4,0],["🚉","station","",4,0],["🚊","tram","",4,0],["🚝","monorail","",4,0],["🚞","mountain_railway","",4,0],["🚋","train","",4,0],["🚌","bus","",4,0],["🚍","oncoming_bus","",4,0],["🚎","trolleybus","",4,0],["🚐","minibus","",4,0],["🚑","ambulance","",4,0],["🚒","fire_engine","",4,0],["🚓","police_car","",4,0],["🚔","oncoming_police_car","",4,0],["🚕","taxi","",4,0],["🚖","oncoming_taxi","",4,0],["🚗","car;red_car","",4,0],["🚘","oncoming_automobile","",4,0],["🚙","blue_car","",4,0],["🛻","pickup_truck","",4,0],["🚚","truck","",4,0],["🚛","articulated_lorry","",4,0],["🚜","tractor","",4,0],["🏎️","racing_car","",4,0],["🏍️","motorcycle","",4,0],["🛵","motor_scooter","",4,0],["🦽","manual_wheelchair","",4,0],["🦼","motorized_wheelchair","",4,0],["🛺","auto_rickshaw","",4,0],["🚲","bike","bicycle",4,0],["🛴","kick_scooter","",4,0],["🛹","skateboard","",4,0],["🛼","roller_skate","",4,0],["🚏","busstop","",4,0],["🛣️","motorway","",4,0],["🛤️","railway_track","",4,0],["🛢️","oil_drum","",4,0],["⛽","fuelpump","",4,0],["🛞","wheel","",4,0],["🚨","rotating_light","911 emergency",4,0],["🚥","traffic_light","",4,0],["🚦","vertical_traffic_light","semaphore",4,0],["🛑","stop_sign","",4,0],["🚧","construction","wip",4,0],["⚓","anchor","ship",4,0],["🛟","ring_buoy","life preserver",4,0],["⛵","boat;sailboat","",4,0],["🛶","canoe","",4,0],["🚤","speedboat","ship",4,0],["🛳️","passenger_ship","cruise",4,0],["⛴️","ferry","",4,0],["🛥️","motor_boat","",4,0],["🚢","ship","",4,0],["✈️","airplane","flight",4,0],["🛩️","small_airplane","flight",4,0],["🛫","flight_departure","",4,0],["🛬","flight_arrival","",4,0],["🪂","parachute","",4,0],["💺","seat","",4,0],["🚁","helicopter","",4,0],["🚟","suspension_railway","",4,0],["🚠","mountain_cableway","",4,0],["🚡","aerial_tramway","",4,0],["🛰️","artificial_satellite","orbit space",4,0],["🚀","rocket","ship launch",4,0],["🛸","flying_saucer","ufo",4,0],["🛎️","bellhop_bell","",4,0],["🧳","luggage","",4,0],["⌛","hourglass","time",4,0],["⏳","hourglass_flowing_sand","time",4,0],["⌚","watch","time",4,0],["⏰","alarm_clock","morning",4,0],["⏱️","stopwatch","",4,0],["⏲️","timer_clock","",4,0],["🕰️","mantelpiece_clock","",4,0],["🕛","clock12","",4,0],["🕧","clock1230","",4,0],["🕐","clock1","",4,0],["🕜","clock130","",4,0],["🕑","clock2","",4,0],["🕝","clock230","",4,0],["🕒","clock3","",4,0],["🕞","clock330","",4,0],["🕓","clock4","",4,0],["🕟","clock430","",4,0],["🕔","clock5","",4,0],["🕠","clock530","",4,0],["🕕","clock6","",4,0],["🕡","clock630","",4,0],["🕖","clock7","",4,0],["🕢","clock730","",4,0],["🕗","clock8","",4,0],["🕣","clock830","",4,0],["🕘","clock9","",4,0],["🕤","clock930","",4,0],["🕙","clock10","",4,0],["🕥","clock1030","",4,0],["🕚","clock11","",4,0],["🕦","clock1130","",4,0],["🌑","new_moon","",4,0],["🌒","waxing_crescent_moon","",4,0],["🌓","first_quarter_moon","",4,0],["🌔","moon;waxing_gibbous_moon","",4,0],["🌕","full_moon","",4,0],["🌖","waning_gibbous_moon","",4,0],["🌗","last_quarter_moon","",4,0],["🌘","waning_crescent_moon","",4,0],["🌙","crescent_moon","night",4,0],["🌚","new_moon_with_face","",4,0],["🌛","first_quarter_moon_with_face","",4,0],["🌜","last_quarter_moon_with_face","",4,0],["🌡️","thermometer","",4,0],["☀️","sunny","weather",4,0],["🌝","full_moon_with_face","",4,0],["🌞","sun_with_face","summer",4,0],["🪐","ringed_planet","",4,0],["⭐","star","",4,0],["🌟","star2","",4,0],["🌠","stars","",4,0],["🌌","milky_way","",4,0],["☁️","cloud","",4,0],["⛅","partly_sunny","weather cloud",4,0],["⛈️","cloud_with_lightning_and_rain","",4,0],["🌤️","sun_behind_small_cloud","",4,0],["🌥️","sun_behind_large_cloud","",4,0],["🌦️","sun_behind_rain_cloud","",4,0],["🌧️","cloud_with_rain","",4,0],["🌨️","cloud_with_snow","",4,0],["🌩️","cloud_with_lightning","",4,0],["🌪️","tornado","",4,0],["🌫️","fog","",4,0],["🌬️","wind_face","",4,0],["🌀","cyclone","swirl",4,0],["🌈","rainbow","",4,0],["🌂","closed_umbrella","weather rain",4,0],["☂️","open_umbrella","",4,0],["☔","umbrella","rain weather",4,0],["⛱️","parasol_on_ground","beach_umbrella",4,0],["⚡","zap","lightning thunder",4,0],["❄️","snowflake","winter cold weather",4,0],["☃️","snowman_with_snow","winter christmas",4,0],["⛄","snowman","winter",4,0],["☄️","comet","",4,0],["🔥","fire","burn",4,0],["💧","droplet","water",4,0],["🌊","ocean","sea",4,0],["🎃","jack_o_lantern","halloween",5,0],["🎄","christmas_tree","",5,0],["🎆","fireworks","festival celebration",5,0],["🎇","sparkler","",5,0],["🧨","firecracker","",5,0],["✨","sparkles","shiny",5,0],["🎈","balloon","party birthday",5,0],["🎉","tada","hooray party",5,0],["🎊","confetti_ball","",5,0],["🎋","tanabata_tree","",5,0],["🎍","bamboo","",5,0],["🎎","dolls","",5,0],["🎏","flags","",5,0],["🎐","wind_chime","",5,0],["🎑","rice_scene","",5,0],["🧧","red_envelope","",5,0],["🎀","ribbon","",5,0],["🎁","gift","present birthday christmas",5,0],["🎗️","reminder_ribbon","",5,0],["🎟️","tickets","",5,0],["🎫","ticket","",5,0],["🎖️","medal_military","",5,0],["🏆","trophy","award contest winner",5,0],["🏅","medal_sports","gold winner",5,0],["🥇","1st_place_medal","gold",5,0],["🥈","2nd_place_medal","silver",5,0],["🥉","3rd_place_medal","bronze",5,0],["⚽","soccer","sports",5,0],["⚾","baseball","sports",5,0],["🥎","softball","",5,0],["🏀","basketball","sports",5,0],["🏐","volleyball","",5,0],["🏈","football","sports",5,0],["🏉","rugby_football","",5,0],["🎾","tennis","sports",5,0],["🥏","flying_disc","",5,0],["🎳","bowling","",5,0],["🏏","cricket_game","",5,0],["🏑","field_hockey","",5,0],["🏒","ice_hockey","",5,0],["🥍","lacrosse","",5,0],["🏓","ping_pong","",5,0],["🏸","badminton","",5,0],["🥊","boxing_glove","",5,0],["🥋","martial_arts_uniform","",5,0],["🥅","goal_net","",5,0],["⛳","golf","",5,0],["⛸️","ice_skate","skating",5,0],["🎣","fishing_pole_and_fish","",5,0],["🤿","diving_mask","",5,0],["🎽","running_shirt_with_sash","marathon",5,0],["🎿","ski","",5,0],["🛷","sled","",5,0],["🥌","curling_stone","",5,0],["🎯","dart","target",5,0],["🪀","yo_yo","",5,0],["🪁","kite","",5,0],["🎱","8ball","pool billiards",5,0],["🔮","crystal_ball","fortune",5,0],["🪄","magic_wand","",5,0],["🧿","nazar_amulet","",5,0],["🪬","hamsa","",5,0],["🎮","video_game","play controller console",5,0],["🕹️","joystick","",5,0],["🎰","slot_machine","",5,0],["🎲","game_die","dice gambling",5,0],["🧩","jigsaw","",5,0],["🧸","teddy_bear","",5,0],["🪅","pinata","",5,0],["🪩","mirror_ball","disco party",5,0],["🪆","nesting_dolls","",5,0],["♠️","spades","",5,0],["♥️","hearts","",5,0],["♦️","diamonds","",5,0],["♣️","clubs","",5,0],["♟️","chess_pawn","",5,0],["🃏","black_joker","",5,0],["🀄","mahjong","",5,0],["🎴","flower_playing_cards","",5,0],["🎭","performing_arts","theater drama",5,0],["🖼️","framed_picture","",5,0],["🎨","art","design paint",5,0],["🧵","thread","",5,0],["🪡","sewing_needle","",5,0],["🧶","yarn","",5,0],["🪢","knot","",5,0],["👓","eyeglasses","glasses",6,0],["🕶️","dark_sunglasses","",6,0],["🥽","goggles","",6,0],["🥼","lab_coat","",6,0],["🦺","safety_vest","",6,0],["👔","necktie","shirt formal",6,0],["👕","shirt;tshirt","",6,0],["👖","jeans","pants",6,0],["🧣","scarf","",6,0],["🧤","gloves","",6,0],["🧥","coat","",6,0],["🧦","socks","",6,0],["👗","dress","",6,0],["👘","kimono","",6,0],["🥻","sari","",6,0],["🩱","one_piece_swimsuit","",6,0],["🩲","swim_brief","",6,0],["🩳","shorts","",6,0],["👙","bikini","beach",6,0],["👚","womans_clothes","",6,0],["👛","purse","",6,0],["👜","handbag","bag",6,0],["👝","pouch","bag",6,0],["🛍️","shopping","bags",6,0],["🎒","school_satchel","",6,0],["🩴","thong_sandal","",6,0],["👞","mans_shoe;shoe","",6,0],["👟","athletic_shoe","sneaker sport running",6,0],["🥾","hiking_boot","",6,0],["🥿","flat_shoe","",6,0],["👠","high_heel","shoe",6,0],["👡","sandal","shoe",6,0],["🩰","ballet_shoes","",6,0],["👢","boot","",6,0],["👑","crown","king queen royal",6,0],["👒","womans_hat","",6,0],["🎩","tophat","hat classy",6,0],["🎓","mortar_board","education college university graduation",6,0],["🧢","billed_cap","",6,0],["🪖","military_helmet","",6,0],["⛑️","rescue_worker_helmet","",6,0],["📿","prayer_beads","",6,0],["💄","lipstick","makeup",6,0],["💍","ring","wedding marriage engaged",6,0],["💎","gem","diamond",6,0],["🔇","mute","sound volume",6,0],["🔈","speaker","",6,0],["🔉","sound","volume",6,0],["🔊","loud_sound","volume",6,0],["📢","loudspeaker","announcement",6,0],["📣","mega","",6,0],["📯","postal_horn","",6,0],["🔔","bell","sound notification",6,0],["🔕","no_bell","volume off",6,0],["🎼","musical_score","",6,0],["🎵","musical_note","",6,0],["🎶","notes","music",6,0],["🎙️","studio_microphone","podcast",6,0],["🎚️","level_slider","",6,0],["🎛️","control_knobs","",6,0],["🎤","microphone","sing",6,0],["🎧","headphones","music earphones",6,0],["📻","radio","podcast",6,0],["🎷","saxophone","",6,0],["🪗","accordion","",6,0],["🎸","guitar","rock",6,0],["🎹","musical_keyboard","piano",6,0],["🎺","trumpet","",6,0],["🎻","violin","",6,0],["🪕","banjo","",6,0],["🥁","drum","",6,0],["🪘","long_drum","",6,0],["📱","iphone","smartphone mobile",6,0],["📲","calling","call incoming",6,0],["☎️","phone;telephone","",6,0],["📞","telephone_receiver","phone call",6,0],["📟","pager","",6,0],["📠","fax","",6,0],["🔋","battery","power",6,0],["🪫","low_battery","",6,0],["🔌","electric_plug","",6,0],["💻","computer","desktop screen",6,0],["🖥️","desktop_computer","",6,0],["🖨️","printer","",6,0],["⌨️","keyboard","",6,0],["🖱️","computer_mouse","",6,0],["🖲️","trackball","",6,0],["💽","minidisc","",6,0],["💾","floppy_disk","save",6,0],["💿","cd","",6,0],["📀","dvd","",6,0],["🧮","abacus","",6,0],["🎥","movie_camera","film video",6,0],["🎞️","film_strip","",6,0],["📽️","film_projector","",6,0],["🎬","clapper","film",6,0],["📺","tv","",6,0],["📷","camera","photo",6,0],["📸","camera_flash","photo",6,0],["📹","video_camera","",6,0],["📼","vhs","",6,0],["🔍","mag","search zoom",6,0],["🔎","mag_right","",6,0],["🕯️","candle","",6,0],["💡","bulb","idea light",6,0],["🔦","flashlight","",6,0],["🏮","izakaya_lantern;lantern","",6,0],["🪔","diya_lamp","",6,0],["📔","notebook_with_decorative_cover","",6,0],["📕","closed_book","",6,0],["📖","book;open_book","",6,0],["📗","green_book","",6,0],["📘","blue_book","",6,0],["📙","orange_book","",6,0],["📚","books","library",6,0],["📓","notebook","",6,0],["📒","ledger","",6,0],["📃","page_with_curl","",6,0],["📜","scroll","document",6,0],["📄","page_facing_up","document",6,0],["📰","newspaper","press",6,0],["🗞️","newspaper_roll","press",6,0],["📑","bookmark_tabs","",6,0],["🔖","bookmark","",6,0],["🏷️","label","tag",6,0],["💰","moneybag","dollar cream",6,0],["🪙","coin","",6,0],["💴","yen","",6,0],["💵","dollar","money",6,0],["💶","euro","",6,0],["💷","pound","",6,0],["💸","money_with_wings","dollar",6,0],["💳","credit_card","subscription",6,0],["🧾","receipt","",6,0],["💹","chart","",6,0],["✉️","envelope","letter email",6,0],["📧","email;e-mail","",6,0],["📨","incoming_envelope","",6,0],["📩","envelope_with_arrow","",6,0],["📤","outbox_tray","",6,0],["📥","inbox_tray","",6,0],["📦","package","shipping",6,0],["📫","mailbox","",6,0],["📪","mailbox_closed","",6,0],["📬","mailbox_with_mail","",6,0],["📭","mailbox_with_no_mail","",6,0],["📮","postbox","",6,0],["🗳️","ballot_box","",6,0],["✏️","pencil2","",6,0],["✒️","black_nib","",6,0],["🖋️","fountain_pen","",6,0],["🖊️","pen","",6,0],["🖌️","paintbrush","",6,0],["🖍️","crayon","",6,0],["📝","memo;pencil","document note",6,0],["💼","briefcase","business",6,0],["📁","file_folder","directory",6,0],["📂","open_file_folder","",6,0],["🗂️","card_index_dividers","",6,0],["📅","date","calendar schedule",6,0],["📆","calendar","schedule",6,0],["🗒️","spiral_notepad","",6,0],["🗓️","spiral_calendar","",6,0],["📇","card_index","",6,0],["📈","chart_with_upwards_trend","graph metrics",6,0],["📉","chart_with_downwards_trend","graph metrics",6,0],["📊","bar_chart","stats metrics",6,0],["📋","clipboard","",6,0],["📌","pushpin","location",6,0],["📍","round_pushpin","location",6,0],["📎","paperclip","",6,0],["🖇️","paperclips","",6,0],["📏","straight_ruler","",6,0],["📐","triangular_ruler","",6,0],["✂️","scissors","cut",6,0],["🗃️","card_file_box","",6,0],["🗄️","file_cabinet","",6,0],["🗑️","wastebasket","trash",6,0],["🔒","lock","security private",6,0],["🔓","unlock","security",6,0],["🔏","lock_with_ink_pen","",6,0],["🔐","closed_lock_with_key","security",6,0],["🔑","key","lock password",6,0],["🗝️","old_key","",6,0],["🔨","hammer","tool",6,0],["🪓","axe","",6,0],["⛏️","pick","",6,0],["⚒️","hammer_and_pick","",6,0],["🛠️","hammer_and_wrench","",6,0],["🗡️","dagger","",6,0],["⚔️","crossed_swords","",6,0],["🔫","gun","shoot weapon",6,0],["🪃","boomerang","",6,0],["🏹","bow_and_arrow","archery",6,0],["🛡️","shield","",6,0],["🪚","carpentry_saw","",6,0],["🔧","wrench","tool",6,0],["🪛","screwdriver","",6,0],["🔩","nut_and_bolt","",6,0],["⚙️","gear","",6,0],["🗜️","clamp","",6,0],["⚖️","balance_scale","",6,0],["🦯","probing_cane","",6,0],["🔗","link","",6,0],["⛓️","chains","",6,0],["🪝","hook","",6,0],["🧰","toolbox","",6,0],["🧲","magnet","",6,0],["🪜","ladder","",6,0],["⚗️","alembic","",6,0],["🧪","test_tube","",6,0],["🧫","petri_dish","",6,0],["🧬","dna","",6,0],["🔬","microscope","science laboratory investigate",6,0],["🔭","telescope","",6,0],["📡","satellite","signal",6,0],["💉","syringe","health hospital needle",6,0],["🩸","drop_of_blood","",6,0],["💊","pill","health medicine",6,0],["🩹","adhesive_bandage","",6,0],["🩼","crutch","",6,0],["🩺","stethoscope","",6,0],["🩻","x_ray","",6,0],["🚪","door","",6,0],["🛗","elevator","",6,0],["🪞","mirror","",6,0],["🪟","window","",6,0],["🛏️","bed","",6,0],["🛋️","couch_and_lamp","",6,0],["🪑","chair","",6,0],["🚽","toilet","wc",6,0],["🪠","plunger","",6,0],["🚿","shower","bath",6,0],["🛁","bathtub","",6,0],["🪤","mouse_trap","",6,0],["🪒","razor","",6,0],["🧴","lotion_bottle","",6,0],["🧷","safety_pin","",6,0],["🧹","broom","",6,0],["🧺","basket","",6,0],["🧻","roll_of_paper","toilet",6,0],["🪣","bucket","",6,0],["🧼","soap","",6,0],["🫧","bubbles","",6,0],["🪥","toothbrush","",6,0],["🧽","sponge","",6,0],["🧯","fire_extinguisher","",6,0],["🛒","shopping_cart","",6,0],["🚬","smoking","cigarette",6,0],["⚰️","coffin","funeral",6,0],["🪦","headstone","",6,0],["⚱️","funeral_urn","",6,0],["🗿","moyai","stone",6,0],["🪧","placard","",6,0],["🪪","identification_card","",6,0],["🏧","atm","",7,0],["🚮","put_litter_in_its_place","",7,0],["🚰","potable_water","",7,0],["♿","wheelchair","accessibility",7,0],["🚹","mens","",7,0],["🚺","womens","",7,0],["🚻","restroom","toilet",7,0],["🚼","baby_symbol","",7,0],["🚾","wc","toilet restroom",7,0],["🛂","passport_control","",7,0],["🛃","customs","",7,0],["🛄","baggage_claim","airport",7,0],["🛅","left_luggage","",7,0],["⚠️","warning","wip",7,0],["🚸","children_crossing","",7,0],["⛔","no_entry","limit",7,0],["🚫","no_entry_sign","block forbidden",7,0],["🚳","no_bicycles","",7,0],["🚭","no_smoking","",7,0],["🚯","do_not_litter","",7,0],["🚱","non-potable_water","",7,0],["🚷","no_pedestrians","",7,0],["📵","no_mobile_phones","",7,0],["🔞","underage","",7,0],["☢️","radioactive","",7,0],["☣️","biohazard","",7,0],["⬆️","arrow_up","",7,0],["↗️","arrow_upper_right","",7,0],["➡️","arrow_right","",7,0],["↘️","arrow_lower_right","",7,0],["⬇️","arrow_down","",7,0],["↙️","arrow_lower_left","",7,0],["⬅️","arrow_left","",7,0],["↖️","arrow_upper_left","",7,0],["↕️","arrow_up_down","",7,0],["↔️","left_right_arrow","",7,0],["↩️","leftwards_arrow_with_hook","return",7,0],["↪️","arrow_right_hook","",7,0],["⤴️","arrow_heading_up","",7,0],["⤵️","arrow_heading_down","",7,0],["🔃","arrows_clockwise","",7,0],["🔄","arrows_counterclockwise","sync",7,0],["🔙","back","",7,0],["🔚","end","",7,0],["🔛","on","",7,0],["🔜","soon","",7,0],["🔝","top","",7,0],["🛐","place_of_worship","",7,0],["⚛️","atom_symbol","",7,0],["🕉️","om","",7,0],["✡️","star_of_david","",7,0],["☸️","wheel_of_dharma","",7,0],["☯️","yin_yang","",7,0],["✝️","latin_cross","",7,0],["☦️","orthodox_cross","",7,0],["☪️","star_and_crescent","",7,0],["☮️","peace_symbol","",7,0],["🕎","menorah","",7,0],["🔯","six_pointed_star","",7,0],["♈","aries","",7,0],["♉","taurus","",7,0],["♊","gemini","",7,0],["♋","cancer","",7,0],["♌","leo","",7,0],["♍","virgo","",7,0],["♎","libra","",7,0],["♏","scorpius","",7,0],["♐","sagittarius","",7,0],["♑","capricorn","",7,0],["♒","aquarius","",7,0],["♓","pisces","",7,0],["⛎","ophiuchus","",7,0],["🔀","twisted_rightwards_arrows","shuffle",7,0],["🔁","repeat","loop",7,0],["🔂","repeat_one","",7,0],["▶️","arrow_forward","",7,0],["⏩","fast_forward","",7,0],["⏭️","next_track_button","",7,0],["⏯️","play_or_pause_button","",7,0],["◀️","arrow_backward","",7,0],["⏪","rewind","",7,0],["⏮️","previous_track_button","",7,0],["🔼","arrow_up_small","",7,0],["⏫","arrow_double_up","",7,0],["🔽","arrow_down_small","",7,0],["⏬","arrow_double_down","",7,0],["⏸️","pause_button","",7,0],["⏹️","stop_button","",7,0],["⏺️","record_button","",7,0],["⏏️","eject_button","",7,0],["🎦","cinema","film movie",7,0],["🔅","low_brightness","",7,0],["🔆","high_brightness","",7,0],["📶","signal_strength","wifi",7,0],["📳","vibration_mode","",7,0],["📴","mobile_phone_off","mute off",7,0],["♀️","female_sign","",7,0],["♂️","male_sign","",7,0],["⚧️","transgender_symbol","",7,0],["✖️","heavy_multiplication_x","",7,0],["➕","heavy_plus_sign","",7,0],["➖","heavy_minus_sign","",7,0],["➗","heavy_division_sign","",7,0],["🟰","heavy_equals_sign","",7,0],["♾️","infinity","",7,0],["‼️","bangbang","",7,0],["⁉️","interrobang","",7,0],["❓","question","confused",7,0],["❔","grey_question","",7,0],["❕","grey_exclamation","",7,0],["❗","exclamation;heavy_exclamation_mark","bang",7,0],["〰️","wavy_dash","",7,0],["💱","currency_exchange","",7,0],["💲","heavy_dollar_sign","",7,0],["⚕️","medical_symbol","",7,0],["♻️","recycle","environment green",7,0],["⚜️","fleur_de_lis","",7,0],["🔱","trident","",7,0],["📛","name_badge","",7,0],["🔰","beginner","",7,0],["⭕","o","",7,0],["✅","white_check_mark","",7,0],["☑️","ballot_box_with_check","",7,0],["✔️","heavy_check_mark","",7,0],["❌","x","",7,0],["❎","negative_squared_cross_mark","",7,0],["➰","curly_loop","",7,0],["➿","loop","",7,0],["〽️","part_alternation_mark","",7,0],["✳️","eight_spoked_asterisk","",7,0],["✴️","eight_pointed_black_star","",7,0],["❇️","sparkle","",7,0],["©️","copyright","",7,0],["®️","registered","",7,0],["™️","tm","trademark",7,0],["#️⃣","hash","number",7,0],["*️⃣","asterisk","",7,0],["0️⃣","zero","",7,0],["1️⃣","one","",7,0],["2️⃣","two","",7,0],["3️⃣","three","",7,0],["4️⃣","four","",7,0],["5️⃣","five","",7,0],["6️⃣","six","",7,0],["7️⃣","seven","",7,0],["8️⃣","eight","",7,0],["9️⃣","nine","",7,0],["🔟","keycap_ten","",7,0],["🔠","capital_abcd","letters",7,0],["🔡","abcd","",7,0],["🔢","1234","numbers",7,0],["🔣","symbols","",7,0],["🔤","abc","alphabet",7,0],["🅰️","a","",7,0],["🆎","ab","",7,0],["🅱️","b","",7,0],["🆑","cl","",7,0],["🆒","cool","",7,0],["🆓","free","",7,0],["ℹ️","information_source","",7,0],["🆔","id","",7,0],["Ⓜ️","m","",7,0],["🆕","new","fresh",7,0],["🆖","ng","",7,0],["🅾️","o2","",7,0],["🆗","ok","yes",7,0],["🅿️","parking","",7,0],["🆘","sos","help emergency",7,0],["🆙","up","",7,0],["🆚","vs","",7,0],["🈁","koko","",7,0],["🈂️","sa","",7,0],["🈷️","u6708","",7,0],["🈶","u6709","",7,0],["🈯","u6307","",7,0],["🉐","ideograph_advantage","",7,0],["🈹","u5272","",7,0],["🈚","u7121","",7,0],["🈲","u7981","",7,0],["🉑","accept","",7,0],["🈸","u7533","",7,0],["🈴","u5408","",7,0],["🈳","u7a7a","",7,0],["㊗️","congratulations","",7,0],["㊙️","secret","",7,0],["🈺","u55b6","",7,0],["🈵","u6e80","",7,0],["🔴","red_circle","",7,0],["🟠","orange_circle","",7,0],["🟡","yellow_circle","",7,0],["🟢","green_circle","",7,0],["🔵","large_blue_circle","",7,0],["🟣","purple_circle","",7,0],["🟤","brown_circle","",7,0],["⚫","black_circle","",7,0],["⚪","white_circle","",7,0],["🟥","red_square","",7,0],["🟧","orange_square","",7,0],["🟨","yellow_square","",7,0],["🟩","green_square","",7,0],["🟦","blue_square","",7,0],["🟪","purple_square","",7,0],["🟫","brown_square","",7,0],["⬛","black_large_square","",7,0],["⬜","white_large_square","",7,0],["◼️","black_medium_square","",7,0],["◻️","white_medium_square","",7,0],["◾","black_medium_small_square","",7,0],["◽","white_medium_small_square","",7,0],["▪️","black_small_square","",7,0],["▫️","white_small_square","",7,0],["🔶","large_orange_diamond","",7,0],["🔷","large_blue_diamond","",7,0],["🔸","small_orange_diamond","",7,0],["🔹","small_blue_diamond","",7,0],["🔺","small_red_triangle","",7,0],["🔻","small_red_triangle_down","",7,0],["💠","diamond_shape_with_a_dot_inside","",7,0],["🔘","radio_button","",7,0],["🔳","white_square_button","",7,0],["🔲","black_square_button","",7,0],["🏁","checkered_flag","milestone finish",8,0],["🚩","triangular_flag_on_post","",8,0],["🎌","crossed_flags","",8,0],["🏴","black_flag","",8,0],["🏳️","white_flag","",8,0],["🏳️🌈","rainbow_flag","pride",8,0],["🏳️⚧️","transgender_flag","",8,0],["🏴☠️","pirate_flag","",8,0],["🇦🇨","ascension_island","",8,0],["🇦🇩","andorra","",8,0],["🇦🇪","united_arab_emirates","",8,0],["🇦🇫","afghanistan","",8,0],["🇦🇬","antigua_barbuda","",8,0],["🇦🇮","anguilla","",8,0],["🇦🇱","albania","",8,0],["🇦🇲","armenia","",8,0],["🇦🇴","angola","",8,0],["🇦🇶","antarctica","",8,0],["🇦🇷","argentina","",8,0],["🇦🇸","american_samoa","",8,0],["🇦🇹","austria","",8,0],["🇦🇺","australia","",8,0],["🇦🇼","aruba","",8,0],["🇦🇽","aland_islands","",8,0],["🇦🇿","azerbaijan","",8,0],["🇧🇦","bosnia_herzegovina","",8,0],["🇧🇧","barbados","",8,0],["🇧🇩","bangladesh","",8,0],["🇧🇪","belgium","",8,0],["🇧🇫","burkina_faso","",8,0],["🇧🇬","bulgaria","",8,0],["🇧🇭","bahrain","",8,0],["🇧🇮","burundi","",8,0],["🇧🇯","benin","",8,0],["🇧🇱","st_barthelemy","",8,0],["🇧🇲","bermuda","",8,0],["🇧🇳","brunei","",8,0],["🇧🇴","bolivia","",8,0],["🇧🇶","caribbean_netherlands","",8,0],["🇧🇷","brazil","",8,0],["🇧🇸","bahamas","",8,0],["🇧🇹","bhutan","",8,0],["🇧🇻","bouvet_island","",8,0],["🇧🇼","botswana","",8,0],["🇧🇾","belarus","",8,0],["🇧🇿","belize","",8,0],["🇨🇦","canada","",8,0],["🇨🇨","cocos_islands","keeling",8,0],["🇨🇩","congo_kinshasa","",8,0],["🇨🇫","central_african_republic","",8,0],["🇨🇬","congo_brazzaville","",8,0],["🇨🇭","switzerland","",8,0],["🇨🇮","cote_divoire","ivory",8,0],["🇨🇰","cook_islands","",8,0],["🇨🇱","chile","",8,0],["🇨🇲","cameroon","",8,0],["🇨🇳","cn","china",8,0],["🇨🇴","colombia","",8,0],["🇨🇵","clipperton_island","",8,0],["🇨🇷","costa_rica","",8,0],["🇨🇺","cuba","",8,0],["🇨🇻","cape_verde","",8,0],["🇨🇼","curacao","",8,0],["🇨🇽","christmas_island","",8,0],["🇨🇾","cyprus","",8,0],["🇨🇿","czech_republic","",8,0],["🇩🇪","de","flag germany",8,0],["🇩🇬","diego_garcia","",8,0],["🇩🇯","djibouti","",8,0],["🇩🇰","denmark","",8,0],["🇩🇲","dominica","",8,0],["🇩🇴","dominican_republic","",8,0],["🇩🇿","algeria","",8,0],["🇪🇦","ceuta_melilla","",8,0],["🇪🇨","ecuador","",8,0],["🇪🇪","estonia","",8,0],["🇪🇬","egypt","",8,0],["🇪🇭","western_sahara","",8,0],["🇪🇷","eritrea","",8,0],["🇪🇸","es","spain",8,0],["🇪🇹","ethiopia","",8,0],["🇪🇺","eu;european_union","",8,0],["🇫🇮","finland","",8,0],["🇫🇯","fiji","",8,0],["🇫🇰","falkland_islands","",8,0],["🇫🇲","micronesia","",8,0],["🇫🇴","faroe_islands","",8,0],["🇫🇷","fr","france french",8,0],["🇬🇦","gabon","",8,0],["🇬🇧","gb;uk","flag british",8,0],["🇬🇩","grenada","",8,0],["🇬🇪","georgia","",8,0],["🇬🇫","french_guiana","",8,0],["🇬🇬","guernsey","",8,0],["🇬🇭","ghana","",8,0],["🇬🇮","gibraltar","",8,0],["🇬🇱","greenland","",8,0],["🇬🇲","gambia","",8,0],["🇬🇳","guinea","",8,0],["🇬🇵","guadeloupe","",8,0],["🇬🇶","equatorial_guinea","",8,0],["🇬🇷","greece","",8,0],["🇬🇸","south_georgia_south_sandwich_islands","",8,0],["🇬🇹","guatemala","",8,0],["🇬🇺","guam","",8,0],["🇬🇼","guinea_bissau","",8,0],["🇬🇾","guyana","",8,0],["🇭🇰","hong_kong","",8,0],["🇭🇲","heard_mcdonald_islands","",8,0],["🇭🇳","honduras","",8,0],["🇭🇷","croatia","",8,0],["🇭🇹","haiti","",8,0],["🇭🇺","hungary","",8,0],["🇮🇨","canary_islands","",8,0],["🇮🇩","indonesia","",8,0],["🇮🇪","ireland","",8,0],["🇮🇱","israel","",8,0],["🇮🇲","isle_of_man","",8,0],["🇮🇳","india","",8,0],["🇮🇴","british_indian_ocean_territory","",8,0],["🇮🇶","iraq","",8,0],["🇮🇷","iran","",8,0],["🇮🇸","iceland","",8,0],["🇮🇹","it","italy",8,0],["🇯🇪","jersey","",8,0],["🇯🇲","jamaica","",8,0],["🇯🇴","jordan","",8,0],["🇯🇵","jp","japan",8,0],["🇰🇪","kenya","",8,0],["🇰🇬","kyrgyzstan","",8,0],["🇰🇭","cambodia","",8,0],["🇰🇮","kiribati","",8,0],["🇰🇲","comoros","",8,0],["🇰🇳","st_kitts_nevis","",8,0],["🇰🇵","north_korea","",8,0],["🇰🇷","kr","korea",8,0],["🇰🇼","kuwait","",8,0],["🇰🇾","cayman_islands","",8,0],["🇰🇿","kazakhstan","",8,0],["🇱🇦","laos","",8,0],["🇱🇧","lebanon","",8,0],["🇱🇨","st_lucia","",8,0],["🇱🇮","liechtenstein","",8,0],["🇱🇰","sri_lanka","",8,0],["🇱🇷","liberia","",8,0],["🇱🇸","lesotho","",8,0],["🇱🇹","lithuania","",8,0],["🇱🇺","luxembourg","",8,0],["🇱🇻","latvia","",8,0],["🇱🇾","libya","",8,0],["🇲🇦","morocco","",8,0],["🇲🇨","monaco","",8,0],["🇲🇩","moldova","",8,0],["🇲🇪","montenegro","",8,0],["🇲🇫","st_martin","",8,0],["🇲🇬","madagascar","",8,0],["🇲🇭","marshall_islands","",8,0],["🇲🇰","macedonia","",8,0],["🇲🇱","mali","",8,0],["🇲🇲","myanmar","burma",8,0],["🇲🇳","mongolia","",8,0],["🇲🇴","macau","",8,0],["🇲🇵","northern_mariana_islands","",8,0],["🇲🇶","martinique","",8,0],["🇲🇷","mauritania","",8,0],["🇲🇸","montserrat","",8,0],["🇲🇹","malta","",8,0],["🇲🇺","mauritius","",8,0],["🇲🇻","maldives","",8,0],["🇲🇼","malawi","",8,0],["🇲🇽","mexico","",8,0],["🇲🇾","malaysia","",8,0],["🇲🇿","mozambique","",8,0],["🇳🇦","namibia","",8,0],["🇳🇨","new_caledonia","",8,0],["🇳🇪","niger","",8,0],["🇳🇫","norfolk_island","",8,0],["🇳🇬","nigeria","",8,0],["🇳🇮","nicaragua","",8,0],["🇳🇱","netherlands","",8,0],["🇳🇴","norway","",8,0],["🇳🇵","nepal","",8,0],["🇳🇷","nauru","",8,0],["🇳🇺","niue","",8,0],["🇳🇿","new_zealand","",8,0],["🇴🇲","oman","",8,0],["🇵🇦","panama","",8,0],["🇵🇪","peru","",8,0],["🇵🇫","french_polynesia","",8,0],["🇵🇬","papua_new_guinea","",8,0],["🇵🇭","philippines","",8,0],["🇵🇰","pakistan","",8,0],["🇵🇱","poland","",8,0],["🇵🇲","st_pierre_miquelon","",8,0],["🇵🇳","pitcairn_islands","",8,0],["🇵🇷","puerto_rico","",8,0],["🇵🇸","palestinian_territories","",8,0],["🇵🇹","portugal","",8,0],["🇵🇼","palau","",8,0],["🇵🇾","paraguay","",8,0],["🇶🇦","qatar","",8,0],["🇷🇪","reunion","",8,0],["🇷🇴","romania","",8,0],["🇷🇸","serbia","",8,0],["🇷🇺","ru","russia",8,0],["🇷🇼","rwanda","",8,0],["🇸🇦","saudi_arabia","",8,0],["🇸🇧","solomon_islands","",8,0],["🇸🇨","seychelles","",8,0],["🇸🇩","sudan","",8,0],["🇸🇪","sweden","",8,0],["🇸🇬","singapore","",8,0],["🇸🇭","st_helena","",8,0],["🇸🇮","slovenia","",8,0],["🇸🇯","svalbard_jan_mayen","",8,0],["🇸🇰","slovakia","",8,0],["🇸🇱","sierra_leone","",8,0],["🇸🇲","san_marino","",8,0],["🇸🇳","senegal","",8,0],["🇸🇴","somalia","",8,0],["🇸🇷","suriname","",8,0],["🇸🇸","south_sudan","",8,0],["🇸🇹","sao_tome_principe","",8,0],["🇸🇻","el_salvador","",8,0],["🇸🇽","sint_maarten","",8,0],["🇸🇾","syria","",8,0],["🇸🇿","swaziland","",8,0],["🇹🇦","tristan_da_cunha","",8,0],["🇹🇨","turks_caicos_islands","",8,0],["🇹🇩","chad","",8,0],["🇹🇫","french_southern_territories","",8,0],["🇹🇬","togo","",8,0],["🇹🇭","thailand","",8,0],["🇹🇯","tajikistan","",8,0],["🇹🇰","tokelau","",8,0],["🇹🇱","timor_leste","",8,0],["🇹🇲","turkmenistan","",8,0],["🇹🇳","tunisia","",8,0],["🇹🇴","tonga","",8,0],["🇹🇷","tr","turkey",8,0],["🇹🇹","trinidad_tobago","",8,0],["🇹🇻","tuvalu","",8,0],["🇹🇼","taiwan","",8,0],["🇹🇿","tanzania","",8,0],["🇺🇦","ukraine","",8,0],["🇺🇬","uganda","",8,0],["🇺🇲","us_outlying_islands","",8,0],["🇺🇳","united_nations","",8,0],["🇺🇸","us","flag united america",8,0],["🇺🇾","uruguay","",8,0],["🇺🇿","uzbekistan","",8,0],["🇻🇦","vatican_city","",8,0],["🇻🇨","st_vincent_grenadines","",8,0],["🇻🇪","venezuela","",8,0],["🇻🇬","british_virgin_islands","",8,0],["🇻🇮","us_virgin_islands","",8,0],["🇻🇳","vietnam","",8,0],["🇻🇺","vanuatu","",8,0],["🇼🇫","wallis_futuna","",8,0],["🇼🇸","samoa","",8,0],["🇽🇰","kosovo","",8,0],["🇾🇪","yemen","",8,0],["🇾🇹","mayotte","",8,0],["🇿🇦","south_africa","",8,0],["🇿🇲","zambia","",8,0],["🇿🇼","zimbabwe","",8,0],["🏴","england","",8,0],["🏴","scotland","",8,0],["🏴","wales","",8,0]]};
|
|
// Geometry (px). The card is a fixed-size panel like Discord's: header, a category rail on the
|
|
// left, the grid, and a preview bar under the grid. Narrow panes shrink the width only: the grid
|
|
// loses columns, the layout stays.
|
|
const BT_EP_CELL = 40;
|
|
const BT_EP_COLS = 9;
|
|
const BT_EP_ROW = BT_EP_CELL;
|
|
const BT_EP_HEAD = 32;
|
|
const BT_EP_PAD_L = 8;
|
|
const BT_EP_PAD_R = 4;
|
|
const BT_EP_MIN_COLS = 5;
|
|
const BT_EP_CARD_W = 432;
|
|
// i18n:begin
|
|
// This plugin's English words. Translations live in the app's catalogs (plugin.<file name>.*,
|
|
// docs/TRANSLATING.md); a plugin of your own can also pass { en: {...}, tr: {...} } to register.
|
|
const MESSAGES = {
|
|
"en": {
|
|
"suggestions": "Emoji suggestions",
|
|
"matching": "Emoji matching",
|
|
"buttonTitle": "Emoji (Ctrl+Shift+E)",
|
|
"picker": "Emoji picker",
|
|
"title": "Emoji Picker",
|
|
"replaceX": "Open this picker from X's emoji button",
|
|
"replaceXHint": "Off: X's own picker stays on its button, and this one gets a button of its own next to it.",
|
|
"cancel": "Cancel",
|
|
"save": "Save",
|
|
"results": "Search results",
|
|
"frequent": "Frequently used",
|
|
"search": "Search emoji",
|
|
"clearSearch": "Clear search",
|
|
"clear": "Clear",
|
|
"skinTone": "Skin tone",
|
|
"tone0": "Default",
|
|
"tone1": "Light",
|
|
"tone2": "Medium-light",
|
|
"tone3": "Medium",
|
|
"tone4": "Medium-dark",
|
|
"tone5": "Dark",
|
|
"categories": "Categories",
|
|
"grid": "Emoji",
|
|
"emptyHint": "Try a word like “party” or “heart”.",
|
|
"settingsLabel": "Emoji picker settings",
|
|
"settings": "Settings",
|
|
"noMatch": "No emoji match “{query}”",
|
|
"resultCount": "{count, plural, one {# result} other {# results}}",
|
|
"noResults": "No results",
|
|
"pick": "Pick an emoji",
|
|
"shiftClick": "Shift+click adds several",
|
|
"added": "{name} added",
|
|
"toneNamed": "Skin tone: {tone}",
|
|
"flag": "Flag: {code}",
|
|
"cat0": "Smileys & Emotion",
|
|
"cat1": "People & Body",
|
|
"cat2": "Animals & Nature",
|
|
"cat3": "Food & Drink",
|
|
"cat4": "Travel & Places",
|
|
"cat5": "Activities",
|
|
"cat6": "Objects",
|
|
"cat7": "Symbols",
|
|
"cat8": "Flags"
|
|
}
|
|
};
|
|
// i18n:end
|
|
/** The words in the app's language (api.i18n); English before start() and outside the app. */
|
|
let i18n = null;
|
|
const t = (key, params = {}) => (i18n ? i18n.t(key, params) : String(MESSAGES.en[key] ?? key)
|
|
.replace(/\{(\w+), plural, one \{([^{}]*)\} other \{([^{}]*)\}\}/g, (m, name, one, other) => (Number(params[name]) === 1 ? one : other).replace('#', String(params[name])))
|
|
.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m)));
|
|
const BT_EP_CARD_H = 440;
|
|
const BT_EP_CARD_MIN = 300;
|
|
/** Glyph atlas: each emoji drawn once into a BT_EP_BOX square, BT_EP_SHEET_COLS to a sheet row. */
|
|
const BT_EP_BOX = 36;
|
|
const BT_EP_SHEET_COLS = 8;
|
|
const BT_EP_SHEET_SIZE = 64;
|
|
const BT_EP_AC_W = 300;
|
|
const BT_EP_TONES = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
|
const BT_EP_TONE_NAMES = ['Default', 'Light', 'Medium-light', 'Medium', 'Medium-dark', 'Dark'];
|
|
const BT_EP_TONE_BASE = '✋';
|
|
const BT_EP_CAT_ICONS = ['smileys', 'people', 'nature', 'food', 'travel', 'activities', 'objects', 'symbols', 'flags'];
|
|
const BT_EP_TRIGGER = /(^|[\s(])[:]([a-z0-9_+-]{2,32})$/i;
|
|
const BT_EP_EXACT = /(^|[\s(])[:]([a-z0-9_+-]{1,32})[:]$/i;
|
|
const BT_EP_EDITABLE = '[contenteditable="true"], textarea, input[type="text"], input:not([type])';
|
|
const BT_EP_NAV = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
const BT_EP_GLYPH = '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", "Twemoji Mozilla", sans-serif';
|
|
/** X's own emoji button in a composer toolbar, by its label in any language X ships. */
|
|
const BT_EP_X_LABEL = /emoji|emoticon|smiley|絵文字|이모지|이모티콘|表情|эмодзи|емодзі|émoji|إيموجي|रमोजी|อีโมจิ/i;
|
|
const BT_EP_SVG = 'http://www.w3.org/2000/svg';
|
|
const BT_EP_RING = 'M3 12a9 9 0 1 0 18 0a9 9 0 1 0-18 0';
|
|
// Monochrome line icons (24 px grid, stroked in currentColor).
|
|
const BT_EP_ICONS = {
|
|
frequent: [BT_EP_RING, 'M12 7.5V12l3 2'],
|
|
smileys: [BT_EP_RING, 'M8.5 14.5a4.5 4.5 0 0 0 7 0', 'M9.2 9.3v1', 'M14.8 9.3v1'],
|
|
people: ['M8.5 7.5a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0-7 0', 'M5 20.5a7 7 0 0 1 14 0'],
|
|
nature: ['M5 19C5 10.5 10 5 19.5 4.5 19.5 14 14 19 5 19Z', 'M5 19l8.5-8.5'],
|
|
food: ['M5 9h11v5a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5V9Z', 'M16 10.5h1.5a2.5 2.5 0 0 1 0 5H16', 'M8.5 3.5V6', 'M12.5 3.5V6'],
|
|
travel: ['M4 13l2.2-5.6A1.5 1.5 0 0 1 7.6 6.5h8.8a1.5 1.5 0 0 1 1.4.9L20 13v4.5H4Z', 'M4 13h16', 'M7 17.5v2', 'M17 17.5v2'],
|
|
activities: [BT_EP_RING, 'M6 4.8c2.7 2 4 4.4 4 7.2s-1.3 5.2-4 7.2', 'M18 4.8c-2.7 2-4 4.4-4 7.2s1.3 5.2 4 7.2'],
|
|
objects: ['M9.5 17.5h5', 'M10.5 20.5h3', 'M12 3.5a5.5 5.5 0 0 0-3.2 10c.7.5 1.2 1.3 1.2 2.2v.3h4v-.3c0-.9.5-1.7 1.2-2.2A5.5 5.5 0 0 0 12 3.5Z'],
|
|
symbols: ['M12 19.5s-7.5-4.4-7.5-9.7A4.3 4.3 0 0 1 12 7.2a4.3 4.3 0 0 1 7.5 2.6c0 5.3-7.5 9.7-7.5 9.7Z'],
|
|
flags: ['M5.5 21V4', 'M5.5 4.5h11.5l-2.5 4 2.5 4H5.5'],
|
|
search: ['M4 10.5a6.5 6.5 0 1 0 13 0a6.5 6.5 0 1 0-13 0', 'M15.5 15.5 20 20'],
|
|
clear: ['M7 7l10 10', 'M17 7 7 17'],
|
|
empty: [BT_EP_RING, 'M9 15.5h6', 'M9.2 9.3v1', 'M14.8 9.3v1'],
|
|
enter: ['M18.5 6v4.5a3 3 0 0 1-3 3H6', 'M9.5 10 6 13.5 9.5 17'],
|
|
gear: ['M9.5 12a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0-5 0', 'M12 3.5l1.6 2.1 2.6-.5.9 2.5 2.5.9-.5 2.6 2.1 1.6-2.1 1.6.5 2.6-2.5.9-.9 2.5-2.6-.5L12 20.5l-1.6-2.1-2.6.5-.9-2.5-2.5-.9.5-2.6L2.8 12l2.1-1.6-.5-2.6 2.5-.9.9-2.5 2.6.5Z'],
|
|
};
|
|
|
|
// The toolbar button and the inline `:shortcode` list live in X's document (the list is referenced
|
|
// by the editor's aria-activedescendant, which cannot point into a shadow root); `all: revert` keeps
|
|
// X's stylesheet off them. The full picker is a shadow root of its own (BT_EP_CARD_CSS).
|
|
const BT_EP_CSS = `
|
|
.bt-ep-slot { display: flex; flex: none; align-items: center; }
|
|
.bt-ep-button { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center;
|
|
width: 34px; height: 34px; min-width: 34px; border-radius: 9999px; color: var(--wb-accent, rgb(29,155,240));
|
|
cursor: pointer; transition: background-color .2s; }
|
|
.bt-ep-button svg { display: block; width: 20px; height: 20px; }
|
|
.bt-ep-button:hover, .bt-ep-button[aria-expanded="true"] { background: color-mix(in srgb, currentColor 10%, transparent); }
|
|
.bt-ep-button:focus-visible { box-shadow: 0 0 0 2px currentColor; }
|
|
.bt-ep-ac, .bt-ep-ac :where(:not(svg, svg *)) { all: revert; box-sizing: border-box; }
|
|
.bt-ep-ac { position: fixed; z-index: 2147483640; inset: auto; margin: 0; padding: 0 0 8px; overflow: hidden auto; background: var(--ep-bg); color: var(--ep-text);
|
|
border: 1px solid var(--ep-border); border-radius: 8px; box-shadow: var(--ep-shadow); font: 400 14px/1.25 var(--ep-font);
|
|
-webkit-font-smoothing: antialiased; text-align: left; animation: bt-ep-ac-in .1s cubic-bezier(.2,.8,.2,1); }
|
|
.bt-ep-ac::-webkit-scrollbar { width: 8px; }
|
|
.bt-ep-ac::-webkit-scrollbar-thumb { background: var(--ep-thumb); background-clip: padding-box; border: 2px solid transparent; border-radius: 8px; }
|
|
.bt-ep-ac-head { display: block; padding: 12px 16px 8px; font: 700 12px/16px var(--ep-font); letter-spacing: .02em; text-transform: uppercase;
|
|
color: var(--ep-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.bt-ep-ac-head b { font-weight: 700; color: var(--ep-text); text-transform: none; letter-spacing: 0; }
|
|
.bt-ep-ac-opt { display: flex; align-items: center; gap: 12px; height: 36px; margin: 0 8px; padding: 0 8px; border-radius: 6px; cursor: pointer; }
|
|
.bt-ep-ac-opt[aria-selected="true"] { background: var(--ep-tile); }
|
|
.bt-ep-ac-glyph { flex: none; display: grid; place-items: center; width: 24px; height: 24px; font: 22px/1 ${BT_EP_GLYPH}; }
|
|
.bt-ep-ac-code { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ep-muted); font-weight: 500; }
|
|
.bt-ep-ac-code mark { background: none; color: var(--ep-text); font-weight: 700; }
|
|
.bt-ep-ac-key { display: none; flex: none; height: 20px; padding: 0 6px; place-items: center; border-radius: 4px; background: var(--ep-bg);
|
|
box-shadow: inset 0 0 0 1px var(--ep-border); color: var(--ep-muted); }
|
|
.bt-ep-ac-opt[aria-selected="true"] .bt-ep-ac-key { display: inline-grid; }
|
|
.bt-ep-ac-key svg { display: block; width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
.bt-ep-ac-flag { display: inline-grid; place-items: center; width: 24px; height: 17px; border-radius: 3px; background: var(--ep-tile);
|
|
box-shadow: inset 0 0 0 1px var(--ep-border); color: var(--ep-text); font: 700 9.5px/1 var(--ep-font); letter-spacing: .04em; }
|
|
@keyframes bt-ep-ac-in { from { opacity: 0; transform: translateY(var(--ep-dy, -4px)); } }
|
|
@media (prefers-reduced-motion: reduce) { .bt-ep-ac, .bt-ep-button { animation: none !important; transition: none !important; } }
|
|
`;
|
|
|
|
const BT_EP_CARD_CSS = `
|
|
:host { all: initial; }
|
|
.ep-card { position: absolute; box-sizing: border-box; display: grid; grid-template-columns: 48px minmax(0, 1fr);
|
|
grid-template-rows: 48px minmax(0, 1fr) 48px; width: ${BT_EP_CARD_W}px; height: ${BT_EP_CARD_H}px; overflow: hidden;
|
|
pointer-events: auto; background: var(--ep-bg); color: var(--ep-text); border: 1px solid var(--ep-border); border-radius: 8px;
|
|
box-shadow: var(--ep-shadow); font: 400 14px/1.25 var(--ep-font); -webkit-font-smoothing: antialiased; text-align: left;
|
|
transform-origin: var(--ep-origin, top left); animation: ep-in .12s cubic-bezier(.2,.8,.2,1); }
|
|
.ep-card *, .ep-card *::before, .ep-card *::after { box-sizing: border-box; }
|
|
.ep-card.ep-closing { animation: ep-out .08s ease-in forwards; pointer-events: none; }
|
|
/* Translate only: a scale would raster the whole card twice (at .96, then again at 1). */
|
|
@keyframes ep-in { from { opacity: 0; transform: translateY(var(--ep-dy, -4px)); } }
|
|
@keyframes ep-out { to { opacity: 0; transform: translateY(var(--ep-dy, -4px)); } }
|
|
@keyframes ep-drop { from { opacity: 0; transform: translateY(-4px); } }
|
|
button { font: inherit; color: inherit; margin: 0; }
|
|
svg { display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
|
|
/* Header: search, skin tone. */
|
|
.ep-head { grid-column: 1 / -1; grid-row: 1; position: relative; display: flex; align-items: center; gap: 8px; padding: 0 8px;
|
|
border-bottom: 1px solid var(--ep-border); }
|
|
.ep-search { flex: 1; min-width: 0; display: flex; align-items: center; gap: 4px; height: 32px; padding: 0 4px 0 10px; cursor: text;
|
|
border-radius: 6px; background: var(--ep-input); box-shadow: inset 0 0 0 1px var(--ep-input-line); transition: box-shadow .1s; }
|
|
.ep-search:focus-within { box-shadow: inset 0 0 0 1px var(--ep-accent); }
|
|
.ep-search-input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: 0; background: transparent; color: var(--ep-text);
|
|
font: 400 14px/1 var(--ep-font); }
|
|
.ep-search-input::placeholder { color: var(--ep-muted); opacity: 1; }
|
|
.ep-search-icon, .ep-clear { flex: none; display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0;
|
|
border-radius: 4px; background: transparent; color: var(--ep-muted); }
|
|
.ep-search-icon svg { width: 16px; height: 16px; }
|
|
.ep-clear { cursor: pointer; }
|
|
.ep-clear:hover { background: var(--ep-hover); color: var(--ep-text); }
|
|
.ep-clear svg { width: 14px; height: 14px; stroke-width: 2; }
|
|
.ep-search-icon[hidden], .ep-clear[hidden] { display: none; }
|
|
.ep-tone-btn { flex: none; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px;
|
|
background: transparent; cursor: pointer; font: 22px/1 ${BT_EP_GLYPH}; transition: background-color .1s; }
|
|
.ep-tone-btn:hover, .ep-tone-btn[aria-expanded="true"] { background: var(--ep-hover); }
|
|
.ep-tones { position: absolute; top: 7px; right: 7px; z-index: 4; display: flex; flex-direction: column; gap: 2px; padding: 3px;
|
|
background: var(--ep-bg); border: 1px solid var(--ep-border); border-radius: 8px; box-shadow: var(--ep-shadow-sm);
|
|
animation: ep-drop .1s cubic-bezier(.2,.8,.2,1); }
|
|
.ep-tones[hidden] { display: none; }
|
|
.ep-tone { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px; background: transparent;
|
|
cursor: pointer; font: 22px/1 ${BT_EP_GLYPH}; }
|
|
.ep-tone:hover { background: var(--ep-hover); }
|
|
.ep-tone[aria-checked="true"] { background: var(--ep-sel); }
|
|
|
|
/* Category rail. */
|
|
.ep-rail { grid-column: 1; grid-row: 2 / 4; display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 8px 0;
|
|
overflow: hidden auto; scrollbar-width: none; background: var(--ep-rail); border-right: 1px solid var(--ep-border); }
|
|
.ep-rail-btn { flex: none; display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 6px;
|
|
background: transparent; color: var(--ep-muted); cursor: pointer; transition: background-color .1s, color .1s; }
|
|
.ep-rail-btn svg { width: 20px; height: 20px; }
|
|
.ep-rail-btn:hover { background: var(--ep-hover); color: var(--ep-text); }
|
|
.ep-rail-btn[aria-current="true"] { background: var(--ep-sel); color: var(--ep-accent); }
|
|
.ep-rail-sep { flex: none; width: 24px; height: 1px; margin: 2px 0; background: var(--ep-border); }
|
|
|
|
/* The grid: one virtualized scroller, a sticky header over it, an empty state. */
|
|
.ep-body { grid-column: 2; grid-row: 2; position: relative; min-width: 0; min-height: 0; }
|
|
.ep-scroll { position: absolute; inset: 0; overflow: hidden auto; outline: none; overscroll-behavior: contain; contain: strict; }
|
|
.ep-scroll::-webkit-scrollbar { width: 8px; }
|
|
.ep-scroll::-webkit-scrollbar-thumb { background: var(--ep-thumb); background-clip: padding-box; border: 2px solid transparent; border-radius: 8px; min-height: 32px; }
|
|
.ep-scroll::-webkit-scrollbar-track { background: transparent; }
|
|
.ep-sizer { position: relative; width: 100%; }
|
|
.ep-row { position: absolute; top: 0; left: 0; display: flex; height: ${BT_EP_ROW}px; contain: layout style; }
|
|
.ep-hdr, .ep-sticky { display: flex; align-items: center; gap: 6px; height: ${BT_EP_HEAD}px; padding: 4px 12px 0 var(--ep-hdr-pad, 12px);
|
|
font: 700 12px/16px var(--ep-font); letter-spacing: .02em; text-transform: uppercase; color: var(--ep-muted); white-space: nowrap; }
|
|
.ep-hdr { position: absolute; top: 0; left: 0; right: 0; }
|
|
.ep-hdr svg, .ep-sticky svg { flex: none; width: 16px; height: 16px; stroke-width: 2; }
|
|
.ep-hdr-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
.ep-count { margin-left: auto; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: 0; }
|
|
.ep-sticky { position: absolute; top: 0; left: 0; right: 0; z-index: 2; background: var(--ep-bg); pointer-events: none; }
|
|
.ep-sticky.ep-lifted { box-shadow: 0 1px 0 var(--ep-border); }
|
|
.ep-sticky[hidden] { display: none; }
|
|
.ep-cell { flex: none; display: grid; place-items: center; width: ${BT_EP_CELL}px; height: ${BT_EP_CELL}px; border-radius: 6px; cursor: pointer;
|
|
font: 28px/1 ${BT_EP_GLYPH}; user-select: none; -webkit-user-select: none; }
|
|
.ep-cell[aria-selected="true"] { background-color: var(--ep-tile); }
|
|
/* Painted from the glyph atlas (the cell's background image; its sheet comes from a rule per sheet): the text glyph
|
|
stays for copy and assistive tech, unpainted. */
|
|
.ep-cell.ep-bm { background-repeat: no-repeat; background-size: ${BT_EP_BOX * BT_EP_SHEET_COLS}px auto; }
|
|
.ep-cell.ep-bm > span { visibility: hidden; }
|
|
${Array.from({ length: BT_EP_SHEET_COLS }, (_, i) => `.ep-bm.ep-c${i} { background-position-x: ${(BT_EP_CELL - BT_EP_BOX) / 2 - i * BT_EP_BOX}px; }`).join(' ')}
|
|
${Array.from({ length: BT_EP_SHEET_SIZE / BT_EP_SHEET_COLS }, (_, i) => `.ep-bm.ep-r${i} { background-position-y: ${(BT_EP_CELL - BT_EP_BOX) / 2 - i * BT_EP_BOX}px; }`).join(' ')}
|
|
.ep-scroll.ep-kbd .ep-cell[aria-selected="true"] { box-shadow: inset 0 0 0 2px var(--ep-accent); }
|
|
.ep-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
|
|
padding: 24px; text-align: center; color: var(--ep-muted); font-size: 13px; }
|
|
.ep-empty[hidden] { display: none; }
|
|
.ep-empty svg { width: 40px; height: 40px; margin-bottom: 8px; stroke-width: 1.4; }
|
|
.ep-empty strong { color: var(--ep-text); font-size: 15px; font-weight: 700; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
/* Preview bar. */
|
|
.ep-foot { grid-column: 2; grid-row: 3; display: flex; align-items: center; gap: 10px; min-width: 0; padding: 0 8px 0 12px;
|
|
background: var(--ep-rail); border-top: 1px solid var(--ep-border); }
|
|
.ep-preview-glyph { flex: none; display: grid; place-items: center; width: 28px; height: 28px; font: 26px/1 ${BT_EP_GLYPH}; color: var(--ep-muted); }
|
|
.ep-preview-glyph svg { width: 24px; height: 24px; stroke-width: 1.6; }
|
|
.ep-preview-text { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: 8px; overflow: hidden; white-space: nowrap; }
|
|
.ep-preview-code { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; font-weight: 700; font-size: 14px; color: var(--ep-text); }
|
|
.ep-preview-alt { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 12px; color: var(--ep-muted); }
|
|
.ep-foot.ep-idle .ep-preview-code { font-weight: 600; color: var(--ep-muted); }
|
|
.ep-gear { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; cursor: pointer;
|
|
background: transparent; color: var(--ep-muted); transition: background-color .1s, color .1s; }
|
|
.ep-gear:hover { background: var(--ep-hover); color: var(--ep-text); }
|
|
.ep-gear svg { width: 18px; height: 18px; stroke-width: 1.7; }
|
|
span.ep-flag { display: grid; place-items: center; width: 28px; height: 20px; border-radius: 4px; background: var(--ep-tile);
|
|
box-shadow: inset 0 0 0 1px var(--ep-border); color: var(--ep-text); font: 700 10px/1 var(--ep-font); letter-spacing: .04em; }
|
|
.ep-preview-glyph span.ep-flag { width: 28px; height: 20px; }
|
|
.ep-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
:focus-visible { outline: 2px solid var(--ep-accent); outline-offset: -2px; }
|
|
.ep-search-input:focus-visible, .ep-scroll:focus-visible { outline: none; }
|
|
@media (prefers-reduced-motion: reduce) { .ep-card, .ep-card * { animation: none !important; transition: none !important; } }
|
|
`;
|
|
|
|
let BT_EP_FLAT = null;
|
|
function btEpFlat() {
|
|
if (BT_EP_FLAT) return BT_EP_FLAT;
|
|
BT_EP_FLAT = EMOJI_DATA.rows.map(row => ({
|
|
emoji: row[0],
|
|
names: row[1].split(';'),
|
|
tags: row[2] ? row[2].split(' ') : [],
|
|
cat: row[3],
|
|
skin: row[4] === 1,
|
|
}));
|
|
return BT_EP_FLAT;
|
|
}
|
|
|
|
let BT_EP_BY_CAT = null;
|
|
function btEpByCat() {
|
|
if (BT_EP_BY_CAT) return BT_EP_BY_CAT;
|
|
BT_EP_BY_CAT = EMOJI_DATA.categories.map(() => []);
|
|
for (const entry of btEpFlat()) BT_EP_BY_CAT[entry.cat]?.push(entry);
|
|
return BT_EP_BY_CAT;
|
|
}
|
|
|
|
function btEpIsEditable(el) {
|
|
if (!el || el.nodeType !== 1) return false;
|
|
const tag = el.tagName;
|
|
if (tag === 'TEXTAREA') return true;
|
|
if (tag === 'INPUT') return /^(text|search)$/i.test(el.type || 'text');
|
|
return Boolean(el.isContentEditable || (el.closest && el.closest('[contenteditable="true"]')));
|
|
}
|
|
|
|
/** An element with a class and attributes; text only ever goes in as text. */
|
|
function btEpEl(tag, className, attrs, text) {
|
|
const el = document.createElement(tag);
|
|
if (className) el.className = className;
|
|
if (attrs) for (const key of Object.keys(attrs)) el.setAttribute(key, attrs[key]);
|
|
if (text != null) el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
function btEpIcon(name) {
|
|
const svg = document.createElementNS(BT_EP_SVG, 'svg');
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
svg.setAttribute('aria-hidden', 'true');
|
|
svg.setAttribute('fill', 'none');
|
|
svg.setAttribute('stroke', 'currentColor');
|
|
svg.setAttribute('stroke-width', '1.8');
|
|
svg.setAttribute('stroke-linecap', 'round');
|
|
svg.setAttribute('stroke-linejoin', 'round');
|
|
for (const d of BT_EP_ICONS[name] || []) {
|
|
const path = document.createElementNS(BT_EP_SVG, 'path');
|
|
path.setAttribute('d', d);
|
|
svg.appendChild(path);
|
|
}
|
|
return svg;
|
|
}
|
|
|
|
let BT_EP_FLAGS_OK = null;
|
|
/** Whether this system's emoji font draws flags (Windows' does not: it shows two letters). */
|
|
function btEpFlagsRender() {
|
|
if (BT_EP_FLAGS_OK != null) return BT_EP_FLAGS_OK;
|
|
try {
|
|
const ctx = document.createElement('canvas').getContext('2d');
|
|
ctx.font = '32px ' + BT_EP_GLYPH;
|
|
BT_EP_FLAGS_OK = ctx.measureText('🇺🇸').width < ctx.measureText('🇺').width * 1.5;
|
|
} catch {
|
|
BT_EP_FLAGS_OK = true;
|
|
}
|
|
return BT_EP_FLAGS_OK;
|
|
}
|
|
|
|
/**
|
|
* An entry's glyph as an element: the emoji (with the skin tone), or, where the system cannot draw
|
|
* flags, a small chip with the region code instead of two loose letters.
|
|
*/
|
|
function btEpGlyph(entry, tone, chipClass) {
|
|
const code = btEpFlagsRender() ? null : module.exports.regionCode(entry.emoji);
|
|
if (code) return btEpEl('span', chipClass, null, code);
|
|
return btEpEl('span', null, null, module.exports.withSkinTone(entry.emoji, entry.skin ? tone : 0));
|
|
}
|
|
|
|
/** The text a grid cell draws for `entry` (null for a flag shown as a letter chip). */
|
|
function btEpGlyphText(entry, tone) {
|
|
if (!btEpFlagsRender() && module.exports.regionCode(entry.emoji)) return null;
|
|
return module.exports.withSkinTone(entry.emoji, entry.skin ? tone : 0);
|
|
}
|
|
|
|
/**
|
|
* Lifts `el` into the browser's top layer (a manual popover): above every X layer and out of any
|
|
* clipping or transformed ancestor such as X's modal composer, while it stays in that dialog's DOM
|
|
* (so X's focus trap keeps focus in it). False where popovers are not supported.
|
|
*/
|
|
function btEpTopLayer(el) {
|
|
if (typeof el.showPopover !== 'function') return false;
|
|
try {
|
|
if (!el.hasAttribute('popover')) el.setAttribute('popover', 'manual');
|
|
if (!el.matches(':popover-open')) el.showPopover();
|
|
return true;
|
|
} catch {
|
|
el.removeAttribute('popover');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---- Glyph atlas ----
|
|
// A color emoji font (Segoe UI Emoji's gradient glyphs on Windows 11) costs ~3-4 ms of raster per
|
|
// glyph, and the compositor's glyph cache is too small to keep a whole emoji grid, so every row
|
|
// scrolled into view was rasterized again: 100-2400 ms frames while scrolling the grid. The atlas
|
|
// draws each glyph once, in a worker, into sprite sheets (plain images, cheap to raster and cached
|
|
// by the compositor), keeps them in IndexedDB for the next page, and the grid paints its cells from
|
|
// them. A cell keeps its text glyph (hidden) for copy, tests and the moment before its sheet exists.
|
|
const BT_EP_ATLAS_VERSION = 1;
|
|
const BT_EP_ATLAS_DB = 'wayBetter-emoji-atlas';
|
|
const BT_EP_ATLAS_WORKER = `
|
|
const cache = new Map();
|
|
onmessage = async event => {
|
|
const { id, glyphs, dpr, family, cols, box, size } = event.data;
|
|
try {
|
|
const s = Math.round(box * dpr);
|
|
const canvas = new OffscreenCanvas(cols * s, Math.ceil(glyphs.length / cols) * s);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.font = (size * dpr) + 'px ' + family;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'alphabetic';
|
|
// The DOM glyph: a line box of font-size height, centered in the cell, baseline after the half-leading.
|
|
const m = ctx.measureText('\\u{1F600}');
|
|
const ascent = m.fontBoundingBoxAscent / dpr;
|
|
const descent = m.fontBoundingBoxDescent / dpr;
|
|
const baseline = ((box - size) / 2 + (size - ascent - descent) / 2 + ascent) * dpr;
|
|
glyphs.forEach((glyph, i) => ctx.fillText(glyph, ((i % cols) + 0.5) * s, Math.floor(i / cols) * s + baseline));
|
|
postMessage({ id, blob: await canvas.convertToBlob({ type: 'image/png' }) });
|
|
} catch (error) {
|
|
postMessage({ id, error: String(error) });
|
|
}
|
|
};`;
|
|
|
|
class BtEpAtlas {
|
|
constructor() {
|
|
this.dpr = 0;
|
|
/** glyph -> { url, col, row } */
|
|
this.ready = new Map();
|
|
this.queued = new Set();
|
|
this.queue = [];
|
|
this.inFlight = 0;
|
|
this.worker = null;
|
|
this.failed = false;
|
|
this.urls = [];
|
|
this.listeners = new Set();
|
|
this.db = null;
|
|
this.loaded = null;
|
|
this.seq = 0;
|
|
this.sheetSeq = 0;
|
|
this.pending = new Map();
|
|
}
|
|
|
|
static supported() {
|
|
return typeof OffscreenCanvas === 'function' && typeof Worker === 'function' && typeof URL.createObjectURL === 'function';
|
|
}
|
|
|
|
/** Matches the atlas to the current device pixel ratio (a monitor or zoom change starts over). */
|
|
sync() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
if (dpr === this.dpr) return;
|
|
this.reset();
|
|
this.dpr = dpr;
|
|
this.loaded = this.loadSaved(dpr);
|
|
}
|
|
|
|
reset() {
|
|
for (const url of this.urls) URL.revokeObjectURL(url);
|
|
this.urls = [];
|
|
this.ready.clear();
|
|
this.queued.clear();
|
|
this.queue = [];
|
|
this.pending.clear();
|
|
this.inFlight = 0;
|
|
}
|
|
|
|
get(glyph) { return this.ready.get(glyph) || null; }
|
|
|
|
/** Sheet id -> blob URL of every sheet drawn so far. */
|
|
sheets() {
|
|
const out = new Map();
|
|
for (const spot of this.ready.values()) out.set(spot.sheet, spot.url);
|
|
return out;
|
|
}
|
|
|
|
onReady(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
|
|
|
/** Queues glyphs (in order); `urgent` puts them ahead of everything else. */
|
|
request(glyphs, urgent = false) {
|
|
if (this.failed || !BtEpAtlas.supported()) return;
|
|
this.sync();
|
|
const fresh = [];
|
|
for (const glyph of glyphs) {
|
|
if (!glyph || this.ready.has(glyph)) continue;
|
|
if (this.queued.has(glyph)) { if (urgent) fresh.push(glyph); continue; }
|
|
this.queued.add(glyph);
|
|
fresh.push(glyph);
|
|
}
|
|
if (!fresh.length) return;
|
|
if (urgent) {
|
|
const set = new Set(fresh);
|
|
this.queue = [...fresh, ...this.queue.filter(glyph => !set.has(glyph))];
|
|
} else this.queue.push(...fresh);
|
|
this.pump();
|
|
}
|
|
|
|
pump() {
|
|
if (this.failed) return;
|
|
// Saved sheets first: a glyph already on disk is never drawn again.
|
|
if (this.loaded) { const loaded = this.loaded; this.loaded = null; loaded.then(() => this.pump()); return; }
|
|
while (this.inFlight < 2 && this.queue.length) {
|
|
const glyphs = [];
|
|
while (glyphs.length < BT_EP_SHEET_SIZE && this.queue.length) {
|
|
const glyph = this.queue.shift();
|
|
if (!this.ready.has(glyph)) glyphs.push(glyph);
|
|
}
|
|
if (!glyphs.length) break;
|
|
this.draw(glyphs);
|
|
}
|
|
}
|
|
|
|
ensureWorker() {
|
|
if (this.worker) return this.worker;
|
|
const url = URL.createObjectURL(new Blob([BT_EP_ATLAS_WORKER], { type: 'text/javascript' }));
|
|
try {
|
|
this.worker = new Worker(url);
|
|
} finally {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
this.worker.onmessage = event => this.onSheet(event.data);
|
|
this.worker.onerror = () => this.fail();
|
|
return this.worker;
|
|
}
|
|
|
|
fail() {
|
|
this.failed = true;
|
|
this.queue = [];
|
|
this.queued.clear();
|
|
try { this.worker?.terminate(); } catch { /* Gone already. */ }
|
|
this.worker = null;
|
|
}
|
|
|
|
draw(glyphs) {
|
|
let worker;
|
|
try { worker = this.ensureWorker(); } catch { this.fail(); return; }
|
|
const id = ++this.seq;
|
|
this.inFlight += 1;
|
|
this.pending.set(id, { glyphs, dpr: this.dpr });
|
|
worker.postMessage({ id, glyphs, dpr: this.dpr, family: BT_EP_GLYPH, cols: BT_EP_SHEET_COLS, box: BT_EP_BOX, size: 28 });
|
|
}
|
|
|
|
onSheet(data) {
|
|
const job = this.pending.get(data.id);
|
|
if (!job) return;
|
|
this.pending.delete(data.id);
|
|
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
if (data.error || !data.blob || job.dpr !== this.dpr) {
|
|
if (data.error) this.fail();
|
|
else this.pump();
|
|
return;
|
|
}
|
|
this.add(job.glyphs, data.blob);
|
|
this.save(job.glyphs, data.blob, job.dpr);
|
|
this.pump();
|
|
}
|
|
|
|
add(glyphs, blob) {
|
|
const url = URL.createObjectURL(blob);
|
|
this.urls.push(url);
|
|
const sheet = ++this.sheetSeq;
|
|
glyphs.forEach((glyph, i) => {
|
|
this.ready.set(glyph, { url, sheet, col: i % BT_EP_SHEET_COLS, row: Math.floor(i / BT_EP_SHEET_COLS) });
|
|
this.queued.delete(glyph);
|
|
});
|
|
for (const fn of [...this.listeners]) { try { fn(); } catch { /* A closed picker. */ } }
|
|
}
|
|
|
|
openDb() {
|
|
if (this.db) return this.db;
|
|
this.db = new Promise(resolve => {
|
|
try {
|
|
const request = indexedDB.open(BT_EP_ATLAS_DB, 1);
|
|
request.onupgradeneeded = () => request.result.createObjectStore('sheets', { keyPath: 'k' });
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => resolve(null);
|
|
request.onblocked = () => resolve(null);
|
|
} catch { resolve(null); }
|
|
});
|
|
return this.db;
|
|
}
|
|
|
|
async loadSaved(dpr) {
|
|
const db = await this.openDb();
|
|
if (!db) return;
|
|
const rows = await new Promise(resolve => {
|
|
try {
|
|
const request = db.transaction('sheets').objectStore('sheets').getAll();
|
|
request.onsuccess = () => resolve(request.result || []);
|
|
request.onerror = () => resolve([]);
|
|
} catch { resolve([]); }
|
|
});
|
|
if (dpr !== this.dpr) return;
|
|
for (const row of rows) {
|
|
if (row.v !== BT_EP_ATLAS_VERSION || row.dpr !== dpr || row.family !== BT_EP_GLYPH || !(row.blob instanceof Blob) || !Array.isArray(row.glyphs)) continue;
|
|
this.add(row.glyphs, row.blob);
|
|
}
|
|
// Queued meanwhile but found on disk: nothing to draw.
|
|
this.queue = this.queue.filter(glyph => !this.ready.has(glyph));
|
|
}
|
|
|
|
async save(glyphs, blob, dpr) {
|
|
const db = await this.openDb();
|
|
if (!db) return;
|
|
try {
|
|
const k = [BT_EP_ATLAS_VERSION, dpr, glyphs.join('')].join('|');
|
|
db.transaction('sheets', 'readwrite').objectStore('sheets').put({ k, v: BT_EP_ATLAS_VERSION, dpr, family: BT_EP_GLYPH, glyphs, blob });
|
|
} catch { /* Quota or a closed database: the atlas just stays in memory. */ }
|
|
}
|
|
|
|
dispose() {
|
|
this.fail();
|
|
this.reset();
|
|
this.listeners.clear();
|
|
this.db?.then(db => { try { db?.close(); } catch { /* Closed. */ } });
|
|
this.db = null;
|
|
}
|
|
}
|
|
|
|
const btEpSheetRule = (id, url) => '.ep-bm[data-sh="' + id + '"] { background-image: url("' + url + '"); }';
|
|
|
|
/**
|
|
* Paints `cell` from the atlas when its glyph is there; true when it did. No inline style: the
|
|
* cell gets its sheet (one rule per sheet in the picker's style sheet) and its column/row classes.
|
|
*/
|
|
function btEpPaintCell(cell, atlas, p) {
|
|
const glyph = cell.dataset.g;
|
|
const spot = glyph && atlas && atlas.get(glyph);
|
|
if (!spot) return false;
|
|
if (!p.sheets.has(spot.sheet)) {
|
|
// At the end of the sheet, after the cell rules.
|
|
const sheet = p.atlasSheet || (p.atlasStyle && p.atlasStyle.sheet);
|
|
if (!sheet) return false;
|
|
try { sheet.insertRule(btEpSheetRule(spot.sheet, spot.url), sheet.cssRules.length); } catch { return false; }
|
|
p.sheets.add(spot.sheet);
|
|
}
|
|
cell.dataset.sh = String(spot.sheet);
|
|
cell.className = 'ep-cell ep-bm ep-c' + spot.col + ' ep-r' + spot.row;
|
|
return true;
|
|
}
|
|
|
|
function btEpReducedMotion() {
|
|
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
}
|
|
|
|
module.exports = class EmojiPicker {
|
|
// ---- Pure helpers: no DOM, safe to unit test directly. ----
|
|
|
|
/** All emoji rows expanded to objects; memoized. */
|
|
static entries() {
|
|
return btEpFlat();
|
|
}
|
|
|
|
/** `{ start, query }` when `text` (everything before the caret) ends in an open `:shortcode` of 2+ chars, else null. */
|
|
static parseTrigger(text) {
|
|
const value = String(text || '');
|
|
const match = BT_EP_TRIGGER.exec(value);
|
|
if (!match) return null;
|
|
const query = match[2];
|
|
return { start: value.length - query.length - 1, query };
|
|
}
|
|
|
|
/**
|
|
* Characters to replace for the open `:trigger` that `text` (everything before the caret, read
|
|
* now) ends in, or null when the caret is no longer in that trigger (`start` = where the trigger
|
|
* began when the list opened).
|
|
*/
|
|
static replaceLength(text, start) {
|
|
const trigger = EmojiPicker.parseTrigger(text);
|
|
if (!trigger || (start != null && trigger.start !== start)) return null;
|
|
return String(text).length - trigger.start;
|
|
}
|
|
|
|
/** `{ start, end, code }` when `text` ends in a just-closed `:shortcode:`, else null. `end` is `text.length`. */
|
|
static parseExact(text) {
|
|
const value = String(text || '');
|
|
const match = BT_EP_EXACT.exec(value);
|
|
if (!match) return null;
|
|
const code = match[2];
|
|
return { start: value.length - code.length - 2, end: value.length, code };
|
|
}
|
|
|
|
/** The entry whose primary or alternate shortcode equals `code` (case-insensitive), or null. */
|
|
static findExact(code) {
|
|
const needle = String(code || '').toLowerCase();
|
|
if (!needle) return null;
|
|
for (const entry of btEpFlat()) {
|
|
if (entry.names.some(name => name.toLowerCase() === needle)) return entry;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Ranked matches for `query`: exact shortcode first, then shortcode prefix, then a word inside a
|
|
* shortcode (`face` in `smiling_face`), then tag word, then tag substring. Stable within a tier
|
|
* (shorter primary name first).
|
|
*/
|
|
static search(query, limit) {
|
|
const q = String(query || '').trim().toLowerCase();
|
|
if (!q) return [];
|
|
const scored = [];
|
|
for (const entry of btEpFlat()) {
|
|
let score = -1;
|
|
for (let i = 0; i < entry.names.length; i += 1) {
|
|
const name = entry.names[i].toLowerCase();
|
|
if (name === q) { score = 100 - i; break; }
|
|
if (name.startsWith(q)) score = Math.max(score, 60 - i);
|
|
else if (name.includes('_' + q)) score = Math.max(score, 30);
|
|
}
|
|
if (score < 0) {
|
|
for (const tag of entry.tags) {
|
|
const lower = tag.toLowerCase();
|
|
if (lower === q) { score = 40; break; }
|
|
if (lower.startsWith(q)) score = Math.max(score, 20);
|
|
}
|
|
}
|
|
if (score < 0 && entry.tags.some(tag => tag.toLowerCase().includes(q))) score = 5;
|
|
if (score >= 0) scored.push({ entry, score });
|
|
}
|
|
scored.sort((a, b) => b.score - a.score || a.entry.names[0].length - b.entry.names[0].length);
|
|
const sliced = limit ? scored.slice(0, limit) : scored;
|
|
return sliced.map(item => item.entry);
|
|
}
|
|
|
|
/**
|
|
* `emoji` with a Fitzpatrick modifier inserted after its first code point; `tone` is 0 (none) to 5.
|
|
* A VS16 (U+FE0F) right after the base is dropped: the modifier already forces emoji presentation.
|
|
*/
|
|
static withSkinTone(emoji, tone) {
|
|
if (!tone) return emoji;
|
|
const modifier = BT_EP_TONES[tone - 1];
|
|
if (!modifier) return emoji;
|
|
const chars = Array.from(emoji);
|
|
if (!chars.length) return emoji;
|
|
const rest = chars.slice(1);
|
|
if (rest[0] === '️') rest.shift();
|
|
return chars[0] + modifier + rest.join('');
|
|
}
|
|
|
|
/**
|
|
* The region a flag emoji stands for: "US" for two regional indicators, "ENG"/"SCT"/"WLS" for
|
|
* the subdivision flags; null for anything else.
|
|
*/
|
|
static regionCode(emoji) {
|
|
const points = Array.from(String(emoji || ''), ch => ch.codePointAt(0));
|
|
if (points.length === 2 && points.every(cp => cp >= 0x1F1E6 && cp <= 0x1F1FF)) {
|
|
return String.fromCharCode(...points.map(cp => cp - 0x1F1E6 + 65));
|
|
}
|
|
if (points[0] === 0x1F3F4 && points[points.length - 1] === 0xE007F && points.length > 4) {
|
|
const tag = String.fromCharCode(...points.slice(1, -1).map(cp => cp - 0xE0000)).toUpperCase();
|
|
return /^[A-Z]{2}[A-Z0-9]{1,3}$/.test(tag) ? tag.slice(2) : null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** A readable name for an entry: its first shortcode with letters, spaced and capitalized. */
|
|
static displayName(entry) {
|
|
const first = entry.names.find(n => /[a-z]/i.test(n)) || entry.names[0];
|
|
if (/^[a-z]{2}$/.test(first) && entry.cat === EMOJI_DATA.categories.length - 1) return t('flag', { code: first.toUpperCase() });
|
|
const name = first.replace(/_/g, ' ');
|
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
}
|
|
|
|
/**
|
|
* The virtual list's rows for sections of `counts` emoji: a header row per section, then rows of
|
|
* `cols` cells. `rows[i]` = `{ kind: 'header'|'row', section, top, height, start?, end?, index? }`
|
|
* (`index` counts emoji rows only), `sectionTops[s]` = where section `s` begins,
|
|
* `sectionFirstRow[s]` = the `rows` index of its first emoji row, `height` = total height.
|
|
*/
|
|
static layoutRows(counts, cols = BT_EP_COLS, rowHeight = BT_EP_ROW, headerHeight = BT_EP_HEAD) {
|
|
const rows = [];
|
|
const sectionTops = [];
|
|
const sectionFirstRow = [];
|
|
let top = 0;
|
|
let gridRows = 0;
|
|
counts.forEach((count, section) => {
|
|
sectionTops.push(top);
|
|
rows.push({ kind: 'header', section, top, height: headerHeight });
|
|
top += headerHeight;
|
|
sectionFirstRow.push(rows.length);
|
|
for (let start = 0; start < count; start += cols) {
|
|
rows.push({ kind: 'row', section, start, end: Math.min(count, start + cols), top, height: rowHeight, index: gridRows });
|
|
gridRows += 1;
|
|
top += rowHeight;
|
|
}
|
|
});
|
|
return { rows, sectionTops, sectionFirstRow, height: top, gridRows };
|
|
}
|
|
|
|
/** `{ start, end }` (end exclusive) of the rows that intersect the viewport, `overscan` px either side. */
|
|
static visibleRange(rows, scrollTop, viewport, overscan = 0) {
|
|
const from = scrollTop - overscan;
|
|
const to = scrollTop + viewport + overscan;
|
|
let lo = 0;
|
|
let hi = rows.length;
|
|
while (lo < hi) {
|
|
const mid = (lo + hi) >> 1;
|
|
if (rows[mid].top + rows[mid].height <= from) lo = mid + 1;
|
|
else hi = mid;
|
|
}
|
|
let end = lo;
|
|
while (end < rows.length && rows[end].top < to) end += 1;
|
|
return { start: lo, end };
|
|
}
|
|
|
|
/** The section whose area contains `scrollTop` (the one the sticky header names). */
|
|
static sectionAt(sectionTops, scrollTop) {
|
|
let section = 0;
|
|
for (let i = 0; i < sectionTops.length; i += 1) {
|
|
if (sectionTops[i] <= scrollTop + 0.5) section = i;
|
|
else break;
|
|
}
|
|
return section;
|
|
}
|
|
|
|
/**
|
|
* Keyboard movement in a grid of sections with `counts` cells, `cols` per row. `pos` is
|
|
* `{ section, index }` or null (then the first cell). Arrows move in 2D and flow across sections
|
|
* keeping the column, Home/End go to the row's ends (with `ctrl`: the first/last cell),
|
|
* PageUp/PageDown jump to the previous/next section. Returns the new position (the same object's
|
|
* values when there is nowhere to go), or null when there are no cells.
|
|
*/
|
|
static moveInGrid(counts, pos, key, cols = BT_EP_COLS, ctrl = false) {
|
|
const filled = [];
|
|
counts.forEach((count, section) => { if (count > 0) filled.push(section); });
|
|
if (!filled.length) return null;
|
|
if (!pos || !(counts[pos.section] > 0)) return { section: filled[0], index: 0 };
|
|
const { section, index } = pos;
|
|
const count = counts[section];
|
|
const col = index % cols;
|
|
const at = filled.indexOf(section);
|
|
const next = filled[at + 1];
|
|
const prev = filled[at - 1];
|
|
const lastRowStart = c => Math.floor((c - 1) / cols) * cols;
|
|
switch (key) {
|
|
case 'ArrowRight':
|
|
if (index + 1 < count) return { section, index: index + 1 };
|
|
return next != null ? { section: next, index: 0 } : { section, index };
|
|
case 'ArrowLeft':
|
|
if (index > 0) return { section, index: index - 1 };
|
|
return prev != null ? { section: prev, index: counts[prev] - 1 } : { section, index };
|
|
case 'ArrowDown':
|
|
if (index + cols < count) return { section, index: index + cols };
|
|
if (index - col < lastRowStart(count)) return { section, index: count - 1 };
|
|
return next != null ? { section: next, index: Math.min(col, counts[next] - 1) } : { section, index };
|
|
case 'ArrowUp':
|
|
if (index - cols >= 0) return { section, index: index - cols };
|
|
return prev != null ? { section: prev, index: Math.min(lastRowStart(counts[prev]) + col, counts[prev] - 1) } : { section, index };
|
|
case 'PageDown':
|
|
return next != null ? { section: next, index: Math.min(col, counts[next] - 1) } : { section, index: Math.min(lastRowStart(count) + col, count - 1) };
|
|
case 'PageUp':
|
|
if (index >= cols) return { section, index: col };
|
|
return prev != null ? { section: prev, index: Math.min(col, counts[prev] - 1) } : { section, index };
|
|
case 'Home':
|
|
return ctrl ? { section: filled[0], index: 0 } : { section, index: index - col };
|
|
case 'End': {
|
|
if (ctrl) { const last = filled[filled.length - 1]; return { section: last, index: counts[last] - 1 }; }
|
|
return { section, index: Math.min(index - col + cols - 1, count - 1) };
|
|
}
|
|
default:
|
|
return { section, index };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Where a `width` x `height` popup goes next to `anchor` (a rect) without covering `avoid` (the
|
|
* text being typed; defaults to the anchor) inside a `viewWidth` x `viewHeight` pane: below when it
|
|
* fits, else above, else on the roomier side shrunk to at least `minHeight`; only a pane too
|
|
* small for that makes it overlap. Left-aligned with the anchor, clamped `margin` px inside.
|
|
* Returns `{ left, top, width, height, side: 'below'|'above'|'over' }`.
|
|
*/
|
|
static placeCard(anchor, avoid, width, height, viewWidth, viewHeight, minHeight = 0, margin = 8, gap = 8) {
|
|
const w = Math.max(0, Math.min(width, viewWidth - margin * 2));
|
|
const left = Math.round(Math.max(margin, Math.min(anchor.left, viewWidth - margin - w)));
|
|
const area = avoid || anchor;
|
|
const belowTop = Math.max(anchor.bottom, area.bottom) + gap;
|
|
const aboveBottom = Math.min(anchor.top, area.top) - gap;
|
|
const below = viewHeight - margin - belowTop;
|
|
const above = aboveBottom - margin;
|
|
const full = Math.max(0, Math.min(height, viewHeight - margin * 2));
|
|
let side;
|
|
let h;
|
|
if (below >= height) { side = 'below'; h = height; }
|
|
else if (above >= height) { side = 'above'; h = height; }
|
|
else if (Math.max(below, above) >= Math.min(minHeight, full)) {
|
|
side = below >= above ? 'below' : 'above';
|
|
h = Math.min(height, Math.max(below, above));
|
|
} else {
|
|
const top = Math.max(margin, Math.min(belowTop, viewHeight - margin - full));
|
|
return { left, top: Math.round(top), width: w, height: Math.round(full), side: 'over' };
|
|
}
|
|
// An anchor scrolled out of view must not put the card off screen.
|
|
const top = Math.max(margin, Math.min(side === 'below' ? belowTop : aboveBottom - h, viewHeight - margin - h));
|
|
return { left, top: Math.round(top), width: w, height: Math.round(h), side };
|
|
}
|
|
|
|
/** How many columns of `cell` px fit in a grid `width` px wide (with the side padding), at least `min`. */
|
|
static columnsFor(width, cell = BT_EP_CELL, min = BT_EP_MIN_COLS) {
|
|
return Math.max(min, Math.floor((Math.max(0, width) - BT_EP_PAD_L - BT_EP_PAD_R) / cell));
|
|
}
|
|
|
|
/** Whether `el` is X's own emoji button in a composer toolbar (found by its label, any language). */
|
|
static isXEmojiButton(el) {
|
|
if (!el || el.nodeType !== 1 || !el.closest) return false;
|
|
if (el.classList && el.classList.contains('bt-ep-button')) return false;
|
|
if (!(el.tagName === 'BUTTON' || el.getAttribute('role') === 'button')) return false;
|
|
if (!el.closest('[data-testid="toolBar"]')) return false;
|
|
return BT_EP_X_LABEL.test(el.getAttribute('aria-label') || '');
|
|
}
|
|
|
|
// ---- Runtime ----
|
|
|
|
start() {
|
|
const api = window.wayBetter.api;
|
|
i18n = api.i18n || null;
|
|
if (i18n) i18n.register(MESSAGES);
|
|
this.api = api;
|
|
this.store = api.storage('emoji-picker');
|
|
this.scope = api.scope();
|
|
this.tone = Math.max(0, Math.min(5, Number(this.store.get('tone', 0)) || 0));
|
|
this.recent = Array.isArray(this.store.get('recent', [])) ? this.store.get('recent', []) : [];
|
|
this.freq = this.store.get('freq', {}) || {};
|
|
/** X's emoji button opens this picker (one picker, not two); off: X's stays and ours gets its own button. */
|
|
this.replaceX = this.store.get('replaceX', true) !== false;
|
|
this.autocomplete = null;
|
|
this.picker = null;
|
|
this.settings = null;
|
|
this.stopped = false;
|
|
/** Picker hosts on the page, including ones still animating out. */
|
|
this.hosts = new Set();
|
|
/** Editors where X showed its own emoji typeahead; ours stays out of them. */
|
|
this.nativeEditors = new WeakSet();
|
|
|
|
this.scope.css('emoji-picker', BT_EP_CSS);
|
|
this.atlas = new BtEpAtlas();
|
|
this.scope.defer(() => this.atlas.dispose());
|
|
// Anything left behind by an earlier run (buttons would make mountButton skip their toolbar).
|
|
for (const stale of document.querySelectorAll('.bt-ep-slot, .bt-ep-button, .bt-ep-ac, wb-emoji-picker')) stale.remove();
|
|
this.scope.on(document, 'input', event => this.onInput(event), true);
|
|
this.scope.on(document, 'keydown', event => this.onKeyDown(event), true);
|
|
this.scope.on(document, 'pointerdown', event => this.onPointerDown(event), true);
|
|
this.scope.on(document, 'mousedown', event => this.onXButtonMouseDown(event), true);
|
|
this.scope.on(document, 'click', event => this.onXButtonClick(event), true);
|
|
this.scope.on(document, 'selectionchange', () => this.onSelectionChange());
|
|
this.scope.on(window, 'resize', () => this.schedulePosition());
|
|
this.scope.on(document, 'scroll', () => this.schedulePosition(), true);
|
|
this.scope.observe('[data-testid="toolBar"]', toolbar => this.mountButton(toolbar));
|
|
this.scope.onRouteChange(() => { this.closeAutocomplete(); this.closePicker({ immediate: true }); });
|
|
this.scope.onThemeChange(() => this.repaint());
|
|
this.scope.defer(() => {
|
|
this.closeAutocomplete();
|
|
this.closePicker({ immediate: true });
|
|
for (const host of this.hosts) host.remove();
|
|
this.hosts.clear();
|
|
try { this.settings?.close(); } catch { /* Already closed. */ }
|
|
this.settings = null;
|
|
});
|
|
this.warmGlyphs();
|
|
}
|
|
|
|
/**
|
|
* Chromium shapes a color-emoji glyph slowly the first time it lays it out (about 1 ms each), which
|
|
* would make the first open take ~100 ms. Lay out what an open shows first (Frequently used and the
|
|
* top of every category) in small idle-time chunks, off screen, so opening is instant.
|
|
*/
|
|
warmGlyphs() {
|
|
const queue = [...new Set([...this.frequentEntries(), ...btEpByCat().flatMap(list => list.slice(0, 72))].map(e => e.emoji))];
|
|
const idle = typeof window.requestIdleCallback === 'function'
|
|
? fn => window.requestIdleCallback(fn, { timeout: 5000 })
|
|
: fn => setTimeout(fn, 120);
|
|
let stopped = false;
|
|
this.scope.defer(() => { stopped = true; });
|
|
let first = true;
|
|
const step = () => {
|
|
if (stopped || !document.body) return;
|
|
const chunk = queue.splice(0, 40);
|
|
if (!chunk.length) { this.prime(); this.warmAtlas(); return; }
|
|
const probe = btEpEl('span', null, { 'aria-hidden': 'true' }, chunk.join(''));
|
|
probe.style.cssText = 'all:initial;position:fixed;left:0;top:0;width:1px;height:1px;overflow:hidden;visibility:hidden;contain:strict;pointer-events:none;font:28px/1 ' + BT_EP_GLYPH;
|
|
if (first) {
|
|
first = false;
|
|
const text = btEpEl('span', null, null, 'FREQUENTLY USED SMILEYS & EMOTION Search emoji Pick an emoji');
|
|
text.style.cssText = 'font:700 12px/1 ' + this.themeFont();
|
|
probe.appendChild(text);
|
|
}
|
|
document.body.appendChild(probe);
|
|
void probe.offsetWidth;
|
|
probe.remove();
|
|
idle(step);
|
|
};
|
|
idle(step);
|
|
}
|
|
|
|
/** Draws what an open shows first into the atlas (from disk when saved), once this page is on screen. */
|
|
warmAtlas() {
|
|
if (this.stopped) return;
|
|
if (document.visibilityState !== 'visible') {
|
|
const later = () => { if (document.visibilityState === 'visible') { document.removeEventListener('visibilitychange', later); this.warmAtlas(); } };
|
|
this.scope.on(document, 'visibilitychange', later);
|
|
return;
|
|
}
|
|
const entries = [...this.frequentEntries(), ...btEpByCat().flatMap(list => list.slice(0, 72))];
|
|
this.atlas.request(entries.map(entry => btEpGlyphText(entry, this.tone)));
|
|
}
|
|
|
|
stop() {
|
|
this.stopped = true;
|
|
this.scope.dispose();
|
|
this.autocomplete = null;
|
|
this.picker = null;
|
|
}
|
|
|
|
// ---- Persistence ----
|
|
|
|
saveTone(tone) {
|
|
this.tone = Math.max(0, Math.min(5, tone || 0));
|
|
this.store.set('tone', this.tone);
|
|
}
|
|
|
|
recordUsage(entry) {
|
|
const name = entry.names[0];
|
|
this.recent = [name, ...this.recent.filter(n => n !== name)].slice(0, 36);
|
|
this.store.set('recent', this.recent);
|
|
this.freq[name] = (this.freq[name] || 0) + 1;
|
|
this.store.set('freq', this.freq);
|
|
}
|
|
|
|
recentEntries() {
|
|
return this.recent.map(name => EmojiPicker.findExact(name)).filter(Boolean);
|
|
}
|
|
|
|
/** Most used first; equal counts go by recency. */
|
|
frequentEntries(limit = BT_EP_COLS * 2) {
|
|
const recency = name => { const i = this.recent.indexOf(name); return i < 0 ? 1e6 : i; };
|
|
return Object.keys(this.freq)
|
|
.sort((a, b) => this.freq[b] - this.freq[a] || recency(a) - recency(b))
|
|
.slice(0, limit)
|
|
.map(name => EmojiPicker.findExact(name))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
// ---- Theme ----
|
|
|
|
themeFont() {
|
|
try { const theme = this.api.theme.current(); return theme?.fonts?.dialog?.stack || theme?.font || 'system-ui, sans-serif'; } catch { return 'system-ui, sans-serif'; }
|
|
}
|
|
|
|
/** The active theme as `--ep-*` custom properties on `el` (the picker host or the inline list). */
|
|
paint(el) {
|
|
let theme = null;
|
|
try { theme = this.api.theme.current(); } catch { theme = null; }
|
|
const c = theme?.colors || { bg: '#ffffff', surface: '#f7f9f9', elevated: '#eff3f4', border: '#cfd9de', text: '#0f1419', muted: '#536471', accent: '#1d9bf0', onAccent: '#ffffff' };
|
|
const dark = theme ? theme.mode === 'dark' : false;
|
|
const font = theme?.fonts?.dialog?.stack || theme?.font || 'system-ui, sans-serif';
|
|
const mono = theme?.fonts?.mono?.stack || 'ui-monospace, "Cascadia Mono", Consolas, monospace';
|
|
const mix = (color, pct, base = 'transparent') => `color-mix(in srgb, ${color} ${pct}%, ${base})`;
|
|
const vars = {
|
|
'--ep-bg': c.bg, '--ep-border': c.border, '--ep-text': c.text, '--ep-muted': c.muted, '--ep-accent': c.accent,
|
|
'--ep-font': font, '--ep-mono': mono,
|
|
// The rail and the preview bar: one step off the card, like Discord's darker sidebar.
|
|
'--ep-rail': dark ? c.surface : mix(c.text, 3.5, c.bg),
|
|
'--ep-input': dark ? c.elevated : mix(c.text, 5, c.bg),
|
|
'--ep-input-line': dark ? 'transparent' : mix(c.text, 6, 'transparent'),
|
|
// The hover tile: an opaque surface step, crisp on any page background.
|
|
'--ep-tile': dark ? mix(c.text, 12, c.bg) : mix(c.text, 8, c.bg),
|
|
'--ep-hover': mix(c.text, dark ? 10 : 7),
|
|
'--ep-sel': mix(c.accent, dark ? 20 : 13),
|
|
'--ep-thumb': mix(c.text, dark ? 28 : 22),
|
|
'--ep-shadow': dark
|
|
? '0 0 0 1px rgba(0,0,0,.5), 0 8px 24px rgba(0,0,0,.55), 0 0 16px rgba(255,255,255,.07)'
|
|
: '0 0 0 1px rgba(15,20,25,.04), 0 8px 28px rgba(15,20,25,.18), 0 2px 6px rgba(15,20,25,.08)',
|
|
'--ep-shadow-sm': dark ? '0 6px 16px rgba(0,0,0,.6)' : '0 6px 16px rgba(15,20,25,.16)',
|
|
};
|
|
for (const name of Object.keys(vars)) el.style.setProperty(name, vars[name]);
|
|
el.style.setProperty('color-scheme', dark ? 'dark' : 'light');
|
|
}
|
|
|
|
repaint() {
|
|
if (this.picker) this.paint(this.picker.host);
|
|
if (this.autocomplete) this.paint(this.autocomplete.list);
|
|
for (const button of document.querySelectorAll('.bt-ep-button')) this.matchXColor(button);
|
|
}
|
|
|
|
// ---- Caret / text reading ----
|
|
|
|
caretContext(target) {
|
|
if (target.tagName === 'TEXTAREA' || target.tagName === 'INPUT') {
|
|
const pos = target.selectionStart == null ? target.value.length : target.selectionStart;
|
|
return { plain: true, target, text: target.value.slice(0, pos), pos };
|
|
}
|
|
const root = target.closest('[contenteditable="true"]');
|
|
if (!root) return null;
|
|
const sel = window.getSelection();
|
|
if (!sel || !sel.rangeCount || !sel.isCollapsed) return null;
|
|
const anchor = sel.anchorNode;
|
|
if (!root.contains(anchor)) return null;
|
|
let text = '';
|
|
try {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(root);
|
|
range.setEnd(anchor, sel.anchorOffset);
|
|
text = range.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
return { plain: false, target, root, anchor, anchorOffset: sel.anchorOffset, text };
|
|
}
|
|
|
|
caretRect(context) {
|
|
if (context.plain) {
|
|
// A plain field has no caret rect: open the list under the field, not over its text.
|
|
const rect = context.target.getBoundingClientRect();
|
|
return { left: rect.left, right: rect.left, top: rect.bottom, bottom: rect.bottom };
|
|
}
|
|
const sel = window.getSelection();
|
|
if (sel && sel.rangeCount) {
|
|
const rect = EmojiPicker.rangeRect(sel.getRangeAt(0));
|
|
if (rect) return rect;
|
|
}
|
|
const rect = context.root.getBoundingClientRect();
|
|
return { left: rect.left, right: rect.left, top: rect.top, bottom: rect.top };
|
|
}
|
|
|
|
/** The collapsed start of `range` on screen, or null when it has no box. */
|
|
static rangeRect(range) {
|
|
if (!range) return null;
|
|
try {
|
|
const probe = range.cloneRange();
|
|
probe.collapse(true);
|
|
const rects = probe.getClientRects();
|
|
if (rects.length) return rects[0];
|
|
const rect = probe.getBoundingClientRect();
|
|
if (rect && (rect.width || rect.height || rect.top)) return rect;
|
|
} catch { /* Detached. */ }
|
|
return null;
|
|
}
|
|
|
|
/** Puts the caret after the last text of a contenteditable editor and reads it; null when impossible. */
|
|
caretAtEnd(editor) {
|
|
const root = editor.closest && editor.closest('[contenteditable="true"]');
|
|
if (!root) return null;
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
let last = null;
|
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) last = node;
|
|
// No text yet: inside the innermost last element (Draft.js keeps a <br> in an empty block).
|
|
let holder = root;
|
|
while (!last && holder.lastElementChild && holder.lastElementChild.tagName !== 'BR') holder = holder.lastElementChild;
|
|
try {
|
|
const range = document.createRange();
|
|
if (last) range.setStart(last, last.length);
|
|
else range.setStart(holder, 0);
|
|
range.collapse(true);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
} catch {
|
|
return null;
|
|
}
|
|
return this.caretContext(editor);
|
|
}
|
|
|
|
// ---- Insertion (focus + execCommand insertText, like the rest of the app) ----
|
|
|
|
replaceBack(context, backChars, replacement) {
|
|
if (context.plain) {
|
|
const target = context.target;
|
|
const end = context.pos;
|
|
const start = Math.max(0, end - backChars);
|
|
target.focus();
|
|
target.setSelectionRange(start, end);
|
|
if (!document.execCommand('insertText', false, replacement)) {
|
|
const value = target.value;
|
|
target.value = value.slice(0, start) + replacement + value.slice(end);
|
|
const at = start + replacement.length;
|
|
target.setSelectionRange(at, at);
|
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
return;
|
|
}
|
|
const walker = document.createTreeWalker(context.root, NodeFilter.SHOW_TEXT);
|
|
const nodes = [];
|
|
let node = walker.nextNode();
|
|
while (node) { nodes.push(node); node = walker.nextNode(); }
|
|
let idx = nodes.indexOf(context.anchor);
|
|
if (idx === -1 && backChars > 0) return;
|
|
let remaining = backChars;
|
|
let atNode = context.anchor;
|
|
let atOffset = context.anchorOffset;
|
|
while (remaining > 0) {
|
|
if (atOffset >= remaining) { atOffset -= remaining; remaining = 0; break; }
|
|
remaining -= atOffset;
|
|
idx -= 1;
|
|
if (idx < 0) { atOffset = 0; break; }
|
|
atNode = nodes[idx];
|
|
atOffset = atNode.length;
|
|
}
|
|
const range = document.createRange();
|
|
range.setStart(atNode, atOffset);
|
|
range.setEnd(context.anchor, context.anchorOffset);
|
|
const sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
context.root.focus();
|
|
if (!document.execCommand('insertText', false, replacement)) {
|
|
range.deleteContents();
|
|
range.insertNode(document.createTextNode(replacement));
|
|
}
|
|
}
|
|
|
|
insertEntry(entry, context, backChars) {
|
|
const emoji = EmojiPicker.withSkinTone(entry.emoji, entry.skin ? this.tone : 0);
|
|
this.replaceBack(context, backChars == null ? 0 : backChars, emoji);
|
|
this.recordUsage(entry);
|
|
}
|
|
|
|
// ---- Inline autocomplete ----
|
|
|
|
onInput(event) {
|
|
if (event.isComposing) return;
|
|
const target = event.target;
|
|
if (!btEpIsEditable(target) || this.nativeEditors.has(target)) return;
|
|
const context = this.caretContext(target);
|
|
if (!context) { this.closeAutocomplete(); return; }
|
|
const exact = EmojiPicker.parseExact(context.text);
|
|
if (exact && EmojiPicker.findExact(exact.code)) {
|
|
this.closeAutocomplete();
|
|
// Let X's editor (Draft.js/React) handle this input first, then swap on a fresh caret read.
|
|
requestAnimationFrame(() => {
|
|
if (this.stopped) return;
|
|
const now = this.caretContext(target);
|
|
const still = now && EmojiPicker.parseExact(now.text);
|
|
if (still && still.code === exact.code) this.insertEntry(EmojiPicker.findExact(still.code), now, still.end - still.start);
|
|
});
|
|
return;
|
|
}
|
|
const trigger = EmojiPicker.parseTrigger(context.text);
|
|
if (!trigger) { this.closeAutocomplete(); return; }
|
|
this.openAutocomplete(target, context, trigger);
|
|
}
|
|
|
|
openAutocomplete(target, context, trigger) {
|
|
const items = EmojiPicker.search(trigger.query, 8);
|
|
if (!items.length) { this.closeAutocomplete(); return; }
|
|
if (!this.autocomplete) {
|
|
const list = btEpEl('div', 'bt-ep-ac', { role: 'listbox', 'aria-label': t('suggestions') });
|
|
list.id = 'bt-ep-auto-' + Math.random().toString(36).slice(2);
|
|
// Choosing on mousedown keeps focus (and the caret) in the editor.
|
|
list.addEventListener('mousedown', event => {
|
|
event.preventDefault();
|
|
const option = event.target.closest && event.target.closest('.bt-ep-ac-opt');
|
|
if (option) this.chooseAutocomplete(Number(option.dataset.index));
|
|
});
|
|
list.addEventListener('pointermove', event => {
|
|
const option = event.target.closest && event.target.closest('.bt-ep-ac-opt');
|
|
if (option) this.selectAutocomplete(Number(option.dataset.index));
|
|
});
|
|
this.autocomplete = { target, list, items: [], options: [], active: 0, start: 0 };
|
|
}
|
|
const ac = this.autocomplete;
|
|
this.paint(ac.list);
|
|
ac.target = target;
|
|
ac.items = items;
|
|
ac.active = 0;
|
|
ac.start = trigger.start;
|
|
const query = trigger.query.toLowerCase();
|
|
const head = btEpEl('div', 'bt-ep-ac-head', { 'aria-hidden': 'true' }, t('matching') + ' ');
|
|
head.appendChild(btEpEl('b', null, null, ':' + trigger.query));
|
|
ac.options = items.map((entry, index) => {
|
|
const option = btEpEl('div', 'bt-ep-ac-opt', { role: 'option', 'aria-selected': index === 0 ? 'true' : 'false' });
|
|
option.id = ac.list.id + '-' + index;
|
|
option.dataset.index = String(index);
|
|
option.setAttribute('aria-label', EmojiPicker.displayName(entry));
|
|
const glyph = btEpEl('span', 'bt-ep-ac-glyph');
|
|
glyph.appendChild(btEpGlyph(entry, this.tone, 'bt-ep-ac-flag'));
|
|
const code = btEpEl('span', 'bt-ep-ac-code');
|
|
const name = entry.names.find(n => n.toLowerCase().startsWith(query)) || entry.names[0];
|
|
code.append(':');
|
|
if (name.toLowerCase().startsWith(query)) {
|
|
code.appendChild(btEpEl('mark', null, null, name.slice(0, query.length)));
|
|
code.append(name.slice(query.length) + ':');
|
|
} else code.append(name + ':');
|
|
const key = btEpEl('span', 'bt-ep-ac-key', { 'aria-hidden': 'true' });
|
|
key.appendChild(btEpIcon('enter'));
|
|
option.append(glyph, code, key);
|
|
return option;
|
|
});
|
|
ac.list.replaceChildren(head, ...ac.options);
|
|
// Inside X's modal composer when there is one (content outside an aria-modal dialog is hidden
|
|
// from screen readers).
|
|
const container = (target.closest && target.closest('[aria-modal="true"]')) || document.body;
|
|
if (ac.list.parentNode !== container) container.appendChild(ac.list);
|
|
btEpTopLayer(ac.list);
|
|
let origin = { left: 0, top: 0 };
|
|
if (container !== document.body) {
|
|
// A transformed dialog is the containing block of a fixed element: measure where 0,0 lands.
|
|
ac.list.style.left = '0px';
|
|
ac.list.style.top = '0px';
|
|
const at = ac.list.getBoundingClientRect();
|
|
origin = { left: at.left, top: at.top };
|
|
}
|
|
const rect = this.caretRect(context);
|
|
const height = 36 + items.length * 36 + 8 + 2;
|
|
const place = EmojiPicker.placeCard({ left: rect.left - 20, right: rect.right, top: rect.top, bottom: rect.bottom }, null,
|
|
BT_EP_AC_W, height, window.innerWidth, window.innerHeight, 120, 8, 6);
|
|
ac.list.style.left = (place.left - origin.left) + 'px';
|
|
ac.list.style.top = (place.top - origin.top) + 'px';
|
|
ac.list.style.width = place.width + 'px';
|
|
ac.list.style.maxHeight = place.height + 'px';
|
|
ac.list.style.setProperty('--ep-dy', place.side === 'above' ? '4px' : '-4px');
|
|
if (btEpIsEditable(target) && target.closest) {
|
|
target.setAttribute('aria-activedescendant', ac.list.id + '-0');
|
|
}
|
|
// X's post composer has its own `:emoji` typeahead: where it shows up, step aside for good.
|
|
setTimeout(() => {
|
|
const native = document.querySelector('[id^="typeaheadDropdown"]');
|
|
if (native && native.getBoundingClientRect().height > 0 && this.autocomplete && this.autocomplete.target === target) {
|
|
this.nativeEditors.add(target);
|
|
this.closeAutocomplete();
|
|
}
|
|
}, 180);
|
|
}
|
|
|
|
selectAutocomplete(index) {
|
|
const ac = this.autocomplete;
|
|
if (!ac || !ac.options[index] || index === ac.active) return;
|
|
ac.options[ac.active]?.setAttribute('aria-selected', 'false');
|
|
ac.active = index;
|
|
const current = ac.options[index];
|
|
current.setAttribute('aria-selected', 'true');
|
|
current.scrollIntoView({ block: 'nearest' });
|
|
if (ac.target.setAttribute) ac.target.setAttribute('aria-activedescendant', current.id);
|
|
}
|
|
|
|
moveAutocomplete(delta) {
|
|
const ac = this.autocomplete;
|
|
if (!ac || !ac.items.length) return;
|
|
this.selectAutocomplete((ac.active + delta + ac.items.length) % ac.items.length);
|
|
}
|
|
|
|
/** Replaces the `:trigger` with option `index`; false when the caret left the trigger (nothing inserted). */
|
|
chooseAutocomplete(index) {
|
|
const ac = this.autocomplete;
|
|
if (!ac || !ac.items[index]) return false;
|
|
const target = ac.target;
|
|
const context = this.caretContext(target);
|
|
this.closeAutocomplete();
|
|
if (!context) return false;
|
|
// Re-read the trigger at the caret now: it may have moved since the list opened.
|
|
const length = EmojiPicker.replaceLength(context.text, ac.start);
|
|
if (length == null) return false;
|
|
this.insertEntry(ac.items[index], context, length);
|
|
return true;
|
|
}
|
|
|
|
/** Closes the list once the caret leaves the `:trigger` it was opened for. */
|
|
onSelectionChange() {
|
|
const ac = this.autocomplete;
|
|
if (!ac) return;
|
|
const context = this.caretContext(ac.target);
|
|
if (!context || EmojiPicker.replaceLength(context.text, ac.start) == null) this.closeAutocomplete();
|
|
}
|
|
|
|
closeAutocomplete() {
|
|
const ac = this.autocomplete;
|
|
if (!ac) return;
|
|
ac.list.remove();
|
|
// X's own typeahead uses this attribute too: only clear it when it points at our list.
|
|
const current = ac.target && ac.target.getAttribute ? ac.target.getAttribute('aria-activedescendant') : null;
|
|
if (current && current.startsWith(ac.list.id)) ac.target.removeAttribute('aria-activedescendant');
|
|
this.autocomplete = null;
|
|
}
|
|
|
|
// ---- Global shortcut, X's emoji button, our toolbar button ----
|
|
|
|
onKeyDown(event) {
|
|
// Keys that commit an IME composition belong to the IME.
|
|
if (event.isComposing || event.keyCode === 229) return;
|
|
if (this.autocomplete) {
|
|
const ac = this.autocomplete;
|
|
// Only keys typed in the editor the list belongs to, without modifiers (Ctrl+Enter still posts).
|
|
const inEditor = event.target === ac.target || (ac.target.contains && ac.target.contains(event.target));
|
|
const plain = !event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey;
|
|
const listKey = ['Escape', 'ArrowDown', 'ArrowUp', 'Enter', 'Tab'].includes(event.key);
|
|
if (['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key) || (listKey && (!inEditor || !plain))) {
|
|
this.closeAutocomplete();
|
|
return;
|
|
}
|
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); this.closeAutocomplete(); return; }
|
|
if (event.key === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); this.moveAutocomplete(1); return; }
|
|
if (event.key === 'ArrowUp') { event.preventDefault(); event.stopPropagation(); this.moveAutocomplete(-1); return; }
|
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
// The key is taken only when an emoji actually went in.
|
|
if (this.chooseAutocomplete(ac.active)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (this.picker && !this.picker.host.isConnected) this.closePicker({ immediate: true });
|
|
if (this.picker) {
|
|
// Keys typed inside the picker are handled (and kept from X) by the picker itself.
|
|
if (event.composedPath().includes(this.picker.host)) return;
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.closePicker({ restoreFocus: true });
|
|
return;
|
|
}
|
|
}
|
|
if (!event.ctrlKey || !event.shiftKey || event.altKey || event.metaKey) return;
|
|
// event.code works on every keyboard layout; the key is only taken when a picker opens or closes.
|
|
if (event.code !== 'KeyE') return;
|
|
if (!this.togglePicker()) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
onPointerDown(event) {
|
|
const path = event.composedPath ? event.composedPath() : [event.target];
|
|
if (this.autocomplete && !path.includes(this.autocomplete.list) && event.target !== this.autocomplete.target) {
|
|
this.closeAutocomplete();
|
|
}
|
|
const p = this.picker;
|
|
if (p && !path.includes(p.host) && !(p.anchor !== p.editor && path.includes(p.anchor))) this.closePicker();
|
|
}
|
|
|
|
/** X's emoji button, when it opens this picker: keep focus (and the caret) in the editor. */
|
|
onXButtonMouseDown(event) {
|
|
if (!this.replaceX) return;
|
|
const button = event.target.closest && event.target.closest('button, [role="button"]');
|
|
if (button && EmojiPicker.isXEmojiButton(button)) event.preventDefault();
|
|
}
|
|
|
|
/** X's emoji button opens this picker instead of X's (setting, on by default). */
|
|
onXButtonClick(event) {
|
|
if (!this.replaceX) return;
|
|
const button = event.target.closest && event.target.closest('button, [role="button"]');
|
|
if (!button || !EmojiPicker.isXEmojiButton(button)) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
this.toggleFromButton(button, button.closest('[data-testid="toolBar"]'));
|
|
}
|
|
|
|
toggleFromButton(button, toolbar) {
|
|
if (this.picker && this.picker.anchor === button) { this.closePicker({ restoreFocus: true }); return; }
|
|
const editor = this.editorNear(toolbar);
|
|
if (editor) this.openPicker(button, editor);
|
|
}
|
|
|
|
/** X's emoji button in this toolbar row, if it has one. */
|
|
xEmojiButton(toolbar) {
|
|
for (const el of toolbar.querySelectorAll('button[aria-label], [role="button"][aria-label]')) {
|
|
if (EmojiPicker.isXEmojiButton(el)) return el;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Our button takes the color X gives its own toolbar icons (the theme's accent, or X's). */
|
|
matchXColor(button) {
|
|
const row = button.closest('[role="tablist"]') || button.parentElement?.parentElement;
|
|
const icon = row && [...row.querySelectorAll('svg')].find(svg => !button.contains(svg));
|
|
if (!icon) return;
|
|
const color = getComputedStyle(icon).color;
|
|
if (color) button.style.color = color;
|
|
}
|
|
|
|
mountButton(toolbar) {
|
|
const xButton = this.xEmojiButton(toolbar);
|
|
const existing = toolbar.querySelector('.bt-ep-slot');
|
|
// One picker, one button: when X's button opens ours, ours is not added next to it.
|
|
if (this.replaceX && xButton) { existing?.remove(); return; }
|
|
if (existing) return;
|
|
const row = toolbar.querySelector('[role="tablist"]') || toolbar.firstElementChild || toolbar;
|
|
const slot = btEpEl('div', 'bt-ep-slot', { role: 'presentation' });
|
|
const button = btEpEl('button', 'bt-ep-button', { type: 'button', title: t('buttonTitle'), 'aria-label': t('picker'), 'aria-haspopup': 'dialog', 'aria-expanded': 'false' });
|
|
button.appendChild(btEpIcon('smileys'));
|
|
button.addEventListener('mousedown', event => event.preventDefault());
|
|
button.addEventListener('click', event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.toggleFromButton(button, toolbar);
|
|
});
|
|
slot.appendChild(button);
|
|
// Right after X's own emoji button (its presentation wrapper), else at the end of the row.
|
|
let after = xButton;
|
|
while (after && after.parentElement && after.parentElement !== row) after = after.parentElement;
|
|
if (after && after.parentElement === row) after.after(slot);
|
|
else row.appendChild(slot);
|
|
this.matchXColor(button);
|
|
// Removed on stop(), like everything else this plugin adds.
|
|
this.scope.add(slot);
|
|
}
|
|
|
|
remountButtons() {
|
|
for (const slot of document.querySelectorAll('.bt-ep-slot')) slot.remove();
|
|
for (const toolbar of document.querySelectorAll('[data-testid="toolBar"]')) this.mountButton(toolbar);
|
|
}
|
|
|
|
setReplaceX(on) {
|
|
this.replaceX = !!on;
|
|
this.store.set('replaceX', this.replaceX);
|
|
this.remountButtons();
|
|
}
|
|
|
|
/** The picker's settings (the gear in the preview bar), in the app's themed dialog. */
|
|
openSettings() {
|
|
const ui = this.api.ui;
|
|
if (!ui || typeof ui.dialog !== 'function' || typeof ui.h !== 'function') return;
|
|
this.closePicker({ restoreFocus: true, immediate: true });
|
|
const h = ui.h;
|
|
const replace = h('input', { type: 'checkbox', checked: this.replaceX });
|
|
this.settings = ui.dialog({
|
|
title: t('title'),
|
|
content: body => body.append(
|
|
h('label', { class: 'wb-check' }, replace, h('span', null, t('replaceX'),
|
|
h('small', null, t('replaceXHint')))),
|
|
),
|
|
actions: [
|
|
{ label: t('cancel') },
|
|
{ label: t('save'), tone: 'primary', default: true, onClick: () => { this.setReplaceX(replace.checked); } },
|
|
],
|
|
onClose: () => { this.settings = null; },
|
|
});
|
|
}
|
|
|
|
/** The single composer/reply/DM text box that shares this toolbar's compose area, if exactly one. */
|
|
editorNear(toolbar) {
|
|
let area = toolbar && toolbar.parentElement;
|
|
for (let depth = 0; area && depth < 40; depth += 1, area = area.parentElement) {
|
|
const editors = area.querySelectorAll(BT_EP_EDITABLE);
|
|
if (editors.length === 1) return editors[0];
|
|
// Several (a thread being written): the one that has the caret.
|
|
if (editors.length > 1) {
|
|
const anchor = window.getSelection()?.anchorNode;
|
|
return anchor ? Array.from(editors).find(editor => editor.contains(anchor)) || null : null;
|
|
}
|
|
if (area.matches && area.matches('main,body,[data-testid="primaryColumn"]')) return null;
|
|
}
|
|
return document.activeElement && btEpIsEditable(document.activeElement) ? document.activeElement : null;
|
|
}
|
|
|
|
/** The emoji button (X's or ours) of the toolbar that belongs to `editor`, so Ctrl+Shift+E opens in the same place. */
|
|
buttonFor(editor) {
|
|
let area = editor.parentElement;
|
|
for (let depth = 0; area && depth < 24; depth += 1, area = area.parentElement) {
|
|
const toolbars = area.querySelectorAll('[data-testid="toolBar"]');
|
|
if (toolbars.length) {
|
|
if (toolbars.length !== 1 || this.editorNear(toolbars[0]) !== editor) return null;
|
|
return toolbars[0].querySelector('.bt-ep-button') || this.xEmojiButton(toolbars[0]);
|
|
}
|
|
if (area.matches && area.matches('main,body')) return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Opens or closes the picker; false when there was nothing to do (no picker, no editor). */
|
|
togglePicker() {
|
|
if (this.picker) { this.closePicker({ restoreFocus: true }); return true; }
|
|
const active = document.activeElement;
|
|
const editor = btEpIsEditable(active) ? active : this.editorNear(document.querySelector('[data-testid="toolBar"]') || document.body);
|
|
if (!editor) return false;
|
|
const button = this.buttonFor(editor);
|
|
this.openPicker(button || editor, editor);
|
|
return true;
|
|
}
|
|
|
|
// ---- Full picker ----
|
|
|
|
/** Sections for the list: search results, or Frequently used (when there is history) + categories. */
|
|
buildSections(query, cols = BT_EP_COLS) {
|
|
if (query) return [{ id: 'search', title: t('results'), icon: 'search', entries: EmojiPicker.search(query) }];
|
|
const sections = [];
|
|
const frequent = this.frequentEntries(cols * 2);
|
|
if (frequent.length) sections.push({ id: 'frequent', title: t('frequent'), icon: 'frequent', entries: frequent });
|
|
EMOJI_DATA.categories.forEach((title, i) => sections.push({ id: 'cat' + i, title: t('cat' + i), icon: BT_EP_CAT_ICONS[i], entries: btEpByCat()[i] }));
|
|
return sections;
|
|
}
|
|
|
|
/**
|
|
* One hidden open and close once the glyphs are warm, so the first real open finds the card's
|
|
* styles, fonts and icons ready too. Skipped while anything of ours is on screen.
|
|
*/
|
|
/**
|
|
* The card's styles for `shadow`. Constructed sheets, built once and adopted by every picker this
|
|
* page opens: parsed once instead of per open, not subject to X's CSP, and they stay with the
|
|
* card wherever its host goes (into X's modal, the top layer). The card CSS, then the atlas rules
|
|
* (one per sheet, shared, grown as sheets arrive). Without constructable sheets: <style>
|
|
* elements (`nodes`) with their own copy of the atlas rules.
|
|
*/
|
|
cardStyles(shadow) {
|
|
if (this.sheets === undefined) {
|
|
this.sheets = null;
|
|
try {
|
|
const card = new CSSStyleSheet();
|
|
card.replaceSync(BT_EP_CARD_CSS);
|
|
this.sheets = { card, atlas: new CSSStyleSheet(), ids: new Set() };
|
|
} catch { /* No constructable sheets: <style> elements below. */ }
|
|
}
|
|
const atlasRules = () => [...this.atlas.sheets()].filter(([id]) => !this.sheets || !this.sheets.ids.has(id));
|
|
if (this.sheets) {
|
|
const shared = this.sheets;
|
|
try {
|
|
for (const [id, url] of atlasRules()) { shared.atlas.insertRule(btEpSheetRule(id, url), shared.atlas.cssRules.length); shared.ids.add(id); }
|
|
shadow.adoptedStyleSheets = [shared.card, shared.atlas];
|
|
return { nodes: [], atlasSheet: shared.atlas, atlasStyle: null, sheets: shared.ids };
|
|
} catch { /* Refused (another realm): <style> elements. */ }
|
|
}
|
|
const card = document.createElement('style');
|
|
card.textContent = BT_EP_CARD_CSS;
|
|
const atlas = document.createElement('style');
|
|
const rules = [...this.atlas.sheets()];
|
|
atlas.textContent = rules.map(([id, url]) => btEpSheetRule(id, url)).join(' ');
|
|
// A <style> element has its sheet once connected: cells read it when they are painted.
|
|
return { nodes: [card, atlas], atlasSheet: null, atlasStyle: atlas, sheets: new Set(rules.map(([id]) => id)) };
|
|
}
|
|
|
|
prime() {
|
|
if (this.picker || this.autocomplete || !document.body) return;
|
|
try {
|
|
this.openPicker(document.body, document.body, { prime: true });
|
|
} finally {
|
|
this.closePicker({ immediate: true });
|
|
}
|
|
}
|
|
|
|
openPicker(anchor, editor, opts = {}) {
|
|
this.closePicker({ immediate: true });
|
|
this.closeAutocomplete();
|
|
// The search box takes focus, which moves the selection out of a contenteditable editor:
|
|
// remember where the caret was so a click can put the emoji there.
|
|
let savedRange = null;
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount && editor.contains(selection.anchorNode)) savedRange = selection.getRangeAt(0).cloneRange();
|
|
|
|
const uid = 'ep' + Math.random().toString(36).slice(2, 8);
|
|
const host = document.createElement('wb-emoji-picker');
|
|
host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483640;pointer-events:none;display:block;';
|
|
const shadow = host.attachShadow({ mode: 'open' });
|
|
const styles = this.cardStyles(shadow);
|
|
const card = btEpEl('div', 'ep-card', { role: 'dialog', 'aria-label': t('picker') });
|
|
|
|
// Header: search + skin tone.
|
|
const head = btEpEl('div', 'ep-head');
|
|
const searchBox = btEpEl('label', 'ep-search');
|
|
const search = btEpEl('input', 'ep-search-input', { type: 'text', placeholder: t('search'), 'aria-label': t('search'), autocomplete: 'off', spellcheck: 'false' });
|
|
search.setAttribute('aria-controls', uid + '-grid');
|
|
const searchIcon = btEpEl('span', 'ep-search-icon', { 'aria-hidden': 'true' });
|
|
searchIcon.appendChild(btEpIcon('search'));
|
|
const clear = btEpEl('button', 'ep-clear', { type: 'button', 'aria-label': t('clearSearch'), title: t('clear'), tabindex: '-1' });
|
|
clear.appendChild(btEpIcon('clear'));
|
|
clear.hidden = true;
|
|
searchBox.append(search, searchIcon, clear);
|
|
const toneBtn = btEpEl('button', 'ep-tone-btn', { type: 'button', 'aria-haspopup': 'true', 'aria-expanded': 'false' });
|
|
const tones = btEpEl('div', 'ep-tones', { role: 'radiogroup', 'aria-label': t('skinTone') });
|
|
tones.hidden = true;
|
|
const toneButtons = BT_EP_TONE_NAMES.map((english, tone) => {
|
|
const name = t('tone' + tone);
|
|
const button = btEpEl('button', 'ep-tone', { type: 'button', role: 'radio', 'aria-label': name, title: name, tabindex: '-1' },
|
|
EmojiPicker.withSkinTone(BT_EP_TONE_BASE, tone));
|
|
button.addEventListener('click', () => this.pickTone(tone));
|
|
tones.appendChild(button);
|
|
return button;
|
|
});
|
|
head.append(searchBox, toneBtn, tones);
|
|
|
|
// Category rail (left).
|
|
const rail = btEpEl('div', 'ep-rail', { role: 'toolbar', 'aria-label': t('categories'), 'aria-orientation': 'vertical' });
|
|
|
|
// The grid: one virtualized scroller, a sticky header over it, an empty state.
|
|
const body = btEpEl('div', 'ep-body');
|
|
const sticky = btEpEl('div', 'ep-sticky', { 'aria-hidden': 'true' });
|
|
const stickyIcon = btEpEl('span');
|
|
const stickyTitle = btEpEl('span', 'ep-hdr-title');
|
|
const stickyCount = btEpEl('span', 'ep-count');
|
|
sticky.append(stickyIcon, stickyTitle, stickyCount);
|
|
const scroll = btEpEl('div', 'ep-scroll', { role: 'grid', tabindex: '0', 'aria-label': t('grid') });
|
|
scroll.id = uid + '-grid';
|
|
const sizer = btEpEl('div', 'ep-sizer');
|
|
scroll.appendChild(sizer);
|
|
const empty = btEpEl('div', 'ep-empty', { role: 'status' });
|
|
empty.hidden = true;
|
|
const emptyTitle = btEpEl('strong');
|
|
empty.append(btEpIcon('empty'), emptyTitle, btEpEl('span', null, null, t('emptyHint')));
|
|
body.append(scroll, sticky, empty);
|
|
|
|
// Preview bar.
|
|
const foot = btEpEl('div', 'ep-foot ep-idle');
|
|
const previewGlyph = btEpEl('div', 'ep-preview-glyph', { 'aria-hidden': 'true' });
|
|
const previewText = btEpEl('div', 'ep-preview-text');
|
|
const previewName = btEpEl('span', 'ep-preview-code');
|
|
const previewCode = btEpEl('span', 'ep-preview-alt');
|
|
previewText.append(previewName, previewCode);
|
|
const gear = btEpEl('button', 'ep-gear', { type: 'button', 'aria-label': t('settingsLabel'), title: t('settings'), tabindex: '-1' });
|
|
gear.appendChild(btEpIcon('gear'));
|
|
gear.addEventListener('click', () => this.openSettings());
|
|
foot.append(previewGlyph, previewText, gear);
|
|
const live = btEpEl('div', 'ep-sr', { 'aria-live': 'polite', 'aria-atomic': 'true' });
|
|
|
|
card.append(live, head, rail, body, foot);
|
|
shadow.append(...styles.nodes, card);
|
|
|
|
const p = {
|
|
uid, host, shadow, card, search, searchIcon, clear, toneBtn, tones, toneButtons, rail, railButtons: [],
|
|
scroll, sizer, sticky, stickyIcon, stickyTitle, stickyCount, empty, emptyTitle, foot, previewGlyph, previewName, previewCode, live,
|
|
anchor, editor, savedRange, sections: [], counts: [], layout: null, rendered: new Map(), active: null, query: '',
|
|
viewport: 300, cols: BT_EP_COLS, pad: BT_EP_PAD_L, raf: 0, stickySection: -1, railId: null,
|
|
atlasSheet: styles.atlasSheet, atlasStyle: styles.atlasStyle, sheets: styles.sheets,
|
|
onSearch: null, missing: null, offAtlas: null, atlasRaf: 0, positionRaf: 0, side: null,
|
|
};
|
|
this.picker = p;
|
|
|
|
// Rail: Frequently used (when there is history), a divider, the nine categories.
|
|
for (const section of this.buildSections('')) {
|
|
const button = btEpEl('button', 'ep-rail-btn', { type: 'button', 'aria-label': section.title, title: section.title, tabindex: '-1' });
|
|
button.dataset.id = section.id;
|
|
button.appendChild(btEpIcon(section.icon));
|
|
button.addEventListener('click', () => this.jumpTo(section.id));
|
|
rail.appendChild(button);
|
|
p.railButtons.push(button);
|
|
if (section.id === 'frequent') rail.appendChild(btEpEl('span', 'ep-rail-sep', { 'aria-hidden': 'true' }));
|
|
}
|
|
this.updateToneButton();
|
|
|
|
const onSearch = () => { const has = !!search.value; clear.hidden = !has; searchIcon.hidden = has; this.setSections(search.value.trim()); };
|
|
p.onSearch = onSearch;
|
|
search.addEventListener('input', onSearch);
|
|
clear.addEventListener('click', () => { search.value = ''; onSearch(); search.focus(); });
|
|
toneBtn.addEventListener('click', () => (tones.hidden ? this.openTones() : this.closeTones(true)));
|
|
card.addEventListener('keydown', event => this.onPickerKey(event));
|
|
// keyup/keypress stay inside too: X's shortcuts see the host, not an editable field.
|
|
for (const type of ['keyup', 'keypress']) card.addEventListener(type, event => event.stopPropagation());
|
|
// Clicks never move focus out of the search box (except into it or the tone popover).
|
|
card.addEventListener('mousedown', event => {
|
|
const target = event.composedPath()[0];
|
|
if (target !== search && !(target.closest && target.closest('.ep-tones, .ep-tone-btn'))) event.preventDefault();
|
|
});
|
|
card.addEventListener('pointerdown', event => {
|
|
const target = event.composedPath()[0];
|
|
if (!tones.hidden && !(target.closest && target.closest('.ep-tones, .ep-tone-btn'))) this.closeTones(false);
|
|
});
|
|
scroll.addEventListener('scroll', () => {
|
|
if (!p.raf) p.raf = requestAnimationFrame(() => { p.raf = 0; if (this.picker === p) this.renderWindow(); });
|
|
}, { passive: true });
|
|
scroll.addEventListener('pointermove', event => {
|
|
p.scroll.classList.remove('ep-kbd');
|
|
const cell = event.target.closest && event.target.closest('.ep-cell');
|
|
if (!cell) return;
|
|
const pos = { section: Number(cell.dataset.s), index: Number(cell.dataset.i) };
|
|
if (!p.active || p.active.section !== pos.section || p.active.index !== pos.index) this.setActive(pos);
|
|
});
|
|
scroll.addEventListener('click', event => {
|
|
const cell = event.target.closest && event.target.closest('.ep-cell');
|
|
if (!cell) return;
|
|
const entry = p.sections[Number(cell.dataset.s)]?.entries[Number(cell.dataset.i)];
|
|
if (entry) this.chooseFromPicker(entry, event.shiftKey);
|
|
});
|
|
scroll.addEventListener('focus', () => {
|
|
if (!p.active) this.setActive(this.firstVisiblePos(), { scroll: true });
|
|
});
|
|
|
|
this.paint(host);
|
|
if (opts.prime) host.style.visibility = 'hidden';
|
|
// Inside X's modal composer, so X's focus trap keeps the search box focused.
|
|
(editor.closest('[aria-modal="true"]') || document.body).appendChild(host);
|
|
btEpTopLayer(host);
|
|
this.hosts.add(host);
|
|
if (!opts.prime && anchor.classList && anchor.classList.contains('bt-ep-button')) anchor.setAttribute('aria-expanded', 'true');
|
|
this.positionPicker();
|
|
if (this.picker !== p) return;
|
|
this.measureGrid();
|
|
this.setSections('');
|
|
if (opts.prime) { void card.offsetHeight; return; }
|
|
// Cells paint from the atlas as its sheets arrive (at most once a frame); everything else is queued behind the rows in view.
|
|
p.offAtlas = this.atlas.onReady(() => {
|
|
if (p.atlasRaf || this.picker !== p) return;
|
|
p.atlasRaf = requestAnimationFrame(() => { p.atlasRaf = 0; if (this.picker === p) this.paintFromAtlas(); });
|
|
});
|
|
// (After the first frame: listing ~1850 glyphs is not part of opening.)
|
|
setTimeout(() => { if (this.picker === p) this.atlas.request(p.sections.flatMap(section => section.entries.map(entry => btEpGlyphText(entry, this.tone)))); }, 50);
|
|
search.focus({ preventScroll: true });
|
|
}
|
|
|
|
/** Reads the grid's width once: how many columns fit and the side padding that centers them. Returns whether it changed. */
|
|
measureGrid() {
|
|
const p = this.picker;
|
|
p.viewport = p.scroll.clientHeight || 300;
|
|
const width = p.scroll.clientWidth || (BT_EP_CARD_W - 58);
|
|
const cols = EmojiPicker.columnsFor(width);
|
|
const pad = Math.max(BT_EP_PAD_L, Math.floor(BT_EP_PAD_L + (width - BT_EP_PAD_L - BT_EP_PAD_R - cols * BT_EP_CELL) / 2));
|
|
p.sticky.style.right = Math.max(0, p.scroll.offsetWidth - p.scroll.clientWidth) + 'px';
|
|
p.card.style.setProperty('--ep-hdr-pad', (pad + 4) + 'px');
|
|
const changed = cols !== p.cols || pad !== p.pad;
|
|
p.cols = cols;
|
|
p.pad = pad;
|
|
p.scroll.setAttribute('aria-colcount', String(cols));
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Places the card under the composer toolbar (or above the text box when there is no room below),
|
|
* lined up with the button that opened it; closes it when that button has scrolled out of view.
|
|
*/
|
|
positionPicker() {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
if (!p.host.isConnected || !p.anchor.isConnected || !p.editor.isConnected) { this.closePicker({ immediate: true }); return; }
|
|
const editorRect = p.editor.getBoundingClientRect();
|
|
let anchorRect;
|
|
let avoid;
|
|
if (p.anchor === p.editor) {
|
|
const caret = EmojiPicker.rangeRect(p.savedRange);
|
|
anchorRect = caret ? { left: caret.left - 16, right: caret.right, top: caret.top, bottom: caret.bottom } : editorRect;
|
|
// Keep a short editor fully visible; in a long one, keep at least the caret's line.
|
|
avoid = editorRect.height <= 160 || !caret ? editorRect : anchorRect;
|
|
} else {
|
|
anchorRect = p.anchor.getBoundingClientRect();
|
|
avoid = editorRect;
|
|
// Scrolled away (or hidden): the picker goes with it.
|
|
if (p.anchor !== document.body && (anchorRect.bottom <= 0 || anchorRect.top >= window.innerHeight || !anchorRect.width)) {
|
|
this.closePicker({ immediate: true });
|
|
return;
|
|
}
|
|
}
|
|
// The host is fixed at inset 0; inside a transformed ancestor its box is the containing block.
|
|
const origin = p.host.getBoundingClientRect();
|
|
const place = EmojiPicker.placeCard(anchorRect, avoid, BT_EP_CARD_W, BT_EP_CARD_H, window.innerWidth, window.innerHeight, BT_EP_CARD_MIN);
|
|
const s = p.card.style;
|
|
s.left = (place.left - origin.left) + 'px';
|
|
s.top = (place.top - origin.top) + 'px';
|
|
s.width = place.width + 'px';
|
|
s.height = place.height + 'px';
|
|
// Grows out of the button it belongs to.
|
|
const ox = Math.round(Math.max(0, Math.min(place.width, anchorRect.left + (anchorRect.right - anchorRect.left) / 2 - place.left)));
|
|
s.setProperty('--ep-origin', ox + 'px ' + (place.side === 'above' ? '100%' : '0'));
|
|
s.setProperty('--ep-dy', place.side === 'above' ? '4px' : '-4px');
|
|
p.side = place.side;
|
|
}
|
|
|
|
schedulePosition() {
|
|
const p = this.picker;
|
|
if (!p || p.positionRaf) return;
|
|
p.positionRaf = requestAnimationFrame(() => {
|
|
p.positionRaf = 0;
|
|
if (this.picker !== p) return;
|
|
const size = p.card.style.width + 'x' + p.card.style.height;
|
|
this.positionPicker();
|
|
if (this.picker !== p) return;
|
|
// Reading the grid right after writing the card's box forces a layout: only when that box changed.
|
|
if (size !== p.card.style.width + 'x' + p.card.style.height && this.measureGrid()) this.setSections(p.query);
|
|
else this.renderWindow();
|
|
});
|
|
}
|
|
|
|
setSections(query) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
p.query = query;
|
|
p.sections = this.buildSections(query, p.cols);
|
|
p.counts = p.sections.map(section => section.entries.length);
|
|
p.layout = EmojiPicker.layoutRows(p.counts.some(Boolean) ? p.counts : [], p.cols, BT_EP_ROW, BT_EP_HEAD);
|
|
p.sizer.style.height = p.layout.height + 8 + 'px';
|
|
p.scroll.setAttribute('aria-rowcount', String(p.layout.gridRows));
|
|
for (const row of p.rendered.values()) row.remove();
|
|
p.rendered.clear();
|
|
p.active = null;
|
|
p.stickySection = -1;
|
|
p.scroll.scrollTop = 0;
|
|
const total = p.counts.reduce((a, b) => a + b, 0);
|
|
p.empty.hidden = total > 0;
|
|
p.sticky.hidden = total === 0;
|
|
if (!total) p.emptyTitle.textContent = t('noMatch', { query });
|
|
this.renderWindow();
|
|
if (query && total) this.setActive({ section: 0, index: 0 }, { announce: t('resultCount', { count: total }) });
|
|
else {
|
|
this.setActive(null);
|
|
if (query) p.live.textContent = t('noResults');
|
|
}
|
|
}
|
|
|
|
/** Renders the rows in view (plus a screen of overscan) and nothing else. */
|
|
renderWindow() {
|
|
const p = this.picker;
|
|
if (!p || !p.layout) return;
|
|
const top = p.scroll.scrollTop;
|
|
const { start, end } = EmojiPicker.visibleRange(p.layout.rows, top, p.viewport, 160);
|
|
for (const [index, row] of p.rendered) {
|
|
if (index < start || index >= end) { row.remove(); p.rendered.delete(index); }
|
|
}
|
|
p.missing = [];
|
|
for (let index = start; index < end; index += 1) {
|
|
if (p.rendered.has(index)) continue;
|
|
const row = this.buildRow(index);
|
|
p.rendered.set(index, row);
|
|
p.sizer.appendChild(row);
|
|
}
|
|
if (p.missing.length) this.atlas.request(p.missing, true);
|
|
// aria-activedescendant only ever names a cell that exists.
|
|
if (p.active) {
|
|
const id = p.uid + '-' + p.active.section + '-' + p.active.index;
|
|
if (p.shadow.getElementById(id)) p.scroll.setAttribute('aria-activedescendant', id);
|
|
else p.scroll.removeAttribute('aria-activedescendant');
|
|
}
|
|
this.updateSticky(top);
|
|
}
|
|
|
|
buildRow(index) {
|
|
const p = this.picker;
|
|
const row = p.layout.rows[index];
|
|
const section = p.sections[row.section];
|
|
if (row.kind === 'header') {
|
|
const header = btEpEl('div', 'ep-hdr', { role: 'presentation' });
|
|
header.append(btEpIcon(section.icon), btEpEl('span', 'ep-hdr-title', null, section.title));
|
|
if (section.id === 'search') header.append(btEpEl('span', 'ep-count', null, String(section.entries.length)));
|
|
header.style.transform = 'translateY(' + row.top + 'px)';
|
|
return header;
|
|
}
|
|
const el = btEpEl('div', 'ep-row', { role: 'row', 'aria-rowindex': String(row.index + 1) });
|
|
el.style.transform = 'translate(' + p.pad + 'px, ' + row.top + 'px)';
|
|
for (let i = row.start; i < row.end; i += 1) {
|
|
const entry = section.entries[i];
|
|
const selected = p.active && p.active.section === row.section && p.active.index === i;
|
|
const cell = btEpEl('div', 'ep-cell', {
|
|
role: 'gridcell', id: p.uid + '-' + row.section + '-' + i, 'aria-colindex': String(i - row.start + 1),
|
|
'aria-selected': selected ? 'true' : 'false', 'aria-label': EmojiPicker.displayName(entry),
|
|
});
|
|
cell.dataset.s = String(row.section);
|
|
cell.dataset.i = String(i);
|
|
const glyph = btEpGlyphText(entry, this.tone);
|
|
if (glyph) {
|
|
cell.appendChild(btEpEl('span', null, null, glyph));
|
|
cell.dataset.g = glyph;
|
|
if (!btEpPaintCell(cell, this.atlas, p)) p.missing?.push(glyph);
|
|
} else cell.appendChild(btEpGlyph(entry, this.tone, 'ep-flag'));
|
|
el.appendChild(cell);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
/** Cells in view whose sheet has arrived switch from the text glyph to the atlas image. */
|
|
paintFromAtlas() {
|
|
const p = this.picker;
|
|
for (const cell of p.sizer.querySelectorAll('.ep-cell[data-g]:not(.ep-bm)')) btEpPaintCell(cell, this.atlas, p);
|
|
}
|
|
|
|
updateSticky(top) {
|
|
const p = this.picker;
|
|
const s = EmojiPicker.sectionAt(p.layout.sectionTops, top);
|
|
const section = p.sections[s];
|
|
if (!section) return;
|
|
if (p.stickySection !== s) {
|
|
p.stickySection = s;
|
|
const icon = btEpIcon(section.icon);
|
|
p.stickyIcon.replaceWith(icon);
|
|
p.stickyIcon = icon;
|
|
p.stickyTitle.textContent = section.title;
|
|
p.stickyCount.textContent = section.id === 'search' ? String(section.entries.length) : '';
|
|
}
|
|
// The next section's header pushes this one up as it arrives.
|
|
const next = p.layout.sectionTops[s + 1];
|
|
const offset = next == null ? 0 : Math.min(0, next - top - BT_EP_HEAD);
|
|
p.sticky.style.transform = offset ? 'translateY(' + offset + 'px)' : '';
|
|
p.sticky.classList.toggle('ep-lifted', top > 1);
|
|
this.setRail(p.query ? null : section.id);
|
|
}
|
|
|
|
setRail(id) {
|
|
const p = this.picker;
|
|
if (p.railId === id) return;
|
|
p.railId = id;
|
|
for (const button of p.railButtons) button.setAttribute('aria-current', button.dataset.id === id ? 'true' : 'false');
|
|
// Roving tabindex: Tab into the rail lands on the current category.
|
|
const focusable = p.railButtons.find(b => b.dataset.id === id) || p.railButtons[0];
|
|
for (const button of p.railButtons) button.tabIndex = button === focusable ? 0 : -1;
|
|
}
|
|
|
|
/** Scrolls a category to the top (the rail); a search is cleared first. */
|
|
jumpTo(id) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
if (p.query) { p.search.value = ''; p.onSearch(); }
|
|
const index = p.sections.findIndex(section => section.id === id);
|
|
if (index < 0) return;
|
|
p.scroll.scrollTop = p.layout.sectionTops[index];
|
|
this.renderWindow();
|
|
return index;
|
|
}
|
|
|
|
firstVisiblePos() {
|
|
const p = this.picker;
|
|
const top = p.scroll.scrollTop;
|
|
const row = p.layout.rows.find(r => r.kind === 'row' && r.top + r.height > top + BT_EP_HEAD);
|
|
return row ? { section: row.section, index: row.start } : EmojiPicker.moveInGrid(p.counts, null, 'Home', p.cols);
|
|
}
|
|
|
|
scrollToPos(pos) {
|
|
const p = this.picker;
|
|
const row = p.layout.rows[p.layout.sectionFirstRow[pos.section] + Math.floor(pos.index / p.cols)];
|
|
if (!row) return;
|
|
const top = p.scroll.scrollTop;
|
|
let next = top;
|
|
// Clear of the sticky header above, and of the bottom edge.
|
|
if (row.top - BT_EP_HEAD < top) next = row.top - BT_EP_HEAD;
|
|
else if (row.top + row.height > top + p.viewport) next = row.top + row.height - p.viewport;
|
|
if (next !== top) {
|
|
p.scroll.scrollTop = Math.max(0, next);
|
|
this.renderWindow();
|
|
}
|
|
}
|
|
|
|
setActive(pos, opts = {}) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
if (p.active) p.shadow.getElementById(p.uid + '-' + p.active.section + '-' + p.active.index)?.setAttribute('aria-selected', 'false');
|
|
p.active = pos;
|
|
if (!pos) {
|
|
p.scroll.removeAttribute('aria-activedescendant');
|
|
this.updatePreview(null);
|
|
return;
|
|
}
|
|
if (opts.scroll) this.scrollToPos(pos);
|
|
const cell = p.shadow.getElementById(p.uid + '-' + pos.section + '-' + pos.index);
|
|
if (cell) {
|
|
cell.setAttribute('aria-selected', 'true');
|
|
p.scroll.setAttribute('aria-activedescendant', cell.id);
|
|
} else p.scroll.removeAttribute('aria-activedescendant');
|
|
const entry = p.sections[pos.section].entries[pos.index];
|
|
this.updatePreview(entry);
|
|
if (opts.announce != null) p.live.textContent = opts.announce;
|
|
}
|
|
|
|
updatePreview(entry) {
|
|
const p = this.picker;
|
|
p.foot.classList.toggle('ep-idle', !entry);
|
|
p.previewGlyph.replaceChildren();
|
|
if (!entry) {
|
|
p.previewGlyph.appendChild(btEpIcon('smileys'));
|
|
p.previewName.textContent = t('pick');
|
|
p.previewCode.textContent = t('shiftClick');
|
|
return;
|
|
}
|
|
p.previewGlyph.appendChild(btEpGlyph(entry, this.tone, 'ep-flag'));
|
|
p.previewName.textContent = ':' + entry.names[0] + ':';
|
|
p.previewCode.textContent = entry.names.slice(1, 3).map(name => ':' + name + ':').join(' ');
|
|
}
|
|
|
|
moveActive(key, ctrl) {
|
|
const p = this.picker;
|
|
if (!p.active) { this.setActive(this.firstVisiblePos(), { scroll: true }); return; }
|
|
const next = EmojiPicker.moveInGrid(p.counts, p.active, key, p.cols, ctrl);
|
|
if (!next) return;
|
|
// Up from the first row goes back to the search box.
|
|
if (key === 'ArrowUp' && next.section === p.active.section && next.index === p.active.index) { p.search.focus(); return; }
|
|
// PageUp/PageDown into another category bring its header to the top, like the rail.
|
|
if ((key === 'PageUp' || key === 'PageDown') && next.section !== p.active.section) {
|
|
p.scroll.scrollTop = p.layout.sectionTops[next.section];
|
|
this.renderWindow();
|
|
this.setActive(next);
|
|
return;
|
|
}
|
|
this.setActive(next, { scroll: true });
|
|
}
|
|
|
|
onPickerKey(event) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
// Keys typed in the picker are ours: X's shortcuts never see them.
|
|
event.stopPropagation();
|
|
if (event.isComposing || event.keyCode === 229) return;
|
|
const key = event.key;
|
|
if (event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && event.code === 'KeyE') {
|
|
event.preventDefault();
|
|
this.closePicker({ restoreFocus: true });
|
|
return;
|
|
}
|
|
if (!p.tones.hidden) { this.onTonesKey(event); return; }
|
|
if (key === 'Escape') { event.preventDefault(); this.closePicker({ restoreFocus: true }); return; }
|
|
const active = p.shadow.activeElement;
|
|
const railFocus = p.railButtons.includes(active);
|
|
if (key === 'Tab') {
|
|
event.preventDefault();
|
|
const rail = p.railButtons.find(b => b.tabIndex === 0) || p.railButtons[0];
|
|
const order = [p.search, p.toneBtn, p.scroll, rail];
|
|
const at = railFocus ? 3 : Math.max(0, order.indexOf(active));
|
|
const target = order[(at + (event.shiftKey ? order.length - 1 : 1)) % order.length];
|
|
if (target === p.scroll) p.scroll.classList.add('ep-kbd');
|
|
target.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
if (active === p.search) {
|
|
if ((key === 'ArrowDown' || key === 'PageDown') && p.counts.some(Boolean)) {
|
|
event.preventDefault();
|
|
p.scroll.classList.add('ep-kbd');
|
|
const had = !!p.active;
|
|
p.scroll.focus({ preventScroll: true });
|
|
if (had && !(p.query && p.active.section === 0 && p.active.index === 0 && key === 'ArrowDown')) this.moveActive(key, false);
|
|
else if (!p.active) this.setActive(this.firstVisiblePos(), { scroll: true });
|
|
} else if (key === 'Enter' && p.active) {
|
|
event.preventDefault();
|
|
this.chooseActive(event.shiftKey);
|
|
}
|
|
return;
|
|
}
|
|
if (active === p.scroll) {
|
|
if (BT_EP_NAV.includes(key)) {
|
|
event.preventDefault();
|
|
p.scroll.classList.add('ep-kbd');
|
|
this.moveActive(key, event.ctrlKey || event.metaKey);
|
|
} else if (key === 'Enter' || key === ' ') {
|
|
event.preventDefault();
|
|
if (p.active) this.chooseActive(event.shiftKey);
|
|
} else if (key === 'Backspace') {
|
|
event.preventDefault();
|
|
p.search.focus();
|
|
p.search.value = p.search.value.slice(0, -1);
|
|
p.onSearch();
|
|
} else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
// Typing anywhere in the grid searches.
|
|
event.preventDefault();
|
|
p.search.focus();
|
|
p.search.value += key;
|
|
p.onSearch();
|
|
}
|
|
return;
|
|
}
|
|
if (railFocus) {
|
|
const at = p.railButtons.indexOf(active);
|
|
let next = -1;
|
|
if (key === 'ArrowDown') next = (at + 1) % p.railButtons.length;
|
|
else if (key === 'ArrowUp') next = (at - 1 + p.railButtons.length) % p.railButtons.length;
|
|
else if (key === 'Home') next = 0;
|
|
else if (key === 'End') next = p.railButtons.length - 1;
|
|
if (next >= 0) {
|
|
event.preventDefault();
|
|
for (const button of p.railButtons) button.tabIndex = -1;
|
|
p.railButtons[next].tabIndex = 0;
|
|
p.railButtons[next].focus();
|
|
} else if (key === 'ArrowRight' || key === 'Enter' || key === ' ') {
|
|
// Into that category's first emoji.
|
|
event.preventDefault();
|
|
const section = this.jumpTo(active.dataset.id);
|
|
p.scroll.classList.add('ep-kbd');
|
|
p.scroll.focus({ preventScroll: true });
|
|
if (section != null) this.setActive({ section, index: 0 }, { scroll: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
chooseActive(keepOpen) {
|
|
const p = this.picker;
|
|
const entry = p && p.active && p.sections[p.active.section]?.entries[p.active.index];
|
|
if (entry) this.chooseFromPicker(entry, keepOpen);
|
|
}
|
|
|
|
/** Inserts at the caret the editor had when the picker opened; Shift keeps the picker open. */
|
|
chooseFromPicker(entry, keepOpen) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
const back = p.shadow.activeElement;
|
|
let context = this.caretContext(p.editor);
|
|
if (!context && p.savedRange) {
|
|
p.editor.focus({ preventScroll: true });
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(p.savedRange);
|
|
context = this.caretContext(p.editor);
|
|
}
|
|
if (!context) {
|
|
// The editor never had a caret (the button was clicked first): insert at the end of its text.
|
|
p.editor.focus({ preventScroll: true });
|
|
context = this.caretAtEnd(p.editor);
|
|
}
|
|
if (context) this.insertEntry(entry, context, 0);
|
|
if (!keepOpen) { this.closePicker(); return; }
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount && p.editor.contains(selection.anchorNode)) p.savedRange = selection.getRangeAt(0).cloneRange();
|
|
(back || p.search).focus({ preventScroll: true });
|
|
p.live.textContent = t('added', { name: EmojiPicker.displayName(entry) });
|
|
}
|
|
|
|
// ---- Skin tone popover ----
|
|
|
|
updateToneButton() {
|
|
const p = this.picker;
|
|
p.toneBtn.textContent = EmojiPicker.withSkinTone(BT_EP_TONE_BASE, this.tone);
|
|
p.toneBtn.setAttribute('aria-label', t('toneNamed', { tone: t('tone' + this.tone) }));
|
|
p.toneBtn.title = t('toneNamed', { tone: t('tone' + this.tone) });
|
|
p.toneButtons.forEach((button, tone) => button.setAttribute('aria-checked', tone === this.tone ? 'true' : 'false'));
|
|
}
|
|
|
|
openTones() {
|
|
const p = this.picker;
|
|
p.tones.hidden = false;
|
|
p.toneBtn.setAttribute('aria-expanded', 'true');
|
|
p.toneButtons.forEach((button, tone) => { button.tabIndex = tone === this.tone ? 0 : -1; });
|
|
p.toneButtons[this.tone].focus();
|
|
}
|
|
|
|
closeTones(focusButton) {
|
|
const p = this.picker;
|
|
if (!p || p.tones.hidden) return;
|
|
p.tones.hidden = true;
|
|
p.toneBtn.setAttribute('aria-expanded', 'false');
|
|
if (focusButton) p.toneBtn.focus();
|
|
}
|
|
|
|
onTonesKey(event) {
|
|
const p = this.picker;
|
|
const at = p.toneButtons.indexOf(p.shadow.activeElement);
|
|
const key = event.key;
|
|
if (key === 'Escape' || key === 'Tab') { event.preventDefault(); this.closeTones(true); return; }
|
|
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowRight' || key === 'ArrowLeft') {
|
|
event.preventDefault();
|
|
const forward = key === 'ArrowDown' || key === 'ArrowRight';
|
|
const next = (Math.max(0, at) + (forward ? 1 : 5)) % 6;
|
|
p.toneButtons[next].focus();
|
|
} else if ((key === 'Enter' || key === ' ') && at >= 0) {
|
|
event.preventDefault();
|
|
this.pickTone(at);
|
|
}
|
|
}
|
|
|
|
pickTone(tone) {
|
|
const p = this.picker;
|
|
this.saveTone(tone);
|
|
this.updateToneButton();
|
|
// Redraw the rows in view with the new tone.
|
|
for (const row of p.rendered.values()) row.remove();
|
|
p.rendered.clear();
|
|
this.renderWindow();
|
|
if (p.active) this.setActive(p.active);
|
|
this.closeTones(true);
|
|
p.live.textContent = t('toneNamed', { tone: t('tone' + tone) });
|
|
}
|
|
|
|
closePicker(opts = {}) {
|
|
const p = this.picker;
|
|
if (!p) return;
|
|
this.picker = null;
|
|
if (p.raf) cancelAnimationFrame(p.raf);
|
|
if (p.atlasRaf) cancelAnimationFrame(p.atlasRaf);
|
|
p.offAtlas?.();
|
|
if (p.positionRaf) cancelAnimationFrame(p.positionRaf);
|
|
if (p.anchor !== p.editor && p.anchor.classList && p.anchor.classList.contains('bt-ep-button')) p.anchor.setAttribute('aria-expanded', 'false');
|
|
const focused = p.shadow.activeElement;
|
|
if (opts.restoreFocus && p.editor.isConnected) {
|
|
// Back to the editor, caret where it was.
|
|
p.editor.focus({ preventScroll: true });
|
|
if (p.savedRange && !/^(TEXTAREA|INPUT)$/.test(p.editor.tagName)) {
|
|
try {
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(p.savedRange);
|
|
} catch { /* The editor changed under us. */ }
|
|
}
|
|
} else if (focused) focused.blur();
|
|
const remove = () => { p.host.remove(); this.hosts.delete(p.host); };
|
|
if (opts.immediate || btEpReducedMotion()) { remove(); return; }
|
|
p.host.setAttribute('data-closing', '');
|
|
p.card.classList.add('ep-closing');
|
|
setTimeout(remove, 90);
|
|
}
|
|
};
|