Explorar el Código

feat(editor): strip code block fences from CM6 input, add header with lang selector + delete

The fences were leaking into CM6 causing the JS/TS parser to misinterpret code as template literals (tok-string2). Now CM6 only sees the body, with a header dropdown for language switching and a × button for deletion. Includes @lezer/highlight dep and Kanagawa-inspired syntax coloring CSS variables.
Zander Hawke hace 1 semana
padre
commit
707b4105ab

+ 1 - 0
packages/editor/package.json

@@ -57,6 +57,7 @@
     "@codemirror/state": "^6.6.0",
     "@codemirror/view": "^6.43.1",
     "@lezer/common": "^1.2.2",
+    "@lezer/highlight": "^1.2.3",
     "@lezer/markdown": "^1.6.3",
     "@nuxt/ui": "^4.7.1",
     "@tailwindcss/vite": "^4.3.0",

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

@@ -262,6 +262,149 @@
   @apply text-(--ui-text-muted);
 }
 
+.cm-code-block-header {
+  @apply flex items-center justify-between px-3 py-1.5 border-b border-(--ui-border) bg-(--ui-bg-elevated)/50;
+}
+
+.cm-code-block-lang-select {
+  @apply appearance-none bg-transparent border-none text-(--ui-text-muted) text-xs font-mono cursor-pointer outline-none;
+}
+
+.cm-code-block-lang-select option {
+  @apply bg-(--ui-bg-elevated) text-(--ui-text);
+}
+
+.cm-code-block-lang-select:hover {
+  @apply text-(--ui-text);
+}
+
+.cm-code-block-delete-btn {
+  @apply appearance-none bg-transparent border-none text-(--ui-text-muted) cursor-pointer text-sm leading-none p-0.5 rounded opacity-0 transition-opacity;
+  font-family: inherit;
+}
+
+.cm-code-block:hover .cm-code-block-delete-btn {
+  @apply opacity-100;
+}
+
+.cm-code-block-delete-btn:hover {
+  @apply text-(--ui-error) bg-(--ui-bg-elevated);
+}
+
+/* ── Syntax Highlighting (Kanagawa-inspired) ──────────────────────── */
+
+/* Define CSS variables for light/dark mode colors */
+:root {
+  --code-keyword: #8481a4;
+  --code-string: #6f894e;
+  --code-string2: #6f894e;
+  --code-comment: #a6a69c;
+  --code-number: #e46876;
+  --code-atom: #c34043;
+  --code-bool: #c34043;
+  --code-null: #c34043;
+  --code-type: #7fb4ca;
+  --code-typeName: #7fb4ca;
+  --code-className: #7fb4ca;
+  --code-function: #7e9cd8;
+  --code-definition: #7e9cd8;
+  --code-variableName: #545464;
+  --code-propertyName: #545464;
+  --code-attributeName: #7e9cd8;
+  --code-operator: #545464;
+  --code-punctuation: #545464;
+  --code-tag: #7e9cd8;
+  --code-meta: #8481a4;
+}
+
+.dark {
+  --code-keyword: #e6c384;
+  --code-string: #98bb6c;
+  --code-string2: #98bb6c;
+  --code-comment: #727169;
+  --code-number: #dca561;
+  --code-atom: #e46876;
+  --code-bool: #e46876;
+  --code-null: #e46876;
+  --code-type: #7e9cd8;
+  --code-typeName: #7e9cd8;
+  --code-className: #7e9cd8;
+  --code-function: #7fb4ca;
+  --code-definition: #7fb4ca;
+  --code-variableName: #dcd7ba;
+  --code-propertyName: #dcd7ba;
+  --code-attributeName: #7fb4ca;
+  --code-operator: #dcd7ba;
+  --code-punctuation: #dcd7ba;
+  --code-tag: #7fb4ca;
+  --code-meta: #e6c384;
+}
+
+/* Apply colors to CM6 token classes */
+.cm-code-block .tok-keyword {
+  color: var(--code-keyword);
+}
+.cm-code-block .tok-string {
+  color: var(--code-string);
+}
+.cm-code-block .tok-string2 {
+  color: var(--code-string2);
+}
+.cm-code-block .tok-comment {
+  color: var(--code-comment);
+}
+.cm-code-block .tok-number {
+  color: var(--code-number);
+}
+.cm-code-block .tok-integer {
+  color: var(--code-number);
+}
+.cm-code-block .tok-atom {
+  color: var(--code-atom);
+}
+.cm-code-block .tok-bool {
+  color: var(--code-bool);
+}
+.cm-code-block .tok-null {
+  color: var(--code-null);
+}
+.cm-code-block .tok-type {
+  color: var(--code-type);
+}
+.cm-code-block .tok-typeName {
+  color: var(--code-typeName);
+}
+.cm-code-block .tok-className {
+  color: var(--code-className);
+}
+.cm-code-block .tok-function {
+  color: var(--code-function);
+}
+.cm-code-block .tok-definition {
+  color: var(--code-definition);
+}
+.cm-code-block .tok-variableName {
+  color: var(--code-variableName);
+}
+.cm-code-block .tok-propertyName {
+  color: var(--code-propertyName);
+}
+.cm-code-block .tok-attributeName {
+  color: var(--code-attributeName);
+}
+.cm-code-block .tok-operator {
+  color: var(--code-operator);
+}
+.cm-code-block .tok-punctuation {
+  color: var(--code-punctuation);
+}
+.cm-code-block .tok-tag {
+  color: var(--code-tag);
+}
+.cm-code-block .tok-meta {
+  color: var(--code-meta);
+}
+
 /* ── Inline Math ────────────────────────────────────────────────── */
 
 .md-math-inline {

+ 189 - 102
packages/editor/src/composables/useCodeBlockView.ts

@@ -6,19 +6,10 @@ import { json } from "@codemirror/lang-json"
 import { markdown as cmMarkdown } from "@codemirror/lang-markdown"
 import { python } from "@codemirror/lang-python"
 import { xml } from "@codemirror/lang-xml"
-import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"
-import {
-  Compartment,
-  EditorState,
-  type Range,
-  StateField,
-} from "@codemirror/state"
-import type { DecorationSet } from "@codemirror/view"
-import {
-  EditorView as CodeMirrorView,
-  Decoration,
-  keymap,
-} from "@codemirror/view"
+import { syntaxHighlighting } from "@codemirror/language"
+import { Compartment, EditorState } from "@codemirror/state"
+import { EditorView as CodeMirrorView, keymap } from "@codemirror/view"
+import { classHighlighter } from "@lezer/highlight"
 import type { Node as ProsemirrorNode } from "prosemirror-model"
 import { TextSelection } from "prosemirror-state"
 import type { EditorView, NodeView } from "prosemirror-view"
@@ -28,33 +19,71 @@ function instanceLogger(componentFilter?: string) {
   return createLogger("CodeBlockView", componentFilter)
 }
 
-const FENCE_RE = /^(```|~~~)\s*(\w*)/
-
-function fenceMarkerExtension() {
-  const fenceLineDeco = Decoration.line({ class: "cm-fence-marker" })
-  const CLOSE_FENCE_RE = /^(```|~~~)\s*$/
-  function buildDecos(state: EditorState) {
-    const decos: Range<Decoration>[] = []
-    const doc = state.doc
-    if (doc.lines >= 2) {
-      const first = doc.line(1)
-      if (FENCE_RE.test(first.text)) decos.push(fenceLineDeco.range(first.from))
-      const last = doc.line(doc.lines)
-      if (CLOSE_FENCE_RE.test(last.text))
-        decos.push(fenceLineDeco.range(last.from))
-    }
-    return Decoration.set(decos)
+/**
+ * Strip fence markers from a code block's text content.
+ *
+ * Input:  "```typescript\nconst x = 1\n```"
+ * Output: "const x = 1"
+ *
+ * Handles edge cases:
+ * - Empty block: "```\n```" (2 lines) → ""
+ * - Block with language but no body: "```js\n```" (2 lines) → ""
+ * - Single-line body: "```js\nconst x = 1\n```" (3 lines) → "const x = 1"
+ */
+function stripFences(content: string): string {
+  const lines = content.split("\n")
+  return lines.slice(1, -1).join("\n")
+}
+
+/**
+ * Re-add fence markers around code body for PM storage.
+ * Normalizes trailing newlines to prevent blank-line drift
+ * before the closing fence.
+ */
+function addFences(content: string, language: string): string {
+  const body = content.replace(/\n$/, "")
+  return `\`\`\`${language}\n${body}\n\`\`\``
+}
+
+/**
+ * Map language aliases to canonical short forms used in the dropdown.
+ *
+ * "typescript" → "ts", "javascript" → "js", "py" → "python", etc.
+ * Unrecognized languages return "" to show "Plain" in the select.
+ */
+function normalizeLanguage(lang: string): string {
+  switch (lang.toLowerCase()) {
+    case "js":
+    case "javascript":
+    case "jsx":
+    case "mjs":
+    case "cjs":
+      return "js"
+    case "ts":
+    case "typescript":
+    case "tsx":
+    case "mts":
+    case "cts":
+      return "ts"
+    case "python":
+    case "py":
+      return "python"
+    case "html":
+    case "htm":
+      return "html"
+    case "css":
+      return "css"
+    case "json":
+      return "json"
+    case "xml":
+    case "svg":
+      return "xml"
+    case "md":
+    case "markdown":
+      return "md"
+    default:
+      return ""
   }
-  return StateField.define<DecorationSet>({
-    create(state) {
-      return buildDecos(state)
-    },
-    update(decos, tr) {
-      if (!tr.docChanged) return decos
-      return buildDecos(tr.state)
-    },
-    provide: (f) => CodeMirrorView.decorations.from(f),
-  })
 }
 
 function resolveLanguage(lang: string) {
@@ -92,6 +121,19 @@ function resolveLanguage(lang: string) {
   }
 }
 
+const LANG_OPTIONS = [
+  { value: "", label: "Plain" },
+  { value: "js", label: "JavaScript" },
+  { value: "ts", label: "TypeScript" },
+  { value: "python", label: "Python" },
+  { value: "html", label: "HTML" },
+  { value: "css", label: "CSS" },
+  { value: "json", label: "JSON" },
+  { value: "xml", label: "XML" },
+  { value: "svg", label: "SVG" },
+  { value: "md", label: "Markdown" },
+]
+
 export class CodeBlockView implements NodeView {
   dom: HTMLElement
   cmView: CodeMirrorView
@@ -99,6 +141,7 @@ export class CodeBlockView implements NodeView {
   private getPos: () => number
   private languageCompartment = new Compartment()
   private lastLanguage = ""
+  private langSelect: HTMLSelectElement
   private log: ReturnType<typeof createLogger>
 
   constructor(
@@ -114,7 +157,39 @@ export class CodeBlockView implements NodeView {
     this.dom = document.createElement("div")
     this.dom.className = "cm-code-block"
 
+    const header = document.createElement("div")
+    header.className = "cm-code-block-header"
+
+    this.langSelect = document.createElement("select")
+    this.langSelect.className = "cm-code-block-lang-select"
+    this.langSelect.addEventListener("mousedown", (e) => e.stopPropagation())
+
     const language = (node.attrs.language as string) || ""
+    for (const opt of LANG_OPTIONS) {
+      const option = document.createElement("option")
+      option.value = opt.value
+      option.textContent = opt.label
+      this.langSelect.appendChild(option)
+    }
+    this.langSelect.value = normalizeLanguage(language)
+
+    this.langSelect.addEventListener("change", () => {
+      this.changeLanguage(this.langSelect.value)
+    })
+
+    const deleteBtn = document.createElement("button")
+    deleteBtn.className = "cm-code-block-delete-btn"
+    deleteBtn.textContent = "×"
+    deleteBtn.title = "Delete code block"
+    deleteBtn.addEventListener("mousedown", (e) => e.stopPropagation())
+    deleteBtn.addEventListener("click", () => {
+      this.removeBlock()
+    })
+
+    header.appendChild(this.langSelect)
+    header.appendChild(deleteBtn)
+    this.dom.appendChild(header)
+
     this.lastLanguage = language
     const langExt = resolveLanguage(language)
     this.log.info("constructor", {
@@ -144,13 +219,10 @@ export class CodeBlockView implements NodeView {
         },
       },
       {
-        key: "Enter",
+        key: "Backspace",
         run: (cm6View) => {
-          const sel = cm6View.state.selection.main
-          if (!sel.empty) return false
-          const line = cm6View.state.doc.lineAt(sel.head)
-          if (line.to === sel.head && /^```\s*$/.test(line.text)) {
-            return this.exitBlock("down")
+          if (cm6View.state.doc.length === 0) {
+            return this.removeBlock()
           }
           return false
         },
@@ -158,14 +230,13 @@ export class CodeBlockView implements NodeView {
     ])
 
     const cmState = EditorState.create({
-      doc: node.textContent,
+      doc: stripFences(node.textContent),
       extensions: [
         this.languageCompartment.of(langExt),
         exitKeymap,
         keymap.of([indentWithTab, ...defaultKeymap]),
         CodeMirrorView.lineWrapping,
-        syntaxHighlighting(defaultHighlightStyle),
-        fenceMarkerExtension(),
+        syntaxHighlighting(classHighlighter),
       ],
     })
 
@@ -179,32 +250,13 @@ export class CodeBlockView implements NodeView {
       },
     })
 
-    // Place CM6 cursor at the end of the first line (after the opening fence
-    // marker), and focus CM6 so keyboard input lands in the code block.
-    const content = node.textContent
-    const firstLine = content.split("\n")[0] ?? ""
-    if (FENCE_RE.test(firstLine)) {
-      const cursorPos = firstLine.length
-      this.cmView.dispatch({
-        selection: { anchor: cursorPos, head: cursorPos },
-      })
+    // Only auto-focus empty code blocks (user-created via auto-close).
+    // Non-empty blocks loaded from content shouldn't steal the cursor.
+    if (stripFences(node.textContent).length === 0) {
       requestAnimationFrame(() => this.cmView.focus())
     }
   }
 
-  private convertToParagraph(content: string) {
-    this.log.info("convertToParagraph", { content: content.slice(0, 40) })
-    const pos = this.getPos()
-    const node = this.pmView.state.doc.nodeAt(pos)
-    if (!node || node.type.name !== "code_block") return
-    const para = this.pmView.state.schema.nodes.paragraph!.create(
-      null,
-      this.pmView.state.schema.text(content),
-    )
-    const tr = this.pmView.state.tr.replaceWith(pos, pos + node.nodeSize, para)
-    this.pmView.dispatch(tr)
-  }
-
   private exitBlock(dir: "up" | "down"): boolean {
     const pos = this.getPos()
     const node = this.pmView.state.doc.nodeAt(pos)
@@ -223,41 +275,77 @@ export class CodeBlockView implements NodeView {
     return true
   }
 
-  private syncToPM() {
-    const content = this.cmView.state.doc.toString()
-    const firstLine = content.split("\n")[0] ?? ""
+  private removeBlock(): boolean {
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node) return false
 
-    // If the first line no longer starts with a fence marker,
-    // revert the code block back to a regular paragraph.
-    if (!FENCE_RE.test(firstLine)) {
-      this.convertToParagraph(content)
-      return
+    // Replacing the only node with a paragraph is safer than deleting it,
+    // since ProseMirror requires at least one block in the document.
+    if (this.pmView.state.doc.childCount === 1) {
+      const para = this.pmView.state.schema.nodes.paragraph!.create()
+      const tr = this.pmView.state.tr.replaceWith(
+        pos,
+        pos + node.nodeSize,
+        para,
+      )
+      this.pmView.dispatch(tr)
+      this.pmView.focus()
+      return true
     }
 
-    const language = firstLine.match(FENCE_RE)?.[2] ?? ""
+    const tr = this.pmView.state.tr.delete(pos, pos + node.nodeSize)
+    const $pos = tr.doc.resolve(pos)
+    const sel = TextSelection.near($pos, -1)
+    if (!sel) return false
+    this.pmView.dispatch(tr.setSelection(sel).scrollIntoView())
+    this.pmView.focus()
+    return true
+  }
+
+  private changeLanguage(newLanguage: string) {
+    const pos = this.getPos()
+    const node = this.pmView.state.doc.nodeAt(pos)
+    if (!node) return
+
+    const body = this.cmView.state.doc.toString()
+    const fencedContent = addFences(body, newLanguage)
+    const textNode = this.pmView.state.schema.text(fencedContent)
+
+    const tr = this.pmView.state.tr
+      .setNodeAttribute(pos, "language", newLanguage)
+      .replaceWith(pos + 1, pos + node.nodeSize - 1, textNode)
+
+    this.log.info("changeLanguage", {
+      from: node.attrs.language,
+      to: newLanguage,
+    })
+
+    this.pmView.dispatch(tr)
+    this.cmView.focus()
+  }
+
+  private syncToPM() {
+    const content = this.cmView.state.doc.toString()
     const pos = this.getPos()
     const node = this.pmView.state.doc.nodeAt(pos)
     if (!node) return
-    const pmText = node.textContent
-    if (pmText === content && node.attrs.language === language) return
-    const textNode = this.pmView.state.schema.text(content)
-    let tr = this.pmView.state.tr.replaceWith(
+
+    const language = node.attrs.language as string
+    const fencedContent = addFences(content, language)
+    if (node.textContent === fencedContent) return
+
+    this.log.debug("syncToPM content sync", {
+      pmLen: node.textContent.length,
+      cmLen: content.length,
+    })
+
+    const textNode = this.pmView.state.schema.text(fencedContent)
+    const tr = this.pmView.state.tr.replaceWith(
       pos + 1,
       pos + node.nodeSize - 1,
       textNode,
     )
-    if (node.attrs.language !== language) {
-      this.log.info("syncToPM language change", {
-        from: node.attrs.language,
-        to: language,
-      })
-      tr = tr.setNodeAttribute(pos, "language", language)
-    } else {
-      this.log.debug("syncToPM content sync", {
-        pmLen: pmText.length,
-        cmLen: content.length,
-      })
-    }
     this.pmView.dispatch(tr)
   }
 
@@ -269,23 +357,22 @@ export class CodeBlockView implements NodeView {
     this.log.debug("update", {
       language,
       langExt: langExt?.constructor?.name ?? typeof langExt,
-      contentMatch: this.cmView.state.doc.toString() === node.textContent,
+      contentMatch:
+        this.cmView.state.doc.toString() === stripFences(node.textContent),
     })
 
-    // Only reconfigure the language compartment when the language
-    // actually changed — avoids re-entering the CM6 dispatch callback
-    // and breaking the syncToPM → update → syncToPM cycle.
     if (language !== this.lastLanguage) {
       this.lastLanguage = language
+      this.langSelect.value = normalizeLanguage(language)
       this.cmView.dispatch({
         effects: this.languageCompartment.reconfigure(langExt),
       })
     }
 
-    const content = node.textContent
-    if (this.cmView.state.doc.toString() === content) return true
+    const body = stripFences(node.textContent)
+    if (this.cmView.state.doc.toString() === body) return true
     this.cmView.dispatch({
-      changes: { from: 0, to: this.cmView.state.doc.length, insert: content },
+      changes: { from: 0, to: this.cmView.state.doc.length, insert: body },
     })
     return true
   }

+ 3 - 0
pnpm-lock.yaml

@@ -102,6 +102,9 @@ importers:
       '@lezer/common':
         specifier: ^1.2.2
         version: 1.5.2
+      '@lezer/highlight':
+        specifier: ^1.2.3
+        version: 1.2.3
       '@lezer/markdown':
         specifier: ^1.6.3
         version: 1.6.3