瀏覽代碼

feat(editor): add extensibility hooks for rules, node views, and pattern specs

- Accept extraInlineRules/extraBlockRules in MarkdownRuleEngine constructor
- Create node-view-registry.ts with global registerNodeView() API
  (merged into EditorBlock.vue's nodeViews map at mount time)
- Accept extraPatterns prop on Editor/EditorBlock, pass to
  createPatternPlugin() — enables custom suggestion triggers like @mention
- Export PatternSpec, registerNodeView, BlockRule, MarkdownRule,
  ParsedDecorations, MarkdownRuleEngine from public API
- Add JSDoc on all new/changed APIs
- Add Extensibility section to README with code examples
Zander Hawke 4 天之前
父節點
當前提交
9f5085ad0b

+ 63 - 9
packages/editor/README.md

@@ -253,20 +253,74 @@ session?.respond("query")  // filter results
 session?.close()           // end the session
 ```
 
-### Custom Extensions
+## Extensibility
 
-Add inline syntax by creating a `MarkdownPattern`:
+The editor exposes three extension points for third-party code.
 
-```ts
-import type { MarkdownPattern } from "@enesis/editor"
+### 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 myPattern: MarkdownPattern = {
-  name: "custom",
-  parseNode: { name: "MyNode", type: "InlineCode" },
-  delimiter: { open: "~~", close: "~~" },
+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 |
+
 ## Development
 
 ```bash
@@ -299,7 +353,7 @@ pnpm dev          # Watch mode rebuild
 | `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/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 |

+ 2 - 0
packages/editor/src/components/Editor.vue

@@ -57,6 +57,7 @@ const props = withDefaults(
     debug?: string
     theme?: ThemeInput
     keyBinding?: KeyBinding
+    extraPatterns?: import("@/composables/usePatternPlugin").PatternSpec[]
   }>(),
   {
     focused: false,
@@ -624,6 +625,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
           :depth="block.depth"
           :debug="debug"
           :registry="registry"
+          :extra-patterns="extraPatterns"
           class="flex-1 min-w-0"
           @update:content="onBlockContentChange"
           @split="(before, after) => onSplit(i, before, after)"

+ 30 - 19
packages/editor/src/components/EditorBlock.vue

@@ -36,6 +36,7 @@ import { createPasteHandler } from "@/composables/usePasteHandler"
 import type {
   PatternClosePayload,
   PatternOpenPayload,
+  PatternSpec,
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
 import { createPatternPlugin } from "@/composables/usePatternPlugin"
@@ -74,6 +75,7 @@ import {
   toggleTask,
 } from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
+import { getRegisteredNodeViews } from "@/lib/node-view-registry"
 
 const content = defineModel<string>("content")
 
@@ -90,6 +92,11 @@ const props = defineProps<{
   // prop closure. Vue emits are unavailable in that context.
   onBoundaryExit?: (direction: "up" | "down") => void
   depth?: number
+  /**
+   * Extra pattern specs appended to the built-in triggers ([[, ((, /, #).
+   * Useful for adding custom suggestion menu triggers like @mention.
+   */
+  extraPatterns?: PatternSpec[]
 }>()
 
 const emit = defineEmits<{
@@ -255,26 +262,29 @@ watch(localSession, (session, prev) => {
   }
 })
 
-const patternPlugin = createPatternPlugin({
-  onPatternOpen: (payload) => {
-    localSession.value = payload
-    emit("pattern-open", payload)
-  },
-  onPatternUpdate: (payload) => {
-    if (!localSession.value) return
-    localSession.value = {
-      ...localSession.value,
-      query: payload.query,
-      position: payload.position,
-      range: payload.range,
-    }
-    emit("pattern-update", payload)
-  },
-  onPatternClose: (payload) => {
-    localSession.value = null
-    emit("pattern-close", payload)
+const patternPlugin = createPatternPlugin(
+  {
+    onPatternOpen: (payload) => {
+      localSession.value = payload
+      emit("pattern-open", payload)
+    },
+    onPatternUpdate: (payload) => {
+      if (!localSession.value) return
+      localSession.value = {
+        ...localSession.value,
+        query: payload.query,
+        position: payload.position,
+        range: payload.range,
+      }
+      emit("pattern-update", payload)
+    },
+    onPatternClose: (payload) => {
+      localSession.value = null
+      emit("pattern-close", payload)
+    },
   },
-})
+  props.extraPatterns,
+)
 
 function focusAt(cursorPosition?: FocusPosition) {
   if (!view) return
@@ -540,6 +550,7 @@ onMounted(() => {
         ),
       math_block: (node, view, getPos) =>
         new MathBlockView(node, view, () => getPos() ?? 0),
+      ...getRegisteredNodeViews(),
     },
     handleKeyDown,
     handleTextInput(_view, _from, _to, text) {

+ 22 - 5
packages/editor/src/composables/usePatternPlugin.ts

@@ -129,6 +129,7 @@ function getTextBeforeCursor(
 function findPattern(
   state: EditorState,
   prevSession: PatternSession | null,
+  specs: PatternSpec[],
 ): PatternSession | null {
   const { $head } = state.selection
   const parentNode = $head.parent
@@ -139,7 +140,7 @@ function findPattern(
 
   const { textBefore } = info
 
-  for (const spec of PATTERN_SPECS) {
+  for (const spec of specs) {
     const match = matchPattern(textBefore, spec)
     if (!match) continue
 
@@ -254,13 +255,15 @@ function isBlockRefComplete(query: string): boolean {
 class PatternView {
   private view: EditorView
   private callbacks: PatternCallbacks
+  private specs: PatternSpec[]
   private currentSession: PatternSession | null = null
   private manualClose = false
   private log = createLogger("PatternPlugin")
 
-  constructor(view: EditorView, callbacks: PatternCallbacks) {
+  constructor(view: EditorView, callbacks: PatternCallbacks, specs: PatternSpec[]) {
     this.view = view
     this.callbacks = callbacks
+    this.specs = specs
   }
 
   update(view: EditorView) {
@@ -327,7 +330,7 @@ class PatternView {
       return
     }
 
-    const spec = PATTERN_SPECS.find((s) => s.start === session.trigger)
+    const spec = this.specs.find((s) => s.start === session.trigger)
     if (!spec) {
       this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
       return
@@ -418,9 +421,22 @@ class PatternView {
   destroy() {}
 }
 
+/**
+ * Create a ProseMirror plugin that detects trigger patterns at the cursor
+ * and emits open/update/close lifecycle events via `callbacks`.
+ *
+ * @param callbacks - Event handlers for pattern session lifecycle.
+ * @param extraPatterns - Additional pattern specs appended after the built-in
+ *   ones ([[, ((, /, #). Merged and sorted by priority.
+ */
 export function createPatternPlugin(
   callbacks: PatternCallbacks,
+  extraPatterns?: PatternSpec[],
 ): Plugin<PatternSession | null> {
+  const allSpecs = [...PATTERN_SPECS, ...(extraPatterns ?? [])].sort(
+    (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
+  )
+
   return new Plugin<PatternSession | null>({
     key: patternPluginKey,
     state: {
@@ -432,9 +448,10 @@ export function createPatternPlugin(
         newState: EditorState,
       ) {
         if (tr.getMeta(forceCloseMetaKey)) return null
-        return findPattern(newState, prevSession)
+        return findPattern(newState, prevSession, allSpecs)
       },
     },
-    view: (editorView: EditorView) => new PatternView(editorView, callbacks),
+    view: (editorView: EditorView) =>
+      new PatternView(editorView, callbacks, allSpecs),
   })
 }

+ 4 - 0
packages/editor/src/index.ts

@@ -47,6 +47,7 @@ export type {
   PatternKind,
   PatternOpenPayload,
   PatternSession,
+  PatternSpec,
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
 export type { KeyBinding, KeyboardAction, KeyboardConfig } from "@/composables/useKeyboard"
@@ -62,6 +63,9 @@ export {
   useMentionGroups,
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
+export { registerNodeView } from "@/lib/node-view-registry"
+export type { BlockRule, MarkdownRule, ParsedDecorations } from "@/lib/markdown-rules"
+export { MarkdownRuleEngine } from "@/lib/markdown-rules"
 export { EditorBlock, Editor, EditorSuggestionMenu, EditorToolbar }
 
 export default {

+ 18 - 3
packages/editor/src/lib/markdown-rules/engine.ts

@@ -93,10 +93,25 @@ export class MarkdownRuleEngine {
   private readonly blockRules: ReadonlyArray<BlockRule>
   private readonly blockRuleIndex: ReadonlyMap<string, ReadonlyArray<BlockRule>>
 
-  constructor() {
-    this.inlineRules = createInlineRules()
+  /**
+   * @param options.extraInlineRules - Additional inline rules appended after
+   *   the built-in ones (bold, italic, code, links, refs, tags, math, highlight).
+   * @param options.extraBlockRules - Additional block rules appended after
+   *   the built-in ones (headings, blockquotes, tasks, tables, callouts, hr).
+   */
+  constructor(options?: {
+    extraInlineRules?: MarkdownRule<unknown>[]
+    extraBlockRules?: BlockRule[]
+  }) {
+    this.inlineRules = [
+      ...createInlineRules(),
+      ...(options?.extraInlineRules ?? []),
+    ]
     this.inlineRuleIndex = buildRuleIndex(this.inlineRules)
-    this.blockRules = createBlockRules()
+    this.blockRules = [
+      ...createBlockRules(),
+      ...(options?.extraBlockRules ?? []),
+    ]
     this.blockRuleIndex = buildBlockRuleIndex(this.blockRules)
   }
 

+ 40 - 0
packages/editor/src/lib/node-view-registry.ts

@@ -0,0 +1,40 @@
+/**
+ * Node view registry — lets third-party code register ProseMirror node views
+ * without modifying EditorBlock.vue.
+ *
+ * Usage:
+ * ```typescript
+ * import { registerNodeView } from "@enesis/editor"
+ *
+ * registerNodeView("my_block", (node, view, getPos) => new MyNodeView(node, view, getPos))
+ * ```
+ */
+
+import type { Node } from "prosemirror-model"
+import type { NodeView } from "prosemirror-view"
+
+export type NodeViewFactory = (
+  node: Node,
+  view: Parameters<
+    Exclude<import("prosemirror-view").EditorProps["nodeViews"], undefined>[string]
+  >[1],
+  getPos: () => number,
+) => NodeView
+
+const registry = new Map<string, NodeViewFactory>()
+
+export function registerNodeView(name: string, factory: NodeViewFactory): void {
+  registry.set(name, factory)
+}
+
+export function getRegisteredNodeViews(): Record<string, NodeViewFactory> {
+  const result: Record<string, NodeViewFactory> = {}
+  for (const [name, factory] of registry) {
+    result[name] = factory
+  }
+  return result
+}
+
+export function clearRegisteredNodeViews(): void {
+  registry.clear()
+}