Просмотр исходного кода

feat(editor): add toolbar layout modes (fixed/bubble/floating) with @floating-ui/vue

- Add `mode` prop to EditorToolbar: `fixed` (sticky, default), `bubble`
  (appears on text selection), `floating` (appears on empty lines)
- Use `@floating-ui/vue` with virtual element tracking selection rect
- Extract shared button groups into EditorToolbarContent.vue
- Bubble/floating teleport to `.editor-shell` (not body) to stay below
  app-level overlays
- Export ToolbarMode type from public API
- Fix UTooltip portal intercepting clicks via pointer-events-none on
  tooltip content
- Fix onBlockBlurHandler: only clear activeHandlers when focus moves
  outside toolbar (allows heading dropdown to work)
- Pass FocusEvent through EditorBlock blur emit, check relatedTarget
- Fix callout margin: remove override, use regular mb-4
- Replace USeparator with HTML div separator
- Add sticky toolbar wrapper and scrollable editor-body in Editor.vue
- Update dev page with toolbar mode toggle and examples
- Update Tauri app to use floating mode by default
- Add @source directive in Tauri app CSS for Tailwind library scanning
- Regenerate visual baselines after toolbar appearance changes
Zander Hawke 21 часов назад
Родитель
Сommit
bce41b9902
28 измененных файлов с 518 добавлено и 320 удалено
  1. 22 1
      apps/dev/src/pages/editor.vue
  2. 2 2
      apps/dev/src/pages/index.vue
  3. 26 4
      apps/dev/src/pages/toolbar.vue
  4. 1 0
      apps/tauri/src/assets/main.css
  5. 4 3
      apps/tauri/src/components/AppEditor.vue
  6. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/callout-blurred-chromium-darwin.png
  7. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/code-block-chromium-darwin.png
  8. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/editor-initial-chromium-darwin.png
  9. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/heading-blurred-chromium-darwin.png
  10. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/live-preview-blurred-chromium-darwin.png
  11. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/markers-always-visible-chromium-darwin.png
  12. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/page-ref-chips-chromium-darwin.png
  13. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/properties-chromium-darwin.png
  14. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/table-blurred-chromium-darwin.png
  15. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/tag-styling-chromium-darwin.png
  16. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/task-states-chromium-darwin.png
  17. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-dark-chromium-darwin.png
  18. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-default-chromium-darwin.png
  19. BIN
      packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-orange-chromium-darwin.png
  20. 1 0
      packages/editor/package.json
  21. 0 11
      packages/editor/src/assets/style.css
  22. 10 4
      packages/editor/src/components/Editor.vue
  23. 3 3
      packages/editor/src/components/EditorBlock.vue
  24. 130 291
      packages/editor/src/components/EditorToolbar.vue
  25. 303 0
      packages/editor/src/components/EditorToolbarContent.vue
  26. 1 0
      packages/editor/src/index.ts
  27. 1 1
      packages/editor/src/lib/__tests__/markdown-parser.test.ts
  28. 14 0
      pnpm-lock.yaml

+ 22 - 1
apps/dev/src/pages/editor.vue

