Prechádzať zdrojové kódy

feat: delegate code block navigation through CodeBlockNavigationDelegate

Strip fence markers from CM6 input, add language selector header with
delete button, and implement delegate pattern for code block boundary
navigation. ArrowUp/Down at document boundaries temporarily materialize
paragraphs until Editor shell insertion zones are built.

- Strip fences from CM6 doc input (fixes JS/TS highlighting)
- Add stripFences()/addFences() helpers with trailing-newline normalization
- Add language selector with normalizeLanguage() and delete button to header
- Implement CodeBlockNavigationDelegate interface
- Move exitBlock/removeBlock/changeLanguage from CodeBlockView to delegate
- Handle ArrowUp at first node and ArrowDown at last node (para materialization)
- Add GapCursor plugin and CSS
- Mark temporary boundary handling with FIXMEs for Editor shell
Zander Hawke 1 týždeň pred
rodič
commit
3de683e1da

+ 68 - 0
AGENTS.md

@@ -297,6 +297,74 @@ In `Block.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plu
 
 ---
 
+## Code Block Navigation Architecture
+
+Code blocks use a **delegate pattern** to decouple CM6 from PM document mutations. This is necessary because `stopEvent()` returns `true` in CodeBlockView — PM never sees keystrokes inside the node view.
+
+### Data flow
+
+```
+CM6 keymap handler
+       ↓
+CodeBlockView emits intent via CodeBlockNavigationDelegate
+       ↓
+Block.vue handles the delegate (creates PM transactions)
+       ↓
+(planned) Editor shell → insertion zones replace direct mutations
+```
+
+### `CodeBlockNavigationDelegate` interface
+
+Defined in `packages/editor/src/composables/useCodeBlockView.ts:23`:
+
+```typescript
+interface CodeBlockNavigationDelegate {
+  requestNavigateOut(direction: "up" | "down", nodePos: number): void
+  requestRemoveBlock(nodePos: number): void
+  requestChangeLanguage(newLanguage: string, body: string, nodePos: number): void
+}
+```
+
+Each method receives `nodePos` — the PM document position of the `code_block` node at call time — so the delegate can resolve neighbours without depending on PM's `NodeView.getPos()` lifecycle.
+
+### Responsibility split
+
+| Layer | Role |
+|---|---|
+| **CM6 keymap** | Detects boundary condition (cursor at first/last char, empty doc) and calls the delegate. |
+| **CodeBlockView** | Constructs the CM6 EditorState, syncs content bidirectionally, delegates navigation intent. Never mutates PM document structure directly. |
+| **Block.vue** | Creates the delegate closure with access to the PM `EditorView`. Dispatches PM transactions to implement navigation, removal, and language changes. |
+| **(future) Editor shell** | Will own insertion zones (click targets between blocks) and handle navigation to empty zones by materializing paragraphs. |
+
+### Document boundary rules
+
+All materialization of paragraphs at boundaries is **temporary** — handled in Block.vue behind `// FIXME: Replace with Editor shell insertion zone.` markers.
+
+| Direction | Boundary | Behavior |
+|---|---|---|
+| ArrowUp | Code block is first node (`nodePos === 0`) | Insert paragraph at position 0, focus it. |
+| ArrowUp | Neighbour paragraph exists before | Exit to it via `TextSelection.near` / `GapCursor`. |
+| ArrowDown | Code block is last node (`targetPos > doc.content.size`) | Insert paragraph at end of doc, focus it. |
+| ArrowDown | Neighbour paragraph exists after | Exit to it via `TextSelection.near` / `GapCursor`. |
+| Backspace | Code block is empty (`body.length === 0`) | Remove block (replace with paragraph if single child). |
+| Backspace | Code block has content | Normal CM6 deletion — no delegate call. |
+
+### Fence separation
+
+Code block content is stored in PM as a single text node with fence markers (`` ```language\n`` … ``\n``` ``). The CM6 EditorState receives only the **body** via `stripFences()` — fences are stripped on the way in and re-added on the way out via `addFences()`.
+
+```typescript
+// PM node text:  "```typescript\nconst x = 1\n```\n"
+// CM6 doc:       "const x = 1\n"
+const body = stripFences(pmNode.textContent).body
+// ...
+const fenced = addFences(cmBody, language) // "```typescript\nconst x = 1\n```\n"
+```
+
+This prevents CM6 from parsing fence backticks as template literals.
+
+---
+
 ## Performance Constraints
 
 - Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `Block.vue` instances.

