إضافة نظام تعليق إلى محرر WYSIWYG

نشرت: 2022-03-10
ملخص سريع ↬ في هذه المقالة ، سنعيد استخدام محرر WYSIWYG الأساسي الذي تم تضمينه في المقالة الأولى لبناء نظام تعليق لمحرر WYSIWYG الذي يمكّن المستخدمين من تحديد نص داخل مستند ومشاركة تعليقاتهم عليه. سنقوم أيضًا بإحضار RecoilJS لإدارة الحالة في تطبيق واجهة المستخدم. (رمز النظام الذي نبنيه هنا متاح في مستودع Github للرجوع إليه.)

في السنوات الأخيرة ، رأينا Collaboration تخترق الكثير من تدفقات العمل الرقمية وحالات الاستخدام عبر العديد من المهن. فقط داخل مجتمع التصميم وهندسة البرمجيات ، نرى المصممين يتعاونون في أعمال التصميم باستخدام أدوات مثل Figma ، والفرق التي تقوم Sprint وتخطيط المشاريع باستخدام أدوات مثل الجدارية والمقابلات التي يتم إجراؤها باستخدام CoderPad. تهدف كل هذه الأدوات باستمرار إلى سد الفجوة بين تجربة الإنترنت والعالم المادي لتنفيذ مهام سير العمل هذه وجعل تجربة التعاون غنية وسلسة قدر الإمكان.

بالنسبة لغالبية أدوات التعاون مثل هذه ، فإن القدرة على مشاركة الآراء مع بعضنا البعض وإجراء مناقشات حول نفس المحتوى أمر لا بد منه. إن نظام التعليق الذي يمكّن المتعاونين من التعليق على أجزاء من المستند وإجراء محادثات حولها هو في صميم هذا المفهوم. جنبًا إلى جنب مع إنشاء واحد للنص في محرر WYSIWYG ، تحاول المقالة إشراك القراء في الطريقة التي نحاول بها تقييم الإيجابيات والسلبيات ومحاولة إيجاد توازن بين تعقيد التطبيق وتجربة المستخدم عندما يتعلق الأمر ببناء ميزات لمحرري WYSIWYG أو معالجات الكلمات بشكل عام.

تمثيل التعليقات في بنية المستند

من أجل إيجاد طريقة لتمثيل التعليقات في هيكل بيانات مستند نص منسق ، دعنا نلقي نظرة على بعض السيناريوهات التي يمكن بموجبها إنشاء التعليقات داخل محرر.

  • التعليقات التي تم إنشاؤها فوق نص لا يحتوي على أنماط عليه (السيناريو الأساسي) ؛
  • التعليقات التي تم إنشاؤها فوق النص الذي قد يكون غامقًا / مائلًا / تحته خط ، وما إلى ذلك ؛
  • التعليقات التي تتداخل مع بعضها البعض بطريقة ما (تداخل جزئي حيث يشترك تعليقان في بضع كلمات فقط أو محتواة بالكامل حيث يتم تضمين نص أحد التعليقات بالكامل في نص تعليق آخر) ؛
  • التعليقات التي تم إنشاؤها على نص داخل رابط (خاصة لأن الروابط هي بحد ذاتها عُقد في هيكل المستند الخاص بنا) ؛
  • التعليقات التي تمتد عبر فقرات متعددة (خاصة لأن الفقرات عبارة عن عُقد في هيكل المستند الخاص بنا ويتم تطبيق التعليقات على العقد النصية التي هي عناصر فرعية للفقرة).

بالنظر إلى حالات الاستخدام المذكورة أعلاه ، يبدو أن التعليقات بالطريقة التي يمكن أن تظهر بها في مستند نصي منسق تشبه إلى حد بعيد أنماط الأحرف (غامق ومائل وما إلى ذلك). يمكن أن تتداخل مع بعضها البعض ، وتنتقل عبر النص في أنواع أخرى من العقد مثل الروابط وحتى تمتد على عقد أصلية متعددة مثل الفقرات.

لهذا السبب ، نستخدم نفس الطريقة لتمثيل التعليقات كما نفعل مع أنماط الأحرف ، أي "العلامات" (كما يطلق عليها في مصطلحات SlateJS). العلامات هي مجرد خصائص عادية على العقد - التخصص هو أن واجهة برمجة تطبيقات Slate حول العلامات ( Editor.addMark و Editor.removeMark ) تتعامل مع تغيير التسلسل الهرمي للعقد عندما يتم تطبيق علامات متعددة على نفس نطاق النص. هذا مفيد للغاية بالنسبة لنا لأننا نتعامل مع الكثير من المجموعات المختلفة من التعليقات المتداخلة.

تعليق المواضيع كعلامات

عندما يحدد المستخدم نطاقًا من النص ويحاول إدراج تعليق ، فإنه من الناحية الفنية يبدأ سلسلة تعليق جديدة لهذا النطاق النصي. نظرًا لأننا نسمح لهم بإدراج تعليق والردود اللاحقة على هذا التعليق ، فإننا نتعامل مع هذا الحدث كإدراج سلسلة تعليق جديد في المستند.

الطريقة التي نمثل بها سلاسل التعليقات كعلامات هي أن كل سلسلة تعليق يتم تمثيلها بعلامة تسمى commentThread_threadID حيث يعتبر threadID فريدًا نقوم بتعيينه لكل سلسلة تعليق. لذلك ، إذا كان نفس النطاق من النص يحتوي على سلسلتين للتعليق عليهما ، فسيكون له خاصيتان تم ضبطهما على true - commentThread_thread1 و commentThread_thread2 . هذا هو المكان الذي تتشابه فيه سلاسل التعليقات إلى حد كبير مع أنماط الأحرف ، لأنه إذا كان النص نفسه غامقًا ومائلًا ، فسيتم تعيين الخصائص على " true " - bold italic .

قبل الغوص في إعداد هذه البنية فعليًا ، يجدر النظر في كيفية تغيير العقد النصية عند تطبيق سلاسل التعليقات عليها. الطريقة التي يعمل بها هذا (كما هو الحال مع أي علامة) هي أنه عندما يتم تعيين خاصية علامة على النص المحدد ، فإن Slate's Editor.addMark API ستقسم عقدة (عقدة) النص إذا لزم الأمر بحيث في البنية الناتجة ، العقد النصية يتم إعدادها بطريقة تجعل كل عقدة نصية لها نفس قيمة العلامة بالضبط.

لفهم هذا بشكل أفضل ، ألق نظرة على الأمثلة الثلاثة التالية التي تعرض الحالة قبل وبعد العقد النصية بمجرد إدراج سلسلة تعليق في النص المحدد:

رسم توضيحي يوضح كيفية تقسيم عقدة النص مع إدراج سلسلة تعليق أساسية
يتم تقسيم عقدة النص إلى ثلاثة حيث يتم إدراج علامة سلسلة التعليق في منتصف النص. (معاينة كبيرة)
رسم توضيحي يوضح كيفية تقسيم عقدة النص في حالة التداخل الجزئي لسلاسل التعليقات
تؤدي إضافة سلسلة تعليق فوق "نص له" إلى إنشاء عقدتين نصيتين جديدتين. (معاينة كبيرة)
رسم توضيحي يوضح كيفية تقسيم عقدة النص في حالة التداخل الجزئي لسلاسل التعليقات مع الروابط
تؤدي إضافة سلسلة تعليق فوق "يحتوي على رابط" إلى تقسيم العقدة النصية داخل الرابط أيضًا. (معاينة كبيرة)
المزيد بعد القفز! أكمل القراءة أدناه ↓

تمييز النص المعلق

الآن بعد أن عرفنا كيف سنقوم بتمثيل التعليقات في بنية المستند ، دعنا نمضي قدمًا ونضيف القليل إلى مثال المستند من المقالة الأولى وقم بتكوين المحرر لإظهارها بالفعل كما تم تمييزها. نظرًا لأنه سيكون لدينا الكثير من الوظائف المساعدة للتعامل مع التعليقات في هذه المقالة ، فإننا ننشئ وحدة EditorCommentUtils التي ستضم كل هذه الأدوات. للبدء ، نقوم بإنشاء وظيفة تنشئ علامة لمعرف سلسلة تعليق معين. ثم نستخدم ذلك لإدراج بعض سلاسل التعليقات في ExampleDocument بنا.

 # src/utils/EditorCommentUtils.js const COMMENT_THREAD_PREFIX = "commentThread_"; export function getMarkForCommentThreadID(threadID) { return `${COMMENT_THREAD_PREFIX}${threadID}`; }

توضح الصورة أدناه باللون الأحمر نطاقات النص التي لدينا كمثال على سلاسل التعليقات المضافة في مقتطف الشفرة التالي. لاحظ أن النص "Richard McClintock" يحتوي على سلسلتي تعليق متداخلتين. على وجه التحديد ، هذه هي حالة خيط تعليق واحد يتم احتوائه بالكامل داخل آخر.

صورة توضح نطاقات النص في المستند التي سيتم التعليق عليها - أحدها مضمّن بالكامل في الآخر.
نطاقات النص التي سيتم التعليق عليها بخط أحمر. (معاينة كبيرة)
 # src/utils/ExampleDocument.js import { getMarkForCommentThreadID } from "../utils/EditorCommentUtils"; import { v4 as uuid } from "uuid"; const exampleOverlappingCommentThreadID = uuid(); const ExampleDocument = [ ... { text: "Lorem ipsum", [getMarkForCommentThreadID(uuid())]: true, }, ... { text: "Richard McClintock", // note the two comment threads here. [getMarkForCommentThreadID(uuid())]: true, [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, { text: ", a Latin scholar", [getMarkForCommentThreadID(exampleOverlappingCommentThreadID)]: true, }, ... ];

نركز على جانب واجهة المستخدم لأشياء نظام التعليق في هذه المقالة ، لذلك نقوم بتعيين معرفات لهم في نموذج المستند مباشرةً باستخدام npm package uuid. من المحتمل جدًا أنه في إصدار إنتاجي من المحرر ، يتم إنشاء هذه المعرفات بواسطة خدمة الواجهة الخلفية.

