Zander Hawke 9d99703fff test(editor): add paste handling unit tests for zone and block paste paths 4 дней назад
..
src 9d99703fff test(editor): add paste handling unit tests for zone and block paste paths 3 дней назад
README.md 58c8257fa5 feat(editor): add error boundaries for KaTeX, parser failures, and mount crashes 3 дней назад
package.json 95b1a5380b fix(editor): resolve code review findings across shell, toolbar, and suggestion menu 4 дней назад
tsconfig.app.json fff9911f7c refactor(editor): fix TypeScript errors, path alias setup, and CI deploy 1 неделя назад
tsconfig.json 9a21611c2f feat: implement core block editor with markdown decorations 1 месяц назад
tsconfig.node.json 9a21611c2f feat: implement core block editor with markdown decorations 1 месяц назад
vite.config.ts 1ed764ac38 feat(editor): add EditorToolbar with Nuxt UI, FormattingHandlers, and focus-preserving refocus 1 неделя назад
vitest.config.ts 95b1a5380b fix(editor): resolve code review findings across shell, toolbar, and suggestion menu 4 дней назад
vitest.setup.ts 95b1a5380b fix(editor): resolve code review findings across shell, toolbar, and suggestion menu 4 дней назад

README.md

@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

pnpm add @enesis/editor

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

Usage

<script setup lang="ts">
import { Block } from "@enesis/editor"
import { ref } from "vue"

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

<template>
  <Block v-model:content="content" focused cursor-position="end" />
</template>

Global registration

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

<Block> 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

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 boolean false Whether the first block receives focus on mount.
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:

<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

const editorRef = ref<{ undo: () => void; redo: () => void; canUndo: Ref<boolean>; canRedo: Ref<boolean> }>()

editorRef.value?.undo()
editorRef.value?.redo()

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 |`
Fenced Code Block python/~~~js` CM6 node view
Highlight ^^text^^ md-highlight

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

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
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.

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.

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.

<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 |

Development

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()