npm package: @rtif-sdk/react
Description: React bindings for RTIF — zero-config to full-control
Author: coryrobinson42@gmail.com
Latest version: 2.14.0

# @rtif-sdk/react

React bindings for [RTIF](../../README.md) with zero-config to full-control DX.

```tsx
import { Editor } from '@rtif-sdk/react';
import '@rtif-sdk/react/styles/all.css';

function App() {
  return (
    <Editor
      preset="standard"
      placeholder="Start writing..."
      onChange={({ html, isEmpty }) => save(html)}
      formats={['html']}
    />
  );
}
```

## Install

```bash
npm install @rtif-sdk/react @rtif-sdk/core @rtif-sdk/engine @rtif-sdk/web

# Optional format plugins (for onChange serialization, defaultHTML, etc.)
npm install @rtif-sdk/format-html @rtif-sdk/format-markdown
```

Requires React 18 or 19 as a peer dependency.

## Styles

Import the aggregated stylesheet once at your app's entry point:

```ts
import '@rtif-sdk/react/styles/all.css';
```

This re-exports `@rtif-sdk/web/styles/all.css` which includes all component styles (tokens, marks, blocks, toolbar, pickers, features). All rules use `:where()` zero-specificity, so your CSS always wins.

For dark mode, add the optional dark theme after:

```ts
import '@rtif-sdk/react/styles/all.css';
import '@rtif-sdk/react/styles/dark.css';
```

Dark mode activates automatically via `prefers-color-scheme: dark`, the `rtif-dark` class, or the `dark` class (Tailwind/shadcn convention).

> All CSS source lives in `@rtif-sdk/web/styles/`. The React package re-exports for convenience. For selective imports and the full CSS reference, see `docs/styling.md` in the `@rtif-sdk/web` package.

## Usage Modes

### Zero config

Renders a default toolbar and content area. No plugins needed for basic text editing.

```tsx
<Editor />
```

### Preset

Named preset bundles (`plaintext`, `basic`, `standard`, `full`) provide curated plugin sets:

```tsx
<Editor preset="standard" placeholder="Type here..." />
```

| Preset | Plugins |
|--------|---------|
| `plaintext` | None |
| `basic` | bold, italic, underline, link |
| `standard` | basic + strikethrough, code, heading, list, blockquote, codeBlock, hr |
| `full` | standard + image, callout, embed, alignment, indent, textColor, backgroundColor, fontSize, fontFamily |

### Explicit plugins

For full control, pass an array of plugin factories:

```tsx
import { Editor, bold, italic, heading, link, preset } from '@rtif-sdk/react';

// Individual plugins
<Editor plugins={[bold(), italic(), heading(), link()]} />

// Preset with surgical overrides
<Editor plugins={preset('full', {
  overrides: { 'block-image': { upload: myUploader } },
  remove: ['block-embed'],
})} />
```

### Compound mode

When `<Editor.Content>` is a child, auto-layout is disabled and you control the layout:

```tsx
<Editor plugins={preset('standard')}>
  <Editor.Toolbar />
  <Editor.BubbleToolbar />
  <Editor.Content className="min-h-[300px] p-4" />
</Editor>
```

### Headless mode

Use `useEditor` + `EditorProvider` for maximum flexibility:

```tsx
import { useEditor, EditorProvider, EditorContent, preset } from '@rtif-sdk/react';

function MyEditor() {
  const editor = useEditor({ plugins: preset('standard') });

  return (
    <EditorProvider editor={editor}>
      <MyCustomToolbar />
      <EditorContent />
    </EditorProvider>
  );
}
```

## Compound Sub-Components

| Component | Description |
|-----------|-------------|
| `Editor.Content` | The contenteditable area |
| `Editor.Toolbar` | Default formatting toolbar — supports append children and render-prop customization |
| `Editor.BubbleToolbar` | Floating toolbar that appears on text selection |
| `Editor.MentionMenu` | Render-prop for mention dropdown UI |
| `Editor.SlashMenu` | Render-prop for slash command menu UI |

