Răsfoiți Sursa

feat: extract editor-operations and add incremental Lezer parsing

- Extract block array manipulation into pure functions in
  `editor-operations.ts` (split, merge, delete, indent,
  outdent, insert, setContent). All are immutable.
- Refactor `Editor.vue` to use the new operations, removing
  dead `getChildrenIndices` code.
- Add 26 comprehensive tests for `editor-operations`.
- Implement incremental Lezer parsing: store Lezer Tree in
  `BlockTokenCache`, match old caches by position when text
  key misses, pass `TreeFragment` to Lezer for O(delta) re-parse.
- Update `MarkdownRuleEngine.parse()` to accept optional old
  Tree and return the new tree alongside decorations.
- Update `parseMarkdown()` to thread `oldTree` through to
  the engine.
Zander Hawke 4 zile în urmă
părinte
comite
fc9c2a6885

+ 48 - 84
packages/editor/src/components/Editor.vue

@@ -14,6 +14,14 @@ import {
   serializeBlocks,
   splitMarkdownIntoBlocks,
 } from "@/lib/block-parser"
+import {
+  deleteIfEmpty as opDeleteIfEmpty,
+  indentBlock as opIndentBlock,
+  insertBlock as opInsertBlock,
+  mergePrevious as opMergePrevious,
+  outdentBlock as opOutdentBlock,
+  splitBlocks as opSplitBlocks,
+} from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
 import {
@@ -229,23 +237,16 @@ function onBlockSelectionChangeHandler(payload: {
 // ── Block event handlers ──────────────────────────────────────────────
 
 function onSplit(index: number, before: string, after: string) {
-  const current = blocks.value[index]
-  if (!current) return
-  current.content = before
-
-  const newBlock: EditorBlock = {
-    id: generateBlockId(),
-    depth: current.depth,
-    content: after,
-  }
-  blocks.value.splice(index + 1, 0, newBlock)
+  const result = opSplitBlocks(blocks.value, index, before, after, generateBlockId)
+  if (!result.newBlock) return
+  blocks.value = result.blocks
   onBlockContentChange()
 
   history.execute({
     type: "split-block",
     index,
-    splitBlockId: newBlock.id,
-    depth: current.depth,
+    splitBlockId: result.newBlock.id,
+    depth: result.newBlock.depth,
     beforeContent: before,
     afterContent: after,
     timestamp: Date.now(),
@@ -253,34 +254,25 @@ function onSplit(index: number, before: string, after: string) {
   canUndo.value = history.canUndo
   canRedo.value = history.canRedo
 
-  nextTick(() => registry.focus(newBlock.id, "start"))
+  nextTick(() => registry.focus(result.newBlock!.id, "start"))
 }
 
 function onMergePrevious(index: number) {
-  if (index <= 0) return
+  const result = opMergePrevious(blocks.value, index)
+  if (result.blocks.length >= blocks.value.length) return
+
   const current = blocks.value[index]
   const prev = blocks.value[index - 1]
   if (!current || !prev) return
-
-  const prevContent = prev.content
-  const appendedContent = current.content
   const removedBlockId = current.id
   const removedBlockDepth = current.depth
 
-  // PM positions are 1-based within a paragraph: position 1 = before the
-  // first character, so landing after the last char of prev content requires
-  // prev.content.length + 1.
-  const joinPos = prevContent.length > 0 ? prevContent.length + 1 : 0
-  const mergedContent = prevContent
-    ? `${prevContent}${appendedContent}`
-    : appendedContent
-
   // Directly update the prev block's PM + model so the debounced watch
   // (which fires ~50ms later) sees currentContent === newContent and is a
   // no-op — preventing a cursor-resetting doc replacement.
-  blockRefs[index - 1]?.setContent(mergedContent)
+  blockRefs[index - 1]?.setContent(result.mergedContent)
 
-  blocks.value.splice(index, 1)
+  blocks.value = result.blocks
   onBlockContentChange()
 
   history.execute({
@@ -288,43 +280,37 @@ function onMergePrevious(index: number) {
     index,
     removedBlockId,
     removedBlockDepth,
-    prevContent,
-    appendedContent,
+    prevContent: prev.content,
+    appendedContent: current.content,
     timestamp: Date.now(),
   })
   canUndo.value = history.canUndo
   canRedo.value = history.canRedo
 
-  nextTick(() => registry.focus(prev.id, joinPos))
+  nextTick(() => registry.focus(prev.id, result.joinPos))
 }
 
 function onDeleteIfEmpty(index: number) {
-  if (blocks.value.length <= 1) return
+  const result = opDeleteIfEmpty(blocks.value, index)
+  if (!result.nextId || result.blocks.length >= blocks.value.length) return
   const removedBlock = blocks.value[index]
-  const nextId =
-    index < blocks.value.length - 1
-      ? blocks.value[index + 1]?.id
-      : blocks.value[index - 1]?.id
-  if (!nextId || !removedBlock) return
-  const removedContent = removedBlock.content
-  const removedDepth = removedBlock.depth
-  const removedId = removedBlock.id
-
-  blocks.value.splice(index, 1)
+  if (!removedBlock) return
+
+  blocks.value = result.blocks
   onBlockContentChange()
 
   history.execute({
     type: "delete-block",
     index,
-    blockId: removedId,
-    content: removedContent,
-    depth: removedDepth,
+    blockId: removedBlock.id,
+    content: removedBlock.content,
+    depth: removedBlock.depth,
     timestamp: Date.now(),
   })
   canUndo.value = history.canUndo
   canRedo.value = history.canRedo
 
-  nextTick(() => registry.focus(nextId, "start"))
+  nextTick(() => registry.focus(result.nextId!, "start"))
 }
 
 function onArrowUpFromStart(index: number) {
@@ -355,34 +341,19 @@ function onBlockBoundaryExit(index: number, direction: "up" | "down") {
   focusZone(zoneIndex)
 }
 
-function getChildrenIndices(index: number): number[] {
-  const parentDepth = blocks.value[index]?.depth
-  if (parentDepth === undefined) return []
-  const children: number[] = []
-  for (let i = index + 1; i < blocks.value.length; i++) {
-    const b = blocks.value[i]
-    if (!b || b.depth <= parentDepth) break
-    children.push(i)
-  }
-  return children
-}
-
 function onIndent(index: number) {
   const block = blocks.value[index]
   if (!block) return
-  const delta = block.depth < 10 ? 1 : 0
-  if (delta === 0) return
-  const childIndices = getChildrenIndices(index)
+  const result = opIndentBlock(blocks.value, index)
+  if (result.childIndices.length === 0 && block.depth >= 10) return
+
   const previousDepth = block.depth
-  const childEntries = childIndices.map((i) => ({
+  const childEntries = result.childIndices.map((i) => ({
     index: i,
     previousDepth: blocks.value[i]?.depth ?? 0,
   }))
-  const affected = [index, ...childIndices]
-  for (const i of affected) {
-    const b = blocks.value[i]
-    if (b) b.depth = Math.min(b.depth + delta, 10)
-  }
+
+  blocks.value = result.blocks
   onBlockContentChange()
 
   history.execute({
@@ -399,19 +370,16 @@ function onIndent(index: number) {
 function onOutdent(index: number) {
   const block = blocks.value[index]
   if (!block) return
-  const delta = block.depth > 0 ? -1 : 0
-  if (delta === 0) return
-  const childIndices = getChildrenIndices(index)
+  const result = opOutdentBlock(blocks.value, index)
+  if (result.childIndices.length === 0 && block.depth <= 0) return
+
   const previousDepth = block.depth
-  const childEntries = childIndices.map((i) => ({
+  const childEntries = result.childIndices.map((i) => ({
     index: i,
     previousDepth: blocks.value[i]?.depth ?? 0,
   }))
-  const affected = [index, ...childIndices]
-  for (const i of affected) {
-    const b = blocks.value[i]
-    if (b) b.depth = Math.max(b.depth + delta, 0)
-  }
+
+  blocks.value = result.blocks
   onBlockContentChange()
 
   history.execute({
@@ -444,18 +412,14 @@ function onZoneBlur() {
 }
 
 function onZoneActivate(index: number, content: string) {
-  const newBlock: EditorBlock = {
-    id: generateBlockId(),
-    depth: 0,
-    content,
-  }
-  blocks.value.splice(index, 0, newBlock)
+  const result = opInsertBlock(blocks.value, index, content, 0, generateBlockId)
+  blocks.value = result.blocks
   focusedTarget.value = null
 
   history.execute({
     type: "insert-block",
     index,
-    blockId: newBlock.id,
+    blockId: result.newBlock.id,
     content,
     depth: 0,
     timestamp: Date.now(),
@@ -464,9 +428,9 @@ function onZoneActivate(index: number, content: string) {
   canRedo.value = history.canRedo
 
   if (content) {
-    nextTick(() => registry.focus(newBlock.id, "end"))
+    nextTick(() => registry.focus(result.newBlock.id, "end"))
   } else {
-    nextTick(() => registry.focus(newBlock.id, "start"))
+    nextTick(() => registry.focus(result.newBlock.id, "start"))
   }
 }
 

+ 25 - 5
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -14,6 +14,7 @@
  * - 'always-visible': markers always visible (traditional markdown editor feel)
  */
 
+import type { Tree } from "@lezer/common"
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
@@ -46,6 +47,8 @@ interface ParsedToken {
 interface BlockTokenCache {
   /** Text content this cache was built from */
   text: string
+  /** Lezer AST tree for incremental re-parsing */
+  tree?: Tree
   /** Inline tokens (bold, links, refs, etc.) */
   content: ParsedToken[]
   /** Block-level tokens (headings, tasks, etc.) */
@@ -66,13 +69,15 @@ interface BlockTokenCache {
 function parseBlockTokens(
   text: string,
   engine: MarkdownRuleEngine,
+  oldTree?: Tree,
 ): BlockTokenCache {
-  const { content, blocks, properties } = parseMarkdown(text, engine)
+  const result = parseMarkdown(text, engine, oldTree)
   return {
     text,
-    content: content as ParsedToken[],
-    blocks: blocks as ParsedToken[],
-    properties,
+    tree: result.tree,
+    content: result.content as ParsedToken[],
+    blocks: result.blocks as ParsedToken[],
+    properties: result.properties,
   }
 }
 
@@ -364,6 +369,7 @@ const markdownDecorationsKey = new PluginKey<DecorationState>(
  */
 function invalidateChangedCaches(
   newDoc: ProsemirrorNode,
+  oldDoc: ProsemirrorNode,
   oldCaches: Map<string, BlockTokenCache>,
   engine: MarkdownRuleEngine,
 ): Map<string, BlockTokenCache> {
@@ -371,6 +377,16 @@ function invalidateChangedCaches(
   let hitCount = 0
   let missCount = 0
 
+  // Collect old caches in document order for position-based incremental parsing
+  const oldCacheOrder: BlockTokenCache[] = []
+  oldDoc.descendants((node: ProsemirrorNode) => {
+    if (!node.isTextblock) return
+    if (node.type.spec.code) return
+    const cache = oldCaches.get(node.textContent)
+    if (cache) oldCacheOrder.push(cache)
+  })
+
+  let blockIndex = 0
   newDoc.descendants((node: ProsemirrorNode) => {
     if (!node.isTextblock) return
     if (node.type.spec.code) return
@@ -382,8 +398,11 @@ function invalidateChangedCaches(
       newCaches.set(blockText, oldCache)
     } else {
       missCount++
-      newCaches.set(blockText, parseBlockTokens(blockText, engine))
+      // Use old tree from same-position block for incremental Lezer parsing
+      const oldTree = oldCacheOrder[blockIndex]?.tree
+      newCaches.set(blockText, parseBlockTokens(blockText, engine, oldTree))
     }
+    blockIndex++
   })
 
   log.debug("cache invalidate", {
@@ -448,6 +467,7 @@ export function createMarkdownDecorationsPlugin(
         if (tr.docChanged) {
           contentCaches = invalidateChangedCaches(
             newState.doc,
+            _oldState.doc,
             prevState.contentCaches,
             prevState.engine,
           )

+ 297 - 0
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -0,0 +1,297 @@
+import { describe, expect, it, vi } from "vitest"
+import type { EditorBlock } from "@/lib/block-parser"
+import {
+  deleteIfEmpty,
+  indentBlock,
+  insertBlock,
+  mergePrevious,
+  outdentBlock,
+  setBlockContent,
+  splitBlocks,
+} from "@/lib/editor-operations"
+
+function block(overrides: Partial<EditorBlock> & { content: string }): EditorBlock {
+  return { id: "auto", depth: 0, ...overrides }
+}
+
+/**
+ * Return a stable ID generator for deterministic tests.
+ */
+function stableId(prefix: string): () => string {
+  let n = 0
+  return () => `${prefix}-${n++}`
+}
+
+// ── splitBlocks ───────────────────────────────────────────────────
+
+describe("splitBlocks", () => {
+  it("splits a block into two at the given index", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", depth: 0, content: "first" }),
+      block({ id: "b", depth: 1, content: "second" }),
+    ]
+    const ids = stableId("new")
+    const result = splitBlocks(blocks, 1, "bef", "aft", ids)
+
+    // Original block gets `before` content
+    expect(result.blocks[0]).toMatchObject({ id: "a", content: "first" })
+
+    // Original at index is updated
+    expect(result.blocks[1]).toMatchObject({ id: "b", content: "bef" })
+
+    // New block inserted after
+    expect(result.blocks[2]).toMatchObject({ id: "new-0", depth: 1, content: "aft" })
+
+    expect(result.blocks).toHaveLength(3)
+    expect(result.newBlock).toMatchObject({ id: "new-0", content: "aft" })
+  })
+
+  it("preserves depth of the original block", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const result = splitBlocks(blocks, 0, "a", "b", stableId("n"))
+    expect(result.blocks[0].depth).toBe(2)
+    expect(result.blocks[1].depth).toBe(2)
+  })
+
+  it("returns original array copy when index is out of bounds", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    expect(splitBlocks(blocks, -1, "", "", stableId("n")).newBlock).toBeNull()
+    expect(splitBlocks(blocks, 5, "", "", stableId("n")).newBlock).toBeNull()
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 1, content: "original" })]
+    const copy = [...blocks]
+    splitBlocks(blocks, 0, "before", "after", stableId("n"))
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── mergePrevious ─────────────────────────────────────────────────
+
+describe("mergePrevious", () => {
+  it("merges two adjacent blocks, appending content", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "hello" }),
+      block({ id: "b", content: " world" }),
+    ]
+    const result = mergePrevious(blocks, 1)
+
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0]).toMatchObject({ id: "a", content: "hello world" })
+    expect(result.mergedContent).toBe("hello world")
+    expect(result.joinPos).toBe(6) // "hello".length + 1
+  })
+
+  it("joinPos is 0 when previous block is empty", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "" }),
+      block({ id: "b", content: "content" }),
+    ]
+    const result = mergePrevious(blocks, 1)
+    expect(result.joinPos).toBe(0)
+    expect(result.blocks[0].content).toBe("content")
+  })
+
+  it("returns no change when index is 0 or out of bounds", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", content: "x" }), block({ id: "b", content: "y" })]
+    const atZero = mergePrevious(blocks, 0)
+    expect(atZero.blocks).toHaveLength(2)
+
+    const oob = mergePrevious(blocks, 5)
+    expect(oob.blocks).toHaveLength(2)
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "hello" }),
+      block({ id: "b", content: " world" }),
+    ]
+    const copy = [...blocks]
+    mergePrevious(blocks, 1)
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── deleteIfEmpty ─────────────────────────────────────────────────
+
+describe("deleteIfEmpty", () => {
+  it("removes a block at the given index", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "second" }),
+      block({ id: "c", content: "third" }),
+    ]
+    const result = deleteIfEmpty(blocks, 1)
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].id).toBe("a")
+    expect(result.blocks[1].id).toBe("c")
+  })
+
+  it("focuses the next block when removing from the beginning", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "second" }),
+    ]
+    const result = deleteIfEmpty(blocks, 0)
+    expect(result.nextId).toBe("b")
+  })
+
+  it("focuses the previous block when removing the last block", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "second" }),
+    ]
+    const result = deleteIfEmpty(blocks, 1)
+    expect(result.nextId).toBe("a")
+  })
+
+  it("does nothing when only one block exists", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", content: "only" })]
+    const result = deleteIfEmpty(blocks, 0)
+    expect(result.blocks).toHaveLength(1)
+    expect(result.nextId).toBeNull()
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "x" }),
+      block({ id: "b", content: "y" }),
+    ]
+    const copy = [...blocks]
+    deleteIfEmpty(blocks, 0)
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── indentBlock ───────────────────────────────────────────────────
+
+describe("indentBlock", () => {
+  it("increments depth of the block", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const result = indentBlock(blocks, 0)
+    expect(result.blocks[0].depth).toBe(1)
+  })
+
+  it("also indents child blocks", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "p", depth: 0, content: "parent" }),
+      block({ id: "c", depth: 1, content: "child" }),
+      block({ id: "g", depth: 2, content: "grandchild" }),
+      block({ id: "s", depth: 0, content: "sibling" }),
+    ]
+    const result = indentBlock(blocks, 0)
+    expect(result.childIndices).toEqual([1, 2])
+    expect(result.blocks[0].depth).toBe(1)
+    expect(result.blocks[1].depth).toBe(2)
+    expect(result.blocks[2].depth).toBe(3)
+    // Sibling at same depth as parent is NOT indented
+    expect(result.blocks[3].depth).toBe(0)
+  })
+
+  it("caps depth at 10", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 10, content: "x" })]
+    const result = indentBlock(blocks, 0)
+    expect(result.blocks[0].depth).toBe(10)
+    expect(result.childIndices).toEqual([])
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const copy = [...blocks]
+    indentBlock(blocks, 0)
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── outdentBlock ──────────────────────────────────────────────────
+
+describe("outdentBlock", () => {
+  it("decrements depth of the block", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const result = outdentBlock(blocks, 0)
+    expect(result.blocks[0].depth).toBe(1)
+  })
+
+  it("also outdents child blocks", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "p", depth: 2, content: "parent" }),
+      block({ id: "c", depth: 3, content: "child" }),
+      block({ id: "g", depth: 4, content: "grandchild" }),
+      block({ id: "s", depth: 0, content: "sibling" }),
+    ]
+    const result = outdentBlock(blocks, 0)
+    expect(result.blocks[0].depth).toBe(1)
+    expect(result.blocks[1].depth).toBe(2)
+    expect(result.blocks[2].depth).toBe(3)
+    // Sibling not affected
+    expect(result.blocks[3].depth).toBe(0)
+  })
+
+  it("floors depth at 0", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const result = outdentBlock(blocks, 0)
+    expect(result.blocks[0].depth).toBe(0)
+    expect(result.childIndices).toEqual([])
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const copy = [...blocks]
+    outdentBlock(blocks, 0)
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── insertBlock ───────────────────────────────────────────────────
+
+describe("insertBlock", () => {
+  it("inserts a block at the given index", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "first" }),
+      block({ id: "b", content: "last" }),
+    ]
+    const ids = stableId("new")
+    const result = insertBlock(blocks, 1, "middle", 0, ids)
+
+    expect(result.blocks).toHaveLength(3)
+    expect(result.blocks[0].id).toBe("a")
+    expect(result.blocks[1]).toMatchObject({ id: "new-0", content: "middle" })
+    expect(result.blocks[2].id).toBe("b")
+    expect(result.newBlock).toMatchObject({ id: "new-0", content: "middle" })
+  })
+
+  it("inserts at the beginning", () => {
+    const blocks: EditorBlock[] = [block({ id: "b", content: "second" })]
+    const result = insertBlock(blocks, 0, "first", 0, stableId("n"))
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].content).toBe("first")
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    const copy = [...blocks]
+    insertBlock(blocks, 0, "y", 0, stableId("n"))
+    expect(blocks).toEqual(copy)
+  })
+})
+
+// ── setBlockContent ───────────────────────────────────────────────
+
+describe("setBlockContent", () => {
+  it("updates content of the matching block", () => {
+    const blocks: EditorBlock[] = [
+      block({ id: "a", content: "old" }),
+      block({ id: "b", content: "keep" }),
+    ]
+    const result = setBlockContent(blocks, "a", "new")
+    expect(result[0].content).toBe("new")
+    expect(result[1].content).toBe("keep")
+  })
+
+  it("does not mutate the original array", () => {
+    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    setBlockContent(blocks, "a", "y")
+    expect(blocks[0].content).toBe("x")
+  })
+})

+ 165 - 0
packages/editor/src/lib/editor-operations.ts

@@ -0,0 +1,165 @@
+import type { EditorBlock } from "@/lib/block-parser"
+
+export interface SplitResult {
+  blocks: EditorBlock[]
+  newBlock: EditorBlock | null
+}
+
+export interface MergeResult {
+  blocks: EditorBlock[]
+  mergedContent: string
+  joinPos: number
+}
+
+export interface DeleteResult {
+  blocks: EditorBlock[]
+  nextId: string | null
+}
+
+export interface IndentResult {
+  blocks: EditorBlock[]
+  childIndices: number[]
+}
+
+function generateId(): string {
+  return crypto.randomUUID()
+}
+
+/**
+ * Split block at `index` into two: the original retains `before`,
+ * a new sibling gets `after`.
+ */
+export function splitBlocks(
+  blocks: EditorBlock[],
+  index: number,
+  before: string,
+  after: string,
+  idFn: () => string = generateId,
+): SplitResult {
+  if (index < 0 || index >= blocks.length) return { blocks: [...blocks], newBlock: null }
+  const newBlocks = blocks.map((b, i) => (i === index ? { ...b, content: before } : { ...b }))
+  const newBlock: EditorBlock = { id: idFn(), depth: newBlocks[index]!.depth, content: after }
+  newBlocks.splice(index + 1, 0, newBlock)
+  return { blocks: newBlocks, newBlock }
+}
+
+/**
+ * Merge block at `index` into its predecessor.
+ */
+export function mergePrevious(blocks: EditorBlock[], index: number): MergeResult {
+  const result: MergeResult = { blocks: [...blocks], mergedContent: "", joinPos: 0 }
+  if (index <= 0 || index >= blocks.length) return result
+  const current = blocks[index]
+  const prev = blocks[index - 1]
+  if (!current || !prev) return result
+
+  const prevContent = prev.content
+  const appendedContent = current.content
+  const joinPos = prevContent.length > 0 ? prevContent.length + 1 : 0
+  const mergedContent = prevContent ? `${prevContent}${appendedContent}` : appendedContent
+
+  const newBlocks = blocks.map((b, i) => {
+    if (i === index - 1) return { ...b, content: mergedContent }
+    return { ...b }
+  })
+  newBlocks.splice(index, 1)
+  return { blocks: newBlocks, mergedContent, joinPos }
+}
+
+/**
+ * Delete the block at `index`. Returns the ID of the block to focus next.
+ * Does nothing if there is only one block.
+ */
+export function deleteIfEmpty(blocks: EditorBlock[], index: number): DeleteResult {
+  const result: DeleteResult = { blocks: [...blocks], nextId: null }
+  if (blocks.length <= 1) return result
+  if (index < 0 || index >= blocks.length) return result
+
+  const nextId =
+    index < blocks.length - 1
+      ? blocks[index + 1]?.id ?? null
+      : blocks[index - 1]?.id ?? null
+
+  const newBlocks = blocks.filter((_, i) => i !== index)
+  return { blocks: newBlocks, nextId }
+}
+
+/**
+ * Indent the block at `index` and its children (blocks at higher depth
+ * immediately following it).
+ */
+export function indentBlock(blocks: EditorBlock[], index: number): IndentResult {
+  if (index < 0 || index >= blocks.length) return { blocks: [...blocks], childIndices: [] }
+  const block = blocks[index]
+  if (!block || block.depth >= 10) return { blocks: [...blocks], childIndices: [] }
+
+  const parentDepth = block.depth
+  const childIndices: number[] = []
+  for (let i = index + 1; i < blocks.length; i++) {
+    const b = blocks[i]
+    if (!b || b.depth <= parentDepth) break
+    childIndices.push(i)
+  }
+
+  const newBlocks = blocks.map((b, i) => {
+    if (i === index || childIndices.includes(i)) {
+      return { ...b, depth: Math.min(b.depth + 1, 10) }
+    }
+    return { ...b }
+  })
+
+  return { blocks: newBlocks, childIndices }
+}
+
+/**
+ * Outdent the block at `index` and its children.
+ */
+export function outdentBlock(blocks: EditorBlock[], index: number): IndentResult {
+  if (index < 0 || index >= blocks.length) return { blocks: [...blocks], childIndices: [] }
+  const block = blocks[index]
+  if (!block || block.depth <= 0) return { blocks: [...blocks], childIndices: [] }
+
+  const parentDepth = block.depth
+  const childIndices: number[] = []
+  for (let i = index + 1; i < blocks.length; i++) {
+    const b = blocks[i]
+    if (!b || b.depth <= parentDepth) break
+    childIndices.push(i)
+  }
+
+  const newBlocks = blocks.map((b, i) => {
+    if (i === index || childIndices.includes(i)) {
+      return { ...b, depth: Math.max(b.depth - 1, 0) }
+    }
+    return { ...b }
+  })
+
+  return { blocks: newBlocks, childIndices }
+}
+
+/**
+ * Insert a new block at `index`.
+ */
+export function insertBlock(
+  blocks: EditorBlock[],
+  index: number,
+  content: string,
+  depth: number,
+  idFn: () => string = generateId,
+): { blocks: EditorBlock[]; newBlock: EditorBlock } {
+  const newBlock: EditorBlock = { id: idFn(), depth, content }
+  const newBlocks = [...blocks]
+  newBlocks.splice(index, 0, newBlock)
+  return { blocks: newBlocks, newBlock }
+}
+
+/**
+ * Set the content of a block by ID.
+ */
+export function setBlockContent(
+  blocks: EditorBlock[],
+  blockId: string,
+  newContent: string,
+): EditorBlock[] {
+  return blocks.map((b) => (b.id === blockId ? { ...b, content: newContent } : { ...b }))
+}

+ 4 - 2
packages/editor/src/lib/markdown-parser.ts

@@ -5,6 +5,7 @@
  * - parseMarkdown(): full decoration extraction
  */
 
+import type { Tree } from "@lezer/common"
 import { GFM, parser } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 import {
@@ -48,6 +49,7 @@ const defaultEngine = new MarkdownRuleEngine()
 export function parseMarkdown(
   docText: string,
   engine: MarkdownRuleEngine = defaultEngine,
-): ParsedDecorations {
-  return engine.parse(docText, markdownParser)
+  oldTree?: Tree,
+): ParsedDecorations & { tree: Tree } {
+  return engine.parse(docText, markdownParser, oldTree)
 }

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

@@ -11,6 +11,7 @@
  */
 
 import type { Parser, Tree } from "@lezer/common"
+import { TreeFragment } from "@lezer/common"
 import { createLogger } from "@/lib/logger"
 import { createBlockRules } from "@/lib/markdown-rules/block-rules"
 import { createInlineRules } from "@/lib/markdown-rules/inline-rules"
@@ -99,17 +100,29 @@ export class MarkdownRuleEngine {
     this.blockRuleIndex = buildBlockRuleIndex(this.blockRules)
   }
 
-  parse(docText: string, markdownParser: Parser): ParsedDecorations {
+  parse(
+    docText: string,
+    markdownParser: Parser,
+    oldTree?: Tree,
+  ): ParsedDecorations & { tree: Tree } {
     const content: ParsedDecorations["content"] = []
     const blocks: BlockDecoration[] = []
     const properties: PropertyInfo[] = []
 
-    if (!docText) return { content, properties, blocks }
+    if (!docText) {
+      return {
+        content,
+        properties,
+        blocks,
+        tree: markdownParser.parse(docText),
+      }
+    }
 
     // ── Phase 1: Lezer AST walk ─────────────────────────────────────
     const ctx: ParseContext = { doc: docText }
     const startTime = performance.now()
-    const tree: Tree = markdownParser.parse(docText)
+    const fragments = oldTree ? TreeFragment.addTree(oldTree) : undefined
+    const tree: Tree = markdownParser.parse(docText, fragments)
     const parseTime = performance.now() - startTime
 
     tree.iterate({
@@ -160,7 +173,8 @@ export class MarkdownRuleEngine {
       blocks: blocks.length,
       inlineTokens: content.length,
       properties: properties.length,
+      incremental: !!oldTree,
     })
-    return { content, properties, blocks }
+    return { content, properties, blocks, tree }
   }
 }