۱۲ بهمن ۱۴۰۳، تلگرام مسابقهای را برای برنامهنویسان جاوااسکریپت ترتیب داد. هدف اصلی این رقابت، رفع مشکل عدم پشتیبانی از قابلیت Undo (یا همان Ctrl+Z) در کامپوننت ورودی متن نسخه وب تلگرام بود. تلگرام از شرکتکنندگان خواسته بود که ترجیحاً با بازنویسی این کامپوننت، مشکل مذکور را برطرف کنند.
من به واسطه تجربه کاریام در پیامرسان «بله»، تسلط کاملی بر کدهای کامپوننت متن تلگرام داشتم و تصور میکردم حل این چالش چندان دشوار نباشد؛ بنابراین تصمیم گرفتم در این مسابقه شرکت کنم. با توجه به پیچیدگیهای توسعه یک ویرایشگر متن، در ابتدا قصدی برای بازنویسی کامل نداشتم و صرفاً بر رفع ایرادهای نسخه فعلی متمرکز شدم. در روزهای نخست، موفق شدم باگها را بدون نیاز به بازنویسی حل کنم، اما همچنان برایم سوال بود که چرا تیم تلگرام بر بازنویسی کامل اصرار دارد؟
پاسخ تلگرام پس از چند روز، دلیل این اصرار را روشن کرد: کامپوننت فعلی، محتوای HTML را به صورت رشته (String) ذخیره میکند. با هر بار ورودی کاربر، این رشته Parse شده و مجدداً به HTML تبدیل میشود؛ فرآیندی که باعث افت عملکرد (Performance) شده بود. بر این اساس، تلگرام اعلام کرد جایزه تنها به کسانی تعلق میگیرد که کامپوننت را از نو پیادهسازی کنند .
توسعه یک ادیتور WYSIWYG در وب، آن هم در بازه زمانی کوتاه سه هفتهای، چالش فنی بزرگی محسوب میشود. با این حال، کار را با تمرکز بر مدیریت قابلیت Undo و نمایش صحیح ایموجیها آغاز کردم. اگرچه پیادهسازی من در مهلت مسابقه به پایان نرسید، اما به دلیل کیفیت ساختار کد (Codebase)، تصمیم گرفتم توسعه آن را ادامه دهم. نتیجه این تلاش ششماهه، theodore-js است؛ ابزاری برای نمایش یکپارچه و بهینه ایموجیها در ورودی متن وباپلیکیشنها در React که در ادامه به معرفی آن میپردازم .
theodore-js یک ورودی متن (Input) contenteditable است که با هدف اصلی نمایش ایموجیها به صورت عکس طراحی شده است. نمایش ایموجی به صورت تصویر دو مزیت دارد: ۱. شخصیسازی: کاربر میتواند ایموجیهای اختصاصی و طراحیشده توسط خود را هنگام تایپ مشاهده کند. ۲. یکپارچگی در پلتفرمها: با این روش، میتوان یک ست ایموجی خاص را در تمام دستگاهها و مرورگرها به صورت یکسان نمایش داد. برای نمونه، تلگرام در تمامی نسخههای خود از ایموجیهای سیستمعامل iOS (اپل) استفاده میکند تا تجربه کاربری یکسانی ایجاد کند.
اگر به دنبال یک ورودی متن ساده، سبک و متمرکز بر ایموجیها هستید و نیازی به پیچیدگیهای ویرایشگرهای متن پیشرفته (Rich Text Editors) ندارید، تئودور گزینهای ایدهآل برای پروژه شماست. جزییات چگونگی نصب و استفاده از آن را میتوانید در اینجا ببینید.

