mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix(desktop): repair native browser preview flows
Fixes #819 and #879. The mounted BrowserSurface now drives native preview navigation when a new browser target is opened for the same session, so the WebContentsView no longer stays on the first previewed file. The preview-agent edit bubble now clamps to the viewport, prefers placing above low selected elements, and keeps its footer controls out of the scroll body. Tested: cd desktop && bun run test -- --run src/components/browser/BrowserSurface.test.tsx src/stores/browserPanelStore.test.ts src/components/workspace/WorkspaceFileOpenWith.test.tsx src/components/chat/CurrentTurnChangeCard.test.tsx src/preview-agent/editBubble.test.ts Tested: bun run check:desktop Confidence: high Scope-risk: narrow
This commit is contained in:
parent
f36af8e17f
commit
10f4768c18
File diff suppressed because one or more lines are too long
@ -109,6 +109,29 @@ describe('BrowserSurface', () => {
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost:3000/')
|
||||
})
|
||||
|
||||
it('navigates the mounted native preview when another browser target opens for the same session', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
|
||||
useBrowserPanelStore.getState().open('s1', 'http://127.0.0.1:3456/preview-fs/s1/first.md')
|
||||
render(<BrowserSurface sessionId="s1" />)
|
||||
await waitFor(() => {
|
||||
expect(bridge.open).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:3456/preview-fs/s1/first.md',
|
||||
expect.objectContaining({ width: expect.any(Number) }),
|
||||
)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://127.0.0.1:3456/preview-fs/s1/second.md')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(bridge.navigate).toHaveBeenCalledWith('http://127.0.0.1:3456/preview-fs/s1/second.md')
|
||||
})
|
||||
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe(
|
||||
'http://127.0.0.1:3456/preview-fs/s1/second.md',
|
||||
)
|
||||
})
|
||||
|
||||
it('closes the native webview on unmount', () => {
|
||||
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
|
||||
const { unmount } = render(<BrowserSurface sessionId="s1" />)
|
||||
|
||||
@ -58,6 +58,8 @@ function resolveBrowserNavigationUrl(input: string, sessionId: string): string {
|
||||
export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const loadSeqRef = useRef(0)
|
||||
const requestedUrlRef = useRef<string | null>(null)
|
||||
const hasNativePreviewRef = useRef(false)
|
||||
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
|
||||
const store = useBrowserPanelStore.getState()
|
||||
const overlayCount = useOverlayStore((s) => s.count)
|
||||
@ -82,20 +84,43 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
await action()
|
||||
})().catch(() => {
|
||||
if (loadSeqRef.current === seq) {
|
||||
if (requestedUrlRef.current === url) {
|
||||
requestedUrlRef.current = null
|
||||
}
|
||||
useBrowserPanelStore.getState().setLoading(sessionId, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const requestNativePreview = (url: string, options?: { force?: boolean }) => {
|
||||
if (!url) return
|
||||
if (!options?.force && requestedUrlRef.current === url) return
|
||||
|
||||
requestedUrlRef.current = url
|
||||
loadNativePreview(url, async () => {
|
||||
if (hasNativePreviewRef.current) {
|
||||
await previewBridge.navigate(url)
|
||||
return
|
||||
}
|
||||
|
||||
const el = hostRef.current
|
||||
hasNativePreviewRef.current = true
|
||||
if (el) {
|
||||
await previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect()))
|
||||
} else {
|
||||
await previewBridge.navigate(url)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = hostRef.current
|
||||
if (el && session?.url) {
|
||||
const bounds = computeWebviewBounds(el.getBoundingClientRect())
|
||||
const url = session.url
|
||||
loadNativePreview(url, () => previewBridge.open(url, bounds))
|
||||
if (session?.url) {
|
||||
requestNativePreview(session.url)
|
||||
}
|
||||
return () => {
|
||||
loadSeqRef.current += 1
|
||||
requestedUrlRef.current = null
|
||||
hasNativePreviewRef.current = false
|
||||
previewBridge.close()
|
||||
}
|
||||
// The visibility-sync effect below owns setVisible() — including the
|
||||
@ -103,6 +128,12 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.url || !session.loading) return
|
||||
requestNativePreview(session.url)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.url, session?.loading, sessionId])
|
||||
|
||||
// Visibility-sync: a fullscreen DOM overlay (e.g. ImageGalleryModal) would
|
||||
// otherwise be partially covered by the native child webview, which always
|
||||
// renders above the DOM. While overlayCount > 0 we hide the webview; when
|
||||
@ -146,19 +177,8 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
const openOrNavigate = (inputUrl: string) => {
|
||||
const url = resolveBrowserNavigationUrl(inputUrl, sessionId)
|
||||
if (!url) return
|
||||
const current = useBrowserPanelStore.getState().bySession[sessionId]
|
||||
store.navigate(sessionId, url)
|
||||
if (current?.url) {
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
return
|
||||
}
|
||||
const el = hostRef.current
|
||||
if (el) {
|
||||
const bounds = computeWebviewBounds(el.getBoundingClientRect())
|
||||
loadNativePreview(url, () => previewBridge.open(url, bounds))
|
||||
} else {
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
}
|
||||
requestNativePreview(url)
|
||||
}
|
||||
|
||||
const actionButtonClass = [
|
||||
@ -214,18 +234,18 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
|
||||
store.goBack(sessionId)
|
||||
store.setLoading(sessionId, true)
|
||||
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
requestNativePreview(url)
|
||||
}}
|
||||
onForward={() => {
|
||||
store.goForward(sessionId)
|
||||
store.setLoading(sessionId, true)
|
||||
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
|
||||
loadNativePreview(url, () => previewBridge.navigate(url))
|
||||
requestNativePreview(url)
|
||||
}}
|
||||
onReload={() => {
|
||||
if (!session.url) return
|
||||
store.setLoading(sessionId, true)
|
||||
loadNativePreview(session.url, () => previewBridge.navigate(session.url))
|
||||
requestNativePreview(session.url, { force: true })
|
||||
}}
|
||||
rightActions={previewActions}
|
||||
/>
|
||||
|
||||
@ -26,6 +26,28 @@ describe('snapshotEditableStyles', () => {
|
||||
})
|
||||
|
||||
describe('createEditBubble', () => {
|
||||
it('positions controls inside the viewport when selecting a low element', () => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 720 })
|
||||
const el = document.getElementById('t')!
|
||||
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 420,
|
||||
y: 650,
|
||||
top: 650,
|
||||
right: 500,
|
||||
bottom: 670,
|
||||
left: 420,
|
||||
width: 80,
|
||||
height: 20,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
|
||||
expect(bubble.host.style.top).toBe('262px')
|
||||
bubble.destroy()
|
||||
})
|
||||
|
||||
it('prefills the text field and live-applies edits to the element', () => {
|
||||
const el = document.getElementById('t')!
|
||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||
|
||||
@ -4,6 +4,12 @@ export type EditableSnapshot = { text: string; color: string; background: string
|
||||
export type EditBubbleChange = EditDiff & { description?: string }
|
||||
type Deps = { onConfirm: (change: EditBubbleChange) => void; onCancel: () => void }
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
const BUBBLE_GAP = 8
|
||||
const BUBBLE_WIDTH = 340
|
||||
const BUBBLE_ESTIMATED_HEIGHT = 380
|
||||
const BUBBLE_MIN_HEIGHT = 160
|
||||
|
||||
const FIELDS: Array<{ key: keyof EditableSnapshot; label: string }> = [
|
||||
{ key: 'text', label: '文本' },
|
||||
{ key: 'color', label: '文字颜色' },
|
||||
@ -43,6 +49,35 @@ function buildPatch(key: keyof EditableSnapshot, value: string): EditInput {
|
||||
return patch
|
||||
}
|
||||
|
||||
function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
|
||||
const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2)
|
||||
const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2)
|
||||
const desiredHeight = Math.min(
|
||||
Math.max(Math.ceil(contentHeight) || BUBBLE_ESTIMATED_HEIGHT, BUBBLE_MIN_HEIGHT),
|
||||
Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN * 2),
|
||||
)
|
||||
const belowTop = rect.bottom + BUBBLE_GAP
|
||||
const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop
|
||||
const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN
|
||||
let top: number
|
||||
|
||||
if (spaceBelow >= desiredHeight) {
|
||||
top = belowTop
|
||||
} else if (spaceAbove >= desiredHeight) {
|
||||
top = rect.top - BUBBLE_GAP - desiredHeight
|
||||
} else if (spaceAbove > spaceBelow) {
|
||||
top = VIEWPORT_MARGIN
|
||||
} else {
|
||||
top = Math.min(Math.max(belowTop, VIEWPORT_MARGIN), viewportHeight - VIEWPORT_MARGIN - BUBBLE_MIN_HEIGHT)
|
||||
}
|
||||
|
||||
top = Math.max(VIEWPORT_MARGIN, Math.round(top))
|
||||
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN)
|
||||
const left = Math.max(VIEWPORT_MARGIN, Math.min(Math.round(rect.left), maxLeft))
|
||||
const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top)
|
||||
return { top, left, maxHeight }
|
||||
}
|
||||
|
||||
export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
|
||||
const original = snapshotEditableStyles(el)
|
||||
const current: EditableSnapshot = { ...original }
|
||||
@ -50,25 +85,26 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
|
||||
|
||||
const host = document.createElement('div')
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - 380))
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 356))
|
||||
host.style.cssText = `position:fixed;top:${top}px;left:${left}px;z-index:2147483647`
|
||||
host.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483647;visibility:hidden'
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
|
||||
const wrap = document.createElement('div')
|
||||
wrap.setAttribute('style', 'width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111')
|
||||
wrap.setAttribute('style', 'width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111;display:flex;flex-direction:column;overflow:hidden')
|
||||
|
||||
const body = document.createElement('div')
|
||||
body.setAttribute('style', 'min-height:0;overflow:auto')
|
||||
|
||||
const desc = document.createElement('input')
|
||||
desc.setAttribute('data-field', 'description')
|
||||
desc.placeholder = '描述这些更改…'
|
||||
desc.setAttribute('style', 'width:100%;box-sizing:border-box;border:none;outline:none;font-size:14px;padding:6px 4px')
|
||||
desc.addEventListener('input', () => { description = desc.value })
|
||||
wrap.appendChild(desc)
|
||||
body.appendChild(desc)
|
||||
|
||||
const tag = document.createElement('div')
|
||||
tag.textContent = el.tagName.toLowerCase()
|
||||
tag.setAttribute('style', 'color:#8a8a8a;border-top:1px solid #eee;margin-top:6px;padding:8px 4px 4px;font-weight:600')
|
||||
wrap.appendChild(tag)
|
||||
body.appendChild(tag)
|
||||
|
||||
for (const f of FIELDS) {
|
||||
const row = document.createElement('label')
|
||||
@ -87,10 +123,11 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
|
||||
})
|
||||
row.appendChild(lab)
|
||||
row.appendChild(inp)
|
||||
wrap.appendChild(row)
|
||||
body.appendChild(row)
|
||||
}
|
||||
|
||||
const footer = document.createElement('div')
|
||||
footer.setAttribute('data-region', 'footer')
|
||||
footer.setAttribute('style', 'display:flex;justify-content:space-between;align-items:center;margin-top:12px')
|
||||
const cancelBtn = document.createElement('button')
|
||||
cancelBtn.setAttribute('data-action', 'cancel')
|
||||
@ -111,9 +148,16 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
|
||||
|
||||
footer.appendChild(cancelBtn)
|
||||
footer.appendChild(confirmBtn)
|
||||
wrap.appendChild(body)
|
||||
wrap.appendChild(footer)
|
||||
shadow.appendChild(wrap)
|
||||
document.documentElement.appendChild(host)
|
||||
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
|
||||
const layout = computeBubbleLayout(rect, measuredHeight)
|
||||
host.style.top = `${layout.top}px`
|
||||
host.style.left = `${layout.left}px`
|
||||
host.style.visibility = 'visible'
|
||||
wrap.style.maxHeight = `${layout.maxHeight}px`
|
||||
|
||||
return { host, destroy: () => { host.remove() } }
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user