fix(desktop): keep open-with menu clear of workbench

This commit is contained in:
程序员阿江(Relakkes)
2026-08-02 19:54:47 +08:00
parent be4c1034b6
commit 7d6136e26b
4 changed files with 96 additions and 38 deletions
@@ -1,10 +1,17 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OpenWithMenu } from './OpenWithMenu'
import type { OpenWithItem } from '../../lib/openWithItems'
const anchor = { top: 100, bottom: 110, left: 20, right: 120 }
const originalViewport = { width: window.innerWidth, height: window.innerHeight }
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: originalViewport.width, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: originalViewport.height, configurable: true })
vi.restoreAllMocks()
})
function makeItems(onSelect1 = vi.fn(), onSelect2 = vi.fn(), onSelect3 = vi.fn()): OpenWithItem[] {
return [
@@ -76,6 +83,34 @@ describe('OpenWithMenu', () => {
expect(onClose).toHaveBeenCalledTimes(1)
})
it('aligns its right edge with the anchor to avoid a right-side native preview', () => {
Object.defineProperty(window, 'innerWidth', { value: 1200, configurable: true })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 160, width: 300, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
const rightEdge = 900
render(<OpenWithMenu items={makeItems()} anchor={{ top: 100, bottom: 110, left: 700, right: rightEdge }} onClose={vi.fn()} />)
const menu = screen.getByRole('menu')
expect(menu.style.left).toBe('600px')
expect(Number.parseFloat(menu.style.left) + 300).toBe(rightEdge)
expect(menu.className).toContain('max-w-[min(300px,calc(100vw-16px))]')
})
it('keeps a left-shifted menu inside the compact chat column', () => {
Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true })
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 160, width: 300, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
render(<OpenWithMenu items={makeItems()} anchor={{ top: 100, bottom: 110, left: 40, right: 100 }} onClose={vi.fn()} />)
const menuLeft = Number.parseFloat(screen.getByRole('menu').style.left)
expect(menuLeft).toBe(8)
expect(menuLeft + 300).toBeLessThan(320)
})
describe('triggerEl exclusion (re-click-trigger toggle support)', () => {
it('does NOT call onClose when mousedown lands inside triggerEl', () => {
// Set up a trigger element + a child within it in the document.
@@ -139,13 +174,12 @@ describe('OpenWithMenu', () => {
// The trigger often sits right above the composer; the menu must not render off-screen below it.
Object.defineProperty(window, 'innerHeight', { value: 300, configurable: true })
Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true })
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 200, width: 220, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
// anchor near the bottom: top:260/bottom:270. Down would be 276, 276+200=476 > 300-8 ⇒ flip up.
render(<OpenWithMenu items={makeItems()} anchor={{ top: 260, bottom: 270, left: 20, right: 120 }} onClose={vi.fn()} />)
// flipped top = anchor.top - height - 6 = 260 - 200 - 6 = 54
expect(screen.getByRole('menu').style.top).toBe('54px')
rectSpy.mockRestore()
})
})
@@ -1,6 +1,7 @@
import { createPortal } from 'react-dom'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import { useCallback, useRef } from 'react'
import { Globe, ExternalLink, FileText, Copy } from 'lucide-react'
import { useAnchoredPosition } from '@/hooks/useAnchoredPosition'
import { useDismissable } from '@/hooks/useDismissable'
import { TargetIcon } from './TargetIcon'
import type { OpenWithItem } from '../../lib/openWithItems'
@@ -23,34 +24,14 @@ function ItemIcon({ item }: { item: OpenWithItem }) {
return <ExternalLink size={18} strokeWidth={1.9} />
}
const MARGIN = 8
export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
const ref = useRef<HTMLDivElement>(null)
// Initial guess; corrected (before paint) by the layout effect once we can measure the menu.
const [pos, setPos] = useState<{ top: number; left: number }>(() => ({
top: anchor.bottom + 6,
left: Math.max(MARGIN, Math.min(anchor.left, window.innerWidth - 240 - MARGIN)),
}))
// Position viewport-aware: flip above the anchor if it would overflow the bottom
// (the trigger often sits right above the composer), and clamp into the viewport.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const { height, width } = el.getBoundingClientRect()
const vh = window.innerHeight
const vw = window.innerWidth
let top = anchor.bottom + 6
if (height > 0 && top + height > vh - MARGIN) {
const flipped = anchor.top - height - 6
top = flipped >= MARGIN ? flipped : Math.max(MARGIN, vh - height - MARGIN)
}
let left = anchor.left
if (width > 0) left = Math.max(MARGIN, Math.min(left, vw - width - MARGIN))
setPos({ top, left })
}, [anchor])
const { style: positionStyle } = useAnchoredPosition({
open: true,
anchorRect: anchor,
floatingRef: ref,
placement: 'bottom-end',
})
// The hook takes a ref; this component is handed the raw trigger element by
// its callers. Only the dismiss handler reads it, and it does so at event
@@ -77,8 +58,8 @@ export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
<div
ref={ref}
role="menu"
className="fixed min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
style={{ top: pos.top, left: pos.left, zIndex: 'var(--z-dropdown)' }}
className="min-w-[min(220px,calc(100vw-16px))] max-w-[min(300px,calc(100vw-16px))] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
style={{ ...positionStyle, zIndex: 'var(--z-dropdown)' }}
>
{items.map((item) => (
<button
@@ -98,6 +98,32 @@ describe('useAnchoredPosition', () => {
expect(result.style.left).toBe(120)
})
it('positions from snapshot anchor geometry', () => {
const captured: { current: Result | null } = { current: null }
function Harness() {
const floatingRef = useRef<HTMLDivElement | null>(null)
captured.current = useAnchoredPosition({
open: true,
anchorRect: { top: 100, bottom: 130, left: 200, right: 300 },
floatingRef,
placement: 'bottom-end',
})
return (
<div
ref={(element) => {
floatingRef.current = element
if (element) stubRect(element, { width: 180, height: 120 })
}}
/>
)
}
render(<Harness />)
expect(captured.current!.style.top).toBe(136)
expect(captured.current!.style.left).toBe(120)
})
it('flips above the anchor when it would overflow the bottom', () => {
const result = renderAnchored({
anchor: { top: 700, bottom: 740, left: 200, right: 300 },
+23 -6
View File
@@ -2,9 +2,14 @@ import { useCallback, useLayoutEffect, useState, type CSSProperties } from 'reac
export type AnchoredPlacement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
export type UseAnchoredPositionOptions = {
export type AnchoredRect = Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left'>
type AnchorSource =
| { anchorRef: { current: HTMLElement | null }; anchorRect?: never }
| { anchorRef?: never; anchorRect: AnchoredRect }
export type UseAnchoredPositionOptions = AnchorSource & {
open: boolean
anchorRef: { current: HTMLElement | null }
floatingRef: { current: HTMLElement | null }
placement?: AnchoredPlacement
/** Gap between anchor and overlay, in pixels. */
@@ -42,6 +47,7 @@ const DEFAULT_MARGIN = 8
export function useAnchoredPosition({
open,
anchorRef,
anchorRect,
floatingRef,
placement = 'bottom-start',
offset = 6,
@@ -54,11 +60,10 @@ export function useAnchoredPosition({
)
const measure = useCallback(() => {
const anchor = anchorRef.current
const anchorBox = anchorRect ?? anchorRef?.current?.getBoundingClientRect()
const floating = floatingRef.current
if (!anchor || !floating) return
if (!anchorBox || !floating) return
const anchorBox = anchor.getBoundingClientRect()
const { width, height } = floating.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
@@ -98,7 +103,19 @@ export function useAnchoredPosition({
}
setState({ top, left, placement: resolved, ready: true })
}, [anchorRef, floatingRef, placement, offset, viewportMargin, flip, shift])
}, [
anchorRect?.top,
anchorRect?.right,
anchorRect?.bottom,
anchorRect?.left,
anchorRef,
floatingRef,
placement,
offset,
viewportMargin,
flip,
shift,
])
useLayoutEffect(() => {
if (!open) {