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řes beforeinput a 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.

Jediná výjimka: konce řádků

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 }).

Vypsat konfiguraci

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

TinyMCENibble
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 / Valueeditor.is(name) a editor.can(name)
tinymce.activeEditorneexistuje — 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 &nbsp;. 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,
)
Pozor na živé rozsahy

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íčekCo v něm je
@nibble/coreModel (parser, serializer, sanitizer, schema), DOM utility, výběr, historie, příkazy, vstup, registr ovládání, nastavení.
@nibble/uiLišta, nabídkový pruh, plovoucí lišta, nabídka u kurzoru, dialogy, výběr barvy, mřížka, stavový řádek, ikony, CSS.
@nibble/pluginsOdkazy, 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ČímCo ověřuje
jednotkovévitest + linkedomModel, schema, serializace, čištění vloženého obsahu, mapování pozic, slučování nastavení, generátor konfigurace.
v prohlížečiPlaywright + ChromiumVý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.