Documentation

TAEditor

A zero-dependency, CDN-droppable WYSIWYG HTML editor. Inspired by CKEditor / TinyMCE, but small enough to read in one sitting.

30-second start

Two <script> tags and one line of JavaScript. That's it.

<!doctype html>
<html>
<head>
  <link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
</head>
<body>
  <textarea id="editor" name="content"></textarea>

  <script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>
  <script>TAEditor.create('#editor');</script>
</body>
</html>

Wrap the <textarea> in a <form> and the editor auto-syncs its HTML back into the textarea on submit — your server-side form handler doesn't need to change.


Recipes

Minimal toolbar (comment box)

Strip the toolbar down for short-form input like comments or chat replies.

<textarea id="comment" name="comment"></textarea>
<script>
  TAEditor.create('#comment', {
    toolbar: ['bold', 'italic', 'link', '|', 'bulletList', 'orderedList'],
    height: 120,
    placeholder: 'Write a comment…'
  });
</script>

Full editor with math

For long-form content like docs, articles, or wikis. Add KaTeX's CDN tags whenever you enable the math button.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js"></script>

<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>

<textarea id="doc" name="doc"></textarea>
<script>
  TAEditor.create('#doc', {
    height: 500,
    math: { katex: window.katex }
  });
</script>

Click the fx button, type \int_0^\infty e^{-x^2}\,dx, hit Insert. Clicking the rendered equation re-opens the dialog with the original LaTeX — no source loss.

Multiple forms on one page

Each <textarea> becomes its own independent editor and auto-syncs to whichever <form> wraps it — so several forms with rich-text bodies on one page all just work. Two ways to wire them up:

Option A — one call per textarea (by ID). Useful when each editor has different options.

<!-- Form 1: long-form post -->
<form action="/posts" method="POST">
  <input name="title" placeholder="Title" required>
  <textarea id="postBody" name="body"></textarea>
  <button type="submit">Publish post</button>
</form>

<!-- Form 2: a quick comment, somewhere else on the same page -->
<form action="/comments" method="POST">
  <textarea id="commentBody" name="body"></textarea>
  <button type="submit">Send comment</button>
</form>

<script>
  TAEditor.create('#postBody', {
    height: 400,
    toolbar: ['bold','italic','underline','|','h2','h3','|',
              'blockquote','pre','|','bulletList','orderedList','|',
              'link','image','table']
  });

  TAEditor.create('#commentBody', {
    height: 120,
    toolbar: ['bold','italic','link','|','bulletList','orderedList'],
    placeholder: 'Write a comment…'
  });
</script>

Option B — one call by class selector. Useful when every editor uses the same config. Returns an array of instances.

<!-- Add the same class to every textarea you want to upgrade. -->
<form action="/posts" method="POST">
  <textarea class="rich" name="body"></textarea>
  <button type="submit">Publish</button>
</form>

<form action="/notes" method="POST">
  <textarea class="rich" name="body"></textarea>
  <button type="submit">Save note</button>
</form>

<form action="/replies" method="POST">
  <textarea class="rich" name="body"></textarea>
  <button type="submit">Reply</button>
</form>

<script>
  const editors = TAEditor.create('.rich', { height: 220 });
  console.log(editors.length); // 3
</script>

Option C — upgrade every <textarea> on the page. Shortest possible setup; works when you want all textareas to be rich-text.

<form action="/posts" method="POST">
  <textarea name="body"></textarea>
  <button type="submit">Publish</button>
</form>

<form action="/notes" method="POST">
  <textarea name="body"></textarea>
  <button type="submit">Save note</button>
</form>

<script>
  TAEditor.create('textarea');   // every <textarea> in the document
</script>

In all three options:

Image upload to your server

Point at any endpoint that accepts multipart/form-data and replies with { "url": "…" }.

TAEditor.create('#editor', {
  image: {
    uploadUrl: '/api/upload',
    maxSize: 5 * 1024 * 1024   // 5 MB cap
  }
});

See Server-side upload endpoint for a minimal Node/Express example.

Image upload via async handler

For S3 signed URLs, custom auth headers, image processing, or any non-standard upload flow. Return the public URL string.

