npm package: @noseberry/nbd-editor
Description: Nbd Editor: a framework-agnostic rich content editor by Noseberry Private Limited.
Author: Dev team
Latest version: 1.1.1

# NBD Editor

> We'd love to hear from you! If you run into any bugs, have feature requests, or just want to share how you're using NBD Editor, please [open an issue](https://github.com/Noseberry-Private-Limited/NBD-editor/issues). Your feedback helps us make NBD Editor better for everyone.

A powerful, framework-agnostic block editor. NBD Editor gives you a clean, modern content editing experience with drag-and-drop blocks, rich text formatting, media uploads, and a slash command menu — all in a single lightweight package with **zero production dependencies**.

Works seamlessly with **vanilla JavaScript**, **React**, and **Next.js**.

### Why NBD Editor?

- **Drop-in ready** — one import, one line of code, and you have a full block editor
- **Framework agnostic** — works with any frontend stack; optional React wrapper included
- **20+ block types** — paragraphs, headings, images, video, audio, tables, columns, code, embeds, and more
- **Rich editing** — inline formatting toolbar (draggable), slash commands (`/`), drag-and-drop reordering, undo/redo
- **Media support** — upload images, video, audio, and files with drag-and-drop or file picker
- **Simple data model** — `getData()` returns `{ content: '<p>...</p>' }` — just store one HTML string
- **TypeScript support** — full type definitions included for autocomplete and type safety
- **Lightweight** — ~89 KB minified JS + ~25 KB CSS, zero runtime dependencies

### How It Works

```
Install → Import → Mount → Done

1. npm install @noseberry/nbd-editor
2. import { NBDEditor } from '@noseberry/nbd-editor'
3. new NBDEditor('#editor', { onSave: (data) => save(data) })
```

The editor manages its own state internally. You interact with it through callbacks (`onChange`, `onSave`) or imperatively via the API (`getData()`, `setData()`, `toHTML()`). In React, use the `ReactNBDEditor` component with or without a ref.

### Usage Flow

```
┌─────────────────────────────────────────────────────┐
│                    Your App                         │
│                                                     │
│   ┌─────────────┐        ┌──────────────────────┐  │
│   │  Initialize  │───────▶│     NBD Editor        │  │
│   │  with config │        │                      │  │
│   └─────────────┘        │  ┌──────────────────┐ │  │
│                           │  │  User writes     │ │  │
│   ┌─────────────┐        │  │  content using    │ │  │
│   │  onChange()  │◀───────│  │  blocks, toolbar, │ │  │
│   │  callback    │        │  │  slash commands   │ │  │
│   └─────────────┘        │  └──────────────────┘ │  │
│                           │                      │  │
│   ┌─────────────┐        │  ┌──────────────────┐ │  │
│   │  onSave()   │◀───────│  │  Ctrl+S or       │ │  │
│   │  callback    │        │  │  save() API call │ │  │
│   └──────┬──────┘        │  └──────────────────┘ │  │
│          │                └──────────────────────┘  │
│          ▼                                          │
│   ┌─────────────┐                                   │
│   │  Store in   │   data = { content: '<p>...</p>' }│
│   │  database   │                                   │
│   └─────────────┘                                   │
│                                                     │
│   To restore: editor.setData({ content: savedHTML })│
└─────────────────────────────────────────────────────┘
```

---

## Table of Contents

- [Installation](#installation)
- [Quick Start (Vanilla JS)](#quick-start-vanilla-js)
- [React Integration](#react-integration)
  - [Without Ref](#without-ref)
  - [With Ref](#with-ref)
- [Next.js Integration](#nextjs-integration)
  - [App Router (Next 13+)](#app-router-next-13)
  - [Pages Router](#pages-router)
- [Configuration Options](#configuration-options)
- [Public API](#public-api)
- [Data Contract](#data-contract)
- [Block Types](#block-types)
- [Media Handling](#media-handling)
- [Theming](#theming)
- [Keyboard Shortcuts](#keyboard-shortcuts)
- [Events](#events)
- [Changelog](#changelog)
- [Build from Source](#build-from-source)
- [License](#license)

---

## Installation

```bash
npm install @noseberry/nbd-editor
# or
yarn add @noseberry/nbd-editor
# or
pnpm add @noseberry/nbd-editor
```

**Important — always import the CSS:**

```js
import '@noseberry/nbd-editor/style.css';
```

Without this import, no editor styles will be applied. This is required for all setups (vanilla JS, React, Next.js).

---

## Quick Start (Vanilla JS)

```js
import { NBDEditor } from '@noseberry/nbd-editor';
import '@noseberry/nbd-editor/style.css';

const editor = new NBDEditor('#editor', {
  siteName: 'My Blog',
  authorName: 'Harshit',
  content: '<p>Start writing here...</p>',
  maxFileSize: 200 * 1024 * 1024,
  onChooseMediaSource: (type) => 'upload',
  onChange: (data) => console.log('Content changed:', data),
  onSave: (data) => console.log('Saved:', data),
  onPublish: (data) => console.log('Published:', data),
  uploadHandler: async (file) => {
    const form = new FormData();
    form.append('file', file);
    const res = await fetch('/api/upload', { method: 'POST', body: form });
    return res.json(); // must return { url, id? }
  },
});
```

You can pass either a CSS selector string (`'#editor'`) or a DOM element directly as the first argument.

---

## React Integration

### Without Ref

The simplest approach — just render the editor and listen for changes via callbacks. No imperative access needed.

```jsx
import { ReactNBDEditor } from '@noseberry/nbd-editor';
import '@noseberry/nbd-editor/style.css';

export default function EditorPage() {
  const handleChange = (data) => {
    console.log('Editor content:', data.content);
  };

  const handleSave = (data) => {
    fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
  };

  return (
    <ReactNBDEditor
      content="<p>Hello World</p>"
      siteName="My App"
      authorName="Harshit"
      maxFileSize={200 * 1024 * 1024}
      onChooseMediaSource={(type) => 'upload'}
      onChange={handleChange}
      onSave={handleSave}
      uploadHandler={async (file) => {
        const form = new FormData();
        form.append('file', file);
        const res = await fetch('/api/upload', { method: 'POST', body: form });
        return res.json();
      }}
    />
  );
}
```

### With Ref

Use a ref when you need imperative control — save programmatically, get data on demand, undo/redo, etc.

```jsx
import React, { useRef, useCallback } from 'react';
import { ReactNBDEditor } from '@noseberry/nbd-editor';
import '@noseberry/nbd-editor/style.css';

export default function EditorPage() {
  const editorRef = useRef(null);

  const handleSaveClick = useCallback(() => {
    editorRef.current?.save();
  }, []);

  const handleGetData = useCallback(() => {
    const data = editorRef.current?.getData();
    console.log('Editor data:', data);
    // data => { content: '<p>...</p>' }
  }, []);

  const handleGetHTML = useCallback(() => {
    const html = editorRef.current?.toHTML();
    console.log('Raw HTML:', html);
  }, []);

  const handleUndo = useCallback(() => {
    editorRef.current?.undo();
  }, []);

  const handleRedo = useCallback(() => {
    editorRef.current?.redo();
  }, []);

  const handleSetContent = useCallback(() => {
    editorRef.current?.setData({ content: '<h2>Replaced content</h2><p>New paragraph</p>' });
  }, []);

  return (
    <div>
      <div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
        <button onClick={handleSaveClick}>Save</button>
        <button onClick={handleGetData}>Get Data</button>
        <button onClick={handleGetHTML}>Get HTML</button>
        <button onClick={handleUndo}>Undo</button>
        <button onClick={handleRedo}>Redo</button>
        <button onClick={handleSetContent}>Replace Content</button>
      </div>

      <ReactNBDEditor
        ref={editorRef}
        content="<p>Edit me...</p>"
        siteName="My App"
        authorName="Harshit"
        maxFileSize={200 * 1024 * 1024}
        onChooseMediaSource={(type) => 'upload'}
        onSave={(data) => console.log('Saved:', data)}
      />
    </div>
  );
}
```

**Ref methods available:**

| Method | Description |
|---|---|
| `getInstance()` | Returns the underlying `NBDEditor` instance |
| `getData()` | Returns `{ content: '<p>...</p>' }` |
| `setData(data)` | Sets content — accepts `{ content: '...' }` |
| `toHTML()` | Returns the raw HTML string |
| `save()` | Triggers save (fires `onSave` callback) |
| `publish()` | Triggers publish (fires `onPublish` callback) |
| `undo(opts?)` | Undo last action |
| `redo(opts?)` | Redo last undone action |
| `destroy()` | Destroys the editor instance and cleans up |

### Controlled Mode (like React Quill)

NBD Editor supports the controlled component pattern — bind `content` to state and update it via `onChange`:

```jsx
import React, { useState } from 'react';
import { ReactNBDEditor } from '@noseberry/nbd-editor';
import '@noseberry/nbd-editor/style.css';

export default function EditorPage() {
  const [content, setContent] = useState('<p>Hello World</p>');

  return (
    <ReactNBDEditor
      content={content}
      onChange={(data) => setContent(data.content)}
      siteName="My App"
      authorName="Harshit"
    />
  );
}
```

For heavy content or frequent updates, use `debounceMs` to limit how often `onChange` fires:

```jsx
<ReactNBDEditor
  content={content}
  onChange={(data) => setContent(data.content)}
  debounceMs={300}  // fires onChange at most every 300ms
/>
```

You can also use `content` as a one-time initial value and use the ref for everything else:

```jsx
editorRef.current?.setData({ content: '<p>New content from server</p>' });
```

---

## Next.js Integration

The editor uses browser APIs (`document`, `window`, `contenteditable`), so it must be rendered **client-side only**.

### App Router (Next 13+)

Create a client component wrapper:

```jsx
// components/Editor.jsx
'use client';

import React, { useRef } from 'react';
import { ReactNBDEditor } from '@noseberry/nbd-editor';
import '@noseberry/nbd-editor/style.css';

export default function Editor({ initialContent }) {
  const editorRef = useRef(null);

  return (
    <ReactNBDEditor
      ref={editorRef}
      content={initialContent}
      siteName="My Next.js App"
      authorName="Harshit"
      maxFileSize={200 * 1024 * 1024}
      onChooseMediaSource={(type) => 'upload'}
      onSave={(data) => {
        console.log('Saved:', data);
      }}
      uploadHandler={async (file) => {
        const form = new FormData();
        form.append('file', file);
        const res = await fetch('/api/upload', { method: 'POST', body: form });
        return res.json();
      }}
    />
  );
}
```

Use it in a page:

```jsx
// app/editor/page.jsx
import Editor from '@/components/Editor';

export default function EditorPage() {
  return (
    <main>
      <h1>Post Editor</h1>
      <Editor initialContent="<p>Write your post...</p>" />
    </main>
  );
}
```

> **Note:** `onSave`, `onChange`, and `uploadHandler` are functions — they cannot be passed from a Server Component. Define them inside the `'use client'` wrapper component (as shown above), or make the page itself a Client Component.

**Alternative — dynamic import (skip SSR entirely):**

```jsx
// app/editor/page.jsx
'use client';

import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('@/components/Editor'), {
  ssr: false,
  loading: () => <p>Loading editor...</p>,
});

export default function EditorPage() {
  return (
    <main>
      <h1>Post Editor</h1>
      <Editor initialContent="<p>Write your post...</p>" />
    </main>
  );
}
```

### Pages Router

```jsx
// pages/editor.jsx
import dynamic from 'next/dynamic';

const Editor = dynamic(() => import('../components/Editor'), {
  ssr: false,
  loading: () => <p>Loading editor...</p>,
});

export default function EditorPage() {
  return (
    <main>
      <h1>Post Editor</h1>
      <Editor initialContent="<p>Write your post...</p>" />
    </main>
  );
}
```

> **Tip:** Using `dynamic` with `ssr: false` ensures the editor is never rendered on the server, preventing hydration mismatches.

---

## Configuration Options

Pass these as the second argument to `new NBDEditor(selector, options)` or as props to `<ReactNBDEditor />`.

| Option | Type | Default | Description |
|---|---|---|---|
| `siteName` | `string` | `'My Site'` | Displayed in the editor header |
| `authorName` | `string` | `'Admin'` | Displayed as the author name |
| `content` | `string` | `null` | Initial HTML content to load |
| `placeholder` | `string` | `'Start writing...'` | Placeholder text for empty blocks |
| `showStatusBar` | `boolean` | `true` | Show/hide the bottom status bar |
| `showSettingsPanel` | `boolean` | `false` | Show/hide the right settings panel |
| `showHeaderActions` | `boolean` | `false` | Show/hide header action buttons |
| `autoSaveInterval` | `number` | `30000` | Auto-save interval in ms (0 to disable) |
| `maxFileSize` | `number` | `10485760` | Max upload file size in bytes (default 10 MB) |
| `uploadHandler` | `async (file) => { url, id? }` | `null` | Custom file upload function |
| `onChooseMediaSource` | `(type) => 'url' \| 'upload'` | `null` | Controls video/audio source chooser |
| `onChange` | `(data) => void` | `null` | Called on every content change |
| `onSave` | `(data) => void` | `null` | Called when save is triggered |
| `onPublish` | `(data) => void` | `null` | Called when publish is triggered |

### React-only props

| Prop | Type | Description |
|---|---|---|
| `className` | `string` | CSS class for the wrapper `div` |
| `style` | `object` | Inline styles for the wrapper `div` |
| `onReady` | `(editor) => void` | Called once the editor instance is initialized |
| `debounceMs` | `number` | Debounce delay (ms) for `onChange` — reduces re-renders in controlled mode |

---

## Public API

These methods are available on the `NBDEditor` instance (vanilla JS) or via the ref (React).

```js
// Vanilla JS
const editor = new NBDEditor('#editor', { ... });

// React — via ref
const instance = editorRef.current?.getInstance();
```

| Method | Returns | Description |
|---|---|---|
| `getData()` | `{ content: string }` | Get the full editor content |
| `setData(data)` | `void` | Set content. Accepts `{ content: '...' }` or a plain string |
| `toHTML()` | `string` | Export all blocks as a merged HTML string |
| `save()` | `void` | Trigger save — fires `onSave` callback and `'save'` event |
| `publish()` | `void` | Trigger publish — fires `onPublish` callback and `'publish'` event |
| `undo(opts?)` | `boolean` | Undo the last action |
| `redo(opts?)` | `boolean` | Redo the last undone action |
| `destroy()` | `void` | Tear down the editor and clean up listeners |
| `getTitle()` | `string` | Get the current title value |
| `setTitle(title)` | `void` | Set the title |
| `selectBlock(id)` | `void` | Select a block by its ID |
| `focusBlock(id)` | `void` | Select and focus a block |
| `insertBlockAtSelection(type, data?)` | `block` | Insert a new block after the selected block |
| `insertBlockAtEnd(type, data?)` | `block` | Append a new block at the end |
| `toggleBlockHtmlMode(blockId?)` | `void` | Toggle a block between visual and raw HTML mode |
| `openBlockHtmlEditor(blockId?)` | `void` | Open the HTML editor modal for a block |

### Event Emitter

The editor extends `EventEmitter`. Listen for events:

```js
editor.on('change', (data) => { /* content changed */ });
editor.on('save', (data) => { /* save triggered */ });
editor.on('publish', (data) => { /* publish triggered */ });
editor.on('autosave', (data) => { /* auto-save tick */ });
editor.on('block:select', (blockId) => { /* block selected */ });
editor.on('input', (block) => { /* block input */ });
```

---

## Data Contract

The editor uses a simple content-only data format:

```js
// Getting data
const data = editor.getData();
// => { content: '<h2>Title</h2><p>Body text...</p>' }

// Setting data
editor.setData({ content: '<p>New content</p>' });

// Getting raw HTML
const html = editor.toHTML();
// => '<h2>Title</h2><p>Body text...</p>'
```

Store the `content` string in your database. Pass it back to the editor to restore.

---

## Block Types

The editor supports these block types out of the box:

**Text blocks:** Paragraph, Heading 2, Heading 3, Heading 4, List (bulleted), Ordered List, Quote, Pullquote, Code, Custom HTML

**Media blocks:** Image, Gallery, Video, Audio, File, Embed

**Design blocks:** Separator, Spacer, Columns, Table, Button

Users can insert blocks via the inserter panel (`+` button) or the slash command menu (type `/` in an empty block).

---

## Media Handling

For video and audio, users see a modal chooser with two options: **Upload file** or **Use URL**.

Control the default behavior with `onChooseMediaSource`:

```js
new NBDEditor('#editor', {
  onChooseMediaSource: (type) => {
    // type is 'video' or 'audio'
    return 'upload'; // or 'url'
  },
  maxFileSize: 500 * 1024 * 1024, // 500 MB
  uploadHandler: async (file) => {
    const form = new FormData();
    form.append('file', file);
    const res = await fetch('/api/upload', { method: 'POST', body: form });
    return res.json(); // { url: 'https://...', id: '...' }
  },
});
```

Files exceeding `maxFileSize` are blocked from uploading. Users can also drag and drop files directly onto the editor canvas.

---

## Theming

NBD Editor exposes a set of CSS custom properties on `.fe-root` that you can override to match your brand. Set them on `.fe-root` or any ancestor element:

```css
.fe-root {
  --fe-blue: #8b5cf6;          /* accent / primary colour */
  --fe-blue-hover: #7c3aed;
  --fe-bg: #fafafa;             /* editor shell background */
  --fe-canvas-bg: #f9fafb;     /* writing canvas background */
  --fe-text: #111827;           /* primary text colour */
  --fe-font: 'Inter', sans-serif;
  --fe-radius: 8px;             /* border radius */
  --fe-content-font-size: 16px; /* body text size inside blocks */
  --fe-content-line-height: 1.7;
}
```

All available tokens:

| Variable | Default | Description |
|---|---|---|
| `--fe-blue` | `#3b82f6` | Accent / primary colour |
| `--fe-blue-hover` | `#2563eb` | Accent hover state |
| `--fe-text` | `#1e1e1e` | Primary text colour |
| `--fe-light-text` | `#6b7280` | Secondary / muted text |
| `--fe-bg` | `#f8f9fb` | Editor shell background |
| `--fe-canvas-bg` | `#ffffff` | Writing canvas background |
| `--fe-hover-bg` | `#f3f4f6` | Hover / subtle highlight |
| `--fe-border` | `#e2e4e9` | Border colour |
| `--fe-success` | `#10b981` | Success state colour |
| `--fe-danger` | `#ef4444` | Destructive / error colour |
| `--fe-radius` | `6px` | Border radius |
| `--fe-font` | system-ui stack | Font family |
| `--fe-shadow-sm` | subtle shadow | Subtle shadow — used on cards and panels |
| `--fe-shadow-md` | medium shadow | Medium shadow — used on dropdowns and modals |
| `--fe-header-height` | `60px` | Height of the sticky header bar |
| `--fe-status-bar-height` | `32px` | Height of the bottom status bar |
| `--fe-content-font-size` | `15px` | Body text size inside blocks |
| `--fe-content-line-height` | `1.65` | Body line height inside blocks |

---

## Keyboard Shortcuts

| Shortcut | Action |
|---|---|
| `Ctrl/Cmd + S` | Save |
| `Ctrl/Cmd + Z` | Undo |
| `Ctrl/Cmd + Shift + Z` / `Ctrl/Cmd + Y` | Redo |
| `Ctrl/Cmd + B` | Bold |
| `Ctrl/Cmd + I` | Italic |
| `Ctrl/Cmd + U` | Underline |
| `Ctrl/Cmd + K` | Insert link |
| `Ctrl/Cmd + A` | Select all (within current block) |
| `Arrow Up` (at block start) | Move to previous block |
| `Arrow Down` (at block end) | Move to next block |
| `Tab` (in table) | Move to next cell / create new row |
| `Shift + Tab` (in table) | Move to previous cell |
| `Tab` (in code block) | Insert 2 spaces |
| `/` (in empty block) | Open slash command menu |

---

## Events

```js
editor.on('change', (data) => { });     // Any content change
editor.on('save', (data) => { });       // Save triggered (Ctrl+S or API)
editor.on('publish', (data) => { });    // Publish triggered
editor.on('autosave', (data) => { });   // Auto-save interval tick
editor.on('block:select', (id) => { }); // Block selected
editor.on('input', (block) => { });     // Block content input
```

---

## Changelog

### v1.1.1

**Bug fixes**

- **List blocks** — bullet and ordered-list blocks now initialize with the correct `<ul><li>` / `<ol><li>` structure. Previously, empty list blocks rendered a bare `<br>`, which caused broken list editing behavior on first interaction.
- **Focus reliability** — block focus and caret positioning after clicking a block now uses double `requestAnimationFrame` instead of `setTimeout(0)`, preventing occasional cases where the caret landed in the wrong position before the DOM had fully painted.

**Improvements**

- **Click-to-deselect** — clicking anywhere on the editor canvas outside a block now deselects the active block and returns the editor to its idle state.
- **Theme tokens** — two new elevation CSS variables are available: `--fe-shadow-sm` and `--fe-shadow-md`. All CSS variable defaults have been refreshed to a cleaner, more neutral palette (new accent `#3b82f6`, updated greys, semantic colours, and layout tokens).

---

## Build from Source

```bash
npm install
npm run build
```

Output files in `dist/`:

- `nbd-editor.esm.js` — ES module
- `nbd-editor.cjs.js` — CommonJS
- `nbd-editor.umd.js` — UMD (for `<script>` tags)
- `nbd-editor.css` — Styles

---

## License

MIT — Noseberry Private Limited