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

@reopt-ai/opt-editor

v2.0.0

Published

AI-first content editor with schema-driven streaming and inline editing.

Readme

@reopt-ai/opt-editor

AI-first content editor with schema-driven streaming and inline editing.

Features

  • Tri-Mode Architecture: Stream mode (AI patches) + Edit mode (contentEditable) + Diff mode (review)
  • RFC 6902 JSON Patch: Standard-compliant streaming protocol
  • Flat Element Map: ID-based paths eliminate index shift problems
  • Canonical Rich Text Content: Text-bearing blocks store inline segments in content
  • Catalog System: Block type registry with AI prompt auto-generation
  • Zero Dependencies: Only react and react-dom as peer deps

Agent skill setup (recommended)

From the consumer project root:

npx skills add reopt-ai/reopt-skills/opt-editor-install

Then ask your agent: Use the opt-editor-install skill to set up @reopt-ai/opt-editor in this project and verify the result. Add with AI streaming to the request when needed. The skill distinguishes a new install from an upgrade, idempotently updates the reopt marker block in AGENTS.md (or CLAUDE.md), reads this installed package's dist/docs/, wires the required stylesheet, and runs the appropriate checks. The source of truth is reopt-ai/reopt-skills.

The commands below are the manual fallback.

Quick Start

npm install @reopt-ai/opt-editor
import {
  createEditorStore,
  defaultCatalog,
  Editor,
} from "@reopt-ai/opt-editor";

const store = createEditorStore();

function App() {
  return <Editor store={store} catalog={defaultCatalog} mode="edit" />;
}

Breaking Change (Major)

EditorMode is now:

type EditorMode = "stream" | "edit" | "diff";

If your consumer app uses exhaustive mode checks (for example switch (mode)), you must handle "diff" explicitly.

AI Streaming

import { StreamCompiler } from "@reopt-ai/opt-editor";

const compiler = new StreamCompiler();

// Feed NDJSON chunks from AI response
for (const chunk of aiStream) {
  const ops = compiler.feed(chunk);
  if (ops.length > 0) {
    store.applyPatch(ops);
  }
}
compiler.flush();

Generate the system prompt for your AI:

const systemPrompt = defaultCatalog.prompt();
// Includes document schema, block types, and patch examples

AI SDK Integration

Use the @reopt-ai/opt-editor/ai-sdk adapters for AI streaming — they handle transport, error surfaces, and patch application:

import { useEditorAI } from "@reopt-ai/opt-editor/ai-sdk";

const ai = useEditorAI({ store, catalog, api: "/api/editor-ai" });

// One-shot generation: patches stream into a draft suggestion layer
<button onClick={() => ai.generate("Write a blog post")}>Generate</button>;
{
  ai.suggestion && (
    <>
      <button onClick={ai.approve}>Apply</button>
      <button onClick={ai.reject}>Discard</button>
    </>
  );
}

Server side, createEditorHandler (from @reopt-ai/opt-editor/server) pairs with the adapter and requires AI SDK v7 (ai >= 7).

extractSSEText / buildAIMessages are deprecated — kept only for backward compatibility with hand-rolled SSE transports. New integrations should use the ai-sdk adapters above.

Block Types

| Type | Attrs | Content | Edit Support | | ------------ | ----------------------- | --------- | ----------------------------- | | paragraph | — | rich text | contentEditable, split/merge | | heading | level | rich text | contentEditable, split/merge | | list | ordered | — | Container for list-items | | list-item | — | rich text | contentEditable | | todo-list | — | — | Container for todo-items | | todo-item | checked | rich text | contentEditable, click toggle | | todo-list | — | — | Container for todo-items | | todo-item | checked | rich text | contentEditable, click toggle | | quote | — | rich text | contentEditable, split/merge | | code | code, language | — | contentEditable | | divider | — | — | — | | table | — | — | Container | | table-row | — | — | Container (skipWrapper) | | table-cell | header | rich text | contentEditable (skipWrapper) | | image | src, alt, caption | — | — | | video | src, caption | — | — | | file | url, name, size | — | — | | embed | url, title | — | — | | callout | variant | rich text | contentEditable |

Custom Blocks

import { defineCatalog } from "@reopt-ai/opt-editor";

const catalog = defineCatalog({
  ...defaultCatalog.blocks,
  callout: {
    type: "callout",
    attrsSchema: { variant: "string" },
    contentKind: "rich-text",
    component: CalloutBlock,
    prompt: "A callout box. Variants: info, warning, error.",
  },
});

