From a8406fd903576389903083a4926db98ea738948e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 19:58:01 +0800 Subject: [PATCH] fix(desktop): open-with menu viewport-flip + openPreview opens panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机验证发现「打开方式」点了没反应:OpenWithMenu 用 top:anchor.bottom+6 无视口翻转, 触发器贴在输入框上方时菜单渲染到输入框后面/视口外 → 改为测量后向上翻转+视口夹取+提高 z-index。 另外 openPreview 只加预览 tab 不开面板,从聊天/卡片触发时面板不显示 → openPreview 现在先 openPanel。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/common/OpenWithMenu.test.tsx | 14 ++++++++ .../src/components/common/OpenWithMenu.tsx | 33 +++++++++++++++++-- .../src/stores/workspacePanelStore.test.ts | 9 +++++ desktop/src/stores/workspacePanelStore.ts | 4 +++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/desktop/src/components/common/OpenWithMenu.test.tsx b/desktop/src/components/common/OpenWithMenu.test.tsx index 5015e818..27cb52d1 100644 --- a/desktop/src/components/common/OpenWithMenu.test.tsx +++ b/desktop/src/components/common/OpenWithMenu.test.tsx @@ -68,4 +68,18 @@ describe('OpenWithMenu', () => { fireEvent.mouseDown(document.body) expect(onClose).toHaveBeenCalledTimes(1) }) + + it('flips above the anchor when it would overflow the viewport bottom', () => { + // 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({ + 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() + // flipped top = anchor.top - height - 6 = 260 - 200 - 6 = 54 + expect(screen.getByRole('menu').style.top).toBe('54px') + rectSpy.mockRestore() + }) }) diff --git a/desktop/src/components/common/OpenWithMenu.tsx b/desktop/src/components/common/OpenWithMenu.tsx index b56b739f..11a2e9cb 100644 --- a/desktop/src/components/common/OpenWithMenu.tsx +++ b/desktop/src/components/common/OpenWithMenu.tsx @@ -1,5 +1,5 @@ import { createPortal } from 'react-dom' -import { useEffect, useRef } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { Globe, ExternalLink, FileText } from 'lucide-react' import { TargetIcon } from './TargetIcon' import type { OpenWithItem } from '../../lib/openWithItems' @@ -17,8 +17,35 @@ function ItemIcon({ item }: { item: OpenWithItem }) { return } +const MARGIN = 8 + export function OpenWithMenu({ items, anchor, onClose }: Props) { const ref = useRef(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]) + useEffect(() => { const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } @@ -31,8 +58,8 @@ export function OpenWithMenu({ items, anchor, onClose }: Props) {
{items.map((item) => (