IlmHamroh
JavaScript Full-stack/21-qism. React: media, integratsiyalar, PWA10/32-dars20 daqiqa
Mundarija (32)

Tiptap React'da: rich text muharrir, toolbar, BubbleMenu va JSON yoki HTML saqlash

Qisqacha: Tiptap — ProseMirror ustidagi muharrir: "Word kabi" tahrir, Markdown belgilarisiz. useEditor({ extensions: [StarterKit] }) muharrirni yaratadi, <EditorContent> uni chizadi, toolbar esa editor.chain().focus().toggleBold().run() kabi buyruqlarni chaqiradi. Hujjat ichida JSON daraxt (doc → paragraph → text + marks); saqlash uchun JSON yaxshiroq — HTML esa serverda tozalanishi shart. Kanon o'lchovi: +120.4 KB, vazifalar ning Trusted Types siyosatida yiqildi. Biz uch sozlama bilan (JSON boshlang'ich kontent, injectCSS: false, ProseMirrorClipboard siyosati) uni CSP 0 bilan ishlatdik.

Bu darsda

  • Rich text muharrir Markdown maydonidan nimasi bilan farq qilishini va ichida qanday hujjat modeli borligini tushuntirasiz.
  • Tiptap'ni useEditor, StarterKit va EditorContent bilan ulaysiz, toolbar va BubbleMenu yasaysiz.
  • Kontentni JSON yoki HTML'da saqlashni tanlaysiz va har birining xavfsizlik shartini bilasiz.
  • Placeholder, havola va rasm yuklashni qo'shasiz.
  • Tiptap'ni qattiq CSP va Trusted Types ostida ishlatish shartlarini bilasiz.

Oldin bilishingiz kerak: Markdown render qilish, Fayl yuklash UI, Fokus va tahrir atributlari, Frontend xavfsizligi React'da, Kod bloklari va sintaksis yoritish.

1. Nega bu kerak?

O'tgan ikki darsda izohlarni Markdown bilan yozdik. Sardor uchun **qalin** tabiiy — u dasturchi. Lekin «Bahor»ning menejeri Dilnoza opa menyu ta'riflarini yozadi: "Osh — 35 000, juma kuni chegirma". U yulduzchalarni bilmaydi va bilishni ham xohlamaydi. U Word'dagidek "Q" tugmasini bosmoqchi.

Bunday maydon — rich text muharrir (WYSIWYG — "What You See Is What You Get", "nima ko'rsangiz, o'sha chiqadi"). Brauzerda buning asosi — contenteditable atributi (Fokus va tahrir atributlari). U har qanday elementni tahrirlanadigan qiladi.

Lekin toza contenteditable — tuzoqlar to'plami. Har brauzer "qalin" ni boshqacha yozadi (<b>, <strong>, style="font-weight:bold"). Word'dan nusxa olingan matn o'zi bilan yuzlab keraksiz teg va stil olib keladi. Bekor qilish (Ctrl+Z) ishonchsiz. Shuning uchun deyarli hech kim toza contenteditable ustiga muharrir yozmaydi.

ProseMirror — bu muammoni hal qilgan kutubxona. U DOM'ni haqiqat manbai deb hisoblamaydi: hujjat — qat'iy sxemali daraxt, DOM esa faqat uning ko'rinishi. Tiptap — ProseMirror ustidagi qulay qatlam: tayyor kengaytmalar (extensions), React integratsiyasi, oddiy API.

Hayotiy o'xshatish: kassadagi chek. Kassir ekranda istagancha bosishi mumkin, lekin chekka faqat kassa dasturi tasdiqlagan qatorlar tushadi: "Osh × 2 — 70 000". Muharrirda ham shunday: ekranda nima ko'rinmasin, saqlanadigan hujjat — faqat sxemaga mos tugunlar.

2. Hujjat modeli: JSON daraxt

2.1 Tugunlar va belgilar

Tiptap hujjati — JSON. "Osh — 35 000" shunday saqlanadi:

json
{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "Osh — " },
        {
          "type": "text",
          "text": "35 000",
          "marks": [{ "type": "bold" }]
        }
      ]
    }
  ]
}

Ikki xil qism bor:

  • Tugun (node) — tuzilma: doc, paragraph, heading, bulletList, image. Tugunlar ichma-ich joylashadi.
  • Belgi (mark) — matnga "yopishgan" bezak: bold, italic, link. Belgi tugun emas — u matn bo'lagining xususiyati.

Foydalanuvchi "Q" ni bosganda muharrir ichida nima bo'ladi:

