npm package: @rtif-sdk/engine
Description: RTIF editor engine, history, and plugin host
Author: coryrobinson42@gmail.com
Latest version: 2.14.0

# @rtif-sdk/engine

Editor engine for RTIF (Rich Text Input Format). Manages document state, dispatches operations through a plugin lifecycle, and provides undo/redo history.

## Install

```bash
npm install @rtif-sdk/engine
```

## Usage

### Create an engine

```typescript
import { createEngine } from '@rtif-sdk/engine';
import type { Document } from '@rtif-sdk/core';

const doc: Document = {
  version: 1,
  blocks: [{ id: 'b1', type: 'text', spans: [{ text: '' }] }],
};

const engine = createEngine(doc);
```

### Dispatch operations

```typescript
engine.dispatch({ type: 'insert_text', offset: 0, text: 'Hello' });

console.log(engine.state.doc.blocks[0].spans[0].text); // 'Hello'
```

### Undo / Redo

```typescript
engine.undo(); // reverts to empty document
engine.redo(); // re-applies 'Hello'
```

### Subscribe to changes

```typescript
const unsubscribe = engine.onChange((state) => {
  console.log('Document changed:', state.doc);
});
```

### Plugins

```typescript
import type { Plugin } from '@rtif-sdk/engine';

const myPlugin: Plugin = {
  id: 'my-plugin',
  init(engine) {
    engine.registerCommand('greet', (eng) => {
      eng.dispatch({ type: 'insert_text', offset: 0, text: 'Hi! ' });
    });
  },
};

engine.use(myPlugin);
engine.exec('greet');
```

## Plugin Lifecycle

1. `beforeApply` hooks — inspect, modify, or cancel operations
2. `apply()` — core applies operations to the document
3. `afterApply` hooks — observe changes, trigger side effects
4. `onChange` listeners — UI re-renders

Plugin errors are caught and isolated — a failing plugin never breaks the editor.

## Transactions

Group multiple dispatches into a single undo entry. Useful for multi-step edits that should undo as one unit.

```typescript
const tx = engine.transaction();

engine.dispatch({ type: 'delete_text', offset: 0, count: 5 });
engine.dispatch({ type: 'insert_text', offset: 0, text: 'Hello' });

tx.commit(); // Single undo group
```

### Snapshot-based undo

For complex replacements where composed inverse ops are fragile, commit with `useSnapshot: true` to capture the pre-transaction document state and restore it on undo:

```typescript
const tx = engine.transaction();
// ... many dispatches ...
tx.commit({ useSnapshot: true }); // Undo restores exact pre-transaction state
```

### Transaction API

```typescript
interface Transaction {
  readonly isOpen: boolean;
  commit(options?: TransactionCommitOptions): void;
  rollback(): void;
}

interface TransactionCommitOptions {
  useSnapshot?: boolean;
}
```

Dispatching while a transaction from a different source is already open throws `RtifError('TRANSACTION_ACTIVE')`.

## Streaming Plugin

Replace a range of content incrementally (e.g., AI-generated text streaming in). Built on transactions for atomic undo.

```typescript
import { streamingPlugin, StreamingCommands } from '@rtif-sdk/engine';

const { plugin, startStream } = streamingPlugin();
engine.use(plugin);

const session = startStream(engine, {
  startOffset: 0,
  endOffset: 10,
  onSession(s) { /* streaming started */ },
  onCommit() { /* streaming finished */ },
  onCancel() { /* streaming cancelled */ },
});

// Append text chunks
session.append({ text: 'Hello ' });
session.append({ text: 'world', marks: { bold: true } });
session.append({ text: '', blockBreak: true }); // New block

// Or replace with fully deserialized blocks
session.replaceContent(deserializedBlocks);

session.commit();  // Finalize (single undo group)
// or session.cancel(); // Roll back everything
```

### StreamingChunk

```typescript
interface StreamingChunk {
  text: string;
  marks?: Record<string, unknown>;
  blockBreak?: boolean;
  blockType?: string;
  blockAttrs?: Record<string, unknown>;
}
```

### StreamingConfig

