anicca-editor
v1.1.2
Published
Rich text editor engine - own plugin system, zero framework dependency
Maintainers
Readme
anicca-editor
A framework-agnostic rich text editor engine. The document is a plain JSON model, every edit is a
pure function over that model, and the DOM is reconciled to match. It does not use execCommand.
It has its own plugin system and no runtime dependencies. The package ships ESM, CommonJS, and
TypeScript types.
Features
Marks:
- bold, italic, underline, strikethrough, code, link, highlight
- superscript, subscript
- text color, background color, font size, font family
Blocks:
- paragraph, headings (h1 to h6), blockquote, code block
- bullet and ordered lists with Tab and Shift+Tab nesting
- tables (insert, add or remove rows and columns, merge and split cells, header row, column width)
- images: insert from file, drop, or paste; drag resize, align, and caption
- horizontal rule
- left, center, right, and justify alignment
- per-paragraph left indent with Tab and Shift+Tab
Editing:
- select all (Ctrl+A), then delete, copy, or type over the whole document
- markdown input rules (type
#for a heading,-for a list,**bold**, and so on) - Enter in a code block inserts a newline; Enter at the end of a heading starts a paragraph
- non-destructive table insert; drag a column edge or a row edge to resize
- Backspace at the start of a list item removes the list; Tab / Shift+Tab nest it
- link from anywhere - including an empty line - by typing or pasting just the URL
- select an image to resize it from a corner, align it, caption it, or delete it
- find and replace with a non-destructive highlight preview (scans top-level blocks; text inside table cells is not searched yet)
- clipboard copy, cut, and paste; paste from Word, Google Docs, and Notion keeps headings, nested lists, marks, alignment, indent, and line breaks (see "Pasting from other apps")
- image insert from a file, drop, or paste, with an optional
uploadhook - IME and composition support
- undo and redo with typing coalescing, stored as reversible patches with a depth cap
- operational transform for collaboration (block level, transport agnostic)
- an accessible toolbar (ARIA roles,
aria-pressed, roving tabindex)
What's new in 1.1.2
CHANGELOG.md is the authoritative history. The behaviour below is covered by
the jsdom test suite (src/**/*.test.ts, 313 passing) and was also checked by
hand in a browser (a manual check, not a committed end-to-end suite).
Editing now behaves like Word / Google Docs
- Select all works. Ctrl+A selects the whole document, so Ctrl+A then Delete, Ctrl+A then type, and Ctrl+A then Copy all do what you expect, even when the document ends with a table. Typing over any selection replaces it.
- Inserting a table no longer eats the line you were on. The table goes in after the caret's block (or replaces it only if it was an empty paragraph), and there is always a paragraph after it to type into.
- Table rows and columns resize with the mouse. Drag a column's right edge or a row's bottom edge; the size is stored on the model and round-trips.
- Lists. Backspace at the start of an item removes the list formatting instead of jumping the caret; Enter on an empty item leaves the list; Tab / Shift+Tab indent and outdent; switching bullet vs. numbered changes the whole list.
- Enter inside a code block inserts a newline instead of splitting the block.
- Markdown inline rules stop at the closing delimiter. After
**bold**,*italic*,`code`or~~strike~~, the text you type next is plain. - Tab indents a paragraph (and headings and blockquotes) one stop; Shift+Tab
outdents. Stored as
margin-left, so it round-trips with Word. - Blockquotes. A pasted
<blockquote><p>...</p></blockquote>keeps its text (it used to be dropped). Enter adds a line inside the quote; a blank line leaves it.
Links
Ctrl+K (or the toolbar button) opens a small editor. The URL field is first and focused, so you can insert a link from anywhere - including an empty paragraph, with nothing selected or typed - by pasting or typing just the address and pressing Enter; the address becomes the visible text. The "Text to show" field is optional. Clicking an existing link opens an edit / open / remove card; an empty URL removes the link.
Images
- Insert from the toolbar (OS file picker), by drag-and-drop, or by paste. Without
an
uploadhandler the image is stored as a base64 data URL. - The new image is selected right away. A selected image has a blue outline and four corner handles pinned to the image itself; drag any corner to resize (the width persists).
- The "Image align" toolbar control moves the image left / centre / right and is greyed out until an image is selected.
- The caption control opens a small text box under the image.
- Backspace / Delete removes a selected image; so does Backspace at the start of the line after it, or Delete at the end of the line before it.
- Dragging the image body no longer copies or moves the picture. A bare
<img>and acontenteditable="false"<figure>are draggable by default; that native drag is now cancelled, so only the resize handles drag. - Images are no longer force-centred.
Horizontal rule and highlight now have plugins
horizontal_rule and the highlight mark existed in the model but there was no
way to create them. HorizontalRulePlugin adds a toolbar button (and the ---
markdown rule); HighlightPlugin adds a <mark> toggle. Both are in allPlugins.
Removed: video embed
MediaEmbedPlugin, the embed node, insertEmbed, sanitizeEmbedSrc, the
toolbar button and its icon, and the paste handling for <iframe> are all gone.
The editor no longer embeds video; a pasted YouTube or Vimeo <iframe> is
dropped.
Package introspection (new exports)
allPlugins (an array of every plugin class), describePlugins() ({ name,
summary, toolbar } for each), editor.getToolbarItems(), and
editor.getCommandNames() let a docs page or plugin picker be generated from the
package. Every plugin class now has a static readonly summary. See
Quick start and Package exports for tooling.
Paste from Word / Google Docs / Notion
The inbound pipeline now keeps paragraph and heading structure, marks written as
inline CSS, real lists rebuilt from Word's mso-list paragraphs, separation
between adjacent lists, <br> as a soft line break, paragraph indent and
spacing, table cell borders / background / vertical align, and Word "Heading N"
paragraphs as real headings. getData() output is now safe to paste back:
setData(getData()) is a fixed point.
Model additions
table_cell.attrs.cellStyle, an optional blockStyle on paragraphs and
headings, list_item.attrs.listId (keeps adjacent lists apart), and
table_row.attrs.height. A hard line break is a \n character, not a new node
type.
Install
npm install anicca-editorQuick start
<div id="editor"></div>import {
Editor,
HistoryPlugin, BoldPlugin, ItalicPlugin, HeadingPlugin,
ListPlugin, LinkPlugin, TablePlugin, ImagePlugin,
InputRulesPlugin, FindReplacePlugin,
} from 'anicca-editor';
const editor = new Editor({
attachTo: document.getElementById('editor'),
plugins: [
HistoryPlugin, BoldPlugin, ItalicPlugin, HeadingPlugin,
ListPlugin, LinkPlugin, TablePlugin, ImagePlugin,
InputRulesPlugin, FindReplacePlugin,
],
data: '<p>Hello <strong>anicca</strong></p>',
});
editor.on('change', () => {
console.log(editor.getData());
});The editor renders a toolbar and an editable area inside the element you pass to attachTo. Only
the plugins you list are active, so you pay for what you use.
To turn everything on, pass the exported allPlugins array:
import { Editor, allPlugins } from 'anicca-editor';
new Editor({ attachTo: el, plugins: allPlugins });describePlugins() returns { name, summary, toolbar } for each entry in allPlugins, and
editor.getToolbarItems() returns the toolbar item configs the active plugins registered, so a
docs page or plugin picker can be built from the package rather than hand-maintained.
Configuration
new Editor(config) accepts:
attachTo(required): the hostHTMLElement.plugins: an array of plugin classes.data: initial content as an HTML string.schema: a customSchema(defaults todefaultSchema).placeholder: placeholder text and the editablearia-label.upload:(file: File) => Promise<string>. When set, pasted or dropped image files are sent to it and the returned URL is stored. Without it, image files fall back to a base64 data URL.historyLimit: maximum undo depth before the oldest step is dropped (default 1000).theme:'light'(default),'dark', or'auto'(follows the OS setting). Change it later witheditor.setTheme(...).themeColors: per-instance color overrides, keyed by token name in camelCase, e.g.{ accent: '#e11d48', accentBg: '#fde4ea' }. Recognized tokens:bg,toolbarBg,text,muted,icon,border,hover,accent,accentBg,codeBg,thBg,placeholder. Every editor color is a--anicca-*CSS custom property; these set them on the container. Change them later witheditor.setThemeColors(...).
Editor API
getData(): stringandsetData(html: string): voidread and write content as HTML.getDoc(): DocDataandsetDoc(doc): voidread and write the JSON model directly.execute(command, attrs?)runs a registered command, for exampleeditor.execute('bold'),editor.execute('heading', { level: 2 }),editor.execute('bulletList').undo()andredo().getVersion(): numberreturns a revision counter that increases on every committed change.applyExternalChange(change)applies a remote collaboration change without touching local undo.getPlugin(PluginClass)returns an initialized plugin instance, or null.getToolbarItems(): ToolbarItemConfig[]returns the toolbar item configs the active plugins registered (each tagged with thepluginthat added it).getCommandNames(): string[]lists every registered command name.getEditable()returns the editable element.getTheme(),setTheme(theme), andsetThemeColors(colors)read and change the theme mode and color tokens at runtime (seetheme/themeColorsin Configuration).destroy()removes the editor and its listeners.
Events (subscribe with editor.on(name, handler), remove with editor.off(name, handler)):
change: the document changed.step: a serializable operation,{ patch, version }, for persistence or transport.ready: the editable is mounted (plugins that need DOM wire up here).keydown: a key event on the editable.selectionChange: the selection moved.imageInserted: an image was inserted; the handler gets itsdoc.contentindex.
Plugins
Import a plugin class and pass it in plugins. Each one registers commands and toolbar items.
| Plugin | Adds |
| --- | --- |
| HistoryPlugin | undo and redo, with Ctrl+Z and Ctrl+Y |
| BoldPlugin, ItalicPlugin, UnderlinePlugin, StrikethroughPlugin | basic marks |
| CodePlugin | inline code mark |
| SuperscriptPlugin, SubscriptPlugin | superscript and subscript |
| HighlightPlugin | toggle a <mark> highlight (Ctrl+Shift+H) |
| TextColorPlugin | text color |
| BackgroundColorPlugin | background / highlight color (a color control) |
| FontSizePlugin | font size |
| FontFamilyPlugin | font family |
| AlignPlugin | block alignment |
| HeadingPlugin | headings and paragraph |
| BlockQuotePlugin | blockquote |
| CodeBlockPlugin | code block |
| ListPlugin | bullet and ordered lists, indent and outdent |
| LinkPlugin | links |
| ImagePlugin | insert image, select to resize / align / caption / delete |
| HorizontalRulePlugin | insert a horizontal rule (<hr>); also the --- markdown rule |
| TablePlugin | tables and table editing |
| InputRulesPlugin | markdown shortcuts while typing (no toolbar item) |
| FindReplacePlugin | find and replace API with highlight preview (no toolbar item) |
allPlugins is this whole set as an array; describePlugins() returns
{ name, summary, toolbar } for each entry, read straight from the plugin
classes and a throwaway editor.
Package exports for tooling
The package describes itself, so a docs page or a plugin picker never has to be kept in sync by hand:
allPlugins:PluginClass[]- every plugin the package ships, in a sensible toolbar order.new Editor({ attachTo, plugins: allPlugins })turns everything on.describePlugins(): { name, summary, toolbar }[]- name andstatic summaryfor each plugin, plus the toolbar items it registers. Needs a DOM (it builds a detached editor).editor.getToolbarItems()andeditor.getCommandNames()- what the active plugins registered on this instance.PluginClass- the type of a concrete plugin class (constructible, withpluginNameandsummary).topLevelBlockElementAt(editable, index)- the DOM element for adoc.contentindex (the inverse oftopLevelBlockIndexOf);editable.children[index]is not equivalent once a list is present.
Data model
The document is a DocData value: a doc node holding an array of block nodes, each with optional
attrs and content. Text nodes carry an optional marks array. Convert between HTML and the
model with serialize(doc) and parse(html). The model is the source of truth; the DOM is a
projection of it.
Pasting from other apps
Pasted HTML goes through preprocess -> normalize -> sanitize -> parse before it enters the model.
The pipeline understands the markup that Word, Google Docs, and Notion put on the clipboard.
What is preserved:
- paragraph and heading structure, even when the source wraps everything in a single
<b style="font-weight:normal">(Google Docs) or<div class="WordSection1">(Word) - bold, italic, underline, and strikethrough, whether written as tags (
<b>,<em>) or as inline CSS (font-weight:700,text-decoration:underline) - text color, background color, font size, font family, and links
- bullet and numbered lists, including nesting; Word desktop lists that come as
<p style="mso-list:...">are rebuilt into real lists - alignment from both
text-alignand the legacyalignattribute - paragraph indent, line height, and space before / after (
margin,text-indent,line-height) - tables, including per-cell borders, background, and vertical align
- Word "Heading N" paragraphs that arrive as
<p class="MsoHeadingN">become real<h1>..<h6> - soft line breaks: a
<br>inside a block becomes a\nin the same block, not a new paragraph - pasting a heading, quote, or list onto an empty line keeps its block type
Not yet handled (the readable text is kept, the feature is dropped):
- footnotes, comments, tracked changes, text boxes, shapes, equations, page columns, headers and footers, page or section breaks, tables of contents
- Google Docs writes
font-familyandfont-sizeonto every span, so those become marks on all pasted text (kept, not stripped, so a real font change still survives) - double / wavy underline collapses to a plain underline
- a multi-paragraph blockquote collapses to one block with
\nbetween the lines
See CHANGELOG.md for the bugs this replaced.
Collaboration
Changes are exposed as serializable operations through the step event and can be merged with a
block level operational transform. This is transport agnostic. You provide the channel; the library
provides convergence.
import { Collab, transform, applyChange, changeFromPatch } from 'anicca-editor';
// each peer has an id used as a global tie-break
const collab = new Collab(myPeerId);
editor.on('step', ({ patch }) => {
const change = changeFromPatch(patch);
collab.local(change);
send(change, myPeerId); // your transport
});
onReceive((remoteChange, remoteId) => {
const applied = collab.receive(remoteChange, remoteId);
editor.applyExternalChange(applied);
});Two peers that each see the other's change converge to the same document. Concurrency is resolved at block granularity: if two peers edit the same block at the same time, one edit wins by the tie break rather than the document becoming corrupt. Character level merging inside a single block and a bundled network server are not included.
Writing a plugin
A plugin registers commands and toolbar items. A command runs a model operation from
Transaction. Nothing touches the DOM directly. This example toggles an inline mark; it is
the same shape as the shipped BoldPlugin / HighlightPlugin.
import { Plugin, Command, toggleMark, rangeHasMark } from 'anicca-editor';
class SmallCapsCommand extends Command {
execute() {
this.editor.applyOp((doc, sel) => toggleMark(doc, sel, 'smallCaps'));
}
refresh() {
this.value = rangeHasMark(this.editor.getDoc(), this.editor.getSelectionModel(), 'smallCaps');
}
}
export class SmallCapsPlugin extends Plugin {
static readonly pluginName = 'SmallCaps';
static readonly summary = 'Toggle small-caps on the selected text.';
init() {
this.editor.registerCommand('smallCaps', new SmallCapsCommand(this.editor));
this.editor.registerToolbarItem({ name: 'smallCaps', label: 'SC', command: 'smallCaps' });
}
}A mark the schema does not declare is dropped on the next normalize, so a real custom mark also
needs a schema with smallCaps: {} added to marks and a serializer/parser rule. For a
command that only inserts text or changes block type, no schema change is needed.
pluginName and summary are what describePlugins() reports, so a plugin
listing in your docs stays correct without being hand-written.
The model operations (insertText, toggleMark, setBlockType, insertTable, insertImage, and
others) are exported so you can build commands on top of them.
Scripts
npm run build: bundle todistwith tsup.npm test: run the test suite with vitest.npm run test:watch: run vitest in watch mode.npm run typecheck: type check withtsc --noEmit.npm run demo: serve the repository so you can opentest/index.htmlin a browser.
Requirements
The editor runs in a browser, since it needs a DOM. Node is used only for building and testing.
Pure functions such as serialize, diffDoc, transform, and applyChange also run in Node
without a DOM.
Links
- Website and live demo: https://anicca-editor-website.pages.dev
- Source: https://github.com/Anicca-Code-Studio/anicca-editor
- npm: https://www.npmjs.com/package/anicca-editor
License
MIT. See the LICENSE file.
