Ver Fonte

refactor(editor): move suggestion menu into Block.vue with scroll-following position

- Render EditorSuggestionMenu inline in Block.vue (absolute positioning)
  instead of teleported to body (fixed positioning), so it scrolls with content
- Track pattern session locally in Block.vue with capture-phase keyboard
  listener for ArrowDown/ArrowUp/Enter/Escape
- Remove forwardKey routing from useKeyboard.ts and Editor.vue
- Filter suggestion items by query prop (case-insensitive label match)
- Fix reference/embed insertion: include trigger ([[/(() in replacement
  text so they aren't stripped when the range is replaced
- Add JSDoc to EditorSuggestionMenu.vue and document API in README.md
Zander Hawke há 4 dias atrás
pai
commit
661fa19b29

+ 43 - 0
packages/editor/README.md

@@ -190,6 +190,49 @@ Tables are parsed by Lezer GFM as `Table` nodes (detected on full re-parse, not
 
 Clicking the rendered table focuses the block and reveals the source. All inline patterns (`**bold**`, `*italic*`, `` `code` ``, `[link](url)`, `$...$`, `[[page]]`, `((id))`, `#tag`, `~~strike~~`, `^^high^^`) render correctly in blurred cells.
 
+## `<EditorSuggestionMenu>` API
+
+A self-contained suggestion listbox for slash commands (`/`) and mention completions (`[[`, `((`, `#`). Rendered via `<Teleport to="body">` with enter/leave transitions. Keyboard navigation is driven externally through the `forwardKey` method — no synthetic DOM events, no external UI library dependency for list navigation.
+
+### Props
+
+| Prop | Type | Description |
+|---|---|---|
+| `groups` | `CommandPaletteGroup[]` | Grouped items matching Nuxt UI's `CommandPaletteGroup` shape (`.id`, `.label`, `.items[]` with `.label`, `.icon`, `.suffix`, `.kbds`, `.onSelect`). |
+| `position` | `{ x: number, y: number }` | Screen-space top-left of the trigger character. Menu is offset 24px below. |
+| `query` | `string` | Current search text — shown in the empty-state message. |
+| `active` | `boolean` | Show/hide the menu. Resets `activeIndex` to 0 on transition. |
+
+### Exposed methods
+
+```ts
+const menuRef = ref<{ forwardKey: (key: string) => void }>()
+
+menuRef.value?.forwardKey("ArrowDown")  // highlight next item
+menuRef.value?.forwardKey("ArrowUp")    // highlight previous item
+menuRef.value?.forwardKey("Enter")      // select highlighted item
+menuRef.value?.forwardKey("Escape")     // close menu
+```
+
+### Emits
+
+| Event | Trigger |
+|---|---|
+| `close` | Item selected (via click or Enter) or Escape pressed. Parent should clear the pattern session. |
+
+### Item shape
+
+Each item in a group accepts the following fields (from `CommandPaletteItem`):
+
+| Field | Type | Rendered as |
+|---|---|---|
+| `icon` | `string` (Iconify class) | `<UIcon :name="..." />` |
+| `label` | `string` | Item text |
+| `suffix` | `string` | Trailing muted text (right-aligned) |
+| `kbds` | `Array<string \| object>` | Small `<kbd>` badges (replaces suffix) |
+| `disabled` | `boolean` | Dims item and blocks selection |
+| `onSelect` | `(e: Event) => void` | Called when the item is chosen |
+
 ### Pattern Detection
 
 As the user types, a ProseMirror `ViewPlugin` (`usePatternPlugin.ts`) detects trigger characters and emits lifecycle events:

+ 115 - 10
packages/editor/src/components/Block.vue

@@ -9,14 +9,24 @@ import {
   type Transaction,
 } from "prosemirror-state"
 import { EditorView } from "prosemirror-view"
-import { onMounted, onUnmounted, useTemplateRef, watch } from "vue"
+import {
+  onMounted,
+  onUnmounted,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue"
+import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import { createBlockKeyboardHandler } from "@/composables/useBlockKeyboardHandlers"
 import {
   addFences,
   type CodeBlockNavigationDelegate,
   CodeBlockView,
 } from "@/composables/useCodeBlockView"
-import type { FocusPosition, FocusRegistry } from "@/composables/useFocusRegistry"
+import type {
+  FocusPosition,
+  FocusRegistry,
+} from "@/composables/useFocusRegistry"
 import {
   createMarkdownDecorationsPlugin,
   type MarkerVisibilityMode,
@@ -29,6 +39,10 @@ import type {
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
 import { createPatternPlugin } from "@/composables/usePatternPlugin"
+import {
+  useMentionGroups,
+  useSlashGroups,
+} from "@/composables/useSuggestionGroups"
 import {
   autoClosePlugin,
   createPairRule,
@@ -111,8 +125,10 @@ const handleKeyDown = createBlockKeyboardHandler({
   "delete-if-empty": () => emit("delete-if-empty"),
   indent: () => emit("indent"),
   outdent: () => emit("outdent"),
-  "arrow-up-from-start": (leftOffset) => emit("arrow-up-from-start", leftOffset),
-  "arrow-down-from-end": (leftOffset) => emit("arrow-down-from-end", leftOffset),
+  "arrow-up-from-start": (leftOffset) =>
+    emit("arrow-up-from-start", leftOffset),
+  "arrow-down-from-end": (leftOffset) =>
+    emit("arrow-down-from-end", leftOffset),
 })
 
 function handlerWithRefocus(fn: (view: EditorView) => void) {
@@ -182,10 +198,82 @@ const { plugin: markdownDecorationsPlugin } = createMarkdownDecorationsPlugin(
   props.markerMode ?? "live-preview",
 )
 
+// ── Suggestion menu ──────────────────────────────────────────────
+
+const localSession = ref<PatternOpenPayload | null>(null)
+const menuPosition = ref({ x: 0, y: 0 })
+const menuRef = useTemplateRef<{ forwardKey: (key: string) => void }>("menuRef")
+const menuWrapperRef = useTemplateRef<HTMLElement>("menuWrapperRef")
+
+function closeSession() {
+  localSession.value = null
+}
+
+const slashGroups = useSlashGroups(
+  localSession,
+  ref<FormattingHandlers | null>(formattingHandlers),
+  closeSession,
+)
+const mentionGroups = useMentionGroups(localSession, closeSession)
+
+function onCaptureKeydown(e: KeyboardEvent) {
+  if (!localSession.value) return
+  if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(e.key)) {
+    e.preventDefault()
+    e.stopPropagation()
+    if (e.key === "Escape") {
+      closeSession()
+      // Also notify the pattern plugin via the session's close callback.
+      localSession.value?.close("cancelled")
+    } else {
+      menuRef.value?.forwardKey(e.key)
+    }
+  }
+}
+
+function recalculateMenuPosition() {
+  if (!localSession.value || !view || !menuWrapperRef.value) return
+  const pos = view.coordsAtPos(localSession.value.range.from)
+  if (!pos) return
+  const rect = menuWrapperRef.value.getBoundingClientRect()
+  menuPosition.value = {
+    x: pos.left - rect.left,
+    y: pos.top - rect.top,
+  }
+}
+
+watch(localSession, (session, prev) => {
+  if (session && !prev) {
+    document.addEventListener("keydown", onCaptureKeydown, true)
+    window.addEventListener("scroll", recalculateMenuPosition, {
+      passive: true,
+    })
+    recalculateMenuPosition()
+  } else if (!session && prev) {
+    document.removeEventListener("keydown", onCaptureKeydown, true)
+    window.removeEventListener("scroll", recalculateMenuPosition)
+  }
+})
+
 const patternPlugin = createPatternPlugin({
-  onPatternOpen: (payload) => emit("pattern-open", payload),
-  onPatternUpdate: (payload) => emit("pattern-update", payload),
-  onPatternClose: (payload) => emit("pattern-close", payload),
+  onPatternOpen: (payload) => {
+    localSession.value = payload
+    emit("pattern-open", payload)
+  },
+  onPatternUpdate: (payload) => {
+    if (!localSession.value) return
+    localSession.value = {
+      ...localSession.value,
+      query: payload.query,
+      position: payload.position,
+      range: payload.range,
+    }
+    emit("pattern-update", payload)
+  },
+  onPatternClose: (payload) => {
+    localSession.value = null
+    emit("pattern-close", payload)
+  },
 })
 
 function focusAt(cursorPosition?: FocusPosition) {
@@ -201,7 +289,11 @@ function focusAt(cursorPosition?: FocusPosition) {
 
   // Coordinate-based focus: resolve screen X to a document position
   // for column-preserving block-to-block navigation.
-  if (cursorPosition && typeof cursorPosition === "object" && "left" in cursorPosition) {
+  if (
+    cursorPosition &&
+    typeof cursorPosition === "object" &&
+    "left" in cursorPosition
+  ) {
     v.focus()
     requestAnimationFrame(() => {
       if (!view) return
@@ -215,7 +307,9 @@ function focusAt(cursorPosition?: FocusPosition) {
       if (pos) {
         setSafeSelection(pos.pos)
       } else {
-        setSafeSelection(cursorPosition.edge === "top" ? 1 : Math.max(1, doc.content.size - 1))
+        setSafeSelection(
+          cursorPosition.edge === "top" ? 1 : Math.max(1, doc.content.size - 1),
+        )
       }
     })
     return
@@ -589,5 +683,16 @@ defineExpose({
 </script>
 
 <template>
-  <div ref="editorRef" class="w-full min-h-7 text-base"></div>
+  <div ref="menuWrapperRef" style="position: relative">
+    <div ref="editorRef" class="w-full min-h-7 text-base"></div>
+
+    <EditorSuggestionMenu
+      ref="menuRef"
+      :active="localSession !== null"
+      :groups="localSession?.kind === 'command' ? slashGroups : mentionGroups"
+      :position="menuPosition"
+      :query="localSession?.query ?? ''"
+      @close="closeSession"
+    />
+  </div>
 </template>

+ 3 - 44
packages/editor/src/components/Editor.vue

@@ -2,7 +2,6 @@
 import type { EditorView } from "prosemirror-view"
 import { computed, nextTick, type Ref, ref, watch } from "vue"
 import Block from "@/components/Block.vue"
-import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 import InsertionZone from "@/components/InsertionZone.vue"
 import {
   type FocusPosition,
@@ -20,10 +19,6 @@ import type {
   PatternOpenPayload,
   PatternUpdatePayload,
 } from "@/composables/usePatternPlugin"
-import {
-  useMentionGroups,
-  useSlashGroups,
-} from "@/composables/useSuggestionGroups"
 import {
   type EditorBlock,
   generateBlockId,
@@ -270,12 +265,10 @@ function onBlockSelectionChangeHandler(payload: {
   emit("selection-change", payload)
 }
 
-// ── Suggestion menus (pattern plugin integration) ──────────────────
+// ── Pattern session (used only for keyboard capture lifecycle) ─────
 
 const activeSession = ref<PatternOpenPayload | null>(null)
 const activeView: Ref<EditorView | null> = ref(null)
-const slashMenuRef = ref<{ forwardKey: (key: string) => void } | null>(null)
-const mentionMenuRef = ref<{ forwardKey: (key: string) => void } | null>(null)
 
 useKeyboard({
   keybinding: props.keyBinding ?? defaultKeyBinding,
@@ -283,20 +276,8 @@ useKeyboard({
   activeView,
   onUndo: undo,
   onRedo: redo,
-  forwardKey: (key) => {
-    const isSlash = activeSession.value?.kind === "command"
-    const target = isSlash ? slashMenuRef : mentionMenuRef
-    target.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
@@ -430,9 +411,7 @@ function onArrowUpFromStart(index: number, leftOffset?: number) {
   const prev = blocks.value[index - 1]
   if (!prev) return
   const target: FocusPosition =
-    leftOffset !== undefined
-      ? { left: leftOffset, edge: "bottom" }
-      : "end"
+    leftOffset !== undefined ? { left: leftOffset, edge: "bottom" } : "end"
   registry.focus(prev.id, target)
 }
 
@@ -445,9 +424,7 @@ function onArrowDownFromEnd(index: number, leftOffset?: number) {
   const next = blocks.value[index + 1]
   if (!next) return
   const target: FocusPosition =
-    leftOffset !== undefined
-      ? { left: leftOffset, edge: "top" }
-      : "start"
+    leftOffset !== undefined ? { left: leftOffset, edge: "top" } : "start"
   registry.focus(next.id, target)
 }
 
@@ -674,22 +651,4 @@ defineExpose({ undo, redo, canUndo, canRedo })
       @navigate-down="onZoneNavigateDown(blocks.length)"
     />
   </div>
-
-  <EditorSuggestionMenu
-    ref="slashMenuRef"
-    :active="activeSession?.kind === 'command'"
-    :groups="slashGroups"
-    :position="activeSession?.position ?? { x: 0, y: 0 }"
-    :query="activeSession?.query ?? ''"
-    @close="closeSession"
-  />
-
-  <EditorSuggestionMenu
-    ref="mentionMenuRef"
-    :active="!!(activeSession && ['reference', 'embed', 'tag'].includes(activeSession.kind))"
-    :groups="mentionGroups"
-    :position="activeSession?.position ?? { x: 0, y: 0 }"
-    :query="activeSession?.query ?? ''"
-    @close="closeSession"
-  />
 </template>

+ 173 - 33
packages/editor/src/components/EditorSuggestionMenu.vue

@@ -1,55 +1,195 @@
 <script setup lang="ts">
-import type { CommandPaletteGroup } from "@nuxt/ui"
-import { ref } from "vue"
+/**
+ * EditorSuggestionMenu
+ *
+ * Self-contained suggestion listbox for slash commands (`/`) and mention
+ * completions (`[[`, `((`, `#`).  Driven entirely by props — no internal
+ * state machine, no external UI library dependency for navigation.
+ *
+ * Keyboard navigation is exposed via `forwardKey(key)` so that the
+ * capture-phase handler in `useKeyboard.ts` can route ArrowDown/ArrowUp/
+ * Enter/Escape without dispatching synthetic DOM events.
+ *
+ * Items are grouped by `groups` (matching Nuxt UI's `CommandPaletteGroup`
+ * shape).  Each item can have an icon, label, suffix, kbd hints, and an
+ * `onSelect` callback.  The actively highlighted item is tracked by
+ * `activeIndex` (flat across all groups) and rendered via
+ * `data-highlighted` attribute — styled with CSS tokens matching the
+ * existing editor theme.
+ */
+import type { CommandPaletteGroup, CommandPaletteItem } from "@nuxt/ui"
+import { computed, ref, watch } from "vue"
 
 const props = defineProps<{
+  /** Grouped items following Nuxt UI's CommandPaletteGroup shape. */
   groups: CommandPaletteGroup[]
+  /** Screen-space position (top-left of the trigger character). */
   position: { x: number; y: number }
+  /** Current search/filter text (shown in the empty-state message). */
   query: string
+  /** Whether the menu is visible.  Resets activeIndex to 0 on transition. */
   active: boolean
 }>()
 
 const emit = defineEmits<{
+  /**
+   * Fired when an item is selected via click or Enter, or when
+   * Escape is pressed.  The parent should clear the pattern session.
+   */
   close: []
 }>()
 
-const paletteRef = ref<any>(null)
+/** Filter groups by query (case-insensitive match against item label). */
+const filteredGroups = computed(() => {
+  const q = props.query.toLowerCase()
+  if (!q) return props.groups
+  return props.groups
+    .map((g) => ({
+      ...g,
+      items: g.items?.filter((item) => item.label?.toLowerCase().includes(q)),
+    }))
+    .filter((g) => g.items && g.items.length > 0)
+})
 
+/** Index of the currently highlighted item (flat across all filtered groups). */
+const activeIndex = ref(0)
+const total = computed(() =>
+  filteredGroups.value.reduce((sum, g) => sum + (g.items?.length ?? 0), 0),
+)
+
+watch(
+  () => props.active,
+  (v) => {
+    if (v) activeIndex.value = 0
+  },
+)
+
+watch(
+  () => props.query,
+  () => {
+    activeIndex.value = 0
+  },
+)
+
+/** Convert (group index, item-within-group index) to a flat global index. */
+function getGlobalIndex(groupIdx: number, itemIdx: number): number {
+  let offset = 0
+  for (let g = 0; g < groupIdx; g++) {
+    offset += filteredGroups.value[g]?.items?.length ?? 0
+  }
+  return offset + itemIdx
+}
+
+/** Select an item by group + item index.  Calls onSelect and emits close. */
+function selectItem(groupIdx: number, itemIdx: number) {
+  const group = filteredGroups.value[groupIdx]
+  const item = group?.items?.[itemIdx]
+  if (item && !item.disabled) {
+    item.onSelect?.(new Event("select"))
+    emit("close")
+  }
+}
+
+/**
+ * Drive keyboard navigation externally.
+ *
+ * Called by the capture-phase handler in `useKeyboard.ts` when a pattern
+ * session is active.  Updates `activeIndex` for ArrowUp/Down, selects
+ * the highlighted item on Enter, and closes on Escape.
+ */
 function forwardKey(key: string) {
-  const input = paletteRef.value?.$el?.querySelector("input")
-  input?.dispatchEvent(
-    new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
-  )
+  if (key === "Escape") {
+    emit("close")
+    return
+  }
+  if (total.value === 0) return
+  if (key === "ArrowDown") {
+    activeIndex.value = (activeIndex.value + 1) % total.value
+  } else if (key === "ArrowUp") {
+    activeIndex.value = (activeIndex.value - 1 + total.value) % total.value
+  } else if (key === "Enter") {
+    const item = findItemByGlobalIndex(activeIndex.value)
+    if (item && !item.disabled) {
+      item.onSelect?.(new Event("select"))
+    }
+    emit("close")
+  }
+}
+
+/** Resolve a flat global index (on filtered items) back to the original item. */
+function findItemByGlobalIndex(index: number): CommandPaletteItem | null {
+  let offset = 0
+  for (const g of filteredGroups.value) {
+    if (!g.items) continue
+    if (index < offset + g.items.length) {
+      return g.items[index - offset] ?? null
+    }
+    offset += g.items.length
+  }
+  return null
 }
 
 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>
+  <div
+    v-if="active"
+    data-command-palette
+    class="flex flex-col absolute 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` }"
+  >
+        <template v-for="(group, gIdx) in filteredGroups" :key="group.id">
+          <div
+            v-if="group.label && group.items?.length"
+            class="px-3 py-1.5 text-xs font-semibold text-(--ui-text-muted) uppercase tracking-wider select-none"
+          >
+            {{ group.label }}
+          </div>
+
+          <div
+            v-for="(item, iIdx) in group.items"
+            :key="`${group.id}-${iIdx}`"
+            :data-highlighted="getGlobalIndex(gIdx, iIdx) === activeIndex ? '' : undefined"
+            :class="[
+              'flex items-center gap-2.5 px-3 py-2 cursor-pointer text-sm transition-colors',
+              item.disabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-(--ui-bg-elevated)/60',
+              getGlobalIndex(gIdx, iIdx) === activeIndex ? 'bg-(--ui-bg-elevated)' : '',
+            ]"
+            @click="selectItem(gIdx, iIdx)"
+            @mouseenter="activeIndex = getGlobalIndex(gIdx, iIdx)"
+          >
+            <UIcon
+              v-if="item.icon"
+              :name="item.icon"
+              class="w-5 h-5 shrink-0 text-(--ui-text-muted)"
+            />
+
+            <span class="flex-1 truncate text-(--ui-text-main)">{{ item.label }}</span>
+
+            <span
+              v-if="item.suffix"
+              class="text-xs text-(--ui-text-muted) font-mono ml-auto"
+            >{{ item.suffix }}</span>
+
+            <span
+              v-else-if="item.kbds?.length"
+              class="flex items-center gap-1 ml-auto"
+            >
+              <kbd
+                v-for="(k, kIdx) in item.kbds"
+                :key="kIdx"
+                class="px-1 py-0.5 text-[10px] font-mono rounded bg-(--ui-bg-elevated)/50 text-(--ui-text-muted) border border-(--ui-border)"
+              >{{ k }}</kbd>
+            </span>
+          </div>
+        </template>
+
+        <div
+          v-if="!total"
+          class="px-3 py-4 text-sm text-(--ui-text-muted) text-center select-none"
+        >
+          {{ query ? `No results for "${query}"` : "No items" }}
+        </div>
+    </div>
 </template>

+ 26 - 45
packages/editor/src/components/__tests__/editor-integration.test.ts

@@ -1,12 +1,16 @@
 import { describe, expect, it, vi } from "vitest"
-import { ref, type Ref } from "vue"
-import { useKeyboard } from "@/composables/useKeyboard"
-import { useSlashGroups, useMentionGroups } from "@/composables/useSuggestionGroups"
+import { ref } from "vue"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
+import {
+  useMentionGroups,
+  useSlashGroups,
+} from "@/composables/useSuggestionGroups"
 import type { FormattingHandlers } from "@/lib/formatting"
-import type { EditorView } from "prosemirror-view"
 
-function createMockSession(kind: string = "command", query: string = "/"): PatternOpenPayload {
+function createMockSession(
+  kind: string = "command",
+  query: string = "/",
+): PatternOpenPayload {
   return {
     kind: kind as any,
     query,
@@ -18,41 +22,6 @@ function createMockSession(kind: string = "command", query: string = "/"): Patte
 }
 
 describe("Editor integration — menu routing", () => {
-  it("routes forwardKey to the correct menu based on session kind", () => {
-    const activeSession = ref<PatternOpenPayload | null>(null)
-    const activeView = ref<EditorView | null>(null) as Ref<EditorView | null>
-
-    const slashForwardKey = vi.fn()
-    const mentionForwardKey = vi.fn()
-
-    function forwardKey(key: string) {
-      const isSlash = activeSession.value?.kind === "command"
-      const target = { forwardKey: isSlash ? slashForwardKey : mentionForwardKey }
-      target.forwardKey(key)
-    }
-
-    useKeyboard({
-      keybinding: { match: () => null },
-      activeSession,
-      activeView,
-      onUndo: vi.fn(),
-      onRedo: vi.fn(),
-      forwardKey,
-    })
-
-    activeSession.value = createMockSession("command")
-    forwardKey("ArrowDown")
-    expect(slashForwardKey).toHaveBeenCalledWith("ArrowDown")
-    expect(mentionForwardKey).not.toHaveBeenCalled()
-
-    slashForwardKey.mockClear()
-
-    activeSession.value = createMockSession("reference")
-    forwardKey("ArrowDown")
-    expect(mentionForwardKey).toHaveBeenCalledWith("ArrowDown")
-    expect(slashForwardKey).not.toHaveBeenCalled()
-  })
-
   it("builds slash and mention groups reactively from session", () => {
     const activeSession = ref<PatternOpenPayload | null>(null)
     const handlers = ref<FormattingHandlers | null>(null)
@@ -94,7 +63,9 @@ describe("Editor integration — menu routing", () => {
   })
 
   it("closeSession calls close and clears state", () => {
-    const activeSession = ref<PatternOpenPayload | null>(createMockSession("command"))
+    const activeSession = ref<PatternOpenPayload | null>(
+      createMockSession("command"),
+    )
 
     function closeSession() {
       activeSession.value?.close("cancelled")
@@ -106,9 +77,15 @@ describe("Editor integration — menu routing", () => {
   })
 
   it("updates query and position on pattern-update", () => {
-    const activeSession = ref<PatternOpenPayload | null>(createMockSession("command", "/test"))
-
-    function onPatternUpdate(payload: { query: string; position: { x: number; y: number }; range: { from: number; to: number } }) {
+    const activeSession = ref<PatternOpenPayload | null>(
+      createMockSession("command", "/test"),
+    )
+
+    function onPatternUpdate(payload: {
+      query: string
+      position: { x: number; y: number }
+      range: { from: number; to: number }
+    }) {
       if (!activeSession.value) return
       activeSession.value = {
         ...activeSession.value,
@@ -118,7 +95,11 @@ describe("Editor integration — menu routing", () => {
       }
     }
 
-    onPatternUpdate({ query: "/newquery", position: { x: 50, y: 100 }, range: { from: 0, to: 9 } })
+    onPatternUpdate({
+      query: "/newquery",
+      position: { x: 50, y: 100 },
+      range: { from: 0, to: 9 },
+    })
     expect(activeSession.value!.query).toBe("/newquery")
     expect(activeSession.value!.position).toEqual({ x: 50, y: 100 })
     expect(activeSession.value!.range).toEqual({ from: 0, to: 9 })

+ 139 - 11
packages/editor/src/components/__tests__/editor-suggestion-menu.test.ts

@@ -1,7 +1,11 @@
 import { mount } from "@vue/test-utils"
-import { afterEach, describe, expect, it } from "vitest"
+import { afterEach, describe, expect, it, vi } from "vitest"
 import EditorSuggestionMenu from "@/components/EditorSuggestionMenu.vue"
 
+interface MenuVM {
+  forwardKey(key: string): void
+}
+
 function createWrapper(props: Record<string, unknown> = {}) {
   return mount(EditorSuggestionMenu, {
     props: {
@@ -12,14 +16,10 @@ function createWrapper(props: Record<string, unknown> = {}) {
       ...props,
     },
     attachTo: document.body,
-    global: {
-      stubs: {
-        UCommandPalette: {
-          template: '<div><input /></div>',
-        },
-      },
-    },
-  })
+  }) as unknown as {
+    vm: MenuVM & Record<string, unknown>
+    emitted: () => Record<string, unknown[]>
+  }
 }
 
 describe("EditorSuggestionMenu", () => {
@@ -40,13 +40,141 @@ describe("EditorSuggestionMenu", () => {
 
   it("positions the menu correctly", () => {
     createWrapper({ active: true, position: { x: 100, y: 200 } })
-    const el = document.body.querySelector("[data-command-palette]") as HTMLElement
+    const el = document.body.querySelector(
+      "[data-command-palette]",
+    ) as HTMLElement
     expect(el.style.top).toBe("224px")
     expect(el.style.left).toBe("100px")
   })
 
   it("exposes forwardKey via template ref", () => {
     const wrapper = createWrapper({ active: true })
-    expect(typeof (wrapper.vm as any).forwardKey).toBe("function")
+    expect(typeof wrapper.vm.forwardKey).toBe("function")
+  })
+
+  it("renders items from groups", () => {
+    const groups = [
+      {
+        id: "test",
+        label: "Test",
+        items: [
+          { label: "Item 1", icon: "i-lucide-text", onSelect: () => {} },
+          { label: "Item 2", onSelect: () => {} },
+        ],
+      },
+    ]
+    createWrapper({ active: true, groups })
+    const items = document.body.querySelectorAll(
+      "[data-command-palette] [data-highlighted]",
+    )
+    expect(items.length).toBe(1)
+    expect(items[0]?.textContent).toContain("Item 1")
+  })
+
+  it("highlights first item by default", () => {
+    const groups = [
+      {
+        id: "test",
+        items: [
+          { label: "First", onSelect: () => {} },
+          { label: "Second", onSelect: () => {} },
+        ],
+      },
+    ]
+    createWrapper({ active: true, groups })
+    const highlighted = document.body.querySelectorAll("[data-highlighted]")
+    expect(highlighted.length).toBe(1)
+    expect(highlighted[0]?.textContent).toContain("First")
+  })
+
+  it("forwardKey ArrowDown moves highlight to next item", async () => {
+    const groups = [
+      {
+        id: "test",
+        items: [
+          { label: "First", onSelect: () => {} },
+          { label: "Second", onSelect: () => {} },
+        ],
+      },
+    ]
+    const wrapper = createWrapper({ active: true, groups })
+    wrapper.vm.forwardKey("ArrowDown")
+    await wrapper.vm.$nextTick()
+    const highlighted = document.body.querySelectorAll("[data-highlighted]")
+    expect(highlighted.length).toBe(1)
+    expect(highlighted[0]?.textContent).toContain("Second")
+  })
+
+  it("forwardKey ArrowUp wraps to last item", async () => {
+    const groups = [
+      {
+        id: "test",
+        items: [
+          { label: "First", onSelect: () => {} },
+          { label: "Second", onSelect: () => {} },
+        ],
+      },
+    ]
+    const wrapper = createWrapper({ active: true, groups })
+    wrapper.vm.forwardKey("ArrowUp")
+    await wrapper.vm.$nextTick()
+    const highlighted = document.body.querySelectorAll("[data-highlighted]")
+    expect(highlighted.length).toBe(1)
+    expect(highlighted[0]?.textContent).toContain("Second")
+  })
+
+  it("forwardKey Enter selects item and emits close", async () => {
+    const onSelect = vi.fn()
+    const groups = [
+      {
+        id: "test",
+        items: [{ label: "Item", onSelect }],
+      },
+    ]
+    const wrapper = createWrapper({ active: true, groups })
+    wrapper.vm.forwardKey("Enter")
+    await wrapper.vm.$nextTick()
+    expect(onSelect).toHaveBeenCalled()
+    expect(wrapper.emitted().close).toBeTruthy()
+  })
+
+  it("forwardKey Escape emits close", () => {
+    const wrapper = createWrapper({ active: true })
+    wrapper.vm.forwardKey("Escape")
+    expect(wrapper.emitted().close).toBeTruthy()
+  })
+
+  it("renders group label", () => {
+    const groups = [
+      {
+        id: "test",
+        label: "Group Label",
+        items: [{ label: "Item", onSelect: () => {} }],
+      },
+    ]
+    createWrapper({ active: true, groups })
+    expect(document.body.textContent).toContain("Group Label")
+  })
+
+  it("renders kbd hints", () => {
+    const groups = [
+      {
+        id: "test",
+        items: [{ label: "Item", kbds: ["⌘B"], onSelect: () => {} }],
+      },
+    ]
+    createWrapper({ active: true, groups })
+    expect(document.body.textContent).toContain("⌘B")
+  })
+
+  it("renders suffix", () => {
+    const groups = [
+      {
+        id: "test",
+        items: [{ label: "Item", suffix: "Page", onSelect: () => {} }],
+      },
+    ]
+    createWrapper({ active: true, groups })
+    expect(document.body.textContent).toContain("Page")
   })
 })

+ 8 - 22
packages/editor/src/composables/useKeyboard.ts

@@ -7,21 +7,19 @@
  *
  * Keybindings are swappable — pass a different binding to support
  * vim, emacs, or custom keymaps without changing any routing logic.
+ *
+ * Palette navigation (ArrowDown/ArrowUp/Enter) is NOT handled here.
+ * Each Block manages its own suggestion menu keyboard via a separate
+ * capture-phase listener when its local session is active.
  */
 
-import { onUnmounted, watch, type Ref } from "vue"
 import type { EditorView } from "prosemirror-view"
+import { onUnmounted, type Ref, watch } from "vue"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 
 // ── Types ────────────────────────────────────────────────────────
 
-export type KeyboardAction =
-  | "navigate-down"
-  | "navigate-up"
-  | "select"
-  | "close-palette"
-  | "undo"
-  | "redo"
+export type KeyboardAction = "close-palette" | "undo" | "redo"
 
 export interface KeyboardContext {
   isPaletteOpen: boolean
@@ -37,7 +35,6 @@ export interface KeyboardConfig {
   activeView: Ref<EditorView | null>
   onUndo: () => void
   onRedo: () => void
-  forwardKey?: (key: string) => void
 }
 
 // ── Composables ──────────────────────────────────────────────────
@@ -70,11 +67,6 @@ export function useKeyboard(config: KeyboardConfig) {
         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
     }
   }
 
@@ -107,10 +99,9 @@ export function useKeyboard(config: KeyboardConfig) {
 
 export const defaultKeyBinding: KeyBinding = {
   match(e, ctx) {
+    // Palette keyboard (ArrowUp/Down/Enter) is handled by each Block's
+    // local capture listener. Only Escape is handled here.
     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"
     }
 
@@ -132,11 +123,6 @@ export const defaultKeyBinding: KeyBinding = {
 export const vimKeyBinding: KeyBinding = {
   match(e, ctx) {
     if (ctx.isPaletteOpen) {
-      // nvim-cmp convention: Ctrl-n/Ctrl-p navigate, Ctrl-y confirms.
-      if (e.key === "n" && (e.ctrlKey || e.metaKey)) return "navigate-down"
-      if (e.key === "p" && (e.ctrlKey || e.metaKey)) return "navigate-up"
-      if (e.key === "y" && (e.ctrlKey || e.metaKey)) return "select"
-      if (e.key === "Enter") return "select"
       if (e.key === "Escape") return "close-palette"
       if (e.key === "[" && e.ctrlKey) return "close-palette"
     }

+ 115 - 24
packages/editor/src/composables/useSuggestionGroups.ts

@@ -1,5 +1,5 @@
 import type { CommandPaletteGroup } from "@nuxt/ui"
-import { computed, type ComputedRef, type Ref } from "vue"
+import { type ComputedRef, computed, type Ref } from "vue"
 import type { PatternOpenPayload } from "@/composables/usePatternPlugin"
 import type { FormattingHandlers } from "@/lib/formatting"
 
@@ -38,43 +38,134 @@ export function buildSlashGroups(
       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("###### ") },
+        {
+          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) } },
+        {
+          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$$") },
+        {
+          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") },
+        {
+          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"),
+        },
       ],
     },
   ]
@@ -113,12 +204,12 @@ export function buildMentionGroups(
   payload: PatternOpenPayload,
   onClose: () => void,
 ): CommandPaletteGroup[] {
-  const { kind, respond, close: closeSession } = payload
+  const { kind, respond, close: closeSession, trigger } = payload
 
   function select(text: string) {
-    const prefix = kind === "tag" ? "#" : ""
-    const closeDelim = kind === "reference" ? "]]" : kind === "embed" ? "))" : ""
-    respond({ text: `${prefix}${text}${closeDelim}`, mode: "replace" })
+    const closeDelim =
+      kind === "reference" ? "]]" : kind === "embed" ? "))" : ""
+    respond({ text: `${trigger}${text}${closeDelim}`, mode: "replace" })
     closeSession("completed")
     onClose()
   }