flowchart LR
  K["Tugma: Qalin"] --> C["buyruq: toggleBold"]
  C --> T["tranzaksiya: o'zgarish"]
  T --> S["yangi hujjat (JSON)"]
  S --> V["ko'rinish: DOM yangilanadi"]
  S --> U["onUpdate: getJSON()"]

Diagrammaga qarang: DOM — oxirgi bosqich, natija. Brauzer contenteditable ga o'zi nima yozsa ham, ProseMirror uni hujjatga solishtirib, sxemaga moslab qayta chizadi.

Shuning uchun <strong><em>…</em></strong> va <em><strong>…</strong></em> farqi yo'qoladi: ikkalasi ham marks: [bold, italic]. HTML'da bir xil matnni o'nlab usulda yozish mumkin, sxemada esa bitta usul.

2.2 O'quv modeli: JSON'dan React

Tiptap natija oynasida yo'q. Lekin JSON hujjatni chizishni kutubxonasiz ko'ra olamiz — bu keyinchalik saqlangan izohni ko'rsatishning eng xavfsiz yo'li:

jsx
import { Fragment } from "react";

const doc = {
  type: "doc",
  content: [
    {
      type: "heading",
      attrs: { level: 3 },
      content: [{ type: "text", text: "Bugungi menyu" }],
    },
    {
      type: "paragraph",
      content: [
        { type: "text", text: "Osh — " },
        { type: "text", text: "35 000", marks: [{ type: "bold" }] },
        { type: "text", text: " <script>alert(1)</script>" },
      ],
    },
  ],
};

const MARKS = { bold: "strong", italic: "em" };

function renderText(node, key) {
  // belgilar — ichma-ich o'ramlar: bold → <strong>
  const wrapped = (node.marks ?? []).reduce((child, mark) => {
    const Tag = MARKS[mark.type];
    return Tag ? <Tag>{child}</Tag> : child;
  }, node.text);
  return <Fragment key={key}>{wrapped}</Fragment>;
}

function renderNode(node, key) {
  const kids = node.content?.map((c, i) =>
    c.type === "text" ? renderText(c, i) : renderNode(c, i),
  );
  if (node.type === "paragraph") return <p key={key}>{kids}</p>;
  if (node.type === "heading") return <h3 key={key}>{kids}</h3>;
  if (node.type === "doc") return <article key={key}>{kids}</article>;
  return null; // noma'lum tugun — chizilmaydi
}

export default function App() {
  return renderNode(doc, 0);
}

Ekranda:

text
Bugungi menyu
Osh — 35 000 <script>alert(1)</script>

Uch narsaga qarang:

  1. Matn — React bolasi. Har bo'lak Fragment ichida — ro'yxatdagi element uchun key shu yerda. <script> oddiy harflar bo'lib chiqdi: React ekranladi. HTML satr yo'q — XSS yo'q.
  2. Noma'lum tugun — null. Kimdir JSON'ga { "type": "iframe" } qo'shsa, chizuvchi uni tanimaydi. Bu — oq ro'yxat: faqat bilgan tugunlarimiz.
  3. Belgilar — o'ram. reduce har belgini tashqi qavat qiladi. Ikki belgi — ikki ichma-ich teg.

Tiptap'ning o'zida bu ishni generateHTML(json, extensions) qiladi (serverda ham — @tiptap/html/server). Biz uni Node 24 da shu ma'lumot bilan (javascript: havolasini ham qo'shib) ishga tushirdik:

text
<p>Osh — <strong>35 000</strong> &lt;script&gt;alert(1)&lt;/script&gt;<a target="_blank" rel="noopener noreferrer nofollow" href="">bos</a></p>

Matn ekranlandi, javascript: havola — bo'sh href. Link kengaytmasi xavfli protokolni o'zi tashlaydi.

3. Tiptap'ni ulash

3.1 O'rnatish va birinchi muharrir

Kanon o'lchagan versiya — 3.31.4. Tiptap 3 da uchta paket kerak:

bash
npm i -E @tiptap/react@3.31.4 @tiptap/pm@3.31.4 \
  @tiptap/starter-kit@3.31.4

@tiptap/pm — ProseMirror paketlari, @tiptap/starter-kit — tayyor to'plam: paragraf, sarlavha, ro'yxatlar, qalin, kursiv, kod, iqtibos, havola, tagiga chizish, bekor qilish tarixi. Tiptap 3 da havola va tagiga chizish ham StarterKit ichida (2-versiyada alohida edi).

tsx
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";

export function MenuEditor() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: "<p>Osh — <strong>35 000</strong></p>",
    onUpdate: ({ editor }) => {
      console.log(editor.getJSON()); // har o'zgarishda
    },
  });
  return <EditorContent editor={editor} aria-label="Menyu ta'rifi" />;
}

