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

plug-and-play-editor

v0.6.3

Published

A modern, extensible rich text editor component for the web. Use it with vanilla JavaScript/TypeScript or directly in React apps — zero config needed.

Readme

Plug-and-Play Editor

A modern, extensible rich text editor component for the web. Use it with vanilla JavaScript/TypeScript or directly in React apps — zero config needed.

npm version bundle size

Version history and breaking-change notes live in CHANGELOG.md.

Plug-and-Play Editor screenshot


✨ Features

| Category | Features | |----------|----------| | Formatting | Bold, Italic, Underline, Strikethrough, Headings (H1–H6) | | Undo / Redo | Toolbar buttons + keyboard shortcuts (Ctrl/Cmd+Z, Ctrl/Cmd+Y) | | Lists | Unordered, Ordered, Indent, Outdent | | Color | Text color picker, Background highlight color | | Alignment | Left, Center, Right, Justify | | Direction | LTR / RTL support | | Links | Insert link via modal (with URL validation & inline error), Unlink | | Media | Insert image (URL, modal-based), Upload image (file picker, 10 MB limit), Paste image (clipboard), Insert video/embed via modal (sanitized iframes) | | Modals | Themable openFormModal / openInfoModal helpers — text / url / textarea / color / number / select fields, row grouping, inline errors, ESC / backdrop close, per-modal submit & cancel button theming | | Tables | Insert table, Add/Delete rows, Add/Delete columns | | Code | Inline code, Code blocks (dark theme), HTML source view toggle | | Mentions | @mention dropdown with keyboard navigation, configurable user list, debounced async search | | Emoji | Tabbed emoji picker (Smileys, Gestures, Hearts, Objects, Arrows) | | Structure | Accordion, Page break, Horizontal rule, Table of Contents | | Date/Time | Insert formatted date or time badges | | Template Variables | Insertable {{token}} placeholders for email templates, searchable picker, grouped by category, configurable delimiters | | Font Size | Dropdown with 12 preset sizes (10px–48px), applies as inline styles for email compatibility | | Spacing | Line height (1.0–2.5) and paragraph spacing (compact–double) controls | | Button Block | CTA button builder with text, URL, colors, radius, padding — email-client-compatible inline styles | | Paste Cleanup | Automatically strips Word/Google Docs junk on paste, Ctrl+Shift+V for plain text paste | | Image Resize | Click-to-select images with drag handles for proportional resizing | | Preview Mode | Toggle preview with token replacement (e.g. {{first_name}} → "Alice"), 600px email-width view | | Source Code | Toggle HTML source code editing mode with syntax-friendly monospace view | | Font Family | Dropdown with 11 font families (Arial, Georgia, Times New Roman, Courier New, etc.) | | Block Quote | Toggle blockquote formatting with active state tracking | | Find & Replace | Search with live highlighting, match counter, Find Next, Replace, Replace All — Ctrl/Cmd+F shortcut | | Word Count | Live word & character count in status bar, selection-aware counts | | Editing | Tab key inserts spaces (doesn't leave editor) | | Keyboard Shortcuts | Ctrl/Cmd+B (Bold), Ctrl/Cmd+I (Italic), Ctrl/Cmd+U (Underline), Ctrl/Cmd+Z (Undo), Ctrl/Cmd+Shift+Z / Ctrl/Cmd+Y (Redo), Ctrl/Cmd+Shift+V (Paste Plain Text), Ctrl/Cmd+F (Find & Replace) | | Accessibility | ARIA roles & labels on toolbar, buttons, dropdowns; focus-visible outlines | | Print | Print-ready styles (toolbar hidden, clean layout) | | Responsive | Mobile-friendly emoji picker and toolbar |


📦 Installation

npm install plug-and-play-editor

🚀 Quick Start

Vanilla JavaScript / TypeScript

<!-- index.html -->
<textarea id="editor">
  <p>Hello, world!</p>
</textarea>

<script type="module">
  import { Editor, FormattingPlugin, UndoRedoPlugin, ListsPlugin, EmojiPlugin } from 'plug-and-play-editor';

  const editor = new Editor('#editor', [
    FormattingPlugin,
    UndoRedoPlugin,
    ListsPlugin,
    EmojiPlugin,
  ]);

  // Get content
  console.log(editor.getContent());

  // Set content
  editor.setContent('<p>New content</p>');

  // Clean up when done
  editor.destroy();
</script>

React

npm install plug-and-play-editor react react-dom
import { PlayEditor } from 'plug-and-play-editor/react';

function App() {
  return (
    <PlayEditor
      defaultValue="<p>Start editing...</p>"
      onChange={(html) => console.log(html)}
      minHeight={400}
    />
  );
}

That's it — all 29 plugins load automatically in the React component (including email template features: tokens, button blocks, font size, spacing, paste cleanup, image resize, preview mode, source code editing, font family, block quote, find & replace, and word count).


📖 Detailed Usage

Vanilla JS — Full Example

import {
  Editor,
  FormattingPlugin,
  UndoRedoPlugin,
  ListsPlugin,
  ColorPlugin,
  AlignmentPlugin,
  DirectionalityPlugin,
  LinksPlugin,
  MediaPlugin,
  TablesPlugin,
  AccordionPlugin,
  PageBreakPlugin,
  TocPlugin,
  PasteImagePlugin,
  MentionsPlugin,
  CodeBlockPlugin,
  DateTimePlugin,
  EmojiPlugin,
  TokensPlugin,
  PasteCleanupPlugin,
  FontSizePlugin,
  SpacingPlugin,
  ButtonBlockPlugin,
  ImageResizePlugin,
  PreviewPlugin,
  SourceCodePlugin,
  FontFamilyPlugin,
  BlockQuotePlugin,
  FindReplacePlugin,
  WordCountPlugin,
} from 'plug-and-play-editor';

const editor = new Editor('#my-textarea', [
  FormattingPlugin,
  UndoRedoPlugin,
  ListsPlugin,
  ColorPlugin,
  AlignmentPlugin,
  DirectionalityPlugin,
  LinksPlugin,
  MediaPlugin,
  TablesPlugin,
  AccordionPlugin,
  PageBreakPlugin,
  TocPlugin,
  PasteImagePlugin,
  MentionsPlugin,
  CodeBlockPlugin,
  DateTimePlugin,
  EmojiPlugin,
  TokensPlugin,
  PasteCleanupPlugin,
  FontSizePlugin,
  SpacingPlugin,
  ButtonBlockPlugin,
  ImageResizePlugin,
  PreviewPlugin,
  SourceCodePlugin,
  FontFamilyPlugin,
  BlockQuotePlugin,
  FindReplacePlugin,
  WordCountPlugin,
]);

React — With Ref Control

import { useRef } from 'react';
import { PlayEditor } from 'plug-and-play-editor/react';
import type { PlayEditorRef } from 'plug-and-play-editor/react';

function App() {
  const editorRef = useRef<PlayEditorRef>(null);

  const handleSave = () => {
    const html = editorRef.current?.getContent();
    // Send html to your API
    fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify({ content: html }),
    });
  };

  return (
    <>
      <PlayEditor
        ref={editorRef}
        defaultValue="<p>Edit me</p>"
        onChange={(html) => console.log('Changed:', html)}
        minHeight={300}
      />
      <button onClick={handleSave}>Save</button>
    </>
  );
}

