Fix Markdown URL actions behind Explorer (#8137)

This commit is contained in:
Neil 2026-07-10 13:34:50 -07:00 committed by GitHub
parent 96d1fa1d62
commit d9b1fbbc07
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 346 additions and 10 deletions

View File

@ -1381,8 +1381,9 @@
/* ── Link Bubble ──────────────────────────────────────── */
.rich-markdown-link-bubble {
position: absolute;
z-index: 30;
position: fixed;
z-index: 60;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
gap: 2px;

View File

@ -346,6 +346,7 @@ export default function RichMarkdownEditor({
<RichMarkdownEditorSurface
editor={editor}
editorFontZoomLevel={editorFontZoomLevel}
rootElement={rootRef.current}
rootRef={setRootElement}
scrollContainerRef={scrollContainerRef}
headerSlot={headerSlot}
@ -380,6 +381,10 @@ export default function RichMarkdownEditor({
searchState={searchState}
searchActions={searchActions}
linkBubbleActions={{
dismissLinkBubble: () => {
setLinkBubble(null)
setIsEditingLink(false)
},
handleLinkSave,
handleLinkRemove,
handleLinkEditCancel,

View File

@ -37,6 +37,7 @@ function shouldFocusEmptyEditorFromSurfaceClick(
type RichMarkdownEditorSurfaceProps = {
editor: Editor | null
editorFontZoomLevel: number
rootElement: HTMLDivElement | null
rootRef: (node: HTMLDivElement | null) => void
scrollContainerRef: React.RefObject<HTMLDivElement | null>
headerSlot?: React.ReactNode
@ -91,6 +92,7 @@ type RichMarkdownEditorSurfaceProps = {
toggleWholeWord: () => void
}
linkBubbleActions: {
dismissLinkBubble: () => void
handleLinkSave: (href: string) => void
handleLinkRemove: () => void
handleLinkEditCancel: () => void
@ -119,6 +121,7 @@ type RichMarkdownEditorSurfaceProps = {
export function RichMarkdownEditorSurface({
editor,
editorFontZoomLevel,
rootElement,
rootRef,
scrollContainerRef,
headerSlot,
@ -249,8 +252,11 @@ export function RichMarkdownEditorSurface({
</div>
{linkBubble ? (
<RichMarkdownLinkBubble
anchorElement={rootElement}
linkBubble={linkBubble}
isEditing={isEditingLink}
onDismiss={linkBubbleActions.dismissLinkBubble}
portalToDocument
onSave={linkBubbleActions.handleLinkSave}
onRemove={linkBubbleActions.handleLinkRemove}
onEditStart={() => linkBubbleActions.setIsEditingLink(true)}

View File

@ -1,4 +1,5 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { Editor } from '@tiptap/react'
import { ExternalLink, Pencil, Unlink } from 'lucide-react'
import { translate } from '@/i18n/i18n'
@ -9,6 +10,66 @@ export type LinkBubbleState = {
top: number
}
const LINK_BUBBLE_VIEWPORT_MARGIN = 8
const LINK_BUBBLE_MAX_WIDTH = 344
const LINK_BUBBLE_HEIGHT = 40
const LINK_BUBBLE_LAYOUT_ATTRIBUTES = ['aria-hidden', 'class', 'hidden', 'inert', 'style']
function hasRectChanged(initialRect: DOMRect, nextRect: DOMRect): boolean {
return (
Math.abs(nextRect.left - initialRect.left) > 0.5 ||
Math.abs(nextRect.top - initialRect.top) > 0.5 ||
Math.abs(nextRect.width - initialRect.width) > 0.5 ||
Math.abs(nextRect.height - initialRect.height) > 0.5
)
}
function getStableAnchorClassName(anchorElement: HTMLElement): string {
return [...anchorElement.classList]
.filter((className) => className !== 'rich-markdown-mod-held')
.sort()
.join(' ')
}
function isAnchorVisible(anchorElement: HTMLElement): boolean {
if (!anchorElement.isConnected || anchorElement.getClientRects().length === 0) {
return false
}
for (let element: HTMLElement | null = anchorElement; element; element = element.parentElement) {
const style = window.getComputedStyle(element)
if (
element.hidden ||
element.inert ||
element.getAttribute('aria-hidden') === 'true' ||
style.display === 'none' ||
style.visibility === 'hidden' ||
style.visibility === 'collapse' ||
Number.parseFloat(style.opacity) === 0
) {
return false
}
}
return true
}
function clampDocumentBubblePosition(linkBubble: LinkBubbleState): React.CSSProperties {
// Why: the body portal no longer inherits editor clipping, so keep every
// action reachable when the selected link sits at a window edge.
const maxLeft = Math.max(
LINK_BUBBLE_VIEWPORT_MARGIN,
window.innerWidth - LINK_BUBBLE_MAX_WIDTH - LINK_BUBBLE_VIEWPORT_MARGIN
)
const maxTop = Math.max(
LINK_BUBBLE_VIEWPORT_MARGIN,
window.innerHeight - LINK_BUBBLE_HEIGHT - LINK_BUBBLE_VIEWPORT_MARGIN
)
return {
position: 'fixed',
left: Math.min(Math.max(linkBubble.left, LINK_BUBBLE_VIEWPORT_MARGIN), maxLeft),
top: Math.min(Math.max(linkBubble.top, LINK_BUBBLE_VIEWPORT_MARGIN), maxTop)
}
}
export function getLinkBubblePosition(
editor: Editor,
rootEl: HTMLElement | null
@ -16,13 +77,12 @@ export function getLinkBubblePosition(
const { from } = editor.state.selection
try {
const coords = editor.view.coordsAtPos(from)
const rootRect = rootEl?.getBoundingClientRect()
if (!rootRect) {
if (!rootEl) {
return null
}
return {
left: coords.left - rootRect.left,
top: coords.bottom - rootRect.top + 4
left: coords.left,
top: coords.bottom + 4
}
} catch {
return null
@ -91,28 +151,126 @@ function LinkEditInput({
}
type RichMarkdownLinkBubbleProps = {
anchorElement: HTMLElement | null
linkBubble: LinkBubbleState
isEditing: boolean
onDismiss: () => void
onSave: (href: string) => void
onRemove: () => void
onEditStart: () => void
onEditCancel: () => void
onOpen: () => void
portalToDocument?: boolean
}
export function RichMarkdownLinkBubble({
anchorElement,
linkBubble,
isEditing,
onDismiss,
onSave,
onRemove,
onEditStart,
onEditCancel,
onOpen
onOpen,
portalToDocument = false
}: RichMarkdownLinkBubbleProps): React.JSX.Element {
return (
const bubbleRef = useRef<HTMLDivElement | null>(null)
const onDismissRef = useRef(onDismiss)
onDismissRef.current = onDismiss
useEffect(() => {
if (!anchorElement || !isAnchorVisible(anchorElement)) {
onDismissRef.current()
return
}
const dismiss = (): void => onDismissRef.current()
const dismissOutside = (event: Event): void => {
const target = event.target
if (
target instanceof Node &&
!anchorElement.contains(target) &&
!bubbleRef.current?.contains(target)
) {
dismiss()
}
}
const initialRect = anchorElement.getBoundingClientRect()
const initialAnchorClassName = getStableAnchorClassName(anchorElement)
const dismissIfLayoutInvalidated = (mutations: MutationRecord[] = []): void => {
const anchorStyleChanged = mutations.some(
(mutation) => mutation.target === anchorElement && mutation.attributeName === 'style'
)
if (
!isAnchorVisible(anchorElement) ||
hasRectChanged(initialRect, anchorElement.getBoundingClientRect()) ||
getStableAnchorClassName(anchorElement) !== initialAnchorClassName ||
anchorStyleChanged
) {
dismiss()
}
}
const resizeObserver = new ResizeObserver(() => {
dismissIfLayoutInvalidated()
})
const intersectionObserver = new IntersectionObserver(([entry]) => {
if (!entry?.isIntersecting || !isAnchorVisible(anchorElement)) {
dismiss()
}
})
const mutationObserver = new MutationObserver(dismissIfLayoutInvalidated)
const dismissOnScroll = (event: Event): void => {
const target = event.target
// Why: long URL inputs scroll horizontally to keep the caret visible;
// only scrolling outside the bubble invalidates its document position.
if (target instanceof Node && bubbleRef.current?.contains(target)) {
return
}
dismiss()
}
resizeObserver.observe(anchorElement)
intersectionObserver.observe(anchorElement)
for (
let element: HTMLElement | null = anchorElement;
element;
element = element.parentElement
) {
mutationObserver.observe(element, {
attributes: true,
attributeFilter: LINK_BUBBLE_LAYOUT_ATTRIBUTES
})
}
window.addEventListener('pointerdown', dismissOutside, true)
window.addEventListener('focusin', dismissOutside, true)
window.addEventListener('scroll', dismissOnScroll, true)
window.addEventListener('resize', dismiss)
return () => {
resizeObserver.disconnect()
intersectionObserver.disconnect()
mutationObserver.disconnect()
window.removeEventListener('pointerdown', dismissOutside, true)
window.removeEventListener('focusin', dismissOutside, true)
window.removeEventListener('scroll', dismissOnScroll, true)
window.removeEventListener('resize', dismiss)
}
}, [anchorElement])
const anchorRect = anchorElement?.getBoundingClientRect()
const positionStyle: React.CSSProperties = portalToDocument
? clampDocumentBubblePosition(linkBubble)
: {
position: 'absolute',
left: linkBubble.left - (anchorRect?.left ?? 0),
top: linkBubble.top - (anchorRect?.top ?? 0)
}
const bubble = (
<div
ref={bubbleRef}
className="rich-markdown-link-bubble"
style={{ left: linkBubble.left, top: linkBubble.top }}
style={positionStyle}
onMouseDown={(e) => {
// Prevent editor blur when clicking bubble buttons, but let inputs
// receive focus normally.
@ -166,4 +324,8 @@ export function RichMarkdownLinkBubble({
)}
</div>
)
// Why: editor panes clip overflow at the workbench boundary, so the URL
// actions must portal to the app layer to remain above the right sidebar.
return portalToDocument ? createPortal(bubble, document.body) : bubble
}

View File

@ -385,8 +385,13 @@ export function GitHubMarkdownComposer({
{attachmentFooter}
{linkBubble ? (
<RichMarkdownLinkBubble
anchorElement={rootRef.current}
linkBubble={linkBubble}
isEditing={isEditingLink}
onDismiss={() => {
setLinkBubble(null)
setIsEditingLink(false)
}}
onSave={handleLinkSave}
onRemove={handleLinkRemove}
onEditStart={() => setIsEditingLink(true)}

View File

@ -0,0 +1,157 @@
/**
* Reliability contract:
* - Invariant: link actions remain visible and interactive when they cross the Explorer seam.
* - Failure source: the in-editor bubble was clipped by overflow-hidden workbench ancestors.
* - Oracle: a real Chromium hit test inside the bubble/Explorer overlap resolves to the bubble.
* - Layer: Electron is required because DOM shims cannot model clipping or stacking contexts.
* - Wait: visible editor, Explorer, link, and bubble locators; no timing sleeps.
* - Artifacts: Playwright retains a trace and screenshot on failure.
* - Maturity: experimental pending CI soak history.
*/
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture,
waitForRichMarkdownEditor
} from './helpers/markdown-ordered-list-exit'
const LINK_HREF = 'https://example.com/a/very/long/path/that/makes/the-link-bubble-wide'
const MARKDOWN = `# Rich markdown link overlay repro
This paragraph deliberately places the link near the right edge of the editor so its URL bubble reaches the Explorer boundary: alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau [hover this URL](${LINK_HREF}).
`
type OverlapHitTest = {
bubbleRight: number
explorerLeft: number
overlapWidth: number
topElementIsBubble: boolean
}
test.describe('Rich markdown link bubble stacking', () => {
test('link actions stay above the right Explorer', async ({ orcaPage }, testInfo) => {
await orcaPage.setViewportSize({ width: 1920, height: 1080 })
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const context = await getActiveWorktreeContext(orcaPage)
let filePath: string | null = null
try {
filePath = await createMarkdownFixture(
context,
'link-bubble-stacking',
testInfo.workerIndex,
MARKDOWN
)
await openMarkdownFixture(orcaPage, context, filePath)
await waitForRichMarkdownEditor(orcaPage)
await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
store.setState({
rightSidebarOpen: true,
rightSidebarTab: 'explorer',
rightSidebarWidth: 780
})
})
const explorer = orcaPage.locator('[data-orca-explorer-shell]')
const link = orcaPage.locator(`.rich-markdown-editor a[href="${LINK_HREF}"]`)
await expect(explorer).toBeVisible()
await expect(link).toBeVisible()
await link.click()
const bubble = orcaPage.locator('.rich-markdown-link-bubble')
await expect(bubble).toBeVisible()
await expect(bubble.locator('.rich-markdown-link-url')).toContainText('https://example.com')
const overlap = await orcaPage.evaluate((): OverlapHitTest => {
const bubble = document.querySelector<HTMLElement>('.rich-markdown-link-bubble')
const explorer = document.querySelector<HTMLElement>('[data-orca-explorer-shell]')
if (!bubble || !explorer) {
throw new Error('Link bubble or Explorer was not rendered')
}
const bubbleRect = bubble.getBoundingClientRect()
const explorerRect = explorer.getBoundingClientRect()
const overlapLeft = Math.max(bubbleRect.left, explorerRect.left)
const overlapRight = Math.min(bubbleRect.right, explorerRect.right)
const overlapWidth = Math.max(0, overlapRight - overlapLeft)
const probeX = overlapLeft + Math.min(4, overlapWidth / 2)
const probeY = bubbleRect.top + bubbleRect.height / 2
const topElement = document.elementFromPoint(probeX, probeY)
return {
bubbleRight: bubbleRect.right,
explorerLeft: explorerRect.left,
overlapWidth,
topElementIsBubble: topElement !== null && bubble.contains(topElement)
}
})
expect(overlap.bubbleRight).toBeGreaterThan(overlap.explorerLeft)
expect(overlap.overlapWidth).toBeGreaterThan(8)
expect(overlap.topElementIsBubble).toBe(true)
const editButton = bubble.getByTitle('Edit link')
const editButtonBounds = await editButton.boundingBox()
const explorerBounds = await explorer.boundingBox()
expect(editButtonBounds).not.toBeNull()
expect(explorerBounds).not.toBeNull()
expect(editButtonBounds!.x + editButtonBounds!.width).toBeGreaterThan(explorerBounds!.x)
await editButton.click()
const input = bubble.locator('input')
await expect(input).toBeFocused()
await input.fill(`https://example.com/${'long-url-segment/'.repeat(30)}`)
await input.press('End')
expect(await input.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0)
await expect(input).toBeFocused()
await expect(bubble).toBeVisible()
await orcaPage.keyboard.press('Escape')
await expect(bubble.locator('.rich-markdown-link-url')).toBeVisible()
await explorer.getByPlaceholder('Find files').click()
await expect(bubble).toHaveCount(0)
await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click()
await link.click()
await expect(bubble).toBeVisible()
const originalEditorZoom = await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
const zoom = store.getState().editorFontZoomLevel
store.getState().setEditorFontZoomLevel(zoom + 1)
return zoom
})
await expect(bubble).toHaveCount(0)
await orcaPage.evaluate((zoom) => {
window.__store?.getState().setEditorFontZoomLevel(zoom)
}, originalEditorZoom)
await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click()
await link.click()
await expect(bubble).toBeVisible()
await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
store.getState().setActiveView('settings')
})
await expect(bubble).toHaveCount(0)
} finally {
await cleanupMarkdownFixture(filePath)
}
})
})