mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
fix(desktop): open-with menu viewport-flip + openPreview opens panel
真机验证发现「打开方式」点了没反应:OpenWithMenu 用 top:anchor.bottom+6 无视口翻转, 触发器贴在输入框上方时菜单渲染到输入框后面/视口外 → 改为测量后向上翻转+视口夹取+提高 z-index。 另外 openPreview 只加预览 tab 不开面板,从聊天/卡片触发时面板不显示 → openPreview 现在先 openPanel。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ca2de74f87
commit
a8406fd903
@ -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(<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,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 <ExternalLink size={18} strokeWidth={1.9} />
|
||||
}
|
||||
|
||||
const MARGIN = 8
|
||||
|
||||
export function OpenWithMenu({ items, anchor, onClose }: 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])
|
||||
|
||||
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) {
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
|
||||
style={{ top: anchor.bottom + 6, left: Math.min(anchor.left, window.innerWidth - 240) }}
|
||||
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: 1000 }}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
|
||||
@ -305,6 +305,15 @@ describe('workspacePanelStore', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('openPreview opens the workspace panel when it was closed', async () => {
|
||||
mocks.getWorkspaceFileMock.mockResolvedValue({
|
||||
state: 'ok', path: 'src/a.ts', content: 'export const a = 1', language: 'typescript', size: 18,
|
||||
})
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(false)
|
||||
await useWorkspacePanelStore.getState().openPreview('session-closed-preview', 'src/a.ts', 'file')
|
||||
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(true)
|
||||
})
|
||||
|
||||
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
|
||||
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
|
||||
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
|
||||
|
||||
@ -431,6 +431,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
|
||||
},
|
||||
|
||||
openPreview: async (sessionId, path, kind) => {
|
||||
// Ensure the workspace panel is visible — openPreview is now triggered from places
|
||||
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
|
||||
// not only from inside the already-open file tree.
|
||||
get().openPanel(sessionId)
|
||||
const tabId = getWorkspacePreviewTabId(path, kind)
|
||||
const requestKey = makePreviewKey(sessionId, tabId)
|
||||
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user