React — Custom Plugin Selection

By default, all plugins load. Pass a plugins prop to use only what you need:

import { PlayEditor } from 'plug-and-play-editor/react';
import { FormattingPlugin, UndoRedoPlugin, ListsPlugin, EmojiPlugin } from 'plug-and-play-editor';

function LightEditor() {
  return (
    <PlayEditor
      plugins={[FormattingPlugin, UndoRedoPlugin, ListsPlugin, EmojiPlugin]}
      onChange={(html) => console.log(html)}
    />
  );
}

Need the full list programmatically? Import ALL_PLUGINS from the dedicated subpath (this is also what PlayEditor loads by default):

import { PlayEditor } from 'plug-and-play-editor/react';
import { ALL_PLUGINS } from 'plug-and-play-editor/react/defaults';
import { MyCustomPlugin } from './my-custom-plugin';

<PlayEditor plugins={[...ALL_PLUGINS, MyCustomPlugin]} />

React — Disabled / Read-Only

// Disabled — no interaction at all
<PlayEditor defaultValue="<p>Locked content</p>" disabled />

// Read-only — content visible but not editable
<PlayEditor defaultValue="<p>View-only content</p>" readOnly />

Modals (for plugin authors)

openFormModal / openInfoModal are the primitives plugins use for data entry and messages. They render a centered dialog inside the editor container, support inline validation errors, ESC / backdrop close, and accept per-modal button theming.

