Fix Markdown URL actions behind Explorer (#8137)
This commit is contained in:
parent
96d1fa1d62
commit
d9b1fbbc07
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue