Просмотр исходного кода

refactor(editor): rename Block→EditorBlock, InsertionZone→EditorInsertionZone, EditorBlock→EditorBlockData

- Rename files: Block.vue → EditorBlock.vue, InsertionZone.vue → EditorInsertionZone.vue
- Rename interface in block-parser.ts: EditorBlock → EditorBlockData
  to avoid collision with the EditorBlock component
- Update all imports and exports across editor package and dev app
- Update docs: AGENTS.md (component flow, suggestion menu section),
  README.md (file tree, key files table, EditorSuggestionMenu API)
Zander Hawke 4 дней назад
Родитель
Сommit
947a7d1b4f

+ 48 - 10
AGENTS.md

@@ -16,11 +16,12 @@ packages/editor/   Core headless engine — parsers, rule registries, composable
 **Component flow (editor shell):**
 
 ```
-apps/dev/src/pages/EditorView.vue
+apps/dev/src/pages/editor-shell.vue
   └── apps/dev/src/components/Editor.vue
         └── packages/editor/src/components/Editor.vue    ← multi-block shell
-              ├── packages/editor/src/components/InsertionZone.vue
-              └── packages/editor/src/components/Block.vue
+              ├── packages/editor/src/components/EditorInsertionZone.vue
+              └── packages/editor/src/components/EditorBlock.vue    ← self-contained block
+                    ├── packages/editor/src/components/EditorSuggestionMenu.vue
                     ├── packages/editor/src/composables/useMarkdownDecorations.ts
                     │     └── packages/editor/src/lib/markdown-parser.ts
                     │           └── packages/editor/src/lib/markdown-rules/engine.ts
@@ -222,7 +223,7 @@ After setting, refresh and open DevTools console. Namespaces set via the `debug`
 
 | Namespace | Source File | What it logs | Level |
 |---|---|---|---|
-| `Block` | `Block.vue` | Content changes, paste classification, external syncs | `debug` |
+| `Block` | `EditorBlock.vue` | Content changes, paste classification, external syncs | `debug` |
 | `CodeBlockView` | `useCodeBlockView.ts` | Constructor, language detection, CM6↔PM sync, fence deletion conversion | `info`/`debug` |
 | `Block.Enter` | `useBlockKeyboardHandlers.ts` | Enter keystroke detection, fence match results | `debug` |
 | `ContentModel` | `content-model.ts` | `contentToDoc`/`docToContent` input/output sizes, node type breakdown | `debug` |
@@ -251,7 +252,7 @@ export function createMyThing(componentFilter?: string) {
   // ...
 }
 
-// In Block.vue:
+// In EditorBlock.vue:
 createMyThing(props.debug)
 ```
 