skipWrapper — opting out of BlockWrapper

For blocks that must render as direct children of specific HTML elements (table rows, table cells), set skipWrapper: true to suppress the default <BlockWrapper> div:

const myBlockDef: BlockDefinition = {
  type: "table-row",
  attrsSchema: {},
  component: TableRowBlock,
  skipWrapper: true, // renders <tr> directly inside <tbody>, not inside a div
};

Input Contract

@reopt-ai/opt-editor only accepts EditorSpec as its document format. Legacy Slate or Plate JSON must be converted in the consumer app before it is passed to createEditorStore(), Editor, or StaticRenderer.

Rich-text blocks are canonical only in V2:

  • paragraph, heading, quote, list-item, table-cell, callout
  • text always lives in content

To migrate stored JSON once:

bun run migrate:v1-to-v2 ./document.json > ./document.v2.json

Store API

const store = createEditorStore(initialSpec, options);

store.getSpec(); // Flat EditorSpec
store.getTree(); // BlockNode[] (cached, reset on mutation)
store.applyPatch(ops); // RFC 6902 patches
store.updateElement(id, attrs);
store.updateContent(id, content);
store.insertElement(parentId, index, element);
store.removeElement(id);
store.moveElement(id, parentId, index);
store.subscribe(listener); // useSyncExternalStore-compatible
store.undo();
store.redo();
store.canUndo();
store.canRedo();
store.getLastChangedIds(); // ReadonlySet<string> — IDs changed in last mutation

EditorStoreOptions

export interface EditorStoreOptions {
  /** Batch rapid updateElement/updateContent calls on the same field into a single undo entry (ms). Default: 300. */
  batchWindow?: number;
}

// Example: batch typing into a single undo step per 300ms idle window
const store = createEditorStore(spec, { batchWindow: 300 });

Without batchWindow, every updateElement / updateContent call pushes a new undo entry. With batchWindow: 300, consecutive calls on the same element field within 300 ms share a single history entry.

Selection Context

The editor context is split into two React contexts to avoid re-render cascades:

import { useEditor, useEditorSelection } from "@reopt-ai/opt-editor";

// useEditor() — stable context (store, catalog, mode, stateStore, etc.)
// Re-renders only when provider props change.
function MyBlock() {
  const { store, mode } = useEditor();
  // ...
}

// useEditorSelection() — selection only (selectedBlockId, onSelectBlock)
// Re-renders on every block select/deselect.
function SelectionAware() {
  const { selectedBlockId, onSelectBlock } = useEditorSelection();
  // ...
}

Components that don't need selection state should use useEditor() — they will not re-render when the user clicks between blocks.

Block Toolbar

<BlockToolbar> renders when a block is selected in edit mode. It provides delete, move up/down, and type conversion:

import { BlockToolbar } from "@reopt-ai/opt-editor";

// Inside an EditorProvider — renders for whichever block is currently selected
<BlockToolbar />

// Or pin it to a specific block
<BlockToolbar blockId="p1" />

Actions: Delete, Move Up, Move Down, Convert to (Paragraph, Heading 1-3, Quote, Code).

Paste Handling

Multi-line paste is automatically intercepted in edit mode. The paste handler:

  • Single-line text: falls through to the browser's default behavior.
  • Multi-line Markdown text: parses supported Markdown blocks and inserts them after the currently focused block.
  • HTML paste: strips tags to plain text first, then applies the same multi-line parsing path.
  • Image files: inserts an image block with a local data URL preview, then swaps in the uploaded URL when onFileUpload is provided.

No configuration needed for standard paste handling. BlockTree wires it at the editor-tree level so nested blocks such as list items and table cells use the same path.

Keyboard Shortcuts

All shortcuts are active in edit mode only.

Text Formatting

| Shortcut | Action | Markdown | | ------------------ | ---------------- | ----------- | | Ctrl+B / Cmd+B | Bold 토글 | **...** | | Ctrl+I / Cmd+I | Italic 토글 | *...* | | Ctrl+E / Cmd+E | Inline code 토글 | `...` |

선택 영역이 이미 마커로 감싸져 있으면 제거 (토글 off).

Block Navigation

