Przeglądaj źródła

feat(editor): support GFM tables with inline markdown rendering

Refactor decoration cache from per-paragraph to combined-text model,
enabling multi-line block elements (tables, callout continuations).
Tables: blurred → hidden source + HTML widget with cells rendered
through Lezer inline parser; focused → | pipes as decorators with
editable raw source.

- Combined-paragraph parsing (joined by \n, mapped across boundaries)
- createTableRule matching Lezer Table nodes (block-rules.ts)
- renderCellContent() for inline markdown→HTML in blurred table cells
- Table CSS (md-table, md-table-hidden, md-table-rendered)
- Callout continuation fix: first line hides marker, continuation >
- Property parsing: per-line iteration for multi-line paragraphs
- Remove incremental Lezer (prevented GFM table reclassification)
- Remove dead code: mapRange, seenAnyBlock, oldTree, wrapperAttrs param
- Remove redundant forceBlockWrapper param, debug log, unused attrs
- 9 table tests; fix resolveOffset comments; document full re-parse
Zander Hawke 4 dni temu
rodzic
commit
1a264aa1e3

+ 7 - 1
AGENTS.md

@@ -90,11 +90,16 @@ status                          priority
 ### Obsidian-Style Elements
 
 | Syntax | Example |
-|---|---|
+|---|---|---|
 | Page reference | `[[Page Name]]` or `[[Real Page\|Display Name]]` |
 | Block reference | `((block-id-1234))` |
 | Callout | `> [!NOTE] Description text` |
 | Property | `author:: John Doe` |
+| Table | `\| A \| B \|\n\|---\|---\|\n\| 1 \| 2 \|` |
+
+### Tables
+
+Tables use a **two-state rendering pattern**: blurred → source hidden, HTML widget rendered via `renderTableHtml()` → `renderCellContent()` (runs each cell through the Lezer parser for inline markdown → HTML conversion); focused → `|` pipes styled as `md-decorator`, cell content editable as raw markdown. Clicking the rendered table focuses the block.
 
 **Valid callout types:** `NOTE`, `WARNING`, `TIP`, `DANGER`, `INFO`
 
@@ -374,6 +379,7 @@ This prevents CM6 from parsing fence backticks as template literals.
 - Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `Block.vue` instances.
 - `visitTree` processes each Lezer node exactly once. Do not introduce nested tree walks.
 - Avoid allocations inside hot paths — reuse range objects where possible.
+- **No incremental Lezer parsing.** `invalidateChangedCaches` always passes `undefined` as the old tree. Incremental parse fragments prevent GFM from re-classifying `Paragraph` → `Table` when the delimiter line is typed later. Full re-parse is negligible for blocks under 100 chars.
 
 ---
 

+ 15 - 2
packages/editor/README.md

@@ -173,11 +173,23 @@ Formatting is purely decorative — markdown syntax is stored as plain text in P
 | Property | `key:: value` | `md-property-*` |
 | Inline Math | `$...$` | `md-math-inline` (+ KaTeX preview on blur) |
 | Display Math | `$$...$$` | MathBlock node view (+ KaTeX preview) |
+| Table | `\| A \| B \|` / `\|---|---\|` / `\| 1 \| 2 \|` | Blurred: rendered HTML widget via `renderCellContent()`. Focused: \`\|\` decorators, editable raw source. |
 | Fenced Code Block | `` ```python `` / `~~~js` | CM6 node view |
 | Highlight | `^^text^^` | `md-highlight` |
 
 > **Priority flags** (`[#A]`, `[#B]`, `[#C]`) were removed from the parser — they render as plain text. Styling classes remain for backward compatibility.
 
+### Table Rendering
+
+Tables are parsed by Lezer GFM as `Table` nodes (detected on full re-parse, not incrementally — see `invalidateChangedCaches` in `useMarkdownDecorations.ts`).
+
+| State | Behavior |
+|---|---|
+| **Blurred** | Source hidden (`md-table-hidden`). A `Decoration.widget` renders the HTML table via `renderTableHtml()` → `renderCellContent()`, which runs each cell through the Lezer parsing pipeline to convert inline markdown (bold, italic, code, links, page-refs, inline-math, etc.) to HTML. |
+| **Focused** | Source revealed. Pipe `\|` characters are styled as `md-decorator`, leaving cell content editable as raw markdown so inline rules (bold, links, math) work via live decorations. |
+
+Clicking the rendered table focuses the block and reveals the source. All inline patterns (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`, `$...$`, `[[page]]`, `((id))`, `#tag`, `~~strike~~`, `^^high^^`) render correctly in blurred cells.
+
 ### Pattern Detection
 
 As the user types, a ProseMirror `ViewPlugin` (`usePatternPlugin.ts`) detects trigger characters and emits lifecycle events:
@@ -250,8 +262,9 @@ pnpm dev          # Watch mode rebuild
 | `src/lib/schema.ts` | ProseMirror schema |
 | `src/lib/markdown-parser.ts` | Lezer parser configuration |
 | `src/lib/markdown-extensions.ts` | Custom Lezer node definitions (PageRef, BlockRef, Tag, Highlight, InlineMath, Task) |
-| `src/lib/markdown-rules/engine.ts` | MarkdownRuleEngine — Lezer AST walk |
+| `src/lib/markdown-rules/engine.ts` | MarkdownRuleEngine — Lezer AST walk, property extraction |
 | `src/lib/markdown-rules/inline-rules.ts` | Inline decoration rules |
-| `src/lib/markdown-rules/block-rules.ts` | Block decoration rules |
+| `src/lib/markdown-rules/block-rules.ts` | Block decoration rules (`createTableRule`, callout, task, etc.) |
 | `src/lib/markdown-rules/types.ts` | Shared type definitions |
 | `src/lib/markdown-rules/block-classifier.ts` | Regex first-line classifier (paste only) |
+| `src/composables/useMarkdownDecorations.ts` | Decoration plugin + `renderTableHtml()` / `renderCellContent()` |

+ 36 - 0
packages/editor/src/assets/style.css

@@ -401,6 +401,42 @@
   color: var(--code-meta);
 }
 
