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:
parent
473c4e8680
commit
2789a67604
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -11713,7 +11713,8 @@
|
|||
"4bb7424d6b": "Canceled",
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "Open",
|
||||
"09a9489aa5": "Show"
|
||||
"09a9489aa5": "Show",
|
||||
"2a4c4b8e1f": "Copy"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "Cancel",
|
||||
|
|
|
|||
|
|
@ -11713,7 +11713,8 @@
|
|||
"4bb7424d6b": "Canceled",
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "Abrir",
|
||||
"09a9489aa5": "Show"
|
||||
"09a9489aa5": "Show",
|
||||
"2a4c4b8e1f": "Copiar"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "Cancelar",
|
||||
|
|
|
|||
|
|
@ -11713,7 +11713,8 @@
|
|||
"4bb7424d6b": "Canceled",
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "開く",
|
||||
"09a9489aa5": "Show"
|
||||
"09a9489aa5": "Show",
|
||||
"2a4c4b8e1f": "コピー"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "キャンセル",
|
||||
|
|
|
|||
|
|
@ -11713,7 +11713,8 @@
|
|||
"4bb7424d6b": "Canceled",
|
||||
"6e776f9ef9": "Download failed",
|
||||
"756bfc25c9": "열기",
|
||||
"09a9489aa5": "Show"
|
||||
"09a9489aa5": "Show",
|
||||
"2a4c4b8e1f": "복사"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "취소",
|
||||
|
|
|
|||
|
|
@ -11713,7 +11713,8 @@
|
|||
"4bb7424d6b": "已取消",
|
||||
"6e776f9ef9": "下载失败",
|
||||
"756bfc25c9": "打开",
|
||||
"09a9489aa5": "显示"
|
||||
"09a9489aa5": "显示",
|
||||
"2a4c4b8e1f": "复制"
|
||||
},
|
||||
"BrowserToolbarMenu": {
|
||||
"429ef481f9": "取消",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export type BrowserContextMenuRequestedEvent = {
|
|||
screenY: number
|
||||
pageUrl: string
|
||||
linkUrl: string | null
|
||||
selectionText: string
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue