feat(browser): add Copy to context menu when text is selected (#7159)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Eren Çakar 2026-07-04 02:52:08 +03:00 committed by GitHub
parent 473c4e8680
commit 2789a67604
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 91 additions and 12 deletions

View File

@ -105,11 +105,30 @@ describe('setupGuestContextMenu', () => {
screenY: 375,
pageUrl: 'https://test.dev/page',
linkUrl: 'https://test.dev/link',
selectionText: '',
canGoBack: true,
canGoForward: true
})
})
it('forwards the native selection text so the renderer can offer Copy', () => {
const guest = makeGuest()
const renderer = makeRenderer()
setupGuestContextMenu({
browserTabId,
guest,
resolveRenderer: () => renderer
})
triggerContextMenu(guest, { x: 10, y: 20, selectionText: 'copied selection' })
expect(rendererSendMock).toHaveBeenCalledWith(
'browser:context-menu-requested',
expect.objectContaining({ selectionText: 'copied selection' })
)
})
it('reads navigation state from navigationHistory', () => {
const deprecatedCanGoBack = vi.fn(() => false)
const deprecatedCanGoForward = vi.fn(() => false)

View File

@ -95,6 +95,11 @@ export function setupGuestContextMenu(args: {
screenY: cursor.y,
pageUrl,
linkUrl,
// Why: forward the native selection so the renderer can offer a Copy that
// writes it to the clipboard directly, bypassing pages that suppress copy
// via oncopy handlers (the reported bug — the selection is never re-read
// through a page-visible copy event).
selectionText: params.selectionText ?? '',
...navigationState
})
}

View File

@ -2167,6 +2167,7 @@ const api = {
screenY: number
pageUrl: string
linkUrl: string | null
selectionText: string
canGoBack: boolean
canGoForward: boolean
}) => void
@ -2181,6 +2182,7 @@ const api = {
screenY: number
pageUrl: string
linkUrl: string | null
selectionText: string
canGoBack: boolean
canGoForward: boolean
}

View File