نركز الآن على تعديل المحرر لإظهار هذه العقد النصية كما تم تمييزها. من أجل القيام بذلك ، عند عرض العقد النصية ، نحتاج إلى طريقة لمعرفة ما إذا كانت تحتوي على سلاسل تعليق عليها. نضيف استخدام getCommentThreadsOnTextNode لذلك. نحن نبني على مكون StyledText الذي أنشأناه في المقالة الأولى للتعامل مع الحالة التي قد يحاول فيها عرض عقدة نصية مع تعليقات عليها. نظرًا لأن لدينا بعض الوظائف القادمة التي ستتم إضافتها إلى العقد النصية المعلقة لاحقًا ، فإننا نقوم بإنشاء مكون CommentedText الذي يعرض النص المعلق عليه. StyledText مما إذا كانت العقدة النصية التي تحاول عرضها بها أي تعليقات. إذا كان الأمر كذلك ، فإنه يعرض CommentedText . يستخدم getCommentThreadsOnTextNode لاستنتاج ذلك.

 # src/utils/EditorCommentUtils.js export function getCommentThreadsOnTextNode(textNode) { return new Set( // Because marks are just properties on nodes, // we can simply use Object.keys() here. Object.keys(textNode) .filter(isCommentThreadIDMark) .map(getCommentThreadIDFromMark) ); } export function getCommentThreadIDFromMark(mark) { if (!isCommentThreadIDMark(mark)) { throw new Error("Expected mark to be of a comment thread"); } return mark.replace(COMMENT_THREAD_PREFIX, ""); } function isCommentThreadIDMark(mayBeCommentThread) { return mayBeCommentThread.indexOf(COMMENT_THREAD_PREFIX) === 0; }

قامت المقالة الأولى ببناء مكون StyledText الذي يعرض العقد النصية (التعامل مع أنماط الأحرف وما إلى ذلك). نقوم بتوسيع هذا المكون لاستخدام الاستخدام أعلاه وتقديم مكون CommentedText إذا كانت العقدة لديها تعليقات عليه.

 # src/components/StyledText.js import { getCommentThreadsOnTextNode } from "../utils/EditorCommentUtils"; export default function StyledText({ attributes, children, leaf }) { ... const commentThreads = getCommentThreadsOnTextNode(leaf); if (commentThreads.size > 0) { return ( <CommentedText {...attributes} // We use commentThreads and textNode props later in the article. commentThreads={commentThreads} textNode={leaf} > {children} </CommentedText> ); } return <span {...attributes}>{children}</span>; }

يوجد أدناه تنفيذ CommentedText الذي يعرض عقدة النص ويربط CSS الذي يعرضها كما تم تمييزها.

 # src/components/CommentedText.js import "./CommentedText.css"; import classNames from "classnames"; export default function CommentedText(props) { const { commentThreads, ...otherProps } = props; return ( <span {...otherProps} className={classNames({ comment: true, })} > {props.children} </span> ); } # src/components/CommentedText.css .comment { background-color: #feeab5; }

مع تجميع كل الكود أعلاه معًا ، نرى الآن العقد النصية مع سلاسل التعليقات المميزة في المحرر.

تظهر العقد النصية المُعلَّقة على أنها مميزة بعد إدراج سلاسل التعليقات
تظهر العقد النصية المُعلَّقة على أنها مميزة بعد إدراج سلاسل التعليقات. (معاينة كبيرة)

ملاحظة : لا يمكن للمستخدمين حاليًا معرفة ما إذا كان هناك نص معين به تعليقات متداخلة. يبدو نطاق النص المميز بالكامل كسلسلة تعليق واحدة. نتناول ذلك لاحقًا في المقالة حيث نقدم مفهوم سلسلة التعليقات النشطة التي تتيح للمستخدمين تحديد سلسلة تعليق معينة والقدرة على رؤية نطاقها في المحرر.

تخزين واجهة المستخدم للتعليقات

قبل أن نضيف الوظيفة التي تمكن المستخدم من إدراج تعليقات جديدة ، نقوم أولاً بإعداد حالة واجهة المستخدم لعقد سلاسل التعليقات الخاصة بنا. في هذه المقالة ، نستخدم RecoilJS كمكتبة إدارة الدولة لدينا لتخزين سلاسل التعليقات والتعليقات الموجودة داخل سلاسل الرسائل والبيانات الوصفية الأخرى مثل وقت الإنشاء والحالة ومؤلف التعليق وما إلى ذلك ، دعنا نضيف Recoil إلى تطبيقنا:

 > yarn add recoil

نستخدم ذرات الارتداد لتخزين هاتين البنيتين من البيانات. إذا لم تكن معتادًا على Recoil ، فإن الذرات هي التي تحمل حالة التطبيق. لأجزاء مختلفة من حالة التطبيق ، عادة ما تريد إنشاء ذرات مختلفة. Atom Family عبارة عن مجموعة من الذرات - يمكن اعتبارها Map من مفتاح فريد يحدد الذرة إلى الذرات نفسها. من الجدير مراجعة المفاهيم الأساسية للارتداد في هذه المرحلة والتعرف عليها.

بالنسبة لحالة الاستخدام الخاصة بنا ، نقوم بتخزين سلاسل التعليقات كعائلة Atom ثم نلف تطبيقنا في مكون RecoilRoot . يتم تطبيق RecoilRoot لتوفير السياق الذي سيتم استخدام قيم الذرة فيه. نقوم بإنشاء وحدة تعليق منفصلة CommentState تحتوي على تعريفات ذرة الارتداد الخاصة بنا حيث نضيف المزيد من تعريفات الذرة لاحقًا في المقالة.

 # src/utils/CommentState.js import { atom, atomFamily } from "recoil"; export const commentThreadsState = atomFamily({ key: "commentThreads", default: [], }); export const commentThreadIDsState = atom({ key: "commentThreadIDs", default: new Set([]), });

يستحق ذكر بعض الأشياء حول تعريفات الذرة هذه:

  • يتم تحديد كل عائلة ذرة / ذرة بشكل فريد بواسطة key ويمكن إعدادها بقيمة افتراضية.
  • مع قيامنا ببناء المزيد في هذه المقالة ، سنحتاج إلى طريقة للتكرار عبر جميع سلاسل التعليقات التي قد تعني في الأساس الحاجة إلى طريقة للتكرار عبر عائلة commentThreadsState atom. في وقت كتابة هذا المقال ، كانت طريقة القيام بذلك باستخدام Recoil هي إعداد ذرة أخرى تحتوي على جميع معرفات عائلة الذرة. نفعل ذلك مع commentThreadIDsState أعلاه. يجب أن تظل هاتان الذرتان متزامنتين عندما نضيف / نحذف سلاسل التعليقات.

نضيف RecoilRoot في مكون App الجذر الخاص بنا حتى نتمكن من استخدام هذه الذرات لاحقًا. توفر وثائق Recoil أيضًا مكونًا مفيدًا لبرنامج تصحيح الأخطاء الذي نأخذه كما هو وننزله في محررنا. سيترك هذا المكون console.debug logs إلى وحدة تحكم Dev الخاصة بنا حيث يتم تحديث ذرات الارتداد في الوقت الفعلي.

 # src/components/App.js import { RecoilRoot } from "recoil"; export default function App() { ... return ( <RecoilRoot> > ... <Editor document={document} onChange={updateDocument} /> </RecoilRoot> ); }
 # src/components/Editor.js export default function Editor({ ... }): JSX.Element { ..... return ( <> <Slate> ..... </Slate> <DebugObserver /> </> ); function DebugObserver(): React.Node { // see API link above for implementation. }

نحتاج أيضًا إلى إضافة رمز يهيئ ذراتنا باستخدام سلاسل التعليقات الموجودة بالفعل في المستند (تلك التي أضفناها إلى مستند المثال في القسم السابق ، على سبيل المثال). نقوم بذلك في وقت لاحق عندما نبني الشريط الجانبي للتعليقات الذي يحتاج إلى قراءة جميع سلاسل التعليقات في المستند.

في هذه المرحلة ، نقوم بتحميل تطبيقنا ، وتأكد من عدم وجود أخطاء تشير إلى إعداد Recoil الخاص بنا والمضي قدمًا.

إضافة تعليقات جديدة

في هذا القسم ، نضيف زرًا إلى شريط الأدوات يتيح للمستخدم إضافة تعليقات (أي إنشاء سلسلة تعليق جديدة) لنطاق النص المحدد. عندما يختار المستخدم نطاقًا نصيًا وينقر على هذا الزر ، نحتاج إلى القيام بما يلي:

  1. قم بتعيين معرف فريد لسلسلة التعليقات الجديدة التي يتم إدراجها.
  2. أضف علامة جديدة إلى هيكل مستند Slate باستخدام المعرف حتى يرى المستخدم هذا النص مميزًا.
  3. أضف سلسلة التعليقات الجديدة إلى ذرات الارتداد التي أنشأناها في القسم السابق.

دعنا نضيف وظيفة استخدام إلى EditorCommentUtils التي تقوم بالرقم 1 و 2.

 # src/utils/EditorCommentUtils.js import { Editor } from "slate"; import { v4 as uuidv4 } from "uuid"; export function insertCommentThread(editor, addCommentThreadToState) { const threadID = uuidv4(); const newCommentThread = { // comments as added would be appended to the thread here. comments: [], creationTime: new Date(), // Newly created comment threads are OPEN. We deal with statuses // later in the article. status: "open", }; addCommentThreadToState(threadID, newCommentThread); Editor.addMark(editor, getMarkForCommentThreadID(threadID), true); return threadID; }

باستخدام مفهوم العلامات لتخزين كل سلسلة تعليق كعلامة خاصة بها ، يمكننا ببساطة استخدام Editor.addMark API لإضافة سلسلة تعليق جديدة على نطاق النص المحدد. تتناول هذه المكالمة وحدها جميع الحالات المختلفة لإضافة التعليقات - والتي وصفنا بعضها في القسم السابق - التعليقات المتداخلة جزئيًا ، والتعليقات داخل / الروابط المتداخلة ، والتعليقات على النص الغامق / المائل ، والتعليقات الممتدة على الفقرات وما إلى ذلك. يقوم استدعاء API هذا بضبط التسلسل الهرمي للعقدة لإنشاء العديد من العقد النصية الجديدة حسب الحاجة للتعامل مع هذه الحالات.

addCommentThreadToState هي وظيفة رد اتصال تتعامل مع الخطوة رقم 3 - إضافة مؤشر ترابط التعليق الجديد إلى Recoil atom. نقوم بتنفيذ ذلك بعد ذلك كخطاف مخصص لرد الاتصال بحيث يمكن إعادة استخدامه. يحتاج رد الاتصال هذا إلى إضافة سلسلة تعليق جديدة إلى الذرات - commentThreadsState و commentThreadIDsState . لتتمكن من القيام بذلك ، نستخدم الخطاف useRecoilCallback . يمكن استخدام هذا الخطاف لإنشاء رد اتصال يحصل على بعض الأشياء التي يمكن استخدامها لقراءة / تعيين بيانات الذرة. الوظيفة التي نهتم بها الآن هي وظيفة set التي يمكن استخدامها لتحديث قيمة الذرة كما set(atom, newValueOrUpdaterFunction) .

 # src/hooks/useAddCommentThreadToState.js import { commentThreadIDsState, commentThreadsState, } from "../utils/CommentState"; import { useRecoilCallback } from "recoil"; export default function useAddCommentThreadToState() { return useRecoilCallback( ({ set }) => (id, threadData) => { set(commentThreadIDsState, (ids) => new Set([...Array.from(ids), id])); set(commentThreadsState(id), threadData); }, [] ); }

