API dokumentace

Nibble je rozdělený do tří balíčků: @nibble/core (model, výběr, historie, příkazy, registr ovládání), @nibble/ui (lišta, nabídka, dialogy, CSS) a @nibble/plugins (odkazy, obrázky, tabulky, videa, nástroje, písma). Jádro běží i bez lišty — hodí se to v testech a při dávkovém zpracování obsahu.

Instalace

git clone https://github.com/svatekr70/nibble
npm install
npm run build

Build vytvoří ESM bundly a stylopis. Do stránky se vkládají takhle:

import { Nibble } from '@nibble/core'
import { attachToolbar } from '@nibble/ui'
import '@nibble/ui/nibble.css'
Styly se importují zvlášť

Knihovna do stránky sama nic nevkládá. Buď načtěte nibble.css, nebo si napište vlastní — všechno používá jen třídy s prefixem nb- a proměnné --nb-*.

Bez balíčkovače (CDN)

Sestavená knihovna je jeden soubor se vším, co je v core, ui i plugins. Nic se neinstaluje a nic se nesestavuje — stačí adresa:

<link rel="stylesheet"
      href="https://cdn.jsdelivr.net/gh/svatekr70/nibble@v0.5.0/dist/nibble.css">

<div id="obsah"><p>Ahoj.</p></div>

<script type="module">
  import { Nibble, attachToolbar, link, image, table }
    from 'https://cdn.jsdelivr.net/gh/svatekr70/nibble@v0.5.0/dist/nibble.min.js'

  const editor = await Nibble.create({
    target: '#obsah',
    plugins: [link, image, table],
  })
  attachToolbar(editor, { menubar: true })
</script>

Je to modul, takže <script> musí mít type="module".

Verzi v adrese si pište vždycky

@v0.5.0 je neměnné: ten soubor už se nikdy nezmění a jsDelivr ho drží v mezipaměti natrvalo. Bez verze (@main) se tahá poslední stav hlavní větve — dobré na zkoušení, nebezpečné v ostrém provozu.

Proto je dist/ v repozitáři, i když se sestavuje: jsDelivr servíruje soubory přímo z tagu a bez commitnutého buildu by ta adresa neexistovala. Že bundle sedí se zdrojem, hlídá CI — po sestavení musí být pracovní strom čistý.