import { openFormModal, openInfoModal } from 'plug-and-play-editor';

// Form modal — rich field types + row grouping
openFormModal(editor, {
  title: 'Insert Button',
  submitLabel: 'Insert',
  fields: [
    { name: 'text', label: 'Label', type: 'text', value: 'Click' },
    { name: 'url',  label: 'URL',   type: 'url',  value: 'https://' },
    [
      { name: 'bg',    label: 'Background', type: 'color',  value: '#3b82f6' },
      { name: 'color', label: 'Text',       type: 'color',  value: '#ffffff' }
    ],
    {
      name: 'size', label: 'Size', type: 'select', value: 'md',
      options: [{ value: 'sm', label: 'Small' }, { value: 'md', label: 'Medium' }, { value: 'lg', label: 'Large' }]
    }
  ],
  theme: {
    submit: { background: '#10b981', color: '#fff', fontFamily: 'Inter', fontWeight: '600' },
    cancel: { color: '#64748b' }
  },
  onSubmit: (values, { showError, close }) => {
    if (!values.url) return showError('URL is required.');
    // ... do work ...
    close();
  }
});

// Info modal — read-only message or preformatted content
openInfoModal(editor, {
  title: 'Content',
  content: editor.getContent(),
  preformatted: true,
  theme: { submit: { background: '#10b981' } }
});

Supported field types: text, url, textarea, color, number, select. Arrays of fields render side-by-side as a row.


🔌 Plugins Reference

Built-in Plugins

| Plugin | Import Name | What It Does | |--------|-------------|--------------| | Formatting | FormattingPlugin | Bold, Italic, Underline, Strikethrough, Headings (H1–H6) dropdown | | Undo / Redo | UndoRedoPlugin | Undo and Redo toolbar buttons | | Lists | ListsPlugin | Unordered/Ordered lists, Indent/Outdent | | Color | ColorPlugin | Text color & background color pickers | | Alignment | AlignmentPlugin | Left, Center, Right, Justify | | Directionality | DirectionalityPlugin | LTR / RTL text direction | | Links | LinksPlugin | Insert/remove hyperlinks (URL validated, javascript: blocked) | | Media | MediaPlugin | Insert image (URL + file picker, 10 MB max), sanitized video embed | | Tables | TablesPlugin | Insert 3x3 table, add/delete rows, add/delete columns | | Accordion | AccordionPlugin | Collapsible accordion sections | | Page Break | PageBreakPlugin | Horizontal rule & page break | | TOC | TocPlugin | Auto-generated Table of Contents from headings | | Paste Image | PasteImagePlugin | Cmd+V / Ctrl+V paste images from clipboard | | Mentions | MentionsPlugin | @mention dropdown (uses default empty user list) | | Code Block | CodeBlockPlugin | Inline code, fenced code blocks, HTML source toggle | | Date/Time | DateTimePlugin | Insert current date or time as styled badge | | Emoji | EmojiPlugin | Tabbed emoji picker popup | | Tokens | TokensPlugin | Template variable picker with default email tokens ({{first_name}}, etc.) | | Paste Cleanup | PasteCleanupPlugin | Auto-cleans pasted HTML from Word/Docs, Ctrl+Shift+V for plain text | | Font Size | FontSizePlugin | Font size dropdown (10–48px) with inline style output | | Spacing | SpacingPlugin | Line height & paragraph spacing controls | | Button Block | ButtonBlockPlugin | CTA button builder with colors, padding, radius — click to re-edit | | Image Resize | ImageResizePlugin | Click images to show resize handles, drag to resize proportionally | | Preview | PreviewPlugin | Preview mode with token replacement and 600px email-width view | | Source Code | SourceCodePlugin | Toggle raw HTML source code editing with monospace view, disables other toolbar controls in source mode | | Font Family | FontFamilyPlugin | Font family dropdown with 11 fonts (Arial, Georgia, Times New Roman, Courier New, etc.) | | Block Quote | BlockQuotePlugin | Toggle blockquote formatting with active state tracking | | Find & Replace | FindReplacePlugin | Search panel with live highlighting, match counter, Find Next, Replace, Replace All — Ctrl/Cmd+F shortcut | | Word Count | WordCountPlugin | Live word & character count status bar, shows selection-aware counts when text is selected |

