Răsfoiți Sursa

fix(editor): preserve single vs double newline via hard_break; add history coalescing tests

- Add hard_break node to ProseMirror schema to distinguish \n (continuation
  line) from \n\n (paragraph break) within block content
- Update contentToDoc: group consecutive non-empty lines into paragraphs with
  hard_break nodes; blank lines (\n\n) remain paragraph separators
- Update docToContent: inlineToString walker emits "\n" for hard_break,
  "\n\n" between paragraphs — round-trip is now idempotent
- Fix downstream consumers (collectParagraphs, getTextBeforeCursor, selectText)
  to use inlineToString instead of node.textContent which omits hard_break
- Fix callout marker: hide "[!NOTE]" only (not ">") so first-line ">" matches
  continuation-line ">" styling
- Add Phase 2.6 E2E tests: rapid typing coalesces into single undo step,
  pause >500ms creates separate history entry
- Add 15 property-style roundtrip tests asserting docToContent(contentToDoc(x)) === x
Zander Hawke 1 zi în urmă
părinte
comite
781191f2f4

+ 62 - 0
packages/editor/e2e/history.spec.ts

@@ -0,0 +1,62 @@
+import { expect, test } from "@playwright/test"
+
+test.describe("History coalescing", () => {
+  test.beforeEach(async ({ page }) => {
+    await page.goto("/editor?e2e=true")
+  })
+
+  test("rapid typing coalesces into single undo step", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      { bid: firstId },
+    )
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.focusAtEnd(bid),
+      { bid: firstId },
+    )
+
+    const before = await page.evaluate(() => window.__testUtils__?.getContent())
+    // Type rapidly — coalesced within same event tick (<500ms apart)
+    await page.keyboard.type("xyz")
+    const afterType = await page.evaluate(() => window.__testUtils__?.getContent())
+    expect(afterType).not.toBe(before)
+
+    // Single undo reverts all three characters
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(before)
+
+    // Redo restores them
+    await page.locator('[data-toolbar-action="redo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(afterType)
+  })
+
+  test("pause >500ms creates separate history entry", async ({ page }) => {
+    const blockIds = await page.evaluate(() => window.__testUtils__?.getBlockIds())
+    const firstId = blockIds![0]
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.replaceContent(bid, "hello"),
+      { bid: firstId },
+    )
+    await page.evaluate(
+      ({ bid }: { bid: string }) => window.__testUtils__?.focusAtEnd(bid),
+      { bid: firstId },
+    )
+
+    const before = await page.evaluate(() => window.__testUtils__?.getContent())
+    await page.keyboard.type("a")
+    const afterA = await page.evaluate(() => window.__testUtils__?.getContent())
+    // Pause beyond 500ms coalescing window
+    await page.waitForTimeout(600)
+    await page.keyboard.type("b")
+
+    // First undo reverts only "b" (separate history entry)
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(afterA)
+
+    // Second undo reverts "a"
+    await page.locator('[data-toolbar-action="undo"]').click()
+    expect(await page.evaluate(() => window.__testUtils__?.getContent())).toBe(before)
+  })
+})

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

@@ -36,6 +36,7 @@ import {
   splitBlocks as opSplitBlocks,
 } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