Totéž leží i tady na webu (https://svatekr70.github.io/nibble/dist/nibble.min.js), jen je to vždy poslední stav hlavní větve. Jednotlivé balíčky jdou z webu načíst i zvlášť — dist/core/src/index.js, dist/ui/src/index.js, dist/plugins/src/index.js — když nechcete tahat všechno.

Rychlý start

import { Nibble } from '@nibble/core'
import { attachToolbar } from '@nibble/ui'
import { link, createImagePlugin, table } from '@nibble/plugins'
import '@nibble/ui/nibble.css'

const editor = await Nibble.create({
  target: '#obsah',
  schema: 'legacy',
  plugins: [
    link,
    createImagePlugin({
      // Bez adaptéru se obrázek vloží jako data: URL.
      upload: async (file) => {
        const body = new FormData()
        body.append('file', file)
        const res = await fetch('/api/upload', { method: 'POST', body })
        return (await res.json()).url
      },
    }),
    table,
  ],
})

attachToolbar(editor)

editor.on('change', ({ html }) => uloz(html))

Při napojení na <textarea> zůstává textarea zdrojem pravdy pro odeslání formuláře — stačí vyměnit jeden řádek a odesílání funguje dál.

Nibble.create()

Nibble.create(config: NibbleConfig): Promise<Editor>

Vrací Promise, protože pluginy smí mít asynchronní přípravu (třeba načtení seznamu písem). Existuje i pojmenovaný export create() se stejným chováním.

target je CSS selektor, prvek nebo <textarea>. U textarey se převezme její obsah, textarea se schová a při každé změně se do ní zapisuje výsledek — odeslání formuláře pak funguje beze změny v backendu.

Přehled voleb

VolbaTypVýznam
targetstring | HTMLElementKam se editor postaví. Povinné.
contentstringPočáteční HTML. Bez něj se převezme obsah cílového prvku.
schema'strict' | 'legacy'Jak přísně se hlídá tvar obsahu. Výchozí legacy.
entityEncoding'named' | 'utf8' | 'auto'Jak se zapisují entity. auto se řídí tím, co bylo v původním obsahu.
heightnumberMinimální výška plochy s obsahem v pixelech.
readonlybooleanEditor se otevře jen ke čtení. Jde přepnout přes setMode().
autofocusbooleanZaostřit obsah hned po vytvoření.
pluginsPlugin[]Hodnoty, ne jména. Bundler tak vidí, co se opravdu použije.
pastePasteOptionsChování při vkládání ze schránky — viz níž.
allowedEmbedHostsstring[]Hostitelé, jejichž <iframe> smí být v obsahu.

Schema

Schema říká, co smí kde být. Dva režimy:

RežimChování
legacyPouští dál i značky a atributy, které by dnes nikdo nenapsal — <font>, align, <center>. Pro obsah, který vznikal roky v jiných nástrojích.
strictSémantické značky a inline styly. Pro nový obsah a nové projekty.

Srovnávání tvaru je líné: běží jen na tom bloku, do kterého uživatel opravdu zasáhl. Načtený dokument se nikdy nepřepisuje jen proto, že by se dnes zapsal jinak.

editor.audit(html?: string): SchemaViolation[]

Zkontroluje obsah proti schématu, aniž by cokoli měnil. Bez argumentu kontroluje aktuální obsah editoru. Hodí se na dávkovou revizi databáze před migrací.

Vkládání

await Nibble.create({
  target: '#obsah',
  paste: {
    keepStyles: ['color', 'background-color', 'text-align'],
    markdown: true,
  },
})
VolbaTypVýznam
keepStylesstring[]Které vlastnosti stylu si z vloženého obsahu nechat.
allowedTagsSet<string>Povolené značky. Co v seznamu není, se rozbalí a text zůstane.
markdownbooleanPřevádět Markdown, když přijde čistý text. Výchozí true.

Zdroj vloženého obsahu se rozpoznává a čistí se podle něj. Událost pasteclean nese, co se stalo:

editor.on('pasteclean', ({ source, removed }) => {
  // source: 'word' | 'gdocs' | 'libreoffice' | 'html' | 'text'
  console.log(source, removed)
})

Vložené rámce

<iframe> je jediná značka, která do stránky pouští cizí kód. Nibble ho proto neřeší seznamem zakázaného, ale seznamem povoleného — co v něm není, se zahodí:

await Nibble.create({
  target: '#obsah',
  // Prázdné pole zakáže rámce úplně.
  allowedEmbedHosts: ['youtube.com', 'youtube-nocookie.com', 'vimeo.com'],
})

Bez uvedení platí DEFAULT_EMBED_HOSTS — běžné videoslužby. Kontrola porovnává hostitele včetně poddomén, ne podřetězec v URL.

Obsah

editor.getHTML(): string

Serializovaný obsah. Bloky, kterých se nikdo nedotkl, se vypíšou v původním znění — proto je výsledek po pouhém načtení znak po znaku shodný se vstupem.

editor.setHTML(html: string, entityEncoding?: EntityEncoding): void

Nahradí obsah. Zahodí historii oblastí a založí nové snímky, takže záruka round-tripu platí i pro nově nastavený obsah.

editor.getText(): string
editor.insertHTML(html: string): boolean

Vloží HTML na pozici kurzoru. Obsah projde sanitizací, takže je bezpečné sem poslat i něco, co přišlo zvenčí.

editor.isDirty(): boolean
editor.getDirtyBlocks(): Node[]

Které uzly nejvyšší úrovně se od načtení změnily. Užitečné při ladění i v testech — demo na tomhle webu jimi ukazuje, kolik bloků úprava opravdu zasáhla.

Stav a služby

VlastnostK čemu je
editor.rootPrvek s contenteditable. Sem míří všechno, co se dotýká DOMu.
editor.documentDokument, ve kterém editor žije. Příkazy z něj vytvářejí uzly.
editor.selectiongetRange(), setRange(), collapseTo(), save(), restore().
editor.schemaKontrola tvaru obsahu.
editor.historyundo(), redo(), canUndo().
editor.formatterapply(), remove(), toggle(), match(), queryStyle().
editor.commandsRegistr příkazů: add(), has().
editor.uiRegistr ovládání, dialogy, hlášky, stavový řádek.
editor.pluginsJména načtených pluginů v pořadí, ve kterém se nastavovaly.

Akce a dotazy

editor.exec('link', { href })   // → boolean (šlo to?)
editor.can('link')              // smí se to teď? → stav tlačítka
editor.is('bold')               // je výběr tučný? → aktivní tlačítko
editor.focus()
editor.setMode('design')        // 'design' | 'readonly'
editor.destroy()

Proti TinyMCE zmizel globální singleton: žádné nibble.activeEditor. Instanci si drží ten, kdo ji vytvořil.

Pomocné dotazy na aktuální blok používá lišta a hodí se i vlastnímu ovládání:

editor.getBlockTag()      // 'p' | 'h2' | 'blockquote' | null
editor.isInList()         // 'ul' | 'ol' | null
editor.getAlignment()     // 'left' | 'center' | 'right' | 'justify' | ''
editor.currentBlock()     // Element | null

Události

on() vrací funkci pro odhlášení — nemusíte si držet referenci na posluchače:

const off = editor.on('change', ({ html }) => uloz(html))
off()
UdálostKdy nastane
changeObsah se změnil a změna se uzavřela do kroku historie. Nese { html }.
inputKaždý zásah do obsahu, ještě před uzavřením kroku.
selectionchangeKurzor nebo výběr se pohnul. Podle toho se sesynchronizuje lišta.
setcontentObsah byl nahrazen přes setHTML().
modechangePřepnul se režim (design / readonly).
pastecleanDoběhlo čištění vloženého obsahu. Nese { source, removed, html }.
schemaviolationDo obsahu se dostalo něco, co schema nepovoluje.

attachToolbar()

attachToolbar(editor: Editor, options?: AttachOptions): EditorUI
const ui = attachToolbar(editor, {
  layout: [
    ['undo', 'redo'],
    ['bold', 'italic', 'underline'],
    ['link', 'image', 'table'],
  ],
  layoutBottom: [['code', 'fullscreen']],
  menubar: true,
  prefsKey: 'clanky',
  prefs: { height: '500px' },
  settings: true,
})
VolbaTypVýznam
layoutstring[][]Skupiny tlačítek v prvním řádku lišty.
layoutBottomstring[][]Skupiny, které mají začít ve druhém řádku.
menubarboolean | MenubarMenu[]Nabídkový pruh nad lištou. true použije výchozí rozvržení.
prefsKeystringKlíč pro uložení nastavení uživatele (nibble:prefs:<id>).
prefsPartial<EditorPrefs>Výchozí hodnoty nastavení. Uživatel je může přebít.
settingsbooleanOzubené kolo vpravo nahoře. Jediné místo, kde se dá schovat.

Vrácené EditorUI nese toolbar, menubar, contextToolbar, prefs a destroy().

Výchozí rozvržení obsahuje: undo redo · blocks fontfamily fontsize lineheight · bold italic underline strike · forecolor backcolor · bullist numlist · alignleft aligncenter alignright alignjustify · link image media table emoji charmap · blockquote hr removeformat · code searchreplace fullscreen. Tlačítko, jehož plugin není načtený, se v liště prostě neobjeví.

Registr ovládání

Plugin si přihlásí ovládací prvek a lišta ho vykreslí. Žádné další zařizování není potřeba — a jádro nemusí o liště vědět.

editor.ui.addButton('zvyraznit', {
  icon: 'forecolor',
  tooltip: 'Zvýraznit',
  shortcut: 'Ctrl+H',
  onAction: () => editor.exec('zvyraznit'),
  isActive: () => editor.is('zvyraznit'),
  isEnabled: () => editor.can('zvyraznit'),
})
MetodaVykreslí se jako
addButton(name, spec)Tlačítko, případně přepínač.
addSelect(name, spec)Rozbalovací seznam (druh bloku, písmo, velikost).
addColor(name, spec)Výběr barvy s kolečkem a paletami.
addMenu(name, spec)Tlačítko s nabídkou položek.
addGrid(name, spec)Mřížka — používá ji výběr rozměru tabulky.
addContextToolbar(name, spec)Plovoucí lišta nad vybraným prvkem.

editor.ui.get(name) vrátí přihlášený prvek, names() seznam všech. Toho využívá i nastavení, aby nenabízelo tlačítka, jejichž plugin se nenačetl.

Dialogy a hlášky

editor.ui.dialog(spec: DialogSpec): Promise<Record<string, unknown> | null>

Dialog se popíše polem polí; vykreslení, ozvučení i validaci obstará lišta. Vrací null, když uživatel dialog zavřel.

const data = await editor.ui.dialog({
  title: 'Vložit odkaz',
  fields: [
    { type: 'url', name: 'href', label: 'Adresa', required: true },
    { type: 'text', name: 'text', label: 'Text odkazu' },
    { type: 'checkbox', name: 'blank', label: 'Otevřít v novém okně' },
  ],
  submit: 'Vložit',
})

if (data) editor.exec('link', data)

Typy polí: text, url, number, select, checkbox, color, textarea, code (velké okno se zvýrazněnou syntaxí) a dvojice emoji / chars (mřížka pojmenovaných znaků s kategoriemi a hledáním; seznam se předává v field.glyphs, liší se jen sazbou políček).

Výběr přežije otevření dialogu

Otevření modálního okna přesune fokus a s ním i výběr. ui.dialog() si ho proto uloží a před spuštěním příkazu vrátí zpátky — jednou pro všechny dialogy, aby se na to nedalo zapomenout v jednotlivých pluginech.

editor.ui.notify(text: string, level?: 'info' | 'warn' | 'error'): void
editor.ui.setStatus(name: string, text: string | null): void

Stav se ukládá i tehdy, když stavový řádek zrovna neexistuje. Když si ho uživatel zapne, hodnoty se do něj přehrají — plugin se nemusí spouštět znovu.

Nastavení uživatele

Rozvržení lišty navrhne programátor, ale poslední slovo má ten, kdo u editoru sedí. Nastavení se ukládá do localStorage a přebíjí konfiguraci.

ui.prefs.get()                     // aktuální nastavení
ui.prefs.set({ sticky: false })      // ovládání se postaví znovu
ui.prefs.layoutFor('top')            // co se vykreslí v prvním řádku
ui.prefs.reset()                   // zpátky podle konfigurace
ui.prefs.onChange(fn)               // vrací funkci pro odhlášení
PoložkaTypVýchozíVýznam
widthstring''Šířka editoru. Prázdné = podle obsahu.
heightstring''Výška plochy s obsahem. Zadaná výška zapne rolování uvnitř.
menubarbooleanfalseNabídkový pruh.
stickybooleantrueLišta se drží u horního okraje.
statusbarbooleantrueInformační řádek s cestou k prvku.
resizablebooleantrueZměna velikosti tažením za pravý dolní roh.
groupsPrefGroup[]z layoutSkupiny tlačítek, jejich pořadí, zapnutí a řádek.
Uloženému nastavení se nevěří slepě

Sedí v prohlížeči uživatele a může být staré půl roku. Slučuje se proto s aktuální konfigurací: prvek, který uživatel zná, si drží pořadí i zapnutí, prvek, který mezitím přibyl, se doplní na konec své skupiny. Bez toho by upgrade editoru znamenal, že nové tlačítko nikdo nikdy neuvidí.

Dialog nastavení umí vypsat hotovou konfiguraci podle aktuálního stavu — 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í.

Dodávané pluginy

PluginPřináší
linkVložení a úprava odkazu, plovoucí lišta nad odkazem, Ctrl+K.
image / createImagePlugin({ upload })Obrázky. Bez adaptéru jako data: URL, s adaptérem nahrání na server.
tableTabulky: mřížkový model, řádky a sloupce, slučování, tažení šířky sloupce, vlastnosti tabulky i řádku.
mediaVidea přes <iframe> z povolených hostitelů.
emoji / createEmojiPlugin({ emoji })Emotikony: mřížka s kategoriemi a hledáním, česky. Vkládá znak, ne obrázek.
charmap / createCharmapPlugin({ chars })Mapa speciálních znaků v téže mřížce: interpunkce, mezery, měny, matematika, zlomky, šipky, diakritika, řecká abeceda, symboly. Hledá i podle U+00A9.
codeEditace zdrojového HTML se zvýrazněnou syntaxí a pamatováním pozice kurzoru.
autolinkZ napsané adresy udělá odkaz po mezeře nebo Enteru.
wordcountPočet slov a znaků do stavového řádku.
fullscreenEditor přes celou obrazovku, F11.
searchreplaceHledání a nahrazování v textu, ne ve značkách. Ctrl+F.
typographyČeské uvozovky, výpustka, pomlčka — při psaní.
fontsRodina a velikost písma včetně webových písem z Google Fonts.

Vlastní plugin

Plugin je objekt se jménem a funkcí setup. Ta dostane celý editor — jakýkoli mezistupeň by musel jen předávat dál. Vrácená funkce je úklid; volá se v destroy(), takže inicializace i úklid stojí na jednom místě.

import type { Plugin } from '@nibble/core'

export const poznamka: Plugin = {
  name: 'poznamka',

  setup(editor) {
    editor.commands.add('poznamka', {
      exec() {
        return editor.insertHTML('<aside class="pozn">…</aside>')
      },
    })

    editor.ui.addButton('poznamka', {
      icon: 'blockquote',
      tooltip: 'Poznámka na okraj',
      onAction: () => editor.exec('poznamka'),
    })

    const off = editor.on('change', spocitejPoznamky)
    return () => { off() }
  },
}

Pluginy s volbami se dělají tovární funkcí — createImagePlugin(), createTablePlugin(), createMediaPlugin(), createFontPlugin() — aby se konfigurace nemusela protahovat globálním stavem.

Serializace a round-trip

Parser si u každé oblasti nejvyšší úrovně pamatuje původní zdroj a snímek DOMu. Serializace pak vypíše zdroj doslova — dokud se DOM od snímku neliší. Znamená to mimo jiné, že:

  • entity zůstanou tak, jak byly (&nbsp; se nepřepíše na mezeru a naopak),
  • pořadí a zápis atributů se nemění, včetně uvozovek,
  • komentáře, podmíněné komentáře i podivné mezery přežijí,
  • úprava jednoho odstavce nepřeformátuje zbytek dokumentu.

Jediná změna, která se dělá vždy, je převod CRLF na LF. Prohlížeč ho udělá sám v textarea.value i při parsování HTML — slib, že se zachová, by se nedal splnit. Pomocná funkce normalizeNewlines() je veřejná, aby šlo porovnávat se stejným základem.

serializeNode(node: Node, options?: SerializeOptions): string
sanitize(html: string, options?: SanitizeOptions): SanitizeResult

Obojí je dostupné samostatně — hodí se při dávkovém zpracování obsahu mimo editor.

CSS proměnné

Žádný generátor motivů, jen proměnné. Sedí na :root, protože dialogy se vkládají do <body> — mimo obal editoru — a jinak by je nezdědily.

ProměnnáSvětláK čemu je
--nb-bg#ffffffPozadí lišty, dialogů a plochy.
--nb-fg#16191cZákladní barva textu.
--nb-muted#6b7280Popisky, nápovědy, stavový řádek.
--nb-border#dcdfe3Rámečky a oddělovače.
--nb-hover#f1f3f5Podbarvení pod myší.
--nb-active#e3ecfbZapnuté tlačítko.
--nb-accent#1f5f5bPřízvučná barva, fokus, hlavní tlačítko.
--nb-radius6pxZaoblení rohů.
--nb-selectionprůsvitnáOznačený text ve zdrojovém kódu. Musí zůstat průsvitná, jinak zmizí obarvená syntaxe.

Tmavý režim se řídí prefers-color-scheme a přepisuje stejné proměnné. Kdo chce vlastní vzhled, přebije je ve svém CSS.

Klávesové zkratky

ZkratkaAkce
Ctrl+B / I / UTučně, kurzíva, podtržení.
Ctrl+ZZpět.
Ctrl+Shift+ZZnovu.
Ctrl+KVložit nebo upravit odkaz.
Ctrl+FHledat a nahradit.
F11Celá obrazovka.
Tab / Shift+TabV seznamu zanoří a vysune položku, v tabulce přejde na další buňku.
Enter / Shift+EnterNový blok / zalomení řádku.

Zkratky pluginů se objevují i v nabídkovém pruhu vedle názvu položky, takže se dají odkoukat bez čtení dokumentace.