@ -281,6 +281,7 @@ type RemoteBrowserContextMenu = {
y: number
linkUrl: string | null
pageUrl: string
selectionText: string
}
type RemoteBrowserViewportSize = {
@ -615,16 +616,21 @@ function buildRemoteContextMenuExpression(x: number, y: number): string {
return `(() => {
const target = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
const anchor = target && typeof target.closest === 'function' ? target.closest('a[href]') : null;
// Why: read the guest selection here so the remote/paired browser can offer
// the same Copy affordance as the local webview (there is no ContextMenuParams
// over the runtime RPC).
const selection = typeof window.getSelection === 'function' ? window.getSelection() : null;
return JSON.stringify({
linkUrl: anchor && anchor.href ? anchor.href : null,
pageUrl: location.href || 'about:blank'
pageUrl: location.href || 'about:blank',
selectionText: selection ? String(selection) : ''
});
})()`
}
function readRemoteContextMenuResult(
result: unknown
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl'> | null {
): Pick<RemoteBrowserContextMenu, 'linkUrl' | 'pageUrl' | 'selectionText'> | null {
if (!result || typeof result !== 'object') {
return null
}
@ -633,10 +639,16 @@ function readRemoteContextMenuResult(
return null
}
try {
const parsed = JSON.parse(raw) as { linkUrl?: unknown; pageUrl?: unknown }
const parsed = JSON.parse(raw) as {
linkUrl?: unknown
pageUrl?: unknown
selectionText?: unknown
}
return {
linkUrl: typeof parsed.linkUrl === 'string' && parsed.linkUrl ? parsed.linkUrl : null,
pageUrl: typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank'
pageUrl:
typeof parsed.pageUrl === 'string' && parsed.pageUrl ? parsed.pageUrl : 'about:blank',
selectionText: typeof parsed.selectionText === 'string' ? parsed.selectionText : ''
}
} catch {
return null
@ -2158,7 +2170,9 @@ function RemoteBrowserPagePane({
x: event.clientX,
y: event.clientY,
linkUrl: null,
pageUrl: browserTab.url || 'about:blank'
pageUrl: browserTab.url || 'about:blank',
// Why: filled in below once the async eval reads the guest selection.
selectionText: ''
})
enqueueRemoteInput(async () => {
const operationToken = createRemoteOperationToken(pageId)
@ -2183,7 +2197,8 @@ function RemoteBrowserPagePane({
? {
...current,
linkUrl: parsed.linkUrl,
pageUrl: redactKagiSessionToken(parsed.pageUrl)
pageUrl: redactKagiSessionToken(parsed.pageUrl),
selectionText: parsed.selectionText
}
: current
)
@ -2441,6 +2456,21 @@ function RemoteBrowserPagePane({
<div className="my-1 h-px bg-border/70" />
</>
) : null}
{contextMenu.selectionText.trim() ? (
<>
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
onClick={() => {
void window.api.ui.writeClipboardText(contextMenu.selectionText)
setContextMenu(null)
}}
>
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
</button>
<div className="my-1 h-px bg-border/70" />
</>
) : null}
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
@ -2761,6 +2791,7 @@ function BrowserPagePane({
y: number
linkUrl: string | null
pageUrl: string
selectionText: string
} | null>(null)
const contextMenuRef = useRef<HTMLDivElement>(null)
const [findOpen, setFindOpen] = useState(false)
@ -3071,7 +3102,8 @@ function BrowserPagePane({
x,
y,
linkUrl: event.linkUrl,
pageUrl: event.pageUrl
pageUrl: event.pageUrl,
selectionText: event.selectionText ?? ''
})
})
}, [browserTab.id])
@ -4780,6 +4812,21 @@ function BrowserPagePane({
<div className="my-1 h-px bg-border/70" />
</>
) : null}
{contextMenu.selectionText.trim() ? (
<>
<button
role="menuitem"
className="relative flex w-full cursor-default items-center gap-2 rounded-[7px] px-2 py-0.5 text-[12px] leading-5 font-medium outline-none select-none hover:bg-black/8 dark:hover:bg-white/14"
onClick={() => {
void window.api.ui.writeClipboardText(contextMenu.selectionText)
setContextMenu(null)
}}
>
{translate('auto.components.browser.pane.BrowserPane.2a4c4b8e1f', 'Copy')}
</button>
<div className="my-1 h-px bg-border/70" />
</>
) : null}
<button
role="menuitem"
disabled={!browserTab.canGoBack}

View File

@ -11713,7 +11713,8 @@
"4bb7424d6b": "Canceled",
"6e776f9ef9": "Download failed",
"756bfc25c9": "Open",
"09a9489aa5": "Show"
"09a9489aa5": "Show",
"2a4c4b8e1f": "Copy"
},
"BrowserToolbarMenu": {
"429ef481f9": "Cancel",

View File

@ -11713,7 +11713,8 @@
"4bb7424d6b": "Canceled",
"6e776f9ef9": "Download failed",
"756bfc25c9": "Abrir",
"09a9489aa5": "Show"
"09a9489aa5": "Show",
"2a4c4b8e1f": "Copiar"
},
"BrowserToolbarMenu": {
"429ef481f9": "Cancelar",

View File

@ -11713,7 +11713,8 @@
"4bb7424d6b": "Canceled",
"6e776f9ef9": "Download failed",
"756bfc25c9": "開く",
"09a9489aa5": "Show"
"09a9489aa5": "Show",
"2a4c4b8e1f": "コピー"
},
"BrowserToolbarMenu": {
"429ef481f9": "キャンセル",

View File

@ -11713,7 +11713,8 @@
"4bb7424d6b": "Canceled",
"6e776f9ef9": "Download failed",
"756bfc25c9": "열기",
"09a9489aa5": "Show"
"09a9489aa5": "Show",
"2a4c4b8e1f": "복사"
},
"BrowserToolbarMenu": {
"429ef481f9": "취소",

View File

@ -11713,7 +11713,8 @@
"4bb7424d6b": "已取消",
"6e776f9ef9": "下载失败",
"756bfc25c9": "打开",
"09a9489aa5": "显示"
"09a9489aa5": "显示",
"2a4c4b8e1f": "复制"
},
"BrowserToolbarMenu": {
"429ef481f9": "取消",

View File

@ -51,6 +51,7 @@ export type BrowserContextMenuRequestedEvent = {
screenY: number
pageUrl: string
linkUrl: string | null
selectionText: string
canGoBack: boolean
canGoForward: boolean
}