+/* ── Tables ────────────────────────────────────────────────── */
+
+.md-table {
+  @apply my-2;
+}
+
+.md-table-hidden {
+  display: none !important;
+}
+
+.md-table-rendered {
+  @apply block w-full overflow-x-auto;
+}
+
+.md-table-rendered table {
+  @apply w-full border-collapse text-sm;
+  table-layout: fixed;
+}
+
+.md-table-rendered th,
+.md-table-rendered td {
+  @apply border border-(--ui-border) px-3 py-2 text-left;
+}
+
+.md-table-rendered th {
+  @apply bg-(--ui-bg-elevated) font-semibold;
+}
+
+.md-table-rendered tbody tr:nth-child(even) td {
+  @apply bg-(--ui-bg-elevated)/50;
+}
+
+.md-table-rendered tbody tr:hover td {
+  @apply bg-(--ui-bg-elevated)/80;
+}
+
 /* ── Inline Math ────────────────────────────────────────────────── */
 
 .md-math-inline {

+ 3 - 1
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -239,7 +239,9 @@ describe("Markdown decorations plugin", () => {
       }
     }
 
-    expect(mdCalloutCount).toBe(2)
+    // Combined-parsing: one node wrapper covers the full callout (both lines
+    // merged into a single md-callout block), not one per paragraph.
+    expect(mdCalloutCount).toBe(1)
   })
 
   it("builds decorations for inline math", () => {

+ 615 - 188
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -41,12 +41,16 @@ interface ParsedToken {
 }
 
 /**
- * Cached token for a single textblock (paragraph).
+ * Cached token for the combined content of all paragraphs in a block.
  * The token array is the source of truth — decorations are derived.
  */
 interface BlockTokenCache {
-  /** Text content this cache was built from */
+  /** Combined text of all paragraphs (joined with \n) */
   text: string
+  /** Cumulative start offsets of each paragraph in the combined text.
+   *  For N paragraphs, boundaries has N+1 entries — the last is the
+   *  combined text length (excluding the trailing \n on the last para). */
+  boundaries: number[]
   /** Lezer AST tree for incremental re-parsing */
   tree?: Tree
   /** Inline tokens (bold, links, refs, etc.) */
@@ -63,17 +67,19 @@ interface BlockTokenCache {
 }
 
 /**
- * Parse tokens for a textblock. Returns cached structure.
+ * Parse tokens for the combined text of a block. Returns cached structure.
  * Only runs when document content changes.
  */
 function parseBlockTokens(
   text: string,
+  boundaries: number[],
   engine: MarkdownRuleEngine,
   oldTree?: Tree,
 ): BlockTokenCache {
   const result = parseMarkdown(text, engine, oldTree)
   return {
     text,
+    boundaries,
     tree: result.tree,
     content: result.content as ParsedToken[],
     blocks: result.blocks as ParsedToken[],
@@ -81,6 +87,74 @@ function parseBlockTokens(
   }
 }
 
+/**
+ * Collect all non-code textblocks from a PM doc.
+ */
+interface ParagraphInfo {
+  node: ProsemirrorNode
+  pos: number
+  text: string
+}
+
+function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
+  const paragraphs: ParagraphInfo[] = []
+  doc.descendants((node: ProsemirrorNode, pos: number) => {
+    if (!node.isTextblock) return
+    if (node.type.spec.code) return
+    paragraphs.push({ node, pos, text: node.textContent })
+  })
+  return paragraphs
+}
+
+/**
+ * Build the combined text (joined by \n) and boundary offsets.
+ * boundaries[0] = 0, boundaries[i] = start offset of paragraph i,
+ * boundaries[N] = total length (last para end, no trailing \n).
+ */
+function buildCombinedText(paragraphs: ParagraphInfo[]): {
+  text: string
+  boundaries: number[]
+} {
+  const parts: string[] = []
+  const boundaries: number[] = [0]
+  for (const p of paragraphs) {
+    parts.push(p.text)
+    boundaries.push(boundaries[boundaries.length - 1]! + p.text.length + 1)
+  }
+  return { text: parts.join("\n"), boundaries }
+}
+
+/**
+ * Map a combined-text offset to PM document position.
+ * Returns null if the offset falls on a \n separator between paragraphs.
+ */
+interface ResolvedOffset {
+  paraIndex: number
+  paraOffset: number
+  pmPos: number
+}
+
+function resolveOffset(
+  combinedOffset: number,
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+): ResolvedOffset | null {
+  for (let i = 0; i < paragraphs.length; i++) {
+    const start = boundaries[i]!
+    const end = boundaries[i + 1]!
+    // end includes the \n separator, so para text is [start, end - 1)
+    // <= accepts end - 1 (the \n) for block-wrapper positioning
+    if (combinedOffset >= start && combinedOffset <= end - 1) {
+      const paraOffset = combinedOffset - start
+      const pmPos = paragraphs[i]!.pos + 1 + paraOffset
+      return { paraIndex: i, paraOffset, pmPos }
+    }
+    // combinedOffset === end - 1 means the \n separator
+    // combinedOffset > end - 1 means beyond this paragraph
+  }
+  return null
+}
+
 /**
  * Build decorations from cached tokens.
  * Runs lazily in props.decorations — only when ProseMirror asks for them.
@@ -94,131 +168,283 @@ function buildDecorationsFromCache(
 ): DecorationSet {
   const decorationSpecs: Decoration[] = []
 
-  const paragraphs: {
-    node: ProsemirrorNode
-    pos: number
-    cache: BlockTokenCache
-  }[] = []
+  const paragraphs = collectParagraphs(doc)
+  if (paragraphs.length === 0) return DecorationSet.empty
 
-  doc.descendants((node: ProsemirrorNode, pos: number) => {
-    if (!node.isTextblock) return
-    if (node.type.spec.code) return
-    const cache = contentCaches.get(node.textContent)
-    if (!cache) return
-    paragraphs.push({ node, pos, cache })
-  })
+  const { text: combinedText, boundaries } = buildCombinedText(paragraphs)
+
+  const cache = contentCaches.get(combinedText)
+  if (!cache) return DecorationSet.empty
 
   let calloutType: string | null = null
 
-  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
+  for (const block of cache.blocks) {
+    // Track callout type from callout 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,
+        boundaries,
+        paragraphs,
+        decorationSpecs,
+        isFocused,
+        markerMode,
+      )
+    } else {
+      applyBlockDecorations(
+        block,
+        boundaries,
+        paragraphs,
+        decorationSpecs,
+        isFocused,
+        markerMode,
+      )
     }
 
-    // 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) {
+    // Track whether a subsequent non-callout/blockquote resets the group
+    if (block.type !== "callout" && block.type !== "blockquote") {
       calloutType = null
     }
+  }
 
-    for (const block of cache.blocks) {
-      if (calloutType && block.type === "blockquote") {
-        const continuationBlock = {
-          ...block,
-          type: "callout" as const,
-          nodeWrapper: {
-            className: "md-callout",
-            attrs: { "data-callout-type": calloutType },
-          },
-          metadata: { calloutType },
-        }
-        if (continuationBlock.nodeWrapper) {
+  for (const token of cache.content) {
+    applyInlineDecorations(
+      token,
+      boundaries,
+      paragraphs,
+      decorationSpecs,
+      isFocused,
+      markerMode,
+    )
+  }
+
+  // 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 block-level decorations (node wrapper + hidden/visible ranges).
+ */
+function applyBlockDecorations(
+  block: ParsedToken,
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+  decorationSpecs: Decoration[],
+  isFocused: boolean,
+  markerMode: MarkerVisibilityMode,
+) {
+  const ranges = block.decorationRanges
+  if (ranges.length === 0 && !block.nodeWrapper) return
+
+  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(pos, pos + node.nodeSize, {
-              class: continuationBlock.nodeWrapper.className ?? undefined,
-              ...continuationBlock.nodeWrapper.attrs,
-            } as Record<string, string>),
+            Decoration.node(fromNode.pos, toNode.pos + toNode.node.nodeSize, {
+              class: block.nodeWrapper.className ?? undefined,
+              ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
+            }),
           )
         }
-        buildTokenDecorations(
-          continuationBlock,
-          blockStart,
-          decorationSpecs,
-          isFocused,
-          markerMode,
+      }
+    }
+
+    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,
         )
