فهرست منبع

refactor: migrate focus/mode to plugin state, consolidate auto-close, extract paste handler

- Migrate `hasFocus` / `markerMode` from mutable closures to `DecorationState` plugin
  state via transaction meta, with `decorationVersion` cache invalidation key
- Extract `createPasteHandler` from Block.vue into `usePasteHandler.ts`
- Consolidate fence-on-Enter into `tripleBacktickRule` auto-close (fires on third
  backtick, not Enter)
- Add `createPairRule` / `createToggleRule` factories with backspace delete-pair
- Fix callout continuation tracking across separate callout groups
- Guard context/word-boundary checks to auto-close only (not wrap-selection)
- Guard `decorationVersion` increment against no-op focus transitions
Zander Hawke 1 هفته پیش
والد
کامیت
63dfeca250

+ 40 - 0
AGENTS.md

@@ -257,6 +257,46 @@ If console output is too noisy, filter to `info`+: `localStorage.setItem("editor
 
 ---
 
+## Auto-Close Plugin
+
+Defined in `packages/editor/src/lib/auto-close-plugin.ts`. A ProseMirror `Plugin` that intercepts text input (`handleTextInput`) and backspace (`handleDOMEvents.keydown`) to auto-close bracket pairs, toggle delimiters, and create code blocks.
+
+### Factories
+
+| Factory | Purpose | Returns |
+|---|---|---|
+| `createPairRule(open, close, opts?)` | Brackets `()`, `[]`, `{}`, `""`, `''` | `AutoCloseRule` |
+| `createToggleRule(delimiter, opts?)` | Multi-char toggles `**`, `~~` | `AutoCloseRule` |
+
+### `createPairRule` behavior
+
+- **Auto-close**: typing `(` inserts `()` with cursor between
+- **Skip-over**: typing `)` when next char is `)` moves cursor past it (only when no text selected)
+- **Wrap selection**: typing `(` with selected text wraps it as `(selected)` with cursor after `)`
+- **Delete-pair**: Backspace between `()` deletes both characters
+- **Context option**: regex filters when auto-close fires (e.g. `"` only after whitespace/structural chars)
+
+### `createToggleRule` behavior
+
+- Requires multi-character delimiter (`**`, `~~`, not `*` or `~`)
+- Typing the last character of the delimiter when prefix already present inserts delimiter pair, cursor between
+- **Word boundary**: optional `wordBoundary: true` prevents mid-word triggering
+
+### Structural rules (built-in)
+
+| Rule | Trigger | Action |
+|---|---|---|
+| `tripleBacktickRule` | ``` at paragraph start on third `` ` `` | Replaces paragraph with `code_block` node, cursor after opening fence |
+| `doubleAsteriskRule` | `**` (via `createToggleRule`) | Inserts `****`, cursor between |
+| `doubleTildeRule` | `~~` (via `createToggleRule`) | Inserts `~~~~`, cursor between |
+| `singleUnderscoreRule` | `_` at word boundary | Inserts `__`, cursor between; no auto-close mid-word |
+
+### Registration
+
+In `Block.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plugins` array. Rules are evaluated in order; the first matching rule handles the event.
+
+---
+
 ## Performance Constraints
 
 - Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `Block.vue` instances.

+ 31 - 114
packages/editor/src/components/Block.vue

@@ -9,6 +9,7 @@ import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
 } from "@/composables/useMarkdownDecorations"
+import { createPasteHandler } from "@/composables/usePasteHandler"
 import type {
   PatternClosePayload,
   PatternOpenPayload,
@@ -17,14 +18,14 @@ import type {
 import { createPatternPlugin } from "@/composables/usePatternPlugin"
 import {
   autoClosePlugin,
+  createPairRule,
   doubleAsteriskRule,
+  doubleTildeRule,
   singleUnderscoreRule,
   tripleBacktickRule,
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
-import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
-import { schema } from "@/lib/schema"
 
 const content = defineModel<string>("content")
 
@@ -54,18 +55,15 @@ const emit = defineEmits<{
 
 const log = createLogger("Block", props.debug)
 
-const handleKeyDown = createBlockKeyboardHandler(
-  {
-    split: (before, after) => emit("split", before, after),
-    "merge-previous": () => emit("merge-previous"),
-    "delete-if-empty": () => emit("delete-if-empty"),
-    indent: () => emit("indent"),
-    outdent: () => emit("outdent"),
-    "arrow-up-from-start": () => emit("arrow-up-from-start"),
-    "arrow-down-from-end": () => emit("arrow-down-from-end"),
-  },
-  props.debug,
-)
+const handleKeyDown = createBlockKeyboardHandler({
+  split: (before, after) => emit("split", before, after),
+  "merge-previous": () => emit("merge-previous"),
+  "delete-if-empty": () => emit("delete-if-empty"),
+  indent: () => emit("indent"),
+  outdent: () => emit("outdent"),
+  "arrow-up-from-start": () => emit("arrow-up-from-start"),
+  "arrow-down-from-end": () => emit("arrow-down-from-end"),
+})
 
 const formattingHandlers = {
   toggleBold: () => {
@@ -118,11 +116,9 @@ const formattingHandlers = {
 const editorRef = useTemplateRef("editorRef")
 let view: EditorView | null = null
 
-const {
-  plugin: markdownDecorationsPlugin,
-  setFocus: setDecorationsFocus,
-  setMarkerMode: updateMarkerMode,
-} = createMarkdownDecorationsPlugin(props.markerMode ?? "live-preview")
+const { plugin: markdownDecorationsPlugin } = createMarkdownDecorationsPlugin(
+  props.markerMode ?? "live-preview",
+)
 
 const patternPlugin = createPatternPlugin({
   onPatternOpen: (payload) => emit("pattern-open", payload),
@@ -185,7 +181,13 @@ onMounted(() => {
       autoClosePlugin([
         tripleBacktickRule,
         doubleAsteriskRule,
+        doubleTildeRule,
         singleUnderscoreRule,
+        createPairRule("(", ")"),
+        createPairRule("[", "]"),
+        createPairRule("{", "}"),
+        createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
+        createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
       ]),
     ],
   })
@@ -200,88 +202,9 @@ onMounted(() => {
         new CodeBlockView(node, view, () => getPos() ?? 0, props.debug),
     },
     handleKeyDown,
-    handlePaste(view, event) {
-      const text = event.clipboardData?.getData("text/plain")
-      if (!text) return false
-
-      const lines = text.split("\n")
-      if (lines.length <= 1) return false
-
-      const classifications = lines.map((line) => classifyBlock(line))
-      const firstType = classifications[0]!.kind
-
-      const isContinuationBlock = (kind: string) =>
-        kind === "blockquote" &&
-        (firstType === "callout" || firstType === "blockquote")
-
-      const hasMixedTypes = classifications
-        .slice(1)
-        .some(
-          (c) =>
-            c.kind !== "paragraph" &&
-            !isContinuationBlock(c.kind) &&
-            c.kind !== firstType,
-        )
-      log.debug("paste", {
-        lines: lines.length,
-        firstType,
-        hasMixedTypes,
-        kinds: classifications.map((c) => c.kind),
-      })
-      if (!hasMixedTypes) return false
-
-      event.preventDefault()
-
-      const { $from } = view.state.selection
-      const beforeDoc = view.state.doc.slice(0, $from.pos)
-      const afterDoc = view.state.doc.slice(
-        $from.pos,
-        view.state.doc.content.size,
-      )
-      const beforeText = docToContent(
-        schema.nodes.doc.create(null, beforeDoc.content),
-      )
-      const afterText = docToContent(
-        schema.nodes.doc.create(null, afterDoc.content),
-      )
-
-      const firstGroupLines: string[] = [lines[0]!]
-      const restLines: string[] = []
-
-      let foundBreak = false
-      for (let i = 1; i < lines.length; i++) {
-        const c = classifications[i]!
-        const line = lines[i]!
-        if (!foundBreak && (c.kind === firstType || c.kind === "paragraph")) {
-          firstGroupLines.push(line)
-        } else {
-          foundBreak = true
-          restLines.push(line)
-        }
-      }
-
-      const groupContent = firstGroupLines.join("\n")
-      const newBefore = beforeText
-        ? `${beforeText}\n${groupContent}`
-        : groupContent
-      const afterSuffix = afterText
-        ? `${restLines.join("\n")}\n${afterText}`
-        : restLines.join("\n")
-
-      const newDoc = contentToDoc(newBefore)
-      const tr = view.state.tr.replaceWith(
-        0,
-        view.state.doc.content.size,
-        newDoc.content,
-      )
-      view.dispatch(tr)
-
-      if (afterSuffix) {
-        emit("split", newBefore, afterSuffix)
-      }
-
-      return true
-    },
+    handlePaste: createPasteHandler((before, after) =>
+      emit("split", before, after),
+    ),
     dispatchTransaction(transaction: Transaction) {
       const currentView = view
       if (!currentView) return
@@ -307,28 +230,24 @@ onMounted(() => {
     },
     handleDOMEvents: {
       focus() {
-        setDecorationsFocus(true)
         if (view) {
-          // Dispatch a no-op transaction to force ProseMirror to re-evaluate
-          // props.decorations. The hasFocus flag lives in a closure outside
-          // ProseMirror's state machine, so without this transaction the
-          // decoration set won't rebuild until the next keystroke.
-          view.dispatch(view.state.tr)
+          view.dispatch(view.state.tr.setMeta("decorations:focus", true))
           emit("focus", { view, handlers: formattingHandlers })
         }
         return false
       },
       blur() {
-        setDecorationsFocus(false)
-        if (view) view.dispatch(view.state.tr)
+        if (view) {
+          view.dispatch(view.state.tr.setMeta("decorations:focus", false))
+        }
         emit("blur")
         return false
       },
     },
   })
 
-  if (props.focused) {
-    setDecorationsFocus(true)
+  if (props.focused && view) {
+    view.dispatch(view.state.tr.setMeta("decorations:focus", true))
     focusAt(props.cursorPosition)
   }
 })
@@ -344,8 +263,7 @@ watch(
   () => [props.focused, props.cursorPosition] as const,
   ([focused, cursorPosition]) => {
     if (!view) return
-    setDecorationsFocus(focused)
-    view.dispatch(view.state.tr)
+    view.dispatch(view.state.tr.setMeta("decorations:focus", !!focused))
     if (focused) focusAt(cursorPosition)
   },
 )
@@ -354,7 +272,6 @@ watch(
   () => props.markerMode,
   (newMode) => {
     if (!view || !newMode) return
-    updateMarkerMode(newMode)
     view.dispatch(view.state.tr.setMeta("markerMode", newMode))
   },
 )

+ 20 - 12
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -1,7 +1,7 @@
 import { EditorState } from "prosemirror-state"
 import { describe, expect, it } from "vitest"
-import { contentToDoc } from "@/lib/content-model"
 import { createMarkdownDecorationsPlugin } from "@/composables/useMarkdownDecorations"
+import { contentToDoc } from "@/lib/content-model"
 
 describe("Markdown decorations plugin", () => {
   it("builds decorations on init with content when blurred", () => {
@@ -22,7 +22,7 @@ describe("Markdown decorations plugin", () => {
   })
 
   it("returns decorations with faded markers when focused", () => {
-    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
     const editorState = EditorState.create({
@@ -30,15 +30,17 @@ describe("Markdown decorations plugin", () => {
       plugins: [plugin],
     })
 
-    setFocus(true)
+    const focusedState = editorState.apply(
+      editorState.tr.setMeta("decorations:focus", true),
+    )
 
-    const decorations = plugin.spec.props?.decorations?.(editorState)
+    const decorations = plugin.spec.props?.decorations?.(focusedState)
     // Should have decorations (structural content), not empty
     expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("returns full decorations when blurred", () => {
-    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
     const editorState = EditorState.create({
@@ -46,14 +48,16 @@ describe("Markdown decorations plugin", () => {
       plugins: [plugin],
     })
 
-    setFocus(false)
+    const blurredState = editorState.apply(
+      editorState.tr.setMeta("decorations:focus", false),
+    )
 
-    const decorations = plugin.spec.props?.decorations?.(editorState)
+    const decorations = plugin.spec.props?.decorations?.(blurredState)
     expect(decorations?.find().length).toBeGreaterThan(0)
   })
 
   it("toggles marker visibility based on focus state", () => {
-    const { plugin, setFocus } = createMarkdownDecorationsPlugin()
+    const { plugin } = createMarkdownDecorationsPlugin()
     const doc = contentToDoc("Hello **world**")
 
     const editorState = EditorState.create({
@@ -61,12 +65,16 @@ describe("Markdown decorations plugin", () => {
       plugins: [plugin],
     })
 
-    setFocus(true)
-    const focusedDecorations = plugin.spec.props?.decorations?.(editorState)
+    const focusedState = editorState.apply(
+      editorState.tr.setMeta("decorations:focus", true),
+    )
+    const focusedDecorations = plugin.spec.props?.decorations?.(focusedState)
     expect(focusedDecorations?.find().length).toBeGreaterThan(0)
 
-    setFocus(false)
-    const blurredDecorations = plugin.spec.props?.decorations?.(editorState)
+    const blurredState = editorState.apply(
+      editorState.tr.setMeta("decorations:focus", false),
+    )
+    const blurredDecorations = plugin.spec.props?.decorations?.(blurredState)
     expect(blurredDecorations?.find().length).toBeGreaterThan(0)
   })
 

+ 2 - 44
packages/editor/src/composables/useBlockKeyboardHandlers.ts

@@ -18,10 +18,9 @@ import {
   joinBackward,
   joinForward,
 } from "prosemirror-commands"
-import { type EditorState, TextSelection } from "prosemirror-state"
+import type { EditorState } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
 import { docToContent } from "@/lib/content-model"
-import { createLogger } from "@/lib/logger"
 import { schema } from "@/lib/schema"
 
 export interface BlockKeyboardHandlers {
@@ -52,11 +51,7 @@ function isSingleEmptyParagraph(state: EditorState): boolean {
   return doc.childCount === 1 && doc.child(0).content.size === 0
 }
 
-export function createBlockKeyboardHandler(
-  handlers: BlockKeyboardHandlers,
-  componentFilter?: string,
-) {
-  const log = createLogger("Block.Enter", componentFilter)
+export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
   return function handleKeyDown(
     view: EditorView,
     event: KeyboardEvent,
@@ -84,43 +79,6 @@ export function createBlockKeyboardHandler(
       const { doc, selection } = state
       const { from } = selection
 
-      // Check if cursor is at end of a fenced code line (``` / ~~~)
-      const $head = state.selection.$head
-      const parent = $head.parent
-      log.debug("Enter pressed", {
-        parentType: parent.type.name,
-        textContent: `"${parent.textContent}"`,
-        cursorPos: $head.pos,
-        paraEnd: $head.end(),
-        atEnd: atParagraphEnd(state, view),
-      })
-      if (parent.type.name === "paragraph" && atParagraphEnd(state, view)) {
-        const fenceMatch = parent.textContent.match(/^(```|~~~)\s*(\w*)\s*$/)
-        log.debug("fence check", {
-          match: fenceMatch?.[0],
-          language: fenceMatch?.[2],
-        })
-        if (fenceMatch) {
-          const language = fenceMatch[2] ?? ""
-          const pos = $head.before()
-          const fence = `\`\`\`${language}`
-          const init = `${fence}\n\n\`\`\``
-          const codeBlock = schema.nodes.code_block.create({ language }, [
-            schema.text(init),
-          ])
-          const pmTr = state.tr.replaceWith(
-            pos,
-            pos + parent.nodeSize,
-            codeBlock,
-          )
-          const resolved = pmTr.doc.resolve(pos + 1 + fence.length + 1)
-          pmTr.setSelection(new TextSelection(resolved))
-          view.dispatch(pmTr)
-          view.focus()
-          return true
-        }
-      }
-
       // Slice document at cursor position and convert each half to markdown
       const beforeSlice = doc.slice(0, from)
       const afterSlice = doc.slice(from, doc.content.size)

+ 99 - 39
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -58,14 +58,6 @@ interface BlockTokenCache {
   }>
 }
 
-/** Plugin state — tokens only, decorations are a lazy projection */
-interface DecorationState {
-  /** Engine instance for this plugin — isolated from other instances */
-  engine: MarkdownRuleEngine
-  /** Token cache keyed by content text — survives position shifts */
-  contentCaches: Map<string, BlockTokenCache>
-}
-
 /**
  * Parse tokens for a textblock. Returns cached structure.
  * Only runs when document content changes.
@@ -113,19 +105,33 @@ function buildDecorationsFromCache(
   })
 
   let calloutType: string | null = null
-  for (const p of paragraphs) {
-    const calloutBlock = p.cache.blocks.find(
-      (b): b is BlockDecoration & { type: "callout" } => b.type === "callout",
-    )
-    if (calloutBlock) {
-      calloutType = calloutBlock.metadata?.calloutType ?? null
-      break
-    }
-  }
 
   for (const { node, pos, cache } of paragraphs) {
     const blockStart = pos + 1
 
+    // Track whether this paragraph has callout or blockquote blocks
+    // so we can correctly reset calloutType between separate callout groups.
+    let hasCallout = false
+    let hasBlockquote = false
+    for (const block of cache.blocks) {
+      if (block.type === "callout") {
+        hasCallout = true
+        calloutType =
+          (block as BlockDecoration & { type: "callout" }).metadata
+            ?.calloutType ?? null
+      }
+      if (block.type === "blockquote") {
+        hasBlockquote = true
+      }
+    }
+
+    // If this paragraph has no callout-related blocks, the callout
+    // group has ended — reset so subsequent blockquotes aren't
+    // incorrectly merged into the previous callout.
+    if (!hasCallout && !hasBlockquote) {
+      calloutType = null
+    }
+
     for (const block of cache.blocks) {
       if (calloutType && block.type === "blockquote") {
         const continuationBlock = {
@@ -315,7 +321,17 @@ function buildPropertyDecorations(
   }
 }
 
-const markdownDecorationsKey = new PluginKey("markdownDecorations")
+interface DecorationState {
+  engine: MarkdownRuleEngine
+  contentCaches: Map<string, BlockTokenCache>
+  hasFocus: boolean
+  markerMode: MarkerVisibilityMode
+  decorationVersion: number
+}
+
+const markdownDecorationsKey = new PluginKey<DecorationState>(
+  "markdownDecorations",
+)
 
 /**
  * Invalidate caches for textblocks whose content changed.
@@ -355,10 +371,14 @@ function invalidateChangedCaches(
 }
 
 export function createMarkdownDecorationsPlugin(
-  markerMode: MarkerVisibilityMode = defaultMarkerMode,
+  initialMarkerMode: MarkerVisibilityMode = defaultMarkerMode,
 ) {
-  let hasFocus = false
-  let currentMarkerMode = markerMode
+  // Closure-level cache for the last-built DecorationSet.
+  // Avoids rebuilding on every cursor-move transaction.
+  let cachedSet: DecorationSet | null = null
+  let cachedVersion = -1
+  let cachedFocus = false
+  let cachedMode = initialMarkerMode
 
   const plugin = new Plugin<DecorationState>({
     key: markdownDecorationsKey,
@@ -373,22 +393,56 @@ export function createMarkdownDecorationsPlugin(
           contentCaches.set(blockText, parseBlockTokens(blockText, engine))
         })
 
-        return { engine, contentCaches }
+        return {
+          engine,
+          contentCaches,
+          hasFocus: false,
+          markerMode: initialMarkerMode,
+          decorationVersion: 0,
+        }
       },
 
       apply(tr, prevState, _oldState, newState) {
-        // Check for mode change in transaction meta
-        if (tr.getMeta("markerMode") !== undefined) {
-          currentMarkerMode = tr.getMeta("markerMode")
+        let hasFocus = prevState.hasFocus
+        let markerMode = prevState.markerMode
+        let decorationVersion = prevState.decorationVersion
+        let contentCaches = prevState.contentCaches
+        let docChanged = false
+
+        const focusMeta = tr.getMeta("decorations:focus")
+        if (focusMeta !== undefined && focusMeta !== prevState.hasFocus) {
+          hasFocus = focusMeta as boolean
+          decorationVersion++
+        }
+
+        const modeMeta = tr.getMeta("markerMode")
+        if (modeMeta !== undefined) {
+          markerMode = modeMeta as MarkerVisibilityMode
+          decorationVersion++
         }
 
         if (tr.docChanged) {
-          const contentCaches = invalidateChangedCaches(
+          contentCaches = invalidateChangedCaches(
             newState.doc,
             prevState.contentCaches,
             prevState.engine,
           )
-          return { engine: prevState.engine, contentCaches }
+          decorationVersion++
+          docChanged = true
+        }
+
+        if (
+          docChanged ||
+          hasFocus !== prevState.hasFocus ||
+          markerMode !== prevState.markerMode
+        ) {
+          return {
+            engine: prevState.engine,
+            contentCaches,
+            hasFocus,
+            markerMode,
+            decorationVersion,
+          }
         }
 
         return prevState
@@ -400,29 +454,35 @@ export function createMarkdownDecorationsPlugin(
         const pluginState = markdownDecorationsKey.getState(state)
         if (!pluginState) return DecorationSet.empty
 
+        if (
+          cachedSet &&
+          cachedVersion === pluginState.decorationVersion &&
+          cachedFocus === pluginState.hasFocus &&
+          cachedMode === pluginState.markerMode
+        ) {
+          return cachedSet
+        }
+
         const { from, to } = state.selection
 
-        return buildDecorationsFromCache(
+        cachedSet = buildDecorationsFromCache(
           state.doc,
           pluginState.contentCaches,
-          hasFocus,
+          pluginState.hasFocus,
           from,
           to,
-          currentMarkerMode,
+          pluginState.markerMode,
         )
+        cachedVersion = pluginState.decorationVersion
+        cachedFocus = pluginState.hasFocus
+        cachedMode = pluginState.markerMode
+
+        return cachedSet
       },
     },
   })
 
-  return {
-    plugin,
-    setFocus: (focused: boolean) => {
-      hasFocus = focused
-    },
-    setMarkerMode: (mode: MarkerVisibilityMode) => {
-      currentMarkerMode = mode
-    },
-  }
+  return { plugin }
 }
 
 export type { DecorationSet }

+ 88 - 0
packages/editor/src/composables/usePasteHandler.ts

@@ -0,0 +1,88 @@
+import type { EditorView } from "prosemirror-view"
+import { contentToDoc, docToContent } from "@/lib/content-model"
+import { classifyBlock } from "@/lib/markdown-rules/block-classifier"
+import { schema } from "@/lib/schema"
+
+export type PasteSplitHandler = (before: string, after: string) => void
+
+export function createPasteHandler(onSplit: PasteSplitHandler) {
+  return function handlePaste(
+    view: EditorView,
+    event: ClipboardEvent,
+  ): boolean {
+    const text = event.clipboardData?.getData("text/plain")
+    if (!text) return false
+
+    const lines = text.split("\n")
+    if (lines.length <= 1) return false
+
+    const classifications = lines.map((line) => classifyBlock(line))
+    const firstType = classifications[0]!.kind
+
+    const isContinuationBlock = (kind: string) =>
+      kind === "blockquote" &&
+      (firstType === "callout" || firstType === "blockquote")
+
+    const hasMixedTypes = classifications
+      .slice(1)
+      .some(
+        (c) =>
+          c.kind !== "paragraph" &&
+          !isContinuationBlock(c.kind) &&
+          c.kind !== firstType,
+      )
+    if (!hasMixedTypes) return false
+
+    event.preventDefault()
+
+    const { $from } = view.state.selection
+    const beforeDoc = view.state.doc.slice(0, $from.pos)
+    const afterDoc = view.state.doc.slice(
+      $from.pos,
+      view.state.doc.content.size,
+    )
+    const beforeText = docToContent(
+      schema.nodes.doc.create(null, beforeDoc.content),
+    )
+    const afterText = docToContent(
+      schema.nodes.doc.create(null, afterDoc.content),
+    )
+    const firstGroupLines: string[] = [lines[0]!]
+
+    const restLines: string[] = []
+
+    let foundBreak = false
+    for (let i = 1; i < lines.length; i++) {
+      const kind = classifications[i]!.kind
+      const line = lines[i]!
+      if (!foundBreak && (kind === firstType || kind === "paragraph")) {
+        firstGroupLines.push(line)
+      } else {
+        foundBreak = true
+        restLines.push(line)
+      }
+    }
+
+    const groupContent = firstGroupLines.join("\n")
+    const newBefore = beforeText
+      ? `${beforeText}\n${groupContent}`
+      : groupContent
+    const afterSuffix = afterText
+      ? `${restLines.join("\n")}\n${afterText}`
+      : restLines.join("\n")
+
+    const newDoc = contentToDoc(newBefore)
+    const tr = view.state.tr.replaceWith(
+      0,
+      view.state.doc.content.size,
+      newDoc.content,
+    )
+    view.dispatch(tr)
+
+    if (afterSuffix) {
+      onSplit(newBefore, afterSuffix)
+    }
+
+    return true
+  }
+}

+ 305 - 0
packages/editor/src/lib/__tests__/auto-close-plugin.test.ts

@@ -0,0 +1,305 @@
+import type { Node } from "prosemirror-model"
+import { EditorState, TextSelection } from "prosemirror-state"
+import { EditorView } from "prosemirror-view"
+import { afterEach, beforeEach, describe, expect, it } from "vitest"
+import {
+  autoClosePlugin,
+  createPairRule,
+  doubleAsteriskRule,
+  doubleTildeRule,
+  singleUnderscoreRule,
+  tripleBacktickRule,
+} from "@/lib/auto-close-plugin"
+import { schema } from "@/lib/schema"
+
+/**
+ * Position mapping for doc(paragraph("content")):
+ *   doc starts at 0
+ *   paragraph starts at 1 → text content starts at 1
+ *   character positions: 1, 2, 3, ..., length
+ *   end-of-text position: 1 + length
+ * So textStart = 1.
+ */
+
+let views: EditorView[] = []
+
+beforeEach(() => {
+  views = []
+})
+
+afterEach(() => {
+  for (const v of views) {
+    v.destroy()
+  }
+  views = []
+})
+
+function createPlugin() {
+  return autoClosePlugin([
+    tripleBacktickRule,
+    doubleAsteriskRule,
+    doubleTildeRule,
+    singleUnderscoreRule,
+    createPairRule("(", ")"),
+    createPairRule("[", "]"),
+    createPairRule("{", "}"),
+    createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
+    createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),
+  ])
+}
+
+function makeParaDoc(content: string) {
+  const textNode = content.length > 0 ? schema.text(content) : undefined
+  const para = textNode
+    ? schema.node("paragraph", {}, [textNode])
+    : schema.node("paragraph")
+  return schema.node("doc", {}, [para])
+}
+
+function getView(content: string, cursorOffset?: number) {
+  const doc = makeParaDoc(content)
+  const pos = 1 + (cursorOffset ?? content.length)
+  const state = EditorState.create({
+    doc,
+    schema,
+    selection: TextSelection.near(doc.resolve(pos)),
+    plugins: [createPlugin()],
+  })
+  const dom = document.createElement("div")
+  const view = new EditorView(dom, { state })
+  views.push(view)
+  return view
+}
+
+function getViewWithRange(
+  content: string,
+  fromOffset: number,
+  toOffset: number,
+) {
+  const doc = makeParaDoc(content)
+  const from = 1 + fromOffset
+  const to = 1 + toOffset
+  const state = EditorState.create({
+    doc,
+    schema,
+    selection: TextSelection.create(doc, from, to),
+    plugins: [createPlugin()],
+  })
+  const dom = document.createElement("div")
+  const view = new EditorView(dom, { state })
+  views.push(view)
+  return view
+}
+
+function typeAtCursor(view: EditorView, text: string) {
+  const { head } = view.state.selection
+  let handled = false
+  view.someProp("handleTextInput", (fn) => {
+    handled = fn(view, head, head, text)
+  })
+  return handled
+}
+
+function typeChar(
+  view: EditorView,
+  from: number,
+  to: number,
+  text: string,
+): boolean {
+  let handled = false
+  view.someProp("handleTextInput", (fn) => {
+    handled = fn(view, from, to, text)
+  })
+  return handled
+}
+
+function pressBackspace(view: EditorView): boolean {
+  let handled = false
+  for (const plugin of view.state.plugins) {
+    const handler = plugin.props.handleDOMEvents?.keydown
+    if (handler) {
+      handled = handler(
+        view,
+        new KeyboardEvent("keydown", { key: "Backspace" }),
+      )
+      if (handled) break
+    }
+  }
+  return handled
+}
+
+function textAt(doc: Node, startOffset: number, length: number) {
+  return doc.textBetween(1 + startOffset, 1 + startOffset + length)
+}
+
+describe("createPairRule", () => {
+  it("auto-closes parentheses when typing ( at end of text", () => {
+    const view = getView("hello", 5)
+
+    const handled = typeAtCursor(view, "(")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 7)).toBe("hello()")
+    // cursor between ( and ) → pos = 1 + 5 + 1 = 7
+    expect(view.state.selection.anchor).toBe(7)
+  })
+
+  it("wraps selection in parentheses", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+
+    const { from, to } = view.state.selection
+    const handled = typeChar(view, from, to, "(")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 13)).toBe("hello (world)")
+    // cursor after closing paren: from + open + selected + close = 7 + 1 + 5 + 1 = 14
+    expect(view.state.selection.anchor).toBe(14)
+  })
+
+  it("skips over closing paren when next character matches", () => {
+    const view = getView("()", 1)
+
+    const handled = typeAtCursor(view, ")")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 2)).toBe("()")
+    // cursor moved past ) → pos = 1 + 2 = 3
+    expect(view.state.selection.anchor).toBe(3)
+  })
+
+  it("does not skip over when there is a selection", () => {
+    const view = getViewWithRange("()", 0, 2)
+
+    const { from, to } = view.state.selection
+    const handled = typeChar(view, from, to, ")")
+
+    expect(handled).toBe(false)
+  })
+
+  it("lets normal close-paren pass when next char does not match", () => {
+    const view = getView("hello", 5)
+
+    const handled = typeAtCursor(view, ")")
+
+    expect(handled).toBe(false)
+  })
+})
+
+describe("createPairRule context-aware quotes", () => {
+  it("auto-closes double-quote after space", () => {
+    const view = getView("hello ", 6)
+
+    const handled = typeAtCursor(view, '"')
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 8)).toBe('hello ""')
+  })
+
+  it("does NOT auto-close double-quote mid-word", () => {
+    const view = getView("hello", 5)
+
+    const handled = typeAtCursor(view, '"')
+
+    expect(handled).toBe(false)
+  })
+})
+
+describe("createPairRule backspace delete-pair", () => {
+  it("deletes empty paren pair on backspace", () => {
+    const view = getView("()", 1)
+
+    const handled = pressBackspace(view)
+
+    expect(handled).toBe(true)
+    // Both parens removed → empty paragraph
+    expect(view.state.doc.textBetween(1, 2)).toBe("")
+  })
+
+  it("lets normal backspace pass when not in a pair", () => {
+    const view = getView("hello", 5)
+
+    const handled = pressBackspace(view)
+
+    expect(handled).toBe(false)
+  })
+})
+
+describe("doubleAsteriskRule (toggle)", () => {
+  it("auto-closes ** when user types second asterisk", () => {
+    const view = getView("hello *", 7)
+
+    const handled = typeAtCursor(view, "*")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 10)).toBe("hello ****")
+    // cursor between ** pairs: 1 + 7 + 1 = 9
+    expect(view.state.selection.anchor).toBe(9)
+  })
+})
+
+describe("doubleTildeRule (toggle)", () => {
+  it("auto-closes ~~ when user types second tilde", () => {
+    const view = getView("~", 1)
+
+    const handled = typeAtCursor(view, "~")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 4)).toBe("~~~~")
+    // cursor between ~~ pairs: start(1) + delimiter.length(2) = 3
+    expect(view.state.selection.anchor).toBe(3)
+  })
+})
+
+describe("singleUnderscoreRule", () => {
+  it("wraps selection in underscores regardless of preceding character", () => {
+    const view = getViewWithRange("hello world", 6, 11)
+
+    const { from, to } = view.state.selection
+    const handled = typeChar(view, from, to, "_")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 13)).toBe("hello _world_")
+    // cursor after closing underscore: from(7) + 1 + 5 + 1 = 14
+    expect(view.state.selection.anchor).toBe(14)
+  })
+
+  it("auto-closes _ at word boundary", () => {
+    const view = getView("hello ", 6)
+
+    const handled = typeAtCursor(view, "_")
+
+    expect(handled).toBe(true)
+    expect(textAt(view.state.doc, 0, 8)).toBe("hello __")
+    // cursor between underscores: 1 + 6 + 1 = 8
+    expect(view.state.selection.anchor).toBe(8)
+  })
+
+  it("does NOT auto-close mid-word", () => {
+    const view = getView("hello", 3)
+
+    const handled = typeAtCursor(view, "_")
+
+    expect(handled).toBe(false)
+  })
+})
+
+describe("tripleBacktickRule", () => {
+  it("creates code block when ``` typed at start of paragraph", () => {
+    const view = getView("``", 2)
+
+    const handled = typeAtCursor(view, "`")
+
+    expect(handled).toBe(true)
+    const doc = view.state.doc
+    expect(doc.childCount).toBe(1)
+    expect(doc.child(0).type.name).toBe("code_block")
+  })
+
+  it("does NOT create code block mid-line", () => {
+    const view = getView("hello ``", 8)
+
+    const handled = typeAtCursor(view, "`")
+
+    expect(handled).toBe(false)
+  })
+})

+ 202 - 24
packages/editor/src/lib/auto-close-plugin.ts

@@ -15,26 +15,205 @@ export interface AutoCloseRule {
     to: number,
     text: string,
   ) => boolean
+  /** For pair rules: opening and closing characters used by backspace delete-pair */
+  pair?: { open: string; close: string }
+}
+
+function isTextBlock(state: EditorState, pos: number) {
+  const $pos = state.doc.resolve(pos)
+  return !$pos.parent.type.spec.code
 }
 
 export function autoClosePlugin(rules: AutoCloseRule[]) {
+  const pairs: NonNullable<AutoCloseRule["pair"]>[] = []
+  for (const rule of rules) {
+    if (rule.pair) pairs.push(rule.pair)
+  }
+
+  const pairLookup = new Map<string, string>()
+  for (const p of pairs) {
+    pairLookup.set(p.open, p.close)
+  }
+
   return new Plugin<unknown>({
     props: {
       handleTextInput(view, from, to, text) {
         if (text.length !== 1) return false
-        const state = view.state
+        if (!isTextBlock(view.state, from)) return false
         for (const rule of rules) {
-          if (rule.handler(state, view, from, to, text)) {
+          if (rule.handler(view.state, view, from, to, text)) {
             return true
           }
         }
         return false
       },
+      handleDOMEvents: {
+        keydown(view, event) {
+          if (event.key !== "Backspace" || pairs.length === 0) return false
+          const { from, to, empty } = view.state.selection
+          if (!empty || from !== to || from < 1) return false
+
+          // textBetween is safe here even near paragraph boundaries:
+          // if |from| points to the start of a paragraph (right after the
+          // parent node), from-1 resolves to the parent's end position and
+          // textBetween returns "" rather than throwing.
+          const prev = view.state.doc.textBetween(from - 1, from)
+          const next = view.state.doc.textBetween(from, from + 1)
+
+          if (prev && next && pairLookup.get(prev) === next) {
+            event.preventDefault()
+            log.debug("delete pair", { open: prev, close: next })
+            const tr = view.state.tr.delete(from - 1, from + 1)
+            view.dispatch(tr)
+            return true
+          }
+          return false
+        },
+      },
     },
   })
 }
 
-// ── Built-in rules ──────────────────────────────────────────
+// ── Pair rule factory ──────────────────────────────────────────
+
+interface PairRuleOptions {
+  /** Context regex: only auto-close if text before cursor matches */
+  context?: RegExp
+  /** Disable skip-over for the closing character */
+  noSkip?: boolean
+}
+
+export function createPairRule(
+  open: string,
+  close: string,
+  options?: PairRuleOptions,
+): AutoCloseRule {
+  return {
+    name: `pair-${open.charCodeAt(0).toString(36)}`,
+    handler(state, view, from, to, text) {
+      // ── Open character: auto-close or wrap selection ──
+      if (text === open) {
+        if (from !== to) {
+          // Wrap selection — no context check; the user's intent is clear
+          const selected = state.doc.textBetween(from, to)
+          const tr = state.tr.replaceWith(
+            from,
+            to,
+            state.schema.text(open + selected + close),
+          )
+          const cursorPos = from + open.length + selected.length + close.length
+          tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+          view.dispatch(tr)
+          view.focus()
+          return true
+        }
+
+        // Context check: only auto-close if preceded by allowed chars
+        if (options?.context) {
+          const before = state.doc.textBetween(Math.max(0, from - 1), from)
+          if (before && !options.context.test(before)) return false
+        }
+
+        // Auto-close: insert open + close, cursor between
+        log.debug("pair auto-close", { open, close })
+        const tr = state.tr.replaceWith(
+          from,
+          from,
+          state.schema.text(open + close),
+        )
+        const cursorPos = from + open.length
+        tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+        view.dispatch(tr)
+        view.focus()
+        return true
+      }
+
+      // ── Close character: skip-over (only when no selection) ──
+      if (text === close && !options?.noSkip && from === to) {
+        const ahead = state.doc.textBetween(from, from + close.length)
+        if (ahead === close) {
+          log.debug("pair skip-over", { close })
+          const tr = view.state.tr.setSelection(
+            TextSelection.create(view.state.doc, from + close.length),
+          )
+          view.dispatch(tr)
+          return true
+        }
+        return false
+      }
+
+      return false
+    },
+    pair: { open, close },
+  }
+}
+
+// ── Toggle rule factory (multi-char delimiters: **, ~~) ────────
+
+interface ToggleRuleOptions {
+  wordBoundary?: boolean
+}
+
+export function createToggleRule(
+  delimiter: string,
+  options?: ToggleRuleOptions,
+): AutoCloseRule {
+  if (delimiter.length < 2) {
+    throw new Error(
+      `createToggleRule requires a multi-character delimiter, got "${delimiter}"`,
+    )
+  }
+
+  const lastChar = delimiter.charAt(delimiter.length - 1)
+  const prefix = delimiter.slice(0, -1)
+
+  return {
+    name: `toggle-${delimiter.charCodeAt(0).toString(36)}`,
+    handler(state, view, from, to, text) {
+      if (text !== lastChar) return false
+      if (!isTextBlock(view.state, from)) return false
+
+      // Check if previous chars match the delimiter prefix
+      const prevChars = state.doc.textBetween(
+        Math.max(0, from - prefix.length),
+        from,
+      )
+      if (prevChars !== prefix) return false
+
+      // Word boundary check for the last character
+      if (options?.wordBoundary) {
+        const before = state.doc.textBetween(
+          Math.max(0, from - prefix.length - 1),
+          from - prefix.length,
+        )
+        if (before && /\w/.test(before)) return false
+      }
+
+      // handleTextInput fires one character at a time, so by the time
+      // both ** are typed any prior selection is already collapsed.
+      // Wrapping selected text with toggle delimiters requires
+      // a keyboard shortcut or toolbar button, not auto-close.
+      if (from !== to) return false
+
+      const start = from - prefix.length
+
+      // Insert delimiter pair, cursor at midpoint
+      log.debug("toggle auto-close", { delimiter })
+      const tr = state.tr.replaceWith(
+        start,
+        from,
+        state.schema.text(delimiter + delimiter),
+      )
+      const cursorPos = start + delimiter.length
+      tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+      view.dispatch(tr)
+      view.focus()
+      return true
+    },
+  }
+}
+
+// ── Structural rules ───────────────────────────────────────────
 
 /** Auto-close ``` (triple backtick) into a code block */
 export const tripleBacktickRule: AutoCloseRule = {
@@ -68,34 +247,33 @@ export const tripleBacktickRule: AutoCloseRule = {
 }
 
 /** Auto-close ** (double asterisk → bold) */
-export const doubleAsteriskRule: AutoCloseRule = {
-  name: "double-asterisk",
-  handler(state, view, from, _to, text) {
-    if (text !== "*") return false
-    const $pos = state.doc.resolve(from)
-    if ($pos.parent.type.name !== "paragraph") return false
-
-    const prev = state.doc.textBetween(Math.max(0, from - 1), from)
-    const prevPrev = state.doc.textBetween(Math.max(0, from - 2), from - 1)
-    if (prev !== "*" || prevPrev === "*") return false
+export const doubleAsteriskRule: AutoCloseRule = createToggleRule("**")
 
-    log.info("double asterisk → bold")
-    const tr = state.tr.replaceWith(from - 1, from, state.schema.text("****"))
-    const resolved = tr.doc.resolve(from - 1 + 2)
-    tr.setSelection(new TextSelection(resolved))
-    view.dispatch(tr)
-    view.focus()
-    return true
-  },
-}
+/** Auto-close ~~ (double tilde → strikethrough) */
+export const doubleTildeRule: AutoCloseRule = createToggleRule("~~")
 
 /** Auto-close _ (single underscore → italic/emphasis) */
 export const singleUnderscoreRule: AutoCloseRule = {
   name: "single-underscore",
-  handler(state, view, from, _to, text) {
+  handler(state, view, from, to, text) {
     if (text !== "_") return false
     const $pos = state.doc.resolve(from)
-    if ($pos.parent.type.name !== "paragraph") return false
+    if ($pos.parent.type.spec.code) return false
+
+    // Wrap selection — no boundary check; the user's intent is clear
+    if (from !== to) {
+      const selected = state.doc.textBetween(from, to)
+      const tr = state.tr.replaceWith(
+        from,
+        to,
+        state.schema.text(`_${selected}_`),
+      )
+      const cursorPos = from + 1 + selected.length + 1
+      tr.setSelection(TextSelection.create(tr.doc, cursorPos))
+      view.dispatch(tr)
+      view.focus()
+      return true
+    }
 
     // Word-boundary check: don't auto-close mid-word
     const prev = state.doc.textBetween(Math.max(0, from - 1), from)