Shadcn Editor
blockeditor-xsource: ok · refreshed
Editor X
A rich text editor built on Lexical extensions with shadcn/ui components: nodes, plugins, toolbars, localization, and RTL support.
npx shadcn add "https://raw.githubusercontent.com/htmujahid/shadcn-editor/refs/heads/main/public/r/editor-x.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@base-ui/react
iconslucide-react
Files126
src/components/editor/dictionaries/arabic.ts
export const ARABIC_WORDS: readonly string[] = [
"المعلومات",
"التكنولوجيا",
"الحكومة",
"الجامعة",
"التعليم",
"التطوير",
"التنمية",
"المجتمع",
"الموارد",
"الإدارة",
"التجربة",
"الخدمات",
"البرنامج",
"المشروع",
"الشركة",
"المنظمة",
"المؤسسة",
"الوزارة",
"الاقتصاد",
"السياسة",
"الثقافة",
"التاريخ",
"الجغرافيا",
"الرياضيات",
"الفيزياء",
"الكيمياء",
"الهندسة",
"الطبيعة",
"البيئة",
"الصناعة",
"الزراعة",
"التجارة",
"الاستثمار",
"التمويل",
"الميزانية",
"الحسابات",
"التأمين",
"المصرف",
"العملة",
"الأسواق",
"المنتجات",
"الجودة",
"الكفاءة",
"الإنتاجية",
"التخطيط",
"التنظيم",
"التنفيذ",
"المتابعة",
"التقييم",
"المراجعة",
"التدقيق",
"التوثيق",
"الأرشيف",
"المكتبة",
"القاموس",
"الموسوعة",
"المقالة",
"الصحيفة",
"المجلة",
"الكتاب",
"الرواية",
"القصيدة",
"المسرحية",
"السينما",
"الموسيقى",
"الفنون",
"التصميم",
"العمارة",
"التصوير",
"الرسم",
"النحت",
"المتحف",
"المعرض",
"المهرجان",
"الاحتفال",
"المناسبة",
"العطلة",
"السفر",
"السياحة",
"الفندق",
"المطعم",
"المطبخ",
"الوجبة",
"الفطور",
"الغداء",
"العشاء",
"الصحة",
"المستشفى",
"العيادة",
"الصيدلية",
"الطبيب",
"الممرضة",
"المريض",
"العلاج",
"الدواء",
"الجراحة",
"التحليل",
"الفحص",
"الوقاية",
"التغذية",
"الرياضة",
"التمرين",
"المباراة",
"البطولة",
"الفريق",
"اللاعب",
"المدرب",
"الجمهور",
"الملعب",
"المدرسة",
"الطالب",
"المعلم",
"الأستاذ",
"المحاضرة",
"الدرس",
"الواجب",
"الامتحان",
"الشهادة",
"التخرج",
"البحث",
"الدراسة",
"المنهج",
"المقرر",
"الفصل",
"المرحلة",
"المستوى",
"المهارة",
"الخبرة",
"الوظيفة",
"العمل",
"المهنة",
"الراتب",
"الترقية",
"التدريب",
"التوظيف",
"المقابلة",
"السيرة",
"الموظف",
"المدير",
"الرئيس",
"النائب",
"الوزير",
"السفير",
"القاضي",
"المحامي",
"المحكمة",
"القانون",
"الدستور",
"الحقوق",
"الواجبات",
"الحرية",
"العدالة",
"المساواة",
"الديمقراطية",
"الانتخابات",
"البرلمان",
"المجلس",
"اللجنة",
"الاجتماع",
"المؤتمر",
"الندوة",
"الورشة",
"الحوار",
"النقاش",
"الاتفاقية",
"المعاهدة",
"التعاون",
"الشراكة",
"العلاقات",
"الدبلوماسية",
"السلام",
"الأمن",
"الاستقرار",
"التسامح",
"الاحترام",
"الصداقة",
"العائلة",
"الأسرة",
"الوالدين",
"الأطفال",
"الشباب",
"المرأة",
"الرجل",
"الإنسان",
"الشخصية",
"السلوك",
"الأخلاق",
"القيم",
"العادات",
"التقاليد",
"الحضارة",
"التراث",
"اللغة",
"الترجمة",
"الكلمة",
"الجملة",
"الفقرة",
"النص",
"الخطاب",
"الرسالة",
"البريد",
"الهاتف",
"الحاسوب",
"الإنترنت",
"الشبكة",
"الموقع",
"التطبيق",
"البرمجة",
"البيانات",
"المعالجة",
"التخزين",
"الذاكرة",
"الشاشة",
"الطابعة",
"الكاميرا",
"الصورة",
"الفيديو",
"التسجيل",
"البث",
"القناة",
"الإذاعة",
"التلفزيون",
"الإعلام",
"الإعلان",
"التسويق",
"المبيعات",
"العملاء",
"الزبائن",
"الطلبات",
"الشحن",
"التوصيل",
"المخزون",
"الأسعار",
"التكلفة",
"الأرباح",
"الخسائر",
"الضرائب",
"الرسوم",
"الفواتير",
"المدفوعات",
"التحويل",
"الحساب",
"البطاقة",
"المحفظة",
"السيارة",
"الطائرة",
"القطار",
"الحافلة",
"السفينة",
"الطريق",
"الشارع",
"الجسر",
"المطار",
"المحطة",
"الميناء",
"المدينة",
"القرية",
"العاصمة",
"المنطقة",
"المحافظة",
"الدولة",
"الوطن",
"الشعب",
"السكان",
"الجنسية",
"الهوية",
"الجواز",
"التأشيرة",
"الإقامة",
"الهجرة",
"الطقس",
"المناخ",
"الحرارة",
"الرطوبة",
"الأمطار",
"العاصفة",
"الرياح",
"الشمس",
"القمر",
"النجوم",
"الفضاء",
"الأرض",
"البحر",
"المحيط",
"النهر",
"البحيرة",
"الجبل",
"الصحراء",
"الغابة",
"الحديقة",
"المزرعة",
"الحيوانات",
"النباتات",
"الأشجار",
"الزهور",
"الفواكه",
"الخضروات",
];
src/components/editor/dictionaries/english.ts
export const ENGLISH_WORDS: readonly string[] = [
"information",
"available",
"copyright",
"university",
"management",
"international",
"development",
"education",
"community",
"technology",
"following",
"resources",
"including",
"directory",
"government",
"department",
"description",
"insurance",
"different",
"categories",
"conditions",
"accessories",
"september",
"questions",
"application",
"financial",
"equipment",
"performance",
"experience",
"important",
"activities",
"additional",
"something",
"professional",
"committee",
"washington",
"california",
"reference",
"companies",
"computers",
"president",
"australia",
"discussion",
"entertainment",
"agreement",
"marketing",
"association",
"collection",
"solutions",
"electronics",
"technical",
"microsoft",
"conference",
"environment",
"statement",
"downloads",
"applications",
"requirements",
"individual",
"subscribe",
"everything",
"production",
"commercial",
"advertising",
"treatment",
"newsletter",
"knowledge",
"currently",
"construction",
"registered",
"protection",
"engineering",
"published",
"corporate",
"customers",
"materials",
"countries",
"standards",
"political",
"advertise",
"environmental",
"availability",
"employment",
"commission",
"administration",
"institute",
"sponsored",
"electronic",
"condition",
"effective",
"organization",
"selection",
"corporation",
"executive",
"necessary",
"according",
"particular",
"facilities",
"opportunities",
"appropriate",
"statistics",
"investment",
"christmas",
"registration",
"furniture",
"wednesday",
"structure",
"distribution",
"industrial",
"potential",
"responsible",
"communications",
"associated",
"foundation",
"documents",
"communication",
"independent",
"operating",
"developed",
"telephone",
"population",
"navigation",
"operations",
"therefore",
"christian",
"understand",
"publications",
"worldwide",
"connection",
"publisher",
"introduction",
"properties",
"accommodation",
"excellent",
"opportunity",
"assessment",
"especially",
"interface",
"operation",
"restaurants",
"beautiful",
"locations",
"significant",
"technologies",
"manufacturer",
"providing",
"authority",
"considered",
"programme",
"enterprise",
"educational",
"employees",
"alternative",
"processing",
"responsibility",
"resolution",
"publication",
"relations",
"photography",
"components",
"assistance",
"completed",
"organizations",
"otherwise",
"transportation",
"disclaimer",
"membership",
"recommended",
"background",
"character",
"maintenance",
"functions",
"trademarks",
"phentermine",
"submitted",
"television",
"interested",
"throughout",
"established",
"programming",
"regarding",
"instructions",
"increased",
"understanding",
"beginning",
"associates",
"instruments",
"businesses",
"specified",
"restaurant",
"procedures",
"relationship",
"traditional",
"sometimes",
"themselves",
"transport",
"interesting",
"evaluation",
"implementation",
"galleries",
"references",
"presented",
"literature",
"respective",
"definition",
"secretary",
"networking",
"australian",
"magazines",
"francisco",
"individuals",
"guidelines",
"installation",
"described",
"attention",
"difference",
"regulations",
"certificate",
"directions",
"documentation",
"automotive",
"successful",
"communities",
"situation",
"publishing",
"emergency",
"developing",
"determine",
"temperature",
"announcements",
"historical",
"ringtones",
"difficult",
"scientific",
"satellite",
"particularly",
"functional",
"monitoring",
"architecture",
"recommend",
"dictionary",
"accounting",
"manufacturing",
"professor",
"generally",
"continued",
"techniques",
"permission",
"generation",
"component",
"guarantee",
"processes",
"interests",
"paperback",
"classifieds",
"supported",
"competition",
"providers",
"characters",
"thousands",
"apartments",
"generated",
"administrative",
"practices",
"reporting",
"essential",
"affiliate",
"immediately",
"designated",
"integrated",
"configuration",
"comprehensive",
"universal",
"presentation",
"languages",
"compliance",
"improvement",
"pennsylvania",
"challenge",
"acceptance",
"strategies",
"affiliates",
"multimedia",
"certified",
"computing",
"interactive",
"procedure",
"leadership",
"religious",
"breakfast",
"developer",
"approximately",
"recommendations",
"comparison",
"automatically",
"minnesota",
"adventure",
"institutions",
"assistant",
"advertisement",
"headlines",
"yesterday",
"determined",
"wholesale",
"extension",
"statements",
"completely",
"electrical",
"applicable",
"manufacturers",
"classical",
"dedicated",
"direction",
"basketball",
"wisconsin",
"personnel",
"identified",
"professionals",
"advantage",
"newsletters",
"estimated",
"anonymous",
"miscellaneous",
"integration",
"interview",
"framework",
"installed",
"massachusetts",
"associate",
"frequently",
"discussions",
"laboratory",
"destination",
"intelligence",
"specifications",
"tripadvisor",
"residential",
"decisions",
"industries",
"partnership",
"editorial",
"expression",
"provisions",
"principles",
"suggestions",
"replacement",
"strategic",
"economics",
"compatible",
"apartment",
"netherlands",
"consulting",
"recreation",
"participants",
"favorites",
"translation",
"estimates",
"protected",
"philadelphia",
"officials",
"contained",
"legislation",
"parameters",
"relationships",
"tennessee",
"representative",
"frequency",
"introduced",
"departments",
"residents",
"displayed",
"performed",
"administrator",
"addresses",
"permanent",
"agriculture",
"constitutes",
"portfolio",
"practical",
"delivered",
"collectibles",
"infrastructure",
"exclusive",
"originally",
"utilities",
"philosophy",
"regulation",
"reduction",
"nutrition",
"recording",
"secondary",
"wonderful",
"announced",
"prevention",
"mentioned",
"automatic",
"healthcare",
"maintained",
"increasing",
"connected",
"directors",
"participation",
"containing",
"combination",
"amendment",
"guaranteed",
"libraries",
"distributed",
"singapore",
"enterprises",
"convention",
"principal",
"certification",
"previously",
"buildings",
"household",
"batteries",
"positions",
"subscription",
"contemporary",
"panasonic",
"permalink",
"signature",
"provision",
"certainly",
"newspaper",
"liability",
"trademark",
"trackback",
"americans",
"promotion",
"conversion",
"reasonable",
"broadband",
"influence",
"importance",
"webmaster",
"prescription",
"specifically",
"represent",
"conservation",
"louisiana",
"javascript",
"marketplace",
"evolution",
"certificates",
"objectives",
"suggested",
"concerned",
"structures",
"encyclopedia",
"continuing",
"interracial",
"competitive",
"suppliers",
"preparation",
"receiving",
"accordance",
"discussed",
"elizabeth",
"reservations",
"playstation",
"instruction",
"annotation",
"differences",
"establish",
"expressed",
"paragraph",
"mathematics",
"compensation",
"conducted",
"percentage",
"mississippi",
"requested",
"connecticut",
"personals",
"immediate",
"agricultural",
"supporting",
"collections",
"participate",
"specialist",
"experienced",
"investigation",
"institution",
"searching",
"proceedings",
"transmission",
"characteristics",
"experiences",
"extremely",
"verzeichnis",
"contracts",
"concerning",
"developers",
"equivalent",
"chemistry",
"neighborhood",
"variables",
"continues",
"curriculum",
"psychology",
"responses",
"circumstances",
"identification",
"appliances",
"elementary",
"unlimited",
"printable",
"enforcement",
"hardcover",
"celebrity",
"chocolate",
"hampshire",
"bluetooth",
"controlled",
"requirement",
"authorities",
"representatives",
"pregnancy",
"biography",
"attractions",
"transactions",
"authorized",
"retirement",
"financing",
"efficiency",
"efficient",
"commitment",
"specialty",
"interviews",
"qualified",
"discovery",
"classified",
"confidence",
"lifestyle",
"consistent",
"clearance",
"connections",
"inventory",
"converter",
"organisation",
"objective",
"indicated",
"securities",
"volunteer",
"democratic",
"switzerland",
"parameter",
"processor",
"dimensions",
"contribute",
"challenges",
"recognition",
"submission",
"encourage",
"regulatory",
"inspection",
"consumers",
"territory",
"transaction",
"manchester",
"contributions",
"continuous",
"resulting",
"cambridge",
"initiative",
"execution",
"disability",
"increases",
"contractor",
"examination",
"indicates",
"committed",
"extensive",
"affordable",
"candidate",
"databases",
"outstanding",
"perspective",
"messenger",
"tournament",
"consideration",
"discounts",
"catalogue",
"publishers",
"caribbean",
"reservation",
"remaining",
"depending",
"expansion",
"purchased",
"performing",
"collected",
"absolutely",
"featuring",
"implement",
"scheduled",
"calculator",
"significantly",
"temporary",
"sufficient",
"awareness",
"vancouver",
"contribution",
"measurement",
"constitution",
"packaging",
"consultation",
"northwest",
"classroom",
"democracy",
"wallpaper",
"merchandise",
"resistance",
"baltimore",
"candidates",
"charlotte",
"biological",
"transition",
"preferences",
"instrument",
"classification",
"physician",
"hollywood",
"wikipedia",
"spiritual",
"photographs",
"relatively",
"satisfaction",
"represents",
"pittsburgh",
"preferred",
"intellectual",
"comfortable",
"interaction",
"listening",
"effectively",
"experimental",
"revolution",
"consolidation",
"landscape",
"dependent",
"mechanical",
"consultants",
"applicant",
"cooperation",
"acquisition",
"implemented",
"directories",
"recognized",
"notification",
"licensing",
"textbooks",
"diversity",
"cleveland",
"investments",
"accessibility",
"sensitive",
"templates",
"completion",
"universities",
"technique",
"contractors",
"subscriptions",
"calculate",
"alexander",
"broadcast",
"converted",
"anniversary",
"improvements",
"specification",
"accessible",
"accessory",
"typically",
"representation",
"arrangements",
"conferences",
"uniprotkb",
"consumption",
"birmingham",
"afternoon",
"consultant",
"controller",
"ownership",
"committees",
"legislative",
"researchers",
"unsubscribe",
"molecular",
"residence",
"attorneys",
"operators",
"sustainable",
"philippines",
"statistical",
"innovation",
"employers",
"definitions",
"elections",
"stainless",
"newspapers",
"hospitals",
"exception",
"successfully",
"indonesia",
"primarily",
"capabilities",
"recommendation",
"recruitment",
"organized",
"improving",
"expensive",
"organisations",
"explained",
"programmes",
"expertise",
"mechanism",
"jewellery",
"eventually",
"agreements",
"considering",
"innovative",
"conclusion",
"disorders",
"collaboration",
"detection",
"formation",
"engineers",
"proposals",
"moderator",
"tutorials",
"settlement",
"collectables",
"fantastic",
"governments",
"purchasing",
"appointed",
"operational",
"corresponding",
"descriptions",
"determination",
"animation",
"productions",
"telecommunications",
"instructor",
"approaches",
"highlights",
"designers",
"melbourne",
"scientists",
"blackjack",
"argentina",
"possibility",
"commissioner",
"dangerous",
"reliability",
"unfortunately",
"respectively",
"volunteers",
"attachment",
"appointment",
"workshops",
"hurricane",
"represented",
"mortgages",
"responsibilities",
"carefully",
"productivity",
"investors",
"underground",
"diagnosis",
"principle",
"vacations",
"calculated",
"appearance",
"incorporated",
"notebooks",
"algorithm",
"valentine",
"involving",
"investing",
"christopher",
"admission",
"terrorism",
"parliament",
"situations",
"allocated",
"corrections",
"structural",
"municipal",
"describes",
"disabilities",
"substance",
"prohibited",
"addressed",
"simulation",
"initiatives",
"concentration",
"interpretation",
"bankruptcy",
"optimization",
"substances",
"discovered",
"restrictions",
"participating",
"exhibition",
"composition",
"nationwide",
"definitely",
"existence",
"commentary",
"limousines",
"developments",
"immigration",
"destinations",
"necessarily",
"attribute",
"apparently",
"surrounding",
"mountains",
"popularity",
"postposted",
"coordinator",
"obviously",
"fundamental",
"substantial",
"progressive",
"championship",
"sacramento",
"impossible",
"depression",
"testimonials",
"memorabilia",
"cartridge",
"explanation",
"cincinnati",
"subsection",
"electricity",
"permitted",
"workplace",
"confirmed",
"wallpapers",
"infection",
"eligibility",
"involvement",
"placement",
"observations",
"vbulletin",
"subsequent",
"motorcycle",
"disclosure",
"establishment",
"presentations",
"undergraduate",
"occupation",
"donations",
"associations",
"citysearch",
"radiation",
"seriously",
"elsewhere",
"pollution",
"conservative",
"guestbook",
"effectiveness",
"demonstrate",
"atmosphere",
"experiment",
"purchases",
"federation",
"assignment",
"chemicals",
"everybody",
"nashville",
"counseling",
"acceptable",
"satisfied",
"measurements",
"milwaukee",
"medication",
"warehouse",
"shareware",
"violation",
"configure",
"stability",
"southwest",
"institutional",
"expectations",
"independence",
"metabolism",
"personally",
"excellence",
"somewhere",
"attributes",
"recognize",
"screening",
"thumbnail",
"forgotten",
"intelligent",
"edinburgh",
"obligation",
"regardless",
"restricted",
"republican",
"merchants",
"attendance",
"arguments",
"amsterdam",
"adventures",
"announcement",
"appreciate",
"regularly",
"mechanisms",
"customize",
"tradition",
"indicators",
"emissions",
"physicians",
"complaint",
"experiments",
"afghanistan",
"scholarship",
"governance",
"supplements",
"camcorder",
"implementing",
"ourselves",
"conversation",
"capability",
"producing",
"precision",
"contributed",
"reproduction",
"ingredients",
"franchise",
"complaints",
"promotions",
"rehabilitation",
"maintaining",
"environments",
"reception",
"correctly",
"consequences",
"geography",
"appearing",
"integrity",
"discrimination",
"processed",
"implications",
"functionality",
"intermediate",
"emotional",
"platforms",
"overnight",
"geographic",
"preliminary",
"districts",
"introduce",
"promotional",
"chevrolet",
"specialists",
"generator",
"suspension",
"correction",
"authentication",
"communicate",
"supplement",
"showtimes",
"promoting",
"machinery",
"bandwidth",
"probability",
"dimension",
"schedules",
"admissions",
"quarterly",
"illustrated",
"continental",
"alternate",
"achievement",
"limitations",
"automated",
"passenger",
"convenient",
"orientation",
"childhood",
"flexibility",
"jurisdiction",
"displaying",
"encouraged",
"cartridges",
"declaration",
"automation",
"advantages",
"preparing",
"recipient",
"extensions",
"athletics",
"southeast",
"alternatives",
"determining",
"personalized",
"conditioning",
"partnerships",
"destruction",
"increasingly",
"migration",
"basically",
"conventional",
"applicants",
"occupational",
"adjustment",
"treatments",
"camcorders",
"difficulty",
"collective",
"coalition",
"enrollment",
"producers",
"collector",
"interfaces",
"advertisers",
"representing",
"observation",
"restoration",
"convenience",
"returning",
"opposition",
"container",
"defendant",
"confirmation",
"supervisor",
"peripherals",
"bestsellers",
"departure",
"minneapolis",
"interactions",
"intervention",
"attraction",
"modification",
"customized",
"understood",
"assurance",
"happening",
"amendments",
"metropolitan",
"compilation",
"verification",
"attractive",
"recordings",
"jefferson",
"gardening",
"obligations",
"orchestra",
"polyphonic",
"outsourcing",
"adjustable",
"allocation",
"discipline",
"demonstrated",
"identifying",
"alphabetical",
"dispatched",
"installing",
"voluntary",
"photographer",
"messaging",
"constructed",
"additions",
"requiring",
"engagement",
"refinance",
"calendars",
"arrangement",
"conclusions",
"bibliography",
"compatibility",
"furthermore",
"cooperative",
"measuring",
"jacksonville",
"headquarters",
"transfers",
"transformation",
"attachments",
"administrators",
"personality",
"facilitate",
"subscriber",
"priorities",
"bookstore",
"parenting",
"incredible",
"commonwealth",
"pharmaceutical",
"manhattan",
"workforce",
"organizational",
"portuguese",
"everywhere",
"discharge",
"halloween",
"hazardous",
"methodology",
"housewares",
"reputation",
"resistant",
"democrats",
"recycling",
"qualifications",
"slideshow",
"variation",
"transferred",
"photograph",
"distributor",
"underlying",
"wrestling",
"photoshop",
"gathering",
"projection",
"mathematical",
"specialized",
"diagnostic",
"indianapolis",
"corporations",
"criticism",
"automobile",
"confidential",
"statutory",
"accommodations",
"northeast",
"downloaded",
"paintings",
"injection",
"yorkshire",
"populations",
"protective",
"initially",
"indicator",
"eliminate",
"sunglasses",
"preference",
"threshold",
"venezuela",
"exploration",
"sequences",
"astronomy",
"translate",
"announces",
"compression",
"establishing",
"constitutional",
"perfectly",
"instantly",
"litigation",
"submissions",
"broadcasting",
"horizontal",
"terrorist",
"informational",
"ecommerce",
"suffering",
"prospective",
"ultimately",
"artificial",
"spectacular",
"coordination",
"connector",
"affiliated",
"activation",
"naturally",
"subscribers",
"mitsubishi",
"underwear",
"potentially",
"constraints",
"inclusive",
"dimensional",
"considerable",
"selecting",
"processors",
"pantyhose",
"difficulties",
"complexity",
"constantly",
"barcelona",
"presidential",
"documentary",
"territories",
"palestinian",
"legislature",
"hospitality",
"procurement",
"theoretical",
"exercises",
"surveillance",
"protocols",
"highlight",
"substitute",
"inclusion",
"hopefully",
"brilliant",
"evaluated",
"assignments",
"termination",
"households",
"authentic",
"montgomery",
"architectural",
"louisville",
"macintosh",
"movements",
"amenities",
"virtually",
"authorization",
"projector",
"comparative",
"psychological",
"surprised",
"genealogy",
"expenditure",
"liverpool",
"connectivity",
"algorithms",
"similarly",
"collaborative",
"excluding",
"commander",
"suggestion",
"spotlight",
"investigate",
"connecting",
"logistics",
"proportion",
"significance",
"symposium",
"essentials",
"protecting",
"transmitted",
"screenshots",
"intensive",
"switching",
"correspondence",
"supervision",
"expenditures",
"separation",
"testimony",
"celebrities",
"mandatory",
"boundaries",
"syndication",
"celebration",
"filtering",
"luxembourg",
"offensive",
"deployment",
"colleagues",
"separated",
"directive",
"governing",
"retailers",
"occasionally",
"attending",
"recruiting",
"instructional",
"traveling",
"permissions",
"biotechnology",
"prescribed",
"catherine",
"reproduced",
"calculation",
"consolidated",
"occasions",
"equations",
"exceptional",
"respondents",
"considerations",
"queensland",
"musicians",
"composite",
"unavailable",
"essentially",
"designing",
"assessments",
"brunswick",
"sensitivity",
"preservation",
"streaming",
"intensity",
"technological",
"syndicate",
"antivirus",
"addressing",
"discounted",
"bangladesh",
"constitute",
"concluded",
"desperate",
"demonstration",
"governmental",
"manufactured",
"graduation",
"variations",
"addiction",
"springfield",
"synthesis",
"undefined",
"unemployment",
"enhancement",
"newcastle",
"performances",
"societies",
"brazilian",
"identical",
"petroleum",
"norwegian",
"retention",
"exchanges",
"soundtrack",
"wondering",
"profession",
"separately",
"physiology",
"collecting",
"participant",
"scholarships",
"recreational",
"dominican",
"friendship",
"expanding",
"provincial",
"investigations",
"medications",
"rochester",
"advertiser",
"encryption",
"downloadable",
"sophisticated",
"possession",
"laboratories",
"vegetables",
"thumbnails",
"stockings",
"respondent",
"destroyed",
"manufacture",
"wordpress",
"vulnerability",
"accountability",
"celebrate",
"accredited",
"appliance",
"compressed",
"scheduling",
"perspectives",
"mortality",
"christians",
"therapeutic",
"impressive",
"accordingly",
"architect",
"challenging",
"microwave",
"accidents",
"relocation",
"contributors",
"violations",
"temperatures",
"competitions",
"discretion",
"cosmetics",
"repository",
"concentrations",
"christianity",
"negotiations",
"realistic",
"generating",
"christina",
"congressional",
"photographic",
"modifications",
"millennium",
"achieving",
"fisheries",
"exceptions",
"reactions",
"macromedia",
"companion",
"divisions",
"additionally",
"fellowship",
"victorian",
"copyrights",
"lithuania",
"mastercard",
"chronicles",
"obtaining",
"distribute",
"decorative",
"enlargement",
"campaigns",
"conjunction",
"instances",
"indigenous",
"validation",
"corruption",
"incentives",
"cholesterol",
"differential",
"scientist",
"starsmerchant",
"arthritis",
"nevertheless",
"practitioners",
"transcript",
"inflation",
"compounds",
"contracting",
"structured",
"reasonably",
"graduates",
"recommends",
"controlling",
"distributors",
"arlington",
"particles",
"extraordinary",
"indicating",
"coordinate",
"exclusively",
"limitation",
"widescreen",
"illustration",
"construct",
"inquiries",
"inspiration",
"affecting",
"downloading",
"aggregate",
"forecasts",
"complicated",
"shopzilla",
"decorating",
"expressions",
"shakespeare",
"connectors",
"conflicts",
"travelers",
"offerings",
"incorrect",
"furnishings",
"guatemala",
"perception",
"renaissance",
"pathology",
"ordinance",
"photographers",
"infections",
"configured",
"festivals",
"possibilities",
"contributing",
"analytical",
"circulation",
"assumption",
"jerusalem",
"transexuales",
"invention",
"technician",
"executives",
"enquiries",
"cognitive",
"exploring",
"registrar",
"supporters",
"withdrawal",
"predicted",
"saskatchewan",
"cancellation",
"ministers",
"veterinary",
"prostores",
"relevance",
"incentive",
"butterfly",
"mechanics",
"numerical",
"reflection",
"accompanied",
"invitation",
"princeton",
"spirituality",
"meanwhile",
"proprietary",
"childrens",
"thumbzilla",
"porcelain",
"pichunter",
"translated",
"columnists",
"consensus",
"delivering",
"journalism",
"intention",
"undertaken",
"statewide",
"semiconductor",
"illustrations",
"happiness",
"substantially",
"identifier",
"calculations",
"conducting",
"accomplished",
"calculators",
"impression",
"correlation",
"fragrance",
"neighbors",
"transparent",
"charleston",
"champions",
"selections",
"projectors",
"inappropriate",
"comparing",
"vocational",
"pharmacies",
"introducing",
"appreciated",
"albuquerque",
"distinguished",
"projected",
"assumptions",
"shareholders",
"developmental",
"regulated",
"anticipated",
"completing",
"comparable",
"confusion",
"copyrighted",
"warranties",
"documented",
"paperbacks",
"keyboards",
"vulnerable",
"reflected",
"respiratory",
"notifications",
"transexual",
"mainstream",
"evaluating",
"subcommittee",
"maternity",
"journalists",
"foundations",
"volleyball",
"liabilities",
"decreased",
"tolerance",
"creativity",
"describing",
"lightning",
"quotations",
"inspector",
"bookmarks",
"behavioral",
"riverside",
"bathrooms",
"abilities",
"initiated",
"nonprofit",
"lancaster",
"suspended",
"containers",
"attitudes",
"simultaneously",
"integrate",
"sociology",
"screenshot",
"exhibitions",
"confident",
"retrieved",
"officially",
"consortium",
"recipients",
"delicious",
"traditions",
"periodically",
"hungarian",
"referring",
"transform",
"educators",
"vegetable",
"humanities",
"independently",
"alignment",
"henderson",
"britannica",
"competitors",
"visibility",
"consciousness",
"encounter",
"resolutions",
"accessing",
"attempted",
"witnesses",
"administered",
"strengthen",
"frederick",
"aggressive",
"advertisements",
"sublimedirectory",
"disturbed",
"determines",
"sculpture",
"motivation",
"pharmacology",
"passengers",
"quantities",
"petersburg",
"consistently",
"powerpoint",
"obituaries",
"punishment",
"appreciation",
"subsequently",
"providence",
"restriction",
"incorporate",
"backgrounds",
"treasurer",
"lightweight",
"transcription",
"complications",
"scripting",
"remembered",
"synthetic",
"testament",
"specifics",
"partially",
"wilderness",
"generations",
"tournaments",
"sponsorship",
"headphones",
"proceeding",
"volkswagen",
"uncertainty",
"breakdown",
"reconstruction",
"subsidiary",
"strengths",
"encouraging",
"furnished",
"terrorists",
"comparisons",
"beneficial",
"distributions",
"viewpicture",
"threatened",
"republicans",
"discusses",
"responded",
"abstracts",
"prediction",
"pharmaceuticals",
"thesaurus",
"individually",
"battlefield",
"literally",
"ecological",
"appraisal",
"consisting",
"submitting",
"citations",
"geographical",
"mozambique",
"disclaimers",
"championships",
"sheffield",
"finishing",
"wellington",
"prospects",
"bulgarian",
"aboriginal",
"remarkable",
"preventing",
"productive",
"boulevard",
"compliant",
"penalties",
"imagination",
"refurbished",
"activated",
"conferencing",
"armstrong",
"politicians",
"trackbacks",
"accommodate",
"christine",
"accepting",
"precipitation",
"isolation",
"sustained",
"approximate",
"programmer",
"greetings",
"inherited",
"incomplete",
"chronicle",
"legitimate",
"biographies",
"investigator",
"plaintiff",
"prisoners",
"mediterranean",
"nightlife",
"architects",
"entrepreneur",
"freelance",
"excessive",
"screensaver",
"valuation",
"unexpected",
"cigarette",
"characteristic",
"metallica",
"consequently",
"appointments",
"narrative",
"academics",
"quantitative",
"screensavers",
"subdivision",
"distinction",
"livestock",
"exemption",
"sustainability",
"formatting",
"nutritional",
"nicaragua",
"affiliation",
"relatives",
"satisfactory",
"revolutionary",
"bracelets",
"telephony",
"breathing",
"thickness",
"adjustments",
"graphical",
"discussing",
"aerospace",
"meaningful",
"maintains",
"shortcuts",
"voyeurweb",
"extending",
"specifies",
"accreditation",
"blackberry",
"meditation",
"microphone",
"macedonia",
"combining",
"instrumental",
"organizing",
"moderators",
"kazakhstan",
"standings",
"partition",
"invisible",
"translations",
"commodity",
"kilometers",
"thanksgiving",
"guarantees",
"indication",
"congratulations",
"cigarettes",
"controllers",
"consultancy",
"conventions",
"coordinates",
"responding",
"physically",
"stakeholders",
"hydrocodone",
"consecutive",
"attempting",
"representations",
"competing",
"peninsula",
"accurately",
"considers",
"ministries",
"vacancies",
"parliamentary",
"acknowledge",
"thoroughly",
"nottingham",
"identifies",
"questionnaire",
"qualification",
"modelling",
"miniature",
"interstate",
"consequence",
"systematic",
"perceived",
"madagascar",
"presenting",
"troubleshooting",
"uzbekistan",
"centuries",
"magnitude",
"richardson",
"fragrances",
"vocabulary",
"earthquake",
"fundraising",
"geological",
"assessing",
"introduces",
"webmasters",
"computational",
"acdbentity",
"participated",
"handhelds",
"answering",
"impressed",
"conspiracy",
"organizer",
"combinations",
"preceding",
"cumulative",
"amplifier",
"arbitrary",
"prominent",
"lexington",
"contacted",
"recorders",
"occasional",
"innovations",
"postcards",
"reviewing",
"explicitly",
"transsexual",
"citizenship",
"informative",
"girlfriend",
"bloomberg",
"hierarchy",
"influenced",
"abandoned",
"complement",
"mauritius",
"checklist",
"requesting",
"lauderdale",
"scenarios",
"extraction",
"elevation",
"utilization",
"beverages",
"calibration",
"efficiently",
"entertaining",
"prerequisite",
"hypothesis",
"medicines",
"regression",
"enhancements",
"renewable",
"intersection",
"passwords",
"consistency",
"collectors",
"azerbaijan",
"astrology",
"occurring",
"supplemental",
"travelling",
"induction",
"precisely",
"spreading",
"provinces",
"widespread",
"incidence",
"incidents",
"enhancing",
"interference",
"palestine",
"listprice",
"atmospheric",
"knowledgestorm",
"referenced",
"publicity",
"proposition",
"allowance",
"designation",
"duplicate",
"criterion",
"civilization",
"vietnamese",
"tremendous",
"corrected",
"encountered",
"internationally",
"surrounded",
"creatures",
"commented",
"accomplish",
"vegetarian",
"newfoundland",
"investigated",
"ambassador",
"stephanie",
"contacting",
"vegetation",
"findarticles",
"specially",
"infectious",
"continuity",
"phenomenon",
"conscious",
"referrals",
"differently",
"integrating",
"revisions",
"reasoning",
"charitable",
"annotated",
"convinced",
"burlington",
"replacing",
"researcher",
"watershed",
"occupations",
"acknowledged",
"equilibrium",
"characterized",
"privilege",
"qualifying",
"estimation",
"pediatric",
"techrepublic",
"institutes",
"brochures",
"traveller",
"appropriations",
"suspected",
"benchmark",
"beginners",
"instructors",
"highlighted",
"stationery",
"unauthorized",
"competent",
"contributor",
"demonstrates",
"gradually",
"desirable",
"journalist",
"afterwards",
"religions",
"explosion",
"signatures",
"disciplines",
"daughters",
"conversations",
"simplified",
"motherboard",
"bibliographic",
"champagne",
"deviation",
"superintendent",
"housewives",
"influences",
"inspections",
"irrigation",
"hydraulic",
"robertson",
"penetration",
"conviction",
"omissions",
"retrieval",
"qualities",
"prototype",
"importantly",
"apparatus",
"explaining",
"nomination",
"empirical",
"dependence",
"sexuality",
"polyester",
"commitments",
"suggesting",
"remainder",
"privileges",
"televisions",
"specializing",
"commodities",
"motorcycles",
"concentrate",
"reproductive",
"molecules",
"refrigerator",
"intervals",
"sentences",
"exclusion",
"workstation",
"holocaust",
"receivers",
"disposition",
"navigator",
"investigators",
"marijuana",
"cathedral",
"fairfield",
"fascinating",
"landscapes",
"lafayette",
"computation",
"cardiovascular",
"salvation",
"predictions",
"accompanying",
"selective",
"arbitration",
"configuring",
"editorials",
"sacrifice",
"removable",
"convergence",
"gibraltar",
"anthropology",
"malpractice",
"reporters",
"necessity",
"rendering",
"hepatitis",
"nationally",
"waterproof",
"specialties",
"humanitarian",
"invitations",
"functioning",
"economies",
"alexandria",
"bacterial",
"undertake",
"continuously",
"achievements",
"convertible",
"secretariat",
"paragraphs",
"adolescent",
"nominations",
"cancelled",
"introductory",
"reservoir",
"occurrence",
"worcester",
"demographic",
"disciplinary",
"respected",
"portraits",
"interpreted",
"evaluations",
"elimination",
"hypothetical",
"immigrants",
"complimentary",
"helicopter",
"performer",
"commissions",
"powerseller",
"graduated",
"surprising",
"unnecessary",
"dramatically",
"yugoslavia",
"characterization",
"likelihood",
"fundamentals",
"contamination",
"endangered",
"compromise",
"expiration",
"namespace",
"peripheral",
"negotiation",
"opponents",
"nominated",
"confidentiality",
"electoral",
"changelog",
"alternatively",
"greensboro",
"controversial",
"recovered",
"upgrading",
"frontpage",
"demanding",
"defensive",
"forbidden",
"programmers",
"monitored",
"installations",
"deutschland",
"practitioner",
"motivated",
"smithsonian",
"examining",
"revelation",
"delegation",
"dictionaries",
"greenhouse",
"transparency",
"currencies",
"survivors",
"positioning",
"descending",
"temporarily",
"frequencies",
"reflections",
"municipality",
"detective",
"experiencing",
"fireplace",
"endorsement",
"psychiatry",
"persistent",
"summaries",
"looksmart",
"magnificent",
"colleague",
"adaptation",
"paintball",
"enclosure",
"supervisors",
"westminster",
"distances",
"absorption",
"treasures",
"transcripts",
"disappointed",
"continually",
"communist",
"collectible",
"entrepreneurs",
"creations",
"acquisitions",
"biodiversity",
"excitement",
"presently",
"mysterious",
"librarian",
"subsidiaries",
"stockholm",
"indonesian",
"therapist",
"promising",
"relaxation",
"thereafter",
"commissioners",
"forwarding",
"nightmare",
"reductions",
"southampton",
"organisms",
"telescope",
"portsmouth",
"advancement",
"harassment",
"generators",
"generates",
"replication",
"inexpensive",
"receptors",
"interventions",
"huntington",
"internship",
"aluminium",
"snowboard",
"beastality",
"evanescence",
"coordinated",
"shipments",
"antarctica",
"chancellor",
"controversy",
"legendary",
"beautifully",
"antibodies",
"examinations",
"immunology",
"departmental",
"terminology",
"gentleman",
"reproduce",
"convicted",
"roommates",
"threatening",
"spokesman",
"activists",
"frankfurt",
"encourages",
"assembled",
"restructuring",
"terminals",
"simulations",
"sufficiently",
"conditional",
"crossword",
"conceptual",
"liechtenstein",
"translator",
"automobiles",
"continent",
"longitude",
"challenged",
"telecharger",
"insertion",
"instrumentation",
"constraint",
"groundwater",
"strengthening",
"insulation",
"infringement",
"subjective",
"swaziland",
"varieties",
"mediawiki",
"configurations",
];
src/components/editor/dictionaries/hebrew.ts
export const HEBREW_WORDS: readonly string[] = [
"מידע",
"טכנולוגיה",
"ממשלה",
"אוניברסיטה",
"חינוך",
"פיתוח",
"קהילה",
"משאבים",
"ניהול",
"ניסיון",
"שירותים",
"תוכנית",
"פרויקט",
"חברה",
"ארגון",
"מוסד",
"משרד",
"כלכלה",
"פוליטיקה",
"תרבות",
"היסטוריה",
"גאוגרפיה",
"מתמטיקה",
"פיזיקה",
"כימיה",
"הנדסה",
"טבע",
"סביבה",
"תעשייה",
"חקלאות",
"מסחר",
"השקעה",
"מימון",
"תקציב",
"חשבונות",
"ביטוח",
"בנקאות",
"מטבע",
"שווקים",
"מוצרים",
"איכות",
"יעילות",
"פרודוקטיביות",
"תכנון",
"ביצוע",
"מעקב",
"הערכה",
"ביקורת",
"תיעוד",
"ארכיון",
"ספרייה",
"מילון",
"אנציקלופדיה",
"מאמר",
"עיתון",
"מגזין",
"ספרות",
"רומן",
"שירה",
"תיאטרון",
"קולנוע",
"מוזיקה",
"אמנות",
"עיצוב",
"אדריכלות",
"צילום",
"ציור",
"פיסול",
"מוזיאון",
"תערוכה",
"פסטיבל",
"חגיגה",
"אירוע",
"חופשה",
"נסיעה",
"תיירות",
"מלון",
"מסעדה",
"מטבח",
"ארוחה",
"בוקר",
"צהריים",
"ערב",
"בריאות",
"רפואה",
"מרפאה",
"רוקחות",
"רופא",
"אחות",
"מטופל",
"טיפול",
"תרופה",
"ניתוח",
"בדיקה",
"מניעה",
"תזונה",
"ספורט",
"אימון",
"משחק",
"אליפות",
"קבוצה",
"שחקן",
"מאמן",
"קהל",
"אצטדיון",
"לימודים",
"סטודנט",
"מורה",
"פרופסור",
"הרצאה",
"שיעור",
"מטלה",
"מבחן",
"תעודה",
"מחקר",
"הוראה",
"שכבה",
"רמה",
"מיומנות",
"מומחיות",
"משרה",
"עבודה",
"מקצוע",
"משכורת",
"קידום",
"הכשרה",
"גיוס",
"ראיון",
"עובד",
"מנהל",
"נשיא",
"שגריר",
"שופט",
"משפטים",
"תביעה",
"חוקה",
"זכויות",
"חובות",
"חירות",
"צדק",
"שוויון",
"דמוקרטיה",
"בחירות",
"כנסת",
"מועצה",
"ועדה",
"ישיבה",
"כנס",
"סדנה",
"שיחה",
"דיון",
"הסכם",
"אמנה",
"שיתוף",
"שותפות",
"יחסים",
"דיפלומטיה",
"שלום",
"ביטחון",
"יציבות",
"סובלנות",
"כבוד",
"חברות",
"משפחה",
"הורים",
"ילדים",
"צעירים",
"אישה",
"אנושות",
"אישיות",
"התנהגות",
"מוסר",
"ערכים",
"הרגלים",
"מסורת",
"ציוויליזציה",
"מורשת",
"שפה",
"תרגום",
"מילה",
"משפט",
"פסקה",
"טקסט",
"נאום",
"הודעה",
"דואר",
"טלפון",
"מחשב",
"אינטרנט",
"רשת",
"אתר",
"יישום",
"תכנות",
"נתונים",
"עיבוד",
"אחסון",
"זיכרון",
"מסך",
"מדפסת",
"מצלמה",
"תמונה",
"וידאו",
"הקלטה",
"שידור",
"ערוץ",
"רדיו",
"טלוויזיה",
"תקשורת",
"פרסום",
"שיווק",
"מכירות",
"לקוחות",
"הזמנות",
"משלוח",
"מלאי",
"מחירים",
"עלות",
"רווחים",
"הפסדים",
"מיסים",
"אגרות",
"חשבוניות",
"תשלומים",
"העברה",
"חשבון",
"כרטיס",
"ארנק",
"מכונית",
"מטוס",
"רכבת",
"אוטובוס",
"ספינה",
"כביש",
"רחוב",
"גשר",
"תעופה",
"תחנה",
"נמל",
"עירייה",
"כפר",
"בירה",
"אזור",
"מחוז",
"מדינה",
"מולדת",
"אוכלוסייה",
"אזרחות",
"זהות",
"דרכון",
"אשרה",
"תושבות",
"הגירה",
"אוויר",
"אקלים",
"טמפרטורה",
"לחות",
"גשמים",
"סערה",
"רוחות",
"שמש",
"ירח",
"כוכבים",
"חלל",
"אדמה",
"אוקיינוס",
"נהר",
"אגם",
"הרים",
"מדבר",
"יער",
"גינה",
"חווה",
"חיות",
"צמחים",
"עצים",
"פרחים",
"פירות",
"ירקות",
];
src/components/editor/extensions/autocomplete.ts
import {
$getNodeByKey,
$getSelection,
$isRangeSelection,
$isTextNode,
$setCompositionKey,
BLUR_COMMAND,
type BaseSelection,
COMMAND_PRIORITY_LOW,
COMPOSITION_END_TAG,
COMPOSITION_START_COMMAND,
type EditorState,
KEY_ARROW_RIGHT_COMMAND,
KEY_TAB_COMMAND,
type LexicalEditor,
type NodeKey,
defineExtension,
getActiveElement,
isHTMLElement,
mergeRegister,
registerEventListener,
safeCast,
setDOMUnmanaged,
} from "lexical";
import {
IMEExtension,
RootElementExtension,
WatchEditableExtension,
effect,
namedSignals,
shallowMergeConfig,
} from "@lexical/extension";
import { $isAtNodeEnd } from "@lexical/selection";
import { locales } from "@/components/editor/locales";
import type { Language } from "@/components/editor/plugins/i18n-plugin";
export interface AutocompleteDictionary {
readonly minPrefixLength: number;
query(prefix: string): null | string;
}
export interface WordlistDictionaryOptions {
minPrefixLength?: number;
caseSensitive?: boolean;
}
export function createWordlistDictionary(
words: readonly string[],
options: WordlistDictionaryOptions = {},
): AutocompleteDictionary {
const { minPrefixLength = 2, caseSensitive = false } = options;
const fold = (text: string): string =>
caseSensitive ? text : text.toLowerCase();
const folded = words.map(fold);
const order = Uint32Array.from(
words
.map((_, index) => index)
.sort((a, b) =>
folded[a] < folded[b] ? -1 : folded[a] > folded[b] ? 1 : a - b,
),
);
return {
minPrefixLength,
query(prefix: string): null | string {
if (prefix.length < minPrefixLength) {
return null;
}
const needle = fold(prefix);
let lo = 0;
let hi = order.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (fold(words[order[mid]]) < needle) {
lo = mid + 1;
} else {
hi = mid;
}
}
let bestIndex = -1;
let bestWord: string | null = null;
for (let k = lo; k < order.length; k++) {
const index = order[k];
const word = words[index];
if (!fold(word).startsWith(needle)) {
break;
}
if (
word.length > prefix.length &&
(bestIndex === -1 || index < bestIndex)
) {
bestIndex = index;
bestWord = word;
}
}
return bestWord === null ? null : bestWord.substring(prefix.length);
},
};
}
function isArabic(cp: number): boolean {
return (
(cp >= 0x0600 && cp <= 0x06ff) ||
(cp >= 0x0750 && cp <= 0x077f) ||
(cp >= 0x08a0 && cp <= 0x08ff) ||
(cp >= 0xfb50 && cp <= 0xfdff) ||
(cp >= 0xfe70 && cp <= 0xfeff)
);
}
function isHebrew(cp: number): boolean {
return (cp >= 0x0590 && cp <= 0x05ff) || (cp >= 0xfb1d && cp <= 0xfb4f);
}
function isZeroWidthOrControl(cp: number): boolean {
return (
cp < 0x20 ||
(cp >= 0x200b && cp <= 0x200f) ||
(cp >= 0x202a && cp <= 0x202e) ||
cp === 0xfeff
);
}
export function detectLanguage(text: string): string {
const codePoints = Array.from(text);
for (let i = codePoints.length - 1; i >= 0; i--) {
const cp = codePoints[i].codePointAt(0);
if (cp === undefined || isZeroWidthOrControl(cp)) {
continue;
}
if (isArabic(cp)) {
return "ar";
}
if (isHebrew(cp)) {
return "he";
}
return "en";
}
return "en";
}
export type AutocompleteDictionaryLoader =
() => Promise<AutocompleteDictionary>;
export const defaultDictionaries: Readonly<
Record<string, AutocompleteDictionaryLoader>
> = {
ar: () =>
import("@/components/editor/dictionaries/arabic").then(({ ARABIC_WORDS }) =>
createWordlistDictionary(ARABIC_WORDS),
),
en: () =>
import("@/components/editor/dictionaries/english").then(
({ ENGLISH_WORDS }) =>
createWordlistDictionary(ENGLISH_WORDS, { minPrefixLength: 4 }),
),
he: () =>
import("@/components/editor/dictionaries/hebrew").then(({ HEBREW_WORDS }) =>
createWordlistDictionary(HEBREW_WORDS),
),
};
const DEFAULT_COMPOSITION_IDLE_DEBOUNCE_MS = 200;
const QUERY_LATENCY_MS = 200;
const AUTOCOMPLETE_GHOST_ATTR = "data-autocomplete-ghost";
function $search(selection: null | BaseSelection): [boolean, string] {
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return [false, ""];
}
const node = selection.getNodes()[0];
const anchor = selection.anchor;
if (!$isTextNode(node) || !node.isSimpleText() || !$isAtNodeEnd(anchor)) {
return [false, ""];
}
const word = [];
const text = node.getTextContent();
let i = node.getTextContentSize();
let c;
while (i-- && i >= 0 && (c = text[i]) !== " ") {
word.push(c);
}
if (word.length === 0) {
return [false, ""];
}
return [true, word.reverse().join("")];
}
function query(
dictionaryPromise: Promise<AutocompleteDictionary | undefined>,
searchText: string,
signal: AbortSignal,
): Promise<null | string> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason);
return;
}
const onAbort = () => {
clearTimeout(timeout);
reject(signal.reason);
};
const timeout = setTimeout(async () => {
signal.removeEventListener("abort", onAbort);
let dictionary: AutocompleteDictionary | undefined;
try {
dictionary = await dictionaryPromise;
} catch (e) {
reject(e);
return;
}
if (signal.aborted) {
reject(signal.reason);
return;
}
if (dictionary === undefined) {
resolve(null);
return;
}
resolve(dictionary.query(searchText));
}, QUERY_LATENCY_MS);
signal.addEventListener("abort", onAbort, { once: true });
});
}
export function extractTrailingWord(text: string): string {
const trimmed = text.replace(/\s+$/u, "");
const match = trimmed.match(/\S+$/u);
return match === null ? "" : match[0];
}
export function getCompositionTextFromDOM(dom: HTMLElement): string {
let text = "";
for (const node of dom.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent ?? "";
} else if (
isHTMLElement(node) &&
!node.hasAttribute(AUTOCOMPLETE_GHOST_ATTR)
) {
text += node.textContent ?? "";
}
}
return text.replace(/[\u200B-\u200D\uFEFF]/g, "");
}
function formatSuggestionText(
suggestion: string,
rootElem: HTMLElement | null,
): string {
const userAgentData = (
window.navigator as Navigator & { userAgentData?: { mobile: boolean } }
).userAgentData;
const isMobile =
userAgentData !== undefined
? userAgentData.mobile
: window.innerWidth <= 800 && window.innerHeight <= 600;
if (!isMobile) {
return `${suggestion} (TAB)`;
}
const lang = rootElem?.closest<HTMLElement>("[lang]")?.lang ?? "en";
const isRTL =
rootElem !== null && getComputedStyle(rootElem).direction === "rtl";
const swipeWord = (locales[lang as Language] ?? locales.en).autocompleteSwipe;
return `${suggestion} (${swipeWord} ${isRTL ? "⬅" : "⮕"})`;
}
function syncGhost(
editor: LexicalEditor,
textNodeKey: NodeKey | null,
ghostText: string | null,
): void {
const root = editor.getRootElement();
if (root) {
for (const el of root.querySelectorAll(`[${AUTOCOMPLETE_GHOST_ATTR}]`)) {
el.remove();
}
}
if (textNodeKey === null || ghostText === null) {
return;
}
const dom = editor.getElementByKey(textNodeKey);
if (!dom) {
return;
}
const ghost = dom.ownerDocument.createElement("span");
ghost.setAttribute(AUTOCOMPLETE_GHOST_ATTR, "true");
ghost.setAttribute("contenteditable", "false");
ghost.className = editor._config.theme.autocomplete || "";
ghost.textContent = ghostText;
setDOMUnmanaged(ghost);
dom.appendChild(ghost);
}
function addSwipeRightListener(
element: HTMLElement,
cb: (force: number, e: TouchEvent) => void,
): () => void {
let start: [number, number] | null = null;
const readTouch = (e: TouchEvent): [number, number] | null => {
const touch = e.changedTouches[0];
return touch === undefined ? null : [touch.clientX, touch.clientY];
};
const handleTouchstart = (e: TouchEvent) => {
start = readTouch(e);
};
const handleTouchend = (e: TouchEvent) => {
if (start === null) {
return;
}
const end = readTouch(e);
if (end !== null) {
const isRTL = getComputedStyle(element).direction === "rtl";
const x = end[0] - start[0];
const y = end[1] - start[1];
const forward = isRTL ? -x : x;
if (forward > 0 && forward > Math.abs(y)) {
cb(forward, e);
}
}
};
element.addEventListener("touchstart", handleTouchstart);
element.addEventListener("touchend", handleTouchend);
return () => {
element.removeEventListener("touchstart", handleTouchstart);
element.removeEventListener("touchend", handleTouchend);
};
}
export interface AutocompleteConfig {
disabled: boolean;
dictionaries: Readonly<Record<string, AutocompleteDictionaryLoader>>;
detectLanguage: (text: string) => string;
compositionIdleDebounceMs: number;
}
function mergeAutocompleteConfig(
config: AutocompleteConfig,
overrides: Partial<AutocompleteConfig>,
): AutocompleteConfig {
const merged = shallowMergeConfig(config, overrides);
if (overrides.dictionaries) {
merged.dictionaries = {
...config.dictionaries,
...overrides.dictionaries,
};
}
return merged;
}
export const AutocompleteExtension = defineExtension({
name: "@shadcn-editor/editor/Autocomplete",
build: (_editor, config) => namedSignals(config),
config: safeCast<AutocompleteConfig>({
compositionIdleDebounceMs: DEFAULT_COMPOSITION_IDLE_DEBOUNCE_MS,
detectLanguage,
dictionaries: defaultDictionaries,
disabled: false,
}),
dependencies: [IMEExtension, RootElementExtension, WatchEditableExtension],
mergeConfig: mergeAutocompleteConfig,
register: (editor: LexicalEditor, _config, state) => {
const ime = state.getDependency(IMEExtension).output;
const editableSignal = state.getDependency(WatchEditableExtension).output;
const rootElemSignal = state.getDependency(RootElementExtension).output;
let activeTextNodeKey: NodeKey | null = null;
let lastMatch: string | null = null;
let lastSuggestion: string | null = null;
let searchController: AbortController | null = null;
let pendingCompositionTimer: number | null = null;
const dictionaryCache = new Map<
AutocompleteDictionaryLoader,
Promise<AutocompleteDictionary>
>();
function loadDictionary(
loader: AutocompleteDictionaryLoader | undefined,
): Promise<AutocompleteDictionary | undefined> {
if (loader === undefined) {
return Promise.resolve(undefined);
}
let cached = dictionaryCache.get(loader);
if (cached === undefined) {
cached = loader();
dictionaryCache.set(loader, cached);
}
return cached;
}
function clearPendingCompositionTimer() {
if (pendingCompositionTimer !== null) {
clearTimeout(pendingCompositionTimer);
pendingCompositionTimer = null;
}
}
function isEditorFocused(): boolean {
const rootElem = editor.getRootElement();
const active = rootElem ? getActiveElement(rootElem) : null;
return rootElem != null && active != null && rootElem.contains(active);
}
function dismiss() {
activeTextNodeKey = null;
lastMatch = null;
lastSuggestion = null;
if (searchController !== null) {
searchController.abort();
searchController = null;
}
syncGhost(editor, null, null);
}
function tryCompositionSuggestion() {
pendingCompositionTimer = null;
const composingNode = ime.composingTextNode.value;
if (composingNode === null) {
return;
}
const composingKey = composingNode.getKey();
const dom = editor.getElementByKey(composingKey);
if (dom === null) {
return;
}
const text = getCompositionTextFromDOM(dom);
if (text.length === 0) {
return;
}
const prefix = extractTrailingWord(text);
if (prefix.length === 0 || prefix === lastMatch) {
return;
}
if (searchController !== null) {
searchController.abort();
}
syncGhost(editor, null, null);
lastMatch = prefix;
lastSuggestion = null;
activeTextNodeKey = null;
const controller = new AbortController();
searchController = controller;
const language = output.detectLanguage.value(prefix);
query(
loadDictionary(output.dictionaries.value[language]),
prefix,
controller.signal,
)
.then((newSuggestion) => {
applyCompositionSuggestion(
controller,
composingKey,
prefix,
newSuggestion,
);
})
.catch((e) => {
if (!(e instanceof DOMException && e.name === "AbortError")) {
console.error(e);
}
});
}
function applyCompositionSuggestion(
refController: AbortController,
composingKey: NodeKey,
prefix: string,
newSuggestion: string | null,
) {
const composingNode = ime.composingTextNode.value;
if (
searchController !== refController ||
newSuggestion === null ||
composingNode === null ||
composingNode.getKey() !== composingKey
) {
return;
}
const dom = editor.getElementByKey(composingKey);
if (dom === null) {
return;
}
if (extractTrailingWord(getCompositionTextFromDOM(dom)) !== prefix) {
return;
}
activeTextNodeKey = composingKey;
lastSuggestion = newSuggestion;
syncGhost(
editor,
composingKey,
formatSuggestionText(newSuggestion, editor.getRootElement()),
);
}
function onCompositionUpdateDOM() {
const debounceMs = output.compositionIdleDebounceMs.value;
if (debounceMs <= 0) {
return;
}
clearPendingCompositionTimer();
pendingCompositionTimer = window.setTimeout(
tryCompositionSuggestion,
debounceMs,
);
}
function onCompositionEndDOM() {
clearPendingCompositionTimer();
Promise.resolve().then(() => {
handleUpdate({
editorState: editor.getEditorState(),
tags: new Set([COMPOSITION_END_TAG]),
});
});
}
function applyAsyncSuggestion(
refController: AbortController,
newSuggestion: string | null,
) {
if (searchController !== refController || newSuggestion === null) {
return;
}
if (!isEditorFocused()) {
return;
}
editor.read("latest", () => {
const selection = $getSelection();
const [hasMatch, match] = $search(selection);
if (!hasMatch || match !== lastMatch || !$isRangeSelection(selection)) {
return;
}
const node = selection.getNodes()[0];
if (!$isTextNode(node)) {
return;
}
activeTextNodeKey = node.getKey();
lastSuggestion = newSuggestion;
syncGhost(
editor,
activeTextNodeKey,
formatSuggestionText(newSuggestion, editor.getRootElement()),
);
});
}
function handleUpdate({
editorState,
tags,
}: {
editorState: EditorState;
tags: Set<string>;
}) {
if (!isEditorFocused()) {
dismiss();
return;
}
if (!tags.has(COMPOSITION_END_TAG) && editor.isComposing()) {
return;
}
editorState.read(
() => {
const selection = $getSelection();
const [hasMatch, match] = $search(selection);
if (!hasMatch) {
dismiss();
return;
}
if (match === lastMatch) {
if ($isRangeSelection(selection)) {
const node = selection.getNodes()[0];
if ($isTextNode(node)) {
const key = node.getKey();
if (key !== activeTextNodeKey) {
activeTextNodeKey = key;
syncGhost(
editor,
activeTextNodeKey,
lastSuggestion
? formatSuggestionText(
lastSuggestion,
editor.getRootElement(),
)
: null,
);
}
}
}
return;
}
if (searchController !== null) {
searchController.abort();
}
syncGhost(editor, null, null);
lastMatch = match;
lastSuggestion = null;
activeTextNodeKey = null;
const controller = new AbortController();
searchController = controller;
const language = output.detectLanguage.value(match);
query(
loadDictionary(output.dictionaries.value[language]),
match,
controller.signal,
)
.then((newSuggestion) => {
applyAsyncSuggestion(controller, newSuggestion);
})
.catch((e) => {
if (!(e instanceof DOMException && e.name === "AbortError")) {
console.error(e);
}
});
},
{ editor },
);
}
function $commitSuggestion(): boolean {
if (activeTextNodeKey === null || lastSuggestion === null) {
return false;
}
const node = $getNodeByKey(activeTextNodeKey);
if (!$isTextNode(node)) {
dismiss();
return false;
}
const composingNode = ime.composingTextNode.value;
if (
composingNode !== null &&
composingNode.getKey() === activeTextNodeKey
) {
const dom = editor.getElementByKey(activeTextNodeKey);
if (dom !== null) {
const liveText = getCompositionTextFromDOM(dom);
const fullText = liveText + lastSuggestion;
node.setTextContent(fullText);
$setCompositionKey(null);
node.select();
dismiss();
return true;
}
}
node.spliceText(node.getTextContentSize(), 0, lastSuggestion, true);
dismiss();
return true;
}
function $handleCommitCommand(event: Event): boolean {
const didCommit = $commitSuggestion();
if (didCommit) {
event.preventDefault();
}
return didCommit;
}
function handleSwipeRight(_force: number, event: TouchEvent) {
let didCommit = false;
editor.update(
() => {
didCommit = $commitSuggestion();
},
{ discrete: true },
);
if (didCommit) {
event.preventDefault();
}
}
const output = state.getOutput();
return effect(() => {
const rootElem = rootElemSignal.value;
const editable = editableSignal.value;
if (output.disabled.value || !rootElem || !editable) {
return;
}
for (const loader of Object.values(output.dictionaries.value)) {
loadDictionary(loader);
}
return mergeRegister(
registerEventListener(
rootElem,
"compositionupdate",
onCompositionUpdateDOM,
),
registerEventListener(rootElem, "compositionend", onCompositionEndDOM),
editor.registerUpdateListener(handleUpdate),
editor.registerCommand(
BLUR_COMMAND,
() => {
dismiss();
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
COMPOSITION_START_COMMAND,
() => {
dismiss();
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_TAB_COMMAND,
$handleCommitCommand,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
$handleCommitCommand,
COMMAND_PRIORITY_LOW,
),
addSwipeRightListener(rootElem, handleSwipeRight),
clearPendingCompositionTimer,
dismiss,
);
});
},
});
src/components/editor/extensions/ai.ts
import {
$createRangeSelection,
$getRoot,
type LexicalCommand,
type RangeSelection,
createCommand,
} from "lexical";
import {
$convertSelectionToMarkdownString,
TRANSFORMERS,
type Transformer,
} from "@lexical/markdown";
import type { Locale } from "@/components/editor/locales";
export type AiMode = "transform" | "insert";
export type AiCommandId =
| "improve"
| "longer"
| "shorter"
| "fix"
| "continue"
| "summarize"
| "brainstorm"
| "custom";
export interface AiContext {
text: string;
before: string;
after: string;
}
export interface AiRequest extends AiContext {
mode: AiMode;
command: AiCommandId;
prompt: string;
}
export interface AiCommandDefinition {
id: Exclude<AiCommandId, "custom">;
mode: AiMode;
labelKey: keyof Locale;
prompt: string;
}
export const AI_COMMANDS: readonly AiCommandDefinition[] = [
{
id: "improve",
mode: "transform",
labelKey: "aiImproveWriting",
prompt:
"Improve the writing of the following text. Keep its meaning and tone, fix awkward phrasing, and return only the rewritten text.",
},
{
id: "longer",
mode: "transform",
labelKey: "aiMakeLonger",
prompt:
"Expand the following text with more detail while keeping its meaning and tone. Return only the rewritten text.",
},
{
id: "shorter",
mode: "transform",
labelKey: "aiMakeShorter",
prompt:
"Shorten the following text while keeping its meaning. Return only the rewritten text.",
},
{
id: "fix",
mode: "transform",
labelKey: "aiFixSpelling",
prompt:
"Fix spelling and grammar mistakes in the following text without changing its meaning. Return only the corrected text.",
},
{
id: "continue",
mode: "insert",
labelKey: "aiContinueWriting",
prompt:
"Continue writing from where the following document ends, matching its tone and style. Return only the new text.",
},
{
id: "summarize",
mode: "insert",
labelKey: "aiSummarize",
prompt:
"Summarize the following document in a few sentences. Return only the summary.",
},
{
id: "brainstorm",
mode: "insert",
labelKey: "aiBrainstorm",
prompt:
"Brainstorm a short list of ideas related to the following document. Return only the list, one idea per line.",
},
];
export const OPEN_AI_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
"OPEN_AI_EDITOR_COMMAND",
);
export function $getAiContext(
selection: RangeSelection,
transformers: Transformer[] = TRANSFORMERS,
): AiContext {
const root = $getRoot();
const [start, end] = selection.isBackward()
? [selection.focus, selection.anchor]
: [selection.anchor, selection.focus];
const before = $createRangeSelection();
before.anchor.set(root.getKey(), 0, "element");
before.focus.set(start.key, start.offset, start.type);
const after = $createRangeSelection();
after.anchor.set(end.key, end.offset, end.type);
after.focus.set(root.getKey(), root.getChildrenSize(), "element");
return {
text: selection.isCollapsed()
? ""
: $convertSelectionToMarkdownString(transformers, selection),
before: $convertSelectionToMarkdownString(transformers, before),
after: $convertSelectionToMarkdownString(transformers, after),
};
}
export function toAiPrompt({ prompt, text, before, after }: AiRequest): string {
const sections = [prompt];
if (text !== "") {
sections.push(`Selected text (markdown):\n${text}`);
}
if (before !== "") {
sections.push(`Document before (markdown):\n${before}`);
}
if (after !== "") {
sections.push(`Document after (markdown):\n${after}`);
}
sections.push("Respond in markdown.");
return sections.join("\n\n");
}
src/components/editor/extensions/auto-link.ts
import { configExtension, defineExtension } from "lexical";
import { $isCodeNode } from "@lexical/code-core";
import {
AutoLinkExtension as LexicalAutoLinkExtension,
autoLinkEmailMatcher,
autoLinkUrlMatcher,
} from "@lexical/link";
export const AutoLinkExtension = defineExtension({
name: "@shadcn-editor/editor/AutoLink",
dependencies: [
configExtension(LexicalAutoLinkExtension, {
excludeParents: [$isCodeNode],
matchers: [autoLinkUrlMatcher, autoLinkEmailMatcher],
}),
],
});
src/components/editor/extensions/chat.ts
import {
$createParagraphNode,
$createTextNode,
$getRoot,
$isDecoratorNode,
$setSelection,
COMMAND_PRIORITY_BEFORE_EDITOR,
KEY_ENTER_COMMAND,
type Klass,
type LexicalCommand,
type LexicalEditor,
type LexicalNode,
type SerializedEditorState,
createCommand,
defineExtension,
safeCast,
} from "lexical";
import { CodeNode } from "@lexical/code-core";
import { HorizontalRuleNode } from "@lexical/extension";
import {
$convertFromMarkdownString,
$convertToMarkdownString,
CHECK_LIST,
ORDERED_LIST,
QUOTE,
TEXT_FORMAT_TRANSFORMERS,
TRANSFORMERS,
type Transformer,
UNORDERED_LIST,
registerMarkdownShortcuts,
} from "@lexical/markdown";
import { HeadingNode } from "@lexical/rich-text";
import { TableNode } from "@lexical/table";
import { $dfs, mergeRegister } from "@lexical/utils";
import { HR } from "@/components/editor/transformers/horizontal-rule-transformer";
import { TABLE } from "@/components/editor/transformers/table-transformer";
export interface ChatInputValue {
markdown: string;
text: string;
editorState: SerializedEditorState;
}
export interface ChatInputConfig {
submitOnEnter: boolean;
markdownShortcuts: boolean;
transformers: Transformer[];
}
export const CHAT_INPUT_TRANSFORMERS: Transformer[] = [
QUOTE,
UNORDERED_LIST,
ORDERED_LIST,
...TEXT_FORMAT_TRANSFORMERS,
];
export const CHAT_MESSAGE_TRANSFORMERS: Transformer[] = [
TABLE,
HR,
CHECK_LIST,
...TRANSFORMERS,
];
export const SUBMIT_CHAT_INPUT_COMMAND: LexicalCommand<void> = createCommand(
"SUBMIT_CHAT_INPUT_COMMAND",
);
export function filterTransformers(
editor: LexicalEditor,
transformers: Transformer[],
): Transformer[] {
return transformers.filter(
(transformer) =>
!("dependencies" in transformer) ||
editor.hasNodes(transformer.dependencies),
);
}
export function $isChatInputEmpty(): boolean {
if ($getRoot().getTextContent().trim() !== "") {
return false;
}
return !$dfs().some(({ node }) => $isDecoratorNode(node));
}
export function $getChatInputContent(
transformers: Transformer[] = CHAT_INPUT_TRANSFORMERS,
): Pick<ChatInputValue, "markdown" | "text"> {
return {
markdown: $convertToMarkdownString(transformers).trim(),
text: $getRoot().getTextContent().trim(),
};
}
export function $clearChatInput(): void {
const root = $getRoot();
root.clear();
root.append($createParagraphNode()).select();
}
export function $setChatMessageContent(
markdown: string,
transformers: Transformer[] = TRANSFORMERS,
): void {
$convertFromMarkdownString(markdown, transformers);
$setSelection(null);
}
function $replaceWithParagraphs(node: LexicalNode): void {
let target: LexicalNode = node;
for (const line of node.getTextContent().split("\n")) {
const paragraph = $createParagraphNode();
if (line !== "") {
paragraph.append($createTextNode(line));
}
target.insertAfter(paragraph);
target = paragraph;
}
node.remove();
}
function registerBlockFlattening(
editor: LexicalEditor,
klass: Klass<LexicalNode>,
$flatten: (node: LexicalNode) => void,
): () => void {
return editor.hasNode(klass)
? editor.registerNodeTransform(klass, $flatten)
: () => {};
}
function hasActiveTypeaheadOption(editor: LexicalEditor): boolean {
return (
editor
.getRootElement()
?.getAttribute("aria-activedescendant")
?.startsWith("typeahead-item-") ?? false
);
}
export const ChatInputExtension = defineExtension({
name: "@shadcn-editor/editor/ChatInput",
config: safeCast<ChatInputConfig>({
markdownShortcuts: true,
submitOnEnter: true,
transformers: CHAT_INPUT_TRANSFORMERS,
}),
register(editor, config) {
return mergeRegister(
registerBlockFlattening(editor, HeadingNode, (node) => {
node.replace($createParagraphNode(), true);
}),
registerBlockFlattening(editor, CodeNode, $replaceWithParagraphs),
registerBlockFlattening(editor, TableNode, $replaceWithParagraphs),
registerBlockFlattening(editor, HorizontalRuleNode, (node) => {
node.remove();
}),
config.submitOnEnter
? editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (
event === null ||
event.shiftKey ||
event.isComposing ||
!editor.isEditable()
) {
return false;
}
if (hasActiveTypeaheadOption(editor)) {
event.preventDefault();
return true;
}
if (
!editor.dispatchCommand(SUBMIT_CHAT_INPUT_COMMAND, undefined)
) {
return false;
}
event.preventDefault();
return true;
},
COMMAND_PRIORITY_BEFORE_EDITOR,
)
: () => {},
config.markdownShortcuts
? registerMarkdownShortcuts(
editor,
filterTransformers(editor, config.transformers),
)
: () => {},
);
},
});
src/components/editor/extensions/card.ts
import {
$createNodeSelection,
$createParagraphNode,
$getAdjacentNode,
$getSelection,
$getSlot,
$getSlotHost,
$getSlotNameWithinHost,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
$setSelection,
$setSlot,
COMMAND_PRIORITY_BEFORE_EDITOR,
COMMAND_PRIORITY_EDITOR,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_TAB_COMMAND,
type LexicalCommand,
type LexicalNode,
type NodeKey,
type PointType,
configExtension,
createCommand,
defineExtension,
isModifierMatch,
mergeRegister,
} from "lexical";
import { NodeSelectionDataSelectedExtension } from "@lexical/extension";
import {
CoreImportExtension,
DOMImportExtension,
DOMRenderExtension,
defineImportRule,
domOverride,
sel,
} from "@lexical/html";
import {
$appendInline,
$insertSlotHostAtRoot,
$isSlotHostTextEmpty,
registerHostChromeSelection,
registerSlotHostArrowEscape,
registerSlotHostBackspace,
} from "@/components/editor/extensions/slot-host";
import {
$createCardNode,
$isCardNode,
CardNode,
} from "@/components/editor/nodes/card-node";
export const INSERT_CARD_COMMAND: LexicalCommand<void> = createCommand(
"INSERT_CARD_COMMAND",
);
function $handleCardArrow(
event: KeyboardEvent | null,
isBackward: boolean,
): boolean {
const selection = $getSelection();
if (
!$isRangeSelection(selection) ||
(event !== null && !isModifierMatch(event, {}))
) {
return false;
}
const adjacent = $getAdjacentNode(selection.focus, isBackward);
if (!$isCardNode(adjacent)) {
return false;
}
event?.preventDefault();
const ns = $createNodeSelection();
ns.add(adjacent.getKey());
$setSelection(ns);
return true;
}
function $findCardSlotContext(
start: LexicalNode,
):
| { card: CardNode; in: "title"; slotValue: LexicalNode }
| { card: CardNode; in: "body" }
| null {
let cursor: LexicalNode | null = start;
while (cursor !== null) {
const slotName = $getSlotNameWithinHost(cursor);
if (slotName === "title") {
const host = $getSlotHost(cursor);
if ($isCardNode(host)) {
return { card: host, in: "title", slotValue: cursor };
}
}
const parent: LexicalNode | null = cursor.getParent();
if ($isCardNode(parent)) {
return { card: parent, in: "body" };
}
cursor = parent ?? $getSlotHost(cursor);
}
return null;
}
function $isAtBlockEnd(point: PointType, block: LexicalNode): boolean {
if (!$isElementNode(block)) {
return false;
}
const last = block.getLastDescendant();
if (last === null) {
return point.key === block.getKey() && point.offset === 0;
}
if ($isTextNode(last)) {
return (
point.key === last.getKey() && point.offset === last.getTextContentSize()
);
}
return false;
}
function $isAtBlockStart(point: PointType, block: LexicalNode): boolean {
if (!$isElementNode(block)) {
return false;
}
const first = block.getFirstDescendant();
if (first === null) {
return point.key === block.getKey() && point.offset === 0;
}
if ($isTextNode(first)) {
return point.key === first.getKey() && point.offset === 0;
}
return false;
}
function $handleCardTab(
event: KeyboardEvent | null,
isBackward: boolean,
): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}
const context = $findCardSlotContext(selection.anchor.getNode());
if (context === null) {
return false;
}
if (!isBackward && context.in === "title") {
const titleBlock = context.slotValue;
if (
!$isElementNode(titleBlock) ||
!$isAtBlockEnd(selection.anchor, titleBlock)
) {
return false;
}
const bodyFirst = context.card.getFirstChild();
if ($isElementNode(bodyFirst)) {
event?.preventDefault();
bodyFirst.selectStart();
return true;
}
event?.preventDefault();
const bodyParagraph = $createParagraphNode();
if (bodyFirst === null) {
context.card.append(bodyParagraph);
} else {
bodyFirst.insertBefore(bodyParagraph);
}
bodyParagraph.select();
return true;
} else if (isBackward && context.in === "body") {
const bodyFirst = context.card.getFirstChild();
if (bodyFirst === null || !$isAtBlockStart(selection.anchor, bodyFirst)) {
return false;
}
const titleSlot = $getSlot(context.card, "title");
if ($isElementNode(titleSlot)) {
event?.preventDefault();
titleSlot.selectEnd();
return true;
}
}
return false;
}
const CardImportRule = defineImportRule({
$import: (ctx, el) => {
const card = $createCardNode().clear();
const prevTitle = $getSlot(card, "title");
const title = $isParagraphNode(prevTitle)
? prevTitle.clear()
: $createParagraphNode();
$setSlot(card, "title", title);
for (const domChild of Array.from(el.children)) {
const slotName = domChild.getAttribute("data-lexical-slot");
if (slotName === "title") {
$appendInline(title, ctx.$importChildren(domChild));
} else {
card.splice(card.getChildrenSize(), 0, ctx.$importOne(domChild));
}
}
return [card];
},
match: sel.tag("div").attr("data-lexical-card", true),
name: "@shadcn-editor/editor/card",
});
export const CardExtension = defineExtension({
name: "@shadcn-editor/editor/Card",
nodes: () => [CardNode],
dependencies: [
configExtension(NodeSelectionDataSelectedExtension, {
nodes: [CardNode],
}),
configExtension(DOMRenderExtension, {
overrides: [
domOverride([CardNode], {
$getSlotTargetElement: (_node, _slotName, hostDom) => hostDom,
}),
],
}),
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [CardImportRule],
}),
],
register: (editor) => {
return mergeRegister(
editor.registerCommand(
INSERT_CARD_COMMAND,
() => {
$insertSlotHostAtRoot($createCardNode());
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
(event) => $handleCardArrow(event, false),
COMMAND_PRIORITY_BEFORE_EDITOR,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
(event) => $handleCardArrow(event, true),
COMMAND_PRIORITY_BEFORE_EDITOR,
),
editor.registerCommand(
KEY_TAB_COMMAND,
(event) => $handleCardTab(event, event.shiftKey),
COMMAND_PRIORITY_BEFORE_EDITOR,
),
registerHostChromeSelection(editor, $isCardNode),
registerSlotHostArrowEscape(editor, $isCardNode),
registerSlotHostBackspace(editor, $isCardNode, $isSlotHostTextEmpty),
(() => {
let prevCardKey: NodeKey | null = null;
let prevSlot: "title" | "body" | null = null;
return editor.registerUpdateListener(({ editorState }) => {
let activeCardKey: NodeKey | null = null;
let activeSlot: "title" | "body" | null = null;
editorState.read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const context = $findCardSlotContext(selection.anchor.getNode());
if (context !== null) {
activeCardKey = context.card.getKey();
activeSlot = context.in;
}
}
});
if (prevCardKey === activeCardKey && prevSlot === activeSlot) {
return;
}
if (prevCardKey !== null && prevCardKey !== activeCardKey) {
const dom = editor.getElementByKey(prevCardKey);
if (dom !== null) {
dom.removeAttribute("data-current-slot");
}
}
if (activeCardKey !== null && activeSlot !== null) {
const dom = editor.getElementByKey(activeCardKey);
if (dom !== null) {
dom.setAttribute("data-current-slot", activeSlot);
}
}
prevCardKey = activeCardKey;
prevSlot = activeSlot;
});
})(),
);
},
});
src/components/editor/extensions/code.tsx
import {
$getRoot,
$getSelection,
$isRangeSelection,
$nodesOfType,
type LexicalEditor,
configExtension,
defineExtension,
} from "lexical";
import {
$createCodeNode,
$isCodeNode,
CodeIndentExtension,
CodeNode,
} from "@lexical/code-core";
import {
CodeShikiExtension,
ShikiTokenizer,
type Tokenizer,
} from "@lexical/code-shiki";
import { $setBlocksType } from "@lexical/selection";
export function insertCodeBlock(editor: LexicalEditor) {
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
if (!$isRangeSelection(selection)) {
return;
}
if ($isCodeNode(selection.anchor.getNode().getTopLevelElement())) {
return;
}
if (selection.isCollapsed()) {
$setBlocksType(selection, () => $createCodeNode());
return;
}
const textContent = selection.getTextContent();
const codeNode = $createCodeNode();
selection.insertNodes([codeNode]);
const updatedSelection = $getSelection();
if ($isRangeSelection(updatedSelection)) {
updatedSelection.insertRawText(textContent);
}
});
}
const LIGHT_CODE_THEME = "github-light";
const DARK_CODE_THEME = "github-dark";
function getPreferredCodeTheme() {
if (typeof document === "undefined") {
return LIGHT_CODE_THEME;
}
return document.documentElement.classList.contains("dark")
? DARK_CODE_THEME
: LIGHT_CODE_THEME;
}
const tokenizer: Tokenizer = {
...ShikiTokenizer,
defaultTheme: getPreferredCodeTheme(),
};
export const CodeExtension = defineExtension({
name: "@shadcn-editor/editor/Code",
dependencies: [
configExtension(CodeShikiExtension, { tokenizer }),
configExtension(CodeIndentExtension, {
tabSize: 2,
escapeWithArrows: true,
}),
],
register: (editor) => {
const syncCodeTheme = () => {
const theme = getPreferredCodeTheme();
editor.update(() => {
for (const codeNode of $nodesOfType(CodeNode)) {
if (codeNode.getTheme() !== theme) {
codeNode.setTheme(theme);
}
}
});
};
const observer = new MutationObserver(syncCodeTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
},
});
src/components/editor/extensions/collapsible.ts
import {
$createParagraphNode,
$findMatchingParent,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
INSERT_PARAGRAPH_COMMAND,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
type LexicalCommand,
type LexicalNode,
configExtension,
createCommand,
defineExtension,
mergeRegister,
} from "lexical";
import {
BlockSchema,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import { $insertNodeToNearestRoot } from "@lexical/utils";
import {
$createCollapsibleContainerNode,
$createCollapsibleContentNode,
$createCollapsibleTitleNode,
$isCollapsibleContainerNode,
$isCollapsibleContentNode,
$isCollapsibleTitleNode,
CollapsibleContainerNode,
CollapsibleContentNode,
CollapsibleTitleNode,
} from "@/components/editor/nodes/collapsible-node";
export const INSERT_COLLAPSIBLE_COMMAND: LexicalCommand<void> = createCommand(
"INSERT_COLLAPSIBLE_COMMAND",
);
const SummaryRule = defineImportRule({
$import: (ctx, el) => [
$createCollapsibleTitleNode().splice(0, 0, ctx.$importChildren(el)),
],
match: sel.tag("summary"),
name: "@shadcn-editor/editor/summary",
});
const CollapsibleContentRule = defineImportRule({
$import: (ctx, el) => [
$createCollapsibleContentNode().splice(
0,
0,
ctx.$importChildren(el, { schema: BlockSchema }),
),
],
match: sel.tag("div").attr("data-lexical-collapsible-content", true),
name: "@shadcn-editor/editor/collapsible-content",
});
const DetailsRule = defineImportRule({
$import: (ctx, el) => {
let titleNode: CollapsibleTitleNode | null = null;
const bodyNodes = ctx.$importChildren(el, {
$onChild: (child) => {
if (titleNode === null && $isCollapsibleTitleNode(child)) {
titleNode = child;
return null;
}
return child;
},
schema: BlockSchema,
});
let contentNode: CollapsibleContentNode | null = null;
const restBody: LexicalNode[] = [];
for (const child of bodyNodes) {
if ($isCollapsibleContentNode(child)) {
if (contentNode === null) {
contentNode = child;
} else {
for (const grand of child.getChildren()) {
restBody.push(grand);
}
}
} else {
restBody.push(child);
}
}
if (titleNode === null) {
titleNode = $createCollapsibleTitleNode();
}
if (contentNode === null) {
contentNode = $createCollapsibleContentNode();
}
for (const node of restBody) {
contentNode.append(node);
}
return [
$createCollapsibleContainerNode(el.open).append(titleNode, contentNode),
];
},
match: sel.tag("details"),
name: "@shadcn-editor/editor/details",
});
function $onEscapeUp(): boolean {
const selection = $getSelection();
if (
$isRangeSelection(selection) &&
selection.isCollapsed() &&
selection.anchor.offset === 0
) {
const container = $findMatchingParent(
selection.anchor.getNode(),
$isCollapsibleContainerNode,
);
if ($isCollapsibleContainerNode(container)) {
const parent = container.getParent();
if (
parent !== null &&
parent.getFirstChild() === container &&
selection.anchor.key === container.getFirstDescendant()?.getKey()
) {
container.insertBefore($createParagraphNode());
}
}
}
return false;
}
function $onEscapeDown(): boolean {
const selection = $getSelection();
if ($isRangeSelection(selection) && selection.isCollapsed()) {
const container = $findMatchingParent(
selection.anchor.getNode(),
$isCollapsibleContainerNode,
);
if ($isCollapsibleContainerNode(container)) {
const parent = container.getParent();
if (parent !== null && parent.getLastChild() === container) {
const titleParagraph = container.getFirstDescendant();
const contentParagraph = container.getLastDescendant();
if (
(contentParagraph !== null &&
selection.anchor.key === contentParagraph.getKey() &&
selection.anchor.offset ===
contentParagraph.getTextContentSize()) ||
(titleParagraph !== null &&
selection.anchor.key === titleParagraph.getKey() &&
selection.anchor.offset === titleParagraph.getTextContentSize() &&
!container.getOpen())
) {
container.insertAfter($createParagraphNode());
}
}
}
}
return false;
}
export const CollapsibleExtension = defineExtension({
name: "@shadcn-editor/editor/Collapsible",
nodes: () => [
CollapsibleContainerNode,
CollapsibleTitleNode,
CollapsibleContentNode,
],
dependencies: [
configExtension(DOMImportExtension, {
rules: [DetailsRule, SummaryRule, CollapsibleContentRule],
}),
],
register: (editor) =>
mergeRegister(
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
const parent = node.getParent();
if (!$isCollapsibleContainerNode(parent)) {
const children = node.getChildren();
for (const child of children) {
node.insertBefore(child);
}
node.remove();
} else if (node.isEmpty()) {
node.append($createParagraphNode());
}
}),
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
const parent = node.getParent();
if (!$isCollapsibleContainerNode(parent)) {
node.replace($createParagraphNode().append(...node.getChildren()));
}
}),
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
const children = node.getChildren();
if (
children.length !== 2 ||
!$isCollapsibleTitleNode(children[0]) ||
!$isCollapsibleContentNode(children[1])
) {
for (const child of children) {
node.insertBefore(child);
}
node.remove();
}
}),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
$onEscapeDown,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
$onEscapeDown,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
$onEscapeUp,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
$onEscapeUp,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const titleNode = $findMatchingParent(
selection.anchor.getNode(),
$isCollapsibleTitleNode,
);
if ($isCollapsibleTitleNode(titleNode)) {
const container = titleNode.getParent();
if (container && $isCollapsibleContainerNode(container)) {
if (!container.getOpen()) {
container.toggleOpen();
}
titleNode.getNextSibling()?.selectEnd();
return true;
}
}
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
INSERT_COLLAPSIBLE_COMMAND,
() => {
const title = $createCollapsibleTitleNode();
const paragraph = $createParagraphNode();
$insertNodeToNearestRoot(
$createCollapsibleContainerNode(true).append(
title.append(paragraph),
$createCollapsibleContentNode().append($createParagraphNode()),
),
);
paragraph.select();
return true;
},
COMMAND_PRIORITY_LOW,
),
),
});
src/components/editor/extensions/comment.ts
import {
$getNodeByKey,
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
type LexicalEditor,
type NodeKey,
createCommand,
defineExtension,
getDOMSelection,
} from "lexical";
import { effect, namedSignals } from "@lexical/extension";
import {
$createMarkNode,
$getMarkIDs,
$isMarkNode,
$unwrapMarkNode,
MarkExtension,
MarkNode,
} from "@lexical/mark";
import { mergeRegister, registerNestedElementResolver } from "@lexical/utils";
export type Comment = {
author: string;
content: string;
deleted: boolean;
id: string;
timeStamp: number;
type: "comment";
};
export type Thread = {
comments: Comment[];
id: string;
quote: string;
type: "thread";
};
export type Comments = (Thread | Comment)[];
function createUID(): string {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, "")
.substring(0, 5);
}
export function createComment(
content: string,
author: string,
id?: string,
timeStamp?: number,
deleted?: boolean,
): Comment {
return {
author,
content,
deleted: deleted ?? false,
id: id ?? createUID(),
timeStamp: timeStamp ?? performance.timeOrigin + performance.now(),
type: "comment",
};
}
export function createThread(
quote: string,
comments: Comment[],
id?: string,
): Thread {
return {
comments,
id: id ?? createUID(),
quote,
type: "thread",
};
}
function cloneThread(thread: Thread): Thread {
return {
comments: Array.from(thread.comments),
id: thread.id,
quote: thread.quote,
type: "thread",
};
}
function markDeleted(comment: Comment): Comment {
return {
author: comment.author,
content: "",
deleted: true,
id: comment.id,
timeStamp: comment.timeStamp,
type: "comment",
};
}
function createCommentState() {
return {
...namedSignals({
activeAnchorKey: null as NodeKey | null,
activeIDs: [] as string[],
comments: [] as Comments,
showCommentInput: false,
}),
markNodeMap: new Map<string, Set<NodeKey>>(),
};
}
export type CommentState = ReturnType<typeof createCommentState>;
export function addComment(
state: CommentState,
commentOrThread: Comment | Thread,
thread?: Thread,
offset?: number,
): void {
const nextComments = Array.from(state.comments.peek());
if (thread !== undefined && commentOrThread.type === "comment") {
for (let i = 0; i < nextComments.length; i++) {
const comment = nextComments[i];
if (comment.type === "thread" && comment.id === thread.id) {
const newThread = cloneThread(comment);
nextComments.splice(i, 1, newThread);
const insertOffset = offset ?? newThread.comments.length;
newThread.comments.splice(insertOffset, 0, commentOrThread);
break;
}
}
} else {
nextComments.splice(offset ?? nextComments.length, 0, commentOrThread);
}
state.comments.value = nextComments;
}
export function deleteCommentOrThread(
state: CommentState,
commentOrThread: Comment | Thread,
thread?: Thread,
): { markedComment: Comment; index: number } | null {
const nextComments = Array.from(state.comments.peek());
let commentIndex = 0;
if (thread !== undefined) {
for (let i = 0; i < nextComments.length; i++) {
const nextComment = nextComments[i];
if (nextComment.type === "thread" && nextComment.id === thread.id) {
const newThread = cloneThread(nextComment);
nextComments.splice(i, 1, newThread);
commentIndex = newThread.comments.indexOf(commentOrThread as Comment);
newThread.comments.splice(commentIndex, 1);
break;
}
}
} else {
commentIndex = nextComments.indexOf(commentOrThread);
nextComments.splice(commentIndex, 1);
}
state.comments.value = nextComments;
if (commentOrThread.type === "comment") {
return {
index: commentIndex,
markedComment: markDeleted(commentOrThread),
};
}
return null;
}
export function removeThreadMarks(
editor: LexicalEditor,
state: CommentState,
id: string,
): void {
const markNodeKeys = state.markNodeMap.get(id);
if (markNodeKeys === undefined) {
return;
}
setTimeout(() => {
editor.update(() => {
for (const key of markNodeKeys) {
const node = $getNodeByKey(key);
if ($isMarkNode(node)) {
node.deleteID(id);
if (node.getIDs().length === 0) {
$unwrapMarkNode(node);
}
}
}
});
});
}
export const INSERT_INLINE_COMMAND: LexicalCommand<void> = createCommand(
"INSERT_INLINE_COMMAND",
);
export const CLOSE_COMMENT_INPUT_COMMAND: LexicalCommand<void> = createCommand(
"CLOSE_COMMENT_INPUT_COMMAND",
);
export const CommentExtension = defineExtension({
name: "@shadcn-editor/editor/Comment",
dependencies: [MarkExtension],
build: createCommentState,
register: (editor, _config, state) => {
const output = state.getOutput();
const { markNodeMap } = output;
const markNodeKeysToIDs = new Map<NodeKey, string[]>();
return mergeRegister(
registerNestedElementResolver<MarkNode>(
editor,
MarkNode,
(from) => $createMarkNode(from.getIDs()),
(from, to) => {
for (const id of from.getIDs()) {
to.addID(id);
}
},
),
editor.registerMutationListener(
MarkNode,
(mutations) => {
editor.read("latest", () => {
for (const [key, mutation] of mutations) {
const node = $getNodeByKey(key);
let ids: string[] = [];
if (mutation === "destroyed") {
ids = markNodeKeysToIDs.get(key) ?? [];
} else if ($isMarkNode(node)) {
ids = node.getIDs();
}
for (const id of ids) {
let markNodeKeys = markNodeMap.get(id);
markNodeKeysToIDs.set(key, ids);
if (mutation === "destroyed") {
if (markNodeKeys !== undefined) {
markNodeKeys.delete(key);
if (markNodeKeys.size === 0) {
markNodeMap.delete(id);
}
}
} else {
if (markNodeKeys === undefined) {
markNodeKeys = new Set();
markNodeMap.set(id, markNodeKeys);
}
markNodeKeys.add(key);
}
}
}
});
},
{ skipInitialization: false },
),
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
let hasActiveIds = false;
let hasAnchorKey = false;
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
if ($isTextNode(anchorNode)) {
const commentIDs = $getMarkIDs(
anchorNode,
selection.anchor.offset,
);
if (commentIDs !== null) {
output.activeIDs.value = commentIDs;
hasActiveIds = true;
}
if (!selection.isCollapsed()) {
output.activeAnchorKey.value = anchorNode.getKey();
hasAnchorKey = true;
}
}
}
if (!hasActiveIds && output.activeIDs.peek().length > 0) {
output.activeIDs.value = [];
}
if (!hasAnchorKey) {
output.activeAnchorKey.value = null;
}
if ($isRangeSelection(selection)) {
output.showCommentInput.value = false;
}
});
}),
editor.registerCommand(
INSERT_INLINE_COMMAND,
() => {
const domSelection = getDOMSelection(editor._window);
if (domSelection !== null) {
domSelection.removeAllRanges();
}
output.showCommentInput.value = true;
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
CLOSE_COMMENT_INPUT_COMMAND,
() => {
output.showCommentInput.value = false;
return true;
},
COMMAND_PRIORITY_EDITOR,
),
effect(() => {
const activeIDs = output.activeIDs.value;
const changedElems: HTMLElement[] = [];
for (const id of activeIDs) {
const keys = markNodeMap.get(id);
if (keys !== undefined) {
for (const key of keys) {
const elem = editor.getElementByKey(key);
if (elem !== null) {
elem.classList.add("selected");
changedElems.push(elem);
}
}
}
}
return () => {
for (const elem of changedElems) {
elem.classList.remove("selected");
}
};
}),
);
},
});
src/components/editor/extensions/datetime.ts
import {
$createParagraphNode,
$getSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import { applyFormatFromStyle } from "@lexical/extension";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import { $insertNodeIntoLeaf, $wrapNodeInElement } from "@lexical/utils";
import {
$createDateTimeNode,
DateTimeNode,
} from "@/components/editor/nodes/datetime-node";
type CommandPayload = {
dateTime: Date;
};
export const INSERT_DATETIME_COMMAND: LexicalCommand<CommandPayload> =
createCommand("INSERT_DATETIME_COMMAND");
const DateTimeRule = defineImportRule({
$import: (ctx, el, $next) => {
const dateTimeValue = el.getAttribute("data-lexical-datetime")!;
const parsedDate = Date.parse(dateTimeValue);
if (isNaN(parsedDate)) {
return $next();
}
const node = $createDateTimeNode(new Date(parsedDate));
const [firstChild] = ctx.$importChildren(el);
if ($isTextNode(firstChild)) {
node.setFormat(firstChild.getFormat());
}
return [node];
},
match: sel.tag("span").attr("data-lexical-datetime", true),
name: "@shadcn-editor/editor/datetime",
});
const GoogleDocsDateRule = defineImportRule({
$import: (_ctx, el, $next) => {
let parsed: {
dat_df?: { dfie_ts?: { tv?: { tv_s?: number } }; dfie_dt?: string };
};
try {
parsed = JSON.parse(el.getAttribute("data-rich-links") ?? "{}");
} catch {
return $next();
}
if (parsed?.dat_df === undefined) {
return $next();
}
const parsedDate =
(parsed.dat_df.dfie_ts?.tv?.tv_s ?? 0) * 1000 ||
Date.parse(parsed.dat_df.dfie_dt ?? "");
if (isNaN(parsedDate)) {
return $next();
}
return [
applyFormatFromStyle($createDateTimeNode(new Date(parsedDate)), el.style),
];
},
match: sel.tag("span").attr("data-rich-links", /"type"\s*:\s*"date"/),
name: "@shadcn-editor/editor/datetime-google-docs",
});
export const DateTimeExtension = defineExtension({
name: "@shadcn-editor/editor/DateTime",
nodes: () => [DateTimeNode],
dependencies: [
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [DateTimeRule, GoogleDocsDateRule],
}),
],
register: (editor) =>
editor.registerCommand(
INSERT_DATETIME_COMMAND,
(payload) => {
const { dateTime } = payload;
const dateTimeNode = $createDateTimeNode(dateTime);
const selection = $getSelection();
if ($isRangeSelection(selection)) {
dateTimeNode.setFormat(selection.format);
}
$insertNodeIntoLeaf(dateTimeNode);
if ($isRootOrShadowRoot(dateTimeNode.getParent())) {
$wrapNodeInElement(dateTimeNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/drag-drop-paste.ts
import { COMMAND_PRIORITY_LOW, defineExtension } from "lexical";
import { DRAG_DROP_PASTE } from "@lexical/rich-text";
import { isMimeType, mediaFileReader } from "@lexical/utils";
import { ImageExtension } from "@/components/editor/extensions/image";
import { INSERT_IMAGE_COMMAND } from "@/components/editor/nodes/image-node";
const ACCEPTABLE_IMAGE_TYPES = [
"image/",
"image/heic",
"image/heif",
"image/gif",
"image/webp",
];
export const DragDropPasteExtension = defineExtension({
name: "@shadcn-editor/drag-drop-paste",
dependencies: [ImageExtension],
register: (editor) =>
editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(async () => {
const filesResult = await mediaFileReader(
files,
ACCEPTABLE_IMAGE_TYPES,
);
for (const { file, result } of filesResult) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
altText: file.name,
src: result,
});
}
}
})();
return true;
},
COMMAND_PRIORITY_LOW,
),
});
src/components/editor/extensions/emoji.ts
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
TextNode,
createCommand,
defineExtension,
} from "lexical";
import compactEmojis from "emojibase-data/en/compact.json";
import {
$createEmojiNode,
EMOJI_CLASS_NAME,
EmojiNode,
} from "@/components/editor/nodes/emoji-node";
export type InsertEmojiPayload = { emoji: string };
export const INSERT_EMOJI_COMMAND: LexicalCommand<InsertEmojiPayload> =
createCommand("INSERT_EMOJI_COMMAND");
const AMBIGUOUS_EMOTICON = /^:[A-Za-z0-9/\\]$/;
const EMOTICON_TO_EMOJI: Record<string, string> = {};
for (const entry of compactEmojis) {
if (!entry.emoticon) {
continue;
}
const emoticons = Array.isArray(entry.emoticon)
? entry.emoticon
: [entry.emoticon];
for (const emoticon of emoticons) {
if (
!AMBIGUOUS_EMOTICON.test(emoticon) &&
!(emoticon in EMOTICON_TO_EMOJI)
) {
EMOTICON_TO_EMOJI[emoticon] = entry.unicode;
}
}
}
const EMOTICONS_BY_LENGTH = Object.keys(EMOTICON_TO_EMOJI).sort(
(a, b) => b.length - a.length,
);
const MAX_EMOTICON_LENGTH =
EMOTICONS_BY_LENGTH.length > 0 ? EMOTICONS_BY_LENGTH[0].length : 0;
function findEmoticonMatch(
text: string,
): { start: number; end: number; emoji: string } | null {
for (let i = 0; i < text.length; i++) {
const maxLength = Math.min(MAX_EMOTICON_LENGTH, text.length - i);
for (let length = maxLength; length >= 1; length--) {
const candidate = text.substring(i, i + length);
const emoji = EMOTICON_TO_EMOJI[candidate];
if (emoji) {
return { start: i, end: i + length, emoji };
}
}
}
return null;
}
function $emoticonTextNodeTransform(node: TextNode): void {
if (!node.isSimpleText()) {
return;
}
const text = node.getTextContent();
const match = findEmoticonMatch(text);
if (match === null) {
return;
}
const targetNode =
match.start === 0
? node.splitText(match.end)[0]
: node.splitText(match.start, match.end)[1];
targetNode.replace($createEmojiNode(EMOJI_CLASS_NAME, match.emoji));
}
export const EmojiExtension = defineExtension({
name: "@shadcn-editor/editor/Emoji",
nodes: () => [EmojiNode],
register: (editor) => {
const unregisterTransform = editor.registerNodeTransform(
TextNode,
$emoticonTextNodeTransform,
);
const unregisterInsert = editor.registerCommand(
INSERT_EMOJI_COMMAND,
({ emoji }) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.insertNodes([$createEmojiNode(EMOJI_CLASS_NAME, emoji)]);
return true;
},
COMMAND_PRIORITY_EDITOR,
);
return () => {
unregisterTransform();
unregisterInsert();
};
},
});
src/components/editor/extensions/equation.ts
import {
$createParagraphNode,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import {
$insertNodeIntoLeaf,
$insertNodeToNearestRoot,
$wrapNodeInElement,
} from "@lexical/utils";
import {
$createEquationNode,
EquationNode,
decodeEquation,
} from "@/components/editor/nodes/equation-node";
export type InsertEquationPayload = {
equation: string;
inline: boolean;
};
export const INSERT_EQUATION_COMMAND: LexicalCommand<InsertEquationPayload> =
createCommand("INSERT_EQUATION_COMMAND");
function $convertEquationElement(el: HTMLElement) {
const encoded = el.getAttribute("data-lexical-equation");
if (!encoded) {
return null;
}
const equation = decodeEquation(encoded);
if (!equation) {
return null;
}
const inline = el.getAttribute("data-lexical-inline") === "true";
return $createEquationNode(equation, inline);
}
const EquationImportRule = defineImportRule({
$import: (_ctx, el, $next) => {
const node = $convertEquationElement(el);
return node ? [node] : $next();
},
match: sel.tag("div", "span").attr("data-lexical-equation", true),
name: "@shadcn-editor/editor/equation",
});
export const EquationExtension = defineExtension({
name: "@shadcn-editor/editor/Equation",
nodes: () => [EquationNode],
dependencies: [
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [EquationImportRule],
}),
],
register: (editor) =>
editor.registerCommand(
INSERT_EQUATION_COMMAND,
({ equation, inline }) => {
const equationNode = $createEquationNode(equation, inline);
if (inline) {
$insertNodeIntoLeaf(equationNode);
if ($isRootOrShadowRoot(equationNode.getParent())) {
$wrapNodeInElement(equationNode, $createParagraphNode).selectEnd();
}
} else {
$insertNodeToNearestRoot(equationNode);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/figma.ts
import {
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import { DOMImportExtension, defineImportRule, sel } from "@lexical/html";
import { $insertNodeToNearestRoot } from "@lexical/utils";
import {
$createFigmaNode,
FigmaNode,
} from "@/components/editor/nodes/figma-node";
export const INSERT_FIGMA_COMMAND: LexicalCommand<string> = createCommand(
"INSERT_FIGMA_COMMAND",
);
const FigmaImportRule = defineImportRule({
$import: (ctx) => [$createFigmaNode(ctx.captures.id[0])],
match: sel
.tag("iframe")
.attr("data-lexical-figma", /^.+$/, { capture: "id" }),
name: "@shadcn-editor/editor/figma",
});
export const FigmaExtension = defineExtension({
name: "@shadcn-editor/editor/Figma",
dependencies: [
configExtension(DOMImportExtension, {
rules: [FigmaImportRule],
}),
],
nodes: () => [FigmaNode],
register: (editor) =>
editor.registerCommand(
INSERT_FIGMA_COMMAND,
(documentID) => {
const figmaNode = $createFigmaNode(documentID);
$insertNodeToNearestRoot(figmaNode);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/find-replace.ts
import {
$getNodeByKeyOrThrow,
$getRoot,
$isElementNode,
$isLineBreakNode,
$isTextNode,
COMMAND_PRIORITY_LOW,
CONTROL_OR_META,
KEY_DOWN_COMMAND,
type LexicalCommand,
type LexicalEditor,
type NodeKey,
createCommand,
defineExtension,
isExactShortcutMatch,
} from "lexical";
import {
computed,
effect,
namedSignals,
watchedSignal,
} from "@lexical/extension";
import { createDOMRange, createRectsFromDOMRange } from "@lexical/selection";
import { $dfsWithSlotsIterator, mergeRegister } from "@lexical/utils";
const MATCH_HIGHLIGHT = "editor-find-match";
const CURRENT_MATCH_HIGHLIGHT = "editor-find-match-current";
export interface TextMatch {
end: number;
matchText: string;
start: number;
}
interface OffsetEntry {
globalEnd: number;
globalStart: number;
key: NodeKey;
}
interface MatchPoints {
anchorKey: NodeKey;
anchorOffset: number;
focusKey: NodeKey;
focusOffset: number;
format: number;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function findMatches(
text: string,
searchTerm: string,
caseSensitive: boolean,
isRegex: boolean,
): TextMatch[] {
if (!searchTerm || !text) {
return [];
}
const pattern = isRegex ? searchTerm : escapeRegExp(searchTerm);
const flags = "g" + (caseSensitive ? "" : "i");
let regex: RegExp;
try {
regex = new RegExp(pattern, flags);
} catch {
return [];
}
const matches: TextMatch[] = [];
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match[0].length === 0) {
regex.lastIndex++;
continue;
}
matches.push({
end: match.index + match[0].length,
matchText: match[0],
start: match.index,
});
}
return matches;
}
function expandReplacement(
template: string,
matchText: string,
searchRegex: RegExp | null,
): string {
if (!searchRegex) {
return template;
}
return matchText.replace(searchRegex, template);
}
function buildSearchRegex(
searchTerm: string,
caseSensitive: boolean,
): RegExp | null {
try {
return new RegExp(searchTerm, caseSensitive ? "" : "i");
} catch {
return null;
}
}
function $buildOffsetMap(): OffsetEntry[] {
const entries: OffsetEntry[] = [];
let offset = 0;
let prevNonInlineDepth: number | null = null;
for (const { node, depth } of $dfsWithSlotsIterator()) {
if ($isElementNode(node) && !node.isInline() && depth > 0) {
if (prevNonInlineDepth !== null && depth <= prevNonInlineDepth) {
offset += 2;
}
prevNonInlineDepth = depth;
}
if ($isLineBreakNode(node)) {
offset += 1;
} else if ($isTextNode(node)) {
const length = node.getTextContentSize();
entries.push({
globalEnd: offset + length,
globalStart: offset,
key: node.getKey(),
});
offset += length;
}
}
return entries;
}
function findEntryForOffset(
offsetMap: OffsetEntry[],
offset: number,
): OffsetEntry | null {
let lo = 0;
let hi = offsetMap.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
const entry = offsetMap[mid];
if (offset < entry.globalStart) {
hi = mid - 1;
} else if (offset >= entry.globalEnd) {
lo = mid + 1;
} else {
return entry;
}
}
return null;
}
function $resolveMatchToPoints(
match: TextMatch,
offsetMap: OffsetEntry[],
): MatchPoints | null {
const anchorEntry = findEntryForOffset(offsetMap, match.start);
const focusEntry = findEntryForOffset(offsetMap, match.end - 1);
if (!anchorEntry || !focusEntry) {
return null;
}
const anchorOffset = match.start - anchorEntry.globalStart;
const focusOffset = match.end - focusEntry.globalStart;
const isSingleNode = anchorEntry.key === focusEntry.key;
const anchorNode = $getNodeByKeyOrThrow(anchorEntry.key);
const format =
isSingleNode && $isTextNode(anchorNode) ? anchorNode.getFormat() : 0;
return {
anchorKey: anchorEntry.key,
anchorOffset,
focusKey: focusEntry.key,
focusOffset,
format,
};
}
function $replaceMatch(
points: MatchPoints,
replacementText: string,
match: TextMatch,
searchRegex: RegExp | null,
): void {
const finalText = searchRegex
? expandReplacement(replacementText, match.matchText, searchRegex)
: replacementText;
const anchorNode = $getNodeByKeyOrThrow(points.anchorKey);
const focusNode = $getNodeByKeyOrThrow(points.focusKey);
if (!$isTextNode(anchorNode) || !$isTextNode(focusNode)) {
return;
}
if (points.anchorKey === points.focusKey) {
anchorNode.spliceText(
points.anchorOffset,
points.focusOffset - points.anchorOffset,
finalText,
true,
);
return;
}
const selection = anchorNode.select(0, 0);
selection.setTextNodeRange(
anchorNode,
points.anchorOffset,
focusNode,
points.focusOffset,
);
selection.format = points.format;
selection.insertText(finalText);
}
function $replaceAllMatches(
matches: TextMatch[],
offsetMap: OffsetEntry[],
replacementText: string,
searchRegex: RegExp | null,
): void {
for (let index = matches.length - 1; index >= 0; index--) {
const points = $resolveMatchToPoints(matches[index], offsetMap);
if (points) {
$replaceMatch(points, replacementText, matches[index], searchRegex);
}
}
}
const SUPPORTS_CSS_HIGHLIGHTS =
typeof Highlight !== "undefined" &&
typeof CSS !== "undefined" &&
"highlights" in CSS;
interface HighlightState {
allHighlight: Highlight | null;
currentHighlight: Highlight | null;
overlayContainer: HTMLDivElement | null;
originalParentPosition: string | null;
}
function createHighlightState(): HighlightState {
return {
allHighlight: null,
currentHighlight: null,
originalParentPosition: null,
overlayContainer: null,
};
}
function ensureHighlightRegistered(state: HighlightState): void {
if (state.allHighlight) {
return;
}
state.allHighlight = new Highlight();
state.currentHighlight = new Highlight();
CSS.highlights.set(MATCH_HIGHLIGHT, state.allHighlight);
CSS.highlights.set(CURRENT_MATCH_HIGHLIGHT, state.currentHighlight);
}
function disposeHighlightState(state: HighlightState): void {
if (state.allHighlight) {
state.allHighlight.clear();
if (CSS.highlights.get(MATCH_HIGHLIGHT) === state.allHighlight) {
CSS.highlights.delete(MATCH_HIGHLIGHT);
}
state.allHighlight = null;
}
if (state.currentHighlight) {
state.currentHighlight.clear();
if (
CSS.highlights.get(CURRENT_MATCH_HIGHLIGHT) === state.currentHighlight
) {
CSS.highlights.delete(CURRENT_MATCH_HIGHLIGHT);
}
state.currentHighlight = null;
}
if (state.overlayContainer) {
if (state.originalParentPosition !== null) {
const parent = state.overlayContainer.parentElement;
if (parent) {
parent.style.position = state.originalParentPosition;
}
state.originalParentPosition = null;
}
state.overlayContainer.remove();
state.overlayContainer = null;
}
}
function scrollRangeIntoView(range: Range): void {
const element = range.startContainer.parentElement;
element?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
function clearHighlights(state: HighlightState): void {
state.allHighlight?.clear();
state.currentHighlight?.clear();
if (state.overlayContainer) {
state.overlayContainer.innerHTML = "";
}
}
function getOverlayContainer(
editor: LexicalEditor,
state: HighlightState,
): HTMLDivElement | null {
if (state.overlayContainer) {
return state.overlayContainer;
}
const root = editor.getRootElement();
const parent = root?.parentElement;
if (!root || !parent) {
return null;
}
if (getComputedStyle(parent).position === "static") {
state.originalParentPosition = parent.style.position;
parent.style.position = "relative";
}
const container = root.ownerDocument.createElement("div");
container.style.position = "absolute";
container.style.top = "0";
container.style.left = "0";
container.style.pointerEvents = "none";
parent.appendChild(container);
state.overlayContainer = container;
return container;
}
function $updateOverlayHighlights(
editor: LexicalEditor,
matches: TextMatch[],
currentIndex: number,
offsetMap: OffsetEntry[],
state: HighlightState,
): void {
const container = getOverlayContainer(editor, state);
const rootElement = editor.getRootElement();
if (!container || !rootElement) {
return;
}
container.innerHTML = "";
const doc = rootElement.ownerDocument;
const containerRect = container.offsetParent
? container.offsetParent.getBoundingClientRect()
: rootElement.getBoundingClientRect();
for (let index = 0; index < matches.length; index++) {
const points = $resolveMatchToPoints(matches[index], offsetMap);
if (!points) {
continue;
}
const range = createDOMRange(
editor,
$getNodeByKeyOrThrow(points.anchorKey),
points.anchorOffset,
$getNodeByKeyOrThrow(points.focusKey),
points.focusOffset,
);
if (!range) {
continue;
}
const isCurrent = index === currentIndex;
if (isCurrent) {
scrollRangeIntoView(range);
}
for (const rect of createRectsFromDOMRange(editor, range)) {
const span = doc.createElement("span");
span.className = isCurrent
? "editor-find-match-overlay editor-find-match-overlay-current"
: "editor-find-match-overlay";
span.style.top = `${rect.top - containerRect.top}px`;
span.style.left = `${rect.left - containerRect.left}px`;
span.style.width = `${rect.width}px`;
span.style.height = `${rect.height}px`;
container.appendChild(span);
}
}
}
function $updateHighlights(
editor: LexicalEditor,
matches: TextMatch[],
currentIndex: number,
state: HighlightState,
): void {
clearHighlights(state);
if (matches.length === 0 || !editor.getRootElement()) {
return;
}
const offsetMap = $buildOffsetMap();
if (!SUPPORTS_CSS_HIGHLIGHTS) {
$updateOverlayHighlights(editor, matches, currentIndex, offsetMap, state);
return;
}
ensureHighlightRegistered(state);
for (let index = 0; index < matches.length; index++) {
const points = $resolveMatchToPoints(matches[index], offsetMap);
if (!points) {
continue;
}
const range = createDOMRange(
editor,
$getNodeByKeyOrThrow(points.anchorKey),
points.anchorOffset,
$getNodeByKeyOrThrow(points.focusKey),
points.focusOffset,
);
if (!range) {
continue;
}
state.allHighlight!.add(range);
if (index === currentIndex) {
state.currentHighlight!.add(range);
scrollRangeIntoView(range);
}
}
}
export const TOGGLE_FIND_REPLACE_COMMAND: LexicalCommand<void> = createCommand(
"TOGGLE_FIND_REPLACE_COMMAND",
);
export const CLOSE_FIND_REPLACE_COMMAND: LexicalCommand<void> = createCommand(
"CLOSE_FIND_REPLACE_COMMAND",
);
export const FIND_NEXT_COMMAND: LexicalCommand<void> =
createCommand("FIND_NEXT_COMMAND");
export const FIND_PREV_COMMAND: LexicalCommand<void> =
createCommand("FIND_PREV_COMMAND");
export const REPLACE_CURRENT_COMMAND: LexicalCommand<void> = createCommand(
"REPLACE_CURRENT_COMMAND",
);
export const REPLACE_ALL_COMMAND: LexicalCommand<void> = createCommand(
"REPLACE_ALL_COMMAND",
);
export const SET_SEARCH_TERM_COMMAND: LexicalCommand<string> = createCommand(
"SET_SEARCH_TERM_COMMAND",
);
export const SET_REPLACE_TERM_COMMAND: LexicalCommand<string> = createCommand(
"SET_REPLACE_TERM_COMMAND",
);
export const TOGGLE_CASE_SENSITIVE_COMMAND: LexicalCommand<void> =
createCommand("TOGGLE_CASE_SENSITIVE_COMMAND");
export const TOGGLE_REGEX_COMMAND: LexicalCommand<void> = createCommand(
"TOGGLE_REGEX_COMMAND",
);
export const FindReplaceExtension = defineExtension({
name: "@shadcn-editor/find-replace",
build: (editor) => {
const named = namedSignals({
caseSensitive: false,
currentIndex: 0,
isOpen: false,
isRegex: false,
replaceTerm: "",
searchTerm: "",
});
const cachedText = watchedSignal(
() => editor.read("latest", () => $getRoot().getTextContent()),
(self) =>
editor.registerTextContentListener((text) => {
self.value = text;
}),
);
const matches = computed(() => {
if (!named.isOpen.value) {
return [];
}
return findMatches(
cachedText.value,
named.searchTerm.value,
named.caseSensitive.value,
named.isRegex.value,
);
});
const effectiveIndex = computed(() => {
const length = matches.value.length;
const index = named.currentIndex.value;
return length > 0 ? Math.min(index, length - 1) : 0;
});
const regexError = computed(() => {
if (!named.isRegex.value || !named.searchTerm.value) {
return false;
}
try {
RegExp(named.searchTerm.value);
return false;
} catch {
return true;
}
});
return { ...named, cachedText, effectiveIndex, matches, regexError };
},
register: (editor, _config, state) => {
const output = state.getOutput();
const highlightState = createHighlightState();
return mergeRegister(
editor.registerCommand(
TOGGLE_FIND_REPLACE_COMMAND,
() => {
output.isOpen.value = !output.isOpen.peek();
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CLOSE_FIND_REPLACE_COMMAND,
() => {
output.isOpen.value = false;
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
FIND_NEXT_COMMAND,
() => {
const length = output.matches.peek().length;
if (length > 0) {
output.currentIndex.value =
(output.effectiveIndex.peek() + 1) % length;
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
FIND_PREV_COMMAND,
() => {
const length = output.matches.peek().length;
if (length > 0) {
output.currentIndex.value =
(output.effectiveIndex.peek() - 1 + length) % length;
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
REPLACE_CURRENT_COMMAND,
() => {
const currentMatches = output.matches.peek();
if (currentMatches.length === 0 || !editor.isEditable()) {
return true;
}
const index = output.effectiveIndex.peek();
const replaceText = output.replaceTerm.peek();
const regex = output.isRegex.peek()
? buildSearchRegex(
output.searchTerm.peek(),
output.caseSensitive.peek(),
)
: null;
editor.update(
() => {
const offsetMap = $buildOffsetMap();
const points = $resolveMatchToPoints(
currentMatches[index],
offsetMap,
);
if (points) {
$replaceMatch(
points,
replaceText,
currentMatches[index],
regex,
);
}
},
{ discrete: true },
);
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
REPLACE_ALL_COMMAND,
() => {
const currentMatches = output.matches.peek();
if (currentMatches.length === 0 || !editor.isEditable()) {
return true;
}
const replaceText = output.replaceTerm.peek();
const regex = output.isRegex.peek()
? buildSearchRegex(
output.searchTerm.peek(),
output.caseSensitive.peek(),
)
: null;
editor.update(
() => {
const offsetMap = $buildOffsetMap();
$replaceAllMatches(currentMatches, offsetMap, replaceText, regex);
},
{ discrete: true },
);
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
SET_SEARCH_TERM_COMMAND,
(term) => {
output.searchTerm.value = term;
output.currentIndex.value = 0;
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
SET_REPLACE_TERM_COMMAND,
(term) => {
output.replaceTerm.value = term;
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
TOGGLE_CASE_SENSITIVE_COMMAND,
() => {
output.caseSensitive.value = !output.caseSensitive.peek();
output.currentIndex.value = 0;
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
TOGGLE_REGEX_COMMAND,
() => {
output.isRegex.value = !output.isRegex.peek();
output.currentIndex.value = 0;
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_DOWN_COMMAND,
(event) => {
if (
isExactShortcutMatch(event, "f", CONTROL_OR_META) ||
isExactShortcutMatch(event, "f", { altKey: true, metaKey: true })
) {
event.preventDefault();
editor.dispatchCommand(TOGGLE_FIND_REPLACE_COMMAND, undefined);
return true;
}
if (output.isOpen.peek()) {
if (
isExactShortcutMatch(event, "g", CONTROL_OR_META) ||
isExactShortcutMatch(event, "g", {
...CONTROL_OR_META,
shiftKey: true,
})
) {
event.preventDefault();
editor.dispatchCommand(
event.shiftKey ? FIND_PREV_COMMAND : FIND_NEXT_COMMAND,
undefined,
);
return true;
}
}
return false;
},
COMMAND_PRIORITY_LOW,
),
effect(() => {
const currentMatches = output.matches.value;
const index = output.effectiveIndex.value;
const open = output.isOpen.value;
clearHighlights(highlightState);
if (!open || currentMatches.length === 0) {
return;
}
editor.read(() => {
$updateHighlights(editor, currentMatches, index, highlightState);
});
}),
() => disposeHighlightState(highlightState),
);
},
});
src/components/editor/extensions/format-state.tsx
import { useMemo } from "react";
import {
$getSelection,
$isElementNode,
$isLineBreakNode,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
type ElementFormatType,
type ElementNode,
type LexicalEditor,
type RangeSelection,
SELECTION_CHANGE_COMMAND,
type TextFormatType,
type TextNode,
} from "lexical";
import {
type ReadonlySignal,
computed,
defineExtension,
watchedSignal,
} from "@lexical/extension";
import { $isAutoLinkNode, $isLinkNode, type LinkNode } from "@lexical/link";
import { useExtensionDependency } from "@lexical/react/useExtensionComponent";
import { useSignalValue } from "@lexical/react/useExtensionSignalValue";
import {
$getSelectionStyleValueForProperty,
$isAtNodeEnd,
$patchStyleText,
} from "@lexical/selection";
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
export const TEXT_FORMATS = [
"bold",
"italic",
"underline",
"strikethrough",
"code",
"subscript",
"superscript",
"highlight",
"lowercase",
"uppercase",
"capitalize",
] as const satisfies readonly TextFormatType[];
export interface FormatState {
formats: TextFormatType[];
elementFormat: Exclude<ElementFormatType, "">;
fontFamily: string;
fontSize: string;
color: string;
backgroundColor: string;
indent: number;
isLink: boolean;
}
export const DEFAULT_FORMAT_STATE: FormatState = {
formats: [],
elementFormat: "left",
fontFamily: "",
fontSize: "16px",
color: "",
backgroundColor: "",
indent: 0,
isLink: false,
};
export function clearFormatting(editor: LexicalEditor) {
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
TEXT_FORMATS.forEach((format) => selection.formatText(format, 0));
$patchStyleText(selection, {
"font-family": null,
"font-size": null,
color: null,
"background-color": null,
});
});
}
export function $getSelectedNode(
selection: RangeSelection,
): TextNode | ElementNode {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
}
if (selection.isBackward()) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
}
return $isAtNodeEnd(anchor) ? anchorNode : focusNode;
}
export function $getSelectedLinkNode(
selection: RangeSelection,
): LinkNode | null {
const node = $getSelectedNode(selection);
if ($isLinkNode(node)) {
return node;
}
const linkParent = $findMatchingParent(node, $isLinkNode);
if ($isLinkNode(linkParent)) {
return linkParent;
}
if (selection.isCollapsed()) {
const anchor = selection.anchor;
if (anchor.type === "text") {
const anchorNode = anchor.getNode();
if (anchor.offset === anchorNode.getTextContentSize()) {
const nextSibling = anchorNode.getNextSibling();
if ($isLinkNode(nextSibling)) {
return nextSibling;
}
}
}
}
return null;
}
function $isSelectionInLink(selection: RangeSelection): boolean {
const focusLinkNode = $getSelectedLinkNode(selection);
const focusNode = $getSelectedNode(selection);
const focusAutoLinkNode = $findMatchingParent(focusNode, $isAutoLinkNode);
if (!(focusLinkNode || focusAutoLinkNode)) {
return false;
}
const badNode = selection
.getNodes()
.filter((node) => !$isLineBreakNode(node))
.find((node) => {
const linkNode = $findMatchingParent(node, $isLinkNode);
const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode);
return (
(focusLinkNode && !focusLinkNode.is(linkNode)) ||
(linkNode && !linkNode.is(focusLinkNode)) ||
(focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) ||
(autoLinkNode &&
(!autoLinkNode.is(focusAutoLinkNode) || autoLinkNode.getIsUnlinked()))
);
});
return !badNode;
}
function formatsEqual(a: TextFormatType[], b: TextFormatType[]): boolean {
return (
a.length === b.length && a.every((format, index) => format === b[index])
);
}
function shallowEqual(a: FormatState, b: FormatState): boolean {
for (const key in b) {
if (a[key as keyof FormatState] !== b[key as keyof FormatState]) {
return false;
}
}
return true;
}
function $getFormatState(prev: FormatState): FormatState {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return prev;
}
const formats = TEXT_FORMATS.filter((format) => selection.hasFormat(format));
const node = $getSelectedNode(selection);
const element = $findMatchingParent(
node,
(parent): parent is ElementNode =>
$isElementNode(parent) && !parent.isInline(),
);
const next: FormatState = {
formats: formatsEqual(prev.formats, formats) ? prev.formats : formats,
elementFormat: (element && element.getFormatType()) || "left",
fontFamily: $getSelectionStyleValueForProperty(
selection,
"font-family",
DEFAULT_FORMAT_STATE.fontFamily,
),
fontSize: $getSelectionStyleValueForProperty(
selection,
"font-size",
DEFAULT_FORMAT_STATE.fontSize,
),
color: $getSelectionStyleValueForProperty(
selection,
"color",
DEFAULT_FORMAT_STATE.color,
),
backgroundColor: $getSelectionStyleValueForProperty(
selection,
"background-color",
DEFAULT_FORMAT_STATE.backgroundColor,
),
indent: element ? element.getIndent() : 0,
isLink: $isSelectionInLink(selection),
};
return shallowEqual(prev, next) ? prev : next;
}
export const FormatStateExtension = defineExtension({
name: "@shadcn-editor/format-state",
build(editor) {
return watchedSignal(
() =>
editor
.getEditorState()
.read(() => $getFormatState(DEFAULT_FORMAT_STATE)),
(self) =>
mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
self.value = editorState.read(() => $getFormatState(self.peek()));
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
self.value = $getFormatState(self.peek());
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
),
);
},
});
export function useFormatStateSignal(): ReadonlySignal<FormatState> {
return useExtensionDependency(FormatStateExtension).output;
}
export function useFormatState(): FormatState {
return useSignalValue(useFormatStateSignal());
}
export function useFormatStateValue<K extends keyof FormatState>(
key: K,
): FormatState[K] {
const state = useFormatStateSignal();
return useSignalValue(
useMemo(() => computed(() => state.value[key]), [state, key]),
);
}
export function useHasFormat(format: TextFormatType): boolean {
const state = useFormatStateSignal();
return useSignalValue(
useMemo(
() => computed(() => state.value.formats.includes(format)),
[state, format],
),
);
}
src/components/editor/extensions/image.ts
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
defineExtension,
} from "lexical";
import {
$createImageNode,
INSERT_IMAGE_COMMAND,
ImageNode,
} from "@/components/editor/nodes/image-node";
export const ImageExtension = defineExtension({
name: "@shadcn-editor/editor/Image",
nodes: () => [ImageNode],
register: (editor) =>
editor.registerCommand(
INSERT_IMAGE_COMMAND,
(payload) => {
const imageNode = $createImageNode(payload);
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
const paragraph = $createParagraphNode();
imageNode.replace(paragraph);
paragraph.append(imageNode);
paragraph.selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/layout.ts
import {
$createParagraphNode,
$findMatchingParent,
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_ARROW_UP_COMMAND,
type LexicalCommand,
createCommand,
defineExtension,
mergeRegister,
} from "lexical";
import { $insertNodeToNearestRoot } from "@lexical/utils";
import {
$createLayoutContainerNode,
$createLayoutItemNode,
$isLayoutContainerNode,
$isLayoutItemNode,
LayoutContainerNode,
LayoutItemNode,
} from "@/components/editor/nodes/layout-node";
export const INSERT_LAYOUT_COMMAND: LexicalCommand<string> = createCommand(
"INSERT_LAYOUT_COMMAND",
);
function getItemsCountFromTemplate(templateColumns: string): number {
return templateColumns.trim().split(/\s+/).filter(Boolean).length;
}
function $onEscape(before: boolean): boolean {
const selection = $getSelection();
if (
$isRangeSelection(selection) &&
selection.isCollapsed() &&
selection.anchor.offset === 0
) {
const container = $findMatchingParent(
selection.anchor.getNode(),
$isLayoutContainerNode,
);
if ($isLayoutContainerNode(container)) {
const parent = container.getParent();
const child =
parent && (before ? parent.getFirstChild() : parent.getLastChild());
const descendant = before
? container.getFirstDescendant()?.getKey()
: container.getLastDescendant()?.getKey();
if (
parent !== null &&
child === container &&
descendant === selection.anchor.key
) {
if (before) {
container.insertBefore($createParagraphNode());
} else {
container.insertAfter($createParagraphNode());
}
}
}
}
return false;
}
export const LayoutExtension = defineExtension({
name: "@shadcn-editor/editor/Layout",
nodes: () => [LayoutContainerNode, LayoutItemNode],
register: (editor) => {
return mergeRegister(
editor.registerCommand(
INSERT_LAYOUT_COMMAND,
(templateColumns) => {
const container = $createLayoutContainerNode(templateColumns);
const itemsCount = getItemsCountFromTemplate(templateColumns);
for (let i = 0; i < itemsCount; i++) {
container.append(
$createLayoutItemNode().append($createParagraphNode()),
);
}
$insertNodeToNearestRoot(container);
container.selectStart();
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerNodeTransform(LayoutItemNode, (node) => {
const parent = node.getParent();
if (!$isLayoutContainerNode(parent)) {
for (const child of node.getChildren()) {
node.insertBefore(child);
}
node.remove();
}
}),
editor.registerNodeTransform(LayoutContainerNode, (node) => {
const children = node.getChildren();
if (!children.every($isLayoutItemNode)) {
for (const child of children) {
if (!$isLayoutItemNode(child)) {
node.insertBefore(child);
}
}
node.remove();
}
}),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
() => $onEscape(false),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
() => $onEscape(false),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
() => $onEscape(true),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
() => $onEscape(true),
COMMAND_PRIORITY_LOW,
),
);
},
});
src/components/editor/extensions/link.ts
import {
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import { LinkExtension as LexicalLinkExtension } from "@lexical/link";
export const OPEN_LINK_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
"OPEN_LINK_EDITOR_COMMAND",
);
const SUPPORTED_URL_PROTOCOLS = new Set([
"http:",
"https:",
"mailto:",
"sms:",
"tel:",
]);
function parseUrl(url: string): URL | null {
try {
return new URL(url);
} catch {
return null;
}
}
export function sanitizeUrl(url: string): string {
const parsedUrl = parseUrl(url);
if (parsedUrl !== null && !SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
return "about:blank";
}
return url;
}
const SINGLE_TOKEN_REGEXP = /^\S*\s*$/;
const WWW_HOST_REGEXP = /^www\.\S/;
export function validateUrl(url: string): boolean {
if (typeof url !== "string") {
return false;
}
if (url === "https://") {
return true;
}
if (!SINGLE_TOKEN_REGEXP.test(url)) {
return false;
}
let parsed = parseUrl(url);
if (parsed === null && WWW_HOST_REGEXP.test(url)) {
parsed = parseUrl(`https://${url}`);
}
return parsed !== null && SUPPORTED_URL_PROTOCOLS.has(parsed.protocol);
}
export const LinkExtension = defineExtension({
name: "@shadcn-editor/editor/Link",
dependencies: [configExtension(LexicalLinkExtension, { validateUrl })],
});
src/components/editor/extensions/mention.ts
import { defineExtension } from "lexical";
import { MentionNode } from "@/components/editor/nodes/mention-node";
export const MentionExtension = defineExtension({
name: "@shadcn-editor/editor/Mention",
nodes: () => [MentionNode],
});
src/components/editor/extensions/poll.ts
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import { $wrapNodeInElement } from "@lexical/utils";
import {
$createPollNode,
PollNode,
createPollOption,
} from "@/components/editor/nodes/poll-node";
export const INSERT_POLL_COMMAND: LexicalCommand<string> = createCommand(
"INSERT_POLL_COMMAND",
);
function $convertPollElement(el: HTMLElement) {
const question = el.getAttribute("data-lexical-poll-question");
const options = el.getAttribute("data-lexical-poll-options");
if (question === null || options === null) {
return null;
}
return $createPollNode(question, JSON.parse(options));
}
const PollImportRule = defineImportRule({
$import: (_ctx, el, $next) => {
const node = $convertPollElement(el);
return node ? [node] : $next();
},
match: sel.tag("span").attr("data-lexical-poll-question", true),
name: "@shadcn-editor/editor/poll",
});
export const PollExtension = defineExtension({
name: "@shadcn-editor/editor/Poll",
nodes: () => [PollNode],
dependencies: [
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [PollImportRule],
}),
],
register: (editor) =>
editor.registerCommand(
INSERT_POLL_COMMAND,
(question) => {
const pollNode = $createPollNode(question, [
createPollOption(),
createPollOption(),
]);
$insertNodes([pollNode]);
if ($isRootOrShadowRoot(pollNode.getParentOrThrow())) {
$wrapNodeInElement(pollNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/pullquote.ts
import {
$createNodeSelection,
$createParagraphNode,
$getSelection,
$getSlot,
$isElementNode,
$isNodeSelection,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_BEFORE_EDITOR,
COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_LOW,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
mergeRegister,
} from "lexical";
import { NodeSelectionDataSelectedExtension } from "@lexical/extension";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import {
$appendInline,
$findSlotHost,
$insertSlotHostAtRoot,
$isSlotHostTextEmpty,
registerHostChromeSelection,
registerSlotHostArrowEscape,
registerSlotHostBackspace,
} from "@/components/editor/extensions/slot-host";
import {
$createPullQuoteNode,
$isPullQuoteNode,
PullQuoteNode,
} from "@/components/editor/nodes/pullquote-node";
import {
$createSlotContainerNode,
SlotContainerNode,
} from "@/components/editor/nodes/slot-container-node";
export const INSERT_PULLQUOTE_COMMAND: LexicalCommand<void> = createCommand(
"INSERT_PULLQUOTE_COMMAND",
);
function $handlePullQuoteEnter(event: KeyboardEvent | null): boolean {
const selection = $getSelection();
if (!$isNodeSelection(selection)) {
return false;
}
const nodes = selection.getNodes();
if (nodes.length !== 1 || !$isPullQuoteNode(nodes[0])) {
return false;
}
const quote = $getSlot(nodes[0], "quote");
if (!$isElementNode(quote)) {
return false;
}
event?.preventDefault();
quote.selectStart();
return true;
}
function $handlePullQuoteEscape(): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const host = $findSlotHost(selection.anchor.getNode(), $isPullQuoteNode);
if (host === null) {
return false;
}
const nodeSelection = $createNodeSelection();
nodeSelection.add(host.getKey());
$setSelection(nodeSelection);
return true;
}
const PullQuoteImportRule = defineImportRule({
$import: (ctx, el) => {
const quote = $createSlotContainerNode();
const attribution = $createParagraphNode();
const pullquote = $createPullQuoteNode(quote, attribution);
for (const domChild of Array.from(el.children)) {
const slotName = domChild.getAttribute("data-lexical-slot");
if (slotName === "quote") {
quote.splice(quote.getChildrenSize(), 0, ctx.$importChildren(domChild));
} else if (slotName === "attribution") {
$appendInline(attribution, ctx.$importChildren(domChild));
} else {
quote.splice(quote.getChildrenSize(), 0, ctx.$importOne(domChild));
}
}
return [pullquote];
},
match: sel.tag("div").attr("data-lexical-pullquote", true),
name: "@shadcn-editor/editor/pullquote",
});
export const PullQuoteExtension = defineExtension({
name: "@shadcn-editor/editor/PullQuote",
nodes: () => [PullQuoteNode, SlotContainerNode],
dependencies: [
configExtension(NodeSelectionDataSelectedExtension, {
nodes: [PullQuoteNode],
}),
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [PullQuoteImportRule],
}),
],
register: (editor) => {
return mergeRegister(
editor.registerCommand(
INSERT_PULLQUOTE_COMMAND,
() => {
$insertSlotHostAtRoot($createPullQuoteNode());
return true;
},
COMMAND_PRIORITY_EDITOR,
),
registerHostChromeSelection(editor, $isPullQuoteNode),
registerSlotHostArrowEscape(editor, $isPullQuoteNode),
registerSlotHostBackspace(editor, $isPullQuoteNode, $isSlotHostTextEmpty),
editor.registerCommand(
KEY_ENTER_COMMAND,
$handlePullQuoteEnter,
COMMAND_PRIORITY_BEFORE_EDITOR,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
$handlePullQuoteEscape,
COMMAND_PRIORITY_LOW,
),
);
},
});
src/components/editor/extensions/review.ts
import {
$createParagraphNode,
$getSlot,
$isParagraphNode,
$setSlot,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
mergeRegister,
} from "lexical";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import {
$appendInline,
$insertSlotHostAtRoot,
$isSlotHostTextEmpty,
registerSlotHostArrowEscape,
registerSlotHostBackspace,
} from "@/components/editor/extensions/slot-host";
import {
$createReviewNode,
$isReviewNode,
ReviewNode,
} from "@/components/editor/nodes/review-node";
export const INSERT_REVIEW_COMMAND: LexicalCommand<void> = createCommand(
"INSERT_REVIEW_COMMAND",
);
const ReviewImportRule = defineImportRule({
$import: (ctx, el) => {
const review = $createReviewNode().clear();
const prevAuthor = $getSlot(review, "author");
const author = $isParagraphNode(prevAuthor)
? prevAuthor.clear()
: $createParagraphNode();
$setSlot(review, "author", author);
const rating = Number(el.getAttribute("data-rating"));
if (Number.isFinite(rating)) {
review.setRating(Math.max(0, Math.min(5, Math.round(rating))));
}
for (const domChild of Array.from(el.children)) {
const slotName = domChild.getAttribute("data-lexical-slot");
if (slotName === "author") {
$appendInline(author, ctx.$importChildren(domChild));
} else {
review.splice(review.getChildrenSize(), 0, ctx.$importOne(domChild));
}
}
return [review];
},
match: sel.tag("div").attr("data-lexical-review", true),
name: "@shadcn-editor/editor/review",
});
export const ReviewExtension = defineExtension({
name: "@shadcn-editor/editor/Review",
nodes: () => [ReviewNode],
dependencies: [
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [ReviewImportRule],
}),
],
register: (editor) =>
mergeRegister(
editor.registerCommand(
INSERT_REVIEW_COMMAND,
() => {
$insertSlotHostAtRoot($createReviewNode());
return true;
},
COMMAND_PRIORITY_EDITOR,
),
registerSlotHostArrowEscape(editor, $isReviewNode),
registerSlotHostBackspace(editor, $isReviewNode, $isSlotHostTextEmpty),
),
});
src/components/editor/extensions/ruby.ts
import {
$caretFromPoint,
$createTextNode,
$getSelection,
$getSiblingCaret,
$isExtendableTextPointCaret,
$isRangeSelection,
$isTextNode,
$isTextPointCaret,
$setPointFromCaret,
COMMAND_PRIORITY_HIGH,
CONTROLLED_TEXT_INSERTION_COMMAND,
type CaretDirection,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_BACKSPACE_COMMAND,
type LexicalCommand,
type LexicalEditor,
SELECTION_CHANGE_COMMAND,
type SiblingCaret,
configExtension,
createCommand,
defineExtension,
getDOMSelection,
registerEventListener,
registerEventListeners,
} from "lexical";
import {
CoreImportExtension,
DOMImportExtension,
defineImportRule,
sel,
} from "@lexical/html";
import { mergeRegister } from "@lexical/utils";
import {
$createRubyNode,
$isRubyNode,
$toggleRuby,
$unwrapRubyNode,
RubyNode,
} from "@/components/editor/nodes/ruby-node";
export const OPEN_RUBY_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
"OPEN_RUBY_EDITOR_COMMAND",
);
export function toggleRubyEditor(editor: LexicalEditor): void {
const hasRuby = editor.read(() => {
const selection = $getSelection();
return (
$isRangeSelection(selection) && selection.getNodes().some($isRubyNode)
);
});
if (hasRuby) {
editor.update(() => {
$toggleRuby(null);
});
} else {
editor.dispatchCommand(OPEN_RUBY_EDITOR_COMMAND, undefined);
}
}
const RubyImportRule = defineImportRule({
$import: (_ctx, el) => {
const children = el.childNodes;
const results = [];
let pendingText = "";
for (let i = 0; i < children.length; i++) {
const child = children[i];
if (child.nodeName === "RT") {
const annotation = child.textContent || "";
if (pendingText) {
results.push($createRubyNode(pendingText, annotation));
pendingText = "";
}
} else if (child.nodeName === "RP") {
continue;
} else {
pendingText += child.textContent || "";
}
}
if (pendingText) {
results.push($createTextNode(pendingText));
}
return results;
},
match: sel.tag("ruby"),
name: "@shadcn-editor/editor/ruby",
});
function $unwrapRubiesInSelection(): void {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return;
}
for (const node of selection.getNodes()) {
if ($isRubyNode(node)) {
$unwrapRubyNode(node);
}
}
}
function $caretPastRubyChain<D extends CaretDirection>(
ruby: RubyNode,
direction: D,
): SiblingCaret<RubyNode, D> {
let caret = $getSiblingCaret(ruby, direction);
for (
let node = caret.getNodeAtCaret();
$isRubyNode(node);
node = caret.getNodeAtCaret()
) {
caret = $getSiblingCaret(node, direction);
}
return caret;
}
function $skipRubyOnArrow(
direction: CaretDirection,
isShift: boolean,
): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
if (!isShift && !selection.isCollapsed()) {
return false;
}
const point = isShift ? selection.focus : selection.anchor;
const caret = $caretFromPoint(point, direction);
let ruby: RubyNode | null = null;
let fromRuby = false;
if ($isTextPointCaret(caret) && $isRubyNode(caret.origin)) {
if (caret.origin.isComposing()) {
return false;
}
ruby = caret.origin;
fromRuby = true;
} else if (!$isExtendableTextPointCaret(caret)) {
const adjacent = caret.getNodeAtCaret();
if ($isRubyNode(adjacent)) {
ruby = adjacent;
}
}
if (ruby === null) {
return false;
}
const edgeCaret = $caretPastRubyChain(ruby, direction);
const beyond = edgeCaret.getNodeAtCaret();
if (beyond !== null && !$isTextNode(beyond)) {
return false;
}
if ($isTextNode(beyond) && fromRuby && isShift && direction === "next") {
point.set(
beyond.getKey(),
Math.min(1, beyond.getTextContentSize()),
"text",
);
} else {
$setPointFromCaret(point, edgeCaret.getFlipped());
}
if (!isShift) {
const { anchor, focus } = selection;
focus.set(anchor.key, anchor.offset, anchor.type);
}
return true;
}
function $nudgeOffRuby(): boolean {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}
const { anchor } = selection;
if (anchor.type !== "text") {
return false;
}
const node = anchor.getNode();
if (!$isRubyNode(node)) {
return false;
}
if (node.isComposing()) {
return false;
}
const len = node.getTextContentSize();
if (anchor.offset === len || anchor.offset === 0) {
const isEnd = anchor.offset === len;
const sibling = isEnd ? node.getNextSibling() : node.getPreviousSibling();
if ($isTextNode(sibling) && !$isRubyNode(sibling)) {
const offset = isEnd ? 0 : sibling.getTextContentSize();
selection.anchor.set(sibling.getKey(), offset, "text");
selection.focus.set(sibling.getKey(), offset, "text");
return false;
}
}
return false;
}
export const RubyExtension = defineExtension({
name: "@shadcn-editor/editor/Ruby",
nodes: () => [RubyNode],
dependencies: [
CoreImportExtension,
configExtension(DOMImportExtension, {
rules: [RubyImportRule],
}),
],
register: (editor) => {
let composingRubyInner: HTMLElement | null = null;
let isMouseDown = false;
function checkCompositionInRuby() {
if (composingRubyInner) {
return;
}
const domSelection = getDOMSelection(editor._window);
if (!domSelection || !domSelection.anchorNode) {
return;
}
let el: HTMLElement | null = domSelection.anchorNode.parentElement;
while (el && !el.dataset.rubyAnnotation) {
if (el.hasAttribute("data-lexical-key")) {
break;
}
el = el.parentElement;
}
if (el && el.dataset.rubyAnnotation) {
el.classList.add("editor-ruby-composing");
composingRubyInner = el;
}
}
function onCompositionEnd() {
if (composingRubyInner) {
composingRubyInner.classList.remove("editor-ruby-composing");
composingRubyInner = null;
}
}
return mergeRegister(
editor.registerRootListener((rootElement) => {
if (rootElement) {
return mergeRegister(
registerEventListeners(
rootElement,
{
compositionend: onCompositionEnd,
compositionstart: checkCompositionInRuby,
compositionupdate: checkCompositionInRuby,
},
true,
),
registerEventListener(rootElement, "mousedown", () => {
isMouseDown = true;
}),
registerEventListener(rootElement.ownerDocument, "mouseup", () => {
isMouseDown = false;
}),
);
}
}),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
if (editor.isComposing()) {
return false;
}
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}
const caret = $caretFromPoint(selection.anchor, "previous");
if (!$isExtendableTextPointCaret(caret)) {
const prev = caret.getNodeAtCaret();
if ($isRubyNode(prev)) {
prev.remove();
event.preventDefault();
return true;
}
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
...(
[
[KEY_ARROW_LEFT_COMMAND, "previous"],
[KEY_ARROW_RIGHT_COMMAND, "next"],
] as const
).map(([command, direction]) =>
editor.registerCommand(
command,
(event) => {
if (event.metaKey || event.ctrlKey || event.altKey) {
return false;
}
if (editor.isComposing()) {
return false;
}
const handled = $skipRubyOnArrow(direction, event.shiftKey);
if (handled) {
event.preventDefault();
}
return handled;
},
COMMAND_PRIORITY_HIGH,
),
),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if (isMouseDown) {
return false;
}
return $nudgeOffRuby();
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
CONTROLLED_TEXT_INSERTION_COMMAND,
(text) => {
if (typeof text === "string") {
$unwrapRubiesInSelection();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
},
});
src/components/editor/extensions/shortcuts.ts
import {
$createParagraphNode,
$getSelection,
$isElementNode,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
CONTROL_OR_META,
type ElementNode,
FORMAT_ELEMENT_COMMAND,
FORMAT_TEXT_COMMAND,
INDENT_CONTENT_COMMAND,
IS_APPLE,
type KeyboardShortcutMatch,
type LexicalCommand,
type LexicalEditor,
OUTDENT_CONTENT_COMMAND,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import {
KeyboardShortcutsExtension,
type NamedKeyboardShortcuts,
formatKeyboardShortcut,
} from "@lexical/extension";
import {
$isListNode,
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
} from "@lexical/list";
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
type HeadingTagType,
} from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
import { insertCodeBlock } from "@/components/editor/extensions/code";
import {
$getSelectedNode,
clearFormatting,
} from "@/components/editor/extensions/format-state";
const CONTROL_OR_META_ALT = { ...CONTROL_OR_META, altKey: true };
const CONTROL_OR_META_SHIFT = { ...CONTROL_OR_META, shiftKey: true };
const CONTROL_SHIFT = { ctrlKey: true, shiftKey: true };
export const SHORTCUT_BINDINGS = Object.freeze({
NORMAL: { key: "0", modifiers: CONTROL_OR_META_ALT },
HEADING1: { key: "1", modifiers: CONTROL_OR_META_ALT },
HEADING2: { key: "2", modifiers: CONTROL_OR_META_ALT },
HEADING3: { key: "3", modifiers: CONTROL_OR_META_ALT },
NUMBERED_LIST: { key: "7", modifiers: CONTROL_OR_META_SHIFT },
BULLET_LIST: { key: "8", modifiers: CONTROL_OR_META_SHIFT },
CHECK_LIST: { key: "9", modifiers: CONTROL_OR_META_SHIFT },
CODE_BLOCK: { key: "c", modifiers: CONTROL_OR_META_ALT },
QUOTE: { key: "q", modifiers: CONTROL_SHIFT },
INSERT_CODE_BLOCK: { key: "c", modifiers: CONTROL_OR_META_SHIFT },
STRIKETHROUGH: { key: "x", modifiers: CONTROL_OR_META_SHIFT },
LOWERCASE: { key: "1", modifiers: CONTROL_SHIFT },
UPPERCASE: { key: "2", modifiers: CONTROL_SHIFT },
CAPITALIZE: { key: "3", modifiers: CONTROL_SHIFT },
CENTER_ALIGN: { key: "e", modifiers: CONTROL_OR_META_SHIFT },
JUSTIFY_ALIGN: { key: "j", modifiers: CONTROL_OR_META_SHIFT },
LEFT_ALIGN: { key: "l", modifiers: CONTROL_OR_META_SHIFT },
RIGHT_ALIGN: { key: "r", modifiers: CONTROL_OR_META_SHIFT },
SUBSCRIPT: { key: ",", modifiers: CONTROL_OR_META },
SUPERSCRIPT: { key: ".", modifiers: CONTROL_OR_META },
INDENT: { key: "]", modifiers: CONTROL_OR_META },
OUTDENT: { key: "[", modifiers: CONTROL_OR_META },
CLEAR_FORMATTING: { key: "\\", modifiers: CONTROL_OR_META },
}) satisfies Record<string, KeyboardShortcutMatch>;
export const BUILTIN_SHORTCUT_BINDINGS = Object.freeze({
BOLD: { key: "b", modifiers: CONTROL_OR_META },
ITALIC: { key: "i", modifiers: CONTROL_OR_META },
UNDERLINE: { key: "u", modifiers: CONTROL_OR_META },
UNDO: { key: "z", modifiers: CONTROL_OR_META },
REDO: IS_APPLE
? { key: "z", modifiers: { ...CONTROL_OR_META, shiftKey: true } }
: { key: "y", modifiers: { ctrlKey: true } },
}) satisfies Record<string, KeyboardShortcutMatch>;
export type ShortcutName = keyof typeof SHORTCUT_BINDINGS;
export type BuiltinShortcutName = keyof typeof BUILTIN_SHORTCUT_BINDINGS;
const SHORTCUT_NAMES = Object.keys(SHORTCUT_BINDINGS) as ShortcutName[];
export const SHORTCUT_COMMANDS: Record<
ShortcutName,
LexicalCommand<KeyboardEvent>
> = Object.fromEntries(
SHORTCUT_NAMES.map((name) => [
name,
createCommand<KeyboardEvent>(`@shadcn-editor/editor/Shortcuts/${name}`),
]),
) as Record<ShortcutName, LexicalCommand<KeyboardEvent>>;
export function formatShortcut(
name: ShortcutName | BuiltinShortcutName,
): string[] {
const binding =
name in SHORTCUT_BINDINGS
? SHORTCUT_BINDINGS[name as ShortcutName]
: BUILTIN_SHORTCUT_BINDINGS[name as BuiltinShortcutName];
return formatKeyboardShortcut(binding);
}
function buildShortcuts(): NamedKeyboardShortcuts {
return Object.fromEntries(
SHORTCUT_NAMES.map((name) => [
name,
{ ...SHORTCUT_BINDINGS[name], command: SHORTCUT_COMMANDS[name] },
]),
);
}
type BlockType =
"paragraph" | HeadingTagType | "number" | "bullet" | "check" | "quote";
function $getBlockType(): BlockType | null {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return null;
}
const node = $getSelectedNode(selection);
const list = $findMatchingParent(node, $isListNode);
if (list) {
return list.getListType();
}
const element = $findMatchingParent(
node,
(parent): parent is ElementNode =>
$isElementNode(parent) && !parent.isInline(),
);
if ($isHeadingNode(element)) {
return element.getTag();
}
return $isQuoteNode(element) ? "quote" : "paragraph";
}
function $formatParagraph() {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode());
}
}
function $formatHeading(tag: HeadingTagType) {
if ($getBlockType() === tag) {
return;
}
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(tag));
}
}
function $formatQuote() {
if ($getBlockType() === "quote") {
return;
}
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode());
}
}
function $formatList(
editor: LexicalEditor,
listType: "number" | "bullet" | "check",
command: LexicalCommand<void>,
) {
if ($getBlockType() === listType) {
$formatParagraph();
} else {
editor.dispatchCommand(command, undefined);
}
}
export const ShortcutsExtension = defineExtension({
name: "@shadcn-editor/editor/Shortcuts",
dependencies: [
configExtension(KeyboardShortcutsExtension, {
shortcuts: buildShortcuts(),
}),
],
register(editor) {
const listen = (
name: ShortcutName,
$onShortcut: (fromEditor: LexicalEditor) => void,
) =>
editor.registerCommand(
SHORTCUT_COMMANDS[name],
(event, fromEditor) => {
$onShortcut(fromEditor);
event.preventDefault();
return true;
},
COMMAND_PRIORITY_EDITOR,
);
return mergeRegister(
listen("NORMAL", () => $formatParagraph()),
listen("HEADING1", () => $formatHeading("h1")),
listen("HEADING2", () => $formatHeading("h2")),
listen("HEADING3", () => $formatHeading("h3")),
listen("NUMBERED_LIST", (e) =>
$formatList(e, "number", INSERT_ORDERED_LIST_COMMAND),
),
listen("BULLET_LIST", (e) =>
$formatList(e, "bullet", INSERT_UNORDERED_LIST_COMMAND),
),
listen("CHECK_LIST", (e) =>
$formatList(e, "check", INSERT_CHECK_LIST_COMMAND),
),
listen("CODE_BLOCK", (e) => insertCodeBlock(e)),
listen("QUOTE", () => $formatQuote()),
listen("INSERT_CODE_BLOCK", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "code"),
),
listen("STRIKETHROUGH", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough"),
),
listen("LOWERCASE", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "lowercase"),
),
listen("UPPERCASE", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "uppercase"),
),
listen("CAPITALIZE", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "capitalize"),
),
listen("CENTER_ALIGN", (e) =>
e.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center"),
),
listen("JUSTIFY_ALIGN", (e) =>
e.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify"),
),
listen("LEFT_ALIGN", (e) =>
e.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left"),
),
listen("RIGHT_ALIGN", (e) =>
e.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right"),
),
listen("SUBSCRIPT", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript"),
),
listen("SUPERSCRIPT", (e) =>
e.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript"),
),
listen("INDENT", (e) =>
e.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
),
listen("OUTDENT", (e) =>
e.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
),
listen("CLEAR_FORMATTING", (e) => clearFormatting(e)),
);
},
});
src/components/editor/extensions/slot-host.ts
import {
$createNodeSelection,
$createParagraphNode,
$getNearestNodeFromDOMNode,
$getSelection,
$getSlot,
$getSlotHost,
$getSlotNames,
$isElementNode,
$isLineBreakNode,
$isParagraphNode,
$isRangeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_BEFORE_EDITOR,
COMMAND_PRIORITY_LOW,
type ElementNode,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
type LexicalEditor,
type LexicalNode,
type RangeSelection,
getActiveElement,
getDOMSelection,
isHTMLElement,
isModifierMatch,
mergeRegister,
} from "lexical";
import {
$insertNodeToNearestRoot,
$isAtEndOfNode,
$isAtStartOfNode,
} from "@lexical/utils";
export function $findSlotHost<T extends LexicalNode>(
start: LexicalNode,
$isHost: (node: LexicalNode | null | undefined) => node is T,
): T | null {
let cur: LexicalNode | null = start;
while (cur !== null) {
if ($isHost(cur)) {
return cur;
}
const parent: LexicalNode | null = cur.getParent();
if (parent === null) {
const host = $getSlotHost(cur);
return host !== null && $isHost(host) ? host : null;
}
cur = parent;
}
return null;
}
interface Region {
startNode: LexicalNode;
endNode: LexicalNode;
isChildren: boolean;
}
function isBefore(a: HTMLElement, b: HTMLElement): boolean {
return (
(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
);
}
function $orderedRegions(editor: LexicalEditor, host: LexicalNode): Region[] {
const regions: Region[] = [];
if ($isElementNode(host)) {
const first = host.getFirstChild();
const last = host.getLastChild();
if (first !== null && last !== null) {
regions.push({ endNode: last, isChildren: true, startNode: first });
}
}
for (const name of $getSlotNames(host)) {
const value = $getSlot(host, name);
if (value !== null) {
regions.push({ endNode: value, isChildren: false, startNode: value });
}
}
return regions.sort((a, b) => {
const aDom = editor.getElementByKey(a.startNode.getKey());
const bDom = editor.getElementByKey(b.startNode.getKey());
if (aDom === null || bDom === null) {
return 0;
}
return isBefore(aDom, bDom) ? -1 : 1;
});
}
function $regionContains(
region: Region,
anchorNode: LexicalNode,
host: LexicalNode,
): boolean {
if (region.isChildren) {
for (
let b: LexicalNode | null = anchorNode;
b !== null;
b = b.getParent()
) {
if (b.getParent() === host) {
return true;
}
}
return false;
}
const value = region.startNode;
return (
value === anchorNode ||
($isElementNode(value) && value.isParentOf(anchorNode))
);
}
function $editingHost(
editor: LexicalEditor,
node: LexicalNode,
): Element | null {
const dom = editor.getElementByKey(node.getKey());
return dom === null ? null : dom.closest('[contenteditable="true"]');
}
function $handleSlotHostArrow<T extends LexicalNode>(
editor: LexicalEditor,
$isHost: (node: LexicalNode | null | undefined) => node is T,
event: KeyboardEvent | null,
down: boolean,
): boolean {
if (event !== null && !isModifierMatch(event, {})) {
return false;
}
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return false;
}
const anchor = selection.anchor;
const host = $findSlotHost(anchor.getNode(), $isHost);
if (host === null) {
return false;
}
const regions = $orderedRegions(editor, host);
const index = regions.findIndex((r) =>
$regionContains(r, anchor.getNode(), host),
);
if (index === -1) {
return false;
}
const region = regions[index];
const edgeNode = down ? region.endNode : region.startNode;
if (!$isElementNode(edgeNode)) {
return false;
}
if (
down
? !$isAtEndOfNode(anchor, edgeNode)
: !$isAtStartOfNode(anchor, edgeNode)
) {
return false;
}
const adjacent = regions[index + (down ? 1 : -1)];
if (adjacent !== undefined) {
const from = $editingHost(editor, edgeNode);
const to = $editingHost(
editor,
down ? adjacent.startNode : adjacent.endNode,
);
if (from === null || to === null || from === to) {
return false;
}
if (down) {
adjacent.startNode.selectStart();
} else {
adjacent.endNode.selectEnd();
}
if (event) {
event.preventDefault();
}
return true;
}
if ((down ? host.getNextSibling() : host.getPreviousSibling()) !== null) {
return false;
}
const paragraph = $createParagraphNode();
if (down) {
host.insertAfter(paragraph);
} else {
host.insertBefore(paragraph);
}
paragraph.selectEnd();
if (event) {
event.preventDefault();
}
return true;
}
export function registerSlotHostArrowEscape<T extends LexicalNode>(
editor: LexicalEditor,
$isHost: (node: LexicalNode | null | undefined) => node is T,
): () => void {
return mergeRegister(
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => $handleSlotHostArrow(editor, $isHost, event, true),
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => $handleSlotHostArrow(editor, $isHost, event, false),
COMMAND_PRIORITY_LOW,
),
);
}
export function $isSlotHostTextEmpty(host: LexicalNode): boolean {
if ($isElementNode(host) && host.getTextContentSize() !== 0) {
return false;
}
for (const name of $getSlotNames(host)) {
const value = $getSlot(host, name);
if ($isElementNode(value) && value.getTextContentSize() !== 0) {
return false;
}
}
return true;
}
function $deleteEmptyHost(host: LexicalNode): void {
const prev = host.getPreviousSibling();
if ($isElementNode(prev)) {
host.remove();
prev.selectEnd();
return;
}
const next = host.getNextSibling();
if ($isElementNode(next)) {
host.remove();
next.selectStart();
return;
}
host.replace($createParagraphNode()).selectStart();
}
function $reanchorRangeBeforeHost<T extends LexicalNode>(
selection: RangeSelection,
$isHost: (node: LexicalNode | null | undefined) => node is T,
): void {
const backward = selection.isBackward();
const start = backward ? selection.focus : selection.anchor;
const end = backward ? selection.anchor : selection.focus;
const host = $findSlotHost(start.getNode(), $isHost);
if (host === null || $findSlotHost(end.getNode(), $isHost) === host) {
return;
}
const parent = host.getParent();
if (
parent !== null &&
$isElementNode(host) &&
$isAtStartOfNode(start, host)
) {
start.set(parent.getKey(), host.getIndexWithinParent(), "element");
}
}
export function registerSlotHostBackspace<T extends LexicalNode>(
editor: LexicalEditor,
$isHost: (node: LexicalNode | null | undefined) => node is T,
$isEmpty: (host: T) => boolean,
): () => void {
return mergeRegister(
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
if (!selection.isCollapsed()) {
$reanchorRangeBeforeHost(selection, $isHost);
return false;
}
const anchor = selection.anchor;
const inner = $findSlotHost(anchor.getNode(), $isHost);
if (inner === null) {
return false;
}
const first = $orderedRegions(editor, inner)[0];
if (
first !== undefined &&
$isElementNode(first.startNode) &&
$isAtStartOfNode(anchor, first.startNode) &&
$isEmpty(inner)
) {
$deleteEmptyHost(inner);
if (event) {
event.preventDefault();
}
return true;
}
return false;
},
COMMAND_PRIORITY_BEFORE_EDITOR,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
$reanchorRangeBeforeHost(selection, $isHost);
}
return false;
},
COMMAND_PRIORITY_BEFORE_EDITOR,
),
);
}
export function registerHostChromeSelection<T extends LexicalNode>(
editor: LexicalEditor,
$isHost: (node: LexicalNode | null | undefined) => node is T,
): () => void {
const $resolveChromeTarget = (target: HTMLElement): T | null => {
const node = $getNearestNodeFromDOMNode(target);
if (!$isHost(node)) {
return null;
}
const hostElement = editor.getElementByKey(node.getKey());
if (hostElement === null || !hostElement.contains(target)) {
return null;
}
const slotWrapper = target.closest("[data-lexical-slot]");
if (slotWrapper !== null && hostElement.contains(slotWrapper)) {
return null;
}
return node;
};
const onChromeMouseDown = (event: MouseEvent) => {
if (!editor.isEditable()) {
return;
}
const target = event.target;
if (!isHTMLElement(target)) {
return;
}
if (editor.read(() => $resolveChromeTarget(target) !== null)) {
event.preventDefault();
const root = editor.getRootElement();
if (root !== null && root !== getActiveElement(root)) {
root.focus({ preventScroll: true });
const domSelection = getDOMSelection(root.ownerDocument.defaultView);
if (domSelection !== null) {
domSelection.removeAllRanges();
}
}
}
};
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
if (!editor.isEditable()) {
return false;
}
const target = event.target;
if (!isHTMLElement(target)) {
return false;
}
const node = $resolveChromeTarget(target);
if (node === null) {
return false;
}
event.preventDefault();
const ns = $createNodeSelection();
ns.add(node.getKey());
$setSelection(ns);
return true;
},
COMMAND_PRIORITY_BEFORE_EDITOR,
),
editor.registerRootListener((rootElement, prevRootElement) => {
if (prevRootElement !== null) {
prevRootElement.removeEventListener("mousedown", onChromeMouseDown);
}
if (rootElement !== null) {
rootElement.addEventListener("mousedown", onChromeMouseDown);
}
}),
);
}
export function $insertSlotHostAtRoot<T extends LexicalNode>(node: T): T {
$insertNodeToNearestRoot(node);
const before = node.getPreviousSibling();
if ($isParagraphNode(before) && before.getTextContentSize() === 0) {
before.remove();
}
return node.getLatest();
}
function $flattenInlines(
output: LexicalNode[],
input: Iterable<LexicalNode>,
): void {
for (const node of input) {
if ($isLineBreakNode(node)) {
continue;
} else if (node.isInline()) {
output.push(node);
} else if ($isElementNode(node)) {
$flattenInlines(output, node.getChildren());
}
}
}
export function $appendInline<T extends ElementNode>(
line: T,
nodes: Iterable<LexicalNode>,
): T {
const children: LexicalNode[] = [];
$flattenInlines(children, nodes);
return line.splice(line.getChildrenSize(), 0, children);
}
src/components/editor/extensions/special-text.ts
import { TextNode, defineExtension } from "lexical";
import {
$createSpecialTextNode,
SpecialTextNode,
} from "@/components/editor/nodes/special-text-node";
const BRACKETED_TEXT_REGEX = /\[([^[\]]+)\]/;
function $findAndTransformText(node: TextNode): TextNode | null {
const match = BRACKETED_TEXT_REGEX.exec(node.getTextContent());
if (match === null) {
return null;
}
const targetNode =
match.index === 0
? node.splitText(match.index + match[0].length)[0]
: node.splitText(match.index, match.index + match[0].length)[1];
const specialTextNode = $createSpecialTextNode(match[1]);
targetNode.replace(specialTextNode);
return specialTextNode;
}
function $specialTextNodeTransform(node: TextNode): void {
let targetNode: TextNode | null = node;
while (targetNode !== null) {
if (!targetNode.isSimpleText()) {
return;
}
targetNode = $findAndTransformText(targetNode);
}
}
export const SpecialTextExtension = defineExtension({
name: "@shadcn-editor/editor/SpecialText",
nodes: () => [SpecialTextNode],
register: (editor) =>
editor.registerNodeTransform(TextNode, $specialTextNodeTransform),
});
src/components/editor/extensions/speech-to-text.ts
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
type LexicalEditor,
REDO_COMMAND,
type RangeSelection,
UNDO_COMMAND,
createCommand,
defineExtension,
} from "lexical";
type SpeechRecognitionResultEventLike = {
resultIndex: number;
results: {
item(index: number): {
isFinal: boolean;
item(index: number): { transcript: string };
};
};
};
type SpeechRecognitionLike = {
continuous: boolean;
interimResults: boolean;
lang: string;
start(): void;
stop(): void;
addEventListener(
type: "result",
listener: (event: SpeechRecognitionResultEventLike) => void,
): void;
addEventListener(type: "end", listener: () => void): void;
};
type WindowWithSpeechRecognition = Window & {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
};
export const SPEECH_TO_TEXT_COMMAND: LexicalCommand<boolean> = createCommand(
"SPEECH_TO_TEXT_COMMAND",
);
export const SUPPORT_SPEECH_RECOGNITION: boolean =
typeof window !== "undefined" &&
("SpeechRecognition" in window || "webkitSpeechRecognition" in window);
const RECOGNITION_LANGUAGE: Record<string, string> = {
ar: "ar-SA",
en: "en-US",
he: "he-IL",
};
const VOICE_COMMANDS: Readonly<
Record<
string,
(arg: { editor: LexicalEditor; selection: RangeSelection }) => void
>
> = {
"\n": ({ selection }) => {
selection.insertParagraph();
},
redo: ({ editor }) => {
editor.dispatchCommand(REDO_COMMAND, undefined);
},
undo: ({ editor }) => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
},
};
export const SpeechToTextExtension = defineExtension({
name: "@shadcn-editor/editor/SpeechToText",
register: (editor) => {
let recognition: SpeechRecognitionLike | null = null;
let lastTranscript = "";
const stopRecognition = () => {
const active = recognition;
recognition = null;
active?.stop();
};
const startRecognition = () => {
const { SpeechRecognition, webkitSpeechRecognition } =
window as WindowWithSpeechRecognition;
const SpeechRecognitionImpl =
SpeechRecognition ?? webkitSpeechRecognition;
if (!SpeechRecognitionImpl) {
return;
}
lastTranscript = "";
const instance = new SpeechRecognitionImpl();
recognition = instance;
instance.continuous = true;
instance.interimResults = true;
const lang = editor
.getRootElement()
?.closest<HTMLElement>("[lang]")?.lang;
instance.lang =
(lang && RECOGNITION_LANGUAGE[lang]) || RECOGNITION_LANGUAGE.en;
instance.addEventListener("result", (event) => {
const resultItem = event.results.item(event.resultIndex);
const { transcript } = resultItem.item(0);
if (!resultItem.isFinal || transcript.length === 0) {
return;
}
const prev = lastTranscript;
if (transcript === prev || prev.startsWith(transcript)) {
return;
}
const textToInsert = transcript.startsWith(prev)
? transcript.slice(prev.length)
: transcript;
lastTranscript = transcript;
editor.update(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const command = VOICE_COMMANDS[textToInsert.toLowerCase().trim()];
if (command) {
command({ editor, selection });
} else if (textToInsert.match(/\s*\n\s*/)) {
selection.insertParagraph();
} else {
selection.insertText(textToInsert);
}
});
});
instance.addEventListener("end", () => {
if (recognition === instance) {
recognition = null;
editor.dispatchCommand(SPEECH_TO_TEXT_COMMAND, false);
}
});
instance.start();
};
const unregisterCommand = editor.registerCommand(
SPEECH_TO_TEXT_COMMAND,
(enabled) => {
if (enabled) {
if (recognition === null) {
startRecognition();
}
} else {
stopRecognition();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
);
return () => {
unregisterCommand();
stopRecognition();
};
},
});
src/components/editor/extensions/tab-focus.ts
import {
$getSelection,
$isRangeSelection,
$setSelection,
COMMAND_PRIORITY_LOW,
FOCUS_COMMAND,
defineExtension,
} from "lexical";
const TAB_TO_FOCUS_INTERVAL = 100;
let lastTabKeyDownTimestamp = 0;
let hasRegisteredKeyDownListener = false;
function registerKeyTimeStampTracker() {
window.addEventListener(
"keydown",
(event: KeyboardEvent) => {
if (event.key === "Tab") {
lastTabKeyDownTimestamp = event.timeStamp;
}
},
true,
);
}
export const TabFocusExtension = defineExtension({
name: "@shadcn-editor/tab-focus",
register: (editor) => {
if (!hasRegisteredKeyDownListener) {
registerKeyTimeStampTracker();
hasRegisteredKeyDownListener = true;
}
return editor.registerCommand(
FOCUS_COMMAND,
(event: FocusEvent) => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
if (
lastTabKeyDownTimestamp + TAB_TO_FOCUS_INTERVAL >
event.timeStamp
) {
$setSelection(selection.clone());
}
}
return false;
},
COMMAND_PRIORITY_LOW,
);
},
});
src/components/editor/extensions/twitter.ts
import {
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import { DOMImportExtension, defineImportRule, sel } from "@lexical/html";
import { $insertNodeToNearestRoot } from "@lexical/utils";
import {
$createTweetNode,
TweetNode,
} from "@/components/editor/nodes/tweet-node";
export const INSERT_TWEET_COMMAND: LexicalCommand<string> = createCommand(
"INSERT_TWEET_COMMAND",
);
const TweetImportRule = defineImportRule({
$import: (ctx) => [$createTweetNode(ctx.captures.id[0])],
match: sel
.tag("div")
.attr("data-lexical-tweet-id", /^.+$/, { capture: "id" }),
name: "@shadcn-editor/editor/tweet",
});
export const TwitterExtension = defineExtension({
name: "@shadcn-editor/editor/Twitter",
dependencies: [
configExtension(DOMImportExtension, {
rules: [TweetImportRule],
}),
],
nodes: () => [TweetNode],
register: (editor) =>
editor.registerCommand(
INSERT_TWEET_COMMAND,
(tweetID) => {
const tweetNode = $createTweetNode(tweetID);
$insertNodeToNearestRoot(tweetNode);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/extensions/youtube.ts
import {
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
configExtension,
createCommand,
defineExtension,
} from "lexical";
import { DOMImportExtension, defineImportRule, sel } from "@lexical/html";
import { $insertNodeToNearestRoot } from "@lexical/utils";
import {
$createYouTubeNode,
YouTubeNode,
} from "@/components/editor/nodes/youtube-node";
export const INSERT_YOUTUBE_COMMAND: LexicalCommand<string> = createCommand(
"INSERT_YOUTUBE_COMMAND",
);
const YouTubeImportRule = defineImportRule({
$import: (ctx) => [$createYouTubeNode(ctx.captures.id[0])],
match: sel
.tag("iframe")
.attr("data-lexical-youtube", /^.+$/, { capture: "id" }),
name: "@shadcn-editor/editor/youtube",
});
export const YouTubeExtension = defineExtension({
name: "@shadcn-editor/editor/YouTube",
dependencies: [
configExtension(DOMImportExtension, {
rules: [YouTubeImportRule],
}),
],
nodes: () => [YouTubeNode],
register: (editor) =>
editor.registerCommand(
INSERT_YOUTUBE_COMMAND,
(videoID) => {
const youTubeNode = $createYouTubeNode(videoID);
$insertNodeToNearestRoot(youTubeNode);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
});
src/components/editor/locales.ts
import type { Language } from "@/components/editor/plugins/i18n-plugin";
export type Locale = {
typeSomething: string;
undo: string;
redo: string;
blockFormatTrigger: string;
paragraph: string;
heading1: string;
heading2: string;
heading3: string;
numberedListBlock: string;
bulletedListBlock: string;
checkListBlock: string;
quote: string;
fontFamilyPlaceholder: string;
noMatches: string;
addBlockBelow: string;
clearFormatting: string;
textFormatToolbar: string;
alignLeft: string;
alignCenter: string;
alignRight: string;
alignJustify: string;
alignStart: string;
alignEnd: string;
outdent: string;
indent: string;
bold: string;
italic: string;
underline: string;
strikethrough: string;
inlineCode: string;
highlight: string;
subscript: string;
superscript: string;
lowercase: string;
uppercase: string;
titleCase: string;
link: string;
insertLink: string;
editLink: string;
removeLink: string;
openLink: string;
linkUrlPlaceholder: string;
saveLink: string;
decreaseFontSize: string;
increaseFontSize: string;
fontSizeTitle: string;
textColor: string;
backgroundColor: string;
switchToReadOnly: string;
switchToEditMode: string;
words: string;
characters: string;
speechToText: string;
autocompleteSwipe: string;
clearEditor: string;
clearEditorDialogTitle: string;
clearEditorDialogDescription: string;
clear: string;
cancel: string;
editorMenu: string;
menuFile: string;
menuEdit: string;
menuInsert: string;
menuFormat: string;
menuLayout: string;
menuHelp: string;
newDocument: string;
importFile: string;
exportFile: string;
exportAs: string;
exportLexical: string;
exportMarkdown: string;
exportHtml: string;
exportPdf: string;
exportPlainText: string;
printDocument: string;
deleteAllContent: string;
cut: string;
copy: string;
paste: string;
pasteAsPlainText: string;
deleteNode: string;
clipboardNotAllowed: string;
selectAll: string;
findAndReplace: string;
tableOfContents: string;
comments: string;
noComments: string;
typeComment: string;
replyToComment: string;
comment: string;
delete: string;
deleteCommentConfirm: string;
deleteThreadConfirm: string;
justNow: string;
deletedComment: string;
you: string;
find: string;
replace: string;
replaceAll: string;
previousMatch: string;
nextMatch: string;
matchCount: string;
matchCase: string;
useRegex: string;
noResults: string;
invalidRegex: string;
closeFindReplace: string;
addComment: string;
insertImage: string;
insertVideo: string;
insertYoutube: string;
youtubeUrlPlaceholder: string;
embedYoutube: string;
insertTweet: string;
tweetUrlPlaceholder: string;
embedTweet: string;
insertFigma: string;
figmaUrlPlaceholder: string;
embedFigma: string;
autoEmbedDismiss: string;
insertTable: string;
addTableRow: string;
addTableColumn: string;
deleteTableRow: string;
deleteTableColumn: string;
insertCodeBlock: string;
insertEmoji: string;
insertSpecialCharacters: string;
insertDateTime: string;
dateTimeToday: string;
dateTimeTomorrow: string;
dateTimeYesterday: string;
dateTimeTime: string;
insertHorizontalRule: string;
insertCard: string;
insertCollapsible: string;
insertPullQuote: string;
insertReview: string;
insertPoll: string;
pollQuestionPlaceholder: string;
pollOptionPlaceholder: string;
pollAddOption: string;
pollRemoveOption: string;
pollVote: string;
pollVotes: string;
insertEquation: string;
equationPlaceholder: string;
equationInline: string;
equationInsert: string;
insertRuby: string;
rubyAnnotationPlaceholder: string;
removeRuby: string;
askAi: string;
askAiPlaceholder: string;
aiImproveWriting: string;
aiMakeLonger: string;
aiMakeShorter: string;
aiFixSpelling: string;
aiContinueWriting: string;
aiSummarize: string;
aiBrainstorm: string;
aiReplace: string;
aiInsertBelow: string;
aiTryAgain: string;
aiDiscard: string;
aiInsert: string;
askAiWritePlaceholder: string;
aiGenerating: string;
chatMessage: string;
chatPlaceholder: string;
chatSend: string;
chatStop: string;
chatThinking: string;
insertPageBreak: string;
textAlignment: string;
lineSpacing: string;
textCase: string;
pageSize: string;
pageSizePageless: string;
pageSizeA4: string;
pageSizeLetter: string;
pageSizeLegal: string;
pageSizeTabloid: string;
pageSizeA3: string;
pageSizeA5: string;
pageSizeB4: string;
pageSizeB5: string;
pageSizeStatement: string;
pageSizeExecutive: string;
pageSizeFolio: string;
orientation: string;
orientationPortrait: string;
orientationLandscape: string;
margins: string;
marginNarrow: string;
marginNormal: string;
marginModerate: string;
marginWide: string;
columns: string;
columnsOne: string;
columnsTwo: string;
columnsThree: string;
documentation: string;
keyboardShortcuts: string;
reportIssue: string;
sendFeedback: string;
aboutEditor: string;
shortcutAction: string;
shortcutKeys: string;
shortcutsHistory: string;
shortcutsFormatting: string;
shortcutsParagraphs: string;
shortcutsLists: string;
};
export const locales: Record<Language, Locale> = {
en: {
typeSomething: "Type something...",
undo: "Undo",
redo: "Redo",
blockFormatTrigger: "Format",
paragraph: "Paragraph",
heading1: "Heading 1",
heading2: "Heading 2",
heading3: "Heading 3",
numberedListBlock: "Numbered List",
bulletedListBlock: "Bulleted List",
checkListBlock: "Check List",
quote: "Quote",
fontFamilyPlaceholder: "Font",
noMatches: "No matches.",
addBlockBelow: "Click to add below",
clearFormatting: "Clear formatting",
textFormatToolbar: "Text formatting",
alignLeft: "Align left",
alignCenter: "Align center",
alignRight: "Align right",
alignJustify: "Justify",
alignStart: "Align start",
alignEnd: "Align end",
outdent: "Outdent",
indent: "Indent",
bold: "Bold",
italic: "Italic",
underline: "Underline",
strikethrough: "Strikethrough",
inlineCode: "Inline code",
highlight: "Highlight",
subscript: "Subscript",
superscript: "Superscript",
lowercase: "Lowercase",
uppercase: "Uppercase",
titleCase: "Title case",
link: "Link",
insertLink: "Insert link",
editLink: "Edit link",
removeLink: "Remove link",
openLink: "Open link",
linkUrlPlaceholder: "Enter a URL",
saveLink: "Save link",
decreaseFontSize: "Decrease font size",
increaseFontSize: "Increase font size",
fontSizeTitle: "Font size",
textColor: "Text color",
backgroundColor: "Background color",
switchToReadOnly: "Switch to read-only mode",
switchToEditMode: "Switch to edit mode",
words: "words",
characters: "characters",
speechToText: "Speech to text",
autocompleteSwipe: "SWIPE",
clearEditor: "Clear editor contents",
clearEditorDialogTitle: "Clear editor",
clearEditorDialogDescription: "Are you sure you want to clear the editor?",
clear: "Clear",
cancel: "Cancel",
editorMenu: "Editor menu",
menuFile: "File",
menuEdit: "Edit",
menuInsert: "Insert",
menuFormat: "Format",
menuLayout: "Layout",
menuHelp: "Help",
newDocument: "New document",
importFile: "Import",
exportFile: "Export",
exportAs: "Export as",
exportLexical: "Lexical",
exportMarkdown: "Markdown",
exportHtml: "HTML",
exportPdf: "PDF",
exportPlainText: "Plain text",
printDocument: "Print",
deleteAllContent: "Delete all content",
cut: "Cut",
copy: "Copy",
paste: "Paste",
pasteAsPlainText: "Paste as plain text",
deleteNode: "Delete node",
clipboardNotAllowed: "Not allowed to paste from clipboard.",
selectAll: "Select all",
findAndReplace: "Find and replace",
tableOfContents: "Table of contents",
comments: "Comments",
noComments: "No comments yet",
typeComment: "Type a comment…",
replyToComment: "Reply…",
comment: "Comment",
delete: "Delete",
deleteCommentConfirm: "Delete this comment?",
deleteThreadConfirm: "Delete this thread?",
justNow: "Just now",
deletedComment: "Comment deleted",
you: "You",
find: "Find",
replace: "Replace",
replaceAll: "Replace all",
previousMatch: "Previous match",
nextMatch: "Next match",
matchCount: "{current} of {total}",
matchCase: "Match case",
useRegex: "Use regular expression",
noResults: "No results",
invalidRegex: "Invalid pattern",
closeFindReplace: "Close find and replace",
addComment: "Add comment",
insertImage: "Image",
insertVideo: "Video",
insertYoutube: "YouTube",
youtubeUrlPlaceholder: "Paste a YouTube link",
embedYoutube: "Embed video",
insertTweet: "X (Twitter)",
tweetUrlPlaceholder: "Paste an X post link",
embedTweet: "Embed post",
insertFigma: "Figma",
figmaUrlPlaceholder: "Paste a Figma link",
embedFigma: "Embed design",
autoEmbedDismiss: "Dismiss",
insertTable: "Table",
addTableRow: "Add row",
addTableColumn: "Add column",
deleteTableRow: "Delete row",
deleteTableColumn: "Delete column",
insertCodeBlock: "Code block",
insertEmoji: "Emoji",
insertSpecialCharacters: "Special characters",
insertDateTime: "Date & time",
dateTimeToday: "Today",
dateTimeTomorrow: "Tomorrow",
dateTimeYesterday: "Yesterday",
dateTimeTime: "Time",
insertHorizontalRule: "Horizontal rule",
insertCard: "Card",
insertCollapsible: "Collapsible",
insertPullQuote: "Pull quote",
insertReview: "Review",
insertPoll: "Poll",
pollQuestionPlaceholder: "Poll question",
pollOptionPlaceholder: "Option",
pollAddOption: "Add option",
pollRemoveOption: "Remove option",
pollVote: "vote",
pollVotes: "votes",
insertEquation: "Equation",
equationPlaceholder: "LaTeX expression",
equationInline: "Inline",
equationInsert: "Insert equation",
insertRuby: "Ruby annotation",
rubyAnnotationPlaceholder: "Annotation",
removeRuby: "Remove annotation",
askAi: "Ask AI",
askAiPlaceholder: "Ask AI anything...",
aiImproveWriting: "Improve writing",
aiMakeLonger: "Make longer",
aiMakeShorter: "Make shorter",
aiFixSpelling: "Fix spelling & grammar",
aiContinueWriting: "Continue writing",
aiSummarize: "Summarize",
aiBrainstorm: "Brainstorm ideas",
aiReplace: "Replace",
aiInsertBelow: "Insert below",
aiTryAgain: "Try again",
aiDiscard: "Discard",
aiInsert: "Insert",
askAiWritePlaceholder: "Ask AI to write something...",
aiGenerating: "Generating…",
chatMessage: "Message",
chatPlaceholder: "Write a message…",
chatSend: "Send message",
chatStop: "Stop generating",
chatThinking: "Thinking…",
insertPageBreak: "Page break",
textAlignment: "Alignment",
lineSpacing: "Line spacing",
textCase: "Text case",
pageSize: "Page size",
pageSizePageless: "Pageless",
pageSizeA4: "A4 (8.27 × 11.69 in)",
pageSizeLetter: "Letter (8.5 × 11 in)",
pageSizeLegal: "Legal (8.5 × 14 in)",
pageSizeTabloid: "Tabloid (11 × 17 in)",
pageSizeA3: "A3 (11.69 × 16.54 in)",
pageSizeA5: "A5 (5.83 × 8.27 in)",
pageSizeB4: "B4 (9.84 × 13.90 in)",
pageSizeB5: "B5 (6.93 × 9.84 in)",
pageSizeStatement: "Statement (5.5 × 8.5 in)",
pageSizeExecutive: "Executive (7.25 × 10.5 in)",
pageSizeFolio: "Folio (8.5 × 13 in)",
orientation: "Orientation",
orientationPortrait: "Portrait",
orientationLandscape: "Landscape",
margins: "Margins",
marginNarrow: "Narrow",
marginNormal: "Normal",
marginModerate: "Moderate",
marginWide: "Wide",
columns: "Columns",
columnsOne: "One column",
columnsTwo: "Two columns",
columnsThree: "Three columns",
documentation: "Documentation",
keyboardShortcuts: "Keyboard shortcuts",
reportIssue: "Report issue",
sendFeedback: "Send feedback",
aboutEditor: "About",
shortcutAction: "Action",
shortcutKeys: "Shortcut",
shortcutsHistory: "History",
shortcutsFormatting: "Formatting",
shortcutsParagraphs: "Paragraphs & headings",
shortcutsLists: "Lists",
},
ar: {
typeSomething: "اكتب شيئًا...",
undo: "تراجع",
redo: "إعادة",
blockFormatTrigger: "تنسيق",
paragraph: "فقرة",
heading1: "عنوان ١",
heading2: "عنوان ٢",
heading3: "عنوان ٣",
numberedListBlock: "قائمة مرقمة",
bulletedListBlock: "قائمة نقطية",
checkListBlock: "قائمة مهام",
quote: "اقتباس",
fontFamilyPlaceholder: "الخط",
noMatches: "لا توجد نتائج.",
addBlockBelow: "انقر للإضافة أدناه",
clearFormatting: "مسح التنسيق",
textFormatToolbar: "تنسيق النص",
alignLeft: "محاذاة لليسار",
alignCenter: "توسيط",
alignRight: "محاذاة لليمين",
alignJustify: "ضبط",
alignStart: "محاذاة للبداية",
alignEnd: "محاذاة للنهاية",
outdent: "تقليل المسافة البادئة",
indent: "زيادة المسافة البادئة",
bold: "غامق",
italic: "مائل",
underline: "تسطير",
strikethrough: "يتوسطه خط",
inlineCode: "كود مضمن",
highlight: "تمييز",
subscript: "نص سفلي",
superscript: "نص علوي",
lowercase: "أحرف صغيرة",
uppercase: "أحرف كبيرة",
titleCase: "تكبير أول حرف",
link: "رابط",
insertLink: "إدراج رابط",
editLink: "تعديل الرابط",
removeLink: "إزالة الرابط",
openLink: "فتح الرابط",
linkUrlPlaceholder: "أدخل عنوان URL",
saveLink: "حفظ الرابط",
decreaseFontSize: "تصغير حجم الخط",
increaseFontSize: "تكبير حجم الخط",
fontSizeTitle: "حجم الخط",
textColor: "لون الخط",
backgroundColor: "لون الخلفية",
switchToReadOnly: "التبديل إلى وضع القراءة فقط",
switchToEditMode: "التبديل إلى وضع التحرير",
words: "كلمة",
characters: "حرف",
speechToText: "تحويل الكلام إلى نص",
autocompleteSwipe: "اسحب",
clearEditor: "مسح محتوى المحرر",
clearEditorDialogTitle: "مسح المحرر",
clearEditorDialogDescription: "هل أنت متأكد أنك تريد مسح المحرر؟",
clear: "مسح",
cancel: "إلغاء",
editorMenu: "قائمة المحرر",
menuFile: "ملف",
menuEdit: "تحرير",
menuInsert: "إدراج",
menuFormat: "تنسيق",
menuLayout: "تخطيط",
menuHelp: "مساعدة",
newDocument: "مستند جديد",
importFile: "استيراد",
exportFile: "تصدير",
exportAs: "تصدير كـ",
exportLexical: "ليكسيكال",
exportMarkdown: "ماركداون",
exportHtml: "HTML",
exportPdf: "PDF",
exportPlainText: "نص عادي",
printDocument: "طباعة",
deleteAllContent: "حذف كل المحتوى",
cut: "قص",
copy: "نسخ",
paste: "لصق",
pasteAsPlainText: "لصق كنص عادي",
deleteNode: "حذف العنصر",
clipboardNotAllowed: "غير مسموح باللصق من الحافظة.",
selectAll: "تحديد الكل",
findAndReplace: "بحث واستبدال",
tableOfContents: "جدول المحتويات",
comments: "التعليقات",
noComments: "لا توجد تعليقات بعد",
typeComment: "اكتب تعليقًا…",
replyToComment: "رد…",
comment: "تعليق",
delete: "حذف",
deleteCommentConfirm: "هل تريد حذف هذا التعليق؟",
deleteThreadConfirm: "هل تريد حذف هذه المحادثة؟",
justNow: "الآن",
deletedComment: "تم حذف التعليق",
you: "أنت",
find: "بحث",
replace: "استبدال",
replaceAll: "استبدال الكل",
previousMatch: "النتيجة السابقة",
nextMatch: "النتيجة التالية",
matchCount: "{current} من {total}",
matchCase: "مطابقة حالة الأحرف",
useRegex: "استخدام التعبيرات النمطية",
noResults: "لا توجد نتائج",
invalidRegex: "نمط غير صالح",
closeFindReplace: "إغلاق البحث والاستبدال",
addComment: "إضافة تعليق",
insertImage: "صورة",
insertVideo: "فيديو",
insertYoutube: "يوتيوب",
youtubeUrlPlaceholder: "الصق رابط يوتيوب",
embedYoutube: "تضمين الفيديو",
insertTweet: "إكس (تويتر)",
tweetUrlPlaceholder: "الصق رابط منشور إكس",
embedTweet: "تضمين المنشور",
insertFigma: "فيجما",
figmaUrlPlaceholder: "الصق رابط فيجما",
embedFigma: "تضمين التصميم",
autoEmbedDismiss: "تجاهل",
insertTable: "جدول",
addTableRow: "إضافة صف",
addTableColumn: "إضافة عمود",
deleteTableRow: "حذف صف",
deleteTableColumn: "حذف عمود",
insertCodeBlock: "كتلة كود",
insertEmoji: "رمز تعبيري",
insertSpecialCharacters: "رموز خاصة",
insertDateTime: "التاريخ والوقت",
dateTimeToday: "اليوم",
dateTimeTomorrow: "غداً",
dateTimeYesterday: "أمس",
dateTimeTime: "الوقت",
insertHorizontalRule: "خط أفقي",
insertCard: "بطاقة",
insertCollapsible: "قائمة قابلة للطي",
insertPullQuote: "اقتباس بارز",
insertReview: "مراجعة",
insertPoll: "استطلاع",
pollQuestionPlaceholder: "سؤال الاستطلاع",
pollOptionPlaceholder: "خيار",
pollAddOption: "إضافة خيار",
pollRemoveOption: "إزالة الخيار",
pollVote: "صوت",
pollVotes: "أصوات",
insertEquation: "معادلة",
equationPlaceholder: "صيغة LaTeX",
equationInline: "ضمن السطر",
equationInsert: "إدراج معادلة",
insertRuby: "شرح روبي",
rubyAnnotationPlaceholder: "الشرح",
removeRuby: "إزالة الشرح",
askAi: "اسأل الذكاء الاصطناعي",
askAiPlaceholder: "اسأل الذكاء الاصطناعي أي شيء...",
aiImproveWriting: "تحسين الكتابة",
aiMakeLonger: "إطالة النص",
aiMakeShorter: "اختصار النص",
aiFixSpelling: "تصحيح الإملاء والنحو",
aiContinueWriting: "متابعة الكتابة",
aiSummarize: "تلخيص",
aiBrainstorm: "عصف ذهني للأفكار",
aiReplace: "استبدال",
aiInsertBelow: "إدراج بالأسفل",
aiTryAgain: "حاول مرة أخرى",
aiDiscard: "تجاهل",
aiInsert: "إدراج",
askAiWritePlaceholder: "اطلب من الذكاء الاصطناعي كتابة شيء...",
aiGenerating: "جارٍ الإنشاء…",
chatMessage: "رسالة",
chatPlaceholder: "اكتب رسالة…",
chatSend: "إرسال الرسالة",
chatStop: "إيقاف التوليد",
chatThinking: "جارٍ التفكير…",
insertPageBreak: "فاصل صفحة",
textAlignment: "المحاذاة",
lineSpacing: "تباعد الأسطر",
textCase: "حالة الأحرف",
pageSize: "حجم الصفحة",
pageSizePageless: "بدون صفحات",
pageSizeA4: "A4 (8.27 × 11.69 بوصة)",
pageSizeLetter: "Letter (8.5 × 11 بوصة)",
pageSizeLegal: "Legal (8.5 × 14 بوصة)",
pageSizeTabloid: "Tabloid (11 × 17 بوصة)",
pageSizeA3: "A3 (11.69 × 16.54 بوصة)",
pageSizeA5: "A5 (5.83 × 8.27 بوصة)",
pageSizeB4: "B4 (9.84 × 13.90 بوصة)",
pageSizeB5: "B5 (6.93 × 9.84 بوصة)",
pageSizeStatement: "Statement (5.5 × 8.5 بوصة)",
pageSizeExecutive: "Executive (7.25 × 10.5 بوصة)",
pageSizeFolio: "Folio (8.5 × 13 بوصة)",
orientation: "الاتجاه",
orientationPortrait: "عمودي",
orientationLandscape: "أفقي",
margins: "الهوامش",
marginNarrow: "ضيقة",
marginNormal: "عادية",
marginModerate: "متوسطة",
marginWide: "واسعة",
columns: "الأعمدة",
columnsOne: "عمود واحد",
columnsTwo: "عمودان",
columnsThree: "ثلاثة أعمدة",
documentation: "التوثيق",
keyboardShortcuts: "اختصارات لوحة المفاتيح",
reportIssue: "الإبلاغ عن مشكلة",
sendFeedback: "إرسال ملاحظات",
aboutEditor: "حول",
shortcutAction: "الإجراء",
shortcutKeys: "الاختصار",
shortcutsHistory: "السجل",
shortcutsFormatting: "التنسيق",
shortcutsParagraphs: "الفقرات والعناوين",
shortcutsLists: "القوائم",
},
he: {
typeSomething: "התחל לכתוב...",
undo: "בטל",
redo: "בצע שוב",
blockFormatTrigger: "עיצוב",
paragraph: "פסקה",
heading1: "כותרת 1",
heading2: "כותרת 2",
heading3: "כותרת 3",
numberedListBlock: "רשימה ממוספרת",
bulletedListBlock: "רשימת תבליטים",
checkListBlock: "רשימת משימות",
quote: "ציטוט",
fontFamilyPlaceholder: "גופן",
noMatches: "לא נמצאו תוצאות.",
addBlockBelow: "לחץ להוספה מתחת",
clearFormatting: "נקה עיצוב",
textFormatToolbar: "עיצוב טקסט",
alignLeft: "יישור לשמאל",
alignCenter: "מרכוז",
alignRight: "יישור לימין",
alignJustify: "יישור דו-צדדי",
alignStart: "יישור להתחלה",
alignEnd: "יישור לסוף",
outdent: "הקטן כניסה",
indent: "הגדל כניסה",
bold: "מודגש",
italic: "נטוי",
underline: "קו תחתון",
strikethrough: "קו חוצה",
inlineCode: "קוד מוטבע",
highlight: "הדגשה",
subscript: "כתב תחתי",
superscript: "כתב עילי",
lowercase: "אותיות קטנות",
uppercase: "אותיות גדולות",
titleCase: "אות ראשונה גדולה",
link: "קישור",
insertLink: "הוספת קישור",
editLink: "עריכת קישור",
removeLink: "הסרת קישור",
openLink: "פתיחת קישור",
linkUrlPlaceholder: "הזן כתובת URL",
saveLink: "שמירת קישור",
decreaseFontSize: "הקטן גודל גופן",
increaseFontSize: "הגדל גודל גופן",
fontSizeTitle: "גודל גופן",
textColor: "צבע גופן",
backgroundColor: "צבע רקע",
switchToReadOnly: "עבור למצב קריאה בלבד",
switchToEditMode: "עבור למצב עריכה",
words: "מילים",
characters: "תווים",
speechToText: "המרת דיבור לטקסט",
autocompleteSwipe: "החלק",
clearEditor: "נקה את תוכן העורך",
clearEditorDialogTitle: "ניקוי העורך",
clearEditorDialogDescription: "האם אתה בטוח שברצונך לנקות את העורך?",
clear: "נקה",
cancel: "ביטול",
editorMenu: "תפריט העורך",
menuFile: "קובץ",
menuEdit: "עריכה",
menuInsert: "הוספה",
menuFormat: "עיצוב",
menuLayout: "פריסה",
menuHelp: "עזרה",
newDocument: "מסמך חדש",
importFile: "ייבוא",
exportFile: "ייצוא",
exportAs: "ייצוא בתור",
exportLexical: "Lexical",
exportMarkdown: "Markdown",
exportHtml: "HTML",
exportPdf: "PDF",
exportPlainText: "טקסט רגיל",
printDocument: "הדפסה",
deleteAllContent: "מחיקת כל התוכן",
cut: "גזור",
copy: "העתק",
paste: "הדבק",
pasteAsPlainText: "הדבק כטקסט רגיל",
deleteNode: "מחק רכיב",
clipboardNotAllowed: "אין הרשאה להדביק מהלוח.",
selectAll: "בחר הכול",
findAndReplace: "חיפוש והחלפה",
tableOfContents: "תוכן עניינים",
comments: "תגובות",
noComments: "אין תגובות עדיין",
typeComment: "כתבו תגובה…",
replyToComment: "השיבו…",
comment: "תגובה",
delete: "מחיקה",
deleteCommentConfirm: "למחוק את התגובה?",
deleteThreadConfirm: "למחוק את השרשור?",
justNow: "הרגע",
deletedComment: "התגובה נמחקה",
you: "אני",
find: "חיפוש",
replace: "החלפה",
replaceAll: "החלף הכול",
previousMatch: "התאמה קודמת",
nextMatch: "התאמה הבאה",
matchCount: "{current} מתוך {total}",
matchCase: "התאמת רישיות",
useRegex: "שימוש בביטוי רגולרי",
noResults: "אין תוצאות",
invalidRegex: "תבנית לא תקינה",
closeFindReplace: "סגירת חיפוש והחלפה",
addComment: "הוסף הערה",
insertImage: "תמונה",
insertVideo: "וידאו",
insertYoutube: "יוטיוב",
youtubeUrlPlaceholder: "הדבק קישור יוטיוב",
embedYoutube: "הטמעת וידאו",
insertTweet: "X (טוויטר)",
tweetUrlPlaceholder: "הדבק קישור לפוסט X",
embedTweet: "הטמעת פוסט",
insertFigma: "פיגמה",
figmaUrlPlaceholder: "הדבק קישור פיגמה",
embedFigma: "הטמעת עיצוב",
autoEmbedDismiss: "התעלם",
insertTable: "טבלה",
addTableRow: "הוספת שורה",
addTableColumn: "הוספת עמודה",
deleteTableRow: "מחיקת שורה",
deleteTableColumn: "מחיקת עמודה",
insertCodeBlock: "בלוק קוד",
insertEmoji: "אימוג'י",
insertSpecialCharacters: "תווים מיוחדים",
insertDateTime: "תאריך ושעה",
dateTimeToday: "היום",
dateTimeTomorrow: "מחר",
dateTimeYesterday: "אתמול",
dateTimeTime: "שעה",
insertHorizontalRule: "קו אופקי",
insertCard: "כרטיס",
insertCollapsible: "בלוק מתקפל",
insertPullQuote: "ציטוט מודגש",
insertReview: "ביקורת",
insertPoll: "סקר",
pollQuestionPlaceholder: "שאלת הסקר",
pollOptionPlaceholder: "אפשרות",
pollAddOption: "הוספת אפשרות",
pollRemoveOption: "הסרת האפשרות",
pollVote: "קול",
pollVotes: "קולות",
insertEquation: "משוואה",
equationPlaceholder: "ביטוי LaTeX",
equationInline: "בתוך השורה",
equationInsert: "הוספת משוואה",
insertRuby: "הערת רובי",
rubyAnnotationPlaceholder: "הערה",
removeRuby: "הסרת ההערה",
askAi: "שאל את ה-AI",
askAiPlaceholder: "שאל את ה-AI כל דבר...",
aiImproveWriting: "שיפור הכתיבה",
aiMakeLonger: "הארכת הטקסט",
aiMakeShorter: "קיצור הטקסט",
aiFixSpelling: "תיקון איות ודקדוק",
aiContinueWriting: "המשך כתיבה",
aiSummarize: "סיכום",
aiBrainstorm: "סיעור מוחות",
aiReplace: "החלפה",
aiInsertBelow: "הוספה מתחת",
aiTryAgain: "נסה שוב",
aiDiscard: "ביטול",
aiInsert: "הוספה",
askAiWritePlaceholder: "בקש מה-AI לכתוב משהו...",
aiGenerating: "יוצר…",
chatMessage: "הודעה",
chatPlaceholder: "כתוב הודעה…",
chatSend: "שלח הודעה",
chatStop: "עצור יצירה",
chatThinking: "חושב…",
insertPageBreak: "מעבר עמוד",
textAlignment: "יישור",
lineSpacing: "מרווח שורות",
textCase: "רישיות טקסט",
pageSize: "גודל עמוד",
pageSizePageless: "ללא עמודים",
pageSizeA4: "A4 (8.27 × 11.69 אינץ')",
pageSizeLetter: "Letter (8.5 × 11 אינץ')",
pageSizeLegal: "Legal (8.5 × 14 אינץ')",
pageSizeTabloid: "Tabloid (11 × 17 אינץ')",
pageSizeA3: "A3 (11.69 × 16.54 אינץ')",
pageSizeA5: "A5 (5.83 × 8.27 אינץ')",
pageSizeB4: "B4 (9.84 × 13.90 אינץ')",
pageSizeB5: "B5 (6.93 × 9.84 אינץ')",
pageSizeStatement: "Statement (5.5 × 8.5 אינץ')",
pageSizeExecutive: "Executive (7.25 × 10.5 אינץ')",
pageSizeFolio: "Folio (8.5 × 13 אינץ')",
orientation: "כיוון",
orientationPortrait: "לאורך",
orientationLandscape: "לרוחב",
margins: "שוליים",
marginNarrow: "צרים",
marginNormal: "רגילים",
marginModerate: "בינוניים",
marginWide: "רחבים",
columns: "עמודות",
columnsOne: "עמודה אחת",
columnsTwo: "שתי עמודות",
columnsThree: "שלוש עמודות",
documentation: "תיעוד",
keyboardShortcuts: "קיצורי מקלדת",
reportIssue: "דווח על בעיה",
sendFeedback: "שלח משוב",
aboutEditor: "אודות",
shortcutAction: "פעולה",
shortcutKeys: "קיצור",
shortcutsHistory: "היסטוריה",
shortcutsFormatting: "עיצוב",
shortcutsParagraphs: "פסקאות וכותרות",
shortcutsLists: "רשימות",
},
};
src/components/editor/nodes/card-node.ts
import {
$create,
$createParagraphNode,
$getDocument,
$getSlot,
$getSlotNames,
$setSlot,
type DOMExportOutput,
type EditorConfig,
ElementNode,
type LexicalEditor,
type LexicalNode,
addClassNamesToElement,
} from "lexical";
import { $appendNodeToHTML } from "@lexical/html";
export class CardNode extends ElementNode {
$config() {
return this.config("card", { extends: ElementNode, slots: ["title"] });
}
createDOM(config: EditorConfig): HTMLElement {
const dom = $getDocument().createElement("div");
const className = config.theme.card;
if (className) {
addClassNamesToElement(dom, className);
}
return dom;
}
updateDOM(): false {
return false;
}
isShadowRoot(): true {
return true;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = $getDocument().createElement("div");
element.setAttribute("data-lexical-card", "true");
for (const name of $getSlotNames(this)) {
const slot = $getSlot(this, name);
if (slot) {
const wrapper = $getDocument().createElement("div");
wrapper.setAttribute("data-lexical-slot", name);
$appendNodeToHTML(editor, slot, wrapper);
element.append(wrapper);
}
}
return { element };
}
}
export function $createCardNode(): CardNode {
const node = $create(CardNode);
$setSlot(node, "title", $createParagraphNode());
node.append($createParagraphNode());
return node;
}
export function $isCardNode(
node: LexicalNode | null | undefined,
): node is CardNode {
return node instanceof CardNode;
}
src/components/editor/nodes/collapsible-node.ts
import {
$createParagraphNode,
$getDocument,
$getSiblingCaret,
$isElementNode,
$rewindSiblingCaret,
type DOMExportOutput,
type EditorConfig,
ElementNode,
IS_CHROME,
IS_FIREFOX,
type LexicalEditor,
type LexicalNode,
type LexicalUpdateJSON,
type NodeKey,
type RangeSelection,
type SerializedElementNode,
type Spread,
addClassNamesToElement,
isHTMLElement,
} from "lexical";
export type SerializedCollapsibleContainerNode = Spread<
{ open: boolean },
SerializedElementNode
>;
function setDomHiddenUntilFound(dom: HTMLElement): void {
(dom as { hidden: boolean | string }).hidden = "until-found";
}
function domOnBeforeMatch(dom: HTMLElement, callback: () => void): void {
dom.onbeforematch = callback;
}
export class CollapsibleContainerNode extends ElementNode {
__open: boolean;
constructor(open: boolean, key?: NodeKey) {
super(key);
this.__open = open;
}
$config() {
return this.config("collapsible-container", { extends: ElementNode });
}
static clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
return new CollapsibleContainerNode(node.__open, node.__key);
}
isShadowRoot(): boolean {
return true;
}
collapseAtStart(): boolean {
const nodesToInsert: LexicalNode[] = [];
for (const child of this.getChildren()) {
if ($isElementNode(child)) {
nodesToInsert.push(...child.getChildren());
}
}
const caret = $rewindSiblingCaret($getSiblingCaret(this, "previous"));
caret.splice(1, nodesToInsert);
const [firstChild] = nodesToInsert;
if (firstChild) {
firstChild.selectStart().deleteCharacter(true);
}
return true;
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
let dom: HTMLElement;
if (IS_CHROME || IS_FIREFOX) {
dom = $getDocument().createElement("div");
if (this.__open) {
dom.setAttribute("open", "");
}
} else {
const detailsDom = $getDocument().createElement("details");
detailsDom.open = this.__open;
detailsDom.addEventListener("toggle", () => {
const open = editor.read("latest", () => this.getOpen());
if (open !== detailsDom.open) {
editor.update(() => this.toggleOpen());
}
});
dom = detailsDom;
}
const className = config.theme.collapsibleContainer;
if (className) {
addClassNamesToElement(dom, className);
}
return dom;
}
updateDOM(prevNode: this, dom: HTMLDetailsElement): boolean {
const currentOpen = this.__open;
if (prevNode.__open !== currentOpen) {
if (IS_CHROME || IS_FIREFOX) {
const contentDom = dom.querySelector(
":scope > .editor-collapsible-content",
);
if (!isHTMLElement(contentDom)) {
throw new Error("Expected contentDom to be an HTMLElement");
}
if (currentOpen) {
dom.setAttribute("open", "");
contentDom.hidden = false;
} else {
dom.removeAttribute("open");
setDomHiddenUntilFound(contentDom);
}
} else {
dom.open = this.__open;
}
}
return false;
}
static importJSON(
serializedNode: SerializedCollapsibleContainerNode,
): CollapsibleContainerNode {
return $createCollapsibleContainerNode(serializedNode.open).updateFromJSON(
serializedNode,
);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedCollapsibleContainerNode>,
): this {
return super.updateFromJSON(serializedNode).setOpen(serializedNode.open);
}
exportJSON(): SerializedCollapsibleContainerNode {
return {
...super.exportJSON(),
open: this.__open,
};
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("details");
if (this.__open) {
element.setAttribute("open", "");
}
return { element };
}
setOpen(open: boolean): this {
const writable = this.getWritable();
writable.__open = open;
return writable;
}
getOpen(): boolean {
return this.getLatest().__open;
}
toggleOpen(): this {
return this.setOpen(!this.getOpen());
}
}
export function $createCollapsibleContainerNode(
isOpen: boolean,
): CollapsibleContainerNode {
return new CollapsibleContainerNode(isOpen);
}
export function $isCollapsibleContainerNode(
node: LexicalNode | null | undefined,
): node is CollapsibleContainerNode {
return node instanceof CollapsibleContainerNode;
}
export class CollapsibleTitleNode extends ElementNode {
$config() {
return this.config("collapsible-title", {
$transform(node: CollapsibleTitleNode) {
if (node.isEmpty()) {
node.remove();
}
},
extends: ElementNode,
});
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = $getDocument().createElement("summary");
const className = config.theme.collapsibleTitle;
if (className) {
addClassNamesToElement(dom, className);
}
if (IS_CHROME || IS_FIREFOX) {
dom.addEventListener("click", () => {
editor.update(() => {
const collapsibleContainer = this.getLatest().getParentOrThrow();
if (!$isCollapsibleContainerNode(collapsibleContainer)) {
throw new Error(
"Expected parent node to be a CollapsibleContainerNode",
);
}
collapsibleContainer.toggleOpen();
});
});
}
return dom;
}
updateDOM(): boolean {
return false;
}
insertNewAfter(_: RangeSelection, restoreSelection = true): ElementNode {
const containerNode = this.getParentOrThrow();
if (!$isCollapsibleContainerNode(containerNode)) {
throw new Error(
"CollapsibleTitleNode expects to be child of CollapsibleContainerNode",
);
}
if (containerNode.getOpen()) {
const contentNode = this.getNextSibling();
if (!$isCollapsibleContentNode(contentNode)) {
throw new Error(
"CollapsibleTitleNode expects to have CollapsibleContentNode sibling",
);
}
const firstChild = contentNode.getFirstChild();
if ($isElementNode(firstChild)) {
return firstChild;
} else {
const paragraph = $createParagraphNode();
contentNode.append(paragraph);
return paragraph;
}
} else {
const paragraph = $createParagraphNode();
containerNode.insertAfter(paragraph, restoreSelection);
return paragraph;
}
}
}
export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
return new CollapsibleTitleNode();
}
export function $isCollapsibleTitleNode(
node: LexicalNode | null | undefined,
): node is CollapsibleTitleNode {
return node instanceof CollapsibleTitleNode;
}
export class CollapsibleContentNode extends ElementNode {
$config() {
return this.config("collapsible-content", { extends: ElementNode });
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = $getDocument().createElement("div");
const className = config.theme.collapsibleContent;
if (className) {
addClassNamesToElement(dom, className);
}
dom.classList.add("editor-collapsible-content");
if (IS_CHROME || IS_FIREFOX) {
editor.read("latest", () => {
const containerNode = this.getParentOrThrow();
if (!$isCollapsibleContainerNode(containerNode)) {
throw new Error(
"Expected parent node to be a CollapsibleContainerNode",
);
}
if (!containerNode.getOpen()) {
setDomHiddenUntilFound(dom);
}
});
domOnBeforeMatch(dom, () => {
editor.update(() => {
const containerNode = this.getParentOrThrow().getLatest();
if (!$isCollapsibleContainerNode(containerNode)) {
throw new Error(
"Expected parent node to be a CollapsibleContainerNode",
);
}
if (!containerNode.getOpen()) {
containerNode.toggleOpen();
}
});
});
}
return dom;
}
updateDOM(): boolean {
return false;
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("div");
element.setAttribute("data-lexical-collapsible-content", "true");
return { element };
}
isShadowRoot(): boolean {
return true;
}
}
export function $createCollapsibleContentNode(): CollapsibleContentNode {
return new CollapsibleContentNode();
}
export function $isCollapsibleContentNode(
node: LexicalNode | null | undefined,
): node is CollapsibleContentNode {
return node instanceof CollapsibleContentNode;
}
src/components/editor/nodes/datetime-node.tsx
import { useState } from "react";
import type { JSX } from "react";
import {
$getDocument,
$getNodeByKey,
$getState,
$setState,
type DOMExportOutput,
IS_BOLD,
IS_HIGHLIGHT,
IS_ITALIC,
IS_STRIKETHROUGH,
IS_UNDERLINE,
type LexicalNode,
type NodeKey,
type Spread,
type StateConfigValue,
type StateValueOrUpdater,
createState,
} from "lexical";
import {
DecoratorTextNode,
type SerializedDecoratorTextNode,
applyFormatToDom,
} from "@lexical/extension";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import { cn } from "cn";
import { setHours, setMinutes } from "date-fns";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Calendar } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupInput } from "@/components/ui/input-group";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const tagToFormat = {
b: "bold",
i: "italic",
mark: "highlight",
s: "strikethrough",
u: "underline",
} as const;
const FORMAT_CLASSES = [
[IS_BOLD, "font-bold"],
[IS_HIGHLIGHT, "bg-yellow-200/70 dark:bg-yellow-500/30"],
[IS_ITALIC, "italic"],
[IS_STRIKETHROUGH, "line-through"],
[IS_UNDERLINE, "underline"],
] as const;
function getDateTimeText(dateTime: Date | undefined): string {
if (dateTime === undefined) {
return "";
}
const hours = dateTime.getHours();
const minutes = dateTime.getMinutes();
return (
dateTime.toDateString() +
(hours === 0 && minutes === 0
? ""
: ` ${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`)
);
}
export type SerializedDateTimeNode = Spread<
{ dateTime?: string },
SerializedDecoratorTextNode
>;
const dateTimeState = createState("dateTime", {
parse: (v) => new Date(v as string),
unparse: (v) => v.toISOString(),
});
export class DateTimeNode extends DecoratorTextNode {
$config() {
return this.config("datetime", {
extends: DecoratorTextNode,
stateConfigs: [{ flat: true, stateConfig: dateTimeState }],
});
}
getDateTime(): StateConfigValue<typeof dateTimeState> {
return $getState(this, dateTimeState);
}
setDateTime(valueOrUpdater: StateValueOrUpdater<typeof dateTimeState>): this {
return $setState(this, dateTimeState, valueOrUpdater);
}
getTextContent(): string {
return getDateTimeText(this.getDateTime());
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("span");
const textDom = $getDocument().createTextNode(
getDateTimeText(this.getDateTime()),
);
element.setAttribute(
"data-lexical-datetime",
this.getDateTime()?.toString() || "",
);
element.appendChild(applyFormatToDom(this, textDom, tagToFormat));
return { element };
}
createDOM(): HTMLElement {
const element = $getDocument().createElement("span");
element.setAttribute(
"data-lexical-datetime",
this.getDateTime()?.toString() || "",
);
element.style.display = "inline-block";
return element;
}
updateDOM(): false {
return false;
}
decorate(): JSX.Element {
return (
<DateTimeComponent
dateTime={this.getDateTime()}
format={this.getFormat()}
nodeKey={this.__key}
/>
);
}
}
export function $createDateTimeNode(dateTime: Date): DateTimeNode {
return new DateTimeNode().setDateTime(dateTime);
}
export function $isDateTimeNode(
node: LexicalNode | null | undefined,
): node is DateTimeNode {
return node instanceof DateTimeNode;
}
function DateTimeComponent({
dateTime,
format,
nodeKey,
}: {
dateTime: Date | undefined;
format: number;
nodeKey: NodeKey;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const { t, dir, language } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [selected, setSelected] = useState(dateTime);
const [month, setMonth] = useState<Date | undefined>(dateTime ?? new Date());
const [includeTime, setIncludeTime] = useState(() => {
if (dateTime === undefined) {
return false;
}
return dateTime.getHours() !== 0 || dateTime.getMinutes() !== 0;
});
const [timeValue, setTimeValue] = useState(() => {
if (dateTime === undefined) {
return "00:00";
}
const hours = dateTime.getHours();
const minutes = dateTime.getMinutes();
if (hours !== 0 || minutes !== 0) {
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}`;
}
return "00:00";
});
const [isNodeSelected] = useLexicalNodeSelection(nodeKey);
const withDateTimeNode = (cb: (node: DateTimeNode) => void): void => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isDateTimeNode(node)) {
cb(node);
}
});
};
const handleIncludeTimeChange = (checked: boolean) => {
withDateTimeNode((node) => {
if (checked) {
setIncludeTime(true);
} else {
if (selected) {
node.setDateTime(setHours(setMinutes(selected, 0), 0));
}
setIncludeTime(false);
setTimeValue("00:00");
}
});
};
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
withDateTimeNode((node) => {
const time = e.target.value;
if (!selected) {
setTimeValue(time);
return;
}
const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10));
const newSelectedDate = setHours(setMinutes(selected, minutes), hours);
setSelected(newSelectedDate);
node.setDateTime(newSelectedDate);
setTimeValue(time);
});
};
const handleDaySelect = (date: Date | undefined) => {
withDateTimeNode((node) => {
if (!timeValue || !date) {
setSelected(date);
return;
}
const [hours, minutes] = timeValue
.split(":")
.map((str) => parseInt(str, 10));
const newDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
hours,
minutes,
);
node.setDateTime(newDate);
setSelected(newDate);
});
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
nativeButton={false}
render={
<span
className={cn(
"cursor-pointer rounded-md bg-secondary px-1.5 py-0.5 text-secondary-foreground transition-colors hover:bg-secondary/80",
FORMAT_CLASSES.filter(([flag]) => format & flag).map(
([, className]) => className,
),
isNodeSelected && "outline-2 outline-offset-1 outline-primary",
)}
>
{dateTime
? new Intl.DateTimeFormat(language, {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(dateTime) + (includeTime ? " " + timeValue : "")
: "Invalid Date"}
</span>
}
/>
<PopoverContent dir={dir} align="start" className="w-auto gap-0 p-0">
<Calendar
mode="single"
captionLayout="dropdown"
fixedWeeks={false}
showOutsideDays={false}
selected={selected}
required={true}
month={month}
onMonthChange={setMonth}
onSelect={handleDaySelect}
startMonth={new Date(1925, 0)}
endMonth={new Date(2042, 7)}
className="p-2.5"
/>
<div className="w-0 min-w-full border-t p-2.5">
<Field>
<FieldLabel className="font-normal has-data-checked:border-transparent has-data-checked:bg-transparent">
<Checkbox
checked={includeTime}
onCheckedChange={handleIncludeTimeChange}
/>
{t.dateTimeTime}
</FieldLabel>
<InputGroup>
<InputGroupInput
type="time"
value={timeValue}
onChange={handleTimeChange}
disabled={!includeTime}
/>
</InputGroup>
</Field>
</div>
</PopoverContent>
</Popover>
);
}
src/components/editor/nodes/emoji-node.ts
import {
$applyNodeReplacement,
type EditorConfig,
type LexicalNode,
type LexicalUpdateJSON,
type NodeKey,
type SerializedTextNode,
type Spread,
TextNode,
addClassNamesToElement,
removeClassNamesFromElement,
} from "lexical";
export const EMOJI_CLASS_NAME = "editor-emoji";
export type SerializedEmojiNode = Spread<
{ className: string },
SerializedTextNode
>;
export class EmojiNode extends TextNode {
__className: string;
constructor(
className: string = EMOJI_CLASS_NAME,
text: string = "",
key?: NodeKey,
) {
super(text, key);
this.__className = className;
}
$config() {
return this.config("emoji", { extends: TextNode });
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__className = prevNode.__className;
}
canHaveFormat(): boolean {
return false;
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
addClassNamesToElement(dom, this.__className);
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
const isUpdated = super.updateDOM(prevNode, dom, config);
if (prevNode.__className !== this.__className) {
removeClassNamesFromElement(dom, prevNode.__className);
addClassNamesToElement(dom, this.__className);
}
return isUpdated;
}
setClassName(className: string): this {
const self = this.getWritable();
self.__className = className;
return self;
}
getClassName(): string {
return this.getLatest().__className;
}
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedEmojiNode>): this {
return super
.updateFromJSON(serializedNode)
.setClassName(serializedNode.className);
}
exportJSON(): SerializedEmojiNode {
return {
...super.exportJSON(),
className: this.__className,
};
}
}
export function $isEmojiNode(
node: LexicalNode | null | undefined,
): node is EmojiNode {
return node instanceof EmojiNode;
}
export function $createEmojiNode(
className: string,
emojiText: string,
): EmojiNode {
const node = new EmojiNode(className, emojiText).setMode("token");
return $applyNodeReplacement(node);
}
src/components/editor/nodes/equation-node.tsx
import * as React from "react";
import { type JSX, useCallback, useEffect, useRef, useState } from "react";
import {
$applyNodeReplacement,
$createParagraphNode,
$getDocument,
$getNodeByKey,
$getSelection,
$isElementNode,
$isNodeSelection,
$isTextNode,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
type DOMExportOutput,
DecoratorNode,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
type LexicalNode,
type NodeKey,
SELECTION_CHANGE_COMMAND,
type SerializedLexicalNode,
type Spread,
getActiveElement,
mergeRegister,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import katex from "katex";
import "katex/dist/katex.min.css";
export type SerializedEquationNode = Spread<
{
equation: string;
inline: boolean;
},
SerializedLexicalNode
>;
export function encodeEquation(equation: string): string {
const bytes = new TextEncoder().encode(equation);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
export function decodeEquation(encoded: string): string {
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
export class EquationNode extends DecoratorNode<JSX.Element> {
__equation: string;
__inline: boolean;
$config() {
return this.config("equation", { extends: DecoratorNode });
}
constructor(equation: string = "", inline?: boolean, key?: NodeKey) {
super(key);
this.__equation = equation;
this.__inline = inline ?? false;
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__equation = prevNode.__equation;
this.__inline = prevNode.__inline;
}
static importJSON(serializedNode: SerializedEquationNode): EquationNode {
return $createEquationNode(
serializedNode.equation,
serializedNode.inline,
).updateFromJSON(serializedNode);
}
exportJSON(): SerializedEquationNode {
return {
...super.exportJSON(),
equation: this.getEquation(),
inline: this.isInline(),
};
}
createDOM(): HTMLElement {
const element = $getDocument().createElement(
this.__inline ? "span" : "div",
);
element.className = "editor-equation";
element.setAttribute("role", "math");
element.setAttribute("aria-label", `Equation: ${this.getEquation()}`);
return element;
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement(
this.__inline ? "span" : "div",
);
element.style.direction = "ltr";
const equation = encodeEquation(this.__equation);
element.setAttribute("data-lexical-equation", equation);
element.setAttribute("data-lexical-inline", `${this.__inline}`);
katex.render(this.__equation, element, {
displayMode: !this.__inline,
errorColor: "#cc0000",
output: "html",
strict: "warn",
throwOnError: false,
trust: false,
});
element.setAttribute("role", "math");
element.setAttribute("aria-label", `Equation: ${this.__equation}`);
return { element };
}
updateDOM(prevNode: this, dom: HTMLElement): boolean {
if (this.__inline !== prevNode.__inline) {
return true;
}
if (this.__equation !== prevNode.__equation) {
dom.setAttribute("aria-label", `Equation: ${this.getEquation()}`);
}
return false;
}
getTextContent(): string {
return this.getEquation();
}
isInline(): boolean {
return this.getLatest().__inline;
}
getEquation(): string {
return this.getLatest().__equation;
}
setEquation(equation: string): this {
const writable = this.getWritable();
writable.__equation = equation;
return writable;
}
decorate(): JSX.Element {
return (
<EquationComponent
equation={this.__equation}
inline={this.__inline}
nodeKey={this.__key}
/>
);
}
}
export function $createEquationNode(
equation = "",
inline = false,
): EquationNode {
return $applyNodeReplacement(new EquationNode(equation, inline));
}
export function $isEquationNode(
node: LexicalNode | null | undefined,
): node is EquationNode {
return node instanceof EquationNode;
}
export function KatexRenderer({
equation,
inline,
onDoubleClick,
}: Readonly<{
equation: string;
inline: boolean;
onDoubleClick?: () => void;
}>): JSX.Element {
const katexElementRef = useRef(null);
useEffect(() => {
const katexElement = katexElementRef.current;
if (katexElement !== null) {
katex.render(equation, katexElement, {
displayMode: !inline,
errorColor: "#cc0000",
output: "html",
strict: "warn",
throwOnError: false,
trust: false,
});
}
}, [equation, inline]);
return (
<>
<img
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
width="0"
height="0"
alt=""
/>
<span onDoubleClick={onDoubleClick} ref={katexElementRef} />
<img
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
width="0"
height="0"
alt=""
/>
</>
);
}
function EquationEditor({
equation,
inline,
setEquation,
onDeleteEmpty,
ref,
}: {
equation: string;
inline: boolean;
setEquation: (equation: string) => void;
onDeleteEmpty: () => void;
ref: React.Ref<HTMLInputElement | HTMLTextAreaElement>;
}): JSX.Element {
const onChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
setEquation(event.target.value);
};
const onKeyDown = (
event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
if (event.key === "Backspace" && equation === "") {
event.preventDefault();
onDeleteEmpty();
}
};
return inline ? (
<span className="inline-flex items-center gap-0.5 rounded-md bg-muted px-1.5 py-0.5 font-mono text-sm">
<span className="text-muted-foreground select-none">$</span>
<input
className="bg-transparent outline-none"
size={Math.max(equation.length, 4)}
value={equation}
onChange={onChange}
onKeyDown={onKeyDown}
autoFocus
ref={ref as React.Ref<HTMLInputElement>}
/>
<span className="text-muted-foreground select-none">$</span>
</span>
) : (
<div className="my-1 flex flex-col rounded-md bg-muted px-3 py-2 text-start font-mono text-sm">
<span className="text-muted-foreground select-none">{"$$"}</span>
<textarea
className="resize-none bg-transparent outline-none"
rows={Math.max(equation.split("\n").length, 1)}
value={equation}
onChange={onChange}
onKeyDown={onKeyDown}
autoFocus
ref={ref as React.Ref<HTMLTextAreaElement>}
/>
<span className="text-muted-foreground select-none">{"$$"}</span>
</div>
);
}
function EquationComponent({
equation,
inline,
nodeKey,
}: {
equation: string;
inline: boolean;
nodeKey: NodeKey;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [equationValue, setEquationValue] = useState(equation);
const [showEquationEditor, setShowEquationEditor] = useState(false);
const inputRef = useRef<HTMLTextAreaElement | HTMLInputElement>(null);
const onClick = useCallback(
(event: MouseEvent) => {
const dom = editor.getElementByKey(nodeKey);
if (dom === null || !dom.contains(event.target as Node)) {
return false;
}
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
},
[clearSelection, editor, isSelected, nodeKey, setSelected],
);
useEffect(() => {
return editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW);
}, [editor, onClick]);
const $onEnter = useCallback(
(event: null | KeyboardEvent) => {
const latestSelection = $getSelection();
if (!(
$isNodeSelection(latestSelection) &&
latestSelection.has(nodeKey) &&
latestSelection.getNodes().length === 1
)) {
return false;
}
const node = $getNodeByKey(nodeKey);
if (!$isEquationNode(node)) {
return false;
}
if (node.isInline()) {
const parent = node.getParent();
if (!$isElementNode(parent)) {
return false;
}
const paragraph = $createParagraphNode();
parent.insertAfter(paragraph);
paragraph.select();
} else {
const paragraph = $createParagraphNode();
node.insertAfter(paragraph);
paragraph.select();
}
event?.preventDefault();
return true;
},
[nodeKey],
);
useEffect(() => {
if (!isEditable) {
return undefined;
}
return editor.registerCommand(
KEY_ENTER_COMMAND,
$onEnter,
COMMAND_PRIORITY_LOW,
);
}, [editor, isEditable, $onEnter]);
const onDeleteEmpty = useCallback(() => {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if (!$isEquationNode(node)) {
return;
}
if (node.isInline()) {
$setSelection(null);
node.remove(true);
return;
}
const prevSibling = node.getPreviousSibling();
if ($isElementNode(prevSibling) || $isTextNode(prevSibling)) {
node.remove();
prevSibling.selectEnd();
return;
}
const paragraph = $createParagraphNode();
node.replace(paragraph);
paragraph.select();
});
}, [editor, nodeKey]);
useEffect(() => {
const dom = editor.getElementByKey(nodeKey);
if (dom === null) {
return;
}
if (isSelected && isEditable) {
dom.classList.add("focused");
} else {
dom.classList.remove("focused");
}
}, [editor, nodeKey, isSelected, isEditable]);
const onHide = useCallback(
(restoreSelection?: boolean) => {
setShowEquationEditor(false);
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isEquationNode(node)) {
node.setEquation(equationValue);
if (restoreSelection) {
node.selectNext(0, 0);
}
}
});
},
[editor, equationValue, nodeKey],
);
useEffect(() => {
if (!showEquationEditor && equationValue !== equation) {
setEquationValue(equation);
}
}, [showEquationEditor, equation, equationValue]);
useEffect(() => {
if (!isEditable) {
return;
}
if (showEquationEditor) {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const inputElem = inputRef.current;
const activeElement = inputElem
? getActiveElement(inputElem)
: null;
if (inputElem !== activeElement) {
onHide();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
const inputElem = inputRef.current;
const activeElement = inputElem
? getActiveElement(inputElem)
: null;
if (inputElem === activeElement) {
onHide(true);
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
}
return undefined;
}, [editor, nodeKey, onHide, showEquationEditor, isEditable]);
return (
<>
{showEquationEditor && isEditable ? (
<EquationEditor
equation={equationValue}
setEquation={setEquationValue}
inline={inline}
onDeleteEmpty={onDeleteEmpty}
ref={inputRef}
/>
) : (
<LexicalErrorBoundary
onError={(e) => editor._onError(e)}
fallback={null}
>
<KatexRenderer
equation={equationValue}
inline={inline}
onDoubleClick={() => {
if (isEditable) {
setShowEquationEditor(true);
}
}}
/>
</LexicalErrorBoundary>
)}
</>
);
}
src/components/editor/nodes/figma-node.tsx
import type { JSX } from "react";
import {
$getDocument,
type DOMExportOutput,
type EditorConfig,
type ElementFormatType,
type LexicalEditor,
type LexicalNode,
type NodeKey,
type Spread,
} from "lexical";
import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents";
import {
DecoratorBlockNode,
type SerializedDecoratorBlockNode,
} from "@lexical/react/LexicalDecoratorBlockNode";
function figmaEmbedUrl(documentID: string): string {
return `https://www.figma.com/embed?embed_host=shadcn-editor&url=${encodeURIComponent(
`https://www.figma.com/file/${documentID}`,
)}`;
}
type FigmaComponentProps = Readonly<{
className: Readonly<{
base: string;
focus: string;
}>;
format: ElementFormatType | null;
nodeKey: NodeKey;
documentID: string;
}>;
function FigmaComponent({
className,
format,
nodeKey,
documentID,
}: FigmaComponentProps) {
return (
<BlockWithAlignableContents
className={className}
format={format}
nodeKey={nodeKey}
>
<iframe
src={figmaEmbedUrl(documentID)}
allowFullScreen
title="Figma document"
className="inline-block aspect-video w-full max-w-xl rounded-md border bg-muted align-top"
/>
</BlockWithAlignableContents>
);
}
export type SerializedFigmaNode = Spread<
{
documentID: string;
},
SerializedDecoratorBlockNode
>;
export class FigmaNode extends DecoratorBlockNode {
__id: string;
$config() {
return this.config("figma", { extends: DecoratorBlockNode });
}
static clone(node: FigmaNode): FigmaNode {
return new FigmaNode(node.__id, node.__format, node.__key);
}
static importJSON(serializedNode: SerializedFigmaNode): FigmaNode {
return $createFigmaNode(serializedNode.documentID).updateFromJSON(
serializedNode,
);
}
exportJSON(): SerializedFigmaNode {
return {
...super.exportJSON(),
documentID: this.__id,
};
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key);
this.__id = id;
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("iframe");
element.setAttribute("data-lexical-figma", this.__id);
element.setAttribute("width", "560");
element.setAttribute("height", "315");
element.setAttribute("src", figmaEmbedUrl(this.__id));
element.setAttribute("allowfullscreen", "true");
element.setAttribute("title", "Figma document");
return { element };
}
updateDOM(): false {
return false;
}
getId(): string {
return this.getLatest().__id;
}
getTextContent(): string {
return `https://www.figma.com/file/${this.__id}`;
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {};
const className = {
base: embedBlockTheme.base || "",
focus: embedBlockTheme.focus || "",
};
return (
<FigmaComponent
className={className}
format={this.__format}
nodeKey={this.getKey()}
documentID={this.__id}
/>
);
}
}
export function $createFigmaNode(documentID: string): FigmaNode {
return new FigmaNode(documentID);
}
export function $isFigmaNode(
node: FigmaNode | LexicalNode | null | undefined,
): node is FigmaNode {
return node instanceof FigmaNode;
}
src/components/editor/nodes/image-node.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import {
$applyNodeReplacement,
$getDocument,
$getNodeByKey,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
type DOMConversionOutput,
type DOMExportOutput,
DecoratorNode,
type EditorConfig,
type LexicalCommand,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
createCommand,
mergeRegister,
registerEventListeners,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import { cn } from "cn";
import { ImageOffIcon } from "lucide-react";
export interface ImagePayload {
altText: string;
height?: number;
key?: NodeKey;
maxWidth?: number;
src: string;
width?: number;
}
export type InsertImagePayload = Readonly<ImagePayload>;
export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
createCommand("INSERT_IMAGE_COMMAND");
export type SerializedImageNode = Spread<
{
altText: string;
height?: number;
maxWidth: number;
src: string;
width?: number;
},
SerializedLexicalNode
>;
function $convertImageElement(domNode: Node): DOMConversionOutput | null {
if (!(domNode instanceof HTMLImageElement)) {
return null;
}
const { alt, src, width, height } = domNode;
if (!src || src.startsWith("file:///")) {
return null;
}
return {
node: $createImageNode({
altText: alt,
height: height || undefined,
src,
width: width || undefined,
}),
};
}
export class ImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: "inherit" | number;
__height: "inherit" | number;
__maxWidth: number;
$config() {
return this.config("image", {
extends: DecoratorNode,
importDOM: {
img: () => ({ conversion: $convertImageElement, priority: 0 }),
},
});
}
static clone(node: ImageNode): ImageNode {
return new ImageNode(
node.__src,
node.__altText,
node.__maxWidth,
node.__width,
node.__height,
node.__key,
);
}
static importJSON(serializedNode: SerializedImageNode): ImageNode {
const { altText, height, maxWidth, src, width } = serializedNode;
return $createImageNode({ altText, height, maxWidth, src, width });
}
exportJSON(): SerializedImageNode {
return {
...super.exportJSON(),
altText: this.getAltText(),
height: this.__height === "inherit" ? undefined : this.__height,
maxWidth: this.__maxWidth,
src: this.getSrc(),
width: this.__width === "inherit" ? undefined : this.__width,
};
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("img");
element.setAttribute("src", this.__src);
element.setAttribute("alt", this.__altText);
if (this.__width !== "inherit") {
element.setAttribute("width", String(this.__width));
}
if (this.__height !== "inherit") {
element.setAttribute("height", String(this.__height));
}
return { element };
}
constructor(
src: string,
altText: string,
maxWidth: number,
width?: "inherit" | number,
height?: "inherit" | number,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__maxWidth = maxWidth;
this.__width = width || "inherit";
this.__height = height || "inherit";
}
createDOM(config: EditorConfig): HTMLElement {
const span = $getDocument().createElement("span");
const className = config.theme.image;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.getLatest().__src;
}
getAltText(): string {
return this.getLatest().__altText;
}
setWidthAndHeight(
width: "inherit" | number,
height: "inherit" | number,
): this {
const writable = this.getWritable();
writable.__width = width;
writable.__height = height;
return writable;
}
decorate(): JSX.Element {
return (
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
/>
);
}
}
export function $createImageNode({
altText,
height,
maxWidth = 500,
src,
width,
key,
}: ImagePayload): ImageNode {
return $applyNodeReplacement(
new ImageNode(src, altText, maxWidth, width, height, key),
);
}
export function $isImageNode(
node: LexicalNode | null | undefined,
): node is ImageNode {
return node instanceof ImageNode;
}
const MIN_WIDTH = 100;
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function ImageComponent({
src,
altText,
nodeKey,
width,
height,
maxWidth,
}: {
altText: string;
height: "inherit" | number;
maxWidth: number;
nodeKey: NodeKey;
src: string;
width: "inherit" | number;
}) {
const imageRef = useRef<HTMLImageElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const [isLoadError, setIsLoadError] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const onClick = useCallback(
(event: MouseEvent) => {
if (isResizing) {
return true;
}
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
[clearSelection, isResizing, isSelected, setSelected],
);
useEffect(
() =>
mergeRegister(
editor.registerCommand(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
),
[editor, onClick],
);
const onResizePointerDown = (event: React.PointerEvent<HTMLSpanElement>) => {
const image = imageRef.current;
if (!isEditable || !image) {
return;
}
event.preventDefault();
event.stopPropagation();
const rect = image.getBoundingClientRect();
const isRTL = getComputedStyle(image).direction === "rtl";
const startX = event.clientX;
const startWidth = rect.width;
const ratio = rect.width / rect.height;
setIsResizing(true);
let cleanup = () => {};
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clamp(
startWidth +
(isRTL ? startX - moveEvent.clientX : moveEvent.clientX - startX),
MIN_WIDTH,
maxWidth,
);
image.style.width = `${nextWidth}px`;
image.style.height = `${Math.round(nextWidth / ratio)}px`;
};
const handlePointerUp = () => {
cleanup();
setIsResizing(false);
const nextWidth = Math.round(image.getBoundingClientRect().width);
const nextHeight = Math.round(image.getBoundingClientRect().height);
editor.update(() => {
const node = $getNodeByKey(nodeKey);
if ($isImageNode(node)) {
node.setWidthAndHeight(nextWidth, nextHeight);
}
});
};
cleanup = registerEventListeners(document, {
pointermove: handlePointerMove,
pointerup: handlePointerUp,
});
};
const isFocused = isSelected && isEditable;
return (
<span
className={cn(
"group/image relative inline-block max-w-full align-top leading-none",
isFocused && "rounded-sm outline-2 outline-offset-2 outline-primary",
)}
>
{isLoadError ? (
<span className="flex aspect-video w-full max-w-sm flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted text-muted-foreground">
<ImageOffIcon className="size-6" />
<span className="text-xs">{altText || "Image failed to load"}</span>
</span>
) : (
<img
ref={imageRef}
src={src}
alt={altText}
draggable={false}
onError={() => setIsLoadError(true)}
style={{
width: width === "inherit" ? undefined : width,
height: height === "inherit" ? undefined : height,
maxWidth,
}}
className="block h-auto max-w-full rounded-md select-none"
/>
)}
{isFocused && !isLoadError && (
<span
role="presentation"
onPointerDown={onResizePointerDown}
className="absolute -end-1.5 -bottom-1.5 size-3.5 cursor-nwse-resize rounded-full border-2 border-background bg-primary opacity-0 transition-opacity group-hover/image:opacity-100 rtl:cursor-nesw-resize"
/>
)}
</span>
);
}
src/components/editor/nodes/layout-node.ts
import {
$applyNodeReplacement,
$getDocument,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
ElementNode,
type LexicalEditor,
type LexicalNode,
type LexicalUpdateJSON,
type NodeKey,
type SerializedElementNode,
type Spread,
addClassNamesToElement,
} from "lexical";
export type SerializedLayoutContainerNode = Spread<
{ templateColumns: string },
SerializedElementNode
>;
export type SerializedLayoutItemNode = SerializedElementNode;
function $convertLayoutContainerElement(
domNode: HTMLElement,
): DOMConversionOutput | null {
const templateColumns = domNode.style.gridTemplateColumns;
return templateColumns
? { node: $createLayoutContainerNode(templateColumns) }
: null;
}
export class LayoutContainerNode extends ElementNode {
__templateColumns: string;
constructor(templateColumns: string, key?: NodeKey) {
super(key);
this.__templateColumns = templateColumns;
}
$config() {
return this.config("layout-container", {
extends: ElementNode,
importDOM: {
div: (domNode) =>
domNode.style.gridTemplateColumns
? { conversion: $convertLayoutContainerElement, priority: 1 }
: null,
},
});
}
static clone(node: LayoutContainerNode): LayoutContainerNode {
return new LayoutContainerNode(node.__templateColumns, node.__key);
}
static importJSON(
serializedNode: SerializedLayoutContainerNode,
): LayoutContainerNode {
return $createLayoutContainerNode(serializedNode.templateColumns);
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedLayoutContainerNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setTemplateColumns(serializedNode.templateColumns);
}
exportJSON(): SerializedLayoutContainerNode {
return {
...super.exportJSON(),
templateColumns: this.getTemplateColumns(),
};
}
createDOM(config: EditorConfig): HTMLElement {
const dom = $getDocument().createElement("div");
dom.style.gridTemplateColumns = this.__templateColumns;
const className = config.theme.layoutContainer;
if (className) {
addClassNamesToElement(dom, className);
}
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement): boolean {
if (prevNode.__templateColumns !== this.__templateColumns) {
dom.style.gridTemplateColumns = this.__templateColumns;
}
return false;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const output = super.exportDOM(editor);
const element = output.element;
if (element instanceof HTMLElement) {
element.style.gridTemplateColumns = this.__templateColumns;
}
return output;
}
getTemplateColumns(): string {
return this.getLatest().__templateColumns;
}
setTemplateColumns(templateColumns: string): this {
const writable = this.getWritable();
writable.__templateColumns = templateColumns;
return writable;
}
canBeEmpty(): boolean {
return false;
}
isShadowRoot(): boolean {
return true;
}
}
export function $createLayoutContainerNode(
templateColumns: string,
): LayoutContainerNode {
return $applyNodeReplacement(new LayoutContainerNode(templateColumns));
}
export function $isLayoutContainerNode(
node: LexicalNode | null | undefined,
): node is LayoutContainerNode {
return node instanceof LayoutContainerNode;
}
function $convertLayoutItemElement(): DOMConversionOutput {
return { node: $createLayoutItemNode() };
}
export class LayoutItemNode extends ElementNode {
$config() {
return this.config("layout-item", {
extends: ElementNode,
importDOM: {
div: (domNode) =>
domNode.hasAttribute("data-lexical-layout-item")
? { conversion: $convertLayoutItemElement, priority: 1 }
: null,
},
});
}
static clone(node: LayoutItemNode): LayoutItemNode {
return new LayoutItemNode(node.__key);
}
static importJSON(): LayoutItemNode {
return $createLayoutItemNode();
}
exportJSON(): SerializedLayoutItemNode {
return { ...super.exportJSON() };
}
createDOM(config: EditorConfig): HTMLElement {
const dom = $getDocument().createElement("div");
dom.setAttribute("data-lexical-layout-item", "true");
const className = config.theme.layoutItem;
if (className) {
addClassNamesToElement(dom, className);
}
return dom;
}
updateDOM(): boolean {
return false;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const output = super.exportDOM(editor);
const element = output.element;
if (element instanceof HTMLElement) {
element.setAttribute("data-lexical-layout-item", "true");
}
return output;
}
isShadowRoot(): boolean {
return true;
}
}
export function $createLayoutItemNode(): LayoutItemNode {
return $applyNodeReplacement(new LayoutItemNode());
}
export function $isLayoutItemNode(
node: LexicalNode | null | undefined,
): node is LayoutItemNode {
return node instanceof LayoutItemNode;
}
src/components/editor/nodes/mention-node.ts
import {
$applyNodeReplacement,
$getDocument,
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type EditorConfig,
type LexicalNode,
type LexicalUpdateJSON,
type NodeKey,
type SerializedTextNode,
type Spread,
TextNode,
addClassNamesToElement,
} from "lexical";
export const MENTION_CLASS_NAME =
"editor-mention rounded bg-primary/10 px-1 text-primary";
export type SerializedMentionNode = Spread<
{ mentionName: string },
SerializedTextNode
>;
export class MentionNode extends TextNode {
__mention: string;
constructor(mentionName: string = "", text?: string, key?: NodeKey) {
super(text ?? mentionName, key);
this.__mention = mentionName;
}
$config() {
return this.config("mention", { extends: TextNode });
}
afterCloneFrom(prevNode: this): void {
super.afterCloneFrom(prevNode);
this.__mention = prevNode.__mention;
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
addClassNamesToElement(dom, MENTION_CLASS_NAME);
dom.spellcheck = false;
return dom;
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("span");
element.setAttribute("data-lexical-mention", "true");
if (this.__text !== this.__mention) {
element.setAttribute("data-lexical-mention-name", this.__mention);
}
element.textContent = this.__text;
return { element };
}
static importDOM(): DOMConversionMap | null {
return {
span: (domNode: HTMLElement) => {
if (!domNode.hasAttribute("data-lexical-mention")) {
return null;
}
return {
conversion: $convertMentionElement,
priority: 1,
};
},
};
}
setMention(mentionName: string): this {
const self = this.getWritable();
self.__mention = mentionName;
return self;
}
getMention(): string {
return this.getLatest().__mention;
}
updateFromJSON(
serializedNode: LexicalUpdateJSON<SerializedMentionNode>,
): this {
return super
.updateFromJSON(serializedNode)
.setMention(serializedNode.mentionName);
}
exportJSON(): SerializedMentionNode {
return {
...super.exportJSON(),
mentionName: this.__mention,
};
}
isTextEntity(): true {
return true;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
}
function $convertMentionElement(domNode: HTMLElement): DOMConversionOutput {
const textContent = domNode.textContent ?? "";
const mentionName =
domNode.getAttribute("data-lexical-mention-name") ?? textContent;
return { node: $createMentionNode(mentionName, textContent) };
}
export function $isMentionNode(
node: LexicalNode | null | undefined,
): node is MentionNode {
return node instanceof MentionNode;
}
export function $createMentionNode(
mentionName: string,
textContent?: string,
): MentionNode {
const node = new MentionNode(mentionName, textContent);
node.setMode("segmented").toggleDirectionless();
return $applyNodeReplacement(node);
}
src/components/editor/nodes/poll-node.tsx
import { useEffect, useMemo, useRef } from "react";
import type { JSX } from "react";
import {
$getDocument,
$getNodeByKey,
$getState,
$setState,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
type DOMExportOutput,
DecoratorNode,
type LexicalNode,
type NodeKey,
type SerializedLexicalNode,
type Spread,
type StateConfigValue,
type StateValueOrUpdater,
createState,
mergeRegister,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { useLexicalNodeSelection } from "@lexical/react/useLexicalNodeSelection";
import { cn } from "cn";
import { Plus, X } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
export type Option = Readonly<{
text: string;
uid: string;
votes: string[];
}>;
export type Options = readonly Option[];
const LOCAL_VOTER = "local";
function createUID(): string {
return Math.random()
.toString(36)
.replace(/[^a-z]+/g, "")
.substring(0, 5);
}
export function createPollOption(text = ""): Option {
return {
text,
uid: createUID(),
votes: [],
};
}
function cloneOption(option: Option, text: string, votes?: string[]): Option {
return {
text,
uid: option.uid,
votes: votes || Array.from(option.votes),
};
}
export type SerializedPollNode = Spread<
{
question: string;
options: Options;
},
SerializedLexicalNode
>;
function parseOptions(json: unknown): Options {
const options = [];
if (Array.isArray(json)) {
for (const row of json) {
if (
row &&
typeof row.text === "string" &&
typeof row.uid === "string" &&
Array.isArray(row.votes) &&
row.votes.every((v: unknown) => typeof v === "string")
) {
options.push(row);
}
}
}
return options;
}
const questionState = createState("question", {
parse: (v) => (typeof v === "string" ? v : ""),
});
const optionsState = createState("options", {
isEqual: (a, b) =>
a.length === b.length && JSON.stringify(a) === JSON.stringify(b),
parse: parseOptions,
});
export class PollNode extends DecoratorNode<JSX.Element> {
$config() {
return this.config("poll", {
extends: DecoratorNode,
stateConfigs: [
{ flat: true, stateConfig: questionState },
{ flat: true, stateConfig: optionsState },
],
});
}
getQuestion(): StateConfigValue<typeof questionState> {
return $getState(this, questionState);
}
setQuestion(valueOrUpdater: StateValueOrUpdater<typeof questionState>): this {
return $setState(this, questionState, valueOrUpdater);
}
getOptions(): StateConfigValue<typeof optionsState> {
return $getState(this, optionsState);
}
setOptions(valueOrUpdater: StateValueOrUpdater<typeof optionsState>): this {
return $setState(this, optionsState, valueOrUpdater);
}
addOption(option: Option): this {
return this.setOptions((options) => [...options, option]);
}
deleteOption(option: Option): this {
return this.setOptions((prevOptions) => {
const index = prevOptions.indexOf(option);
if (index === -1) {
return prevOptions;
}
const options = Array.from(prevOptions);
options.splice(index, 1);
return options;
});
}
setOptionText(option: Option, text: string): this {
return this.setOptions((prevOptions) => {
const clonedOption = cloneOption(option, text);
const options = Array.from(prevOptions);
const index = options.indexOf(option);
options[index] = clonedOption;
return options;
});
}
toggleVote(option: Option, username: string): this {
return this.setOptions((prevOptions) => {
const index = prevOptions.indexOf(option);
if (index === -1) {
return prevOptions;
}
const votes = option.votes;
const votesClone = Array.from(votes);
const voteIndex = votes.indexOf(username);
if (voteIndex === -1) {
votesClone.push(username);
} else {
votesClone.splice(voteIndex, 1);
}
const clonedOption = cloneOption(option, option.text, votesClone);
const options = Array.from(prevOptions);
options[index] = clonedOption;
return options;
});
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("span");
element.setAttribute("data-lexical-poll-question", this.getQuestion());
element.setAttribute(
"data-lexical-poll-options",
JSON.stringify(this.getOptions()),
);
return { element };
}
createDOM(): HTMLElement {
const elem = $getDocument().createElement("span");
elem.style.display = "inline-block";
return elem;
}
updateDOM(): false {
return false;
}
decorate(): JSX.Element {
return (
<PollComponent
question={this.getQuestion()}
options={this.getOptions()}
nodeKey={this.__key}
/>
);
}
}
export function $createPollNode(question: string, options: Options): PollNode {
return new PollNode().setQuestion(question).setOptions(options);
}
export function $isPollNode(
node: LexicalNode | null | undefined,
): node is PollNode {
return node instanceof PollNode;
}
function getTotalVotes(options: Options): number {
return options.reduce(
(totalVotes, next) => totalVotes + next.votes.length,
0,
);
}
function PollOptionComponent({
option,
index,
options,
totalVotes,
withPollNode,
}: {
index: number;
option: Option;
options: Options;
totalVotes: number;
withPollNode: (
cb: (pollNode: PollNode) => void,
onSelect?: () => void,
) => void;
}): JSX.Element {
const { t } = useTranslation();
const isEditable = useLexicalEditable();
const votes = option.votes.length;
const checked = option.votes.includes(LOCAL_VOTER);
return (
<div className="flex items-center gap-2">
<Checkbox
checked={checked}
disabled={!isEditable}
aria-label={option.text || `${t.pollOptionPlaceholder} ${index + 1}`}
onCheckedChange={() => {
withPollNode((node) => {
node.toggleVote(option, LOCAL_VOTER);
});
}}
/>
<div className="relative min-w-0 flex-1">
<div
className="pointer-events-none absolute inset-y-0 start-0 rounded-md bg-primary/10 transition-all"
style={{
width: `${votes === 0 ? 0 : (votes / totalVotes) * 100}%`,
}}
/>
<span className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
{votes > 0 && `${votes} ${votes === 1 ? t.pollVote : t.pollVotes}`}
</span>
<Input
value={option.text}
disabled={!isEditable}
className="bg-transparent pe-16 dark:bg-transparent"
placeholder={`${t.pollOptionPlaceholder} ${index + 1}`}
onChange={(e) => {
const target = e.target;
const value = target.value;
const { selectionStart, selectionEnd } = target;
withPollNode(
(node) => {
node.setOptionText(option, value);
},
() => {
target.selectionStart = selectionStart;
target.selectionEnd = selectionEnd;
},
);
}}
/>
</div>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
disabled={!isEditable || options.length < 3}
aria-label={t.pollRemoveOption}
onClick={() => {
withPollNode((node) => {
node.deleteOption(option);
});
}}
>
<X />
</Button>
</div>
);
}
function PollComponent({
question,
options,
nodeKey,
}: {
nodeKey: NodeKey;
options: Options;
question: string;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const isEditable = useLexicalEditable();
const totalVotes = useMemo(() => getTotalVotes(options), [options]);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
CLICK_COMMAND,
(event) => {
if (event.target === ref.current) {
if (!event.shiftKey) {
clearSelection();
}
setSelected(!isSelected);
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [clearSelection, editor, isSelected, nodeKey, setSelected]);
const withPollNode = (
cb: (node: PollNode) => void,
onUpdate?: () => void,
): void => {
editor.update(
() => {
const node = $getNodeByKey(nodeKey);
if ($isPollNode(node)) {
cb(node);
}
},
{ onUpdate },
);
};
const addOption = () => {
withPollNode((node) => {
node.addOption(createPollOption());
});
};
return (
<div
ref={ref}
className={cn(
"my-1 flex w-full max-w-md flex-col gap-2 rounded-lg border bg-card p-4 text-card-foreground",
isSelected && "ring-2 ring-ring",
)}
>
<Input
value={question}
disabled={!isEditable}
className="border-transparent bg-transparent text-sm font-medium shadow-none dark:bg-transparent"
placeholder={t.pollQuestionPlaceholder}
onChange={(e) => {
const target = e.target;
const value = target.value;
const { selectionStart, selectionEnd } = target;
withPollNode(
(node) => {
node.setQuestion(value);
},
() => {
target.selectionStart = selectionStart;
target.selectionEnd = selectionEnd;
},
);
}}
/>
{options.map((option, index) => (
<PollOptionComponent
key={option.uid}
withPollNode={withPollNode}
option={option}
index={index}
options={options}
totalVotes={totalVotes}
/>
))}
<div>
<Button
variant="outline"
size="sm"
disabled={!isEditable}
onClick={addOption}
>
<Plus />
{t.pollAddOption}
</Button>
</div>
</div>
);
}
src/components/editor/nodes/pullquote-node.tsx
import type { JSX } from "react";
import {
$create,
$createParagraphNode,
$createTextNode,
$getDocument,
$getSlot,
$getSlotNames,
$setSlot,
type DOMExportOutput,
DecoratorNode,
type EditorConfig,
type LexicalEditor,
type LexicalNode,
type NodeKey,
type SlotChildNode,
addClassNamesToElement,
} from "lexical";
import { $appendNodeToHTML } from "@lexical/html";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalSlotRef } from "@lexical/react/useLexicalSlotRef";
import { $createSlotContainerNode } from "@/components/editor/nodes/slot-container-node";
function PullQuoteComponent({ nodeKey }: { nodeKey: NodeKey }): JSX.Element {
const [editor] = useLexicalComposerContext();
const quoteRef = useLexicalSlotRef<HTMLDivElement>(editor, nodeKey, "quote");
const attributionRef = useLexicalSlotRef<HTMLDivElement>(
editor,
nodeKey,
"attribution",
);
return (
<div className="flex flex-col gap-2">
<div
ref={quoteRef}
className="text-lg leading-relaxed italic outline-none [&_[data-lexical-slot]]:outline-none"
/>
<div className="flex items-baseline justify-end gap-1.5 text-sm text-muted-foreground">
<span className="select-none">-</span>
<div
ref={attributionRef}
className="min-w-0 outline-none [&_[data-lexical-slot]]:outline-none"
/>
</div>
</div>
);
}
export class PullQuoteNode extends DecoratorNode<JSX.Element> {
$config() {
return this.config("pullquote", {
extends: DecoratorNode,
slots: ["quote", "attribution"],
});
}
createDOM(config: EditorConfig): HTMLElement {
const dom = $getDocument().createElement("div");
const className = config.theme.pullQuote;
if (className) {
addClassNamesToElement(dom, className);
}
return dom;
}
updateDOM(): false {
return false;
}
isInline(): false {
return false;
}
decorate(): JSX.Element {
return <PullQuoteComponent nodeKey={this.__key} />;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const host = $getDocument().createElement("div");
host.setAttribute("data-lexical-pullquote", "true");
for (const name of $getSlotNames(this)) {
const slot = $getSlot(this, name);
if (slot) {
const wrapper = $getDocument().createElement("div");
wrapper.setAttribute("data-lexical-slot", name);
$appendNodeToHTML(editor, slot, wrapper);
host.append(wrapper);
}
}
return { element: host };
}
}
export function $createPullQuoteNode(
quote?: LexicalNode & SlotChildNode,
attribution?: LexicalNode & SlotChildNode,
): PullQuoteNode {
const node = $create(PullQuoteNode);
$setSlot(
node,
"quote",
quote ||
$createSlotContainerNode().append(
$createParagraphNode().append(
$createTextNode(
"The only way to discover the limits of the possible is to go beyond them into the impossible.",
),
),
),
);
$setSlot(
node,
"attribution",
attribution ||
$createParagraphNode().append($createTextNode("Arthur C. Clarke")),
);
return node;
}
export function $isPullQuoteNode(
node: LexicalNode | null | undefined,
): node is PullQuoteNode {
return node instanceof PullQuoteNode;
}
src/components/editor/nodes/review-node.ts
import {
$create,
$createParagraphNode,
$getDocument,
$getEditor,
$getSlot,
$getState,
$isElementNode,
$markSlotEditable,
$setSlot,
$setState,
type DOMExportOutput,
type EditorConfig,
type ElementDOMSlot,
ElementNode,
type LexicalEditor,
type LexicalNode,
type NodeStateVersion,
type StateConfigValue,
type StateValueOrUpdater,
addClassNamesToElement,
createState,
setDOMUnmanaged,
} from "lexical";
import { $appendNodeToHTML } from "@lexical/html";
const ratingState = createState("rating", {
parse: (v): number =>
typeof v === "number" && v >= 0 && v <= 5 ? Math.round(v) : 0,
});
export class ReviewNode extends ElementNode {
$config() {
return this.config("review", {
extends: ElementNode,
slots: ["author"],
stateConfigs: [{ flat: true, stateConfig: ratingState }],
});
}
getRating(version?: NodeStateVersion): StateConfigValue<typeof ratingState> {
return $getState(this, ratingState, version);
}
setRating(valueOrUpdater: StateValueOrUpdater<typeof ratingState>): this {
return $setState(this, ratingState, valueOrUpdater);
}
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = $getDocument().createElement("div");
const className = config.theme.review;
if (className) {
addClassNamesToElement(dom, className);
}
dom.contentEditable = "false";
setDOMUnmanaged(dom);
const children = $getDocument().createElement("div");
children.className = "editor-review-children";
children.style.display = "none";
$markSlotEditable(children, editor);
dom.appendChild(children);
return dom;
}
updateDOM(_prevNode: this, dom: HTMLElement): boolean {
const children = dom.querySelector<HTMLElement>(".editor-review-children");
if (children !== null) {
$markSlotEditable(children, $getEditor());
}
return false;
}
getDOMSlot(element: HTMLElement): ElementDOMSlot<HTMLElement> {
const childrenEl = element.querySelector<HTMLElement>(
".editor-review-children",
);
const domSlot = super.getDOMSlot(element);
return childrenEl !== null ? domSlot.withElement(childrenEl) : domSlot;
}
isShadowRoot(): true {
return true;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = $getDocument().createElement("div");
element.setAttribute("data-lexical-review", "true");
element.setAttribute("data-rating", String(this.getRating()));
const author = $getSlot(this, "author");
if ($isElementNode(author)) {
const wrapper = $getDocument().createElement("div");
wrapper.setAttribute("data-lexical-slot", "author");
$appendNodeToHTML(editor, author, wrapper);
element.append(wrapper);
}
return { element };
}
}
export function $createReviewNode(): ReviewNode {
const node = $create(ReviewNode);
$setSlot(node, "author", $createParagraphNode());
node.append($createParagraphNode());
return node;
}
export function $isReviewNode(
node: LexicalNode | null | undefined,
): node is ReviewNode {
return node instanceof ReviewNode;
}
src/components/editor/nodes/ruby-node.ts
import {
$create,
$createTextNode,
$getDocument,
$getSelection,
$getState,
$getStateChange,
$isRangeSelection,
$isTextNode,
$setState,
type DOMExportOutput,
type DOMSlot,
type EditorConfig,
type LexicalNode,
type NodeStateVersion,
type StateConfigValue,
type StateValueOrUpdater,
TextNode,
createState,
isHTMLElement,
} from "lexical";
import { addClassNamesToElement } from "@lexical/utils";
const annotationState = createState("annotation", {
parse: (v) => (typeof v === "string" ? v : ""),
});
export class RubyNode extends TextNode {
$config() {
return this.config("ruby", {
extends: TextNode,
stateConfigs: [{ flat: true, stateConfig: annotationState }],
});
}
createDOM(config: EditorConfig): HTMLElement {
const inner = super.createDOM(config);
inner.dataset.rubyAnnotation = this.getAnnotation();
addClassNamesToElement(inner, config.theme.ruby || "editor-ruby");
const wrapper = $getDocument().createElement("span");
wrapper.setAttribute("role", "group");
wrapper.setAttribute(
"aria-label",
`${this.getTextContent()} (${this.getAnnotation()})`,
);
wrapper.appendChild(inner);
return wrapper;
}
getDOMSlot(dom: HTMLElement): DOMSlot<HTMLElement> {
const inner = dom.firstElementChild;
if (isHTMLElement(inner)) {
return super.getDOMSlot(dom).withElement(inner);
}
return super.getDOMSlot(dom);
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
const inner = dom.firstElementChild;
if (!isHTMLElement(inner)) {
return true;
}
const updated = super.updateDOM(prevNode, inner, config);
const annotationChange = $getStateChange(this, prevNode, annotationState);
if (annotationChange || prevNode.__text !== this.__text) {
inner.dataset.rubyAnnotation = this.getAnnotation();
dom.setAttribute(
"aria-label",
`${this.__text} (${this.getAnnotation()})`,
);
}
return updated;
}
exportDOM(): DOMExportOutput {
const ruby = $getDocument().createElement("ruby");
ruby.textContent = this.getTextContent();
const rpOpen = $getDocument().createElement("rp");
rpOpen.textContent = "(";
ruby.appendChild(rpOpen);
const rt = $getDocument().createElement("rt");
rt.textContent = this.getAnnotation();
ruby.appendChild(rt);
const rpClose = $getDocument().createElement("rp");
rpClose.textContent = ")";
ruby.appendChild(rpClose);
return { element: ruby };
}
getAnnotation(
version?: NodeStateVersion,
): StateConfigValue<typeof annotationState> {
return $getState(this, annotationState, version);
}
setAnnotation(
valueOrUpdater: StateValueOrUpdater<typeof annotationState>,
): this {
return $setState(this, annotationState, valueOrUpdater);
}
isInline(): true {
return true;
}
canInsertTextBefore(): false {
return false;
}
canInsertTextAfter(): false {
return false;
}
}
export function $createRubyNode(text: string, annotation: string): RubyNode {
return $create(RubyNode)
.setTextContent(text)
.setMode("token")
.setAnnotation(annotation);
}
export function $isRubyNode(
node: LexicalNode | null | undefined,
): node is RubyNode {
return node instanceof RubyNode;
}
export function $unwrapRubyNode(node: RubyNode): TextNode {
const text = $createTextNode(node.getTextContent());
text.setFormat(node.getFormat());
text.setStyle(node.getStyle());
node.replace(text);
return text;
}
export function $toggleRuby(annotation: string | null): void {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
if (annotation === null) {
for (const node of selection.getNodes()) {
if ($isRubyNode(node)) {
$unwrapRubyNode(node);
}
}
return;
}
if (selection.isCollapsed()) {
return;
}
const nodes = selection.getNodes();
const firstTextNode = nodes.find($isTextNode);
const text = selection.getTextContent();
const rubyNode = $createRubyNode(text, annotation);
if (firstTextNode) {
rubyNode.setFormat(firstTextNode.getFormat());
rubyNode.setStyle(firstTextNode.getStyle());
}
selection.insertNodes([rubyNode]);
}
src/components/editor/nodes/slot-container-node.ts
import {
$create,
$getDocument,
type DOMExportOutput,
ElementNode,
type LexicalNode,
} from "lexical";
export class SlotContainerNode extends ElementNode {
$config() {
return this.config("slot-container", { extends: ElementNode });
}
createDOM(): HTMLElement {
return $getDocument().createElement("div");
}
exportDOM(): DOMExportOutput {
return { element: $getDocument().createDocumentFragment() };
}
updateDOM(): false {
return false;
}
isShadowRoot(): boolean {
return true;
}
collapseAtStart(): true {
return true;
}
}
export function $createSlotContainerNode(): SlotContainerNode {
return $create(SlotContainerNode);
}
export function $isSlotContainerNode(
node: LexicalNode | null | undefined,
): node is SlotContainerNode {
return node instanceof SlotContainerNode;
}
src/components/editor/nodes/special-text-node.ts
import {
$applyNodeReplacement,
$getDocument,
type EditorConfig,
type LexicalNode,
TextNode,
addClassNamesToElement,
} from "lexical";
export class SpecialTextNode extends TextNode {
$config() {
return this.config("specialText", { extends: TextNode });
}
createDOM(config: EditorConfig): HTMLElement {
const dom = $getDocument().createElement("span");
addClassNamesToElement(dom, config.theme.specialText);
dom.textContent = this.getTextContent();
return dom;
}
updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean {
if (prevNode.__text !== this.__text) {
dom.textContent = this.getTextContent();
}
addClassNamesToElement(dom, config.theme.specialText);
return false;
}
isTextEntity(): true {
return true;
}
canInsertTextAfter(): boolean {
return false;
}
}
export function $createSpecialTextNode(text = ""): SpecialTextNode {
return $applyNodeReplacement(new SpecialTextNode(text));
}
export function $isSpecialTextNode(
node: LexicalNode | null | undefined,
): node is SpecialTextNode {
return node instanceof SpecialTextNode;
}
src/components/editor/nodes/tweet-node.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import type { JSX } from "react";
import {
$getDocument,
type DOMExportOutput,
type EditorConfig,
type ElementFormatType,
type LexicalEditor,
type LexicalNode,
type NodeKey,
type Spread,
} from "lexical";
import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents";
import {
DecoratorBlockNode,
type SerializedDecoratorBlockNode,
} from "@lexical/react/LexicalDecoratorBlockNode";
import { Loader2 } from "lucide-react";
const WIDGET_SCRIPT_URL = "https://platform.twitter.com/widgets.js";
type TwitterWindow = Window & {
twttr?: {
widgets: {
createTweet: (
tweetID: string,
container: HTMLElement | null,
) => Promise<unknown>;
};
};
};
type TweetComponentProps = Readonly<{
className: Readonly<{
base: string;
focus: string;
}>;
format: ElementFormatType | null;
nodeKey: NodeKey;
onError?: (error: string) => void;
onLoad?: () => void;
tweetID: string;
}>;
let isTwitterScriptLoading = true;
function TweetComponent({
className,
format,
nodeKey,
onError,
onLoad,
tweetID,
}: TweetComponentProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const previousTweetIDRef = useRef<string>("");
const [isTweetLoading, setIsTweetLoading] = useState(false);
const createTweet = useCallback(async () => {
try {
await (window as TwitterWindow).twttr?.widgets.createTweet(
tweetID,
containerRef.current,
);
setIsTweetLoading(false);
isTwitterScriptLoading = false;
if (onLoad) {
onLoad();
}
} catch (error) {
if (onError) {
onError(String(error));
}
}
}, [onError, onLoad, tweetID]);
useEffect(() => {
if (tweetID !== previousTweetIDRef.current) {
setIsTweetLoading(true);
if (isTwitterScriptLoading) {
const doc = containerRef.current?.ownerDocument ?? document;
const script = doc.createElement("script");
script.src = WIDGET_SCRIPT_URL;
script.async = true;
doc.body?.appendChild(script);
script.onload = createTweet;
if (onError) {
script.onerror = onError as OnErrorEventHandler;
}
} else {
createTweet().catch(console.error);
}
previousTweetIDRef.current = tweetID;
}
}, [createTweet, onError, tweetID]);
return (
<BlockWithAlignableContents
className={className}
format={format}
nodeKey={nodeKey}
>
{isTweetLoading ? (
<div className="flex h-32 w-full max-w-xl items-center justify-center rounded-md border bg-muted">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : null}
<div
ref={containerRef}
className="inline-block w-full max-w-xl align-top"
/>
</BlockWithAlignableContents>
);
}
export type SerializedTweetNode = Spread<
{
id: string;
},
SerializedDecoratorBlockNode
>;
export class TweetNode extends DecoratorBlockNode {
__id: string;
$config() {
return this.config("tweet", { extends: DecoratorBlockNode });
}
static clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key);
}
static importJSON(serializedNode: SerializedTweetNode): TweetNode {
return $createTweetNode(serializedNode.id).updateFromJSON(serializedNode);
}
exportJSON(): SerializedTweetNode {
return {
...super.exportJSON(),
id: this.getId(),
};
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("div");
element.setAttribute("data-lexical-tweet-id", this.__id);
const text = $getDocument().createTextNode(this.getTextContent());
element.append(text);
return { element };
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key);
this.__id = id;
}
getId(): string {
return this.getLatest().__id;
}
getTextContent(): string {
return `https://x.com/i/web/status/${this.__id}`;
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {};
const className = {
base: embedBlockTheme.base || "",
focus: embedBlockTheme.focus || "",
};
return (
<TweetComponent
className={className}
format={this.__format}
nodeKey={this.getKey()}
tweetID={this.__id}
/>
);
}
}
export function $createTweetNode(tweetID: string): TweetNode {
return new TweetNode(tweetID);
}
export function $isTweetNode(
node: TweetNode | LexicalNode | null | undefined,
): node is TweetNode {
return node instanceof TweetNode;
}
src/components/editor/nodes/youtube-node.tsx
import type { JSX } from "react";
import {
$getDocument,
type DOMExportOutput,
type EditorConfig,
type ElementFormatType,
type LexicalEditor,
type LexicalNode,
type NodeKey,
type Spread,
} from "lexical";
import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents";
import {
DecoratorBlockNode,
type SerializedDecoratorBlockNode,
} from "@lexical/react/LexicalDecoratorBlockNode";
type YouTubeComponentProps = Readonly<{
className: Readonly<{
base: string;
focus: string;
}>;
format: ElementFormatType | null;
nodeKey: NodeKey;
videoID: string;
}>;
function YouTubeComponent({
className,
format,
nodeKey,
videoID,
}: YouTubeComponentProps) {
return (
<BlockWithAlignableContents
className={className}
format={format}
nodeKey={nodeKey}
>
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoID}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video"
className="inline-block aspect-video w-full max-w-xl rounded-md border bg-muted align-top"
/>
</BlockWithAlignableContents>
);
}
export type SerializedYouTubeNode = Spread<
{
videoID: string;
},
SerializedDecoratorBlockNode
>;
export class YouTubeNode extends DecoratorBlockNode {
__id: string;
$config() {
return this.config("youtube", { extends: DecoratorBlockNode });
}
static clone(node: YouTubeNode): YouTubeNode {
return new YouTubeNode(node.__id, node.__format, node.__key);
}
static importJSON(serializedNode: SerializedYouTubeNode): YouTubeNode {
return $createYouTubeNode(serializedNode.videoID).updateFromJSON(
serializedNode,
);
}
exportJSON(): SerializedYouTubeNode {
return {
...super.exportJSON(),
videoID: this.__id,
};
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key);
this.__id = id;
}
exportDOM(): DOMExportOutput {
const element = $getDocument().createElement("iframe");
element.setAttribute("data-lexical-youtube", this.__id);
element.setAttribute("width", "560");
element.setAttribute("height", "315");
element.setAttribute(
"src",
`https://www.youtube-nocookie.com/embed/${this.__id}`,
);
element.setAttribute("frameborder", "0");
element.setAttribute(
"allow",
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
);
element.setAttribute("allowfullscreen", "true");
element.setAttribute("title", "YouTube video");
return { element };
}
updateDOM(): false {
return false;
}
getId(): string {
return this.getLatest().__id;
}
getTextContent(): string {
return `https://www.youtube.com/watch?v=${this.__id}`;
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {};
const className = {
base: embedBlockTheme.base || "",
focus: embedBlockTheme.focus || "",
};
return (
<YouTubeComponent
className={className}
format={this.__format}
nodeKey={this.getKey()}
videoID={this.__id}
/>
);
}
}
export function $createYouTubeNode(videoID: string): YouTubeNode {
return new YouTubeNode(videoID);
}
export function $isYouTubeNode(
node: YouTubeNode | LexicalNode | null | undefined,
): node is YouTubeNode {
return node instanceof YouTubeNode;
}
src/components/editor/plugins/activitybar/activitybar-plugin.tsx
import { cn } from "cn";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
export function ActivityBar({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<ScrollArea
data-slot="activitybar"
className={cn("w-full shrink-0 border-t border-input", className)}
>
<div className="flex h-10 w-max min-w-full items-center justify-between gap-3 px-4 text-xs whitespace-nowrap text-muted-foreground">
{children}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
}
src/components/editor/plugins/activitybar/count-plugin.tsx
import { useEffect, useState } from "react";
import { $getRoot } from "lexical";
import { CharacterLimitPlugin } from "@lexical/react/LexicalCharacterLimitPlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { cn } from "cn";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
const graphemeSegmenter =
typeof Intl.Segmenter === "undefined"
? null
: new Intl.Segmenter(undefined, { granularity: "grapheme" });
const wordSegmenter =
typeof Intl.Segmenter === "undefined"
? null
: new Intl.Segmenter(undefined, { granularity: "word" });
function computeCounts(text: string): { words: number; characters: number } {
let words = 0;
if (wordSegmenter === null) {
words = text.split(/\s+/).filter(Boolean).length;
} else {
for (const segment of wordSegmenter.segment(text)) {
if (segment.isWordLike) {
words++;
}
}
}
const flattened = text.replace(/\n/g, "");
let characters = 0;
if (graphemeSegmenter === null) {
characters = [...flattened].length;
} else {
for (const _ of graphemeSegmenter.segment(flattened)) {
characters++;
}
}
return { words, characters };
}
export function CountPlugin({ maxLength }: { maxLength?: number }) {
const [editor] = useLexicalComposerContext();
const { language, t } = useTranslation();
const [counts, setCounts] = useState(() =>
editor
.getEditorState()
.read(() => computeCounts($getRoot().getTextContent())),
);
useEffect(() => {
return editor.registerUpdateListener(
({ editorState, dirtyElements, dirtyLeaves }) => {
if (dirtyElements.size === 0 && dirtyLeaves.size === 0) {
return;
}
setCounts(
editorState.read(() => computeCounts($getRoot().getTextContent())),
);
},
);
}, [editor]);
const format = new Intl.NumberFormat(language);
return (
<>
<span>
{format.format(counts.words)} {t.words}
</span>
{maxLength === undefined ? (
<span>
{format.format(counts.characters)} {t.characters}
</span>
) : (
<CharacterLimitPlugin
charset="UTF-16"
maxLength={maxLength}
renderer={({ remainingCharacters }) => (
<span className={cn(remainingCharacters < 0 && "text-destructive")}>
{format.format(maxLength - remainingCharacters)} /{" "}
{format.format(maxLength)} {t.characters}
</span>
)}
/>
)}
</>
);
}
src/components/editor/plugins/activitybar/read-only-toggle-plugin.tsx
import { useEffect, useState } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Lock, LockOpen } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
export function ReadOnlyTogglePlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
useEffect(() => {
return editor.registerEditableListener(setIsEditable);
}, [editor]);
const label = isEditable ? t.switchToReadOnly : t.switchToEditMode;
return (
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title={label}
aria-label={label}
aria-pressed={!isEditable}
onClick={() => editor.setEditable(!editor.isEditable())}
>
{isEditable ? <LockOpen /> : <Lock />}
</Button>
);
}
src/components/editor/plugins/activitybar/shortcut-plugin.tsx
import { Keyboard } from "lucide-react";
import {
type BuiltinShortcutName,
type ShortcutName,
formatShortcut,
} from "@/components/editor/extensions/shortcuts";
import type { Locale } from "@/components/editor/locales";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Kbd, KbdGroup } from "@/components/ui/kbd";
type ShortcutRow = {
name: ShortcutName | BuiltinShortcutName;
labelKey: keyof Locale;
};
const SHORTCUT_GROUPS: { groupKey: keyof Locale; rows: ShortcutRow[] }[] = [
{
groupKey: "shortcutsHistory",
rows: [
{ name: "UNDO", labelKey: "undo" },
{ name: "REDO", labelKey: "redo" },
],
},
{
groupKey: "shortcutsFormatting",
rows: [
{ name: "BOLD", labelKey: "bold" },
{ name: "ITALIC", labelKey: "italic" },
{ name: "UNDERLINE", labelKey: "underline" },
{ name: "STRIKETHROUGH", labelKey: "strikethrough" },
{ name: "INSERT_CODE_BLOCK", labelKey: "inlineCode" },
{ name: "SUBSCRIPT", labelKey: "subscript" },
{ name: "SUPERSCRIPT", labelKey: "superscript" },
{ name: "LOWERCASE", labelKey: "lowercase" },
{ name: "UPPERCASE", labelKey: "uppercase" },
{ name: "CAPITALIZE", labelKey: "titleCase" },
{ name: "LEFT_ALIGN", labelKey: "alignLeft" },
{ name: "CENTER_ALIGN", labelKey: "alignCenter" },
{ name: "RIGHT_ALIGN", labelKey: "alignRight" },
{ name: "JUSTIFY_ALIGN", labelKey: "alignJustify" },
{ name: "INDENT", labelKey: "indent" },
{ name: "OUTDENT", labelKey: "outdent" },
{ name: "CLEAR_FORMATTING", labelKey: "clearFormatting" },
],
},
{
groupKey: "shortcutsParagraphs",
rows: [
{ name: "NORMAL", labelKey: "paragraph" },
{ name: "HEADING1", labelKey: "heading1" },
{ name: "HEADING2", labelKey: "heading2" },
{ name: "HEADING3", labelKey: "heading3" },
{ name: "QUOTE", labelKey: "quote" },
{ name: "CODE_BLOCK", labelKey: "insertCodeBlock" },
],
},
{
groupKey: "shortcutsLists",
rows: [
{ name: "NUMBERED_LIST", labelKey: "numberedListBlock" },
{ name: "BULLET_LIST", labelKey: "bulletedListBlock" },
{ name: "CHECK_LIST", labelKey: "checkListBlock" },
],
},
];
export function ShortcutPlugin() {
const { t, dir } = useTranslation();
return (
<Dialog>
<DialogTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title={t.keyboardShortcuts}
aria-label={t.keyboardShortcuts}
>
<Keyboard />
</Button>
}
/>
<DialogContent dir={dir} className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t.keyboardShortcuts}</DialogTitle>
</DialogHeader>
<div className="-mx-4 no-scrollbar max-h-[60vh] overflow-y-auto px-4">
{SHORTCUT_GROUPS.map(({ groupKey, rows }) => (
<section key={groupKey} className="mb-4 last:mb-0">
<h3 className="mb-1.5 text-xs font-medium text-muted-foreground">
{t[groupKey]}
</h3>
<div className="divide-y divide-border rounded-md border">
{rows.map(({ name, labelKey }) => (
<div
key={name}
className="flex items-center justify-between gap-3 px-3 py-1.5 text-sm"
>
<span>{t[labelKey]}</span>
<KbdGroup>
{formatShortcut(name).map((segment, index) => (
<Kbd key={index}>{segment}</Kbd>
))}
</KbdGroup>
</div>
))}
</div>
</section>
))}
</div>
</DialogContent>
</Dialog>
);
}
src/components/editor/plugins/activitybar/speech-to-text-plugin.tsx
import { useEffect, useState } from "react";
import { COMMAND_PRIORITY_LOW } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { cn } from "cn";
import { Mic, MicOff } from "lucide-react";
import {
SPEECH_TO_TEXT_COMMAND,
SUPPORT_SPEECH_RECOGNITION,
} from "@/components/editor/extensions/speech-to-text";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
function SpeechToTextButton() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const [isListening, setIsListening] = useState(false);
useEffect(() => {
return editor.registerCommand(
SPEECH_TO_TEXT_COMMAND,
(enabled) => {
setIsListening(enabled);
return false;
},
COMMAND_PRIORITY_LOW,
);
}, [editor]);
return (
<Button
variant="ghost"
size="icon-xs"
className={cn(
"text-muted-foreground",
isListening && "text-destructive hover:text-destructive",
)}
title={t.speechToText}
aria-label={t.speechToText}
aria-pressed={isListening}
onClick={() =>
editor.dispatchCommand(SPEECH_TO_TEXT_COMMAND, !isListening)
}
>
<Mic />
</Button>
);
}
export function SpeechToTextPlugin() {
const { t } = useTranslation();
if (SUPPORT_SPEECH_RECOGNITION) {
return <SpeechToTextButton />;
}
return (
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
title={t.speechToText}
aria-label={t.speechToText}
disabled
>
<MicOff />
</Button>
);
}
src/components/editor/plugins/auto-embed-plugin.tsx
import { useCallback } from "react";
import type { LexicalEditor, LexicalNode } from "lexical";
import {
AutoEmbedOption,
type EmbedConfig,
type EmbedMatchResult,
LexicalAutoEmbedPlugin,
} from "@lexical/react/LexicalAutoEmbedPlugin";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import type { LucideIcon } from "lucide-react";
import { Frame, MessageSquareQuote, SquarePlay, X } from "lucide-react";
import { INSERT_FIGMA_COMMAND } from "@/components/editor/extensions/figma";
import { INSERT_TWEET_COMMAND } from "@/components/editor/extensions/twitter";
import { INSERT_YOUTUBE_COMMAND } from "@/components/editor/extensions/youtube";
import type { Locale } from "@/components/editor/locales";
import { parseFigmaDocumentID } from "@/components/editor/plugins/block-insert/insert-figma-plugin";
import { parseTweetID } from "@/components/editor/plugins/block-insert/insert-twitter-plugin";
import { parseYouTubeVideoID } from "@/components/editor/plugins/block-insert/insert-youtube-plugin";
import {
useLanguage,
useTranslation,
} from "@/components/editor/plugins/i18n-plugin";
import { Command, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type EditorEmbedConfig = EmbedConfig & {
labelKey: keyof Locale;
icon: LucideIcon;
};
const YouTubeEmbedConfig: EditorEmbedConfig = {
type: "youtube-video",
labelKey: "embedYoutube",
icon: SquarePlay,
parseUrl: (text: string) => {
const id = parseYouTubeVideoID(text);
return id ? { id, url: text } : null;
},
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
},
};
const TwitterEmbedConfig: EditorEmbedConfig = {
type: "tweet",
labelKey: "embedTweet",
icon: MessageSquareQuote,
parseUrl: (text: string) => {
const id = parseTweetID(text);
return id ? { id, url: text } : null;
},
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id);
},
};
const FigmaEmbedConfig: EditorEmbedConfig = {
type: "figma",
labelKey: "embedFigma",
icon: Frame,
parseUrl: (text: string) => {
const id = parseFigmaDocumentID(text);
return id ? { id, url: text } : null;
},
insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
editor.dispatchCommand(INSERT_FIGMA_COMMAND, result.id);
},
};
const EMBED_CONFIGS: EditorEmbedConfig[] = [
YouTubeEmbedConfig,
TwitterEmbedConfig,
FigmaEmbedConfig,
];
class EditorAutoEmbedOption extends AutoEmbedOption {
Icon: LucideIcon;
constructor(
title: string,
Icon: LucideIcon,
options: { onSelect: (targetNode: LexicalNode | null) => void },
) {
super(title, options);
this.Icon = Icon;
}
}
export function AutoEmbedPlugin() {
const { t } = useTranslation();
const { dir } = useLanguage();
const getMenuOptions = useCallback(
(
activeEmbedConfig: EditorEmbedConfig,
embedFn: () => void,
dismissFn: () => void,
) => [
new EditorAutoEmbedOption(t.autoEmbedDismiss, X, {
onSelect: dismissFn,
}),
new EditorAutoEmbedOption(
t[activeEmbedConfig.labelKey],
activeEmbedConfig.icon,
{ onSelect: embedFn },
),
],
[t],
);
return (
<LexicalAutoEmbedPlugin<EditorEmbedConfig>
embedConfigs={EMBED_CONFIGS}
getMenuOptions={getMenuOptions}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && options.length > 0 ? (
<Popover open>
<PopoverPrimitive.Portal container={anchorElementRef.current}>
<PopoverTrigger
nativeButton={false}
render={
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-0"
/>
}
/>
<PopoverContent
dir={dir}
side="bottom"
align="start"
sideOffset={6}
initialFocus={false}
className="w-auto overflow-hidden p-0"
>
<Command
value={
selectedIndex === null
? ""
: (options[selectedIndex]?.key ?? "")
}
onValueChange={(nextValue) => {
const index = options.findIndex(
(option) => option.key === nextValue,
);
if (index >= 0) {
setHighlightedIndex(index);
}
}}
shouldFilter={false}
>
<CommandList className="max-h-64 w-56">
{options.map((option) => {
const Icon =
option instanceof EditorAutoEmbedOption
? option.Icon
: null;
return (
<CommandItem
key={option.key}
ref={option.setRefElement}
value={option.key}
onSelect={() => selectOptionAndCleanUp(option)}
>
{Icon ? (
<Icon className="size-4 text-muted-foreground" />
) : null}
<span className="truncate">{option.title}</span>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
) : null
}
/>
);
}
src/components/editor/plugins/block-insert/block-insert-plugin.tsx
import { ButtonGroup } from "@/components/ui/button-group";
export function BlockInsert(props: React.ComponentProps<typeof ButtonGroup>) {
return <ButtonGroup {...props} />;
}
src/components/editor/plugins/block-insert/insert-code-block-plugin.tsx
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { CodeXml } from "lucide-react";
import { insertCodeBlock } from "@/components/editor/extensions/code";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function InsertCodeBlockPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const isEditable = useLexicalEditable();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertCodeBlock}
disabled={!isEditable}
onClick={() => insertCodeBlock(editor)}
>
<CodeXml />
</Button>
}
/>
<TooltipContent>{t.insertCodeBlock}</TooltipContent>
</Tooltip>
);
}
src/components/editor/plugins/block-insert/insert-columns-plugin.tsx
import { $getRoot, $getSelection, type LexicalEditor } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Columns2 } from "lucide-react";
import { INSERT_LAYOUT_COMMAND } from "@/components/editor/extensions/layout";
import type { Locale } from "@/components/editor/locales";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type ColumnOption = {
value: string;
labelKey: keyof Locale;
templateColumns: string;
};
const COLUMN_OPTIONS: ColumnOption[] = [
{ value: "one", labelKey: "columnsOne", templateColumns: "1fr" },
{ value: "two", labelKey: "columnsTwo", templateColumns: "1fr 1fr" },
{ value: "three", labelKey: "columnsThree", templateColumns: "1fr 1fr 1fr" },
];
function insertColumns(editor: LexicalEditor, value: string) {
const option = COLUMN_OPTIONS.find((candidate) => candidate.value === value);
if (!option) {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_LAYOUT_COMMAND, option.templateColumns);
}
export function InsertColumnsPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.columns}
disabled={!isEditable}
>
<Columns2 />
</Button>
}
/>
}
/>
<TooltipContent>{t.columns}</TooltipContent>
</Tooltip>
<DropdownMenuContent dir={dir}>
{COLUMN_OPTIONS.map(({ value, labelKey }) => (
<DropdownMenuItem
key={value}
onClick={() => insertColumns(editor, value)}
>
{t[labelKey]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
src/components/editor/plugins/block-insert/insert-emoji-plugin.tsx
import {
$getRoot,
$getSelection,
$isRangeSelection,
type LexicalEditor,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Smile } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function insertEmojiTrigger(editor: LexicalEditor) {
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
if ($isRangeSelection(selection)) {
selection.insertText(":");
}
});
editor.focus();
}
export function InsertEmojiPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertEmoji}
disabled={!isEditable}
onClick={() => insertEmojiTrigger(editor)}
>
<Smile />
</Button>
}
/>
<TooltipContent>{t.insertEmoji}</TooltipContent>
</Tooltip>
);
}
src/components/editor/plugins/block-insert/insert-equation-plugin.tsx
import { useState } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Radical } from "lucide-react";
import { INSERT_EQUATION_COMMAND } from "@/components/editor/extensions/equation";
import { KatexRenderer } from "@/components/editor/nodes/equation-node";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function InsertEquationPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const [open, setOpen] = useState(false);
const [equation, setEquation] = useState("");
const [inline, setInline] = useState(false);
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setEquation("");
setInline(false);
}
};
const onSubmit = () => {
if (equation.trim() === "") {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_EQUATION_COMMAND, { equation, inline });
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertEquation}
disabled={!isEditable}
>
<Radical />
</Button>
}
/>
}
/>
<TooltipContent>{t.insertEquation}</TooltipContent>
</Tooltip>
<PopoverContent dir={dir} align="start" className="w-80">
<div className="flex flex-col gap-2">
<Input
dir="ltr"
className="font-mono"
placeholder={t.equationPlaceholder}
value={equation}
onChange={(event) => setEquation(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmit();
}
}}
/>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={inline}
onCheckedChange={(checked) => setInline(checked === true)}
/>
{t.equationInline}
</label>
{equation.trim() !== "" && (
<div
dir="ltr"
className="overflow-x-auto rounded-md border border-input px-3 py-2 text-center"
>
<KatexRenderer equation={equation} inline={inline} />
</div>
)}
<Button
size="sm"
disabled={equation.trim() === ""}
onClick={onSubmit}
>
{t.equationInsert}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
src/components/editor/plugins/block-insert/insert-figma-plugin.tsx
import { useState } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Frame } from "lucide-react";
import { INSERT_FIGMA_COMMAND } from "@/components/editor/extensions/figma";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function parseFigmaDocumentID(url: string): string | null {
const trimmed = url.trim();
if (/^[0-9a-zA-Z]{22,128}$/.test(trimmed)) {
return trimmed;
}
const match =
/^https:\/\/([\w.-]+\.)?figma\.com\/(file|proto|design|board)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/.exec(
trimmed,
);
return match?.[3] ?? null;
}
export function InsertFigmaPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const documentID = parseFigmaDocumentID(url);
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setUrl("");
}
};
const onSubmit = () => {
if (!documentID) {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_FIGMA_COMMAND, documentID);
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertFigma}
disabled={!isEditable}
>
<Frame />
</Button>
}
/>
}
/>
<TooltipContent>{t.insertFigma}</TooltipContent>
</Tooltip>
<PopoverContent dir={dir} align="start" className="w-72">
<div className="flex flex-col gap-2">
<Input
dir="ltr"
placeholder={t.figmaUrlPlaceholder}
value={url}
onChange={(event) => setUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmit();
}
}}
/>
<Button size="sm" disabled={!documentID} onClick={onSubmit}>
{t.embedFigma}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
src/components/editor/plugins/block-insert/insert-horizontal-rule-plugin.tsx
import { $getRoot, $getSelection } from "lexical";
import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Minus } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function InsertHorizontalRulePlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertHorizontalRule}
disabled={!isEditable}
onClick={() => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
}}
>
<Minus />
</Button>
}
/>
<TooltipContent>{t.insertHorizontalRule}</TooltipContent>
</Tooltip>
);
}
src/components/editor/plugins/block-insert/insert-image-plugin.tsx
import { useCallback, useRef } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Image } from "lucide-react";
import { INSERT_IMAGE_COMMAND } from "@/components/editor/nodes/image-node";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
export function useImageFilePicker() {
const [editor] = useLexicalComposerContext();
const inputRef = useRef<HTMLInputElement>(null);
const pick = useCallback(() => {
inputRef.current?.click();
}, []);
const onChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
readFileAsDataUrl(file).then((src) => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
altText: file.name,
src,
});
});
},
[editor],
);
const input = (
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onChange}
/>
);
return { pick, input };
}
export function InsertImagePlugin() {
const isEditable = useLexicalEditable();
const { t } = useTranslation();
const { pick, input } = useImageFilePicker();
return (
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertImage}
disabled={!isEditable}
onClick={pick}
>
<Image />
</Button>
}
/>
<TooltipContent>{t.insertImage}</TooltipContent>
</Tooltip>
{input}
</>
);
}
src/components/editor/plugins/block-insert/insert-table-plugin.tsx
import { useCallback, useState } from "react";
import {
$getRoot,
$getSelection,
$isParagraphNode,
$isRangeSelection,
type LexicalEditor,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { $findTableNode, INSERT_TABLE_COMMAND } from "@lexical/table";
import { cn } from "cn";
import { Table } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const MAX_ROWS = 8;
const MAX_COLUMNS = 10;
export function insertTable(
editor: LexicalEditor,
rows: number,
columns: number,
) {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_TABLE_COMMAND, {
columns: String(columns),
rows: String(rows),
includeHeaders: { columns: true, rows: true },
});
editor.update(() => {
const selection = $getSelection();
const tableNode = $isRangeSelection(selection)
? $findTableNode(selection.anchor.getNode())
: null;
if (!tableNode) {
return;
}
const previousSibling = tableNode.getPreviousSibling();
if ($isParagraphNode(previousSibling) && previousSibling.isEmpty()) {
previousSibling.remove();
}
const nextSibling = tableNode.getNextSibling();
if ($isParagraphNode(nextSibling) && nextSibling.isEmpty()) {
nextSibling.remove();
}
});
}
function TableGridPicker({
onSelect,
}: {
onSelect: (rows: number, columns: number) => void;
}) {
const [hovered, setHovered] = useState({ rows: 1, columns: 1 });
return (
<div className="flex flex-col items-center gap-2">
<div
className="grid gap-1"
style={{ gridTemplateColumns: `repeat(${MAX_COLUMNS}, 1.125rem)` }}
onPointerLeave={() => setHovered({ rows: 1, columns: 1 })}
>
{Array.from({ length: MAX_ROWS * MAX_COLUMNS }, (_, index) => {
const row = Math.floor(index / MAX_COLUMNS) + 1;
const column = (index % MAX_COLUMNS) + 1;
const active = row <= hovered.rows && column <= hovered.columns;
return (
<div
key={index}
role="button"
aria-label={`${column} x ${row}`}
onPointerEnter={() => setHovered({ rows: row, columns: column })}
onClick={() => onSelect(row, column)}
className={cn(
"size-[1.125rem] rounded-xs border",
active ? "border-primary bg-primary/20" : "border-border",
)}
/>
);
})}
</div>
<p className="text-xs text-muted-foreground">
{hovered.columns} x {hovered.rows}
</p>
</div>
);
}
export function InsertTablePlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const [open, setOpen] = useState(false);
const onSelect = useCallback(
(rows: number, columns: number) => {
insertTable(editor, rows, columns);
setOpen(false);
},
[editor],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertTable}
disabled={!isEditable}
>
<Table />
</Button>
}
/>
}
/>
<TooltipContent>{t.insertTable}</TooltipContent>
</Tooltip>
<PopoverContent dir={dir} align="start" className="w-auto">
<TableGridPicker onSelect={onSelect} />
</PopoverContent>
</Popover>
);
}
src/components/editor/plugins/block-insert/insert-twitter-plugin.tsx
import { useState } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { MessageSquareQuote } from "lucide-react";
import { INSERT_TWEET_COMMAND } from "@/components/editor/extensions/twitter";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function parseTweetID(url: string): string | null {
const trimmed = url.trim();
if (/^\d{4,25}$/.test(trimmed)) {
return trimmed;
}
const match =
/^https:\/\/(twitter|x)\.com\/(#!\/)?(\w+)\/status(es)?\/(\d+)/.exec(
trimmed,
);
return match?.[5] ?? null;
}
export function InsertTwitterPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const tweetID = parseTweetID(url);
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setUrl("");
}
};
const onSubmit = () => {
if (!tweetID) {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_TWEET_COMMAND, tweetID);
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertTweet}
disabled={!isEditable}
>
<MessageSquareQuote />
</Button>
}
/>
}
/>
<TooltipContent>{t.insertTweet}</TooltipContent>
</Tooltip>
<PopoverContent dir={dir} align="start" className="w-72">
<div className="flex flex-col gap-2">
<Input
dir="ltr"
placeholder={t.tweetUrlPlaceholder}
value={url}
onChange={(event) => setUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmit();
}
}}
/>
<Button size="sm" disabled={!tweetID} onClick={onSubmit}>
{t.embedTweet}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
src/components/editor/plugins/block-insert/insert-youtube-plugin.tsx
import { useState } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { SquarePlay } from "lucide-react";
import { INSERT_YOUTUBE_COMMAND } from "@/components/editor/extensions/youtube";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function parseYouTubeVideoID(url: string): string | null {
const trimmed = url.trim();
if (/^[A-Za-z0-9_-]{11}$/.test(trimmed)) {
return trimmed;
}
const match =
/^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|shorts\/|live\/|watch\?v=|&v=)([^#&?]*).*/.exec(
trimmed,
);
const id = match?.[2];
return id && id.length === 11 ? id : null;
}
export function InsertYouTubePlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const videoID = parseYouTubeVideoID(url);
const onOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) {
setUrl("");
}
};
const onSubmit = () => {
if (!videoID) {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, videoID);
onOpenChange(false);
};
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Tooltip>
<TooltipTrigger
render={
<PopoverTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertYoutube}
disabled={!isEditable}
>
<SquarePlay />
</Button>
}
/>
}
/>
<TooltipContent>{t.insertYoutube}</TooltipContent>
</Tooltip>
<PopoverContent dir={dir} align="start" className="w-72">
<div className="flex flex-col gap-2">
<Input
dir="ltr"
placeholder={t.youtubeUrlPlaceholder}
value={url}
onChange={(event) => setUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
onSubmit();
}
}}
/>
<Button size="sm" disabled={!videoID} onClick={onSubmit}>
{t.embedYoutube}
</Button>
</div>
</PopoverContent>
</Popover>
);
}
src/components/editor/plugins/component-picker/ai-picker-plugin.tsx
import { useMemo } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Sparkles } from "lucide-react";
import { OPEN_AI_EDITOR_COMMAND } from "@/components/editor/extensions/ai";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function AiPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "ask-ai",
label: t.askAi,
icon: <Sparkles className="text-muted-foreground" />,
keywords: ["ai", "ask", "assistant", "generate", "write"],
onSelect: () => {
editor.dispatchCommand(OPEN_AI_EDITOR_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/bulleted-list-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { List } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function BulletedListPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "bulleted-list",
label: t.bulletedListBlock,
icon: <List className="text-muted-foreground" />,
keywords: ["bulleted list", "unordered list", "ul"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/card-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LayoutPanelTop } from "lucide-react";
import { INSERT_CARD_COMMAND } from "@/components/editor/extensions/card";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function CardPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "card",
label: t.insertCard,
icon: <LayoutPanelTop className="text-muted-foreground" />,
keywords: ["card", "panel", "container", "box"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_CARD_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/check-list-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ListTodo } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function CheckListPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "check-list",
label: t.checkListBlock,
icon: <ListTodo className="text-muted-foreground" />,
keywords: ["check list", "todo list", "checkbox"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/code-picker-plugin.tsx
import { useMemo } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { CodeXml } from "lucide-react";
import { insertCodeBlock } from "@/components/editor/extensions/code";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function CodePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "code-block",
label: t.insertCodeBlock,
icon: <CodeXml className="text-muted-foreground" />,
keywords: ["code", "codeblock", "javascript", "python", "js"],
onSelect: () => insertCodeBlock(editor),
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/collapsible-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ListCollapse } from "lucide-react";
import { INSERT_COLLAPSIBLE_COMMAND } from "@/components/editor/extensions/collapsible";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function CollapsiblePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "collapsible",
label: t.insertCollapsible,
icon: <ListCollapse className="text-muted-foreground" />,
keywords: ["collapse", "collapsible", "toggle", "details", "accordion"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/columns-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
Columns2,
Columns3,
type LucideIcon,
RectangleVertical,
} from "lucide-react";
import { INSERT_LAYOUT_COMMAND } from "@/components/editor/extensions/layout";
import type { Locale } from "@/components/editor/locales";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
const COLUMN_ITEMS: {
value: string;
labelKey: keyof Locale;
icon: LucideIcon;
templateColumns: string;
}[] = [
{
value: "columns-one",
labelKey: "columnsOne",
icon: RectangleVertical,
templateColumns: "1fr",
},
{
value: "columns-two",
labelKey: "columnsTwo",
icon: Columns2,
templateColumns: "1fr 1fr",
},
{
value: "columns-three",
labelKey: "columnsThree",
icon: Columns3,
templateColumns: "1fr 1fr 1fr",
},
];
export function ColumnsPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() =>
COLUMN_ITEMS.map(({ value, labelKey, icon: Icon, templateColumns }) => ({
value,
label: t[labelKey],
icon: <Icon className="text-muted-foreground" />,
keywords: ["columns", "layout", "grid"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_LAYOUT_COMMAND, templateColumns);
},
})),
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/component-picker-plugin.tsx
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import type { TextNode } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from "@lexical/react/LexicalTypeaheadMenuPlugin";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { useLanguage } from "@/components/editor/plugins/i18n-plugin";
import { Command, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export type ComponentPickerItem = {
value: string;
label: string;
icon?: React.ReactNode;
keywords?: string[];
onSelect: (queryString: string) => void;
};
type ComponentPickerContextValue = {
registerItems: (id: string, items: ComponentPickerItem[]) => () => void;
queryString: string | null;
};
const ComponentPickerContext =
createContext<ComponentPickerContextValue | null>(null);
function useComponentPickerContext(): ComponentPickerContextValue {
const context = useContext(ComponentPickerContext);
if (context === null) {
throw new Error(
"ComponentPicker plugins must be rendered inside <ComponentPicker>",
);
}
return context;
}
export function useComponentPickerQuery(): string | null {
return useComponentPickerContext().queryString;
}
export function useComponentPickerItems(items: ComponentPickerItem[]) {
const { registerItems } = useComponentPickerContext();
const id = useId();
useEffect(() => registerItems(id, items), [registerItems, id, items]);
}
class ComponentPickerOption extends MenuOption {
item: ComponentPickerItem;
constructor(item: ComponentPickerItem) {
super(item.value);
this.item = item;
}
}
export function ComponentPicker({ children }: { children: React.ReactNode }) {
const [editor] = useLexicalComposerContext();
const { dir } = useLanguage();
const [queryString, setQueryString] = useState<string | null>(null);
const [itemsById, setItemsById] = useState(
() => new Map<string, ComponentPickerItem[]>(),
);
const registrationOrderRef = useRef(new Map<string, number>());
const registerItems = useCallback(
(id: string, items: ComponentPickerItem[]) => {
const order = registrationOrderRef.current;
if (!order.has(id)) {
order.set(id, order.size);
}
setItemsById((prev) => new Map(prev).set(id, items));
return () => {
setItemsById((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
};
},
[],
);
const contextValue = useMemo(
() => ({ registerItems, queryString }),
[registerItems, queryString],
);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
allowWhitespace: true,
minLength: 0,
});
const options = useMemo(() => {
const order = registrationOrderRef.current;
const allItems = Array.from(itemsById.entries())
.sort(([a], [b]) => (order.get(a) ?? 0) - (order.get(b) ?? 0))
.flatMap(([, items]) => items);
const query = (queryString ?? "").trim().toLowerCase();
const matches =
query === ""
? allItems
: allItems.filter(
(item) =>
item.label.toLowerCase().includes(query) ||
(item.keywords ?? []).some((keyword) =>
keyword.toLowerCase().includes(query),
),
);
return matches.map((item) => new ComponentPickerOption(item));
}, [itemsById, queryString]);
const onSelectOption = useCallback(
(
option: ComponentPickerOption,
nodeToReplace: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => {
editor.update(() => {
if (nodeToReplace) {
nodeToReplace.remove();
}
});
closeMenu();
option.item.onSelect(matchingString);
},
[editor],
);
return (
<ComponentPickerContext.Provider value={contextValue}>
{children}
<LexicalTypeaheadMenuPlugin<ComponentPickerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && options.length > 0 ? (
<Popover open>
<PopoverPrimitive.Portal
dir={dir}
container={anchorElementRef.current}
>
<PopoverTrigger
nativeButton={false}
render={
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-0"
/>
}
/>
<PopoverContent
dir={dir}
side="bottom"
align="start"
sideOffset={6}
initialFocus={false}
finalFocus={false}
className="w-auto overflow-hidden p-0"
>
<Command
value={
selectedIndex === null
? ""
: (options[selectedIndex]?.item.value ?? "")
}
onValueChange={(nextValue) => {
const index = options.findIndex(
(option) => option.item.value === nextValue,
);
if (index >= 0) {
setHighlightedIndex(index);
}
}}
shouldFilter={false}
>
<CommandList className="max-h-64 w-56">
{options.map((option) => (
<CommandItem
key={option.key}
ref={option.setRefElement}
value={option.item.value}
onSelect={() => selectOptionAndCleanUp(option)}
>
{option.item.icon}
<span className="truncate">{option.item.label}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
) : null
}
/>
</ComponentPickerContext.Provider>
);
}
src/components/editor/plugins/component-picker/datetime-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection, type LexicalEditor } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { addDays } from "date-fns";
import {
CalendarCheck,
CalendarDays,
CalendarMinus,
CalendarPlus,
type LucideIcon,
} from "lucide-react";
import { INSERT_DATETIME_COMMAND } from "@/components/editor/extensions/datetime";
import type { Locale } from "@/components/editor/locales";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
const DATETIME_ITEMS: {
value: string;
labelKey: keyof Locale;
icon: LucideIcon;
offsetDays: number;
keywords: string[];
}[] = [
{
value: "datetime",
labelKey: "insertDateTime",
icon: CalendarDays,
offsetDays: 0,
keywords: ["date", "time", "datetime", "calendar"],
},
{
value: "datetime-today",
labelKey: "dateTimeToday",
icon: CalendarCheck,
offsetDays: 0,
keywords: ["today", "date", "now"],
},
{
value: "datetime-tomorrow",
labelKey: "dateTimeTomorrow",
icon: CalendarPlus,
offsetDays: 1,
keywords: ["tomorrow", "date"],
},
{
value: "datetime-yesterday",
labelKey: "dateTimeYesterday",
icon: CalendarMinus,
offsetDays: -1,
keywords: ["yesterday", "date"],
},
];
function insertDateTime(editor: LexicalEditor, offsetDays: number) {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
const date = addDays(new Date(), offsetDays);
date.setHours(0, 0, 0, 0);
editor.dispatchCommand(INSERT_DATETIME_COMMAND, { dateTime: date });
}
export function DateTimePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() =>
DATETIME_ITEMS.map(
({ value, labelKey, icon: Icon, offsetDays, keywords }) => ({
value,
label: t[labelKey],
icon: <Icon className="text-muted-foreground" />,
keywords,
onSelect: () => insertDateTime(editor, offsetDays),
}),
),
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/divider-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/extension";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Minus } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function DividerPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "divider",
label: t.insertHorizontalRule,
icon: <Minus className="text-muted-foreground" />,
keywords: ["horizontal rule", "divider", "hr", "separator"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/heading-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $createHeadingNode, type HeadingTagType } from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import { Heading1, Heading2, Heading3, type LucideIcon } from "lucide-react";
import type { Locale } from "@/components/editor/locales";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
const HEADINGS: {
tag: HeadingTagType;
labelKey: keyof Locale;
icon: LucideIcon;
}[] = [
{ tag: "h1", labelKey: "heading1", icon: Heading1 },
{ tag: "h2", labelKey: "heading2", icon: Heading2 },
{ tag: "h3", labelKey: "heading3", icon: Heading3 },
];
export function HeadingPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() =>
HEADINGS.map(({ tag, labelKey, icon: Icon }) => ({
value: tag,
label: t[labelKey],
icon: <Icon className="text-muted-foreground" />,
keywords: ["heading", "header", tag],
onSelect: () =>
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$setBlocksType(selection, () => $createHeadingNode(tag));
}),
})),
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/image-picker-plugin.tsx
import { useMemo } from "react";
import { Image } from "lucide-react";
import { useImageFilePicker } from "@/components/editor/plugins/block-insert/insert-image-plugin";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function ImagePickerPlugin() {
const { t } = useTranslation();
const { pick, input } = useImageFilePicker();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "image",
label: t.insertImage,
icon: <Image className="text-muted-foreground" />,
keywords: ["image", "photo", "picture", "file", "img"],
onSelect: pick,
},
],
[t, pick],
);
useComponentPickerItems(items);
return input;
}
src/components/editor/plugins/component-picker/numbered-list-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { INSERT_ORDERED_LIST_COMMAND } from "@lexical/list";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ListOrdered } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function NumberedListPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "numbered-list",
label: t.numberedListBlock,
icon: <ListOrdered className="text-muted-foreground" />,
keywords: ["numbered list", "ordered list", "ol"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/paragraph-picker-plugin.tsx
import { useMemo } from "react";
import { $createParagraphNode, $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $setBlocksType } from "@lexical/selection";
import { Pilcrow } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function ParagraphPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "paragraph",
label: t.paragraph,
icon: <Pilcrow className="text-muted-foreground" />,
keywords: ["normal", "paragraph", "p", "text"],
onSelect: () =>
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$setBlocksType(selection, () => $createParagraphNode());
}),
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/poll-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ListTodo } from "lucide-react";
import { INSERT_POLL_COMMAND } from "@/components/editor/extensions/poll";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function PollPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "poll",
label: t.insertPoll,
icon: <ListTodo className="text-muted-foreground" />,
keywords: ["poll", "vote", "survey", "question"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_POLL_COMMAND, "");
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/pullquote-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Quote } from "lucide-react";
import { INSERT_PULLQUOTE_COMMAND } from "@/components/editor/extensions/pullquote";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function PullQuotePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "pullquote",
label: t.insertPullQuote,
icon: <Quote className="text-muted-foreground" />,
keywords: ["pullquote", "pull", "quote", "citation", "blockquote"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_PULLQUOTE_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/quote-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $createQuoteNode } from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import { TextQuote } from "lucide-react";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function QuotePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "quote",
label: t.quote,
icon: <TextQuote className="text-muted-foreground" />,
keywords: ["quote", "block quote", "blockquote"],
onSelect: () =>
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$setBlocksType(selection, () => $createQuoteNode());
}),
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/review-picker-plugin.tsx
import { useMemo } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Star } from "lucide-react";
import { INSERT_REVIEW_COMMAND } from "@/components/editor/extensions/review";
import {
type ComponentPickerItem,
useComponentPickerItems,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export function ReviewPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const items = useMemo<ComponentPickerItem[]>(
() => [
{
value: "review",
label: t.insertReview,
icon: <Star className="text-muted-foreground" />,
keywords: ["review", "rating", "stars", "testimonial"],
onSelect: () => {
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
editor.dispatchCommand(INSERT_REVIEW_COMMAND, undefined);
},
},
],
[editor, t],
);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/component-picker/table-picker-plugin.tsx
import { useMemo } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { Table } from "lucide-react";
import { insertTable } from "@/components/editor/plugins/block-insert/insert-table-plugin";
import {
type ComponentPickerItem,
useComponentPickerItems,
useComponentPickerQuery,
} from "@/components/editor/plugins/component-picker/component-picker-plugin";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
const FULL_TABLE_REGEX = /^([1-9]|10)x([1-9]|10)$/;
const PARTIAL_TABLE_REGEX = /^([1-9]|10)x?$/;
export function TablePickerPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const queryString = useComponentPickerQuery();
const items = useMemo<ComponentPickerItem[]>(() => {
const items: ComponentPickerItem[] = [
{
value: "table",
label: t.insertTable,
icon: <Table className="text-muted-foreground" />,
keywords: ["table", "grid", "spreadsheet", "rows", "columns"],
onSelect: () => insertTable(editor, 3, 3),
},
];
const query = (queryString ?? "").trim();
const fullMatch = FULL_TABLE_REGEX.exec(query);
const partialMatch = fullMatch ? null : PARTIAL_TABLE_REGEX.exec(query);
const sizes = fullMatch
? [{ rows: Number(fullMatch[1]), columns: Number(fullMatch[2]) }]
: partialMatch
? Array.from({ length: 5 }, (_, index) => ({
rows: Number(partialMatch[1]),
columns: index + 1,
}))
: [];
for (const { rows, columns } of sizes) {
items.push({
value: `table-${rows}x${columns}`,
label: `${rows}x${columns} ${t.insertTable}`,
icon: <Table className="text-muted-foreground" />,
keywords: ["table", `${rows}x${columns}`],
onSelect: () => insertTable(editor, rows, columns),
});
}
return items;
}, [editor, t, queryString]);
useComponentPickerItems(items);
return null;
}
src/components/editor/plugins/chat/chat-input-plugin.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import {
type AnyLexicalExtensionArgument,
CLEAR_HISTORY_COMMAND,
COMMAND_PRIORITY_EDITOR,
defineExtension,
} from "lexical";
import type { Transformer } from "@lexical/markdown";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { cn } from "cn";
import { ArrowUp, Square } from "lucide-react";
import {
$clearChatInput,
$getChatInputContent,
$isChatInputEmpty,
CHAT_INPUT_TRANSFORMERS,
ChatInputExtension,
type ChatInputValue,
SUBMIT_CHAT_INPUT_COMMAND,
} from "@/components/editor/extensions/chat";
import { ContentEditable } from "@/components/editor/plugins/content-editable";
import {
type LocalizedText,
useTranslation,
} from "@/components/editor/plugins/i18n-plugin";
import { InputGroup, InputGroupButton } from "@/components/ui/input-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const INTERACTIVE_SELECTOR = "button, a, [contenteditable], [role=menu]";
function preventDefault(event: React.MouseEvent<HTMLElement>): void {
event.preventDefault();
}
export interface ChatInputProps {
extension: AnyLexicalExtensionArgument;
onSubmit: (value: ChatInputValue) => void;
transformers?: Transformer[];
clearOnSubmit?: boolean;
disabled?: boolean;
autoFocus?: boolean;
placeholder?: LocalizedText;
className?: string;
editorClassName?: string;
children?: React.ReactNode;
}
export function ChatInput({
extension,
onSubmit,
transformers = CHAT_INPUT_TRANSFORMERS,
clearOnSubmit = true,
disabled = false,
autoFocus = false,
placeholder,
className,
editorClassName,
children,
}: ChatInputProps) {
const app = useMemo(
() =>
defineExtension({
name: "@shadcn-editor/chat-input/[root]",
dependencies: [ChatInputExtension, extension],
}),
[extension],
);
return (
<LexicalExtensionComposer extension={app} contentEditable={null}>
<ChatInputFrame className={className}>
<ChatInputEditor
autoFocus={autoFocus}
disabled={disabled}
placeholder={placeholder}
className={editorClassName}
/>
{children}
</ChatInputFrame>
<ChatInputSubmitHandler
onSubmit={onSubmit}
clearOnSubmit={clearOnSubmit}
transformers={transformers}
/>
</LexicalExtensionComposer>
);
}
function ChatInputFrame({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const [editor] = useLexicalComposerContext();
return (
<InputGroup
data-slot="chat-input"
className={cn(
"h-auto items-end has-aria-disabled:opacity-50 has-[>[data-align=block-end]]:items-stretch",
className,
)}
onClick={(event) => {
const target = event.target as HTMLElement;
if (!target.closest(INTERACTIVE_SELECTOR)) {
editor.focus();
}
}}
>
{children}
</InputGroup>
);
}
function ChatInputEditor({
autoFocus,
disabled,
placeholder,
className,
}: {
autoFocus: boolean;
disabled: boolean;
placeholder?: LocalizedText;
className?: string;
}) {
const [editor] = useLexicalComposerContext();
const { language, t } = useTranslation();
useEffect(() => {
editor.setEditable(!disabled);
}, [editor, disabled]);
useEffect(() => {
if (autoFocus) {
editor.focus();
}
}, [editor, autoFocus]);
return (
<div
data-slot="chat-input-editor"
className={cn(
"relative max-h-48 w-full min-w-0 flex-1 overflow-y-auto",
className,
)}
>
<ContentEditable
data-slot="input-group-control"
aria-label={t.chatMessage}
aria-disabled={disabled || undefined}
placeholder={{
[language]: placeholder?.[language] ?? t.chatPlaceholder,
}}
/>
</div>
);
}
function ChatInputSubmitHandler({
onSubmit,
clearOnSubmit,
transformers,
}: {
onSubmit: (value: ChatInputValue) => void;
clearOnSubmit: boolean;
transformers: Transformer[];
}) {
const [editor] = useLexicalComposerContext();
const onSubmitRef = useRef(onSubmit);
useEffect(() => {
onSubmitRef.current = onSubmit;
}, [onSubmit]);
useEffect(
() =>
editor.registerCommand(
SUBMIT_CHAT_INPUT_COMMAND,
() => {
if (!editor.isEditable() || $isChatInputEmpty()) {
return true;
}
const value: ChatInputValue = {
...$getChatInputContent(transformers),
editorState: editor.getEditorState().toJSON(),
};
if (clearOnSubmit) {
$clearChatInput();
queueMicrotask(() => {
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
});
}
onSubmitRef.current(value);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
[editor, clearOnSubmit, transformers],
);
return null;
}
export function ChatInputSubmit({
isLoading = false,
onStop,
className,
...props
}: Omit<
React.ComponentProps<typeof InputGroupButton>,
"children" | "onClick" | "type"
> & {
isLoading?: boolean;
onStop?: () => void;
}) {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const isEditable = useLexicalEditable();
const [isEmpty, setIsEmpty] = useState(() =>
editor.getEditorState().read($isChatInputEmpty),
);
useEffect(
() =>
editor.registerUpdateListener(({ editorState }) => {
setIsEmpty(editorState.read($isChatInputEmpty));
}),
[editor],
);
const showStop = isLoading && onStop !== undefined;
const label = showStop ? t.chatStop : t.chatSend;
return (
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
type="button"
variant={showStop ? "outline" : "default"}
size="icon-sm"
aria-label={label}
disabled={showStop ? false : isEmpty || !isEditable || isLoading}
className={cn("rounded-full", className)}
onMouseDown={preventDefault}
onClick={() => {
if (showStop) {
onStop();
} else {
editor.dispatchCommand(SUBMIT_CHAT_INPUT_COMMAND, undefined);
}
}}
{...props}
>
{showStop ? <Square className="fill-current" /> : <ArrowUp />}
</InputGroupButton>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
src/components/editor/plugins/chat/chat-message-plugin.tsx
import { useEffect, useMemo, useRef, useState } from "react";
import {
$getEditor,
type AnyLexicalExtensionArgument,
SKIP_DOM_SELECTION_TAG,
defineExtension,
} from "lexical";
import type { Transformer } from "@lexical/markdown";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
import { cn } from "cn";
import {
$setChatMessageContent,
CHAT_MESSAGE_TRANSFORMERS,
filterTransformers,
} from "@/components/editor/extensions/chat";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
export interface ChatMessageProps {
extension: AnyLexicalExtensionArgument;
content: string;
isStreaming?: boolean;
transformers?: Transformer[];
className?: string;
}
export function ChatMessage({
extension,
content,
isStreaming = false,
transformers = CHAT_MESSAGE_TRANSFORMERS,
className,
}: ChatMessageProps) {
const [initialContent] = useState(content);
const app = useMemo(
() =>
defineExtension({
name: "@shadcn-editor/chat-message/[root]",
dependencies: [extension],
editable: false,
$initialEditorState: () => {
$setChatMessageContent(
initialContent,
filterTransformers($getEditor(), transformers),
);
},
}),
[extension, initialContent, transformers],
);
return (
<LexicalExtensionComposer extension={app} contentEditable={null}>
<ChatMessageContent
content={content}
isStreaming={isStreaming}
transformers={transformers}
className={className}
/>
</LexicalExtensionComposer>
);
}
function ChatMessageContent({
content,
isStreaming,
transformers,
className,
}: {
content: string;
isStreaming: boolean;
transformers: Transformer[];
className?: string;
}) {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const appliedContentRef = useRef(content);
const activeTransformers = useMemo(
() => filterTransformers(editor, transformers),
[editor, transformers],
);
useEffect(() => {
if (appliedContentRef.current === content) {
return;
}
appliedContentRef.current = content;
editor.update(
() => {
$setChatMessageContent(content, activeTransformers);
},
{ tag: SKIP_DOM_SELECTION_TAG },
);
}, [editor, content, activeTransformers]);
if (content === "" && isStreaming) {
return (
<span
data-slot="chat-message-thinking"
className="shimmer text-muted-foreground"
>
{t.chatThinking}
</span>
);
}
return (
<ContentEditable
data-slot="chat-message"
aria-busy={isStreaming || undefined}
className={cn("min-w-0 text-sm outline-none", className)}
/>
);
}
src/components/editor/plugins/content-editable.tsx
import { useEffect } from "react";
import { $getRoot, HISTORY_MERGE_TAG } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
type ContentEditableElementProps,
ContentEditable as LexicalContentEditable,
} from "@lexical/react/LexicalContentEditable";
import { type VariantProps, cva } from "class-variance-authority";
import { cn } from "cn";
import {
type LocalizedText,
useTranslation,
} from "@/components/editor/plugins/i18n-plugin";
const contentEditableVariants = cva(
"min-h-full w-full bg-transparent text-sm outline-none",
{
variants: {
variant: {
default: "px-2.5 py-2",
toolbar: "px-4 py-4",
draggable: "px-12 py-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
const placeholderVariants = cva(
"pointer-events-none absolute text-sm text-muted-foreground select-none",
{
variants: {
variant: {
default: "start-2.5 top-2",
toolbar: "start-4 top-4",
draggable: "start-12 top-4",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function ContentEditable({
variant,
className,
placeholder,
placeholderClassName,
...props
}: VariantProps<typeof contentEditableVariants> &
Omit<ContentEditableElementProps, "editor" | "className"> & {
className?: string;
placeholder?: LocalizedText;
placeholderClassName?: string;
}) {
const [editor] = useLexicalComposerContext();
const { language, dir, t } = useTranslation();
const placeholderText = placeholder?.[language] ?? t.typeSomething;
useEffect(() => {
editor.update(
() => {
$getRoot().setDirection(dir);
},
{ tag: HISTORY_MERGE_TAG },
);
}, [editor, dir]);
return (
<LexicalContentEditable
{...props}
className={cn(contentEditableVariants({ variant }), className)}
aria-placeholder={placeholderText}
placeholder={
<div
className={cn(placeholderVariants({ variant }), placeholderClassName)}
>
{placeholderText}
</div>
}
/>
);
}
src/components/editor/plugins/context-menu-plugin.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getRoot,
$getSelection,
$isDecoratorNode,
$isNodeSelection,
COPY_COMMAND,
CUT_COMMAND,
PASTE_COMMAND,
registerEventListener,
} from "lexical";
import { $isAutoLinkNode, $isLinkNode } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
Clipboard,
ClipboardType,
Copy,
Link2Off,
Scissors,
Trash2,
} from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
export function ContextMenuPlugin() {
const [editor] = useLexicalComposerContext();
const { t, dir } = useTranslation();
const triggerRef = useRef<HTMLDivElement>(null);
const [target, setTarget] = useState<{ key: string; isLink: boolean } | null>(
null,
);
useEffect(() => {
function onContextMenu(event: MouseEvent) {
const trigger = triggerRef.current;
if (trigger === null || !(event.target instanceof Node)) {
return;
}
event.preventDefault();
editor.read(() => {
const node =
$getNearestNodeFromDOMNode(event.target as Node) ?? $getRoot();
setTarget({
key: node.getKey(),
isLink: $isLinkNode(node) || $isLinkNode(node.getParent()),
});
});
trigger.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: event.clientX,
clientY: event.clientY,
}),
);
}
return editor.registerRootListener((rootElement) => {
if (rootElement !== null) {
return registerEventListener(rootElement, "contextmenu", onContextMenu);
}
});
}, [editor]);
const removeLink = useCallback(() => {
if (target === null) {
return;
}
editor.update(() => {
const node = $getNodeByKey(target.key);
const parent = node?.getParent();
const link = $isLinkNode(node)
? node
: $isLinkNode(parent)
? parent
: null;
if (link === null) {
return;
}
if ($isAutoLinkNode(link)) {
link.setIsUnlinked(true);
return;
}
for (const child of link.getChildren()) {
link.insertBefore(child);
}
link.remove();
});
}, [editor, target]);
const pasteFromClipboard = useCallback(
async (plainText: boolean) => {
try {
const data = new DataTransfer();
if (plainText) {
data.setData("text/plain", await navigator.clipboard.readText());
} else {
const [item] = await navigator.clipboard.read();
for (const type of item.types) {
data.setData(type, await (await item.getType(type)).text());
}
}
editor.dispatchCommand(
PASTE_COMMAND,
new ClipboardEvent("paste", { clipboardData: data }),
);
} catch {
window.alert(t.clipboardNotAllowed);
}
},
[editor, t],
);
const deleteNode = useCallback(() => {
editor.update(() => {
const selection = $getSelection();
if ($isNodeSelection(selection)) {
for (const node of selection.getNodes()) {
if ($isDecoratorNode(node)) {
node.remove();
}
}
return;
}
const node = target === null ? null : $getNodeByKey(target.key);
if (node === null) {
return;
}
if ($isDecoratorNode(node)) {
node.remove();
} else {
node.getTopLevelElement()?.remove();
}
});
}, [editor, target]);
return (
<ContextMenu>
<ContextMenuTrigger ref={triggerRef} className="hidden" />
<ContextMenuContent dir={dir} className="w-52">
{target?.isLink && (
<>
<ContextMenuItem onClick={removeLink}>
<Link2Off />
{t.removeLink}
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem
onClick={() => editor.dispatchCommand(CUT_COMMAND, null)}
>
<Scissors />
{t.cut}
</ContextMenuItem>
<ContextMenuItem
onClick={() => editor.dispatchCommand(COPY_COMMAND, null)}
>
<Copy />
{t.copy}
</ContextMenuItem>
<ContextMenuItem onClick={() => pasteFromClipboard(false)}>
<Clipboard />
{t.paste}
</ContextMenuItem>
<ContextMenuItem onClick={() => pasteFromClipboard(true)}>
<ClipboardType />
{t.pasteAsPlainText}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onClick={deleteNode}>
<Trash2 />
{t.deleteNode}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
src/components/editor/plugins/draggable-block-plugin.tsx
import { useEffect, useRef, useState } from "react";
import {
$createParagraphNode,
$createTextNode,
$getNearestNodeFromDOMNode,
$isElementNode,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { DraggableBlockPlugin_EXPERIMENTAL } from "@lexical/react/LexicalDraggableBlockPlugin";
import { cn } from "cn";
import { GripVertical, Plus } from "lucide-react";
import { useLanguage } from "@/components/editor/plugins/i18n-plugin";
const DRAGGABLE_BLOCK_MENU_CLASSNAME = "draggable-block-menu";
function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
}
export function DraggableBlockPlugin() {
const [editor] = useLexicalComposerContext();
const { dir } = useLanguage();
const menuRef = useRef<HTMLDivElement>(null);
const targetLineRef = useRef<HTMLDivElement>(null);
const [draggableElement, setDraggableElement] = useState<HTMLElement | null>(
null,
);
const [anchorElem, setAnchorElem] = useState<HTMLElement | null>(null);
useEffect(() => {
return editor.registerRootListener((rootElement) => {
setAnchorElem(rootElement?.parentElement ?? null);
});
}, [editor]);
if (!anchorElem) {
return null;
}
function insertSlashBlock(event: React.MouseEvent) {
if (!draggableElement) {
return;
}
editor.update(() => {
const node = $getNearestNodeFromDOMNode(draggableElement);
if (!node) {
return;
}
if ($isElementNode(node)) {
const textContent = node.getTextContent();
const needsSpace = textContent !== "" && !/\s$/.test(textContent);
node.selectEnd().insertText(needsSpace ? " /" : "/");
return;
}
const paragraph = $createParagraphNode();
const textNode = $createTextNode("/");
paragraph.append(textNode);
if (event.altKey || event.ctrlKey) {
node.insertBefore(paragraph);
} else {
node.insertAfter(paragraph);
}
textNode.selectEnd();
});
editor.focus();
}
return (
<DraggableBlockPlugin_EXPERIMENTAL
anchorElem={anchorElem}
menuRef={menuRef}
targetLineRef={targetLineRef}
menuComponent={
<div
ref={menuRef}
className={cn(
DRAGGABLE_BLOCK_MENU_CLASSNAME,
"absolute top-0 flex cursor-grab items-center rounded-md p-px opacity-0 will-change-transform active:cursor-grabbing",
dir === "rtl" ? "right-2" : "left-0",
)}
>
<button
type="button"
title="Click to open block menu"
className="flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground"
onMouseDown={(event) => event.preventDefault()}
onClick={insertSlashBlock}
>
<Plus className="size-4" />
</button>
<div className="flex size-5 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground">
<GripVertical className="size-4" />
</div>
</div>
}
targetLineComponent={
<div
ref={targetLineRef}
className="pointer-events-none absolute top-0 left-0 h-1 rounded-full bg-primary/50 opacity-0 will-change-transform"
/>
}
isOnMenu={isOnMenu}
onElementChanged={setDraggableElement}
/>
);
}
src/components/editor/plugins/emoji-picker-plugin.tsx
import { useCallback, useMemo, useState } from "react";
import type { TextNode } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from "@lexical/react/LexicalTypeaheadMenuPlugin";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import compactEmojis from "emojibase-data/en/compact.json";
import {
$createEmojiNode,
EMOJI_CLASS_NAME,
} from "@/components/editor/nodes/emoji-node";
import { useLanguage } from "@/components/editor/plugins/i18n-plugin";
import { Command, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const MAX_SUGGESTIONS = 8;
const EXCLUDED_GROUPS = new Set([2]);
type EmojiEntry = {
emoji: string;
name: string;
keywords: string[];
hexcode: string;
};
const EMOJI_ENTRIES: EmojiEntry[] = compactEmojis
.filter(
(entry) => entry.group !== undefined && !EXCLUDED_GROUPS.has(entry.group),
)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((entry) => ({
emoji: entry.unicode,
name: entry.label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, ""),
keywords: entry.tags ?? [],
hexcode: entry.hexcode,
}));
function normalize(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[_\s]+/g, "");
}
function searchEmojis(query: string): EmojiEntry[] {
const q = normalize(query);
if (!q) {
return [];
}
const startsWith: EmojiEntry[] = [];
const includes: EmojiEntry[] = [];
for (const entry of EMOJI_ENTRIES) {
const name = normalize(entry.name);
if (name.startsWith(q)) {
startsWith.push(entry);
continue;
}
const keywordMatch = entry.keywords.some((keyword) =>
normalize(keyword).startsWith(q),
);
if (keywordMatch) {
startsWith.push(entry);
continue;
}
if (
name.includes(q) ||
entry.keywords.some((keyword) => normalize(keyword).includes(q))
) {
includes.push(entry);
}
}
return [...startsWith, ...includes];
}
class EmojiTypeaheadOption extends MenuOption {
entry: EmojiEntry;
constructor(entry: EmojiEntry) {
super(entry.hexcode);
this.entry = entry;
}
}
export function EmojiPickerPlugin() {
const [editor] = useLexicalComposerContext();
const { dir } = useLanguage();
const [queryString, setQueryString] = useState<string | null>(null);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(":", {
minLength: 0,
});
const options = useMemo(() => {
if (queryString === null) {
return [];
}
const matches =
queryString === "" ? EMOJI_ENTRIES : searchEmojis(queryString);
return matches
.slice(0, MAX_SUGGESTIONS)
.map((entry) => new EmojiTypeaheadOption(entry));
}, [queryString]);
const onSelectOption = useCallback(
(
option: EmojiTypeaheadOption,
nodeToReplace: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const emojiNode = $createEmojiNode(
EMOJI_CLASS_NAME,
option.entry.emoji,
);
if (nodeToReplace) {
nodeToReplace.replace(emojiNode);
}
emojiNode.selectNext();
closeMenu();
});
},
[editor],
);
return (
<LexicalTypeaheadMenuPlugin<EmojiTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && options.length > 0 ? (
<Popover open>
<PopoverPrimitive.Portal
dir={dir}
container={anchorElementRef.current}
>
<PopoverTrigger
nativeButton={false}
render={
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-0"
/>
}
/>
<PopoverContent
dir={dir}
side="bottom"
align="start"
sideOffset={6}
initialFocus={false}
className="w-auto overflow-hidden p-0"
>
<Command
value={
selectedIndex === null
? ""
: (options[selectedIndex]?.entry.hexcode ?? "")
}
onValueChange={(nextValue) => {
const index = options.findIndex(
(option) => option.entry.hexcode === nextValue,
);
if (index >= 0) {
setHighlightedIndex(index);
}
}}
shouldFilter={false}
>
<CommandList className="max-h-64 w-56">
{options.map((option) => (
<CommandItem
key={option.key}
ref={option.setRefElement}
value={option.entry.hexcode}
onSelect={() => selectOptionAndCleanUp(option)}
>
<span className="text-base leading-none">
{option.entry.emoji}
</span>
<span className="truncate text-muted-foreground">
:{option.entry.name}:
</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
) : null
}
/>
);
}
src/components/editor/plugins/floating/ai-editor-plugin.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
$createParagraphNode,
$getNodeByKey,
$getSelection,
$isRangeSelection,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_HIGH,
KEY_ESCAPE_COMMAND,
type RangeSelection,
registerEventListener,
} from "lexical";
import {
$generateNodesFromMarkdownString,
TRANSFORMERS,
type Transformer,
} from "@lexical/markdown";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { createDOMRange } from "@lexical/selection";
import { mergeRegister } from "@lexical/utils";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { Command as CommandPrimitive } from "cmdk";
import {
CornerDownRight,
FoldVertical,
Lightbulb,
ListCollapse,
type LucideIcon,
PenLine,
RefreshCw,
Replace,
Sparkles,
SpellCheck,
UnfoldVertical,
WandSparkles,
X,
} from "lucide-react";
import {
$getAiContext,
AI_COMMANDS,
type AiCommandId,
type AiContext,
type AiMode,
type AiRequest,
OPEN_AI_EDITOR_COMMAND,
} from "@/components/editor/extensions/ai";
import {
hideFloatingAnchor,
setFloatingAnchorRect,
} from "@/components/editor/plugins/floating/floating-utils";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Command, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const COMMAND_ICONS: Record<Exclude<AiCommandId, "custom">, LucideIcon> = {
improve: WandSparkles,
longer: UnfoldVertical,
shorter: FoldVertical,
fix: SpellCheck,
continue: PenLine,
summarize: ListCollapse,
brainstorm: Lightbulb,
};
type Phase = "closed" | "prompt" | "generating";
export interface AiEditorPluginProps {
output: string;
isLoading: boolean;
error?: Error | string | null;
onGenerate: (request: AiRequest) => void;
onStop?: () => void;
transformers?: Transformer[];
}
const EMPTY_CONTEXT: AiContext = { text: "", before: "", after: "" };
function preventDefault(event: React.MouseEvent<HTMLElement>): void {
event.preventDefault();
}
function $isLiveSelection(selection: RangeSelection): boolean {
return (
$getNodeByKey(selection.anchor.key) !== null &&
$getNodeByKey(selection.focus.key) !== null
);
}
export function AiEditorPlugin({
output,
isLoading,
error,
onGenerate,
onStop,
transformers = TRANSFORMERS,
}: AiEditorPluginProps) {
const [editor] = useLexicalComposerContext();
const { t, dir } = useTranslation();
const activeTransformers = useMemo(
() =>
transformers.filter(
(transformer) =>
!("dependencies" in transformer) ||
editor.hasNodes(transformer.dependencies),
),
[editor, transformers],
);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const selectionRef = useRef<RangeSelection | null>(null);
const lastRequestRef = useRef<AiRequest | null>(null);
const [phase, setPhase] = useState<Phase>("closed");
const [context, setContext] = useState<AiContext>(EMPTY_CONTEXT);
const [mode, setMode] = useState<AiMode>("transform");
const [query, setQuery] = useState("");
const [baseline, setBaseline] = useState<string | null>(null);
const isOpen = phase !== "closed";
const errorMessage =
error == null ? null : typeof error === "string" ? error : error.message;
const visibleOutput = isLoading && output === baseline ? "" : output;
const status =
phase !== "generating"
? phase
: isLoading
? "loading"
: errorMessage !== null
? "error"
: "result";
const $getSavedSelection = useCallback((): RangeSelection | null => {
const saved = selectionRef.current;
if (saved !== null && $isLiveSelection(saved)) {
return saved;
}
const current = $getSelection();
if ($isRangeSelection(current)) {
selectionRef.current = current.clone();
return selectionRef.current;
}
return null;
}, []);
const positionToSelection = useCallback(() => {
const triggerElem = triggerRef.current;
if (triggerElem === null) {
return;
}
const rect = isOpen
? editor.read(() => {
const selection = $getSavedSelection();
if (selection === null) {
return null;
}
const range = createDOMRange(
editor,
selection.anchor.getNode(),
selection.anchor.offset,
selection.focus.getNode(),
selection.focus.offset,
);
const rangeRect = range?.getBoundingClientRect() ?? null;
if (rangeRect && (rangeRect.width > 0 || rangeRect.height > 0)) {
return rangeRect;
}
const block = selection.anchor.getNode().getTopLevelElement();
const element = block ? editor.getElementByKey(block.getKey()) : null;
return element?.getBoundingClientRect() ?? rangeRect;
})
: null;
if (rect === null) {
hideFloatingAnchor(triggerElem);
} else {
setFloatingAnchorRect(triggerElem, rect);
}
}, [editor, isOpen, $getSavedSelection]);
const close = useCallback(() => {
if (isLoading) {
onStop?.();
}
selectionRef.current = null;
lastRequestRef.current = null;
setBaseline(null);
setPhase("closed");
setQuery("");
}, [isLoading, onStop]);
const generate = (request: AiRequest) => {
lastRequestRef.current = request;
setBaseline(output);
setPhase("generating");
onGenerate(request);
};
const retry = () => {
const last = lastRequestRef.current;
if (last) {
generate(last);
}
};
const $restoreSelection = (): RangeSelection | null => {
const saved = $getSavedSelection();
if (saved === null) {
return null;
}
const selection = saved.clone();
$setSelection(selection);
return selection;
};
const $insertOutput = (selection: RangeSelection) => {
const nodes = $generateNodesFromMarkdownString(output, activeTransformers);
if (nodes.length > 0) {
selection.insertNodes(nodes);
}
};
const insertAtSelection = () => {
editor.update(() => {
const selection = $restoreSelection();
if ($isRangeSelection(selection)) {
$insertOutput(selection);
}
});
close();
requestAnimationFrame(() => editor.focus());
};
const insertBelow = () => {
editor.update(() => {
const selection = $restoreSelection();
if (!$isRangeSelection(selection)) {
return;
}
const block = selection.focus.getNode().getTopLevelElementOrThrow();
const paragraph = $createParagraphNode();
block.insertAfter(paragraph);
$insertOutput(paragraph.select());
});
close();
requestAnimationFrame(() => editor.focus());
};
useEffect(() => {
return mergeRegister(
editor.registerCommand(
OPEN_AI_EDITOR_COMMAND,
() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
if (isLoading) {
onStop?.();
}
selectionRef.current = selection.clone();
lastRequestRef.current = null;
setBaseline(null);
setMode(selection.isCollapsed() ? "insert" : "transform");
setContext($getAiContext(selection, activeTransformers));
setQuery("");
setPhase("prompt");
return true;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
CLICK_COMMAND,
(event) => {
if (isOpen && !contentRef.current?.contains(event.target as Node)) {
close();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
if (isOpen) {
close();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
);
}, [editor, isOpen, isLoading, onStop, close, activeTransformers]);
useEffect(() => {
positionToSelection();
if (!isOpen) {
return;
}
return mergeRegister(
registerEventListener(window, "resize", positionToSelection),
registerEventListener(document, "scroll", positionToSelection, true),
);
}, [isOpen, positionToSelection]);
return (
<Popover open={isOpen}>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir={dir}
ref={contentRef}
side="bottom"
align="start"
sideOffset={8}
initialFocus={false}
finalFocus={false}
aria-label={t.askAi}
className="w-80 min-w-0 flex-col gap-0 p-0"
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
close();
}
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
close();
}
}}
>
{status === "prompt" && (
<Command className="rounded-lg">
<div className="flex items-center gap-2 border-b px-3">
<Sparkles className="size-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
ref={(elem) => {
if (elem) {
elem.focus();
}
}}
value={query}
onValueChange={setQuery}
placeholder={
mode === "insert"
? t.askAiWritePlaceholder
: t.askAiPlaceholder
}
aria-label={t.askAi}
className="h-9 w-full bg-transparent text-sm outline-hidden placeholder:text-muted-foreground"
/>
</div>
<CommandList className="p-1">
{query.trim() !== "" && (
<CommandItem
forceMount
value={`custom ${query}`}
onSelect={() => {
generate({
mode,
command: "custom",
prompt: query.trim(),
...context,
});
}}
>
<Sparkles />
<span className="truncate">
{t.askAi}: {query}
</span>
</CommandItem>
)}
{AI_COMMANDS.filter((command) => command.mode === mode).map(
({ id, labelKey, prompt }) => {
const Icon = COMMAND_ICONS[id];
return (
<CommandItem
key={id}
value={t[labelKey]}
onSelect={() => {
generate({
mode,
command: id,
prompt,
...context,
});
}}
>
<Icon />
<span className="truncate">{t[labelKey]}</span>
</CommandItem>
);
},
)}
</CommandList>
</Command>
)}
{status !== "prompt" && status !== "closed" && (
<div className="flex flex-col gap-2 p-2">
<div className="max-h-48 min-h-9 overflow-y-auto rounded-md bg-muted/50 px-2.5 py-2 text-sm whitespace-pre-wrap">
{status === "error" ? (
<span className="text-destructive">{errorMessage}</span>
) : (
<>
{visibleOutput}
{status === "loading" && (
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
{visibleOutput === "" && t.aiGenerating}
<Spinner className="size-3.5" />
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-1">
{mode === "insert" ? (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="default"
size="sm"
disabled={status !== "result"}
onMouseDown={preventDefault}
onClick={insertAtSelection}
>
<CornerDownRight />
{t.aiInsert}
</Button>
}
/>
<TooltipContent>{t.aiInsert}</TooltipContent>
</Tooltip>
) : (
<>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="default"
size="sm"
disabled={status !== "result"}
onMouseDown={preventDefault}
onClick={insertAtSelection}
>
<Replace />
{t.aiReplace}
</Button>
}
/>
<TooltipContent>{t.aiReplace}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="sm"
disabled={status !== "result"}
onMouseDown={preventDefault}
onClick={insertBelow}
>
<CornerDownRight />
{t.aiInsertBelow}
</Button>
}
/>
<TooltipContent>{t.aiInsertBelow}</TooltipContent>
</Tooltip>
</>
)}
<div className="ms-auto flex items-center">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.aiTryAgain}
disabled={status === "loading"}
onMouseDown={preventDefault}
onClick={retry}
>
<RefreshCw />
</Button>
}
/>
<TooltipContent>{t.aiTryAgain}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.aiDiscard}
onMouseDown={preventDefault}
onClick={close}
>
<X />
</Button>
}
/>
<TooltipContent>{t.aiDiscard}</TooltipContent>
</Tooltip>
</div>
</div>
</div>
)}
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
src/components/editor/plugins/floating/comment-plugin.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
$getNodeByKey,
$getSelection,
$isRangeSelection,
type LexicalEditor,
type RangeSelection,
registerEventListener,
} from "lexical";
import { $isMarkNode, $wrapSelectionInMarkNode } from "@lexical/mark";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useExtensionDependency } from "@lexical/react/useExtensionComponent";
import { useExtensionSignalValue } from "@lexical/react/useExtensionSignalValue";
import { createDOMRange, createRectsFromDOMRange } from "@lexical/selection";
import { mergeRegister } from "@lexical/utils";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { cn } from "cn";
import { MessageSquarePlus, SendHorizontal, Trash2 } from "lucide-react";
import {
CLOSE_COMMENT_INPUT_COMMAND,
type Comment,
CommentExtension,
type CommentState,
INSERT_INLINE_COMMAND,
type Thread,
addComment,
createComment,
createThread,
deleteCommentOrThread,
removeThreadMarks,
} from "@/components/editor/extensions/comment";
import {
hideFloatingAnchor,
setFloatingAnchorRect,
} from "@/components/editor/plugins/floating/floating-utils";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
} from "@/components/ui/sidebar";
import { Textarea } from "@/components/ui/textarea";
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
let current = element?.parentElement ?? null;
while (current) {
const { overflowY } = getComputedStyle(current);
if (overflowY === "auto" || overflowY === "scroll") {
return current;
}
current = current.parentElement;
}
return null;
}
function AddCommentButton({
editor,
anchorKey,
}: {
editor: LexicalEditor;
anchorKey: string;
}) {
const { t, dir } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const updatePosition = useCallback(() => {
const triggerElem = triggerRef.current;
const rootElement = editor.getRootElement();
const anchorElem = editor.getElementByKey(anchorKey);
if (triggerElem === null) {
return;
}
if (rootElement === null || anchorElem === null) {
hideFloatingAnchor(triggerElem);
return;
}
const rootRect = rootElement.getBoundingClientRect();
const anchorRect = anchorElem.getBoundingClientRect();
const x = dir === "rtl" ? rootRect.left + 12 : rootRect.right - 12;
setFloatingAnchorRect(
triggerElem,
new DOMRect(x, anchorRect.top, 0, anchorRect.height),
);
}, [editor, anchorKey, dir]);
useEffect(() => {
updatePosition();
return mergeRegister(
registerEventListener(window, "resize", updatePosition),
registerEventListener(document, "scroll", updatePosition, true),
editor.registerUpdateListener(() => {
updatePosition();
}),
);
}, [editor, updatePosition]);
return (
<Popover open>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir={dir}
side="top"
align="end"
sideOffset={4}
initialFocus={false}
finalFocus={false}
className="w-auto min-w-0 rounded-full p-0.5"
>
<Button
variant="ghost"
size="icon-sm"
aria-label={t.addComment}
className="rounded-full"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined);
}}
>
<MessageSquarePlus />
</Button>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
function CommentInputBox({
editor,
state,
}: {
editor: LexicalEditor;
state: CommentState;
}) {
const { t, dir } = useTranslation();
const [content, setContent] = useState("");
const triggerRef = useRef<HTMLButtonElement | null>(null);
const selectionRef = useRef<RangeSelection | null>(null);
const highlightsRef = useRef<HTMLDivElement | null>(null);
const updateLocation = useCallback(() => {
editor.read("latest", () => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
selectionRef.current = selection.clone();
const range = createDOMRange(
editor,
selection.anchor.getNode(),
selection.anchor.offset,
selection.focus.getNode(),
selection.focus.offset,
);
const triggerElem = triggerRef.current;
const rootElement = editor.getRootElement();
const parent = rootElement?.parentElement;
if (range === null || triggerElem === null || !rootElement || !parent) {
return;
}
setFloatingAnchorRect(triggerElem, range.getBoundingClientRect());
let container = highlightsRef.current;
if (container === null) {
container = rootElement.ownerDocument.createElement("div");
container.style.position = "absolute";
container.style.top = "0";
container.style.left = "0";
container.style.pointerEvents = "none";
parent.appendChild(container);
highlightsRef.current = container;
}
container.innerHTML = "";
const containerRect = container.getBoundingClientRect();
for (const rect of createRectsFromDOMRange(editor, range)) {
const span = rootElement.ownerDocument.createElement("span");
span.className = "editor-comment-highlight";
span.style.top = `${rect.top - containerRect.top}px`;
span.style.left = `${rect.left - containerRect.left}px`;
span.style.width = `${rect.width}px`;
span.style.height = `${rect.height}px`;
container.appendChild(span);
}
});
}, [editor]);
useEffect(() => {
updateLocation();
return mergeRegister(
registerEventListener(window, "resize", updateLocation),
() => {
highlightsRef.current?.remove();
highlightsRef.current = null;
},
);
}, [updateLocation]);
const cancel = useCallback(() => {
editor.update(() => {
const selection = $getSelection();
if (selection !== null) {
selection.dirty = true;
}
});
editor.dispatchCommand(CLOSE_COMMENT_INPUT_COMMAND, undefined);
}, [editor]);
const submit = useCallback(() => {
const text = content.trim();
if (text === "") {
return;
}
let quote = editor.read(
"latest",
() => selectionRef.current?.getTextContent() ?? "",
);
if (quote.length > 100) {
quote = quote.slice(0, 99) + "…";
}
const thread = createThread(quote, [createComment(text, t.you)]);
addComment(state, thread);
editor.update(() => {
const selection = selectionRef.current;
if ($isRangeSelection(selection)) {
$wrapSelectionInMarkNode(selection, selection.isBackward(), thread.id);
}
});
selectionRef.current = null;
editor.dispatchCommand(CLOSE_COMMENT_INPUT_COMMAND, undefined);
}, [content, editor, state, t]);
return (
<Popover open>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir={dir}
side="bottom"
align="center"
sideOffset={12}
finalFocus={false}
className="w-72 gap-2 p-3"
>
<Textarea
autoFocus
rows={3}
placeholder={t.typeComment}
value={content}
className="resize-none"
onChange={(event) => setContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
cancel();
} else if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={cancel}>
{t.cancel}
</Button>
<Button size="sm" disabled={content.trim() === ""} onClick={submit}>
{t.comment}
</Button>
</div>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
export function CommentPlugin() {
const [editor] = useLexicalComposerContext();
const state = useExtensionDependency(CommentExtension).output;
const showCommentInput = useExtensionSignalValue(
CommentExtension,
"showCommentInput",
);
const activeAnchorKey = useExtensionSignalValue(
CommentExtension,
"activeAnchorKey",
);
if (showCommentInput) {
return <CommentInputBox editor={editor} state={state} />;
}
if (activeAnchorKey != null) {
return <AddCommentButton editor={editor} anchorKey={activeAnchorKey} />;
}
return null;
}
function DeleteButton({
title,
onDelete,
className,
}: {
title: string;
onDelete: () => void;
className?: string;
}) {
const { t, dir } = useTranslation();
return (
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label={t.delete}
className={cn("text-muted-foreground", className)}
onClick={(event) => event.stopPropagation()}
>
<Trash2 />
</Button>
}
/>
<AlertDialogContent
dir={dir}
onClick={(event) => event.stopPropagation()}
>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.cancel}</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>{t.delete}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function CommentRow({
comment,
thread,
state,
rtf,
now,
}: {
comment: Comment;
thread: Thread;
state: CommentState;
rtf: Intl.RelativeTimeFormat;
now: number;
}) {
const { t } = useTranslation();
const minutes = Math.round((comment.timeStamp - now) / 60000);
return (
<div className="group/comment">
<div className="flex items-center gap-1 text-xs">
<span className="font-medium">{comment.author}</span>
<span className="text-muted-foreground">
{minutes > -1 ? t.justNow : rtf.format(minutes, "minute")}
</span>
{!comment.deleted && (
<DeleteButton
title={t.deleteCommentConfirm}
className="ms-auto opacity-0 group-hover/comment:opacity-100"
onDelete={() => {
const info = deleteCommentOrThread(state, comment, thread);
if (info !== null) {
addComment(state, info.markedComment, thread, info.index);
}
}}
/>
)}
</div>
{comment.deleted ? (
<p className="text-xs text-muted-foreground italic">
{t.deletedComment}
</p>
) : (
<p className="text-sm break-words">{comment.content}</p>
)}
</div>
);
}
function ReplyComposer({
thread,
state,
}: {
thread: Thread;
state: CommentState;
}) {
const { t } = useTranslation();
const [content, setContent] = useState("");
const submit = () => {
const text = content.trim();
if (text === "") {
return;
}
addComment(state, createComment(text, t.you), thread);
setContent("");
};
return (
<InputGroup
className="mt-2 h-8 rounded-md"
onClick={(event) => event.stopPropagation()}
>
<InputGroupInput
value={content}
placeholder={t.replyToComment}
className="text-xs"
onChange={(event) => setContent(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
submit();
}
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
aria-label={t.comment}
disabled={content.trim() === ""}
onClick={submit}
>
<SendHorizontal className="rtl:rotate-180" />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
);
}
function ThreadCard({
editor,
thread,
state,
active,
rtf,
now,
}: {
editor: LexicalEditor;
thread: Thread;
state: CommentState;
active: boolean;
rtf: Intl.RelativeTimeFormat;
now: number;
}) {
const { t } = useTranslation();
const handleClickThread = () => {
const markNodeKeys = state.markNodeMap.get(thread.id);
if (markNodeKeys === undefined || active) {
return;
}
const activeElement = document.activeElement;
const markNodeKey = Array.from(markNodeKeys)[0];
editor.update(
() => {
const markNode = $getNodeByKey(markNodeKey);
if ($isMarkNode(markNode)) {
markNode.selectStart();
}
},
{
onUpdate() {
const markElem = editor.getElementByKey(markNodeKey);
const scroller = getScrollParent(editor.getRootElement());
if (markElem !== null && scroller !== null) {
const markRect = markElem.getBoundingClientRect();
const scrollerRect = scroller.getBoundingClientRect();
if (
markRect.top < scrollerRect.top ||
markRect.bottom > scrollerRect.bottom
) {
scroller.scrollTo({
behavior: "smooth",
top:
markRect.top -
scrollerRect.top +
scroller.scrollTop -
scroller.clientHeight / 2,
});
}
}
if (activeElement !== null) {
(activeElement as HTMLElement).focus();
}
},
},
);
};
return (
<div
onClick={handleClickThread}
className={cn(
"rounded-md border bg-background p-2 text-start",
state.markNodeMap.has(thread.id) && "cursor-pointer",
active && "border-ring ring-1 ring-ring",
)}
>
<div className="flex items-start justify-between gap-1">
<blockquote className="line-clamp-2 border-s-2 ps-2 text-xs text-muted-foreground italic">
{thread.quote}
</blockquote>
<DeleteButton
title={t.deleteThreadConfirm}
className="-mt-0.5 shrink-0"
onDelete={() => {
deleteCommentOrThread(state, thread);
removeThreadMarks(editor, state, thread.id);
}}
/>
</div>
<div className="mt-2 flex flex-col gap-2">
{thread.comments.map((comment) => (
<CommentRow
key={comment.id}
comment={comment}
thread={thread}
state={state}
rtf={rtf}
now={now}
/>
))}
</div>
<ReplyComposer thread={thread} state={state} />
</div>
);
}
export function CommentsPanel() {
const [editor] = useLexicalComposerContext();
const { t, language } = useTranslation();
const state = useExtensionDependency(CommentExtension).output;
const comments = useExtensionSignalValue(CommentExtension, "comments");
const activeIDs = useExtensionSignalValue(CommentExtension, "activeIDs");
const [now, setNow] = useState(() => Date.now());
const rtf = useMemo(
() =>
new Intl.RelativeTimeFormat(language, {
numeric: "auto",
style: "short",
}),
[language],
);
useEffect(() => {
const id = setInterval(() => {
setNow(Date.now());
}, 10000);
return () => clearInterval(id);
}, []);
return (
<SidebarGroup>
<SidebarGroupLabel>{t.comments}</SidebarGroupLabel>
<SidebarGroupContent className="px-2">
{comments.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{t.noComments}
</p>
) : (
<div className="flex flex-col gap-2">
{comments.map((commentOrThread) =>
commentOrThread.type === "thread" ? (
<ThreadCard
key={commentOrThread.id}
editor={editor}
thread={commentOrThread}
state={state}
active={activeIDs.includes(commentOrThread.id)}
rtf={rtf}
now={now}
/>
) : null,
)}
</div>
)}
</SidebarGroupContent>
</SidebarGroup>
);
}
src/components/editor/plugins/decorator/find-replace-panel.tsx
import { useRef } from "react";
import {
CONTROL_OR_META,
configExtension,
defineExtension,
isExactShortcutMatch,
} from "lexical";
import { ReactExtension } from "@lexical/react/ReactExtension";
import type { DecoratorComponentProps } from "@lexical/react/ReactPluginHostExtension";
import { useExtensionSignalValue } from "@lexical/react/useExtensionSignalValue";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { cn } from "cn";
import {
CaseSensitive,
ChevronDown,
ChevronUp,
Regex,
Replace,
ReplaceAll,
X,
} from "lucide-react";
import { createPortal } from "react-dom";
import {
CLOSE_FIND_REPLACE_COMMAND,
FIND_NEXT_COMMAND,
FIND_PREV_COMMAND,
FindReplaceExtension,
REPLACE_ALL_COMMAND,
REPLACE_CURRENT_COMMAND,
SET_REPLACE_TERM_COMMAND,
SET_SEARCH_TERM_COMMAND,
TOGGLE_CASE_SENSITIVE_COMMAND,
TOGGLE_REGEX_COMMAND,
} from "@/components/editor/extensions/find-replace";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function FindReplacePanel({ context }: DecoratorComponentProps) {
const [editor] = context;
const { t, dir } = useTranslation();
const isEditable = useLexicalEditable();
const isOpen = useExtensionSignalValue(FindReplaceExtension, "isOpen");
const searchTerm = useExtensionSignalValue(
FindReplaceExtension,
"searchTerm",
);
const replaceTerm = useExtensionSignalValue(
FindReplaceExtension,
"replaceTerm",
);
const caseSensitive = useExtensionSignalValue(
FindReplaceExtension,
"caseSensitive",
);
const isRegex = useExtensionSignalValue(FindReplaceExtension, "isRegex");
const matches = useExtensionSignalValue(FindReplaceExtension, "matches");
const effectiveIndex = useExtensionSignalValue(
FindReplaceExtension,
"effectiveIndex",
);
const regexError = useExtensionSignalValue(
FindReplaceExtension,
"regexError",
);
const findInputRef = useRef<HTMLInputElement>(null);
if (!isOpen) {
return null;
}
const goToNext = () => editor.dispatchCommand(FIND_NEXT_COMMAND, undefined);
const goToPrevious = () =>
editor.dispatchCommand(FIND_PREV_COMMAND, undefined);
const close = () => {
editor.dispatchCommand(CLOSE_FIND_REPLACE_COMMAND, undefined);
editor.focus();
};
const handlePanelKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (
isExactShortcutMatch(event, "g", CONTROL_OR_META) ||
isExactShortcutMatch(event, "g", { ...CONTROL_OR_META, shiftKey: true })
) {
event.preventDefault();
if (event.shiftKey) {
goToPrevious();
} else {
goToNext();
}
return;
}
if (
isExactShortcutMatch(event, "f", CONTROL_OR_META) ||
isExactShortcutMatch(event, "f", { altKey: true, metaKey: true })
) {
event.preventDefault();
findInputRef.current?.focus();
}
};
const canNavigate = matches.length > 0;
const canReplace = isEditable && matches.length > 0;
const statusLabel = regexError
? t.invalidRegex
: searchTerm === ""
? null
: matches.length === 0
? t.noResults
: t.matchCount
.replace("{current}", String(effectiveIndex + 1))
.replace("{total}", String(matches.length));
const rootElement = editor.getRootElement();
const portalTarget =
rootElement?.parentElement ?? rootElement?.ownerDocument.body ?? null;
if (!portalTarget) {
return null;
}
return createPortal(
<div
role="dialog"
aria-label={t.findAndReplace}
dir={dir}
onKeyDown={handlePanelKeyDown}
className="absolute end-2 top-2 z-50 flex w-80 flex-col gap-2 rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
>
<div className="flex items-center justify-between gap-2">
<span className="ps-1 text-sm font-medium">{t.findAndReplace}</span>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label={t.closeFindReplace}
onClick={close}
>
<X />
</Button>
}
/>
<TooltipContent>{t.closeFindReplace}</TooltipContent>
</Tooltip>
</div>
<InputGroup>
<InputGroupInput
ref={findInputRef}
autoFocus
placeholder={t.find}
aria-label={t.find}
aria-invalid={regexError || undefined}
value={searchTerm}
onChange={(event) => {
editor.dispatchCommand(SET_SEARCH_TERM_COMMAND, event.target.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (event.shiftKey) {
goToPrevious();
} else {
goToNext();
}
}
}}
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
aria-label={t.matchCase}
aria-pressed={caseSensitive}
className={cn(
caseSensitive && "bg-accent text-accent-foreground",
)}
onClick={() => {
editor.dispatchCommand(
TOGGLE_CASE_SENSITIVE_COMMAND,
undefined,
);
}}
>
<CaseSensitive />
</InputGroupButton>
}
/>
<TooltipContent>{t.matchCase}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
aria-label={t.useRegex}
aria-pressed={isRegex}
className={cn(isRegex && "bg-accent text-accent-foreground")}
onClick={() => {
editor.dispatchCommand(TOGGLE_REGEX_COMMAND, undefined);
}}
>
<Regex />
</InputGroupButton>
}
/>
<TooltipContent>{t.useRegex}</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupInput
placeholder={t.replace}
aria-label={t.replace}
disabled={!isEditable}
value={replaceTerm}
onChange={(event) => {
editor.dispatchCommand(
SET_REPLACE_TERM_COMMAND,
event.target.value,
);
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
editor.dispatchCommand(REPLACE_CURRENT_COMMAND, undefined);
}
}}
/>
<InputGroupAddon align="inline-end">
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
aria-label={t.replace}
disabled={!canReplace}
onClick={() => {
editor.dispatchCommand(REPLACE_CURRENT_COMMAND, undefined);
}}
>
<Replace className="rtl:-scale-x-100" />
</InputGroupButton>
}
/>
<TooltipContent>{t.replace}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<InputGroupButton
size="icon-xs"
aria-label={t.replaceAll}
disabled={!canReplace}
onClick={() => {
editor.dispatchCommand(REPLACE_ALL_COMMAND, undefined);
}}
>
<ReplaceAll className="rtl:-scale-x-100" />
</InputGroupButton>
}
/>
<TooltipContent>{t.replaceAll}</TooltipContent>
</Tooltip>
</InputGroupAddon>
</InputGroup>
<div className="flex items-center justify-between gap-2">
<span
aria-live="polite"
className={cn(
"ps-1 text-xs whitespace-nowrap tabular-nums",
regexError ? "text-destructive" : "text-muted-foreground",
)}
>
{statusLabel}
</span>
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.previousMatch}
disabled={!canNavigate}
onClick={goToPrevious}
>
<ChevronUp />
</Button>
}
/>
<TooltipContent>{t.previousMatch}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.nextMatch}
disabled={!canNavigate}
onClick={goToNext}
>
<ChevronDown />
</Button>
}
/>
<TooltipContent>{t.nextMatch}</TooltipContent>
</Tooltip>
</ButtonGroup>
</div>
</div>,
portalTarget,
);
}
export const ReactFindReplaceExtension = defineExtension({
name: "@shadcn-editor/react-find-replace",
dependencies: [
FindReplaceExtension,
configExtension(ReactExtension, {
decorators: [FindReplacePanel],
}),
],
});
src/components/editor/plugins/floating/floating-toolbar-plugin.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
$getSelection,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_LOW,
type LexicalEditor,
SELECTION_CHANGE_COMMAND,
getDOMSelection,
getDOMSelectionPoints,
isDOMDocumentNode,
isDOMShadowRoot,
registerEventListener,
registerEventListeners,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { mergeRegister } from "@lexical/utils";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { $getSelectedNode } from "@/components/editor/extensions/format-state";
import {
getDOMRangeRect,
hideFloatingAnchor,
setFloatingAnchorRect,
} from "@/components/editor/plugins/floating/floating-utils";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { TextFormatToolbarPlugin } from "@/components/editor/plugins/toolbar/text-format-toolbar-plugin";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
function FloatingToolbar({
editor,
children,
ref,
}: {
editor: LexicalEditor;
children: React.ReactNode;
ref?: React.Ref<HTMLDivElement | null>;
}) {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const { t, dir } = useTranslation();
const setPopupRefs = useCallback(
(elem: HTMLDivElement | null) => {
popupRef.current = elem;
if (typeof ref === "function") {
ref(elem);
} else if (ref) {
ref.current = elem;
}
},
[ref],
);
useEffect(() => {
function mouseMoveListener(e: MouseEvent) {
const popupElem = popupRef.current;
if (popupElem && (e.buttons === 1 || e.buttons === 3)) {
if (popupElem.style.pointerEvents !== "none") {
const popupRoot = popupElem.getRootNode();
const elementUnderMouse =
isDOMDocumentNode(popupRoot) || isDOMShadowRoot(popupRoot)
? popupRoot.elementFromPoint(e.clientX, e.clientY)
: null;
if (!popupElem.contains(elementUnderMouse)) {
popupElem.style.pointerEvents = "none";
}
}
}
}
function mouseUpListener() {
const popupElem = popupRef.current;
if (popupElem && popupElem.style.pointerEvents !== "auto") {
popupElem.style.pointerEvents = "auto";
}
}
return registerEventListeners(document, {
mousemove: mouseMoveListener,
mouseup: mouseUpListener,
});
}, []);
const $updateAnchorPosition = useCallback(() => {
const triggerElem = triggerRef.current;
if (triggerElem === null) {
return;
}
const selection = $getSelection();
const nativeSelection = getDOMSelection(editor._window);
const rootElement = editor.getRootElement();
let targetRect: DOMRect | null = null;
if (
$isRangeSelection(selection) &&
nativeSelection !== null &&
rootElement !== null
) {
const points = getDOMSelectionPoints(nativeSelection, rootElement);
const pointsCollapsed =
points.anchorNode === points.focusNode &&
points.anchorOffset === points.focusOffset;
if (!pointsCollapsed && rootElement.contains(points.anchorNode)) {
targetRect = getDOMRangeRect(nativeSelection, rootElement);
}
}
if (targetRect !== null) {
setFloatingAnchorRect(triggerElem, targetRect);
} else {
hideFloatingAnchor(triggerElem);
}
}, [editor]);
useEffect(() => {
const update = () => {
editor.read("latest", () => {
$updateAnchorPosition();
});
};
return mergeRegister(
registerEventListener(window, "resize", update),
registerEventListener(document, "scroll", update, true),
registerEventListener(document, "selectionchange", update),
);
}, [editor, $updateAnchorPosition]);
useEffect(() => {
editor.read("latest", () => {
$updateAnchorPosition();
});
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
$updateAnchorPosition();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
$updateAnchorPosition();
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, $updateAnchorPosition]);
return (
<Popover open>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir={dir}
ref={setPopupRefs}
side="top"
align="start"
sideOffset={8}
initialFocus={false}
finalFocus={false}
role="toolbar"
aria-label={t.textFormatToolbar}
className="w-auto min-w-0 flex-row items-center gap-1 p-1"
>
{children}
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
function useIsTextSelected(editor: LexicalEditor): boolean {
const [isText, setIsText] = useState(false);
const updatePopup = useCallback(() => {
editor.read("latest", () => {
if (editor.isComposing()) {
return;
}
const selection = $getSelection();
const nativeSelection = getDOMSelection(editor._window);
const rootElement = editor.getRootElement();
if (
nativeSelection !== null &&
(!$isRangeSelection(selection) ||
rootElement === null ||
!rootElement.contains(
getDOMSelectionPoints(nativeSelection, rootElement).anchorNode,
))
) {
setIsText(false);
return;
}
if (!$isRangeSelection(selection)) {
return;
}
const rawTextContent = selection.getTextContent().replace(/\n/g, "");
if (selection.isCollapsed() || rawTextContent === "") {
setIsText(false);
return;
}
const node = $getSelectedNode(selection);
setIsText($isTextNode(node) || $isParagraphNode(node));
});
}, [editor]);
useEffect(() => {
return registerEventListener(document, "selectionchange", updatePopup);
}, [updatePopup]);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(() => {
updatePopup();
}),
editor.registerRootListener(() => {
if (editor.getRootElement() === null) {
setIsText(false);
}
}),
);
}, [editor, updatePopup]);
return isText;
}
export function FloatingToolbarPlugin({
formats = "all",
children,
}: {
formats?: "basic" | "all";
children?: React.ReactNode;
}) {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const isText = useIsTextSelected(editor);
const toolbarRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const onDragStart = () => {
if (toolbarRef.current) {
toolbarRef.current.style.display = "none";
}
};
const onDragEnd = () => {
if (toolbarRef.current && toolbarRef.current.style.display === "none") {
toolbarRef.current.style.display = "";
}
};
return registerEventListeners(
document,
{ dragend: onDragEnd, dragstart: onDragStart, drop: onDragEnd },
true,
);
}, []);
if (!isEditable || !isText) {
return null;
}
return (
<FloatingToolbar editor={editor} ref={toolbarRef}>
<TextFormatToolbarPlugin formats={formats} />
{children}
</FloatingToolbar>
);
}
src/components/editor/plugins/floating/floating-utils.ts
import { getDOMSelectionRangeAndPoints } from "lexical";
export function getDOMRangeRect(
nativeSelection: Selection,
rootElement: HTMLElement,
): DOMRect {
const { points, range } = getDOMSelectionRangeAndPoints(
nativeSelection,
rootElement,
);
if (points.anchorNode === rootElement || range === null) {
let inner = rootElement;
while (inner.firstElementChild != null) {
inner = inner.firstElementChild as HTMLElement;
}
return inner.getBoundingClientRect();
}
return range.getBoundingClientRect();
}
export function setFloatingAnchorRect(
floatingAnchor: HTMLElement,
targetRect: DOMRect,
): void {
floatingAnchor.style.transform = `translate(${targetRect.left}px, ${targetRect.top}px)`;
floatingAnchor.style.width = `${targetRect.width}px`;
floatingAnchor.style.height = `${targetRect.height}px`;
}
export function hideFloatingAnchor(floatingAnchor: HTMLElement): void {
floatingAnchor.style.transform = "translate(-10000px, -10000px)";
}
src/components/editor/plugins/floating/link-editor-plugin.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
$getSelection,
$isRangeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
type NodeKey,
SELECTION_CHANGE_COMMAND,
getDOMSelection,
registerEventListener,
} from "lexical";
import {
$createLinkNode,
$isAutoLinkNode,
$isLinkNode,
TOGGLE_LINK_COMMAND,
} from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { Check, Pencil, Trash2, X } from "lucide-react";
import {
$getSelectedLinkNode,
$getSelectedNode,
} from "@/components/editor/extensions/format-state";
import {
OPEN_LINK_EDITOR_COMMAND,
sanitizeUrl,
} from "@/components/editor/extensions/link";
import {
getDOMRangeRect,
hideFloatingAnchor,
setFloatingAnchorRect,
} from "@/components/editor/plugins/floating/floating-utils";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function preventDefault(
event: React.KeyboardEvent<HTMLElement> | React.MouseEvent<HTMLElement>,
): void {
event.preventDefault();
}
export function LinkEditorPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t, dir } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [linkNodeKey, setLinkNodeKey] = useState<NodeKey | null>(null);
const [linkUrl, setLinkUrl] = useState("");
const [editedLinkUrl, setEditedLinkUrl] = useState("https://");
const [isEditMode, setIsEditMode] = useState(false);
const [dismissed, setDismissed] = useState(false);
const isOpen = isEditable && linkNodeKey !== null && !dismissed;
const positionToRect = useCallback((rect: DOMRect | null) => {
const triggerElem = triggerRef.current;
if (triggerElem === null) {
return;
}
if (rect === null) {
hideFloatingAnchor(triggerElem);
} else {
setFloatingAnchorRect(triggerElem, rect);
}
}, []);
const $updateLinkState = useCallback(() => {
const selection = $getSelection();
const linkNode = $isRangeSelection(selection)
? $getSelectedLinkNode(selection)
: null;
if (linkNode) {
setLinkNodeKey(linkNode.getKey());
setLinkUrl(linkNode.getURL());
} else {
setLinkNodeKey(null);
setLinkUrl("");
}
}, []);
const close = useCallback(() => {
setDismissed(true);
setIsEditMode(false);
}, []);
useEffect(() => {
editor.read("latest", () => {
$updateLinkState();
});
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
$updateLinkState();
});
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
setDismissed(false);
$updateLinkState();
const selection = $getSelection();
if (!(
$isRangeSelection(selection) && $getSelectedLinkNode(selection)
)) {
setIsEditMode(false);
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CLICK_COMMAND,
(payload) => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const node = $getSelectedNode(selection);
const linkNode = $findMatchingParent(node, $isLinkNode);
if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) {
window.open(linkNode.getURL(), "_blank");
return true;
}
}
return false;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
OPEN_LINK_EDITOR_COMMAND,
() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const linkNode = $getSelectedLinkNode(selection);
if (!linkNode && selection.isCollapsed()) {
return false;
}
setDismissed(false);
setEditedLinkUrl(linkNode ? linkNode.getURL() : "https://");
setIsEditMode(true);
if (!linkNode) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
}
return true;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, $updateLinkState]);
useEffect(() => {
return editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
if (isOpen) {
close();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, isOpen, close]);
useEffect(() => {
if (!isOpen) {
positionToRect(null);
return;
}
const update = () => {
const element =
linkNodeKey !== null ? editor.getElementByKey(linkNodeKey) : null;
if (element) {
positionToRect(element.getBoundingClientRect());
return;
}
const nativeSelection = getDOMSelection(editor._window);
const rootElement = editor.getRootElement();
positionToRect(
nativeSelection && rootElement
? getDOMRangeRect(nativeSelection, rootElement)
: null,
);
};
update();
return mergeRegister(
editor.registerUpdateListener(update),
registerEventListener(window, "resize", update),
registerEventListener(document, "scroll", update, true),
);
}, [editor, isOpen, linkNodeKey, positionToRect]);
const handleLinkSubmission = (
event:
React.KeyboardEvent<HTMLInputElement> | React.MouseEvent<HTMLElement>,
) => {
event.preventDefault();
if (linkNodeKey === null) {
return;
}
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl));
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const parent = $getSelectedNode(selection).getParent();
if ($isAutoLinkNode(parent)) {
const linkNode = $createLinkNode(parent.getURL(), {
rel: parent.__rel,
target: parent.__target,
title: parent.__title,
});
parent.replace(linkNode, true);
}
}
});
setEditedLinkUrl("https://");
setIsEditMode(false);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
handleLinkSubmission(event);
} else if (event.key === "Escape") {
event.preventDefault();
setIsEditMode(false);
}
};
return (
<Popover open={isOpen}>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir="ltr"
ref={contentRef}
side="bottom"
align="start"
sideOffset={8}
initialFocus={false}
finalFocus={false}
aria-label={t.link}
className="w-auto min-w-0 flex-row items-center gap-1 p-1"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
close();
}
}}
>
{isEditMode ? (
<>
<Input
ref={(elem) => {
if (elem) {
elem.focus();
}
}}
value={editedLinkUrl}
placeholder={t.linkUrlPlaceholder}
className="h-7 w-56 md:text-[0.8rem]"
onChange={(event) => {
setEditedLinkUrl(event.target.value);
}}
onKeyDown={handleKeyDown}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.cancel}
onMouseDown={preventDefault}
onClick={() => {
setIsEditMode(false);
}}
>
<X />
</Button>
}
/>
<TooltipContent>{t.cancel}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.saveLink}
onMouseDown={preventDefault}
onClick={handleLinkSubmission}
>
<Check />
</Button>
}
/>
<TooltipContent>{t.saveLink}</TooltipContent>
</Tooltip>
</>
) : (
<>
<a
href={sanitizeUrl(linkUrl)}
target="_blank"
rel="noopener noreferrer"
title={t.openLink}
className="max-w-56 truncate ps-2 text-[0.8rem] text-primary underline underline-offset-2"
>
{linkUrl}
</a>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.editLink}
onMouseDown={preventDefault}
onClick={(event) => {
event.preventDefault();
setEditedLinkUrl(linkUrl);
setIsEditMode(true);
}}
>
<Pencil />
</Button>
}
/>
<TooltipContent>{t.editLink}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.removeLink}
onMouseDown={preventDefault}
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}}
>
<Trash2 />
</Button>
}
/>
<TooltipContent>{t.removeLink}</TooltipContent>
</Tooltip>
</>
)}
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
src/components/editor/plugins/decorator/review-plugin.tsx
import { type JSX, type RefCallback, useCallback, useState } from "react";
import {
$getNodeByKey,
type LexicalEditor,
NODE_STATE_DIRECT,
type NodeKey,
configExtension,
defineExtension,
} from "lexical";
import { namedSignals } from "@lexical/extension";
import { ReactExtension } from "@lexical/react/ReactExtension";
import type { DecoratorComponentProps } from "@lexical/react/ReactPluginHostExtension";
import { useExtensionSignalValue } from "@lexical/react/useExtensionSignalValue";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { useLexicalSlotRef } from "@lexical/react/useLexicalSlotRef";
import { cn } from "cn";
import { Star } from "lucide-react";
import { createPortal } from "react-dom";
import { ReviewExtension } from "@/components/editor/extensions/review";
import {
$isReviewNode,
ReviewNode,
} from "@/components/editor/nodes/review-node";
const STARS = [1, 2, 3, 4, 5];
function useReviewChildren<T extends HTMLElement = HTMLElement>(
editor: LexicalEditor,
nodeKey: NodeKey,
): RefCallback<T | null> {
return useCallback<RefCallback<T | null>>(
(target) => {
const hostDom = editor.getElementByKey(nodeKey);
if (target === null || hostDom === null) {
return;
}
const childrenEl = hostDom.querySelector<HTMLElement>(
".editor-review-children",
);
if (childrenEl === null) {
return;
}
if (childrenEl.parentElement !== target) {
target.appendChild(childrenEl);
}
childrenEl.style.display = "";
return () => {
childrenEl.style.display = "none";
if (childrenEl.parentElement !== hostDom) {
hostDom.appendChild(childrenEl);
}
};
},
[editor, nodeKey],
);
}
function ReviewStars({
editor,
node,
}: {
editor: LexicalEditor;
node: ReviewNode;
}): JSX.Element {
const rating = node.getRating(NODE_STATE_DIRECT);
const isEditable = useLexicalEditable();
const [hover, setHover] = useState(0);
const setStars = (value: number) =>
editor.update(() => {
node.setRating(value === rating ? 0 : value);
});
const shown = (isEditable && hover) || rating;
return (
<div
className="flex gap-0.5"
aria-label={`Rating: ${rating} of 5`}
onMouseLeave={() => setHover(0)}
>
{STARS.map((value) => (
<button
key={value}
type="button"
className="cursor-pointer p-0 disabled:cursor-default"
aria-pressed={value <= rating}
aria-label={`${value} star${value === 1 ? "" : "s"}`}
disabled={!isEditable}
onMouseEnter={() => setHover(value)}
onClick={() => {
if (isEditable) {
setStars(value);
}
}}
>
<Star
className={cn(
"size-4 transition-colors",
value <= shown
? "fill-amber-400 text-amber-400"
: "text-muted-foreground/40",
)}
/>
</button>
))}
</div>
);
}
function ReviewChrome({
editor,
node,
}: {
editor: LexicalEditor;
node: ReviewNode;
}): JSX.Element {
const nodeKey = node.getKey();
const authorRef = useLexicalSlotRef<HTMLDivElement>(
editor,
nodeKey,
"author",
);
const childrenRef = useReviewChildren<HTMLDivElement>(editor, nodeKey);
return (
<div className="flex flex-col gap-2">
<ReviewStars editor={editor} node={node} />
<div
className="text-sm leading-relaxed italic outline-none [&_.editor-review-children]:outline-none [&_[data-lexical-slot]]:outline-none"
ref={childrenRef}
/>
<div className="editor-review-author flex items-baseline gap-1.5 text-sm text-muted-foreground">
<span className="select-none">-</span>
<div
ref={authorRef}
className="min-w-0 flex-1 font-medium outline-none [&_[data-lexical-slot]]:outline-none"
/>
</div>
</div>
);
}
export function ReviewPlugin({
context,
}: DecoratorComponentProps): JSX.Element {
const [editor] = context;
const nodeMap = useExtensionSignalValue(ReactReviewExtension, "nodeMap");
return (
<>
{Array.from(nodeMap.entries(), ([key, node]) => {
const dom = editor.getElementByKey(key);
return dom === null
? null
: createPortal(
<ReviewChrome editor={editor} node={node} />,
dom,
key,
);
})}
</>
);
}
export const ReactReviewExtension = defineExtension({
build: () => namedSignals({ nodeMap: new Map<NodeKey, ReviewNode>() }),
dependencies: [
configExtension(ReactExtension, {
decorators: [ReviewPlugin],
}),
ReviewExtension,
],
name: "@shadcn-editor/editor/ReactReview",
register: (editor, _config, state) => {
const nodeMapSignal = state.getOutput().nodeMap;
return editor.registerMutationListener(ReviewNode, (nodes) => {
nodeMapSignal.value = editor.read("latest", () => {
const nodeMap = new Map(nodeMapSignal.peek());
for (const k of nodes.keys()) {
const node = $getNodeByKey(k);
if ($isReviewNode(node)) {
nodeMap.set(k, node);
} else {
nodeMap.delete(k);
}
}
return nodeMap;
});
});
},
});
src/components/editor/plugins/floating/ruby-editor-plugin.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
$getSelection,
$isRangeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
type NodeKey,
SELECTION_CHANGE_COMMAND,
getDOMSelection,
isHTMLElement,
registerEventListener,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { Check, Trash2 } from "lucide-react";
import { OPEN_RUBY_EDITOR_COMMAND } from "@/components/editor/extensions/ruby";
import {
$isRubyNode,
$toggleRuby,
$unwrapRubyNode,
type RubyNode,
} from "@/components/editor/nodes/ruby-node";
import {
getDOMRangeRect,
hideFloatingAnchor,
setFloatingAnchorRect,
} from "@/components/editor/plugins/floating/floating-utils";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function preventDefault(
event: React.KeyboardEvent<HTMLElement> | React.MouseEvent<HTMLElement>,
): void {
event.preventDefault();
}
export function RubyEditorPlugin() {
const [editor] = useLexicalComposerContext();
const { t, dir } = useTranslation();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [baseText, setBaseText] = useState("");
const [annotation, setAnnotation] = useState("");
const [rubyNodeKey, setRubyNodeKey] = useState<NodeKey | null>(null);
const positionToRect = useCallback((rect: DOMRect | null) => {
const triggerElem = triggerRef.current;
if (triggerElem === null) {
return;
}
if (rect === null) {
hideFloatingAnchor(triggerElem);
} else {
setFloatingAnchorRect(triggerElem, rect);
}
}, []);
const positionToRubyNode = useCallback(
(node: RubyNode) => {
setBaseText(node.getTextContent());
setAnnotation(node.getAnnotation());
setRubyNodeKey(node.getKey());
const element = editor.getElementByKey(node.getKey());
positionToRect(element ? element.getBoundingClientRect() : null);
},
[editor, positionToRect],
);
const $positionToSelection = useCallback(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
return false;
}
const nativeSelection = getDOMSelection(editor._window);
const rootElement = editor.getRootElement();
if (nativeSelection === null || rootElement === null) {
return false;
}
setBaseText(selection.getTextContent());
setAnnotation("");
setRubyNodeKey(null);
positionToRect(getDOMRangeRect(nativeSelection, rootElement));
return true;
}, [editor, positionToRect]);
const close = useCallback(() => {
setIsOpen(false);
positionToRect(null);
}, [positionToRect]);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
OPEN_RUBY_EDITOR_COMMAND,
() => {
const opened = $positionToSelection();
if (opened) {
setIsOpen(true);
}
return opened;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
CLICK_COMMAND,
(event) => {
if (contentRef.current?.contains(event.target as Node)) {
return false;
}
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
close();
return false;
}
const target = event.target;
const node = isHTMLElement(target)
? $getNearestNodeFromDOMNode(target)
: null;
if ($isRubyNode(node)) {
positionToRubyNode(node);
setIsOpen(true);
} else {
close();
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
if (isOpen) {
close();
return true;
}
return false;
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
if (isOpen && rubyNodeKey === null) {
$positionToSelection();
}
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [
editor,
isOpen,
rubyNodeKey,
$positionToSelection,
positionToRubyNode,
close,
]);
useEffect(() => {
if (!isOpen) {
return;
}
const update = () => {
if (rubyNodeKey !== null) {
const element = editor.getElementByKey(rubyNodeKey);
positionToRect(element ? element.getBoundingClientRect() : null);
}
};
return mergeRegister(
registerEventListener(window, "resize", update),
registerEventListener(document, "scroll", update, true),
);
}, [editor, isOpen, rubyNodeKey, positionToRect]);
const handleSubmit = (
event: React.KeyboardEvent<HTMLElement> | React.MouseEvent<HTMLElement>,
) => {
event.preventDefault();
const value = annotation.trim();
if (!value) {
return;
}
editor.update(() => {
if (rubyNodeKey) {
const node = $getNodeByKey(rubyNodeKey);
if ($isRubyNode(node)) {
node.setAnnotation(value);
}
} else {
$toggleRuby(value);
}
});
close();
requestAnimationFrame(() => editor.focus());
};
const handleDelete = () => {
editor.update(() => {
if (rubyNodeKey) {
const node = $getNodeByKey(rubyNodeKey);
if ($isRubyNode(node)) {
$unwrapRubyNode(node);
}
} else {
$toggleRuby(null);
}
});
close();
requestAnimationFrame(() => editor.focus());
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) {
return;
}
if (event.key === "Enter") {
handleSubmit(event);
} else if (event.key === "Escape") {
event.preventDefault();
close();
}
};
return (
<Popover open={isOpen}>
<PopoverTrigger
ref={triggerRef}
aria-hidden="true"
tabIndex={-1}
className="pointer-events-none fixed top-0 left-0 opacity-0"
style={{ transform: "translate(-10000px, -10000px)" }}
/>
<PopoverPrimitive.Portal dir={dir}>
<PopoverContent
dir={dir}
ref={contentRef}
side="bottom"
align="start"
sideOffset={8}
initialFocus={false}
finalFocus={false}
aria-label={t.insertRuby}
className="w-auto min-w-0 flex-row items-center gap-1.5 p-1.5"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) {
close();
}
}}
>
<span
className="max-w-30 shrink-0 truncate text-sm text-muted-foreground"
title={baseText}
>
{baseText}
</span>
<Input
ref={(elem) => {
if (elem && isOpen) {
elem.focus();
}
}}
value={annotation}
placeholder={t.rubyAnnotationPlaceholder}
aria-label={t.rubyAnnotationPlaceholder}
className="h-7 min-w-24 flex-1 md:text-[0.8rem]"
onChange={(event) => {
setAnnotation(event.target.value);
}}
onKeyDown={handleKeyDown}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.insertRuby}
onMouseDown={preventDefault}
onClick={handleSubmit}
>
<Check />
</Button>
}
/>
<TooltipContent>{t.insertRuby}</TooltipContent>
</Tooltip>
{rubyNodeKey !== null && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={t.removeRuby}
onMouseDown={preventDefault}
onClick={handleDelete}
>
<Trash2 />
</Button>
}
/>
<TooltipContent>{t.removeRuby}</TooltipContent>
</Tooltip>
)}
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
);
}
src/components/editor/plugins/floating/table-hover-actions-plugin.tsx
import { useCallback, useEffect, useRef, useState } from "react";
import {
$getNearestNodeFromDOMNode,
type EditorThemeClasses,
getComposedEventTarget,
isHTMLElement,
isHTMLTableRowElement,
mergeRegister,
registerEventListener,
} from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import {
$deleteTableColumnAtSelection,
$deleteTableRowAtSelection,
$insertTableColumnAtSelection,
$insertTableRowAtSelection,
$isTableCellNode,
} from "@lexical/table";
import { cn } from "cn";
import { Plus, Trash2 } from "lucide-react";
import { createPortal } from "react-dom";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
type ActionsPosition = { x: number; y: number; canDelete: boolean };
const BUTTON_CLASSNAME =
"flex size-5 cursor-pointer items-center justify-center rounded-sm border bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-accent-foreground";
function getCellSelector(theme: EditorThemeClasses | null | undefined): string {
const cellClassName = theme?.tableCell?.split(/\s+/)[0];
return cellClassName ? `td.${cellClassName}, th.${cellClassName}` : "td, th";
}
function getColumnCount(tableElement: HTMLTableElement): number {
const firstRow = tableElement.rows[0];
if (!firstRow) {
return 0;
}
return Array.from(firstRow.cells).reduce(
(count, cell) => count + cell.colSpan,
0,
);
}
function getClosestTopCell(
tableElement: HTMLTableElement,
clientX: number,
): HTMLTableCellElement | null {
const firstRow = tableElement.rows[0];
if (!firstRow) {
return null;
}
let closest: HTMLTableCellElement | null = null;
let smallestDelta = Number.POSITIVE_INFINITY;
for (const cell of Array.from(firstRow.cells)) {
const rect = cell.getBoundingClientRect();
const delta = Math.abs(rect.left + rect.width / 2 - clientX);
if (delta < smallestDelta) {
smallestDelta = delta;
closest = cell;
}
}
return closest;
}
export function TableHoverActionsPlugin() {
const [editor, { getTheme }] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
const [columnPosition, setColumnPosition] = useState<ActionsPosition | null>(
null,
);
const [rowPosition, setRowPosition] = useState<ActionsPosition | null>(null);
const columnActionsRef = useRef<HTMLDivElement | null>(null);
const rowActionsRef = useRef<HTMLDivElement | null>(null);
const columnCellRef = useRef<HTMLTableCellElement | null>(null);
const rowCellRef = useRef<HTMLTableCellElement | null>(null);
const hideActions = useCallback(() => {
columnCellRef.current = null;
rowCellRef.current = null;
setColumnPosition(null);
setRowPosition(null);
}, []);
const updatePositions = useCallback(() => {
const columnCell = columnCellRef.current;
const columnTable = columnCell?.closest("table") ?? null;
if (columnCell?.isConnected && columnTable) {
const rect = columnCell.getBoundingClientRect();
setColumnPosition({
x: rect.left + rect.width / 2,
y: columnTable.getBoundingClientRect().top,
canDelete: getColumnCount(columnTable) > 1,
});
} else {
columnCellRef.current = null;
setColumnPosition(null);
}
const rowCell = rowCellRef.current;
const rowTable = rowCell?.closest("table") ?? null;
if (rowCell?.isConnected && rowTable) {
const rect = rowCell.getBoundingClientRect();
const tableRect = rowTable.getBoundingClientRect();
const isRTL = getComputedStyle(rowTable).direction === "rtl";
setRowPosition({
x: isRTL ? tableRect.right : tableRect.left,
y: rect.top + rect.height / 2,
canDelete: rowTable.rows.length > 1,
});
} else {
rowCellRef.current = null;
setRowPosition(null);
}
}, []);
useEffect(() => {
if (!isEditable) {
return;
}
const handleMouseMove = (event: MouseEvent) => {
const target = getComposedEventTarget(event);
if (!isHTMLElement(target)) {
return;
}
if (
columnActionsRef.current?.contains(target) ||
rowActionsRef.current?.contains(target)
) {
return;
}
const cell = target.closest<HTMLTableCellElement>(
getCellSelector(getTheme()),
);
const tableElement = cell?.closest("table") ?? null;
const rootElement = editor.getRootElement();
if (
!cell ||
tableElement === null ||
rootElement === null ||
!rootElement.contains(tableElement)
) {
hideActions();
return;
}
const rowIndex = isHTMLTableRowElement(cell.parentElement)
? cell.parentElement.rowIndex
: -1;
columnCellRef.current =
rowIndex === 0 ? getClosestTopCell(tableElement, event.clientX) : null;
rowCellRef.current = cell.cellIndex === 0 ? cell : null;
updatePositions();
};
return mergeRegister(
registerEventListener(document, "mousemove", handleMouseMove),
hideActions,
);
}, [editor, getTheme, hideActions, isEditable, updatePositions]);
useEffect(() => {
if (!isEditable) {
return;
}
const handleMouseLeave = (event: MouseEvent) => {
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
(columnActionsRef.current?.contains(nextTarget) ||
rowActionsRef.current?.contains(nextTarget))
) {
return;
}
hideActions();
};
return editor.registerRootListener((rootElement) => {
if (rootElement) {
return registerEventListener(
rootElement,
"mouseleave",
handleMouseLeave,
);
}
});
}, [editor, hideActions, isEditable]);
useEffect(() => {
return mergeRegister(
registerEventListener(window, "resize", updatePositions),
registerEventListener(document, "scroll", updatePositions, true),
editor.registerUpdateListener(updatePositions),
);
}, [editor, updatePositions]);
const withCell = (
cellRef: React.RefObject<HTMLTableCellElement | null>,
$action: () => void,
) => {
const cell = cellRef.current;
if (!cell) {
return;
}
editor.update(() => {
const node = $getNearestNodeFromDOMNode(cell);
if ($isTableCellNode(node)) {
node.selectEnd();
$action();
}
});
};
if (!isEditable) {
return null;
}
return createPortal(
<>
{columnPosition ? (
<div
ref={columnActionsRef}
className="fixed z-10 flex -translate-x-1/2 -translate-y-1/2 items-center gap-0.5"
style={{ left: columnPosition.x, top: columnPosition.y }}
>
<button
type="button"
aria-label={t.addTableColumn}
title={t.addTableColumn}
className={BUTTON_CLASSNAME}
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
withCell(columnCellRef, $insertTableColumnAtSelection)
}
>
<Plus className="size-3.5" />
</button>
{columnPosition.canDelete ? (
<button
type="button"
aria-label={t.deleteTableColumn}
title={t.deleteTableColumn}
className={cn(BUTTON_CLASSNAME, "hover:text-destructive")}
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
withCell(columnCellRef, $deleteTableColumnAtSelection)
}
>
<Trash2 className="size-3.5" />
</button>
) : null}
</div>
) : null}
{rowPosition ? (
<div
ref={rowActionsRef}
className="fixed z-10 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5"
style={{ left: rowPosition.x, top: rowPosition.y }}
>
<button
type="button"
aria-label={t.addTableRow}
title={t.addTableRow}
className={BUTTON_CLASSNAME}
onMouseDown={(event) => event.preventDefault()}
onClick={() => withCell(rowCellRef, $insertTableRowAtSelection)}
>
<Plus className="size-3.5" />
</button>
{rowPosition.canDelete ? (
<button
type="button"
aria-label={t.deleteTableRow}
title={t.deleteTableRow}
className={cn(BUTTON_CLASSNAME, "hover:text-destructive")}
onMouseDown={(event) => event.preventDefault()}
onClick={() => withCell(rowCellRef, $deleteTableRowAtSelection)}
>
<Trash2 className="size-3.5" />
</button>
) : null}
</div>
) : null}
</>,
document.body,
);
}
src/components/editor/plugins/i18n-plugin.tsx
import {
type Dispatch,
type ReactNode,
type SetStateAction,
createContext,
useContext,
useState,
} from "react";
import { cn } from "cn";
import { locales } from "@/components/editor/locales";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Language = "en" | "ar" | "he";
export type Direction = "ltr" | "rtl";
export type LocalizedText = Partial<Record<Language, string>>;
export const languageOptions = [
{ value: "en", label: "English" },
{ value: "ar", label: "Arabic (العربية)" },
{ value: "he", label: "Hebrew (עברית)" },
] as const;
const LANGUAGE_DIRECTION: Record<Language, Direction> = {
en: "ltr",
ar: "rtl",
he: "rtl",
};
type LanguageContextType = {
language: Language;
setLanguage: Dispatch<SetStateAction<Language>>;
};
export const LanguageContext = createContext<LanguageContextType | undefined>(
undefined,
);
export function LanguageProvider({
children,
defaultLanguage = "en",
}: {
children: ReactNode;
defaultLanguage?: Language;
}) {
const [language, setLanguage] = useState<Language>(defaultLanguage);
return (
<LanguageContext.Provider value={{ language, setLanguage }}>
{children}
</LanguageContext.Provider>
);
}
export function useLanguage() {
const context = useContext(LanguageContext);
const [localLanguage, setLocalLanguage] = useState<Language>("en");
const language = context?.language ?? localLanguage;
const setLanguage = context?.setLanguage ?? setLocalLanguage;
return { language, setLanguage, dir: LANGUAGE_DIRECTION[language] };
}
export function useTranslation() {
const { language, setLanguage, dir } = useLanguage();
return { language, setLanguage, dir, t: locales[language] };
}
export interface LanguageSelectorProps {
value: Language;
onValueChange: (value: Language) => void;
}
export function LanguageSelector({
value,
onValueChange,
className,
languages = ["en", "ar", "he"],
}: LanguageSelectorProps & {
className?: string;
languages?: Language[];
}) {
return (
<Select
items={languageOptions}
value={value}
onValueChange={(value) => onValueChange(value as Language)}
>
<SelectTrigger
size="sm"
className={cn(
"w-36 border-transparent bg-transparent hover:bg-muted hover:text-foreground dark:bg-transparent dark:hover:bg-muted/50",
className,
)}
dir="ltr"
>
<SelectValue />
</SelectTrigger>
<SelectContent
dir="ltr"
className="data-open:animate-none data-closed:animate-none"
>
<SelectGroup>
{languageOptions
.filter((option) => languages.includes(option.value as Language))
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
export function LanguageSelectorPlugin({ className }: { className?: string }) {
const { language, setLanguage } = useLanguage();
return (
<LanguageSelector
value={language}
onValueChange={setLanguage}
className={cn("h-6 w-auto gap-1 px-1.5 text-xs", className)}
/>
);
}
src/components/editor/plugins/mention-plugin.tsx
import { useCallback, useEffect, useMemo, useState } from "react";
import type { TextNode } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
LexicalTypeaheadMenuPlugin,
MenuOption,
type MenuTextMatch,
useBasicTypeaheadTriggerMatch,
} from "@lexical/react/LexicalTypeaheadMenuPlugin";
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
import { User } from "lucide-react";
import { $createMentionNode } from "@/components/editor/nodes/mention-node";
import { useLanguage } from "@/components/editor/plugins/i18n-plugin";
import { Command, CommandItem, CommandList } from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
const MAX_SUGGESTIONS = 5;
const PUNCTUATION =
"\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;";
const TRIGGERS = ["@"].join("");
const VALID_CHARS = "[^" + TRIGGERS + PUNCTUATION + "\\s]";
const VALID_JOINS = "(?:" + "\\.[ |$]|" + " |" + "[" + PUNCTUATION + "]|" + ")";
const LENGTH_LIMIT = 75;
const AtSignMentionsRegex = new RegExp(
"(^|\\s|\\()(" +
"[" +
TRIGGERS +
"]" +
"((?:" +
VALID_CHARS +
VALID_JOINS +
"){0," +
LENGTH_LIMIT +
"})" +
")$",
);
const ALIAS_LENGTH_LIMIT = 50;
const AtSignMentionsRegexAliasRegex = new RegExp(
"(^|\\s|\\()(" +
"[" +
TRIGGERS +
"]" +
"((?:" +
VALID_CHARS +
"){0," +
ALIAS_LENGTH_LIMIT +
"})" +
")$",
);
const MENTIONS = [
"Aayla Secura",
"Admiral Gial Ackbar",
"Ahsoka Tano",
"Anakin Skywalker",
"Asajj Ventress",
"BB-8",
"Bail Organa",
"Beru Lars",
"Boba Fett",
"Bodhi Rook",
"C-3PO",
"Captain Phasma",
"Captain Rex",
"Cassian Andor",
"Chewbacca",
"Chirrut Îmwe",
"Count Dooku",
"Darth Maul",
"Darth Vader",
"Ezra Bridger",
"Finn",
"Galen Erso",
"General Grievous",
"Grand Moff Tarkin",
"Greedo",
"Han Solo",
"Hera Syndulla",
"Jabba the Hutt",
"Jango Fett",
"Jar Jar Binks",
"Jyn Erso",
"K-2SO",
"Kanan Jarrus",
"Ki-Adi-Mundi",
"Kit Fisto",
"Kylo Ren",
"Lando Calrissian",
"Leia Organa",
"Luke Skywalker",
"Mace Windu",
"Maz Kanata",
"Mon Mothma",
"Obi-Wan Kenobi",
"Owen Lars",
"Padmé Amidala",
"Plo Koon",
"Poe Dameron",
"Qui-Gon Jinn",
"R2-D2",
"Rey",
"Rose Tico",
"Sabine Wren",
"Saw Gerrera",
"Sheev Palpatine",
"Shmi Skywalker",
"Wedge Antilles",
"Yoda",
];
const mentionsCache = new Map<string, string[] | null>();
const lookupService = {
search(query: string, callback: (results: string[]) => void): void {
setTimeout(() => {
const results = MENTIONS.filter((mention) =>
mention.toLowerCase().includes(query.toLowerCase()),
);
callback(results);
}, 250);
},
};
function useMentionLookupService(mentionString: string | null) {
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (mentionString === null) {
setResults([]);
return;
}
const cachedResults = mentionsCache.get(mentionString);
if (cachedResults === null) {
return;
}
if (cachedResults !== undefined) {
setResults(cachedResults);
return;
}
mentionsCache.set(mentionString, null);
lookupService.search(mentionString, (newResults) => {
mentionsCache.set(mentionString, newResults);
setResults(newResults);
});
}, [mentionString]);
return results;
}
function checkForAtSignMentions(
text: string,
minMatchLength: number,
): MenuTextMatch | null {
let match = AtSignMentionsRegex.exec(text);
if (match === null) {
match = AtSignMentionsRegexAliasRegex.exec(text);
}
if (match !== null) {
const maybeLeadingWhitespace = match[1];
const matchingString = match[3];
if (matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
};
}
}
return null;
}
class MentionTypeaheadOption extends MenuOption {
name: string;
constructor(name: string) {
super(name);
this.name = name;
}
}
export function MentionPlugin() {
const [editor] = useLexicalComposerContext();
const { dir } = useLanguage();
const [queryString, setQueryString] = useState<string | null>(null);
const results = useMentionLookupService(queryString);
const options = useMemo(
() =>
results
.slice(0, MAX_SUGGESTIONS)
.map((result) => new MentionTypeaheadOption(result)),
[results],
);
const onSelectOption = useCallback(
(
option: MentionTypeaheadOption,
nodeToReplace: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
const mentionNode = $createMentionNode(option.name);
if (nodeToReplace) {
nodeToReplace.replace(mentionNode);
}
mentionNode.select();
closeMenu();
});
},
[editor],
);
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
minLength: 0,
});
const checkForMentionMatch = useCallback(
(text: string) => {
if (checkForSlashTriggerMatch(text, editor) !== null) {
return null;
}
return checkForAtSignMentions(text, 1);
},
[checkForSlashTriggerMatch, editor],
);
return (
<LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForMentionMatch}
options={options}
menuRenderFn={(
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && options.length > 0 ? (
<Popover open>
<PopoverPrimitive.Portal
dir={dir}
container={anchorElementRef.current}
>
<PopoverTrigger
nativeButton={false}
render={
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-0"
/>
}
/>
<PopoverContent
dir={dir}
side="bottom"
align="start"
sideOffset={6}
initialFocus={false}
className="w-auto overflow-hidden p-0"
>
<Command
value={
selectedIndex === null
? ""
: (options[selectedIndex]?.name ?? "")
}
onValueChange={(nextValue) => {
const index = options.findIndex(
(option) => option.name === nextValue,
);
if (index >= 0) {
setHighlightedIndex(index);
}
}}
shouldFilter={false}
>
<CommandList className="max-h-64 w-56">
{options.map((option) => (
<CommandItem
key={option.key}
ref={option.setRefElement}
value={option.name}
onSelect={() => selectOptionAndCleanUp(option)}
>
<User className="size-4 text-muted-foreground" />
<span className="truncate">{option.name}</span>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</PopoverPrimitive.Portal>
</Popover>
) : null
}
/>
);
}
src/components/editor/plugins/table-of-contents-plugin.tsx
import { useEffect, useRef, useState } from "react";
import { type NodeKey, registerEventListener } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
TableOfContentsPlugin as LexicalTableOfContentsPlugin,
type TableOfContentsEntry,
} from "@lexical/react/LexicalTableOfContentsPlugin";
import type { HeadingTagType } from "@lexical/rich-text";
import { cn } from "cn";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
const INDENTS: Partial<Record<HeadingTagType, string>> = {
h2: "ps-5",
h3: "ps-8",
h4: "ps-8",
h5: "ps-8",
h6: "ps-8",
};
function getScrollParent(element: HTMLElement | null): HTMLElement | null {
let current = element?.parentElement ?? null;
while (current) {
const { overflowY } = getComputedStyle(current);
if (overflowY === "auto" || overflowY === "scroll") {
return current;
}
current = current.parentElement;
}
return null;
}
function TableOfContentsList({
tableOfContents,
}: {
tableOfContents: TableOfContentsEntry[];
}) {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const [selectedKey, setSelectedKey] = useState<NodeKey>("");
const suppressTrackingUntil = useRef(0);
const scrollToNode = (key: NodeKey) => {
editor.read("latest", () => {
const domElement = editor.getElementByKey(key);
if (domElement === null) {
return;
}
suppressTrackingUntil.current = performance.now() + 1000;
const scroller = getScrollParent(editor.getRootElement());
if (scroller === null) {
domElement.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
const top =
domElement.getBoundingClientRect().top -
scroller.getBoundingClientRect().top +
scroller.scrollTop;
scroller.scrollTo({ top, behavior: "smooth" });
}
setSelectedKey(key);
});
};
useEffect(() => {
const rootElement = editor.getRootElement();
const scroller = getScrollParent(rootElement);
if (rootElement === null) {
return;
}
let timerId: ReturnType<typeof setTimeout>;
const updateSelection = () => {
if (performance.now() < suppressTrackingUntil.current) {
return;
}
const topBoundary =
(scroller ?? rootElement).getBoundingClientRect().top + 16;
let nextKey = tableOfContents[0]?.[0] ?? "";
for (const [key] of tableOfContents) {
const heading = editor.getElementByKey(key);
if (heading === null) {
continue;
}
if (heading.getBoundingClientRect().top <= topBoundary) {
nextKey = key;
} else {
break;
}
}
setSelectedKey(nextKey);
};
updateSelection();
const onScroll = () => {
clearTimeout(timerId);
timerId = setTimeout(updateSelection, 50);
};
return registerEventListener(scroller ?? document, "scroll", onScroll);
}, [editor, tableOfContents]);
if (tableOfContents.length === 0) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>{t.tableOfContents}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{tableOfContents.map(([key, text, tag]) => (
<SidebarMenuItem key={key}>
<SidebarMenuButton
isActive={selectedKey === key}
className={cn(INDENTS[tag])}
onClick={() => scrollToNode(key)}
>
<span>{text}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export function TableOfContentsPlugin() {
return (
<LexicalTableOfContentsPlugin>
{(tableOfContents) => (
<TableOfContentsList tableOfContents={tableOfContents} />
)}
</LexicalTableOfContentsPlugin>
);
}
src/components/editor/plugins/toolbar/ai-toolbar-plugin.tsx
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Sparkles } from "lucide-react";
import { OPEN_AI_EDITOR_COMMAND } from "@/components/editor/extensions/ai";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function AiToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.askAi}
disabled={!isEditable}
onClick={() => {
editor.dispatchCommand(OPEN_AI_EDITOR_COMMAND, undefined);
}}
>
<Sparkles />
</Button>
}
/>
<TooltipContent>{t.askAi}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/block-format-toolbar-plugin.tsx
import { useEffect, useMemo, useState } from "react";
import {
$createParagraphNode,
$getRoot,
$getSelection,
$isElementNode,
$isRangeSelection,
COMMAND_PRIORITY_CRITICAL,
type ElementNode,
SELECTION_CHANGE_COMMAND,
} from "lexical";
import { getPeerDependencyFromEditor } from "@lexical/extension";
import {
$isListNode,
type CheckListExtension,
INSERT_CHECK_LIST_COMMAND,
INSERT_ORDERED_LIST_COMMAND,
INSERT_UNORDERED_LIST_COMMAND,
type ListExtension,
} from "@lexical/list";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import {
$createHeadingNode,
$createQuoteNode,
$isHeadingNode,
$isQuoteNode,
} from "@lexical/rich-text";
import { $setBlocksType } from "@lexical/selection";
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListTodo,
type LucideIcon,
Pilcrow,
TextQuote,
} from "lucide-react";
import { $getSelectedNode } from "@/components/editor/extensions/format-state";
import type { Locale } from "@/components/editor/locales";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
const BLOCK_TYPES = [
"paragraph",
"h1",
"h2",
"h3",
"number",
"bullet",
"check",
"quote",
] as const;
type BlockType = (typeof BLOCK_TYPES)[number];
const BLOCK_ITEMS: Record<
BlockType,
{ labelKey: keyof Locale; icon: LucideIcon }
> = {
paragraph: { labelKey: "paragraph", icon: Pilcrow },
h1: { labelKey: "heading1", icon: Heading1 },
h2: { labelKey: "heading2", icon: Heading2 },
h3: { labelKey: "heading3", icon: Heading3 },
number: { labelKey: "numberedListBlock", icon: ListOrdered },
bullet: { labelKey: "bulletedListBlock", icon: List },
check: { labelKey: "checkListBlock", icon: ListTodo },
quote: { labelKey: "quote", icon: TextQuote },
};
function $getBlockType(): BlockType | null {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return null;
}
const node = $getSelectedNode(selection);
const list = $findMatchingParent(node, $isListNode);
if (list) {
return list.getListType();
}
const element = $findMatchingParent(
node,
(parent): parent is ElementNode =>
$isElementNode(parent) && !parent.isInline(),
);
if ($isHeadingNode(element)) {
const tag = element.getTag();
return (BLOCK_TYPES as readonly string[]).includes(tag)
? (tag as BlockType)
: "paragraph";
}
return $isQuoteNode(element) ? "quote" : "paragraph";
}
function $createBlockNode(blockType: BlockType) {
switch (blockType) {
case "h1":
case "h2":
case "h3":
return $createHeadingNode(blockType);
case "quote":
return $createQuoteNode();
default:
return $createParagraphNode();
}
}
function useBlockType(): BlockType {
const [editor] = useLexicalComposerContext();
const [blockType, setBlockType] = useState<BlockType>(
() => editor.getEditorState().read($getBlockType) ?? "paragraph",
);
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
const next = editorState.read($getBlockType);
if (next) {
setBlockType(next);
}
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const next = $getBlockType();
if (next) {
setBlockType(next);
}
return false;
},
COMMAND_PRIORITY_CRITICAL,
),
);
}, [editor]);
return blockType;
}
export function BlockFormatToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t, dir, language } = useTranslation();
const blockType = useBlockType();
const isEditable = useLexicalEditable();
const items = useMemo(() => {
const hasList =
getPeerDependencyFromEditor<typeof ListExtension>(
editor,
"@lexical/list/List",
) !== undefined;
const hasCheckList =
getPeerDependencyFromEditor<typeof CheckListExtension>(
editor,
"@lexical/list/CheckList",
) !== undefined;
return BLOCK_TYPES.filter((item) => {
if (item === "number" || item === "bullet") {
return hasList;
}
if (item === "check") {
return hasCheckList;
}
return true;
});
}, [editor]);
return (
<Combobox
key={language}
items={items}
value={blockType}
disabled={!isEditable}
itemToStringLabel={(item) => t[BLOCK_ITEMS[item].labelKey]}
onValueChange={(value) => {
if (!value) {
return;
}
editor.update(() => {
if (!$getSelection()) {
$getRoot().selectEnd();
}
});
switch (value) {
case "number":
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
return;
case "bullet":
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
return;
case "check":
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
return;
}
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$setBlocksType(selection, () => $createBlockNode(value));
});
}}
>
<ComboboxInput
aria-label={t.blockFormatTrigger}
disabled={!isEditable}
className="h-7 w-48"
/>
<ComboboxContent dir={dir}>
<ComboboxEmpty>{t.noMatches}</ComboboxEmpty>
<ComboboxList>
{(item: BlockType) => {
const { labelKey, icon: Icon } = BLOCK_ITEMS[item];
return (
<ComboboxItem key={item} value={item}>
<Icon className="text-muted-foreground" />
{t[labelKey]}
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
src/components/editor/plugins/toolbar/clear-toolbar-plugin.tsx
import { useEffect, useState } from "react";
import { $getRoot, $isParagraphNode, CLEAR_EDITOR_COMMAND } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { RemoveFormatting, Trash2 } from "lucide-react";
import { clearFormatting } from "@/components/editor/extensions/format-state";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function $isEditorEmpty() {
const children = $getRoot().getChildren();
if (children.length > 1) {
return false;
}
const firstChild = children[0];
if (firstChild === undefined) {
return true;
}
return $isParagraphNode(firstChild) && firstChild.isEmpty();
}
export function ClearToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const isEditable = useLexicalEditable();
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
useEffect(() => {
setIsEditorEmpty(editor.read($isEditorEmpty));
return editor.registerUpdateListener(() => {
setIsEditorEmpty(editor.read($isEditorEmpty));
});
}, [editor]);
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.clearFormatting}
disabled={!isEditable}
onClick={() => {
clearFormatting(editor);
}}
>
<RemoveFormatting />
</Button>
}
/>
<TooltipContent>{t.clearFormatting}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.clearEditor}
disabled={!isEditable || isEditorEmpty}
onClick={() => {
editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
editor.focus();
}}
>
<Trash2 />
</Button>
}
/>
<TooltipContent>{t.clearEditor}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/color-toolbar-plugin.tsx
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { $patchStyleText } from "@lexical/selection";
import { cn } from "cn";
import { Baseline, type LucideIcon, PaintBucket } from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { buttonVariants } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
function ColorPicker({
property,
label,
icon: Icon,
}: {
property: "color" | "background-color";
label: string;
icon: LucideIcon;
}) {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const color = useFormatStateValue(
property === "color" ? "color" : "backgroundColor",
);
return (
<Tooltip>
<TooltipTrigger
render={
<label
data-slot="button"
aria-label={label}
className={cn(
buttonVariants({ variant: "outline", size: "icon-sm" }),
"cursor-pointer",
!isEditable && "pointer-events-none opacity-50",
)}
>
<Icon />
<input
type="color"
aria-label={label}
value={HEX_COLOR.test(color) ? color : "#000000"}
disabled={!isEditable}
onChange={(event) => {
const value = event.target.value;
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$patchStyleText(selection, { [property]: value });
});
}}
className="sr-only"
/>
</label>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export function ColorToolbarPlugin() {
const { t } = useTranslation();
return (
<ButtonGroup>
<ColorPicker property="color" label={t.textColor} icon={Baseline} />
<ColorPicker
property="background-color"
label={t.backgroundColor}
icon={PaintBucket}
/>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/element-format-toolbar-plugin.tsx
import { type ElementFormatType, FORMAT_ELEMENT_COMMAND } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
type LucideIcon,
PilcrowLeft,
PilcrowRight,
} from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import type { Locale } from "@/components/editor/locales";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
type AlignItem = {
value: Exclude<ElementFormatType, "">;
labelKey: keyof Locale;
icon: LucideIcon;
};
function AlignToggleGroup({ items }: { items: AlignItem[] }) {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const elementFormat = useFormatStateValue("elementFormat");
const isEditable = useLexicalEditable();
return (
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
disabled={!isEditable}
value={[elementFormat]}
onValueChange={(value) => {
const next = value[0] as Exclude<ElementFormatType, ""> | undefined;
if (next) {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, next);
}
}}
>
{items.map(({ value, labelKey, icon: Icon }) => (
<Tooltip key={value}>
<TooltipTrigger
render={
<ToggleGroupItem value={value} aria-label={t[labelKey]}>
<Icon />
</ToggleGroupItem>
}
/>
<TooltipContent>{t[labelKey]}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
);
}
export function ElementFormatToolbarPlugin({
formats = "all",
}: {
formats?: "basic" | "all";
}) {
const { dir } = useTranslation();
const isRtl = dir === "rtl";
const alignGroups: AlignItem[][] = [
[
{ value: "left", icon: AlignLeft, labelKey: "alignLeft" },
{ value: "center", icon: AlignCenter, labelKey: "alignCenter" },
{ value: "right", icon: AlignRight, labelKey: "alignRight" },
{ value: "justify", icon: AlignJustify, labelKey: "alignJustify" },
],
[
{
value: "start",
icon: isRtl ? PilcrowRight : PilcrowLeft,
labelKey: "alignStart",
},
{
value: "end",
icon: isRtl ? PilcrowLeft : PilcrowRight,
labelKey: "alignEnd",
},
],
];
const groups = formats === "basic" ? alignGroups.slice(0, 1) : alignGroups;
return (
<>
{groups.map((items, groupIndex) => (
<AlignToggleGroup key={groupIndex} items={items} />
))}
</>
);
}
src/components/editor/plugins/toolbar/find-replace-toolbar-plugin.tsx
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useExtensionSignalValue } from "@lexical/react/useExtensionSignalValue";
import { TextSearch } from "lucide-react";
import {
FindReplaceExtension,
TOGGLE_FIND_REPLACE_COMMAND,
} from "@/components/editor/extensions/find-replace";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function FindReplaceToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const isOpen = useExtensionSignalValue(FindReplaceExtension, "isOpen");
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.findAndReplace}
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={() => {
editor.dispatchCommand(TOGGLE_FIND_REPLACE_COMMAND, undefined);
}}
>
<TextSearch />
</Button>
}
/>
<TooltipContent>{t.findAndReplace}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/font-family-toolbar-plugin.tsx
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { $patchStyleText } from "@lexical/selection";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
const FONT_FAMILIES = [
"Arial",
"Courier New",
"Georgia",
"Times New Roman",
"Trebuchet MS",
"Verdana",
];
export function FontFamilyToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t, dir } = useTranslation();
const fontFamily = useFormatStateValue("fontFamily");
const isEditable = useLexicalEditable();
return (
<Combobox
items={FONT_FAMILIES}
value={fontFamily || FONT_FAMILIES[0]}
disabled={!isEditable}
onValueChange={(value) => {
if (!value) {
return;
}
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$patchStyleText(selection, { "font-family": value });
});
}}
>
<ComboboxInput
aria-label={t.fontFamilyPlaceholder}
disabled={!isEditable}
className="h-7 w-44"
/>
<ComboboxContent dir={dir}>
<ComboboxEmpty>{t.noMatches}</ComboboxEmpty>
<ComboboxList>
{(family: string) => (
<ComboboxItem key={family} value={family}>
<span style={{ fontFamily: family }}>{family}</span>
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
src/components/editor/plugins/toolbar/font-size-toolbar-plugin.tsx
import { useEffect, useState } from "react";
import { $getRoot, $getSelection } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { $patchStyleText } from "@lexical/selection";
import { Minus, Plus } from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 72;
const DEFAULT_FONT_SIZE = 16;
export function FontSizeToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const fontSize = useFormatStateValue("fontSize");
const isEditable = useLexicalEditable();
const currentSize = Number.parseInt(fontSize, 10) || DEFAULT_FONT_SIZE;
const [inputValue, setInputValue] = useState(String(currentSize));
useEffect(() => {
setInputValue(String(currentSize));
}, [currentSize]);
const updateFontSize = (size: number) => {
const next = Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, size));
editor.update(() => {
const selection = $getSelection() ?? $getRoot().selectEnd();
$patchStyleText(selection, { "font-size": `${next}px` });
});
};
const commitInput = () => {
const parsed = Number.parseInt(inputValue, 10);
if (Number.isNaN(parsed)) {
setInputValue(String(currentSize));
} else {
updateFontSize(parsed);
}
};
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.decreaseFontSize}
disabled={!isEditable || currentSize <= MIN_FONT_SIZE}
onClick={() => {
updateFontSize(currentSize - 2);
}}
>
<Minus />
</Button>
}
/>
<TooltipContent>{t.decreaseFontSize}</TooltipContent>
</Tooltip>
<Input
inputMode="numeric"
aria-label={t.fontSizeTitle}
disabled={!isEditable}
value={inputValue}
className="h-7 w-11 px-1 text-center md:text-[0.8rem]"
onChange={(event) => {
setInputValue(event.target.value);
}}
onBlur={commitInput}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitInput();
} else if (event.key === "Escape") {
setInputValue(String(currentSize));
}
}}
/>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.increaseFontSize}
disabled={!isEditable || currentSize >= MAX_FONT_SIZE}
onClick={() => {
updateFontSize(currentSize + 2);
}}
>
<Plus />
</Button>
}
/>
<TooltipContent>{t.increaseFontSize}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/history-toolbar-plugin.tsx
import { REDO_COMMAND, UNDO_COMMAND } from "lexical";
import { HistoryExtension } from "@lexical/history";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useExtensionDependency } from "@lexical/react/useExtensionComponent";
import { useSignalValue } from "@lexical/react/useExtensionSignalValue";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Redo2, Undo2 } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function HistoryToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const { canUndo, canRedo } = useExtensionDependency(HistoryExtension).output;
const canUndoValue = useSignalValue(canUndo);
const canRedoValue = useSignalValue(canRedo);
const isEditable = useLexicalEditable();
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.undo}
disabled={!isEditable || !canUndoValue}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND, undefined);
}}
>
<Undo2 className="rtl:-scale-x-100" />
</Button>
}
/>
<TooltipContent>{t.undo}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.redo}
disabled={!isEditable || !canRedoValue}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND, undefined);
}}
>
<Redo2 className="rtl:-scale-x-100" />
</Button>
}
/>
<TooltipContent>{t.redo}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/import-export-toolbar-plugin.tsx
import { exportFile, importFile } from "@lexical/file";
import { $generateHtmlFromNodes } from "@lexical/html";
import {
$convertToMarkdownString,
TRANSFORMERS,
type Transformer,
} from "@lexical/markdown";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { FileDown, FileUp } from "lucide-react";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
function toSafeFileName(value: string) {
return value
.replace(/[^a-zA-Z0-9-_. ]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^[-. ]+|[-. ]+$/g, "");
}
function downloadFile(content: string, fileName: string, mimeType: string) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
anchor.click();
URL.revokeObjectURL(url);
}
export function ImportExportToolbarPlugin({
transformers = TRANSFORMERS,
}: {
transformers?: Transformer[];
}) {
const [editor] = useLexicalComposerContext();
const { t, dir, language } = useTranslation();
const isEditable = useLexicalEditable();
const namespace = editor._config.namespace;
const getFileName = () =>
toSafeFileName(`${namespace} ${new Date().toISOString()}`);
const exportLexical = () => {
exportFile(editor, {
fileName: getFileName(),
source: namespace,
});
};
const exportMarkdown = () => {
const markdown = editor.read(() => $convertToMarkdownString(transformers));
downloadFile(markdown, `${getFileName()}.md`, "text/markdown");
};
const exportHtml = () => {
const body = editor.read(() => $generateHtmlFromNodes(editor));
const html = `<!doctype html>\n<html lang="${language}" dir="${dir}">\n<body>\n${body}\n</body>\n</html>`;
downloadFile(html, `${getFileName()}.html`, "text/html");
};
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.importFile}
disabled={!isEditable}
onClick={() => {
importFile(editor);
}}
>
<FileUp />
</Button>
}
/>
<TooltipContent>{t.importFile}</TooltipContent>
</Tooltip>
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.exportAs}
>
<FileDown />
</Button>
}
/>
}
/>
<TooltipContent>{t.exportAs}</TooltipContent>
</Tooltip>
<DropdownMenuContent dir={dir}>
<DropdownMenuItem onClick={exportLexical}>
{t.exportLexical}
</DropdownMenuItem>
<DropdownMenuItem onClick={exportMarkdown}>
{t.exportMarkdown}
</DropdownMenuItem>
<DropdownMenuItem onClick={exportHtml}>
{t.exportHtml}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/indent-toolbar-plugin.tsx
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { IndentDecrease, IndentIncrease } from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function IndentToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const indent = useFormatStateValue("indent");
const isEditable = useLexicalEditable();
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.outdent}
disabled={!isEditable || indent === 0}
onClick={() => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
}}
>
<IndentDecrease className="rtl:-scale-x-100" />
</Button>
}
/>
<TooltipContent>{t.outdent}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.indent}
disabled={!isEditable}
onClick={() => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined);
}}
>
<IndentIncrease className="rtl:-scale-x-100" />
</Button>
}
/>
<TooltipContent>{t.indent}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/link-toolbar-plugin.tsx
import { TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Link as LinkIcon } from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import { OPEN_LINK_EDITOR_COMMAND } from "@/components/editor/extensions/link";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function LinkToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
const isLink = useFormatStateValue("isLink");
return (
<ToggleGroup
multiple
variant="outline"
size="sm"
spacing={0}
disabled={!isEditable}
value={isLink ? ["link"] : []}
onValueChange={() => {
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
} else {
editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined);
}
}}
>
<Tooltip>
<TooltipTrigger
render={
<ToggleGroupItem value="link" aria-label={t.insertLink}>
<LinkIcon />
</ToggleGroupItem>
}
/>
<TooltipContent>{t.insertLink}</TooltipContent>
</Tooltip>
</ToggleGroup>
);
}
src/components/editor/plugins/toolbar/ruby-toolbar-plugin.tsx
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import { Languages } from "lucide-react";
import { toggleRubyEditor } from "@/components/editor/extensions/ruby";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export function RubyToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
const { t } = useTranslation();
return (
<ButtonGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="outline"
size="icon-sm"
aria-label={t.insertRuby}
disabled={!isEditable}
onClick={() => {
toggleRubyEditor(editor);
}}
>
<Languages />
</Button>
}
/>
<TooltipContent>{t.insertRuby}</TooltipContent>
</Tooltip>
</ButtonGroup>
);
}
src/components/editor/plugins/toolbar/text-format-toolbar-plugin.tsx
import { FORMAT_TEXT_COMMAND, type TextFormatType } from "lexical";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useLexicalEditable } from "@lexical/react/useLexicalEditable";
import {
Bold,
CaseLower,
CaseSensitive,
CaseUpper,
Code,
Highlighter,
Italic,
type LucideIcon,
Strikethrough,
Subscript,
Superscript,
Underline,
} from "lucide-react";
import { useFormatStateValue } from "@/components/editor/extensions/format-state";
import type { Locale } from "@/components/editor/locales";
import { useTranslation } from "@/components/editor/plugins/i18n-plugin";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const FORMAT_GROUPS: {
format: TextFormatType;
labelKey: keyof Locale;
icon: LucideIcon;
}[][] = [
[
{ format: "bold", labelKey: "bold", icon: Bold },
{ format: "italic", labelKey: "italic", icon: Italic },
{ format: "underline", labelKey: "underline", icon: Underline },
{ format: "strikethrough", labelKey: "strikethrough", icon: Strikethrough },
],
[
{ format: "highlight", labelKey: "highlight", icon: Highlighter },
{ format: "code", labelKey: "inlineCode", icon: Code },
],
[
{ format: "subscript", labelKey: "subscript", icon: Subscript },
{ format: "superscript", labelKey: "superscript", icon: Superscript },
],
[
{ format: "uppercase", labelKey: "uppercase", icon: CaseUpper },
{ format: "lowercase", labelKey: "lowercase", icon: CaseLower },
{ format: "capitalize", labelKey: "titleCase", icon: CaseSensitive },
],
];
function FormatToggleGroup({
items,
}: {
items: (typeof FORMAT_GROUPS)[number];
}) {
const [editor] = useLexicalComposerContext();
const { t } = useTranslation();
const formats = useFormatStateValue("formats");
const isEditable = useLexicalEditable();
return (
<ToggleGroup
multiple
variant="outline"
size="sm"
spacing={0}
disabled={!isEditable}
value={formats}
onValueChange={(value) => {
const next = new Set(value as TextFormatType[]);
for (const { format } of items) {
if (next.has(format) !== formats.includes(format)) {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
}
}
}}
>
{items.map(({ format, labelKey, icon: Icon }) => (
<Tooltip key={format}>
<TooltipTrigger
render={
<ToggleGroupItem value={format} aria-label={t[labelKey]}>
<Icon />
</ToggleGroupItem>
}
/>
<TooltipContent>{t[labelKey]}</TooltipContent>
</Tooltip>
))}
</ToggleGroup>
);
}
export function TextFormatToolbarPlugin({
formats = "all",
}: {
formats?: "basic" | "all";
}) {
const groups =
formats === "basic" ? FORMAT_GROUPS.slice(0, 1) : FORMAT_GROUPS;
return (
<>
{groups.map((items, groupIndex) => (
<FormatToggleGroup key={groupIndex} items={items} />
))}
</>
);
}
src/components/editor/plugins/toolbar/toolbar-plugin.tsx
import { cn } from "cn";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
export function Toolbar({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<ScrollArea
data-slot="toolbar"
className={cn("w-full border-b bg-muted/30", className)}
{...props}
>
<div
role="toolbar"
className="flex w-max min-w-full items-center gap-1 px-2 py-2"
>
{children}
</div>
<ScrollBar
orientation="horizontal"
className="opacity-0 transition-opacity data-hovering:opacity-100 data-scrolling:opacity-100"
/>
</ScrollArea>
);
}
src/components/editor/theme.css
.editor-indent {
--lexical-indent-base-value: 32px;
}
::highlight(editor-find-match) {
background-color: rgb(250 204 21 / 0.4);
color: inherit;
}
::highlight(editor-find-match-current) {
background-color: rgb(249 115 22 / 0.6);
color: inherit;
}
@media (forced-colors: active) {
::highlight(editor-find-match) {
background-color: Highlight;
color: HighlightText;
forced-color-adjust: none;
}
::highlight(editor-find-match-current) {
background-color: Mark;
color: MarkText;
forced-color-adjust: none;
}
}
.editor-find-match-overlay {
position: absolute;
pointer-events: none;
background-color: rgb(250 204 21 / 0.4);
}
.editor-find-match-overlay-current {
background-color: rgb(249 115 22 / 0.6);
}
.editor-mark {
background-color: rgb(251 191 36 / 0.2);
border-bottom: 2px solid rgb(251 191 36 / 0.45);
padding-bottom: 1px;
cursor: pointer;
}
.editor-mark.selected {
background-color: rgb(251 191 36 / 0.45);
border-bottom-color: rgb(245 158 11);
}
.editor-mark-overlap {
background-color: rgb(251 191 36 / 0.35);
}
.editor-mark-overlap.selected {
background-color: rgb(251 191 36 / 0.6);
}
.editor-comment-highlight {
position: absolute;
pointer-events: none;
z-index: 5;
background-color: rgb(251 191 36 / 0.35);
}
.editor-code {
direction: ltr;
unicode-bidi: isolate;
background-color: var(--card) !important;
}
.editor-code::before {
content: attr(data-gutter);
position: sticky;
inset-inline-start: 0;
top: 0;
float: left;
z-index: 1;
min-width: 1.75rem;
margin-inline-end: 1rem;
padding-inline: 0.5rem;
color: var(--muted-foreground);
text-align: right;
white-space: pre-wrap;
user-select: none;
background-color: var(--card);
}
.editor-card [data-lexical-slot="title"] {
outline: none;
font-weight: 500;
}
.editor-card > *:not([data-lexical-slot]) {
color: var(--muted-foreground);
}
.editor-card [data-lexical-slot="title"] > p:has(br:only-child),
.editor-card > p:only-of-type:has(br:only-child) {
position: relative;
}
.editor-card [data-lexical-slot="title"] > p:has(br:only-child)::before {
content: "Title";
}
.editor-card > p:only-of-type:has(br:only-child)::before {
content: "Body";
}
:lang(ar)
.editor-card
[data-lexical-slot="title"]
> p:has(br:only-child)::before {
content: "العنوان";
}
:lang(ar) .editor-card > p:only-of-type:has(br:only-child)::before {
content: "المحتوى";
}
:lang(he)
.editor-card
[data-lexical-slot="title"]
> p:has(br:only-child)::before {
content: "כותרת";
}
:lang(he) .editor-card > p:only-of-type:has(br:only-child)::before {
content: "תוכן";
}
.editor-card [data-lexical-slot="title"] > p:has(br:only-child)::before,
.editor-card > p:only-of-type:has(br:only-child)::before {
position: absolute;
inset-inline-start: 0;
top: 0;
color: var(--muted-foreground);
opacity: 0.7;
pointer-events: none;
user-select: none;
}
.editor-ruby::after {
content: attr(data-ruby-annotation);
position: absolute;
left: 50%;
transform: translateX(-50%);
top: -0.75em;
font-size: 0.5em;
line-height: 1;
color: var(--muted-foreground);
pointer-events: none;
user-select: none;
white-space: nowrap;
}
.editor-ruby-composing::after {
display: none;
}
.editor-review-children > p:only-of-type:has(br:only-child),
.editor-review-author [data-lexical-slot="author"] > p:has(br:only-child) {
position: relative;
}
.editor-review-children > p:only-of-type:has(br:only-child)::before {
content: "Write a review…";
}
.editor-review-author
[data-lexical-slot="author"]
> p:has(br:only-child)::before {
content: "Author";
}
:lang(ar) .editor-review-children > p:only-of-type:has(br:only-child)::before {
content: "اكتب مراجعة…";
}
:lang(ar)
.editor-review-author
[data-lexical-slot="author"]
> p:has(br:only-child)::before {
content: "الكاتب";
}
:lang(he) .editor-review-children > p:only-of-type:has(br:only-child)::before {
content: "כתבו ביקורת…";
}
:lang(he)
.editor-review-author
[data-lexical-slot="author"]
> p:has(br:only-child)::before {
content: "מחבר";
}
.editor-review-children > p:only-of-type:has(br:only-child)::before,
.editor-review-author
[data-lexical-slot="author"]
> p:has(br:only-child)::before {
position: absolute;
inset-inline-start: 0;
top: 0;
color: var(--muted-foreground);
opacity: 0.7;
pointer-events: none;
user-select: none;
}
.editor-collapsible > summary::marker,
.editor-collapsible > summary::-webkit-details-marker {
display: none;
}
.editor-collapsible > summary::before {
content: "";
position: absolute;
inset-inline-start: 0.5rem;
top: 50%;
width: 1rem;
height: 1rem;
background-color: var(--muted-foreground);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
mask-size: 100% 100%;
transform: translateY(-50%);
transition: transform 0.2s ease;
}
[dir="rtl"] .editor-collapsible > summary::before {
transform: translateY(-50%) rotate(180deg);
}
.editor-collapsible[open] > summary::before,
[dir="rtl"] .editor-collapsible[open] > summary::before {
transform: translateY(-50%) rotate(90deg);
}
.editor-collapsible-content {
interpolate-size: allow-keywords;
overflow: hidden;
transition:
height 0.2s ease-out,
padding 0.2s ease-out,
opacity 0.2s ease-out,
visibility 0.2s ease-out allow-discrete;
}
.editor-collapsible-content[hidden] {
content-visibility: visible;
display: block;
height: 0;
padding-block: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
height 0.2s ease-in,
padding 0.2s ease-in,
opacity 0.2s ease-in,
visibility 0.2s ease-in allow-discrete;
}
details.editor-collapsible::details-content {
interpolate-size: allow-keywords;
overflow: hidden;
transition:
height 0.2s ease-out,
opacity 0.2s ease-out,
content-visibility 0.2s ease-out allow-discrete;
}
details.editor-collapsible:not([open])::details-content {
height: 0;
opacity: 0;
content-visibility: hidden;
transition:
height 0.2s ease-in,
opacity 0.2s ease-in,
content-visibility 0.2s ease-in allow-discrete;
}
.editor-table-scrollable-wrapper {
scrollbar-width: none;
}
.editor-table-scrollable-wrapper::-webkit-scrollbar {
display: none;
}
.editor-table-selection *::selection {
background-color: transparent;
}
[contenteditable]:focus-within .editor-table-selected {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.editor-table-cell-selected {
caret-color: transparent;
}
[contenteditable]:focus-within .editor-table-cell-selected::after {
position: absolute;
inset: 0;
background-color: var(--primary);
opacity: 0.2;
content: "";
pointer-events: none;
}
.editor-equation {
direction: ltr;
unicode-bidi: isolate;
cursor: default;
user-select: none;
}
span.editor-equation {
display: inline-block;
}
div.editor-equation {
display: block;
text-align: center;
}
.editor-equation.focused {
border-radius: var(--radius-sm);
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.katex {
direction: ltr;
unicode-bidi: isolate;
}
src/components/editor/theme.ts
import type { EditorThemeClasses } from "lexical";
import "./theme.css";
export const editorTheme: EditorThemeClasses = {
root: "relative outline-none",
autocomplete: "text-muted-foreground select-none",
characterLimit: "inline bg-destructive/10 text-destructive",
ltr: "text-left",
rtl: "text-right",
paragraph: "relative m-0 mb-2 last:mb-0",
heading: {
h1: "relative mt-6 mb-4 scroll-m-20 text-4xl font-extrabold tracking-tight text-balance first:mt-0 last:mb-0",
h2: "relative mt-6 mb-3 scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0 last:mb-0",
h3: "relative mt-4 mb-2 scroll-m-20 text-2xl font-semibold tracking-tight first:mt-0 last:mb-0",
h4: "relative mt-4 mb-2 scroll-m-20 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
h5: "relative mt-3 mb-2 scroll-m-20 text-lg font-semibold tracking-tight first:mt-0 last:mb-0",
h6: "relative mt-3 mb-2 scroll-m-20 text-base font-semibold tracking-tight first:mt-0 last:mb-0",
},
quote:
"relative my-4 border-s-2 ps-6 text-muted-foreground italic first:mt-0 last:mb-0",
list: {
checklist: "!ps-1",
listitem: "relative",
listitemChecked:
"relative list-none ps-6 text-muted-foreground line-through outline-none before:absolute before:start-0 before:top-1 before:size-4 before:cursor-pointer before:rounded-[4px] before:border before:border-primary before:bg-primary before:shadow-xs after:absolute after:start-[5px] after:top-[6px] after:h-[9px] after:w-[5px] after:rotate-45 after:cursor-pointer after:border-r-2 after:border-b-2 after:border-primary-foreground focus:before:ring-2 focus:before:ring-ring/50",
listitemUnchecked:
"relative list-none ps-6 outline-none before:absolute before:start-0 before:top-1 before:size-4 before:cursor-pointer before:rounded-[4px] before:border before:border-input before:shadow-xs focus:before:ring-2 focus:before:ring-ring/50 dark:before:bg-input/30",
nested: {
listitem: "list-none before:hidden after:hidden",
},
ol: "m-0 mb-2 ps-6 last:mb-0",
olDepth: [
"list-decimal",
"list-[upper-alpha]",
"list-[lower-alpha]",
"list-[upper-roman]",
"list-[lower-roman]",
],
ul: "m-0 mb-2 ps-6 last:mb-0",
ulDepth: ["list-disc", "list-[circle]", "list-[square]"],
},
indent: "editor-indent",
link: "cursor-pointer text-primary underline underline-offset-2 hover:text-primary/80",
hashtag: "rounded bg-primary/10 px-0.5 text-primary",
mark: "editor-mark",
markOverlap: "editor-mark-overlap",
ruby: "editor-ruby relative inline-block align-baseline leading-[1.3]",
specialText:
"rounded bg-yellow-200/70 px-1 font-semibold dark:bg-yellow-500/30",
text: {
bold: "font-bold",
italic: "italic",
underline: "underline",
strikethrough: "line-through",
underlineStrikethrough: "[text-decoration-line:underline_line-through]",
code: "rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
subscript: "align-sub text-xs",
superscript: "align-super text-xs",
highlight: "rounded bg-yellow-200/70 dark:bg-yellow-500/30",
lowercase: "lowercase",
uppercase: "uppercase",
capitalize: "capitalize",
},
card: "editor-card relative my-3 grid w-full gap-0.5 rounded-lg border bg-card px-2.5 py-2 text-start text-sm text-card-foreground transition-[color,box-shadow] first:mt-0 last:mb-0 data-[current-slot]:border-ring data-[selected=true]:border-primary data-[selected=true]:ring-2 data-[selected=true]:ring-primary/30",
code: "editor-code relative mb-3 block overflow-x-auto rounded-lg border bg-card py-3 pe-4 ps-0 font-mono text-[13px] leading-relaxed [tab-size:2] [white-space:pre]",
collapsibleContainer:
"editor-collapsible my-2 w-full rounded-lg border bg-card text-card-foreground first:mt-0 last:mb-0",
collapsibleTitle:
"relative block cursor-pointer list-none rounded-lg py-2 pe-3 ps-7 text-start text-sm font-medium outline-none select-none hover:underline",
collapsibleContent: "pt-0 pe-3 pb-2 ps-7 text-sm",
codeHighlight: {
inserted: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
deleted: "bg-red-500/15 text-red-700 dark:text-red-400",
},
review:
"relative my-3 rounded-lg border bg-card px-4 py-3 text-sm text-card-foreground first:mt-0 last:mb-0",
pullQuote:
"relative my-4 rounded-md border-s-4 border-primary bg-muted/50 px-5 py-4 transition-[color,box-shadow] first:mt-0 last:mb-0 data-[selected=true]:ring-2 data-[selected=true]:ring-primary/40",
embedBlock: {
base: "relative my-3 select-none first:mt-0 last:mb-0",
focus: "rounded-md outline-2 outline-offset-2 outline-primary",
},
layoutContainer: "editor-layout-container my-3 grid gap-4",
layoutItem: "min-w-0 rounded-md border border-dashed border-border p-2",
tableScrollableWrapper:
"editor-table-scrollable-wrapper relative my-3 w-full overflow-x-auto",
table: "w-full table-fixed caption-bottom text-sm",
tableRow: "border-b transition-colors hover:bg-muted/50",
tableCell:
"editor-table-cell relative border p-2 text-start align-middle outline-none",
tableCellHeader: "bg-muted/50 text-start font-medium",
tableCellSelected: "editor-table-cell-selected",
tableSelected: "editor-table-selected",
tableSelection: "editor-table-selection",
tableAlignment: {
center: "mx-auto",
right: "ms-auto",
},
};
export const chatMessageTheme: EditorThemeClasses = {
...editorTheme,
heading: {
h1: "relative mt-4 mb-2 text-xl font-semibold tracking-tight first:mt-0 last:mb-0",
h2: "relative mt-4 mb-2 text-lg font-semibold tracking-tight first:mt-0 last:mb-0",
h3: "relative mt-3 mb-1.5 text-base font-semibold tracking-tight first:mt-0 last:mb-0",
h4: "relative mt-3 mb-1.5 text-sm font-semibold first:mt-0 last:mb-0",
h5: "relative mt-3 mb-1.5 text-sm font-semibold first:mt-0 last:mb-0",
h6: "relative mt-3 mb-1.5 text-sm font-semibold first:mt-0 last:mb-0",
},
quote:
"relative my-2 border-s-2 border-current/30 ps-3 opacity-80 first:mt-0 last:mb-0",
link: "cursor-pointer underline underline-offset-2 hover:opacity-80",
text: {
...editorTheme.text,
code: "rounded bg-current/10 px-1 py-0.5 font-mono text-[0.875em]",
},
code: "editor-code relative my-2 block overflow-x-auto rounded-lg border bg-card py-3 pe-4 ps-0 font-mono text-xs leading-relaxed text-card-foreground [tab-size:2] [white-space:pre] first:mt-0 last:mb-0",
tableScrollableWrapper:
"editor-table-scrollable-wrapper relative my-2 w-full overflow-x-auto first:mt-0 last:mb-0",
tableCell:
"editor-table-cell relative border border-current/20 px-2 py-1.5 text-start align-middle outline-none",
tableCellHeader: "bg-current/5 text-start font-medium",
tableRow: "border-b border-current/20",
};
src/components/editor/transformers/emoji-transformer.ts
import type { TextMatchTransformer } from "@lexical/markdown";
import compactEmojis from "emojibase-data/en/compact.json";
import {
$createEmojiNode,
$isEmojiNode,
EMOJI_CLASS_NAME,
EmojiNode,
} from "@/components/editor/nodes/emoji-node";
const EMOJI_BY_NAME = new Map<string, string>();
for (const entry of compactEmojis) {
const name = entry.label
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
if (name && !EMOJI_BY_NAME.has(name)) {
EMOJI_BY_NAME.set(name, entry.unicode);
}
}
export const EMOJI: TextMatchTransformer = {
dependencies: [EmojiNode],
export: (node) => ($isEmojiNode(node) ? node.getTextContent() : null),
importRegExp: /:([a-z0-9_]+):/,
regExp: /:([a-z0-9_]+):$/,
replace: (textNode, [, name]) => {
const emoji = EMOJI_BY_NAME.get(name);
if (emoji) {
textNode.replace($createEmojiNode(EMOJI_CLASS_NAME, emoji));
}
},
trigger: ":",
type: "text-match",
};
src/components/editor/transformers/horizontal-rule-transformer.ts
import {
$createHorizontalRuleNode,
$isHorizontalRuleNode,
HorizontalRuleNode,
} from "@lexical/extension";
import type { ElementTransformer } from "@lexical/markdown";
export const HR: ElementTransformer = {
dependencies: [HorizontalRuleNode],
export: (node) => ($isHorizontalRuleNode(node) ? "***" : null),
regExp: /^(---|\*\*\*|___)\s?$/,
replace: (parentNode, _children, _match, isImport) => {
const line = $createHorizontalRuleNode();
if (isImport || parentNode.getNextSibling() != null) {
parentNode.replace(line);
} else {
parentNode.insertBefore(line);
}
line.selectNext();
},
type: "element",
};
src/components/editor/transformers/image-transformer.ts
import type { TextMatchTransformer } from "@lexical/markdown";
import {
$createImageNode,
$isImageNode,
ImageNode,
} from "@/components/editor/nodes/image-node";
export const IMAGE: TextMatchTransformer = {
dependencies: [ImageNode],
export: (node) => {
if (!$isImageNode(node)) {
return null;
}
return `})`;
},
importRegExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))/,
regExp: /!(?:\[([^[]*)\])(?:\(([^(]+)\))$/,
replace: (textNode, match) => {
const [, altText, src] = match;
textNode.replace($createImageNode({ altText, src }));
},
trigger: ")",
type: "text-match",
};
src/components/editor/transformers/table-transformer.ts
import { $isParagraphNode, $isTextNode, type LexicalNode } from "lexical";
import {
$convertFromMarkdownString,
$convertToMarkdownString,
CHECK_LIST,
ELEMENT_TRANSFORMERS,
type ElementTransformer,
MULTILINE_ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
type Transformer,
isTableRowDivider,
} from "@lexical/markdown";
import {
$createTableCellNode,
$createTableNode,
$createTableRowNode,
$isTableCellNode,
$isTableNode,
$isTableRowNode,
TableCellHeaderStates,
TableCellNode,
TableNode,
TableRowNode,
} from "@lexical/table";
import { EMOJI } from "@/components/editor/transformers/emoji-transformer";
import { IMAGE } from "@/components/editor/transformers/image-transformer";
const CELL_TRANSFORMERS: Transformer[] = [
IMAGE,
EMOJI,
CHECK_LIST,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
];
const TABLE_ROW_REG_EXP = /^\|(.+)\|\s?$/;
function getTableColumnsSize(table: TableNode) {
const row = table.getFirstChild();
return $isTableRowNode(row) ? row.getChildrenSize() : 0;
}
function $createTableCell(textContent: string): TableCellNode {
textContent = textContent.replace(/\\n/g, "\n");
const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
$convertFromMarkdownString(textContent.trim(), CELL_TRANSFORMERS, cell);
return cell;
}
function mapToTableCells(textContent: string): TableCellNode[] | null {
const match = textContent.match(TABLE_ROW_REG_EXP);
if (!match || !match[1]) {
return null;
}
return match[1].split("|").map((text) => $createTableCell(text));
}
export const TABLE: ElementTransformer = {
dependencies: [TableNode, TableRowNode, TableCellNode],
export: (node: LexicalNode) => {
if (!$isTableNode(node)) {
return null;
}
const output: string[] = [];
for (const row of node.getChildren()) {
if (!$isTableRowNode(row)) {
continue;
}
const rowOutput: string[] = [];
let isHeaderRow = false;
for (const cell of row.getChildren()) {
if ($isTableCellNode(cell)) {
rowOutput.push(
$convertToMarkdownString(CELL_TRANSFORMERS, cell).replace(
/\n/g,
"\\n",
),
);
if (cell.hasHeaderState(TableCellHeaderStates.ROW)) {
isHeaderRow = true;
}
}
}
output.push(`| ${rowOutput.join(" | ")} |`);
if (isHeaderRow) {
output.push(`| ${rowOutput.map(() => "---").join(" | ")} |`);
}
}
return output.join("\n");
},
regExp: TABLE_ROW_REG_EXP,
replace: (parentNode, _children, match) => {
if (isTableRowDivider(match[0])) {
const table = parentNode.getPreviousSibling();
if (!table || !$isTableNode(table)) {
return;
}
const rows = table.getChildren();
const lastRow = rows[rows.length - 1];
if (!lastRow || !$isTableRowNode(lastRow)) {
return;
}
for (const cell of lastRow.getChildren()) {
if ($isTableCellNode(cell)) {
cell.setHeaderStyles(
TableCellHeaderStates.ROW,
TableCellHeaderStates.ROW,
);
}
}
parentNode.remove();
return;
}
const matchCells = mapToTableCells(match[0]);
if (matchCells == null) {
return;
}
const rows = [matchCells];
let sibling = parentNode.getPreviousSibling();
let maxCells = matchCells.length;
while (sibling) {
if (!$isParagraphNode(sibling) || sibling.getChildrenSize() !== 1) {
break;
}
const firstChild = sibling.getFirstChild();
if (!$isTextNode(firstChild)) {
break;
}
const cells = mapToTableCells(firstChild.getTextContent());
if (cells == null) {
break;
}
maxCells = Math.max(maxCells, cells.length);
rows.unshift(cells);
const previousSibling = sibling.getPreviousSibling();
sibling.remove();
sibling = previousSibling;
}
const table = $createTableNode();
for (const cells of rows) {
const tableRow = $createTableRowNode();
table.append(tableRow);
for (let i = 0; i < maxCells; i++) {
tableRow.append(i < cells.length ? cells[i] : $createTableCell(""));
}
}
const previousSibling = parentNode.getPreviousSibling();
if (
$isTableNode(previousSibling) &&
getTableColumnsSize(previousSibling) === maxCells
) {
previousSibling.append(...table.getChildren());
parentNode.remove();
} else {
parentNode.replace(table);
}
table.selectEnd();
},
type: "element",
};