# @enesis/editor

A headless block markdown editor component for Vue 3. Each **Block** is a self-contained ProseMirror editor instance that renders markdown syntax as visual decorations via a Lezer-based parsing pipeline.

## Installation

```bash
pnpm add @enesis/editor
```

Requires peer dependencies: `vue`, `@nuxt/ui`, `@vueuse/core`, `pinia`.

## Usage

```vue
<script setup lang="ts">
import { Editor, EditorBlock } from "@enesis/editor"
import { ref } from "vue"

const content = ref("# Hello\n\nThis is **bold** text.\n\n> [!NOTE] A callout")
</script>

<template>
  <EditorBlock v-model:content="content" :focused="true" cursor-position="end" />
</template>
```

### Global registration

```ts
import EnesisEditor from "@enesis/editor"
app.use(EnesisEditor)
// → <EditorBlock /> registered globally
```

## `<EditorBlock>` API

### Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `content` (v-model) | `string` | required | Markdown content of the block |
| `focused` | `boolean` | `false` | Whether the block receives focus on mount |
| `cursorPosition` | `"start" \| "end" \| number` | — | Cursor placement on focus |
| `markerMode` | `"live-preview" \| "always-visible"` | `"live-preview"` | When to show markdown delimiters |
| `debug` | `string` | — | Namespace filter for debug logs (e.g. `"CodeBlockView"`, `"*"`) |
| `onBoundaryExit` | `(dir: "up" \| "down") => void` | — | Called when code-block cursor exits at a doc boundary. |
| `registry` | `FocusRegistry` | — | Cross-block focus registry (see `useFocusRegistry`). |
| `depth` | `number` | — | List-item indent depth (0–10). |

### Emits

| Event | Payload | Trigger |
|---|---|---|
| `change` | `content: string` | Content edited |
| `split` | `before: string, after: string` | Enter pressed |
| `merge-previous` | — | Backspace at document start |
| `delete-if-empty` | — | Backspace on empty block |
| `indent` / `outdent` | — | Tab / Shift+Tab |
| `arrow-up-from-start` | — | Cursor at first line start |
| `arrow-down-from-end` | — | Cursor at last line end |
| `pattern-open` | `PatternOpenPayload` | Trigger pattern detected |
| `pattern-update` | `PatternUpdatePayload` | Pattern query changed |
| `pattern-close` | `PatternClosePayload` | Pattern session ended |
| `focus` | `{ view, handlers }` | Block received focus |
| `blur` | — | Block lost focus |
| `selection-change` | `{ from, to, empty }` | Selection changed |
| `error` | `{ code, message?, blockId? }` | Mount failure, KaTeX parse error, or parser crash |

### Exposed methods

```ts
const blockRef = ref<BlockExposed>()

blockRef.value?.view          // ProseMirror EditorView
blockRef.value?.focus(pos?)   // Programmatic focus
blockRef.value?.getContent()  // Current markdown content
blockRef.value?.setContent(md)// Set content externally
```

## `<Editor>` API

The `Editor` component manages a list of blocks with insertion zones between them. Each block is a `* ` list item in the unified markdown string. The editor owns parsing, serialization, split/merge, indent/outdent, and undo/redo.

### Props

| Prop | Type | Default | Description |
|---|---|---|---|---|
| `content` (v-model) | `string` | required | Full document markdown (all blocks serialized). |
| `focused` | `"first-block" \| "last-block" \| "first-zone" \| "last-zone" \| { kind: "block"; id: string } \| { kind: "zone"; index: number } \| true` | — | Where to place focus on mount. String values target positions; object values target specific blocks or zones; `true` focuses the first block. |
| `markerMode` | `"live-preview" \| "always-visible"` | `"live-preview"` | When to show markdown delimiters. |
| `debug` | `string` | — | Namespace filter for debug logs. |
| `theme` | `ThemeInput` | — | CSS-variable theme preset or overrides (see `theme.ts`). |
| `keyBinding` | `KeyBinding` | `defaultKeyBinding` | Swappable keyboard handler (`defaultKeyBinding` / `vimKeyBinding`). |

### Scoped Slot: `#toolbar`

The editor forwards a `#toolbar` slot with bindings from the focused block:

```vue
<Editor v-model:content="md">
  <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
    <EditorToolbar :handlers="handlers" :selection-version="selectionVersion" />
  </template>
</Editor>
```

| Binding | Type | Description |
|---|---|---|
| `handlers` | `FormattingHandlers \| null` | Handler object from the focused block, or `null` if blurred. |
| `selectionVersion` | `number` | Incremented on each selection change — use as watch key. |
| `canUndo` | `boolean` | Whether undo history has entries. |
| `canRedo` | `boolean` | Whether redo history has entries. |
| `undo` | `() => void` | Pop and apply the inverse of the last operation. |
| `redo` | `() => void` | Re-apply the last undone operation. |