-      } else {
-        if (block.nodeWrapper) {
+        for (const spec of specs) {
           decorationSpecs.push(
-            Decoration.node(pos, pos + node.nodeSize, {
-              class: block.nodeWrapper.className ?? undefined,
-              ...block.nodeWrapper.attrs,
-            } as Record<string, string>),
+            Decoration.inline(spec.from, spec.to, {
+              class: "md-table-hidden",
+            }),
           )
         }
-        buildTokenDecorations(
-          block,
-          blockStart,
-          decorationSpecs,
-          isFocused,
-          markerMode,
+      }
+      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
     }
 
-    for (const token of cache.content) {
-      buildTokenDecorations(
-        token,
-        blockStart,
-        decorationSpecs,
-        isFocused,
-        markerMode,
+    // ── 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++
     }
+    return
+  }
 
-    buildPropertyDecorations(
-      cache.properties,
-      blockStart,
-      decorationSpecs,
-    )
+  // ── Blockquote / callout continuation merge support ──
+  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 may point at a \n separator → resolveOffset returns null
+      // (node wrapper is silently dropped — fine for block text ranges).
+      const resolvedLast = resolveOffset(lastRange.to, boundaries, paragraphs)
+      if (resolvedFirst && resolvedLast) {
+        const fromNode = paragraphs[resolvedFirst.paraIndex]!
+        const toNode = paragraphs[resolvedLast.paraIndex]!
+        const startPos = fromNode.pos
+        const endPos = toNode.pos + toNode.node.nodeSize
+        const wrapperClassName = block.nodeWrapper?.className
+        decorationSpecs.push(
+          Decoration.node(startPos, endPos, {
+            class: wrapperClassName ?? undefined,
+            ...((block.nodeWrapper?.attrs as Record<string, string>) ?? {}),
+          }),
+        )
+      }
+    }
   }
 