@@ -301,7 +302,44 @@ Defined in `packages/editor/src/lib/auto-close-plugin.ts`. A ProseMirror `Plugin
 
 ### Registration
 
-In `Block.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plugins` array. Rules are evaluated in order; the first matching rule handles the event.
+In `EditorBlock.vue`, pass rules to `autoClosePlugin([...])` in the EditorState's `plugins` array. Rules are evaluated in order; the first matching rule handles the event.
+
+---
+
+## Suggestion Menu
+
+Each `EditorBlock.vue` renders its own `EditorSuggestionMenu` instance inline (not teleported). The menu is positioned with `position: absolute` inside a `position: relative` wrapper so it scrolls naturally with content.
+
+### Trigger flow
+
+```
+PM input → pattern plugin detects trigger (/ [[ (( #)
+  → onPatternOpen callback
+    → localSession ref set in EditorBlock
+    → EditorSuggestionMenu becomes visible
+    → capture-phase keydown listener registered on document
+```
+
+### Keyboard handling
+
+Keyboard navigation is **not** handled by `useKeyboard` in the Editor shell. Each `EditorBlock.vue` registers its own capture-phase listener when its `localSession` is active:
+
+| Key | Action |
+|---|---|
+| `ArrowDown` | Highlight next item (wraps) |
+| `ArrowUp` | Highlight previous item (wraps) |
+| `Enter` | Select highlighted item |
+| `Escape` | Close menu, cancel session |
+
+The Editor's `useKeyboard` only handles `Escape` (close-palette), `Mod+Z` (undo), and `Mod+Shift+Z` (redo). Arrow navigation keys pass through the Editor's handler (the keybinding returns `null` for them) and are caught by the focused Block's capture listener.
+
+### Position recalculation on scroll
+
+A passive `window` scroll listener calls `view.coordsAtPos(session.range.from)` on scroll and converts viewport coords to wrapper-relative coords by subtracting `getBoundingClientRect()` of the positioned wrapper. This keeps the menu attached to the cursor position during scroll.
+
+### Item selection
+
+The `onSelect` callback in each menu item calls `payload.respond({ text, mode: "replace" })` which replaces the trigger range (`[[query`) with the full syntax (`[[Selected Item]]`). The `trigger` from the payload is included in the replacement text so delimiters aren't stripped.
 
 ---
 
@@ -316,7 +354,7 @@ CM6 keymap handler
 CodeBlockView emits intent via CodeBlockNavigationDelegate
-Block.vue handles the delegate (creates PM transactions)
+EditorBlock.vue handles the delegate (creates PM transactions)
 (planned) Editor shell → insertion zones replace direct mutations
 ```
@@ -341,12 +379,12 @@ Each method receives `nodePos` — the PM document position of the `code_block`
 |---|---|
 | **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. |
+| **EditorBlock.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.
+All materialization of paragraphs at boundaries is **temporary** — handled in EditorBlock.vue behind `// FIXME: Replace with Editor shell insertion zone.` markers.
 
 | Direction | Boundary | Behavior |
 |---|---|---|
@@ -375,7 +413,7 @@ 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.
+- Target **< 16ms per parse cycle** (60fps budget). The parser runs on every keystroke in focused `EditorBlock.vue` instances.
 - `visitTree` processes each Lezer node exactly once. Do not introduce nested tree walks.
 - Avoid allocations inside hot paths — reuse range objects where possible.
 - **No incremental Lezer parsing.** `invalidateChangedCaches` always passes `undefined` as the old tree. Incremental parse fragments prevent GFM from re-classifying `Paragraph` → `Table` when the delimiter line is typed later. Full re-parse is negligible for blocks under 100 chars.

+ 2 - 2
README.md

@@ -65,9 +65,9 @@ Enesis is being built as a complete block-editing substrate:
 │       ├── src/
 │       │   ├── index.ts               Public API (Block + Editor + EditorToolbar components)
 │       │   ├── components/
-│       │   │   ├── Block.vue           Self-contained ProseMirror block editor
+│       │   │   ├── EditorBlock.vue     Self-contained ProseMirror block editor
 │       │   │   ├── Editor.vue          Multi-block shell with insertion zones, undo/redo
-│       │   │   ├── InsertionZone.vue   32px hit target between blocks, hover-reveal
+│       │   │   ├── EditorInsertionZone.vue   32px hit target between blocks, hover-reveal
 │       │   │   └── EditorToolbar.vue   Shared toolbar bound to active block
 │       │   ├── composables/
 │       │   │   ├── useMarkdownDecorations.ts   ProseMirror decoration plugin

+ 2 - 2
apps/dev/src/components/LiveExample.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { Block } from "@enesis/editor"
+import { EditorBlock } from "@enesis/editor"
 import { ref } from "vue"
 import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
 
@@ -28,7 +28,7 @@ const content = ref(props.content)
 
     <div class="rounded-lg border border-default bg-elevated overflow-hidden">
       <div class="p-4">
-        <Block
+        <EditorBlock
           v-model:content="content"
           :focused="true"
           cursor-position="end"

+ 2 - 2
apps/dev/src/pages/index.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { Block } from "@enesis/editor"
+import { EditorBlock } from "@enesis/editor"
 import { ref } from "vue"
 
 const example = ref(`# Welcome to the Editor
@@ -29,7 +29,7 @@ Try editing any of the examples on the left.
             Markdown syntax is rendered as rich decorations in live-preview mode: delimiters fade when blurred, syntax elements get contextual styling.
           </p>
           <div class="rounded-lg border border-default bg-elevated p-4">
-            <Block
+            <EditorBlock
               v-model:content="example"
               :focused="true"
               cursor-position="end"

+ 4 - 4
packages/editor/README.md

@@ -192,14 +192,14 @@ Clicking the rendered table focuses the block and reveals the source. All inline
 
 ## `<EditorSuggestionMenu>` API
 
-A self-contained suggestion listbox for slash commands (`/`) and mention completions (`[[`, `((`, `#`). Rendered via `<Teleport to="body">` with enter/leave transitions. Keyboard navigation is driven externally through the `forwardKey` method — no synthetic DOM events, no external UI library dependency for list navigation.
+A self-contained suggestion listbox for slash commands (`/`) and mention completions (`[[`, `((`, `#`). Rendered inline with `position: absolute` inside a positioned parent (the `EditorBlock` wrapper); no teleport, so the menu scrolls naturally with content. Keyboard navigation is driven externally through the `forwardKey` method — no synthetic DOM events, no external UI library dependency for list navigation.
 
 ### Props
 
 | Prop | Type | Description |
 |---|---|---|
 | `groups` | `CommandPaletteGroup[]` | Grouped items matching Nuxt UI's `CommandPaletteGroup` shape (`.id`, `.label`, `.items[]` with `.label`, `.icon`, `.suffix`, `.kbds`, `.onSelect`). |
-| `position` | `{ x: number, y: number }` | Screen-space top-left of the trigger character. Menu is offset 24px below. |
+| `position` | `{ x: number, y: number }` | Container-relative offset (viewport coords minus the positioned parent's bounding rect). Menu is offset 24px below. |
 | `query` | `string` | Current search text — shown in the empty-state message. |
 | `active` | `boolean` | Show/hide the menu. Resets `activeIndex` to 0 on transition. |
 
@@ -280,9 +280,9 @@ pnpm dev          # Watch mode rebuild
 | File | Purpose |
 |---|---|
 | `src/index.ts` | Public API surface |
-| `src/components/Block.vue` | ProseMirror block editor component |
+| `src/components/EditorBlock.vue` | ProseMirror block editor component |
 | `src/components/Editor.vue` | Multi-block shell with insertion zones, split/merge, undo/redo |
-| `src/components/InsertionZone.vue` | Invisible click target between blocks |
+| `src/components/EditorInsertionZone.vue` | Invisible click target between blocks |
 | `src/components/EditorToolbar.vue` | Shared toolbar bound to active block's handlers |
 | `src/components/EditorSuggestionMenu.vue` | Slash-command and mention suggestion palette |
 | `src/composables/useKeyboard.ts` | Capture-phase keyboard handler with swappable keybindings |

+ 10 - 10
packages/editor/src/components/Editor.vue

@@ -1,8 +1,8 @@
 <script setup lang="ts">
 import type { EditorView } from "prosemirror-view"
 import { computed, nextTick, type Ref, ref, watch } from "vue"
-import Block from "@/components/Block.vue"
-import InsertionZone from "@/components/InsertionZone.vue"
+import EditorBlock from "@/components/EditorBlock.vue"
+import EditorInsertionZone from "@/components/EditorInsertionZone.vue"
 import {
   type FocusPosition,
   type FocusTarget,
@@ -20,7 +20,7 @@ import type {
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
 import {
-  type EditorBlock,
+  type EditorBlockData,
   generateBlockId,
   serializeBlocks,
   splitMarkdownIntoBlocks,
@@ -81,7 +81,7 @@ const themeCSSVars = computed(() => {
 // for future refactoring.
 let internalUpdatePending = false
 
-const blocks = ref<EditorBlock[]>([])
+const blocks = ref<EditorBlockData[]>([])
 
 const registry = useFocusRegistry()
 
@@ -175,7 +175,7 @@ function applyOperation(op: EditorOperation): void {
       break
     }
     case "set-block-content": {
-      const setBlock = blocks.value.find((b) => b.id === op.blockId)
+      const setBlock = blocks.value.find((b: EditorBlockData) => b.id === op.blockId)
       if (setBlock) {
         setBlock.content = op.newContent
         blockRefs.get(setBlock.id)?.setContent(op.newContent)
@@ -597,9 +597,9 @@ defineExpose({ undo, redo, canUndo, canRedo })
       />
     </div>
     <template v-for="(block, i) in blocks" :key="block.id">
-      <InsertionZone
+      <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs[i] = el; else delete zoneRefs[i] }"
-        @activate="(content) => onZoneActivate(i, content)"
+        @activate="(content: string) => onZoneActivate(i, content)"
         @focus="onZoneFocus(i)"
         @blur="onZoneBlur"
         @navigate-up="onZoneNavigateUp(i)"
@@ -614,7 +614,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
           <UIcon name="i-lucide-dot" class="size-6 text-muted" />
         </div>
 
-        <Block
+        <EditorBlock
           :ref="(el: any) => { if (el) blockRefs.set(block.id, el); else blockRefs.delete(block.id) }"
           :id="block.id"
           :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
@@ -642,9 +642,9 @@ defineExpose({ undo, redo, canUndo, canRedo })
         />
       </div>
     </template>
-    <InsertionZone
+    <EditorInsertionZone
       :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el; else delete zoneRefs[blocks.length] }"
-      @activate="(content) => onZoneActivate(blocks.length, content)"
+      @activate="(content: string) => onZoneActivate(blocks.length, content)"
       @focus="onZoneFocus(blocks.length)"
       @blur="onZoneBlur"
       @navigate-up="onZoneNavigateUp(blocks.length)"

+ 0 - 0
packages/editor/src/components/Block.vue → packages/editor/src/components/EditorBlock.vue


+ 0 - 0
packages/editor/src/components/InsertionZone.vue → packages/editor/src/components/EditorInsertionZone.vue


+ 3 - 4
packages/editor/src/index.ts

@@ -1,5 +1,5 @@
 import type { App } from "vue"
-import Block from "@/components/Block.vue"
+import EditorBlock from "@/components/EditorBlock.vue"
 import Editor from "@/components/Editor.vue"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
@@ -7,7 +7,6 @@ import EditorToolbar from "@/components/EditorToolbar.vue"
 import "@/assets/style.css"
 
 export type { FocusTarget } from "@/composables/useFocusRegistry"
-export type { EditorBlock } from "@/lib/block-parser"
 export {
   extractEmbeddedId,
   generateBlockId,
@@ -63,10 +62,10 @@ export {
   useMentionGroups,
   useSlashGroups,
 } from "@/composables/useSuggestionGroups"
-export { Block, Editor, EditorSuggestionMenu, EditorToolbar }
+export { EditorBlock, Editor, EditorSuggestionMenu, EditorToolbar }
 
 export default {
   install(app: App) {
-    app.component("Block", Block)
+    app.component("EditorBlock", EditorBlock)
   },
 }

+ 28 - 28
packages/editor/src/lib/__tests__/editor-operations.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it, vi } from "vitest"
-import type { EditorBlock } from "@/lib/block-parser"
+import type { EditorBlockData } from "@/lib/block-parser"
 import {
   deleteIfEmpty,
   indentBlock,
@@ -10,7 +10,7 @@ import {
   splitBlocks,
 } from "@/lib/editor-operations"
 
-function block(overrides: Partial<EditorBlock> & { content: string }): EditorBlock {
+function block(overrides: Partial<EditorBlockData> & { content: string }): EditorBlockData {
   return { id: "auto", depth: 0, ...overrides }
 }
 
@@ -26,7 +26,7 @@ function stableId(prefix: string): () => string {
 
 describe("splitBlocks", () => {
   it("splits a block into two at the given index", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", depth: 0, content: "first" }),
       block({ id: "b", depth: 1, content: "second" }),
     ]
@@ -47,20 +47,20 @@ describe("splitBlocks", () => {
   })
 
   it("preserves depth of the original block", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
     const result = splitBlocks(blocks, 0, "a", "b", stableId("n"))
     expect(result.blocks[0].depth).toBe(2)
     expect(result.blocks[1].depth).toBe(2)
   })
 
   it("returns original array copy when index is out of bounds", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", content: "x" })]
     expect(splitBlocks(blocks, -1, "", "", stableId("n")).newBlock).toBeNull()
     expect(splitBlocks(blocks, 5, "", "", stableId("n")).newBlock).toBeNull()
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 1, content: "original" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 1, content: "original" })]
     const copy = [...blocks]
     splitBlocks(blocks, 0, "before", "after", stableId("n"))
     expect(blocks).toEqual(copy)
@@ -71,7 +71,7 @@ describe("splitBlocks", () => {
 
 describe("mergePrevious", () => {
   it("merges two adjacent blocks, appending content", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "hello" }),
       block({ id: "b", content: " world" }),
     ]
@@ -84,7 +84,7 @@ describe("mergePrevious", () => {
   })
 
   it("joinPos is 0 when previous block is empty", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "" }),
       block({ id: "b", content: "content" }),
     ]
@@ -94,7 +94,7 @@ describe("mergePrevious", () => {
   })
 
   it("returns no change when index is 0 or out of bounds", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", content: "x" }), block({ id: "b", content: "y" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", content: "x" }), block({ id: "b", content: "y" })]
     const atZero = mergePrevious(blocks, 0)
     expect(atZero.blocks).toHaveLength(2)
 
@@ -103,7 +103,7 @@ describe("mergePrevious", () => {
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "hello" }),
       block({ id: "b", content: " world" }),
     ]
@@ -117,7 +117,7 @@ describe("mergePrevious", () => {
 
 describe("deleteIfEmpty", () => {
   it("removes a block at the given index", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "first" }),
       block({ id: "b", content: "second" }),
       block({ id: "c", content: "third" }),
@@ -129,7 +129,7 @@ describe("deleteIfEmpty", () => {
   })
 
   it("focuses the next block when removing from the beginning", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "first" }),
       block({ id: "b", content: "second" }),
     ]
@@ -138,7 +138,7 @@ describe("deleteIfEmpty", () => {
   })
 
   it("focuses the previous block when removing the last block", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "first" }),
       block({ id: "b", content: "second" }),
     ]
@@ -147,14 +147,14 @@ describe("deleteIfEmpty", () => {
   })
 
   it("does nothing when only one block exists", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", content: "only" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", content: "only" })]
     const result = deleteIfEmpty(blocks, 0)
     expect(result.blocks).toHaveLength(1)
     expect(result.nextId).toBeNull()
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "x" }),
       block({ id: "b", content: "y" }),
     ]
@@ -168,13 +168,13 @@ describe("deleteIfEmpty", () => {
 
 describe("indentBlock", () => {
   it("increments depth of the block", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
   })
 
   it("also indents child blocks", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "p", depth: 0, content: "parent" }),
       block({ id: "c", depth: 1, content: "child" }),
       block({ id: "g", depth: 2, content: "grandchild" }),
@@ -190,14 +190,14 @@ describe("indentBlock", () => {
   })
 
   it("caps depth at 10", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 10, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 10, content: "x" })]
     const result = indentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(10)
     expect(result.childIndices).toEqual([])
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
     const copy = [...blocks]
     indentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
@@ -208,13 +208,13 @@ describe("indentBlock", () => {
 
 describe("outdentBlock", () => {
   it("decrements depth of the block", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(1)
   })
 
   it("also outdents child blocks", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "p", depth: 2, content: "parent" }),
       block({ id: "c", depth: 3, content: "child" }),
       block({ id: "g", depth: 4, content: "grandchild" }),
@@ -229,14 +229,14 @@ describe("outdentBlock", () => {
   })
 
   it("floors depth at 0", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 0, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 0, content: "x" })]
     const result = outdentBlock(blocks, 0)
     expect(result.blocks[0].depth).toBe(0)
     expect(result.childIndices).toEqual([])
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", depth: 2, content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", depth: 2, content: "x" })]
     const copy = [...blocks]
     outdentBlock(blocks, 0)
     expect(blocks).toEqual(copy)
@@ -247,7 +247,7 @@ describe("outdentBlock", () => {
 
 describe("insertBlock", () => {
   it("inserts a block at the given index", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "first" }),
       block({ id: "b", content: "last" }),
     ]
@@ -262,14 +262,14 @@ describe("insertBlock", () => {
   })
 
   it("inserts at the beginning", () => {
-    const blocks: EditorBlock[] = [block({ id: "b", content: "second" })]
+    const blocks: EditorBlockData[] = [block({ id: "b", content: "second" })]
     const result = insertBlock(blocks, 0, "first", 0, stableId("n"))
     expect(result.blocks).toHaveLength(2)
     expect(result.blocks[0].content).toBe("first")
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", content: "x" })]
     const copy = [...blocks]
     insertBlock(blocks, 0, "y", 0, stableId("n"))
     expect(blocks).toEqual(copy)
@@ -280,7 +280,7 @@ describe("insertBlock", () => {
 
 describe("setBlockContent", () => {
   it("updates content of the matching block", () => {
-    const blocks: EditorBlock[] = [
+    const blocks: EditorBlockData[] = [
       block({ id: "a", content: "old" }),
       block({ id: "b", content: "keep" }),
     ]
@@ -290,7 +290,7 @@ describe("setBlockContent", () => {
   })
 
   it("does not mutate the original array", () => {
-    const blocks: EditorBlock[] = [block({ id: "a", content: "x" })]
+    const blocks: EditorBlockData[] = [block({ id: "a", content: "x" })]
     setBlockContent(blocks, "a", "y")
     expect(blocks[0].content).toBe("x")
   })

+ 5 - 5
packages/editor/src/lib/block-parser.ts

@@ -2,7 +2,7 @@ import type { SyntaxNode } from "@lezer/common"
 import { GFM, parser } from "@lezer/markdown"
 import { createMarkdownExtensions } from "@/lib/markdown-extensions"
 
-export interface EditorBlock {
+export interface EditorBlockData {
   id: string
   depth: number
   content: string
@@ -49,7 +49,7 @@ export function extractEmbeddedId(content: string): string | null {
  * The `id::` line uses continuation indentation (`(depth + 1) * 2` spaces)
  * so it remains inside the list item during GFM parsing.
  */
-export function stampBlockId(block: EditorBlock): void {
+export function stampBlockId(block: EditorBlockData): void {
   if (extractEmbeddedId(block.content)) return
   const indent = "  ".repeat(block.depth + 1)
   block.content += `\n${indent}id:: ${block.id}`
@@ -100,8 +100,8 @@ function ownContentEnd(node: SyntaxNode): number {
 export function splitMarkdownIntoBlocks(
   md: string,
   idFn: () => string = crypto.randomUUID.bind(crypto),
-  existing?: Pick<EditorBlock, "id">[],
-): EditorBlock[] {
+  existing?: Pick<EditorBlockData, "id">[],
+): EditorBlockData[] {
   if (!md.trim()) return []
 
   const tree = markdownParser.parse(md)
@@ -150,7 +150,7 @@ export function splitMarkdownIntoBlocks(
  * content) are indented by `(depth + 1) * 2` spaces so the markdown stays
  * structurally valid — continuation paragraphs remain inside the list item.
  */
-export function serializeBlocks(blocks: EditorBlock[]): string {
+export function serializeBlocks(blocks: EditorBlockData[]): string {
   return blocks
     .map((block) => {
       const indent = "  ".repeat(block.depth)

+ 17 - 17
packages/editor/src/lib/editor-operations.ts

@@ -1,23 +1,23 @@
-import type { EditorBlock } from "@/lib/block-parser"
+import type { EditorBlockData } from "@/lib/block-parser"
 
 export interface SplitResult {
-  blocks: EditorBlock[]
-  newBlock: EditorBlock | null
+  blocks: EditorBlockData[]
+  newBlock: EditorBlockData | null
 }
 
 export interface MergeResult {
-  blocks: EditorBlock[]
+  blocks: EditorBlockData[]
   mergedContent: string
   joinPos: number
 }
 
 export interface DeleteResult {
-  blocks: EditorBlock[]
+  blocks: EditorBlockData[]
   nextId: string | null
 }
 
 export interface IndentResult {
-  blocks: EditorBlock[]
+  blocks: EditorBlockData[]
   childIndices: number[]
 }
 
@@ -30,7 +30,7 @@ function generateId(): string {
  * a new sibling gets `after`.
  */
 export function splitBlocks(
-  blocks: EditorBlock[],
+  blocks: EditorBlockData[],
   index: number,
   before: string,
   after: string,
@@ -38,7 +38,7 @@ export function splitBlocks(
 ): SplitResult {
   if (index < 0 || index >= blocks.length) return { blocks: [...blocks], newBlock: null }
   const newBlocks = blocks.map((b, i) => (i === index ? { ...b, content: before } : { ...b }))
-  const newBlock: EditorBlock = { id: idFn(), depth: newBlocks[index]!.depth, content: after }
+  const newBlock: EditorBlockData = { id: idFn(), depth: newBlocks[index]!.depth, content: after }
   newBlocks.splice(index + 1, 0, newBlock)
   return { blocks: newBlocks, newBlock }
 }
@@ -46,7 +46,7 @@ export function splitBlocks(
 /**
  * Merge block at `index` into its predecessor.
  */
-export function mergePrevious(blocks: EditorBlock[], index: number): MergeResult {
+export function mergePrevious(blocks: EditorBlockData[], index: number): MergeResult {
   const result: MergeResult = { blocks: [...blocks], mergedContent: "", joinPos: 0 }
   if (index <= 0 || index >= blocks.length) return result
   const current = blocks[index]
@@ -70,7 +70,7 @@ export function mergePrevious(blocks: EditorBlock[], index: number): MergeResult
  * Delete the block at `index`. Returns the ID of the block to focus next.
  * Does nothing if there is only one block.
  */
-export function deleteIfEmpty(blocks: EditorBlock[], index: number): DeleteResult {
+export function deleteIfEmpty(blocks: EditorBlockData[], index: number): DeleteResult {
   const result: DeleteResult = { blocks: [...blocks], nextId: null }
   if (blocks.length <= 1) return result
   if (index < 0 || index >= blocks.length) return result
@@ -88,7 +88,7 @@ export function deleteIfEmpty(blocks: EditorBlock[], index: number): DeleteResul
  * Indent the block at `index` and its children (blocks at higher depth
  * immediately following it).
  */
-export function indentBlock(blocks: EditorBlock[], index: number): IndentResult {
+export function indentBlock(blocks: EditorBlockData[], index: number): IndentResult {
   if (index < 0 || index >= blocks.length) return { blocks: [...blocks], childIndices: [] }
   const block = blocks[index]
   if (!block || block.depth >= 10) return { blocks: [...blocks], childIndices: [] }
@@ -114,7 +114,7 @@ export function indentBlock(blocks: EditorBlock[], index: number): IndentResult
 /**
  * Outdent the block at `index` and its children.
  */
-export function outdentBlock(blocks: EditorBlock[], index: number): IndentResult {
+export function outdentBlock(blocks: EditorBlockData[], index: number): IndentResult {
   if (index < 0 || index >= blocks.length) return { blocks: [...blocks], childIndices: [] }
   const block = blocks[index]
   if (!block || block.depth <= 0) return { blocks: [...blocks], childIndices: [] }
@@ -141,13 +141,13 @@ export function outdentBlock(blocks: EditorBlock[], index: number): IndentResult
  * Insert a new block at `index`.
  */
 export function insertBlock(
-  blocks: EditorBlock[],
+  blocks: EditorBlockData[],
   index: number,
   content: string,
   depth: number,
   idFn: () => string = generateId,
-): { blocks: EditorBlock[]; newBlock: EditorBlock } {
-  const newBlock: EditorBlock = { id: idFn(), depth, content }
+): { blocks: EditorBlockData[]; newBlock: EditorBlockData } {
+  const newBlock: EditorBlockData = { id: idFn(), depth, content }
   const newBlocks = [...blocks]
   newBlocks.splice(index, 0, newBlock)
   return { blocks: newBlocks, newBlock }
@@ -157,9 +157,9 @@ export function insertBlock(
  * Set the content of a block by ID.
  */
 export function setBlockContent(
-  blocks: EditorBlock[],
+  blocks: EditorBlockData[],
   blockId: string,
   newContent: string,
-): EditorBlock[] {
+): EditorBlockData[] {
   return blocks.map((b) => (b.id === blockId ? { ...b, content: newContent } : { ...b }))
 }