+ 31 - 1
packages/editor/src/assets/style.css

@@ -4,7 +4,7 @@
 /* ── Base Editor ─────────────────────────────────────────────────── */
 
 .ProseMirror {
-  @apply outline-none min-h-7 whitespace-pre-wrap break-words text-(--ui-text-main) leading-relaxed;
+  @apply outline-none min-h-7 whitespace-pre-wrap break-words text-(--ui-text-main) leading-relaxed px-1;
 }
 
 .ProseMirror p {
@@ -448,3 +448,33 @@
 .ProseMirror-focused .math-block-preview {
   display: none;
 }
+
+/* ── Gap Cursor ──────────────────────────────────────────────────── */
+/* Appears before/after node views (code blocks, math) so the user can
+   click into boundaries and arrow past them. */
+
+.ProseMirror .ProseMirror-gapcursor {
+  display: none;
+  pointer-events: none;
+  position: relative;
+}
+
+.ProseMirror .ProseMirror-gapcursor:after {
+  content: "";
+  display: block;
+  position: absolute;
+  top: 0;
+  width: 20px;
+  border-top: 1px solid var(--ui-text);
+  animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
+}
+
+@keyframes ProseMirror-cursor-blink {
+  to {
+    visibility: hidden;
+  }
+}
+
+.ProseMirror-focused .ProseMirror-gapcursor {
+  display: block;
+}

+ 175 - 5
packages/editor/src/components/Block.vue

@@ -1,16 +1,26 @@
 <script setup lang="ts">
 import { watchDebounced } from "@vueuse/core"
+import { GapCursor, gapCursor } from "prosemirror-gapcursor"
 import { keymap } from "prosemirror-keymap"
-import { EditorState, Selection, type Transaction } from "prosemirror-state"
+import {
+  EditorState,
+  Selection,
+  TextSelection,
+  type Transaction,
+} from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
 import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
 import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
-import { CodeBlockView } from "@/composables/useCodeBlockView"
-import { MathBlockView } from "@/composables/useMathBlockView"
+import {
+  addFences,
+  type CodeBlockNavigationDelegate,
+  CodeBlockView,
+} from "@/composables/useCodeBlockView"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
 } from "@/composables/useMarkdownDecorations"