Configurable Plugins

Mentions — Custom User List

import { createMentionsPlugin } from 'plug-and-play-editor';

// Static user list
const mentions = createMentionsPlugin({
  users: [
    { id: '1', name: 'Alice Johnson', avatar: 'https://...' },
    { id: '2', name: 'Bob Smith' },
  ],
  trigger: '@', // default
});

// OR async user fetching (automatically debounced at 200 ms)
const asyncMentions = createMentionsPlugin({
  users: async (query) => {
    const res = await fetch(`/api/users?search=${query}`);
    return res.json(); // must return { id, name, avatar? }[]
  },
});

// Use in vanilla JS
const editor = new Editor('#editor', [FormattingPlugin, mentions]);

// Use in React
<PlayEditor plugins={[FormattingPlugin, asyncMentions]} />

Tokens — Custom Variables for Email Templates

import { createTokensPlugin } from 'plug-and-play-editor';

// Custom token list with categories
const tokens = createTokensPlugin({
  tokens: [
    { key: 'first_name', label: 'First Name', category: 'Recipient' },
    { key: 'last_name', label: 'Last Name', category: 'Recipient' },
    { key: 'order_id', label: 'Order ID', category: 'Order' },
    { key: 'total', label: 'Order Total', category: 'Order' },
  ],
});

// With a different delimiter style
const percentTokens = createTokensPlugin({
  tokens: [{ key: 'name', label: 'Name' }],
  delimiter: 'percent',   // renders %name% instead of {{name}}
  // Also supports: 'single-curly' → {name}
});

// Use in vanilla JS
const editor = new Editor('#editor', [FormattingPlugin, tokens]);

// Use in React
<PlayEditor plugins={[FormattingPlugin, tokens]} />

The default TokensPlugin export includes common email template variables:

| Variable | Description | |----------|-------------| | {{first_name}} | Recipient's first name | | {{last_name}} | Recipient's last name | | {{full_name}} | Recipient's full name | | {{email}} | Recipient's email address | | {{company}} | Company name | | {{unsubscribe_url}} | Unsubscribe link | | {{preferences_url}} | Email preferences link | | {{current_year}} | Current year | | {{current_date}} | Current date |

Preview — Custom Sample Data

import { createPreviewPlugin } from 'plug-and-play-editor';

const preview = createPreviewPlugin({
  sampleData: {
    first_name: 'John',
    company: 'My Corp',
    order_id: 'ORD-12345',
    // Add any custom token keys your templates use
  },
});

const editor = new Editor('#editor', [FormattingPlugin, TokensPlugin, preview]);

⌨️ Keyboard Shortcuts

| Shortcut | Action | |----------|--------| | Ctrl/Cmd + B | Bold | | Ctrl/Cmd + I | Italic | | Ctrl/Cmd + U | Underline | | Ctrl/Cmd + Z | Undo | | Ctrl/Cmd + Shift + Z | Redo | | Ctrl/Cmd + Y | Redo | | Ctrl/Cmd + Shift + V | Paste as plain text | | Ctrl/Cmd + F | Find & Replace | | Tab | Insert tab space |


🎨 Styling