useEditor muharrir nusxasini yaratadi va komponent o'chganda yo'q qiladi — o'zingiz cleanup yozmaysiz. <EditorContent> ichiga ProseMirror o'z contenteditable elementini qo'yadi. Klaviatura qisqartmalari tayyor: Ctrl+B — qalin, Ctrl+I — kursiv, # — sarlavha, - — ro'yxat.

3.2 Toolbar: buyruqlar zanjiri

Tugma bosilganda muharrirga buyruq beramiz:

tsx
import { useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";

function Toolbar({ editor }: { editor: Editor }) {
  // faqat shu qiymatlar o'zgarganda qayta chiziladi
  const s = useEditorState({
    editor,
    selector: ({ editor }) => ({
      bold: editor.isActive("bold"),
      italic: editor.isActive("italic"),
      canUndo: editor.can().undo(),
    }),
  });
  return (
    <div role="toolbar" aria-label="Formatlash">
      <button
        type="button"
        aria-pressed={s.bold}
        onClick={() => editor.chain().focus().toggleBold().run()}
      >
        Qalin
      </button>
      <button
        type="button"
        aria-pressed={s.italic}
        onClick={() => editor.chain().focus().toggleItalic().run()}
      >
        Kursiv
      </button>
      <button
        type="button"
        disabled={!s.canUndo}
        onClick={() => editor.chain().focus().undo().run()}
      >
        Bekor qilish
      </button>
    </div>
  );
}

Buyruqlar zanjiri — chain() ... run(). Orada istalgancha buyruq, ular bitta o'zgarish (tranzaksiya) bo'lib bajariladi. focus() muhim: tugma bosilganda fokus muharrirdan tugmaga o'tadi, focus() uni qaytaradi — foydalanuvchi yozishda davom etadi.

useEditorState — Tiptap 3 ning tavsiya etilgan yo'li. Sukut bo'yicha useEditor har harf kiritilganda komponentni qayta chizmaydi (shouldRerenderOnTransaction: false) — tezlik uchun. Toolbar esa "qalin hozir yoqilganmi?" ni bilishi kerak. selector faqat kerakli qiymatlarni oladi va ular o'zgarganda chizadi (useSyncExternalStore g'oyasi).

3.3 O'quv modeli: toolbar va a11y

Toolbar'ning a11y qismini jonli ko'raylik. Muharrir o'rnida — bitta paragrafli soxta model, buyruqlar esa Tiptap nomlari bilan:

jsx
import { useState } from "react";

const MARKS = { bold: "strong", italic: "em" };

export default function App() {
  const [marks, setMarks] = useState([]);
  function toggle(type) {
    const next = marks.includes(type)
      ? marks.filter((m) => m !== type)
      : [...marks, type];
    const name = type[0].toUpperCase() + type.slice(1);
    console.log(`toggle${name}()`, next);
    setMarks(next);
  }
  const text = marks.reduce((child, m) => {
    const Tag = MARKS[m];
    return <Tag>{child}</Tag>;
  }, "Osh — 35 000");
  return (
    <section>
      <div role="toolbar" aria-label="Formatlash">
        <button
          type="button"
          aria-pressed={marks.includes("bold")}
          onClick={() => toggle("bold")}
        >
          Qalin
        </button>{" "}
        <button
          type="button"
          aria-pressed={marks.includes("italic")}
          onClick={() => toggle("italic")}
        >
          Kursiv
        </button>
      </div>
      <p>{text}</p>
    </section>
  );
}

Konsolda:

text
toggleBold() (1) ['bold']
toggleItalic() (2) ['bold', 'italic']
toggleBold() (1) ['italic']

Ekranda:

text
Qalin Kursiv
Osh — 35 000

Ekrandagi matn har doim bir xil — o'zgaradi faqat uning o'ramlari (<strong>, <em>). aria-pressed — tugma "bosilgan/bosilmagan" holatini ekran o'quvchiga aytadi: Malika "Qalin, toggle tugma, bosilmagan" ni eshitadi. Rangli fon yolg'iz yetmaydi.

role="toolbar" ham shart: WAI-ARIA tavsiyasiga ko'ra toolbar ichida Tab bitta to'xtaydi, tugmalar orasida strelkalar bilan yuriladi (roving tabindex). Bu yerda soddalik uchun yozmadik — real toolbar'da qo'shing.

4. BubbleMenu, placeholder, havola va rasm

4.1 BubbleMenu