+import { inlineToString } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
 import {
   createOperationHistory,
@@ -792,14 +793,14 @@ onMounted(() => {
       const v = api.view as EditorView
       v.focus()
       // Map 0-based character offsets (as returned by docToContent) → PM document positions.
-      // Walks all textblocks accounting for "\n\n" separators between them.
+      // Uses inlineToString to account for hard_break nodes (textContent omits them).
       const doc = v.state.doc
       let charPos = 0
       let pmFrom: number | null = null
       let pmTo: number | null = null
       doc.descendants((node, pos) => {
         if (!node.isTextblock) return true
-        const textLen = node.textContent.length
+        const textLen = inlineToString(node).length
         const contentStart = pos + 1
         const charEnd = charPos + textLen
         if (pmFrom === null && from < charEnd) {

+ 6 - 8
packages/editor/src/components/__tests__/block-expose.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { contentToDoc, docToContent } from "@/lib/content-model"
+import { contentToDoc, docToContent, inlineToString } from "@/lib/content-model"
 
 // Unit tests for expose logic without mounting
 describe("Block expose API", () => {
@@ -12,7 +12,8 @@ describe("Block expose API", () => {
 
     it("handles multiline content", () => {
       const doc = contentToDoc("line1\nline2\nline3")
-      expect(doc.childCount).toBe(3)
+      expect(doc.childCount).toBe(1)
+      expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
     })
 
     it("handles empty content", () => {
@@ -50,12 +51,9 @@ describe("Block expose API", () => {
 
     it("handles multiline strings", () => {
       const doc = contentToDoc("line1\nline2\nline3")
-      expect(doc.childCount).toBe(3) // three paragraphs
-      expect(doc.child(0).textContent).toBe("line1")
-      expect(doc.child(1).type.name).toBe("paragraph")
-      expect(doc.child(1).textContent).toBe("line2")
-      expect(doc.child(2).type.name).toBe("paragraph")
-      expect(doc.child(2).textContent).toBe("line3")
+      expect(doc.childCount).toBe(1)
+      expect(doc.child(0).type.name).toBe("paragraph")
+      expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
     })
   })
 

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

@@ -239,9 +239,9 @@ describe("Markdown decorations plugin", () => {
       }
     }
 
-    // Combined-parsing with \n\n separator: each paragraph gets its own
-    // node wrapper, so callout + continuation = 2 md-callout decorations.
-    expect(mdCalloutCount).toBe(2)
+    // With hard_break preservation, both lines are in one paragraph.
+    // Lezer produces a single callout block decoration for the pair.
+    expect(mdCalloutCount).toBe(1)
   })
 
   it("builds decorations for inline math", () => {

+ 15 - 10
packages/editor/src/composables/__tests__/pattern-plugin.test.ts

@@ -91,11 +91,12 @@ describe("Pattern plugin", () => {
     expect(session).toBeNull()
   })
 
-  // ── Multi-line patterns (multiple paragraphs) ──────────────────────
-  // With multiple paragraphs per block, each pattern is detected within
-  // its own paragraph. Cursor is placed at the end of the document.
+  // ── Multi-line patterns (with hard_break) ──────────────────────────
+  // Single \n becomes a hard_break within the same paragraph. Patterns
+  // are still detected because the inlineToString walker emits "\n" for
+  // each hard_break, so the text before cursor is correct.
 
-  it("detects [[ pattern in second paragraph", () => {
+  it("detects [[ pattern after hard_break", () => {
     const state = createTestState("line1\n[[query")
     const session = getSession(state)
 
@@ -104,7 +105,7 @@ describe("Pattern plugin", () => {
     expect(session?.query).toBeTruthy()
   })
 
-  it("detects (( pattern in second paragraph", () => {
+  it("detects (( pattern after hard_break", () => {
     const state = createTestState("prefix\n((block-id")
     const session = getSession(state)
 
@@ -113,7 +114,7 @@ describe("Pattern plugin", () => {
     expect(session?.query).toBeTruthy()
   })
 
-  it("detects / command in second paragraph", () => {
+  it("detects / command after hard_break", () => {
     const state = createTestState("text\n/cmd")
     const session = getSession(state)
 
@@ -122,7 +123,7 @@ describe("Pattern plugin", () => {
     expect(session?.kind).toBe("command")
   })
 
-  it("detects # tag in second paragraph", () => {
+  it("detects # tag after hard_break", () => {
     const state = createTestState("text\n#tag")
     const session = getSession(state)
 
@@ -131,12 +132,16 @@ describe("Pattern plugin", () => {
     expect(session?.kind).toBe("tag")
   })
 
-  it("does not detect pattern from previous paragraph", () => {
+  it("detects [[ pattern across hard_break within same paragraph", () => {
     const state = createTestState("[[Page\nhello")
     const session = getSession(state)
 
-    // Should NOT detect [[ from paragraph 1 — each paragraph is isolated
-    expect(session).toBeNull()
+    // With hard_break preservation, both lines are in one paragraph,
+    // so [[ at the start is detected even after a hard_break.
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.kind).toBe("reference")
+    expect(session?.query).toBe("Page\nhello")
   })
 
   // ── Spaces in delimiter pattern queries ──────────────────────────────

+ 2 - 1
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -20,6 +20,7 @@ import { Plugin, PluginKey, TextSelection } from "prosemirror-state"
 import { Decoration, DecorationSet } from "prosemirror-view"
 import { renderKatexSync } from "@/lib/katex"
 import { createLogger } from "@/lib/logger"
+import { inlineToString } from "@/lib/content-model"
 import {
   type DecorationRange,
   MarkdownRuleEngine,
@@ -118,7 +119,7 @@ function collectParagraphs(doc: ProsemirrorNode): ParagraphInfo[] {
   doc.descendants((node: ProsemirrorNode, pos: number) => {
     if (!node.isTextblock) return
     if (node.type.spec.code) return
-    paragraphs.push({ node, pos, text: node.textContent })
+    paragraphs.push({ node, pos, text: inlineToString(node) })
   })
   return paragraphs
 }

+ 3 - 1
packages/editor/src/composables/usePatternPlugin.ts

@@ -18,6 +18,7 @@ import {
   type Transaction,
 } from "prosemirror-state"
 import type { EditorView } from "prosemirror-view"
+import { inlineToString } from "@/lib/content-model"
 import { createLogger } from "@/lib/logger"
 
 export type PatternKind = "reference" | "embed" | "command" | "tag"
@@ -121,7 +122,8 @@ function getTextBeforeCursor(
   const parentNode = $head.parent
   if (!parentNode?.isTextblock) return null
 
-  const textBefore = parentNode.textContent.slice(0, $head.parentOffset)
+  // Use inlineToString to account for hard_break nodes (textContent omits them)
+  const textBefore = inlineToString(parentNode).slice(0, $head.parentOffset)
 
   return { textBefore }
 }

+ 31 - 8
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { contentToDoc, docToContent } from "@/lib/content-model"
+import { contentToDoc, docToContent, inlineToString } from "@/lib/content-model"
 import { schema } from "@/lib/schema"
 
 describe("contentToDoc", () => {
@@ -9,13 +9,11 @@ describe("contentToDoc", () => {
     expect(doc.child(0).textContent).toBe("hello world")
   })
 
-  it("handles newlines as separate paragraphs", () => {
+  it("handles newlines as hard_break within a single paragraph", () => {
     const doc = contentToDoc("line1\nline2")
-    expect(doc.childCount).toBe(2)
-    expect(doc.child(0).textContent).toBe("line1")
-    expect(doc.child(1).textContent).toBe("line2")
+    expect(doc.childCount).toBe(1)
     expect(doc.child(0).type.name).toBe("paragraph")
-    expect(doc.child(1).type.name).toBe("paragraph")
+    expect(inlineToString(doc.child(0))).toBe("line1\nline2")
   })
 
   it("handles fenced code block with backticks", () => {
@@ -146,10 +144,11 @@ describe("roundtrip", () => {
     expect(docToContent(doc)).toBe(original)
   })
 
-  it("multiline roundtrips", () => {
+  it("multiline roundtrips as single paragraph with hard_breaks", () => {
     const original = "line1\nline2\nline3"
     const doc = contentToDoc(original)
-    expect(doc.childCount).toBe(3)
+    expect(doc.childCount).toBe(1)
+    expect(inlineToString(doc.child(0))).toBe("line1\nline2\nline3")
   })
 
   it("unicode roundtrips", () => {
@@ -240,3 +239,27 @@ describe("math block (content model)", () => {
     expect(doc.child(0).textContent).toBe("$$\n$$")
   })
 })
+
+describe("roundtrip (hard_break preservation)", () => {
+  const ROUNDTRIP_CASES: [string, string][] = [
+    ["plain text", "hello world"],
+    ["single newline", "line1\nline2"],
+    ["double newline (paragraph break)", "para1\n\npara2"],
+    ["mixed newlines", "section1\nline1\nline2\n\nsection2"],
+    ["triple line", "line1\nline2\nline3"],
+    ["multiple paragraphs", "para1\n\npara2\n\npara3"],
+    ["code block", "```js\nconst x = 1\n```"],
+    ["code block with internal blank lines", "text\n\n```\nline1\n\nline2\n```\n\nafter"],
+    ["math block", "$$\nE = mc^2\n$$"],
+    ["wrapped math", "text\n\n$$\na^2 + b^2 = c^2\n$$\n\nmore"],
+    ["properties (motivating case)", "## Properties\n\nauthor:: Enesis Editor\nversion:: 0.1.0"],
+    ["single line only", "line1\nline2\nline3"],
+    ["empty string", ""],
+  ]
+
+  for (const [label, input] of ROUNDTRIP_CASES) {
+    it(`roundtrips: ${label}`, () => {
+      expect(docToContent(contentToDoc(input))).toBe(input)
+    })
+  }
+})

+ 5 - 5
packages/editor/src/lib/__tests__/schema.test.ts

@@ -16,8 +16,8 @@ describe("schema", () => {
       expect(schema.nodes.text).toBeDefined()
     })
 
-    it("does not have hard_break node (removed in re-architecture)", () => {
-      expect(schema.nodes.hard_break).toBeUndefined()
+    it("has hard_break node (re-added for newline fidelity)", () => {
+      expect(schema.nodes.hard_break).toBeDefined()
     })
   })
 
@@ -74,13 +74,13 @@ describe("schema", () => {
   })
 
   describe("roundtrip with content-model", () => {
-    it("preserves multi-paragraph structure", () => {
+    it("preserves single newline as hard_break", () => {
       const original = "line1\nline2"
       const doc = contentToDoc(original)
       const result = docToContent(doc)
 
-      expect(result).toBe("line1\n\nline2")
-      expect(doc.childCount).toBe(2)
+      expect(result).toBe("line1\nline2")
+      expect(doc.childCount).toBe(1)
     })
   })
 })

+ 45 - 8
packages/editor/src/lib/content-model.ts

@@ -2,7 +2,8 @@
  * Content model conversion functions.
  *
  * Converts between raw markdown string and ProseMirror document nodes.
- * Supports paragraphs (one per line) and fenced code blocks.
+ * Single \n within block content becomes hard_break; \n\n separates paragraphs.
+ * Fenced code blocks and math blocks are detected first.
  */
 
 import type { Node as ProsemirrorNode } from "prosemirror-model"
@@ -11,10 +12,27 @@ import { schema } from "@/lib/schema"
 
 const log = createLogger("ContentModel")
 
+/**
+ * Serialize a node's inline content to a string.
+ * hard_break nodes emit "\n"; text nodes emit their text.
+ */
+function inlineToString(node: ProsemirrorNode): string {
+  let out = ""
+  node.forEach((child: ProsemirrorNode) => {
+    if (child.isText) {
+      out += child.text ?? ""
+    } else if (child.type.name === "hard_break") {
+      out += "\n"
+    }
+  })
+  return out
+}
+
 /**
  * Convert markdown string to ProseMirror document.
  * Detects fenced code blocks (``` / ~~~) and creates code_block nodes.
- * Everything else becomes one paragraph per line.
+ * Blank lines (\n\n) separate paragraphs; single \n within a group of
+ * consecutive non-empty lines becomes a hard_break.
  */
 export function contentToDoc(content: string): ProsemirrorNode {
   const nodes: ProsemirrorNode[] = []
@@ -82,15 +100,29 @@ export function contentToDoc(content: string): ProsemirrorNode {
         ),
       )
     } else {
-      const line = lines[i]!
-      if (line.length === 0) {
-        // Blank lines separate paragraphs — skip, don't create empty <p>
+      // Non-empty line: collect consecutive non-empty lines into one paragraph
+      // with hard_break nodes between them. Blank lines separate paragraphs.
+      if (lines[i]!.length === 0) {
         i++
         continue
       }
+      const paraLines: string[] = []
+      while (i < lines.length && lines[i]!.length > 0) {
+        // Guard: if we hit a potential fence, stop grouping
+        const l = lines[i]!
+        if (l.match(/^(```|~~~)/) || l.trim() === "$$") break
+        paraLines.push(l)
+        i++
+      }
       paraCount++
-      nodes.push(schema.nodes.paragraph.create(null, [schema.text(line)]))
-      i++
+      const inline: ProsemirrorNode[] = []
+      for (let j = 0; j < paraLines.length; j++) {
+        if (j > 0) {
+          inline.push(schema.nodes.hard_break.create())
+        }
+        inline.push(schema.text(paraLines[j]!))
+      }
+      nodes.push(schema.nodes.paragraph.create(null, inline))
     }
   }
 
@@ -111,6 +143,7 @@ export function contentToDoc(content: string): ProsemirrorNode {
 /**
  * Convert ProseMirror document to markdown string.
  * code_block nodes are serialized as fenced code blocks.
+ * Paragraphs use inlineToString which emits "\n" for hard_break nodes.
  */
 export function docToContent(doc: ProsemirrorNode): string {
   const lines: string[] = []
@@ -119,8 +152,10 @@ export function docToContent(doc: ProsemirrorNode): string {
   doc.forEach((node: ProsemirrorNode) => {
     if (node.type.name === "code_block") {
       codeBlockCount++
+      lines.push(node.textContent)
+    } else {
+      lines.push(inlineToString(node))
     }
-    lines.push(node.textContent)
   })
 
   const result = lines.join("\n\n")
@@ -131,3 +166,5 @@ export function docToContent(doc: ProsemirrorNode): string {
   })
   return result
 }
+
+export { inlineToString }

+ 11 - 12
packages/editor/src/lib/markdown-rules/block-rules.ts

@@ -115,19 +115,18 @@ function createCalloutDecoration(
     const leadingOffset = line.length - trimmed.length
 
     if (i === 0) {
-      // First line: hide "> [!NOTE]" marker
+      // First line: hide "[!NOTE]" marker only (keep ">" visible so it
+      // matches the ">" on continuation lines after a hard_break).
+      const bracketStart = trimmed.indexOf("[!")
       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",
-      })
+      if (bracketStart >= 0 && bracketEnd > bracketStart) {
+        decorationRanges.push({
+          type: "hidden",
+          from: offset + leadingOffset + bracketStart,
+          to: offset + leadingOffset + bracketEnd + 1,
+          className: "md-marker",
+        })
+      }
     } else {
       // Continuation lines: hide just the ">" marker
       const gtPos = trimmed.indexOf(">")

+ 10 - 2
packages/editor/src/lib/schema.ts

@@ -3,11 +3,11 @@
  *
  * Only contains structural nodes - no semantic marks (bold, italic, etc).
  * Markdown syntax is preserved as plain text and rendered via decorations.
- * Each Block.vue instance is one or more <p> paragraphs; no hard_breaks.
+ * hard_break preserves the \n vs \n\n distinction within block content.
  */
 import { type NodeSpec, Schema } from "prosemirror-model"
 
-type DOMNode = "doc" | "paragraph" | "code_block" | "math_block" | "text"
+type DOMNode = "doc" | "paragraph" | "hard_break" | "code_block" | "math_block" | "text"
 
 /** Node definitions */
 const nodes: Record<DOMNode, NodeSpec> = {
@@ -22,6 +22,14 @@ const nodes: Record<DOMNode, NodeSpec> = {
     parseDOM: [{ tag: "p" }],
     toDOM: () => ["p", 0],
   },
+  /** Hard line break — preserves single \n within a paragraph */
+  hard_break: {
+    inline: true,
+    group: "inline",
+    selectable: false,
+    parseDOM: [{ tag: "br" }],
+    toDOM: () => ["br"],
+  },
   /** Fenced code block — rendered via CodeMirror 6 node view */
   code_block: {
     group: "block",