تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

RichEditor

RichEditor هو محرر نصوص منسّقة مبني على معمارية ProseMirror. يحدد التطبيق مما يتكوّن المستند عبر مخطط (schema) من أنواع العقد والعلامات التي يحتاجها (فقرات، عناوين، قوائم، إشارات، وغيرها)، ويُبقي المحرر كل مستند صالحًا وفق هذا المخطط. التغييرات خطوات مجمّعة في معاملات (transactions)، ولذلك يمكن التراجع عنها وتحويل المواضع عبرها ونقلها بصيغة JSON. أما السلوك فيأتي من الإضافات (plugins): السجل، وربط المفاتيح، وقواعد الإدخال، واختصارات Markdown، وإضافات التطبيق نفسه. والأوامر قيم يشغّلها شريط الأدوات ويستعلم عنها. حالة المحرر وأوامره وإضافاته تعمل في Rust: الكتابة وربط المفاتيح وقواعد الإدخال لا تنتظر JS أبدًا، ويقرأ التطبيق الحالة بصيغة JSON عندما يطلبها.

el-rich-editor.tsx
el-rich-editor.tsxLive
import { mount, signal, Text, View } from "nsh";
import { RichEditor, StarterKit, command, createRichEditor, redo, undo } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, ui, wide } from "./palette";

const CONTENT = `# A document

Type **Markdown** as you go: \`#\` makes a heading, \`-\` a list, \`[ ]\` a task, each followed by a space.

- Select text and press Bold
- Undo and redo keep every step
`;

