Преглед изворни кода

feat(editor): add unified suggestion menu, keyboard composable, and toolbar modal prompts

- Add EditorSuggestionMenu.vue: teleport-based command palette with
  forwardKey expose, sr-only input, and transition animations
- Add useKeyboard.ts: capture-phase keydown handler with swappable
  KeyBinding API (default + vim), palette navigation forwarding,
  undo/redo shortcuts, and focus trap
- Add useSuggestionGroups.ts: buildSlashGroups/buildMentionGroups
  factories with demo data, useSlashGroups/useMentionGroups composables
- Replace window.prompt() in EditorToolbar with UModal + UInput for
  page ref, block ref, and tag insertion
- Wire Editor.vue to pattern plugin events and render slash/mention
  menus via menuRef template ref
- Remove ( and [ auto-close rules from Block.vue
- Remove priority [#A/B/C] examples from dev pages
- Export new types and composables from index.ts
Zander Hawke пре 4 дана
родитељ
комит
02c1a37d92

+ 2 - 2
apps/dev/src/pages/editor-shell.vue

@@ -10,9 +10,9 @@ const content = ref(`* TODO Build the Editor shell
   * Phase 5: Wire navigation events
   * Phase 6: InsertionZone component
 * LATER Add drag-and-drop support
-  * [#A] Research libraries
+  * Research libraries
   * Design drop targets
-* DOING Implement toolbar integration [#B]
+* DOING Implement toolbar integration
 * DONE Create demo pages`)
 
 const serialized = ref("")

+ 1 - 1
apps/dev/src/pages/math.vue

@@ -64,7 +64,7 @@ import LiveExample from "~/components/LiveExample.vue"
             <LiveExample
               title="Math with Other Syntax"
               description="Math works alongside formatting, callouts, refs, and tasks."
-              :content="`## Math Document\n\nYou can use **bold** and $x$ inline math together.\n\n> [!NOTE] Callouts with math: $E = mc^2$\n\n[[Physics Notes]] with $\\theta$ angle.\n\nTODO Review $\\Delta$ formula [#A]`"
+              :content="`## Math Document\n\nYou can use **bold** and $x$ inline math together.\n\n> [!NOTE] Callouts with math: $E = mc^2$\n\n[[Physics Notes]] with $\\theta$ angle.\n\nTODO Review $\\Delta$ formula`"
             />
           </div>
         </section>

+ 1 - 1
apps/dev/src/pages/properties.vue

@@ -23,7 +23,7 @@ import LiveExample from "~/components/LiveExample.vue"
         <LiveExample
           title="Mixed Properties"
           description="Properties work alongside other syntax."
-          :content="`## Meeting Notes\ntags:: #development #planning\ndate:: 2026-06-14\nTODO Review the agenda [#A]`"
+          :content="`## Meeting Notes\ntags:: #development #planning\ndate:: 2026-06-14\nTODO Review the agenda`"
         />
       </div>
     </UPageBody>

+ 2 - 8
apps/dev/src/pages/tasks.vue

@@ -4,7 +4,7 @@ import LiveExample from "~/components/LiveExample.vue"
 
 <template>
   <UPage>
-    <UPageHeader title="Tasks & Priorities" description="Task states with priority levels." />
+    <UPageHeader title="Tasks" description="Task states with valid state progression." />
 
     <UPageBody>
       <div class="space-y-8">
@@ -20,16 +20,10 @@ import LiveExample from "~/components/LiveExample.vue"
           :content="`TODO Write documentation\nDOING Refactor tests\nDONE Ship v1.0`"
         />
 
-        <LiveExample
-          title="Priorities"
-          description="Append [#A], [#B], or [#C] to set priority."
-          :content="`TODO Fix critical bug [#A]\nDOING Add feature [#B]\nDONE Minor cleanup [#C]`"
-        />
-
         <LiveExample
           title="Mixed Content"
           description="Tasks work with inline formatting."
-          :content="`TODO Write **bold** documentation\nDOING Fix the *really* tricky bug [#A]\nDONE ~~Remove~~ deprecated code [#C]`"
+          :content="`TODO Write **bold** documentation\nDOING Fix the *really* tricky bug\nDONE ~~Remove~~ deprecated code`"
         />
       </div>
     </UPageBody>

+ 1 - 1
apps/dev/src/pages/themes.vue

@@ -6,7 +6,7 @@ import { computed, ref } from "vue"
 const content = ref(`* TODO Build the Editor shell
   * Phase 1: Block parser
   * Phase 2: Test edge cases
-* DOING Theme system [#A]
+* DOING Theme system
   * Define EditorTheme type
   * Create kanagawa preset
   * Wire CSS vars to shell

+ 0 - 1
apps/dev/src/pages/toolbar.vue

@@ -69,7 +69,6 @@ const content =
         <section class="space-y-4">
           <h2 class="text-lg font-semibold text-highlighted">Known Limitations</h2>
           <ul class="text-sm text-muted leading-relaxed space-y-2 list-disc list-inside">
-            <li>Reference insertion (Page Ref, Block Ref, Tag) uses <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">window.prompt()</code> — will be replaced with the pattern plugin's suggestion menu in a future phase.</li>
             <li>Undo/redo (<kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">⌘Z</kbd> / <kbd class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">⌘⇧Z</kbd>) are wired via global keyboard shortcuts. <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">canUndo</code>, <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">canRedo</code>, <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">undo</code>, and <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">redo</code> are provided via the <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">#toolbar</code> slot scope.</li>
           </ul>
         </section>

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
     "build": "pnpm run --filter @enesis/editor build",
     "test": "pnpm run --filter @enesis/editor test",
     "check": "biome check",
-    "mix": "repomix --compress --style xml --include \"**/*.ts,**/*.md,**/*.vue\" --ignore \"**/*.test.ts,docs/,tmp/\" --token-count-tree --remove-empty-lines"
+    "mix": "repomix --compress --style xml --include \"**/*.ts,**/*.md,**/*.vue\" --token-count-tree --remove-empty-lines"
   },
   "devDependencies": {
     "@biomejs/biome": "^2.4.15",

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

@@ -245,8 +245,6 @@ onMounted(() => {
         doubleAsteriskRule,
         doubleTildeRule,
         singleUnderscoreRule,
-        createPairRule("(", ")"),
-        createPairRule("[", "]"),
         createPairRule("{", "}"),
         createPairRule('"', '"', { context: /^$|[\s([{:>]/ }),
         createPairRule("'", "'", { context: /^$|[\s([{:"]/ }),

+ 82 - 22
packages/editor/src/components/Editor.vue

@@ -1,7 +1,8 @@
 <script setup lang="ts">
 import type { EditorView } from "prosemirror-view"
-import { computed, nextTick, ref, watch } from "vue"
+import { computed, nextTick, ref, watch, type Ref } from "vue"
 import Block from "@/components/Block.vue"
+import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import InsertionZone from "@/components/InsertionZone.vue"
 import {
   type FocusTarget,
@@ -24,6 +25,20 @@ import {
 } from "@/lib/editor-operations"
 import type { FormattingHandlers } from "@/lib/formatting"
 import { createLogger } from "@/lib/logger"
+import type {
+  PatternClosePayload,
+  PatternOpenPayload,
+  PatternUpdatePayload,
+} from "@/composables/usePatternPlugin"
+import {
+  useSlashGroups,
+  useMentionGroups,
+} from "@/composables/useSuggestionGroups"
+import {
+  defaultKeyBinding,
+  useKeyboard,
+  type KeyBinding,
+} from "@/composables/useKeyboard"
 import {
   createOperationHistory,
   type EditorOperation,
@@ -45,6 +60,7 @@ const props = withDefaults(
     markerMode?: MarkerVisibilityMode
     debug?: string
     theme?: ThemeInput
+    keyBinding?: KeyBinding
   }>(),
   {
     focused: false,
@@ -160,26 +176,6 @@ function redo() {
   canRedo.value = history.canRedo
 }
 
-// Undo/redo keyboard shortcuts, scoped to the editor shell.
-// Uses @keydown on the root div so that only focus within the editor
-// triggers the shortcuts, avoiding conflicts with other editors or
-// modals on the same page.
-function onShellKeydown(e: KeyboardEvent) {
-  if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
-    e.preventDefault()
-    undo()
-    return
-  }
-  if (
-    (e.metaKey || e.ctrlKey) &&
-    (e.key === "Z" || (e.key === "z" && e.shiftKey))
-  ) {
-    e.preventDefault()
-    redo()
-    return
-  }
-}
-
 function parseContent(md: string | undefined) {
   blocks.value = splitMarkdownIntoBlocks(md || "", undefined, blocks.value)
 }
@@ -217,6 +213,7 @@ function onBlockFocusHandler(payload: {
   view: EditorView
   handlers: FormattingHandlers
 }) {
+  activeView.value = payload.view
   activeHandlers.value = payload.handlers
   emit("focus", payload)
 }
@@ -234,6 +231,48 @@ function onBlockSelectionChangeHandler(payload: {
   emit("selection-change", payload)
 }
 
+// ── Suggestion menus (pattern plugin integration) ──────────────────
+
+const activeSession = ref<PatternOpenPayload | null>(null)
+const activeView: Ref<EditorView | null> = ref(null)
+const menuRef = ref<{ forwardKey: (key: string) => void } | null>(null)
+
+useKeyboard({
+  keybinding: props.keyBinding ?? defaultKeyBinding,
+  activeSession,
+  activeView,
+  onUndo: undo,
+  onRedo: redo,
+  forwardKey: (key) => menuRef.value?.forwardKey(key),
+})
+
+function closeSession() {
+  activeSession.value = null
+}
+
+const slashGroups = useSlashGroups(activeSession, activeHandlers, closeSession)
+const mentionGroups = useMentionGroups(activeSession, closeSession)
+
+function onPatternOpen(payload: PatternOpenPayload) {
+  log.info("pattern-open", { kind: payload.kind, query: payload.query })
+  activeSession.value = payload
+}
+
+function onPatternUpdate(payload: PatternUpdatePayload) {
+  if (!activeSession.value) return
+  activeSession.value = {
+    ...activeSession.value,
+    query: payload.query,
+    position: payload.position,
+    range: payload.range,
+  }
+}
+
+function onPatternClose(_payload: PatternClosePayload) {
+  log.info("pattern-close")
+  activeSession.value = null
+}
+
 // ── Block event handlers ──────────────────────────────────────────────
 
 function onSplit(index: number, before: string, after: string) {
@@ -438,7 +477,7 @@ defineExpose({ undo, redo, canUndo, canRedo })
 </script>
 
 <template>
-  <div class="editor-shell" :style="themeCSSVars" @keydown="onShellKeydown">
+  <div class="editor-shell" :style="themeCSSVars">
     <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
       <slot
         name="toolbar"
@@ -488,6 +527,9 @@ defineExpose({ undo, redo, canUndo, canRedo })
           @focus="onBlockFocusHandler"
           @blur="onBlockBlurHandler"
           @selection-change="onBlockSelectionChangeHandler"
+          @pattern-open="onPatternOpen"
+          @pattern-update="onPatternUpdate"
+          @pattern-close="onPatternClose"
         />
       </div>
     </template>
@@ -498,4 +540,22 @@ defineExpose({ undo, redo, canUndo, canRedo })
       @blur="onZoneBlur"
     />
   </div>
+
+  <EditorSuggestionMenu
+    ref="menuRef"
+    :active="activeSession?.kind === 'command'"
+    :groups="slashGroups"
+    :position="activeSession?.position ?? { x: 0, y: 0 }"
+    :query="activeSession?.query ?? ''"
+    @close="closeSession"
+  />
+
+  <EditorSuggestionMenu
+    ref="menuRef"
+    :active="!!(activeSession && ['reference', 'embed', 'tag'].includes(activeSession.kind))"
+    :groups="mentionGroups"
+    :position="activeSession?.position ?? { x: 0, y: 0 }"
+    :query="activeSession?.query ?? ''"
+    @close="closeSession"
+  />
 </template>

+ 55 - 0
packages/editor/src/components/EditorSuggestionMenu.vue

@@ -0,0 +1,55 @@
+<script setup lang="ts">
+import type { CommandPaletteGroup } from "@nuxt/ui"
+import { ref } from "vue"
+
+const props = defineProps<{
+  groups: CommandPaletteGroup[]
+  position: { x: number; y: number }
+  query: string
+  active: boolean
+}>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+
+const paletteRef = ref<any>(null)
+
+function forwardKey(key: string) {
+  const input = paletteRef.value?.$el?.querySelector("input")
+  input?.dispatchEvent(
+    new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
+  )
+}
+
+defineExpose({ forwardKey })
+</script>
+
+<template>
+  <Teleport to="body">
+    <Transition
+      enter-active-class="transition ease-out duration-100"
+      enter-from-class="opacity-0 scale-95"
+      enter-to-class="opacity-100 scale-100"
+      leave-active-class="transition ease-in duration-75"
+      leave-from-class="opacity-100 scale-100"
+      leave-to-class="opacity-0 scale-95"
+    >
+      <div
+        v-if="active"
+        data-command-palette
+        class="fixed z-50 w-80 shadow-lg rounded-lg overflow-hidden ring ring-default bg-(--ui-bg)"
+        :style="{ top: `${props.position.y + 24}px`, left: `${props.position.x}px` }"
+      >
+        <UCommandPalette
+          ref="paletteRef"
+          :groups="props.groups"
+          :autofocus="false"
+          :ui="{ input: 'sr-only' }"
+          :search-term="props.query"
+          @update:model-value="emit('close')"
+        />
+      </div>
+    </Transition>
+  </Teleport>
+</template>

+ 57 - 13
packages/editor/src/components/EditorToolbar.vue

@@ -1,7 +1,9 @@
 <script setup lang="ts">
-import { computed } from "vue"
+import { computed, nextTick, ref } from "vue"
 import type { FormattingHandlers } from "@/lib/formatting"
 
+type PromptKind = "page" | "block" | "tag"
+
 const props = defineProps<{
   handlers: FormattingHandlers | null
   selectionVersion?: number
@@ -59,19 +61,36 @@ const headingItems = computed(() => {
   ]
 })
 
-function promptPageRef() {
-  const name = window.prompt("Enter page name:")
-  if (name) props.handlers?.insertPageRef(name)
+const promptKind = ref<PromptKind | null>(null)
+const promptValue = ref("")
+const inputRef = ref<any>(null)
+
+const promptOpen = computed({
+  get: () => promptKind.value !== null,
+  set: (val: boolean) => { if (!val) promptKind.value = null },
+})
+
+function openPrompt(kind: PromptKind) {
+  promptValue.value = ""
+  promptKind.value = kind
+  nextTick(() => inputRef.value?.input?.focus())
 }
 
-function promptBlockRef() {
-  const id = window.prompt("Enter block ID:")
-  if (id) props.handlers?.insertBlockRef(id)
+function confirmPrompt() {
+  const kind = promptKind.value
+  const val = promptValue.value.trim()
+  if (!kind || !val || !props.handlers) {
+    promptKind.value = null
+    return
+  }
+  if (kind === "page") props.handlers.insertPageRef(val)
+  else if (kind === "block") props.handlers.insertBlockRef(val)
+  else if (kind === "tag") props.handlers.insertTag(val)
+  promptKind.value = null
 }
 
-function promptTag() {
-  const tag = window.prompt("Enter tag name:")
-  if (tag) props.handlers?.insertTag(tag)
+function cancelPrompt() {
+  promptKind.value = null
 }
 </script>
 
@@ -228,7 +247,7 @@ function promptTag() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
-          @click="promptPageRef"
+          @click="openPrompt('page')"
         />
       </UTooltip>
       <UTooltip text="Block reference" :disabled="!handlers">
@@ -238,7 +257,7 @@ function promptTag() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
-          @click="promptBlockRef"
+          @click="openPrompt('block')"
         />
       </UTooltip>
       <UTooltip text="Tag" :disabled="!handlers">
@@ -248,9 +267,34 @@ function promptTag() {
           variant="ghost"
           size="sm"
           :disabled="!handlers"
-          @click="promptTag"
+          @click="openPrompt('tag')"
         />
       </UTooltip>
     </div>
   </div>
+
+  <UModal v-model:open="promptOpen">
+    <UCard>
+      <template #header>
+        <h3 class="text-sm font-semibold">
+          {{ promptKind === "page" ? "Insert Page Reference" : promptKind === "block" ? "Insert Block Reference" : "Insert Tag" }}
+        </h3>
+      </template>
+
+      <UInput
+        ref="inputRef"
+        v-model="promptValue"
+        :placeholder="promptKind === 'page' ? 'Enter page name...' : promptKind === 'block' ? 'Enter block ID...' : 'Enter tag name...'"
+        @keydown.enter="confirmPrompt"
+        @keydown.escape="cancelPrompt"
+      />
+
+      <template #footer>
+        <div class="flex justify-end gap-2">
+          <UButton label="Cancel" color="neutral" variant="ghost" size="sm" @click="cancelPrompt" />
+          <UButton label="Insert" color="primary" size="sm" :disabled="!promptValue.trim()" @click="confirmPrompt" />
+        </div>
+      </template>
+    </UCard>
+  </UModal>
 </template>

+ 162 - 0
packages/editor/src/composables/useKeyboard.ts

@@ -0,0 +1,162 @@
+/**
+ * Single entry point for keyboard handling.
+ *
+ * Sets up a capture-phase listener that intercepts keydown events
+ * before ProseMirror sees them. The provided keybinding defines what
+ * each key does in various contexts (palette open/closed, etc.).
+ *
+ * Keybindings are swappable — pass a different binding to support
+ * vim, emacs, or custom keymaps without changing any routing logic.
+ */
+
+import { onUnmounted, watch, type Ref } from "vue"
+import type { EditorView } from "prosemirror-view"
+import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
+
+// ── Types ────────────────────────────────────────────────────────
+
+export type KeyboardAction =
+  | "navigate-down"
+  | "navigate-up"
+  | "select"
+  | "close-palette"
+  | "undo"
+  | "redo"
+
+export interface KeyboardContext {
+  isPaletteOpen: boolean
+}
+
+export interface KeyBinding {
+  match(event: KeyboardEvent, ctx: KeyboardContext): KeyboardAction | null
+}
+
+export interface KeyboardConfig {
+  keybinding: KeyBinding
+  activeSession: Ref<PatternOpenPayload | null>
+  activeView: Ref<EditorView | null>
+  onUndo: () => void
+  onRedo: () => void
+  forwardKey?: (key: string) => void
+}
+
+// ── Composables ──────────────────────────────────────────────────
+
+export function useKeyboard(config: KeyboardConfig) {
+  const { keybinding, activeSession, activeView, onUndo, onRedo } = config
+
+  function onKeydownCapture(e: KeyboardEvent) {
+    const target = e.target as HTMLElement | null
+    if (!target || !target.closest(".editor-shell")) return
+
+    const ctx: KeyboardContext = {
+      isPaletteOpen: activeSession.value !== null,
+    }
+
+    const action = keybinding.match(e, ctx)
+    if (!action) return
+
+    e.preventDefault()
+    e.stopPropagation()
+
+    switch (action) {
+      case "undo":
+        onUndo()
+        break
+      case "redo":
+        onRedo()
+        break
+      case "close-palette":
+        activeSession.value?.close("cancelled")
+        activeSession.value = null
+        break
+      case "navigate-down":
+      case "navigate-up":
+      case "select":
+        config.forwardKey?.(action === "navigate-down" ? "ArrowDown" : action === "navigate-up" ? "ArrowUp" : "Enter")
+        break
+    }
+  }
+
+  function onFocusTrap(e: FocusEvent) {
+    const target = e.target as HTMLElement | null
+    if (!target) return
+    if (target.closest("[data-command-palette]")) {
+      activeView.value?.focus()
+    }
+  }
+
+  // Listeners active only when the palette is open.
+  watch(activeSession, (session, oldSession) => {
+    if (session && !oldSession) {
+      document.addEventListener("keydown", onKeydownCapture, true)
+      document.addEventListener("focusin", onFocusTrap, true)
+    } else if (!session && oldSession) {
+      document.removeEventListener("keydown", onKeydownCapture, true)
+      document.removeEventListener("focusin", onFocusTrap, true)
+    }
+  })
+
+  onUnmounted(() => {
+    document.removeEventListener("keydown", onKeydownCapture, true)
+    document.removeEventListener("focusin", onFocusTrap, true)
+  })
+}
+
+// ── Built-in keybindings ─────────────────────────────────────────
+
+export const defaultKeyBinding: KeyBinding = {
+  match(e, ctx) {
+    if (ctx.isPaletteOpen) {
+      if (e.key === "ArrowDown") return "navigate-down"
+      if (e.key === "ArrowUp") return "navigate-up"
+      if (e.key === "Enter") return "select"
+      if (e.key === "Escape") return "close-palette"
+    }
+
+    // Editor-level shortcuts (always active).
+    if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
+      return "undo"
+    }
+    if (
+      (e.metaKey || e.ctrlKey) &&
+      (e.key === "Z" || (e.key === "z" && e.shiftKey))
+    ) {
+      return "redo"
+    }
+
+    return null
+  },
+}
+
+export const vimKeyBinding: KeyBinding = {
+  match(e, ctx) {
+    if (ctx.isPaletteOpen) {
+      // NOTE: j/k are unmodified keys, so typing "j" or "k" while the palette
+      // is open navigates it instead of inserting those characters into the
+      // document. This is intentional for vim mode but will surprise users
+      // who type to filter palette results.
+      if (e.key === "j" && !e.ctrlKey && !e.metaKey && !e.altKey) {
+        return "navigate-down"
+      }
+      if (e.key === "k" && !e.ctrlKey && !e.metaKey && !e.altKey) {
+        return "navigate-up"
+      }
+      if (e.key === "Enter") return "select"
+      if (e.key === "Escape") return "close-palette"
+      if (e.key === "[" && e.ctrlKey) return "close-palette"
+    }
+
+    if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
+      return "undo"
+    }
+    if (
+      (e.metaKey || e.ctrlKey) &&
+      (e.key === "Z" || (e.key === "z" && e.shiftKey))
+    ) {
+      return "redo"
+    }
+
+    return null
+  },
+}

+ 193 - 0
packages/editor/src/composables/useSuggestionGroups.ts

@@ -0,0 +1,193 @@
+import type { CommandPaletteGroup } from "@nuxt/ui"
+import { computed, type ComputedRef, type Ref } from "vue"
+import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
+import type { FormattingHandlers } from "@/lib/formatting"
+
+export interface SuggestionActionItem {
+  label: string
+  icon?: string
+  kbds?: string[]
+  description?: string
+  disabled?: boolean
+  class?: string
+  onSelect: () => void
+}
+
+export function buildSlashGroups(
+  payload: PatternOpenPayload,
+  handlers: FormattingHandlers | null,
+  onClose: () => void,
+): CommandPaletteGroup[] {
+  const { respond, close: closeSession } = payload
+
+  function replace(text: string) {
+    respond({ text, mode: "replace" })
+    closeSession("completed")
+    onClose()
+  }
+
+  function run(fn: () => void) {
+    respond({ text: "", mode: "replace" })
+    closeSession("cancelled")
+    fn()
+    onClose()
+  }
+
+  return [
+    {
+      id: "headings",
+      label: "Headings",
+      items: [
+        { label: "Paragraph", icon: "i-lucide-text", onSelect: () => replace("") },
+        { label: "Heading 1", icon: "i-lucide-heading-1", kbds: ["⌘1"], onSelect: () => replace("# ") },
+        { label: "Heading 2", icon: "i-lucide-heading-2", kbds: ["⌘2"], onSelect: () => replace("## ") },
+        { label: "Heading 3", icon: "i-lucide-heading-3", onSelect: () => replace("### ") },
+        { label: "Heading 4", icon: "i-lucide-heading-4", onSelect: () => replace("#### ") },
+        { label: "Heading 5", icon: "i-lucide-heading-5", onSelect: () => replace("##### ") },
+        { label: "Heading 6", icon: "i-lucide-heading-6", onSelect: () => replace("###### ") },
+      ],
+    },
+    {
+      id: "formatting",
+      label: "Formatting",
+      items: [
+        { label: "Bold", icon: "i-lucide-bold", kbds: ["⌘B"], onSelect: () => { if (handlers) run(handlers.toggleBold) } },
+        { label: "Italic", icon: "i-lucide-italic", kbds: ["⌘I"], onSelect: () => { if (handlers) run(handlers.toggleItalic) } },
+        { label: "Code", icon: "i-lucide-code", kbds: ["⌘`"], onSelect: () => { if (handlers) run(handlers.toggleCode) } },
+        { label: "Strikethrough", icon: "i-lucide-strikethrough", onSelect: () => { if (handlers) run(handlers.toggleStrikethrough) } },
+        { label: "Highlight", icon: "i-lucide-highlighter", onSelect: () => { if (handlers) run(handlers.toggleHighlight) } },
+      ],
+    },
+    {
+      id: "blocks",
+      label: "Blocks",
+      items: [
+        { label: "Task", icon: "i-lucide-list-todo", onSelect: () => replace("TODO ") },
+        { label: "Block Math", icon: "i-lucide-square-radical", onSelect: () => replace("$$\n\n$$") },
+      ],
+    },
+    {
+      id: "insert",
+      label: "Insert",
+      items: [
+        { label: "Link", icon: "i-lucide-link", onSelect: () => replace("[link](url)") },
+        { label: "Inline Math", icon: "i-lucide-sigma", onSelect: () => replace("$$") },
+        { label: "Page Reference", icon: "i-lucide-book-marked", onSelect: () => replace("[[Page Name]]") },
+        { label: "Block Reference", icon: "i-lucide-arrow-up-from-dot", onSelect: () => replace("((block-id))") },
+        { label: "Tag", icon: "i-lucide-hash", onSelect: () => replace("#tag") },
+      ],
+    },
+  ]
+}
+
+const DEMO_PAGES = [
+  { label: "Getting Started", suffix: "Page" },
+  { label: "Physics Notes", suffix: "Page" },
+  { label: "Documentation", suffix: "Page" },
+  { label: "Architecture", suffix: "Page" },
+  { label: "API Reference", suffix: "Page" },
+  { label: "Development Guide", suffix: "Page" },
+  { label: "Release Notes", suffix: "Page" },
+  { label: "Meeting Notes", suffix: "Page" },
+]
+
+const DEMO_BLOCKS = [
+  { label: "Fix login bug", suffix: "abc123" },
+  { label: "Implement API endpoint", suffix: "def456" },
+  { label: "Update documentation", suffix: "ghi789" },
+  { label: "Refactor tests", suffix: "jkl012" },
+  { label: "Review PR #42", suffix: "mno345" },
+]
+
+const DEMO_TAGS = [
+  { label: "documentation", suffix: "Tag" },
+  { label: "development", suffix: "Tag" },
+  { label: "bug", suffix: "Tag" },
+  { label: "feature", suffix: "Tag" },
+  { label: "enhancement", suffix: "Tag" },
+  { label: "planning", suffix: "Tag" },
+  { label: "urgent", suffix: "Tag" },
+]
+
+export function buildMentionGroups(
+  payload: PatternOpenPayload,
+  onClose: () => void,
+): CommandPaletteGroup[] {
+  const { kind, respond, close: closeSession } = payload
+
+  function select(text: string) {
+    const prefix = kind === "tag" ? "#" : ""
+    const closeDelim = kind === "reference" ? "]]" : kind === "embed" ? "))" : ""
+    respond({ text: `${prefix}${text}${closeDelim}`, mode: "replace" })
+    closeSession("completed")
+    onClose()
+  }
+
+  if (kind === "reference") {
+    return [
+      {
+        id: "pages",
+        label: "Pages",
+        items: DEMO_PAGES.map((p) => ({
+          label: p.label,
+          suffix: p.suffix,
+          icon: "i-lucide-file-text",
+          onSelect: () => select(p.label),
+        })),
+      },
+    ]
+  }
+
+  if (kind === "embed") {
+    return [
+      {
+        id: "blocks",
+        label: "Blocks",
+        items: DEMO_BLOCKS.map((b) => ({
+          label: b.label,
+          suffix: b.suffix,
+          icon: "i-lucide-message-square",
+          onSelect: () => select(b.suffix),
+        })),
+      },
+    ]
+  }
+
+  if (kind === "tag") {
+    return [
+      {
+        id: "tags",
+        label: "Tags",
+        items: DEMO_TAGS.map((t) => ({
+          label: t.label,
+          suffix: t.suffix,
+          icon: "i-lucide-hash",
+          onSelect: () => select(t.label),
+        })),
+      },
+    ]
+  }
+
+  return []
+}
+
+export function useSlashGroups(
+  payload: Ref<PatternOpenPayload | null>,
+  handlers: Ref<FormattingHandlers | null>,
+  onClose: () => void,
+): ComputedRef<CommandPaletteGroup[]> {
+  return computed(() => {
+    if (!payload.value) return []
+    return buildSlashGroups(payload.value, handlers.value, onClose)
+  })
+}
+
+export function useMentionGroups(
+  payload: Ref<PatternOpenPayload | null>,
+  onClose: () => void,
+): ComputedRef<CommandPaletteGroup[]> {
+  return computed(() => {
+    if (!payload.value) return []
+    return buildMentionGroups(payload.value, onClose)
+  })
+}

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