TAEditor.create('#editor', {
  image: {
    uploadHandler: async (file) => {
      // 1. Ask your backend for a signed upload URL.
      const { signedUrl, publicUrl } = await fetch('/api/sign-upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: file.name, type: file.type })
      }).then(r => r.json());

      // 2. PUT the file directly to S3 (or whatever).
      await fetch(signedUrl, { method: 'PUT', body: file });

      // 3. Return the URL the editor should put in <img src>.
      return publicUrl;
    }
  }
});

If you set neither uploadUrl nor uploadHandler, images become data: URIs embedded in the HTML — fine for prototypes, bad for large or many images.

Loading and saving content

const editor = TAEditor.create('#editor');

// Set the initial HTML programmatically (in addition to or instead of textarea value).
editor.setData('<h2>Welcome</h2><p>Edit me.</p>');

// Read it back any time.
const html = editor.getData();

// Save to your API.
await fetch('/api/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ content: editor.getData() })
});

setData() sanitizes the input — <script> tags, inline event handlers, and javascript: URLs are stripped before they reach the DOM.

Listening for changes (autosave)

const editor = TAEditor.create('#editor');

let timer;
editor.on('change', (html) => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    fetch('/api/autosave', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: html })
    });
  }, 800);
});

editor.on('ready', () => console.log('Editor mounted'));
editor.on('blur',  () => console.log('Editor lost focus'));

Inside a Bootstrap form

The editor's CSS is namespaced under .ta-editor so it doesn't fight with Bootstrap's utilities.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>

<div class="container py-4" style="max-width: 720px;">
  <form>
    <div class="mb-3">
      <label class="form-label">Title</label>
      <input type="text" class="form-control" name="title">
    </div>
    <div class="mb-3">
      <label class="form-label">Body</label>
      <textarea id="body" name="body" class="form-control"></textarea>
    </div>
    <button type="submit" class="btn btn-primary">Save draft</button>
  </form>
</div>

<script>TAEditor.create('#body', { height: 320 });</script>

Inside a Tailwind layout

<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://taeditor.trogon.info/dist/taeditor.min.css">
<script src="https://taeditor.trogon.info/dist/taeditor.min.js"></script>

<div class="max-w-2xl mx-auto py-8 px-4">
  <h1 class="text-xl font-semibold mb-3">New post</h1>
  <textarea id="post" name="post"></textarea>
</div>

<script>TAEditor.create('#post', { height: 360 });</script>

Mount on a div (no textarea)

You don't have to use a textarea. Any element works — TAEditor takes over its inner HTML as the initial content.

<div id="editor"><p>Pre-loaded content.</p></div>
<script>
  const ed = TAEditor.create('#editor');
  document.querySelector('#save').addEventListener('click', () => {
    console.log(ed.getData());
  });
</script>

Note that in this mode there is no form auto-sync — you read getData() yourself.

Framework compatibility

TAEditor is vanilla JS with no global side-effects beyond window.TAEditor, all CSS is namespaced under .ta-*, and the editor opts itself out of common reactive scanners. It coexists cleanly with the major libraries:

Library Status Notes
jQuery 3 The textarea is synced on every keystroke, so $('#form').serialize() and $.ajax({ data: $(form).serialize() }) return up-to-date content from any click — you don't need to wait for the submit event.
Bootstrap 5 TAEditor dialogs use z-index: 9999, sitting above Bootstrap modals (1055). You can put a TAEditor inside a Bootstrap modal and open the link/image dialog on top.
Tailwind 3 Editor CSS overrides Tailwind preflight where it would damage editor content (list bullets, inline images).
Alpine.js 3 Editor root carries x-ignore, so Alpine skips the contenteditable subtree even if users type x-data-like strings.
Vue 3 Editor root carries v-pre, so Vue's template compiler skips the subtree. Mount with v-once (or :key="...") so Vue doesn't re-render the host element.
HTMX When hx-swap removes a region containing an editor, the editor auto-detects the disconnect via MutationObserver and tears down its document listeners. No need for htmx:beforeSwap cleanup hooks.
React Mount once via useEffect + useRef and never render children inside the host element — React's virtual DOM will fight contenteditable. Call editor.destroy() in the effect's cleanup function.

See examples/compat-test.html for a single page that loads all of the above together.


Init API

TAEditor.create(target, options?) accepts any CSS selector string — anything document.querySelectorAll() understands — or a raw element. The shape of the return depends on how many elements matched:

