Browse Source

feat(editor): add multi-block Editor shell

- block-parser: split/serialize markdown with Lezer, preserve IDs
- useFocusRegistry: ID-based focus management for block navigation
- Block: integrate with focus registry
- dev: add /editor-shell demo page
Zander Hawke 6 days ago
parent
commit
4cbc937443

+ 1 - 0
apps/dev/src/App.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
 const navigationItems = [
   { label: "Overview", icon: "i-lucide-book-open", to: "/" },
+  { label: "Editor Shell", icon: "i-lucide-layers", to: "/editor-shell" },
   { label: "Basic Editing", icon: "i-lucide-type", to: "/basic-editing" },
   { label: "Inline Marks", icon: "i-lucide-bold", to: "/inline-marks" },
   { label: "Tasks & Priorities", icon: "i-lucide-check-square", to: "/tasks" },

+ 2 - 0
apps/dev/src/main.ts

@@ -5,6 +5,7 @@ import App from "~/App.vue"
 import BasicEditing from "~/pages/basic-editing.vue"
 import Blockquotes from "~/pages/blockquotes.vue"
 import CodeBlocks from "~/pages/code-blocks.vue"
+import EditorShell from "~/pages/editor-shell.vue"
 import IndexPage from "~/pages/index.vue"
 import InlineMarks from "~/pages/inline-marks.vue"
 import Links from "~/pages/links.vue"
@@ -17,6 +18,7 @@ import "~/style.css"
 
 const routes = [
   { path: "/", component: IndexPage },
+  { path: "/editor-shell", component: EditorShell },
   { path: "/basic-editing", component: BasicEditing },
   { path: "/inline-marks", component: InlineMarks },
   { path: "/tasks", component: Tasks },

+ 66 - 0
apps/dev/src/pages/editor-shell.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+import { Editor } from "@enesis/editor"
+import { ref, watch } from "vue"
+
+const content = ref(`* TODO Build the Editor shell
+  * Phase 1: Block parser
+  * Phase 2: Test edge cases
+  * Phase 3: ID preservation
+  * Phase 4: Focus registry
+  * Phase 5: Wire navigation events
+  * Phase 6: InsertionZone component
+* LATER Add drag-and-drop support
+  * [#A] Research libraries
+  * Design drop targets
+* DOING Implement toolbar integration [#B]
+* DONE Create demo pages`)
+
+const serialized = ref("")
+
+watch(content, (val) => {
+  serialized.value = val
+})
+</script>
+
+<template>
+  <UPage>
+    <UPageHeader
+      title="Editor Shell"
+      description="Multi-block document editor with navigation, split/merge, and task state support."
+    />
+
+    <UPageBody>
+      <div class="space-y-8">
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Multi-Block Document</h2>
+          <p class="text-sm text-muted leading-relaxed">
+            The <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Editor</code> component manages a list of blocks, each one a <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">* </code> list item.
+            Use <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Enter</kbd> to split, <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Backspace</kbd> at start to merge,
+            <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Tab</kbd> / <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Shift+Tab</kbd> to change depth.
+          </p>
+
+          <div class="rounded-lg border border-default bg-elevated overflow-hidden">
+            <div class="p-4">
+              <Editor
+                v-model:content="content"
+                :focused="true"
+                marker-mode="always-visible"
+              />
+            </div>
+          </div>
+        </section>
+
+        <section class="space-y-4">
+          <h2 class="text-lg font-semibold text-highlighted">Serialized Output</h2>
+          <p class="text-xs text-muted leading-relaxed">
+            The <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">v-model:content</code> binding updates with the full serialized markdown.
+            Try splitting, merging, or reordering blocks above.
+          </p>
+          <div class="rounded-lg border border-default bg-elevated overflow-hidden">
+            <pre class="p-4 overflow-x-auto text-xs leading-relaxed font-mono text-muted"><code>{{ serialized }}</code></pre>
+          </div>
+        </section>
+      </div>
+    </UPageBody>
+  </UPage>
+</template>

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

@@ -16,6 +16,7 @@ import {
   type CodeBlockNavigationDelegate,
   CodeBlockView,
 } from "@/composables/useCodeBlockView"
+import type { FocusRegistry } from "@/composables/useFocusRegistry"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
@@ -62,10 +63,12 @@ import { createLogger } from "@/lib/logger"
 const content = defineModel<string>("content")
 
 const props = defineProps<{
+  id?: string
   focused?: boolean
   cursorPosition?: "start" | "end" | number
   markerMode?: MarkerVisibilityMode
   debug?: string
+  registry?: FocusRegistry
 }>()
 
 const emit = defineEmits<{
@@ -477,6 +480,9 @@ onMounted(() => {
         if (view) {
           view.dispatch(view.state.tr.setMeta("decorations:focus", true))
           emit("focus", { view, handlers: formattingHandlers })
+          if (props.id && props.registry) {
+            props.registry.notifyFocused(props.id)
+          }
         }
         return false
       },
@@ -494,9 +500,19 @@ onMounted(() => {
     view.dispatch(view.state.tr.setMeta("decorations:focus", true))
     focusAt(props.cursorPosition)
   }
+
+  if (props.id && props.registry) {
+    props.registry.register(props.id, {
+      focus: focusAt,
+      element: mountEl ?? undefined,
+    })
+  }
 })
 
 onUnmounted(() => {
+  if (props.id && props.registry) {
+    props.registry.unregister(props.id)
+  }
   if (view) {
     view.destroy()
     view = null

+ 147 - 0
packages/editor/src/components/Editor.vue

@@ -0,0 +1,147 @@
+<script setup lang="ts">
+import { nextTick, ref, watch } from "vue"
+import Block from "@/components/Block.vue"
+import { useFocusRegistry } from "@/composables/useFocusRegistry"
+import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
+import {
+  type EditorBlock,
+  serializeBlocks,
+  splitMarkdownIntoBlocks,
+} from "@/lib/block-parser"
+
+const content = defineModel<string>("content", { required: false })
+
+const props = withDefaults(
+  defineProps<{
+    focused?: boolean
+    markerMode?: MarkerVisibilityMode
+    debug?: string
+  }>(),
+  {
+    focused: false,
+  },
+)
+
+// Whether the last update came from inside (block edit) vs outside (parent set).
+let updatingInternally = false
+
+const blocks = ref<EditorBlock[]>([])
+
+const registry = useFocusRegistry()
+
+function parseContent(md: string | undefined) {
+  blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
+}
+
+// Sync: parent content → blocks (external updates only)
+watch(
+  () => content.value,
+  (val) => {
+    if (updatingInternally) {
+      updatingInternally = false
+      return
+    }
+    parseContent(val)
+  },
+  { immediate: true },
+)
+
+// Serialize and emit when any block's content changes.
+function onBlockContentChange() {
+  updatingInternally = true
+  content.value = serializeBlocks(blocks.value)
+}
+
+// ── Block event handlers ──────────────────────────────────────────────
+
+function onSplit(index: number, before: string, after: string) {
+  const current = blocks.value[index]
+  if (!current) return
+  current.content = before
+
+  const newBlock: EditorBlock = {
+    id: crypto.randomUUID(),
+    depth: current.depth,
+    content: after,
+  }
+  blocks.value.splice(index + 1, 0, newBlock)
+  onBlockContentChange()
+  nextTick(() => registry.focus(newBlock.id, "start"))
+}
+
+function onMergePrevious(index: number) {
+  if (index <= 0) return
+  const current = blocks.value[index]
+  const prev = blocks.value[index - 1]
+  if (!current || !prev) return
+  prev.content = prev.content
+    ? `${prev.content}\n${current.content}`
+    : current.content
+  blocks.value.splice(index, 1)
+  onBlockContentChange()
+  nextTick(() => registry.focus(prev.id, "end"))
+}
+
+function onDeleteIfEmpty(index: number) {
+  if (blocks.value.length <= 1) return
+  const nextId =
+    index < blocks.value.length - 1
+      ? blocks.value[index + 1]?.id
+      : blocks.value[index - 1]?.id
+  if (!nextId) return
+  blocks.value.splice(index, 1)
+  onBlockContentChange()
+  nextTick(() => registry.focus(nextId, "start"))
+}
+
+function onArrowUpFromStart(index: number) {
+  if (index <= 0) return
+  const prev = blocks.value[index - 1]
+  if (!prev) return
+  registry.focus(prev.id, "end")
+}
+
+function onArrowDownFromEnd(index: number) {
+  if (index >= blocks.value.length - 1) return
+  const next = blocks.value[index + 1]
+  if (!next) return
+  registry.focus(next.id, "start")
+}
+
+function onIndent(index: number) {
+  const block = blocks.value[index]
+  if (!block) return
+  block.depth = Math.min(block.depth + 1, 10)
+  onBlockContentChange()
+}
+
+function onOutdent(index: number) {
+  const block = blocks.value[index]
+  if (!block) return
+  block.depth = Math.max(block.depth - 1, 0)
+  onBlockContentChange()
+}
+</script>
+
+<template>
+  <div class="editor-shell">
+    <Block
+      v-for="(block, i) in blocks"
+      :key="block.id"
+      :id="block.id"
+      v-model:content="block.content"
+      :focused="focused && i === 0"
+      :marker-mode="markerMode"
+      :debug="debug"
+      :registry="registry"
+      @update:content="onBlockContentChange"
+      @split="(before, after) => onSplit(i, before, after)"
+      @merge-previous="onMergePrevious(i)"
+      @delete-if-empty="onDeleteIfEmpty(i)"
+      @indent="onIndent(i)"
+      @outdent="onOutdent(i)"
+      @arrow-up-from-start="onArrowUpFromStart(i)"
+      @arrow-down-from-end="onArrowDownFromEnd(i)"
+    />
+  </div>
+</template>

+ 169 - 0
packages/editor/src/composables/__tests__/useFocusRegistry.test.ts

@@ -0,0 +1,169 @@
+import { describe, expect, it, vi } from "vitest"
+import { useFocusRegistry } from "@/composables/useFocusRegistry"
+
+describe("useFocusRegistry", () => {
+  it("starts with no focused block", () => {
+    const reg = useFocusRegistry()
+    expect(reg.focusedId.value).toBeNull()
+  })
+
+  it("register and unregister a handle", () => {
+    const reg = useFocusRegistry()
+    const focus = vi.fn()
+    reg.register("a", { focus })
+    expect(reg.focus("a")).toBe(true)
+    expect(focus).toHaveBeenCalledTimes(1)
+
+    reg.unregister("a")
+    expect(reg.focus("a")).toBe(false)
+  })
+
+  it("notifyFocused updates focusedId", () => {
+    const reg = useFocusRegistry()
+    reg.register("a", { focus: () => {} })
+    reg.notifyFocused("a")
+    expect(reg.focusedId.value).toBe("a")
+  })
+
+  it("focus calls handle and sets focusedId", () => {
+    const reg = useFocusRegistry()
+    const focus = vi.fn()
+    reg.register("b", { focus })
+
+    const result = reg.focus("b", "end")
+    expect(result).toBe(true)
+    expect(focus).toHaveBeenCalledWith("end")
+    expect(reg.focusedId.value).toBe("b")
+  })
+
+  it("focus returns false for unknown id", () => {
+    const reg = useFocusRegistry()
+    expect(reg.focus("nonexistent")).toBe(false)
+  })
+
+  it("unregister clears focusedId if it was that block", () => {
+    const reg = useFocusRegistry()
+    reg.register("x", { focus: () => {} })
+    reg.notifyFocused("x")
+    expect(reg.focusedId.value).toBe("x")
+    reg.unregister("x")
+    expect(reg.focusedId.value).toBeNull()
+  })
+
+  describe("navigation", () => {
+    it("focusFirst focuses the first registered block", () => {
+      const reg = useFocusRegistry()
+      const a = vi.fn()
+      const b = vi.fn()
+      reg.register("a", { focus: a })
+      reg.register("b", { focus: b })
+
+      expect(reg.focusFirst()).toBe(true)
+      expect(a).toHaveBeenCalledTimes(1)
+      expect(reg.focusedId.value).toBe("a")
+    })
+
+    it("focusFirst returns false when no blocks", () => {
+      expect(useFocusRegistry().focusFirst()).toBe(false)
+    })
+
+    it("focusLast focuses the last registered block", () => {
+      const reg = useFocusRegistry()
+      const a = vi.fn()
+      const b = vi.fn()
+      reg.register("a", { focus: a })
+      reg.register("b", { focus: b })
+
+      expect(reg.focusLast()).toBe(true)
+      expect(b).toHaveBeenCalledTimes(1)
+      expect(reg.focusedId.value).toBe("b")
+    })
+
+    it("focusNext goes to next block in order", () => {
+      const reg = useFocusRegistry()
+      reg.register("a", { focus: () => {} })
+      reg.register("b", { focus: () => {} })
+      reg.register("c", { focus: () => {} })
+      reg.notifyFocused("a")
+
+      expect(reg.focusNext()).toBe(true)
+      expect(reg.focusedId.value).toBe("b")
+
+      expect(reg.focusNext()).toBe(true)
+      expect(reg.focusedId.value).toBe("c")
+    })
+
+    it("focusNext wraps to first block", () => {
+      const reg = useFocusRegistry()
+      const a = vi.fn()
+      reg.register("a", { focus: a })
+      reg.register("b", { focus: () => {} })
+      reg.notifyFocused("b")
+
+      expect(reg.focusNext()).toBe(true)
+      expect(a).toHaveBeenCalledTimes(1)
+      expect(reg.focusedId.value).toBe("a")
+    })
+
+    it("focusNext with no focusedId goes to first", () => {
+      const reg = useFocusRegistry()
+      reg.register("a", { focus: () => {} })
+      reg.register("b", { focus: () => {} })
+
+      expect(reg.focusNext()).toBe(true)
+      expect(reg.focusedId.value).toBe("a")
+    })
+
+    it("focusPrevious goes to previous block in order", () => {
+      const reg = useFocusRegistry()
+      reg.register("a", { focus: () => {} })
+      reg.register("b", { focus: () => {} })
+      reg.register("c", { focus: () => {} })
+      reg.notifyFocused("c")
+
+      expect(reg.focusPrevious()).toBe(true)
+      expect(reg.focusedId.value).toBe("b")
+
+      expect(reg.focusPrevious()).toBe(true)
+      expect(reg.focusedId.value).toBe("a")
+    })
+
+    it("focusPrevious wraps to last block", () => {
+      const reg = useFocusRegistry()
+      reg.register("a", { focus: () => {} })
+      const b = vi.fn()
+      reg.register("b", { focus: b })
+      reg.notifyFocused("a")
+
+      expect(reg.focusPrevious()).toBe(true)
+      expect(b).toHaveBeenCalledTimes(1)
+      expect(reg.focusedId.value).toBe("b")
+    })
+
+    it("focusPrevious with no focusedId goes to last", () => {
+      const reg = useFocusRegistry()
+      reg.register("a", { focus: () => {} })
+      reg.register("b", { focus: () => {} })
+
+      expect(reg.focusPrevious()).toBe(true)
+      expect(reg.focusedId.value).toBe("b")
+    })
+  })
+
+  it("register does not duplicate ids", () => {
+    const reg = useFocusRegistry()
+    reg.register("a", { focus: () => {} })
+    reg.register("a", { focus: () => {} })
+    // After registering the same id twice, focusFirst should still work
+    expect(reg.focusFirst()).toBe(true)
+  })
+
+  it("element is stored and accessible via focus", () => {
+    const reg = useFocusRegistry()
+    const el = document.createElement("div")
+    const focus = vi.fn()
+    reg.register("el", { focus, element: el })
+    expect(reg.focus("el")).toBe(true)
+    expect(focus).toHaveBeenCalledWith(undefined)
+  })
+})

+ 98 - 0
packages/editor/src/composables/useFocusRegistry.ts

@@ -0,0 +1,98 @@
+import { type Ref, ref } from "vue"
+
+export interface FocusRegistryHandle {
+  focus: (pos?: "start" | "end" | number) => void
+  element?: HTMLElement
+}
+
+export interface FocusRegistry {
+  focusedId: Ref<string | null>
+  notifyFocused: (id: string) => void
+  focus: (id: string, pos?: "start" | "end" | number) => boolean
+  focusFirst: () => boolean
+  focusLast: () => boolean
+  focusNext: () => boolean
+  focusPrevious: () => boolean
+  register: (id: string, handle: FocusRegistryHandle) => void
+  unregister: (id: string) => void
+}
+
+export function useFocusRegistry(): FocusRegistry {
+  const handles = new Map<string, FocusRegistryHandle>()
+  const ids: string[] = []
+  const focusedId = ref<string | null>(null)
+
+  function register(id: string, handle: FocusRegistryHandle) {
+    handles.set(id, handle)
+    if (!ids.includes(id)) {
+      ids.push(id)
+    }
+  }
+
+  function unregister(id: string) {
+    handles.delete(id)
+    const idx = ids.indexOf(id)
+    if (idx !== -1) ids.splice(idx, 1)
+    if (focusedId.value === id) {
+      focusedId.value = null
+    }
+  }
+
+  function notifyFocused(id: string) {
+    focusedId.value = id
+  }
+
+  function focus(id: string, pos?: "start" | "end" | number): boolean {
+    const handle = handles.get(id)
+    if (!handle) return false
+    handle.focus(pos)
+    focusedId.value = id
+    return true
+  }
+
+  function focusFirst(): boolean {
+    if (ids.length === 0) return false
+    const first = ids[0]
+    if (!first) return false
+    return focus(first)
+  }
+
+  function focusLast(): boolean {
+    if (ids.length === 0) return false
+    const last = ids[ids.length - 1]
+    if (!last) return false
+    return focus(last)
+  }
+
+  function focusPrevious(): boolean {
+    const current = focusedId.value
+    if (current === null) return focusLast()
+    const idx = ids.indexOf(current)
+    if (idx <= 0) return focusLast()
+    const prev = ids[idx - 1]
+    if (!prev) return focusLast()
+    return focus(prev)
+  }
+
+  function focusNext(): boolean {
+    const current = focusedId.value
+    if (current === null) return focusFirst()
+    const idx = ids.indexOf(current)
+    if (idx === -1 || idx >= ids.length - 1) return focusFirst()
+    const next = ids[idx + 1]
+    if (!next) return focusFirst()
+    return focus(next)
+  }
+
+  return {
+    focusedId,
+    notifyFocused,
+    focus,
+    focusFirst,
+    focusLast,
+    focusNext,
+    focusPrevious,
+    register,
+    unregister,
+  }
+}

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

@@ -1,12 +1,14 @@
 import type { App } from "vue"
 import Block from "@/components/Block.vue"
+import Editor from "@/components/Editor.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
 
 import "@/assets/style.css"
 
-export type { MarkdownPattern } from "@/lib/markdown-extensions"
+export type { EditorBlock } from "@/lib/block-parser"
 export type { FormattingHandlers } from "@/lib/formatting"
-export { Block, EditorToolbar }
+export type { MarkdownPattern } from "@/lib/markdown-extensions"
+export { Block, Editor, EditorToolbar }
 
 export default {
   install(app: App) {

+ 327 - 0
packages/editor/src/lib/__tests__/block-parser.test.ts

@@ -0,0 +1,327 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import {
+  type EditorBlock,
+  serializeBlocks,
+  splitMarkdownIntoBlocks,
+} from "@/lib/block-parser"
+
+// Deterministic ID generator for testing.
+let nextId = 0
+function testId(): string {
+  return String(nextId++)
+}
+
+beforeEach(() => {
+  nextId = 0
+})
+
+function parse(md: string): EditorBlock[] {
+  return splitMarkdownIntoBlocks(md, testId)
+}
+
+describe("splitMarkdownIntoBlocks", () => {
+  it("single block", () => {
+    const result = parse("* TODO my item")
+    expect(result).toEqual([{ id: "0", depth: 0, content: "TODO my item" }])
+  })
+
+  it("multiple blocks", () => {
+    const result = parse("* Item one\n* Item two")
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "Item one" },
+      { id: "1", depth: 0, content: "Item two" },
+    ])
+  })
+
+  it("nested blocks", () => {
+    const result = parse("* Root\n  * Child\n* Another")
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "Root" },
+      { id: "1", depth: 1, content: "Child" },
+      { id: "2", depth: 0, content: "Another" },
+    ])
+  })
+
+  it("deep nesting", () => {
+    const result = parse("* A\n  * B\n    * C\n  * D\n* E")
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "A" },
+      { id: "1", depth: 1, content: "B" },
+      { id: "2", depth: 2, content: "C" },
+      { id: "3", depth: 1, content: "D" },
+      { id: "4", depth: 0, content: "E" },
+    ])
+  })
+
+  it("continuation lines without blank lines", () => {
+    const result = parse("* Item with\n  continuation")
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toBe("Item with\n  continuation")
+  })
+
+  it("continuation lines preserve indentation", () => {
+    const result = parse("* TODO my item\n  gotta do the thing")
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toBe("TODO my item\n  gotta do the thing")
+  })
+
+  it("code fence inside a block", () => {
+    const result = parse(
+      "* A note\n  ```ts\n  const x = 1\n  const y = 2\n  ```",
+    )
+    expect(result).toHaveLength(1)
+    expect(result[0].depth).toBe(0)
+    expect(result[0].content).toContain("```ts")
+    expect(result[0].content).toContain("const x = 1")
+    expect(result[0].content).toContain("const y = 2")
+  })
+
+  it("blank line inside fence inside a block", () => {
+    const md = "* A note\n  ```ts\n  const x = 1\n\n  const y = 2\n  ```"
+    const result = parse(md)
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toContain("\n\n")
+  })
+
+  it("two fences in one block", () => {
+    const md =
+      "* Thoughts\n  ```ts\n  const x = 1\n  ```\n  and also\n  ```css\n  .a { color: red }\n  ```"
+    const result = parse(md)
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toMatch(/```ts/)
+    expect(result[0].content).toMatch(/```css/)
+  })
+
+  it("tilde fence inside a block", () => {
+    const result = parse("* Using tilde\n  ~~~\n  code\n  ~~~")
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toContain("~~~")
+    expect(result[0].content).toContain("code")
+  })
+
+  it("nested item with fence", () => {
+    const result = parse(
+      "* Root\n  * Child with\n    ```py\n    pass\n    ```\n* Another",
+    )
+    expect(result).toHaveLength(3)
+    expect(result[0]).toEqual({
+      id: "0",
+      depth: 0,
+      content: "Root",
+    })
+    expect(result[1].depth).toBe(1)
+    expect(result[1].content).toContain("Child with")
+    expect(result[1].content).toContain("```py")
+    expect(result[2]).toEqual({ id: "2", depth: 0, content: "Another" })
+  })
+
+  it("multiple blank lines between items", () => {
+    const md = "* One\n\n\n* Two"
+    const result = parse(md)
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "One" },
+      { id: "1", depth: 0, content: "Two" },
+    ])
+  })
+
+  it("blank line between items", () => {
+    const md = "* One\n\n* Two"
+    const result = parse(md)
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "One" },
+      { id: "1", depth: 0, content: "Two" },
+    ])
+  })
+
+  it("blank line between non-fence content splits list item (GFM behavior)", () => {
+    // In GFM, a blank line between non-fence content within a list item
+    // terminates the item. "Continuation" becomes a standalone paragraph
+    // outside the list and is NOT captured as a block.
+    const result = parse("* One\n\nContinuation\n* Two")
+    expect(result).toHaveLength(2)
+    expect(result[0].content).toBe("One")
+    expect(result[1].content).toBe("Two")
+  })
+
+  it("empty block between non-empty blocks", () => {
+    const result = parse("* \n* Item")
+    expect(result).toHaveLength(2)
+    expect(result[0].content).toBe("")
+    expect(result[1].content).toBe("Item")
+  })
+
+  it("task state in block content", () => {
+    const result = parse("* TODO my first item\n* DONE already completed")
+    expect(result[0].content).toBe("TODO my first item")
+    expect(result[1].content).toBe("DONE already completed")
+  })
+
+  it("block with inline math", () => {
+    const result = parse("* Solve $x^2 + y^2 = z^2$")
+    expect(result[0].content).toContain("$x^2 + y^2 = z^2$")
+  })
+
+  it("empty document", () => {
+    expect(parse("")).toEqual([])
+    expect(parse("  ")).toEqual([])
+  })
+
+  it("empty block", () => {
+    const result = parse("* ")
+    expect(result).toHaveLength(1)
+    expect(result[0].content).toBe("")
+    expect(result[0].depth).toBe(0)
+  })
+
+  it("single newline only", () => {
+    expect(parse("\n")).toEqual([])
+  })
+
+  it("block with leading whitespace in content", () => {
+    // Content after "* " should preserve its own leading whitespace
+    const result = parse("*   indented content")
+    expect(result[0].content).toBe("  indented content")
+  })
+})
+
+describe("ID preservation", () => {
+  it("preserves IDs by position when existing blocks provided", () => {
+    const existing: EditorBlock[] = [
+      { id: "keep-a", depth: 0, content: "old one" },
+      { id: "keep-b", depth: 0, content: "old two" },
+    ]
+    const result = splitMarkdownIntoBlocks(
+      "* new one\n* new two",
+      testId,
+      existing,
+    )
+    expect(result).toHaveLength(2)
+    expect(result[0].id).toBe("keep-a")
+    expect(result[1].id).toBe("keep-b")
+  })
+
+  it("generates new IDs for surplus blocks beyond existing", () => {
+    const existing: EditorBlock[] = [{ id: "keep-a", depth: 0, content: "old" }]
+    const result = splitMarkdownIntoBlocks(
+      "* one\n* two\n* three",
+      testId,
+      existing,
+    )
+    expect(result).toHaveLength(3)
+    expect(result[0].id).toBe("keep-a")
+    expect(result[1].id).toBe("0") // new, from testId counter
+    expect(result[2].id).toBe("1")
+  })
+
+  it("ignores existing IDs beyond parsed block count", () => {
+    // When there are fewer blocks after re-parse, the extra existing IDs are dropped
+    const existing: EditorBlock[] = [
+      { id: "keep-a", depth: 0, content: "old one" },
+      { id: "dropped-b", depth: 0, content: "old two" },
+    ]
+    const result = splitMarkdownIntoBlocks("* only one", testId, existing)
+    expect(result).toHaveLength(1)
+    expect(result[0].id).toBe("keep-a")
+  })
+
+  it("content and depth are always from the parsed input, not existing", () => {
+    const existing: EditorBlock[] = [
+      { id: "keep", depth: 99, content: "old content" },
+    ]
+    const result = splitMarkdownIntoBlocks("* new content", testId, existing)
+    expect(result).toHaveLength(1)
+    expect(result[0].id).toBe("keep")
+    expect(result[0].content).toBe("new content")
+    expect(result[0].depth).toBe(0)
+  })
+})
+
+describe("serializeBlocks", () => {
+  it("single block", () => {
+    const md = serializeBlocks([{ id: "0", depth: 0, content: "TODO my item" }])
+    expect(md).toBe("* TODO my item")
+  })
+
+  it("multiple blocks", () => {
+    const md = serializeBlocks([
+      { id: "0", depth: 0, content: "Item one" },
+      { id: "1", depth: 0, content: "Item two" },
+    ])
+    expect(md).toBe("* Item one\n* Item two")
+  })
+
+  it("nested blocks", () => {
+    const md = serializeBlocks([
+      { id: "0", depth: 0, content: "Root" },
+      { id: "1", depth: 1, content: "Child" },
+      { id: "2", depth: 0, content: "Another" },
+    ])
+    expect(md).toBe("* Root\n  * Child\n* Another")
+  })
+})
+
+describe("round-trip", () => {
+  it("lossless for simple blocks", () => {
+    const md = "* Item one\n* Item two"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for nested blocks", () => {
+    const md = "* Root\n  * Child\n    * Grandchild\n  * Another\n* End"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for block with code fence", () => {
+    const md = "* A note\n  ```ts\n  const x = 1\n  ```"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for continuation lines", () => {
+    const md = "* Item with\n  continuation"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for blank lines inside fence", () => {
+    const md = "* A note\n  ```ts\n  const x = 1\n\n  const y = 2\n  ```"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for two fences in one block", () => {
+    const md =
+      "* Thoughts\n  ```ts\n  const x = 1\n  ```\n  and also\n  ```css\n  .a { color: red }\n  ```"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for tilde fence", () => {
+    const md = "* Using tilde\n  ~~~\n  code\n  ~~~"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for nested item with fence", () => {
+    const md = "* Root\n  * Child with\n    ```py\n    pass\n    ```\n* Another"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for task states", () => {
+    const md = "* TODO first\n* DONE second\n* LATER third"
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("lossless for empty blocks", () => {
+    const md = "* \n* Item\n* "
+    expect(serializeBlocks(parse(md))).toBe(md)
+  })
+
+  it("blank lines between items are normalized away on serialize", () => {
+    // Blank lines between items are consumed by the parser — they
+    // don't affect block content. The re-serialized output normalizes
+    // them away, which is intentional.
+    const md = "* One\n\n* Two"
+    const result = parse(md)
+    expect(result).toEqual([
+      { id: "0", depth: 0, content: "One" },
+      { id: "1", depth: 0, content: "Two" },
+    ])
+    expect(serializeBlocks(result)).toBe("* One\n* Two")
+  })
+})

+ 98 - 0
packages/editor/src/lib/block-parser.ts

@@ -0,0 +1,98 @@
+import type { SyntaxNode } from "@lezer/common"
+import { GFM, parser } from "@lezer/markdown"
+import { createMarkdownExtensions } from "@/lib/markdown-extensions"
+
+export interface EditorBlock {
+  id: string
+  depth: number
+  content: string
+}
+
+const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
+
+/**
+ * Find the start of the line containing `pos` in `md`.
+ */
+function lineStart(md: string, pos: number): number {
+  const idx = md.lastIndexOf("\n", pos - 1)
+  return idx === -1 ? 0 : idx + 1
+}
+
+/**
+ * Given a ListItem SyntaxNode, find where its "own content" ends —
+ * i.e., the start of any nested BulletList/List child.
+ * If there's no nested list, the content spans the entire node.
+ */
+function ownContentEnd(node: SyntaxNode): number {
+  let child = node.firstChild
+  while (child) {
+    if (
+      child.type.name === "BulletList" ||
+      child.type.name === "List" ||
+      child.type.name === "OrderedList"
+    ) {
+      return child.from
+    }
+    child = child.nextSibling
+  }
+  return node.to
+}
+
+/**
+ * Parse markdown into an array of editor blocks.
+ *
+ * Each block corresponds to one ListItem in the Lezer AST.
+ * Depth is computed from the indentation before `*`.
+ * Content is everything after `* `, excluding nested child list items.
+ *
+ * If `existing` is provided, block IDs are preserved by position (matching
+ * parsed index to `existing[i].id`). New IDs are generated for any surplus
+ * parsed blocks. This prevents unnecessary Block remounts when content
+ * changes externally but structural identity is unchanged.
+ */
+export function splitMarkdownIntoBlocks(
+  md: string,
+  idFn: () => string = crypto.randomUUID.bind(crypto),
+  existing?: Pick<EditorBlock, "id">[],
+): EditorBlock[] {
+  if (!md.trim()) return []
+
+  const tree = markdownParser.parse(md)
+  const cursor = tree.cursor()
+
+  // Collect all ListItem SyntaxNodes.
+  const listItems: SyntaxNode[] = []
+  do {
+    if (cursor.type.name === "ListItem") {
+      listItems.push(cursor.node)
+    }
+  } while (cursor.next())
+
+  // Sort by position
+  listItems.sort((a, b) => a.from - b.from)
+
+  return listItems.map((node, i) => {
+    const leadingSpaces = node.from - lineStart(md, node.from)
+    const depth = leadingSpaces / 2
+    const end = ownContentEnd(node)
+
+    // Content: skip "* " (2 chars) after the bullet
+    const contentStart = node.from + 2
+    const content = md.slice(contentStart, end)
+
+    return {
+      id: existing?.[i]?.id ?? idFn(),
+      depth,
+      content: content.trimEnd(),
+    }
+  })
+}
+
+/**
+ * Serialize an array of editor blocks back to markdown.
+ */
+export function serializeBlocks(blocks: EditorBlock[]): string {
+  return blocks
+    .map((block) => `${"  ".repeat(block.depth)}* ${block.content}`)
+    .join("\n")
+}