@@ -1,6 +1,7 @@
 import type { App } from "vue"
 import Block from "@/components/Block.vue"
 import Editor from "@/components/Editor.vue"
+import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import EditorToolbar from "@/components/EditorToolbar.vue"
 
 import "@/assets/style.css"
@@ -37,8 +38,32 @@ export type {
   ThemeInput,
   ThemeSyntax,
 } from "@/lib/theme"
-export { resolveTheme, themePresets, themeToCSSVars } from "@/lib/theme"
-export { Block, Editor, EditorToolbar }
+export {
+  resolveTheme,
+  themePresets,
+  themeToCSSVars,
+} from "@/lib/theme"
+export type {
+  PatternClosePayload,
+  PatternKind,
+  PatternOpenPayload,
+  PatternSession,
+  PatternUpdatePayload,
+} from "@/composables/usePatternPlugin"
+export type { KeyBinding, KeyboardAction, KeyboardConfig } from "@/composables/useKeyboard"
+export {
+  defaultKeyBinding,
+  useKeyboard,
+  vimKeyBinding,
+} from "@/composables/useKeyboard"
+export type { SuggestionActionItem } from "@/composables/useSuggestionGroups"
+export {
+  buildMentionGroups,
+  buildSlashGroups,
+  useMentionGroups,
+  useSlashGroups,
+} from "@/composables/useSuggestionGroups"
+export { Block, Editor, EditorSuggestionMenu, EditorToolbar }
 
 export default {
   install(app: App) {