BubbleMenu — matn belgilanganda uning ustida chiqadigan kichik menyu (Medium, Notion'dagidek). Tiptap 3 da u alohida kirish nuqtasidan import qilinadi:

tsx
import { BubbleMenu } from "@tiptap/react/menus";

<BubbleMenu editor={editor}>
  <button
    type="button"
    onClick={() => editor.chain().focus().toggleBold().run()}
  >
    Qalin
  </button>
  <button
    type="button"
    onClick={() => {
      const url = window.prompt("Havola manzili");
      if (url) editor.chain().focus().setLink({ href: url }).run();
    }}
  >
    Havola
  </button>
</BubbleMenu>;

Eski (2-versiya) maqolalarda import { BubbleMenu } from "@tiptap/react" ni ko'rasiz — 3 da u @tiptap/react/menus ga ko'chgan. Menyu belgilangan matnga "yopishadi" va joylashuvini o'zi hisoblaydi (Floating UI). Klaviatura bilan ishlaydigan foydalanuvchi uchun BubbleMenu yetarli emas — doimiy toolbar ham bo'lsin.

4.2 Placeholder

Bo'sh muharrirda kulrang ishora: "Ta'rifni yozing…". Tiptap 3 da u @tiptap/extensions paketida:

tsx
import { Placeholder } from "@tiptap/extensions";

useEditor({
  extensions: [
    StarterKit,
    Placeholder.configure({ placeholder: "Ta'rifni yozing…" }),
  ],
});

Kengaytma bo'sh paragrafga data-placeholder atributi va is-editor-empty sinfini qo'yadi. Matnning o'zini esa CSS chizadi: p.is-editor-empty:first-child::before { content: attr(data-placeholder); }. Bu ishora — label emas: ekran o'quvchi uchun muharrirga aria-label yoki ko'rinadigan <label> bering.

4.3 Havola

StarterKit'dagi Link kengaytmasi javascript: kabi xavfli manzillarni qabul qilmaydi — «JSON'dan React» bo'limidagi sinovda href bo'sh bo'ldi. Ruxsat etilgan protokollarni protocols va isAllowedUri sozlamalari bilan toraytirasiz. Havolalar sukut bo'yicha target="_blank" va rel="noopener noreferrer nofollow" bilan chiqadi — o'tgan darsdagi qoidaning o'zi.

4.4 Rasm

Rasm — @tiptap/extension-image, buyruq setImage({ src, alt }). Asosiy savol — rasm qayerda saqlanadi. Ikki yo'l:

  • Base64 hujjat ichida — 2 MB rasm JSON'ni 2.7 MB ga aylantiradi, har saqlashda qayta yuboriladi. Image kengaytmasida bu allowBase64 sozlamasi bilan boshqariladi — yoqmang.
  • Serverga yuklash, hujjatda — manzil. Fayl tanlanadi yoki joylashtiriladi → yuklash → server URL qaytaradi → setImage({ src: url, alt }). To'g'ri yo'l.
tsx
async function insertImage(editor: Editor, file: File) {
  // server baribir tekshiradi
  if (!file.type.startsWith("image/")) return;
  const body = new FormData();
  body.append("rasm", file);
  const res = await fetch("/api/rasmlar", { method: "POST", body });
  const { url } = (await res.json()) as { url: string };
  editor.chain().focus().setImage({ src: url, alt: file.name }).run();
}

alt — fayl nomi emas, ma'no bo'lishi kerak. Rasm qo'yilgach, foydalanuvchidan qisqa ta'rif so'rang.

5. Saqlash: JSON yoki HTML

5.1 Ikki format

editor.getJSON() va editor.getHTML() — bir hujjatning ikki ko'rinishi:

JSON HTML
Tuzilma sxemaga mos, qat'iy erkin matn
Keyin tahrirlash to'g'ridan-to'g'ri qayta tahlil (parse)
Ko'rsatish o'z chizuvchingiz yoki generateHTML dangerouslySetInnerHTML (+ sanitize)
Qidiruv, tahlil maydonlar bo'yicha matnni qidirish

Kod-ko'rib chiqishda ko'p uchraydigan qaror: JSON saqlang, ko'rsatishda HTML yasang (serverda generateHTML yoki brauzerda o'z chizuvchingiz).

5.2 Server klientga ishonmaydi

Muharrir faqat sxemaga mos hujjat yaratadi. Lekin server muharrirdan emas, tarmoqdan ma'lumot oladi. Hujumchi brauzerni chetlab, to'g'ridan-to'g'ri yuboradi:

js
fetch("/api/menyu/7", {
  method: "PUT",
  body: JSON.stringify({ html: '<img src=x onerror="…">' }),
});