## Smart onChange

The `onChange` callback is debounced and serializes only the formats you request:

```tsx
<Editor
  preset="standard"
  onChange={({ doc, html, text, markdown, isEmpty, state }) => {
    console.log(html);    // only present if 'html' is in formats
    console.log(isEmpty); // true when document is empty
  }}
  formats={['html', 'text']}
  debounceMs={500}         // default: 300
  transientAttrs={['focused']} // strip before serialization
/>
```

Requires the corresponding format plugin to be installed (`@rtif-sdk/format-html`, `@rtif-sdk/format-markdown`). Serializers are loaded lazily on first use.

## Initial Content

```tsx
// From an RTIF document
<Editor defaultDoc={savedDoc} />

// From HTML (requires @rtif-sdk/format-html)
<Editor defaultHTML="<p>Hello <strong>world</strong></p>" />
```

## Imperative Ref

```tsx
import { useRef } from 'react';
import type { EditorRef } from '@rtif-sdk/react';

function MyEditor() {
  const ref = useRef<EditorRef>(null);

  return (
    <>
      <button onClick={() => ref.current?.focus()}>Focus</button>
      <button onClick={() => console.log(ref.current?.getHTML())}>Log HTML</button>
      <button onClick={() => ref.current?.clear()}>Clear</button>
      <Editor ref={ref} preset="standard" />
    </>
  );
}
```

### EditorRef methods

| Method | Returns | Description |
|--------|---------|-------------|
| `focus()` | `void` | Focus the editor |
| `blur()` | `void` | Blur the editor |
| `isFocused()` | `boolean` | Whether the editor is focused |
| `getDoc()` | `Document` | Current RTIF document |
| `getSelection()` | `Selection` | Current selection |
| `getHTML()` | `string` | HTML serialization (requires format-html) |
| `getText()` | `string` | Plain text serialization (requires format-plaintext) |
| `isEmpty()` | `boolean` | Whether the document is empty |
| `exec(cmd, payload?)` | `void` | Execute a named command |
| `isActive(cmd, payload?)` | `boolean` | Whether a command's effect is active |
| `canExecute(cmd, payload?)` | `boolean` | Whether a command can execute |
| `clear()` | `void` | Clear all content |

## Subscription Hooks

Reactive hooks for building custom UI. All use `useSyncExternalStore` for tear-free reads.

```tsx
import {
  useIsActive,
  useCanExecute,
  useMarksAtCursor,
  useBlockAtCursor,
  useEditorState,
  useEditorFocused,
  useFocusedBlock,
} from '@rtif-sdk/react';

function MyToolbar() {
  const editor = useEditorContext();
  const isBold = useIsActive(editor, 'toggleMark:bold');
  const canUndo = useCanExecute(editor, 'undo');
  const marks = useMarksAtCursor(editor);
  const block = useBlockAtCursor(editor);
  const focused = useEditorFocused(editor);

  return (
    <button
      aria-pressed={isBold}
      onMouseDown={(e) => { e.preventDefault(); editor.exec('toggleMark:bold'); }}
    >
      Bold
    </button>
  );
}
```

## Toolbar Components

Pre-built toolbar primitives that auto-reflect active/disabled state:

```tsx
import {
  Toolbar,
  ToolbarButton,
  ToolbarSeparator,
  ToolbarGroup,
  BoldButton,
  ItalicButton,
  HeadingSelect,
  DefaultToolbar,
  BubbleToolbar,
  ColorPicker,
  FontSizePicker,
  FontFamilyPicker,
} from '@rtif-sdk/react';

// Use pre-built buttons
<Toolbar>
  <BoldButton />
  <ItalicButton />
  <ToolbarSeparator />
  <ToolbarButton command="toggleMark:underline" label="Underline">U</ToolbarButton>
</Toolbar>

// Or the all-in-one default toolbar
<DefaultToolbar />

// Floating selection toolbar
<BubbleToolbar />

// Block type dropdown
<HeadingSelect />

// Pickers
<ColorPicker />
<FontSizePicker />
<FontFamilyPicker />
```