The editor ships with its own CSS that loads automatically. You can customize it by overriding CSS custom properties:

/* Override design tokens */
:root {
  --pe-bg: #ffffff;
  --pe-text: #1e293b;
  --pe-text-muted: #64748b;
  --pe-font: 'Inter', system-ui, sans-serif;
  --pe-border: #e2e8f0;
  --pe-radius: 10px;
  --pe-accent: #3b82f6;
  --pe-toolbar-bg: #f8fafc;
  --pe-btn-hover: rgba(0, 0, 0, 0.06);
  --pe-focus: rgba(59, 130, 246, 0.45);
}

Dark Mode Example

.dark .play-editor-container {
  --pe-bg: #1e293b;
  --pe-text: #e2e8f0;
  --pe-text-muted: #94a3b8;
  --pe-border: #334155;
  --pe-toolbar-bg: #0f172a;
  --pe-btn-hover: rgba(255, 255, 255, 0.08);
}

🏛️ Advanced Architecture

The editor ships with three layered subsystems on top of the contentEditable DOM — opt in when you need them. They are structured, path-based, and DOM-independent, giving you a principled way to observe and mutate content without touching the live tree directly.

Selection model

Structured, path-based selection that survives DOM churn. editor.getSelection() returns a discriminated union (caret / range / none) you can pattern-match on:

const sel = editor.getSelection();
if (sel.kind === 'caret') {
  console.log('caret at', sel.point.path, sel.point.offset);
} else if (sel.kind === 'range') {
  console.log('range', sel.anchor, '→', sel.focus);
}

editor.setSelection(sel) writes the selection back to the DOM, and editor.resolvePoint(node, offset) converts a DOM position into a structured Point.

Document model

A full Node ADT (Doc / Paragraph / Heading / Text / Link / List / …), schema validation, HTML ↔ Doc conversion, and a diffing reconciler. The model runs alongside the DOM — the DOM remains the source of truth, the Doc is an observable projection you can snapshot or re-render.

import { parseDom, serializeToHtml } from 'plug-and-play-editor';

const doc = parseDom(editor.editorArea);
for (const block of doc.children) {
  console.log(block.type, 'children:', block.children?.length ?? 0);
}
const html = serializeToHtml(doc);

serializeToDom(doc) and render(container, prev, next) round out the pipeline for headless mutation and diffed re-rendering.

Transform system

A 14-variant Transform ADT (Insert / Delete / Replace / SetAttr / AddMark / RemoveMark / Move / Split / Join / Wrap / Unwrap / ReplaceNode) with pure apply() + invert(), a TransformLog that tracks an undo/redo cursor with subscribers, and a MutationObserver-based startRecording that auto-captures every edit as a Transform.

import { TransformLog, startRecording, apply, parseDom } from 'plug-and-play-editor';

const log = new TransformLog();
const stop = startRecording(editor.editorArea, log);
// ... user types ...
log.stepUndo();                            // walk back one transform
const next = apply(parseDom(editor.editorArea), someTransform);
stop();

Each log entry is round-trippable (invert gives you the inverse transform), so undo/redo is derived rather than reconstructed from snapshots.

Full design rationale lives in docs/specs/2026-04-19-custom-selection-model-design.md.


📐 API Reference

Editor Class

const editor = new Editor(selector: string | HTMLTextAreaElement, plugins: Plugin[]);

| Method | Returns | Description | |--------|---------|-------------| | getContent() | string | Get the current HTML content | | setContent(html) | void | Set the editor HTML content | | execCommand(command, value?) | void | Run a document.execCommand | | addToolbarButton(iconHtml, tooltip, onClick, command?) | HTMLButtonElement | Add a custom toolbar button. Pass command to enable active state tracking. | | addToolbarDivider() | void | Add a visual divider to the toolbar | | onSelectionChange(fn) | () => void | Subscribe to selection changes inside the editor. Handler fires at most once per frame (rAF-coalesced) and only when the selection is inside the editor. Returns an unsubscribe function. | | onInput(fn) | () => void | Subscribe to editor input changes. Handler fires at most once per frame, after the backing textarea is synced. Returns an unsubscribe function. | | getSelection() | Selection | Read the current selection as a structured, path-based value (caret / range / none). DOM-independent. | | setSelection(sel) | void | Write a structured Selection back to the DOM. | | resolvePoint(node, offset) | Point \| null | Resolve a DOM (node, offset) pair into a structured Point, or null if the position is outside the editor. | | onDestroy(fn) | void | Register a cleanup function called on destroy() | | destroy() | void | Tear down the editor, clean up plugins and event listeners, restore the textarea |

