From 2789a67604a1ad55cec2a54a69668ddd9026407d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eren=20=C3=87akar?= Date: Sat, 4 Jul 2026 02:52:08 +0300 Subject: [PATCH] feat(browser): add Copy to context menu when text is selected (#7159) Co-authored-by: Orca Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/main/browser/browser-guest-ui.test.ts | 19 ++++++ src/main/browser/browser-guest-ui.ts | 5 ++ src/preload/index.ts | 2 + .../components/browser-pane/BrowserPane.tsx | 61 ++++++++++++++++--- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/browser-guest-events.ts | 1 + 10 files changed, 91 insertions(+), 12 deletions(-) diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 6428352ea..28b7a8b24 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -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) diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index d3de5906c..469803cc8 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -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 }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 40a539ba0..7e619af49 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 } diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 4896ecd39..54db52802 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -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 | null { +): Pick | 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({
) : null} + {contextMenu.selectionText.trim() ? ( + <> + +
+ + ) : null} +
+ + ) : null}