📚 CBTEditor Documentation
CBTEditor is a free, open-source (MIT) WYSIWYG editor with
visual math and chemistry editing. Built on TipTap (ProseMirror) +
MathLive. No framework required — drop a <script>
tag into any HTML page, PHP template, or CMS.
Features
- Rich text — headings (H1–H3), bold, italic, bullet/numbered lists, tables
- Visual math (📐) — tap-to-build equations using MathLive — fractions, superscripts, symbols, no LaTeX typing required
- Visual chemistry (⚗️) — click-to-build formulas — subscripts, charges, reaction arrows
- Save/Load — documents stored as JSON, fully serializable
- Export — HTML, LaTeX (math), structured JSON (chemistry)
- Undo/Redo — full history support
- Framework-agnostic — one
.jsfile, one.cssfile, works anywhere
cbt-editor.min.js (~1.1 MB),
cbt-editor.min.css (~6 KB), and a fonts/ directory for
MathLive's KaTeX math fonts (~2 MB). That's it.
Quick Start
Option A: Plain <script> tag (no build tools)
Copy three things into your project and add 5 lines of HTML:
<link rel="stylesheet" href="cbt-editor.min.css">
<script src="cbt-editor.min.js"></script>
<div id="editor"></div>
<script>
CBTEditor.setMathLiveFontsDir('./fonts');
const editor = new CBTEditor({ target: document.getElementById('editor') });
</script>
Works in plain HTML, PHP, WordPress, any CMS. Zero build step.
Option B: npm (if you use a bundler)
npm install cbt-editor
import CBTEditor from 'cbt-editor';
const editor = new CBTEditor({ target: document.getElementById('editor') });
Installation
Option A: Plain <script> tag — No build tools
This is the primary path. Copy these into your project:
your-project/
├── cbt-editor.min.js ← the editor bundle
├── cbt-editor.min.css ← styles
└── fonts/ ← MathLive math fonts (required for rendering)
Then in any HTML or PHP page:
<link rel="stylesheet" href="cbt-editor.min.css">
<script src="cbt-editor.min.js"></script>
<div id="editor"></div>
<script>
CBTEditor.setMathLiveFontsDir('./fonts');
new CBTEditor({ target: document.getElementById('editor') });
</script>
Using in PHP / WordPress
<link rel="stylesheet"
href="<?php echo get_template_directory_uri(); ?>/cbt-editor/cbt-editor.min.css">
<script src="<?php echo get_template_directory_uri(); ?>/cbt-editor/cbt-editor.min.js"></script>
<div id="my-editor"></div>
<script>
CBTEditor.setMathLiveFontsDir(
'<?php echo get_template_directory_uri(); ?>/cbt-editor/fonts'
);
new CBTEditor({ target: document.getElementById('my-editor') });
</script>
Option B: npm + bundler
npm install cbt-editor
import CBTEditor from 'cbt-editor';
import 'cbt-editor/dist/cbt-editor.min.css';
const editor = new CBTEditor({
target: document.getElementById('editor')
});
Note: even with npm, you still need the fonts/ directory accessible at a URL. Copy it from node_modules/cbt-editor/dist/fonts/ to your public folder.
Usage & API Reference
Creating an Editor
const editor = new CBTEditor({
target: document.getElementById('editor'), // REQUIRED
initialContent: myDocumentJSON, // optional
onSave: (json) => { /* handle save */ }, // optional
readOnly: false // optional
});
Options
| Option | Type | Default | Description |
|---|---|---|---|
target | HTMLElement | required | The DOM element to mount the editor into |
initialContent | Object | null | A TipTap-compatible JSON document to load on init |
onSave | Function | null | Called with document JSON when user clicks Save |
readOnly | boolean | false | Whether the editor is read-only |
Methods
editor.getJSON() — Returns the full document as a TipTap-compatible JSON object.
const doc = editor.getJSON();
editor.getHTML() — Returns the document as an HTML string.
const html = editor.getHTML();
editor.loadJSON(json) — Loads a document from JSON (replaces current content).
editor.loadJSON(savedDocument);
editor.setReadOnly(bool) — Toggles read-only mode.
editor.setReadOnly(true); // disable editing
editor.setReadOnly(false); // enable editing
editor.destroy() — Destroys the editor and cleans up the DOM.
editor.destroy();
editor.on(event, callback) — Register an event listener (supports 'save').
editor.on('save', (json) => {
fetch('/api/documents', { method: 'POST', body: JSON.stringify(json) });
});
Static Method
CBTEditor.setMathLiveFontsDir(dir) — Sets the directory where MathLive looks for its KaTeX font files. Call once before creating any editor.
CBTEditor.setMathLiveFontsDir('./dist/fonts');
Export & Import
editor.exportToHTML()
Returns the document as an HTML string, suitable for PDF generation or web display.
const html = editor.exportToHTML();
editor.exportMathAsLatex()
Extracts LaTeX strings from all MathBlocks in the document. Returns an array of { latex: string } objects.
const mathBlocks = editor.exportMathAsLatex();
// [{ latex: "x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}" }]
mathBlocks.forEach(m => console.log(m.latex));
editor.exportChemAsJSON()
Extracts the chemistry formula tree from all ChemBlocks. Returns an array of parsed formula objects.
const chemBlocks = editor.exportChemAsJSON();
// [{ terms: [{ coefficient: 2, groups: [...] }, ...] }]
chemBlocks.forEach(c => console.log(c.terms));
editor.importFromJSON(json)
Imports a document from saved JSON. Validates the document has a type field. Returns true on success.
const ok = editor.importFromJSON(savedDocument);
if (!ok) console.error('Invalid document format');
importFromJSON() (and passing initialContent)
reconstructs the full ProseMirror document tree. Math blocks and chemistry blocks
reappear as fully editable interactive blocks — never flattened to static
text or images. As long as the JSON includes the correct "type": "mathBlock"
/ "type": "chemBlock" node types with their attributes intact, the blocks
will be click-to-edit.
Edit / Update Pattern (full lifecycle)
This is the most common pattern: load a saved document, edit it, save back:
// 1. Fetch saved document from your backend
const response = await fetch('/api/documents/123');
const savedDoc = await response.json();
// 2. Load it into the editor (math & chem blocks are EDITABLE)
const editor = new CBTEditor({
target: document.getElementById('editor'),
initialContent: savedDoc
});
// 3. User edits... then saves
editor.on('save', async (updatedJson) => {
await fetch('/api/documents/123', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document: updatedJson })
});
console.log('✅ Saved!');
});
Round-Trip Test
// Confidence check: save, clear, reload, verify blocks still editable
const saved = editor.getJSON();
editor.loadJSON({ type: 'doc', content: [] }); // clear
editor.importFromJSON(saved); // reload
const math = editor.exportMathAsLatex();
const chem = editor.exportChemAsJSON();
console.assert(math.length > 0, 'Math blocks survived');
console.assert(chem.length > 0, 'Chem blocks survived');
console.log('✅ Round-trip passed');
Architecture
CBTEditor is built in three independent layers, following a shell + plugins pattern:
CBTEditor (public API)
│
├── Shell: TipTap (ProseMirror) ← document model, toolbar, formatting
│ MIT license
│
├── Custom Node: MathBlock ← visual math editing
│ wraps MathLive (<math-field>) ← MIT license
│ stores content as LaTeX string
│
└── Custom Node: ChemBlock ← visual chemistry formula editing
custom tree-based model
stores formula tree as JSON string
1. Shell (TipTap)
TipTap (built on ProseMirror) handles the hard parts: document model, state management, selection/cursor, keyboard handling, undo/redo history, JSON/HTML serialization, table editing, and an extension system. We use @tiptap/core directly — not @tiptap/react — to keep the package framework-agnostic. The toolbar is built with plain DOM elements.
2. MathBlock
A custom TipTap Node that uses the NodeView API to render a MathLive <math-field> web component at the node's position in the document. LaTeX is stored as a node attribute (attrs.latex). Clicking the display toggles the math-field into edit mode. MathLive provides visual fraction/superscript/subscript editing, tap-to-build virtual keyboard, and LaTeX/MathJSON import/export.
3. ChemBlock
A custom TipTap Node with its own visual editor built in plain HTML/CSS. The formula is stored as a JSON tree in attrs.formula:
{
"terms": [
{ "coefficient": 2, "groups": [
{ "symbol": "H", "subscript": "2", "superscript": "" }
]},
{ "operator": "+" },
{ "coefficient": 1, "groups": [
{ "symbol": "O", "subscript": "2", "superscript": "" }
]},
{ "operator": "→" },
{ "coefficient": 2, "groups": [
{ "symbol": "H", "subscript": "2", "superscript": "" },
{ "symbol": "O", "subscript": "1", "superscript": "" }
]}
]
}
Extending with Custom Nodes
Follow this pattern to add a new block type (e.g. GraphBlock, CodeBlock, DiagramBlock):
Step 1: Create the Node file
Create src/nodes/GraphBlock.js:
import { Node } from '@tiptap/core';
const GraphBlock = Node.create({
name: 'graphBlock', // Must be unique
group: 'block',
atom: true, // Atomic unit (no nested text editing)
inline: false,
draggable: true,
addAttributes() {
return {
data: {
default: '{}',
parseHTML: (el) => el.getAttribute('data-graph') || '{}',
renderHTML: (attrs) => ({ 'data-graph': attrs.data }),
},
};
},
parseHTML() {
return [{ tag: 'div[data-graph-block]' }];
},
renderHTML({ node }) {
return ['div', {
'data-graph-block': '',
'data-graph': node.attrs.data,
class: 'cbt-graph-block',
}, ['span', {}, '📊 Graph']];
},
addNodeView() {
return ({ node, getPos, editor }) => {
const wrapper = document.createElement('div');
wrapper.className = 'cbt-graph-block';
wrapper.setAttribute('data-graph-block', '');
wrapper.contentEditable = 'false'; // ← IMPORTANT
// Build your custom UI here...
const canvas = document.createElement('canvas');
canvas.width = 400; canvas.height = 200;
wrapper.appendChild(canvas);
return {
dom: wrapper,
update(updatedNode) {
if (updatedNode.attrs.data !== node.attrs.data) {
// Re-render
}
return true;
},
ignoreMutation(mutation) {
return wrapper.contains(mutation.target);
},
};
};
},
});
Step 2: Register in CBTEditor.js
import GraphBlock from './nodes/GraphBlock.js';
// Inside _createTipTapEditor():
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
Table.configure({ resizable: true }),
TableRow, TableCell, TableHeader,
MathBlock,
ChemBlock,
GraphBlock, // ← add here
],
Step 3: Add Toolbar Button
In _toolbarHTML(), add your button with data-action="insertGraphBlock", then handle it in _bindToolbarEvents():
case 'insertGraphBlock':
this._editor.chain().focus().insertContent({
type: 'graphBlock', attrs: { data: '{}' }
}).run();
break;
Step 4: Test Round-Tripping
editor.getJSON(); // → should include your graphBlock
editor.importFromJSON(savedJson); // → should reconstruct it as editable
The key rules:
- Always set
wrapper.contentEditable = 'false'in your NodeView - Store all data in node attributes (they serialize to JSON automatically)
- Use
ignoreMutationto tell ProseMirror your DOM is externally managed - Use
editor.view.dispatch(editor.state.tr.setNodeAttribute(pos, ...))to update attributes from your editor
Updating CBTEditor
Versioning
CBTEditor follows Semantic Versioning (MAJOR.MINOR.PATCH):
- MAJOR: Breaking changes to the public API or document format
- MINOR: New features — backward compatible
- PATCH: Bug fixes — fully backward compatible
Release Checklist
- Test backward compatibility — load an old document, save it, reload it. Verify math/chem blocks are still editable.
- Check the public API — all documented methods still work
- Test both distribution paths — UMD (script tag) and npm (ESM import)
- Build and check sizes —
npm run build - Update CHANGELOG.md
- Bump version in package.json
- Run
bash build-release.sh - Upload
public-release/to your hosting - Tag the release in git:
git tag v1.2.0 && git push --tags
Backward Compatibility Test Script
const oldDoc = { /* JSON from a previous version */ };
editor.importFromJSON(oldDoc);
const mathBlocks = editor.exportMathAsLatex();
console.assert(mathBlocks.length > 0, 'Math blocks should survive');
const chemBlocks = editor.exportChemAsJSON();
console.assert(chemBlocks.length > 0, 'Chem blocks should survive');
const saved = editor.getJSON();
const reloaded = editor.importFromJSON(saved);
console.assert(reloaded, 'Round-trip should succeed');
Contributing
Getting Started
git clone <repo-url>
cd cbt-editor
npm install
npm run build
open test/index.html # verify it works
Development Workflow
- Make changes in
src/ - Rebuild:
npm run build - Test by opening
test/index.htmlin a browser - Check browser console for errors
Coding Style
- Vanilla JavaScript (ES modules). No TypeScript, no React, no JSX.
- Use
classsyntax for components, factory functions for TipTap nodes. - CSS uses the
cbt-prefix for all class names. - DOM manipulation uses native APIs — no jQuery.
- NodeViews follow the MathBlock/ChemBlock pattern (see Extending).
Before Submitting
- ✅
npm run buildsucceeds - ✅
test/index.htmlworks in a browser - ✅ Math blocks insert, edit, and save correctly
- ✅ Chemistry blocks insert, edit, and save correctly
- ✅ Export methods return expected results
- ✅ Round-trip test passes (save → clear → reload → content intact)
- ✅ No new console errors
License
By contributing, you agree that your contributions will be licensed under the MIT License.