```typescript
interface StreamingConfig {
  startOffset: number;
  endOffset: number;
  onSession?: (session: StreamingSession) => void;
  onCommit?: () => void;
  onCancel?: () => void;
}
```

### Stream Insert (Cursor-Position)

Insert new content at the cursor without requiring a selection. The LLM's output controls all block types, marks, and structure — no boundary corrections are applied.

```typescript
import { createStreamInsert } from '@rtif-sdk/engine';

// Start session at cursor (startOffset === endOffset — no deletion)
const session = streaming.startStream(engine, {
  startOffset: cursor, endOffset: cursor,
});

const insert = createStreamInsert({ session, deserialize });

for await (const token of llm.stream(prompt)) {
  insert.pushToken(token);
}
insert.commit();
```

### Stream Rewrite (Selection Range)

Replace a selected range with boundary-aware corrections. Preserves the prefix block type when the selection starts mid-block, and splits the suffix when types differ.

```typescript
import { computeSelectionBoundaries, createStreamRewrite } from '@rtif-sdk/engine';

// 1. Capture boundaries before deletion
const boundaries = computeSelectionBoundaries(doc, start, end);
// 2. Start session (deletes original range)
const session = streaming.startStream(engine, { startOffset: start, endOffset: end });
// 3. Wrap with boundary corrections
const rewrite = createStreamRewrite({ session, engine, deserialize, boundaries });

for await (const token of llm.stream(prompt)) {
  rewrite.pushToken(token);
}
rewrite.commit();
```

### StreamSession (Generic)

Both `StreamInsertSession` and `StreamRewriteSession` extend the shared `StreamSession` interface. Use it when handling either mode generically:

```typescript
import type { StreamSession } from '@rtif-sdk/engine';

let stream: StreamSession;
if (hasSelection) {
  stream = createStreamRewrite({ ... });
} else {
  stream = createStreamInsert({ ... });
}
```

## Format Stream Adapter

Bridge a format deserializer (e.g., markdown, HTML) for incremental streaming. Each token push re-deserializes the accumulated text and replaces the streamed content.

```typescript
import { createStreamingAdapter } from '@rtif-sdk/engine';
import { deserialize } from '@rtif-sdk/format-markdown';

const adapter = createStreamingAdapter(deserialize);

// As tokens arrive from an LLM:
for (const token of tokens) {
  const blocks = adapter.push(token); // Re-deserializes accumulated text
  session.replaceContent(blocks);
}

session.commit();
```

## Additional Exports

| Export | Description |
|--------|-------------|
| `blockMovePlugin` | Engine plugin that registers block-move operations and keyboard shortcuts. |
| `BlockMoveCommands` | Command name constants for block move (`BlockMoveCommands.MOVE_UP`, `BlockMoveCommands.MOVE_DOWN`). |
| `computeMoveBlockOps(doc, blockId, direction)` | Returns the composed operation array to move a block up or down without a new core op. |
| `computeAttrsDiff(prev, next)` | Returns the minimal `attrs` patch (with `null` removals) needed to transform `prev` into `next`. Used internally by move ops; useful for custom block-mutation commands. |
| `streamingPlugin()` | Factory returning `{ plugin, startStream }` for streaming content replacement. |
| `StreamingCommands` | Command name constants (`START`, `CANCEL`). |
| `createStreamingAdapter(deserialize)` | Creates a format-aware streaming adapter for incremental re-deserialization. |
| `createStreamInsert({ session, deserialize })` | Cursor-position insert — no boundary corrections. |
| `createStreamRewrite({ session, engine, deserialize, boundaries })` | Selection-range rewrite with boundary-aware corrections. |
| `computeSelectionBoundaries(doc, start, end)` | Compute block-type boundary metadata for rewrite sessions. |
| `buildRewriteContext(doc, start, end, serialize)` | Build LLM rewrite context (boundaries + serialized selection). |
| `buildRewritePrompt({ selected, instruction, ... })` | Optional prompt builder for LLM rewrites. |
| `extractRtifSlice(doc, start, end)` | Extract a slice of blocks/spans from a document range. |
| `deleteSelectionOps(doc, start, end)` | Compute `delete_text` + `merge_block` ops for a cross-block range. |

## License

MIT