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

Improved positioning of reference popup (@ action)

paviko пре 2 недеља
родитељ
комит
c6a85b5359

+ 2 - 2
packages/opencode/webgui/src/components/mention/MentionPlugin/MentionDetector.tsx

@@ -7,9 +7,9 @@ export function useMentionDetector(
   setQuery: (query: string) => void,
   setShowPopover: (show: boolean) => void,
   setMentionStartOffset: (offset: number | null) => void,
-  setPosition: (pos: { top: number; left: number }) => void,
+  setPosition: (pos: { top: number; left: number; placement: "top" | "bottom" }) => void,
 ) {
-  const leftRef = useRef<number | null>(null)
+  const leftRef = useRef<{ left: number; width: number } | null>(null)
 
   const handlePositionUpdate = useCallback(() => {
     updatePopoverPosition(editor, leftRef, setPosition)

+ 6 - 2
packages/opencode/webgui/src/components/mention/MentionPlugin/index.tsx

@@ -18,7 +18,11 @@ export function MentionPlugin() {
   const [editor] = useLexicalComposerContext()
   const [showPopover, setShowPopover] = useState(false)
   const [query, setQuery] = useState("")
-  const [position, setPosition] = useState({ top: 0, left: 0 })
+  const [position, setPosition] = useState<{ top: number; left: number; placement: "top" | "bottom" }>({
+    top: 0,
+    left: 0,
+    placement: "top",
+  })
   const [mentionStartOffset, setMentionStartOffset] = useState<number | null>(null)
   const { openedFiles } = useIdeBridgeState()
 
@@ -107,7 +111,7 @@ export function MentionPlugin() {
 
   return showPopover
     ? createPortal(
-        <MentionPopover query={query} position={position} onSelect={insertMention} onClose={resetState} />,
+        <MentionPopover query={query} position={position} onSelect={insertMention} onClose={resetState} onReposition={handlePositionUpdate} />,
         document.body,
       )
     : null

+ 41 - 19
packages/opencode/webgui/src/components/mention/MentionPlugin/utils.ts

@@ -39,8 +39,8 @@ export function extractMentionQuery(setMentionStartOffset: (offset: number | nul
 
 export function updatePopoverPosition(
   editor: { getRootElement: () => HTMLElement | null },
-  leftRef: React.MutableRefObject<number | null>,
-  setPosition: (pos: { top: number; left: number }) => void,
+  leftRef: React.MutableRefObject<{ left: number; width: number } | null>,
+  setPosition: (pos: { top: number; left: number; placement: "top" | "bottom" }) => void,
 ) {
   const root = editor.getRootElement()
   if (!root) return
@@ -54,32 +54,54 @@ export function updatePopoverPosition(
   if (!root.contains(range.startContainer)) return
 
   const rect = range.getBoundingClientRect()
-  const rootRect = root.getBoundingClientRect()
   const gap = 8
 
+  const viewportHeight = window.innerHeight
+  const popover = root.ownerDocument.querySelector<HTMLElement>("[data-mention-popover]")
+  const popoverRect = popover ? popover.getBoundingClientRect() : null
+  const rawHeight = popoverRect && popoverRect.height > 0 ? Math.ceil(popoverRect.height) : 280
+  const desiredHeight = Math.min(rawHeight, viewportHeight - gap * 2)
+  const below = viewportHeight - rect.bottom - gap
+  const above = rect.top - gap
+  const placement =
+    above >= desiredHeight ? "top" : below >= desiredHeight ? "bottom" : above >= below ? "top" : "bottom"
+
   const viewportWidth = window.innerWidth
-  const estimatedWidth = 500
+  const rawWidth = popoverRect && popoverRect.width > 0 ? Math.ceil(popoverRect.width) : 500
+  const desiredWidth = Math.min(rawWidth, viewportWidth - gap * 2)
   const minLeft = window.scrollX + gap
-  const maxLeft = window.scrollX + viewportWidth - estimatedWidth - gap
+  const maxLeft = window.scrollX + viewportWidth - desiredWidth - gap
 
-  let left = leftRef.current
-  if (left === null) {
-    left = rect.left + window.scrollX
-  }
-  if (maxLeft <= minLeft) {
-    left = minLeft
-  }
-  if (left < minLeft) {
-    left = minLeft
-  }
-  if (left > maxLeft) {
-    left = maxLeft
+  const clamp = (value: number) => {
+    if (maxLeft <= minLeft) return minLeft
+    if (value < minLeft) return minLeft
+    if (value > maxLeft) return maxLeft
+    return value
   }
 
-  leftRef.current = left
+  const anchor = rect.left + window.scrollX
+  const stored = leftRef.current
+  const shouldReanchor = stored === null || Math.abs(stored.width - desiredWidth) > 8
+  const openedLeft = (() => {
+    const viewportRight = window.scrollX + viewportWidth - gap
+    const start = anchor
+    const end = anchor - desiredWidth
+
+    if (start + desiredWidth <= viewportRight) return start
+    if (end >= minLeft) return end
+    return clamp(start)
+  })()
+
+  const preferred = shouldReanchor ? openedLeft : stored.left
+  const left = clamp(preferred)
+
+  leftRef.current = { left, width: desiredWidth }
+
+  const top = placement === "top" ? rect.top + window.scrollY - gap : rect.bottom + window.scrollY + gap
 
   setPosition({
-    top: rootRect.top + window.scrollY - gap,
+    top,
     left,
+    placement,
   })
 }

+ 35 - 4
packages/opencode/webgui/src/components/mention/MentionPopover.tsx

@@ -1,16 +1,45 @@
+import { useLayoutEffect, useMemo, useRef } from "react"
 import { useMentionSearch, type MentionResult } from "../../hooks/useMentionSearch"
 import { useMentionNavigation } from "../../hooks/useMentionNavigation"
 import type { MentionMetadata } from "./MentionNode"
 
 interface MentionPopoverProps {
   query: string
-  position: { top: number; left: number }
+  position: { top: number; left: number; placement: "top" | "bottom" }
   onSelect: (metadata: MentionMetadata) => void
   onClose: () => void
+  onReposition?: () => void
 }
 
-export function MentionPopover({ query, position, onSelect, onClose }: MentionPopoverProps) {
+export function MentionPopover({ query, position, onSelect, onClose, onReposition }: MentionPopoverProps) {
   const { results, isLoading } = useMentionSearch(query)
+  const rootRef = useRef<HTMLDivElement>(null)
+
+  const transform = useMemo(
+    () => (position.placement === "top" ? "translateY(-100%)" : "translateY(0)"),
+    [position.placement],
+  )
+
+  useLayoutEffect(() => {
+    if (!onReposition) return
+    const node = rootRef.current
+    if (!node) return
+
+    onReposition()
+    const frame = requestAnimationFrame(() => onReposition())
+
+    if (typeof ResizeObserver === "undefined") {
+      return () => cancelAnimationFrame(frame)
+    }
+
+    const observer = new ResizeObserver(() => onReposition())
+    observer.observe(node)
+
+    return () => {
+      cancelAnimationFrame(frame)
+      observer.disconnect()
+    }
+  }, [onReposition, isLoading, results.length])
 
   const handleSelect = (index: number) => {
     if (results[index]) {
@@ -28,8 +57,9 @@ export function MentionPopover({ query, position, onSelect, onClose }: MentionPo
   if (results.length === 0 && !isLoading) {
     return (
       <div
+        ref={rootRef}
         className="absolute z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded shadow-lg"
-        style={{ top: position.top, left: position.left, transform: "translateY(-100%)" }}
+        style={{ top: position.top, left: position.left, transform, maxWidth: "calc(100vw - 16px)" }}
         data-mention-popover
       >
         <div className="px-2 py-1 text-xs text-gray-500 dark:text-gray-400">
@@ -41,8 +71,9 @@ export function MentionPopover({ query, position, onSelect, onClose }: MentionPo
 
   return (
     <div
+      ref={rootRef}
       className="absolute z-50 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded shadow-lg"
-      style={{ top: position.top, left: position.left, transform: "translateY(-100%)" }}
+      style={{ top: position.top, left: position.left, transform, maxWidth: "calc(100vw - 16px)" }}
       data-mention-popover
     >
       <div ref={listRef} className="max-h-64 overflow-y-auto" style={{ maxWidth: "calc(100vw - 16px)" }}>