npm package: @overlap/rte
Description: A lightweight, extensible Rich Text Editor for React
Author: Almir Nakicevic
Latest version: 1.0.17

# @overlap/rte

A lightweight, extensible Rich Text Editor for React -- zero extra dependencies beyond React itself.

**[Live Demo](https://overlap-dev.github.io/rte/)**

## Installation

```bash
npm install @overlap/rte
```

## Features

- **Lightweight** -- only peer-depends on React, no heavy framework
- **Plugin system** -- compose exactly the toolbar you need
- **Settings object** -- configure features with a single object instead of assembling plugins manually
- **Contenteditable-based** -- uses native browser editing for performance
- **Full formatting** -- bold, italic, underline, strikethrough, subscript, superscript, inline code
- **Block formats** -- headings (h1-h6), bullet lists, numbered lists, checkbox lists, blockquote, code block, horizontal rule
- **Tables** -- insert, row/column operations, context menu
- **Images** -- upload via callback, URL insert, drag & drop, paste, alt text support
- **Links** -- floating editor, advanced attributes, custom fields, hover tooltip
- **Colors** -- text color and background color pickers with custom hex input
- **Font sizes** -- configurable size dropdown
- **Alignment** -- left, center, right, justify
- **Indent / Outdent** -- nested list support via Tab / Shift+Tab
- **Undo / Redo** -- full history management with cursor restoration
- **HTML import/export** -- `htmlToContent()` and `contentToHTML()`
- **Keyboard shortcuts** -- Cmd/Ctrl+B, I, U, E, Shift+X, Shift+7, Shift+8 and more
- **Markdown shortcuts** -- type `#`, `-`, `>`, `[]`, `---`, `` ``` `` to auto-format
- **Auto-linking** -- URLs are auto-detected and converted to links on Space/Enter
- **Read-only mode** -- `readOnly` prop disables editing and hides toolbars
- **Floating toolbar** -- appears on text selection with formatting options
- **Word/character count** -- optional `showWordCount` display
- **Max length** -- enforce character limit with `maxLength` prop
- **Focus/blur callbacks** -- `onFocus` / `onBlur` props
- **HTML sanitization** -- built-in XSS protection on paste, import, and JSON rendering
- **Theming** -- CSS variables + theme prop for brand colors
- **Lexical compatible** -- correctly parses and renders HTML generated by Lexical editors
- **TypeScript** -- fully typed, ships `.d.ts`

## Quick Start

```tsx
import { Editor, EditorContent } from "@overlap/rte";
import "@overlap/rte/dist/styles.css";

function App() {
    const [content, setContent] = useState<EditorContent>();

    return (
        <Editor
            initialContent={content}
            onChange={setContent}
            placeholder="Enter text..."
        />
    );
}
```

> **Note:** Always import the CSS: `import '@overlap/rte/dist/styles.css'`

---

## Settings-Based Configuration

Instead of assembling plugins manually, pass a **settings object** to control which features are enabled. This is the recommended approach for most use cases.

```tsx
import { Editor, EditorSettings, buildPluginsFromSettings } from "@overlap/rte";
import "@overlap/rte/dist/styles.css";

const settings: EditorSettings = {
    format: {
        bold: true,
        italic: true,
        underline: true,
        strikethrough: true,
        code: false,
        subscript: true,
        superscript: true,
        bulletList: true,
        numberedList: true,
        quote: true,
        codeBlock: false,
        check: true,
        typography: ["h1", "h2", "h3"],
        colors: ["#000", "#ff0000", "#00aa00", "#0000ff"],
        fontSize: true,
        alignment: ["left", "center", "right", "justify", "indent", "outdent"],
    },
    link: {
        external: true,
        internal: true,
    },
    table: {
        enabled: true,
    },
    image: {
        enabled: true,
    },
};

function App() {
    // Option A: Let the Editor build plugins from settings automatically
    return <Editor settings={settings} onImageUpload={handleUpload} />;

    // Option B: Build plugins yourself for more control
    const plugins = buildPluginsFromSettings(settings, {
        onImageUpload: handleUpload,
        linkCustomFields: [
            {
                key: "urlExtra",
                label: "Anchor / Params",
                placeholder: "?param=value or #anchor",
                dataAttribute: "data-url-extra",
                appendToHref: true,
            },
        ],
    });
    return <Editor plugins={plugins} />;
}
```

### EditorSettings Interface

```typescript
interface EditorSettings {
    format?: {
        bold?: boolean;
        italic?: boolean;
        underline?: boolean;
        strikethrough?: boolean;
        code?: boolean;           // inline code
        subscript?: boolean;
        superscript?: boolean;
        bulletList?: boolean;
        numberedList?: boolean;
        quote?: boolean;
        codeBlock?: boolean;
        check?: boolean;          // checkbox lists
        typography?: string[];    // e.g. ["h1", "h2", "h3"]
        colors?: string[];        // e.g. ["#000", "#ff0000"]
        fontSize?: boolean;
        alignment?: string[];     // e.g. ["left", "center", "right", "justify", "indent", "outdent"]
    };
    link?: {
        external?: boolean;
        internal?: boolean;
    };
    table?: {
        enabled?: boolean;
    };
    image?: {
        enabled?: boolean;
    };
}
```

By default, **everything is enabled** (`defaultEditorSettings`). Disable features by setting them to `false` or removing values from arrays.

---

## Manual Plugin Composition

For full control, assemble plugins yourself:

```tsx
import {
    Editor,
    boldPlugin,
    italicPlugin,
    underlinePlugin,
    strikethroughPlugin,
    subscriptPlugin,
    superscriptPlugin,
    codeInlinePlugin,
    undoPlugin,
    redoPlugin,
    clearFormattingPlugin,
    indentListItemPlugin,
    outdentListItemPlugin,
    createBlockFormatPlugin,
    createTextColorPlugin,
    createBackgroundColorPlugin,
    createFontSizePlugin,
    createAlignmentPlugin,
    createAdvancedLinkPlugin,
    createImagePlugin,
    tablePlugin,
} from "@overlap/rte";

const plugins = [
    undoPlugin,
    redoPlugin,
    createBlockFormatPlugin(["h1", "h2", "h3"], {
        bulletList: true,
        numberedList: true,
        quote: true,
        check: true,
        codeBlock: false,
    }),
    boldPlugin,
    italicPlugin,
    underlinePlugin,
    strikethroughPlugin,
    createAdvancedLinkPlugin({ enableTarget: true }),
    createTextColorPlugin(["#000", "#ff0000", "#00aa00", "#0000ff"]),
    createBackgroundColorPlugin(["#ffff00", "#00ff00", "#ff00ff"]),
    createFontSizePlugin([12, 14, 16, 18, 20, 24, 28, 32]),
    createAlignmentPlugin(["left", "center", "right", "justify"]),
    tablePlugin,
    createImagePlugin(handleUpload),
    subscriptPlugin,
    superscriptPlugin,
    codeInlinePlugin,
    clearFormattingPlugin,
    indentListItemPlugin,
    outdentListItemPlugin,
];

function App() {
    return <Editor plugins={plugins} onChange={console.log} />;
}
```

---

## Advanced Link Plugin

The link plugin supports a floating editor with advanced attributes and extensible custom fields:

```tsx
import { createAdvancedLinkPlugin, LinkCustomField } from "@overlap/rte";

const linkPlugin = createAdvancedLinkPlugin({
    enableTarget: true, // show "Open in new tab" checkbox
    customFields: [
        {
            key: "urlExtra",
            label: "Anchor / Params",
            placeholder: "?param=value or #anchor",
            dataAttribute: "data-url-extra",
            appendToHref: true,
        },
        {
            key: "pageRef",
            label: "Page Reference",
            placeholder: "Select a page...",
            dataAttribute: "data-page-ref",
            disablesUrl: true,
        },
    ],
});
```

### LinkCustomField

| Property | Type | Description |
| --- | --- | --- |
| `key` | `string` | Unique identifier |
| `label` | `string` | Input label text |
| `placeholder` | `string?` | Input placeholder |
| `dataAttribute` | `string` | HTML data attribute stored on the `<a>` tag |
| `appendToHref` | `boolean?` | If true, value is appended to the `href` |
| `disablesUrl` | `boolean?` | If true, hides the URL field when this field has a value |

---

## Image Upload

```tsx
import { Editor, createImagePlugin } from "@overlap/rte";

async function handleUpload(file: File): Promise<string> {
    const formData = new FormData();
    formData.append("file", file);
    const res = await fetch("/api/upload", { method: "POST", body: formData });
    const { url } = await res.json();
    return url;
}

// Via settings:
<Editor settings={{ image: { enabled: true } }} onImageUpload={handleUpload} />

// Via plugin:
<Editor plugins={[...otherPlugins, createImagePlugin(handleUpload)]} />
```

Images can be inserted by:
- Clicking the toolbar button (file picker or URL input)
- Pasting an image from the clipboard
- Dragging and dropping an image file

---

## Tables

```tsx
import { Editor, tablePlugin } from "@overlap/rte";

// Via settings:
<Editor settings={{ table: { enabled: true } }} />

// Via plugin:
<Editor plugins={[...otherPlugins, tablePlugin]} />
```

Right-click a table cell to access the context menu with row/column operations.

---

## Theming

### CSS Variables

Override CSS variables on `.rte-container` or a parent element:

```css
.my-editor {
    --rte-primary-color: #339192;
    --rte-primary-light: rgba(51, 145, 146, 0.15);
    --rte-border-color: #e0e0e0;
    --rte-toolbar-bg: #fafafa;
    --rte-content-bg: #ffffff;
    --rte-button-hover-bg: rgba(51, 145, 146, 0.08);
    --rte-border-radius: 8px;
}
```

### Theme Prop

```tsx
<Editor
    theme={{
        primaryColor: "#339192",
        borderColor: "#e0e0e0",
        borderRadius: 8,
        toolbarBg: "#fafafa",
        contentBg: "#ffffff",
        buttonHoverBg: "rgba(51, 145, 146, 0.08)",
    }}
/>
```

### CSS Class Overrides

```css
.rte-container        { /* outer wrapper */ }
.rte-toolbar          { /* toolbar row */ }
.rte-toolbar-button   { /* individual toolbar button */ }
.rte-toolbar-button-active { /* active state */ }
.rte-editor           { /* contenteditable area */ }
```

---

## HTML Import / Export

```tsx
import { htmlToContent, contentToHTML, EditorAPI } from "@overlap/rte";

// Import HTML into the editor
const content = htmlToContent("<p>Hello <strong>world</strong></p>");
<Editor initialContent={content} />

// Export HTML from the editor
function MyEditor() {
    const apiRef = useRef<EditorAPI>(null);

    return (
        <Editor
            onEditorAPIReady={(api) => { apiRef.current = api; }}
            onChange={() => {
                const html = apiRef.current?.exportHtml();
                console.log(html);
            }}
        />
    );
}
```

---

## Editor Props

| Prop | Type | Description |
| --- | --- | --- |
| `initialContent` | `EditorContent?` | Initial editor content (JSON) |
| `onChange` | `(content: EditorContent) => void` | Called on every content change |
| `plugins` | `Plugin[]?` | Manual plugin array (overrides settings) |
| `settings` | `EditorSettings?` | Settings object to auto-build plugins |
| `settingsOptions` | `BuildPluginsOptions?` | Extra options when using settings (e.g. `onImageUpload`, `linkCustomFields`) |
| `placeholder` | `string?` | Placeholder text (default: `"Enter text..."`) |
| `className` | `string?` | CSS class for the outer container |
| `toolbarClassName` | `string?` | CSS class for the toolbar |
| `editorClassName` | `string?` | CSS class for the editor area |
| `headings` | `string[]?` | Heading levels when using default plugins |
| `fontSizes` | `number[]?` | Font sizes when using default plugins |
| `colors` | `string[]?` | Color palette when using default plugins |
| `onImageUpload` | `(file: File) => Promise<string>` | Image upload callback |
| `onEditorAPIReady` | `(api: EditorAPI) => void` | Callback when editor API is ready |
| `theme` | `object?` | Theme overrides (see Theming section) |
| `readOnly` | `boolean?` | When `true`, hides toolbars and disables editing |
| `onFocus` | `() => void` | Called when the editor receives focus |
| `onBlur` | `() => void` | Called when the editor loses focus |
| `maxLength` | `number?` | Maximum character count; prevents input beyond this limit |
| `showWordCount` | `boolean?` | Shows a word/character count bar below the editor |

---

## EditorAPI

Available via `onEditorAPIReady` callback or passed to plugin functions:

| Method | Description |
| --- | --- |
| `executeCommand(cmd, value?)` | Execute a `document.execCommand` |
| `getSelection()` | Get the current `Selection` |
| `getContent()` | Get content as `EditorContent` JSON |
| `setContent(content)` | Set content from `EditorContent` JSON |
| `exportHtml()` | Export current content as HTML string |
| `importHtml(html)` | Import HTML string into the editor |
| `insertBlock(type, attrs?)` | Insert a block element |
| `insertInline(type, attrs?)` | Insert an inline element |
| `undo()` / `redo()` | History navigation |
| `canUndo()` / `canRedo()` | Check history state |
| `indentListItem()` | Indent the current list item |
| `outdentListItem()` | Outdent the current list item |
| `clearFormatting()` | Remove all formatting from selection |
| `clearTextColor()` | Remove text color |
| `clearBackgroundColor()` | Remove background color |
| `clearFontSize()` | Remove font size |
| `clearLinks()` | Remove links (keep text) |
| `getTextStats()` | Get `{ characters, words }` count |

---

## Creating Custom Plugins

```typescript
import { Plugin, EditorAPI, ButtonProps } from "@overlap/rte";

const highlightPlugin: Plugin = {
    name: "highlight",
    type: "inline",
    renderButton: (props: ButtonProps) => (
        <button
            onClick={props.onClick}
            className={`rte-toolbar-button ${props.isActive ? "rte-toolbar-button-active" : ""}`}
            title="Highlight"
        >
            H
        </button>
    ),
    execute: (editor: EditorAPI) => {
        editor.executeCommand("backColor", "#ffff00");
    },
    isActive: () => {
        const sel = document.getSelection();
        if (!sel || sel.rangeCount === 0) return false;
        const el = sel.getRangeAt(0).commonAncestorContainer;
        const parent = el.nodeType === Node.TEXT_NODE ? el.parentElement : el as HTMLElement;
        return parent?.closest("[style*='background-color: rgb(255, 255, 0)']") !== null;
    },
    canExecute: () => true,
};
```

---

## Exports Overview

```typescript
// Components
export { Editor, Toolbar, Dropdown };

// Settings
export { EditorSettings, defaultEditorSettings, buildPluginsFromSettings, BuildPluginsOptions };

// Individual plugins
export { boldPlugin, italicPlugin, underlinePlugin, strikethroughPlugin };
export { subscriptPlugin, superscriptPlugin, codeInlinePlugin };
export { undoPlugin, redoPlugin, clearFormattingPlugin };
export { indentListItemPlugin, outdentListItemPlugin };
export { horizontalRulePlugin };
export { defaultPlugins };

// Plugin factories
export { createBlockFormatPlugin, BlockFormatOptions };
export { createTextColorPlugin, createBackgroundColorPlugin };
export { createFontSizePlugin };
export { createAlignmentPlugin };
export { createAdvancedLinkPlugin, LinkCustomField };
export { createImagePlugin };
export { tablePlugin };

// Utilities
export { htmlToContent, contentToHTML, contentToDOM, domToContent, createEmptyContent };
export { HistoryManager };
export { sanitizeHtml, isUrlSafe };

// Types
export { Plugin, EditorAPI, EditorContent, EditorNode, EditorProps, ButtonProps };
```

---

## Security

The editor includes built-in XSS protection:

- **HTML sanitization** -- `htmlToContent()` automatically sanitizes incoming HTML, stripping `<script>`, `<iframe>`, event handlers (`onclick`, `onerror`, etc.), and `javascript:` URLs.
- **URL validation** -- `isUrlSafe(url)` is available as an export to validate URLs against a safe scheme allowlist. It is used internally for links, images, and auto-linking.
- **Paste protection** -- pasted HTML is sanitized before insertion.
- **Allowlist-based** -- the sanitizer uses a tag and attribute allowlist approach, removing dangerous elements (`<script>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<template>`, `<video>`, `<audio>`, `<marquee>`, etc.) and filtering attributes that start with `on` or contain unsafe values.

```tsx
import { sanitizeHtml, isUrlSafe } from "@overlap/rte";

// Sanitize untrusted HTML
const clean = sanitizeHtml('<p>Hello</p><script>alert("xss")</script>');

// Validate a URL
if (isUrlSafe(userUrl)) {
    // safe to use
}
```

---

## Development

```bash
npm install       # install dependencies
npm run build     # production build
npm run dev       # watch mode

cd example && npm run dev   # run the showcase app
```

## License

MIT