| Shortcut | Action | | ---------------------------- | -------------------------------------------- | | ArrowUp (커서가 블록 처음) | 이전 블록 끝으로 포커스 이동 | | ArrowDown (커서가 블록 끝) | 다음 블록 처음으로 포커스 이동 | | Escape | 편집 모드 → 블록 선택 모드 (wrapper 포커스) | | Enter (선택 모드) | 블록 선택 모드 → 편집 모드 (editable 포커스) | | ArrowUp/Down (선택 모드) | 블록 간 탐색 |

Block Operations

| Shortcut | Action | | ---------------------------------- | ------------------------------------------ | | Enter | 커서 위치에서 블록 분할 | | Shift+Enter | 소프트 줄바꿈 (\n 삽입, 블록 분할 안 함) | | Backspace (커서가 블록 처음) | 이전 블록과 병합 | | Backspace / Delete (선택 모드) | 선택된 블록 삭제 | | Alt+ArrowUp | 블록을 위로 이동 | | Alt+ArrowDown | 블록을 아래로 이동 | | Ctrl+D / Cmd+D | 블록 복제 (바로 아래에 삽입) | | Tab | 블록 들여쓰기 (+1 레벨, 최대 3) | | Shift+Tab | 블록 내어쓰기 (-1 레벨, 최소 0) | | / | 슬래시 커맨드 메뉴 (새 블록 타입 삽입) |

Markdown Auto-Convert

편집 모드에서 paragraph 시작 부분에 아래를 입력하면 자동 변환:

| Input | Result | | ------- | ---------- | | # | Heading 1 | | ## | Heading 2 | | ### | Heading 3 | | > | Blockquote | | ``` | Code block | | --- | Divider |

Static Rendering

For published content (blogs, docs, CMS preview), use StaticRenderer or specToHtml instead of the full <Editor>. Zero client-side JS, no store/context overhead.

React Server Component

import { StaticRenderer } from "@reopt-ai/opt-editor";

// Works as RSC — no "use client", no hooks, no state
export default function BlogPost({ spec }) {
  return <StaticRenderer spec={spec} className="prose" />;
}

Renders clean semantic HTML: <article><h1>, <p>, <blockquote>, <table>, etc. No data-block-id, no wrappers, no event handlers.

HTML String (emails, RSS, static files)

import { specToHtml } from "@reopt-ai/opt-editor";

const html = specToHtml(spec);
// → "<h1>Title</h1>\n<p>Body with <strong>bold</strong></p>"

No React dependency in the output. HTML entities are escaped for XSS safety.

Comparison

| | <Editor mode="edit"> | <Editor mode="stream"> | <Editor mode="diff"> | <StaticRenderer> | specToHtml() | | ---------------- | ---------------------- | ------------------------ | ---------------------- | ------------------ | -------------- | | Editing | Yes | No | No | No | No | | Store required | Yes | Yes | Yes | No | No | | Client JS | Yes | Yes | Yes | No (RSC) | No | | Event handlers | Yes | Some | Review actions | None | N/A | | HTML cleanliness | Editor attrs | Editor attrs | Editor attrs | Clean | Clean | | Use case | Editor UI | Live streaming | AI suggestion review | Published content | Email/RSS/SSG |

Inline Marks

Parse and serialize markdown-style inline formatting:

import { parseInlineMarks } from "@reopt-ai/opt-editor";

parseInlineMarks("Hello **bold** and *italic* text");
// → [{ text: "Hello ", marks: [] }, { text: "bold", marks: ["bold"] }, ...]

Architecture

EditorSpec (flat)           BlockNode[] (tree)
┌──────────────────┐       ┌──────────────┐
│ root: ["h1","p1"] │──────▶│ toTree()     │──▶ React render
│ elements: {       │       │ fromTree()   │
│   h1: {...},      │◀──────│              │
│   p1: {...},      │       └──────────────┘
│ }                 │
└──────────────────┘
      ▲        ▲
      │        │
   Stream/Edit/Diff
   (runtime mode)

Delta-based history: Only the element IDs that changed in each mutation are stored. On undo/redo, only the affected slice of the spec is restored — not a full clone. For large documents (500+ blocks), undo memory usage drops by ~95%.

Context split: EditorCtx (stable) and SelectionCtx (changes per click) are separate React contexts. Blocks that do not consume SelectionCtx are shielded from re-renders when the user clicks between blocks.

Tree caching: store.getTree() returns the same array reference until the next mutation. No re-computation on concurrent React renders.

License

MIT