### Customizing the Default Toolbar

`<DefaultToolbar>` (also available as `<Editor.Toolbar>`) supports three modes:

```tsx
// 1. Default — standard layout, no customization
<Editor.Toolbar />

// 2. Append — add custom elements after the defaults
<Editor.Toolbar>
  <ColorPicker />
  <FontSizePicker />
</Editor.Toolbar>

// 3. Render prop — full layout control via sections object
<Editor.Toolbar>
  {(sections) => (
    <>
      {sections.undoRedo}
      <ToolbarSeparator />
      <MyCustomHeadingSelect />
      <ToolbarSeparator />
      {sections.formatMarks}
    </>
  )}
</Editor.Toolbar>
```

The render prop exposes pre-built groups (`undoRedo`, `headingSelect`, `formatMarks`, `link`) and individual buttons (`undo`, `redo`, `bold`, `italic`, `underline`, `strikethrough`, `code`).

Two helpers simplify common patterns:

```tsx
// sections.group() — render sections with auto-separators
{sections.group('undoRedo', 'formatMarks', 'link')}

// sections.without() — default layout minus excluded sections
{sections.without('headingSelect')}
```

## Mention Support

```tsx
<Editor
  preset="standard"
  mention={{
    triggerChar: '@',
    onKeyDown: (session, event) => {
      if (event.key === 'Enter') {
        session.accept({ id: '1', displayName: 'Alice' });
        return true;
      }
      return false;
    },
  }}
>
  <Editor.Content />
  <Editor.MentionMenu>
    {({ isActive, query, triggerRect, accept, dismiss }) =>
      isActive && (
        <MentionDropdown
          query={query}
          position={triggerRect}
          onSelect={(user) => accept({ id: user.id, displayName: user.name })}
          onClose={dismiss}
        />
      )
    }
  </Editor.MentionMenu>
</Editor>
```

## Slash Commands

```tsx
import { filterSlashItems } from '@rtif-sdk/react';

<Editor
  preset="standard"
  slashMenu={{
    triggerChar: '/',
    items: DEFAULT_SLASH_ITEMS, // or custom items
  }}
>
  <Editor.Content />
  <Editor.SlashMenu>
    {({ isActive, query, items, triggerRect, accept, dismiss }) =>
      isActive && (
        <SlashCommandMenu
          items={filterSlashItems(items, query)}
          position={triggerRect}
          onSelect={(item) => accept(item)}
          onClose={dismiss}
        />
      )
    }
  </Editor.SlashMenu>
</Editor>
```

## Read-Only Document Rendering

For feeds, previews, and static content display without editor overhead:

```tsx
import { DocumentView, DocumentViewProvider } from '@rtif-sdk/react';

// Single document
<DocumentView doc={post.doc} />

// Shared config across multiple views
<DocumentViewProvider marks={{ mention: mentionRenderer }}>
  {posts.map(p => <DocumentView key={p.id} doc={p.doc} />)}
</DocumentViewProvider>
```

## Multi-Block Selection

All mark and block type commands automatically handle multi-block selections. When the user selects across multiple blocks:

- **Format buttons** (bold, italic, color, etc.) apply to all selected blocks
- **Block type buttons** (heading, blockquote, list, etc.) change all selected blocks
- **Undo** reverts the entire multi-block change in one step

Configure how the toolbar reports active state for mixed-type selections via `blockTypeActiveStrategy` on the engine:

```tsx
const editor = useEditor({
  plugins: preset('standard'),
  blockTypeActiveStrategy: 'first', // or 'all' (default)
});
```

| Strategy | Behavior | Use case |
|----------|----------|----------|
| `'all'` | Active only when every selected block matches | Conservative default |
| `'first'` | Active when the first selected block matches | Google Docs/Notion behavior |

## Plugin System

### Built-in plugin factories

Every plugin is a factory function returning a unified `EditorPlugin`:

