Příručka
Nibble vznikl jako náhrada TinyMCE v projektu, kde je dvacet tisíc dokumentů, které za deset let prošly třemi různými nástroji. To zadání určilo skoro všechno, co je na něm jinak.
Proč Nibble
Free verze TinyMCE má stropy tam, kde to v praxi zabolí, a zároveň nese spoustu věcí, které nikdo nepoužije. Nibble je záměrně menší:
- Žádná runtime závislost. Celé jádro i s lištou má ~44 kB gzip.
- Žádný globální singleton. Instanci si drží ten, kdo ji vytvořil.
- Žádné
document.execCommand. Všechno jde přesbeforeinputa vlastní příkazy, takže existuje jediná cesta, jak se obsah může změnit. - Pluginy jako hodnoty, ne jako řetězec se jmény — bundler vidí, co se opravdu použije.
Záruka round-tripu
Načtu, nesáhnu, uložím → musí být znak po znaku totéž. Zní to samozřejmě, ale běžný editor to nesplňuje: cestou přerovná atributy, nahradí entity, doplní uvozovky, zahodí komentář. U nového textu si toho nikdo nevšimne. U dokumentů, které se dědí roky, je z toho tichá ztráta dat — a nikdo neví, kdy k ní došlo.
Nibble si při načtení uloží u každé oblasti nejvyšší úrovně její původní zdroj a snímek DOMu. Při serializaci se ptá: liší se ta oblast od snímku? Pokud ne, vypíše se původní zdroj doslova. Pokud ano, serializuje se normálně — ale jen ta jedna oblast.
CRLF se převádí na LF. Prohlížeč to udělá sám v textarea.value
i při parsování HTML — slib, že se CRLF zachová, by se nedal splnit. Funkce
normalizeNewlines() je proto veřejná, aby šlo porovnávat proti
stejnému základu.
Z toho plyne i pravidlo, které drží celý zbytek: bezpečnost se řeší
vždycky, tvar jen na tom, do čeho uživatel zasáhl. Sanitizace maže
skripty, on* atributy a javascript: odkazy při každém
načtení. Srovnávání tvaru — obalení holého textu odstavcem, oprava seznamu, do
kterého někdo vložil <ul> jako sourozence <li>
— běží líně, až když se s tím blokem pracuje.
Napojení na textareu
Nejjednodušší způsob, jak nahradit stávající editor: nechat textareu na místě.
<textarea name="popis" id="popis"></textarea>
const editor = await Nibble.create({ target: '#popis', schema: 'legacy', height: 400, })
Textarea zůstává zdrojem pravdy pro odeslání formuláře — obsah se do ní zapisuje při každé změně. V backendu se nemění nic.
Má-li textarea atribut placeholder, editor ho převezme jako šedou
nápovědu v prázdném obsahu. Jiný text se nastaví volbou placeholder.
Nápovědu kreslí jen CSS, do uloženého HTML se nedostane.
Více editorů na stránce
Každý si drží vlastní instanci. Nastavení uživatele se odlišuje klíčem:
attachToolbar(perex, { prefsKey: 'perex' }) attachToolbar(telo, { prefsKey: 'telo' })
Lišta a nabídka
Rozvržení lišty je pole skupin. Skupiny se od sebe oddělí čárou a lišta se láme po skupinách, ne po jednotlivých tlačítkách — jinak by na druhém řádku zbylo osamocené tlačítko.
attachToolbar(editor, { layout: [ ['undo', 'redo'], ['blocks'], ['bold', 'italic'], ['link', 'image', 'table'], ], menubar: true, })
Tlačítko, jehož plugin není načtený, se v liště prostě neobjeví — nemusíte držet rozvržení a seznam pluginů v souladu ručně. Totéž platí pro nabídkový pruh: celá nabídka zmizí, když v ní nezbude žádná dostupná položka.
Poslední slovo má uživatel
Ozubené kolo vpravo nahoře otevře nastavení, ve kterém si uživatel zapne nebo vypne jednotlivá tlačítka, přeskládá je tažením myší, rozdělí do dvou řádků, nastaví rozměry a zapne či vypne nabídku, lepkavou lištu a informační řádek. Volba se ukládá do prohlížeče a přebíjí konfiguraci.
Kolo samotné se vypnout nedá — bylo by to jednosměrné. Schovat ho může jen
programátor: attachToolbar(editor, { settings: false }).
V nastavení je tlačítko, které z aktuálního stavu udělá hotový inicializační kód. Když si uživatel lištu přeskládá tak, že to dává smysl, je to nejlepší podklad pro to, jak má editor vypadat pro všechny ostatní — a z ladění „posuň to o jedno doleva a pošli screenshot" se stane jedno zkopírování.
Obrázky a nahrávání
Bez adaptéru se obrázek vloží jako data: URL. Funguje to hned, ale
dokument tím tloustne — proto je adaptér první věc, kterou v ostrém provozu
doplníte:
import { createImagePlugin } from '@nibble/plugins' createImagePlugin({ upload(file, onProgress) { const body = new FormData() body.append('file', file) return fetch('/api/upload', { method: 'POST', body }) .then((res) => res.json()) .then((data) => data.url) }, })
Adaptér vrací výslednou adresu. Vkládání ze schránky i přetažení souboru jdou stejnou cestou, takže stačí napsat ho jednou.
Vlastní vzhled
Žádný generátor motivů — jen proměnné. Přepište je ve svém CSS a je hotovo:
:root { --nb-accent: #7b4dd8; --nb-radius: 10px; --nb-border: #d7d2e4; }
Proměnné sedí na :root, ne na obalu editoru: dialogy se vkládají do
<body>, tedy mimo něj, a jinak by je nezdědily. (Tuhle chybu
jsme udělali — a dialog vyšel průhledný, zatímco všechny testy svítily zeleně,
protože ověřovaly chování, ne vzhled.)
Limit znaků ve formuláři
Perex nebo meta popis mívá doporučenou délku. maxlength na
textaree tu nepomůže — editor do ní zapisuje HTML, ne text. Počítadlo proto umí
limit samo:
import { createWordcountPlugin, wordcountLabel } from '@nibble/plugins' const max = 160 const editor = await Nibble.create({ target: '#perex', plugins: [createWordcountPlugin({ maxChars: max })], }) const ulozit = document.querySelector('#ulozit') // Stav hned po načtení — událost přijde až při přechodu přes limit. ulozit.disabled = wordcountLabel(editor.getText(), max).over editor.on('limit', ({ over }) => { ulozit.disabled = over })
Stavový řádek ukáže „152 / 160 znaků“ a po překročení zčervená. Psát jde dál a nic se neusekne: uživatel, který přes limit vloží odstavec, ho má mít možnost zkrátit sám, ne přijít o konec. Co s překročením udělat, rozhoduje formulář.
Událost limit chodí jen při přechodu přes hranici,
ne po každém úhozu. Obsah načtený už přes limit proto žádnou nevyšle — proto ten
řádek s wordcountLabel() po create(). Počítají se
i mezery, stejně jako u maxlength: limity bývají kvůli místu, kam
text půjde, a tam zabírá místo i mezera.
Úkoly a rozbalovací bloky na webu
Editor si úkoly a rozbalovací bloky kreslí vlastním CSS. Uložené HTML ale jde na
veřejný web, kde nibble.css není — a tam je potřeba myslet na dvě
věci.
Úkoly
Plugin tasks ukládá seznam ve formátu TipTapu:
<ul data-type="taskList"> <li data-type="taskItem" data-checked="true">koupit mléko</li> <li data-type="taskItem" data-checked="false">zavolat do servisu</li> </ul>
Žádný <input> v něm není — zaškrtávátko v editoru je jen
CSS. <input> uvnitř contenteditable dělá
s kurzorem neplechu a na webu by stejně potřeboval vlastní pravidla. Bez nich
se úkoly ukážou jako obyčejné odrážky; nejjednodušší podoba:
/* Úkoly z Nibble na webu */ li[data-type="taskItem"] { list-style: none; } li[data-type="taskItem"]::before { content: '☐ '; } li[data-type="taskItem"][data-checked="true"]::before { content: '☑ '; }
Stejný formát znamená i to, že úkoly zkopírované z aplikací na TipTapu se po vložení poznají samy.
Rozbalovací blok
<details><summary> prohlížeč umí sám, CSS není potřeba.
V editoru je obsah vždy rozbalený a klik na nadpis nic nesbaluje: sbalení by
měnilo atribut open, tedy uložené HTML, jen proto, že si někdo
klikl. Jestli bude blok na webu rozbalený, se proto přepíná vědomě — tlačítkem
v plovoucí liště nad blokem.
Z TinyMCE
| TinyMCE | Nibble |
|---|---|
tinymce.init({ selector }) | await Nibble.create({ target }) |
plugins: 'link image table' | plugins: [link, image, table] — hodnoty, ne jména |
toolbar: 'bold italic | link' | attachToolbar(editor, { layout: [['bold','italic'], ['link']] }) |
menubar: 'edit view insert' | menubar: true nebo vlastní pole nabídek |
editor.getContent() | editor.getHTML() |
editor.setContent(html) | editor.setHTML(html) |
editor.triggerSave() | není potřeba — do textarey se zapisuje průběžně |
queryCommandState / Value | editor.is(name) a editor.can(name) |
tinymce.activeEditor | neexistuje — instanci si drží volající |
editor.on('change', fn) | const off = editor.on('change', fn) — on() vrací odhlášení |
Kompatibilní vrstva ani most se nedělaly schválně. V projektu, pro který Nibble vznikl, je padesát volání editoru, ale prakticky jedna konfigurace: šestnáct z nich má znak po znaku stejnou lištu, dalších deset se liší o jedno tlačítko. Most by tu byl trvalá údržba navíc kvůli kódu, který se dá nahradit jednou sdílenou funkcí.
Starý obsah
Na starý obsah se nesahá. Režim schema: 'legacy' pouští dál i to,
co by dnes nikdo nenapsal — <font>, align,
<center>, prázdné odstavce s . Nikde se
neopravuje „preventivně“.
Když chcete vědět, co v databázi vlastně je, aniž byste cokoli měnili:
const nalezy = editor.audit(html) // [{ node: 'font', reason: 'zastaralá značka' }, …]
Reálné číslo z projektu, pro který Nibble vznikl: z 142 vzorků by přísné schema přepsalo jedenáct dokumentů, kterých se přitom nikdo nedotkl. Proto se to nedělá.
Vlastní plugin
Plugin je objekt se jménem a funkcí setup. Vrácená funkce je úklid —
volá se v editor.destroy():
import type { Plugin } from '@nibble/core' export const zkratky: Plugin = { name: 'zkratky', setup(editor) { const off = editor.on('change', () => { editor.ui.setStatus('zkratky', spocitej(editor.getText())) }) return () => { off() editor.ui.setStatus('zkratky', null) } }, }
Když plugin potřebuje volby, udělá se z něj tovární funkce
(createImagePlugin({ upload })) — konfigurace se tak nemusí
protahovat globálním stavem.
Vlastní příkaz
Příkaz je jediná cesta, jak se smí změnit obsah. Díky tomu má historie co zaznamenat a lišta se má čeho ptát:
editor.commands.add( 'poznamka', // Provedení: vrátí, jestli se něco stalo. (ed) => ed.insertHTML('<aside class="pozn">…</aside>'), // Nepovinná stráž: editor.can('poznamka') a stav tlačítka. (ed) => ed.selection.getRange() !== null, )
Přesun uzlu zneplatní Range, který do něj ukazoval. Kurzor se proto
ukládá odkazem na uzel a offset (captureCaret /
restoreCaret), ne indexovou cestou. Tahle jediná věc stála za
většinou chyb v seznamech, tabulkách i citacích.
Vrstvy
| Balíček | Co v něm je |
|---|---|
@nibble/core | Model (parser, serializer, sanitizer, schema), DOM utility, výběr, historie, příkazy, vstup, registr ovládání, nastavení. |
@nibble/ui | Lišta, nabídkový pruh, plovoucí lišta, nabídka u kurzoru, dialogy, výběr barvy, mřížka, stavový řádek, ikony, CSS. |
@nibble/plugins | Odkazy, obrázky, tabulky, videa, zdrojový kód, autolink, počítadlo slov, celá obrazovka, hledání, typografie, písma, Markdown při psaní, úkoly, rozbalovací blok, lomítkové příkazy, úchyt bloku. |
Jádro o liště neví. Plugin si přihlásí tlačítko do registru a lišta ho vykreslí, pokud existuje — proto jádro běží i bez UI, což se hodí v testech a při dávkovém zpracování obsahu.
Jak se to testuje
Dvě sady, dělicí čára nevede podle vrstev, ale podle toho, co se dá mimo prohlížeč vůbec ověřit:
| Sada | Čím | Co ověřuje |
|---|---|---|
| jednotkové | vitest + linkedom | Model, schema, serializace, čištění vloženého obsahu, mapování pozic, slučování nastavení, generátor konfigurace. |
| v prohlížeči | Playwright + Chromium | Výběr a kurzor, klávesnice, vkládání ze schránky, tažení myší, rozměry a rolování, round-trip nad reálnými vzorky. |
Důvod pro tvrdou dělicí čáru: linkedom se od prohlížeče liší víc, než
by člověk čekal. Zachovává CRLF, nemá Range.setStart, nezachovává
pořadí atributů po setAttribute a styly serializuje bez mezery za
dvojtečkou. Každý z těch rozdílů už jednou způsobil, že testy svítily zeleně
u chování, které v Chrome neplatilo.
Pasti, na které jsme narazili
Tohle je seznam věcí, které vypadaly jako detail a nebyly. Když budete něco podobného stavět, ušetří vám den:
- Dialog sebere výběr. Otevření modálního okna přesune fokus a s ním i výběr. Příkaz spuštěný po zavření pak nemá na čem pracovat — a selže tiše, protože „není výběr“ je legitimní stav. Ukládá se to na jednom místě, ne v každém pluginu zvlášť.
- Testy svítí, obrazovka je rozbitá. Průhledný dialog, natvrdo nakreslené rámečky tabulek, pole přetékající o 20 px, půlka okna se zdrojovým kódem — nic z toho žádný test nechytil. Chce to se na výsledek podívat.
- Cizí CSS na stránce. Hostitelská stránka měla
pre { max-height: 260px }a uřízla polovinu okna se zdrojovým kódem. Knihovna se proti stylům stránky musí bránit sama. - Každý úhoz vytvářel nový textový uzel. Skryté od začátku; projevilo se to až u autolinku, který pak viděl jen poslední napsaný znak.
- Fokus roluje stránku. Otevření nabídky odskočilo na jiné
místo dokumentu, dokud se nezačal používat
preventScroll.
Podrobný rozbor rozhodnutí, čísel z reálných dat a architektury je v dokumentu o architektuře v repozitáři.