| Property | Type | Description | |----------|------|-------------| | editorArea | HTMLDivElement | The contentEditable div | | toolbar | HTMLDivElement | The toolbar container | | textArea | HTMLTextAreaElement | The backing textarea | | container | HTMLElement | The root wrapper element |

PlayEditor React Component

<PlayEditor
  defaultValue?: string          // Initial HTML
  onChange?: (html: string) => void  // Change callback
  plugins?: Plugin[]             // Custom plugins (all by default)
  className?: string             // Wrapper CSS class
  minHeight?: number             // Min editor height in px
  disabled?: boolean             // Disable editor entirely
  readOnly?: boolean             // Read-only mode (visible but not editable)
  ref?: React.Ref<PlayEditorRef> // Imperative handle
/>

PlayEditorRef (via useRef)

| Method / Property | Type | Description | |-------------------|------|-------------| | getContent() | string | Get current HTML | | setContent(html) | void | Set HTML programmatically | | editor | Editor \| null | Access underlying Editor instance |


🧩 Writing Custom Plugins

Create your own plugin by implementing the Plugin interface:

import type { Plugin } from 'plug-and-play-editor';
import type { Editor } from 'plug-and-play-editor';

export const MyPlugin: Plugin = {
  name: 'my-plugin',
  init(editor: Editor) {
    // Add a toolbar button with active state tracking
    editor.addToolbarButton(
      '<svg>...</svg>',   // icon HTML (SVG recommended)
      'My Action',         // tooltip (also used as aria-label)
      () => {
        editor.execCommand('insertHTML', '<strong>Hello!</strong>');
      },
      'bold'               // optional: command name for active state tracking
    );

    // Add a divider before your buttons
    editor.addToolbarDivider();

    // Access the contentEditable area
    const handler = (e: KeyboardEvent) => {
      // Custom keyboard handling
    };
    editor.editorArea.addEventListener('keydown', handler);

    // Register cleanup for when the editor is destroyed
    editor.onDestroy(() => {
      editor.editorArea.removeEventListener('keydown', handler);
    });
  },
  // Optional: called when editor.destroy() is invoked
  destroy() {
    // Clean up any external resources
  }
};

Then use it:

import { Editor, FormattingPlugin } from 'plug-and-play-editor';
import { MyPlugin } from './my-plugin';

const editor = new Editor('#editor', [FormattingPlugin, MyPlugin]);

// Later, clean up
editor.destroy();

📦 Bundle Size & Tree-Shaking

The package is published with "sideEffects" limited to CSS files, so bundlers that support tree-shaking (esbuild, Rollup, Vite, Webpack 5+) drop everything you don't import:

// Vanilla — selective imports yield ~10 KB bundled (gzipped), not ~21 KB.
import { Editor, FormattingPlugin, LinksPlugin } from 'plug-and-play-editor';

For React, default plugins load synchronously so the toolbar paints on the first frame. If you want the smallest React bundle, pass your own plugins prop and optionally pull the full list from the subpath import:

import { PlayEditor } from 'plug-and-play-editor/react';
import { FormattingPlugin, LinksPlugin } from 'plug-and-play-editor';

<PlayEditor plugins={[FormattingPlugin, LinksPlugin]} />

No runtime dependencies. CSS ships once at plug-and-play-editor/style.css (22 KB, 4.5 KB gzipped).


⚡ Performance