در اکوسیستم React، کتابخانههای متعددی برای پیادهسازی ویرایشگرهای متن پیشرفته (Rich Text Editors) وجود دارد؛ با این حال، اکثر آنها ایموجی را صرفاً یک کاراکتر رشتهای (String) میدانند و تعداد محدودی از نمایش ایموجی به صورت تصویر پشتیبانی میکنند. از میان معدود کتابخانههایی که این قابلیت را ارائه میدهند، Lexical و Quill شناختهشدهترین گزینهها هستند.
در بررسی فنی Lexical، دو چالش جدی مشاهده شد:
اختلال در محاسبات کرسر: هنگام جابهجایی کرسر (Cursor) بین دو خط که هر دو شامل ایموجی هستند، این کتابخانه نمیتواند مکان جدید کرسر را به درستی محاسبه کند. این باگ در نوشتارهای طولانی، برای کاربر اذیتکننده خواهد بود.
محدودیت در رندر بصری: Lexical برای نمایش ایموجی از تگ <img> استفاده نمیکند، بلکه با استفاده از تگ <span> و ویژگی background-image تصویر را نمایش میدهد . این رویکرد باعث میشود هنگام انتخاب (Select) بخشی از متن، تصویر پسزمینه محو شده و کاربر مجدداً کاراکتر ایموجی پیشفرض سیستمعامل را مشاهده کند.

ویرایشگر Quill امکان درج تصاویر به صورت Inline را فراهم میکند که با کمی کدنویسی میتوان از آن برای نمایش ایموجیها استفاده کرد. اگرچه Quill در زبانهای چپبهراست (LTR) عملکرد پایداری دارد، اما در زبانهای راستبهچپ (RTL) با باگهای جدی در پیمایش میان متن و تصویر مواجه است. با این حال، در مجموع Quill نسبت به Lexical انتخاب بهتری به نظر میرسد.
جدول زیر این سه کتابخانه را مقایسه میکند

