npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

anicca-editor

v1.1.2

Published

Rich text editor engine - own plugin system, zero framework dependency

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 upload hook
  • 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 upload handler 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 a contenteditable="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-editor

Quick 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 host HTMLElement.
  • plugins: an array of plugin classes.
  • data: initial content as an HTML string.
  • schema: a custom Schema (defaults to defaultSchema).
  • placeholder: placeholder text and the editable aria-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 with editor.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 with editor.setThemeColors(...).

Editor API

  • getData(): string and setData(html: string): void read and write content as HTML.
  • getDoc(): DocData and setDoc(doc): void read and write the JSON model directly.
  • execute(command, attrs?) runs a registered command, for example editor.execute('bold'), editor.execute('heading', { level: 2 }), editor.execute('bulletList').
  • undo() and redo().
  • getVersion(): number returns 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 the plugin that added it).
  • getCommandNames(): string[] lists every registered command name.
  • getEditable() returns the editable element.
  • getTheme(), setTheme(theme), and setThemeColors(colors) read and change the theme mode and color tokens at runtime (see theme / themeColors in 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 its doc.content index.

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 and static summary for each plugin, plus the toolbar items it registers. Needs a DOM (it builds a detached editor).
  • editor.getToolbarItems() and editor.getCommandNames() - what the active plugins registered on this instance.
  • PluginClass - the type of a concrete plugin class (constructible, with pluginName and summary).
  • topLevelBlockElementAt(editable, index) - the DOM element for a doc.content index (the inverse of topLevelBlockIndexOf); 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-align and the legacy align attribute
  • 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 \n in 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-family and font-size onto 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 \n between 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 to dist with tsup.
  • npm test: run the test suite with vitest.
  • npm run test:watch: run vitest in watch mode.
  • npm run typecheck: type check with tsc --noEmit.
  • npm run demo: serve the repository so you can open test/index.html in 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.