### Emits

| Event | Payload | Trigger |
|---|---|---|
| `focus` | `{ view, handlers }` | Any block received focus. |
| `blur` | — | All blocks lost focus. |
| `selection-change` | `{ from, to, empty }` | Selection changed in the active block. |
| `error` | `{ code, message?, blockId? }` | Mount failure, KaTeX parse error, or parser crash from any block. |

### Exposed methods

```ts
const editorRef = ref<{
  undo: () => void
  redo: () => void
  canUndo: Ref<boolean>
  canRedo: Ref<boolean>
  getHistoryState: () => { canUndo: boolean; canRedo: boolean }
  applyHistory: (op: EditorOperation) => void
}>()

editorRef.value?.undo()
editorRef.value?.redo()
editorRef.value?.getHistoryState()           // inspect undo/redo availability
editorRef.value?.applyHistory({              // push an operation into the history stack
  type: "set-block-content",
  blockId: "abc",
  previousContent: "old",
  newContent: "new",
  timestamp: Date.now(),
})
```

## Architecture

### Parsing Pipeline

```
[Raw block content string]
       │
       ├── Phase 1: Lezer AST Walk
       │     ├─ enter → block rules (headings, blockquotes, callouts, tasks)
       │     └─ leave → inline rules (bold, italic, code, links, refs, tags, highlights)
       │
       ├── Phase 2: Property Extraction (key:: value)
       │
       ▼
[ParsedDecorations — unified output struct]
```

The pipeline runs inside a **ProseMirror decoration plugin** (`useMarkdownDecorations.ts`). Results are cached per-paragraph and rebuilt only on content change. Target: <16ms per parse cycle.

### Decoration Model

Formatting is purely decorative — markdown syntax is stored as plain text in ProseMirror and rendered via decorations. Delimiters (`**`, `[[`, `^^`, etc.) are `hidden` ranges that disappear when the cursor is inside the content (live-preview mode) or are always visible.

### Supported Syntax

| Element | Example | Decoration Class |
|---|---|---|
| Bold | `**text**` | `md-strong` |
| Italic | `*text*` | `md-em` |
| Inline Code | `` `code` `` | `md-code` |
| Strikethrough | `~~text~~` | `md-strike` |
| Link | `[text](url)` | `md-link` |
| Page Reference | `[[Page Name\|Alias]]` | `md-page-ref` |
| Block Reference | `((block-id))` | `md-block-ref` |
| Tag | `#tag` | `md-tag` |
| Highlight | `^^text^^` | `md-highlight` |
| Heading | `# H1` through `###### H6` | `md-heading-content` |
| Blockquote | `> text` | `md-blockquote` |
| Callout | `> [!NOTE] text` | `md-callout` |
| Task | `TODO task text` | `md-task-marker` / `md-task-content` |
| Property | `key:: value` | `md-property-*` |
| Inline Math | `$...$` | `md-math-inline` (+ KaTeX preview on blur) |
| Display Math | `$$...$$` | MathBlock node view (+ KaTeX preview) |
| Table | `\| A \| B \|` / `\|---|---\|` / `\| 1 \| 2 \|` | Blurred: rendered HTML widget via `renderCellContent()`. Focused: \`\|\` decorators, editable raw source. |
| Fenced Code Block | `` ```python `` / `~~~js` | CM6 node view |

### Table Rendering

Tables are parsed by Lezer GFM as `Table` nodes (detected on full re-parse, not incrementally — see `invalidateChangedCaches` in `useMarkdownDecorations.ts`).

| State | Behavior |
|---|---|
| **Blurred** | Source hidden (`md-table-hidden`). A `Decoration.widget` renders the HTML table via `renderTableHtml()` → `renderCellContent()`, which runs each cell through the Lezer parsing pipeline to convert inline markdown (bold, italic, code, links, page-refs, inline-math, etc.) to HTML. |
| **Focused** | Source revealed. Pipe `\|` characters are styled as `md-decorator`, leaving cell content editable as raw markdown so inline rules (bold, links, math) work via live decorations. |

