Jelajahi Sumber

feat(editor): wire undo/redo history, embed block IDs, scope keybinding

- Add OperationHistory with computeInverse for split/merge/indent/
  outdent/insert/delete operations; cascade-aware children tracking
  for indent/outdent undo
- Fix split↔merge inverse to correctly restore content on undo
- Extract generateBlockId() and stampBlockId() — blocks get a
  persistent id:: property in markdown when referenced
- splitMarkdownIntoBlocks prefers embedded id:: → position match
  → fresh UUID, solving identity loss on external re-parse
- Scoped undo/redo keybinding (@keydown on shell div) replaces
  global document listener, preventing multi-editor conflicts
- Add InsertionZone.vue with hover/focus states, deferring block
  materialization until user types or pastes
- Fix depth-dots pointer-events-none (was invalid pointer-none)
- Replace fragile DOM child-index focusZone with zoneRefs array
- Expose canUndo/canRedo/undo/redo via toolbar slot scope
- Clear history on external v-model updates
- Support FOUC: editor CSS uses @import "tailwindcss", dev app uses
  @tailwindcss/vite + @source for HMR on editor source changes
Zander Hawke 5 hari lalu
induk
melakukan
a6d343a98b

+ 1 - 0
apps/dev/package.json

@@ -21,6 +21,7 @@
     "vue-router": "^5.0.7"
   },
   "devDependencies": {
+    "@tailwindcss/vite": "^4.3.0",
     "@types/node": "^24.12.3",
     "@vitejs/plugin-vue": "^6.0.6",
     "@vue/tsconfig": "^0.9.1",

+ 21 - 66
apps/dev/src/pages/toolbar.vue

@@ -1,44 +1,16 @@
 <script setup lang="ts">
-import { Block, EditorToolbar } from "@enesis/editor"
-import type { FormattingHandlers } from "@enesis/editor"
+import { Editor, EditorToolbar } from "@enesis/editor"
 import { ref } from "vue"
 
-const content = ref(`# Toolbar Demo
-
-This page shows the **EditorToolbar** integrated with a **Block** component.
-
-Try selecting text and clicking toolbar buttons — active state **updates** as the cursor moves.
-
-| Feature | Shortcut |
-|---|---|
-| Bold | **⌘B** |
-| Italic | *⌘I* |
-| Code | \`⌘\`\` |
-| Strikethrough | ~~⌘⇧S~~ |
-| Highlight | ^^⌘⇧H^^ |
-| Link | ⌘K |
-| Inline Math | ⌘M |
-
-You can also change the heading level or toggle task state from the toolbar.`)
-
-const handlers = ref<FormattingHandlers | null>(null)
-const selectionVersion = ref(0)
-
-function onFocus(payload: { handlers: FormattingHandlers }) {
-  handlers.value = payload.handlers
-}
-
-// Don't clear handlers on blur — the toolbar buttons need to
-// remain interactive when the editor loses focus (mousedown on a button
-// fires blur on the editor before the click event). Handlers are replaced
-// when a different Block emits @focus.
-function onBlur() {
-  // no-op
-}
-
-function onSelectionChange() {
-  selectionVersion.value++
-}
+const content =
+  ref(`* **Bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $math$ work inline
+* ## Heading 2
+* TODO This task can be toggled from the toolbar
+* [[Page refs]] and #tags are supported inline
+* | Feature | Shortcut |
+  |---|---|
+  | Bold | **⌘B** |
+  | Italic | *⌘I* |`)
 </script>
 
 <template>
@@ -53,24 +25,16 @@ function onSelectionChange() {
         <section class="space-y-4">
           <h2 class="text-lg font-semibold text-highlighted">Editor with Toolbar</h2>
           <p class="text-sm text-muted leading-relaxed">
-            The toolbar receives <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> from the Block's <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@focus</code> event.
-            Active state re-evaluates on <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@selection-change</code> via a <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code> counter.
+            Toolbar and editor are wired automatically via the <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">#toolbar</code> slot — no manual event handling needed.
+            The Editor provides <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> and <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code> to the slot scope.
           </p>
 
           <div class="flex flex-col gap-3 rounded-lg border border-default bg-elevated p-4">
-            <EditorToolbar
-              :handlers="handlers"
-              :selection-version="selectionVersion"
-            />
-            <Block
-              v-model:content="content"
-              :focused="true"
-              cursor-position="end"
-              marker-mode="always-visible"
-              @focus="onFocus"
-              @blur="onBlur"
-              @selection-change="onSelectionChange"
-            />
+            <Editor v-model:content="content" :focused="true" marker-mode="always-visible">
+              <template #toolbar="{ handlers, selectionVersion }">
+                <EditorToolbar :handlers="handlers" :selection-version="selectionVersion" />
+              </template>
+            </Editor>
           </div>
         </section>
 
@@ -87,24 +51,16 @@ function onSelectionChange() {
               <tbody>
                 <tr class="border-b border-default">
                   <td class="px-4 py-2 font-mono text-xs">1</td>
-                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@focus</code> with <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">{ view, handlers }</code></td>
+                  <td class="px-4 py-2 text-xs text-muted">Editor detects <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">#toolbar</code> slot and renders it above the block list</td>
                 </tr>
                 <tr class="border-b border-default">
                   <td class="px-4 py-2 font-mono text-xs">2</td>
-                  <td class="px-4 py-2 text-xs text-muted">Parent passes <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> to <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">EditorToolbar</code> prop</td>
+                  <td class="px-4 py-2 text-xs text-muted">Editor passes <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code> and <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code> from the focused Block into the slot</td>
                 </tr>
                 <tr class="border-b border-default">
                   <td class="px-4 py-2 font-mono text-xs">3</td>
-                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@selection-change</code> on cursor move — parent increments <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">selectionVersion</code></td>
-                </tr>
-                <tr class="border-b border-default">
-                  <td class="px-4 py-2 font-mono text-xs">4</td>
                   <td class="px-4 py-2 text-xs text-muted">Toolbar's <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">computed</code>s re-run, calling <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers.isActive(format)</code> for each button</td>
                 </tr>
-                <tr>
-                  <td class="px-4 py-2 font-mono text-xs">5</td>
-                  <td class="px-4 py-2 text-xs text-muted">Block emits <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">@blur</code> — parent does <strong>not</strong> clear handlers (toolbar must stay interactive while button click is in flight)</td>
-                </tr>
               </tbody>
             </table>
           </div>
@@ -113,9 +69,8 @@ function onSelectionChange() {
         <section class="space-y-4">
           <h2 class="text-lg font-semibold text-highlighted">Known Limitations</h2>
           <ul class="text-sm text-muted leading-relaxed space-y-2 list-disc list-inside">
-            <li>Reference insertion (Page Ref, Block Ref, Tag) uses <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">window.prompt()</code> — will be replaced with the pattern plugin's suggestion menu in Phase 8.</li>
-            <li>Undo/redo buttons are not implemented yet.</li>
-            <li>Single-block toolbar only — a cross-block toolbar (BlockList) will share state via <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">activeHandlers</code> in a future phase.</li>
+            <li>Reference insertion (Page Ref, Block Ref, Tag) uses <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">window.prompt()</code> — will be replaced with the pattern plugin's suggestion menu in a future phase.</li>
+            <li>Undo/redo (<kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">⌘Z</kbd> / <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">⌘⇧Z</kbd>) are wired via global keyboard shortcuts. <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">canUndo</code>, <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">canRedo</code>, <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">undo</code>, and <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">redo</code> are provided via the <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">#toolbar</code> slot scope.</li>
           </ul>
         </section>
       </div>

+ 2 - 1
apps/dev/src/style.css

@@ -1,6 +1,7 @@
 @import "tailwindcss";
 @import "@nuxt/ui";
-@import "@enesis/editor";
+@source "../../../packages/editor/src";
+@import "../../../packages/editor/src/assets/style.css";
 @import "katex/dist/katex.min.css";
 
 @theme static {

+ 2 - 0
apps/dev/vite.config.ts

@@ -1,4 +1,5 @@
 import { resolve } from "node:path"
+import tailwindcss from "@tailwindcss/vite"
 import ui from "@nuxt/ui/vite"
 import vue from "@vitejs/plugin-vue"
 import { defineConfig } from "vite"
@@ -9,6 +10,7 @@ export default defineConfig({
   base: process.env.BASE_URL || "/",
   plugins: [
     vue(),
+    tailwindcss(),
     ui({
       scanPackages: ["@enesis/editor"],
     }),

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

@@ -1,4 +1,4 @@
-@reference "tailwindcss";
+@import "tailwindcss";
 @reference "@nuxt/ui";
 
 /* ── Base Editor ─────────────────────────────────────────────────── */

+ 9 - 56
packages/editor/src/components/Block.vue

@@ -69,6 +69,7 @@ const props = defineProps<{
   markerMode?: MarkerVisibilityMode
   debug?: string
   registry?: FocusRegistry
+  onBoundaryExit?: (direction: "up" | "down") => void
 }>()
 
 const emit = defineEmits<{
@@ -303,38 +304,16 @@ onMounted(() => {
       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.
+      // Document boundary — delegate to Editor shell via onBoundaryExit.
       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())
-        }
+        log.info("requestNavigateOut — doc boundary up")
+        props.onBoundaryExit?.("up")
         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())
-        }
+        log.info("requestNavigateOut — doc boundary down")
+        props.onBoundaryExit?.("down")
         return
       }
 
@@ -352,35 +331,9 @@ onMounted(() => {
         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())
-        }
-      }
+      // No valid target in this document — delegate to Editor shell.
+      log.info("requestNavigateOut — no valid target, delegating boundary exit")
+      props.onBoundaryExit?.(direction)
     },
 
     requestRemoveBlock(nodePos) {

+ 416 - 31
packages/editor/src/components/Editor.vue

@@ -1,16 +1,35 @@
 <script setup lang="ts">
-import { nextTick, ref, watch } from "vue"
+import type { EditorView } from "prosemirror-view"
+import { nextTick, ref, useSlots, watch } from "vue"
 import Block from "@/components/Block.vue"
-import { useFocusRegistry } from "@/composables/useFocusRegistry"
+import InsertionZone from "@/components/InsertionZone.vue"
+import {
+  type FocusTarget,
+  useFocusRegistry,
+} from "@/composables/useFocusRegistry"
 import type { MarkerVisibilityMode } from "@/composables/useMarkdownDecorations"
 import {
   type EditorBlock,
+  generateBlockId,
   serializeBlocks,
   splitMarkdownIntoBlocks,
 } from "@/lib/block-parser"
+import type { FormattingHandlers } from "@/lib/formatting"
+import { createLogger } from "@/lib/logger"
+import {
+  createOperationHistory,
+  type EditorOperation,
+  type OperationHistory,
+} from "@/lib/operation-history"
 
 const content = defineModel<string>("content", { required: false })
 
+const emit = defineEmits<{
+  focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
+  blur: []
+  "selection-change": [payload: { from: number; to: number; empty: boolean }]
+}>()
+
 const props = withDefaults(
   defineProps<{
     focused?: boolean
@@ -29,11 +48,133 @@ const blocks = ref<EditorBlock[]>([])
 
 const registry = useFocusRegistry()
 
+const log = createLogger("Editor", props.debug)
+const slots = useSlots()
+
+const blockRefs: { setContent: (md: string) => void }[] = []
+const zoneRefs: HTMLElement[] = []
+
+function focusZone(index: number) {
+  const zone = zoneRefs[index]
+  if (!zone) return
+  zone.focus()
+}
+
+const editorShellRef = ref<HTMLElement | null>(null)
+
+// ── Undo / Redo history ──────────────────────────────────────────
+
+const history: OperationHistory = createOperationHistory(100)
+const canUndo = ref(false)
+const canRedo = ref(false)
+
+function applyOperation(op: EditorOperation): void {
+  switch (op.type) {
+    case "split-block": {
+      const block = blocks.value[op.index]
+      if (!block) return
+      block.content = op.beforeContent
+      blocks.value.splice(op.index + 1, 0, {
+        id: op.splitBlockId,
+        depth: op.depth,
+        content: op.afterContent,
+      })
+      break
+    }
+    case "merge-block": {
+      // Remove block at op.index and append its content to the previous block.
+      const prev = blocks.value[op.index - 1]
+      if (prev) {
+        prev.content = op.prevContent
+        prev.content += op.appendedContent
+      }
+      blocks.value.splice(op.index, 1)
+      break
+    }
+    case "insert-block":
+      blocks.value.splice(op.index, 0, {
+        id: op.blockId,
+        depth: op.depth,
+        content: op.content,
+      })
+      break
+    case "delete-block":
+      blocks.value.splice(op.index, 1)
+      break
+    case "indent-block": {
+      const indentBlock = blocks.value[op.index]
+      if (indentBlock) indentBlock.depth = Math.min(op.previousDepth + 1, 10)
+      if (op.childrenPreviousDepths) {
+        for (const c of op.childrenPreviousDepths) {
+          const child = blocks.value[c.index]
+          if (child) child.depth = Math.min(c.previousDepth + 1, 10)
+        }
+      }
+      break
+    }
+    case "outdent-block": {
+      const outdentBlock = blocks.value[op.index]
+      if (outdentBlock) outdentBlock.depth = Math.max(op.previousDepth - 1, 0)
+      if (op.childrenPreviousDepths) {
+        for (const c of op.childrenPreviousDepths) {
+          const child = blocks.value[c.index]
+          if (child) child.depth = Math.max(c.previousDepth - 1, 0)
+        }
+      }
+      break
+    }
+    case "set-block-content": {
+      const setBlock = blocks.value.find((b) => b.id === op.blockId)
+      if (setBlock) setBlock.content = op.newContent
+      break
+    }
+  }
+  onBlockContentChange()
+}
+
+function undo() {
+  const inverse = history.undo()
+  if (!inverse) return
+  applyOperation(inverse)
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+}
+
+function redo() {
+  const op = history.redo()
+  if (!op) return
+  applyOperation(op)
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+}
+
+// Undo/redo keyboard shortcuts, scoped to the editor shell.
+// Uses @keydown on the root div so that only focus within the editor
+// triggers the shortcuts, avoiding conflicts with other editors or
+// modals on the same page.
+function onShellKeydown(e: KeyboardEvent) {
+  if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
+    e.preventDefault()
+    undo()
+    return
+  }
+  if (
+    (e.metaKey || e.ctrlKey) &&
+    (e.key === "Z" || (e.key === "z" && e.shiftKey))
+  ) {
+    e.preventDefault()
+    redo()
+    return
+  }
+}
+
 function parseContent(md: string | undefined) {
   blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
 }
 
-// Sync: parent content → blocks (external updates only)
+// Sync: parent content → blocks (external updates only).
+// External content replacement invalidates all history entries
+// (they reference stale indices/block IDs), so clear the undo stacks.
 watch(
   () => content.value,
   (val) => {
@@ -41,6 +182,9 @@ watch(
       updatingInternally = false
       return
     }
+    history.clear()
+    canUndo.value = false
+    canRedo.value = false
     parseContent(val)
   },
   { immediate: true },
@@ -52,6 +196,32 @@ function onBlockContentChange() {
   content.value = serializeBlocks(blocks.value)
 }
 
+// ── Active block state (toolbar binding) ──────────────────────────────
+
+const activeHandlers = ref<FormattingHandlers | null>(null)
+const selectionVersion = ref(0)
+
+function onBlockFocusHandler(payload: {
+  view: EditorView
+  handlers: FormattingHandlers
+}) {
+  activeHandlers.value = payload.handlers
+  emit("focus", payload)
+}
+
+function onBlockBlurHandler() {
+  emit("blur")
+}
+
+function onBlockSelectionChangeHandler(payload: {
+  from: number
+  to: number
+  empty: boolean
+}) {
+  selectionVersion.value++
+  emit("selection-change", payload)
+}
+
 // ── Block event handlers ──────────────────────────────────────────────
 
 function onSplit(index: number, before: string, after: string) {
@@ -60,12 +230,25 @@ function onSplit(index: number, before: string, after: string) {
   current.content = before
 
   const newBlock: EditorBlock = {
-    id: crypto.randomUUID(),
+    id: generateBlockId(),
     depth: current.depth,
     content: after,
   }
   blocks.value.splice(index + 1, 0, newBlock)
   onBlockContentChange()
+
+  history.execute({
+    type: "split-block",
+    index,
+    splitBlockId: newBlock.id,
+    depth: current.depth,
+    beforeContent: before,
+    afterContent: after,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+
   nextTick(() => registry.focus(newBlock.id, "start"))
 }
 
@@ -74,74 +257,276 @@ function onMergePrevious(index: number) {
   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
+
+  const prevContent = prev.content
+  const appendedContent = current.content
+  const removedBlockId = current.id
+  const removedBlockDepth = current.depth
+
+  // PM positions are 1-based within a paragraph: position 1 = before the
+  // first character, so landing after the last char of prev content requires
+  // prev.content.length + 1.
+  const joinPos = prevContent.length > 0 ? prevContent.length + 1 : 0
+  const mergedContent = prevContent
+    ? `${prevContent}${appendedContent}`
+    : appendedContent
+
+  // Directly update the prev block's PM + model so the debounced watch
+  // (which fires ~50ms later) sees currentContent === newContent and is a
+  // no-op — preventing a cursor-resetting doc replacement.
+  blockRefs[index - 1]?.setContent(mergedContent)
+
   blocks.value.splice(index, 1)
   onBlockContentChange()
-  nextTick(() => registry.focus(prev.id, "end"))
+
+  history.execute({
+    type: "merge-block",
+    index,
+    removedBlockId,
+    removedBlockDepth,
+    prevContent,
+    appendedContent,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+
+  nextTick(() => registry.focus(prev.id, joinPos))
 }
 
 function onDeleteIfEmpty(index: number) {
   if (blocks.value.length <= 1) return
+  const removedBlock = blocks.value[index]
   const nextId =
     index < blocks.value.length - 1
       ? blocks.value[index + 1]?.id
       : blocks.value[index - 1]?.id
-  if (!nextId) return
+  if (!nextId || !removedBlock) return
+  const removedContent = removedBlock.content
+  const removedDepth = removedBlock.depth
+  const removedId = removedBlock.id
+
   blocks.value.splice(index, 1)
   onBlockContentChange()
+
+  history.execute({
+    type: "delete-block",
+    index,
+    blockId: removedId,
+    content: removedContent,
+    depth: removedDepth,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+
   nextTick(() => registry.focus(nextId, "start"))
 }
 
 function onArrowUpFromStart(index: number) {
-  if (index <= 0) return
+  // Boundary: focus the insertion zone before the first block.
+  if (index <= 0) {
+    focusZone(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
+  // Boundary: focus the insertion zone after the last block.
+  if (index >= blocks.value.length - 1) {
+    focusZone(blocks.value.length)
+    return
+  }
   const next = blocks.value[index + 1]
   if (!next) return
   registry.focus(next.id, "start")
 }
 
+function onBlockBoundaryExit(index: number, direction: "up" | "down") {
+  const zoneIndex = direction === "up" ? index : index + 1
+  log.info("onBlockBoundaryExit", { index, direction, zoneIndex })
+  focusZone(zoneIndex)
+}
+
+function getChildrenIndices(index: number): number[] {
+  const parentDepth = blocks.value[index]?.depth
+  if (parentDepth === undefined) return []
+  const children: number[] = []
+  for (let i = index + 1; i < blocks.value.length; i++) {
+    const b = blocks.value[i]
+    if (!b || b.depth <= parentDepth) break
+    children.push(i)
+  }
+  return children
+}
+
 function onIndent(index: number) {
   const block = blocks.value[index]
   if (!block) return
-  block.depth = Math.min(block.depth + 1, 10)
+  const delta = block.depth < 10 ? 1 : 0
+  if (delta === 0) return
+  const childIndices = getChildrenIndices(index)
+  const previousDepth = block.depth
+  const childEntries = childIndices.map((i) => ({
+    index: i,
+    previousDepth: blocks.value[i]?.depth ?? 0,
+  }))
+  const affected = [index, ...childIndices]
+  for (const i of affected) {
+    const b = blocks.value[i]
+    if (b) b.depth = Math.min(b.depth + delta, 10)
+  }
   onBlockContentChange()
+
+  history.execute({
+    type: "indent-block",
+    index,
+    previousDepth,
+    childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
 }
 
 function onOutdent(index: number) {
   const block = blocks.value[index]
   if (!block) return
-  block.depth = Math.max(block.depth - 1, 0)
+  const delta = block.depth > 0 ? -1 : 0
+  if (delta === 0) return
+  const childIndices = getChildrenIndices(index)
+  const previousDepth = block.depth
+  const childEntries = childIndices.map((i) => ({
+    index: i,
+    previousDepth: blocks.value[i]?.depth ?? 0,
+  }))
+  const affected = [index, ...childIndices]
+  for (const i of affected) {
+    const b = blocks.value[i]
+    if (b) b.depth = Math.max(b.depth + delta, 0)
+  }
   onBlockContentChange()
+
+  history.execute({
+    type: "outdent-block",
+    index,
+    previousDepth,
+    childrenPreviousDepths: childEntries.length > 0 ? childEntries : undefined,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+}
+
+const focusedTarget = ref<FocusTarget | null>(null)
+
+watch(registry.focusedId, (id) => {
+  if (id !== null) {
+    focusedTarget.value = { kind: "block", id }
+  }
+})
+
+function onZoneFocus(index: number) {
+  focusedTarget.value = { kind: "zone", index }
+}
+
+function onZoneBlur() {
+  if (focusedTarget.value?.kind === "zone") {
+    focusedTarget.value = null
+  }
 }
+
+function onZoneActivate(index: number, content: string) {
+  const newBlock: EditorBlock = {
+    id: generateBlockId(),
+    depth: 0,
+    content,
+  }
+  blocks.value.splice(index, 0, newBlock)
+  focusedTarget.value = null
+
+  history.execute({
+    type: "insert-block",
+    index,
+    blockId: newBlock.id,
+    content,
+    depth: 0,
+    timestamp: Date.now(),
+  })
+  canUndo.value = history.canUndo
+  canRedo.value = history.canRedo
+
+  if (content) {
+    nextTick(() => registry.focus(newBlock.id, "end"))
+  } else {
+    nextTick(() => registry.focus(newBlock.id, "start"))
+  }
+}
+
+defineExpose({ undo, redo, canUndo, canRedo })
 </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 ref="editorShellRef" class="editor-shell" @keydown="onShellKeydown">
+    <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
+      <slot
+        name="toolbar"
+        :handlers="activeHandlers"
+        :selection-version="selectionVersion"
+        :can-undo="canUndo"
+        :can-redo="canRedo"
+        :undo="undo"
+        :redo="redo"
+      />
+    </div>
+    <template v-for="(block, i) in blocks" :key="block.id">
+      <InsertionZone
+        :ref="(el: any) => { if (el) zoneRefs[i] = el }"
+        @activate="(content) => onZoneActivate(i, content)"
+        @focus="onZoneFocus(i)"
+        @blur="onZoneBlur"
+      />
+      <div class="flex items-start">
+        <div
+          class="flex items-center justify-end h-7 select-none pointer-events-none"
+          :style="{ width: (block.depth + 1) * 20 + 'px' }"
+          aria-hidden="true"
+        >
+          <UIcon name="i-lucide-dot" class="size-6 text-muted" />
+        </div>
+
+        <Block
+          :ref="(el: any) => { if (el) blockRefs[i] = el }"
+          :id="block.id"
+          :on-boundary-exit="(dir) => onBlockBoundaryExit(i, dir)"
+          v-model:content="block.content"
+          :focused="focused && i === 0"
+          :marker-mode="markerMode"
+          :debug="debug"
+          :registry="registry"
+          class="flex-1 min-w-0"
+          @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)"
+          @focus="onBlockFocusHandler"
+          @blur="onBlockBlurHandler"
+          @selection-change="onBlockSelectionChangeHandler"
+        />
+      </div>
+    </template>
+    <InsertionZone
+      :ref="(el: any) => { if (el) zoneRefs[blocks.length] = el }"
+      @activate="(content) => onZoneActivate(blocks.length, content)"
+      @focus="onZoneFocus(blocks.length)"
+      @blur="onZoneBlur"
     />
   </div>
 </template>

+ 90 - 0
packages/editor/src/components/InsertionZone.vue

@@ -0,0 +1,90 @@
+<script setup lang="ts">
+import { ref } from "vue"
+
+const emit = defineEmits<{
+  activate: [content: string]
+  focus: []
+  blur: []
+}>()
+
+const hovered = ref(false)
+const focused = ref(false)
+const rootEl = ref<HTMLElement | null>(null)
+
+function onKeydown(e: KeyboardEvent) {
+  if (e.key === "Enter") {
+    e.preventDefault()
+    emit("activate", "")
+    return
+  }
+
+  if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
+    e.preventDefault()
+    emit("activate", e.key)
+    return
+  }
+}
+
+function onPaste(e: ClipboardEvent) {
+  e.preventDefault()
+  const text = e.clipboardData?.getData("text/plain") ?? ""
+  emit("activate", text)
+}
+
+function onFocus() {
+  focused.value = true
+  emit("focus")
+}
+
+function onBlur() {
+  focused.value = false
+  emit("blur")
+}
+
+function onClick() {
+  rootEl.value?.focus()
+}
+
+defineExpose({
+  focus: () => rootEl.value?.focus(),
+})
+</script>
+
+<template>
+  <div
+    ref="rootEl"
+    class="insertion-zone relative outline-none cursor-pointer transition-all duration-150"
+    :class="focused ? 'h-10' : 'h-1'"
+    tabindex="0"
+    role="button"
+    aria-label="Insert block"
+    @keydown="onKeydown"
+    @paste="onPaste"
+    @click="onClick"
+    @focus="onFocus"
+    @blur="onBlur"
+    @mouseenter="hovered = true"
+    @mouseleave="hovered = false"
+  >
+    <div
+      v-if="hovered && !focused"
+      class="absolute inset-0 flex items-center"
+    >
+      <USeparator
+        icon="i-lucide-square-plus"
+        class="w-full text-(--ui-text-muted)"
+        :ui="{ icon: 'text-(--ui-text-muted)' }"
+        color="natural"
+        type="dotted"
+      />
+    </div>
+
+    <div
+      v-if="focused"
+      class="absolute inset-0 rounded-lg border-2 bg-(--ui-bg-elevated) flex items-center gap-2.5 px-4 shadow-sm border-(--ui-text-muted)/40"
+    >
+      <span class="inline-block w-0.5 h-5 bg-(--ui-primary) rounded-full animate-pulse" />
+      <span class="text-sm text-(--ui-text-muted) select-none">Type to add a block</span>
+    </div>
+  </div>
+</template>

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

@@ -1,5 +1,9 @@
 import { type Ref, ref } from "vue"
 
+export type FocusTarget =
+  | { kind: "block"; id: string }
+  | { kind: "zone"; index: number }
+
 export interface FocusRegistryHandle {
   focus: (pos?: "start" | "end" | number) => void
   element?: HTMLElement

+ 23 - 0
packages/editor/src/index.ts

@@ -5,9 +5,32 @@ 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,
+  stampBlockId,
+} from "@/lib/block-parser"
 export type { FormattingHandlers } from "@/lib/formatting"
 export type { MarkdownPattern } from "@/lib/markdown-extensions"
+export type {
+  DeleteBlockOp,
+  EditorOperation,
+  ExecuteResult,
+  IndentBlockOp,
+  InsertBlockOp,
+  MergeBlockOp,
+  OperationHistory,
+  OutdentBlockOp,
+  SetBlockContentOp,
+  SplitBlockOp,
+} from "@/lib/operation-history"
+export {
+  computeInverse,
+  createOperationHistory,
+  setTimeSource,
+} from "@/lib/operation-history"
 export { Block, Editor, EditorToolbar }
 
 export default {

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

@@ -1,8 +1,10 @@
 import { beforeEach, describe, expect, it } from "vitest"
 import {
   type EditorBlock,
+  extractEmbeddedId,
   serializeBlocks,
   splitMarkdownIntoBlocks,
+  stampBlockId,
 } from "@/lib/block-parser"
 
 // Deterministic ID generator for testing.
@@ -236,6 +238,278 @@ describe("ID preservation", () => {
   })
 })
 
+describe("embedded id::", () => {
+  it("extractEmbeddedId returns null when no id:: present", () => {
+    expect(extractEmbeddedId("TODO my item")).toBeNull()
+    expect(extractEmbeddedId("")).toBeNull()
+    expect(extractEmbeddedId("author:: John")).toBeNull()
+  })
+
+  it("extractEmbeddedId finds id:: on last line", () => {
+    const content = "TODO Do something\nid:: abc-123"
+    expect(extractEmbeddedId(content)).toBe("abc-123")
+  })
+
+  it("extractEmbeddedId finds id:: on last line with leading whitespace", () => {
+    const content = "TODO Do something\n  id:: abc-123"
+    expect(extractEmbeddedId(content)).toBe("abc-123")
+  })
+
+  it("extractEmbeddedId skips id:: in middle of text line", () => {
+    // Line starting with "id::" must be a standalone property line
+    expect(extractEmbeddedId("Consider id:: not-an-id")).toBeNull()
+  })
+
+  it("splitMarkdownIntoBlocks uses embedded id:: when present", () => {
+    const result = parse("* TODO item\n  id:: my-embedded-id")
+    expect(result).toHaveLength(1)
+    expect(result[0].id).toBe("my-embedded-id")
+    expect(result[0].content).toBe("TODO item\n  id:: my-embedded-id")
+  })
+
+  it("embedded id:: takes priority over position-based preservation", () => {
+    const existing: EditorBlock[] = [{ id: "pos-id", depth: 0, content: "old" }]
+    const result = splitMarkdownIntoBlocks(
+      "* content\n  id:: embedded-id",
+      testId,
+      existing,
+    )
+    expect(result).toHaveLength(1)
+    // Embedded id:: wins over position match
+    expect(result[0].id).toBe("embedded-id")
+  })
+
+  it("stampBlockId adds id:: to content with continuation indent", () => {
+    const block: EditorBlock = { id: "my-uuid", depth: 0, content: "TODO item" }
+    stampBlockId(block)
+    expect(block.content).toBe("TODO item\n  id:: my-uuid")
+  })
+
+  it("stampBlockId indents by (depth + 1) * 2 spaces", () => {
+    const block: EditorBlock = {
+      id: "nested-uuid",
+      depth: 2,
+      content: "Nested item",
+    }
+    stampBlockId(block)
+    // depth 2 → continuation indent = 6 spaces (3 * 2)
+    expect(block.content).toBe("Nested item\n      id:: nested-uuid")
+  })
+
+  it("stampBlockId is idempotent", () => {
+    const block: EditorBlock = {
+      id: "my-uuid",
+      depth: 0,
+      content: "TODO item\n  id:: my-uuid",
+    }
+    stampBlockId(block)
+    // Content unchanged — already has id:: line
+    expect(block.content).toBe("TODO item\n  id:: my-uuid")
+  })
+
+  it("stampBlockId matches different embedded id", () => {
+    // Edge case: block.id differs from id:: in content (should not re-stamp)
+    const block: EditorBlock = {
+      id: "different-id",
+      depth: 0,
+      content: "TODO item\n  id:: existing-id",
+    }
+    stampBlockId(block)
+    expect(block.content).toBe("TODO item\n  id:: existing-id")
+  })
+
+  it("serializeBlocks preserves id:: line in content", () => {
+    // Content with continuation indentation on the id:: line, matching
+    // how the parser produces it from "  id:: ...".
+    const blocks: EditorBlock[] = [
+      { id: "abc-123", depth: 0, content: "Referenced block\n  id:: abc-123" },
+    ]
+    expect(serializeBlocks(blocks)).toBe("* Referenced block\n  id:: abc-123")
+  })
+
+  it("round-trip preserves embedded id::", () => {
+    const md = "* Referenced block\n  id:: abc-123"
+    const blocks = parse(md)
+    expect(blocks[0].id).toBe("abc-123")
+    expect(serializeBlocks(blocks)).toBe(md)
+  })
+})
+
+describe("block operations", () => {
+  it("split: creates a new block after index with given content", () => {
+    const blocks = parse("* TODO Alpha\n* DONE Beta")
+    const after = blocks[0]
+    // Simulate split: current block keeps "TODO", new block gets "Alpha"
+    after.content = "TODO"
+    const newBlock: EditorBlock = {
+      id: "new",
+      depth: after.depth,
+      content: "Alpha",
+    }
+    blocks.splice(1, 0, newBlock)
+    expect(serializeBlocks(blocks)).toBe("* TODO\n* Alpha\n* DONE Beta")
+  })
+
+  it("split: new block inherits depth from split block", () => {
+    // Nested via parent-child, not bare indentation
+    const blocks = parse("* Parent\n  * Child")
+    // The child block (index 1) has depth 1
+    const newBlock: EditorBlock = {
+      id: "new",
+      depth: blocks[1].depth,
+      content: "",
+    }
+    blocks.splice(2, 0, newBlock)
+    const result = serializeBlocks(blocks)
+    expect(result).toBe("* Parent\n  * Child\n  * ")
+    expect(newBlock.depth).toBe(1)
+  })
+
+  it("merge: appends current content to previous, join pos at boundary", () => {
+    const blocks = parse("* Hello\n* World")
+    const prev = blocks[0]
+    const current = blocks[1]
+    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
+    prev.content = `${prev.content}${current.content}`
+    blocks.splice(1, 1)
+    expect(prev.content).toBe("HelloWorld")
+    // PM positions are 1-based: position 6 = after "Hello" (5 chars + 1)
+    expect(joinPos).toBe(6)
+  })
+
+  it("merge: empty prev block takes current content, join at 0", () => {
+    const blocks = parse("* \n* World")
+    const prev = blocks[0]
+    const current = blocks[1]
+    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
+    prev.content = `${prev.content}${current.content}`
+    blocks.splice(1, 1)
+    expect(prev.content).toBe("World")
+    expect(joinPos).toBe(0)
+  })
+
+  it("merge: preserves depth of previous block", () => {
+    const blocks = parse("* Depth 1\n* Depth 0")
+    const prev = blocks[0]
+    const current = blocks[1]
+    const joinPos = prev.content.length > 0 ? prev.content.length + 1 : 0
+    prev.content = `${prev.content}${current.content}`
+    blocks.splice(1, 1)
+    expect(prev.content).toBe("Depth 1Depth 0")
+    // PM position after "Depth 1" (7 chars + 1)
+    expect(joinPos).toBe(8)
+  })
+
+  it("delete: removes block at index", () => {
+    const blocks = parse("* Keep\n* Remove\n* Keep")
+    blocks.splice(1, 1)
+    expect(serializeBlocks(blocks)).toBe("* Keep\n* Keep")
+  })
+
+  it("delete: retains last block", () => {
+    const blocks = parse("* Solo")
+    expect(blocks.length).toBe(1)
+    // Simulate delete-if-empty guard: don't delete last block
+    if (blocks.length > 1) {
+      blocks.splice(0, 1)
+    }
+    expect(serializeBlocks(blocks)).toBe("* Solo")
+  })
+
+  describe("indent/outdent cascade", () => {
+    it("indent: cascades depth increase to children", () => {
+      const blocks = parse("* Parent\n  * Child\n    * Grandchild\n* Other")
+      const parent = blocks[0]
+      const child = blocks[1]
+      const grandchild = blocks[2]
+      const other = blocks[3]
+      expect(parent.depth).toBe(0)
+      expect(child.depth).toBe(1)
+      expect(grandchild.depth).toBe(2)
+
+      const delta = parent.depth < 10 ? 1 : 0
+      const children: number[] = []
+      for (let i = 1; i < blocks.length; i++) {
+        if (blocks[i].depth > parent.depth) children.push(i)
+        else break
+      }
+      const affected = [0, ...children]
+      for (const i of affected) {
+        blocks[i].depth = Math.min(blocks[i].depth + delta, 10)
+      }
+
+      expect(parent.depth).toBe(1)
+      expect(child.depth).toBe(2)
+      expect(grandchild.depth).toBe(3)
+      // Sibling is not affected
+      expect(other.depth).toBe(0)
+    })
+
+    it("outdent: cascades depth decrease to children", () => {
+      const blocks = parse("* Root\n  * Section\n    * Subsection\n* Footer")
+      const section = blocks[1]
+      const subsection = blocks[2]
+      expect(section.depth).toBe(1)
+      expect(subsection.depth).toBe(2)
+
+      const delta = section.depth > 0 ? -1 : 0
+      const children: number[] = []
+      for (let i = 2; i < blocks.length; i++) {
+        if (blocks[i].depth > section.depth) children.push(i)
+        else break
+      }
+      const affected = [1, ...children]
+      for (const i of affected) {
+        blocks[i].depth = Math.max(blocks[i].depth + delta, 0)
+      }
+
+      expect(section.depth).toBe(0)
+      expect(subsection.depth).toBe(1)
+    })
+
+    it("indent: does not cascade to siblings at same depth", () => {
+      const blocks = parse("* A\n  * B\n* C")
+      const a = blocks[0]
+      const b = blocks[1]
+      const c = blocks[2]
+      expect(a.depth).toBe(0)
+      expect(b.depth).toBe(1)
+
+      const delta = a.depth < 10 ? 1 : 0
+      const children: number[] = []
+      for (let i = 1; i < blocks.length; i++) {
+        if (blocks[i].depth > a.depth) children.push(i)
+        else break
+      }
+      for (const i of [0, ...children]) {
+        blocks[i].depth = Math.min(blocks[i].depth + delta, 10)
+      }
+
+      expect(a.depth).toBe(1)
+      expect(b.depth).toBe(2)
+      // C is a sibling, not a child — stays at depth 0
+      expect(c.depth).toBe(0)
+    })
+
+    it("indent: clamped at depth 10", () => {
+      const blocks: EditorBlock[] = [{ id: "0", depth: 10, content: "Maxed" }]
+      const delta = blocks[0].depth < 10 ? 1 : 0
+      expect(delta).toBe(0)
+      // No change when at max depth
+      blocks[0].depth = Math.min(blocks[0].depth + 1, 10)
+      expect(blocks[0].depth).toBe(10)
+    })
+
+    it("outdent: does not go below depth 0", () => {
+      const blocks: EditorBlock[] = [{ id: "0", depth: 0, content: "Root" }]
+      const delta = blocks[0].depth > 0 ? -1 : 0
+      expect(delta).toBe(0)
+      blocks[0].depth = Math.max(blocks[0].depth + delta, 0)
+      expect(blocks[0].depth).toBe(0)
+    })
+  })
+})
+
 describe("serializeBlocks", () => {
   it("single block", () => {
     const md = serializeBlocks([{ id: "0", depth: 0, content: "TODO my item" }])

+ 340 - 0
packages/editor/src/lib/__tests__/operation-history.test.ts

@@ -0,0 +1,340 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import {
+  computeInverse,
+  createOperationHistory,
+  type EditorOperation,
+  setTimeSource,
+} from "@/lib/operation-history"
+
+beforeEach(() => {
+  setTimeSource(() => 1000)
+})
+
+describe("computeInverse", () => {
+  it("split-block inverse is merge-block at index+1", () => {
+    const op: EditorOperation = {
+      type: "split-block",
+      index: 2,
+      splitBlockId: "new-block",
+      depth: 0,
+      beforeContent: "hello",
+      afterContent: "world",
+      timestamp: 1,
+    }
+    const inverse = computeInverse(op)
+    expect(inverse).toEqual({
+      type: "merge-block",
+      index: 3,
+      removedBlockId: "new-block",
+      removedBlockDepth: 0,
+      prevContent: "hello",
+      appendedContent: "world",
+      timestamp: 1000,
+    })
+  })
+
+  it("merge-block inverse is split-block at index-1", () => {
+    const op: EditorOperation = {
+      type: "merge-block",
+      index: 1,
+      removedBlockId: "block-b",
+      removedBlockDepth: 0,
+      prevContent: "hello",
+      appendedContent: "world",
+      timestamp: 1,
+    }
+    const inverse = computeInverse(op)
+    expect(inverse).toEqual({
+      type: "split-block",
+      index: 0,
+      splitBlockId: "block-b",
+      depth: 0,
+      beforeContent: "hello",
+      afterContent: "world",
+      timestamp: 1000,
+    })
+  })
+
+  it("insert-block and delete-block are inverses of each other", () => {
+    const insert: EditorOperation = {
+      type: "insert-block",
+      index: 0,
+      blockId: "x",
+      content: "test",
+      depth: 1,
+      timestamp: 1,
+    }
+    const deleteInverse = computeInverse(insert)
+    expect(deleteInverse).toEqual({
+      type: "delete-block",
+      index: 0,
+      blockId: "x",
+      content: "test",
+      depth: 1,
+      timestamp: 1000,
+    })
+
+    const del: EditorOperation = {
+      type: "delete-block",
+      index: 0,
+      blockId: "x",
+      content: "test",
+      depth: 1,
+      timestamp: 2,
+    }
+    const insertInverse = computeInverse(del)
+    expect(insertInverse).toEqual({
+      type: "insert-block",
+      index: 0,
+      blockId: "x",
+      content: "test",
+      depth: 1,
+      timestamp: 1000,
+    })
+  })
+
+  it("indent-block and outdent-block are inverses of each other", () => {
+    const indent: EditorOperation = {
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    }
+    expect(computeInverse(indent)).toEqual({
+      type: "outdent-block",
+      index: 0,
+      previousDepth: 1,
+      timestamp: 1000,
+    })
+
+    const outdent: EditorOperation = {
+      type: "outdent-block",
+      index: 0,
+      previousDepth: 1,
+      timestamp: 2,
+    }
+    expect(computeInverse(outdent)).toEqual({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1000,
+    })
+  })
+
+  it("indent-block inverse carries childrenPreviousDepths", () => {
+    const op: EditorOperation = {
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      childrenPreviousDepths: [
+        { index: 1, previousDepth: 1 },
+        { index: 2, previousDepth: 1 },
+      ],
+      timestamp: 1,
+    }
+    const inverse = computeInverse(op)
+    expect(inverse).toEqual({
+      type: "outdent-block",
+      index: 0,
+      previousDepth: 1,
+      childrenPreviousDepths: [
+        { index: 1, previousDepth: 2 },
+        { index: 2, previousDepth: 2 },
+      ],
+      timestamp: 1000,
+    })
+  })
+
+  it("outdent-block inverse carries childrenPreviousDepths", () => {
+    const op: EditorOperation = {
+      type: "outdent-block",
+      index: 0,
+      previousDepth: 1,
+      childrenPreviousDepths: [
+        { index: 1, previousDepth: 2 },
+        { index: 2, previousDepth: 2 },
+      ],
+      timestamp: 1,
+    }
+    const inverse = computeInverse(op)
+    expect(inverse).toEqual({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      childrenPreviousDepths: [
+        { index: 1, previousDepth: 1 },
+        { index: 2, previousDepth: 1 },
+      ],
+      timestamp: 1000,
+    })
+  })
+
+  it("set-block-content swaps previous and new content", () => {
+    const op: EditorOperation = {
+      type: "set-block-content",
+      blockId: "b",
+      previousContent: "old",
+      newContent: "new",
+      timestamp: 1,
+    }
+    expect(computeInverse(op)).toEqual({
+      type: "set-block-content",
+      blockId: "b",
+      previousContent: "new",
+      newContent: "old",
+      timestamp: 1000,
+    })
+  })
+})
+
+describe("createOperationHistory", () => {
+  it("starts with no undo/redo available", () => {
+    const h = createOperationHistory()
+    expect(h.canUndo).toBe(false)
+    expect(h.canRedo).toBe(false)
+    expect(h.undo()).toBeNull()
+    expect(h.redo()).toBeNull()
+  })
+
+  it("execute returns inverse and makes undo available", () => {
+    const h = createOperationHistory()
+    const op: EditorOperation = {
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    }
+    const result = h.execute(op)
+    expect(result.inverse.type).toBe("outdent-block")
+    expect(h.canUndo).toBe(true)
+    expect(h.canRedo).toBe(false)
+  })
+
+  it("undo returns inverse operation", () => {
+    const h = createOperationHistory()
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    const inverse = h.undo()
+    expect(inverse).not.toBeNull()
+    expect(inverse?.type).toBe("outdent-block")
+    expect(h.canUndo).toBe(false)
+    expect(h.canRedo).toBe(true)
+  })
+
+  it("redo re-executes the undone operation", () => {
+    const h = createOperationHistory()
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    h.undo()
+    expect(h.canRedo).toBe(true)
+
+    const redoOp = h.redo()
+    expect(redoOp).not.toBeNull()
+    expect(redoOp?.type).toBe("indent-block")
+    expect((redoOp as { previousDepth: number }).previousDepth).toBe(0)
+    expect(h.canUndo).toBe(true)
+    expect(h.canRedo).toBe(false)
+  })
+
+  it("execute clears future stack", () => {
+    const h = createOperationHistory()
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    h.undo()
+    expect(h.canRedo).toBe(true)
+
+    // New operation clears future
+    h.execute({
+      type: "indent-block",
+      index: 1,
+      previousDepth: 1,
+      timestamp: 2,
+    })
+    expect(h.canRedo).toBe(false)
+    expect(h.canUndo).toBe(true)
+  })
+
+  it("enforces max size by dropping oldest operations", () => {
+    const h = createOperationHistory(2)
+    for (let i = 0; i < 5; i++) {
+      h.execute({
+        type: "indent-block",
+        index: i,
+        previousDepth: 0,
+        timestamp: i,
+      })
+    }
+    // Only the last 2 operations remain
+    expect(h.canUndo).toBe(true)
+    const firstUndo = h.undo() as { index: number } & EditorOperation
+    expect(firstUndo.index).toBe(4)
+    const secondUndo = h.undo() as { index: number } & EditorOperation
+    expect(secondUndo.index).toBe(3)
+    expect(h.undo()).toBeNull()
+  })
+
+  it("multiple undo/redo round-trip", () => {
+    const h = createOperationHistory()
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    h.execute({
+      type: "indent-block",
+      index: 1,
+      previousDepth: 1,
+      timestamp: 2,
+    })
+    h.execute({
+      type: "indent-block",
+      index: 2,
+      previousDepth: 2,
+      timestamp: 3,
+    })
+
+    // Undo 2
+    expect((h.undo() as { index: number } & EditorOperation).index).toBe(2)
+    expect((h.undo() as { index: number } & EditorOperation).index).toBe(1)
+
+    // Redo 1
+    expect((h.redo() as { index: number } & EditorOperation).index).toBe(1)
+
+    // Undo 1
+    expect((h.undo() as { index: number } & EditorOperation).index).toBe(1)
+
+    // Redo all
+    expect((h.redo() as { index: number } & EditorOperation).index).toBe(1)
+    expect((h.redo() as { index: number } & EditorOperation).index).toBe(2)
+    expect(h.redo()).toBeNull()
+  })
+
+  it("clear resets both stacks", () => {
+    const h = createOperationHistory()
+    h.execute({
+      type: "indent-block",
+      index: 0,
+      previousDepth: 0,
+      timestamp: 1,
+    })
+    h.undo()
+    h.clear()
+    expect(h.canUndo).toBe(false)
+    expect(h.canRedo).toBe(false)
+    expect(h.undo()).toBeNull()
+    expect(h.redo()).toBeNull()
+  })
+})

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

@@ -8,6 +8,53 @@ export interface EditorBlock {
   content: string
 }
 
+// ── ID utilities ──────────────────────────────────────────────────
+
+/**
+ * Generate a new block identifier. Abstracted from `crypto.randomUUID()`
+ * so the caller (or tests) can provide an alternative.
+ */
+export function generateBlockId(): string {
+  return crypto.randomUUID()
+}
+
+/**
+ * Regex matching a line whose trimmed content is `id:: <value>`.
+ * Scanned from the end of a block's content lines.
+ */
+const ID_LINE_RE = /^\s*id::\s*(\S+)\s*$/
+
+/**
+ * Extract an embedded block ID from content lines.
+ * Scans from the last line upward looking for `id:: <uuid>`. If found,
+ * returns the ID — the `id::` line is NOT removed from content (it's a
+ * visible property line that survives round-trips automatically).
+ */
+export function extractEmbeddedId(content: string): string | null {
+  const lines = content.split("\n")
+  for (let i = lines.length - 1; i >= 0; i--) {
+    const line = lines[i] as string
+    const match = line.match(ID_LINE_RE)
+    if (match) return match[1] as string
+  }
+  return null
+}
+
+/**
+ * Stamp a persistent `id::` property onto a block's content.
+ * This is called when a block becomes referenced (by a page ref, block
+ * ref, backlink, etc.) — making its ID immutable across re-parses.
+ * No-op if the block already has an `id::` line.
+ *
+ * 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 {
+  if (extractEmbeddedId(block.content)) return
+  const indent = "  ".repeat(block.depth + 1)
+  block.content += `\n${indent}id:: ${block.id}`
+}
+
 const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
 
 /**
@@ -78,13 +125,21 @@ export function splitMarkdownIntoBlocks(
 
     // Content: skip "* " (2 chars) after the bullet
     const contentStart = node.from + 2
-    const content = md.slice(contentStart, end)
+    const content = md.slice(contentStart, end).trimEnd()
 
-    return {
-      id: existing?.[i]?.id ?? idFn(),
-      depth,
-      content: content.trimEnd(),
+    // Priority 1: embedded id:: in content (survives any repositioning)
+    const embeddedId = extractEmbeddedId(content)
+    if (embeddedId) {
+      return { id: embeddedId, depth, content }
     }
+
+    // Priority 2: position-based preservation (prevents remount in-session)
+    if (existing?.[i]?.id) {
+      return { id: existing[i].id as string, depth, content }
+    }
+
+    // Priority 3: fresh ID
+    return { id: idFn(), depth, content }
   })
 }
 

+ 291 - 0
packages/editor/src/lib/operation-history.ts

@@ -0,0 +1,291 @@
+/**
+ * Editor operation types and undo history foundation.
+ *
+ * Defines the discriminated union of all possible block-level operations
+ * and a `OperationHistory` class that stores operations and computes their
+ * inverses. This is the contract for undo/redo — not yet wired into
+ * `Editor.vue`.
+ */
+
+// ── Time source (injectable for testing) ─────────────────────────────
+
+let timeSource: () => number = () => Date.now()
+
+export function setTimeSource(fn: () => number) {
+  timeSource = fn
+}
+
+// ── Operation types ──────────────────────────────────────────────────
+
+export interface SplitBlockOp {
+  type: "split-block"
+  /** Index of the block that was split */
+  index: number
+  /** ID of the newly created block (the "after" part) */
+  splitBlockId: string
+  /** Depth shared by both blocks */
+  depth: number
+  /** Content kept by the original block */
+  beforeContent: string
+  /** Content that became the new block */
+  afterContent: string
+  timestamp: number
+}
+
+export interface MergeBlockOp {
+  type: "merge-block"
+  /** Index of the block that was removed */
+  index: number
+  /** ID of the removed block */
+  removedBlockId: string
+  /** Depth of the removed block */
+  removedBlockDepth: number
+  /** Content of the previous block before the merge */
+  prevContent: string
+  /** Content that was appended */
+  appendedContent: string
+  timestamp: number
+}
+
+export interface InsertBlockOp {
+  type: "insert-block"
+  /** Index where the block was inserted */
+  index: number
+  /** ID of the inserted block */
+  blockId: string
+  /** Content of the inserted block */
+  content: string
+  /** Depth of the inserted block */
+  depth: number
+  timestamp: number
+}
+
+export interface DeleteBlockOp {
+  type: "delete-block"
+  /** Index of the deleted block */
+  index: number
+  /** ID of the deleted block */
+  blockId: string
+  /** Content of the deleted block */
+  content: string
+  /** Depth of the deleted block */
+  depth: number
+  timestamp: number
+}
+
+export interface IndentBlockOp {
+  type: "indent-block"
+  /** Index of the block that was indented */
+  index: number
+  /** Depth before indentation */
+  previousDepth: number
+  /** Depths of cascaded child blocks before indentation */
+  childrenPreviousDepths?: Array<{ index: number; previousDepth: number }>
+  timestamp: number
+}
+
+export interface OutdentBlockOp {
+  type: "outdent-block"
+  /** Index of the block that was outdented */
+  index: number
+  /** Depth before outdentation */
+  previousDepth: number
+  /** Depths of cascaded child blocks before outdentation */
+  childrenPreviousDepths?: Array<{ index: number; previousDepth: number }>
+  timestamp: number
+}
+
+export interface SetBlockContentOp {
+  type: "set-block-content"
+  /** ID of the block whose content changed */
+  blockId: string
+  /** Content before the change */
+  previousContent: string
+  /** Content after the change */
+  newContent: string
+  timestamp: number
+}
+
+export type EditorOperation =
+  | SplitBlockOp
+  | MergeBlockOp
+  | InsertBlockOp
+  | DeleteBlockOp
+  | IndentBlockOp
+  | OutdentBlockOp
+  | SetBlockContentOp
+
+// ── Execute result ───────────────────────────────────────────────────
+
+/**
+ * Every operation execution returns its own inverse, enabling undo.
+ * The inverse operation, when executed, reverses the original mutation.
+ */
+export interface ExecuteResult {
+  inverse: EditorOperation
+}
+
+// ── Inverse computation ──────────────────────────────────────────────
+
+export function computeInverse(op: EditorOperation): EditorOperation {
+  const now = timeSource()
+
+  switch (op.type) {
+    // Inverse of split is merge: the new block at index+1 is removed,
+    // and its content is appended back to the block at index.
+    case "split-block":
+      return {
+        type: "merge-block",
+        index: op.index + 1,
+        removedBlockId: op.splitBlockId,
+        removedBlockDepth: op.depth,
+        prevContent: op.beforeContent,
+        appendedContent: op.afterContent,
+        timestamp: now,
+      }
+
+    // Inverse of merge is split: the block at index-1 is split into
+    // its original content and the removed block's content.
+    case "merge-block":
+      return {
+        type: "split-block",
+        index: op.index - 1,
+        splitBlockId: op.removedBlockId,
+        depth: op.removedBlockDepth,
+        beforeContent: op.prevContent,
+        afterContent: op.appendedContent,
+        timestamp: now,
+      }
+
+    case "insert-block":
+      return {
+        type: "delete-block",
+        index: op.index,
+        blockId: op.blockId,
+        content: op.content,
+        depth: op.depth,
+        timestamp: now,
+      }
+
+    case "delete-block":
+      return {
+        type: "insert-block",
+        index: op.index,
+        blockId: op.blockId,
+        content: op.content,
+        depth: op.depth,
+        timestamp: now,
+      }
+
+    // Inverse of indent is outdent; carry children depths through so the
+    // undo can restore all cascaded descendants.
+    case "indent-block":
+      return {
+        type: "outdent-block",
+        index: op.index,
+        previousDepth: op.previousDepth + 1,
+        childrenPreviousDepths: op.childrenPreviousDepths?.map((c) => ({
+          index: c.index,
+          previousDepth: c.previousDepth + 1,
+        })),
+        timestamp: now,
+      }
+
+    case "outdent-block":
+      return {
+        type: "indent-block",
+        index: op.index,
+        previousDepth: op.previousDepth - 1,
+        childrenPreviousDepths: op.childrenPreviousDepths?.map((c) => ({
+          index: c.index,
+          previousDepth: c.previousDepth - 1,
+        })),
+        timestamp: now,
+      }
+
+    case "set-block-content":
+      return {
+        type: "set-block-content",
+        blockId: op.blockId,
+        previousContent: op.newContent,
+        newContent: op.previousContent,
+        timestamp: now,
+      }
+  }
+}
+
+// ── Operation history ────────────────────────────────────────────────
+
+export interface OperationHistory {
+  /** Execute an operation, compute its inverse, and push to the stack. */
+  execute(op: EditorOperation): ExecuteResult
+  /**
+   * Undo the most recent operation.
+   * Returns the inverse operation (caller should execute it), or null
+   * if there is nothing to undo.
+   */
+  undo(): EditorOperation | null
+  /**
+   * Redo the most recently undone operation.
+   * Returns the operation (caller should execute it), or null if there
+   * is nothing to redo.
+   */
+  redo(): EditorOperation | null
+  /** Whether undo is available */
+  readonly canUndo: boolean
+  /** Whether redo is available */
+  readonly canRedo: boolean
+  /** Clear all history */
+  clear(): void
+}
+
+interface HistoryEntry {
+  operation: EditorOperation
+  inverse: EditorOperation
+}
+
+export function createOperationHistory(
+  maxSize: number = 100,
+): OperationHistory {
+  const past: HistoryEntry[] = []
+  const future: HistoryEntry[] = []
+
+  return {
+    get canUndo() {
+      return past.length > 0
+    },
+
+    get canRedo() {
+      return future.length > 0
+    },
+
+    execute(op: EditorOperation): ExecuteResult {
+      const inverse = computeInverse(op)
+      past.push({ operation: op, inverse })
+      if (past.length > maxSize) {
+        past.shift()
+      }
+      future.length = 0
+      return { inverse }
+    },
+
+    undo(): EditorOperation | null {
+      if (past.length === 0) return null
+      const entry = past.pop() as HistoryEntry
+      future.push(entry)
+      return entry.inverse
+    },
+
+    redo(): EditorOperation | null {
+      if (future.length === 0) return null
+      const entry = future.pop() as HistoryEntry
+      past.push(entry)
+      return entry.operation
+    },
+
+    clear() {
+      past.length = 0
+      future.length = 0
+    },
+  }
+}

+ 3 - 0
pnpm-lock.yaml

@@ -45,6 +45,9 @@ importers:
         specifier: ^5.0.7
         version: 5.0.7(@vue/[email protected])([email protected]([email protected])([email protected]([email protected])))([email protected]([email protected]))
     devDependencies:
+      '@tailwindcss/vite':
+        specifier: ^4.3.0
+        version: 4.3.0([email protected](@types/[email protected])([email protected])([email protected])([email protected]))
       '@types/node':
         specifier: ^24.12.3
         version: 24.12.4