@reopt-ai/opt-editor
v2.0.0
Published
AI-first content editor with schema-driven streaming and inline editing.
Maintainers
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
reactandreact-domas peer deps
Agent skill setup (recommended)
From the consumer project root:
npx skills add reopt-ai/reopt-skills/opt-editor-installThen 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-editorimport {
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 examplesAI 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/buildAIMessagesare 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.jsonStore 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 mutationEditorStoreOptions
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
onFileUploadis 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