-  log.debug("build decorations", {
-    count: decorationSpecs.length,
-    paragraphs: paragraphs.length,
-  })
-  return DecorationSet.create(doc, decorationSpecs)
+  // ── Decorations for each range ──
+  for (const range of sorted) {
+    const specs = buildDecorationsForRange(
+      range,
+      boundaries,
+      paragraphs,
+    )
+    for (const spec of specs) {
+      const isHiddenDecorator = range.type === "hidden"
+
+      let rangeClass =
+        range.className ??
+        (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
+
+      if (isHiddenDecorator) {
+        const baseClass = range.className ?? "md-decorator"
+        if (markerMode === "always-visible") {
+          rangeClass = baseClass
+        } else if (markerMode === "live-preview") {
+          rangeClass = isFocused
+            ? baseClass
+            : `${baseClass} md-decorator-hidden`
+        }
+      }
+
+      decorationSpecs.push(
+        Decoration.inline(spec.from, spec.to, {
+          nodeName: "span",
+          class: rangeClass,
+          ...(block.wrapperAttrs ?? {}),
+        }),
+      )
+    }
+  }
 }
 
 /**
- * Build decorations for a single token.
- *
- * ProseMirror decorations are flat — overlapping ranges produce sibling
- * spans, not nested elements. Wrapper attrs are stamped onto every
- * segment so each span is self-contained.
- *
- * In live preview mode:
- * - type: "content" ranges always show
- * - type: "hidden" ranges (markers) fade based on cursor proximity when focused
+ * Apply inline token decorations (bold, italic, links, tags, etc.).
  */