HTML saqlasangiz va keyin dangerouslySetInnerHTML bilan ko'rsatsangiz — saqlangan XSS. Himoya:

  • HTML saqlansa — serverda tozalash (DOMPurify yoki shunga o'xshash, ruxsat ro'yxati bilan), ko'rsatishda ham.
  • JSON saqlansa — serverda sxemani tekshirish: faqat ma'lum type lar va attrs. Ko'rsatish — generateHTML yoki o'z chizuvchingiz (noma'lum tugun — chizilmaydi).

Server tomoni — 25-qismdan. Frontend dasturchining vazifasi: formatni tanlash va "muharrir tozalaydi" degan noto'g'ri ishonchga tayanmaslik.

5.3 JSON'dan xavfsiz HTML — o'zimiz

generateHTML qanday ishlashini kichik serializer bilan ko'ramiz. Ikki qoida: matn ekranlanadi, havola protokoli tekshiriladi:

js
const ENTITY = { "&": "amp", "<": "lt", ">": "gt", '"': "quot" };
const escape = (s) => s.replace(/[&<>"]/g, (c) => `&${ENTITY[c]};`);

const safeHref = (url) =>
  /^(https?:|mailto:)/i.test(url) ? url : "";

function textToHtml(node) {
  let html = escape(node.text);
  for (const m of node.marks ?? []) {
    if (m.type === "bold") html = `<strong>${html}</strong>`;
    if (m.type === "italic") html = `<em>${html}</em>`;
    if (m.type === "link") {
      const href = escape(safeHref(m.attrs.href));
      html = `<a href="${href}">${html}</a>`;
    }
  }
  return html;
}

const paragraph = [
  { type: "text", text: "Osh — " },
  { type: "text", text: "35 000", marks: [{ type: "bold" }] },
  { type: "text", text: " <script>alert(1)</script>" },
  {
    type: "text",
    text: "bos",
    marks: [{ type: "link", attrs: { href: "javascript:alert(1)" } }],
  },
];
console.log(`<p>${paragraph.map(textToHtml).join("")}</p>`);

Konsolda:

text
<p>Osh — <strong>35 000</strong> &lt;script&gt;alert(1)&lt;/script&gt;<a href="">bos</a></p>

Natija Tiptap'ning generateHTML chiqishiga juda yaqin («JSON'dan React» bo'limi): matn ekranlangan, javascript: bo'shatilgan. Farqi — Tiptap havolaga target va rel ham qo'shadi. Bu — o'quv modeli. Haqiqiy loyihada o'z serializer'ingizni emas, generateHTML ni ishlating: sxema va kengaytmalar bilan bir xil bo'ladi.

Tekshirib ko'ring: Nega escape dan " (qo'shtirnoq) ham o'tadi, faqat < va > emas?

Javob

Havola manzili atribut ichiga, qo'shtirnoqlar orasiga tushadi: href="…". Manzilda " bo'lsa, atribut yopiladi va undan keyingi matn yangi atribut bo'lib qoladi: " onmouseover="alert(1). &quot; ga aylantirish buni oldini oladi. Matn uchun <, >, & yetadi, atribut uchun qo'shtirnoq ham kerak.

6. Biz sinab ko'rdik: hajm va Trusted Types

6.1 Kanon o'lchovi

text
muharrir-tiptap  JS 190986 (+ 120.4 KB) | CSP require-trusted-types-for×2
  xato: … Failed to execute 'parseFromString' on 'DOMParser': … 'TrustedHTML' …
  CSP'siz: «Tiptap Osh — 35 000», 5 element

+120.4 KB gzip — React'ning deyarli ikki barobari. CSP ostida muharrir chizilmadi (0 element). Sabab — boshlang'ich kontent HTML satr edi ("<p>Osh — <strong>35 000</strong></p>"): Tiptap uni DOMParser bilan tahlil qiladi, Trusted Types esa satrni to'sadi.

6.2 Uch sozlama bilan — CSP 0

Biz dars uchun chuqurroq qazidik (esbuild, Chrome 155, kanon CSP'si) — manba kodida uchta HTML yo'lini topdik:

Yo'l Qayerda Yechim
HTML satr kontent content: "<p>…", insertContent("…") JSON tugunlar
Muharrir CSS'i styleNode.innerHTML = style injectCSS: false, CSS o'zimizning faylda
Joylashtirish (paste) ProseMirror ProseMirrorClipboard siyosati CSP: trusted-types vazifalar ProseMirrorClipboard

Natijalar bosqichma-bosqich:

  1. JSON kontent + injectCSS: false, kanon CSP — muharrir ishladi, lekin ikki buzilish: insertContent(" lag'mon") satri va joylashtirishda Policy "ProseMirrorClipboard" disallowed.
  2. CSP'ga ProseMirrorClipboard qo'shildi — joylashtirish ishladi.
  3. insertContent({ type: "text", text: " lag'mon" }) — 0 buzilish: yozish, kursiv, joylashtirish, getJSON, getHTML — hammasi ishladi.

ProseMirror Trusted Types'ni biladi (manba kodiga ko'ra): joylashtirilgan HTML'ni alohida, ko'rinmas hujjatda tahlil qiladi va buning uchun o'z siyosatini yaratadi. Bu siyosat satrni o'zgartirmaydi, lekin ko'rinmas hujjatda skript ishlamaydi va rasm yuklanmaydi. Joylashtirilgan HTML'dan keyin nima qolgani — sxema hal qiladi. Sinovimizda <img onerror> tashlandi (Image kengaytmasi yo'q), <b> → <strong>, javascript: havola — bo'sh href.

Bu — bitta sinov va bitta konfiguratsiya. CSP'ga yangi siyosat nomi qo'shish — xavfsizlik ko'rigi talab qiladigan qaror.

6.3 Nega vazifalar da Tiptap yo'q

Kanon izohi — oddiy <textarea> + Markdown (Markdown render qilish). Kanon fayli izohida ham shunday yozilgan: "rich text muharrir emas: #10–#11 o'lchovi — Tiptap/Lexical bitta maydon uchun ortiqcha". Bitta izoh maydoni uchun +120 KB va CSP'ni kengaytirish — qimmat. Vazifalar ilovasining foydalanuvchisi — Sardor kabi odamlar, ular Markdown'ni biladi.

Tiptap qachon to'g'ri tanlov? Kontent — mahsulotning o'zi bo'lganda: blog, CMS, bilimlar bazasi, e-pochta muharriri, foydalanuvchilar dasturchi bo'lmaganda.

7. SSR va boshqa tuzoqlar

Server tomonda chizadigan frameworklarda (Next.js — 23-qism) Tiptap muharriri serverda yaratilmasligi kerak — contenteditable va DOM faqat brauzerda. Tiptap 3 da buning uchun useEditor({ immediatelyRender: false, … }): birinchi render'da editor — null, brauzerda keyingi render'da yaratiladi. Tur fayli ham shuni aytadi: SSR'da false, faqat klientda — true (sukut).

Yana bir tuzoq — boshqariladigan muharrir. content={value} ni har render'da o'zgartirib, onUpdate da setValue qilsangiz — kursor sakraydi va har harfda hujjat qayta yaratiladi. content — faqat boshlang'ich qiymat (xaritalardagi center kabi). Tashqaridan almashtirish kerak bo'lsa — editor.commands.setContent(json), faqat haqiqatan boshqa hujjat kelganda.

8. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
toolbar "qalin" holatini ko'rsatmaydi useEditor har tranzaksiyada chizmaydi useEditorState({ selector })
tugma bosilgach yozish to'xtadi fokus tugmada qoldi chain().focus()…run()
kursor har harfda oxirga sakraydi content har render'da yangilanadi content — boshlang'ich; setContent faqat yangi hujjatda
BubbleMenu import xatosi 2-versiya yo'li @tiptap/react/menus
SSR'da hydration xatosi muharrir serverda yaratildi immediatelyRender: false
hujjat bir necha MB rasm base64 serverga yuklash, src — URL
TT: parseFromString … TrustedHTML HTML satr kontent JSON tugunlar
TT: innerHTML (style) Tiptap CSS'ni o'zi qo'shadi injectCSS: false + o'z CSS
saqlangan HTML'da <img onerror> server klientga ishondi serverda sanitize yoki JSON + sxema

9. Mashqlar

Mashqlar kurs/mashqlar/21/10-tiptap/ da. Jonli yechimlarni «Ishga tushir» bilan sinang.

1-mashq (oson): bo'sh joylarni to'ldiring

  • Tiptap asosidagi kutubxona:
  • Matnga yopishgan bezak (qalin, havola) Tiptap JSON'ida:
  • Buyruqlar zanjirini bajaradigan oxirgi chaqiruv:
  • Toolbar holatini tanlab kuzatuvchi hook:
  • SSR uchun useEditor sozlamasi: immediatelyRender:
Yechim
  • ProseMirror — sxemali hujjat modeli va tahrir dvigateli.
  • mark — belgi; tuzilma esa — node (tugun).
  • run — chain() dan keyingi buyruqlar bitta tranzaksiya bo'lib bajariladi.
  • useEditorState — selector qiymati o'zgarganda qayta chizadi.
  • false — muharrir brauzerda, keyingi render'da yaratiladi.

2-mashq (o'rta): matn statistikasi

Tiptap JSON hujjatidan statistika: nechta paragraf, nechta so'z va nechta qalin bo'lak. Hujjat:

js
const doc = {
  type: "doc",
  content: [
    {
      type: "paragraph",
      content: [{ type: "text", text: "Osh tayyor" }],
    },
    {
      type: "paragraph",
      content: [
        { type: "text", text: "Narxi " },
        { type: "text", text: "35 000", marks: [{ type: "bold" }] },
      ],
    },
  ],
};

Daraxtni rekursiv aylanib chiqib, konsolga paragraf: 2, so'z: 5, qalin: 1 chiqaring.

Ishora: so'zlar — barcha text larni bo'shliq bilan birlashtirib, split(/\s+/).

Yechim
js
const doc = {
  type: "doc",
  content: [
    {
      type: "paragraph",
      content: [{ type: "text", text: "Osh tayyor" }],
    },
    {
      type: "paragraph",
      content: [
        { type: "text", text: "Narxi " },
        { type: "text", text: "35 000", marks: [{ type: "bold" }] },
      ],
    },
  ],
};

function walk(node, stats) {
  if (node.type === "paragraph") stats.paragraphs++;
  if (node.type === "text") {
    stats.texts.push(node.text);
    if (node.marks?.some((m) => m.type === "bold")) stats.bold++;
  }
  for (const child of node.content ?? []) walk(child, stats);
  return stats;
}

const s = walk(doc, { paragraphs: 0, bold: 0, texts: [] });
const words = s.texts.join(" ").trim().split(/\s+/).length;
console.log(
  `paragraf: ${s.paragraphs}, so'z: ${words}, qalin: ${s.bold}`,
);

Konsolda:

text
paragraf: 2, so'z: 5, qalin: 1

"35 000" ikki so'z bo'lib sanaldi (bo'shliq bor) — shuning uchun 5: Osh, tayyor, Narxi, 35, 000. Haqiqiy so'z hisoblagichlar sonlarni alohida qoida bilan sanaydi. Tiptap'da tayyor CharacterCount kengaytmasi bor (@tiptap/extensions).

3-mashq (qiyin): oq ro'yxatli chizuvchi

«JSON'dan React» bo'limidagi chizuvchini kengaytiring: bulletList → <ul>, listItem → <li>, link belgisi → <a> (faqat https: manzil, aks holda faqat matn). Sinov hujjati — ro'yxat ichida ikki band: oddiy "non" va javascript:alert(1) havolali "sut". Ekranda ikkalasi ham ko'rinsin, konsolga esa sahifadagi <a> lar soni.

Ishora: belgilar reduce ichida; link uchun mark.attrs.href.

Yechim
jsx
import { Fragment, useEffect } from "react";

const BAD = "javascript:alert(1)";
const doc = {
  type: "doc",
  content: [
    {
      type: "bulletList",
      content: [
        {
          type: "listItem",
          content: [{ type: "text", text: "non" }],
        },
        {
          type: "listItem",
          content: [
            {
              type: "text",
              text: "sut",
              marks: [{ type: "link", attrs: { href: BAD } }],
            },
          ],
        },
      ],
    },
  ],
};

const TAGS = { doc: "article", bulletList: "ul", listItem: "li" };

function renderText(node, key) {
  const wrapped = (node.marks ?? []).reduce((child, mark) => {
    const href = mark.attrs?.href ?? "";
    if (mark.type === "bold") return <strong>{child}</strong>;
    if (mark.type === "link" && href.startsWith("https:")) {
      return <a href={href}>{child}</a>;
    }
    return child; // noma'lum yoki xavfli belgi — faqat matn
  }, node.text);
  return <Fragment key={key}>{wrapped}</Fragment>;
}

function renderNode(node, key) {
  const Tag = TAGS[node.type];
  if (!Tag) return null;
  return (
    <Tag key={key}>
      {node.content?.map((c, i) =>
        c.type === "text" ? renderText(c, i) : renderNode(c, i),
      )}
    </Tag>
  );
}

export default function App() {
  useEffect(() => {
    console.log("havolalar:", document.querySelectorAll("a").length);
  }, []);
  return renderNode(doc, 0);
}

Konsolda:

text
havolalar: 0

Ekranda:

text
non
sut

"sut" ko'rindi, lekin havola bo'lmadi: javascript: https: bilan boshlanmaydi — belgi tashlandi, matn qoldi. Xavfli narsani "o'chirish" emas, "faqat xavfsizini ruxsat etish" — oq ro'yxat tamoyili. TAGS da yo'q tugun (masalan iframe) umuman chizilmaydi.

4-mashq: tajriba qadami (ixtiyoriy)

vazifalar ga tegmang. Alohida Vite loyihada (kurs/tajriba/tiptap/) Tiptap bilan menyu ta'rifi muharririni yasang: toolbar (Qalin, Kursiv, Ro'yxat, Bekor qilish, aria-pressed), placeholder, BubbleMenu. onUpdate da getJSON() ni localStorage ga saqlang. Keyin «Uch sozlama bilan — CSP 0» bo'limidagi sozlamalarni vazifalar CSP <meta> i bilan sinab ko'ring.

Yechim

Tekshiruv ro'yxati:

  • npm i -E @tiptap/react@3.31.4 @tiptap/pm@3.31.4 @tiptap/starter-kit@3.31.4 @tiptap/extensions@3.31.4.
  • Toolbar holati useEditorState bilan; tugma bosilgach fokus muharrirda qoladi.
  • Sahifa yangilanganda hujjat localStorage dagi JSON'dan tiklanadi (content: saved ?? undefined).
  • CSP bilan: avval HTML content — parseFromString … TrustedHTML; JSON + injectCSS: false — muharrir ishlaydi; joylashtirish uchun trusted-types … ProseMirrorClipboard.
  • npm run build: kanonda +120.4 KB gzip (sizda kengaytmalar soniga qarab).

10. Real ishda

  • CMS va bloglar (Ghost, Strapi'ga o'xshash tizimlar) — rich text muharrir asosiy ish quroli; JSON yoki HTML saqlanadi.
  • Bilimlar bazasi va hujjatlar (Notion, Confluence'ga o'xshash) — ko'pincha ProseMirror yoki Lexical asosida, hamkorlikda tahrir bilan (keyingi dars).
  • E-pochta va xabar muharrirlari — qisqa formatlash, havola, rasm.
  • Admin panellar — mahsulot ta'rifi, yangilik matni.
  • Intervyu savollari: "contenteditable ustiga o'z muharriringizni nega yozmaysiz?", "JSON yoki HTML saqlaysizmi va nega?", "muharrir kontentini qanday xavfsiz ko'rsatasiz?", "toolbar'ni qanday a11y qilasiz?".

Xulosa

  • Rich text muharrir — contenteditable ustida, lekin haqiqat manbai — sxemali hujjat (ProseMirror); Tiptap — uning React'ga qulay qatlami.
  • Hujjat — JSON: tugunlar (paragraph, heading, list) va belgilar (bold, italic, link).
  • useEditor + StarterKit + EditorContent; buyruqlar chain().focus()…run(); toolbar holati — useEditorState, a11y — role="toolbar", aria-pressed.
  • BubbleMenu — @tiptap/react/menus, Placeholder — @tiptap/extensions; rasm — serverga yuklab, URL bilan.
  • JSON saqlash afzal; server klientga ishonmaydi — HTML'ni tozalaydi yoki JSON sxemasini tekshiradi.
  • Kanon: +120.4 KB, TT'da yiqildi. Bizning sinov: JSON kontent + injectCSS: false + ProseMirrorClipboard siyosati — CSP 0. vazifalar da — <textarea> + Markdown.

Keyingi dars: Rich text II va kod muharrirlari — Lexical, BlockNote (Notion uslubi), Monaco (VS Code muharriri) va CodeMirror: hajm, CDN va CSP saboqlari, hamkorlikda tahrir (Yjs).

Manbalar

  • Tiptap 3: "Install React", "Configure the editor" (injectCSS, immediatelyRender, shouldRerenderOnTransaction), "useEditorState", "BubbleMenu" (@tiptap/react/menus), "Placeholder" (@tiptap/extensions), "Link", "Image", "generateHTML" — tiptap.dev/docs
  • ProseMirror: "Guide" (hujjat modeli, sxema, tranzaksiya) — prosemirror.net/docs; manba: prosemirror-view (maybeWrapTrusted, ProseMirrorClipboard)
  • WAI-ARIA APG: "Toolbar Pattern" — w3.org/WAI/ARIA/apg/patterns/toolbar
  • MDN: contenteditable, Trusted Types API — developer.mozilla.org
  • Kanon: natijalar/tajriba/kutubxonalar-21.txt, tajriba/kutubxonalar/src/k/muharrir-tiptap.tsx; vazifalar holat 08 VazifaIzohi.tsx izohi (2026-10-17)
Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!