Browse Source

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 giờ trước cách đây
mục cha
commit
bce41b9902
28 tập tin đã thay đổi với 518 bổ sung320 xóa
  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])':