+import { MathBlockView } from "@/composables/useMathBlockView"
 import { createPasteHandler } from "@/composables/usePasteHandler"
 import type {
   PatternClosePayload,
@@ -29,6 +39,7 @@ import {
 } from "@/lib/auto-close-plugin"
 import { contentToDoc, docToContent } from "@/lib/content-model"
 import {
+  type FormattingHandlers,
   getActiveHeading,
   getActiveTask,
   insertBlockMath,
@@ -45,7 +56,6 @@ import {
   toggleItalic,
   toggleStrikethrough,
   toggleTask,
-  type FormattingHandlers,
 } from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
 
@@ -77,6 +87,17 @@ const emit = defineEmits<{
 
 const log = createLogger("Block", props.debug)
 
+// GapCursor.valid exists at runtime but is missing from the published type defs.
+// Try-construct as a proxy — the constructor throws on invalid positions.
+function isValidGapCursor($pos: unknown): boolean {
+  try {
+    new GapCursor($pos as never)
+    return true
+  } catch {
+    return false
+  }
+}
+
 const handleKeyDown = createBlockKeyboardHandler({
   split: (before, after) => emit("split", before, after),
   "merge-previous": () => emit("merge-previous"),
@@ -210,6 +231,7 @@ onMounted(() => {
   const editorState = EditorState.create({
     doc: initialDoc,
     plugins: [
+      gapCursor(),
       markdownDecorationsPlugin,
       patternPlugin,
       autoClosePlugin([
@@ -267,11 +289,159 @@ onMounted(() => {
   const mountEl = editorRef.value
   if (!mountEl) return
 
+  const delegate: CodeBlockNavigationDelegate = {
+    requestNavigateOut(direction, nodePos) {
+      log.info("requestNavigateOut", { direction, nodePos })
+      const currentView = view
+      if (!currentView) return
+      const node = currentView.state.doc.nodeAt(nodePos)
+      if (!node) return
+
+      const targetPos =
+        direction === "up" ? nodePos - 1 : nodePos + node.nodeSize
+
+      // FIXME: Replace with Editor shell insertion zone.
+      // ArrowUp from the very first node — materialize a paragraph before.
+      if (direction === "up" && targetPos < 0) {
+        log.info(
+          "requestNavigateOut — materializing paragraph before code block (first node)",
+        )
+        const para = currentView.state.schema.nodes.paragraph!.create()
+        const tr = currentView.state.tr.insert(0, para)
+        const cursor$pos = tr.doc.resolve(0)
+        const cursorSel = TextSelection.near(cursor$pos, 1)
+        if (cursorSel) {
+          currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
+          requestAnimationFrame(() => currentView.focus())
+        }
+        return
+      }
+
+      // FIXME: Replace with Editor shell insertion zone.
+      // ArrowDown past the end — materialize a paragraph after.
+      if (targetPos > currentView.state.doc.content.size) {
+        log.info(
+          "requestNavigateOut — materializing paragraph after code block (last node)",
+        )
+        const insertPos = currentView.state.doc.content.size
+        const para = currentView.state.schema.nodes.paragraph!.create()
+        const tr = currentView.state.tr.insert(insertPos, para)
+        const cursor$pos = tr.doc.resolve(insertPos)
+        const cursorSel = TextSelection.near(cursor$pos, 1)
+        if (cursorSel) {
+          currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
+          requestAnimationFrame(() => currentView.focus())
+        }
+        return
+      }
+
+      const $pos = currentView.state.doc.resolve(targetPos)
+      let sel = TextSelection.near($pos, direction === "up" ? -1 : 1)
+      if (!sel && isValidGapCursor($pos)) {
+        log.info("requestNavigateOut — falling back to GapCursor")
+        sel = new GapCursor($pos)
+      }
+      if (sel) {
+        currentView.dispatch(
+          currentView.state.tr.setSelection(sel).scrollIntoView(),
+        )
+        currentView.focus()
+        return
+      }
+
+      // FIXME: Replace with Editor shell insertion zone.
+      // No valid selection or gap cursor — materialize a paragraph.
+      if (direction === "up") {
+        log.info(
+          "requestNavigateOut — materializing paragraph before code block",
+        )
+        const para = currentView.state.schema.nodes.paragraph!.create()
+        const tr = currentView.state.tr.insert(nodePos, para)
+        const cursor$pos = tr.doc.resolve(nodePos)
+        const cursorSel = TextSelection.near(cursor$pos, 1)
+        if (cursorSel) {
+          currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
+          requestAnimationFrame(() => currentView.focus())
+        }
+        // FIXME: Replace with Editor shell insertion zone.
+      } else {
+        log.info(
+          "requestNavigateOut — materializing paragraph after code block",
+        )
+        const insertPos = nodePos + node.nodeSize
+        const para = currentView.state.schema.nodes.paragraph!.create()
+        const tr = currentView.state.tr.insert(insertPos, para)
+        const cursor$pos = tr.doc.resolve(insertPos)
+        const cursorSel = TextSelection.near(cursor$pos, 1)
+        if (cursorSel) {
+          currentView.dispatch(tr.setSelection(cursorSel).scrollIntoView())
+          requestAnimationFrame(() => currentView.focus())
+        }
+      }
+    },
+
+    requestRemoveBlock(nodePos) {
+      log.info("requestRemoveBlock", { nodePos })
+      const currentView = view
+      if (!currentView) return
+      const node = currentView.state.doc.nodeAt(nodePos)
+      if (!node) return
+
+      if (currentView.state.doc.childCount === 1) {
+        log.info("requestRemoveBlock — single node, replacing with paragraph")
+        const para = currentView.state.schema.nodes.paragraph!.create()
+        const tr = currentView.state.tr.replaceWith(
+          nodePos,
+          nodePos + node.nodeSize,
+          para,
+        )
+        currentView.dispatch(tr)
+        currentView.focus()
+        return
+      }
+
+      log.info("requestRemoveBlock — deleting code block")
+      const tr = currentView.state.tr.delete(nodePos, nodePos + node.nodeSize)
+      const $pos = tr.doc.resolve(nodePos)
+      const sel = TextSelection.near($pos, -1)
+      if (sel) {
+        currentView.dispatch(tr.setSelection(sel).scrollIntoView())
+        currentView.focus()
+      }
+    },
+
+    requestChangeLanguage(newLanguage, body, nodePos) {
+      log.info("requestChangeLanguage", {
+        newLanguage,
+        nodePos,
+        bodyLength: body.length,
+      })
+      const currentView = view
+      if (!currentView) return
+      const node = currentView.state.doc.nodeAt(nodePos)
+      if (!node) return
+
+      const fencedContent = addFences(body, newLanguage)
+      const textNode = currentView.state.schema.text(fencedContent)
+      const tr = currentView.state.tr
+        .setNodeAttribute(nodePos, "language", newLanguage)
+        .replaceWith(nodePos + 1, nodePos + node.nodeSize - 1, textNode)
+      currentView.dispatch(tr)
+      requestAnimationFrame(() => currentView.focus())
+    },
+  }
+
   view = new EditorView(mountEl, {
     state: editorState,
     nodeViews: {
       code_block: (node, view, getPos) =>
-        new CodeBlockView(node, view, () => getPos() ?? 0, props.debug),
+        new CodeBlockView(
+          node,
+          view,
+          () => getPos() ?? 0,
+          delegate,
+          props.debug,
+        ),
       math_block: (node, view, getPos) =>
         new MathBlockView(node, view, () => getPos() ?? 0),
     },

+ 43 - 76
packages/editor/src/composables/useCodeBlockView.ts

@@ -11,10 +11,34 @@ 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"
 import { createLogger } from "@/lib/logger"
 
+/**
+ * Delegate through which CodeBlockView signals navigation intent.
+ *
+ * The node view never mutates document structure directly — it emits
+ * intent and lets the owning layer (Block / Editor) decide what to do.
+ *
+ * Methods receive `nodePos` — the ProseMirror position of the code_block
+ * node at call time — so the delegate can resolve it to find neighbour
+ * nodes without depending on PM's node view lifecycle.
+ */
+export interface CodeBlockNavigationDelegate {
+  /** User pressed ArrowUp at first line or ArrowDown at last line. */
+  requestNavigateOut(direction: "up" | "down", nodePos: number): void
+
+  /** User pressed Backspace on an empty code block. */
+  requestRemoveBlock(nodePos: number): void
+
+  /** User selected a new language from the dropdown. */
+  requestChangeLanguage(
+    newLanguage: string,
+    body: string,
+    nodePos: number,
+  ): void
+}
+
 function instanceLogger(componentFilter?: string) {
   return createLogger("CodeBlockView", componentFilter)
 }
@@ -30,7 +54,7 @@ function instanceLogger(componentFilter?: string) {
  * - 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 {
+export function stripFences(content: string): string {
   const lines = content.split("\n")
   return lines.slice(1, -1).join("\n")
 }
@@ -40,7 +64,7 @@ function stripFences(content: string): string {
  * Normalizes trailing newlines to prevent blank-line drift
  * before the closing fence.
  */
-function addFences(content: string, language: string): string {
+export function addFences(content: string, language: string): string {
   const body = content.replace(/\n$/, "")
   return `\`\`\`${language}\n${body}\n\`\`\``
 }
@@ -139,6 +163,7 @@ export class CodeBlockView implements NodeView {
   cmView: CodeMirrorView
   private pmView: EditorView
   private getPos: () => number
+  private delegate: CodeBlockNavigationDelegate
   private languageCompartment = new Compartment()
   private lastLanguage = ""
   private langSelect: HTMLSelectElement
@@ -148,10 +173,12 @@ export class CodeBlockView implements NodeView {
     node: ProsemirrorNode,
     view: EditorView,
     getPos: () => number,
+    delegate: CodeBlockNavigationDelegate,
     componentFilter?: string,
   ) {
     this.pmView = view
     this.getPos = getPos
+    this.delegate = delegate
     this.log = instanceLogger(componentFilter)
 
     this.dom = document.createElement("div")
@@ -174,7 +201,12 @@ export class CodeBlockView implements NodeView {
     this.langSelect.value = normalizeLanguage(language)
 
     this.langSelect.addEventListener("change", () => {
-      this.changeLanguage(this.langSelect.value)
+      const body = this.cmView.state.doc.toString()
+      this.delegate.requestChangeLanguage(
+        this.langSelect.value,
+        body,
+        this.getPos(),
+      )
     })
 
     const deleteBtn = document.createElement("button")
@@ -183,7 +215,7 @@ export class CodeBlockView implements NodeView {
     deleteBtn.title = "Delete code block"
     deleteBtn.addEventListener("mousedown", (e) => e.stopPropagation())
     deleteBtn.addEventListener("click", () => {
-      this.removeBlock()
+      this.delegate.requestRemoveBlock(this.getPos())
     })
 
     header.appendChild(this.langSelect)
@@ -203,7 +235,8 @@ export class CodeBlockView implements NodeView {
         run: (cm6View) => {
           const sel = cm6View.state.selection.main
           if (sel.from === 0 && sel.empty) {
-            return this.exitBlock("up")
+            this.delegate.requestNavigateOut("up", this.getPos())
+            return true
           }
           return false
         },
@@ -213,7 +246,8 @@ export class CodeBlockView implements NodeView {
         run: (cm6View) => {
           const sel = cm6View.state.selection.main
           if (sel.to === cm6View.state.doc.length && sel.empty) {
-            return this.exitBlock("down")
+            this.delegate.requestNavigateOut("down", this.getPos())
+            return true
           }
           return false
         },
@@ -222,7 +256,8 @@ export class CodeBlockView implements NodeView {
         key: "Backspace",
         run: (cm6View) => {
           if (cm6View.state.doc.length === 0) {
-            return this.removeBlock()
+            this.delegate.requestRemoveBlock(this.getPos())
+            return true
           }
           return false
         },
@@ -257,74 +292,6 @@ export class CodeBlockView implements NodeView {
     }
   }
 
-  private exitBlock(dir: "up" | "down"): boolean {
-    const pos = this.getPos()
-    const node = this.pmView.state.doc.nodeAt(pos)
-    if (!node) return false
-    const targetPos = dir === "up" ? pos - 1 : pos + node.nodeSize
-    if (targetPos < 0 || targetPos > this.pmView.state.doc.content.size) {
-      return false
-    }
-    const $pos = this.pmView.state.doc.resolve(targetPos)
-    const sel = TextSelection.near($pos, dir === "up" ? -1 : 1)
-    if (!sel) return false
-    this.pmView.dispatch(
-      this.pmView.state.tr.setSelection(sel).scrollIntoView(),
-    )
-    this.pmView.focus()
-    return true
-  }
-
-  private removeBlock(): boolean {
-    const pos = this.getPos()
-    const node = this.pmView.state.doc.nodeAt(pos)
-    if (!node) return false
-
-    // 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 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()