@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import { Editor, EditorToolbar } from "@enesis/editor"
+import type { ToolbarMode } from "@enesis/editor"
 import { computed, ref } from "vue"
 
 const isE2E = computed(() =>
@@ -18,6 +19,11 @@ const markerMode = computed(() => {
   if (m === "live-preview" || m === "always-visible") return m
   return "always-visible"
 })
+const toolbarMode = ref<ToolbarMode | undefined>(undefined)
+
+function setToolbarMode(m: ToolbarMode) {
+  toolbarMode.value = toolbarMode.value === m ? undefined : m
+}
 
 const content = ref(`
 * # Welcome to Enesis Editor
@@ -100,7 +106,21 @@ function onTagClick({ tag }: { tag: string }) {
             Drag the <span class="i-lucide-grip-vertical size-4 inline-block align-middle text-muted" /> handle to reorder blocks.
           </p>
 
-          <div class="rounded-lg border border-default dark:bg-neutral-950/50 p-4 sm:p-8 rounded-t-md overflow-hidden">
+          <div class="flex items-center gap-2 mb-4">
+            <span class="text-sm text-muted">Toolbar:</span>
+            <UButton
+              v-for="m in (['fixed', 'bubble', 'floating'] as const)"
+              :key="m"
+              size="xs"
+              :variant="toolbarMode === m || (m === 'fixed' && !toolbarMode) ? 'solid' : 'outline'"
+              :color="toolbarMode === m || (m === 'fixed' && !toolbarMode) ? 'primary' : 'neutral'"
+              @click="setToolbarMode(m)"
+            >
+              {{ m }}
+            </UButton>
+          </div>
+
+          <div class="rounded-lg border border-default dark:bg-neutral-950/50 p-4 sm:p-8 rounded-t-md overflow-hidden flex flex-col min-h-0">
             <Editor
               v-model:content="content"
               :focused="true"
@@ -115,6 +135,7 @@ function onTagClick({ tag }: { tag: string }) {
             >
               <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
                 <EditorToolbar
+                  :mode="toolbarMode"
                   :handlers="handlers"
                   :selection-version="selectionVersion"
                   :can-undo="canUndo"

+ 2 - 2
apps/dev/src/pages/index.vue

@@ -4,13 +4,13 @@ import { ref } from "vue"
 
 const example = ref(`# Welcome to the Editor
 
+![Preview](https://picsum.photos/seed/hero/600/200)
+
 This is a **block-based markdown editor** built with Vue 3, TypeScript, and ProseMirror.
 
 > [!NOTE] Every paragraph is its own Block component.
 > Blocks are independent editors with their own state.
 
-![Preview](https://picsum.photos/seed/hero/600/200)
-
 Try the examples under each component page.`)
 </script>
 

+ 26 - 4
apps/dev/src/pages/toolbar.vue

@@ -1,19 +1,21 @@
 <script setup lang="ts">
 import { Editor, EditorToolbar } from "@enesis/editor"
+import type { ToolbarMode } from "@enesis/editor"
 import { ref } from "vue"
 
-const content =
-  ref(`* **Bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $math$ work inline
+const content = ref(`* **Bold**, *italic*, \`code\`, ~~strikethrough~~, ^^highlight^^, and $math$ work inline
 * ## Heading 2
 * TODO This task can be toggled from the toolbar
 * [[Page refs]] and #tags are supported inline`)
+
+const mode = ref<ToolbarMode | undefined>("fixed")
 </script>
 
 <template>
   <UPage>
     <UPageHeader
       title="EditorToolbar"
-      description="Formatting toolbar with active state tracking and undo/redo buttons."
+      description="Formatting toolbar with active state tracking, undo/redo, and three layout modes."
     />
 
     <UPageBody>
@@ -22,12 +24,31 @@ const content =
           <p class="text-sm text-muted leading-relaxed">
             The toolbar is rendered via the <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">#toolbar</code> slot on <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">Editor</code>.
             It receives <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">handlers</code>, <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>, and <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">undo</code>/<code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">redo</code> from the focused block.
+            The <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">mode</code> prop controls the toolbar layout:
+            <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">fixed</code> (default, sticky to editor top),
+            <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">bubble</code> (appears on text selection), or
+            <code class="text-xs font-mono px-1 py-0.5 rounded bg-elevated border border-default">floating</code> (appears on empty lines).
           </p>
 
           <div class="rounded-lg border border-default dark:bg-neutral-950/50 p-4 sm:p-8 rounded-t-md">
+            <div class="flex items-center gap-2 mb-4">
+              <span class="text-sm text-muted">Mode:</span>
+              <UButton
+                v-for="m in (['fixed', 'bubble', 'floating'] as const)"
+                :key="m"
+                size="xs"
+                :variant="(mode === m) ? 'solid' : 'outline'"
+                :color="(mode === m) ? 'primary' : 'neutral'"
+                @click="mode = mode === m ? undefined : m"
+              >
+                {{ m }}
+              </UButton>
+            </div>
+
             <Editor v-model:content="content" :focused="true" marker-mode="always-visible">
               <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
                 <EditorToolbar
+                  :mode="mode"
                   :handlers="handlers"
                   :selection-version="selectionVersion"
                   :can-undo="canUndo"
@@ -53,8 +74,9 @@ const content =
                 </tr>
               </thead>
               <tbody>
+                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">mode</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'fixed' | 'bubble' | 'floating'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">'fixed'</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Toolbar layout mode</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">handlers</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">FormattingHandlers</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Handlers from focused block</td></tr>
-                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">selectionVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Re-compute active states</td></tr>
+                 <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">selectionVersion</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">number</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Re-compute active states on cursor move</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">canUndo / canRedo</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">boolean</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Enable undo/redo buttons</td></tr>
                  <tr><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">undo / redo</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left"><code class="md-code">() => void</code></td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left">—</td><td class="py-3 px-4 text-sm align-top border-e border-b first:border-s border-muted text-left text-muted">Undo/redo callbacks</td></tr>
               </tbody>

+ 1 - 0
apps/tauri/src/assets/main.css

@@ -1,5 +1,6 @@
 @import "tailwindcss";
 @import "@nuxt/ui";
+@source "../../../../packages/editor/src";
 
 @theme {
   --font-sans: 'Geist', sans-serif;

+ 4 - 3
apps/tauri/src/components/AppEditor.vue

@@ -18,11 +18,11 @@ function onTagClick({ tag }: { tag: string }) {
 </script>
 
 <template>
-  <div class="flex flex-col p-4 sm:p-6 dark:bg-neutral-950/50">
-      <Editor
+  <div class="flex flex-col flex-1 min-h-0 p-4 sm:p-6 dark:bg-neutral-950/50">
+    <Editor
       v-model:content="content"
-      :focused="true"
       marker-mode="live-preview"
+      :focused="true"
       :resolved-refs="resolvedRefs"
       :resolved-refs-version="resolvedRefs.size"
       @page-ref-clicked="onPageRefClick"
@@ -31,6 +31,7 @@ function onTagClick({ tag }: { tag: string }) {
     >
       <template #toolbar="{ handlers, selectionVersion, canUndo, canRedo, undo, redo }">
         <EditorToolbar
+          mode="bubble"
           :handlers="handlers"
           :selection-version="selectionVersion"
           :can-undo="canUndo"

BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/callout-blurred-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/code-block-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/editor-initial-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/heading-blurred-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/live-preview-blurred-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/markers-always-visible-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/page-ref-chips-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/properties-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/table-blurred-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/tag-styling-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/task-states-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-dark-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-default-chromium-darwin.png


BIN
packages/editor/e2e/visual/regression.spec.ts-snapshots/theme-orange-chromium-darwin.png


+ 1 - 0
packages/editor/package.json

@@ -62,6 +62,7 @@
     "@codemirror/language-data": "^6.5.2",
     "@codemirror/state": "^6.6.0",
     "@codemirror/view": "^6.43.1",
+    "@floating-ui/vue": "^2.0.0",
     "@lezer/common": "^1.2.2",
     "@lezer/highlight": "^1.2.3",
     "@lezer/markdown": "^1.6.3",

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

@@ -11,17 +11,6 @@
   @apply m-0 text-pretty mb-4;
 }
 
-/* Callouts and blockquotes — no spacing between inner paragraphs */
-.ProseMirror p.md-callout,
-.ProseMirror p.md-blockquote {
-  margin: 0;
-}
-
-.ProseMirror p.md-callout:last-child,
-.ProseMirror p.md-blockquote:last-child {
-  margin-bottom: calc(var(--spacing) * 4);
-}
-
 /* ── Decorators (Markdown syntax) ────────────────────────────────── */
 
 /* All marker classes — both inline decorators and block markers —

+ 10 - 4
packages/editor/src/components/Editor.vue

@@ -422,7 +422,13 @@ function onBlockFocusHandler(payload: {
   emit("focus", payload)
 }
 
-function onBlockBlurHandler() {
+function onBlockBlurHandler(event: FocusEvent) {
+  // Keep handlers when focus moves to the toolbar (e.g., heading dropdown click).
+  // Only clear when focus moves outside the editor.
+  const target = event.relatedTarget as HTMLElement | null
+  if (target?.closest('[role="toolbar"], [data-slot="content"]')) return
+  activeHandlers.value = null
+  activeView.value = null
   emit("blur")
 }
 
@@ -938,13 +944,13 @@ defineExpose({
 </script>
 
 <template>
-  <div class="editor-shell flex flex-col gap-4" :style="themeCSSVars">
+  <div class="editor-shell flex flex-col min-h-0" :style="themeCSSVars">
     <div
       aria-live="polite"
       aria-atomic="true"
       class="sr-only"
     >{{ liveAnnouncement }}</div>
-    <div v-if="$slots.toolbar" class="editor-toolbar-wrapper">
+    <div v-if="$slots.toolbar" class="sticky top-0 z-30 pb-2">
       <slot
         name="toolbar"
         :handlers="activeHandlers"
@@ -955,7 +961,7 @@ defineExpose({
         :redo="redo"
       />
     </div>
-    <div class="editor-body" @dragover.prevent>
+    <div class="editor-body flex-1 overflow-y-auto min-h-0" @dragover.prevent>
       <template v-for="(block, i) in blocks" :key="block.id">
         <EditorInsertionZone
         :ref="(el: any) => { if (el) zoneRefs.set(i, el); else zoneRefs.delete(i) }"

+ 3 - 3
packages/editor/src/components/EditorBlock.vue

@@ -149,7 +149,7 @@ const emit = defineEmits<{
   "pattern-update": [payload: PatternUpdatePayload]
   "pattern-close": [PatternClosePayload]
   focus: [payload: { view: EditorView; handlers: FormattingHandlers }]
-  blur: []
+  blur: [event: FocusEvent]
   "selection-change": [payload: { from: number; to: number; empty: boolean }]
   "content-change-op": [
     payload: {
@@ -736,11 +736,11 @@ onMounted(() => {
           }
           return false
         },
-        blur() {
+        blur(_view: EditorView, event: FocusEvent) {
           if (view) {
             view.dispatch(view.state.tr.setMeta("decorations:focus", false))
           }
-          emit("blur")
+          emit("blur", event)
           return false
         },
         click(_evView, event) {

+ 130 - 291
packages/editor/src/components/EditorToolbar.vue

@@ -1,74 +1,116 @@
 <script setup lang="ts">
-import { computed, nextTick, ref } from "vue"
+import { nextTick, ref, watch, computed } from "vue"
+import { useFloating, offset, flip, shift } from "@floating-ui/vue"
 import type { FormattingHandlers } from "@/lib/formatting"
+import EditorToolbarContent from "./EditorToolbarContent.vue"
+
+/**
+ * Layout mode for the toolbar:
+ * - `fixed`: Sticky bar at the editor top.
+ * - `bubble`: Floating popover near text selection.
+ * - `floating`: Floating popover at the cursor on empty lines.
+ */
+export type ToolbarMode = "fixed" | "bubble" | "floating"
 
 type PromptKind = "page" | "block" | "tag"
 
 const props = defineProps<{
+  /** Toolbar layout mode. Defaults to `fixed`. */
+  mode?: ToolbarMode
+  /** Formatting handlers from the focused block, or null when no block is focused. */
   handlers: FormattingHandlers | null
+  /** Bumped on every cursor move — forces reactivity on active state checks. */
   selectionVersion?: number
+  /** Whether undo is available in the operation history. */
   canUndo?: boolean
+  /** Whether redo is available in the operation history. */
   canRedo?: boolean
+  /** Undo the last operation. */
   undo?: () => void
+  /** Redo the last undone operation. */
   redo?: () => void
 }>()
 
-const active = computed(() => {
-  if (!props.handlers) return {}
-  // Force reactivity: selectionVersion is bumped on every cursor move,
-  // but handlers.isActive() reads PM state imperatively and is not
-  // reactive. Tapping the prop here makes this computed re-evaluate
-  // when selection changes.
-  void props.selectionVersion
-  return {
-    bold: props.handlers.isActive("bold"),
-    italic: props.handlers.isActive("italic"),
-    code: props.handlers.isActive("code"),
-    strikethrough: props.handlers.isActive("strikethrough"),
-    highlight: props.handlers.isActive("highlight"),
-    inlineMath: props.handlers.isActive("inlineMath"),
-  }
-})
-
-const currentHeading = computed(() => {
-  if (!props.handlers) return null
-  // Force reactivity — see active computed above.
-  void props.selectionVersion
-  return props.handlers.getActiveHeading()
-})
-
-const currentTask = computed(() => {
-  if (!props.handlers) return null
-  // Force reactivity — see active computed above.
-  void props.selectionVersion
-  return props.handlers.getActiveTask()
-})
-
-const headingLabel = computed(() => {
-  const level = currentHeading.value
-  return level === null ? "Paragraph" : `H${level}`
-})
-
-const headingIcon = computed(() => {
-  const level = currentHeading.value
-  if (level === null) return "i-lucide-text"
-  return `i-lucide-heading-${level}` as const
+const floatingRef = ref<HTMLElement | null>(null)
+
+/**
+ * Target element for the bubble/floating toolbar teleport.
+ * Teleports into the nearest `.editor-shell` ancestor to keep the toolbar
+ * in the editor's stacking context (below app-level overlays).
+ * Falls back to `document.body`.
+ *
+ * In `fixed` mode no teleport is used (the toolbar renders inline).
+ */
+const teleportTarget = computed(() => {
+  if (props.mode === "fixed" || props.mode === undefined) return undefined
+  return document.querySelector(".editor-shell") ?? "body"
 })
 
-const headingItems = computed(() => {
-  const h = props.handlers
-  if (!h) return []
-  const current = currentHeading.value
-  return [
-    { label: "Paragraph", icon: "i-lucide-text", active: current === null, onSelect: () => h.setHeading(null) },
-    { type: "separator" as const },
-    { label: "Heading 1", icon: "i-lucide-heading-1", active: current === 1, onSelect: () => h.setHeading(1) },
-    { label: "Heading 2", icon: "i-lucide-heading-2", active: current === 2, onSelect: () => h.setHeading(2) },
-    { label: "Heading 3", icon: "i-lucide-heading-3", active: current === 3, onSelect: () => h.setHeading(3) },
-    { label: "Heading 4", icon: "i-lucide-heading-4", active: current === 4, onSelect: () => h.setHeading(4) },
-    { label: "Heading 5", icon: "i-lucide-heading-5", active: current === 5, onSelect: () => h.setHeading(5) },
-    { label: "Heading 6", icon: "i-lucide-heading-6", active: current === 6, onSelect: () => h.setHeading(6) },
-  ]
+/**
+ * Virtual reference element for Floating UI positioning.
+ * Provides a `getBoundingClientRect` that tracks the current selection's
+ * screen coordinates. Updated reactively when `selectionVersion` changes.
+ * Set to `null` to hide the bubble/floating toolbar.
+ */
+const virtualRef = ref<{
+  getBoundingClientRect: () => DOMRect
+} | null>(null)
+
+/**
+ * Track selection changes and update the floating toolbar position.
+ * - In `bubble` mode: show when text is selected, hide when collapsed.
+ * - In `floating` mode: show at cursor position regardless of selection state.
+ * - Hides when there is no selection (window.getSelection is empty).
+ */
+watch(
+  () => props.selectionVersion,
+  () => {
+    if (props.mode === "fixed" || props.mode === undefined) return
+
+    const sel = window.getSelection()
+    if (!sel || !sel.rangeCount) {
+      virtualRef.value = null
+      return
+    }
+
+    if (props.mode === "bubble" && sel.isCollapsed) {
+      virtualRef.value = null
+      return
+    }
+
+    const rect = sel.getRangeAt(0).getBoundingClientRect()
+    virtualRef.value =
+      rect.width > 0 || rect.height > 0
+        ? { getBoundingClientRect: () => rect }
+        : null
+  },
+  { immediate: true },
+)
+
+/**
+ * Close bubble/floating toolbar when the focused block loses focus
+ * (handlers becomes null). This covers the case where the insertion zone
+ * is clicked and no selectionVersion change fires.
+ */
+watch(
+  () => props.handlers,
+  (h) => {
+    if (!h && props.mode !== "fixed" && props.mode !== undefined) {
+      virtualRef.value = null
+    }
+  },
+)
+
+/**
+ * Floating UI positioning for bubble/floating modes.
+ * Uses the virtual element as the anchor and auto-updates on scroll/resize.
+ * - `offset(8)`: 8px gap between toolbar and selection.
+ * - `flip()`: flip to bottom when there's no space above.
+ * - `shift()`: keep within viewport horizontally.
+ */
+const { floatingStyles } = useFloating(virtualRef, floatingRef, {
+  placement: "top",
+  middleware: [offset(8), flip(), shift({ crossAxis: true })],
 })
 
 const promptKind = ref<PromptKind | null>(null)
@@ -98,7 +140,6 @@ const promptLabels = computed(() => ({
 function openPrompt(kind: PromptKind) {
   promptValue.value = ""
   promptKind.value = kind
-  // UInput exposes { inputRef } via defineExpose — use the public API.
   nextTick(() => inputRef.value?.inputRef?.focus())
 }
 
@@ -122,254 +163,53 @@ function cancelPrompt() {
 
 <template>
   <div
+    v-if="mode === 'fixed' || mode === undefined"
     role="toolbar"
     aria-label="Formatting toolbar"
     data-slot="base"
     class="flex items-stretch gap-1.5"
+    @mousedown.prevent
   >
-    <!-- Undo / Redo -->
-    <div role="group" aria-label="History" class="flex items-center gap-0.5">
-      <UTooltip text="Undo (⌘Z)">
-        <UButton
-          icon="i-lucide-undo-2"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!canUndo"
-          data-toolbar-action="undo"
-          @click="undo?.()"
-        />
-      </UTooltip>
-      <UTooltip text="Redo (⌘⇧Z)">
-        <UButton
-          icon="i-lucide-redo-2"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!canRedo"
-          data-toolbar-action="redo"
-          @click="redo?.()"
-        />
-      </UTooltip>
-    </div>
-
-    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
+    <EditorToolbarContent
+      :handlers="handlers"
+      :selection-version="selectionVersion"
+      :can-undo="canUndo"
+      :can-redo="canRedo"
+      :undo="undo"
+      :redo="redo"
+      @open-prompt="openPrompt"
+    />
+  </div>
 
-    <!-- Block -->
-    <div role="group" aria-label="Block" class="flex items-center gap-0.5">
-      <UDropdownMenu :items="headingItems" :disabled="!handlers">
-        <UButton
-          :icon="headingIcon"
-          :label="headingLabel"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="heading"
-        />
-      </UDropdownMenu>
-      <UButton
-        icon="i-lucide-list-todo"
-        color="neutral"
-        active-color="primary"
-        variant="ghost"
-        active-variant="soft"
-        size="sm"
-        :active="currentTask !== null"
-        :disabled="!handlers"
-        :aria-pressed="currentTask !== null ? 'true' : 'false'"
-          data-toolbar-action="task"
-        @click="handlers?.toggleTask()"
+  <Teleport v-else :to="teleportTarget">
+    <div
+      v-if="virtualRef"
+      ref="floatingRef"
+      role="toolbar"
+      aria-label="Formatting toolbar"
+      data-slot="base"
+      class="flex items-stretch gap-1.5 rounded-lg border border-default bg-white dark:bg-neutral-950 shadow-lg p-1"
+      :style="floatingStyles"
+      @mousedown.prevent
+    >
+      <EditorToolbarContent
+        :handlers="handlers"
+        :selection-version="selectionVersion"
+        :can-undo="canUndo"
+        :can-redo="canRedo"
+        :undo="undo"
+        :redo="redo"
+        @open-prompt="openPrompt"
       />
     </div>
-
-    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
-
-    <!-- Inline -->
-    <div role="group" aria-label="Inline" class="flex items-center gap-0.5">
-      <UTooltip text="Bold (⌘B)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-bold"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.bold"
-          :disabled="!handlers"
-          :aria-pressed="active.bold ? 'true' : 'false'"
-          data-toolbar-action="bold"
-          @click="handlers?.toggleBold()"
-        />
-      </UTooltip>
-      <UTooltip text="Italic (⌘I)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-italic"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.italic"
-          :disabled="!handlers"
-          :aria-pressed="active.italic ? 'true' : 'false'"
-          data-toolbar-action="italic"
-          @click="handlers?.toggleItalic()"
-        />
-      </UTooltip>
-      <UTooltip text="Code (⌘`)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-code"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.code"
-          :disabled="!handlers"
-          :aria-pressed="active.code ? 'true' : 'false'"
-          data-toolbar-action="code"
-          @click="handlers?.toggleCode()"
-        />
-      </UTooltip>
-      <UTooltip text="Strikethrough (⌘⇧S)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-strikethrough"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.strikethrough"
-          :disabled="!handlers"
-          :aria-pressed="active.strikethrough ? 'true' : 'false'"
-          data-toolbar-action="strikethrough"
-          @click="handlers?.toggleStrikethrough()"
-        />
-      </UTooltip>
-      <UTooltip text="Highlight (⌘⇧H)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-highlighter"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.highlight"
-          :disabled="!handlers"
-          :aria-pressed="active.highlight ? 'true' : 'false'"
-          data-toolbar-action="highlight"
-          @click="handlers?.toggleHighlight()"
-        />
-      </UTooltip>
-      <UTooltip text="Inline math (⌘M)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-sigma"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.inlineMath"
-          :disabled="!handlers"
-          :aria-pressed="active.inlineMath ? 'true' : 'false'"
-          data-toolbar-action="inline-math"
-          @click="handlers?.insertInlineMath()"
-        />
-      </UTooltip>
-    </div>
-
-    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
-
-    <!-- Insert -->
-    <div role="group" aria-label="Insert" class="flex items-center gap-0.5">
-      <UTooltip text="Link (⌘K)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-link"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="link"
-          @click="handlers?.insertLink()"
-        />
-      </UTooltip>
-      <UTooltip text="Inline math (⌘M)" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-sigma"
-          color="neutral"
-          active-color="primary"
-          variant="ghost"
-          active-variant="soft"
-          size="sm"
-          :active="active.inlineMath"
-          :disabled="!handlers"
-          data-toolbar-action="inline-math-insert"
-          @click="handlers?.insertInlineMath()"
-        />
-      </UTooltip>
-      <UTooltip text="Block math" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-square-radical"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="block-math"
-          @click="handlers?.insertBlockMath()"
-        />
-      </UTooltip>
-    </div>
-
-    <USeparator orientation="vertical" class="w-px self-stretch bg-border" />
-
-    <!-- References -->
-    <div role="group" aria-label="References" class="flex items-center gap-0.5">
-      <UTooltip text="Page reference" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-book-marked"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="page-ref"
-          @click="openPrompt('page')"
-        />
-      </UTooltip>
-      <UTooltip text="Block reference" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-arrow-up-from-dot"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="block-ref"
-          @click="openPrompt('block')"
-        />
-      </UTooltip>
-      <UTooltip text="Tag" :disabled="!handlers">
-        <UButton
-          icon="i-lucide-hash"
-          color="neutral"
-          variant="ghost"
-          size="sm"
-          :disabled="!handlers"
-          data-toolbar-action="tag"
-          @click="openPrompt('tag')"
-        />
-      </UTooltip>
-    </div>
-  </div>
+  </Teleport>
 
   <UModal v-model:open="promptOpen">
     <template #content>
       <UCard>
         <template #header>
-          <h3 class="text-sm font-semibold">
-            {{ promptLabels.title }}
-          </h3>
+          <h3 class="text-sm font-semibold">{{ promptLabels.title }}</h3>
         </template>
-
         <UInput
           ref="inputRef"
           v-model="promptValue"
@@ -377,7 +217,6 @@ function cancelPrompt() {
           @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" />

+ 303 - 0
packages/editor/src/components/EditorToolbarContent.vue

@@ -0,0 +1,303 @@
+<script setup lang="ts">
+/**
+ * Shared button groups for the formatting toolbar.
+ * Used by EditorToolbar in both its inline (fixed) and teleported (bubble/floating) modes.
+ * Renders undo/redo, block controls, inline formatting, insert, and reference buttons.
+ */
+import { computed } from "vue"
+import type { FormattingHandlers } from "@/lib/formatting"
+
+type PromptKind = "page" | "block" | "tag"
+
+const props = defineProps<{
+  /** Formatting handlers from the focused block, or null when no block is focused. */
+  handlers: FormattingHandlers | null
+  /** Bumped on every cursor move to force reactivity on isActive() checks. */
+  selectionVersion?: number
+  /** Whether undo is available. */
+  canUndo?: boolean
+  /** Whether redo is available. */
+  canRedo?: boolean
+  /** Undo the last operation. */
+  undo?: () => void
+  /** Redo the last undone operation. */
+  redo?: () => void
+}>()
+
+const emit = defineEmits<{
+  "open-prompt": [kind: PromptKind]
+}>()
+
+const active = computed(() => {
+  if (!props.handlers) return {}
+  void props.selectionVersion
+  return {
+    bold: props.handlers.isActive("bold"),
+    italic: props.handlers.isActive("italic"),
+    code: props.handlers.isActive("code"),
+    strikethrough: props.handlers.isActive("strikethrough"),
+    highlight: props.handlers.isActive("highlight"),
+    inlineMath: props.handlers.isActive("inlineMath"),
+  }
+})
+
+const currentHeading = computed(() => {
+  if (!props.handlers) return null
+  void props.selectionVersion
+  return props.handlers.getActiveHeading()
+})
+
+const currentTask = computed(() => {
+  if (!props.handlers) return null
+  void props.selectionVersion
+  return props.handlers.getActiveTask()
+})
+
+const headingLabel = computed(() => {
+  const level = currentHeading.value
+  return level === null ? "Paragraph" : `H${level}`
+})
+
+const headingIcon = computed(() => {
+  const level = currentHeading.value
+  if (level === null) return "i-lucide-text"
+  return `i-lucide-heading-${level}` as const
+})
+
+const headingItems = computed(() => {
+  const h = props.handlers
+  if (!h) return []
+  const current = currentHeading.value
+  return [
+    { label: "Paragraph", icon: "i-lucide-text", active: current === null, onSelect: () => h.setHeading(null) },
+    { type: "separator" as const },
+    { label: "Heading 1", icon: "i-lucide-heading-1", active: current === 1, onSelect: () => h.setHeading(1) },
+    { label: "Heading 2", icon: "i-lucide-heading-2", active: current === 2, onSelect: () => h.setHeading(2) },
+    { label: "Heading 3", icon: "i-lucide-heading-3", active: current === 3, onSelect: () => h.setHeading(3) },
+    { label: "Heading 4", icon: "i-lucide-heading-4", active: current === 4, onSelect: () => h.setHeading(4) },
+    { label: "Heading 5", icon: "i-lucide-heading-5", active: current === 5, onSelect: () => h.setHeading(5) },
+    { label: "Heading 6", icon: "i-lucide-heading-6", active: current === 6, onSelect: () => h.setHeading(6) },
+  ]
+})
+</script>
+
+<template>
+  <!-- Undo / Redo -->
+  <div role="group" aria-label="History" class="flex items-center gap-0.5">
+    <UTooltip text="Undo (⌘Z)" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-undo-2"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!canUndo"
+        data-toolbar-action="undo"
+        @click="undo?.()"
+      />
+    </UTooltip>
+    <UTooltip text="Redo (⌘⇧Z)" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-redo-2"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!canRedo"
+        data-toolbar-action="redo"
+        @click="redo?.()"
+      />
+    </UTooltip>
+  </div>
+
+  <div class="w-px self-stretch bg-border" role="separator" aria-orientation="vertical"></div>
+
+  <!-- Block -->
+  <div role="group" aria-label="Block" class="flex items-center gap-0.5">
+    <UDropdownMenu :items="headingItems" :disabled="!handlers">
+      <UButton
+        :icon="headingIcon"
+        :label="headingLabel"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="heading"
+      />
+    </UDropdownMenu>
+    <UButton
+      icon="i-lucide-list-todo"
+      color="neutral"
+      active-color="primary"
+      variant="ghost"
+      active-variant="soft"
+      size="sm"
+      :active="currentTask !== null"
+      :disabled="!handlers"
+      :aria-pressed="currentTask !== null ? 'true' : 'false'"
+      data-toolbar-action="task"
+      @click="handlers?.toggleTask()"
+    />
+  </div>
+
+  <div class="w-px self-stretch bg-border" role="separator" aria-orientation="vertical"></div>
+
+  <!-- Inline -->
+  <div role="group" aria-label="Inline" class="flex items-center gap-0.5">
+    <UTooltip text="Bold (⌘B)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-bold"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.bold"
+        :disabled="!handlers"
+        :aria-pressed="active.bold ? 'true' : 'false'"
+        data-toolbar-action="bold"
+        @click="handlers?.toggleBold()"
+      />
+    </UTooltip>
+    <UTooltip text="Italic (⌘I)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-italic"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.italic"
+        :disabled="!handlers"
+        :aria-pressed="active.italic ? 'true' : 'false'"
+        data-toolbar-action="italic"
+        @click="handlers?.toggleItalic()"
+      />
+    </UTooltip>
+    <UTooltip text="Code (⌘`)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-code"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.code"
+        :disabled="!handlers"
+        :aria-pressed="active.code ? 'true' : 'false'"
+        data-toolbar-action="code"
+        @click="handlers?.toggleCode()"
+      />
+    </UTooltip>
+    <UTooltip text="Strikethrough (⌘⇧S)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-strikethrough"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.strikethrough"
+        :disabled="!handlers"
+        :aria-pressed="active.strikethrough ? 'true' : 'false'"
+        data-toolbar-action="strikethrough"
+        @click="handlers?.toggleStrikethrough()"
+      />
+    </UTooltip>
+    <UTooltip text="Highlight (⌘⇧H)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-highlighter"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.highlight"
+        :disabled="!handlers"
+        :aria-pressed="active.highlight ? 'true' : 'false'"
+        data-toolbar-action="highlight"
+        @click="handlers?.toggleHighlight()"
+      />
+    </UTooltip>
+    <UTooltip text="Inline math (⌘M)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-sigma"
+        color="neutral"
+        active-color="primary"
+        variant="ghost"
+        active-variant="soft"
+        size="sm"
+        :active="active.inlineMath"
+        :disabled="!handlers"
+        :aria-pressed="active.inlineMath ? 'true' : 'false'"
+        data-toolbar-action="inline-math"
+        @click="handlers?.insertInlineMath()"
+      />
+    </UTooltip>
+  </div>
+
+  <div class="w-px self-stretch bg-border" role="separator" aria-orientation="vertical"></div>
+
+  <!-- Insert -->
+  <div role="group" aria-label="Insert" class="flex items-center gap-0.5">
+    <UTooltip text="Link (⌘K)" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-link"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="link"
+        @click="handlers?.insertLink()"
+      />
+    </UTooltip>
+    <UTooltip text="Block math" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-square-radical"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="block-math"
+        @click="handlers?.insertBlockMath()"
+      />
+    </UTooltip>
+  </div>
+
+  <div class="w-px self-stretch bg-border" role="separator" aria-orientation="vertical"></div>
+
+  <!-- References -->
+  <div role="group" aria-label="References" class="flex items-center gap-0.5">
+    <UTooltip text="Page reference" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-book-marked"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="page-ref"
+        @click="emit('open-prompt', 'page')"
+      />
+    </UTooltip>
+    <UTooltip text="Block reference" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-arrow-up-from-dot"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="block-ref"
+        @click="emit('open-prompt', 'block')"
+      />
+    </UTooltip>
+    <UTooltip text="Tag" :disabled="!handlers" :ui="{ content: 'pointer-events-none' }">
+      <UButton
+        icon="i-lucide-hash"
+        color="neutral"
+        variant="ghost"
+        size="sm"
+        :disabled="!handlers"
+        data-toolbar-action="tag"
+        @click="emit('open-prompt', 'tag')"
+      />
+    </UTooltip>
+  </div>
+</template>

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

@@ -70,6 +70,7 @@ export { registerNodeView } from "@/lib/node-view-registry"
 export type { BlockRule, MarkdownRule, ParsedDecorations } from "@/lib/markdown-rules"
 export { MarkdownRuleEngine } from "@/lib/markdown-rules"
 export { EditorBlock, Editor, EditorSuggestionMenu, EditorToolbar }
+export type { ToolbarMode } from "@/components/EditorToolbar.vue"
 
 export default {
   install(app: App) {

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

@@ -500,7 +500,7 @@ describe("leading whitespace handling", () => {
     expect(result.blocks).toHaveLength(1)
     expect(result.blocks[0].decorationRanges[0]).toEqual({
       type: "hidden",
-      from: 2,
+      from: 4,
       to: 11,
       className: "md-marker",
     })

+ 14 - 0
pnpm-lock.yaml

@@ -154,6 +154,9 @@ importers:
       '@codemirror/view':
         specifier: ^6.43.1
         version: 6.43.1
+      '@floating-ui/vue':
+        specifier: ^2.0.0
+        version: 2.0.0([email protected]([email protected]))
       '@lezer/common':
         specifier: ^1.2.2
         version: 1.5.2
@@ -999,6 +1002,11 @@ packages:
   '@floating-ui/[email protected]':
     resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
 
+  '@floating-ui/[email protected]':
+    resolution: {integrity: sha512-I7hYpCAkgBrtXdZbfCpGaqAV+E09fENSHBIm81z6WhSgcl1ctkb3+1gW9h8PVDus0Em2FwGRR41epgxILS6YhQ==}
+    peerDependencies:
+      vue: '>=3.3.0'
+
   '@fontsource-variable/[email protected]':
     resolution: {integrity: sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==}
 
@@ -6707,6 +6715,12 @@ snapshots:
       - '@vue/composition-api'
       - vue
 
+  '@floating-ui/[email protected]([email protected]([email protected]))':
+    dependencies:
+      '@floating-ui/dom': 1.7.6
+      '@floating-ui/utils': 0.2.11
+      vue: 3.5.34([email protected])
+
   '@fontsource-variable/[email protected]': {}
 
   '@hono/[email protected]([email protected])':