يضيف الاستدعاء الأول المراد set معرفًا جديدًا إلى المجموعة الحالية من معرفات سلسلة التعليقات وإرجاع Set الجديدة (التي تصبح القيمة الجديدة للذرة).

في الاستدعاء الثاني ، نحصل على atom للمعرف من عائلة atom - commentThreadsState مثل commentThreadsState(id) ثم قم بتعيين قيمة threadData لتكون قيمتها. atomFamilyName(atomID) هو كيف يتيح لنا Recoil الوصول إلى ذرة من عائلة الذرة باستخدام المفتاح الفريد. بشكل فضفاض ، يمكننا القول أنه إذا كانت commentThreadsState عبارة عن خريطة جافا سكريبت ، فإن هذه المكالمة هي في الأساس - commentThreadsState.set(id, threadData) .

الآن بعد أن أصبح لدينا كل إعداد الكود هذا للتعامل مع إدراج سلسلة تعليق جديدة إلى المستند وذرات الارتداد ، دعنا نضيف زرًا إلى شريط الأدوات الخاص بنا وقم بتوصيله باستدعاء هذه الوظائف.

 # src/components/Toolbar.js import { insertCommentThread } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); ... const addCommentThread = useAddCommentThreadToState(); const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); }, [editor, addCommentThread]); return ( <div className="toolbar"> ... <ToolBarButton isActive={false} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> ); }

ملاحظة : نحن نستخدم onMouseDown وليس onClick وهو ما كان سيجعل المحرر يفقد التركيز ويصبح التحديد null . لقد ناقشنا ذلك بمزيد من التفصيل في قسم إدراج الرابط في المقالة الأولى.

في المثال أدناه ، نرى الإدراج قيد التنفيذ لسلسلة تعليق بسيطة وسلسلة تعليق متداخلة مع روابط. لاحظ كيف نحصل على تحديثات من Recoil Debugger تؤكد أن حالتنا يتم تحديثها بشكل صحيح. نتحقق أيضًا من إنشاء عقد نصية جديدة أثناء إضافة سلاسل رسائل إلى المستند.

يؤدي إدراج سلسلة تعليق إلى تقسيم العقدة النصية مما يجعل النص المعلق عليه عقدة خاصة به.
يتم إنشاء المزيد من العقد النصية عندما نضيف تعليقات متداخلة.

تعليقات متداخلة

قبل أن نبدأ في إضافة المزيد من الميزات إلى نظام التعليق الخاص بنا ، نحتاج إلى اتخاذ بعض القرارات حول كيفية تعاملنا مع التعليقات المتداخلة ومجموعاتها المختلفة في المحرر. لمعرفة سبب حاجتنا إلى ذلك ، دعنا نلقي نظرة سريعة على كيفية عمل Comment Popover - وهي وظيفة سنقوم ببنائها لاحقًا في المقالة. عندما ينقر المستخدم على نص معين به سلسلة (سلاسل) تعليق عليه ، فإننا "نختار" سلسلة تعليق ونعرض نافذة منبثقة حيث يمكن للمستخدم إضافة تعليقات إلى هذا الموضوع.

عندما ينقر المستخدم على عقدة نصية بها تعليقات متداخلة ، يحتاج المحرر إلى تحديد سلسلة التعليقات التي يجب تحديدها.

كما يمكنك أن تقول من الفيديو أعلاه ، أصبحت كلمة "المصممون" الآن جزءًا من ثلاث سلاسل تعليق. لذلك لدينا سلسلتي تعليق تتداخلان مع بعضهما البعض على كلمة واحدة. وكلا سلاسل التعليقات هذه (# 1 و # 2) مضمنة بالكامل داخل نطاق نصي أطول لسلسلة التعليقات (# 3). هذا يثير بعض الأسئلة:

  1. أي سلسلة تعليق يجب أن نختارها ونعرضها عندما ينقر المستخدم على كلمة "المصممون"؟
  2. استنادًا إلى الطريقة التي قررنا بها معالجة السؤال أعلاه ، هل سنواجه حالة تداخل حيث لن يؤدي النقر فوق أي كلمة مطلقًا إلى تنشيط سلسلة تعليق معينة ولا يمكن الوصول إلى سلسلة الرسائل على الإطلاق؟

هذا يعني في حالة وجود تعليقات متداخلة ، فإن أهم شيء يجب مراعاته هو - بمجرد قيام المستخدم بإدراج سلسلة تعليق ، هل ستكون هناك طريقة تمكنه من تحديد سلسلة التعليق هذه في المستقبل من خلال النقر على بعض النص بداخله هو - هي؟ إذا لم يكن الأمر كذلك ، فربما لا نريد السماح لهم بإدخاله في المقام الأول. لضمان احترام هذا المبدأ في معظم الأوقات في محررنا ، نقدم قاعدتين بخصوص التعليقات المتداخلة ونطبقها في محررنا.

قبل أن نحدد هذه القواعد ، يجدر بنا أن نذكر أن المحررين المختلفين ومعالجات النصوص لها مناهج مختلفة عندما يتعلق الأمر بالتعليقات المتداخلة. لتبسيط الأمور ، لا يسمح بعض المحررين بالتعليقات المتداخلة على الإطلاق. في حالتنا ، نحاول إيجاد حل وسط من خلال عدم السماح بحالات معقدة للغاية من التداخلات ولكن مع الاستمرار في السماح بالتعليقات المتداخلة بحيث يمكن للمستخدمين الحصول على تجربة تعاون ومراجعة أكثر ثراءً.

أقصر نطاق تعليق القاعدة

تساعدنا هذه القاعدة في الإجابة على السؤال رقم 1 أعلاه فيما يتعلق بأي سلسلة تعليق لتحديد ما إذا كان المستخدم ينقر على عقدة نصية بها سلاسل تعليق متعددة عليها. القاعدة هي:

"إذا نقر المستخدم على نص يحتوي على سلاسل تعليق متعددة عليه ، فسنجد سلسلة التعليقات لأقصر نطاق نصي ونحدده."

حدسيًا ، من المنطقي القيام بذلك بحيث يكون لدى المستخدم دائمًا طريقة للوصول إلى سلسلة التعليقات الأعمق المضمنة بالكامل داخل سلسلة تعليق أخرى. بالنسبة للشروط الأخرى (التداخل الجزئي أو عدم التداخل) ، يجب أن يكون هناك بعض النص الذي يحتوي على سلسلة تعليق واحدة فقط ، لذا يجب أن يكون من السهل استخدام هذا النص لتحديد سلسلة التعليق هذه. إنها حالة تداخل كامل (أو كثيف ) للخيوط ولماذا نحتاج إلى هذه القاعدة.

لنلقِ نظرة على حالة معقدة نوعًا ما من التداخل تسمح لنا باستخدام هذه القاعدة و "فعل الشيء الصحيح" عند تحديد سلسلة التعليقات.

مثال يُظهر ثلاثة سلاسل تعليق متداخلة مع بعضها البعض بطريقة أن الطريقة الوحيدة لتحديد سلسلة تعليق هي استخدام قاعدة أقصر طول.
باتباع قاعدة سلسلة رسائل التعليق الأقصر ، يؤدي النقر فوق "B" إلى تحديد سلسلة التعليقات رقم 1. (معاينة كبيرة)

في المثال أعلاه ، يُدرج المستخدم سلاسل التعليقات التالية بهذا الترتيب:

  1. تعليق الموضوع رقم 1 على الحرف "ب" (الطول = 1).
  2. تعليق الموضوع رقم 2 على 'AB' (الطول = 2).
  3. تعليق الموضوع رقم 3 على 'BC' (الطول = 2).

في نهاية هذه الإدخالات ، وبسبب الطريقة التي يقسم بها Slate العقد النصية بالعلامات ، سيكون لدينا ثلاث عقد نصية - واحدة لكل حرف. الآن ، إذا نقر المستخدم على "ب" ، بالانتقال إلى قاعدة أقصر طول ، فإننا نختار الخيط رقم 1 لأنه الأقصر من الثلاثة في الطول. إذا لم نفعل ذلك ، فلن يكون لدينا طريقة لتحديد سلسلة التعليقات رقم 1 لأنها مكونة من حرف واحد فقط في الطول وهي أيضًا جزء من سلسلتين أخريين.

على الرغم من أن هذه القاعدة تجعل من السهل عرض سلاسل تعليق أقصر طولًا ، إلا أننا قد نواجه مواقف يتعذر فيها الوصول إلى سلاسل التعليقات الأطول نظرًا لأن جميع الأحرف الموجودة فيها جزء من سلسلة تعليق أخرى أقصر. لنلق نظرة على مثال لذلك.

لنفترض أن لدينا 100 حرف (على سبيل المثال ، تمت كتابة الحرف "أ" 100 مرة أي) ويقوم المستخدم بإدراج سلاسل التعليقات بالترتيب التالي:

  1. تعليق الموضوع رقم 1 من النطاق 20،80
  2. تعليق الموضوع رقم 2 من النطاق 0،50
  3. تعليق الموضوع رقم 3 من النطاق 51100
مثال يوضح قاعدة أقصر طول مما يجعل سلسلة تعليق غير قابلة للتحديد حيث يتم تغطية كل نصها بواسطة سلاسل تعليق أقصر.
كل النص الموجود ضمن سلسلة التعليقات رقم 1 هو أيضًا جزء من سلسلة تعليق أخرى أقصر من رقم 1. (معاينة كبيرة)

كما ترى في المثال أعلاه ، إذا اتبعنا القاعدة التي وصفناها للتو هنا ، فإن النقر فوق أي حرف بين # 20 و # 80 ، سيحدد دائمًا سلاسل الرسائل رقم 2 أو رقم 3 نظرًا لأنها أقصر من رقم 1 وبالتالي رقم 1 لن تكون قابلة للتحديد. سيناريو آخر حيث يمكن لهذه القاعدة أن تتركنا مترددين فيما يتعلق بسلسلة التعليقات التي يجب تحديدها عندما يكون هناك أكثر من سلاسل تعليق واحدة بنفس الطول الأقصر على عقدة نصية.

لمثل هذا المزيج من التعليقات المتداخلة والعديد من التركيبات الأخرى التي يمكن للمرء أن يفكر فيها حيث يؤدي اتباع هذه القاعدة إلى جعل سلسلة تعليق معينة غير قابلة للوصول عن طريق النقر فوق النص ، نقوم بإنشاء شريط جانبي للتعليقات لاحقًا في هذه المقالة والذي يمنح المستخدم عرضًا لجميع سلاسل التعليقات موجود في المستند حتى يتمكنوا من النقر فوق هذه المواضيع في الشريط الجانبي وتنشيطها في المحرر لرؤية نطاق التعليق. ما زلنا نرغب في الحصول على هذه القاعدة وتنفيذها لأنها يجب أن تغطي الكثير من سيناريوهات التداخل باستثناء الأمثلة الأقل احتمالًا التي ذكرناها أعلاه. لقد بذلنا كل هذا الجهد حول هذه القاعدة بشكل أساسي لأن رؤية النص المميز في المحرر والنقر عليه للتعليق هي طريقة أكثر سهولة للوصول إلى تعليق على النص بدلاً من مجرد استخدام قائمة من التعليقات في الشريط الجانبي.

قاعدة الإدراج

القاعدة هي:

"إذا كان المستخدم قد حدد النص ويحاول التعليق عليه وقد تمت تغطيته بالكامل بالفعل من خلال سلسلة (سلاسل) التعليق ، فلا تسمح بهذا الإدراج."

هذا لأنه إذا سمحنا بهذا الإدراج ، فسينتهي الأمر بكل حرف في هذا النطاق بوجود سلسلتي تعليق على الأقل (أحدهما موجود والآخر الجديد الذي سمحنا به للتو) مما يجعل من الصعب علينا تحديد أيهما نختاره عند ينقر المستخدم على هذه الشخصية لاحقًا.

بالنظر إلى هذه القاعدة ، قد يتساءل المرء عن سبب حاجتنا إليها في المقام الأول إذا كان لدينا بالفعل قاعدة أقصر نطاق للتعليق التي تسمح لنا بتحديد أصغر نطاق نصي. لماذا لا نسمح لجميع مجموعات التداخلات إذا كان بإمكاننا استخدام القاعدة الأولى لاستنتاج سلسلة التعليق الصحيحة لإظهارها؟ مثل بعض الأمثلة التي ناقشناها سابقًا ، تعمل القاعدة الأولى مع الكثير من السيناريوهات ولكن ليس جميعها. باستخدام قاعدة الإدراج ، نحاول تقليل عدد السيناريوهات التي لا يمكن أن تساعدنا فيها القاعدة الأولى ويتعين علينا الرجوع إلى الشريط الجانبي باعتباره الطريقة الوحيدة للمستخدم للوصول إلى سلسلة التعليقات هذه. كما تمنع قاعدة الإدراج التداخلات الدقيقة لسلاسل التعليقات. يتم تنفيذ هذه القاعدة بشكل شائع من قبل الكثير من المحررين المشهورين.

يوجد أدناه مثال حيث إذا لم تكن هذه القاعدة موجودة ، فسنسمح بسلسلة التعليقات رقم 3 وبعد ذلك كنتيجة للقاعدة الأولى ، # 3 لن تكون متاحة لأنها ستصبح الأطول في الطول.

لا تسمح قاعدة الإدراج بسلسلة تعليق ثالثة يتم تغطية نطاق نصها بالكامل بواسطة سلسلتي تعليق أخرى.

ملاحظة : وجود هذه القاعدة لا يعني أننا لم نكن لنحتوي التعليقات المتداخلة بشكل كامل. الشيء الصعب بشأن التعليقات المتداخلة هو أنه على الرغم من القواعد ، فإن الترتيب الذي يتم إدخال التعليقات به يمكن أن يتركنا في حالة لم نرغب في أن يكون التداخل فيها. بالإشارة إلى مثالنا للتعليقات على كلمة "المصممين" في وقت سابق ، كان أطول سلسلة تعليق تم إدراجها هناك آخر واحد تمت إضافته ، لذا فإن قاعدة الإدراج تسمح بذلك وينتهي بنا الأمر بموقف كامل - رقم 1 ورقم 2 موجود داخل رقم 3. هذا جيد لأن قاعدة نطاق التعليقات الأقصر ستساعدنا في ذلك.

سنقوم بتنفيذ قاعدة أقصر نطاق للتعليق في القسم التالي حيث نقوم باختيار سلاسل التعليقات. نظرًا لأن لدينا الآن زر شريط أدوات لإدراج التعليقات ، يمكننا تنفيذ قاعدة الإدراج على الفور عن طريق التحقق من القاعدة عندما يكون لدى المستخدم بعض النص المحدد. إذا لم يتم استيفاء القاعدة ، فسنقوم بتعطيل الزر "تعليق" حتى لا يتمكن المستخدمون من إدراج سلسلة تعليق جديدة على النص المحدد. هيا بنا نبدأ!

 # src/utils/EditorCommentUtils.js export function shouldAllowNewCommentThreadAtSelection(editor, selection) { if (selection == null || Range.isCollapsed(selection)) { return false; } const textNodeIterator = Editor.nodes(editor, { at: selection, mode: "lowest", }); let nextTextNodeEntry = textNodeIterator.next().value; const textNodeEntriesInSelection = []; while (nextTextNodeEntry != null) { textNodeEntriesInSelection.push(nextTextNodeEntry); nextTextNodeEntry = textNodeIterator.next().value; } if (textNodeEntriesInSelection.length === 0) { return false; } return textNodeEntriesInSelection.some( ([textNode]) => getCommentThreadsOnTextNode(textNode).size === 0 ); }

المنطق في هذه الوظيفة مباشر نسبيًا.

  • إذا كان تحديد المستخدم عبارة عن حرف إقحام وامض ، فإننا لا نسمح بإدراج تعليق هناك حيث لم يتم تحديد أي نص.
  • إذا لم يكن تحديد المستخدم مطويًا ، فسنجد جميع العقد النصية في التحديد. لاحظ استخدام mode: lowest في استدعاء Editor.nodes (وظيفة مساعدة بواسطة SlateJS) تساعدنا في تحديد جميع العقد النصية لأن العقد النصية هي بالفعل أوراق شجرة المستند.
  • إذا كانت هناك عقدة نصية واحدة على الأقل لا تحتوي على سلاسل تعليق عليها ، فقد نسمح بالإدراج. نحن نستخدم getCommentThreadsOnTextNode الذي كتبناه سابقًا هنا.

نستخدم الآن هذه الوظيفة داخل شريط الأدوات للتحكم في حالة تعطيل الزر.

 # src/components/Toolbar.js export default function Toolbar({ selection, previousSelection }) { const editor = useEditor(); .... return ( <div className="toolbar"> .... <ToolBarButton isActive={false} disabled={!shouldAllowNewCommentThreadAtSelection( editor, selection )} label={<i className={`bi ${getIconForButton("comment")}`} />} onMouseDown={onInsertComment} /> </div> );

دعنا نختبر تنفيذ القاعدة من خلال إعادة إنشاء المثال أعلاه.

تم تعطيل زر الإدراج في شريط الأدوات حيث يحاول المستخدم إدراج تعليق عبر نطاق النص الذي تمت تغطيته بالكامل بالفعل بواسطة تعليقات أخرى.

من التفاصيل الدقيقة لتجربة المستخدم التي يجب ذكرها هنا أنه بينما نقوم بتعطيل زر شريط الأدوات إذا كان المستخدم قد حدد سطر النص بالكامل هنا ، فإنه لا يكمل تجربة المستخدم. قد لا يفهم المستخدم تمامًا سبب تعطيل الزر ومن المحتمل أن يشعر بالارتباك لأننا لا نستجيب لنياتهم لإدراج سلسلة تعليق هناك. نتناول هذا لاحقًا حيث تم إنشاء التعليقات المنبثقة بحيث أنه حتى إذا تم تعطيل زر شريط الأدوات ، فسيظهر العنصر المنبثق لأحد سلاسل التعليقات وسيظل المستخدم قادرًا على ترك التعليقات.

دعنا أيضًا نختبر حالة حيث توجد عقدة نصية غير موصوفة وتسمح القاعدة بإدراج سلسلة تعليق جديدة.

قاعدة الإدراج تسمح بإدراج سلسلة التعليقات عندما يكون هناك بعض النص غير المعلق ضمن تحديد المستخدم.

اختيار مواضيع التعليق

في هذا القسم ، نقوم بتمكين الميزة حيث ينقر المستخدم على عقدة نص معلق ونستخدم قاعدة أقصر نطاق للتعليق لتحديد سلسلة التعليقات التي يجب تحديدها. خطوات العملية هي:

  1. ابحث عن أقصر سلسلة تعليق في عقدة النص المعلق التي ينقر المستخدم عليها.
  2. قم بتعيين سلسلة التعليق هذه لتكون سلسلة التعليق النشط. (نقوم بإنشاء ذرة نكص جديدة والتي ستكون مصدر الحقيقة لهذا.)
  3. ستستمع العقد النصية المعلقة إلى حالة الارتداد وإذا كانت جزءًا من سلسلة التعليقات النشطة ، فإنها ستبرز نفسها بشكل مختلف. بهذه الطريقة ، عندما ينقر المستخدم على سلسلة التعليقات ، يبرز نطاق النص بالكامل حيث ستعمل جميع العقد النصية على تحديث لون تمييزها.

الخطوة 1: تنفيذ قاعدة أقصر نطاق للتعليقات

لنبدأ بالخطوة رقم 1 التي تنفذ أساسًا قاعدة أقصر نطاق للتعليق. الهدف هنا هو العثور على سلسلة التعليقات لأقصر نطاق في العقدة النصية التي نقر المستخدم عليها. للعثور على أقصر سلسلة رسائل ، نحتاج إلى حساب طول جميع سلاسل التعليقات في عقدة النص تلك. خطوات القيام بذلك هي:

  1. احصل على جميع سلاسل التعليقات في العقدة النصية المعنية.
  2. انتقل في أي اتجاه من عقدة النص هذه واستمر في تحديث أطوال السلسلة التي يتم تعقبها.
  3. أوقف الاجتياز في اتجاه ما عندما وصلنا إلى إحدى الحواف التالية:
    • عقدة نصية غير موصوفة (مما يعني أننا وصلنا إلى أقصى حافة بداية / نهاية لجميع سلاسل التعليقات التي نتتبعها).
    • عقدة نصية حيث وصلت جميع سلاسل التعليقات التي نتتبعها إلى حافة (بداية / نهاية).
    • لا يوجد المزيد من العقد النصية لاجتياز هذا الاتجاه (مما يعني أننا إما وصلنا إلى بداية المستند أو نهايته أو إلى عقدة غير نصية).

نظرًا لأن عمليات الاجتياز في الاتجاه الأمامي والعكس هي نفسها وظيفيًا ، فسنكتب تحديثًا لوظيفة updateCommentThreadLengthMap الذي يأخذ أساسًا مكرر عقدة نصية. سيستمر في استدعاء المكرر ويستمر في تحديث أطوال سلسلة التعقب. سنسمي هذه الوظيفة مرتين - مرة للأمام ومرة ​​للخلف. Let's write our main utility function that will use this helper function.

 # src/utils/EditorCommentUtils.js export function getSmallestCommentThreadAtTextNode(editor, textNode) { const commentThreads = getCommentThreadsOnTextNode(textNode); const commentThreadsAsArray = [...commentThreads]; let shortestCommentThreadID = commentThreadsAsArray[0]; const reverseTextNodeIterator = (slateEditor, nodePath) => Editor.previous(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); const forwardTextNodeIterator = (slateEditor, nodePath) => Editor.next(slateEditor, { at: nodePath, mode: "lowest", match: Text.isText, }); if (commentThreads.size > 1) { // The map here tracks the lengths of the comment threads. // We initialize the lengths with length of current text node // since all the comment threads span over the current text node // at the least. const commentThreadsLengthByID = new Map( commentThreadsAsArray.map((id) => [id, textNode.text.length]) ); // traverse in the reverse direction and update the map updateCommentThreadLengthMap( editor, commentThreads, reverseTextNodeIterator, commentThreadsLengthByID ); // traverse in the forward direction and update the map updateCommentThreadLengthMap( editor, commentThreads, forwardTextNodeIterator, commentThreadsLengthByID ); let minLength = Number.POSITIVE_INFINITY; // Find the thread with the shortest length. for (let [threadID, length] of commentThreadsLengthByID) { if (length < minLength) { shortestCommentThreadID = threadID; minLength = length; } } } return shortestCommentThreadID; }

The steps we listed out are all covered in the above code. The comments should help follow how the logic flows there.

One thing worth calling out is how we created the traversal functions. We want to give a traversal function to updateCommentThreadLengthMap such that it can call it while it is iterating text node's path and easily get the previous/next text node. To do that, Slate's traversal utilities Editor.previous and Editor.next (defined in the Editor interface) are very helpful. Our iterators reverseTextNodeIterator and forwardTextNodeIterator call these helpers with two options mode: lowest and the match function Text.isText so we know we're getting a text node from the traversal, if there is one.

Now we implement updateCommentThreadLengthMap which traverses using these iterators and updates the lengths we're tracking.

 # src/utils/EditorCommentUtils.js function updateCommentThreadLengthMap( editor, commentThreads, nodeIterator, map ) { let nextNodeEntry = nodeIterator(editor); while (nextNodeEntry != null) { const nextNode = nextNodeEntry[0]; const commentThreadsOnNextNode = getCommentThreadsOnTextNode(nextNode); const intersection = [...commentThreadsOnNextNode].filter((x) => commentThreads.has(x) ); // All comment threads we're looking for have already ended meaning // reached an uncommented text node OR a commented text node which // has none of the comment threads we care about. if (intersection.length === 0) { break; } // update thread lengths for comment threads we did find on this // text node. for (let i = 0; i < intersection.length; i++) { map.set(intersection[i], map.get(intersection[i]) + nextNode.text.length); } // call the iterator to get the next text node to consider nextNodeEntry = nodeIterator(editor, nextNodeEntry[1]); } return map; }

One might wonder why do we wait until the intersection becomes 0 to stop iterating in a certain direction. Why can't we just stop if we're reached the edge of at least one comment thread — that would imply we've reached the shortest length in that direction, right? The reason we can't do that is that we know that a comment thread can span over multiple text nodes and we wouldn't know which of those text nodes did the user click on and we started our traversal from. We wouldn't know the range of all comment threads in question without fully traversing to the farthest edges of the union of the text ranges of the comment threads in both the directions.

Check out the below example where we have two comment threads 'A' and 'B' overlapping each other in some way resulting into three text nodes 1,2 and 3 — #2 being the text node with the overlap.

Example of multiple comment threads overlapping on a text node.
Two comment threads overlapping over the word 'text'. (معاينة كبيرة)

In this example, let's assume we don't wait for intersection to become 0 and just stop when we reach the edge of a comment thread. Now, if the user clicked on #2 and we start traversal in reverse direction, we'd stop at the start of text node #2 itself since that's the start of the comment thread A. As a result, we might not compute the comment thread lengths correctly for A & B. With the implementation above traversing the farthest edges (text nodes 1,2, and 3), we should get B as the shortest comment thread as expected.

To see the implementation visually, below is a walkthrough with a slideshow of the iterations. We have two comment threads A and B that overlap each other over text node #3 and the user clicks on the overlapping text node #3.

Slideshow showing iterations in the implementation of Shortest Comment Thread Rule.

Steps 2 & 3: Maintaining State Of The Selected Comment Thread And Highlighting It

Now that we have the logic for the rule fully implemented, let's update the editor code to use it. For that, we first create a Recoil atom that'll store the active comment thread ID for us. We then update the CommentedText component to use our rule's implementation.

# src/utils/CommentState.js import { atom } from "recoil"; export const activeCommentThreadIDAtom = atom({ key: "activeCommentThreadID", default: null, }); # src/components/CommentedText.js import { activeCommentThreadIDAtom } from "../utils/CommentState"; import classNames from "classnames"; import { getSmallestCommentThreadAtTextNode } from "../utils/EditorCommentUtils"; import { useRecoilState } from "recoil"; export default function CommentedText(props) { .... const { commentThreads, textNode, ...otherProps } = props; const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = () => { setActiveCommentThreadID( getSmallestCommentThreadAtTextNode(editor, textNode) ); }; return ( <span {...otherProps} className={classNames({ comment: true, // a different background color treatment if this text node's // comment threads do contain the comment thread active on the // document right now. "is-active": commentThreads.has(activeCommentThreadID), })} onClick={onClick} > {props.children} ≷/span> ); }

يستخدم هذا المكون useRecoilState الذي يسمح للمكون بالاشتراك فيه ويكون أيضًا قادرًا على تعيين قيمة Recoil atom. نحتاج إلى أن يعرف المشترك ما إذا كانت هذه العقدة النصية جزءًا من سلسلة التعليقات النشطة حتى تتمكن من تصميم نفسها بشكل مختلف. تحقق من لقطة الشاشة أدناه حيث يكون سلسلة التعليقات في المنتصف نشطة ويمكننا رؤية نطاقها بوضوح.

مثال يوضح كيفية القفز إلى الخارج عقدة (عقدة) نصية ضمن سلسلة تعليق محددة.
عقدة (عقدة) النص ضمن سلسلة التعليقات المحددة تتغير في النمط وتقفز للخارج. (معاينة كبيرة)

الآن بعد أن أصبح لدينا كل الكود لجعل اختيار سلاسل التعليقات يعمل ، دعنا نراها في العمل. لاختبار كود الاجتياز الخاص بنا جيدًا ، نقوم باختبار بعض حالات التداخل المباشرة وبعض حالات الحافة مثل:

  • النقر فوق عقدة نص معلق في بداية / نهاية المحرر.
  • النقر فوق عقدة نص معلق بها سلاسل تعليق تمتد على عدة فقرات.
  • النقر فوق عقدة نص معلق قبل عقدة صورة مباشرة.
  • النقر فوق عقدة نص معلق عليها روابط متداخلة.
اختيار أقصر سلسلة تعليق لمجموعات متداخلة مختلفة.

نظرًا لأن لدينا الآن Recoil atom لتتبع معرف مؤشر ترابط التعليق النشط ، فإن أحد التفاصيل الصغيرة التي يجب الاهتمام بها هو تعيين مؤشر ترابط التعليق الذي تم إنشاؤه حديثًا ليكون العنصر النشط عندما يستخدم المستخدم زر شريط الأدوات لإدراج سلسلة تعليق جديدة. يتيح لنا هذا ، في القسم التالي ، إظهار نافذة سلسلة التعليقات المنبثقة فور الإدراج حتى يتمكن المستخدم من البدء في إضافة التعليقات على الفور.

 # src/components/Toolbar.js import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; import { useSetRecoilState } from "recoil"; export default function Toolbar({ selection, previousSelection }) { ... const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); ..... const onInsertComment = useCallback(() => { const newCommentThreadID = insertCommentThread(editor, addCommentThread); setActiveCommentThreadID(newCommentThreadID); }, [editor, addCommentThread, setActiveCommentThreadID]); return <div className='toolbar'> .... </div>; };

ملاحظة: استخدام useSetRecoilState هنا (خطاف الارتداد الذي يفضح واضعًا للذرة ولكنه لا يشترك في المكون في قيمته) هو ما نحتاجه لشريط الأدوات في هذه الحالة.

إضافة تعليق المواضيع المنبثقة

في هذا القسم ، نقوم ببناء تعليق Popover يستخدم مفهوم سلسلة التعليقات المحددة / النشطة ويظهر نافذة منبثقة تتيح للمستخدم إضافة تعليقات إلى سلسلة التعليقات هذه. قبل أن نبنيها ، دعونا نلقي نظرة سريعة على كيفية عملها.

معاينة ميزة التعليق المنبثق.

عند محاولة عرض تعليق Popover قريبًا من سلسلة التعليقات النشطة ، نواجه بعض المشكلات التي واجهناها في المقالة الأولى بقائمة محرر الروابط. في هذه المرحلة ، يُنصح بقراءة القسم الموجود في المقالة الأولى الذي ينشئ محرر ارتباط ومشكلات التحديد التي نواجهها مع ذلك.

لنعمل أولاً على عرض مكون منبثق فارغ في المكان المناسب بناءً على مؤشر ترابط التعليق النشط. طريقة عمل popover هي:

  • يتم عرض تعليق سلسلة الرسائل المنبثقة فقط عندما يكون هناك معرف سلسلة تعليق نشط. للحصول على هذه المعلومات ، نستمع إلى ذرة الارتداد التي أنشأناها في القسم السابق.
  • عندما يتم عرضه ، نجد العقدة النصية عند تحديد المحرر ونعرض النافذة المنبثقة بالقرب منها.
  • عندما ينقر المستخدم في أي مكان خارج النافذة المنبثقة ، نقوم بتعيين سلسلة التعليق النشطة لتكون null وبالتالي يتم إلغاء تنشيط سلسلة التعليق وكذلك جعل العنصر المنبثق يختفي.
 # src/components/CommentThreadPopover.js import NodePopover from "./NodePopover"; import { getFirstTextNodeAtSelection } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; import { useSetRecoilState} from "recoil"; import {activeCommentThreadIDAtom} from "../utils/CommentState"; export default function CommentThreadPopover({ editorOffsets, selection, threadID }) { const editor = useEditor(); const textNode = getFirstTextNodeAtSelection(editor, selection); const setActiveCommentThreadID = useSetRecoilState( activeCommentThreadIDAtom ); const onClickOutside = useCallback( () => {}, [] ); return ( <NodePopover editorOffsets={editorOffsets} isBodyFullWidth={true} node={textNode} className={"comment-thread-popover"} onClickOutside={onClickOutside} > {`Comment Thread Popover for threadID:${threadID}`} </NodePopover> ); }

زوجان من الأشياء التي يجب استدعاؤها لتنفيذ هذا المكون المنبثق:

  • يأخذ editorOffsets selection من مكون Editor حيث سيتم تقديمه. editorOffsets هي حدود مكون Editor حتى نتمكن من حساب موضع العنصر المنبثق ويمكن أن يكون selection هو التحديد الحالي أو السابق في حالة استخدام المستخدم لزر شريط الأدوات مما تسبب في أن يصبح selection null . يتطرق القسم الموجود في محرر الروابط من المقالة الأولى المرتبطة أعلاه إلى هذه التفاصيل بالتفصيل.
  • منذ LinkEditor من المقالة الأولى و CommentThreadPopover هنا ، يعرض كلاهما نافذة منبثقة حول عقدة نصية ، لقد نقلنا هذا المنطق المشترك إلى مكون NodePopover الذي يتعامل مع عرض المكون المحاذي لعقدة النص المعنية. تفاصيل التنفيذ الخاصة به هي ما كان يحتوي عليه مكون LinkEditor في المقالة الأولى.
  • تأخذ NodePopover طريقة onClickOutside تسمى إذا نقر المستخدم في مكان ما خارج النافذة المنبثقة. نقوم بتنفيذ ذلك من خلال إرفاق مستمع حدث mousedown document - كما هو موضح بالتفصيل في مقالة Smashing هذه حول هذه الفكرة.
  • يحصل getFirstTextNodeAtSelection على العقدة النصية الأولى داخل تحديد المستخدم والتي نستخدمها لعرض العنصر المنبثق مقابل. يستخدم تنفيذ هذه الوظيفة مساعدي Slate للعثور على عقدة النص.
 # src/utils/EditorUtils.js export function getFirstTextNodeAtSelection(editor, selection) { const selectionForNode = selection ?? editor.selection; if (selectionForNode == null) { return null; } const textNodeEntry = Editor.nodes(editor, { at: selectionForNode, mode: "lowest", match: Text.isText, }).next().value; return textNodeEntry != null ? textNodeEntry[0] : null; }

دعنا ننفذ رد الاتصال onClickOutside الذي يجب أن يمسح سلسلة التعليق النشط. ومع ذلك ، يتعين علينا أن نأخذ في الحسبان السيناريو عندما يكون المنبثق لسلسلة التعليقات مفتوحًا ويكون موضوعًا معينًا نشطًا ويحدث المستخدم للنقر على سلسلة تعليق أخرى. في هذه الحالة ، لا نريد أن يقوم onClickOutside بإعادة تعيين مؤشر ترابط التعليق النشط لأن حدث النقر في المكون CommentedText الآخر يجب أن يعين سلسلة التعليق الأخرى لتصبح نشطة. لا نريد التدخل في ذلك في النافذة المنبثقة.

الطريقة التي نقوم بها هي أننا نجد العقدة Slate Node الأقرب إلى عقدة DOM التي حدث فيها حدث النقر. إذا كانت هذه العقدة Slate عبارة عن عقدة نصية ولديها تعليقات عليها ، فإننا نتخطى إعادة تعيين مؤشر ترابط التعليق النشط Recoil atom. دعونا ننفذها!

 # src/components/CommentThreadPopover.js const setActiveCommentThreadID = useSetRecoilState(activeCommentThreadIDAtom); const onClickOutside = useCallback( (event) => { const slateDOMNode = event.target.hasAttribute("data-slate-node") ? event.target : event.target.closest('[data-slate-node]'); // The click event was somewhere outside the Slate hierarchy. if (slateDOMNode == null) { setActiveCommentThreadID(null); return; } const slateNode = ReactEditor.toSlateNode(editor, slateDOMNode); // Click is on another commented text node => do nothing. if ( Text.isText(slateNode) && getCommentThreadsOnTextNode(slateNode).size > 0 ) { return; } setActiveCommentThreadID(null); }, [editor, setActiveCommentThreadID] );

يحتوي Slate على طريقة مساعدة toSlateNode والتي تُرجع عقدة Slate التي تعين عقدة DOM أو أقرب سلف لها إذا لم تكن عقدة Slate Node. يؤدي التنفيذ الحالي لهذا المساعد إلى ظهور خطأ إذا لم يتمكن من العثور على عقدة Slate بدلاً من إرجاعه null . نتعامل مع ذلك أعلاه عن طريق التحقق من الحالة null بأنفسنا وهو سيناريو محتمل جدًا إذا نقر المستخدم في مكان ما خارج المحرر حيث لا توجد عقد Slate.

يمكننا الآن تحديث مكون Editor للاستماع إلى activeCommentThreadIDAtom وعرض الإطار المنبثق فقط عندما يكون مؤشر ترابط التعليق نشطًا.

 # src/components/Editor.js import { useRecoilValue } from "recoil"; import { activeCommentThreadIDAtom } from "../utils/CommentState"; export default function Editor({ document, onChange }): JSX.Element { const activeCommentThreadID = useRecoilValue(activeCommentThreadIDAtom); // This hook is described in detail in the first article const [previousSelection, selection, setSelection] = useSelection(editor); return ( <> ... <div className="editor" ref={editorRef}> ... {activeCommentThreadID != null ? ( <CommentThreadPopover editorOffsets={editorOffsets} selection={selection ?? previousSelection} threadID={activeCommentThreadID} /> ) : null} </div> ... </> ); }

دعنا نتحقق من تحميل العنصر المنبثق في المكان المناسب لسلسلة التعليقات الصحيحة ويقوم بمسح سلسلة التعليق النشط عند النقر بالخارج.

التعليق يتم تحميل قائمة الرسائل المنبثقة بشكل صحيح لسلسلة التعليقات المحددة.

ننتقل الآن إلى تمكين المستخدمين من إضافة تعليقات إلى سلسلة تعليق ورؤية جميع تعليقات هذا الموضوع في النافذة المنبثقة. سنستخدم عائلة Recoil atom - commentThreadsState أنشأناها مسبقًا في المقالة لهذا الغرض.

يتم تخزين التعليقات في سلسلة comments في مصفوفة التعليقات. لتمكين إضافة تعليق جديد ، نقدم إدخال نموذج يسمح للمستخدم بإدخال تعليق جديد. أثناء كتابة المستخدم للتعليق ، نحافظ على ذلك في متغير حالة محلي - commentText . عند النقر فوق الزر ، نقوم بإلحاق نص التعليق كتعليق جديد بمصفوفة comments .

 # src/components/CommentThreadPopover.js import { commentThreadsState } from "../utils/CommentState"; import { useRecoilState } from "recoil"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); const [commentText, setCommentText] = useState(""); const onClick = useCallback(() => { setCommentThreadData((threadData) => ({ ...threadData, comments: [ ...threadData.comments, // append comment to the comments on the thread. { text: commentText, author: "Jane Doe", creationTime: new Date() }, ], })); // clear the input setCommentText(""); }, [commentText, setCommentThreadData]); const onCommentTextChange = useCallback( (event) => setCommentText(event.target.value), [setCommentText] ); return ( <NodePopover ... > <div className={"comment-input-wrapper"}> <Form.Control bsPrefix={"comment-input form-control"} placeholder={"Type a comment"} type="text" value={commentText} onChange={onCommentTextChange} /> <Button size="sm" variant="primary" disabled={commentText.length === 0} onClick={onClick} > Comment </Button> </div> </NodePopover> ); }

ملاحظة : على الرغم من أننا نقدم إدخالًا للمستخدم لكتابة التعليق ، إلا أننا لا نسمح بالضرورة بالتركيز عليه عند تحميل المنبثقة. هذا قرار انطباع المستخدم قد يختلف من محرر إلى آخر. لا تسمح بعض برامج التحرير للمستخدمين بتحرير النص أثناء فتح الإطار المنبثق لسلسلة التعليقات. في حالتنا ، نريد أن نكون قادرين على السماح للمستخدم بتحرير النص المعلق عند النقر فوقه.

تجدر الإشارة إلى كيفية وصولنا إلى بيانات سلسلة التعليقات المحددة من عائلة Recoil atom - عن طريق استدعاء الذرة كـ - commentThreadsState(threadID) . هذا يعطينا قيمة الذرة واضعة لتحديث تلك الذرة فقط في الأسرة. إذا كانت التعليقات يتم تحميلها كسولًا من الخادم ، فإن Recoil يوفر أيضًا خطاف useRecoilStateLoadable الذي يقوم بإرجاع كائن قابل للتحميل والذي يخبرنا عن حالة تحميل بيانات الذرة. إذا كان لا يزال قيد التحميل ، فيمكننا اختيار إظهار حالة التحميل في النافذة المنبثقة.

الآن ، نصل إلى threadData قائمة التعليقات. يتم تقديم كل تعليق بواسطة مكون CommentRow .

 # src/components/CommentThreadPopover.js return ( <NodePopover ... > <div className={"comment-list"}> {threadData.comments.map((comment, index) => ( <CommentRow key={`comment_${index}`} comment={comment} /> ))} </div> ... </NodePopover> );

يوجد أدناه تنفيذ CommentRow الذي يعرض نص التعليق والبيانات الوصفية الأخرى مثل اسم المؤلف ووقت الإنشاء. نستخدم وحدة date-fns لإظهار وقت الإنشاء المنسق.

 # src/components/CommentRow.js import { format } from "date-fns"; export default function CommentRow({ comment: { author, text, creationTime }, }) { return ( <div className={"comment-row"}> <div className="comment-author-photo"> <i className="bi bi-person-circle comment-author-photo"></i> </div> <div> <span className="comment-author-name">{author}</span> <span className="comment-creation-time"> {format(creationTime, "eee MM/dd H:mm")} </span> <div className="comment-text">{text}</div> </div> </div> ); }

لقد استخرجنا هذا ليكون مكونًا خاصًا به حيث نعيد استخدامه لاحقًا عند تنفيذ الشريط الجانبي للتعليق.

في هذه المرحلة ، يحتوي Comment Popover على كل الكود الذي يحتاجه للسماح بإدراج تعليقات جديدة وتحديث حالة الارتداد لنفسه. دعنا نتحقق من ذلك. في وحدة تحكم المتصفح ، باستخدام Recoil Debug Observer الذي أضفناه سابقًا ، يمكننا التحقق من تحديث ذرة الارتداد لسلسلة التعليقات بشكل صحيح حيث نضيف تعليقات جديدة إلى سلسلة الرسائل.

يتم تحميل قائمة سلسلة التعليقات المنبثقة عند تحديد سلسلة تعليق.

إضافة شريط جانبي للتعليقات

في وقت سابق من المقالة ، أوضحنا لماذا قد يحدث أحيانًا أن تمنع القواعد التي طبقناها من إمكانية الوصول إلى سلسلة تعليق معينة من خلال النقر على عقدة (عقدة) النص وحدها - اعتمادًا على مجموعة التداخل. في مثل هذه الحالات ، نحتاج إلى شريط جانبي للتعليقات يتيح للمستخدم الوصول إلى أي وجميع سلاسل التعليقات في المستند.

يعد الشريط الجانبي للتعليقات أيضًا إضافة جيدة تنسج في سير عمل الاقتراح والمراجعة حيث يمكن للمراجع التنقل عبر جميع سلاسل التعليقات واحدة تلو الأخرى في عملية مسح وتكون قادرًا على ترك التعليقات / الردود أينما شعروا بالحاجة إلى ذلك. قبل أن نبدأ في تنفيذ الشريط الجانبي ، هناك مهمة واحدة غير مكتملة نعتني بها أدناه.

تهيئة حالة الارتداد من سلاسل التعليق

عندما يتم تحميل المستند في المحرر ، نحتاج إلى مسح المستند ضوئيًا للعثور على جميع سلاسل التعليقات وإضافتها إلى ذرات الارتداد التي أنشأناها أعلاه كجزء من عملية التهيئة. دعنا نكتب وظيفة مساعدة في EditorCommentUtils تقوم بمسح العقد النصية ، والعثور على جميع سلاسل التعليقات وإضافتها إلى Recoil atom.

 # src/utils/EditorCommentUtils.js export async function initializeStateWithAllCommentThreads( editor, addCommentThread ) { const textNodesWithComments = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).size > 0, }); const commentThreads = new Set(); let textNodeEntry = textNodesWithComments.next().value; while (textNodeEntry != null) { [...getCommentThreadsOnTextNode(textNodeEntry[0])].forEach((threadID) => { commentThreads.add(threadID); }); textNodeEntry = textNodesWithComments.next().value; } Array.from(commentThreads).forEach((id) => addCommentThread(id, { comments: [ { author: "Jane Doe", text: "Comment Thread Loaded from Server", creationTime: new Date(), }, ], status: "open", }) ); }

المزامنة مع تخزين الواجهة الخلفية ومراعاة الأداء

بالنسبة لسياق المقالة ، نظرًا لأننا نركز فقط على تنفيذ واجهة المستخدم ، فإننا نقوم فقط بتهيئتها ببعض البيانات التي تتيح لنا تأكيد عمل كود التهيئة.

في الاستخدام الفعلي لنظام التعليقات ، من المحتمل أن يتم تخزين سلاسل التعليقات بشكل منفصل عن محتويات المستند نفسها. في مثل هذه الحالة ، يجب تحديث الكود أعلاه لإجراء استدعاء API يقوم بجلب جميع البيانات الوصفية والتعليقات على جميع معرفات سلسلة التعليقات في commentThreads . بمجرد تحميل سلاسل التعليقات ، من المحتمل أن يتم تحديثها حيث يضيف العديد من المستخدمين المزيد من التعليقات إليهم في الوقت الفعلي ، ويغيرون حالتهم وما إلى ذلك. سيحتاج إصدار الإنتاج من نظام التعليق إلى هيكلة تخزين Recoil بطريقة يمكننا من خلالها الاستمرار في مزامنته مع الخادم. إذا اخترت استخدام Recoil لإدارة الحالة ، فهناك بعض الأمثلة على Atom Effects API (التجريبية حتى كتابة هذه المقالة) التي تفعل شيئًا مشابهًا.

إذا كان المستند طويلًا حقًا ولديه الكثير من المستخدمين المتعاونين عليه في الكثير من سلاسل التعليقات ، فقد نضطر إلى تحسين كود التهيئة لتحميل سلاسل التعليقات للصفحات القليلة الأولى من المستند فقط. بدلاً من ذلك ، قد نختار فقط تحميل البيانات الوصفية خفيفة الوزن لجميع سلاسل التعليقات بدلاً من القائمة الكاملة للتعليقات التي من المحتمل أن تكون الجزء الأثقل من الحمولة.

الآن ، دعنا ننتقل إلى استدعاء هذه الوظيفة عندما يتصاعد مكون Editor مع المستند حتى تتم تهيئة حالة الارتداد بشكل صحيح.

 # src/components/Editor.js import { initializeStateWithAllCommentThreads } from "../utils/EditorCommentUtils"; import useAddCommentThreadToState from "../hooks/useAddCommentThreadToState"; export default function Editor({ document, onChange }): JSX.Element { ... const addCommentThread = useAddCommentThreadToState(); useEffect(() => { initializeStateWithAllCommentThreads(editor, addCommentThread); }, [editor, addCommentThread]); return ( <> ... </> ); }

نحن نستخدم نفس الخطاف المخصص - useAddCommentThreadToState الذي استخدمناه مع تنفيذ زر تعليق شريط الأدوات لإضافة سلاسل تعليق جديدة. نظرًا لأن المنبثقة تعمل ، يمكننا النقر فوق أحد سلاسل التعليقات الموجودة مسبقًا في المستند والتحقق من أنها تعرض البيانات التي استخدمناها لتهيئة سلسلة الرسائل أعلاه.

يؤدي النقر فوق سلسلة تعليق موجودة مسبقًا إلى تحميل النافذة المنبثقة بتعليقاتها بشكل صحيح.
يؤدي النقر فوق سلسلة تعليق موجودة مسبقًا إلى تحميل النافذة المنبثقة بتعليقاتها بشكل صحيح. (معاينة كبيرة)

الآن بعد أن تمت تهيئة حالتنا بشكل صحيح ، يمكننا البدء في تنفيذ الشريط الجانبي. يتم تخزين جميع سلاسل التعليقات الخاصة بنا في واجهة المستخدم في عائلة Recoil atom - commentThreadsState . كما أوضحنا سابقًا ، فإن الطريقة التي نكرر بها عبر جميع العناصر في عائلة ذرة الارتداد هي من خلال تتبع مفاتيح / معرفات الذرة في ذرة أخرى. لقد كنا نفعل ذلك مع commentThreadIDsState . دعنا نضيف مكون CommentSidebar الذي يتكرر خلال مجموعة المعرفات في هذه الذرة ويعرض مكون CommentThread لكل منها.

 # src/components/CommentsSidebar.js import "./CommentSidebar.css"; import {commentThreadIDsState,} from "../utils/CommentState"; import { useRecoilValue } from "recoil"; export default function CommentsSidebar(params) { const allCommentThreadIDs = useRecoilValue(commentThreadIDsState); return ( <Card className={"comments-sidebar"}> <Card.Header>Comments</Card.Header> <Card.Body> {Array.from(allCommentThreadIDs).map((id) => ( <Row key={id}> <Col> <CommentThread id={id} /> </Col> </Row> ))} </Card.Body> </Card> ); }

الآن ، نقوم بتنفيذ مكون CommentThread الذي يستمع إلى ذرة الارتداد في العائلة المقابلة لسلسلة التعليقات التي يتم عرضها. بهذه الطريقة ، عندما يضيف المستخدم المزيد من التعليقات على سلسلة الرسائل في المحرر أو يغير أي بيانات وصفية أخرى ، يمكننا تحديث الشريط الجانبي ليعكس ذلك.

نظرًا لأن الشريط الجانبي يمكن أن يصبح كبيرًا حقًا بالنسبة لمستند يحتوي على الكثير من التعليقات ، فإننا نخفي جميع التعليقات باستثناء التعليق الأول عند عرض الشريط الجانبي. يمكن للمستخدم استخدام زر "إظهار / إخفاء الردود" لإظهار / إخفاء سلسلة التعليقات بالكامل.

 # src/components/CommentSidebar.js function CommentThread({ id }) { const { comments } = useRecoilValue(commentThreadsState(id)); const [shouldShowReplies, setShouldShowReplies] = useState(false); const onBtnClick = useCallback(() => { setShouldShowReplies(!shouldShowReplies); }, [shouldShowReplies, setShouldShowReplies]); if (comments.length === 0) { return null; } const [firstComment, ...otherComments] = comments; return ( <Card body={true} className={classNames({ "comment-thread-container": true, })} > <CommentRow comment={firstComment} showConnector={false} /> {shouldShowReplies ? otherComments.map((comment, index) => ( <CommentRow key={`comment-${index}`} comment={comment} showConnector={true} /> )) : null} {comments.length > 1 ? ( <Button className={"show-replies-btn"} size="sm" variant="outline-primary" onClick={onBtnClick} > {shouldShowReplies ? "Hide Replies" : "Show Replies"} </Button> ) : null} </Card> ); }

لقد أعدنا استخدام مكون CommentRow من القائمة المنبثقة على الرغم من أننا أضفنا معالجة تصميمية باستخدام showConnector التي تجعل كل التعليقات تبدو مرتبطة بخيط في الشريط الجانبي.

الآن ، نقوم CommentSidebar في Editor والتحقق من أنه يعرض جميع سلاسل الرسائل الموجودة لدينا في المستند ويتم تحديثه بشكل صحيح حيث نضيف سلاسل رسائل جديدة أو تعليقات جديدة إلى سلاسل الرسائل الموجودة.

 # src/components/Editor.js return ( <> <Slate ... > ..... <div className={"sidebar-wrapper"}> <CommentsSidebar /> </div> </Slate> </> );
شريط جانبي للتعليقات مع كل سلاسل التعليقات في المستند.

ننتقل الآن إلى تنفيذ تفاعل شعبي للشريط الجانبي للتعليقات موجود في المحررين:

يجب أن يؤدي النقر فوق سلسلة تعليق في الشريط الجانبي إلى تحديد / تنشيط سلسلة التعليق هذه. نضيف أيضًا معالجة تصميم تفاضلية لإبراز سلسلة تعليق في الشريط الجانبي إذا كانت نشطة في المحرر. لكي نتمكن من القيام بذلك ، نستخدم Recoil atom - activeCommentThreadIDAtom . دعنا نقوم بتحديث مكون CommentThread لدعم هذا.

 # src/components/CommentsSidebar.js function CommentThread({ id }) { const [activeCommentThreadID, setActiveCommentThreadID] = useRecoilState( activeCommentThreadIDAtom ); const onClick = useCallback(() => { setActiveCommentThreadID(id); }, [id, setActiveCommentThreadID]); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-active": activeCommentThreadID === id, })} onClick={onClick} > .... </Card> );
يؤدي النقر فوق سلسلة تعليق في الشريط الجانبي للتعليقات إلى تحديده في المحرر وإبراز نطاقه.

إذا نظرنا عن كثب ، فلدينا خطأ في تنفيذنا لمزامنة سلسلة التعليقات النشطة مع الشريط الجانبي. أثناء النقر فوق سلاسل التعليقات المختلفة في الشريط الجانبي ، يتم بالفعل تمييز سلسلة التعليقات الصحيحة في المحرر. ومع ذلك ، فإن التعليق المنبثق لا ينتقل فعليًا إلى سلسلة التعليقات النشطة التي تم تغييرها. يبقى حيث تم تقديمه لأول مرة. إذا نظرنا إلى تنفيذ التعليق Popover ، فإنه يعرض نفسه مقابل العقدة النصية الأولى في تحديد المحرر. في هذه المرحلة من التنفيذ ، كانت الطريقة الوحيدة لتحديد سلسلة تعليق هي النقر فوق عقدة نصية حتى نتمكن من الاعتماد بشكل ملائم على اختيار المحرر حيث تم تحديثه بواسطة Slate كنتيجة لحدث النقر. في حدث onClick أعلاه ، لا نقوم بتحديث التحديد ولكن نقوم فقط بتحديث قيمة ذرة الارتداد مما يتسبب في بقاء اختيار Slate بدون تغيير وبالتالي لا يتحرك التعليق المنبثق.

حل هذه المشكلة هو تحديث اختيار المحرر مع تحديث ذرة الارتداد عندما ينقر المستخدم على سلسلة التعليقات في الشريط الجانبي. خطوات القيام بذلك هي:

  1. ابحث عن جميع العقد النصية التي تحتوي على سلسلة التعليقات هذه والتي سنقوم بتعيينها كسلسلة نشطة جديدة.
  2. قم بفرز هذه العقد النصية بالترتيب الذي تظهر به في المستند (نستخدم واجهة برمجة تطبيقات Slate's Path.compare لهذا الغرض).
  3. احسب نطاق التحديد الذي يمتد من بداية عقدة النص الأولى إلى نهاية العقدة النصية الأخيرة.
  4. قم بتعيين نطاق التحديد ليكون التحديد الجديد للمحرر (باستخدام Slate's Transforms.select API).

إذا أردنا فقط إصلاح الخطأ ، فيمكننا فقط العثور على العقدة النصية الأولى في الخطوة رقم 1 التي تحتوي على سلسلة التعليقات وتعيين ذلك ليكون اختيار المحرر. ومع ذلك ، يبدو الأمر وكأنه نهج أنظف لتحديد نطاق التعليقات بالكامل حيث إننا بالفعل نختار سلسلة التعليقات.

لنقم بتحديث تنفيذ رد الاتصال onClick لتضمين الخطوات المذكورة أعلاه.

 const onClick = useCallback(() => { const textNodesWithThread = Editor.nodes(editor, { at: [], mode: "lowest", match: (n) => Text.isText(n) && getCommentThreadsOnTextNode(n).has(id), }); let textNodeEntry = textNodesWithThread.next().value; const allTextNodePaths = []; while (textNodeEntry != null) { allTextNodePaths.push(textNodeEntry[1]); textNodeEntry = textNodesWithThread.next().value; } // sort the text nodes allTextNodePaths.sort((p1, p2) => Path.compare(p1, p2)); // set the selection on the editor Transforms.select(editor, { anchor: Editor.point(editor, allTextNodePaths[0], { edge: "start" }), focus: Editor.point( editor, allTextNodePaths[allTextNodePaths.length - 1], { edge: "end" } ), }); // Update the Recoil atom value. setActiveCommentThreadID(id); }, [editor, id, setActiveCommentThreadID]);

ملاحظة : يحتوي allTextNodePaths على المسار إلى جميع العقد النصية. نحن نستخدم Editor.point API للحصول على نقطتي البداية والنهاية في هذا المسار. تتناول المقالة الأولى مفاهيم موقع Slate. كما أنها موثقة جيدًا في وثائق Slate.

دعنا نتحقق من أن هذا التنفيذ يعمل على إصلاح الخطأ وأن التعليق المنبثق ينتقل إلى سلسلة التعليق النشط بشكل صحيح. هذه المرة ، نختبر أيضًا في حالة وجود خيوط متداخلة للتأكد من عدم كسرها هناك.

يؤدي النقر فوق سلسلة تعليق في الشريط الجانبي للتعليقات إلى تحديده وتحميل تعليق سلسلة التعليقات المنبثقة.

من خلال إصلاح الخطأ ، قمنا بتمكين تفاعل شريط جانبي آخر لم نناقشه بعد. إذا كان لدينا مستند طويل جدًا ونقر المستخدم على سلسلة تعليق في الشريط الجانبي خارج إطار العرض ، فنحن نرغب في التمرير إلى هذا الجزء من المستند حتى يتمكن المستخدم من التركيز على سلسلة التعليقات في المحرر. من خلال تعيين التحديد أعلاه باستخدام واجهة برمجة تطبيقات Slate ، نحصل على ذلك مجانًا. دعونا نراه في العمل أدناه.

يقوم المستند بالتمرير إلى سلسلة التعليقات بشكل صحيح عند النقر عليه في الشريط الجانبي للتعليقات.

بذلك ، نختتم تنفيذنا للشريط الجانبي. في نهاية المقالة ، قمنا بإدراج بعض الإضافات والتحسينات الرائعة للميزات التي يمكننا القيام بها في الشريط الجانبي للتعليقات التي تساعد في رفع مستوى تجربة التعليق والمراجعة على المحرر.

حل وإعادة فتح التعليقات

في هذا القسم ، نركز على تمكين المستخدمين من وضع علامة "تم الحل" على سلاسل التعليقات أو أن يكونوا قادرين على إعادة فتحها للمناقشة إذا لزم الأمر. من منظور تفاصيل التنفيذ ، هذه هي البيانات الوصفية status في سلسلة تعليق نقوم بتغييرها أثناء قيام المستخدم بتنفيذ هذا الإجراء. من وجهة نظر المستخدم ، تعد هذه ميزة مفيدة للغاية لأنها تمنحهم طريقة للتأكيد على أن المناقشة حول شيء ما في المستند قد انتهت أو يجب إعادة فتحها نظرًا لوجود بعض التحديثات / وجهات النظر الجديدة وما إلى ذلك.

لتمكين تبديل الحالة ، نضيف زرًا إلى CommentPopover الذي يسمح للمستخدم بالتبديل بين الحالتين: open resolved .

 # src/components/CommentThreadPopover.js export default function CommentThreadPopover({ editorOffsets, selection, threadID, }) { … const [threadData, setCommentThreadData] = useRecoilState( commentThreadsState(threadID) ); ... const onToggleStatus = useCallback(() => { const currentStatus = threadData.status; setCommentThreadData((threadData) => ({ ...threadData, status: currentStatus === "open" ? "resolved" : "open", })); }, [setCommentThreadData, threadData.status]); return ( <NodePopover ... header={ <Header status={threadData.status} shouldAllowStatusChange={threadData.comments.length > 0} onToggleStatus={onToggleStatus} /> } > <div className={"comment-list"}> ... </div> </NodePopover> ); } function Header({ onToggleStatus, shouldAllowStatusChange, status }) { return ( <div className={"comment-thread-popover-header"}> {shouldAllowStatusChange && status != null ? ( <Button size="sm" variant="primary" onClick={onToggleStatus}> {status === "open" ? "Resolve" : "Re-Open"} </Button> ) : null} </div> ); }

قبل أن نختبر هذا ، دعنا أيضًا نمنح الشريط الجانبي للتعليقات تصميمًا تفاضليًا للتعليقات التي تم حلها بحيث يمكن للمستخدم بسهولة اكتشاف سلاسل التعليقات التي لم يتم حلها أو المفتوحة والتركيز عليها إذا أراد ذلك.

 # src/components/CommentsSidebar.js function CommentThread({ id }) { ... const { comments, status } = useRecoilValue(commentThreadsState(id)); ... return ( <Card body={true} className={classNames({ "comment-thread-container": true, "is-resolved": status === "resolved", "is-active": activeCommentThreadID === id, })} onClick={onClick} > ... </Card> ); }
التعليق يتم تبديل حالة سلسلة الرسائل من القائمة المنبثقة وتنعكس في الشريط الجانبي.

خاتمة

في هذه المقالة ، قمنا ببناء البنية الأساسية الأساسية لواجهة المستخدم لنظام التعليق على محرر نص منسق. تعمل مجموعة الوظائف التي نضيفها هنا كأساس لبناء تجربة تعاون أكثر ثراءً على محرر حيث يمكن للمتعاونين وضع تعليقات توضيحية على أجزاء من المستند وإجراء محادثات بشأنها. تمنحنا إضافة شريط جانبي للتعليقات مساحة للحصول على وظائف محادثة أو قائمة على المراجعة يتم تمكينها على المنتج.

إلى جانب هذه السطور ، إليك بعض الميزات التي يمكن أن يفكر محرر النص المنسق في إضافتها فوق ما أنشأناه في هذه المقالة:

  • دعم @ الإشارات حتى يتمكن المتعاونون من وضع علامة على بعضهم البعض في التعليقات ؛
  • دعم أنواع الوسائط مثل الصور ومقاطع الفيديو لإضافتها إلى سلاسل التعليقات ؛
  • وضع الاقتراح على مستوى المستند الذي يسمح للمراجعين بإجراء تعديلات على المستند تظهر كاقتراحات للتغييرات. يمكن للمرء أن يشير إلى هذه الميزة في محرر مستندات Google أو تتبع التغيير في Microsoft Word كأمثلة ؛
  • تحسينات على الشريط الجانبي للبحث في المحادثات حسب الكلمة الأساسية ، وتصفية المواضيع حسب الحالة أو مؤلف (مؤلفي) التعليق ، وما إلى ذلك.