-function buildTokenDecorations(
+function applyInlineDecorations(
   token: ParsedToken,
-  offset: number,
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
   decorationSpecs: Decoration[],
   isFocused: boolean,
   markerMode: MarkerVisibilityMode,
@@ -231,21 +457,29 @@ function buildTokenDecorations(
   // ── Inline math: show KaTeX when blurred; show source when focused ──
   if (token.type === "inline-math") {
     if (!isFocused) {
-      const tokenStart = offset + sorted[0]!.from
-      // Hide all ranges
+      const firstRange = sorted[0]
+      const tokenStart = firstRange
+        ? resolveOffset(firstRange.from, boundaries, paragraphs)
+        : null
       const source = token.wrapperAttrs?.["data-math"] ?? ""
       for (const range of sorted) {
-        decorationSpecs.push(
-          Decoration.inline(offset + range.from, offset + range.to, {
-            class: "md-math-hidden",
-          }),
+        const specs = buildDecorationsForRange(
+          range,
+          boundaries,
+          paragraphs,
         )
+        for (const spec of specs) {
+          decorationSpecs.push(
+            Decoration.inline(spec.from, spec.to, {
+              class: "md-math-hidden",
+            }),
+          )
+        }
       }
-      // Add clickable KaTeX widget
-      if (source) {
+      if (source && tokenStart) {
         decorationSpecs.push(
           Decoration.widget(
-            tokenStart,
+            tokenStart.pmPos,
             (view) => {
               const widgetSpan = document.createElement("span")
               widgetSpan.className = "md-math-rendered"
@@ -255,7 +489,9 @@ function buildTokenDecorations(
                 e.stopPropagation()
                 view.dispatch(
                   view.state.tr.setSelection(
-                    TextSelection.near(view.state.doc.resolve(tokenStart)),
+                    TextSelection.near(
+                      view.state.doc.resolve(tokenStart.pmPos),
+                    ),
                   ),
                 )
                 view.focus()
@@ -271,43 +507,88 @@ function buildTokenDecorations(
   }
 
   for (const range of sorted) {
-    const rangeFrom = offset + range.from
-    const rangeTo = offset + range.to
-
-    const isHiddenDecorator = range.type === "hidden"
-
-    let rangeClass =
-      range.className ??
-      (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
-
-    // Apply visibility rules based on marker mode
-    if (isHiddenDecorator) {
-      const baseClass = range.className ?? "md-decorator"
-      if (markerMode === "always-visible") {
-        // Always show markers
-        rangeClass = baseClass
-      } else if (markerMode === "live-preview") {
-        // Live preview: reveal markers when focused
-        rangeClass = isFocused ? baseClass : `${baseClass} md-decorator-hidden`
+    const specs = buildDecorationsForRange(
+      range,
+      boundaries,
+      paragraphs,
+    )
+
+    for (const spec of specs) {
+      const isHiddenDecorator = range.type === "hidden"
+
+      let rangeClass =
+        range.className ??
+        (isHiddenDecorator ? "md-decorator" : `md-${range.type}`)
+
+      if (isHiddenDecorator) {
+        const baseClass = range.className ?? "md-decorator"
+        if (markerMode === "always-visible") {
+          rangeClass = baseClass
+        } else if (markerMode === "live-preview") {
+          rangeClass = isFocused
+            ? baseClass
+            : `${baseClass} md-decorator-hidden`
+        }
       }
-    }
 
-    const classes = rangeClass
+      decorationSpecs.push(
+        Decoration.inline(spec.from, spec.to, {
+          nodeName: "span",
+          class: rangeClass,
+          ...(token.wrapperAttrs ?? {}),
+        }),
+      )
+    }
+  }
+}
 
-    const attrs: Record<string, string> = {
-      ...token.wrapperAttrs,
-      class: classes,
+/**
+ * Map a single decoration range (combined-text offsets) to PM-relative specs,
+ * splitting across paragraph boundaries if needed.
+ */
+function buildDecorationsForRange(
+  range: DecorationRange,
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
+): Array<{ from: number; to: number; className?: string }> {
+  const specs: Array<{ from: number; to: number }> = []
+  let pos = range.from
+
+  while (pos < range.to) {
+    // Find paragraph containing this offset
+    let paraIdx = -1
+    let paraStart = 0
+    let paraEnd = 0
+    for (let i = 0; i < paragraphs.length; i++) {
+      const s = boundaries[i]!
+      const e = boundaries[i + 1]! // includes the \n separator
+      if (pos >= s && pos < e - 1) {
+        paraIdx = i
+        paraStart = s
+        paraEnd = e - 1
+        break
+      }
+      if (pos >= s && pos === e - 1 && pos < range.to) {
+        pos = e
+      }
     }
 
-    decorationSpecs.push(
-      Decoration.inline(rangeFrom, rangeTo, {
-        nodeName: "span",
-        ...attrs,
-      }),
-    )
+    if (paraIdx < 0) break
+
+    const segmentEnd = Math.min(range.to, paraEnd)
+    const localFrom = pos - paraStart
+    const localTo = segmentEnd - paraStart
+    const pmPos = paragraphs[paraIdx]!.pos + 1
+    specs.push({ from: pmPos + localFrom, to: pmPos + localTo })
+    pos = segmentEnd
   }
+
+  return specs
 }
 
+/**
+ * Build property decorations (key:: value).
+ */
 function buildPropertyDecorations(
   properties: Array<{
     keyRange: [number, number]
@@ -315,34 +596,35 @@ function buildPropertyDecorations(
     valueRange: [number, number]
     value?: string
   }>,
-  childOffset: number,
+  boundaries: number[],
+  paragraphs: ParagraphInfo[],
   decorationSpecs: Decoration[],
 ) {
   for (const prop of properties) {
+    const resolved = resolveOffset(prop.keyRange[0], boundaries, paragraphs)
+    if (!resolved) continue
+
+    const paraIdx = resolved.paraIndex
+    const base = paragraphs[paraIdx]!.pos + 1 - boundaries[paraIdx]!
+
     const [keyFrom, keyTo] = prop.keyRange
     const [delimFrom, delimTo] = prop.delimiterRange
     const [valueFrom, valueTo] = prop.valueRange
 
-    const keyStart = keyFrom + childOffset
-    const keyEnd = keyTo + childOffset
-    const delimStart = delimFrom + childOffset
-    const delimEnd = delimTo + childOffset
-
-    // Property keys and delimiters always show (they're structural elements)
     decorationSpecs.push(
-      Decoration.inline(keyStart, keyEnd, {
+      Decoration.inline(base + keyFrom, base + keyTo, {
         class: "md-property-key",
       }),
     )
     decorationSpecs.push(
-      Decoration.inline(delimStart, delimEnd, {
+      Decoration.inline(base + delimFrom, base + delimTo, {
         class: "md-property-delimiter",
       }),
     )
 
     if (prop.value) {
       decorationSpecs.push(
-        Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
+        Decoration.inline(base + valueFrom, base + valueTo, {
           class: "md-property-value",
         }),
       )
@@ -350,6 +632,160 @@ function buildPropertyDecorations(
   }
 }
 
+/**
+ * Render a GFM table string to an HTML table.
+ */
+function renderTableHtml(source: string): string {
+  const lines = source.split("\n").filter((l) => l.trim().length > 0)
+  if (lines.length < 2) return ""
+
+  // line[1] is expected to be the GFM separator row (|---|)
+  // If it doesn't look like one, skip it to avoid rendering dashes as cells
+  if (lines.length >= 2 && !/^[\s:|:-]+$/.test(lines[1]!)) {
+    lines.splice(1, 1)
+  }
+
+  const rows: string[] = []
+
+  const headerCells = parseRow(lines[0]!)
+  rows.push(
+    `<thead><tr>${headerCells.map((c) => `<th>${renderCellContent(c.trim())}</th>`).join("")}</tr></thead>`,
+  )
+
+  if (lines.length > 2) {
+    const body: string[] = []
+    for (let i = 2; i < lines.length; i++) {
+      const cells = parseRow(lines[i]!)
+      body.push(
+        `<tr>${cells.map((c) => `<td>${renderCellContent(c.trim())}</td>`).join("")}</tr>`,
+      )
+    }
+    rows.push(`<tbody>${body.join("")}</tbody>`)
+  }
+
+  return `<table>${rows.join("")}</table>`
+}
+
+function parseRow(line: string): string[] {
+  const trimmed = line.trim()
+  const content = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed
+  const endContent = content.endsWith("|") ? content.slice(0, -1) : content
+  // Split on | but not inside escaped pipes
+  return endContent.split(/(?<!\\)\|/).map((c) => c.trim())
+}
+
+function escapeHtml(s: string): string {
+  return s
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;")
+    .replace(/"/g, "&quot;")
+}
+
+interface InlineTagInfo {
+  open: string
+  close: string
+}
+
+/**
+ * Map an inline token to its HTML open/close tags.
+ * For links, extracts the URL from the decoration ranges.
+ */
+function getInlineTagInfo(
+  token: { type: string; decorationRanges: DecorationRange[] },
+  text: string,
+): InlineTagInfo | null {
+  switch (token.type) {
+    case "strong":
+      return { open: "<strong>", close: "</strong>" }
+    case "em":
+      return { open: "<em>", close: "</em>" }
+    case "code":
+      return { open: "<code>", close: "</code>" }
+    case "strike":
+      return { open: "<s>", close: "</s>" }
+    case "highlight":
+      return { open: "<mark>", close: "</mark>" }
+    case "link": {
+      const urlRange = token.decorationRanges[2]
+      if (urlRange?.type === "hidden") {
+        const url = text.slice(urlRange.from + 2, urlRange.to - 1)
+        return { open: `<a href="${escapeHtml(url)}">`, close: "</a>" }
+      }
+      return null
+    }
+    case "page-ref":
+      return { open: '<span class="md-page-ref">', close: "</span>" }
+    case "block-ref":
+      return { open: '<span class="md-block-ref">', close: "</span>" }
+    case "tag":
+      return { open: '<span class="md-tag">', close: "</span>" }
+    default:
+      return null
+  }
+}
+
+/**
+ * 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.
+ */
+function renderCellContent(text: string): string {
+  if (!text) return ""
+  const result = parseMarkdown(text)
+  if (result.content.length === 0) return escapeHtml(text)
+
+  const hidden = new Set<number>()
+  const opens: Record<number, string> = {}
+  const closes: Record<number, string> = {}
+
+  for (const token of result.content) {
+    if (token.type === "inline-math") {
+      const contentRange = token.decorationRanges.find(
+        (r) => r.type === "content",
+      )
+      if (contentRange) {
+        for (const range of token.decorationRanges) {
+          for (let i = range.from; i < range.to; i++) hidden.add(i)
+        }
+        const mathSource = text.slice(contentRange.from, contentRange.to)
+        const temp = document.createElement("span")
+        renderKatexSync(mathSource, temp, false)
+        opens[contentRange.from] = (opens[contentRange.from] ?? "") + temp.innerHTML
+      }
+      continue
+    }
+
+    const tagInfo = getInlineTagInfo(token, text)
+    if (!tagInfo) continue
+
+    for (const range of token.decorationRanges) {
+      if (range.type === "hidden") {
+        for (let i = range.from; i < range.to; i++) hidden.add(i)
+      } else if (range.type === "content") {
+        opens[range.from] = (opens[range.from] ?? "") + tagInfo.open
+        closes[range.to] = (closes[range.to] ?? "") + tagInfo.close
+      }
+    }
+  }
+
+  let html = ""
+  for (let pos = 0; pos < text.length; pos++) {
+    if (closes[pos]) html += closes[pos]
+    if (opens[pos]) html += opens[pos]
+    if (!hidden.has(pos)) html += escapeHtml(text[pos]!)
+  }
+  if (closes[text.length]) html += closes[text.length]
+
+  return html
+}
+
+// ── Plugin ──────────────────────────────────────────────────────────
+
 interface DecorationState {
   engine: MarkdownRuleEngine
   contentCaches: Map<string, BlockTokenCache>
@@ -363,13 +799,12 @@ const markdownDecorationsKey = new PluginKey<DecorationState>(
 )
 
 /**
- * Invalidate caches for textblocks whose content changed.
- * Caches are keyed by content text, so identical content reuses
- * cached tokens even if document positions have shifted.
+ * Invalidate caches when document content changes.
+ * Cache is keyed by the combined text of all paragraphs.
  */
 function invalidateChangedCaches(
   newDoc: ProsemirrorNode,
-  oldDoc: ProsemirrorNode,
+  _oldDoc: ProsemirrorNode,
   oldCaches: Map<string, BlockTokenCache>,
   engine: MarkdownRuleEngine,
 ): Map<string, BlockTokenCache> {
@@ -377,39 +812,28 @@ 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)
-  })
+  const paragraphs = collectParagraphs(newDoc)
+  const combined = buildCombinedText(paragraphs)
+  const combinedText = combined.text
+  const boundaries = combined.boundaries
 
-  let blockIndex = 0
-  newDoc.descendants((node: ProsemirrorNode) => {
-    if (!node.isTextblock) return
-    if (node.type.spec.code) return
-    const blockText = node.textContent
+  if (!combinedText) {
+    // Empty block — no paragraphs
+    return newCaches
+  }
 
-    const oldCache = oldCaches.get(blockText)
-    if (oldCache) {
-      hitCount++
-      newCaches.set(blockText, oldCache)
-    } else {
-      missCount++
-      // Use old tree from same-position block for incremental Lezer parsing
-      const oldTree = oldCacheOrder[blockIndex]?.tree
-      log.info("CACHE MISS", {
-        blockText: JSON.stringify(blockText),
-        blockIndex,
-        hasOldTree: !!oldTree,
-        oldBlockText: oldCacheOrder[blockIndex] ? JSON.stringify(oldCacheOrder[blockIndex]!.text) : "N/A",
-      })
-      newCaches.set(blockText, parseBlockTokens(blockText, engine, oldTree))
-    }
-    blockIndex++
-  })
+  const oldCache = oldCaches.get(combinedText)
+  if (oldCache) {
+    hitCount++
+    newCaches.set(combinedText, oldCache)
+  } else {
+    missCount++
+    // Force full re-parse (no incremental) — incremental Lezer reuses old tree
+    // fragments, which prevents GFM table detection when a Paragraph was
+    // classified before the delimiter line existed.
+    const parsed = parseBlockTokens(combinedText, boundaries, engine, undefined)
+    newCaches.set(combinedText, parsed)
+  }
 
   log.debug("cache invalidate", {
     hitCount,
@@ -422,8 +846,6 @@ function invalidateChangedCaches(
 export function createMarkdownDecorationsPlugin(
   initialMarkerMode: MarkerVisibilityMode = defaultMarkerMode,
 ) {
-  // 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
@@ -436,11 +858,16 @@ export function createMarkdownDecorationsPlugin(
         const engine = new MarkdownRuleEngine()
         const contentCaches = new Map<string, BlockTokenCache>()
 
-        state.doc.descendants((node: ProsemirrorNode) => {
-          if (!node.isTextblock) return
-          const blockText = node.textContent
-          contentCaches.set(blockText, parseBlockTokens(blockText, engine))
-        })
+        const paragraphs = collectParagraphs(state.doc)
+        const combined = buildCombinedText(paragraphs)
+        const combinedText = combined.text
+        const boundaries = combined.boundaries
+        if (combinedText) {
+          contentCaches.set(
+            combinedText,
+            parseBlockTokens(combinedText, boundaries, engine),
+          )
+        }
 
         return {
           engine,

+ 68 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -783,3 +783,71 @@ describe("horizontal-rule", () => {
     expect(result.blocks[0].type).toBe("horizontal-rule")
   })
 })
+
+describe("table", () => {
+  it("parses a simple table as block decoration", () => {
+    const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("parses table without leading/trailing pipes", () => {
+    const result = parseMarkdown("A | B\n--- | ---\nC | D")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("hides table source with hidden decoration range", () => {
+    const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
+    const block = result.blocks[0] as BlockDecoration
+    expect(block.decorationRanges).toHaveLength(1)
+    expect(block.decorationRanges[0]).toEqual({
+      type: "hidden",
+      from: 0,
+      to: 33,
+      className: "md-table-hidden",
+    })
+  })
+
+  it("stores raw source in wrapperAttrs", () => {
+    const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
+    expect(result.blocks[0].wrapperAttrs?.["data-table-source"]).toBe(
+      "| A | B |\n| --- | --- |\n| C | D |",
+    )
+  })
+
+  it("has nodeWrapper with md-table class", () => {
+    const result = parseMarkdown("| A | B |\n| --- | --- |\n| C | D |")
+    expect(
+      (result.blocks[0] as BlockDecoration).nodeWrapper?.className,
+    ).toBe("md-table")
+  })
+
+  it("parses table with alignment markers", () => {
+    const result = parseMarkdown(
+      "| Left | Center | Right |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |",
+    )
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("parses table with header only (no body rows)", () => {
+    const result = parseMarkdown("| A | B |\n| --- | --- |")
+    expect(result.blocks).toHaveLength(1)
+    expect(result.blocks[0].type).toBe("table")
+  })
+
+  it("does not create table block for plain text", () => {
+    const result = parseMarkdown("not a table")
+    expect(result.blocks).toHaveLength(0)
+  })
+
+  it("tables coexist with other blocks in the same text", () => {
+    const result = parseMarkdown(
+      "# Title\n\n| A | B |\n| --- | --- |\n| C | D |",
+    )
+    expect(result.blocks).toHaveLength(2)
+    expect(result.blocks[0].type).toBe("heading")
+    expect(result.blocks[1].type).toBe("table")
+  })
+})

+ 79 - 21
packages/editor/src/lib/markdown-rules/block-rules.ts

@@ -99,20 +99,50 @@ function createCalloutDecoration(
   calloutType: CalloutType,
   text: string,
 ): BlockDecoration {
-  const trimmed = text.trimStart()
-  const leadingOffset = text.length - trimmed.length
+  const decorationRanges: {
+    type: "hidden" | "content"
+    from: number
+    to: number
+    className: string
+  }[] = []
 
-  // Find position of "]" character to determine marker end
-  const bracketEnd = trimmed.indexOf("]")
+  let offset = node.from
+  const lines = text.split("\n")
 
-  // Absolute positions in docText
-  // Marker starts at the ">" (including leading spaces)
-  const markerStart = node.from + leadingOffset
-  // Marker ends right after "]" (just the "> [!NOTE]" part)
-  const markerEnd =
-    bracketEnd >= 0
-      ? markerStart + (bracketEnd + 1) // +1 to include the "]"
-      : markerStart + trimmed.length
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]!
+    const trimmed = line.trimStart()
+    const leadingOffset = line.length - trimmed.length
+
+    if (i === 0) {
+      // First line: hide "> [!NOTE]" marker
+      const bracketEnd = trimmed.indexOf("]")
+      const markerStart = offset + leadingOffset
+      const markerEnd =
+        bracketEnd >= 0
+          ? markerStart + (bracketEnd + 1)
+          : markerStart + trimmed.length
+      decorationRanges.push({
+        type: "hidden",
+        from: markerStart,
+        to: markerEnd,
+        className: "md-marker",
+      })
+    } else {
+      // Continuation lines: hide just the ">" marker
+      const gtPos = trimmed.indexOf(">")
+      if (gtPos >= 0) {
+        decorationRanges.push({
+          type: "hidden",
+          from: offset + leadingOffset + gtPos,
+          to: offset + leadingOffset + gtPos + 1,
+          className: "md-marker",
+        })
+      }
+    }
+
+    offset += line.length + 1
+  }
 
   return {
     type: "callout",
@@ -120,14 +150,7 @@ function createCalloutDecoration(
       className: "md-callout",
       attrs: { "data-callout-type": calloutType },
     },
-    decorationRanges: [
-      {
-        type: "hidden",
-        from: markerStart,
-        to: markerEnd,
-        className: "md-marker",
-      },
-    ],
+    decorationRanges,
     metadata: { calloutType },
   }
 }
@@ -231,7 +254,11 @@ export function createHorizontalRuleRule(): BlockRule {
       // Reject even-length *-based HRs (e.g. ****) — they're likely
       // bold toggle pairs, not thematic breaks.
       const text = ctx.doc.slice(node.from, node.to)
-      if (text.length >= 4 && text[0] === "*" && text.split("").every((c) => c === "*")) {
+      if (
+        text.length >= 4 &&
+        text[0] === "*" &&
+        text.split("").every((c) => c === "*")
+      ) {
         return text.length % 2 !== 0
       }
       return true
@@ -257,6 +284,36 @@ export function createHorizontalRuleRule(): BlockRule {
   }
 }
 
+// ── Table rule ──────────────────────────────────────────────────
+
+export function createTableRule(): BlockRule {
+  return {
+    name: "table",
+    nodeTypes: ["Table"],
+    match(node) {
+      return node.type.name === "Table"
+    },
+    run(node, ctx) {
+      const source = ctx.doc.slice(node.from, node.to)
+      return {
+        type: "table",
+        nodeWrapper: {
+          className: "md-table",
+        },
+        wrapperAttrs: { "data-table-source": source },
+        decorationRanges: [
+          {
+            type: "hidden",
+            from: node.from,
+            to: node.to,
+            className: "md-table-hidden",
+          },
+        ],
+      }
+    },
+  }
+}
+
 // ── Default block rule set ────────────────────────────────────────
 
 export function createBlockRules(): BlockRule[] {
@@ -265,5 +322,6 @@ export function createBlockRules(): BlockRule[] {
     createHeadingRule(),
     createTaskRule(),
     createHorizontalRuleRule(),
+    createTableRule(),
   ]
 }

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

@@ -133,7 +133,16 @@ export class MarkdownRuleEngine {
         const nodeType = node.type.name
         visitedNodeTypes.push(nodeType)
 
-        log.info("ENTER", { type: nodeType, from: node.from, to: node.to, text: JSON.stringify(node.to - node.from <= 50 ? docText.slice(node.from, node.to) : "...") })
+        log.info("ENTER", {
+          type: nodeType,
+          from: node.from,
+          to: node.to,
+          text: JSON.stringify(
+            node.to - node.from <= 80
+              ? docText.slice(node.from, node.to)
+              : "...",
+          ),
+        })
 
         const blockCandidates = this.blockRuleIndex.get(nodeType)
         if (blockCandidates) {
@@ -169,8 +178,22 @@ export class MarkdownRuleEngine {
       },
     })
 
-    // ── Phase 2: Property extraction ────────────────────────────────
-    properties.push(...parseProperties(docText))
+    // ── Phase 2: Property extraction (per line) ────────────────────
+    const lines = docText.split("\n")
+    let lineOffset = 0
+    for (const line of lines) {
+      const lineProps = parseProperties(line)
+      for (const prop of lineProps) {
+        prop.keyRange[0] += lineOffset
+        prop.keyRange[1] += lineOffset
+        prop.delimiterRange[0] += lineOffset
+        prop.delimiterRange[1] += lineOffset
+        prop.valueRange[0] += lineOffset
+        prop.valueRange[1] += lineOffset
+        properties.push(prop)
+      }
+      lineOffset += line.length + 1
+    }
 
     const totalTime = performance.now() - startTime
     log.debug("parse", {

+ 7 - 1
packages/editor/src/lib/markdown-rules/types.ts

@@ -85,7 +85,13 @@ export type PriorityLevel = "A" | "B" | "C"
 export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
 
 export interface BlockDecoration extends ParsedToken {
-  type: "heading" | "task" | "blockquote" | "callout" | "horizontal-rule"
+  type:
+    | "heading"
+    | "task"
+    | "blockquote"
+    | "callout"
+    | "horizontal-rule"
+    | "table"
   metadata?: {
     level?: number
     taskState?: TaskState