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.
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.)
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', { exec() { return editor.insertHTML('<aside class="pozn">…</aside>') }, canExec() { return editor.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, 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. |
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.