```tsx
import { bold, image, link } from '@rtif-sdk/react';

bold()                // EditorPlugin<BoldConfig>
image({ upload: fn }) // EditorPlugin<ImageConfig>
link()                // EditorPlugin<LinkConfig>
```

### Surgical overrides via `extend()`

```tsx
const customImage = image({ upload: myUploader }).extend({ maxFileSize: 10_000_000 });
```

### Preset customization

```tsx
const plugins = preset('full', {
  overrides: {
    'block-image': { upload: myUploader },
  },
  remove: ['block-embed'],
  add: [myCustomPlugin()],
});
```

### Creating custom plugins

Use `createEditorPlugin` to reduce boilerplate:

```tsx
import { createEditorPlugin } from '@rtif-sdk/react';

interface MyConfig {
  color?: string;
}

export const myPlugin = createEditorPlugin<MyConfig>('my-plugin', (config) => ({
  engine: createMyEnginePlugin(config),
  marks: { myMark: myRenderer },
}));

// Usage
const plugins = [myPlugin({ color: 'red' })];
```

### EditorFeatures (UI plugins)

UI features like block gutter, context menu, and link popover are tree-shakeable opt-ins:

```tsx
import { blockGutter, contextMenu, linkPopover, defaultFeatures } from '@rtif-sdk/react';

<Editor
  plugins={preset('standard')}
  features={[blockGutter(), contextMenu(), linkPopover()]}
/>

// Or use the convenience helper
<Editor plugins={preset('standard')} features={defaultFeatures()} />
```

`blockDrag()` provides drag-to-reorder with keyboard support and a theme-aware preview.
Use it as an alternative to `blockGutter` — do not use both at the same time.

```tsx
import { Editor, blockDrag } from '@rtif-sdk/react';

// Basic
<Editor features={[blockDrag()]} />

// With custom preview config
<Editor features={[blockDrag({ preview: { maxWidth: 400 }, keyboard: true })]} />
```

Alt+ArrowUp / Alt+ArrowDown reorder the focused block without dragging.

## CSS Custom Properties

All visual tokens are overridable via CSS custom properties. The toolbar-specific tokens defined by this package:

```css
:where(.rtif-toolbar) {
  --rtif-toolbar-bg: var(--rtif-surface, #fff);
  --rtif-toolbar-border: var(--rtif-border, #ccc);
  --rtif-toolbar-gap: 2px;
  --rtif-toolbar-padding: 4px 8px;
  --rtif-toolbar-button-size: 32px;
  --rtif-toolbar-button-radius: 4px;
  --rtif-toolbar-button-hover: var(--rtif-hover-bg, #e8e8e8);
  --rtif-toolbar-button-active: var(--rtif-active-bg, #d0e0ff);
  --rtif-content-padding: 16px;
  --rtif-content-font-size: 16px;
  --rtif-content-line-height: 1.6;
}
```

Base design tokens (`--rtif-accent-color`, `--rtif-surface`, `--rtif-border`, `--rtif-shadow`, `--rtif-focus-ring`), interactive state tokens (`--rtif-text-color`, `--rtif-hover-bg`, `--rtif-active-bg`, `--rtif-danger-color`, etc.), and content tokens (`--rtif-code-bg`, `--rtif-code-block-bg`, `--rtif-blockquote-text`, `--rtif-placeholder-color`, etc.) are defined by `@rtif-sdk/web` and inherited — not redeclared.

## Programmatic Theming

The `theme` prop sets CSS custom properties as inline styles without writing CSS:

```tsx
<Editor
  theme={{ surface: '#1a1a1a', textColor: '#e0e0e0', accentColor: '#4a9eff' }}
  preset="standard"
/>
```

The `EditorTheme` interface supports all 15 tokens. See the [Styling Guide](../web/docs/styling.md) for the full key-to-property mapping.

## Testing

A separate entry point provides test utilities:

```tsx
import { createMockEditorHandle, createDefaultDoc, isDocEmpty } from '@rtif-sdk/react/testing';

const editor = createMockEditorHandle();
// Use with <EditorContext.Provider value={editor}> in tests
```

