Quellcode durchsuchen

fix(editor): fix pattern recognition for multi-line blocks and spaces in queries

Resolves pattern detection issues after migrating to single <p> tags per block by:
- Implement layout-aware node iteration instead of textContent to respect hard_break boundaries
- Fix startPos calculation with lineStartOffset tracking for correct positioning on multi-line content
- Allow spaces in queries for delimiter patterns ([[, (() while maintaining single-word constraints for boundary patterns (/ #)
- Extract getTextBeforeCursor helper to eliminate code duplication and ensure consistent behavior
Adds comprehensive test coverage for:
- Multi-line pattern detection on lines after hard_break
- Page and block references with spaces in names ([[Page Name]], ((block-id))
- Boundary patterns (/cmd, #tag) on continuation lines
- Negative test ensuring patterns don't cross hard_break boundaries
Zander Hawke vor 1 Monat
Ursprung
Commit
bda12a084f

+ 98 - 0
packages/editor/src/composables/__tests__/pattern-plugin.test.ts

@@ -91,4 +91,102 @@ describe("Pattern plugin", () => {
 
     expect(session).toBeNull()
   })
+
+  // ── Multi-line patterns (single paragraph, hard_break lines) ────────
+
+  it("detects [[ pattern on line after hard_break", () => {
+    const state = createTestState("line1\n[[query")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.query).toBe("query")
+    // Position should account for line1 + hard_break
+    expect(session?.range.from).toBe(7)
+    expect(session?.range.to).toBe(14)
+  })
+
+  it("detects (( pattern on line after hard_break", () => {
+    const state = createTestState("prefix\n((block-id")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("((")
+    expect(session?.query).toBe("block-id")
+  })
+
+  it("detects / command on second line after hard_break", () => {
+    const state = createTestState("text\n/cmd")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("/")
+    expect(session?.kind).toBe("command")
+    expect(session?.query).toBe("cmd")
+  })
+
+  it("detects # tag on second line after hard_break", () => {
+    const state = createTestState("text\n#tag")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("#")
+    expect(session?.kind).toBe("tag")
+    expect(session?.query).toBe("tag")
+  })
+
+  it("does not detect pattern from previous line across hard_break", () => {
+    // [[ on line 1, cursor on line 2 with no trigger
+    const state = createTestState("[[Page\nhello")
+    const session = getSession(state)
+
+    // Should NOT detect [[ from line 1 — hard_break resets the line
+    expect(session).toBeNull()
+  })
+
+  // ── Spaces in delimiter pattern queries ──────────────────────────────
+
+  it("detects [[ pattern with spaces in query", () => {
+    const state = createTestState("[[page name")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.kind).toBe("reference")
+    expect(session?.query).toBe("page name")
+  })
+
+  it("detects [[ pattern with spaces on second line", () => {
+    const state = createTestState("line1\n[[page name")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.query).toBe("page name")
+    // Correct position: line1 (6) + hard_break (1) = offset 7
+    expect(session?.range.from).toBe(7)
+  })
+
+  it("detects (( pattern with special chars in query", () => {
+    const state = createTestState("((my-block_id")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("((")
+    expect(session?.query).toBe("my-block_id")
+  })
+
+  it("completes [[ pattern with spaces", () => {
+    const state = createTestState("[[Page Name]]")
+    const session = getSession(state)
+
+    expect(session).toBeNull()
+  })
+
+  it("completes [[ pattern with spaces on second line", () => {
+    const state = createTestState("line1\n[[Page Name]]")
+    const session = getSession(state)
+
+    expect(session).toBeNull()
+  })
 })

+ 51 - 26
packages/editor/src/composables/usePatternPlugin.ts

@@ -100,49 +100,65 @@ export function getPatternSession(state: EditorState): PatternSession | null {
 }
 const forceCloseMetaKey = "pattern-force-close"
 
-function findPattern(
+/**
+ * Extract text before cursor on the current line, respecting hard_break boundaries.
+ * Returns the line's text and the parentOffset at which the line started.
+ */
+function getTextBeforeCursor(
   state: EditorState,
-  prevSession: PatternSession | null,
-): PatternSession | null {
+): { textBefore: string; lineStartOffset: number } | null {
   const { $head } = state.selection
   const parentNode = $head.parent
-  if (!parentNode || !parentNode.isTextblock) return null
+  if (!parentNode?.isTextblock) return null
 
-  // Build layout-aware textBefore by iterating nodes
-  // This respects hard_break boundaries instead of flattening via textContent
   let textBefore = ""
   let currentOffset = 0
+  let lineStartOffset = 0
   const targetOffset = $head.parentOffset
 
   for (let i = 0; i < parentNode.childCount; i++) {
     const child = parentNode.child(i)
-
     if (currentOffset >= targetOffset) break
 
     if (child.type.name === "hard_break") {
-      // Hard break resets the current line
       textBefore = ""
       currentOffset += child.nodeSize
+      lineStartOffset = currentOffset
     } else if (child.isText && child.text) {
       const remainingSpace = targetOffset - currentOffset
       if (remainingSpace <= child.text.length) {
         textBefore += child.text.slice(0, remainingSpace)
         break
-      } else {
-        textBefore += child.text
-        currentOffset += child.text.length
       }
+      textBefore += child.text
+      currentOffset += child.text.length
     } else {
       currentOffset += child.nodeSize
     }
   }
 
+  return { textBefore, lineStartOffset }
+}
+
+function findPattern(
+  state: EditorState,
+  prevSession: PatternSession | null,
+): PatternSession | null {
+  const { $head } = state.selection
+  const parentNode = $head.parent
+  if (!parentNode?.isTextblock) return null
+
+  const info = getTextBeforeCursor(state)
+  if (!info) return null
+
+  const { textBefore, lineStartOffset } = info
+
   for (const spec of PATTERN_SPECS) {
     const match = matchPattern(textBefore, spec)
     if (!match) continue
 
     const { startIdx, trigger, query } = match
-    const startPos = $head.start() + startIdx
+    const startPos = $head.start() + lineStartOffset + startIdx
     if (isPatternCompleted(spec, query)) return null
 
     // For delimiter-terminated patterns, check if the closing delimiter
@@ -203,6 +219,14 @@ function matchPattern(
   if (idx === -1) return null
 
   const afterTrigger = textBefore.slice(idx + start.length)
+
+  // For delimiter-terminated patterns ([[, ((), the query includes everything
+  // until the closing delimiter — spaces are allowed in page/block names.
+  // For boundary patterns (/, #), the query must be a single word.
+  if (spec.termination === "delimiter") {
+    return { startIdx: idx, trigger: start, query: afterTrigger }
+  }
+
   const queryMatch = afterTrigger.match(/^[^\s\n]*/)
   if (!queryMatch) return null
   const query = queryMatch[0]
@@ -225,18 +249,17 @@ function getCurrentRange(
   state: EditorState,
   trigger: string,
 ): { from: number; to: number } | null {
-  const { $head } = state.selection
+  const $head = state.selection.$head
   if (!$head.parent.isTextblock) return null
 
-  const text = $head.parent.textContent
-  const offset = $head.parentOffset
-  const textBefore = text.slice(0, offset)
+  const info = getTextBeforeCursor(state)
+  if (!info) return null
 
-  const startIdx = textBefore.lastIndexOf(trigger)
+  const startIdx = info.textBefore.lastIndexOf(trigger)
   if (startIdx === -1) return null
 
-  const startPos = $head.start() + startIdx
-  const query = textBefore.slice(startIdx + trigger.length)
+  const startPos = $head.start() + info.lineStartOffset + startIdx
+  const query = info.textBefore.slice(startIdx + trigger.length)
 
   return { from: startPos, to: startPos + trigger.length + query.length }
 }
@@ -320,29 +343,31 @@ class PatternView {
       return
     }
 
-    const { $head } = this.view.state.selection
+    const $head = this.view.state.selection.$head
     if (!$head.parent.isTextblock) {
       this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
       return
     }
 
-    const text = $head.parent.textContent
-    const offset = $head.parentOffset
-    const textBefore = text.slice(0, offset)
+    const info = getTextBeforeCursor(this.view.state)
+    if (!info) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+      return
+    }
 
-    const startIdx = textBefore.lastIndexOf(session.trigger)
+    const startIdx = info.textBefore.lastIndexOf(session.trigger)
     if (startIdx === -1) {
       this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
       return
     }
 
-    const startPos = $head.start() + startIdx
+    const startPos = $head.start() + info.lineStartOffset + startIdx
     if (startPos !== session.range.from) {
       this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
       return
     }
 
-    const query = textBefore.slice(startIdx + session.trigger.length)
+    const query = info.textBefore.slice(startIdx + session.trigger.length)
 
     let isComplete = false
     if (spec.start === "[[") {

+ 0 - 10
pnpm-lock.yaml

@@ -66,9 +66,6 @@ importers:
       '@lezer/common':
         specifier: ^1.2.2
         version: 1.5.2
-      '@lezer/lr':
-        specifier: ^1.4.2
-        version: 1.4.10
       '@lezer/markdown':
         specifier: ^1.6.3
         version: 1.6.3
@@ -541,9 +538,6 @@ packages:
   '@lezer/[email protected]':
     resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==}
 
-  '@lezer/[email protected]':
-    resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==}
-
   '@lezer/[email protected]':
     resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==}
 
@@ -2837,10 +2831,6 @@ snapshots:
     dependencies:
       '@lezer/common': 1.5.2
 
-  '@lezer/[email protected]':
-    dependencies:
-      '@lezer/common': 1.5.2
-
   '@lezer/[email protected]':
     dependencies:
       '@lezer/common': 1.5.2

+ 3 - 0
pnpm-workspace.yaml

@@ -1,3 +1,6 @@
 packages:
   - "apps/*"
   - "packages/*"
+allowBuilds:
+  esbuild: false
+  vue-demi: false