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 and either type LaTeX directly (e.g. \int_0^\infty e^{-x^2}\,dx) or pick from the built-in library of 200+ equations across 14 topics — algebra, trig, calculus, linear algebra, statistics, logic, physics, chemistry, and Greek symbols — using the search box. A live preview renders as you type. 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.


Sticky toolbar

The toolbar is position: sticky inside the editor, so when the editor grows past the viewport the toolbar pins itself to the top while you scroll the content underneath. No JS, no IntersectionObserver — just CSS.

If your page already has a fixed/sticky bar at the top (site nav, app shell header, banner), the editor toolbar will pin behind it by default. Tell the editor how much vertical space to skip by setting --ta-toolbar-offset on any ancestor (typically body or :root):

/* Plain CSS — set to your own header's height */
body { --ta-toolbar-offset: 64px; }
/* Bootstrap 5 with a .navbar.fixed-top */
body { --ta-toolbar-offset: 56px; }
/* Tailwind app with a sticky h-16 header */
body { --ta-toolbar-offset: 4rem; } /* = theme('spacing.16') */

The variable can also be scoped to a specific editor (e.g. one inside a modal where there's no offset, but another in the main page where there is), or swapped inside a media query if your header is responsive:

.modal-body { --ta-toolbar-offset: 0px; }

@media (max-width: 768px) {
  body { --ta-toolbar-offset: 48px; }
}

Gotcha — overflow: hidden breaks sticky. position: sticky requires that no ancestor between the toolbar and the page's scroll container has overflow: hidden | auto | scroll. If you wrap the editor in <div style="overflow: hidden"> (common for rounded-corner clipping), the toolbar will silently not stick — no error, no warning. Fix it on the host side:

Inside a fixed-height scroll container. If you mount the editor inside an element that has its own scroll (e.g. a split pane or a fixed-height card), position: sticky pins to that container's top — which is what you want. Don't set --ta-toolbar-offset in that case; your host's outer chrome isn't in the same scroll context.


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: [
    'blockStyle', '|',
    'bold', 'italic', 'underline', 'strikethrough', 'code', 'highlight', 'textColor', 'superscript', 'subscript', '|',
    'alignLeft', 'alignCenter', 'alignRight', 'alignJustify', '|',
    'blockquote', 'pre', 'hr', '|',
    'bulletList', 'orderedList', '|',
    'link', 'image', 'table', 'math', '|',
    'clearFormat'
  ],
  height: 400,                              // min-height in px
  placeholder: 'Start typing…',
  sanitize: true,                           // strip <script>, on* attrs, etc.
  branding: true,                           // show the "Powered by TAEditor" footer link
  image: {
    uploadUrl: '/api/upload',               // POST multipart, expects { url }
    uploadHandler: async (file) => '…',     // overrides uploadUrl when provided
    maxSize: 1024 * 1024,                   // 1 MB. Larger files are auto-compressed (dimensions + JPEG quality) to fit, never rejected
    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
blockStyle Dropdown that switches between Paragraph, Heading (<h3>) and Sub heading (<h4>) — label tracks the current block
bold, italic, underline, strikethrough Inline formatting (<strong>, <em>, <u>, <s>)
code Toggle inline <code> on the selection
highlight Toggle <mark> on the selection — renders as a light chip
textColor Open a swatch palette (12 presets + custom picker) and color the selection; includes a "Remove" option
superscript, subscript Toggle <sup> / <sub> on the selection
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
alignLeft, alignCenter, alignRight, alignJustify Set text-alignment on the selected block(s) — also positions images, since they're aligned via their parent block
link Insert or edit a link
image Insert an image (file upload or URL); inserted images can be drag-resized from the corners and cropped in place
table Insert an N×M table; cells get a context menu for add/remove row/col
math Insert / edit a LaTeX equation with live preview and a searchable 200+ preset library (requires KaTeX)
clearFormat Strip inline formatting (bold, italic, sup/sub, links, etc.) from the selection — block-level style and math embeds are preserved

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
* / - / 1. then Enter Markdown shortcut — start a bullet or ordered list
Ctrl/Cmd+Enter (in the math dialog) Insert the equation
Escape (in any dialog) Cancel

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.