## API Reference

### Components

| Export | Description |
|--------|-------------|
| `Editor` | Compound component with auto-layout |
| `EditorContent` | Contenteditable container |
| `EditorProvider` | Headless context provider |
| `DocumentView` | Read-only document renderer |
| `DocumentViewProvider` | Shared config for multiple DocumentViews |
| `Toolbar` | Toolbar container (`role="toolbar"`) |
| `ToolbarButton` | Command button with active/disabled state |
| `ToolbarSeparator` | Visual divider |
| `ToolbarGroup` | Button grouping |
| `DefaultToolbar` | Pre-composed toolbar with common buttons (supports append and render-prop children) |
| `HeadingSelect` | Block type dropdown |
| `BubbleToolbar` | Floating selection toolbar |
| `ColorPicker` | Text color picker |
| `FontSizePicker` | Font size picker |
| `FontFamilyPicker` | Font family picker |
| `BoldButton` ... `RedoButton` | Pre-built format buttons |

### Hooks

| Export | Description |
|--------|-------------|
| `useEditor` | Core lifecycle hook — creates engine + handle |
| `useEditorContext` | Access EditorHandle from context |
| `useIsActive` | Whether a command effect is active |
| `useCanExecute` | Whether a command can execute |
| `useMarksAtCursor` | Marks at the current cursor offset |
| `useBlockAtCursor` | Block at the current cursor offset |
| `useEditorState` | Full editor state (re-renders on every change) |
| `useEditorFocused` | Whether the editor is focused |
| `useFocusedBlock` | Currently focused block info |
| `useSmartOnChange` | Debounced, serialized onChange subscription |
| `useMentionPlugin` | Headless mention trigger hook |
| `useSlashCommandPlugin` | Headless slash command hook |
| `useTrigger` | Generic trigger hook |
| `useSavedSelection` | Saved selection range and text, with save/clear actions |

### Plugin Factories

| Export | ID | Type |
|--------|----|------|
| `bold()` | `mark-bold` | Mark |
| `italic()` | `mark-italic` | Mark |
| `underline()` | `mark-underline` | Mark |
| `strikethrough()` | `mark-strikethrough` | Mark |
| `code()` | `mark-code` | Mark |
| `textColor()` | `mark-textColor` | Mark |
| `fontSize()` | `mark-fontSize` | Mark |
| `fontFamily()` | `mark-fontFamily` | Mark |
| `link()` | `mark-link` | Mark |
| `heading()` | `block-heading` | Block |
| `list()` | `block-list` | Block |
| `blockquote()` | `block-blockquote` | Block |
| `codeBlock()` | `block-codeBlock` | Block |
| `hr()` | `block-hr` | Block |
| `image()` | `block-image` | Block |
| `callout()` | `block-callout` | Block |
| `embed()` | `block-embed` | Block |
| `alignment()` | `attr-alignment` | Attr |
| `indent()` | `attr-indent` | Attr |

### Utilities

| Export | Description |
|--------|-------------|
| `preset(name, options?)` | Resolve a preset name to plugins |
| `createEditorPlugin(id, build)` | Factory helper for custom plugins |
| `createDefaultDoc()` | Create an empty RTIF document |
| `isDocEmpty(doc)` | Check if a document is empty |
| `filterSlashItems(items, query)` | Filter slash command items by query |
| `getAvailableItems(items, ctx)` | Filter items by availability |
| `themeToStyle(theme)` | Convert `EditorTheme` to CSS custom property style object |
| `blockGutter(config?)` | EditorFeature: hover-revealed gutter with drag handle and add-block button |
| `contextMenu(config?)` | EditorFeature: right-click context menu |
| `linkPopover(config?)` | EditorFeature: inline link editing popover |
| `blockDrag(config?)` | EditorFeature: drag-to-reorder blocks with keyboard support and theme-aware preview |
| `defaultFeatures(config?)` | Convenience helper returning `[blockGutter(), contextMenu(), linkPopover()]` |

## License

MIT