// The session owns the document; the view renders it and turns input into
// transactions. The toolbar runs Tiptap's named commands on the session.
function App() {
  const editor = createRichEditor({ extensions: [StarterKit], content: CONTENT, format: "markdown" });
  const markdown = signal(editor.getMarkdown());
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));
  editor.on("revision", () => markdown.set(editor.getMarkdown()));
  editor.on("focus", () => log("focus"));
  editor.on("blur", () => log("blur"));

  const action = (label: string, run: () => void) => (
    <View onPress={run} role="button" accessibilityLabel={label} {...button("secondary", { size: "sm" })}>
      <Text style={buttonText()}>{label}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>rich editor</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Click into the document and type; Cmd/Ctrl+B makes bold" : "Tap into the document and type"}
      </Text>

      <Split
        grow
        width={640}
        inspector={
          <>
            <Panel title="markdown">
              <Text style={{ fontSize: 12, fontFamily: "monospace", color: ui().text }}>{markdown()}</Text>
            </Panel>
            <Log title="events" lines={events()} minHeight={72} />
          </>
        }
      >
        <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
          {action("Bold", () => editor.run(command("toggleBold")))}
          {action("Italic", () => editor.run(command("toggleItalic")))}
          {action("Heading", () => editor.run(command("toggleHeading", { level: 2 })))}
          {action("List", () => editor.run(command("toggleBulletList")))}
          {action("Task", () => editor.run(command("toggleTaskList")))}
          {action("Undo", () => editor.run(undo))}
          {action("Redo", () => editor.run(redo))}
        </View>
        <View style={{ height: wide() ? 360 : 300, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <RichEditor
            editor={editor}
            accessibilityLabel="Document"
            placeholder="Write something"
            classes={CLASSES}
            style={{ flex: 1 }}
          />
        </View>
      </Split>
    </View>
  );
}

/** The document's look: NSH's editor draws nothing of its own. */
const CLASSES = {
  content: "p-4 gap-3",
  taskCheckbox: "size-4 mr-2 mt-0.5 rounded border border-border checked:bg-primary checked:border-primary",
  listMarker: "text-muted-foreground",
  nodes: {
    "heading[level=1]": "text-2xl font-bold",
    "heading[level=2]": "text-xl font-semibold",
    "heading[level=3]": "text-lg font-semibold",
    blockquote: "border-l-4 border-border pl-3 text-muted-foreground",
    codeBlock: "font-mono text-sm bg-muted rounded-md px-3 py-2",
    bulletList: "gap-1 pl-5",
    orderedList: "gap-1 pl-5",
    taskList: "gap-1",
    taskItem: "flex-row",
    horizontalRule: "h-px bg-border my-2",
  },
  marks: {
    bold: "font-semibold",
    italic: "italic",
    strike: "line-through",
    code: "font-mono text-sm bg-muted rounded px-1",
    link: "text-primary underline",
  },
};

mount(App);

يتكوّن المحرر من جزأين: الجلسة (createRichEditor) التي تملك الحالة ويقودها كود التطبيق، والعرض (<RichEditor editor={…} />) الذي يرسم الجلسة بشكل أصلي ويحوّل مدخلات المنصة إلى معاملات. يمكن لعدة عروض أن تعرض جلسة واحدة.

نواة المحرر هي نقل إلى Rust من ProseMirror لمؤلفه Marijn Haverbeke، وتتبع طبقة الامتدادات فيه Tiptap: هياكل بياناتهما وأوامرهما وأسماؤهما منقولة كما هي، وكذلك ما تعرفه عنهما. كلاهما مرخّص بترخيص MIT، وتسري تراخيصهما على ما نُقل منهما.

import { mount } from "nsh";
import { RichEditor, createRichEditor, history, inputRules, keymap, markdown, markdownRules, starterKeymap, baseKeymap, toggleMark } from "nsh/base";

const editor = createRichEditor({
  content: "# Notes\n\nStart typing…",
  format: "markdown",
  plugins: [
    history(),
    keymap({ "Mod-b": toggleMark("bold"), "Mod-i": toggleMark("italic") }),
    starterKeymap(),
    baseKeymap(),
    inputRules(markdownRules()),
    markdown(),
  ],
});

function App() {
  return <RichEditor editor={editor} class="flex-1" />;
}

mount(App);

بدون schema يستخدم المحرر المخطط الابتدائي (starterSchema())؛ وبدون plugins يحصل على الحزمة الابتدائية (starterKit(): قواعد الإدخال وربط المفاتيح الخاصة بالمخطط الابتدائي، وربط المفاتيح الأساسي، والسجل). المصفوفة الفارغة plugins تنشئ محررًا بلا أي إضافات.

#التنسيق

يرسم المحرر المستند بمظهر التطبيق: classes وstyles ينسّقان أجزاءه (root، content، caret، selection، placeholder، composition، selectedNode، listMarker، وللجداول selectedCell، tableGrip، tableResizeHandle، tableDropIndicator)، وعقده حسب النوع (nodes: { heading: "text-2xl font-bold", "heading[level=2]": "text-xl" }) and its marks (marks: { bold: "font-bold" }). عناصر القوائم التي تضبط مواصفات عقدتها extra.listMarker على "bullet" أو "ordered" (قوائم المخطط الابتدائي) تعرض علامة في الجزء listMarker: • أو ◦ أو ▪ بحسب العمق، أو أرقامًا تبدأ من start الخاصة بالقائمة، وتوضع قبل العنصر، ضمن إزاحة القائمة (pl-6 على القائمة، كما يزيح المتصفح ul).

<RichEditor
  editor={editor}
  classes={{
    nodes: { heading: "text-2xl font-bold", bulletList: "pl-6", codeBlock: "font-mono bg-muted p-3 rounded-md" },
    marks: { bold: "font-bold", italic: "italic", code: "font-mono bg-muted" },
    listMarker: "text-muted-foreground",
  }}
/>

الجزء content هو صندوق المستند داخل عرض التمرير الخاص بالمحرر: حشوته (content: "px-6 py-5") هي المساحة المحيطة بالنص، ويمتد شريط التمرير على حافة المحرر نفسها. السحب الذي يحدد نصًا متجاوزًا أعلى المحرر أو أسفله يمرّر المستند معه، بسرعة تزداد كلما ابتعد المؤشر.

#قائمة الفقاعة

bubbleMenu هي قائمة الفقاعة (bubble menu) في Tiptap: محتوى التطبيق يظهر بجانب التحديد، وفوقه افتراضيًا (bubbleMenuPlacement، ويقلب إلى الجهة الأخرى حيث لا توجد مساحة) على مسافة bubbleMenuOffset نقاط (8)، داخل طبقة علوية تتبع النص أثناء تمريره. يمرّرها التطبيق ما دام يريد إظهارها، انطلاقًا من حالة الجلسة:

const revision = signal(editor.revision);
editor.on("revision", ({ revision: r }) => revision.set(r));
const inLink = () => (revision(), editor.isActive("link"));

<RichEditor
  editor={editor}
  bubbleMenu={inLink() ? <LinkMenu href={editor.getAttributes("link").href} /> : undefined}
  bubbleMenuPlacement="bottom"
/>

تعدّل قائمة الروابط الرابط الواقع تحت المؤشر باستخدام chain([extendMarkRange("link"), command("setLink", { href })]) وتزيله باستخدام command("unsetLink") بعد extendMarkRange نفسها.

#المخططات

defineSchema(spec) يقرأ مخططًا بصيغة ProseMirror ويرمي خطأً يصف ما فيه من مشكلة. تحدد أنواع العقد content (تعبير المحتوى: "inline*"، "paragraph+"، "(heading | paragraph) block*")، وgroup، وهل هي inline أو atom، وmarks التي تسمح بها، وattrs مع القيم الافتراضية والتحقق ("string"، "number"، "boolean"). أول نوع كتلة هو النوع الافتراضي: ما ينشئه Enter وما يملأ المحتوى المطلوب. المفاتيح التي لا تعرفها المواصفات تُحفظ كبيانات إضافية للنوع، لتستفيد منها الأطر المبنية على المحرر.

import { createRichEditor, defineSchema } from "nsh/base";

const schema = defineSchema({
  nodes: {
    doc: { content: "block+" },
    paragraph: { content: "inline*", group: "block" },
    mention: { group: "inline", inline: true, atom: true, attrs: { id: {}, label: { default: "" } } },
    text: { group: "inline" },
  },
  marks: { bold: {}, link: { attrs: { href: {} }, inclusive: false } },
});

const editor = createRichEditor({ schema, doc: schema.parse("Hello") });

RichSchema يقرأ المستندات ويكتبها: parse(content, format?) (نص عادي افتراضيًا، أو "markdown"، أو JSON)، وparseMarkdown(markdown)، وserialize(doc, format?) (Markdown افتراضيًا، أو "text"، أو "json") وcheck(doc) الذي يعيد المستند بعد ملء القيم الافتراضية لسماته. nodes وmarks وtopNode تسمّي أنواعه. يطابق Markdown أسماء المخطط الابتدائي (paragraph، heading، blockquote، codeBlock، bulletList، orderedList، listItem، bold، italic، code، link، strike).

#الأوامر

الأمر قيمة: toggleMark("bold") ينشئ كائنًا يحتفظ به التطبيق ويربطه بالمفاتيح ويمرره إلى المحرر. editor.run(command) ينفذه ويعيد ما إذا كان قد طُبّق؛ وeditor.can(command) يسأل دون تغيير أي شيء، وهكذا يفعّل شريط الأدوات أزراره. editor.isActive(name, attrs?) يخبر ما إذا كانت علامة أو عقدة فعّالة عند التحديد (isActive("heading", { level: 2 })).

الأمرما يفعله
toggleMark(name, attrs?)يضيف العلامة إلى التحديد، أو يزيلها حيث توجد؛ وعند المؤشر يسري على النص المكتوب لاحقًا
setMark(name, attrs?) / unsetMark(name)يضيف علامة أو يزيلها
setBlockType(name, attrs?)يجعل كتل النص المحددة من نوع معيّن (setBlockType("heading", { level: 2 }))
wrapIn(name, attrs?) / lift()يلف الكتل المحددة في عقدة (اقتباس) / يرفعها خارج أصلها
wrapInList(name, attrs?)يلف الكتل المحددة في قائمة
splitListItem(name) / sinkListItem(name) / liftListItem(name)يقسّم عناصر القائمة أو يزيح إزاحة للداخل أو للخارج
undo() / redo()يتراجع عن تغيير أو يعيده (مع history())
selectAll()، setTextSelection(anchor, head?)، setNodeSelection(pos)يحدد
insertText(text)، insertContent(content, format?)يُدرج نصًا أو محتوى فوق التحديد
setNodeAttribute(pos, attr, value)يضبط سمة عقدة عند موضع ما، كتغيير واحد يمكن التراجع عنه
command(name)أمر بلا معاملات حسب الاسم: "splitBlock"، "joinBackward"، "exitCode"، "newlineInCode"، "selectParentNode"، …
chain(commands)ينفذ أوامر الواحد تلو الآخر كمعاملة واحدة (خطوة تراجع واحدة)؛ ولا يُطبّق إلا إذا طُبّقت كلها
first(commands)ينفذ أول أمر ينطبق
editor.run(chain([setBlockType("heading", { level: 1 }), toggleMark("bold")]));
const canIndent = editor.can(sinkListItem("listItem"));

#الجلسة

العضوما هو
state، doc، selectionالحالة والمستند والتحديد بصيغة JSON، تُنشأ عند القراءة
revisionعدد المعاملات التي طبّقتها الحالة
isEmpty، undoDepth، redoDepth، composingما تعرضه واجهة المستخدم عن المحرر
setContent(content, format?)يستبدل المستند، كتغيير واحد يمكن التراجع عنه
insertContent(content, format?)يُدرج محتوى فوق التحديد
getJSON()، getMarkdown()، getText()، textBetween(from?, to?, separator?)المستند بصيغة معيّنة
dispatch(tr)يطبّق معاملة معطاة بصيغة JSON (خطواتها على المستند الحالي)
on(event, listener)يستدعي listener بحمولة كل event، في الدورة التالية من JS: revision ({ revision }، بعد كل تغيير ومن أي مصدر)، update، selectionUpdate، transaction، focus، blur، delete. يعيد دالة توقفه؛ والمستمع المضاف أثناء رسم مكوّن يتوقف مع المكوّن
focus()، blur()ينقل تركيز لوحة المفاتيح إلى العروض التي تعرض المحرر أو منها

تُحتسب المواضع كما في ProseMirror: النص يحسب وحدات UTF-16 البرمجية، والعقدة الورقية تحسب واحدًا، والعقدة ذات المحتوى تحسب حديها. أي شيء يجب أن يبقى بعد التعديلات يحتفظ بمعرّف في سمات عقدة أو في علامة. استعلام الموضع (resolve(pos)، nodeAt(pos)، coordsAtPos(pos)، findParentNode(name, pos)، …) يرمي RangeError لموضع خارج المستند (0 إلى doc.content.size)، كما يفعل resolve في ProseMirror، ويجيب بـnull حيث لا يوجد شيء (nodeAt في نهاية عقدة، coordsAtPos عندما لا يوجد عرض يعرض المحرر). run وcan يرميان TypeError لأمر أو نوع عقدة أو علامة لا يعرفها المحرر.

#تولّي المعاملات

editor.dispatchTransaction = (tr) => { … } يجعل التطبيق يُثبّت كل معاملة بنفسه: يتلقى المعالج كل واحدة بصيغة JSON ويثبتها باستخدام editor.applyTransaction(tr)، أو يتجاهلها. يُستخدم هذا في التعاون والتسجيل والمعاملات التي يجب أن تمر بفحوص التطبيق؛ وnull يعيد مهمة التثبيت إلى المحرر. يسمع المعالج الكتابة في الدورة التالية من JS، ويتغير المستند عندما يثبّت المعاملة.

#الامتدادات

الامتدادات (extensions) هي امتدادات Tiptap: كل واحد منها يجلب معًا نوع عقدته أو علامته، وربط مفاتيحه، وقواعد الإدخال واللصق، والإضافات والأوامر المسمّاة، والمحرر المصنوع من امتدادات يحصل على مخططه وخريطة مفاتيحه وقواعده وجدول أوامره منها. الامتدادات المدمجة أصناف (classes) بأسماء Tiptap نفسها، تُستخدم كما هي أو مهيأة:

import { RichEditor, StarterKit, Underline, command, createRichEditor } from "nsh/base";

const editor = createRichEditor({
  extensions: [StarterKit.configure({ heading: { levels: [1, 2, 3] }, codeBlock: false })],
  content: "Hello",
  editable: true,
});

editor.run(command("toggleHeading", { level: 2 }));
editor.run(command("toggleBold"));

StarterKit يجلب Document، Paragraph، عقدة النص، Heading، Blockquote، BulletList، OrderedList، ListItem، TaskList، TaskItem، CodeBlock، HardBreak، HorizontalRule، RichImage (Image في Tiptap)، Bold، Italic، Strike، Code، Underline، RichLink (Link في Tiptap)، Highlight، Subscript، Superscript، RichTextStyle (TextStyle في Tiptap)، TextAlign، History، TrailingNode، Typography وListKeymap. الخيار الذي يحمل اسم واحد منها ومضبوط على false يستبعده، والكائن يهيّئه (heading: { levels }، history: { depth }، textAlign: { types }، codeBlock: { enableTabIndentation }). configure(options) وextend({...}) لكل صنف ينشئان RichExtension، وله name وtype وpriority وoptions. كل محرر يحصل أيضًا على خريطة المفاتيح الأساسية (Enter وBackspace وDelete وتحديد الكل).

كل امتداد يضيف أوامر Tiptap، التي ينفذها command(name, args): toggleBold، setItalic، unsetStrike، toggleHeading({ level })، setParagraph، toggleBlockquote، toggleBulletList، toggleOrderedList، toggleTaskList، toggleCodeBlock({ language })، setHardBreak، setHorizontalRule، setImage({ src })، toggleHighlight({ color })، setTextAlign("center")، unsetTextAlign، وأوامر الروابط وعدّ الأحرف والاقتراحات أدناه.

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

ترتيب extensions هو أولويتها بين المتساويات: الامتداد المدرج لاحقًا يربط مفاتيحه قبل الامتدادات التي قبله؛ وpriority (100 افتراضيًا) يرتّبها بطريقة أخرى. مع extensions، تأتي plugins الخاصة بالمحرر بعد إضافات الامتدادات.

#امتدادات التطبيق

defineExtension ينشئ واحدًا. تعمل دوالّه مرة واحدة، عندما يُنشأ محرر به، مع خيارات الامتداد، وتعيد بيانات: أوامر وقواعد وإضافات كما تصنعها المصانع أعلاه.

import { StarterKit, command, createRichEditor, defineExtension, insertText, textRule } from "nsh/base";

const Signature = defineExtension({
  name: "signature",
  priority: 200,
  addOptions: () => ({ text: "— Sent from NSH" }),
  addCommands: (options) => ({ insertSignature: insertText(options.text) }),
  addKeyboardShortcuts: () => ({ "Mod-Alt-s": command("insertSignature") }),
  addInputRules: () => [textRule("\\(sig\\)$", "✍")],
});

const editor = createRichEditor({ extensions: [StarterKit, Signature.configure({ text: "Best, Ana" })] });

node أو mark (بصيغة defineSchema) يجعله امتداد عقدة أو علامة؛ وaddGlobalAttributes يضيف سمات إلى أنواع أخرى؛ وaddProseMirrorPlugins يعيد إضافات، بما فيها RichEditorPlugin؛ وaddPasteRules يعيد قواعد textRule وmarkRule للنص الملصوق. extend({...}) في صنف مدمج يستبدل الأجزاء التي يقدمها: Underline.extend({ addKeyboardShortcuts: () => ({ "Mod-Alt-u": toggleMark("underline") }) }).

تسجّل حزمة (crate) التطبيق الامتدادات المكتوبة بـ Rust عبر مجموعة واجهتها، ويأخذها JS بالاسم:

use nsh_base::rich_editor::extensions::{Extension, RichExtensionRegistry};

#[native_module("app/native")]
fn native(registry: &mut Registry) {
    registry.rich_extension("mention", || Extension::new("mention") /* .add_plugins(...) */);
}
const editor = createRichEditor({ extensions: [StarterKit, richExtension("mention", { char: "@" })] });

richExtension(name, options) يجد أيضًا كل امتداد مدمج باسمه ("text"، "bold"، …).

#الإضافات

المصنعما يضيفه
history({ depth?, newGroupDelay? })سجل التراجع؛ التغييرات خلال newGroupDelay ميلي ثانية (500) يُتراجع عنها معًا
keymap(bindings)أوامر حسب اسم المفتاح: "Mod-b" (Cmd على منصات Apple، وCtrl في غيرها)، "Shift-Enter"، "Alt-ArrowUp"
baseKeymap()Enter وBackspace وDelete وتحديد الكل ومفاتيح تحرير النص الخاصة بالمنصة
starterKeymap()ربط المخطط الابتدائي: العلامات والعناوين والقوائم وكتل الكود وفواصل الأسطر
inputRules(rules)النص المكتوب قبل المؤشر والمطابق لقاعدة يتحوّل إلى ما تنتجه القاعدة؛ وBackspace مباشرة بعده يتراجع عنه
markdown(options?)Markdown أثناء الكتابة، ولصق Markdown، ونسخ Markdown (blockRules، inlineRules، paste، copyAsMarkdown)
starterKit()قواعد الإدخال وربط المفاتيح الابتدائيان، وربط المفاتيح الأساسي، والسجل

مكان الإضافة في القائمة هو أولويتها: خرائط المفاتيح وقواعد الإدخال الأسبق في القائمة تعالج المفتاح أو النص أولًا. تأتي قواعد الإدخال من markdownRules() (العناوين والاقتباسات والقوائم وأسوار الكود والقواعد والعلامات الشائعة)، وtypographyRules() (علامات الاقتباس الذكية، والحذف، والشرطة الطويلة)، وtextRule(match, replacement)، وmarkRule(match, mark)، وwrappingRule(match, node) وtextblockRule(match, node). match في القاعدة تعبير نمطي ينتهي بـ$، يُطابَق مع النص الواقع قبل المؤشر.

#إضافات التطبيق

new RichEditorPlugin(key) ينشئ إضافة سلوكها من التطبيق، تُضبط باستدعاءات متسلسلة:

import { RichEditorPlugin, createRichEditor, starterKit } from "nsh/base";

const suggestions = new RichEditorPlugin("suggestions")
  .captureKeys(["ArrowUp", "ArrowDown", "Enter", "Escape"], (key) => moveSuggestion(key))
  .filterTransaction((tr) => tr.meta?.locked !== true)
  .appendTransaction((trs, state) => null);

const editor = createRichEditor({ plugins: [starterKit(), suggestions] });

// While the suggestion list is open, its keys are the list's.
suggestions.capturing = true;
editor.setDecorations("suggestions", [{ from: 5, to: 9, kind: "inline", attrs: { class: "bg-accent" } }]);
  • captureKeys(keys, handler): ما دام capturing صحيحًا، يستهلك المحرر هذه المفاتيح ويخبر handler بالمفتاح الذي ضُغط، مباشرة بعد ذلك؛ وتبقى لوحة المفاتيح سريعة كما لو لم تكن هناك إضافة.
  • editor.setDecorations(key, decorations): سمات مضمّنة وسمات عقد وعناصر واجهة (widgets) يرسمها العرض فوق المستند دون تغييره. وهي تتبع كل تغيير حتى تُضبط من جديد.
  • filterTransaction(hook) وappendTransaction(hook) يريان المعاملات التي تنتجها استدعاءات التطبيق نفسه (run، dispatch، setContent، …) في ذلك الاستدعاء: filterTransaction يمكنه إسقاط معاملة، وappendTransaction يمكنه إعادة معاملة إضافية تُطبَّق معها. on("revision", …) يسمع كل تغيير، بما فيه الكتابة.

#الروابط وعدّ الأحرف والاقتراحات

ثلاث إضافات تحمل سلوك Link وCharacterCount وSuggestion من Tiptap، وكلها أصلية (native):

import { RichEditor, characterCount, createRichEditor, insertMention, link, starterKit, starterSchema, suggestion, withMention } from "nsh/base";

const editor = createRichEditor({
  schema: withMention(starterSchema()),
  plugins: [
    starterKit(),
    link({ defaultProtocol: "https" }),
    characterCount({ limit: 280 }),
    suggestion({ key: "mention", char: "@", decorationClass: "text-primary", captureKeys: ["ArrowUp", "ArrowDown", "Enter"] }),
  ],
});

editor.on("transaction", () => {
  const s = editor.suggestion("mention");
  if (s?.active) showPeople(s.query, editor.coordsAtPos(s.range.from), s.lastKey);
  setCount(`${editor.characters()} / ${editor.characterLimit}`);
});

// The list picked someone:
editor.run(insertMention(editor.suggestion("mention")!.range, "u1", "Joan"));
  • link(options?): أي عنوان URL يُكتب قبل مسافة أو Enter يتحول إلى رابط، وكذلك عناوين URL في النص الملصوق (autolink)؛ وعنوان URL يُلصق فوق تحديد يحوّل التحديد إلى رابط (linkOnPaste)؛ والضغط على رابط مع الإبقاء على Mod مضغوطًا، أو أثناء كون المحرر للقراءة فقط، يفتحه عبر معالجة عناوين URL في التطبيق (openOnClick)؛ وenableClickSelection يحدد الرابط كاملًا عند الضغط عليه. تُكتشف عناوين URL مع بروتوكول أو بدونه (nsh.dev، https://nsh.dev/docs، وعناوين البريد الإلكتروني)، والبروتوكول الذي يُمنح للمضيف المجرد هو defaultProtocol (http). تقبل الروابط البروتوكولات http، https، ftp، ftps، mailto، tel، callto، sms، cid، xmpp وتلك الموجودة في protocols، لذا فإن setLink({ href }) وtoggleLink(attrs?) والربط التلقائي تُبقي السكربتات (javascript:) وعناوين data URL خارج الروابط. unsetLink() يزيل الرابط المحيط بالتحديد.
  • characterCount(options?): editor.characters(mode?) يعدّ أحرف النص ("textSize"، بوحدات UTF-16 كما يفعل length في JS) أو مواضع المستند ("nodeSize")؛ وeditor.words() يعدّ كلماته (وفق حدود الكلمات في Unicode). مع limit، تُرفض التغييرات التي ستتجاوز الحد ويُقتطع المحتوى الملصوق بما يتسع له؛ والمستند الأول الذي يتجاوز الحد يُقتطع (autoTrim). setCharacterLimit(limit) يغيّر الحد و editor.characterLimit يقرؤه.
  • suggestion(options?): يتتبع مُحفِّزًا (char، @ افتراضيًا) يُكتب قبل المؤشر. allowSpaces يتيح أن يحتوي الاستعلام على مسافات، وallowedPrefixes يسرد ما يجوز أن يسبق المُحفِّز (مسافة افتراضيًا؛ وanyPrefix يقبل أي شيء)، وstartOfLine يشترط وجوده في بداية السطر (قائمة أوامر /). editor.suggestion(key) يعطي { active, range, query, text, lastKey, keyCount }؛ والنطاق النشط يحمل decorationClass الخاص بالتطبيق (و decorationEmptyClass ما دام الاستعلام فارغًا). وما دام الاقتراح نشطًا فإن المفاتيح الموجودة في captureKeys تخص قائمة التطبيق: يلتقطها المحرر ويسجل آخرها في الحالة، ويُغلق Escape الاقتراح (وكذلك exitSuggestion(key)). replaceSuggestion(content, key?) يستبدل المُحفِّز والاستعلام بنص أو بمحتوى JSON.
  • withMention(schema, { name?, char? }) يضيف عقدة mention من Tiptap: ذرّة (atom) داخل السطر مع id وlabel ومُحفِّزها، وتُكتب على الشكل @label في النص وعلى الشكل <span data-type="mention"> في HTML. insertMention(range, id, label?) يستبدل نطاق الاقتراح بإشارة (mention) ومسافة.

النافذة المنبثقة من مسؤولية التطبيق: يقرأ حالة الاقتراح، ويضع نفسه عند coordsAtPos(range.from)، ويرسم العناصر بطريقته الخاصة.

#الجداول

TableKit يجلب جداول Tiptap: RichTable (Table في Tiptap)، وRichTableRow، وRichTableHeader، و RichTableCell. ويُضاف بجانب StarterKit:

import { RichEditor, StarterKit, TableKit, addRowAfter, createRichEditor, insertTable, mergeCells } from "nsh/base";

const editor = createRichEditor({
  extensions: [StarterKit, TableKit.configure({ table: { cellMinWidth: 40 } })],
});

editor.run(insertTable({ rows: 3, cols: 3, withHeaderRow: true }));
editor.run(addRowAfter());

يقبل الجدول الخيارات resizable (true)، وcellMinWidth (25)، وlastColumnResizable (true)، وallowTableNodeSelection (false)، وHTMLAttributes؛ وTableKit.configure({ tableHeader: false }) يترك امتدادًا واحدًا خارجًا. تحتوي الخلايا على كتل ولها colspan وrowspan وcolwidth (عرض بالنقاط لكل عمود تمتد عبره)؛ وللصفوف height.

ينقل Tab وShift-Tab بين الخلايا، ويضيف Tab في الخلية الأخيرة صفًا. تنتقل مفاتيح الأسهم عند حافة الخلية إلى الخلية التالية؛ ويحدد Shift مع سهم، أو السحب عبر الخلايا، مستطيلًا من الخلايا (تحديد من النوع "cell"، حيث anchor وhead هما الموضعان قبل أول خلية وقبل آخر خلية فيه). يفرّغ Backspace وDelete الخلايا المحددة، ويحذفان الجدول عندما تكون كل خلاياه محددة.

يظهر الجدول في المحرر كشبكة من خلاياه: كل عمود بعرض colwidth لخلاياه، والأعمدة التي لا تملكه تتقاسم المساحة (بعرض لا يقل عن cellMinWidth)، وكل صف بارتفاع لا يقل عن height. يبدأ مظهره من السمة (theme): شبكة border بسماكة نقطة واحدة، وخلايا الرأس على muted، وخلايا ذات حشوة، وكل خلية محددة تحت الجزء selectedCell بلون التحديد. classes.nodes (table، tableCell، tableHeader) والأجزاء تعيد تنسيق كل ذلك. يؤدي السهمان لأعلى ولأسفل في السطر الأول أو الأخير من الخلية إلى الانتقال إلى الخلية التي فوقها أو تحتها.

أثناء إمكانية تحرير المستند:

  • على أجهزة سطح المكتب، تتيح الحافة اليمنى لكل عمود والحافة السفلية لكل صف تغيير حجمها: يعرض المؤشر مؤشر تغيير الحجم وخط tableResizeHandle، ويتبع الجدول السحب، ويضبط الإفلات colwidth أو height في خطوة واحدة يمكن التراجع عنها. resizable: false يوقف هذا، وlastColumnResizable: false يُبقي عرض العمود الأخير.
  • يعرض الجدول الذي يوجد فيه التحديد مقابض (tableGrip) على حافته العلوية فوق كل عمود وعلى حافته اليسرى بجانب كل صف. يحدد الضغط على أحدها ذلك العمود أو الصف؛ وسحبه على أجهزة سطح المكتب يُظهر موضع الإفلات (tableDropIndicator) وينقله إليه عند الإفلات.
<RichEditor
  editor={editor}
  classes={{
    nodes: { tableHeader: "font-semibold", tableCell: "px-3 py-2" },
    selectedCell: "bg-primary/10",
    tableGrip: "bg-muted-foreground/60 rounded-full",
  }}
/>

الأوامر، وكلٌّ منها مساعد (helper) من nsh/base:

  • insertTable({ rows, cols, withHeaderRow }) (3 × 3 مع صف رأس افتراضيًا) وdeleteTable().
  • addColumnBefore()، addColumnAfter()، deleteColumn()، addRowBefore()، addRowAfter()، deleteRow().
  • mergeCells() وsplitCell() وmergeOrSplit().
  • toggleHeaderRow() وtoggleHeaderColumn() وtoggleHeaderCell().
  • setCellAttribute(name, value)، setCellSelection({ anchorCell, headCell })، goToNextCell()، goToPreviousCell() وfixTables()، الذي يصلح الجداول ذات الخلايا الناقصة أو المتداخلة.
  • moveTableRow({ from, to, select }) وmoveTableColumn({ from, to, select }) ينقلان صفًا أو عمودًا، مع الصفوف أو الأعمدة التي تمتد عبرها خلاياه المدمجة؛ وsetColumnWidth({ col, width }) و setRowHeight({ row, height }) يضبطان حجمها (null يضبطه وفق محتواها). وتقبل هذه table، وهو الموضع قبل جدول، للعمل على جدول غير جدول التحديد.

تُقرأ الجداول من HTML وتُكتب إليه (<table> مع <th> / <td>، وcolspan، وrowspan وdata-colwidth) ومن Markdown وإليه: الجدول الذي له صف رأس وكل خلية فيه فقرة واحدة هو جدول GFM بالأعمدة العمودية (pipe table)، وأي جدول آخر يُكتب بصيغة HTML الخاصة به، وتُقرأ بالشكل نفسه. وفي النص العادي يكون الصف سطرًا من خلاياه مفصولة بعلامات Tab.