Target What it does Returns
'#postBody' (ID selector) upgrades that one element single instance
'.editor' (class selector) upgrades every matching element array of instances
'textarea' (tag selector) upgrades every <textarea> on the page array of instances
'form.rich textarea' (any complex selector) whatever querySelectorAll returns array of instances (or single if 1)
document.getElementById('x') (raw element) upgrades that element single instance
A NodeList / Element[] upgrades each array of instances
// All of these are valid, all use the same one entry point.
TAEditor.create('#postBody');                 // by ID
TAEditor.create('.editor');                   // by class — every match
TAEditor.create('textarea');                  // every textarea on the page
TAEditor.create('form.post textarea[name=body]');   // any CSS selector
TAEditor.create(document.querySelector('#x'));      // a raw element
TAEditor.create(document.querySelectorAll('.x'));   // a NodeList

If the target is a <textarea>, the textarea is hidden, the editor mounts next to it, and the value is synced back on every change and on the surrounding <form>'s submit. Otherwise the editor mounts inside the target and replaces its inner HTML.

All options

TAEditor.create('#editor', {
  toolbar: [
    'bold', 'italic', 'underline', '|',
    'paragraph', 'h1', 'h2', 'h3', '|',
    'blockquote', 'pre', 'hr', '|',
    'bulletList', 'orderedList', '|',
    'link', 'image', 'table', 'math'
  ],
  height: 400,                              // min-height in px
  placeholder: 'Start typing…',
  sanitize: true,                           // strip <script>, on* attrs, etc.
  image: {
    uploadUrl: '/api/upload',               // POST multipart, expects { url }
    uploadHandler: async (file) => '…',     // overrides uploadUrl when provided
    maxSize: 5 * 1024 * 1024,
    accept: 'image/*'
  },
  math: { katex: window.katex }             // peer dep, pass it in
});

Instance API

const editor = TAEditor.create('#editor');

editor.getData();              // → HTML string (sanitized)
editor.setData(html);          // also sanitizes
editor.destroy();              // detach; the original <textarea> is restored

editor.on('change', html => {});
editor.on('focus',  () => {});
editor.on('blur',   () => {});
editor.on('ready',  () => {});
editor.off('change', fn);

Toolbar buttons

Pass an array of these names to toolbar, with '|' as a separator. Reorder, remove, or duplicate freely.

Name What it does
bold, italic, underline Inline formatting
paragraph Convert current block to <p>
h1, h2, h3, h4, h5, h6 Convert current block to heading
blockquote Convert current block to <blockquote>
pre Convert current block to <pre><code> (code-editor-style Tab)
hr Insert a horizontal rule
bulletList, orderedList Toggle list
link Insert or edit a link
image Insert an image (file upload or URL)
table Insert an N×M table; cells get a context menu for add/remove row/col
math Insert / edit a LaTeX equation (requires KaTeX)

Keyboard shortcuts

Key Effect
Ctrl/Cmd+B Bold
Ctrl/Cmd+I Italic
Ctrl/Cmd+U Underline
Tab (in a list) Nest the current item
Shift+Tab (in a list) Outdent the current item
Tab (in a table cell) Move to next cell (creates a new row at the end)
Shift+Tab (in a table cell) Move to previous cell
Tab (in <pre>) Insert \t, or indent every selected line
Shift+Tab (in <pre>) Outdent current line, or every selected line
Tab (elsewhere) Insert four non-breaking spaces
Enter (on empty nested list item) Outdent one level
Enter (on empty root list item) Exit list to paragraph
Enter twice (on blank line at end of <pre>) Exit code block to paragraph below

Server-side upload endpoint

The shape image.uploadUrl expects is intentionally tiny — POST multipart/form-data with one field named file, return JSON { "url": "…" }. Here's a 20-line Node + Express example:

const express = require('express');
const multer = require('multer');
const path = require('path');
const upload = multer({ dest: 'public/uploads/' });

const app = express();
app.use(express.static('public'));

app.post('/api/upload', upload.single('file'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'no file' });
  res.json({ url: '/uploads/' + path.basename(req.file.path) });
});

app.listen(3000);

Any framework works the same way — Flask, Rails, Laravel, ASP.NET, Go, etc. Read the file field, store it, return its URL.

Browser support

Modern evergreen browsers (Chrome, Firefox, Safari, Edge). Uses native Selection/Range APIs and ES2020 syntax. No IE11 support.