برای مقایسه دقیق حجم باندل (Bundle Size)، برای هر ادیتور یک اپلیکیشن ساده React پیادهسازی شد که در آن ادیتور وظیفه رندر ایموجیها را بر عهده داشت. برای اندازهگیری حجم، در هر سه پروژه از باندلر vite استفاده شد. حجم نهایی، مجموع حجم خودِ کتابخانه و تمامی وابستگیهای (Dependencies) آن است؛ برای نمونه، بخشی از حجم کتابخانه Quill ناشی از استفاده از Lodash است.
برای نمایش ایموجیها به صورت تصویر، استفاده از تگ<input> امکانپذیر نیست؛ چرا که این تگها صرفاً از رشتههای متنی (Strings) پشتیبانی میکنند. برای عبور از این محدودیت و بهرهگیری از تگهای <img> در محیط ورودی، تگ <div> با ویژگی contenteditable بهکار میرود. این قابلیت به ما اجازه میدهد علاوه بر متن، تمامی تگهای معتبر HTML را در محیط ورودی رندر کنیم. در Theodore-js، تنها به نمایش متن و عکسها به صورت inline نیاز داریم و بنابراین، محتوا در به صورت یک درخت کم عمق شامل تگهای p، span و img نمایش داده میشود. در منطق درونی تئودور، برای ذخیرهی محتوا، از یک آرایه دو بعدی از نودها (Nodes) استفاده میکنیم که در معماری سیستم به آن «درخت» (Tree) میگوییم؛ به طوری که هر زیرآرایه نمایانگر یک پاراگراف و اعضای آن شامل نودهای متنی و ایموجی است.
وضعیت(State) کلی تئودور برای مدیریت دقیق تعاملات کاربر، از سه جزء کلیدی تشکیل شده است:
محتوای کنونی (Content): همان آرایه دو بعدی که نودهای پاراگراف(ParagraphNode)،متنی (TextNode) و ایموجی (EmojiNode) را ذخیره میکند.
محل کنونی کرسر (Selection): مدیریت مکان نشانگر.
تاریخچه تغییرات (History): ذخیره تغییرات در قالب یک «استک» (Stack) از دستورات، جهت پیادهسازی قابلیت Undo.
محتوای ادیتور در تئودور به واسطه یک آرایه دوبعدی از نودها (Nodes) ذخیره میشود که در ساختار داخلی برنامه به آن «Tree» میگوییم. «نود» انتزاعیترین واحد سازنده محتوا در این کتابخانه است و سه نوع نود تعریف میشود: TextNode برای ذخیره رشتههای متنی، EmojiNode جهت نمایش ایموجیها و paragraphNode که نمایانگر یک پاراگراف مستقل است. در این معماری، تمامی نودهای متنی و ایموجی باید حتماً در دل یک paragraphNode محصور باشند و قرارگیری آنها خارج از نودِ پاراگراف، وضعیتی غیرمجاز و خطا تلقی میشود .
برای ذخیره وضعیت ورودی، از ساختار آرایهای از زیرآرایهها استفاده میشود که هر زیرآرایه نشاندهنده یک پاراگراف و نودهای موجود در آن است. در این مدل، عنصر نخست هر زیرآرایه همواره یک paragraphNode است. هر نود با یک شناسهی عددی منحصربهفرد به نام nodeIndex شناخته میشود. زمانی که ادیتور کاملاً خالی است، وضعیت آن به صورت زیر نمایش داده میشود؛ این یعنی تنها یک پاراگراف با شناسهی ۱ وجود دارد.
[[<P, 1>]]
اگر در همان خط متنی نوشته شود، وضعیت به این شکل تغییر مییابد:
[[<P, 1>, <T, 2>]]
که نشاندهنده اضافه شدن یک TextNode با شناسهی ۲ به پاراگراف اول است.
حالا اگر یک متن پیچیده شامل متن و ایموجی داشته باشیم:
سلام 👋
من فاطمه هستم و از دیدن شما خوشحالم 🌱🌱
امیدوارم این کتابخانه 📚 برای شما کاربردی باشد
یک حالت مجاز برای نمایش محتوا به شکل زیر است:
[ [<P, 1>, <T, 2>, <E, 3>], [<P, 4>, <T, 5>, <E, 6>, <E, 7>], [<P, 8>, <T, 9>, <E, 10>, <T, 11], ]
باید توجه داشت که ترتیب شمارهگذاری nodeIndexها مستقیماً به زمانِ ساخت نود توسط کاربر بستگی دارد. این اعداد از ۱ شروع شده و با ساخت هر نود جدید، یک واحد افزایش مییابند. ثابت بودن این شناسهها کمک میکند تا تئودور تشخیص دهد که تغییرات باید روی کدام نود اعمال شود یا نشانگر متن (Selection) در حال حاضر روی کدام نود قرار دارد.
در مرحله رندر نهایی، هر نود به المانهای متناظر HTML تبدیل میشود: paragraphNodeها به شکل تگ <p> ظاهر میشوند و نودهای همگروه خود را در بر میگیرند. اگر پاراگرافی خالی باشد، برای حفظ فرم بصری، یک تگ <br /> درون آن رندر میشود. TextNodeها به صورت تگ <span> رندر شده و متن کاربر را نمایش میدهند. برای EmojiNodeها، مسئولیت رندر به عهده توسعهدهنده گذاشته شده است؛ تئودور تابع renderEmoji را فراخوانی کرده و خروجی آن را در یک <span> محصور میکند. برای عملکرد بهینه، توصیه میشود تابع renderEmoji تنها یک تگ <img> بازگرداند.
برای دانستن محل قرارگیری کرسر، تئودور آبجکت selection از api مرورگر را به آبجکت selection قابل فهم برای خودش، تبدیل میکند. آبجکت selection شامل دو کلید اصلی start و end است که محدوده انتخاب شده توسط کاربر را مشخص میکنند؛ در صورتی که متنی انتخاب نشده باشد و تنها کرسر در جای خود قرار داشته باشد، مقادیر این دو کلید با یکدیگر یکسان خواهند بود.
در ساختار تئودور، هر نقطه از سلکشن با دو پارامتر nodeIndex و offset تعریف میشود. nodeIndex مشخص میکند که انتخاب در کدام نود از DOM صورت گرفته است و offset فاصله دقیق کرسر از ابتدای آن نود را نشان میدهد. شایان ذکر است که مفهوم offset تنها در textNodeها معنا دارد و برای نودهای پاراگراف یا ایموجی کاربردی ندارد.
یکی از چالشهای فنی در توسعه این بخش، تبدیل سلکشن (Selection) مرورگر به آبجکت استاندارد تئودور است. برای این انتقال، اطلاعات nodeIndex که در زمان رندر در المانها تعبیه شده، از ویژگیهایanchorNode و focusNode خوانده میشود. در این فرآیند باید به تفاوت رفتار مرورگرها توجه داشت: در حالی که کروم و سافاری مستقیماً المانی که کرسر بر روی آن قرار دارد را به عنوان anchorNode معرفی میکنند، فایرفاکس node والد را بازمیگرداند و مکان دقیق فرزند را از طریق anchorOffset مشخص میکند. تئودور با استفاده از تابع اختصاصی convertDomSelectionToEditorSelection تمامی این تفاوتهای ساختاری در مرورگرهای مختلف را یکپارچهسازی کرده و سلکشن نهایی را برای استفاده در تمامی بخشهای منطقی برنامه آماده میکند.
هنگام کار با المانهای contenteditable برخلاف فیلدهای ورودی استاندارد، مرورگرها در مدیریت صحیح عملیات Undo (بهویژه زمانی که پای ایموجیهای تصویری در میان باشد) عملکرد پایداری ندارند. برای حل این چالش، تئودور از یک رویکرد مدیریت تاریخچه اختصاصی بهره میبرد. در این رویکرد، به جای استفاده از رویکرد سنگینِ ذخیره کل محتوا (Snapshot) که در متنهای طولانی باعث هدررفت حافظه میشود، از روش بهینهتری مبتنی بر ذخیره تغییرات استفاده شده است. در این رویکرد، تنها اصلاحاتی که بر روی حالت فعلی انجام شده تا حالت جدید حاصل شود، ثبت میگردند تا در زمان نیاز، با انجام عکس آن فعالیتها، محتوا بازیابی شود. برای روشنتر شدن موضوع، فرض کنید کاربر، در ادیتور خالی، یک حرف وارد میکند؛ در این صورت این تغییر در استک تاریخچه به صورت زیر ذخیره میشود:
{ command: COMMAND_INSERT_TEXT, nodeIndex: <the node index of new created node>, prevState: null }
این آبجکت شامل نوع تغییر (command)، شناسهی نود تغییر یافته (nodeIndex) و وضعیت قبلی آن نود (prevState) است. برای مثال، اگر نودی جدید ساخته شود، مقدار prevState برابر با null خواهد بود و تئودور در زمان Undo متوجه میشود که باید آن نود را حذف کند. اما اگر نود از قبل وجود داشته باشد، محتوای آن پیش از تغییر ذخیره میشود تا در صورت بازگشت، دقیقاً همان متن قبلی جایگزین گردد.
در موارد پیچیدهتر، مانند زمانی که کاربر بخشی از متن را انتخاب و با کاراکتر جدیدی جایگزین میکند، یا ایموجی را در میانه یک رشته قرار میدهد، یک آبجکت به تنهایی برای بازسازی محتوا کافی نیست. در این حالت، چندین عملیات کوچک (مانند حذف بخشی از متن و سپس درج محتوای جدید) به صورت همزمان انجام میشوند. تصور کنید کاربر بخشی از متن را انتخاب کرده و کاراکتری را وارد میکند، در این حالت لازم است که ابتدا بخشی از محتوای نود که انتخاب شده حذف شود، سپس کاراکتر جدید اضافه شود. در این حالت برای ساده نگه داشتن کار، به جای آنکه تنها از یک آبجکت برای بازسازی محتوای ادیتور استفاده کنیم، از دو آبجکت استفاده میکنیم. یک آبجکت، تغییر حذف شدن متن را ذخیره میکند و دیگری، تغییر اضافه شدن متن را. اگر محتوای کنونی ادیتور I meet you tomorrow باشد و کاربر کلمه tomorrow را انتخاب و با t جایگزین کند تا به این عبارت برسد:I meet you t، برای این تغییر، دو آبجکت زیر در استک تاریخچه قرار میگیرند.
{ command: COMMAND_INSERT_TEXT, nodeIndex: <the nodeIndex of selected node>, prevState: "I meet you ", transactionId: 2, } { command: COMMAND_REPLACE_TEXT, nodeIndex: <the nodeIndex of selected node>, prevState: "I meet you tomorrow", transactionId: 2, }
حالا اگر بخواهیم تغییرات اعمال شده را برگردانیم، ابتدا آبجکت نخست را از روی استک برمیداریم و محتوا به «I meet you» تغییر مییابد؛ سپس بلافاصله آبجکت دوم پردازش میشود و محتوا را به «I meet you tomorrow» بازمیگرداند. به این ترتیب، ادیتور دقیقاً به وضعیتی بازمیگردد که کاربر پیش از انتخاب کلمهی «tomorrow» و جایگزینی آن با حرف «t» در اختیار داشت.
ممکن است این سوال برایتان پیش بیاید که سیستم چگونه متوجه میشود چه تعداد آبجکت باید از روی استک برداشته و اعمال شوند؟ پاسخ این پرسش در فیلد transactionId نهفته است. در هر بار اجرای دستور Undo، بالاترین آبجکت از استک استخراج و اعمال میشود و مادامی که آبجکتهای بعدی موجود در استک دارای شناسهی تراکنش (transactionId) یکسانی با آبجکت قبلی باشند، این فرآیند تکرار میشود. این سازوکار به ما اجازه میدهد تا تغییرات پیچیدهی محتوا را به مجموعهای از اعمال کوچک و برگشتپذیر تقسیم کنیم و با اجرای معکوس و مرحلهبهمرحلهی آنها، به وضعیت دقیق پیش از تغییر دست یابیم.
به عنوان یک نمونهی کاربردی، افزودن یک ایموجی در میانهی یک رشته متنی، باعث شکسته شدن یک textNode به دو نود مجزا و اضافه شدن یک emojiNode در میان آنها میشود. در این حالت، تئودور با ثبت ۴ آبجکت (شامل حذف نود متنی اصلی، و اضافه شدن دو نود متنی جدید و یک نود ایموجی) که همگی دارای شناسهی تراکنش یکسانی هستند، امکان بازسازی دقیق وضعیت را فراهم میسازد. همچنین در برخی موارد خاص، دادههای تکمیلی نیز ذخیره میشوند؛ برای مثال، هنگام حذف یک نود، اطلاعات نودهای مجاور (قبل و بعد) نیز ثبت میگردد تا در زمان عملیات Undo، تئودور به درستی تشخیص دهد که نود بازیابی شده باید دقیقاً در کدام نقطه از Tree قرار گیرد.
تئودور در هر پروژهای که مدیریت بصری و نمایش دقیق ایموجیها در اولویت باشد، انتخابی مناسب است. این کتابخانه میتواند برای برنامههایی در آنها ایموجیها شخصیسازی شدهاند، ارزشآفرین باشد.
درباره مسیر آینده این پروژه، شاید بهدلیل شباهتهای ساختاری تئودور به ویرایشگرهای پیشرفته گمان کنید که تئودور به سمت یک ادیتور سنگین و همهمنظوره (Rich Text Editor) حرکت خواهد کرد؛ اما اینگونه نخواهد شد و در آینده ویژگیهایی کاملا جدید را از این ادیتور خواهید دید 💫!