Internal hot paths are optimized for large documents and fast typing:

  • rAF-coalesced selection/input dispatchdocument.selectionchange fires on every cursor move; the editor subscribes once and fans out to plugins at most once per frame, after a single "is selection in editor?" check.
  • Cached command buttonsupdateActiveStates iterates a pre-built array instead of re-querying the toolbar DOM.
  • Plugin subscription APIseditor.onSelectionChange(fn) and editor.onInput(fn) let plugins hook into the same coalesced dispatch instead of adding their own document-level listeners.
  • Short-circuited word count — text stats are cached; selection changes re-render without rescanning textContent.

Rapid cursor movement (50 events) dispatches in <1 ms and collapses into a single update.


🔒 Security

The editor includes built-in protections against common web vulnerabilities:

  • XSS prevention — User input in links, media embeds, TOC headings, and mentions is sanitized/escaped before insertion
  • URL validation — Only http:, https:, and mailto: URLs are allowed for links; javascript: URLs are blocked
  • Iframe sanitization — Video/media embeds are parsed and rebuilt with only safe attributes; only http:/https: iframe sources are permitted
  • File size limits — Image uploads are capped at 10 MB
  • Safe link defaults — All inserted links get target="_blank" and rel="noopener noreferrer"

📁 Project Structure

plug-and-play-editor/
├── src/
│   ├── core/
│   │   ├── Editor.ts       # Core editor class (lifecycle, shortcuts, rAF-coalesced selectionchange/input)
│   │   ├── Plugin.ts       # Plugin interface (init + optional destroy)
│   │   ├── icons.ts        # SVG icon library
│   │   └── modal.ts        # Reusable openFormModal / openInfoModal helpers
│   ├── plugins/
│   │   ├── formatting.ts   # Bold, Italic, Headings, Undo/Redo
│   │   ├── lists.ts        # UL, OL, indent
│   │   ├── color.ts        # Color pickers
│   │   ├── alignment.ts    # Text alignment
│   │   ├── directionality.ts # LTR/RTL
│   │   ├── links.ts        # Hyperlinks (with URL validation)
│   │   ├── media.ts        # Images & video (with sanitization)
│   │   ├── tables.ts       # Table management (add/delete rows & cols)
│   │   ├── accordion.ts    # Accordion blocks
│   │   ├── page-break.ts   # HR & page breaks
│   │   ├── toc.ts          # Table of Contents (XSS-safe)
│   │   ├── paste-image.ts  # Clipboard paste
│   │   ├── mentions.ts     # @mentions (debounced, ARIA, XSS-safe)
│   │   ├── code-block.ts   # Code blocks
│   │   ├── datetime.ts     # Date/Time insert
│   │   ├── emoji.ts        # Emoji picker (ARIA, scoped cleanup)
│   │   ├── tokens.ts       # Template variable tokens (configurable, searchable)
│   │   ├── paste-cleanup.ts # Paste cleanup (Word/Docs sanitization)
│   │   ├── font-size.ts    # Font size dropdown (inline styles)
│   │   ├── spacing.ts      # Line height & paragraph spacing
│   │   ├── button-block.ts # CTA button builder (email-compatible)
│   │   ├── image-resize.ts # Image resize handles
│   │   ├── preview.ts      # Preview mode with token replacement
│   │   ├── source-code.ts  # HTML source code editing toggle
│   │   ├── font-family.ts  # Font family dropdown
│   │   ├── block-quote.ts  # Blockquote formatting
│   │   ├── find-replace.ts # Find & Replace panel
│   │   └── word-count.ts   # Word & character count status bar
│   ├── styles/
│   │   └── core.css        # All styles (responsive, print, a11y)
│   ├── index.ts            # Vanilla JS entry
│   ├── react.tsx           # React component (with disabled/readOnly)
│   └── react-defaults.ts   # ALL_PLUGINS constant, re-exported from `/react/defaults`
├── package.json
├── vite.config.ts
└── tsconfig.json

🧪 Testing

Unit tests live alongside their sources as *.test.ts and run under vitest with the happy-dom environment:

npm test

Coverage spans selection path math, document schema validation, parser/serializer round-trip, and transform apply + invert correctness — the building blocks behind the Advanced Architecture section above.


📄 License

MIT