From d9b1fbbc07bc53647622a7d004a48b61b0cfcd2e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:34:50 -0700 Subject: [PATCH] Fix Markdown URL actions behind Explorer (#8137) --- .../src/assets/rich-markdown-editor.css | 5 +- .../components/editor/RichMarkdownEditor.tsx | 5 + .../editor/RichMarkdownEditorSurface.tsx | 6 + .../editor/RichMarkdownLinkBubble.tsx | 178 +++++++++++++++++- .../github/GitHubMarkdownComposer.tsx | 5 + ...rich-markdown-link-bubble-stacking.spec.ts | 157 +++++++++++++++ 6 files changed, 346 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/rich-markdown-link-bubble-stacking.spec.ts diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 395b18131..ccf79ad36 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -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; diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 990d2fdaf..94e0835ea 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -346,6 +346,7 @@ export default function RichMarkdownEditor({ { + setLinkBubble(null) + setIsEditingLink(false) + }, handleLinkSave, handleLinkRemove, handleLinkEditCancel, diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 92fe187e8..f5283616a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -37,6 +37,7 @@ function shouldFocusEmptyEditorFromSurfaceClick( type RichMarkdownEditorSurfaceProps = { editor: Editor | null editorFontZoomLevel: number + rootElement: HTMLDivElement | null rootRef: (node: HTMLDivElement | null) => void scrollContainerRef: React.RefObject 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({ {linkBubble ? ( linkBubbleActions.setIsEditingLink(true)} diff --git a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx index 7a144d446..7ff8464ce 100644 --- a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx +++ b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx @@ -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(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 = (
{ // Prevent editor blur when clicking bubble buttons, but let inputs // receive focus normally. @@ -166,4 +324,8 @@ export function RichMarkdownLinkBubble({ )}
) + + // 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 } diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 9a4019a3f..3159408be 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -385,8 +385,13 @@ export function GitHubMarkdownComposer({ {attachmentFooter} {linkBubble ? ( { + setLinkBubble(null) + setIsEditingLink(false) + }} onSave={handleLinkSave} onRemove={handleLinkRemove} onEditStart={() => setIsEditingLink(true)} diff --git a/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts new file mode 100644 index 000000000..f84691db5 --- /dev/null +++ b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts @@ -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('.rich-markdown-link-bubble') + const explorer = document.querySelector('[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) + } + }) +})