Эх сурвалжийг харах

chore: address code review items across history, caching, and event handling

- Add coalesce() to OperationHistory; use it in Editor.vue instead of
  peek() + type-cast mutation (inverse recomputed, future stack cleared)
- Replace content-generation counter + nextTick with lastSerialized
  string comparison (no race window for external vs internal updates)
- Add missing reactive import to Editor.vue
- Extract applyCalloutGroupDecorations and applyTableDecorations from
  buildDecorationsFromCache / applyBlockDecorations
- Memoize renderCellContent Lezer parses with 50-entry FIFO cache
- Narrow CodeBlockView.stopEvent to pass through clipboard, composition,
  and paste events to ProseMirror
- Scope zoneRefs.clear() to structural mutation handlers only (not
  per-keystroke onBlockContentChange)
- Add metadata field to local ParsedToken interface (TS fix)
- Document extractEmbeddedId code-fence false-positive risk
- Add "Why Not TipTap" rationale to README
Zander Hawke 1 өдөр өмнө
parent
commit
b80121dc71

+ 2 - 3
.forgejo/workflows/deploy.yml

@@ -9,7 +9,7 @@ on:
 
 jobs:
   deploy:
-    runs-on: codeberg-medium
+    runs-on: codeberg-small
     steps:
       - uses: actions/checkout@v5
       - uses: actions/setup-node@v5
@@ -17,8 +17,7 @@ jobs:
           node-version: 22
       - run: corepack enable && corepack prepare pnpm@latest --activate
       - run: pnpm install
-      - run: pnpm --filter @enesis/editor build
-      - run: BASE_URL=/editor/ pnpm --filter @enesis/dev build
+      - run: BASE_URL=/editor/ pnpm build
       - if: ${{ (forge.event_name == 'push' && forge.event.ref == 'refs/heads/master') || forge.event_name == 'workflow_dispatch' }}
         uses: actions/git-pages@v2
         with:

+ 6 - 0
README.md

@@ -41,6 +41,12 @@ Enesis is being built as a complete block-editing substrate:
 - **Lezer-based parsing.** All syntax features flow through a single `@lezer/markdown` AST walk with custom extensions for Obsidian-style elements.
 - **Block-level isolation.** Each block is an independent ProseMirror instance. Blocks coordinate via emitted events (`split`, `merge-previous`, `arrow-up-from-start`, etc.) — no shared state.
 
+### Why Not TipTap?
+
+TipTap's extension ecosystem assumes formatting is structural — Bold becomes a `<strong>` mark, Italic becomes `<em>`, headings become heading nodes. This editor's core premise is the opposite: markdown syntax stays as raw text and formatting is purely decorative via Lezer-driven ProseMirror decorations. Adopting TipTap would mean fighting its content model rather than using it, essentially reimplementing the decoration layer on top of an abstraction that actively resists it. TipTap also manages the ProseMirror schema for you, which would conflict with the deliberately minimal schema here (`doc`, `paragraph`, `code_block`, `math_block`, `text` — no marks at all).
+
+The multi-block architecture is the other major friction point. Each `EditorBlock` is its own independent `EditorView` instance, coordinating via events. TipTap assumes a single editor wrapping the whole document, so the block-per-instance model would require either abandoning TipTap's lifecycle entirely (at which point you're just using raw ProseMirror anyway) or shoehorning the pattern into something TipTap wasn't designed for. The custom Lezer parsing pipeline, pattern plugin, and node view registry all interact directly with ProseMirror's internals in ways that TipTap's abstraction layer would make unnecessarily awkward to access.
+
 ## Monorepo Structure
 
 ```

+ 1 - 1
package.json

@@ -3,7 +3,7 @@
   "private": true,
   "scripts": {
     "dev": "pnpm run --filter @enesis/dev dev",
-    "build": "pnpm run --filter @enesis/editor build",
+    "build": "pnpm -r --filter=!@enesis/tauri build",
     "test": "pnpm run --filter @enesis/editor test",
     "tauri": "pnpm run --filter @enesis/tauri tauri",
     "check": "biome check",

+ 32 - 19
packages/editor/src/components/Editor.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import type { EditorView } from "prosemirror-view"
-import { computed, nextTick, type Ref, ref, watch } from "vue"
+import { computed, nextTick, reactive, type Ref, ref, watch } from "vue"
 import EditorBlock from "@/components/EditorBlock.vue"
 import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import {
@@ -79,15 +79,16 @@ const themeCSSVars = computed(() => {
 })
 
 /**
- * Generation counter disambiguates internal content updates from external
- * ones. onBlockContentChange increments it synchronously before setting
- * content.value; the watcher compares it against lastContentGeneration.
- * If Vue collapses N writes into one watcher invocation, the generational
- * gap is still detected. Unlike a boolean, this survives interleaved async
- * calls (e.g. resolveAsset then nextTick then another content change).
+ * Tracks the string last set by an internal content update. The watcher
+ * compares content.value against this; if they match, the watcher was
+ * triggered by us and the update is skipped. If they differ, the change
+ * came from the parent (external) and the editor reloads.
+ * Unlike a generation counter + nextTick approach, this has no race
+ * window where an external update between set and nextTick can be
+ * mistaken for internal.
  */
-let contentGeneration = 0
-let lastContentGeneration = 0
+let lastSerialized: string | undefined
+let isInitialized = false
 
 const blocks = ref<EditorBlockData[]>([])
 
@@ -204,6 +205,7 @@ function onDropOnZone(zoneIndex: number) {
   })
   canUndo.value = history.canUndo
   canRedo.value = history.canRedo
+  zoneRefs.clear()
   onBlockContentChange()
 
   dragState.value = null
@@ -220,6 +222,7 @@ const canRedo = ref(false)
 // update canUndo/canRedo — that's the caller's responsibility (undo/redo
 // update the flags afterward; direct operation callsites do so inline).
 function applyOperation(op: EditorOperation): void {
+  zoneRefs.clear()
   let focusInfo: { id: string; pos: "start" | "end" | number } | null = null
 
   switch (op.type) {
@@ -342,9 +345,13 @@ function onBlockContentChangeOp(op: {
     last.blockId === op.blockId &&
     op.timestamp - last.timestamp < COALESCE_MS
   ) {
-    ;(last as import("@/lib/operation-history").SetBlockContentOp).newContent =
-      op.newContent
-    last.timestamp = op.timestamp
+    history.coalesce({
+      type: "set-block-content",
+      blockId: op.blockId,
+      previousContent: last.previousContent,
+      newContent: op.newContent,
+      timestamp: op.timestamp,
+    })
   } else {
     void history.execute({
       type: "set-block-content",
@@ -368,10 +375,12 @@ function parseContent(md: string | undefined) {
 watch(
   () => content.value,
   (val) => {
-    if (contentGeneration !== lastContentGeneration) {
-      lastContentGeneration = contentGeneration
+    if (!isInitialized) {
+      isInitialized = true
+      parseContent(val)
       return
     }
+    if (val === lastSerialized) return
     history.clear()
     canUndo.value = false
     canRedo.value = false
@@ -382,11 +391,8 @@ watch(
 
 // Serialize and emit when any block's content changes.
 function onBlockContentChange() {
-  contentGeneration++
-  content.value = serializeBlocks(blocks.value)
-  nextTick(() => {
-    lastContentGeneration = contentGeneration
-  })
+  lastSerialized = serializeBlocks(blocks.value)
+  content.value = lastSerialized
 }
 
 // ── Active block state (toolbar binding) ──────────────────────────────
@@ -482,6 +488,7 @@ function onSplit(index: number, before: string, after: string) {
   if (!result.newBlock) return
   announce("Block split")
   blocks.value = result.blocks
+  zoneRefs.clear()
   onBlockContentChange()
 
   void history.execute({
@@ -523,6 +530,7 @@ function onMergePrevious(index: number) {
   if (prevIndex !== -1) result.blocks[prevIndex]!.content = result.mergedContent
 
   blocks.value = result.blocks
+  zoneRefs.clear()
   onBlockContentChange()
 
   void history.execute({
@@ -548,6 +556,7 @@ function onDeleteIfEmpty(index: number) {
   announce("Block deleted")
 
   blocks.value = result.blocks
+  zoneRefs.clear()
   onBlockContentChange()
 
   void history.execute({
@@ -609,6 +618,7 @@ function onIndent(index: number) {
   }))
 
   blocks.value = result.blocks
+  zoneRefs.clear()
   onBlockContentChange()
 
   void history.execute({
@@ -635,6 +645,7 @@ function onOutdent(index: number) {
   }))
 
   blocks.value = result.blocks
+  zoneRefs.clear()
   onBlockContentChange()
 
   void history.execute({
@@ -705,6 +716,7 @@ function onZoneActivate(index: number, content: string) {
       }
       insertIndex++
     }
+    zoneRefs.clear()
     onBlockContentChange()
     canUndo.value = history.canUndo
     canRedo.value = history.canRedo
@@ -722,6 +734,7 @@ function onZoneActivate(index: number, content: string) {
       generateBlockId,
     )
     blocks.value = result.blocks
+    zoneRefs.clear()
     onBlockContentChange()
 
     void history.execute({

+ 39 - 23
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -10,10 +10,7 @@ import {
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
 import type { FormattingHandlers } from "@/lib/formatting"
-import {
-  createOperationHistory,
-  type SetBlockContentOp,
-} from "@/lib/operation-history"
+import { createOperationHistory } from "@/lib/operation-history"
 import { createPasteHandler } from "@/composables/usePasteHandler"
 import { insertBlock } from "@/lib/editor-operations"
 import { moveBlock } from "@/lib/editor-operations"
@@ -139,8 +136,13 @@ describe("content-change-op coalescing", () => {
         last.blockId === op.blockId &&
         op.timestamp - last.timestamp < COALESCE_MS
       ) {
-        ;(last as SetBlockContentOp).newContent = op.newContent
-        last.timestamp = op.timestamp
+        history.coalesce({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: last.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
       } else {
         void history.execute({
           type: "set-block-content",
@@ -163,13 +165,12 @@ describe("content-change-op coalescing", () => {
 
     expect(canUndo).toBe(true)
     expect(history.peek()?.type).toBe("set-block-content")
-    expect((history.peek() as SetBlockContentOp).newContent).toBe("hello")
-    expect(history.peek() as SetBlockContentOp).not.toBeNull()
+    expect(history.peek()).toHaveProperty("newContent", "hello")
 
     // Undo should revert directly from "hello" to ""
     const inverse = history.undo()
     expect(inverse).not.toBeNull()
-    expect((inverse as SetBlockContentOp).newContent).toBe("")
+    expect(inverse).toHaveProperty("newContent", "")
     canUndo = history.canUndo
     expect(canUndo).toBe(false)
   })
@@ -190,8 +191,13 @@ describe("content-change-op coalescing", () => {
         last.blockId === op.blockId &&
         op.timestamp - last.timestamp < COALESCE_MS
       ) {
-        ;(last as SetBlockContentOp).newContent = op.newContent
-        last.timestamp = op.timestamp
+        history.coalesce({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: last.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
       } else {
         void history.execute({
           type: "set-block-content",
@@ -210,10 +216,10 @@ describe("content-change-op coalescing", () => {
 
     // Two separate entries
     const first = history.undo()
-    expect((first as SetBlockContentOp).newContent).toBe("hi")
+    expect(first).toHaveProperty("newContent", "hi")
     expect(history.canUndo).toBe(true)
     const second = history.undo()
-    expect((second as SetBlockContentOp).newContent).toBe("")
+    expect(second).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(false)
   })
 
@@ -233,8 +239,13 @@ describe("content-change-op coalescing", () => {
         last.blockId === op.blockId &&
         op.timestamp - last.timestamp < COALESCE_MS
       ) {
-        ;(last as SetBlockContentOp).newContent = op.newContent
-        last.timestamp = op.timestamp
+        history.coalesce({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: last.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
       } else {
         void history.execute({
           type: "set-block-content",
@@ -251,8 +262,8 @@ describe("content-change-op coalescing", () => {
 
     // Two separate entries (different block)
     const first = history.undo()
-    expect((first as SetBlockContentOp).blockId).toBe("b")
-    expect((first as SetBlockContentOp).newContent).toBe("") // reverted to ""
+    expect(first).toHaveProperty("blockId", "b")
+    expect(first).toHaveProperty("newContent", "") // reverted to ""
     expect(history.canUndo).toBe(true)
     expect(history.undo()).not.toBeNull()
   })
@@ -273,8 +284,13 @@ describe("content-change-op coalescing", () => {
         last.blockId === op.blockId &&
         op.timestamp - last.timestamp < COALESCE_MS
       ) {
-        ;(last as SetBlockContentOp).newContent = op.newContent
-        last.timestamp = op.timestamp
+        history.coalesce({
+          type: "set-block-content",
+          blockId: op.blockId,
+          previousContent: last.previousContent,
+          newContent: op.newContent,
+          timestamp: op.timestamp,
+        })
       } else {
         void history.execute({
           type: "set-block-content",
@@ -307,8 +323,8 @@ describe("content-change-op coalescing", () => {
     const undo1 = history.undo()
     expect(undo1).not.toBeNull()
     expect(undo1!.type).toBe("set-block-content")
-    expect((undo1 as SetBlockContentOp).blockId).toBe("b")
-    expect((undo1 as SetBlockContentOp).newContent).toBe("")
+    expect(undo1).toHaveProperty("blockId", "b")
+    expect(undo1).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(true)
 
     // Step 2 undo — revert the split (merge block B back into block A)
@@ -321,8 +337,8 @@ describe("content-change-op coalescing", () => {
     const undo3 = history.undo()
     expect(undo3).not.toBeNull()
     expect(undo3!.type).toBe("set-block-content")
-    expect((undo3 as SetBlockContentOp).blockId).toBe("a")
-    expect((undo3 as SetBlockContentOp).newContent).toBe("")
+    expect(undo3).toHaveProperty("blockId", "a")
+    expect(undo3).toHaveProperty("newContent", "")
     expect(history.canUndo).toBe(false)
     expect(history.undo()).toBeNull()
   })

+ 11 - 1
packages/editor/src/composables/useCodeBlockView.ts

@@ -370,7 +370,17 @@ export class CodeBlockView implements NodeView {
     this.cmView.destroy()
   }
 
-  stopEvent() {
+  stopEvent(event: Event) {
+    if (
+      event.type === "copy" ||
+      event.type === "cut" ||
+      event.type === "paste" ||
+      event.type === "compositionstart" ||
+      event.type === "compositionupdate" ||
+      event.type === "compositionend"
+    ) {
+      return false
+    }
     return true
   }
 }

+ 182 - 137
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -40,6 +40,7 @@ interface ParsedToken {
   decorationRanges: DecorationRange[]
   wrapperAttrs?: Record<string, string>
   nodeWrapper?: { className: string; attrs?: Record<string, string> }
+  metadata?: Record<string, unknown>
 }
 
 /**
@@ -232,29 +233,76 @@ function buildDecorationsFromCache(
   const cache = contentCaches.get(combinedText)
   if (!cache) return DecorationSet.empty
 
+  applyCalloutGroupDecorations(
+    cache.blocks,
+    boundaries,
+    paragraphs,
+    decorationSpecs,
+    isFocused,
+    markerMode,
+  )
+
+  for (const token of cache.content) {
+    applyInlineDecorations(
+      token,
+      boundaries,
+      paragraphs,
+      decorationSpecs,
+      isFocused,
+      markerMode,
+      resolvedRefs,
+    )
+  }
+
+  // Properties — keyed by single-line paragraphs
+  if (cache.properties.length > 0) {
+    buildPropertyDecorations(
+      cache.properties,
+      boundaries,
+      paragraphs,
+      decorationSpecs,
+    )
+  }
+
+  log.debug("build decorations", {
+    count: decorationSpecs.length,
+    paragraphs: paragraphs.length,
+  })
+  return DecorationSet.create(doc, decorationSpecs)
+}
+
+/**
+ * Apply callout group decorations, merging blockquote continuations into
+ * their preceding callout block so they share the same node wrapper class.
+ */
+function applyCalloutGroupDecorations(
+  blocks: ParsedToken[],
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+  decorationSpecs: Decoration[],
+  isFocused: boolean,
+  markerMode: MarkerVisibilityMode,
+) {
   let calloutType: string | null = null
 
-  for (const block of cache.blocks) {
-    // Track callout type from callout blocks
+  for (const block of blocks) {
     if (block.type === "callout") {
       calloutType =
         (block as BlockDecoration & { type: "callout" }).metadata
           ?.calloutType ?? null
     }
 
-    // If this is a blockquote that should be rendered as callout continuation
     if (calloutType && block.type === "blockquote") {
-      const continuationBlock = {
-        ...block,
-        type: "callout" as const,
-        nodeWrapper: {
-          className: "md-callout",
-          attrs: { "data-callout-type": calloutType },
-        },
-        metadata: { calloutType },
-      }
       applyBlockDecorations(
-        continuationBlock,
+        {
+          ...block,
+          type: "callout" as const,
+          nodeWrapper: {
+            className: "md-callout",
+            attrs: { "data-callout-type": calloutType },
+          },
+          metadata: { calloutType },
+        },
         boundaries,
         paragraphs,
         decorationSpecs,
@@ -272,39 +320,116 @@ function buildDecorationsFromCache(
       )
     }
 
-    // Track whether a subsequent non-callout/blockquote resets the group
     if (block.type !== "callout" && block.type !== "blockquote") {
       calloutType = null
     }
   }
+}
 
-  for (const token of cache.content) {
-    applyInlineDecorations(
-      token,
-      boundaries,
-      paragraphs,
-      decorationSpecs,
-      isFocused,
-      markerMode,
-      resolvedRefs,
-    )
+/**
+ * Apply table decorations — blurred state hides the raw | pipes and renders
+ * an HTML table widget; focused state shows the source with | as muted
+ * decorators. Node wrapper is applied in both states.
+ */
+function applyTableDecorations(
+  block: ParsedToken,
+  sorted: DecorationRange[],
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+  decorationSpecs: Decoration[],
+  isFocused: boolean,
+) {
+  if (block.nodeWrapper) {
+    const firstRange = sorted[0]
+    const lastRange = sorted[sorted.length - 1]
+    if (firstRange && lastRange) {
+      const resolvedFirst = resolveOffset(
+        firstRange.from,
+        boundaries,
+        paragraphs,
+      )
+      const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
+      if (resolvedFirst && resolvedLast) {
+        const fromNode = paragraphs[resolvedFirst.paraIndex]!
+        const toNode = paragraphs[resolvedLast.paraIndex]!
+        decorationSpecs.push(
+          Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
+            class: block.nodeWrapper.className ?? undefined,
+            ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
+          }),
+        )
+      }
+    }
   }
 
-  // Properties — keyed by single-line paragraphs
-  if (cache.properties.length > 0) {
-    buildPropertyDecorations(
-      cache.properties,
+  if (!isFocused) {
+    const firstRange = sorted[0]
+    const tokenStart = firstRange
+      ? resolveOffset(firstRange.from, boundaries, paragraphs)
+      : null
+    const source = block.wrapperAttrs?.["data-table-source"] ?? ""
+    for (const range of sorted) {
+      const specs = buildDecorationsForRange(range, boundaries, paragraphs)
+      for (const spec of specs) {
+        decorationSpecs.push(
+          Decoration.inline(spec.from, spec.to, {
+            class: "md-table-hidden",
+          }),
+        )
+      }
+    }
+    if (source && tokenStart) {
+      decorationSpecs.push(
+        Decoration.widget(
+          tokenStart.pmPos,
+          (view) => {
+            const widgetSpan = document.createElement("span")
+            widgetSpan.className = "md-table-rendered"
+            widgetSpan.innerHTML = renderTableHtml(source)
+            widgetSpan.addEventListener("mousedown", (e) => {
+              e.preventDefault()
+              e.stopPropagation()
+              view.dispatch(
+                view.state.tr.setSelection(
+                  TextSelection.near(
+                    view.state.doc.resolve(tokenStart.pmPos),
+                  ),
+                ),
+              )
+              view.focus()
+            })
+            return widgetSpan
+          },
+          { side: -1 },
+        ),
+      )
+    }
+    return
+  }
+
+  const source = block.wrapperAttrs?.["data-table-source"] ?? ""
+  const tableStart = sorted[0]!.from
+  let idx = 0
+  for (;;) {
+    idx = source.indexOf("|", idx)
+    if (idx === -1) break
+    const combinedFrom = tableStart + idx
+    const combinedTo = tableStart + idx + 1
+    const specs = buildDecorationsForRange(
+      { type: "hidden", from: combinedFrom, to: combinedTo },
       boundaries,
       paragraphs,
-      decorationSpecs,
     )
+    for (const spec of specs) {
+      decorationSpecs.push(
+        Decoration.inline(spec.from, spec.to, {
+          class: "md-decorator",
+          ...(block.wrapperAttrs ?? {}),
+        }),
+      )
+    }
+    idx++
   }
-
-  log.debug("build decorations", {
-    count: decorationSpecs.length,
-    paragraphs: paragraphs.length,
-  })
-  return DecorationSet.create(doc, decorationSpecs)
 }
 
 /**
@@ -323,106 +448,15 @@ function applyBlockDecorations(
 
   const sorted = [...ranges].sort((a, b) => a.from - b.from)
 
-  // ── Table: hide source when blurred, render widget ──
   if (block.type === "table") {
-    // ── Apply node wrapper (same for both focused and blurred) ──
-    if (block.nodeWrapper) {
-      const firstRange = sorted[0]
-      const lastRange = sorted[sorted.length - 1]
-      if (firstRange && lastRange) {
-        const resolvedFirst = resolveOffset(
-          firstRange.from,
-          boundaries,
-          paragraphs,
-        )
-        // lastRange.to is the exclusive end — may land on a \n separator
-        // boundary where resolveOffset returns null (no node wrapper).
-        const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
-        if (resolvedFirst && resolvedLast) {
-          const fromNode = paragraphs[resolvedFirst.paraIndex]!
-          const toNode = paragraphs[resolvedLast.paraIndex]!
-          decorationSpecs.push(
-            Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
-              class: block.nodeWrapper.className ?? undefined,
-              ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
-            }),
-          )
-        }
-      }
-    }
-
-    if (!isFocused) {
-      const firstRange = sorted[0]
-      const tokenStart = firstRange
-        ? resolveOffset(firstRange.from, boundaries, paragraphs)
-        : null
-      const source = block.wrapperAttrs?.["data-table-source"] ?? ""
-      for (const range of sorted) {
-        const specs = buildDecorationsForRange(range, boundaries, paragraphs)
-        for (const spec of specs) {
-          decorationSpecs.push(
-            Decoration.inline(spec.from, spec.to, {
-              class: "md-table-hidden",
-            }),
-          )
-        }
-      }
-      if (source && tokenStart) {
-        decorationSpecs.push(
-          Decoration.widget(
-            tokenStart.pmPos,
-            (view) => {
-              const widgetSpan = document.createElement("span")
-              widgetSpan.className = "md-table-rendered"
-              // Rendered HTML from user source — cell content is escaped
-              // inside renderCellContent, but inline-math injects KaTeX
-              // output via innerHTML. This is local-editor-context safe.
-              widgetSpan.innerHTML = renderTableHtml(source)
-              widgetSpan.addEventListener("mousedown", (e) => {
-                e.preventDefault()
-                e.stopPropagation()
-                view.dispatch(
-                  view.state.tr.setSelection(
-                    TextSelection.near(
-                      view.state.doc.resolve(tokenStart.pmPos),
-                    ),
-                  ),
-                )
-                view.focus()
-              })
-              return widgetSpan
-            },
-            { side: -1 },
-          ),
-        )
-      }
-      return
-    }
-
-    // ── Focused: show source with | as muted decorators ──
-    const source = block.wrapperAttrs?.["data-table-source"] ?? ""
-    const tableStart = sorted[0]!.from
-    let idx = 0
-    for (;;) {
-      idx = source.indexOf("|", idx)
-      if (idx === -1) break
-      const combinedFrom = tableStart + idx
-      const combinedTo = tableStart + idx + 1
-      const specs = buildDecorationsForRange(
-        { type: "hidden", from: combinedFrom, to: combinedTo },
-        boundaries,
-        paragraphs,
-      )
-      for (const spec of specs) {
-        decorationSpecs.push(
-          Decoration.inline(spec.from, spec.to, {
-            class: "md-decorator",
-            ...(block.wrapperAttrs ?? {}),
-          }),
-        )
-      }
-      idx++
-    }
+    applyTableDecorations(
+      block,
+      sorted,
+      boundaries,
+      paragraphs,
+      decorationSpecs,
+      isFocused,
+    )
     return
   }
 
@@ -869,18 +903,29 @@ function getInlineTagInfo(
   }
 }
 
+const cellParseCache = new Map<string, ParsedDecorations>()
+const CELL_CACHE_MAX = 50
+
 /**
  * Render inline markdown to HTML for table cells.
  * Uses the existing Lezer-based parser to extract tokens,
  * then builds HTML by skipping hidden delimiter positions
  * and wrapping content ranges in the appropriate tags.
  *
- * Each cell is parsed independently (no cache) — cells are
- * short enough that this is negligible for the target 16ms budget.
+ * Cell parse results are cached by text content to avoid redundant
+ * Lezer parses on every decoration rebuild.
  */
 function renderCellContent(text: string): string {
   if (!text) return ""
-  const result = parseMarkdown(text)
+  let result = cellParseCache.get(text)
+  if (!result) {
+    result = parseMarkdown(text)
+    if (cellParseCache.size >= CELL_CACHE_MAX) {
+      const firstKey = cellParseCache.keys().next().value
+      if (firstKey !== undefined) cellParseCache.delete(firstKey)
+    }
+    cellParseCache.set(text, result)
+  }
   if (result.content.length === 0) return escapeHtml(text)
 
   const hidden = new Set<number>()

+ 139 - 0
packages/editor/src/lib/__tests__/operation-history.test.ts

@@ -379,4 +379,143 @@ describe("createOperationHistory", () => {
       timestamp: 1000,
     })
   })
+
+  describe("coalesce", () => {
+    it("returns false when history is empty", () => {
+      const h = createOperationHistory()
+      expect(
+        h.coalesce({
+          type: "set-block-content",
+          blockId: "a",
+          previousContent: "",
+          newContent: "hello",
+          timestamp: 1,
+        }),
+      ).toBe(false)
+    })
+
+    it("returns false when type does not match last entry", () => {
+      const h = createOperationHistory()
+      h.execute({
+        type: "indent-block",
+        index: 0,
+        previousDepth: 0,
+        timestamp: 1,
+      })
+      expect(
+        h.coalesce({
+          type: "set-block-content",
+          blockId: "a",
+          previousContent: "",
+          newContent: "hello",
+          timestamp: 2,
+        }),
+      ).toBe(false)
+    })
+
+    it("replaces last operation and recomputes inverse", () => {
+      const h = createOperationHistory()
+      h.execute({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "h",
+        timestamp: 1,
+      })
+
+      const result = h.coalesce({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "hello",
+        timestamp: 2,
+      })
+      expect(result).toBe(true)
+
+      // Inverse should restore to the preserved previousContent
+      const inverse = h.undo()
+      expect(inverse).not.toBeNull()
+      expect(inverse!.type).toBe("set-block-content")
+      expect((inverse as any).newContent).toBe("")
+      expect((inverse as any).previousContent).toBe("hello")
+    })
+
+    it("replaces the operation and recomputes inverse for set-block-content", () => {
+      const h = createOperationHistory()
+
+      // First burst
+      h.execute({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "hi",
+        timestamp: 1,
+      })
+
+      // Coalesce with preserved previousContent
+      const result = h.coalesce({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "hi there",
+        timestamp: 2,
+      })
+      expect(result).toBe(true)
+
+      // Undo step 1: restore to "" (original previousContent)
+      const undo1 = h.undo()
+      expect(undo1).not.toBeNull()
+      expect((undo1 as any).newContent).toBe("")
+      expect((undo1 as any).previousContent).toBe("hi there")
+      expect(h.canUndo).toBe(false)
+
+      // Redo
+      const redo1 = h.redo()
+      expect(redo1).not.toBeNull()
+      expect(redo1!.type).toBe("set-block-content")
+      expect((redo1 as any).newContent).toBe("hi there")
+      expect(h.canUndo).toBe(true)
+      expect(h.canRedo).toBe(false)
+    })
+
+    it("clears future stack on coalesce", () => {
+      const h = createOperationHistory()
+      h.execute({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "hi",
+        timestamp: 1,
+      })
+      h.execute({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "hi",
+        newContent: "hello",
+        timestamp: 2,
+      })
+      // past: [hi, hello], future: []
+
+      // Undo the second entry → future has hello entry
+      h.undo()
+      expect(h.canRedo).toBe(true)
+      expect(h.canUndo).toBe(true)
+
+      // Coalesce on the remaining past entry
+      h.coalesce({
+        type: "set-block-content",
+        blockId: "a",
+        previousContent: "",
+        newContent: "wasup",
+        timestamp: 3,
+      })
+      expect(h.canRedo).toBe(false)
+      expect(h.canUndo).toBe(true)
+
+      // Undo should restore to the preserved previousContent
+      const undo1 = h.undo()
+      expect(undo1).not.toBeNull()
+      expect(undo1).toHaveProperty("newContent", "")
+    })
+  })
 })

+ 8 - 0
packages/editor/src/lib/block-parser.ts

@@ -30,6 +30,14 @@ const ID_LINE_RE = /^\s*id::\s*(\S+)\s*$/
  * Scans from the last line upward looking for `id:: <uuid>`. If found,
  * returns the ID — the `id::` line is NOT removed from content (it's a
  * visible property line that survives round-trips automatically).
+ *
+ * NOTE: This operates on raw content lines without knowledge of code
+ * fences (```). A line like `  id:: abc-123` inside a code block (e.g.
+ * YAML, config file) will be falsely matched. In practice this is benign
+ * because the matched ID still uniquely identifies the block — the
+ * false match doesn't corrupt content — but it means the code-fence
+ * content cannot declare its own `id::` property separate from the
+ * block's identity.
  */
 export function extractEmbeddedId(content: string): string | null {
   const lines = content.split("\n")

+ 21 - 0
packages/editor/src/lib/operation-history.ts

@@ -270,6 +270,17 @@ export interface OperationHistory {
    * Returns null if the history is empty.
    */
   peek(): EditorOperation | null
+  /**
+   * Replace the most recent past entry's operation with `op`, recompute
+   * its inverse, and clear the redo stack. Returns false if the history
+   * is empty or the operation type doesn't match the last entry.
+   *
+   * Does NOT run `execute()` side effects — this is purely a storage-level
+   * coalesce. The caller is responsible for checking coalescing criteria
+   * (same target, time window, etc.) and for preserving any fields that
+   * should not change (e.g. `previousContent` on set-block-content).
+   */
+  coalesce(op: EditorOperation): boolean
 }
 
 interface HistoryEntry {
@@ -325,5 +336,15 @@ export function createOperationHistory(
       if (past.length === 0) return null
       return past[past.length - 1]!.operation
     },
+
+    coalesce(op: EditorOperation): boolean {
+      if (past.length === 0) return false
+      const entry = past[past.length - 1]!
+      if (entry.operation.type !== op.type) return false
+      entry.operation = op
+      entry.inverse = computeInverse(op)
+      future.length = 0
+      return true
+    },
   }
 }