|
|
1 روز پیش | |
|---|---|---|
| .. | ||
| e2e | 1 روز پیش | |
| src | 1 روز پیش | |
| README.md | 2 روز پیش | |
| package.json | 1 روز پیش | |
| playwright.config.ts | 1 روز پیش | |
| tsconfig.app.json | 1 هفته پیش | |
| tsconfig.json | 1 ماه پیش | |
| tsconfig.node.json | 1 ماه پیش | |
| vite.config.ts | 1 روز پیش | |
| vitest.config.ts | 1 روز پیش | |
| vitest.setup.ts | 4 روز پیش | |
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.
pnpm add @enesis/editor
Requires peer dependencies: vue, @nuxt/ui, @vueuse/core, pinia.
<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>
import EnesisEditor from "@enesis/editor"
app.use(EnesisEditor)
// → <Block /> registered globally
<Block> API| 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). |
| 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 |
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> APIThe 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.
| 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). |
#toolbarThe 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. |
| 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. |
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(),
})
[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.
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.
| 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 |
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> APIA 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.
| 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. |
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
| Event | Trigger |
|---|---|
close |
Item selected (via click or Enter) or Escape pressed. Parent should clear the pattern session. |
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 |
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
The editor exposes three extension points for third-party code.
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: [],
})
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.
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 |
Each block has a draggable bullet in its left gutter. The bullet is always visible, transitions from muted to primary on hover, and grows (size-6 → size-7) with primary color while being dragged. Drop targets are the insertion zones between blocks — a primary-colored 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.
MoveBlockOp in the shell's OperationHistory (inverse swaps fromIndex / toIndex)moveBlock(blocks, fromIndex, toIndex) — pure function in editor-operations.tsMoveBlockOp — operation type with fromIndex, toIndex, blockId, content, depth, and timestamp
import { moveBlock } from "@enesis/editor"
import type { MoveBlockOp } from "@enesis/editor"
const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
import { moveBlock } from "@enesis/editor"
import type { MoveBlockOp } from "@enesis/editor"
const reordered = moveBlock(blocks, 0, 2) // move block 0 to index 2
pnpm build # Build library
pnpm test # Run tests (Vitest)
pnpm dev # Watch mode rebuild
| 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() |