Przeglądaj źródła

fix(editor): prevent heading syntax from being intercepted by tag palette and non-breaking space

- Don't open tag suggestion palette on bare `#` (likely a heading attempt)
- Normalize non-breaking spaces (U+00A0) to regular spaces on typed input and in content model so GFM heading parser recognizes headings
- Treat non-breaking space as whitespace in tag parser boundary checks
- Clamp heading marker ranges to node bounds to prevent negative-length content ranges
- Reject whitespace-only content in tag decoration rule as safety net
Zander Hawke 4 dni temu
rodzic
commit
13faea5f69

+ 7 - 0
packages/editor/src/components/Block.vue

@@ -400,6 +400,13 @@ onMounted(() => {
         new MathBlockView(node, view, () => getPos() ?? 0),
     },
     handleKeyDown,
+    handleTextInput(_view, _from, _to, text) {
+      if (text.indexOf("\u00a0") !== -1) {
+        _view.dispatch(_view.state.tr.insertText(text.replace(/\u00a0/g, " "), _from, _to))
+        return true
+      }
+      return false
+    },
     handlePaste: createPasteHandler((before, after) =>
       emit("split", before, after),
     ),

+ 6 - 0
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -400,6 +400,12 @@ function invalidateChangedCaches(
       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++

+ 4 - 0
packages/editor/src/composables/usePatternPlugin.ts

@@ -144,6 +144,10 @@ function findPattern(
     if (!match) continue
 
     const { startIdx, trigger, query } = match
+
+    // Don't open tag palette on bare # — it's likely a heading attempt.
+    if (spec.kind === "tag" && query.length === 0) continue
+
     const startPos = $head.pos - textBefore.length + startIdx
     if (isPatternCompleted(spec, query)) return null
 

+ 4 - 1
packages/editor/src/lib/content-model.ts

@@ -23,7 +23,10 @@ export function contentToDoc(content: string): ProsemirrorNode {
     return schema.nodes.doc.create(null, [schema.nodes.paragraph.create()])
   }
 
-  const lines = content.split("\n")
+  // Normalize non-breaking spaces to regular spaces to ensure
+  // GFM heading parser and tag parser work correctly.
+  const normalized = content.replace(/\u00a0/g, " ")
+  const lines = normalized.split("\n")
   let i = 0
   let codeBlockCount = 0
   let mathBlockCount = 0

+ 5 - 2
packages/editor/src/lib/markdown-extensions.ts

@@ -40,8 +40,9 @@ function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
     })),
     parseInline: patterns.map((pattern) => {
       if (pattern.start === "#" && !pattern.end) {
+        const tagName = pattern.nodeType
         return {
-          name: pattern.nodeType,
+          name: tagName,
           before: "Link",
           parse(cx, next, pos) {
             if (next !== 35) return -1
@@ -52,6 +53,7 @@ function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
                 prev != null &&
                 prev > 0 &&
                 prev !== 32 &&
+                prev !== 160 &&
                 prev !== 9 &&
                 prev !== 10 &&
                 prev !== 13
@@ -67,6 +69,7 @@ function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
               const ch = cx.char(i)
               if (
                 ch === 32 ||
+                ch === 160 ||
                 ch === 9 ||
                 ch === 10 ||
                 ch === 13 ||
@@ -79,7 +82,7 @@ function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
             }
 
             if (hasContent) {
-              return cx.addElement(cx.elt(pattern.nodeType, pos, i))
+              return cx.addElement(cx.elt(tagName, pos, i))
             }
             return -1
           },

+ 2 - 3
packages/editor/src/lib/markdown-rules/block-rules.ts

@@ -154,8 +154,7 @@ export function createHeadingRule(): BlockRule {
         10,
       )
 
-      // Heading text: "# Title" → markerEnd is after "# "
-      const markerEnd = node.from + level + 1 // +1 for space after #
+      const markerEnd = Math.min(node.from + level + 1, node.to)
 
       return {
         type: "heading",
@@ -164,7 +163,7 @@ export function createHeadingRule(): BlockRule {
           {
             type: "hidden",
             from: node.from,
-            to: node.from + level,
+            to: Math.min(node.from + level, node.to),
             className: "md-heading-marker",
           },
           {

+ 10 - 0
packages/editor/src/lib/markdown-rules/engine.ts

@@ -125,10 +125,15 @@ export class MarkdownRuleEngine {
     const tree: Tree = markdownParser.parse(docText, fragments)
     const parseTime = performance.now() - startTime
 
+    const visitedNodeTypes: string[] = []
+
     tree.iterate({
       enter: (ref) => {
         const node = ref.node
         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) : "...") })
 
         const blockCandidates = this.blockRuleIndex.get(nodeType)
         if (blockCandidates) {
@@ -145,6 +150,8 @@ export class MarkdownRuleEngine {
         const node = ref.node
         const nodeType = node.type.name
 
+        log.info("LEAVE", { type: nodeType })
+
         if (this.blockRuleIndex.has(nodeType)) return
 
         const inlineCandidates = this.inlineRuleIndex.get(nodeType)
@@ -174,6 +181,9 @@ export class MarkdownRuleEngine {
       inlineTokens: content.length,
       properties: properties.length,
       incremental: !!oldTree,
+      tree: visitedNodeTypes.join("→"),
+      blockTypes: blocks.map((b: any) => b.type).join(","),
+      inlineTypes: content.map((c: any) => c.type).join(","),
     })
     return { content, properties, blocks, tree }
   }

+ 1 - 0
packages/editor/src/lib/markdown-rules/inline-rules.ts

@@ -204,6 +204,7 @@ export function createTagRule(): MarkdownRule<TagDecoration> {
     run(node, ctx: ParseContext) {
       const { from, to } = node
       const tag = ctx.doc.slice(from + 1, to)
+      if (tag.length === 0 || !tag.trim()) return null
       return {
         type: "tag",
         tag,