إنشاء محرر نص منسق (WYSIWYG)
نشرت: 2022-03-10في السنوات الأخيرة ، شهد مجال إنشاء المحتوى والتمثيل على المنصات الرقمية اضطرابًا كبيرًا. أظهر النجاح الواسع النطاق لمنتجات مثل Quip و Google Docs و Dropbox Paper كيف تتسابق الشركات لبناء أفضل تجربة لمنشئي المحتوى في مجال المؤسسة ومحاولة إيجاد طرق مبتكرة لكسر القوالب التقليدية لكيفية مشاركة المحتوى واستهلاكه. الاستفادة من الانتشار الواسع لمنصات الوسائط الاجتماعية ، هناك موجة جديدة من منشئي المحتوى المستقلين الذين يستخدمون منصات مثل Medium لإنشاء محتوى ومشاركته مع جمهورهم.
نظرًا لأن العديد من الأشخاص من مختلف المهن والخلفيات يحاولون إنشاء محتوى على هذه المنتجات ، فمن المهم أن توفر هذه المنتجات تجربة فعالة وسلسة لإنشاء المحتوى ولديها فرق من المصممين والمهندسين الذين يطورون مستوى معينًا من الخبرة في المجال بمرور الوقت في هذا المجال . من خلال هذه المقالة ، نحاول ليس فقط وضع الأساس لبناء محرر ولكن أيضًا إعطاء القراء لمحة عن كيف أن القليل من الوظائف عندما يتم دمجها معًا يمكن أن تخلق تجربة مستخدم رائعة لمنشئ المحتوى.
فهم هيكل الوثيقة
قبل أن نتعمق في بناء المحرر ، دعنا نلقي نظرة على كيفية تنظيم المستند لمحرر نص منسق وما هي الأنواع المختلفة لهياكل البيانات المتضمنة.
عقد الوثيقة
تستخدم عقد الوثيقة لتمثيل محتويات الوثيقة. الأنواع الشائعة من العقد التي يمكن أن يحتوي عليها مستند نص منسق هي الفقرات والعناوين والصور ومقاطع الفيديو وكتل التعليمات البرمجية والاقتباسات المنسدلة. قد تحتوي بعض هذه العقد على عقد أخرى مثل الأطفال بداخلها (على سبيل المثال ، تحتوي عقد الفقرة على عقد نصية بداخلها). تحتوي العُقد أيضًا على أي خصائص خاصة بالكائن الذي تمثله والتي تلزم لعرض تلك العقد داخل المحرر. (على سبيل المثال ، تحتوي عُقد الصورة على خاصية src
الخاصة بالصورة ، وقد تحتوي كتل التعليمات البرمجية على خاصية language
وما إلى ذلك).
هناك نوعان إلى حد كبير من العقد التي تمثل كيفية تقديمها -
- عقد الكتل (المشابه لمفهوم HTML لعناصر مستوى الكتلة) التي يتم عرض كل منها في سطر جديد وتشغل العرض المتاح. يمكن أن تحتوي عقد الكتلة على عقد كتلة أخرى أو عقد مضمنة بداخلها. ملاحظة هنا هي أن عقد المستوى الأعلى للمستند ستكون دائمًا عقدًا مجمعة.
- العقد المضمنة (المماثلة لمفهوم HTML للعناصر المضمنة) التي تبدأ في العرض على نفس السطر مثل العقدة السابقة. توجد بعض الاختلافات في كيفية تمثيل العناصر المضمنة في مكتبات التحرير المختلفة. يسمح SlateJS للعناصر المضمنة بأن تكون عُقدًا بحد ذاتها. تتيح لك DraftJS ، وهي مكتبة أخرى شهيرة لتحرير النص المنسق ، استخدام مفهوم الكيانات لعرض العناصر المضمنة. الروابط والصور المضمنة هي أمثلة على العقد المضمنة.
- العقد الفارغة - يسمح SlateJS أيضًا لهذه الفئة الثالثة من العقد التي سنستخدمها لاحقًا في هذه المقالة لتقديم الوسائط.
إذا كنت ترغب في معرفة المزيد حول هذه الفئات ، فإن توثيق SlateJS على Nodes يعد مكانًا جيدًا للبدء.
صفات
على غرار مفهوم HTML للسمات ، تُستخدم السمات الموجودة في مستند نص منسق لتمثيل خصائص غير المحتوى للعقدة أو عناصرها الفرعية. على سبيل المثال ، يمكن أن تحتوي العقدة النصية على سمات نمط الأحرف التي تخبرنا ما إذا كان النص غامقًا / مائلًا / مسطرًا وما إلى ذلك. على الرغم من أن هذه المقالة تمثل العناوين كعقد بحد ذاتها ، إلا أن هناك طريقة أخرى لتمثيلها وهي أن العقد لها أنماط فقرة ( paragraph
& h1-h6
) كسمات عليها.
تقدم الصورة أدناه مثالاً على كيفية وصف بنية المستند (في JSON) على مستوى أكثر دقة باستخدام العقد والسمات التي تسلط الضوء على بعض العناصر الموجودة في الهيكل إلى اليسار.
بعض الأشياء التي تستحق الذكر هنا مع الهيكل هي:
- يتم تمثيل العقد النصية على أنها
{text: 'text content'}
- يتم تخزين خصائص العقد مباشرة على العقدة (مثل
url
للروابطcaption
للصور) - تمثيل سمات النص الخاص بـ SlateJS يكسر العقد النصية لتكون عقدًا خاصة بها إذا تغير نمط الحرف. ومن ثم ، فإن النص " Duis aute irure dolor " هو عقدة نصية خاصة به
bold: true
مضبوط عليه. هذا هو الحال مع نص مائل وتسطير ونمط التعليمات البرمجية في هذا المستند.
المواقع والاختيار
عند إنشاء محرر نص منسق ، من الضروري أن يكون لديك فهم لكيفية تمثيل الجزء الأكثر دقة من المستند (على سبيل المثال حرفًا) بنوع من الإحداثيات. يساعدنا هذا في التنقل في بنية المستند في وقت التشغيل لفهم مكان وجودنا في التسلسل الهرمي للمستند. والأهم من ذلك ، أن كائنات الموقع تعطينا طريقة لتمثيل اختيار المستخدم والتي تُستخدم على نطاق واسع لتخصيص تجربة المستخدم للمحرر في الوقت الفعلي. سنستخدم التحديد لبناء شريط الأدوات الخاص بنا لاحقًا في هذه المقالة. ومن الأمثلة على ذلك:
- هل مؤشر المستخدم موجود حاليًا داخل رابط ، فربما يجب أن نظهر لهم قائمة لتحرير / إزالة الرابط؟
- هل اختار المستخدم صورة؟ ربما نعطيهم قائمة لتغيير حجم الصورة.
- إذا اختار المستخدم نصًا معينًا وضغط على الزر DELETE ، فإننا نحدد النص الذي حدده المستخدم ونزيله من المستند.
تشرح وثيقة SlateJS الخاصة بالموقع هياكل البيانات هذه على نطاق واسع ولكننا نراجعها هنا بسرعة حيث نستخدم هذه المصطلحات في حالات مختلفة في المقالة ونعرض مثالاً في الرسم التخطيطي التالي.
- طريق
يمثل المسار ، الذي يمثله مصفوفة من الأرقام ، طريقة الوصول إلى عقدة في المستند. على سبيل المثال ، يمثل المسار[2,3]
العقدة الفرعية الثالثة للعقدة الثانية في المستند. - نقطة
المزيد من المواقع الدقيقة للمحتوى ممثلة بالمسار + الإزاحة. على سبيل المثال ، تمثل نقطة{path: [2,3], offset: 14}
الحرف الرابع عشر من العقدة الفرعية الثالثة داخل العقدة الثانية من المستند. - نطاق
زوج من النقاط (يسمىanchor
focus
) يمثلان نطاقًا من النص داخل المستند. يأتي هذا المفهوم من Web's Selection API حيث يكونanchor
هو المكان الذي بدأ فيه اختيار المستخدمfocus
عند نقطة النهاية. يشير النطاق / التحديد المطوي إلى المكان الذي تتماثل فيه نقاط الارتساء والتركيز (فكر في المؤشر الوامض في إدخال النص على سبيل المثال).
كمثال ، لنفترض أن اختيار المستخدم في مثال المستند أعلاه هو ipsum
:
يمكن تمثيل اختيار المستخدم على النحو التالي:
{ anchor: {path: [2,0], offset: 5}, /*0th text node inside the paragraph node which itself is index 2 in the document*/ focus: {path: [2,0], offset: 11}, // space + 'ipsum' }`
إعداد المحرر
في هذا القسم ، سنقوم بإعداد التطبيق والحصول على محرر نص منسق أساسي يتماشى مع SlateJS. سيكون تطبيق boilerplate عبارة
create-رد فعل مع إضافة تبعيات SlateJS إليه. نحن نبني واجهة المستخدم للتطبيق باستخدام مكونات من create-react-app
. هيا بنا نبدأ!react-bootstrap
قم بإنشاء مجلد يسمى wysiwyg-editor
وقم بتشغيل الأمر أدناه من داخل الدليل لإعداد تطبيق رد الفعل. نقوم بعد ذلك بتشغيل أمر yarn start
الذي يجب أن يقوم بتدوير خادم الويب المحلي (المنفذ الافتراضي إلى 3000) ويظهر لك شاشة ترحيب React.
npx create-react-app . yarn start
ننتقل بعد ذلك لإضافة تبعيات SlateJS إلى التطبيق.
yarn add slate slate-react
slate
هي الحزمة الأساسية لـ slate-react
وتتضمن Slate-reaction مجموعة مكونات React التي سنستخدمها لتقديم محرري Slate. يعرض SlateJS بعض الحزم المنظمة حسب الوظيفة التي قد يفكر المرء في إضافتها إلى محرره.
نقوم أولاً utils
مجلد أدوات يحتوي على أي وحدات أدوات مساعدة نقوم بإنشائها في هذا التطبيق. نبدأ بإنشاء ExampleDocument.js
التي تُرجع بنية المستند الأساسية التي تحتوي على فقرة مع بعض النص. هذه الوحدة تبدو كما يلي:
const ExampleDocument = [ { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, ], }, ]; export default ExampleDocument;
نضيف الآن مجلدًا يسمى components
والذي سيحتوي على جميع مكونات React الخاصة بنا ونقوم بما يلي:
- أضف مكون
Editor.js
الأول Editor.js إليه. تقوم فقط بإرجاعdiv
في الوقت الحالي. - قم بتحديث مكون
App.js
للاحتفاظ بالمستند في حالته التي تمت تهيئتها إلىExampleDocument
أعلاه. - اعرض المحرر داخل التطبيق وقم بتمرير حالة المستند ومعالج
onChange
إلى المحرر حتى يتم تحديث حالة المستند الخاصة بنا أثناء قيام المستخدم بتحديثها. - نستخدم مكونات Nav في React bootstrap لإضافة شريط تنقل إلى التطبيق أيضًا.
يبدو الآن مكون App.js
كما يلي:
import Editor from './components/Editor'; function App() { const [document, updateDocument] = useState(ExampleDocument); return ( <> <Navbar bg="dark" variant="dark"> <Navbar.Brand href="#"> <img alt="" src="/app-icon.png" width="30" height="30" className="d-inline-block align-top" />{" "} WYSIWYG Editor </Navbar.Brand> </Navbar> <div className="App"> <Editor document={document} onChange={updateDocument} /> </div> </> );
داخل مكون المحرر ، نقوم بعد ذلك بإنشاء مثيل لمحرر SlateJS والاحتفاظ به داخل useMemo
حتى لا يتغير الكائن بين عمليات إعادة التصيير.
// dependencies imported as below. import { withReact } from "slate-react"; import { createEditor } from "slate"; const editor = useMemo(() => withReact(createEditor()), []);
يمنحنا createEditor
مثيل editor
SlateJS الذي نستخدمه على نطاق واسع من خلال التطبيق للوصول إلى التحديدات وتشغيل تحويلات البيانات وما إلى ذلك. withReact هو مكون إضافي من SlateJS يضيف سلوكيات React و DOM إلى كائن المحرر. ملحقات SlateJS هي وظائف Javascript تتلقى كائن editor
وتعلق بعض التكوين به. يتيح ذلك لمطوري الويب إضافة تكوينات إلى مثيل محرر SlateJS الخاص بهم بطريقة قابلة للإنشاء.
نقوم الآن باستيراد وعرض مكونات <Slate />
و <Editable />
من SlateJS باستخدام خاصية المستند التي نحصل عليها من App.js. يعرض Slate
مجموعة من سياقات React التي نستخدمها للوصول إلى كود التطبيق. Editable
هو المكون الذي يعرض التسلسل الهرمي للمستند للتحرير. بشكل عام ، تبدو وحدة
في هذه المرحلة كما يلي:Editor.js
import { Editable, Slate, withReact } from "slate-react"; import { createEditor } from "slate"; import { useMemo } from "react"; export default function Editor({ document, onChange }) { const editor = useMemo(() => withReact(createEditor()), []); return ( <Slate editor={editor} value={document} onChange={onChange}> <Editable /> </Slate> ); }
في هذه المرحلة ، تمت إضافة مكونات React الضرورية وتم ملء المحرر بمستند نموذجي. يجب الآن إعداد محررنا للسماح لنا بكتابة المحتوى وتغييره في الوقت الفعلي - كما هو الحال في تسجيل الشاشة أدناه.
الآن ، دعنا ننتقل إلى القسم التالي حيث نقوم بتكوين المحرر لتقديم أنماط الأحرف وعقد الفقرة.
عرض نص مخصص وشريط أدوات
عقد نمط الفقرة
حاليًا ، يستخدم محررنا العرض الافتراضي لـ SlateJS لأي أنواع عقدة جديدة قد نضيفها إلى المستند. في هذا القسم ، نريد أن نكون قادرين على عرض العقد الرئيسية. لكي نتمكن من القيام بذلك ، نقدم خاصية renderElement
Slate. يتم استدعاء هذه الوظيفة بواسطة Slate في وقت التشغيل عندما تحاول اجتياز شجرة المستند وعرض كل عقدة. تحصل الدالة renderElement على ثلاث معاملات -
-
attributes
SlateJS الخاصة التي يجب تطبيقها على عنصر DOM ذي المستوى الأعلى الذي يتم إرجاعه من هذه الوظيفة. -
element
كائن العقدة نفسه كما هو موجود في بنية المستند -
children
العناصر الفرعية لهذه العقدة كما تم تعريفها في بنية المستند.
نضيف تطبيق renderElement
إلى خطاف يسمى useEditorConfig
حيث سنضيف المزيد من تكوينات المحرر أثناء انتقالنا. ثم نستخدم الخطاف في نسخة المحرر داخل Editor.js
.
import { DefaultElement } from "slate-react"; export default function useEditorConfig(editor) { return { renderElement }; } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "paragraph": return <p {...attributes}>{children}</p>; case "h1": return <h1 {...attributes}>{children}</h1>; case "h2": return <h2 {...attributes}>{children}</h2>; case "h3": return <h3 {...attributes}>{children}</h3>; case "h4": return <h4 {...attributes}>{children}</h4>; default: // For the default case, we delegate to Slate's default rendering. return <DefaultElement {...props} />; } }
نظرًا لأن هذه الوظيفة تتيح لنا الوصول إلى element
(وهو العقدة نفسها) ، فيمكننا تخصيص renderElement
لتنفيذ عرض أكثر تخصيصًا يقوم بأكثر من مجرد التحقق من element.type
. على سبيل المثال ، يمكن أن يكون لديك عقدة صورة لها خاصية isInline
التي يمكننا استخدامها لإرجاع بنية DOM مختلفة تساعدنا في عرض الصور المضمنة مقابل الصور المحظورة.
نقوم الآن بتحديث مكون المحرر لاستخدام هذا الخطاف على النحو التالي:
const { renderElement } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} /> );
مع العرض المخصص في مكانه الصحيح ، نقوم بتحديث ExampleDocument لتضمين أنواع العقد الجديدة الخاصة بنا والتحقق من أنها تعرض بشكل صحيح داخل المحرر.
const ExampleDocument = [ { type: "h1", children: [{ text: "Heading 1" }], }, { type: "h2", children: [{ text: "Heading 2" }], }, // ...more heading nodes
أنماط الأحرف
على غرار renderElement
، يقدم SlateJS خاصية دالة تسمى renderLeaf يمكن استخدامها لتخصيص عرض العقد النصية (تشير Leaf
إلى العقد النصية التي تمثل العقد / العقد ذات المستوى الأدنى لشجرة المستند). باتباع مثال renderElement
، نكتب تنفيذًا لـ renderLeaf
.
export default function useEditorConfig(editor) { return { renderElement, renderLeaf }; } // ... function renderLeaf({ attributes, children, leaf }) { let el = <>{children}</>; if (leaf.bold) { el = <strong>{el}</strong>; } if (leaf.code) { el = <code>{el}</code>; } if (leaf.italic) { el = <em>{el}</em>; } if (leaf.underline) { el = <u>{el}</u>; } return <span {...attributes}>{el}</span>; }
ملاحظة مهمة للتنفيذ أعلاه هي أنه يسمح لنا باحترام دلالات HTML لأنماط الأحرف. نظرًا لأن RenderLeaf يمنحنا الوصول إلى leaf
عقدة النص نفسها ، يمكننا تخصيص الوظيفة لتنفيذ عرض أكثر تخصيصًا. على سبيل المثال ، قد يكون لديك طريقة للسماح للمستخدمين باختيار highlightColor
للنص والتحقق من خاصية الورقة هنا لإرفاق الأنماط المعنية.
نقوم الآن بتحديث مكون المحرر لاستخدام ما سبق ، وهو ExampleDocument
للحصول على عدد قليل من العقد النصية في الفقرة مع مجموعات من هذه الأنماط والتحقق من تقديمها كما هو متوقع في المحرر باستخدام العلامات الدلالية التي استخدمناها.
# src/components/Editor.js const { renderElement, renderLeaf } = useEditorConfig(editor); return ( ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> );
# src/utils/ExampleDocument.js { type: "paragraph", children: [ { text: "Hello World! This is my paragraph inside a sample document." }, { text: "Bold text.", bold: true, code: true }, { text: "Italic text.", italic: true }, { text: "Bold and underlined text.", bold: true, underline: true }, { text: "variableFoo", code: true }, ], },
إضافة شريط أدوات
لنبدأ بإضافة مكون جديد Toolbar.js
نضيف إليه بضعة أزرار لأنماط الأحرف وقائمة منسدلة لأنماط الفقرة وسنقوم بتوصيلها لاحقًا في القسم.
const PARAGRAPH_STYLES = ["h1", "h2", "h3", "h4", "paragraph", "multiple"]; const CHARACTER_STYLES = ["bold", "italic", "underline", "code"]; export default function Toolbar({ selection, previousSelection }) { return ( <div className="toolbar"> {/* Dropdown for paragraph styles */} <DropdownButton className={"block-style-dropdown"} disabled={false} title={getLabelForBlockStyle("paragraph")} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> {/* Buttons for character styles */} {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={false} /> ))} </div> ); } function ToolBarButton(props) { const { icon, isActive, ...otherProps } = props; return ( <Button variant="outline-primary" className="toolbar-btn" active={isActive} {...otherProps} > {icon} </Button> ); }
نقوم بتجريد الأزرار إلى مكون ToolbarButton
وهو عبارة عن غلاف حول مكون React Bootstrap Button. نقوم بعد ذلك بعرض شريط الأدوات أعلى المكون Editable
داخل Editor
والتحقق من ظهور شريط الأدوات في التطبيق.
فيما يلي الوظائف الرئيسية الثلاث التي نحتاج إلى شريط الأدوات لدعمها:
- عندما يكون مؤشر المستخدم في مكان معين في المستند ويقوم بالنقر فوق أحد أزرار نمط الأحرف ، نحتاج إلى تبديل النمط للنص الذي قد يكتبه بعد ذلك.
- عندما يحدد المستخدم نطاقًا من النص والنقر فوق أحد أزرار نمط الأحرف ، نحتاج إلى تبديل النمط لهذا القسم المحدد.
- عندما يحدد المستخدم نطاقًا من النص ، نريد تحديث القائمة المنسدلة لنمط الفقرة لتعكس نوع الفقرة في التحديد. إذا قاموا بتحديد قيمة مختلفة من التحديد ، فنحن نريد تحديث نمط الفقرة للتحديد بأكمله ليكون ما حددوه.
دعونا نلقي نظرة على كيفية عمل هذه الوظائف على المحرر قبل أن نبدأ في تنفيذها.
الاستماع إلى التحديد
أهم شيء يحتاجه شريط الأدوات ليكون قادرًا على أداء الوظائف المذكورة أعلاه هو حالة التحديد الخاصة بالمستند. حتى كتابة هذا المقال ، لا يعرض onSelectionChange
طريقة onSelectionChange التي يمكن أن تعطينا أحدث حالة اختيار للمستند. ومع ذلك ، مع تغير التحديد في المحرر ، يستدعي SlateJS طريقة onChange
، حتى لو لم تتغير محتويات المستند. نستخدم هذا كطريقة لإعلامنا بتغيير التحديد وتخزينه في حالة مكون Editor
. نقوم بتجريد هذا في تحديد استخدام useSelection
حيث يمكننا القيام بتحديث أكثر أمثل لحالة التحديد. هذا مهم لأن التحديد هو خاصية تتغير كثيرًا لمثيل WYSIWYG Editor.
import areEqual from "deep-equal"; export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const setSelectionOptimized = useCallback( (newSelection) => { // don't update the component state if selection hasn't changed. if (areEqual(selection, newSelection)) { return; } setSelection(newSelection); }, [setSelection, selection] ); return [selection, setSelectionOptimized]; }
نستخدم هذا الخطاف داخل مكون Editor
على النحو التالي ونمرر التحديد إلى مكون شريط الأدوات.
const [selection, setSelection] = useSelection(editor); const onChangeHandler = useCallback( (document) => { onChange(document); setSelection(editor.selection); }, [editor.selection, onChange, setSelection] ); return ( <Slate editor={editor} value={document} onChange={onChangeHandler}> <Toolbar selection={selection} /> ...
مقابل الأداء
في تطبيق حيث لدينا قاعدة أكواد محرر أكبر بكثير مع وظائف أكثر بكثير ، من المهم تخزين تغييرات التحديد والاستماع إليها بطريقة أداء (مثل استخدام بعض مكتبات إدارة الدولة) حيث من المحتمل أن تعرض المكونات التي تستمع إلى تغييرات التحديد أيضًا غالبا. تتمثل إحدى طرق القيام بذلك في الحصول على محددات مُحسَّنة أعلى حالة التحديد التي تحتوي على معلومات تحديد محددة. على سبيل المثال ، قد يرغب المحرر في عرض قائمة تغيير حجم الصورة عند تحديد صورة. في مثل هذه الحالة ، قد يكون من المفيد أن يكون المُحدِّد isImageSelected
محسوبًا من حالة التحديد الخاصة بالمحرر وستتم إعادة عرض قائمة الصورة فقط عندما تتغير قيمة هذا المحدد. Redux's Reselect هي إحدى هذه المكتبات التي تتيح إنشاء محددات.
لا نستخدم selection
داخل شريط الأدوات حتى وقت لاحق ، لكن تمريره لأسفل كعنصر يجعل شريط الأدوات يعيد عرضه في كل مرة يتغير فيها التحديد في المحرر. نقوم بذلك لأننا لا نستطيع الاعتماد فقط على تغيير محتوى المستند لبدء إعادة العرض على التسلسل الهرمي ( App -> Editor -> Toolbar
) حيث قد يستمر المستخدمون في النقر حول المستند وبالتالي تغيير التحديد ولكنهم لا يغيرون محتوى المستند أبدًا بحد ذاتها.
تبديل أنماط الأحرف
ننتقل الآن إلى الحصول على أنماط الشخصيات النشطة من SlateJS واستخدام تلك الموجودة داخل المحرر. دعنا نضيف محرر JS module EditorUtils
الجديد الذي سيستضيف جميع الوظائف المفيدة التي نبنيها للمضي قدمًا للحصول على / القيام بالأشياء باستخدام SlateJS. وظيفتنا الأولى في الوحدة هي getActiveStyles
التي توفر Set
من الأنماط النشطة في المحرر. نضيف أيضًا وظيفة لتبديل نمط في وظيفة المحرر - toggleStyle
:
# src/utils/EditorUtils.js import { Editor } from "slate"; export function getActiveStyles(editor) { return new Set(Object.keys(Editor.marks(editor) ?? {})); } export function toggleStyle(editor, style) { const activeStyles = getActiveStyles(editor); if (activeStyles.has(style)) { Editor.removeMark(editor, style); } else { Editor.addMark(editor, style, true); } }
تأخذ كلتا الوظيفتين كائن editor
وهو مثيل Slate كمعامل وكذلك الكثير من الوظائف المفيدة التي نضيفها لاحقًا في المقالة. في مصطلحات Slate ، تسمى أنماط التنسيق Marks ونستخدم طرقًا مساعدة على واجهة المحرر وإزالة هذه العلامات ، نقوم باستيراد هذه الوظائف المفيدة داخل شريط الأدوات وربطها بالأزرار التي أضفناها سابقًا.
# src/components/Toolbar.js import { getActiveStyles, toggleStyle } from "../utils/EditorUtils"; import { useEditor } from "slate-react"; export default function Toolbar({ selection }) { const editor = useEditor(); return <div ... {CHARACTER_STYLES.map((style) => ( <ToolBarButton key={style} characterStyle={style} icon={<i className={`bi ${getIconForButton(style)}`} />} isActive={getActiveStyles(editor).has(style)} onMouseDown={(event) => { event.preventDefault(); toggleStyle(editor, style); }} /> ))} </div>
useEditor
هو رابط Slate يتيح لنا الوصول إلى مثيل Slate من السياق حيث تم إرفاقه بواسطة مكون <Slate>
أعلى في التسلسل الهرمي للعرض.
قد يتساءل المرء لماذا نستخدم onMouseDown
هنا بدلاً من onClick
؟ هناك مشكلة Github مفتوحة حول كيفية تحويل Slate selection
إلى null
عندما يفقد المحرر التركيز بأي شكل من الأشكال. لذلك ، إذا قمنا بإرفاق معالجات onClick
بأزرار شريط الأدوات الخاصة بنا ، فسيصبح selection
null
ويفقد المستخدمون موضع المؤشر في محاولة تبديل نمط لا يمثل تجربة رائعة. بدلاً من ذلك ، نتبدل النمط عن طريق إرفاق حدث onMouseDown
الذي يمنع إعادة تعيين التحديد. هناك طريقة أخرى للقيام بذلك وهي تتبع التحديد بأنفسنا حتى نعرف ما كان التحديد الأخير واستخدامه لتبديل الأنماط. نقدم مفهوم الاختيار previousSelection
لاحقًا في المقالة ولكن لحل مشكلة مختلفة.
يسمح لنا SlateJS بتكوين معالجات الأحداث على المحرر. نستخدم ذلك لتوصيل اختصارات لوحة المفاتيح لتبديل أنماط الأحرف. للقيام بذلك ، نضيف كائن KeyBindings
داخل useEditorConfig
حيث نعرض معالج حدث onKeyDown
المرفق بالمكون القابل Editable
. نستخدم is-hotkey
لتحديد مجموعة المفاتيح وتبديل النمط المقابل.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const onKeyDown = useCallback( (event) => KeyBindings.onKeyDown(editor, event), [editor] ); return { renderElement, renderLeaf, onKeyDown }; } const KeyBindings = { onKeyDown: (editor, event) => { if (isHotkey("mod+b", event)) { toggleStyle(editor, "bold"); return; } if (isHotkey("mod+i", event)) { toggleStyle(editor, "italic"); return; } if (isHotkey("mod+c", event)) { toggleStyle(editor, "code"); return; } if (isHotkey("mod+u", event)) { toggleStyle(editor, "underline"); return; } }, }; # src/components/Editor.js ... <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} />
عمل القائمة المنسدلة لنمط الفقرة
دعنا ننتقل إلى جعل القائمة المنسدلة أنماط الفقرة تعمل. على غرار كيفية عمل القوائم المنسدلة بنمط الفقرة في تطبيقات معالجة الكلمات الشائعة مثل MS Word أو Google Docs ، نريد أن تنعكس أنماط كتل المستوى الأعلى في اختيار المستخدم في القائمة المنسدلة. إذا كان هناك نمط واحد ثابت عبر التحديد ، نقوم بتحديث القيمة المنسدلة لتكون كذلك. إذا كان هناك العديد من هؤلاء ، فسنقوم بتعيين القيمة المنسدلة لتكون "متعددة". يجب أن يعمل هذا السلوك لكل من - التحديدات المطوية والموسعة.
لتنفيذ هذا السلوك ، نحتاج إلى أن نكون قادرين على العثور على الكتل عالية المستوى التي تغطي اختيار المستخدم. للقيام بذلك ، نستخدم Slate's Editor.nodes
- وظيفة مساعدة شائعة الاستخدام للبحث عن العقد في شجرة تمت تصفيتها بخيارات مختلفة.
nodes( editor: Editor, options?: { at?: Location | Span match?: NodeMatch<T> mode?: 'all' | 'highest' | 'lowest' universal?: boolean reverse?: boolean voids?: boolean } ) => Generator<NodeEntry<T>, void, undefined>
تأخذ وظيفة المساعد مثيل محرر وكائن options
وهو طريقة لتصفية العقد في الشجرة أثناء عبورها. تقوم الوظيفة بإرجاع منشئ NodeEntry
. إن مصطلحات NodeEntry
في Slate هي مجموعة من العقدة والمسار إليها - [node, pathToNode]
. تتوفر الخيارات الموجودة هنا في معظم وظائف مساعد Slate. دعنا نستعرض ما يعنيه كل من هؤلاء:
-
at
يمكن أن يكون هذا مسارًا / نقطة / نطاقًا ستستخدمه الوظيفة المساعدة لتوسيع نطاق اجتياز الشجرة إليه. هذا الإعداد الافتراضي إلىeditor.selection
إذا لم يتم توفيره. نستخدم أيضًا الوضع الافتراضي لحالة الاستخدام أدناه نظرًا لأننا مهتمون بالعقد ضمن اختيار المستخدم. -
match
هذه وظيفة مطابقة يمكن للمرء توفيرها والتي يتم استدعاؤها على كل عقدة ويتم تضمينها إذا كانت مطابقة. نستخدم هذه المعلمة في التنفيذ أدناه للتصفية لحظر العناصر فقط. -
mode
دعنا نعرف وظائف المساعد ما إذا كنا مهتمين بجميع العقد ذات المستوى الأعلى أو الأدنىat
وظيفةmatch
الموقع المحددة. تساعدنا هذه المعلمة (الضبط علىhighest
) على الهروب من محاولة اجتياز الشجرة لأعلى لأنفسنا للعثور على العقد ذات المستوى الأعلى. -
universal
علامة للاختيار بين التطابقات الكاملة أو الجزئية للعقد. (مشكلة GitHub مع اقتراح هذه العلامة لديها بعض الأمثلة التي توضح ذلك) -
reverse
إذا كان يجب أن يكون بحث العقدة في الاتجاه العكسي لنقاط البداية والنهاية للموقع الذي تم تمريره. -
voids
إذا كان البحث يجب أن يقوم بالتصفية إلى عناصر باطلة فقط.
يعرض SlateJS الكثير من الوظائف المساعدة التي تتيح لك الاستعلام عن العقد بطرق مختلفة ، أو اجتياز الشجرة ، أو تحديث العقد أو التحديدات بطرق معقدة. يجدر البحث في بعض هذه الواجهات (المدرجة في نهاية هذه المقالة) عند إنشاء وظائف تحرير معقدة أعلى Slate.
مع هذه الخلفية على وظيفة المساعد ، يوجد أدناه تطبيق getTextBlockStyle
.
# src/utils/EditorUtils.js export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } const topLevelBlockNodesInSelection = Editor.nodes(editor, { at: editor.selection, mode: "highest", match: (n) => Editor.isBlock(editor, n), }); let blockType = null; let nodeEntry = topLevelBlockNodesInSelection.next(); while (!nodeEntry.done) { const [node, _] = nodeEntry.value; if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } nodeEntry = topLevelBlockNodesInSelection.next(); } return blockType; }
مقابل الأداء
يعثر التطبيق الحالي لـ Editor.nodes
على جميع العقد في جميع أنحاء الشجرة عبر جميع المستويات التي تقع ضمن نطاق المعلمة at
ثم يقوم بتشغيل مرشحات المطابقة عليها (تحقق من nodeEntries
والتصفية لاحقًا - المصدر). هذا جيد للمستندات الأصغر. ومع ذلك ، بالنسبة لحالة الاستخدام الخاصة بنا ، إذا حدد المستخدم ، قل 3 عناوين وفقرتين (كل فقرة تحتوي على 10 عقد نصية على سبيل المثال) ، فسوف يتنقل عبر 25 عقدة على الأقل (3 + 2 + 2 * 10) ويحاول تشغيل المرشحات عليهم. نظرًا لأننا نعلم بالفعل أننا مهتمون بالعقد ذات المستوى الأعلى فقط ، فيمكننا العثور على فهارس البداية والنهاية للكتل ذات المستوى الأعلى من التحديد وتكرار أنفسنا. مثل هذا المنطق سوف يمر عبر 3 مدخلات فقط للعقدة (عنوانان وفقرة واحدة). سيبدو الرمز الخاص بذلك على النحو التالي:
export function getTextBlockStyle(editor) { const selection = editor.selection; if (selection == null) { return null; } // gives the forward-direction points in case the selection was // was backwards. const [start, end] = Range.edges(selection); //path[0] gives us the index of the top-level block. let startTopLevelBlockIndex = start.path[0]; const endTopLevelBlockIndex = end.path[0]; let blockType = null; while (startTopLevelBlockIndex <= endTopLevelBlockIndex) { const [node, _] = Editor.node(editor, [startTopLevelBlockIndex]); if (blockType == null) { blockType = node.type; } else if (blockType !== node.type) { return "multiple"; } startTopLevelBlockIndex++; } return blockType; }
نظرًا لأننا نضيف المزيد من الوظائف إلى محرر WYSIWYG ونحتاج إلى اجتياز شجرة المستندات كثيرًا ، فمن المهم التفكير في أكثر الطرق أداءً للقيام بذلك بالنسبة لحالة الاستخدام الحالية ، حيث قد لا تكون دائمًا واجهة برمجة التطبيقات أو الطرق المساعدة المتاحة هي الأكثر طريقة فعالة للقيام بذلك.
بمجرد تنفيذ getTextBlockStyle
، يكون تبديل نمط الكتلة أمرًا بسيطًا نسبيًا. إذا لم يكن النمط الحالي هو ما حدده المستخدم في القائمة المنسدلة ، فسنقوم بتبديل النمط إليه. إذا كان هذا هو بالفعل ما حدده المستخدم ، فسنقوم بتبديله ليكون فقرة. نظرًا لأننا نمثل أنماط الفقرة كعقد في هيكل المستند الخاص بنا ، فإن تبديل نمط الفقرة يعني بشكل أساسي تغيير خاصية type
على العقدة. نستخدم Transforms.setNodes
المقدمة من Slate لتحديث الخصائص على العقد.
تنفيذ toggleBlockType
الخاص بنا على النحو التالي:
# src/utils/EditorUtils.js export function toggleBlockType(editor, blockType) { const currentBlockType = getTextBlockStyle(editor); const changeTo = currentBlockType === blockType ? "paragraph" : blockType; Transforms.setNodes( editor, { type: changeTo }, // Node filtering options supported here too. We use the same // we used with Editor.nodes above. { at: editor.selection, match: (n) => Editor.isBlock(editor, n) } ); }
أخيرًا ، نقوم بتحديث القائمة المنسدلة Paragraph-Style الخاصة بنا لاستخدام وظائف الأداة المساعدة هذه.
#src/components/Toolbar.js const onBlockTypeChange = useCallback( (targetType) => { if (targetType === "multiple") { return; } toggleBlockType(editor, targetType); }, [editor] ); const blockType = getTextBlockStyle(editor); return ( <div className="toolbar"> <DropdownButton ..... disabled={blockType == null} title={getLabelForBlockStyle(blockType ?? "paragraph")} onSelect={onBlockTypeChange} > {PARAGRAPH_STYLES.map((blockType) => ( <Dropdown.Item eventKey={blockType} key={blockType}> {getLabelForBlockStyle(blockType)} </Dropdown.Item> ))} </DropdownButton> .... );
الروابط
في هذا القسم ، سنضيف دعمًا لإظهار الروابط وإضافتها وإزالتها وتغييرها. سنضيف أيضًا وظيفة Link-Detector - تشبه تمامًا طريقة Google Docs أو MS Word التي تفحص النص الذي كتبه المستخدم وتتحقق مما إذا كانت هناك روابط هناك. إذا كانت موجودة ، يتم تحويلها إلى كائنات ارتباط بحيث لا يضطر المستخدم إلى استخدام أزرار شريط الأدوات للقيام بذلك بنفسه.
تقديم الروابط
في محررنا ، سنقوم بتنفيذ الروابط كعقد مضمنة مع SlateJS. نقوم بتحديث تكوين المحرر الخاص بنا لوضع علامة على الروابط كعقد مضمنة لـ SlateJS ونوفر أيضًا مكونًا لعرضه حتى يعرف Slate كيفية عرض عقد الارتباط.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { ... editor.isInline = (element) => ["link"].includes(element.type); return {....} } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { ... case "link": return <Link {...props} url={element.url} />; ... } }
# src/components/Link.js export default function Link({ element, attributes, children }) { return ( <a href={element.url} {...attributes} className={"link"}> {children} </a> ); }
We then add a link node to our ExampleDocument
and verify that it renders correctly (including a case for character styles inside a link) in the Editor.
# src/utils/ExampleDocument.js { type: "paragraph", children: [ ... { text: "Some text before a link." }, { type: "link", url: "https://www.google.com", children: [ { text: "Link text" }, { text: "Bold text inside link", bold: true }, ], }, ... }
Adding A Link Button To The Toolbar
Let's add a Link Button to the toolbar that enables the user to do the following:
- Selecting some text and clicking on the button converts that text into a link
- Having a blinking cursor (collapsed selection) and clicking the button inserts a new link there
- If the user's selection is inside a link, clicking on the button should toggle the link — meaning convert the link back to text.
To build these functionalities, we need a way in the toolbar to know if the user's selection is inside a link node. We add a util function that traverses the levels in upward direction from the user's selection to find a link node if there is one, using Editor.above
helper function from SlateJS.
# src/utils/EditorUtils.js export function isLinkNodeAtSelection(editor, selection) { if (selection == null) { return false; } return ( Editor.above(editor, { at: selection, match: (n) => n.type === "link", }) != null ); }
Now, let's add a button to the toolbar that is in active state if the user's selection is inside a link node.
# src/components/Toolbar.js return ( <div className="toolbar"> ... {/* Link Button */} <ToolBarButton isActive={isLinkNodeAtSelection(editor, editor.selection)} label={<i className={`bi ${getIconForButton("link")}`} />} /> </div> );
To toggle links in the editor, we add a util function toggleLinkAtSelection
. Let's first look at how the toggle works when you have some text selected. When the user selects some text and clicks on the button, we want only the selected text to become a link. What this inherently means is that we need to break the text node that contains selected text and extract the selected text into a new link node. The before and after states of these would look something like below:
If we had to do this by ourselves, we'd have to figure out the range of selection and create three new nodes (text, link, text) that replace the original text node. SlateJS has a helper function called Transforms.wrapNodes
that does exactly this — wrap nodes at a location into a new container node. We also have a helper available for the reverse of this process — Transforms.unwrapNodes
which we use to remove links from selected text and merge that text back into the text nodes around it. With that, toggleLinkAtSelection
has the below implementation to insert a new link at an expanded selection.
# src/utils/EditorUtils.js export function toggleLinkAtSelection(editor) { if (!isLinkNodeAtSelection(editor, editor.selection)) { const isSelectionCollapsed = Range.isCollapsed(editor.selection); if (isSelectionCollapsed) { Transforms.insertNodes( editor, { type: "link", url: '#', children: [{ text: 'link' }], }, { at: editor.selection } ); } else { Transforms.wrapNodes( editor, { type: "link", url: '#', children: [{ text: '' }] }, { split: true, at: editor.selection } ); } } else { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === "link", }); } }
If the selection is collapsed, we insert a new node there with
that inserts the node at the given location in the document. We wire this function up with the toolbar button and should now have a way to add/remove links from the document with the help of the link button.Transform.insertNodes
# src/components/Toolbar.js <ToolBarButton ... isActive={isLinkNodeAtSelection(editor, editor.selection)} onMouseDown={() => toggleLinkAtSelection(editor)} />
Link Editor Menu
So far, our editor has a way to add and remove links but we don't have a way to update the URLs associated with these links. How about we extend the user experience to allow users to edit it easily with a contextual menu? To enable link editing, we will build a link-editing popover that shows up whenever the user selection is inside a link and lets them edit and apply the URL to that link node. Let's start with building an empty LinkEditor
component and rendering it whenever the user selection is inside a link.
# src/components/LinkEditor.js export default function LinkEditor() { return ( <Card className={"link-editor"}> <Card.Body></Card.Body> </Card> ); }
# src/components/Editor.js <div className="editor"> {isLinkNodeAtSelection(editor, selection) ? <LinkEditor /> : null} <Editable renderElement={renderElement} renderLeaf={renderLeaf} onKeyDown={onKeyDown} /> </div>
نظرًا لأننا LinkEditor
خارج المحرر ، فنحن بحاجة إلى طريقة لإخبار LinkEditor
الارتباط في شجرة DOM حتى يتمكن من عرض نفسه بالقرب من المحرر. الطريقة التي نقوم بها هي استخدام واجهة برمجة تطبيقات Slate's React للعثور على عقدة DOM المقابلة لعقدة الارتباط المحددة. ثم نستخدم getBoundingClientRect()
للعثور على حدود عنصر DOM للرابط وحدود مكون المحرر وحساب الجزء top
left
لمحرر الرابط. تحديثات الكود إلى Editor
و LinkEditor
هي على النحو التالي -
# src/components/Editor.js const editorRef = useRef(null) <div className="editor" ref={editorRef}> {isLinkNodeAtSelection(editor, selection) ? ( <LinkEditor editorOffsets={ editorRef.current != null ? { x: editorRef.current.getBoundingClientRect().x, y: editorRef.current.getBoundingClientRect().y, } : null } /> ) : null} <Editable renderElement={renderElement} ...
# src/components/LinkEditor.js import { ReactEditor } from "slate-react"; export default function LinkEditor({ editorOffsets }) { const linkEditorRef = useRef(null); const [linkNode, path] = Editor.above(editor, { match: (n) => n.type === "link", }); useEffect(() => { const linkEditorEl = linkEditorRef.current; if (linkEditorEl == null) { return; } const linkDOMNode = ReactEditor.toDOMNode(editor, linkNode); const { x: nodeX, height: nodeHeight, y: nodeY, } = linkDOMNode.getBoundingClientRect(); linkEditorEl.style.display = "block"; linkEditorEl.style.top = `${nodeY + nodeHeight — editorOffsets.y}px`; linkEditorEl.style.left = `${nodeX — editorOffsets.x}px`; }, [editor, editorOffsets.x, editorOffsets.y, node]); if (editorOffsets == null) { return null; } return <Card ref={linkEditorRef} className={"link-editor"}></Card>; }
تحتفظ SlateJS داخليًا بخرائط للعقد لعناصر DOM الخاصة بها. نصل إلى تلك الخريطة ونجد عنصر DOM الخاص بالرابط باستخدام ReactEditor.toDOMNode
.
كما هو موضح في الفيديو أعلاه ، عندما يتم إدخال رابط ولا يحتوي على عنوان URL ، نظرًا لوجود التحديد داخل الرابط ، فإنه يفتح محرر الرابط وبالتالي يمنح المستخدم طريقة لكتابة عنوان URL للرابط المُدرج حديثًا و ومن ثم يغلق الحلقة الخاصة بتجربة المستخدم هناك.
نضيف الآن عنصر إدخال وزرًا إلى LinkEditor
للمستخدم كتابة عنوان URL وتطبيقه على عقدة الارتباط. نستخدم حزمة isUrl
للتحقق من صحة عنوان URL.
# src/components/LinkEditor.js import isUrl from "is-url"; export default function LinkEditor({ editorOffsets }) { const [linkURL, setLinkURL] = useState(linkNode.url); // update state if `linkNode` changes useEffect(() => { setLinkURL(linkNode.url); }, [linkNode]); const onLinkURLChange = useCallback( (event) => setLinkURL(event.target.value), [setLinkURL] ); const onApply = useCallback( (event) => { Transforms.setNodes(editor, { url: linkURL }, { at: path }); }, [editor, linkURL, path] ); return ( ... <Form.Control size="sm" type="text" value={linkURL} onChange={onLinkURLChange} /> <Button className={"link-editor-btn"} size="sm" variant="primary" disabled={!isUrl(linkURL)} onClick={onApply} > Apply </Button> ... );
مع توصيل عناصر النموذج ، دعنا نرى ما إذا كان محرر الارتباط يعمل كما هو متوقع.
كما نرى هنا في الفيديو ، عندما يحاول المستخدم النقر فوق الإدخال ، يختفي محرر الارتباط. هذا لأننا عندما نعرض محرر الرابط خارج المكون القابل Editable
، عندما ينقر المستخدم على عنصر الإدخال ، يعتقد SlateJS أن المحرر قد فقد التركيز وأعاد تعيين selection
ليكون null
مما يزيل LinkEditor
لأن isLinkActiveAtSelection
لم يعد true
بعد الآن. توجد مشكلة GitHub مفتوحة تتحدث عن سلوك Slate هذا. تتمثل إحدى طرق حل هذه المشكلة في تتبع التحديد السابق للمستخدم أثناء تغيره وعندما يفقد المحرر التركيز ، يمكننا إلقاء نظرة على التحديد السابق وما زلنا نعرض قائمة محرر الارتباط إذا كان التحديد السابق يحتوي على رابط بداخله. لنقم بتحديث خطاف useSelection
لتذكر التحديد السابق وإعادته إلى مكون Editor.
# src/hooks/useSelection.js export default function useSelection(editor) { const [selection, setSelection] = useState(editor.selection); const previousSelection = useRef(null); const setSelectionOptimized = useCallback( (newSelection) => { if (areEqual(selection, newSelection)) { return; } previousSelection.current = selection; setSelection(newSelection); }, [setSelection, selection] ); return [previousSelection.current, selection, setSelectionOptimized]; }
نقوم بعد ذلك بتحديث المنطق في مكون Editor
لإظهار قائمة الارتباط حتى لو كان التحديد السابق به رابط.
# src/components/Editor.js const [previousSelection, selection, setSelection] = useSelection(editor); let selectionForLink = null; if (isLinkNodeAtSelection(editor, selection)) { selectionForLink = selection; } else if (selection == null && isLinkNodeAtSelection(editor, previousSelection)) { selectionForLink = previousSelection; } return ( ... <div className="editor" ref={editorRef}> {selectionForLink != null ? ( <LinkEditor selectionForLink={selectionForLink} editorOffsets={..} ... );
نقوم بعد ذلك بتحديث LinkEditor
لاستخدام selectionForLink
للبحث عن عقدة الارتباط ، والعرض أدناه وتحديث عنوان URL الخاص بها.
# src/components/Link.js export default function LinkEditor({ editorOffsets, selectionForLink }) { ... const [node, path] = Editor.above(editor, { at: selectionForLink, match: (n) => n.type === "link", }); ...
كشف الروابط في النص
تقوم معظم تطبيقات معالجة النصوص بتحديد وتحويل الروابط داخل النص لربط الكائنات. دعونا نرى كيف سيعمل ذلك في المحرر قبل أن نبدأ في بنائه.
خطوات المنطق لتمكين هذا السلوك ستكون:
- عندما يتغير المستند مع كتابة المستخدم ، ابحث عن آخر حرف أدخله المستخدم. إذا كانت هذه الشخصية مسافة ، فنحن نعلم أنه لا بد من وجود كلمة قبلها.
- إذا كان الحرف الأخير عبارة عن مسافة ، فإننا نضع علامة على ذلك باعتباره الحد النهائي للكلمة التي جاءت قبله. ثم ننتقل للخلف حرفًا بحرف داخل عقدة النص للعثور على المكان الذي بدأت فيه هذه الكلمة. أثناء هذا الاجتياز ، يجب أن نكون حريصين على عدم تجاوز حافة بداية العقدة إلى العقدة السابقة.
- بمجرد العثور على حدود البداية والنهاية للكلمة من قبل ، نتحقق من سلسلة الكلمة ونرى ما إذا كان هذا هو عنوان URL. إذا كان الأمر كذلك ، فإننا نقوم بتحويله إلى عقدة ارتباط.
منطقنا يعيش في وظيفة identifyLinksInTextIfAny
useLinksInTextIfAny التي تعيش في EditorUtils
ويتم استدعاؤها داخل مكون onChange
في Editor
.
# src/components/Editor.js const onChangeHandler = useCallback( (document) => { ... identifyLinksInTextIfAny(editor); }, [editor, onChange, setSelection] );
هنا هو identifyLinksInTextIfAny
مع تطبيق منطق الخطوة 1:
export function identifyLinksInTextIfAny(editor) { // if selection is not collapsed, we do not proceed with the link // detection if (editor.selection == null || !Range.isCollapsed(editor.selection)) { return; } const [node, _] = Editor.parent(editor, editor.selection); // if we are already inside a link, exit early. if (node.type === "link") { return; } const [currentNode, currentNodePath] = Editor.node(editor, editor.selection); // if we are not inside a text node, exit early. if (!Text.isText(currentNode)) { return; } let [start] = Range.edges(editor.selection); const cursorPoint = start; const startPointOfLastCharacter = Editor.before(editor, editor.selection, { unit: "character", }); const lastCharacter = Editor.string( editor, Editor.range(editor, startPointOfLastCharacter, cursorPoint) ); if(lastCharacter !== ' ') { return; }
هناك وظيفتان مساعدتان في SlateJS تسهّلان الأمور هنا.
-
Editor.before
- يعطينا النقطة قبل موقع معين. يستغرق الأمرunit
كمعامل حتى نتمكن من طلب الحرف / الكلمة / الكتلة وما إلى ذلك قبل تمريرlocation
. -
Editor.string
- الحصول على السلسلة داخل النطاق.
كمثال ، يوضح الرسم البياني أدناه ما هي قيم هذه المتغيرات عندما يقوم المستخدم بإدراج حرف "E" ويجلس المؤشر بعده.
إذا كان النص "ABCDE" هو العقدة النصية الأولى للفقرة الأولى في المستند ، فستكون قيم النقاط لدينا -
cursorPoint = { path: [0,0], offset: 5} startPointOfLastCharacter = { path: [0,0], offset: 4}
إذا كان الحرف الأخير عبارة عن مسافة ، فنحن نعرف من أين بدأ - startPointOfLastCharacter.
دعنا ننتقل إلى الخطوة 2 حيث نتحرك للخلف حرفًا بحرف حتى نجد مساحة أخرى أو بداية عقدة النص نفسها.
... if (lastCharacter !== " ") { return; } let end = startPointOfLastCharacter; start = Editor.before(editor, end, { unit: "character", }); const startOfTextNode = Editor.point(editor, currentNodePath, { edge: "start", }); while ( Editor.string(editor, Editor.range(editor, start, end)) !== " " && !Point.isBefore(start, startOfTextNode) ) { end = start; start = Editor.before(editor, end, { unit: "character" }); } const lastWordRange = Editor.range(editor, end, startPointOfLastCharacter); const lastWord = Editor.string(editor, lastWordRange);
فيما يلي رسم بياني يوضح المكان الذي تشير إليه هذه النقاط المختلفة بمجرد أن نجد الكلمة الأخيرة التي تم إدخالها لتكون ABCDE
.
لاحظ أن start
end
هما النقطتان قبل وبعد المسافة هناك. وبالمثل ، فإن startPointOfLastCharacter
و cursorPoint
هما النقطتان قبل وبعد إدخال مستخدم الفضاء للتو. ومن ثم فإن [end,startPointOfLastCharacter]
يعطينا الكلمة الأخيرة التي تم إدراجها.
نقوم بتسجيل قيمة lastWord
في وحدة التحكم والتحقق من القيم أثناء الكتابة.
الآن بعد أن استنتجنا الكلمة الأخيرة التي كتبها المستخدم ، نتحقق من أنه عنوان URL بالفعل وقمنا بتحويل هذا النطاق إلى كائن ارتباط. يبدو هذا التحويل مشابهًا لكيفية تحويل زر ارتباط شريط الأدوات لنص المستخدم المحدد إلى ارتباط.
if (isUrl(lastWord)) { Promise.resolve().then(() => { Transforms.wrapNodes( editor, { type: "link", url: lastWord, children: [{ text: lastWord }] }, { split: true, at: lastWordRange } ); }); }
يتم استدعاء identifyLinksInTextIfAny
داخل Slate's onChange
لذلك لا نريد تحديث بنية المستند داخل onChange
. ومن ثم ، وضعنا هذا التحديث في قائمة انتظار المهام الخاصة بنا مع Promise.resolve().then(..)
استدعاء.
دعونا نرى المنطق يجتمع في العمل! نتحقق مما إذا كنا ندرج الروابط في نهاية العقدة النصية أو في منتصفها أو في بدايتها.
بذلك نكون قد انتهينا من وظائف الروابط في المحرر وانتقلنا إلى الصور.
التعامل مع الصور
في هذا القسم ، نركز على إضافة دعم لعرض عُقد الصور وإضافة صور جديدة وتحديث تسميات توضيحية للصور. الصور ، في هيكل المستند الخاص بنا ، سيتم تمثيلها كعقد باطلة. العقد الفارغة في SlateJS (مشابهة لعناصر Void في مواصفات HTML) تجعل محتوياتها نصًا غير قابل للتحرير. يسمح لنا ذلك بجعل الصور فراغات. نظرًا لمرونة Slate في العرض ، لا يزال بإمكاننا عرض العناصر القابلة للتحرير الخاصة بنا داخل عناصر Void - والتي سنقوم بها لتحرير التسمية التوضيحية للصور. يحتوي SlateJS على مثال يوضح كيف يمكنك تضمين محرر نص منسق بالكامل داخل عنصر فارغ.
لتصيير الصور ، نقوم بتهيئة المحرر لمعاملة الصور كعناصر باطلة ونوفر تنفيذ تجسيد لكيفية عرض الصور. نضيف صورة إلى ExampleDocument الخاصة بنا ونتحقق من أنها تُعرض بشكل صحيح مع التسمية التوضيحية.
# src/hooks/useEditorConfig.js export default function useEditorConfig(editor) { const { isVoid } = editor; editor.isVoid = (element) => { return ["image"].includes(element.type) || isVoid(element); }; ... } function renderElement(props) { const { element, children, attributes } = props; switch (element.type) { case "image": return <Image {...props} />; ... `` `` # src/components/Image.js function Image({ attributes, children, element }) { return ( <div contentEditable={false} {...attributes}> <div className={classNames({ "image-container": true, })} > <img src={String(element.url)} alt={element.caption} className={"image"} /> <div className={"image-caption-read-mode"}>{element.caption}</div> </div> {children} </div> ); }
يجب تذكر شيئين عند محاولة عرض العقد الفارغة باستخدام SlateJS:
- يجب أن يحتوي عنصر DOM الجذر على
contentEditable={false}
مضبوطًا عليه بحيث يتعامل SlateJS مع محتوياته على هذا النحو. بدون ذلك ، أثناء تفاعلك مع العنصر الفارغ ، قد يحاول SlateJS حساب التحديدات وما إلى ذلك ، ثم كسره نتيجة لذلك. - حتى إذا لم يكن للعقد الفارغة أي عقد فرعية (مثل عقدة الصورة الخاصة بنا كمثال) ، ما زلنا بحاجة إلى عرض
ExampleDocument
children
) والتي يتم التعامل معها كنقطة تحديد لـ Void عنصر بواسطة SlateJS
نقوم الآن بتحديث ExampleDocument
لإضافة صورة والتحقق من ظهورها مع التسمية التوضيحية في المحرر.
# src/utils/ExampleDocument.js const ExampleDocument = [ ... { type: "image", url: "/photos/puppy.jpg", caption: "Puppy", // empty text node as child for the Void element. children: [{ text: "" }], }, ];
الآن دعنا نركز على تحرير التسمية التوضيحية. الطريقة التي نريد أن تكون هذه تجربة سلسة للمستخدم هي أنه عندما ينقرون على التسمية التوضيحية ، نعرض إدخال نص حيث يمكنهم تعديل التسمية التوضيحية. إذا نقروا خارج الإدخال أو ضغطوا على مفتاح RETURN ، فإننا نتعامل مع ذلك كتأكيد لتطبيق التسمية التوضيحية. ثم نقوم بتحديث التسمية التوضيحية على عقدة الصورة وإعادة التسمية التوضيحية إلى وضع القراءة. دعنا نرى ذلك عمليًا حتى يكون لدينا فكرة عما نبنيه.
دعنا نقوم بتحديث مكون الصورة الخاص بنا للحصول على حالة لأوضاع قراءة وتحرير التسمية التوضيحية. نقوم بتحديث حالة التسمية التوضيحية المحلية أثناء قيام المستخدم بتحديثها وعند النقر فوق ( onBlur
) أو الضغط على RETURN ( onKeyDown
) ، نقوم بتطبيق التسمية التوضيحية على العقدة والتبديل إلى وضع القراءة مرة أخرى.
const Image = ({ attributes, children, element }) => { const [isEditingCaption, setEditingCaption] = useState(false); const [caption, setCaption] = useState(element.caption); ... const applyCaptionChange = useCallback( (captionInput) => { const imageNodeEntry = Editor.above(editor, { match: (n) => n.type === "image", }); if (imageNodeEntry == null) { return; } if (captionInput != null) { setCaption(captionInput); } Transforms.setNodes( editor, { caption: captionInput }, { at: imageNodeEntry[1] } ); }, [editor, setCaption] ); const onCaptionChange = useCallback( (event) => { setCaption(event.target.value); }, [editor.selection, setCaption] ); const onKeyDown = useCallback( (event) => { if (!isHotkey("enter", event)) { return; } applyCaptionChange(event.target.value); setEditingCaption(false); }, [applyCaptionChange, setEditingCaption] ); const onToggleCaptionEditMode = useCallback( (event) => { const wasEditing = isEditingCaption; setEditingCaption(!isEditingCaption); wasEditing && applyCaptionChange(caption); }, [editor.selection, isEditingCaption, applyCaptionChange, caption] ); return ( ... {isEditingCaption ? ( <Form.Control autoFocus={true} className={"image-caption-input"} size="sm" type="text" defaultValue={element.caption} onKeyDown={onKeyDown} onChange={onCaptionChange} onBlur={onToggleCaptionEditMode} /> ) : ( <div className={"image-caption-read-mode"} onClick={onToggleCaptionEditMode} > {caption} </div> )} </div> ...
وبذلك تكتمل وظيفة تحرير التسمية التوضيحية. ننتقل الآن إلى إضافة طريقة للمستخدمين لتحميل الصور إلى المحرر. دعنا نضيف زر شريط الأدوات الذي يتيح للمستخدمين تحديد وتحميل صورة.
# src/components/Toolbar.js const onImageSelected = useImageUploadHandler(editor, previousSelection); return ( <div className="toolbar"> .... <ToolBarButton isActive={false} as={"label"} htmlFor="image-upload" label={ <> <i className={`bi ${getIconForButton("image")}`} /> <input type="file" className="image-upload-input" accept="image/png, image/jpeg" onChange={onImageSelected} /> </> } /> </div>
أثناء عملنا مع عمليات تحميل الصور ، يمكن أن تنمو الشفرة قليلاً لذا ننقل معالجة تحميل الصور إلى خطاف useImageUploadHandler
الذي يعطي رد اتصال مرفق بعنصر إدخال الملف. سنناقش بعد قليل سبب حاجتها إلى حالة previousSelection
.
قبل تنفيذ useImageUploadHandler
، سنقوم بإعداد الخادم حتى نتمكن من تحميل صورة إليه. نقوم بإعداد خادم Express وتثبيت حزمتين أخريين - cors
و multer
التي تتعامل مع تحميلات الملفات لنا.
yarn add express cors multer
نضيف بعد ذلك src/server.js
بتكوين خادم Express باستخدام cors و multer ويكشف نقطة نهاية /upload
سنقوم بتحميل الصورة إليه.
# src/server.js const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, "./public/photos/"); }, filename: function (req, file, cb) { cb(null, file.originalname); }, }); var upload = multer({ storage: storage }).single("photo"); app.post("/upload", function (req, res) { upload(req, res, function (err) { if (err instanceof multer.MulterError) { return res.status(500).json(err); } else if (err) { return res.status(500).json(err); } return res.status(200).send(req.file); }); }); app.use(cors()); app.listen(port, () => console.log(`Listening on port ${port}`));
الآن بعد أن أصبح لدينا إعداد الخادم ، يمكننا التركيز على معالجة تحميل الصورة. عندما يقوم المستخدم بتحميل صورة ، قد يستغرق الأمر بضع ثوانٍ قبل أن يتم تحميل الصورة ولدينا عنوان URL لها. ومع ذلك ، فإننا نفعل ما نعطي المستخدم ملاحظات فورية بأن تحميل الصورة قيد التقدم حتى يعرفوا أنه يتم إدراج الصورة في المحرر. فيما يلي الخطوات التي ننفذها لجعل هذا السلوك يعمل -
- بمجرد أن يختار المستخدم صورة ، نقوم بإدخال عقدة صورة في موضع مؤشر المستخدم مع وضع علامة هي
isUploading
مجموعة عليها حتى نتمكن من إظهار حالة تحميل للمستخدم. - نقوم بإرسال الطلب إلى الخادم لتحميل الصورة.
- بمجرد اكتمال الطلب ولدينا عنوان URL للصورة ، قمنا بتعيين ذلك على الصورة وإزالة حالة التحميل.
لنبدأ بالخطوة الأولى حيث نقوم بإدخال عقدة الصورة. الآن ، الجزء الصعب هنا هو أننا نواجه نفس المشكلة مع التحديد كما هو الحال مع زر الارتباط في شريط الأدوات. بمجرد أن ينقر المستخدم على زر الصورة في شريط الأدوات ، يفقد المحرر التركيز ويصبح التحديد null
. إذا حاولنا إدراج صورة ، فإننا لا نعرف مكان مؤشر المستخدم. يمنحنا تتبع التحديد previousSelection
هذا الموقع ونستخدمه لإدراج العقدة.
# src/hooks/useImageUploadHandler.js import { v4 as uuidv4 } from "uuid"; export default function useImageUploadHandler(editor, previousSelection) { return useCallback( (event) => { event.preventDefault(); const files = event.target.files; if (files.length === 0) { return; } const file = files[0]; const fileName = file.name; const formData = new FormData(); formData.append("photo", file); const id = uuidv4(); Transforms.insertNodes( editor, { id, type: "image", caption: fileName, url: null, isUploading: true, children: [{ text: "" }], }, { at: previousSelection, select: true } ); }, [editor, previousSelection] ); }
أثناء قيامنا بإدخال عقدة الصورة الجديدة ، نقوم أيضًا بتعيين id
لها باستخدام حزمة uuid. سنناقش في تنفيذ الخطوة (3) سبب حاجتنا لذلك. نقوم الآن بتحديث مكون الصورة لاستخدام علامة isUploading
لإظهار حالة التحميل.
{!element.isUploading && element.url != null ? ( <img src={element.url} alt={caption} className={"image"} /> ) : ( <div className={"image-upload-placeholder"}> <Spinner animation="border" variant="dark" /> </div> )}
هذا يكمل تنفيذ الخطوة 1. دعنا نتحقق من قدرتنا على تحديد صورة لتحميلها ، شاهد عقدة الصورة التي يتم إدراجها مع مؤشر التحميل حيث تم إدراجها في المستند.
بالانتقال إلى الخطوة (2) ، سنستخدم مكتبة axois لإرسال طلب إلى الخادم.
export default function useImageUploadHandler(editor, previousSelection) { return useCallback((event) => { .... Transforms.insertNodes( … {at: previousSelection, select: true} ); axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { // update the image node. }) .catch((error) => { // Fire another Transform.setNodes to set an upload failed state on the image }); }, [...]); }
نتحقق من أن تحميل الصورة يعمل وأن الصورة لا تظهر في المجلد public/photos
للتطبيق. الآن وبعد اكتمال تحميل الصورة ، ننتقل إلى الخطوة (3) حيث نريد تعيين عنوان URL على الصورة في وظيفة resolve()
للوعد المحوري. يمكننا تحديث الصورة باستخدام Transforms.setNodes
ولكن لدينا مشكلة - ليس لدينا المسار إلى عقدة الصورة المدرجة حديثًا. دعونا نرى ما هي خياراتنا للوصول إلى تلك الصورة -
- ألا يمكننا استخدام
editor.selection
لأن التحديد يجب أن يكون على عقدة الصورة المدرجة حديثًا؟ لا يمكننا ضمان ذلك لأنه أثناء تحميل الصورة ، ربما نقر المستخدم في مكان آخر وربما تغير التحديد. - ماذا عن استخدام
previousSelection
الذي استخدمناه لإدخال عقدة الصورة في المقام الأول؟ للسبب نفسه لا يمكننا استخدامeditor.selection
، لا يمكننا استخدام الاختيارpreviousSelection
لأنه ربما تم تغييره أيضًا. - يحتوي SlateJS على وحدة محفوظات تتعقب جميع التغييرات التي تحدث للمستند. يمكننا استخدام هذه الوحدة للبحث في السجل والعثور على آخر عقدة صورة مدرجة. هذا أيضًا لا يمكن الاعتماد عليه تمامًا إذا استغرق تحميل الصورة وقتًا أطول وأدخل المستخدم المزيد من الصور في أجزاء مختلفة من المستند قبل اكتمال التحميل الأول.
- حاليًا ، لا تقوم واجهة برمجة تطبيقات
Transform.insertNodes
بإرجاع أي معلومات حول العقد المدرجة. إذا كان بإمكانه إرجاع المسارات إلى العقد المدرجة ، فيمكننا استخدام ذلك للعثور على عقدة الصورة الدقيقة التي يجب علينا تحديثها.
نظرًا لعدم نجاح أي من الأساليب المذكورة أعلاه ، فإننا نطبق id
على عقدة الصورة المدرجة (في الخطوة (1)) ونستخدم نفس id
مرة أخرى لتحديد موقعه عند اكتمال تحميل الصورة. مع ذلك ، يبدو رمز الخطوة (3) كما يلي -
axios .post("/upload", formData, { headers: { "content-type": "multipart/form-data", }, }) .then((response) => { const newImageEntry = Editor.nodes(editor, { match: (n) => n.id === id, }); if (newImageEntry == null) { return; } Transforms.setNodes( editor, { isUploading: false, url: `/photos/${fileName}` }, { at: newImageEntry[1] } ); }) .catch((error) => { // Fire another Transform.setNodes to set an upload failure state // on the image. });
مع اكتمال تنفيذ جميع الخطوات الثلاث ، نحن جاهزون لاختبار تحميل الصورة من البداية إلى النهاية.
مع ذلك ، قمنا بإنهاء الصور لمحررنا. حاليًا ، نعرض حالة تحميل بنفس الحجم بغض النظر عن الصورة. قد تكون هذه تجربة مزعجة للمستخدم إذا تم استبدال حالة التحميل بصورة كبيرة أو أصغر حجمًا عند اكتمال التحميل. من المتابعة الجيدة لتجربة التحميل الحصول على أبعاد الصورة قبل التحميل وإظهار عنصر نائب بهذا الحجم بحيث يكون الانتقال سلسًا. يمكن تمديد الخطاف الذي أضفناه أعلاه لدعم أنواع الوسائط الأخرى مثل الفيديو أو المستندات وتقديم هذه الأنواع من العقد أيضًا.
خاتمة
في هذه المقالة ، قمنا ببناء محرر WYSIWYG الذي يحتوي على مجموعة أساسية من الوظائف وبعض تجارب المستخدم الصغيرة مثل اكتشاف الارتباط ، وتحرير الرابط في مكانه ، وتحرير تعليقات الصور التي ساعدتنا على التعمق في استخدام SlateJS ومفاهيم تحرير النص الغني في جنرال لواء. إذا كانت مساحة المشكلة المحيطة بتحرير النص المنسق أو معالجة النصوص تهمك ، فقد تكون بعض المشكلات الرائعة التي يجب معالجتها:
- تعاون
- تجربة تحرير نص أكثر ثراءً تدعم محاذاة النص والصور المضمنة والنسخ واللصق وتغيير ألوان الخط والنص وما إلى ذلك.
- الاستيراد من التنسيقات الشائعة مثل مستندات Word و Markdown.
إذا كنت تريد معرفة المزيد عن SlateJS ، فإليك بعض الروابط التي قد تكون مفيدة.
- أمثلة على SlateJS
هناك الكثير من الأمثلة التي تتجاوز الأساسيات وتبني الوظائف التي توجد عادةً في المحررين مثل Search & Highlight و Markdown Preview والإشارات. - مستندات API
إشارة إلى الكثير من الوظائف المساعدة التي يعرضها SlateJS والتي قد يرغب المرء في الاحتفاظ بها في متناول اليد عند محاولة إجراء استعلامات / تحويلات معقدة على كائنات SlateJS.
أخيرًا ، تعد قناة Slack من SlateJS مجتمعًا نشطًا للغاية لمطوري الويب الذين يقومون بإنشاء تطبيقات تحرير نص منسق باستخدام SlateJS ومكانًا رائعًا لمعرفة المزيد عن المكتبة والحصول على المساعدة إذا لزم الأمر.