Zander Hawke 02c1a37d92 feat(editor): add unified suggestion menu, keyboard composable, and toolbar modal prompts 5 дней назад
..
src 02c1a37d92 feat(editor): add unified suggestion menu, keyboard composable, and toolbar modal prompts 5 дней назад
README.md 5c82c7b008 docs: update AGENTS and READMEs to match current architecture 5 дней назад
package.json 707b4105ab feat(editor): strip code block fences from CM6 input, add header with lang selector + delete 1 неделя назад
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 fff9911f7c refactor(editor): fix TypeScript errors, path alias setup, and CI deploy 1 неделя назад
vitest.setup.ts 9a21611c2f feat: implement core block editor with markdown decorations 1 месяц назад

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

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

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.

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

Priority flags ([#A], [#B], [#C]) and date emoji (📅 YYYY-MM-DD) were removed from the parser — they render as plain text. Styling classes remain for backward compatibility.

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

Custom Extensions

Add inline syntax by creating a MarkdownPattern:

import type { MarkdownPattern } from "@enesis/editor"

const myPattern: MarkdownPattern = {
  name: "custom",
  parseNode: { name: "MyNode", type: "InlineCode" },
  delimiter: { open: "~~", close: "~~" },
}

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/Block.vue ProseMirror block editor component
src/components/Editor.vue Multi-block shell with insertion zones, split/merge, undo/redo
src/components/InsertionZone.vue Invisible click target between blocks
src/components/EditorToolbar.vue Shared toolbar bound to active block's handlers
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/katex.ts Dynamic KaTeX import + render helpers
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
src/lib/markdown-rules/inline-rules.ts Inline decoration rules
src/lib/markdown-rules/block-rules.ts Block decoration rules
src/lib/markdown-rules/types.ts Shared type definitions
src/lib/markdown-rules/block-classifier.ts Regex first-line classifier (paste only)