Bläddra i källkod

feat: implement core block editor with markdown decorations

- Phase 1: Minimal ProseMirror schema with raw markdown preservation
  * contentToDoc/docToContent for markdown ↔ doc conversion
  * Raw EditorView mounting in Vue component

- Phase 2: Keyboard handling with boundary events
  * Enter splits block, Backspace merges/deletes
  * Tab/Shift+Tab for indent/outdent
  * Arrow keys emit boundary events at document edges
  * Pattern triggers: /, [[, ((, # for slash menu and mentions

- Phase 3: Focus and cursor control
  * focused and cursorPosition props
  * defineExpose with view, focus, getContent, setContent

- Phase 4: Inline markdown decorations
  * Bold, italic, strikethrough, code, highlight
  * Links, page refs [[Name|Alias]], block refs ((id)), tags #tag
  * Property lines key:: value with cursor-aware reveal

- Phase 5: Block-level decorations
  * Headings # - ######, blockquotes >, task states
  * Priority flags [#A/B/C], dates 📅, callouts > [!NOTE/...]

- Additional: Refactored keyboard handlers to use ProseMirror commands,
  added comprehensive JSDoc, fixed biome lint issues
Zander Hawke 1 månad sedan
incheckning
9a21611c2f
50 ändrade filer med 4460 tillägg och 0 borttagningar
  1. 16 0
      .editorconfig
  2. 3 0
      .envrc
  3. 42 0
      .gitignore
  4. 13 0
      apps/dev/index.html
  5. 30 0
      apps/dev/package.json
  6. 0 0
      apps/dev/public/favicon.svg
  7. 24 0
      apps/dev/public/icons.svg
  8. 51 0
      apps/dev/src/App.vue
  9. 40 0
      apps/dev/src/components/AppLogo.vue
  10. 57 0
      apps/dev/src/components/Editor.vue
  11. 18 0
      apps/dev/src/main.ts
  12. 3 0
      apps/dev/src/pages/EditorView.vue
  13. 19 0
      apps/dev/src/style.css
  14. 6 0
      apps/dev/src/vue-env.d.ts
  15. 19 0
      apps/dev/tsconfig.app.json
  16. 7 0
      apps/dev/tsconfig.json
  17. 29 0
      apps/dev/tsconfig.node.json
  18. 62 0
      apps/dev/vite.config.ts
  19. 62 0
      biome.json
  20. 250 0
      bun.lock
  21. 65 0
      devenv.lock
  22. 5 0
      devenv.nix
  23. 18 0
      package.json
  24. 57 0
      packages/editor/package.json
  25. 177 0
      packages/editor/src/assets/style.css
  26. 258 0
      packages/editor/src/components/Block.vue
  27. 82 0
      packages/editor/src/components/__tests__/block-expose.test.ts
  28. 138 0
      packages/editor/src/components/__tests__/block-focus.test.ts
  29. 198 0
      packages/editor/src/components/__tests__/block-keyboard.test.ts
  30. 106 0
      packages/editor/src/composables/__tests__/markdown-decorations.test.ts
  31. 94 0
      packages/editor/src/composables/__tests__/pattern-plugin.test.ts
  32. 154 0
      packages/editor/src/composables/useBlockKeyboardHandlers.ts
  33. 359 0
      packages/editor/src/composables/useMarkdownDecorations.ts
  34. 375 0
      packages/editor/src/composables/usePatternPlugin.ts
  35. 14 0
      packages/editor/src/index.ts
  36. 81 0
      packages/editor/src/lib/__tests__/content-model.test.ts
  37. 483 0
      packages/editor/src/lib/__tests__/markdown-parser.test.ts
  38. 89 0
      packages/editor/src/lib/__tests__/schema.test.ts
  39. 70 0
      packages/editor/src/lib/content-model.ts
  40. 158 0
      packages/editor/src/lib/markdown-extensions.ts
  41. 557 0
      packages/editor/src/lib/markdown-parser.ts
  42. 41 0
      packages/editor/src/lib/schema.ts
  43. 11 0
      packages/editor/src/vite-env.d.ts
  44. 18 0
      packages/editor/tsconfig.app.json
  45. 11 0
      packages/editor/tsconfig.json
  46. 27 0
      packages/editor/tsconfig.node.json
  47. 24 0
      packages/editor/vite.config.ts
  48. 8 0
      packages/editor/vitest.config.ts
  49. 8 0
      packages/editor/vitest.setup.ts
  50. 23 0
      skills-lock.json

+ 16 - 0
.editorconfig

@@ -0,0 +1,16 @@
+root = true
+
+[*]
+indent_size = 2
+indent_style = space
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+max_line_length = 80
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.rs]
+indent_size = 4

+ 3 - 0
.envrc

@@ -0,0 +1,3 @@
+export DIRENV_WARN_TIMEOUT=20s
+eval "$(devenv direnvrc)"
+use devenv

+ 42 - 0
.gitignore

@@ -0,0 +1,42 @@
+# devenv
+.devenv*
+devenv.local.nix
+
+# direnv
+.direnv
+
+# pre-commit
+.pre-commit-config.yaml
+
+# Agents
+.agents
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Auto-generated type declarations
+auto-imports.d.ts
+components.d.ts
+

+ 13 - 0
apps/dev/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Enesis Editor Development</title>
+  </head>
+  <body>
+    <div id="app" class="isolate"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 30 - 0
apps/dev/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "@enesis/dev",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@enesis/editor": "workspace:*",
+    "@iconify-json/lucide": "^1.2.106",
+    "@iconify-json/simple-icons": "^1.2.81",
+    "@nuxt/ui": "^4.7.1",
+    "@tiptap/core": "^3.23.4",
+    "@tiptap/vue-3": "^3.23.4",
+    "tailwindcss": "^4.3.0",
+    "vue": "^3.5.34",
+    "vue-router": "^5.0.7"
+  },
+  "devDependencies": {
+    "@types/node": "^24.12.3",
+    "@vitejs/plugin-vue": "^6.0.6",
+    "@vue/tsconfig": "^0.9.1",
+    "typescript": "~6.0.2",
+    "vite": "^8.0.12",
+    "vue-tsc": "^3.2.8"
+  }
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
apps/dev/public/favicon.svg


+ 24 - 0
apps/dev/public/icons.svg

@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+  <symbol id="bluesky-icon" viewBox="0 0 16 17">
+    <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
+    <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
+  </symbol>
+  <symbol id="discord-icon" viewBox="0 0 20 19">
+    <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
+  </symbol>
+  <symbol id="documentation-icon" viewBox="0 0 21 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
+  </symbol>
+  <symbol id="github-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
+  </symbol>
+  <symbol id="social-icon" viewBox="0 0 20 20">
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
+    <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
+  </symbol>
+  <symbol id="x-icon" viewBox="0 0 19 19">
+    <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
+  </symbol>
+</svg>

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

@@ -0,0 +1,51 @@
+<script setup>
+// import Editor from "./components/Editor.vue"
+</script>
+
+<template>
+  <UApp>
+    <UHeader :toggle="false">
+      <template #left>
+        <AppLogo class="w-auto h-6 shrink-0" />
+      </template>
+
+      <template #right>
+        <UColorModeButton />
+
+        <UButton
+          to="https://codeberg.org/control/editor"
+          target="_blank"
+          icon="i-simple-icons:codeberg"
+          aria-label="codeberg.org"
+          color="neutral"
+          variant="ghost"
+        />
+      </template>
+    </UHeader>
+
+    <UMain>
+      <RouterView />
+    </UMain>
+
+    <USeparator icon="i-simple-icons:nuxtdotjs" />
+
+    <UFooter>
+      <template #left>
+        <p class="text-sm text-muted">
+          Built with Nuxt UI • © {{ new Date().getFullYear() }}
+        </p>
+      </template>
+
+      <template #right>
+        <UButton
+          to="https://codeberg.org/control/editor"
+          target="_blank"
+          icon="i-simple-icons:codeberg"
+          aria-label="codeberg.org"
+          color="neutral"
+          variant="ghost"
+        />
+      </template>
+    </UFooter>
+  </UApp>
+</template>

+ 40 - 0
apps/dev/src/components/AppLogo.vue

@@ -0,0 +1,40 @@
+<template>
+  <svg
+    width="1020"
+    height="200"
+    viewBox="0 0 1020 200"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M377 200C379.16 200 381 198.209 381 196V103C381 103 386 112 395 127L434 194C435.785 197.74 439.744 200 443 200H470V50H443C441.202 50 439 51.4941 439 54V148L421 116L385 55C383.248 51.8912 379.479 50 376 50H350V200H377Z"
+      fill="currentColor"
+    />
+    <path
+      d="M726 92H739C742.314 92 745 89.3137 745 86V60H773V92H800V116H773V159C773 169.5 778.057 174 787 174H800V200H783C759.948 200 745 185.071 745 160V116H726V92Z"
+      fill="currentColor"
+    />
+    <path
+      d="M591 92V154C591 168.004 585.742 179.809 578 188C570.258 196.191 559.566 200 545 200C530.434 200 518.742 196.191 511 188C503.389 179.809 498 168.004 498 154V92H514C517.412 92 520.769 92.622 523 95C525.231 97.2459 526 98.5652 526 102V154C526 162.059 526.457 167.037 530 171C533.543 174.831 537.914 176 545 176C552.217 176 555.457 174.831 559 171C562.543 167.037 563 162.059 563 154V102C563 98.5652 563.769 96.378 566 94C567.96 91.9107 570.028 91.9599 573 92C573.411 92.0055 574.586 92 575 92H591Z"
+      fill="currentColor"
+    />
+    <path
+      d="M676 144L710 92H684C680.723 92 677.812 93.1758 676 96L660 120L645 97C643.188 94.1758 639.277 92 636 92H611L645 143L608 200H634C637.25 200 640.182 196.787 642 194L660 167L679 195C680.818 197.787 683.75 200 687 200H713L676 144Z"
+      fill="currentColor"
+    />
+    <path
+      d="M168 200H279C282.542 200 285.932 198.756 289 197C292.068 195.244 295.23 193.041 297 190C298.77 186.959 300.002 183.51 300 179.999C299.998 176.488 298.773 173.04 297 170.001L222 41C220.23 37.96 218.067 35.7552 215 34C211.933 32.2448 207.542 31 204 31C200.458 31 197.067 32.2448 194 34C190.933 35.7552 188.77 37.96 187 41L168 74L130 9.99764C128.228 6.95784 126.068 3.75491 123 2C119.932 0.245087 116.542 0 113 0C109.458 0 106.068 0.245087 103 2C99.9323 3.75491 96.7717 6.95784 95 9.99764L2 170.001C0.226979 173.04 0.00154312 176.488 1.90993e-06 179.999C-0.0015393 183.51 0.229648 186.959 2 190C3.77035 193.04 6.93245 195.244 10 197C13.0675 198.756 16.4578 200 20 200H90C117.737 200 137.925 187.558 152 164L186 105L204 74L259 168H186L168 200ZM89 168H40L113 42L150 105L125.491 147.725C116.144 163.01 105.488 168 89 168Z"
+      fill="var(--ui-primary)"
+    />
+    <path
+      d="M958 60.0001H938C933.524 60.0001 929.926 59.9395 927 63C924.074 65.8905 925 67.5792 925 72V141C925 151.372 923.648 156.899 919 162C914.352 166.931 908.468 169 899 169C889.705 169 882.648 166.931 878 162C873.352 156.899 873 151.372 873 141V72.0001C873 67.5793 872.926 65.8906 870 63.0001C867.074 59.9396 863.476 60.0001 859 60.0001H840V141C840 159.023 845.016 173.458 855 184C865.156 194.542 879.893 200 899 200C918.107 200 932.844 194.542 943 184C953.156 173.458 958 159.023 958 141V60.0001Z"
+      fill="var(--ui-primary)"
+    />
+    <path
+      fill-rule="evenodd"
+      clip-rule="evenodd"
+      d="M1000 60.0233L1020 60V77L1020 128V156.007L1020 181L1020 189.004C1020 192.938 1019.98 194.429 1017 197.001C1014.02 199.725 1009.56 200 1005 200H986.001V181.006L986 130.012V70.0215C986 66.1576 986.016 64.5494 989 62.023C991.819 59.6358 995.437 60.0233 1000 60.0233Z"
+      fill="var(--ui-primary)"
+    />
+  </svg>
+</template>

+ 57 - 0
apps/dev/src/components/Editor.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import { Block } from "@enesis/editor"
+import { ref } from "vue"
+
+const content = ref(`# Welcome
+This is a **block editor** with markdown decorations.
+
+> [!NOTE]
+> This is a callout block with important information.
+
+[[Page Name]] creates a page reference chip, while ((block-id)) creates a block reference.
+
+#tag creates a tag chip, and you can use links like [this link](https://example.com).
+
+TODO this is a task item
+DOING this is another task
+DONE a completed task [#A]
+
+2026-05-20 is a date
+
+property:: This is a property line
+another:: [[Page reference]] in a property
+`)
+
+function log(event: string, payload?: unknown) {
+  console.log(`[Block] ${event}`, payload)
+
+  if (event === "blur") {
+    console.log(content.value)
+  }
+}
+</script>
+
+<template>
+  <div class="p-8 max-w-2xl mx-auto">
+    <Block
+      v-model:content="content"
+      :focused="true"
+      cursor-position="end"
+      @change="log('change', $event)"
+      @split="log('split', $event)"
+      @merge-previous="log('merge-previous')"
+      @delete-if-empty="log('delete-if-empty')"
+      @indent="log('indent')"
+      @outdent="log('outdent')"
+      @arrow-up-from-start="log('arrow-up-from-start')"
+      @arrow-down-from-end="log('arrow-down-from-end')"
+      @pattern-open="log('pattern-open', $event)"
+      @pattern-update="log('pattern-update', $event)"
+      @pattern-close="log('pattern-close', $event)"
+      @focus="log('focus', $event)"
+      @blur="log('blur')"
+      @selection-change="log('selection-change', $event)"
+      @cursor-change="log('cursor-change', $event)"
+    />
+  </div>
+</template>

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

@@ -0,0 +1,18 @@
+import ui from "@nuxt/ui/vue-plugin"
+import { createApp } from "vue"
+import { createRouter, createWebHistory } from "vue-router"
+import App from "~/App.vue"
+import EditorView from "~/pages/EditorView.vue"
+import "~/style.css"
+
+const routes = [{ path: "/", component: EditorView }]
+
+const app = createApp(App)
+const router = createRouter({
+  history: createWebHistory(),
+  routes,
+})
+
+app.use(router)
+app.use(ui)
+app.mount("#app")

+ 3 - 0
apps/dev/src/pages/EditorView.vue

@@ -0,0 +1,3 @@
+<template>
+  <Editor />
+</template>

+ 19 - 0
apps/dev/src/style.css

@@ -0,0 +1,19 @@
+@import "tailwindcss";
+@import "@nuxt/ui";
+@import "@enesis/editor";
+
+@theme static {
+  --font-sans: "Public Sans", sans-serif;
+
+  --color-green-50: #effdf5;
+  --color-green-100: #d9fbe8;
+  --color-green-200: #b3f5d1;
+  --color-green-300: #75edae;
+  --color-green-400: #00dc82;
+  --color-green-500: #00c16a;
+  --color-green-600: #00a155;
+  --color-green-700: #007f45;
+  --color-green-800: #016538;
+  --color-green-900: #0a5331;
+  --color-green-950: #052e16;
+}

+ 6 - 0
apps/dev/src/vue-env.d.ts

@@ -0,0 +1,6 @@
+declare module "*.vue" {
+  import type { DefineComponent } from "vue"
+
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}

+ 19 - 0
apps/dev/tsconfig.app.json

@@ -0,0 +1,19 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+    "types": ["vite/client"],
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+
+    "paths": {
+      "#build/ui/*": ["./node_modules/.nuxt-ui/ui/*"],
+      "~/*": ["./src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
+}

+ 7 - 0
apps/dev/tsconfig.json

@@ -0,0 +1,7 @@
+{
+  "files": [],
+  "references": [
+    { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.node.json" }
+  ]
+}

+ 29 - 0
apps/dev/tsconfig.node.json

@@ -0,0 +1,29 @@
+{
+  "compilerOptions": {
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+    "target": "es2023",
+    "lib": ["ES2023"],
+    "module": "esnext",
+    "types": ["node"],
+    "skipLibCheck": true,
+
+    /* Bundler mode */
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "verbatimModuleSyntax": true,
+    "moduleDetection": "force",
+    "noEmit": true,
+
+    /* Linting */
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "erasableSyntaxOnly": true,
+    "noFallthroughCasesInSwitch": true,
+
+    "paths": {
+      "#build/ui": ["./node_modules/.nuxt-ui/ui"],
+      "~": ["./src"]
+    }
+  },
+  "include": ["vite.config.ts"]
+}

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

@@ -0,0 +1,62 @@
+import { resolve } from "node:path"
+import ui from "@nuxt/ui/vite"
+import vue from "@vitejs/plugin-vue"
+import { defineConfig } from "vite"
+
+const PKGS_EDITOR = "../../packages/editor"
+
+export default defineConfig({
+  plugins: [
+    vue(),
+    ui({
+      scanPackages: ["@enesis/editor"],
+    }),
+  ],
+  build: {
+    sourcemap: false,
+  },
+  optimizeDeps: {
+    include: [
+      "prosemirror-state",
+      "prosemirror-view",
+      "prosemirror-model",
+      "prosemirror-transform",
+      "prosemirror-gapcursor",
+      "prosemirror-keymap",
+    ],
+    exclude: ["node"],
+  },
+  resolve: {
+    alias: {
+      "@enesis/editor": resolve(__dirname, `${PKGS_EDITOR}/src/index.ts`),
+      "prosemirror-state": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-state`,
+      ),
+      "prosemirror-view": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-view`,
+      ),
+      "prosemirror-model": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-model`,
+      ),
+      "prosemirror-transform": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-transform`,
+      ),
+      "prosemirror-gapcursor": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-gapcursor`,
+      ),
+      "prosemirror-keymap": resolve(
+        __dirname,
+        `${PKGS_EDITOR}/node_modules/prosemirror-keymap`,
+      ),
+      "~": resolve(__dirname, "src"),
+    },
+  },
+  server: {
+    sourcemapIgnoreList: (sourcePath) => sourcePath.includes("node_modules"),
+  },
+})

+ 62 - 0
biome.json

@@ -0,0 +1,62 @@
+{
+  "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
+  "vcs": {
+    "enabled": true,
+    "clientKind": "git",
+    "useIgnoreFile": true
+  },
+  "files": {
+    "ignoreUnknown": false
+  },
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "indentStyle": "space",
+    "indentWidth": 2,
+    "lineEnding": "lf",
+    "lineWidth": 80
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "recommended": true
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "quoteStyle": "double",
+      "semicolons": "asNeeded",
+      "trailingCommas": "all"
+    }
+  },
+  "css": {
+    "parser": {
+      "tailwindDirectives": true
+    }
+  },
+  "assist": {
+    "enabled": true,
+    "actions": {
+      "source": {
+        "organizeImports": "on"
+      }
+    }
+  },
+  "overrides": [
+    {
+      "includes": ["**/*.vue"],
+      "linter": {
+        "rules": {
+          "style": {
+            "useConst": "off",
+            "useImportType": "off"
+          },
+          "correctness": {
+            "noUnusedVariables": "off",
+            "noUnusedImports": "off"
+          }
+        }
+      }
+    }
+  ]
+}

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 250 - 0
bun.lock


+ 65 - 0
devenv.lock

@@ -0,0 +1,65 @@
+{
+  "nodes": {
+    "devenv": {
+      "locked": {
+        "dir": "src/modules",
+        "lastModified": 1779032662,
+        "narHash": "sha256-+qN6YTSArEThWA8XGCyIz0qZz1GTNRLRUcHxPjf1eZk=",
+        "owner": "cachix",
+        "repo": "devenv",
+        "rev": "58b04522edfe56e4b37a4d4600104db6429f4667",
+        "type": "github"
+      },
+      "original": {
+        "dir": "src/modules",
+        "owner": "cachix",
+        "repo": "devenv",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "inputs": {
+        "nixpkgs-src": "nixpkgs-src"
+      },
+      "locked": {
+        "lastModified": 1778507786,
+        "narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
+        "owner": "cachix",
+        "repo": "devenv-nixpkgs",
+        "rev": "8f24a228a782e24576b155d1e39f0d914b380691",
+        "type": "github"
+      },
+      "original": {
+        "owner": "cachix",
+        "ref": "rolling",
+        "repo": "devenv-nixpkgs",
+        "type": "github"
+      }
+    },
+    "nixpkgs-src": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1778274207,
+        "narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "devenv": "devenv",
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}

+ 5 - 0
devenv.nix

@@ -0,0 +1,5 @@
+{
+  languages.javascript.enable = true;
+  languages.javascript.bun.enable = true;
+  languages.javascript.npm.enable = true;
+}

+ 18 - 0
package.json

@@ -0,0 +1,18 @@
+{
+  "name": "enesis",
+  "private": true,
+  "scripts": {
+    "dev": "bun --filter @enesis/dev dev",
+    "dev:editor": "bun --filter @enesis/editor dev",
+    "test": "bun --filter @enesis/editor test",
+    "check": "biome check"
+  },
+  "workspaces": [
+    "apps/*",
+    "packages/*"
+  ],
+  "devDependencies": {
+    "@biomejs/biome": "^2.4.15",
+    "jsdom": "^29.1.1"
+  }
+}

+ 57 - 0
packages/editor/package.json

@@ -0,0 +1,57 @@
+{
+  "name": "@enesis/editor",
+  "type": "module",
+  "exports": {
+    ".": {
+      "import": "./dist/index.js",
+      "types": "./dist/index.d.ts",
+      "style": "./dist/index.css"
+    }
+  },
+  "main": "./dist/index.js",
+  "types": "./dist/index.d.ts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "vite build && vue-tsc --declaration --emitDeclarationOnly",
+    "dev": "vite build --watch",
+    "test": "vitest run",
+    "test:watch": "vitest"
+  },
+  "devDependencies": {
+    "@nuxt/ui": "^4.7.1",
+    "@tsconfig/node24": "^24.0.4",
+    "@types/node": "^25.9.0",
+    "@vitejs/plugin-vue": "^6.0.7",
+    "@vue/test-utils": "^2.4.10",
+    "@vue/tsconfig": "^0.9.1",
+    "@vueuse/core": "^14.3.0",
+    "pinia": "^3.0.4",
+    "typescript": "^6.0.3",
+    "vite": "^8.0.13",
+    "vitest": "^4.1.6",
+    "vue": "^3.5.34",
+    "vue-tsc": "^3.2.9"
+  },
+  "peerDependencies": {
+    "@nuxt/ui": "^4.7.1",
+    "@vueuse/core": "^14.3.0",
+    "pinia": "^3.0.4",
+    "vue": "^3.5.34"
+  },
+  "dependencies": {
+    "@lezer/common": "^1.2.2",
+    "@lezer/lr": "^1.4.2",
+    "@lezer/markdown": "^1.6.3",
+    "@nuxt/ui": "^4.7.1",
+    "@tailwindcss/vite": "^4.3.0",
+    "prosemirror-commands": "^1.7.1",
+    "prosemirror-gapcursor": "^1.4.1",
+    "prosemirror-keymap": "^1.2.3",
+    "prosemirror-model": "^1.25.7",
+    "prosemirror-state": "^1.4.3",
+    "prosemirror-transform": "^1.12.0",
+    "prosemirror-view": "^1.38.1"
+  }
+}

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

@@ -0,0 +1,177 @@
+@reference "tailwindcss";
+@reference "@nuxt/ui";
+
+.ProseMirror {
+  @apply outline-none min-h-7 whitespace-pre-wrap break-words;
+}
+
+.ProseMirror p {
+  @apply m-0;
+}
+
+/* Marks */
+.md-strong {
+  @apply font-bold;
+}
+.md-em {
+  @apply italic;
+}
+
+.md-code {
+  @apply font-mono bg-(--ui-bg-elevated) px-1 py-0.5 rounded text-sm;
+}
+
+.md-strike {
+  @apply line-through;
+}
+
+.md-highlight {
+  @apply bg-yellow-400/20 dark:bg-yellow-400/30 px-0.5 py-0.5 rounded;
+}
+
+.md-link {
+  @apply text-(--ui-primary) underline cursor-pointer;
+}
+
+/* References - Using safe CSS variables */
+.md-page-ref {
+  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-primary)/10 text-(--ui-primary) border border-(--ui-primary)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-primary)/15;
+}
+
+.md-block-ref {
+  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-purple)/10 text-(--ui-purple) border border-(--ui-purple)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-purple)/15;
+}
+
+.md-tag {
+  @apply inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-(--ui-success)/10 text-(--ui-success) border border-(--ui-success)/20 text-sm font-medium cursor-pointer hover:bg-(--ui-success)/15;
+}
+
+/* Properties */
+.md-property-key {
+  @apply text-(--ui-text-highlight) font-semibold;
+}
+
+.md-property-delimiter {
+  @apply text-(--ui-text-muted) font-semibold;
+}
+
+.md-property-value {
+  @apply text-(--ui-text-muted);
+}
+
+/* Hidden */
+.md-hidden,
+[data-md-hidden="true"] {
+  @apply opacity-0 text-[0px] pointer-events-none;
+}
+
+.md-page-ref-content[data-md-hidden="true"] {
+  @apply opacity-0 text-[0px] pointer-events-none;
+}
+
+/* Page Reference Alias */
+.md-page-ref-alias {
+  @apply bg-(--ui-primary)/10 text-(--ui-primary) px-1 py-px rounded text-[0.9em];
+}
+
+.ProseMirror .md-page-ref-alias {
+  @apply bg-(--ui-primary)/10 text-(--ui-primary);
+}
+
+/* Block-Level Decorations */
+.md-heading-content {
+  transition: all 0.2s ease-out;
+}
+
+.md-heading-1 {
+  @apply text-2xl font-bold;
+}
+.md-heading-2 {
+  @apply text-xl font-semibold;
+}
+.md-heading-3 {
+  @apply text-lg font-medium;
+}
+.md-heading-4 {
+  @apply text-base font-medium;
+}
+.md-heading-5 {
+  @apply text-sm font-medium;
+}
+.md-heading-6 {
+  @apply text-sm text-(--ui-text-muted);
+}
+
+.md-heading-marker {
+  @apply text-(--ui-text-muted) mr-2;
+}
+
+/* Task States */
+.md-task-marker {
+  @apply font-bold uppercase;
+}
+.md-task-todo {
+  @apply text-red-500;
+}
+.md-task-doing {
+  @apply text-yellow-500;
+}
+.md-task-done {
+  @apply text-green-500;
+}
+.md-task-later {
+  @apply text-gray-500;
+}
+.md-task-now {
+  @apply text-blue-500;
+}
+.md-task-waiting {
+  @apply text-orange-500;
+}
+.md-task-cancelled {
+  @apply text-gray-400 line-through;
+}
+
+/* Priority */
+.md-priority-marker {
+  @apply font-bold;
+}
+.md-priority-A {
+  @apply text-red-600 font-bold;
+}
+.md-priority-B {
+  @apply text-orange-500 font-bold;
+}
+.md-priority-C {
+  @apply text-gray-500;
+}
+
+/* Blockquote */
+.md-blockquote-marker {
+  @apply text-(--ui-primary) font-bold mr-2;
+}
+
+/* Callouts */
+.md-callout-marker {
+  @apply font-bold;
+}
+.md-callout-note {
+  @apply text-blue-500;
+}
+.md-callout-warning {
+  @apply text-yellow-600;
+}
+.md-callout-tip {
+  @apply text-green-500;
+}
+.md-callout-danger {
+  @apply text-red-600;
+}
+.md-callout-info {
+  @apply text-(--ui-primary);
+}
+
+/* Date */
+.md-date-marker {
+  @apply text-(--ui-text-muted);
+}

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

@@ -0,0 +1,258 @@
+<script setup lang="ts">
+import { watchDebounced } from "@vueuse/core"
+import { EditorState, TextSelection, type Transaction } from "prosemirror-state"
+import { EditorView } from "prosemirror-view"
+import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
+import { createBlockKeyboardHandler } from "../composables/useBlockKeyboardHandlers"
+import { createMarkdownDecorationsPlugin } from "../composables/useMarkdownDecorations"
+import { createPatternPlugin } from "../composables/usePatternPlugin"
+import type {
+  CloseReason,
+  PatternClosePayload,
+  PatternOpenPayload,
+  PatternUpdatePayload,
+} from "../composables/usePatternPlugin.ts"
+import { contentToDoc, docToContent } from "../lib/content-model"
+
+const content = defineModel<string>("content")
+
+const props = defineProps<{
+  focused?: boolean
+  cursorPosition?: "start" | "end" | number
+}>()
+
+const emit = defineEmits<{
+  change: [content: string]
+  split: [before: string, after: string]
+  "merge-previous": []
+  indent: []
+  outdent: []
+  "delete-if-empty": []
+  "arrow-up-from-start": []
+  "arrow-down-from-end": []
+  "pattern-open": [payload: PatternOpenPayload]
+  "pattern-update": [payload: PatternUpdatePayload]
+  "pattern-close": [PatternClosePayload]
+  focus: [payload: { view: EditorView; handlers: typeof formattingHandlers }]
+  blur: []
+  "selection-change": [payload: { from: number; to: number; empty: boolean }]
+}>()
+
+const handleKeyDown = createBlockKeyboardHandler({
+  split: (before, after) => emit("split", before, after),
+  "merge-previous": () => emit("merge-previous"),
+  "delete-if-empty": () => emit("delete-if-empty"),
+  indent: () => emit("indent"),
+  outdent: () => emit("outdent"),
+  "arrow-up-from-start": () => emit("arrow-up-from-start"),
+  "arrow-down-from-end": () => emit("arrow-down-from-end"),
+})
+
+const formattingHandlers = {
+  toggleBold: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  toggleItalic: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  toggleCode: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  toggleStrikethrough: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  insertBold: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  insertItalic: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  insertCode: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  insertStrikethrough: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+  insertLink: () => {
+    if (view) {
+      /* Phase 7 */
+    }
+  },
+}
+
+const editorRef = useTemplateRef("editorRef")
+let view: EditorView | null = null
+
+const markdownDecorationsPlugin = createMarkdownDecorationsPlugin()
+
+const patternPlugin = createPatternPlugin({
+  onPatternOpen: (payload) => emit("pattern-open", payload),
+  onPatternUpdate: (payload) => emit("pattern-update", payload),
+  onPatternClose: (payload) => emit("pattern-close", payload),
+})
+
+function focusAt(cursorPosition?: "start" | "end" | number) {
+  if (!view) return
+  const doc = view.state.doc
+
+  if (cursorPosition === "start") {
+    const pos = doc.content.size > 0 ? 1 : 0
+    if (pos >= 0 && pos <= doc.content.size) {
+      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
+    }
+    view.focus()
+    return
+  }
+
+  if (cursorPosition === "end") {
+    const pos = Math.max(1, doc.content.size - 1)
+    if (pos >= 0 && pos <= doc.content.size - 1) {
+      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
+    }
+    view.focus()
+    return
+  }
+
+  if (typeof cursorPosition === "number") {
+    if (doc.content.size <= 1) {
+      view.focus()
+      return
+    }
+    const pos = Math.max(1, Math.min(cursorPosition, doc.content.size - 1))
+    if (pos >= 0 && pos <= doc.content.size - 1) {
+      view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)))
+    }
+    view.focus()
+    return
+  }
+
+  view.focus()
+}
+
+onMounted(() => {
+  const initialDoc = contentToDoc(content.value || "")
+
+  let lastSelectionFrom = -1
+  let lastSelectionTo = -1
+
+  const editorState = EditorState.create({
+    doc: initialDoc,
+    plugins: [markdownDecorationsPlugin, patternPlugin],
+  })
+
+  const mountEl = editorRef.value
+  if (!mountEl) return
+
+  view = new EditorView(mountEl, {
+    state: editorState,
+    handleKeyDown,
+    dispatchTransaction(transaction: Transaction) {
+      const currentView = view
+      if (!currentView) return
+      const newState = currentView.state.apply(transaction)
+      currentView.updateState(newState)
+
+      if (transaction.docChanged && !transaction.getMeta("externalUpdate")) {
+        const newContent = docToContent(newState.doc)
+        content.value = newContent
+        emit("change", newContent)
+      }
+
+      const { from, to, empty } = newState.selection
+      if (from !== lastSelectionFrom || to !== lastSelectionTo) {
+        lastSelectionFrom = from
+        lastSelectionTo = to
+        emit("selection-change", { from, to, empty })
+      }
+    },
+    handleDOMEvents: {
+      focus() {
+        if (view) emit("focus", { view, handlers: formattingHandlers })
+        return false
+      },
+      blur() {
+        emit("blur")
+        return false
+      },
+    },
+  })
+
+  if (props.focused) {
+    focusAt(props.cursorPosition)
+  }
+})
+
+onUnmounted(() => {
+  if (view) {
+    view.destroy()
+    view = null
+  }
+})
+
+watch(
+  () => [props.focused, props.cursorPosition],
+  ([focused, cursorPosition]) => {
+    if (!view || !focused) return
+    focusAt(cursorPosition)
+  },
+)
+
+watchDebounced(
+  () => content.value,
+  (newContent) => {
+    if (!view) return
+    const currentContent = docToContent(view.state.doc)
+    if (currentContent === newContent) return
+
+    const newDoc = contentToDoc(newContent)
+    const tr = view.state.tr.replaceWith(
+      0,
+      view.state.doc.content.size,
+      newDoc.content,
+    )
+    tr.setMeta("externalUpdate", true)
+    view.dispatch(tr)
+  },
+  { debounce: 50 },
+)
+
+defineExpose({
+  get view() {
+    return view
+  },
+  focus: (pos?: "start" | "end" | number) => focusAt(pos),
+  getContent: () => (view ? docToContent(view.state.doc) : ""),
+  setContent: (md: string) => {
+    if (!view) return
+    const newDoc = contentToDoc(md)
+    const tr = view.state.tr.replaceWith(
+      0,
+      view.state.doc.content.size,
+      newDoc.content,
+    )
+    tr.setMeta("externalUpdate", true)
+    view.dispatch(tr)
+    content.value = md
+  },
+})
+</script>
+
+<template>
+  <div ref="editorRef" class="w-full min-h-7 text-base"></div>
+</template>

+ 82 - 0
packages/editor/src/components/__tests__/block-expose.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest"
+import { contentToDoc, docToContent } from "../../lib/content-model"
+
+// Unit tests for expose logic without mounting
+describe("Block expose API", () => {
+  describe("getContent logic", () => {
+    it("returns content from docToContent", () => {
+      const doc = contentToDoc("hello world")
+      const result = docToContent(doc)
+      expect(result).toBe("hello world")
+    })
+
+    it("handles multiline content", () => {
+      const doc = contentToDoc("line1\nline2\nline3")
+      const result = docToContent(doc)
+      expect(result).toBe("line1\nline2\nline3")
+    })
+
+    it("handles empty content", () => {
+      const doc = contentToDoc("")
+      const result = docToContent(doc)
+      expect(result).toBe("")
+    })
+
+    it("handles unicode content", () => {
+      const doc = contentToDoc("Hello 🌍 你好 🔥")
+      const result = docToContent(doc)
+      expect(result).toBe("Hello 🌍 你好 🔥")
+    })
+  })
+
+  describe("setContent logic", () => {
+    it("contentToDoc creates correct doc structure", () => {
+      const doc = contentToDoc("updated content")
+      expect(doc.childCount).toBe(1)
+      expect(doc.child(0).textContent).toBe("updated content")
+    })
+
+    it("roundtrips correctly", () => {
+      const original = "test content"
+      const doc = contentToDoc(original)
+      const result = docToContent(doc)
+      expect(result).toBe(original)
+    })
+
+    it("handles empty string", () => {
+      const doc = contentToDoc("")
+      expect(doc.childCount).toBe(1)
+      expect(doc.child(0).textContent).toBe("")
+    })
+
+    it("handles multiline strings", () => {
+      const doc = contentToDoc("line1\nline2\nline3")
+      expect(doc.childCount).toBe(3)
+      expect(doc.child(0).textContent).toBe("line1")
+      expect(doc.child(1).textContent).toBe("line2")
+      expect(doc.child(2).textContent).toBe("line3")
+    })
+  })
+
+  describe("view null safety", () => {
+    it("getContent returns empty string for null view", () => {
+      // Simulating the getContent logic when view is null
+      const view = null
+      const result = view ? docToContent(view.state.doc) : ""
+      expect(result).toBe("")
+    })
+
+    it("setContent is a no-op for null view", () => {
+      // Simulating the setContent logic when view is null
+      const view = null
+
+      // This should not throw
+      if (!view) {
+        // Early return in the actual implementation
+        expect(true).toBe(true)
+        return
+      }
+      expect(true).toBe(false) // Should not reach here
+    })
+  })
+})

+ 138 - 0
packages/editor/src/components/__tests__/block-focus.test.ts

@@ -0,0 +1,138 @@
+import { TextSelection } from "prosemirror-state"
+import { describe, expect, it, vi } from "vitest"
+import { contentToDoc } from "../../lib/content-model"
+
+describe("focusAt logic", () => {
+  // Test the focusAt function behavior directly
+  function focusAt(
+    doc: ReturnType<typeof contentToDoc>,
+    view: {
+      dispatch: (tr: { selection: { head: number } }) => void
+      focus: () => void
+    } | null,
+    cursorPosition?: "start" | "end" | number,
+  ) {
+    if (!view) return
+    const docObj = typeof doc === "string" ? contentToDoc(doc) : doc
+
+    let pos: number
+    if (cursorPosition === "start") {
+      pos = 1
+    } else if (cursorPosition === "end") {
+      pos = docObj.content.size - 1
+    } else if (typeof cursorPosition === "number") {
+      pos = Math.max(1, Math.min(cursorPosition, docObj.content.size - 1))
+    } else {
+      view.focus()
+      return
+    }
+
+    try {
+      view.dispatch({ selection: TextSelection.create(docObj, pos) })
+      view.focus()
+    } catch {
+      view.focus()
+    }
+  }
+
+  it("calculates start position correctly", () => {
+    const doc = contentToDoc("hello")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, "start")
+
+    expect(dispatch).toHaveBeenCalled()
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(1)
+    expect(focus).toHaveBeenCalled()
+  })
+
+  it("calculates end position correctly", () => {
+    const doc = contentToDoc("hello")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, "end")
+
+    expect(dispatch).toHaveBeenCalled()
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(doc.content.size - 1)
+    expect(focus).toHaveBeenCalled()
+  })
+
+  it("clamps number position to valid range", () => {
+    const doc = contentToDoc("hi")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    // Position 1000 should clamp to end
+    focusAt(doc, { dispatch, focus }, 1000)
+
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(doc.content.size - 1)
+  })
+
+  it("focuses without selection when no position given", () => {
+    const doc = contentToDoc("hello")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, undefined)
+
+    expect(dispatch).not.toHaveBeenCalled()
+    expect(focus).toHaveBeenCalled()
+  })
+
+  it("clamps position below 1 to 1", () => {
+    const doc = contentToDoc("hello")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, -5)
+
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(1)
+  })
+
+  it("handles empty document for position calculation", () => {
+    const doc = contentToDoc("")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    // Empty doc has size 2 (doc wrapper + empty paragraph)
+    // end position should be 1
+    focusAt(doc, { dispatch, focus }, "end")
+
+    expect(dispatch).toHaveBeenCalled()
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(1)
+  })
+
+  it("handles null view gracefully", () => {
+    // Should not throw
+    focusAt(contentToDoc("test"), null, "start")
+  })
+
+  it("handles specific valid position", () => {
+    const doc = contentToDoc("hello world")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, 5)
+
+    const call = dispatch.mock.calls[0][0]
+    expect(call.selection.anchor).toBe(5)
+  })
+
+  it("handles multiline document end position", () => {
+    const doc = contentToDoc("line1\nline2\nline3")
+    const dispatch = vi.fn()
+    const focus = vi.fn()
+
+    focusAt(doc, { dispatch, focus }, "end")
+
+    expect(dispatch).toHaveBeenCalled()
+    expect(focus).toHaveBeenCalled()
+  })
+})

+ 198 - 0
packages/editor/src/components/__tests__/block-keyboard.test.ts

@@ -0,0 +1,198 @@
+import { TextSelection } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
+import { describe, expect, it, vi } from "vitest"
+import {
+  type BlockKeyboardHandlers,
+  createBlockKeyboardHandler,
+} from "../../composables/useBlockKeyboardHandlers"
+import { contentToDoc } from "../../lib/content-model"
+
+function createMockKeyEvent(
+  key: string,
+  modifiers: Record<string, boolean> = {},
+): KeyboardEvent {
+  return {
+    key,
+    altKey: modifiers.alt ?? false,
+    ctrlKey: modifiers.ctrl ?? false,
+    metaKey: modifiers.meta ?? false,
+    shiftKey: modifiers.shift ?? modifiers.shiftKey ?? false,
+  } as KeyboardEvent
+}
+
+function createMockView(content: string, cursorPos: number) {
+  const doc = contentToDoc(content)
+  const selection = TextSelection.create(doc, cursorPos)
+
+  const endOfTextblock = (dir: string) => {
+    if (dir === "up") {
+      return cursorPos <= 1
+    }
+    if (dir === "down") {
+      return cursorPos >= content.length
+    }
+    return false
+  }
+
+  return {
+    state: {
+      doc,
+      selection,
+    },
+    coordsAtPos: () => ({ left: 100, top: 200 }),
+    dispatch: vi.fn(),
+    endOfTextblock,
+  } as unknown as EditorView
+}
+
+function createTestHandlers(): {
+  handlers: BlockKeyboardHandlers
+  emit: ReturnType<typeof vi.fn>
+} {
+  const emit = vi.fn()
+  const handlers: BlockKeyboardHandlers = {
+    split: (before, after) => emit("split", before, after),
+    "merge-previous": () => emit("merge-previous"),
+    "delete-if-empty": () => emit("delete-if-empty"),
+    indent: () => emit("indent"),
+    outdent: () => emit("outdent"),
+    "arrow-up-from-start": () => emit("arrow-up-from-start"),
+    "arrow-down-from-end": () => emit("arrow-down-from-end"),
+  }
+  return { handlers, emit }
+}
+
+describe("Block keyboard handler", () => {
+  describe("Enter", () => {
+    it("emits split with correct before/after when pressed in middle", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("hello world", 6)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Enter"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("split", "hello", " world")
+    })
+  })
+
+  describe("Backspace at position 1", () => {
+    it("emits merge-previous when at position 1", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("hello", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Backspace"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("merge-previous")
+    })
+  })
+
+  describe("Tab", () => {
+    it("emits indent when pressed", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Tab"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("indent")
+    })
+
+    it("emits outdent when shift+tab pressed", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("Tab", { shiftKey: true }),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("outdent")
+    })
+  })
+
+  describe("Arrow keys", () => {
+    it("emits arrow-up-from-start when arrow up at document start", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("hello", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("ArrowUp"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("arrow-up-from-start")
+    })
+
+    it("emits arrow-down-from-end when arrow down at document end", () => {
+      const { handlers, emit } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("hello", 6)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("ArrowDown"),
+      )
+
+      expect(result).toBe(true)
+      expect(emit).toHaveBeenCalledWith("arrow-down-from-end")
+    })
+  })
+
+  describe("Pattern detection", () => {
+    it("returns false for / (handled by plugin)", () => {
+      const { handlers } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("/"),
+      )
+
+      expect(result).toBe(false)
+    })
+
+    it("returns false for [ (handled by plugin)", () => {
+      const { handlers } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("[", 2)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("["),
+      )
+
+      expect(result).toBe(false)
+    })
+
+    it("returns false for # (handled by plugin)", () => {
+      const { handlers } = createTestHandlers()
+      const handleKeyDown = createBlockKeyboardHandler(handlers)
+      const mockView = createMockView("", 1)
+
+      const result = handleKeyDown(
+        mockView as EditorView,
+        createMockKeyEvent("#"),
+      )
+
+      expect(result).toBe(false)
+    })
+  })
+})

+ 106 - 0
packages/editor/src/composables/__tests__/markdown-decorations.test.ts

@@ -0,0 +1,106 @@
+import { EditorState } from "prosemirror-state"
+import { describe, expect, it } from "vitest"
+import { contentToDoc } from "../../lib/content-model"
+import { createMarkdownDecorationsPlugin } from "../useMarkdownDecorations"
+
+describe("Markdown decorations plugin", () => {
+  it("builds decorations on init with content", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello **world**")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBeGreaterThan(0)
+  })
+
+  it("returns empty decorations with empty document on init", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBe(0)
+  })
+
+  it("rebuilds decorations when document changes", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("plain")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const tr = editorState.tr.insertText(" **bold**", 5)
+    const newState = editorState.apply(tr)
+    const newPluginState = plugin.getState(newState)
+
+    expect(newPluginState?.find().length).toBeGreaterThan(0)
+  })
+
+  it("builds decorations for highlight", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello ^^world^^")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBeGreaterThan(0)
+  })
+
+  it("builds decorations for page reference", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello [[Page]]")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBeGreaterThan(0)
+  })
+
+  it("builds decorations for block reference", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello ((block-id))")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBeGreaterThan(0)
+  })
+
+  it("builds decorations for tag", () => {
+    const plugin = createMarkdownDecorationsPlugin()
+    const doc = contentToDoc("Hello #tag")
+
+    const editorState = EditorState.create({
+      doc,
+      plugins: [plugin],
+    })
+
+    const pluginState = plugin.getState(editorState)
+    expect(pluginState).not.toBeNull()
+    expect(pluginState?.find().length).toBeGreaterThan(0)
+  })
+})

+ 94 - 0
packages/editor/src/composables/__tests__/pattern-plugin.test.ts

@@ -0,0 +1,94 @@
+import { EditorState, TextSelection } from "prosemirror-state"
+import { describe, expect, it, vi } from "vitest"
+import { contentToDoc } from "../../lib/content-model"
+import {
+  createPatternPlugin,
+  getPatternSession,
+  type PatternSession,
+} from "../usePatternPlugin"
+
+describe("Pattern plugin", () => {
+  function createTestState(content: string) {
+    const doc = contentToDoc(content)
+    const firstChild = doc.firstChild
+    const pos = firstChild ? firstChild.content.size + 1 : 1
+    const plugins = [
+      createPatternPlugin({
+        onPatternOpen: vi.fn(),
+        onPatternUpdate: vi.fn(),
+        onPatternClose: vi.fn(),
+      }),
+    ]
+    return EditorState.create({
+      doc,
+      selection: TextSelection.create(doc, pos),
+      plugins,
+    })
+  }
+
+  function getSession(state: EditorState): PatternSession | null {
+    const newState = state.apply(state.tr)
+    return getPatternSession(newState)
+  }
+
+  it("detects [[ pattern", () => {
+    const state = createTestState("[[query")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("[[")
+    expect(session?.kind).toBe("reference")
+    expect(session?.query).toBe("query")
+  })
+
+  it("detects (( pattern", () => {
+    const state = createTestState("((query")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("((")
+    expect(session?.kind).toBe("embed")
+    expect(session?.query).toBe("query")
+  })
+
+  it("detects / command pattern", () => {
+    const state = createTestState("/cmd")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("/")
+    expect(session?.kind).toBe("command")
+    expect(session?.query).toBe("cmd")
+  })
+
+  it("detects # tag pattern", () => {
+    const state = createTestState("#tag")
+    const session = getSession(state)
+
+    expect(session).toBeTruthy()
+    expect(session?.trigger).toBe("#")
+    expect(session?.kind).toBe("tag")
+    expect(session?.query).toBe("tag")
+  })
+
+  it("returns null when no pattern detected", () => {
+    const state = createTestState("hello world")
+    const session = getSession(state)
+
+    expect(session).toBeNull()
+  })
+
+  it("completes [[ pattern", () => {
+    const state = createTestState("[[Page]]")
+    const session = getSession(state)
+
+    expect(session).toBeNull()
+  })
+
+  it("completes (( pattern", () => {
+    const state = createTestState("((block))")
+    const session = getSession(state)
+
+    expect(session).toBeNull()
+  })
+})

+ 154 - 0
packages/editor/src/composables/useBlockKeyboardHandlers.ts

@@ -0,0 +1,154 @@
+/**
+ * Block keyboard handler for ProseMirror.
+ *
+ * Handles key events for block editor:
+ * - Enter: split block (emit to parent)
+ * - Shift+Enter: insert new paragraph within block
+ * - Backspace: chain of delete, join, merge-previous, delete-if-empty
+ * - Delete: join forward
+ * - Arrow keys: emit boundary events at document edges
+ * - Tab/Shift+Tab: indent/outdent
+ *
+ * Uses ProseMirror commands for default behavior where possible.
+ */
+
+import {
+  deleteSelection,
+  joinBackward,
+  joinForward,
+} from "prosemirror-commands"
+import type { EditorState } from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
+
+export interface BlockKeyboardHandlers {
+  split: (before: string, after: string) => void
+  "merge-previous": () => void
+  "delete-if-empty": () => void
+  indent: () => void
+  outdent: () => void
+  "arrow-up-from-start": () => void
+  "arrow-down-from-end": () => void
+}
+
+// Position detection helpers
+function atDocumentStart(state: EditorState): boolean {
+  return state.selection.head <= 1
+}
+
+function atParagraphStart(_state: EditorState, view: EditorView): boolean {
+  return view.endOfTextblock("up")
+}
+
+function atParagraphEnd(_state: EditorState, view: EditorView): boolean {
+  return view.endOfTextblock("down")
+}
+
+function isSingleEmptyParagraph(state: EditorState): boolean {
+  const { doc } = state
+  return doc.childCount === 1 && doc.child(0).content.size === 0
+}
+
+export function createBlockKeyboardHandler(handlers: BlockKeyboardHandlers) {
+  return function handleKeyDown(
+    view: EditorView,
+    event: KeyboardEvent,
+  ): boolean {
+    const state = view.state
+
+    // Shift+Enter: insert new paragraph (soft line break within block)
+    if (event.key === "Enter" && event.shiftKey) {
+      const $head = state.selection.$head
+      const paragraph = state.schema.nodes.paragraph.create()
+      const tr = state.tr.insert($head.after(), paragraph)
+      tr.setSelection(
+        state.selection.constructor.near(tr.doc.resolve($head.after() + 1)),
+      )
+      view.dispatch(tr)
+      view.focus()
+      return true
+    }
+
+    // Enter: split block (create new Block.vue at parent level)
+    if (event.key === "Enter") {
+      const $head = state.selection.$head
+      const parentText = $head.parent.textContent ?? ""
+      const before = parentText.slice(0, $head.parentOffset)
+      const after = parentText.slice($head.parentOffset)
+      handlers.split(before, after)
+      return true
+    }
+
+    // Backspace: chain of commands
+    if (event.key === "Backspace" && state.selection.empty) {
+      // Try default delete selection first
+      if (deleteSelection(state, view.dispatch)) {
+        return true
+      }
+
+      // At document start of single empty paragraph
+      if (atDocumentStart(state) && isSingleEmptyParagraph(state)) {
+        handlers["delete-if-empty"]()
+        return true
+      }
+
+      // At document start of first paragraph
+      if (atDocumentStart(state) && atParagraphStart(state, view)) {
+        handlers["merge-previous"]()
+        return true
+      }
+
+      // Otherwise try ProseMirror's joinBackward
+      if (joinBackward(state, view.dispatch, view)) {
+        return true
+      }
+
+      return false
+    }
+
+    // Delete: try ProseMirror's joinForward
+    if (event.key === "Delete" && state.selection.empty) {
+      if (joinForward(state, view.dispatch, view)) {
+        return true
+      }
+      return false
+    }
+
+    // Tab: indent
+    if (event.key === "Tab") {
+      if (event.shiftKey) {
+        handlers.outdent()
+      } else {
+        handlers.indent()
+      }
+      return true
+    }
+
+    // ArrowUp: emit at document start
+    if (event.key === "ArrowUp" && state.selection.empty) {
+      const $head = state.selection.$head
+      const isFirstParagraph = $head.index($head.depth - 1) === 0
+
+      if (isFirstParagraph && atParagraphStart(state, view)) {
+        handlers["arrow-up-from-start"]()
+        return true
+      }
+      return false
+    }
+
+    // ArrowDown: emit at document end
+    if (event.key === "ArrowDown" && state.selection.empty) {
+      const $head = state.selection.$head
+      const docNode = $head.node($head.depth - 1)
+      const isLastParagraph =
+        $head.index($head.depth - 1) === docNode.childCount - 1
+
+      if (isLastParagraph && atParagraphEnd(state, view)) {
+        handlers["arrow-down-from-end"]()
+        return true
+      }
+      return false
+    }
+
+    return false
+  }
+}

+ 359 - 0
packages/editor/src/composables/useMarkdownDecorations.ts

@@ -0,0 +1,359 @@
+/**
+ * Markdown decorations plugin for ProseMirror.
+ *
+ * Parses markdown text using Lezer and renders decorations for:
+ * - Bold, italic, strikethrough, inline code, highlight
+ * - Links (hidden delimiters, styled text)
+ * - Page references [[Page Name]] and [[Page Name|Alias]]
+ * - Block references ((block-id))
+ * - Tags #tag
+ * - Property lines (key:: value)
+ * - Cursor-aware reveal: delimiters hidden when cursor inside formatted region
+ */
+
+import type { Node as ProsemirrorNode } from "prosemirror-model"
+import { Plugin, PluginKey } from "prosemirror-state"
+import { Decoration, DecorationSet } from "prosemirror-view"
+import {
+  defaultPatterns,
+  type PatternDecorationConfig,
+} from "../lib/markdown-extensions"
+import {
+  type BlockRefDecoration,
+  getActiveDecorations,
+  type HighlightDecoration,
+  type PageRefDecoration,
+  type TagDecoration,
+  type TokenDecoration,
+} from "../lib/markdown-parser"
+
+type Token =
+  | TokenDecoration
+  | PageRefDecoration
+  | BlockRefDecoration
+  | TagDecoration
+  | HighlightDecoration
+
+type DecorationSpec = ReturnType<typeof Decoration.inline>
+
+function hasPageName(token: Token): token is PageRefDecoration {
+  return "pageName" in token
+}
+
+function hasBlockId(token: Token): token is BlockRefDecoration {
+  return "blockId" in token
+}
+
+function hasTag(token: Token): token is Token {
+  return "tag" in token
+}
+
+function hasAlias(token: Token): token is PageRefDecoration {
+  return (
+    "alias" in token && typeof (token as PageRefDecoration).alias === "string"
+  )
+}
+
+function getTokenName(token: Token): string {
+  if (hasPageName(token)) return token.pageName
+  if (hasBlockId(token)) return token.blockId
+  if (hasTag(token) && "tag" in token) return (token as { tag: string }).tag
+  return ""
+}
+
+const markdownDecorationsKey = new PluginKey("markdownDecorations")
+
+const decorationConfigMap: Record<string, PatternDecorationConfig> =
+  defaultPatterns.reduce(
+    (acc, pattern) => {
+      if (pattern.decoration) {
+        acc[pattern.nodeType] = pattern.decoration
+      }
+      return acc
+    },
+    {} as Record<string, PatternDecorationConfig>,
+  )
+
+/**
+ * Build decoration set from ProseMirror document.
+ * Walks each text node and applies decorations based on Lezer parsing.
+ */
+function buildDecorationSet(
+  doc: ProsemirrorNode,
+  cursorPos: number,
+): DecorationSet {
+  const decorationSpecs: ReturnType<typeof Decoration.inline>[] = []
+
+  doc.descendants((node: ProsemirrorNode, pos: number) => {
+    if (!node.isTextblock) return
+
+    const blockStart = pos + 1
+
+    let blockText = ""
+    for (let i = 0; i < node.childCount; i++) {
+      const child = node.child(i)
+      if (child.type.name === "hardBreak") {
+        blockText += "\n"
+      } else if (child.isText && child.text) {
+        blockText += child.text
+      }
+    }
+
+    const blockCursor =
+      cursorPos >= blockStart && cursorPos <= blockStart + blockText.length
+        ? cursorPos - blockStart
+        : 0
+    const { blocks } = getActiveDecorations(blockText, blockCursor)
+
+    for (const block of blocks) {
+      const [markerFrom, markerTo] = block.markerRange
+      const [contentFrom, contentTo] = block.contentRange
+
+      if (block.type === "heading") {
+        const level = block.metadata?.level || 1
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: `md-heading-marker md-heading-${level}`,
+            "data-md-block": "heading",
+          }),
+        )
+        if (contentFrom < contentTo) {
+          decorationSpecs.push(
+            Decoration.inline(
+              blockStart + contentFrom,
+              blockStart + contentTo,
+              {
+                class: `md-heading-content md-heading-${level}`,
+                "data-md-block": "heading",
+              },
+            ),
+          )
+        }
+      } else if (block.type === "task") {
+        const taskState = block.metadata?.taskState || "TODO"
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: `md-task-marker md-task-${taskState.toLowerCase()}`,
+            "data-md-block": "task",
+          }),
+        )
+      } else if (block.type === "priority") {
+        const priority = block.metadata?.priority || "B"
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: `md-priority-marker md-priority-${priority}`,
+            "data-md-block": "priority",
+          }),
+        )
+      } else if (block.type === "blockquote") {
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: "md-blockquote-marker",
+            "data-md-block": "blockquote",
+          }),
+        )
+      } else if (block.type === "callout") {
+        const calloutType = block.metadata?.calloutType || "NOTE"
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: `md-callout-marker md-callout-${calloutType.toLowerCase()}`,
+            "data-md-block": "callout",
+          }),
+        )
+      } else if (block.type === "date") {
+        decorationSpecs.push(
+          Decoration.inline(blockStart + markerFrom, blockStart + markerTo, {
+            class: "md-date-marker",
+            "data-md-block": "date",
+          }),
+        )
+      }
+    }
+
+    let localPos = 0
+
+    for (let i = 0; i < node.childCount; i++) {
+      const child = node.child(i)
+
+      if (child.type.name === "hardBreak") {
+        localPos += 1
+        continue
+      }
+
+      if (!child.isText || !child.text) continue
+
+      const text = child.text
+      const childOffset = blockStart + localPos
+
+      const adjustedCursor = cursorPos - childOffset
+      const { content: tokens, properties } = getActiveDecorations(
+        text,
+        adjustedCursor < 0 ? 0 : adjustedCursor,
+      )
+
+      function applyPatternDecoration(
+        token: Token,
+        childOffset: number,
+        decorationSpecs: DecorationSpec[],
+      ) {
+        const config = decorationConfigMap[token.type]
+        if (!config) return false
+
+        const hiddenClass = config.hiddenClass || "md-hidden"
+
+        const lastDelim = token.delimiters[token.delimiters.length - 1]
+        const isThreePart =
+          token.delimiters.length >= 3 && lastDelim.type === "hidden"
+
+        for (const delim of token.delimiters) {
+          if (delim.type === "hidden") {
+            decorationSpecs.push(
+              Decoration.inline(
+                delim.from + childOffset,
+                delim.to + childOffset,
+                { class: hiddenClass, "data-md-hidden": "true" },
+              ),
+            )
+          } else {
+            const contentFrom = delim.from + childOffset
+            const contentTo = delim.to + childOffset
+
+            const showAlias = isThreePart && config.showAlias && hasAlias(token)
+            if (showAlias) {
+              const widget = document.createElement("span")
+              widget.className = config.widgetClass || config.contentClass
+              widget.setAttribute("data-md-type", token.type)
+              widget.setAttribute(
+                `data-${token.type.split("-")[0]}-name`,
+                getTokenName(token),
+              )
+              if (hasAlias(token) && (token as PageRefDecoration).alias)
+                widget.setAttribute(
+                  `data-${token.type.split("-")[0]}-alias`,
+                  (token as PageRefDecoration).alias,
+                )
+              widget.textContent =
+                (token as PageRefDecoration).alias || getTokenName(token)
+
+              decorationSpecs.push(
+                Decoration.widget(contentFrom, widget, { side: 1 }),
+              )
+              decorationSpecs.push(
+                Decoration.inline(contentFrom, contentTo, {
+                  class: config.contentClass,
+                  "data-md-hidden": "true",
+                }),
+              )
+            } else {
+              const attrs: Record<string, string> = {
+                class: config.contentClass,
+                "data-md-type": token.type,
+              }
+              if (hasPageName(token)) attrs["data-page-name"] = token.pageName
+              if (hasBlockId(token)) attrs["data-block-id"] = token.blockId
+
+              decorationSpecs.push(
+                Decoration.inline(contentFrom, contentTo, attrs),
+              )
+            }
+          }
+        }
+
+        return true
+      }
+
+      for (const token of tokens) {
+        if (applyPatternDecoration(token, childOffset, decorationSpecs)) {
+          continue
+        }
+
+        for (const delim of token.delimiters) {
+          if (delim.type === "hidden") {
+            decorationSpecs.push(
+              Decoration.inline(
+                delim.from + childOffset,
+                delim.to + childOffset,
+                {
+                  class: "md-hidden",
+                  "data-md-hidden": "true",
+                },
+              ),
+            )
+          } else {
+            decorationSpecs.push(
+              Decoration.inline(
+                delim.from + childOffset,
+                delim.to + childOffset,
+                {
+                  class: delim.className || "",
+                  "data-md-type": token.type,
+                },
+              ),
+            )
+          }
+        }
+      }
+
+      for (const prop of properties) {
+        const [keyFrom, keyTo] = prop.keyRange
+        const [delimFrom, delimTo] = prop.delimiterRange
+        const [valueFrom, valueTo] = prop.valueRange
+
+        decorationSpecs.push(
+          Decoration.inline(keyFrom + childOffset, keyTo + childOffset, {
+            class: "md-property-key",
+          }),
+        )
+        decorationSpecs.push(
+          Decoration.inline(delimFrom + childOffset, delimTo + childOffset, {
+            class: "md-property-delimiter",
+          }),
+        )
+        if (prop.value) {
+          decorationSpecs.push(
+            Decoration.inline(valueFrom + childOffset, valueTo + childOffset, {
+              class: "md-property-value",
+            }),
+          )
+        }
+      }
+
+      localPos += text.length
+    }
+  })
+
+  return DecorationSet.create(doc, decorationSpecs)
+}
+
+/**
+ * Create a ProseMirror plugin for markdown decorations.
+ * Each block should have its own plugin instance.
+ * @returns Plugin to be added to EditorState
+ */
+export function createMarkdownDecorationsPlugin() {
+  const plugin = new Plugin({
+    key: markdownDecorationsKey,
+    state: {
+      init(_, state) {
+        const cursor = state.selection?.head ?? 1
+        return buildDecorationSet(state.doc, cursor)
+      },
+      apply(tr, oldPluginState) {
+        if (!tr.docChanged && !tr.selectionSet) {
+          return oldPluginState
+        }
+        const cursor = tr.selection.head
+        return buildDecorationSet(tr.doc, cursor)
+      },
+    },
+    props: {
+      decorations(state) {
+        return this.getState(state) ?? DecorationSet.empty
+      },
+    },
+  })
+
+  return plugin
+}
+
+export type { DecorationSet }

+ 375 - 0
packages/editor/src/composables/usePatternPlugin.ts

@@ -0,0 +1,375 @@
+/**
+ * Pattern detection plugin for ProseMirror.
+ *
+ * Detects trigger patterns at cursor position and emits events:
+ * - "/" at word boundary → trigger("slash") for command menu
+ * - "[[" at word boundary → trigger("page-ref") for page suggestions
+ * - "((" at word boundary → trigger("block-ref") for block suggestions
+ * - "#" at word boundary → trigger("tag") for tag suggestions
+ *
+ * Emits "cancelled" when pattern session ends without completion.
+ */
+
+import {
+  type EditorState,
+  Plugin,
+  PluginKey,
+  TextSelection,
+  type Transaction,
+} from "prosemirror-state"
+import type { EditorView } from "prosemirror-view"
+
+export type PatternKind = "reference" | "embed" | "command" | "tag"
+export type CloseReason = "completed" | "cancelled"
+export type Termination = "delimiter" | "boundary" | "manual"
+
+export interface PatternSpec {
+  start: string
+  end?: string
+  kind: PatternKind
+  termination: Termination
+  priority?: number
+}
+
+const PATTERN_SPECS: PatternSpec[] = [
+  {
+    start: "[[",
+    end: "]]",
+    kind: "reference",
+    termination: "delimiter",
+    priority: 3,
+  },
+  {
+    start: "((",
+    end: "))",
+    kind: "embed",
+    termination: "delimiter",
+    priority: 3,
+  },
+  { start: "/", kind: "command", termination: "boundary", priority: 2 },
+  { start: "#", kind: "tag", termination: "boundary", priority: 1 },
+].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+
+export interface PatternSession {
+  id: string
+  trigger: string
+  kind: PatternKind
+  query: string
+  range: { from: number; to: number }
+  position: { x: number; y: number }
+}
+
+export interface PatternOpenPayload {
+  id: string
+  trigger: string
+  kind: PatternKind
+  query: string
+  range: { from: number; to: number }
+  position: { x: number; y: number }
+  respond: (opts: {
+    text: string
+    mode?: "partial" | "replace" | "append"
+  }) => void
+  close: (reason?: CloseReason) => void
+}
+
+export interface PatternUpdatePayload {
+  id: string
+  trigger: string
+  kind: PatternKind
+  query: string
+  range: { from: number; to: number }
+  position: { x: number; y: number }
+}
+
+export interface PatternClosePayload {
+  id: string
+  reason: CloseReason
+}
+
+export interface PatternCallbacks {
+  onPatternOpen: (payload: PatternOpenPayload) => void
+  onPatternUpdate: (payload: PatternUpdatePayload) => void
+  onPatternClose: (payload: PatternClosePayload) => void
+}
+
+export const patternPluginKey = new PluginKey<PatternSession | null>("pattern")
+
+export function getPatternSession(state: EditorState): PatternSession | null {
+  return patternPluginKey.getState(state)
+}
+const forceCloseMetaKey = "pattern-force-close"
+
+function findPattern(
+  state: EditorState,
+  prevSession: PatternSession | null,
+): PatternSession | null {
+  const { $head } = state.selection
+  if (!$head.parent.isTextblock) return null
+
+  const text = $head.parent.textContent
+  const offset = $head.parentOffset
+  const textBefore = text.slice(0, offset)
+
+  for (const spec of PATTERN_SPECS) {
+    const match = matchPattern(textBefore, spec)
+    if (!match) continue
+
+    const { startIdx, trigger, query } = match
+    const startPos = $head.start() + startIdx
+
+    if (isPatternCompleted(spec, query)) return null
+
+    if (spec.termination === "boundary" && startIdx > 0) {
+      const charBefore = textBefore[startIdx - 1]
+      if (charBefore && !/[\s\n]/.test(charBefore)) continue
+    }
+
+    const id =
+      prevSession &&
+      prevSession.trigger === trigger &&
+      prevSession.range.from === startPos
+        ? prevSession.id
+        : `pattern-${Math.random().toString(36).slice(2, 8)}`
+
+    return {
+      id,
+      trigger,
+      kind: spec.kind,
+      query,
+      range: { from: startPos, to: startPos + trigger.length + query.length },
+      position: { x: 0, y: 0 },
+    }
+  }
+  return null
+}
+
+function matchPattern(
+  textBefore: string,
+  spec: PatternSpec,
+): { startIdx: number; trigger: string; query: string } | null {
+  const start = spec.start
+  const idx = textBefore.lastIndexOf(start)
+  if (idx === -1) return null
+
+  const afterTrigger = textBefore.slice(idx + start.length)
+  const queryMatch = afterTrigger.match(/^[^\s\n]*/)
+  if (!queryMatch) return null
+  const query = queryMatch[0]
+  if (afterTrigger !== query) return null
+
+  return { startIdx: idx, trigger: start, query }
+}
+
+function isPatternCompleted(spec: PatternSpec, query: string): boolean {
+  if (spec.termination === "delimiter" && spec.end) {
+    return query.includes(spec.end)
+  }
+  if (spec.termination === "boundary") {
+    return !/^[^\s\n]*$/.test(query)
+  }
+  return false
+}
+
+function getCurrentRange(
+  state: EditorState,
+  trigger: string,
+): { from: number; to: number } | null {
+  const { $head } = state.selection
+  if (!$head.parent.isTextblock) return null
+
+  const text = $head.parent.textContent
+  const offset = $head.parentOffset
+  const textBefore = text.slice(0, offset)
+
+  const startIdx = textBefore.lastIndexOf(trigger)
+  if (startIdx === -1) return null
+
+  const startPos = $head.start() + startIdx
+  const query = textBefore.slice(startIdx + trigger.length)
+
+  return { from: startPos, to: startPos + trigger.length + query.length }
+}
+
+function isPageRefComplete(query: string): boolean {
+  return query.includes("]]")
+}
+
+function isBlockRefComplete(query: string): boolean {
+  return query.includes(")")
+}
+
+class PatternView {
+  private view: EditorView
+  private callbacks: PatternCallbacks
+  private currentSession: PatternSession | null = null
+  private manualClose = false
+
+  constructor(view: EditorView, callbacks: PatternCallbacks) {
+    this.view = view
+    this.callbacks = callbacks
+  }
+
+  update(view: EditorView) {
+    const state = view.state
+    const newSession = patternPluginKey.getState(state)
+    const prevSession = this.currentSession
+
+    if (newSession && !prevSession) {
+      this.open(newSession)
+    } else if (
+      newSession &&
+      prevSession &&
+      newSession.query !== prevSession.query
+    ) {
+      this.updateQuery(newSession)
+    } else if (!newSession && prevSession) {
+      this.close(prevSession)
+    }
+
+    this.currentSession = newSession || null
+  }
+
+  private open(session: PatternSession) {
+    const pos = this.view.coordsAtPos(session.range.from)
+    const payload: PatternOpenPayload = {
+      id: session.id,
+      trigger: session.trigger,
+      kind: session.kind,
+      query: session.query,
+      range: session.range,
+      position: { x: pos.left, y: pos.top },
+      respond: (opts) => this.respond(session.id, opts),
+      close: (reason = "completed") => this.forceClose(session.id, reason),
+    }
+    this.callbacks.onPatternOpen(payload)
+  }
+
+  private updateQuery(session: PatternSession) {
+    const pos = this.view.coordsAtPos(session.range.from)
+    this.callbacks.onPatternUpdate({
+      id: session.id,
+      trigger: session.trigger,
+      kind: session.kind,
+      query: session.query,
+      range: session.range,
+      position: { x: pos.left, y: pos.top },
+    })
+  }
+
+  private close(session: PatternSession) {
+    if (this.manualClose) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "completed" })
+      this.manualClose = false
+      return
+    }
+
+    const spec = PATTERN_SPECS.find((s) => s.start === session.trigger)
+    if (!spec) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+      return
+    }
+
+    const { $head } = this.view.state.selection
+    if (!$head.parent.isTextblock) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+      return
+    }
+
+    const text = $head.parent.textContent
+    const offset = $head.parentOffset
+    const textBefore = text.slice(0, offset)
+
+    const startIdx = textBefore.lastIndexOf(session.trigger)
+    if (startIdx === -1) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+      return
+    }
+
+    const startPos = $head.start() + startIdx
+    if (startPos !== session.range.from) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+      return
+    }
+
+    const query = textBefore.slice(startIdx + session.trigger.length)
+
+    let isComplete = false
+    if (spec.start === "[[") {
+      isComplete = isPageRefComplete(query)
+    } else if (spec.start === "((") {
+      isComplete = isBlockRefComplete(query)
+    } else {
+      isComplete = isPatternCompleted(spec, query)
+    }
+
+    if (isComplete) {
+      this.callbacks.onPatternClose({ id: session.id, reason: "completed" })
+    } else {
+      this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
+    }
+  }
+
+  private respond(
+    sessionId: string,
+    opts: {
+      text: string
+      mode?: "partial" | "replace" | "append"
+      cursor?: number | "end"
+    },
+  ) {
+    const state = this.view.state
+    const session = patternPluginKey.getState(state)
+    if (!session || session.id !== sessionId) return
+
+    const currentRange = getCurrentRange(state, session.trigger)
+    if (!currentRange) return
+
+    const text = opts.mode === "append" ? session.query + opts.text : opts.text
+    const tr = state.tr.insertText(text, currentRange.from, currentRange.to)
+
+    if (opts.cursor !== undefined) {
+      if (opts.cursor === "end") {
+        const pos = currentRange.from + text.length
+        tr.setSelection(TextSelection.create(tr.doc, pos))
+      } else {
+        tr.setSelection(TextSelection.create(tr.doc, opts.cursor))
+      }
+    }
+
+    this.view.dispatch(tr)
+  }
+
+  private forceClose(sessionId: string, reason: CloseReason = "completed") {
+    const state = this.view.state
+    const session = patternPluginKey.getState(state)
+    if (!session || session.id !== sessionId) return
+
+    this.manualClose = true
+    const tr = state.tr.setMeta(forceCloseMetaKey, reason)
+    this.view.dispatch(tr)
+  }
+
+  destroy() {}
+}
+
+export function createPatternPlugin(
+  callbacks: PatternCallbacks,
+): Plugin<PatternSession | null> {
+  return new Plugin<PatternSession | null>({
+    key: patternPluginKey,
+    state: {
+      init: () => null,
+      apply(
+        tr: Transaction,
+        prevSession: PatternSession | null,
+        _oldState: EditorState,
+        newState: EditorState,
+      ) {
+        if (tr.getMeta(forceCloseMetaKey)) return null
+        return findPattern(newState, prevSession)
+      },
+    },
+    view: (editorView: EditorView) => new PatternView(editorView, callbacks),
+  })
+}

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

@@ -0,0 +1,14 @@
+import type { App } from "vue"
+import Block from "./components/Block.vue"
+
+import "./assets/style.css"
+
+export type { MarkdownPattern } from "./lib/markdown-extensions"
+export { getActiveDecorations } from "./lib/markdown-parser"
+export { Block }
+
+export default {
+  install(app: App) {
+    app.component("Block", Block)
+  },
+}

+ 81 - 0
packages/editor/src/lib/__tests__/content-model.test.ts

@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest"
+import { contentToDoc, docToContent } from "../content-model"
+import { schema } from "../schema"
+
+describe("contentToDoc", () => {
+  it("handles plain text", () => {
+    const doc = contentToDoc("hello world")
+    expect(doc.child(0).textContent).toBe("hello world")
+  })
+
+  it("handles newlines as separate paragraphs", () => {
+    const doc = contentToDoc("line1\nline2")
+    expect(doc.childCount).toBe(2)
+    expect(doc.child(0).textContent).toBe("line1")
+    expect(doc.child(1).textContent).toBe("line2")
+  })
+
+  it("handles unicode and emoji", () => {
+    const doc = contentToDoc("Hello 🌍 你好 🔥")
+    expect(doc.child(0).textContent).toBe("Hello 🌍 你好 🔥")
+  })
+
+  it("handles empty string", () => {
+    const doc = contentToDoc("")
+    expect(doc.childCount).toBe(1)
+    expect(doc.child(0).textContent).toBe("")
+  })
+
+  it("handles only newlines", () => {
+    const doc = contentToDoc("\n\n")
+    expect(doc.childCount).toBe(3)
+  })
+})
+
+describe("docToContent", () => {
+  it("converts doc to content string", () => {
+    const doc = schema.nodes.doc.create(null, [
+      schema.nodes.paragraph.create(null, [schema.text("hello")]),
+    ])
+    expect(docToContent(doc)).toBe("hello")
+  })
+
+  it("joins multiple paragraphs with newlines", () => {
+    const doc = schema.nodes.doc.create(null, [
+      schema.nodes.paragraph.create(null, [schema.text("line1")]),
+      schema.nodes.paragraph.create(null, [schema.text("line2")]),
+    ])
+    expect(docToContent(doc)).toBe("line1\nline2")
+  })
+
+  it("handles empty paragraph", () => {
+    const doc = schema.nodes.doc.create(null, [schema.nodes.paragraph.create()])
+    expect(docToContent(doc)).toBe("")
+  })
+})
+
+describe("roundtrip", () => {
+  it("plain text roundtrips", () => {
+    const original = "hello world"
+    const doc = contentToDoc(original)
+    expect(docToContent(doc)).toBe(original)
+  })
+
+  it("multiline roundtrips", () => {
+    const original = "line1\nline2\nline3"
+    const doc = contentToDoc(original)
+    expect(docToContent(doc)).toBe(original)
+  })
+
+  it("unicode roundtrips", () => {
+    const original = "Hello 🌍 你好 🔥"
+    const doc = contentToDoc(original)
+    expect(docToContent(doc)).toBe(original)
+  })
+
+  it("empty string roundtrips", () => {
+    const original = ""
+    const doc = contentToDoc(original)
+    expect(docToContent(doc)).toBe(original)
+  })
+})

+ 483 - 0
packages/editor/src/lib/__tests__/markdown-parser.test.ts

@@ -0,0 +1,483 @@
+import { describe, expect, it } from "vitest"
+import {
+  type BlockRefDecoration,
+  getActiveDecorations,
+  type PageRefDecoration,
+  parseMarkdown,
+  type TagDecoration,
+} from "../markdown-parser"
+
+describe("parseMarkdown", () => {
+  it("parses bold", () => {
+    const result = parseMarkdown("**bold**")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("strong")
+    const [opener, content, closer] = result.content[0].delimiters
+    expect(opener.type).toBe("hidden")
+    expect(opener.from).toBe(0)
+    expect(opener.to).toBe(2)
+    expect(content.type).toBe("content")
+    expect(content.from).toBe(2)
+    expect(content.to).toBe(6)
+    expect(closer.type).toBe("hidden")
+    expect(closer.from).toBe(6)
+    expect(closer.to).toBe(8)
+  })
+
+  it("parses italic", () => {
+    const result = parseMarkdown("*italic*")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("em")
+  })
+
+  it("parses code", () => {
+    const result = parseMarkdown("`code`")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("code")
+  })
+
+  it("parses strikethrough", () => {
+    const result = parseMarkdown("~~strike~~")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("strike")
+  })
+
+  it("parses highlight", () => {
+    const result = parseMarkdown("^^highlight^^")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("highlight")
+    const [opener, content, closer] = result.content[0].delimiters
+    expect(opener.type).toBe("hidden")
+    expect(opener.from).toBe(0)
+    expect(opener.to).toBe(2)
+    expect(content.type).toBe("content")
+    expect(content.from).toBe(2)
+    expect(content.to).toBe(11)
+    expect(closer.type).toBe("hidden")
+    expect(closer.from).toBe(11)
+    expect(closer.to).toBe(13)
+  })
+
+  it("parses link", () => {
+    const result = parseMarkdown("[link](http://example.com)")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("link")
+    const [opener, content, closer] = result.content[0].delimiters
+    expect(opener.type).toBe("hidden")
+    expect(opener.from).toBe(0)
+    expect(opener.to).toBe(1)
+    expect(content.type).toBe("content")
+    expect(content.from).toBe(1)
+    expect(content.to).toBe(5)
+    expect(closer.type).toBe("hidden")
+    expect(closer.from).toBe(5)
+    expect(closer.to).toBe(26)
+  })
+
+  it("parses page reference [[Page Name]]", () => {
+    const result = parseMarkdown("[[Page Name]]")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("page-ref")
+    const ref = result.content[0] as PageRefDecoration
+    expect(ref.pageName).toBe("Page Name")
+    expect(ref.alias).toBeUndefined()
+  })
+
+  it("parses page reference with alias [[Page Name|Alias]]", () => {
+    const result = parseMarkdown("[[Page Name|Alias]]")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("page-ref")
+    const ref = result.content[0] as PageRefDecoration
+    expect(ref.pageName).toBe("Page Name")
+    expect(ref.alias).toBe("Alias")
+  })
+
+  it("parses block reference ((block-id))", () => {
+    const result = parseMarkdown("((block-id))")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("block-ref")
+    const ref = result.content[0] as BlockRefDecoration
+    expect(ref.blockId).toBe("block-id")
+  })
+
+  it("parses tag #tag", () => {
+    const result = parseMarkdown("#tag")
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("tag")
+    const tag = result.content[0] as TagDecoration
+    expect(tag.tag).toBe("tag")
+  })
+
+  it("parses nested italic+bold", () => {
+    const result = parseMarkdown("***italicbold***")
+    expect(result.content).toHaveLength(2)
+    const types = result.content.map((t) => t.type)
+    expect(types).toContain("strong")
+    expect(types).toContain("em")
+  })
+
+  it("parses property syntax", () => {
+    const result = parseMarkdown("key:: value")
+    expect(result.properties).toHaveLength(1)
+    expect(result.properties[0].key).toBe("key")
+    expect(result.properties[0].value).toBe("value")
+    expect(result.properties[0].keyRange).toEqual([0, 3])
+    expect(result.properties[0].delimiterRange).toEqual([3, 5])
+    expect(result.properties[0].valueRange).toEqual([5, 11])
+  })
+
+  it("parses multiple tokens in one string", () => {
+    const result = parseMarkdown("**bold** and *italic*")
+    expect(result.content).toHaveLength(2)
+  })
+
+  it("returns empty for plain text", () => {
+    const result = parseMarkdown("just plain text")
+    expect(result.content).toHaveLength(0)
+    expect(result.properties).toHaveLength(0)
+  })
+
+  it("property with no value", () => {
+    const result = parseMarkdown("key::")
+    expect(result.properties).toHaveLength(1)
+    expect(result.properties[0].value).toBe("")
+  })
+
+  it("does not parse property mid-sentence", () => {
+    const result = parseMarkdown("some text key:: value")
+    expect(result.properties).toHaveLength(0)
+  })
+
+  it("parses property only at start of text", () => {
+    const result = parseMarkdown("key:: value")
+    expect(result.properties).toHaveLength(1)
+    expect(result.properties[0].key).toBe("key")
+  })
+
+  it("does not parse property when text before it", () => {
+    const result = parseMarkdown("text key:: value")
+    expect(result.properties).toHaveLength(0)
+  })
+
+  it("gracefully handles malformed markdown", () => {
+    const result = parseMarkdown("**unclosed")
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("gracefully handles nested malformed tokens", () => {
+    const result = parseMarkdown("**bold *italic**")
+    // Should not throw, returns what it can parse
+    expect(typeof result.content).toBe("object")
+  })
+
+  it("handles unmatched delimiters", () => {
+    const result = parseMarkdown("*no closing")
+    expect(result.content).toHaveLength(0)
+  })
+})
+
+describe("getActiveDecorations", () => {
+  it("shows decoration when cursor outside", () => {
+    const result = getActiveDecorations("**bold**", 10)
+    expect(result.content).toHaveLength(1)
+  })
+
+  it("hides decoration when cursor inside", () => {
+    const result = getActiveDecorations("**bold**", 3)
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("shows decoration when cursor at start (before delimiter)", () => {
+    const result = getActiveDecorations("**bold**", 0)
+    expect(result.content).toHaveLength(1)
+  })
+
+  it("shows highlight when cursor outside", () => {
+    const result = getActiveDecorations("^^highlight^^", 13)
+    expect(result.content).toHaveLength(1)
+  })
+
+  it("hides highlight when cursor inside", () => {
+    const result = getActiveDecorations("^^highlight^^", 5)
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("shows page ref when cursor outside", () => {
+    const result = getActiveDecorations("[[Page]]", 10)
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("page-ref")
+  })
+
+  it("hides page ref when cursor inside", () => {
+    const result = getActiveDecorations("[[Page]]", 5)
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("shows block ref when cursor outside", () => {
+    const result = getActiveDecorations("((block-id))", 14)
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("block-ref")
+  })
+
+  it("hides block ref when cursor inside", () => {
+    const result = getActiveDecorations("((block-id))", 5)
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("shows tag when cursor outside", () => {
+    const result = getActiveDecorations("#tag", 5)
+    expect(result.content).toHaveLength(1)
+    expect(result.content[0].type).toBe("tag")
+  })
+
+  it("hides tag when cursor inside", () => {
+    const result = getActiveDecorations("#tag", 2)
+    expect(result.content).toHaveLength(0)
+  })
+
+  it("shows decoration when cursor just after token", () => {
+    const result = getActiveDecorations("**bold** text", 9)
+    expect(result.content).toHaveLength(1)
+  })
+
+  describe("unmatched delimiters", () => {
+    it("unmatched bold opening only", () => {
+      const result = parseMarkdown("**bold")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched bold closing only", () => {
+      const result = parseMarkdown("bold**")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched italic opening only", () => {
+      const result = parseMarkdown("*text")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched strikethrough only", () => {
+      const result = parseMarkdown("~~strike")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched code opening only", () => {
+      const result = parseMarkdown("`code")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched highlight opening only", () => {
+      const result = parseMarkdown("^^highlight")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched page ref opening only", () => {
+      const result = parseMarkdown("[[Page")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched block ref opening only", () => {
+      const result = parseMarkdown("((block")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("unmatched tag no content", () => {
+      const result = parseMarkdown("#")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("multiple unmatched delimiters", () => {
+      const result = parseMarkdown("**bold and *italic")
+      expect(result.content).toHaveLength(0)
+    })
+  })
+
+  describe("nested malformed tokens", () => {
+    it("italic containing unmatched bold - parses partial", () => {
+      const result = parseMarkdown("*text **bold*")
+      expect(result.content.length).toBeGreaterThanOrEqual(0)
+    })
+
+    it("bold containing unmatched italic - parses partial", () => {
+      const result = parseMarkdown("**text *italic")
+      expect(result.content.length).toBeGreaterThanOrEqual(0)
+    })
+
+    it("strikethrough containing unmatched bold - parses partial", () => {
+      const result = parseMarkdown("~~text **bold~~")
+      expect(result.content.length).toBeGreaterThanOrEqual(0)
+    })
+
+    it("nested triple delimiters", () => {
+      const result = parseMarkdown("***text***")
+      expect(result.content.length).toBeGreaterThan(0)
+    })
+
+    it("code inside bold", () => {
+      const result = parseMarkdown("**`code`**")
+      expect(result.content).toHaveLength(2)
+    })
+
+    it("link inside bold", () => {
+      const result = parseMarkdown("**[text](url)**")
+      expect(result.content).toHaveLength(2)
+    })
+  })
+
+  describe("incomplete patterns at end of text", () => {
+    it("incomplete page ref at end", () => {
+      const result = parseMarkdown("text [[Page")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("incomplete block ref at end", () => {
+      const result = parseMarkdown("text ((block")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("incomplete highlight at end", () => {
+      const result = parseMarkdown("text ^^high")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("tag at end of word not at boundary", () => {
+      const result = parseMarkdown("word#tag")
+      expect(result.content).toHaveLength(0)
+    })
+
+    it("tag at boundary", () => {
+      const result = parseMarkdown("word #tag")
+      expect(result.content).toHaveLength(1)
+      expect(result.content[0].type).toBe("tag")
+    })
+  })
+
+  describe("whitespace variations", () => {
+    it("multiple spaces before tag", () => {
+      const result = parseMarkdown("text  #tag")
+      expect(result.content).toHaveLength(1)
+    })
+
+    it("tab before tag", () => {
+      const result = parseMarkdown("text\t#tag")
+      expect(result.content).toHaveLength(1)
+    })
+
+    it("newlines in page ref - Lezer allows but content split", () => {
+      const result = parseMarkdown("[[Page\nName]]")
+      expect(result.content.length).toBeGreaterThanOrEqual(0)
+    })
+  })
+
+  describe("block decorations", () => {
+    it("parses heading level 1", () => {
+      const result = parseMarkdown("# Hello World")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("heading")
+      expect(result.blocks[0].metadata?.level).toBe(1)
+      expect(result.blocks[0].markerRange).toEqual([0, 2])
+      expect(result.blocks[0].contentRange).toEqual([2, 13])
+    })
+
+    it("parses heading level 3", () => {
+      const result = parseMarkdown("### Title")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("heading")
+      expect(result.blocks[0].metadata?.level).toBe(3)
+    })
+
+    it("parses task TODO", () => {
+      const result = parseMarkdown("TODO buy milk")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("task")
+      expect(result.blocks[0].metadata?.taskState).toBe("TODO")
+    })
+
+    it("parses task with priority", () => {
+      const result = parseMarkdown("TODO buy milk [#A]")
+      expect(result.blocks).toHaveLength(2)
+      expect(result.blocks[0].type).toBe("task")
+      expect(result.blocks[0].metadata?.taskState).toBe("TODO")
+      expect(result.blocks[1].type).toBe("priority")
+      expect(result.blocks[1].metadata?.priority).toBe("A")
+    })
+
+    it("parses priority only", () => {
+      const result = parseMarkdown("[#B] high priority")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("priority")
+      expect(result.blocks[0].metadata?.priority).toBe("B")
+    })
+
+    it("parses blockquote", () => {
+      const result = parseMarkdown("> Quote text")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("blockquote")
+    })
+
+    it("parses callout note", () => {
+      const result = parseMarkdown("> [!NOTE] important info")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("callout")
+      expect(result.blocks[0].metadata?.calloutType).toBe("NOTE")
+    })
+
+    it("parses callout warning", () => {
+      const result = parseMarkdown("> [!WARNING] be careful")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("callout")
+      expect(result.blocks[0].metadata?.calloutType).toBe("WARNING")
+    })
+
+    it("parses date", () => {
+      const result = parseMarkdown("📅 2024-01-15")
+      expect(result.blocks).toHaveLength(1)
+      expect(result.blocks[0].type).toBe("date")
+      expect(result.blocks[0].metadata?.date).toBe("2024-01-15")
+    })
+
+    it("returns empty blocks for plain text", () => {
+      const result = parseMarkdown("Just some plain text")
+      expect(result.blocks).toHaveLength(0)
+    })
+
+    it("handles all task states", () => {
+      const states = [
+        "TODO",
+        "DOING",
+        "DONE",
+        "LATER",
+        "NOW",
+        "WAITING",
+        "CANCELLED",
+      ]
+      for (const state of states) {
+        const result = parseMarkdown(`${state} task`)
+        expect(result.blocks).toHaveLength(1)
+        expect(result.blocks[0].metadata?.taskState).toBe(state)
+      }
+    })
+  })
+
+  describe("getActiveDecorations blocks", () => {
+    it("hides block markers when cursor inside block", () => {
+      const result = getActiveDecorations("TODO buy milk", 5)
+      expect(result.blocks).toHaveLength(0)
+    })
+
+    it("shows block markers when cursor at start", () => {
+      const result = getActiveDecorations("TODO buy milk", 0)
+      expect(result.blocks).toHaveLength(1)
+    })
+
+    it("shows heading when cursor at start", () => {
+      const result = getActiveDecorations("# Title", 0)
+      expect(result.blocks).toHaveLength(1)
+    })
+
+    it("hides heading when cursor inside", () => {
+      const result = getActiveDecorations("# Title", 3)
+      expect(result.blocks).toHaveLength(0)
+    })
+  })
+})

+ 89 - 0
packages/editor/src/lib/__tests__/schema.test.ts

@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest"
+import { contentToDoc, docToContent } from "../content-model"
+import { schema } from "../schema"
+
+describe("schema", () => {
+  describe("nodes", () => {
+    it("has doc node", () => {
+      expect(schema.nodes.doc).toBeDefined()
+    })
+
+    it("has paragraph node", () => {
+      expect(schema.nodes.paragraph).toBeDefined()
+    })
+
+    it("has hard_break node", () => {
+      expect(schema.nodes.hard_break).toBeDefined()
+    })
+
+    it("has text node", () => {
+      expect(schema.nodes.text).toBeDefined()
+    })
+  })
+
+  describe("doc node", () => {
+    it("creates doc with content", () => {
+      const doc = schema.nodes.doc.create(null, [
+        schema.nodes.paragraph.create(),
+      ])
+      expect(doc.type.name).toBe("doc")
+      expect(doc.childCount).toBe(1)
+    })
+
+    it("requires block content", () => {
+      const doc = schema.nodes.doc.create(null, [
+        schema.nodes.paragraph.create(null, [schema.text("hello")]),
+      ])
+      expect(doc.child(0).type.name).toBe("paragraph")
+    })
+  })
+
+  describe("paragraph node", () => {
+    it("creates empty paragraph", () => {
+      const p = schema.nodes.paragraph.create()
+      expect(p.type.name).toBe("paragraph")
+      expect(p.content.size).toBe(0)
+    })
+
+    it("creates paragraph with text", () => {
+      const p = schema.nodes.paragraph.create(null, [schema.text("hello")])
+      expect(p.textContent).toBe("hello")
+    })
+  })
+
+  describe("hard_break node", () => {
+    it("creates hard break", () => {
+      const br = schema.nodes.hard_break.create()
+      expect(br.type.name).toBe("hard_break")
+    })
+
+    it("is atom (no content)", () => {
+      const br = schema.nodes.hard_break.create()
+      expect(br.isAtom).toBe(true)
+    })
+  })
+
+  describe("text node", () => {
+    it("creates text node", () => {
+      const text = schema.text("hello")
+      expect(text.type.name).toBe("text")
+      expect(text.text).toBe("hello")
+    })
+  })
+
+  describe("marks", () => {
+    it("has no marks defined", () => {
+      expect(Object.keys(schema.marks)).toHaveLength(0)
+    })
+  })
+
+  describe("roundtrip with content-model", () => {
+    it("preserves paragraph structure", () => {
+      const original = "line1\nline2"
+      const doc = contentToDoc(original)
+      const result = docToContent(doc)
+
+      expect(result).toBe(original)
+    })
+  })
+})

+ 70 - 0
packages/editor/src/lib/content-model.ts

@@ -0,0 +1,70 @@
+/**
+ * Content model conversion functions.
+ *
+ * Converts between raw markdown string and ProseMirror document nodes.
+ * Each newline becomes a separate paragraph (not a hard_break).
+ */
+
+import type { Node as ProsemirrorNode } from "prosemirror-model"
+import { schema } from "./schema"
+
+/**
+ * Convert markdown string to ProseMirror document.
+ * Each line becomes a separate paragraph.
+ * @param content - Raw markdown string
+ * @returns ProseMirror document node
+ */
+export function contentToDoc(content: string): ProsemirrorNode {
+  if (!content) {
+    const paragraph = schema.nodes.paragraph.create()
+    return schema.nodes.doc.create(null, [paragraph])
+  }
+
+  const lines = content.split("\n")
+  const children: ProsemirrorNode[] = []
+
+  for (let i = 0; i < lines.length; i++) {
+    const lineText = lines[i]
+
+    let paragraphContent: ProsemirrorNode | null = null
+
+    if (lineText && lineText.length > 0) {
+      paragraphContent = schema.nodes.paragraph.create(null, [
+        schema.text(lineText),
+      ])
+    } else {
+      paragraphContent = schema.nodes.paragraph.create()
+    }
+
+    children.push(paragraphContent)
+  }
+
+  return schema.nodes.doc.create(null, children)
+}
+
+/**
+ * Convert ProseMirror document to markdown string.
+ * @param doc - ProseMirror document node
+ * @returns Raw markdown string
+ */
+export function docToContent(doc: ProsemirrorNode): string {
+  const parts: string[] = []
+
+  doc.forEach((child: ProsemirrorNode) => {
+    if (child.type.name === "paragraph") {
+      let text = ""
+
+      child.forEach((inline: ProsemirrorNode) => {
+        if (inline.type.name === "text") {
+          text += inline.text || ""
+        } else if (inline.type.name === "hard_break") {
+          text += "\n"
+        }
+      })
+
+      parts.push(text)
+    }
+  })
+
+  return parts.join("\n")
+}

+ 158 - 0
packages/editor/src/lib/markdown-extensions.ts

@@ -0,0 +1,158 @@
+/**
+ * Markdown extensions for Lezer parser.
+ *
+ * Defines custom inline patterns:
+ * - Page references: [[Page Name]] and [[Page Name|Alias]]
+ * - Block references: ((block-id))
+ * - Tags: #tagname
+ * - Highlights: ^^text^^
+ */
+
+import type { MarkdownConfig } from "@lezer/markdown"
+
+export interface PatternDecorationConfig {
+  contentClass: string
+  hiddenClass?: string
+  showAlias?: boolean
+  widgetClass?: string
+}
+
+export interface MarkdownPattern {
+  name: string
+  start: string
+  end?: string
+  nodeType: string
+  decoration?: PatternDecorationConfig
+}
+
+function createPatternExtension(patterns: MarkdownPattern[]): MarkdownConfig {
+  return {
+    defineNodes: patterns.map((p) => ({
+      name: p.nodeType,
+      block: false,
+    })),
+    parseInline: patterns.map((pattern) => {
+      if (pattern.start === "#" && !pattern.end) {
+        return {
+          name: pattern.nodeType,
+          before: "Link",
+          parse(cx, next, pos) {
+            if (next !== 35) return -1
+
+            if (pos > 0) {
+              const prev = cx.char(pos - 1)
+              if (prev !== 32 && prev !== 9 && prev !== 10 && prev !== 13) {
+                return -1
+              }
+            }
+
+            let i = pos + 1
+            let hasContent = false
+
+            while (i < cx.end) {
+              const ch = cx.char(i)
+              if (
+                ch === 32 ||
+                ch === 9 ||
+                ch === 10 ||
+                ch === 13 ||
+                ch === 35
+              ) {
+                break
+              }
+              hasContent = true
+              i++
+            }
+
+            if (hasContent) {
+              return cx.addElement(cx.elt(pattern.nodeType, pos, i))
+            }
+            return -1
+          },
+        }
+      }
+
+      return {
+        name: pattern.nodeType,
+        before: "Link",
+        parse(cx, _next, pos) {
+          if (!pattern.end) return -1
+
+          const input = cx.slice(pos, pos + pattern.start.length)
+          if (input !== pattern.start) return -1
+
+          const endChar = pattern.end.charCodeAt(0)
+          let i = pos + pattern.start.length
+          let hasContent = false
+
+          while (i < cx.end) {
+            const ch = cx.char(i)
+            if (ch === endChar) {
+              const rest = cx.slice(i, i + pattern.end.length)
+              if (rest === pattern.end && hasContent) {
+                return cx.addElement(
+                  cx.elt(pattern.nodeType, pos, i + pattern.end.length),
+                )
+              }
+              return -1
+            }
+            hasContent = true
+            i++
+          }
+          return -1
+        },
+      }
+    }),
+  }
+}
+
+export const defaultPatterns: MarkdownPattern[] = [
+  {
+    name: "PageRef",
+    start: "[[",
+    end: "]]",
+    nodeType: "PageRef",
+    decoration: {
+      contentClass: "md-page-ref",
+      hiddenClass: "md-hidden",
+      showAlias: true,
+      widgetClass: "md-page-ref-alias",
+    },
+  },
+  {
+    name: "BlockRef",
+    start: "((",
+    end: "))",
+    nodeType: "BlockRef",
+    decoration: {
+      contentClass: "md-block-ref",
+      hiddenClass: "md-hidden",
+    },
+  },
+  {
+    name: "Tag",
+    start: "#",
+    nodeType: "Tag",
+    decoration: {
+      contentClass: "md-tag",
+      hiddenClass: "md-hidden",
+    },
+  },
+  {
+    name: "Highlight",
+    start: "^^",
+    end: "^^",
+    nodeType: "Highlight",
+    decoration: {
+      contentClass: "md-highlight",
+      hiddenClass: "md-hidden",
+    },
+  },
+]
+
+export function createMarkdownExtensions(
+  customPatterns: MarkdownPattern[] = [],
+): MarkdownConfig[] {
+  const allPatterns = [...defaultPatterns, ...customPatterns]
+  return [createPatternExtension(allPatterns)]
+}

+ 557 - 0
packages/editor/src/lib/markdown-parser.ts

@@ -0,0 +1,557 @@
+/**
+ * Markdown parser using Lezer.
+ *
+ * Parses markdown text into structured decoration data for:
+ * - Inline tokens (bold, italic, code, strikethrough, links)
+ * - Reference tokens (page refs, block refs, tags, highlights)
+ * - Block-level decorations (headings, tasks, priorities, blockquotes, callouts, dates)
+ * - Property lines (key:: value)
+ */
+
+import type { SyntaxNode, TreeCursor } from "@lezer/common"
+import { GFM, parser } from "@lezer/markdown"
+import { createMarkdownExtensions } from "./markdown-extensions"
+
+interface LezerNode {
+  type: { name: string }
+  from: number
+  to: number
+  tree: { slice(from: number, to: number): string }
+  cursor: () => TreeCursor
+}
+
+export interface DelimiterDecoration {
+  type: "hidden" | "content"
+  className?: string
+  from: number
+  to: number
+}
+
+export interface TokenDecoration {
+  type: string
+  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
+}
+
+export interface PropertyInfo {
+  key: string
+  value: string
+  keyRange: [number, number]
+  delimiterRange: [number, number]
+  valueRange: [number, number]
+}
+
+export interface PageRefDecoration {
+  type: "page-ref"
+  pageName: string
+  alias?: string
+  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
+}
+
+export interface BlockRefDecoration {
+  type: "block-ref"
+  blockId: string
+  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
+}
+
+export interface TagDecoration {
+  type: "tag"
+  tag: string
+  delimiters: [DelimiterDecoration, DelimiterDecoration]
+}
+
+export interface HighlightDecoration {
+  type: "highlight"
+  delimiters: [DelimiterDecoration, DelimiterDecoration, DelimiterDecoration]
+}
+
+export interface ParsedDecorations {
+  content: (
+    | TokenDecoration
+    | PageRefDecoration
+    | BlockRefDecoration
+    | TagDecoration
+    | HighlightDecoration
+  )[]
+  properties: PropertyInfo[]
+  blocks: BlockDecoration[]
+}
+
+export type TaskState =
+  | "TODO"
+  | "DOING"
+  | "DONE"
+  | "LATER"
+  | "NOW"
+  | "WAITING"
+  | "CANCELLED"
+export type PriorityLevel = "A" | "B" | "C"
+export type CalloutType = "NOTE" | "WARNING" | "TIP" | "DANGER" | "INFO"
+
+export interface BlockDecoration {
+  type: "heading" | "task" | "priority" | "blockquote" | "callout" | "date"
+  markerRange: [number, number]
+  contentRange: [number, number]
+  metadata?: {
+    level?: number
+    taskState?: TaskState
+    priority?: PriorityLevel
+    calloutType?: CalloutType
+    date?: string
+  }
+}
+
+const markdownParser = parser.configure([GFM, ...createMarkdownExtensions()])
+
+const TYPE_MAP: Record<string, string> = {
+  StrongEmphasis: "strong",
+  Emphasis: "em",
+  InlineCode: "code",
+  Strikethrough: "strike",
+  Link: "link",
+}
+
+const DELIMITER_MARK: Record<string, string> = {
+  StrongEmphasis: "EmphasisMark",
+  Emphasis: "EmphasisMark",
+  InlineCode: "CodeMark",
+  Strikethrough: "StrikethroughMark",
+}
+
+const STRUCTURED_TOKENS = new Set([
+  "StrongEmphasis",
+  "Emphasis",
+  "InlineCode",
+  "Strikethrough",
+  "Link",
+])
+
+const LEAF_TOKENS = new Set(["PageRef", "BlockRef", "Tag", "Highlight"])
+
+const TASK_STATES = new Set([
+  "TODO",
+  "DOING",
+  "DONE",
+  "LATER",
+  "NOW",
+  "WAITING",
+  "CANCELLED",
+])
+const CALLOUT_TYPES = new Set(["NOTE", "WARNING", "TIP", "DANGER", "INFO"])
+
+function detectBlockDecorations(text: string): BlockDecoration[] {
+  const blocks: BlockDecoration[] = []
+  const trimmed = text.trimStart()
+
+  if (trimmed.startsWith("#")) {
+    const match = trimmed.match(/^(#{1,6})\s+(.*)$/)
+    if (match) {
+      const level = match[1].length
+      const contentStart = match[1].length + 1
+      blocks.push({
+        type: "heading",
+        markerRange: [0, contentStart],
+        contentRange: [contentStart, text.length],
+        metadata: { level },
+      })
+    }
+  } else if (trimmed.startsWith(">")) {
+    const calloutMatch = trimmed.match(/^>\s*\[!([A-Z]+)\]\s*(.*)$/)
+    if (calloutMatch) {
+      const calloutType = calloutMatch[1] as CalloutType
+      if (CALLOUT_TYPES.has(calloutType)) {
+        const markerEnd = calloutMatch[0].indexOf("]") + 1
+        const contentStart = calloutMatch[0].indexOf(" ", markerEnd) + 1
+        blocks.push({
+          type: "callout",
+          markerRange: [0, contentStart],
+          contentRange: [contentStart, text.length],
+          metadata: { calloutType },
+        })
+      }
+    } else {
+      const match = trimmed.match(/^>\s*(.*)$/)
+      if (match?.[1]) {
+        const markerEnd = match[0].indexOf(match[1])
+        blocks.push({
+          type: "blockquote",
+          markerRange: [0, markerEnd],
+          contentRange: [markerEnd, text.length],
+        })
+      }
+    }
+  } else if (trimmed.startsWith("📅")) {
+    const match = trimmed.match(/^📅\s*(\d{4}-\d{2}-\d{2})(.*)$/)
+    if (match) {
+      const date = match[1]
+      const afterDate = match[2]
+      const contentStart = date.length + 1
+      blocks.push({
+        type: "date",
+        markerRange: [0, contentStart],
+        contentRange: afterDate.trim()
+          ? [contentStart, text.length]
+          : [text.length, text.length],
+        metadata: { date },
+      })
+    }
+  } else {
+    const taskMatch = trimmed.match(
+      /^(TODO|DOING|DONE|LATER|NOW|WAITING|CANCELLED)\s+(.*)$/i,
+    )
+    if (taskMatch) {
+      const taskState = taskMatch[1].toUpperCase() as TaskState
+      if (TASK_STATES.has(taskState)) {
+        const afterTask = taskMatch[2]
+        const markerEnd = taskMatch[0].length - afterTask.length
+        let priority: PriorityLevel | undefined
+        let priorityMarkerStart = -1
+        let priorityContentStart = text.length
+
+        const priorityMatch = afterTask.match(/\[#([A-C])\]\s*$/)
+        if (priorityMatch) {
+          priority = priorityMatch[1] as PriorityLevel
+          priorityMarkerStart =
+            markerEnd + afterTask.lastIndexOf(`[#${priority}]`)
+          priorityContentStart =
+            markerEnd +
+            afterTask.lastIndexOf(`[#${priority}]`) +
+            priorityMatch[0].length
+        }
+
+        blocks.push({
+          type: "task",
+          markerRange: [0, markerEnd],
+          contentRange: [
+            priorityMarkerStart > 0 ? priorityMarkerStart : text.length,
+            text.length,
+          ],
+          metadata: { taskState },
+        })
+
+        if (priority) {
+          blocks.push({
+            type: "priority",
+            markerRange: [priorityMarkerStart, priorityMarkerStart + 4],
+            contentRange: [priorityContentStart, text.length],
+            metadata: { priority },
+          })
+        }
+      }
+    } else {
+      const priorityOnlyMatch = trimmed.match(/^\[#([A-C])\]\s*(.*)$/)
+      if (priorityOnlyMatch) {
+        const priority = priorityOnlyMatch[1] as PriorityLevel
+        const markerEnd =
+          priorityOnlyMatch[0].length - priorityOnlyMatch[2].length
+        blocks.push({
+          type: "priority",
+          markerRange: [0, markerEnd],
+          contentRange: [markerEnd, text.length],
+          metadata: { priority },
+        })
+      }
+    }
+  }
+
+  return blocks
+}
+
+function findChildByTypeName(
+  node: LezerNode,
+  typeName: string,
+): LezerNode | null {
+  const cursor = node.cursor()
+  if (!cursor.firstChild()) return null
+  do {
+    if (cursor.node.type.name === typeName) {
+      const child = cursor.node as unknown as LezerNode
+      cursor.parent()
+      return child
+    }
+  } while (cursor.nextSibling())
+  cursor.parent()
+  return null
+}
+
+function findChildrenByTypeName(
+  node: LezerNode,
+  typeName: string,
+): LezerNode[] {
+  const results: LezerNode[] = []
+  const cursor = node.cursor()
+  if (!cursor.firstChild()) return results
+  do {
+    if (cursor.node.type.name === typeName) {
+      results.push(cursor.node as unknown as LezerNode)
+    }
+  } while (cursor.nextSibling())
+  cursor.parent()
+  return results
+}
+
+function getEmphasisDecorations(
+  typeName: string,
+  node: LezerNode,
+): TokenDecoration | null {
+  const contentType = TYPE_MAP[typeName]
+  const delimiterMarkName = DELIMITER_MARK[typeName]
+  if (!contentType || !delimiterMarkName) return null
+
+  const delimiterMarks = findChildrenByTypeName(node, delimiterMarkName)
+  if (delimiterMarks.length < 2) return null
+
+  const opener = delimiterMarks[0]
+  const closer = delimiterMarks[delimiterMarks.length - 1]
+  if (!opener || !closer) return null
+
+  return {
+    type: contentType,
+    delimiters: [
+      { type: "hidden", from: opener.from, to: opener.to },
+      {
+        type: "content",
+        from: opener.to,
+        to: closer.from,
+        className: `md-${contentType}`,
+      },
+      { type: "hidden", from: closer.from, to: closer.to },
+    ],
+  }
+}
+
+function getLinkDecorations(node: SyntaxNode): TokenDecoration | null {
+  const opener = findChildByTypeName(node, "LinkMark")
+  if (!opener) return null
+
+  const linkMarks = findChildrenByTypeName(node, "LinkMark")
+  const url = findChildByTypeName(node, "URL")
+  if (!url) return null
+
+  const closer = linkMarks[linkMarks.length - 1]
+  const textEnd = linkMarks.length >= 2 ? linkMarks[1] : opener
+  if (!closer || !textEnd) return null
+
+  return {
+    type: "link",
+    delimiters: [
+      { type: "hidden", from: opener.from, to: opener.to },
+      {
+        type: "content",
+        from: opener.to,
+        to: textEnd.from,
+        className: "md-link",
+      },
+      { type: "hidden", from: textEnd.from, to: closer.to },
+    ],
+  }
+}
+
+function getStructuredTokenDecoration(
+  typeName: string,
+  node: SyntaxNode,
+): TokenDecoration | null {
+  if (typeName === "Link") {
+    return getLinkDecorations(node)
+  }
+
+  const contentType = TYPE_MAP[typeName]
+  if (!contentType) return null
+
+  return getEmphasisDecorations(typeName, node)
+}
+
+function getLeafTokenDecoration(
+  typeName: string,
+  node: LezerNode,
+  docText: string,
+):
+  | PageRefDecoration
+  | BlockRefDecoration
+  | TagDecoration
+  | HighlightDecoration
+  | null {
+  const from = node.from
+  const to = node.to
+
+  if (typeName === "PageRef") {
+    const innerText = docText.slice(from + 2, to - 2)
+    const parts = innerText.split("|")
+    const pageName = parts[0]
+    const alias = parts[1]
+    return {
+      type: "page-ref",
+      pageName,
+      alias: alias || undefined,
+      delimiters: [
+        { type: "hidden", from, to: from + 2 },
+        {
+          type: "content",
+          from: from + 2,
+          to: to - 2,
+          className: "md-page-ref",
+        },
+        { type: "hidden", from: to - 2, to },
+      ],
+    }
+  }
+
+  if (typeName === "BlockRef") {
+    const blockId = docText.slice(from + 2, to - 2)
+    return {
+      type: "block-ref",
+      blockId,
+      delimiters: [
+        { type: "hidden", from, to: from + 2 },
+        {
+          type: "content",
+          from: from + 2,
+          to: to - 2,
+          className: "md-block-ref",
+        },
+        { type: "hidden", from: to - 2, to },
+      ],
+    }
+  }
+
+  if (typeName === "Tag") {
+    const tagText = docText.slice(from, to)
+    const tag = tagText.slice(1)
+    return {
+      type: "tag",
+      tag,
+      delimiters: [
+        { type: "hidden", from, to: from + 1 },
+        {
+          type: "content",
+          from: from + 1,
+          to,
+          className: "md-tag",
+        },
+      ],
+    }
+  }
+
+  if (typeName === "Highlight") {
+    return {
+      type: "highlight",
+      delimiters: [
+        { type: "hidden", from, to: from + 2 },
+        {
+          type: "content",
+          from: from + 2,
+          to: to - 2,
+          className: "md-highlight",
+        },
+        { type: "hidden", from: to - 2, to },
+      ],
+    }
+  }
+
+  return null
+}
+
+export function parseMarkdown(docText: string): ParsedDecorations {
+  const content: ParsedDecorations["content"] = []
+  const properties: PropertyInfo[] = []
+  const blocks: BlockDecoration[] = detectBlockDecorations(docText)
+
+  if (!docText) {
+    return { content, properties, blocks }
+  }
+
+  try {
+    const tree = markdownParser.parse(docText)
+
+    function walkTree(cursor: TreeCursor): void {
+      do {
+        const node = cursor.node
+        const typeName = node.type.name
+
+        if (STRUCTURED_TOKENS.has(typeName)) {
+          const token = getStructuredTokenDecoration(typeName, node)
+          if (token) {
+            content.push(token)
+          }
+          if (cursor.firstChild()) {
+            walkTree(cursor)
+            cursor.parent()
+          }
+          continue
+        }
+
+        if (LEAF_TOKENS.has(typeName)) {
+          const token = getLeafTokenDecoration(typeName, node, docText)
+          if (token) {
+            content.push(token)
+          }
+          continue
+        }
+
+        if (cursor.firstChild()) {
+          walkTree(cursor)
+          cursor.parent()
+        }
+      } while (cursor.nextSibling())
+    }
+
+    const cursor = tree.cursor()
+    walkTree(cursor)
+  } catch {
+    // On parse error, return empty decorations so raw text displays
+  }
+
+  const propMatch = docText.match(/^([a-zA-Z_][a-zA-Z0-9_-]*)::(.*)$/)
+  if (propMatch) {
+    const key = propMatch[1] ?? ""
+    const value = propMatch[2]?.trim() ?? ""
+    const keyEnd = key.length
+    const delimiterEnd = keyEnd + 2
+
+    properties.push({
+      key,
+      value,
+      keyRange: [0, keyEnd],
+      delimiterRange: [keyEnd, delimiterEnd],
+      valueRange: [delimiterEnd, docText.length],
+    })
+  }
+
+  return { content, properties, blocks }
+}
+
+export function getActiveDecorations(
+  docText: string,
+  cursorPos: number,
+): ParsedDecorations {
+  const { content, properties, blocks } = parseMarkdown(docText)
+
+  const activeContent = content.filter((token) => {
+    if (token.type === "tag") {
+      const [opener, closer] = token.delimiters
+      if (cursorPos > 0 && cursorPos >= opener.from && cursorPos < closer.to) {
+        return false
+      }
+      return true
+    }
+    const [opener, , closer] = token.delimiters
+    if (cursorPos > 0 && cursorPos >= opener.from && cursorPos < closer.to) {
+      return false
+    }
+    return true
+  })
+
+  const activeBlocks = blocks.filter((_block) => {
+    if (cursorPos > 0) {
+      return false
+    }
+    return true
+  })
+
+  const activeProperties = properties
+
+  return {
+    content: activeContent,
+    properties: activeProperties,
+    blocks: activeBlocks,
+  }
+}

+ 41 - 0
packages/editor/src/lib/schema.ts

@@ -0,0 +1,41 @@
+/**
+ * Minimal ProseMirror schema for markdown editor.
+ *
+ * Only contains structural nodes - no semantic marks (bold, italic, etc).
+ * Markdown syntax is preserved as plain text and rendered via decorations.
+ */
+import { type NodeSpec, Schema } from "prosemirror-model"
+
+type DOMNode = "doc" | "paragraph" | "hard_break" | "text"
+
+/** Node definitions */
+const nodes: Record<DOMNode, NodeSpec> = {
+  /** Root document container */
+  doc: {
+    content: "block+",
+  },
+  /** Paragraph - each line becomes a paragraph */
+  paragraph: {
+    group: "block",
+    content: "inline*",
+    parseDOM: [{ tag: "p" }],
+    toDOM: () => ["p", 0],
+  },
+  /** Hard break (newline within paragraph) */
+  hard_break: {
+    group: "inline",
+    inline: true,
+    selectable: false,
+    parseDOM: [{ tag: "br" }],
+    toDOM: () => ["br"],
+  },
+  /** Text content */
+  text: {
+    group: "inline",
+  },
+}
+
+/** No marks - all formatting handled via decorations */
+const marks = {}
+
+export const schema = new Schema<DOMNode, never>({ nodes, marks })

+ 11 - 0
packages/editor/src/vite-env.d.ts

@@ -0,0 +1,11 @@
+declare module "*.vue" {
+  import type { DefineComponent } from "vue"
+
+  const component: DefineComponent<object, object, unknown>
+  export default component
+}
+
+declare module "*.css" {
+  const css: string
+  export default css
+}

+ 18 - 0
packages/editor/tsconfig.app.json

@@ -0,0 +1,18 @@
+{
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
+  "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+  "exclude": ["src/**/__tests__/*"],
+  "compilerOptions": {
+    // Extra safety for array and object lookups, but may have false positives.
+    "noUncheckedIndexedAccess": true,
+
+    // Path mapping for cleaner imports.
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+
+    // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
+    // Specified here to keep it out of the root directory.
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
+  }
+}

+ 11 - 0
packages/editor/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "files": [],
+  "references": [
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.app.json"
+    }
+  ]
+}

+ 27 - 0
packages/editor/tsconfig.node.json

@@ -0,0 +1,27 @@
+// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
+{
+  "extends": "@tsconfig/node24/tsconfig.json",
+  "include": [
+    "vite.config.*",
+    "vitest.config.*",
+    "cypress.config.*",
+    "playwright.config.*",
+    "eslint.config.*"
+  ],
+  "compilerOptions": {
+    // Most tools use transpilation instead of Node.js's native type-stripping.
+    // Bundler mode provides a smoother developer experience.
+    "module": "preserve",
+    "moduleResolution": "bundler",
+
+    // Include Node.js types and avoid accidentally including other `@types/*` packages.
+    "types": ["node"],
+
+    // Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
+    "noEmit": true,
+
+    // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
+    // Specified here to keep it out of the root directory.
+    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
+  }
+}

+ 24 - 0
packages/editor/vite.config.ts

@@ -0,0 +1,24 @@
+import { resolve } from "node:path"
+import tailwindcss from "@tailwindcss/vite"
+import vue from "@vitejs/plugin-vue"
+import { defineConfig } from "vite"
+
+export default defineConfig({
+  plugins: [vue(), tailwindcss()],
+  build: {
+    lib: {
+      entry: resolve(__dirname, "src/index.ts"),
+      fileName: "index",
+      formats: ["es"],
+    },
+    rollupOptions: {
+      external: ["vue", "@nuxt/ui", "tailwindcss", "pinia"],
+      output: {
+        preserveModules: true,
+        preserveModulesRoot: "src",
+      },
+    },
+    cssCodeSplit: false,
+    cssMinify: "lightningcss",
+  },
+})

+ 8 - 0
packages/editor/vitest.config.ts

@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config"
+
+export default defineConfig({
+  test: {
+    environment: "jsdom",
+    setupFiles: ["./vitest.setup.ts"],
+  },
+})

+ 8 - 0
packages/editor/vitest.setup.ts

@@ -0,0 +1,8 @@
+Object.defineProperty(document, "getSelection", {
+  value: () => ({
+    removeAllRanges: () => {},
+    addRange: () => {},
+    rangeCount: 0,
+  }),
+  writable: true,
+})

+ 23 - 0
skills-lock.json

@@ -0,0 +1,23 @@
+{
+  "version": 1,
+  "skills": {
+    "nuxt-ui": {
+      "source": "nuxt/ui",
+      "sourceType": "github",
+      "skillPath": "skills/nuxt-ui/SKILL.md",
+      "computedHash": "2d58c6092b7028db223a819d5a37badc860a4036ca134a3e457ce7b056562f72"
+    },
+    "vue": {
+      "source": "antfu/skills",
+      "sourceType": "github",
+      "skillPath": "skills/vue/SKILL.md",
+      "computedHash": "9c241141e07e836e4f0537c63e8f929fba3767c2997250be38e699f19b75e3f2"
+    },
+    "vueuse": {
+      "source": "onmax/nuxt-skills",
+      "sourceType": "github",
+      "skillPath": "skills/vueuse/SKILL.md",
+      "computedHash": "e1381f919ee988e327df56210ca3acd10c3807cc5720cffa89ccfb9abe1dfcdd"
+    }
+  }
+}

Vissa filer visades inte eftersom för många filer har ändrats