Clicking the rendered table focuses the block and reveals the source. All inline patterns (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`, `$...$`, `[[page]]`, `((id))`, `#tag`, `~~strike~~`, `^^high^^`) render correctly in blurred cells.

## `<EditorSuggestionMenu>` API

A self-contained suggestion listbox for slash commands (`/`) and mention completions (`[[`, `((`, `#`). Rendered inline with `position: absolute` inside a positioned parent (the `EditorBlock` wrapper); no teleport, so the menu scrolls naturally with content. Keyboard navigation is driven externally through the `forwardKey` method — no synthetic DOM events, no external UI library dependency for list navigation.

### Props

| Prop | Type | Description |
|---|---|---|
| `groups` | `CommandPaletteGroup[]` | Grouped items matching Nuxt UI's `CommandPaletteGroup` shape (`.id`, `.label`, `.items[]` with `.label`, `.icon`, `.suffix`, `.kbds`, `.onSelect`). |
| `position` | `{ x: number, y: number }` | Container-relative offset (viewport coords minus the positioned parent's bounding rect). Menu is offset 24px below. |
| `query` | `string` | Current search text — shown in the empty-state message. |
| `active` | `boolean` | Show/hide the menu. Resets `activeIndex` to 0 on transition. |

### Exposed methods

```ts
const menuRef = ref<{ forwardKey: (key: string) => void }>()

menuRef.value?.forwardKey("ArrowDown")  // highlight next item
menuRef.value?.forwardKey("ArrowUp")    // highlight previous item
menuRef.value?.forwardKey("Enter")      // select highlighted item
menuRef.value?.forwardKey("Escape")     // close menu
```

### Emits

| Event | Trigger |
|---|---|
| `close` | Item selected (via click or Enter) or Escape pressed. Parent should clear the pattern session. |

### Item shape

Each item in a group accepts the following fields (from `CommandPaletteItem`):

| Field | Type | Rendered as |
|---|---|---|
| `icon` | `string` (Iconify class) | `<UIcon :name="..." />` |
| `label` | `string` | Item text |
| `suffix` | `string` | Trailing muted text (right-aligned) |
| `kbds` | `Array<string \| object>` | Small `<kbd>` badges (replaces suffix) |
| `disabled` | `boolean` | Dims item and blocks selection |
| `onSelect` | `(e: Event) => void` | Called when the item is chosen |

### Pattern Detection

As the user types, a ProseMirror `ViewPlugin` (`usePatternPlugin.ts`) detects trigger characters and emits lifecycle events:

| Trigger | Pattern Kind | Terminator |
|---|---|---|
| `[[` | `reference` | `]]` |
| `((` | `embed` | `))` |
| `/` | `command` | whitespace |
| `#` | `tag` | whitespace |

```ts
import { getPatternSession } from "@enesis/editor"

// Inside a pattern-open handler:
const session = getPatternSession(view.state)
session?.respond("query")  // filter results
session?.close()           // end the session
```

## Extensibility

The editor exposes three extension points for third-party code.

### 1. Custom Decoration Rules (MarkdownRuleEngine)

Add new inline or block-level syntax by passing extra rules to the engine. Rules are standard Lezer AST visitors — same API as the built-in ones.

```typescript
import { MarkdownRuleEngine, type BlockRule, type MarkdownRule } from "@enesis/editor"

const myInlineRule: MarkdownRule<unknown> = {
  name: "my-inline",
  nodeTypes: ["MyNodeType"],
  match(node) { return node.type.name === "MyNodeType" },
  run(node, ctx) {
    return [{ type: "content", from: node.from, to: node.to, className: "my-decoration" }]
  },
}

const engine = new MarkdownRuleEngine({
  extraInlineRules: [myInlineRule],
  extraBlockRules: [],
})
```

### 2. Custom Node Views (registerNodeView)

Register ProseMirror `NodeView` factories for custom PM node types without modifying EditorBlock.vue.

```typescript
import { registerNodeView } from "@enesis/editor"

registerNodeView("embed_block", (node, view, getPos) => {
  // Return a NodeView instance — see prosemirror-view docs
  return new MyEmbedView(node, view, getPos)
})
```

Factories are merged with the built-in `code_block` and `math_block` views at mount time. The `NodeViewFactory` signature is `(node, view, getPos) => NodeView`.

### 3. Custom Pattern Triggers (extraPatterns)

Add new suggestion menu triggers (e.g. `@mention`, `:emoji`) by passing extra `PatternSpec` entries to the Editor or EditorBlock component.

```vue
<script setup lang="ts">
import type { PatternSpec } from "@enesis/editor"

const extraPatterns: PatternSpec[] = [
  { start: "@", kind: "command" as const, termination: "boundary", priority: 2 },
]
</script>

<template>
  <Editor :extra-patterns="extraPatterns" />
</template>
```

Each pattern spec defines:
| Field | Type | Description |
|---|---|---|
| `start` | `string` | Trigger character(s) like `[[`, `@`, `:` |
| `end` | `string` (optional) | Closing delimiter for delimiter-terminated patterns |
| `kind` | `PatternKind` | Logical group — `reference`, `embed`, `command`, `tag` |
| `termination` | `"delimiter" \| "boundary"` | `delimiter` matches until `end` is found; `boundary` matches a single word |
| `priority` | `number` (optional) | Higher priority wins when multiple specs match |

## Drag-to-Reorder

Each block has a draggable grip in its left gutter. The grip is hidden at rest (`opacity-0`), fades in on block hover, and transitions from muted to primary color while being dragged. Drop targets are the insertion zones between blocks — a primary-tinted bar appears on the zone during a valid hover. Dragging to a zone before the first block, between any two blocks, or after the last block inserts the block at that position.

- **Native HTML5 drag** — no external drag library required
- **Zone-based drop** — drop on any insertion zone, not on the block itself
- **Undo support** — each drag records a `MoveBlockOp` in the shell's `OperationHistory` (inverse swaps `fromIndex` / `toIndex`)
- **`moveBlock(blocks, fromIndex, toIndex)`** — pure function in `editor-operations.ts`
- **`MoveBlockOp`** — operation type with `fromIndex`, `toIndex`, `blockId`, `content`, `depth`, and `timestamp`

```typescript
import { moveBlock } from "@enesis/editor"
import type { MoveBlockOp } from "@enesis/editor"

const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
```

## Development

```bash
pnpm build        # Build library
pnpm test         # Run tests (Vitest)
pnpm dev          # Watch mode rebuild
```

## Key Files

| File | Purpose |
|---|---|
| `src/index.ts` | Public API surface |
| `src/components/EditorBlock.vue` | ProseMirror block editor component |
| `src/components/Editor.vue` | Multi-block shell with insertion zones, split/merge, undo/redo |
| `src/components/EditorInsertionZone.vue` | Invisible click target between blocks |
| `src/components/EditorToolbar.vue` | Shared toolbar bound to active block's handlers |
| `src/components/EditorSuggestionMenu.vue` | Slash-command and mention suggestion palette |
| `src/composables/useKeyboard.ts` | Capture-phase keyboard handler with swappable keybindings |
| `src/composables/useSuggestionGroups.ts` | Suggestion menu group builders (slash, mention) |
| `src/composables/useMarkdownDecorations.ts` | Decoration plugin (live-preview, caching) |
| `src/composables/useBlockKeyboardHandlers.ts` | Keyboard event handler |
| `src/composables/useCodeBlockView.ts` | CM6 NodeView for fenced code blocks |
| `src/composables/useMathBlockView.ts` | KaTeX NodeView for display math blocks |
| `src/composables/usePatternPlugin.ts` | Pattern detection plugin |
| `src/composables/usePasteHandler.ts` | Multi-line paste split handler |
| `src/composables/useFocusRegistry.ts` | Cross-block focus management |
| `src/lib/block-parser.ts` | `splitMarkdownIntoBlocks` / `serializeBlocks` |
| `src/lib/content-model.ts` | `contentToDoc(md)` / `docToContent(doc)` — Markdown ↔ ProseMirror |
| `src/lib/formatting.ts` | Formatting handlers — toggle/wrap/insert for bold, link, math, page refs, tags, heading, task |
| `src/lib/operation-history.ts` | `EditorOperation` types + undo/redo stack |
| `src/lib/theme.ts` | CSS-variable theme system with presets |
| `src/lib/node-view-registry.ts` | Global `registerNodeView()` API for custom PM NodeViews |
| `src/lib/auto-close-plugin.ts` | Auto-close for ```, **, _ and bracket pairs |
| `src/lib/logger.ts` | Namespace-based debug logger |
| `src/lib/schema.ts` | ProseMirror schema |
| `src/lib/markdown-parser.ts` | Lezer parser configuration |
| `src/lib/markdown-extensions.ts` | Custom Lezer node definitions (PageRef, BlockRef, Tag, Highlight, InlineMath, Task) |
| `src/lib/markdown-rules/engine.ts` | MarkdownRuleEngine — Lezer AST walk, property extraction |
| `src/lib/markdown-rules/inline-rules.ts` | Inline decoration rules |
| `src/lib/markdown-rules/block-rules.ts` | Block decoration rules (`createTableRule`, callout, task, etc.) |
| `src/lib/markdown-rules/types.ts` | Shared type definitions |
| `src/lib/markdown-rules/block-classifier.ts` | Regex first-line classifier (paste only) |
| `src/composables/useMarkdownDecorations.ts` | Decoration plugin + `renderTableHtml()` / `renderCellContent()` |
